Files
Project-SDE-WP-Suite/html/console-util.js
n.siegfried c024cba844 T4.5/T4.6/T4.7 - S10/S11/S12: it announces, it is legible, focus is visible
Three small accessibility items, done together because they share one probe and
one measurement method. tests/a11y_check.py, 22 checks, all passing.

S10 — ANNOUNCEMENTS. The app had zero aria-live regions; login.html's
role="alert" / role="status" pair was the only correct example in the codebase.
Both toasts now take an optional kind and set the role BEFORE the text, because
assistive technology announces on the content change and a role applied after
describes the next message rather than this one. The sync badge announces
politely.

Admin banners are handled by a rule rather than by editing thirteen assignment
sites: a MutationObserver in console-util.js marks anything carrying `.bad` as
role=alert and everything else role=status. Thirteen edits is thirteen chances to
get it wrong, and any banner added later would have missed out. The probe checks
a banner created after load, which is the case that would have regressed.

S11 — CONTRAST. Re-measured rather than quoting either published figure, as the
file map asked. #8d8d8d is 3.32:1 on white, not "about 2.9:1" as the plan says;
console.css:103's 3.3:1 was right. On the shaded surfaces it is worse - 3.01:1 on
a success banner. --cds-text-helper (#6f6f6f) clears 4.5:1 on all seven
backgrounds the app actually paints, tightest 4.56:1.

Fixed once, in the token, so all three sheets inherit it. console.css's local
override is gone, as the task requires. Disabled text was repointed to
--cds-text-disabled rather than darkened with everything else: making disabled
text MORE legible makes a disabled control look enabled.

The probe measures against the background actually painted behind each element,
walking ancestors for the first non-transparent one - not an assumed white, which
is how "passes on paper" and "fails on the page" come to disagree.

S12 — FOCUS. An app-wide :focus-visible floor in theme-light.css at zero
specificity, so any component can still draw its own. Filled controls get an
explicit rule at class specificity: a blue ring inside a blue button measures
1.00:1, which is not a subtle problem but no indicator at all. console.css's
inset ring had exactly that defect on button.primary.

`outline: none` is down from six to one, and that one (.wpc-search-input) has its
replacement in the rule above it - the shell rings on :focus-within, and ringing
both would draw two rectangles.

TWO REAL DEFECTS THE PROBE FOUND that reading would not have:

  - .wp-navbtn's ring is white, which is right on the near-black app bar and
    invisible on the creator's white header. Same button, same class, two hosts,
    1.00:1 on one of them.
  - The comment drawer is translated off-screen when closed, and a transform
    moves a thing without removing it from the tab order. Its name field,
    textarea, Add button and close button were all still focusable: a keyboard
    user could tab into a panel they could not see and could not tell they were
    in. Now visibility:hidden while closed, with the transition delayed so it
    still animates both ways.

The probe itself needed three corrections, each of which was a wrong answer
before it was a right one, and each worth knowing:

  - focus emulation must be ON, or :focus-visible never matches in headless and
    every element reports clean - a pass that means nothing.
  - which surface a ring is drawn against depends on the offset the BROWSER uses,
    not the one the stylesheet asked for. Chromium redraws a low-contrast author
    ring in white at offset 0 on a filled control, which is more contrast than was
    requested; measuring that against the parent scores it 1.00:1 and calls a
    correct ring a defect.
  - focus() on a hidden control does nothing, so the probe has to ask whether the
    focus actually landed. A closed drawer still has layout; a bounding box is not
    evidence that anyone can reach it.

Metric 7, aria-live regions: was 0 at wave 0, now 13 role/aria-live sites across
7 files.

browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:20:57 -05:00

141 lines
7.3 KiB
JavaScript

/* Shared helpers for the suite's admin pages (Admin Console, User Directory).
These used to live in admin.js. They are here because the User Directory needs
the same escaping and the same role vocabulary, and a second copy of either is a
liability: a divergent jsq() is an XSS, and a divergent role list quietly offers
a permission the server will refuse.
Loaded as plain globals (no modules) to match the rest of the suite. */
// ── api ──────────────────────────────────────────────────────────────────────
// Never throws: returns {status, json} with status 0 when the request itself
// failed, so every caller can branch on one shape.
async function api(method, path, body){
const opt = { method, headers:{ 'Accept':'application/json' } };
if(body !== undefined){ opt.headers['Content-Type']='application/json'; opt.body=JSON.stringify(body); }
try {
const r = await fetch(path, opt);
const t = await r.text();
let json; try { json = t ? JSON.parse(t) : null; } catch(_){ json = t; }
return { status:r.status, json };
} catch(e){ return { status:0, json:String(e) }; }
}
// The message to show for a failed call, preferring the server's own words.
function apiError(status, json, fallback){
if(json && json.detail) return json.detail;
if(status === 0) return 'Could not reach the server.';
if(status === 401) return 'Not signed in. Reload and log in again.';
return (fallback || 'Request failed') + ' (HTTP ' + status + ').';
}
// ── escaping ─────────────────────────────────────────────────────────────────
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// A value bound into an inline handler — onclick="fn('…')" — is escaped TWICE: once
// for the JS string literal it lands in, and again for the HTML attribute carrying
// it. The order is the whole point. Escape the backslashes FIRST, then the quotes,
// then hand the result to uesc: uesc leaves \ and ' alone, so the JS escaping
// survives, and the browser decodes the entities before the JS parser runs.
//
// Doing it the other way round — uesc(v).replace(/'/g,"\\'") — silently fails on a
// value containing a backslash: the \ we add is itself escaped by the stored one,
// the quote closes the literal, and everything after it runs as code. Project names,
// full names and usernames are free text that a signed-in user can write, so that is
// a real path from a project_user to whatever an admin's session can do. Use jsq()
// for EVERY value that lands inside an inline handler.
function jsq(v){
return uesc(String(v==null ? '' : v).replace(/\\/g,'\\\\').replace(/'/g,"\\'"));
}
// ── role vocabulary (mirrors server/auth.py) ─────────────────────────────────
// Permissions roles: what an account may DO. Ordered most- to least-privileged,
// same as auth.ROLES, because that is the order the dropdowns render in.
const PERM_ROLES = ['admin','project_super_user','project_admin','project_user'];
const PERM_LABELS = {
admin:'Administrator',
project_super_user:'Project Super User',
project_admin:'Project Admin',
project_user:'Project User',
};
// One-line description of each, used in the legends and dropdown titles.
const PERM_HELP = {
admin:'Manages users, app settings and every project.',
project_super_user:'On their assigned projects: everything a Project Admin can do, '+
'plus creating and managing that project\'s user accounts.',
project_admin:'On their assigned projects: may delete work packages, change a completed SOP, '+
'and delete the project.',
project_user:'Creates and edits work packages and authors the SOP, but cannot delete WPs '+
'or change the SOP once it is complete.',
};
// Roles that can be held on a SINGLE project (ProjectMember.role); '' inherits the
// account's own. 'admin' is app-wide by definition and never appears here.
const PROJECT_SCOPED_ROLES = ['project_super_user','project_admin','project_user'];
// Job functions on a project. Descriptive only — no permissions attached.
const PROJECT_ROLES = ['Project Manager','Assistant Project Manager','Construction Manager',
'Quality Manager','Superintendent','General Foreman','Foreman','Planner / Scheduler',
'BIM / VDC Coordinator','Engineer','Safety (HSE)','Warehouse / Materials','Commissioning',
'Field Technician'];
// Accounts created before permissions roles existed carry the legacy value 'user'.
function normRole(r){ return r==='user' ? 'project_user' : (PERM_ROLES.indexOf(r)>=0 ? r : 'project_user'); }
function roleLabel(r){ const n = normRole(r); return PERM_LABELS[n] || n; }
// Which pill a role wears. Admin and super user each get their own colour because
// "can reach every project" and "can create users here" are the two facts you scan
// this column for.
function roleTagClass(r){
const n = normRole(r);
return n==='admin' ? 'admin' : n==='project_super_user' ? 'super' : 'user';
}
// ── announcements (S10 / T4.5) ───────────────────────────────────────────────
// Every banner on these pages announces. They did not: the app had zero aria-live
// regions, and login.html's role="alert" / role="status" pair was the only correct
// example in the codebase. This is that pattern, applied where the banners are.
//
// Done with an observer rather than by editing thirteen assignment sites in
// admin.js, for two reasons. Those sites set className and textContent together
// and would each need the same two extra lines, which is thirteen chances to get
// it wrong; and any banner added later would silently miss out. The rule lives in
// one place instead: a banner that carries `.bad` interrupts, and everything else
// waits its turn.
(function () {
'use strict';
function politeness(el) {
// .bad is an error the user has to act on, so it interrupts (assertive).
// Success and progress do not: announcing "loading" over the top of whatever
// someone was reading is how a screen reader becomes unusable.
return /\bbad\b/.test(el.className || '') ? 'alert' : 'status';
}
function mark(el) {
if (!el) return;
var want = politeness(el);
if (el.getAttribute('role') !== want) el.setAttribute('role', want);
}
function markAll(root) {
var sel = '.banner, [id$="-banner"], .secwarn, .gate-msg';
try {
(root || document).querySelectorAll(sel).forEach(mark);
} catch (e) {}
}
function start() {
markAll(document);
try {
new MutationObserver(function (muts) {
muts.forEach(function (m) {
if (m.type === 'attributes') mark(m.target);
(m.addedNodes || []).forEach(function (n) {
if (n.nodeType !== 1) return;
mark(n);
markAll(n);
});
});
}).observe(document.documentElement, {
subtree: true, childList: true, attributes: true, attributeFilter: ['class'],
});
} catch (e) {}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else start();
})();