From b83f2fd8d5451522c3ba196491a621a6f320bc6e Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Wed, 19 Aug 2026 13:15:59 -0700 Subject: [PATCH] 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 --- docs/reference/file-map.md | 1 + docs/reference/tokens.md | 48 +++++++++++++++++++ html/admin.js | 36 +++++++-------- html/field.js | 4 +- html/help.js | 18 ++++---- html/users.js | 8 ++-- html/wp-creation-app.js | 16 +++---- html/wp-creation-index.html | 10 ++-- html/wp-sidenav.js | 4 +- tests/icon_check.py | 91 +++++++++++++++++++++++++++++++++++++ 10 files changed, 188 insertions(+), 48 deletions(-) create mode 100644 tests/icon_check.py diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index 5871c2a..c2ab155 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -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 diff --git a/docs/reference/tokens.md b/docs/reference/tokens.md index 200de90..a0b5919 100644 --- a/docs/reference/tokens.md +++ b/docs/reference/tokens.md @@ -961,3 +961,51 @@ Column `#` is the line number in `html/theme-light.css`. Read down each column p **120 declarations.** Lines 3–116 are the Carbon g10 set; lines 170–175 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. diff --git a/html/admin.js b/html/admin.js index dc20c26..7805658 100644 --- a/html/admin.js +++ b/html/admin.js @@ -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 — 3–5 days (≈40–80 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 can’t archive or delete projects here.'; + banner.textContent='✕ Your account is not an admin, so you can’t 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 can’t change who is added to new projects.'; + banner.textContent='✕ Your account is not an admin, so you can’t 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; diff --git a/html/field.js b/html/field.js index 090edcf..53d9ad9 100644 --- a/html/field.js +++ b/html/field.js @@ -149,11 +149,11 @@ function renderDetail() { (((p.files) || []).length ? '

Drawings

' + p.files.map(function (f) { return '' + - '📄 ' + esc(f.name || 'drawing') + (f.description ? ' — ' + esc(f.description) : '') + ''; + '' + esc(f.name || 'drawing') + (f.description ? ' — ' + esc(f.description) : '') + ''; }).join('') + '
' : '') + '

Add field update

' + '' + - '
' + + '
' + '' + (pendingPhoto ? 'Photo attached ✓' : '') + '
' + '
' + '
' + diff --git a/html/help.js b/html/help.js index 8e23341..361dd5f 100644 --- a/html/help.js +++ b/html/help.js @@ -93,7 +93,7 @@
  • Dashboard — track status, hours, due dates, and what's gating each package across the project.
  • Moving around

    -

    From the home page, open SOP Configuration, the Work Package Creator, or the Dashboard. Inside the suite, switch any time using the top tabs: ⚙️ SOP Configuration, 📋 Work Package Creation, and 📊 Dashboard. The active project and SOP follow you across all of them.

    +

    From the home page, open SOP Configuration, the Work Package Creator, or the Dashboard. Inside the suite, switch any time using the top tabs: ⚙SOP Configuration, Work Package Creation, and Dashboard. The active project and SOP follow you across all of them.

    Quick start

    1. Open “SOP Configuration” and complete the 10 steps for your project (~15 minutes).
    2. @@ -101,7 +101,7 @@
    3. Open “Work Package Creation” to author packages with your SOP defaults pre-populated.
    4. Update from the field using the Field View, and leave feedback on any page with the Feedback button.
    -
    New here? On the home page choose the Sample Project, then click ⭐ Load sample in the suite to see a fully filled-out SOP and an example Work Package.
    ` }, +
    New here? On the home page choose the Sample Project, then click Load sample data in the suite to see a fully filled-out SOP and an example Work Package.
    ` }, { id: 'projects', title: 'Projects', body: `

    Projects

    @@ -133,7 +133,7 @@
  • Release Gate Constraints — choose which standard AWP constraints apply and add custom ones (see Constraints).
  • Engineering Sources & References — labelled links (Design Drawings, Specs, …) that appear as quick-access buttons in the WP Creator's Drawings & Attachments.
  • -
    Fields a WP inherits from the SOP show a "from SOP" tag and are locked. You can override a locked field with 🔒 Edit, which requires a logged reason.
    ` }, +
    Fields a WP inherits from the SOP show a "from SOP" tag and are locked. You can override a locked field with ✎ Edit, which requires a logged reason.
    ` }, { id: 'wps', title: 'Work Packages', body: `

    Creating Work Packages

    @@ -152,7 +152,7 @@
  • Quality / Hold Points, Approvals & Sign-offs, and Closeout (actual hours, as-builts, lessons learned — shown at QC/Closed).
  • Saving

    -

    Save Draft stores the package; ⚡ Save & View 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.

    ` }, +

    Save Draft stores the package; Save & View 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.

    ` }, { id: 'statuses', title: 'Statuses', body: `

    Work Package statuses

    @@ -223,7 +223,7 @@ { id: 'dashboard', title: 'Dashboard', body: `

    Dashboard & metrics

    -

    The dashboard aggregates every (non-master) package in the active project. Open it from the home page, the suite's 📊 Dashboard tab, or the Creator header.

    +

    The dashboard aggregates every (non-master) package in the active project. Open it from the home page, the suite's Dashboard tab, or the Creator header.

    Metric cards (click to filter)

    • Total WPs, Release-ready, On hold, Overdue
    • @@ -232,7 +232,7 @@

      Breakdowns & gates

      • By status and by discipline chips.
      • -
      • ⛔ Gating constraints — lists every blocked package and exactly which constraints are holding it.
      • +
      • ⊘ Gating constraints — lists every blocked package and exactly which constraints are holding it.

      The table

      Shows WP #, subject, type, discipline, status, Gates (clear, n open, or master), due date (red if overdue), and hours. Row actions: issue (when release-ready), view, and edit. Filter with the search box and the status / discipline dropdowns.

      @@ -241,7 +241,7 @@ { id: 'data', title: 'Samples, sharing & comments', body: `

      Samples, import / export & comments

      Load sample

      -

      ⭐ Load sample 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.

      +

      Load sample data 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.

      Import / Export

      • Work Packages⤓ Export (JSON) downloads all saved packages; import restores them.
      • @@ -249,9 +249,9 @@
      • Materials — import a bill of materials from Excel/CSV, or download a template.

      Comments & feedback

      -

      Leave feedback from the home page, per-step comments in the SOP tool (💬 Step Comments), or package comments in the Creator's 💬 Comments drawer. Comments are saved and can be exported/imported as .json so reviewers can share them — and, when the API is reachable, they're collected centrally too.

      +

      Leave feedback from the home page, per-step comments in the SOP tool (Step Comments), or package comments in the Creator's Comments drawer. Comments are saved and can be exported/imported as .json so reviewers can share them — and, when the API is reachable, they're collected centrally too.

      Usage logs

      -

      📊 Usage Logs / ▤ Usage Data shows session and event counts and can export the full log. A dev-mode toggle pauses tracking during demos.

      ` }, +

      Usage Logs / ▤ Usage Data shows session and event counts and can export the full log. A dev-mode toggle pauses tracking during demos.

      ` }, { id: 'shortcuts', title: 'Tips & shortcuts', body: `

      Tips & keyboard shortcuts

      diff --git a/html/users.js b/html/users.js index 959b52d..fe63e98 100644 --- a/html/users.js +++ b/html/users.js @@ -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')); } } diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index 7ce0988..34a356e 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -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=``+ - ``; + ``; } 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=>`
      - ${f.mime==='application/pdf'?'📄':'🖼'} ${esc(f.name||'drawing')} + ${esc(f.name||'drawing')} ${wpFileSize(f.size||0)} @@ -1310,7 +1310,7 @@ function renderSopFileFolders(){ const srcs=sopLinkedSources(); box.innerHTML = srcs.length ? `
      1) Open a folder, multi-select files in SharePoint, then use Copy link:
      `+ - `` + `` : `
      No SOP folders defined — load or import an SOP first.
      `; } 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 = '⛔ ' + (open ? open + ' open — ' : '') + holdLine = '⊘ ' + (open ? open + ' open — ' : '') + esc(why) + ''; } return '