diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md
index 0747381..ae0da09 100644
--- a/docs/reference/file-map.md
+++ b/docs/reference/file-map.md
@@ -220,12 +220,23 @@ python tests/autosave_check.py # S2/B5 — does unsaved work survive?
python tests/a11y_check.py # S10/S11/S12 — announce, legible, focus 22 checks
```
-Wave 5 added one more, for the same reason:
+Wave 5 added more, for the same reason:
```bash
python tests/stepper_check.py # A4/S9 — ten real buttons, keyboard operable 70 checks
+python tests/launcher_check.py # B3 — can a brand-new account get started? 58 checks
```
+`launcher_check.py` is the only probe that runs itself in **two subprocesses**, and both
+reasons are worth knowing before writing a third:
+
+- The two states it tests are *database* states — an account with no projects, and an
+ account with two — and faking "no projects" in the browser would test the fake.
+- `server/db.py` builds its engine at import time from `DATABASE_URL`, so a second `seed()`
+ in one interpreter still points at the first phase's database, which has been deleted by
+ then. That surfaces as `unable to open database file`, which reads like a broken
+ environment rather than what it is.
+
It drives the rail with **real** key events over `Input.dispatchKeyEvent` rather than
`page.key()`, which dispatches a synthetic `KeyboardEvent` on `document`. That event
never reaches a listener bound to the rail and never triggers a button's native
@@ -252,7 +263,7 @@ one.
| — | `outline: none` in stylesheets | 6 | **1**, with its replacement one rule above | `T3.4`, `T4.7` |
| — | helper-text contrast, worst case | 3.01:1 | **4.56:1** | `T4.6` |
| 3 | `
` | 12 | **2** | `T5.1` |
-| 1 | native dialogs app-wide | 79 | **78** | `T5.1` (2 removed), wave 4 (1 added) |
+| 1 | native dialogs app-wide | 79 | **77** | `T5.1` (2), `T5.2` (1), wave 4 (+1) |
Metrics 2, 4 and 6 (creator dialogs, ``, `.help-tip` badges) are wave 9's to
move and are unchanged.
diff --git a/docs/waves/backlog.md b/docs/waves/backlog.md
index d1520e6..c607536 100644
--- a/docs/waves/backlog.md
+++ b/docs/waves/backlog.md
@@ -309,3 +309,10 @@ deliberately deferred.
- **Why not now:** adding rings to the launcher and field view is outside `T3.4`, whose files
are the wizard stylesheet, and both surfaces are touched by later waves anyway.
- **Suggested wave or follow-up:** `T9.5`, with the `C1` audit.
+- **Update, T5.2 — two of the four sites no longer exist.** `.proj-row select` and
+ `.link-like` went with the project-picker card (`B3`). The third, `.proj-form-grid input`,
+ survives in the rebuilt create form and was **measured rather than assumed**: with CDP
+ focus emulation on it draws `2px var(--cds-focus)` from the app-wide `:where()` floor
+ `T4.7` added, which post-dates this entry. So the launcher half of BL-014 is closed;
+ what is left is `field.html`'s `.fld-search`, and `T9.5` should re-measure that one the
+ same way rather than inheriting this entry's wording.
diff --git a/html/index.html b/html/index.html
index 744bc88..742fceb 100644
--- a/html/index.html
+++ b/html/index.html
@@ -292,25 +292,38 @@
color: var(--cds-text-primary);
}
- /* PROJECT PICKER */
+ /* PROJECT ENTRY POINTS — B3
+ The picker card (a "Select a project…" dropdown in its own section) is gone.
+ Switching projects is the app bar's switcher, which is on every page; the
+ launcher keeps the two things a dropdown could not do — the first-run empty
+ state, and creating a project. */
.proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; }
- .proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
- .proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px;
- border: 1px solid var(--cds-border-strong); background: var(--cds-field); }
- .proj-empty { background: var(--cds-ui-01); border: 1px dashed var(--cds-border-strong);
- padding: 1.25rem; }
- .proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); }
- .proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
+ .proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
/* Shown once when the project someone had open turns out to have been archived
- rather than deleted — otherwise the picker just silently resets on them. */
+ rather than deleted — otherwise the launcher just silently resets on them. */
.proj-archived-note { background: var(--wp-status-warning-bg); border: 1px solid var(--cds-support-warning); color: var(--wp-status-warning-text);
padding: 0.7rem 0.9rem; margin-bottom: 0.9rem; font-size: 13px; line-height: 1.5; }
- .proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03); background: var(--cds-ui-01); }
.proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; }
.proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); }
- .proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong); }
- .proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); }
- .link-like { background: none; border: none; color: var(--cds-link-01); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; }
+ .proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong); background: var(--cds-field); }
+ .proj-form-grid input[aria-invalid="true"] { border-color: var(--cds-support-error); }
+ /* .link-like and .proj-row went with the picker card. Both were named in
+ BL-014 as controls falling back to the UA focus ring; two of that entry's
+ four sites no longer exist. */
+
+ /* An inline error at the field it belongs to, rather than a native dialog that
+ names no field and highlights nothing (C1, and the pattern T5.8 uses in the
+ wizard). :empty so the element can sit in the markup and announce on change. */
+ .field-error { color: var(--cds-text-error); font-size: 12px; font-weight: 600; margin-top: 0.3rem; }
+ .field-error:empty { display: none; }
+
+ /* FIRST RUN
+ A brand-new account has no projects, so it has no tool cards either — this
+ is the whole page for that person, and it has to say what to do next. */
+ .first-run { border-left: 3px solid var(--cds-interactive-01); }
+ .first-run-note { font-size: 13px; margin-top: 1rem; }
+ .first-run-sample { margin-top: 1.5rem; padding-top: 1.25rem; border-top: 1px solid var(--cds-border-subtle); }
+ .first-run-sample h3 { margin-bottom: 0.35rem; }
/* RESPONSIVE */
@media (max-width: 768px) {
@@ -344,12 +357,85 @@
Standardized Work Package creation for Prime Controls construction projects. Select a project to begin — or create one.
-
-
-
Project
-
Projects are stored centrally. Pick the project you're working on, or set up a new one.
-
Loading projects…
-
+
+
Loading projects…
+
+
+
+
No projects yet
+
A project holds one SOP — the baseline every work package on the job
+ inherits — and the work packages written against it. Everything else in
+ the suite hangs off a project, so this is the first thing to make.
+ Fill in the form below, or start from the sample underneath it.
+
+
+
+
+
Choose a project
+
Pick the job you are working on from the project
+ switcher in the bar at the top of the page. It is there on every page, so
+ you can change job without coming back here.
+
+
+
+
+
+
+
+
+
Create a project
+
+
+
+
+
+
Not ready to set one up?
+
The sample project is a fully configured example — a finished SOP and a
+ few work packages — so you can see what the tools do before committing a
+ real job to them. It is labelled as a sample everywhere it appears, and it
+ can be deleted later.
+
+
+
+
@@ -452,7 +538,8 @@
}
const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null;
if(dropped) ProjectData.setActive(null);
- renderProjectPicker();
+ _listLoaded = true;
+ renderProjectEntry();
applyActiveProject();
// "No longer in the list" used to mean one thing — deleted. Now it also
// means archived, and resetting someone to "Select a project" with no word
@@ -466,7 +553,7 @@
// taken away) fails here, and that case genuinely has nothing to say.
ProjectData.get(p.id).then(full => {
if(!full || !full.archived) return;
- const box = document.getElementById('project-picker');
+ const box = document.getElementById('proj-status');
if(!box || document.getElementById('proj-archived-note')) return;
const note = document.createElement('div');
note.id = 'proj-archived-note';
@@ -478,55 +565,86 @@
}).catch(() => {});
}
- function createFormHtml(){
- return `
-
-
-
-
-
-
-
-
-
-
-
-
`;
- }
+ // ── PROJECT ENTRY POINTS (B3 / T5.2) ──────────────────────────────────────
+ // Three states, one visible at a time. The picker card that used to render
+ // here is gone; the app bar's switcher does that job on every page, and this
+ // page keeps the two things a dropdown could not do.
+ //
+ // ORDER MATTERS, and it is the whole reason B3 exists: the first-run empty
+ // state used to live INSIDE the picker card. Deleting the card without
+ // building this first would have left a brand-new account on a page whose
+ // only instruction was to choose from a list with nothing in it.
+ const $ = id => document.getElementById(id);
+ let _createOpenedManually = false;
+ let _focusCreateOnRender = false;
+ // Nothing renders until the list is in. "No projects yet" is a claim, and
+ // making it while the request is still in flight would show every new-account
+ // page to every returning user for a beat.
+ let _listLoaded = false;
- function renderProjectPicker(){
- const box = document.getElementById('project-picker');
- const activeId = ProjectData.getActiveId();
- if(!_projects.length){
- box.innerHTML = `
-
No projects yet. Create your first project, or start from a sample.
-
-
-
-
-
` + createFormHtml();
- return;
+ function show(id, on){ const el = $(id); if(el) el.hidden = !on; }
+
+ function renderProjectEntry(){
+ if(!_listLoaded) return;
+ const status = $('proj-status');
+ const loading = status && status.querySelector('.proj-loading');
+ if(loading) loading.remove();
+
+ const firstRun = !_projects.length;
+ const active = ProjectData.getActive();
+
+ show('first-run', firstRun);
+ show('sample-offer', firstRun);
+ // On first run the form IS the page — there is nothing else to do here, so
+ // hiding it behind a button would be one click of ceremony in front of the
+ // only path forward.
+ show('new-project', firstRun || _createOpenedManually);
+ show('pick-prompt', !firstRun && !active && !_createOpenedManually);
+
+ const head = $('new-project-head');
+ if(head) head.textContent = firstRun ? 'Create your first project' : 'Create a project';
+ // Nothing to cancel back to on first run.
+ show('np-cancel', !firstRun);
+
+ if(_focusCreateOnRender && !$('new-project').hidden){
+ _focusCreateOnRender = false;
+ const n = $('np_name');
+ if(n){ n.focus(); n.scrollIntoView({behavior:'smooth', block:'center'}); }
}
- const opts = _projects.map(p =>
- ``
- ).join('');
- box.innerHTML = `
-
-
-
-
- ` + createFormHtml();
}
- function showCreateProject(){ const f=document.getElementById('proj-form'); if(f){ f.style.display=''; const n=document.getElementById('np_name'); if(n) n.focus(); } }
- function hideCreateProject(){ const f=document.getElementById('proj-form'); if(f) f.style.display='none'; }
+ function showCreateProject(){
+ _createOpenedManually = true;
+ _focusCreateOnRender = true;
+ renderProjectEntry();
+ }
+ function hideCreateProject(){
+ _createOpenedManually = false;
+ setFieldError('np_name', '');
+ renderProjectEntry();
+ }
+
+ // An error at the field, associated with aria-describedby and announced —
+ // rather than a native dialog that names no field and highlights nothing.
+ // Same shape T5.8 gives the SOP wizard (C1).
+ function setFieldError(fieldId, message){
+ const field = $(fieldId), box = $(fieldId + '_err');
+ if(box) box.textContent = message || '';
+ if(field){
+ if(message) field.setAttribute('aria-invalid', 'true');
+ else field.removeAttribute('aria-invalid');
+ }
+ }
function saveNewProject(){
- const v = id => (document.getElementById(id)?.value || '').trim();
+ const v = id => ($(id)?.value || '').trim();
const name = v('np_name');
- if(!name){ alert('Project name is required.'); return; }
+ if(!name){
+ setFieldError('np_name', 'Enter a project name — it is the only field this needs.');
+ $('np_name')?.focus();
+ return;
+ }
+ setFieldError('np_name', '');
const p = { name, number:v('np_number'), client:v('np_client'), division:v('np_division'), site:v('np_site'), sample:false };
ProjectData.save(p).then(saved => { afterProjectChosen(saved); });
}
@@ -537,6 +655,33 @@
ProjectData.save(Object.assign({}, ProjectData.SAMPLE)).then(saved => { afterProjectChosen(saved); });
}
+ // The switcher is wp-chrome.js's, injected into the app bar after this script
+ // runs. Reaching for its button is deliberate: the alternative is a second
+ // project list on this page, which is the card B3 removes.
+ function openProjectSwitcher(){
+ const btn = document.querySelector('.wpc-proj-btn');
+ if(btn){ btn.click(); btn.focus(); return true; }
+ return false;
+ }
+
+ document.addEventListener('DOMContentLoaded', function(){
+ $('np-form')?.addEventListener('submit', function(e){ e.preventDefault(); saveNewProject(); });
+ $('np-cancel')?.addEventListener('click', hideCreateProject);
+ $('new-project-btn')?.addEventListener('click', showCreateProject);
+ $('use-sample-btn')?.addEventListener('click', useSampleProject);
+ $('open-switcher-btn')?.addEventListener('click', function(){
+ if(!openProjectSwitcher()){
+ // No chrome mounted (it returns early inside an iframe, and it loads
+ // after this file). Say so rather than doing nothing on a click.
+ const sub = $('pick-prompt-sub');
+ if(sub) sub.textContent = 'The project switcher is in the bar at the top of the page.';
+ }
+ });
+ // The app bar's popover links here for "new project". Honour it, so that
+ // link lands on the form rather than on a page that no longer has one.
+ if(location.hash === '#new-project') showCreateProject();
+ });
+
// S3: which project you are in is addressable, so a launcher link carries it
// and Back returns to the project you were looking at before.
function urlSyncProject(id, replace){
@@ -544,16 +689,16 @@
(replace ? WPUrl.replace : WPUrl.push).call(WPUrl, { project: id || '' });
}
- function selectProject(id){
- if(!id){ ProjectData.setActive(null); urlSyncProject('', false); applyActiveProject(); return; }
- const p = _projects.find(x => x.id === id);
- if(p){ ProjectData.setActive(p); urlSyncProject(p.id, false); applyActiveProject(); }
- }
+ // selectProject() went with the picker card's