T9.3 - S6: one icon system - monochrome text glyphs, one meaning each

The set mixed colour emoji with dingbats, and the same glyph read as two
things partly BECAUSE emoji render as per-platform artwork. The system chosen:
monochrome text-presentation glyphs - the suite is classic-script vanilla HTML
with no bundler, so an SVG sprite or icon font is a new asset pipeline, while
text glyphs render through the same font stack as the words beside them. The
enforceable form of "renders identically on Windows, macOS and a tablet":
no emoji-range codepoint and no U+FE0F selector anywhere in UI source,
swept by the probe on every run.

Converted: green-check/red-cross emoji in the admin and users consoles to
checkmark/cross, no-entry to circled-slash (blocked/on hold), the lock to the
pencil already meaning "edit with a logged reason" on sign-offs, the star to
the diamond, the folder to the reference marker, the side nav's lightning to
the gear, and the WATCH glyph (U+231A - emoji-presentation BY DEFAULT per
Unicode) to a text-presentation clock face. Dropped where the label already
carried the meaning: lightning on Save & view, the camera on Add photo, the
page/frame pictograms on file rows (the filename is the label). Stale help
copy fixed while its emoji left: it still described the pre-T9.4 "Load
sample" and the pre-T7.10 "Usage Logs" locations.

The meaning-to-icon mapping is in docs/reference/tokens.md - one meaning per
glyph, one glyph per meaning, both directions asserted from the document
itself; the probe also sweeps every page for glyphs not in the approved set,
so an unmapped icon cannot creep in.

Verification (each probe run alone): NEW tests/icon_check.py 5/5.
Regressions: frame_check 38/38, files_check 36/36, a11y_check 22/22,
cards_check 44/44.

Items: S6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 13:15:59 -07:00
parent b44afa7672
commit b83f2fd8d5
10 changed files with 188 additions and 48 deletions

View File

@@ -301,6 +301,7 @@ Wave 9 adds these:
```bash
python tests/export_check.py # CR-008/CR-017 - export walk + hours guard 20 checks
python tests/sample_check.py # S7 - one sample affordance, confirmed+fenced 10 checks
python tests/icon_check.py # S6 - one icon system, no emoji, mapped 5 checks
```
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live

View File

@@ -961,3 +961,51 @@ Column `#` is the line number in `html/theme-light.css`. Read down each column p
**120 declarations.** Lines 3116 are the Carbon g10 set; lines 170175 are the app-shell
group added by the suite. Many share a literal by design — that is Carbon's v10→v11 alias
layer, not the `S5` defect. See the note at the end of §3.
## Icons (S6 / T9.3)
One system: **monochrome text-presentation glyphs**, chosen because the suite
is classic-script vanilla HTML with no bundler — an SVG sprite or icon font is
a new asset pipeline, while text glyphs render through the same font stack as
the words beside them. The trade accepted: text glyphs vary slightly by font,
but never flip into per-platform colour artwork the way emoji do, which is the
failure S6 names. **No emoji anywhere in the UI**; `tests/icon_check.py`
sweeps every page for emoji-range codepoints and the U+FE0F emoji-presentation
selector on every run.
One meaning per glyph, one glyph per meaning:
| Meaning | Glyph | Codepoint | Notes |
|---|---|---|---|
| done / ok / success | ✓ | U+2713 | replaced emoji ✅ |
| close / remove / failure | ✕ | U+2715 | replaced emoji ❌ |
| needs attention / warning | ⚠ | U+26A0 | text presentation |
| blocked / on hold | ⊘ | U+2298 | replaced emoji ⛔ |
| edit with a logged reason | ✎ | U+270E | replaced emoji 🔒; same meaning on sign-off overrides |
| revert / refresh | ↺ / ↻ | U+21BA / U+21BB | direction distinguishes undo from reload |
| settings / admin | ⚙ | U+2699 | replaced ⚡ on the side nav |
| export / download | ⤓ | U+2913 | |
| import / upload | ⤒ | U+2912 | |
| key point (help copy) | ◆ | U+25C6 | replaced emoji ⭐ |
| reference link | ▸ | U+25B8 | replaced emoji 📁 |
| info tooltip badge | ⓘ | U+24D8 | the `.help-tip` component (S8/T9.5) |
| back / navigation | ← → | U+2190 U+2039 U+203A U+2192 | |
| views (nav) | ◔ ▤ ▦ ▧ | U+25D4 U+25A4 U+25A6 U+25A7 | one per view, never reused |
| drag handle | ⣿ | U+283F | sequence reordering |
| home | ⌂ | U+2302 | side nav |
| search | ⌕ | U+2315 | app bar |
| print / save PDF | ⎙ | U+2399 | |
| power / sign out | ⏻ | U+23FB | side nav |
| sort direction | ▲ ▼ | U+25B2 U+25BC | column headers, paired |
| in the QA queue | ◉ | U+25C9 | the CR-014 banner |
| clock / language & time | ◷ | U+25F7 | replaced ⌚ U+231A, which is emoji-presentation by default |
| menu (off-canvas) | ☰ | U+2630 | app bar |
| people / directory | ☺ | U+263A | side nav; text presentation |
| field view | ⚒ | U+2692 | side nav; text presentation |
| password / key | ⚿ | U+26BF | side nav |
Dropped rather than mapped: ⚡ on action buttons (the label carries the
action), 📷 on “Add photo” (labelled), 📄/🖼 on file rows (the filename is the
label; a pictogram repeating “this is a file” said nothing). Every remaining
glyph sits beside visible text or carries an accessible name — none is the
only carrier of meaning.

View File

@@ -36,13 +36,13 @@ async function checkHealth(){
b.className='banner'; b.textContent='Checking…';
const { status, json } = await api('GET','/api/health');
if(status===200 && json && json.ok){
b.className='banner ok'; b.textContent=' API reachable — /api/health returned ok.';
b.className='banner ok'; b.textContent=' API reachable — /api/health returned ok.';
} else if(status===404){
b.className='banner bad'; b.textContent=' /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.';
b.className='banner bad'; b.textContent=' /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.';
} else if(status===0){
b.className='banner bad'; b.textContent=' Could not reach the server: '+json;
b.className='banner bad'; b.textContent=' Could not reach the server: '+json;
} else {
b.className='banner bad'; b.textContent=' Unexpected response: HTTP '+status;
b.className='banner bad'; b.textContent=' Unexpected response: HTTP '+status;
}
}
@@ -121,9 +121,9 @@ function stdConstraints(open){ return ['Safety & Permitting','Quality Control /
async function seedDemo(){
const o=document.getElementById('demo-out'); o.innerHTML='';
let r = await api('GET','/api/health');
if(!(r.status===200 && r.json && r.json.ok)){ demoLog(' API unreachable — fix /api/ routing first.'); return; }
if(!(r.status===200 && r.json && r.json.ok)){ demoLog(' API unreachable — fix /api/ routing first.'); return; }
r = await api('POST','/api/projects',{name:'DEMO — Micron INC (test data)',number:'DEMO-001',client:'Micron Technology, Inc.',division:'Semiconductor',site:'Boise, ID — Fab',created_by:'admin-console'});
if(r.status!==200){ demoLog(' create project failed (HTTP '+r.status+')'); return; }
if(r.status!==200){ demoLog(' create project failed (HTTP '+r.status+')'); return; }
const pid=r.json.id; demoLog('Project created: '+r.json.name);
r = await api('POST','/api/sops',{project_id:pid,name:'DEMO SOP',number:'DEMO-001',complete:true,data:{governance:{woFormat:'WP##-[Sector]-[TYPE]',disciplines:['Mechanical','Electrical','Tech'],discMode:'choice',instanceSuffix:'letter',woSize:'Standard — 35 days (≈4080 hrs)',sizeHoursMax:'80'}}});
const sid=r.json && r.json.id; demoLog('SOP created (complete).');
@@ -142,7 +142,7 @@ async function seedDemo(){
await mk('WP05-3P-PANEL','3P panel install','Panel Install','Draft',{disciplines:['Electrical'],hours:'120',constraints:stdConstraints(['Schedule']),due:'2026-07-20'});
r = await api('GET','/api/wps/metrics?project_id='+pid);
demoLog('\nMetrics (masters excluded): '+JSON.stringify(r.json));
demoLog('\n Done — "DEMO — Micron INC (test data)" now appears in the home picker.');
demoLog('\n Done — "DEMO — Micron INC (test data)" now appears in the home picker.');
snapshot();
}
async function cleanDemo(){
@@ -151,11 +151,11 @@ async function cleanDemo(){
// archived=all, or an archived DEMO-/SMOKE- project becomes unreachable from
// this button — the default list hides it and nothing else here can delete it.
const r = await api('GET','/api/projects?archived=all');
if(r.status!==200){ demoLog(' API unreachable (HTTP '+r.status+').'); return; }
if(r.status!==200){ demoLog(' API unreachable (HTTP '+r.status+').'); return; }
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
if(!targets.length){ demoLog('Nothing to remove.'); return; }
for(const p of targets){ await api('DELETE','/api/projects/'+p.id); demoLog('Deleted: '+p.name+' ('+p.number+')'); }
demoLog('\n Removed '+targets.length+' project(s).');
demoLog('\n Removed '+targets.length+' project(s).');
snapshot();
}
@@ -175,14 +175,14 @@ async function loadProjects(){
const { status, json } = await api('GET','/api/projects?archived=all');
if(status===403){
banner.className='banner bad';
banner.textContent=' Your account is not an admin, so you cant archive or delete projects here.';
banner.textContent=' Your account is not an admin, so you cant archive or delete projects here.';
wrap.innerHTML=''; return;
}
if(status===401){
banner.className='banner bad'; banner.textContent=' Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
banner.className='banner bad'; banner.textContent=' Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
}
if(status!==200 || !Array.isArray(json)){
banner.className='banner bad'; banner.textContent=' Could not load projects (HTTP '+status+').'; wrap.innerHTML=''; return;
banner.className='banner bad'; banner.textContent=' Could not load projects (HTTP '+status+').'; wrap.innerHTML=''; return;
}
banner.style.display='none';
_adminProjects = json;
@@ -284,14 +284,14 @@ async function loadDefaultMembers(){
const { status, json } = await api('GET','/api/auth/users');
if(status===403){
banner.className='banner bad';
banner.textContent=' Your account is not an admin, so you cant change who is added to new projects.';
banner.textContent=' Your account is not an admin, so you cant change who is added to new projects.';
wrap.innerHTML=''; return;
}
if(status===401){
banner.className='banner bad'; banner.textContent=' Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
banner.className='banner bad'; banner.textContent=' Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
}
if(status!==200 || !Array.isArray(json)){
banner.className='banner bad'; banner.textContent=' Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
banner.className='banner bad'; banner.textContent=' Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
}
banner.style.display='none';
_defMemUsers = json;
@@ -560,7 +560,7 @@ async function saveLocalization(){
const m = document.getElementById('l10n-msg');
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
} else {
msg.textContent = ' '+((json && json.detail) || ('HTTP '+status));
msg.textContent = ' '+((json && json.detail) || ('HTTP '+status));
msg.style.color = 'var(--red)';
}
}
@@ -627,8 +627,8 @@ async function saveSettings(){
async function testEmail(){
const msg = document.getElementById('set-msg'); msg.textContent = 'Sending test…'; msg.style.color = 'var(--muted)';
const { status, json } = await api('POST','/api/settings/test-email', {});
if(status===200) { msg.textContent = ' Test sent to '+((json&&json.to)||'you')+'.'; msg.style.color = 'var(--green)'; }
else { msg.textContent = ' '+((json && json.detail) || ('HTTP '+status)); msg.style.color = 'var(--red)'; }
if(status===200) { msg.textContent = ' Test sent to '+((json&&json.to)||'you')+'.'; msg.style.color = 'var(--green)'; }
else { msg.textContent = ' '+((json && json.detail) || ('HTTP '+status)); msg.style.color = 'var(--red)'; }
}
async function loadNotifications(){
const box = document.getElementById('notif-box'); if(!box) return;

View File

@@ -149,11 +149,11 @@ function renderDetail() {
(((p.files) || []).length ? '<div class="fld-sec"><h3>Drawings</h3>' +
p.files.map(function (f) {
return '<a class="fld-drawing" href="/api/files/' + esc(f.id) + '" target="_blank" rel="noopener">' +
'📄 ' + esc(f.name || 'drawing') + (f.description ? ' — ' + esc(f.description) : '') + '</a>';
'' + esc(f.name || 'drawing') + (f.description ? ' — ' + esc(f.description) : '') + '</a>';
}).join('') + '</div>' : '') +
'<div class="fld-sec"><h3>Add field update</h3>' +
'<textarea class="fld-note" id="fld-note" placeholder="What happened on site? (progress, blockers, notes)" oninput="draftNote=this.value">' + esc(draftNote) + '</textarea>' +
'<div class="fld-photo-row"><label class="fld-btn">📷 Add photo<input type="file" accept="image/*" capture="environment" style="display:none" onchange="onPhoto(event)"></label>' +
'<div class="fld-photo-row"><label class="fld-btn">Add photo<input type="file" accept="image/*" capture="environment" style="display:none" onchange="onPhoto(event)"></label>' +
'<span id="photo-status" style="font-size:13px;color:var(--cds-text-secondary)">' + (pendingPhoto ? 'Photo attached ✓' : '') + '</span></div>' +
'<div style="margin-top:12px"><button class="fld-btn primary" onclick="addUpdate()">Add to log</button></div>' +
'</div>' +

View File

@@ -93,7 +93,7 @@
<li><strong>Dashboard</strong> — track status, hours, due dates, and what's gating each package across the project.</li>
</ol>
<h4>Moving around</h4>
<p>From the home page, open <strong>SOP Configuration</strong>, the <strong>Work Package Creator</strong>, or the <strong>Dashboard</strong>. Inside the suite, switch any time using the top tabs: <strong>⚙ SOP Configuration</strong>, <strong>📋 Work Package Creation</strong>, and <strong>📊 Dashboard</strong>. The active project and SOP follow you across all of them.</p>
<p>From the home page, open <strong>SOP Configuration</strong>, the <strong>Work Package Creator</strong>, or the <strong>Dashboard</strong>. Inside the suite, switch any time using the top tabs: <strong>⚙SOP Configuration</strong>, <strong>Work Package Creation</strong>, and <strong>Dashboard</strong>. The active project and SOP follow you across all of them.</p>
<h4>Quick start</h4>
<ol>
<li><strong>Open “SOP Configuration”</strong> and complete the 10 steps for your project (~15 minutes).</li>
@@ -101,7 +101,7 @@
<li><strong>Open “Work Package Creation”</strong> to author packages with your SOP defaults pre-populated.</li>
<li><strong>Update from the field</strong> using the <strong>Field View</strong>, and <strong>leave feedback</strong> on any page with the Feedback button.</li>
</ol>
<div class="ui-help-callout">New here? On the home page choose the <strong>Sample Project</strong>, then click <strong>Load sample</strong> in the suite to see a fully filled-out SOP and an example Work Package.</div>` },
<div class="ui-help-callout">New here? On the home page choose the <strong>Sample Project</strong>, then click <strong>Load sample data</strong> in the suite to see a fully filled-out SOP and an example Work Package.</div>` },
{ id: 'projects', title: 'Projects', body: `
<h3>Projects</h3>
@@ -133,7 +133,7 @@
<li><strong>Release Gate Constraints</strong> — choose which standard AWP constraints apply and add custom ones (see <a data-help-jump="constraints">Constraints</a>).</li>
<li><strong>Engineering Sources &amp; References</strong> — labelled links (Design Drawings, Specs, …) that appear as quick-access buttons in the WP Creator's <em>Drawings &amp; Attachments</em>.</li>
</ol>
<div class="ui-help-callout">Fields a WP inherits from the SOP show a <strong>"from SOP"</strong> tag and are locked. You can override a locked field with <strong>🔒 Edit</strong>, which requires a logged reason.</div>` },
<div class="ui-help-callout">Fields a WP inherits from the SOP show a <strong>"from SOP"</strong> tag and are locked. You can override a locked field with <strong> Edit</strong>, which requires a logged reason.</div>` },
{ id: 'wps', title: 'Work Packages', body: `
<h3>Creating Work Packages</h3>
@@ -152,7 +152,7 @@
<li><strong>Quality / Hold Points</strong>, <strong>Approvals &amp; Sign-offs</strong>, and <strong>Closeout</strong> (actual hours, as-builts, lessons learned — shown at QC/Closed).</li>
</ul>
<h4>Saving</h4>
<p><strong>Save Draft</strong> stores the package; <strong>Save &amp; View</strong> saves and renders the print-ready output. Drafts auto-save to your browser as you type, so nothing is lost if you close the tab.</p>` },
<p><strong>Save Draft</strong> stores the package; <strong>Save &amp; View</strong> saves and renders the print-ready output. Drafts auto-save to your browser as you type, so nothing is lost if you close the tab.</p>` },
{ id: 'statuses', title: 'Statuses', body: `
<h3>Work Package statuses</h3>
@@ -223,7 +223,7 @@
{ id: 'dashboard', title: 'Dashboard', body: `
<h3>Dashboard &amp; metrics</h3>
<p>The dashboard aggregates every (non-master) package in the active project. Open it from the home page, the suite's <strong>📊 Dashboard</strong> tab, or the Creator header.</p>
<p>The dashboard aggregates every (non-master) package in the active project. Open it from the home page, the suite's <strong>Dashboard</strong> tab, or the Creator header.</p>
<h4>Metric cards (click to filter)</h4>
<ul>
<li><strong>Total WPs</strong>, <strong>Release-ready</strong>, <strong>On hold</strong>, <strong>Overdue</strong></li>
@@ -232,7 +232,7 @@
<h4>Breakdowns &amp; gates</h4>
<ul>
<li><strong>By status</strong> and <strong>by discipline</strong> chips.</li>
<li><strong> Gating constraints</strong> — lists every blocked package and exactly which constraints are holding it.</li>
<li><strong> Gating constraints</strong> — lists every blocked package and exactly which constraints are holding it.</li>
</ul>
<h4>The table</h4>
<p>Shows WP #, subject, type, discipline, status, <strong>Gates</strong> (<em>clear</em>, <em>n open</em>, or <em>master</em>), due date (red if overdue), and hours. Row actions: <strong>issue</strong> (when release-ready), <strong>view</strong>, and <strong>edit</strong>. Filter with the search box and the status / discipline dropdowns.</p>
@@ -241,7 +241,7 @@
{ id: 'data', title: 'Samples, sharing & comments', body: `
<h3>Samples, import / export &amp; comments</h3>
<h4>Load sample</h4>
<p><strong>Load sample</strong> is context-aware: on the SOP tab it loads a complete sample SOP; on the WP tab it loads an example Work Package. Great for learning the tool or demoing.</p>
<p><strong>Load sample data</strong> is context-aware: on the SOP tab it loads a complete sample SOP; on the WP tab it loads an example Work Package. Great for learning the tool or demoing.</p>
<h4>Import / Export</h4>
<ul>
<li><strong>Work Packages</strong> — <em>⤓ Export (JSON)</em> downloads all saved packages; import restores them.</li>
@@ -249,9 +249,9 @@
<li><strong>Materials</strong> — import a bill of materials from Excel/CSV, or download a template.</li>
</ul>
<h4>Comments &amp; feedback</h4>
<p>Leave feedback from the home page, per-step comments in the SOP tool (<strong>💬 Step Comments</strong>), or package comments in the Creator's <strong>💬 Comments</strong> drawer. Comments are saved and can be exported/imported as <code>.json</code> so reviewers can share them — and, when the API is reachable, they're collected centrally too.</p>
<p>Leave feedback from the home page, per-step comments in the SOP tool (<strong>Step Comments</strong>), or package comments in the Creator's <strong>Comments</strong> drawer. Comments are saved and can be exported/imported as <code>.json</code> so reviewers can share them — and, when the API is reachable, they're collected centrally too.</p>
<h4>Usage logs</h4>
<p><strong>📊 Usage Logs</strong> / <strong>▤ Usage Data</strong> shows session and event counts and can export the full log. A <strong>dev-mode</strong> toggle pauses tracking during demos.</p>` },
<p><strong>Usage Logs</strong> / <strong>▤ Usage Data</strong> shows session and event counts and can export the full log. A <strong>dev-mode</strong> toggle pauses tracking during demos.</p>` },
{ id: 'shortcuts', title: 'Tips & shortcuts', body: `
<h3>Tips &amp; keyboard shortcuts</h3>

View File

@@ -34,7 +34,7 @@ async function boot(){
_scope = (status === 200 && json) ? json : { can_manage_users:false, scope:'projects',
grantable_roles:[], grantable_project_roles:[], managed_projects:[], project_roles:PROJECT_ROLES };
if(status !== 200){
banner('scope-banner','bad',' '+apiError(status, json, 'Could not work out what you may do here')+
banner('scope-banner','bad',' '+apiError(status, json, 'Could not work out what you may do here')+
' Showing the directory read-only.');
} else {
renderScope();
@@ -85,7 +85,7 @@ async function loadUsers(){
const wrap = document.getElementById('users-table');
const { status, json } = await api('GET','/api/auth/users');
if(status !== 200 || !Array.isArray(json)){
banner('users-banner','bad',' '+apiError(status, json, 'Could not load the directory'));
banner('users-banner','bad',' '+apiError(status, json, 'Could not load the directory'));
wrap.innerHTML = ''; return;
}
banner('users-banner','', '');
@@ -354,11 +354,11 @@ async function createUser(){
role: val('nu-role'), project_role: val('nu-project-role'),
});
if(status === 200){
say('var(--green)',' Created '+username+'.');
say('var(--green)',' Created '+username+'.');
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id => document.getElementById(id).value = '');
loadUsers();
} else {
say('var(--red)',' '+apiError(status, json, 'Could not create the account'));
say('var(--red)',' '+apiError(status, json, 'Could not create the account'));
}
}

View File

@@ -519,7 +519,7 @@ function lockQuality(id){
el.classList.toggle('sop-inherited', fromSOP); // comment 8: blue-tinted SOP field
el.classList.toggle('locked-field', false);
const wrap=el.closest('.field'); const btn=wrap&&wrap.querySelector('.lock-edit'); const note=wrap&&wrap.querySelector('.override-note');
if(btn) btn.textContent = fromSOP ? '🔒 Edit (reason required)' : '↺ Revert to SOP';
if(btn) btn.textContent = fromSOP ? ' Edit (reason required)' : '↺ Revert to SOP';
if(note) note.innerHTML = pkgOverrides[id] ? `Overridden: ${esc(pkgOverrides[id])}` : '';
}
function editQuality(id){
@@ -570,7 +570,7 @@ function renderSopRefLinks(){
const srcs=sopLinkedSources();
if(!srcs.length){ box.innerHTML=''; return; }
box.innerHTML=`<div class="ref-links-title">Reference folders (from SOP) — navigate to find &amp; copy the specific file link:</div>`+
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link"> ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
}
function renderSpecFolderLink(){
const el=document.getElementById('spec-folder-link'); if(!el) return;
@@ -802,7 +802,7 @@ function renderPredHint(){
el.textContent = 'None — this package can be released as soon as its constraints are cleared.';
el.style.color = '';
} else if(blocking.length){
el.innerHTML = ' Waiting on ' + blocking.map(p => esc(p.number || p.id) + ' (' + esc(p.status) + ')').join(', ') +
el.innerHTML = ' Waiting on ' + blocking.map(p => esc(p.number || p.id) + ' (' + esc(p.status) + ')').join(', ') +
'. Release is gated until they are Closed.';
el.style.color = 'var(--red)';
} else {
@@ -1241,7 +1241,7 @@ function wpFilesMirror(){
function wpFilesRender(){
const box=document.getElementById('wp-file-list'); if(!box) return;
box.innerHTML=pkgFiles.map(f=>`<div class="wp-file-item">
<a href="/api/files/${encodeURIComponent(f.id)}" target="_blank" rel="noopener">${f.mime==='application/pdf'?'📄':'🖼'} ${esc(f.name||'drawing')}</a>
<a href="/api/files/${encodeURIComponent(f.id)}" target="_blank" rel="noopener">${esc(f.name||'drawing')}</a>
<span class="wf-size">${wpFileSize(f.size||0)}</span>
<input type="text" value="${(f.description||'').replace(/"/g,'&quot;')}" placeholder="focus area, e.g. Tray section, Level 3 east only"
aria-label="Description of ${esc(f.name||'drawing')}" onchange="wpFileDescSave('${f.id}', this.value)">
@@ -1310,7 +1310,7 @@ function renderSopFileFolders(){
const srcs=sopLinkedSources();
box.innerHTML = srcs.length
? `<div class="field-hint">1) Open a folder, multi-select files in SharePoint, then use <b>Copy link</b>:</div>`+
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link"> ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`
: `<div class="field-hint">No SOP folders defined — load or import an SOP first.</div>`;
}
function toggleSopFilePanel(){
@@ -1417,7 +1417,7 @@ function readiness(){
function updateReleaseBanner(){
const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status');
let cls, txt;
if(st==='Issue'){ cls='rb-hold'; txt=` On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; }
if(st==='Issue'){ cls='rb-hold'; txt=` On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; }
else if(st==='Ready for QA'){
// CR-014: the QA decision lives where the state is announced. Accept moves to
// QC; reject returns to the crew and REQUIRES a comment (the modal enforces
@@ -2790,7 +2790,7 @@ function renderWpNav(){
const h = live[live.length - 1];
const why = h ? (h.constraint ? h.constraint + ': ' : '') + (h.details || '')
: 'no reason recorded — log it from the status control';
holdLine = '<span class="wp-nav-hold"> ' + (open ? open + ' open — ' : '')
holdLine = '<span class="wp-nav-hold"> ' + (open ? open + ' open — ' : '')
+ esc(why) + '</span>';
}
return '<button type="button" class="wp-nav-item' + active + '" onclick="wpNavOpen(' + r.i + ')"' +
@@ -3430,7 +3430,7 @@ function renderDashboard(){
// gating panel — what's blocking release, from the server
const gated=m.gating||[];
h+=`<div class="dash-panel"><div class="dash-panel-title"> Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)</div>`;
h+=`<div class="dash-panel"><div class="dash-panel-title"> Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)</div>`;
h+= gated.length ? `<table class="dash-table"><thead><tr><th>WP #</th><th>Subject</th><th>Blocked by</th></tr></thead><tbody>`+
gated.map(g=>`<tr><td class="row-label">${esc(g.number||'—')}</td><td>${esc(g.subject||'')}</td>
<td>${(g.blocked_by||[]).map(c=>esc(c.name)+(c.comment?` <span style="color:var(--text-dim)">(${esc(c.comment)})</span>`:'')).join('<br>')}</td></tr>`).join('')+

View File

@@ -440,12 +440,12 @@
<div class="sub-heading">Quality, Inspection & Hold Points</div>
<div class="field-grid">
<div class="field"><label>QC required</label><input type="text" id="wp_qc" placeholder="from SOP" readonly>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_qc')">🔒 Edit (reason required)</button><span class="override-note"></span></div></div>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_qc')"> Edit (reason required)</button><span class="override-note"></span></div></div>
<div class="field"><label>Photo documentation</label><input type="text" id="wp_photo" placeholder="from SOP" readonly>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_photo')">🔒 Edit (reason required)</button><span class="override-note"></span></div></div>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_photo')"> Edit (reason required)</button><span class="override-note"></span></div></div>
</div>
<div class="field field-grid col1"><div class="field"><label>Witness / hold points</label><textarea id="wp_hold" rows="2" readonly placeholder="from SOP"></textarea>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_hold')">🔒 Edit (reason required)</button><span class="override-note"></span></div>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_hold')"> Edit (reason required)</button><span class="override-note"></span></div>
<div class="field-hint">Inherited from the SOP. A <strong>Hold Point</strong> stops work until inspection sign-off; a <strong>Witness Point</strong> is offered for inspection but work may proceed if declined.</div></div></div>
</div>
@@ -476,7 +476,7 @@
<div class="nav-row"><button class="btn btn-ghost" onclick="newPackage()">↺ Clear</button>
<div style="display:flex;gap:10px">
<button class="btn btn-ghost" onclick="savePackage(false)">Save draft</button>
<button class="btn btn-generate" onclick="savePackage(true)">Save &amp; view</button>
<button class="btn btn-generate" onclick="savePackage(true)">Save &amp; view</button>
</div></div>
<!-- DASHBOARD -->
@@ -614,7 +614,7 @@
<span class="sticky-status" id="sticky-status"></span>
<div class="sticky-actions">
<button class="btn btn-ghost" onclick="savePackage(false)">Save draft</button>
<button class="btn btn-generate" onclick="savePackage(true)">Save &amp; view</button>
<button class="btn btn-generate" onclick="savePackage(true)">Save &amp; view</button>
</div>
</div>

View File

@@ -43,7 +43,7 @@
{ section: 'People' },
{ href: 'users.html', match: /(^|\/)users\.html$/, icon: '☺', label: 'User Directory',
sub: 'Who\'s on the project' },
{ href: 'admin.html', match: /(^|\/)admin\.html$/, icon: '', label: 'Admin Console',
{ href: 'admin.html', match: /(^|\/)admin\.html$/, icon: '', label: 'Admin Console',
sub: 'Settings & diagnostics',
show: function () { return typeof window.wpIsAdmin === 'function' && window.wpIsAdmin(); } },
// Account actions, inherited from the flat user menu that used to sit in the app
@@ -51,7 +51,7 @@
// drawer already had; these two were its only unique contents, so they moved here
// rather than being lost with it. `action` items render as buttons, not links.
{ section: 'Account' },
{ action: 'wpPreferences', icon: '', label: 'Language & time',
{ action: 'wpPreferences', icon: '', label: 'Language & time',
sub: 'Dates, numbers and time zone' },
{ action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' },
];

91
tests/icon_check.py Normal file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Is there one icon system, with one meaning per glyph? — S6, T9.3.
The set mixed emoji and dingbats, and at least one glyph carried two meanings.
Now: monochrome text-presentation glyphs only, mapped one-to-one in
docs/reference/tokens.md. Emoji render as per-platform colour artwork - which
is WHY the same glyph read as two things - so the enforceable form of "renders
identically on Windows, macOS and a tablet" is: no emoji-range codepoint and
no U+FE0F emoji-presentation selector anywhere in the UI source.
Static sweep - no browser needed. Exit 0 all passed, 1 a failure.
"""
import io
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from browser_check import chk, _PASS, _FAIL # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
# The approved system, verbatim from docs/reference/tokens.md.
APPROVED = {
0x2713, 0x2715, 0x26A0, 0x2298, 0x270E, 0x21BA, 0x21BB, 0x2699,
0x2913, 0x2912, 0x25C6, 0x25B8, 0x24D8, 0x2190, 0x2039, 0x203A,
0x2192, 0x2193, 0x25D4, 0x25A4, 0x25A6, 0x25A7, 0x283F, 0x25BE,
0x2248, 0x2398, 0x29C9, 0x2022, 0x00B7, 0x2298,
0x2302, 0x2315, 0x2399, 0x23FB, 0x25B2, 0x25BC, 0x25C9, 0x25F7,
0x2630, 0x263A, 0x2692, 0x26BF,
}
# Emoji territory: anything here in UI source is a system violation.
def is_emoji(o):
return (0x1F000 <= o <= 0x1FAFF) or o in (0x2705, 0x274C, 0x26D4, 0x2B50,
0x26A1, 0xFE0F, 0x2757, 0x2B55)
def main():
print("\n1. no emoji, anywhere in the UI")
offenders = []
glyphs_seen = set()
for name in sorted(os.listdir(HTML)):
if not name.endswith((".html", ".js")):
continue
src = io.open(os.path.join(HTML, name), encoding="utf-8").read()
for ch in set(src):
o = ord(ch)
if is_emoji(o):
offenders.append((name, "U+%04X" % o))
if o > 0x2000 and o not in (0x2013, 0x2014, 0x2018, 0x2019,
0x201C, 0x201D, 0x2026):
glyphs_seen.add(o)
chk("no emoji-range codepoint and no U+FE0F selector survives in any page",
not offenders, offenders[:8])
print("\n2. the mapping document")
tokens = io.open(os.path.join(ROOT, "docs", "reference", "tokens.md"),
encoding="utf-8").read()
chk("the meaning-to-icon mapping is documented in tokens.md",
"## Icons (S6 / T9.3)" in tokens and "U+2713" in tokens and "U+2298" in tokens)
body = tokens[tokens.find("## Icons"):]
rows = re.findall(r"^\| ([^|]+) \| [^|]+ \| (U\+[0-9A-F]{4}[^|]*) \|", body, re.M)
meanings = [r[0].strip() for r in rows]
chk("no meaning appears twice in the mapping", len(meanings) == len(set(meanings)),
[m for m in meanings if meanings.count(m) > 1])
codes = []
for _, cp in rows:
codes.extend(re.findall(r"U\+([0-9A-F]{4})", cp))
chk("no glyph carries two meanings", len(codes) == len(set(codes)),
[c for c in codes if codes.count(c) > 1])
print("\n3. what the pages use is what the document names")
# Box-drawing comment art (U+2500-257F) and the A7 locale samples in
# wp-format.js are not icons; everything else above U+2200 must be mapped.
unmapped = sorted("U+%04X" % o for o in glyphs_seen
if o >= 0x2200 and o not in APPROVED
and not (0x2500 <= o <= 0x257F)
and not (0x4E00 <= o <= 0xD7FF)) # CJK/Hangul: A7 locale data
chk("every glyph in use above the punctuation range is in the approved set",
not unmapped, unmapped[:10])
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())