From 1d847b0f8a3b4c300ac293ac17d37b4ee19ed922 Mon Sep 17 00:00:00 2001
From: Jared Scott
Date: Thu, 17 Sep 2026 20:21:23 +0800
Subject: [PATCH 1/4] feat(web): keep inert controls on the page, and give the
board one control primitive
Two issues on one branch, because exactly one PR may touch cargento_runtime/web/
at a time and both of these do. One commit rather than two: the change to
next-cockpit.js and styles.css interleaves, and the byte pins are shared, so a
split would leave a first commit whose oracles are red.
DRC-4588 -- always render the Held to reading control.
The reading block took an early return whenever a reason withheld the offer,
which deleted the button, the offer paragraph, the sending disclosure and the
request counter together, in the one state a newcomer lands in. The control now
renders in all four reason states and prints the reason after the button, so
the sentence moves rather than going.
The dangerous half is the attribute. `aria-disabled` keeps the control in the
tab order and lets its reason be announced, but it also restores the click that
`disabled` was suppressing, and neither handler behind these controls gated on
anything that could refuse it: a press would have reached POST /api/reading and
spent the reader's own model capacity from a state the page calls unavailable.
Both handlers now refuse on the same expression their control renders, from one
function, so the two cannot disagree. The save control is inert rather than
`hidden` for the same reason, and `clear` keeps `hidden` because an empty box
has nothing to clear and no explanation to offer.
This supersedes the DRC-4565-era ruling that the offer stays withheld on the
discarded and never-typed rows. Two assertions invert, and the comment above
them is replaced rather than deleted: the reason the offer was withheld was
that the sentence had to be load-bearing, and what changed is that the sentence
is now bound to the control through `aria-describedby`.
DRC-4590 -- one control primitive, and a disabled state that survives greyscale.
The stylesheet had no way to say "this one": no shared control class and no
radius token, against 24 resting control rules over six corner treatments.
`.next-action` is that primitive, opted into by writing the class rather than by
appending to a selector group, which is the drift that produced the 24 recipes.
Seven rules collapse onto it, `.next-action--primary` marks the one tab that has
an action to mark, and disabled is dashed rather than dimmer because `--ink3` is
the resting colour of the prose these controls sit in. The dead `.next-tabs`
class is removed from two shared selector groups rather than by line range.
Both criteria are accepted on enumerated verifiers, not universal readings: the
remaining five control recipes and the four tabs with no action to mark are
filed as DRC-4604 and DRC-4603.
The durable rationale for both, including the rejected selector-group
alternative, is NUI-18 in docs/design-next-ui.md.
Implements DRC-4588
Implements DRC-4590
Signed-off-by: Jared Scott
Co-Authored-By: Claude Opus 5 (1M context)
---
cargento/skills/cargento/SKILL.md | 2 +-
.../cargento_runtime/web/next-boot.js | 5 +-
.../cargento_runtime/web/next-capacity.js | 3 +-
.../cargento_runtime/web/next-chrome.js | 3 +-
.../cargento_runtime/web/next-cockpit.js | 105 +++--
.../cargento_runtime/web/next-controls.js | 6 +-
.../cargento_runtime/web/next-notify.js | 2 +-
.../cargento/cargento_runtime/web/styles.css | 60 ++-
cargento/skills/cargento/tests/test_focus.py | 2 +-
.../skills/cargento/tests/test_next_chrome.py | 24 +-
.../cargento/tests/test_next_cockpit.py | 361 +++++++++++++++++-
.../cargento/tests/test_next_controls.py | 5 +-
.../skills/cargento/tests/test_next_flag.py | 4 +-
.../skills/cargento/tests/test_next_page.py | 167 +++++++-
docs/design-next-ui.md | 60 +++
docs/design-reader-state.md | 2 +-
16 files changed, 720 insertions(+), 91 deletions(-)
diff --git a/cargento/skills/cargento/SKILL.md b/cargento/skills/cargento/SKILL.md
index db8c99a3..6c822450 100644
--- a/cargento/skills/cargento/SKILL.md
+++ b/cargento/skills/cargento/SKILL.md
@@ -66,7 +66,7 @@ them. **Console** holds Delegation, Waiting on you, Capacity, and Tripwires, fol
selected session's optional read-only terminal in the same panel. **Held to** appears only with a
session selected and holds the goal and expected output you typed for it, the observed entries
naming it, any direction you gave after you saved those words, the reading block, and how the
-session landed as two cards that do not imply each other. Missing readings name their reason.
+session landed as two cards that do not imply each other. The control that asks for a reading is always on the tab; when it cannot fire it stays put, refuses the press and names the reason beside itself.
Browser-local
human context and old tripwire notes do not instruct an agent and remain inert.
diff --git a/cargento/skills/cargento/cargento_runtime/web/next-boot.js b/cargento/skills/cargento/cargento_runtime/web/next-boot.js
index f72723ec..9c20b9ab 100644
--- a/cargento/skills/cargento/cargento_runtime/web/next-boot.js
+++ b/cargento/skills/cargento/cargento_runtime/web/next-boot.js
@@ -255,7 +255,8 @@ function nextSessionCopyControl(session){
// other session-keyed structure here is (`nextSessionKey`): a sid is unique
// within a harness and nowhere else.
const harness = String(session && session.harness || "");
- return `
";
diff --git a/cargento/skills/cargento/cargento_runtime/web/next-chrome.js b/cargento/skills/cargento/cargento_runtime/web/next-chrome.js
index b493cf42..690e89e8 100644
--- a/cargento/skills/cargento/cargento_runtime/web/next-chrome.js
+++ b/cargento/skills/cargento/cargento_runtime/web/next-chrome.js
@@ -653,7 +653,8 @@ function nextRefreshNotice(){
return '
' +
`Live refresh failed ${failures} in a row.` +
`${state} Retrying automatically every ${retrySeconds}s.` +
- `Retry now
`;
+ `` +
+ "Retry now";
}
/* The two reasons `history.RESET_UNREADABLE` and `history.RESET_VERSION` publish,
diff --git a/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js b/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js
index db28cbf3..93585e80 100644
--- a/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js
+++ b/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js
@@ -818,14 +818,25 @@ function nextCockpitHeldCap(){
raw bidi control in a source file is the thing this class exists to strip. */
const NEXT_COCKPIT_HELD_UNSAFE = /[\x00-\x1f\x7f\u200b\u200e\u200f\u202a-\u202e\u2066-\u2069]+/g;
-function nextCockpitHeldControl(action, label, kind, shown){
+/* `inert` picks how a control that does not apply is drawn, and the two here
+ want different answers. `save` is the field's own verb, so it stays on the
+ page and says why it cannot fire; `clear` stays `hidden`, because an empty
+ box has nothing to clear and no explanation to offer. */
+function nextCockpitHeldControl(action, label, kind, shown, inert){
+ const off = inert ? ' aria-disabled="true"' : " hidden";
return `${label}`;
+ `${shown ? "" : off}>${label}`;
}
-function nextCockpitHeldToggle(field, action, shown){
+function nextCockpitHeldToggle(field, action, shown, inert){
const control = field.querySelector(`[data-next-cockpit-action="${action}"]`);
- if(control) control.hidden = !shown;
+ if(!control) return;
+ /* The attribute, not the property, on the inert path. This runs on a
+ keystroke with no redraw, so whichever of the two the renderer chose is
+ the one already in the DOM and the one that has to be cleared here. */
+ if(!inert){ control.hidden = !shown; return; }
+ if(shown) control.removeAttribute("aria-disabled");
+ else control.setAttribute("aria-disabled", "true");
}
/* What the last save attempt is still worth saying, and for how long.
@@ -1039,8 +1050,8 @@ function nextCockpitHeldField(session, annotation, spec, cap){
`data-next-focus="${esc(key)}" placeholder="${esc(placeholder)}">${esc(draft)}` +
`` +
`${draft.length}/${cap}` +
- nextCockpitHeldControl("held-clear", "clear", kind, Boolean(draft)) +
- nextCockpitHeldControl("held-save", "save", kind, draft !== saved) +
+ nextCockpitHeldControl("held-clear", "clear", kind, Boolean(draft), false) +
+ nextCockpitHeldControl("held-save", "save", kind, draft !== saved, true) +
/* The absence sentence answers "why is this empty", so it goes when the
box stops being empty. It read the SERVER value alone, which put "No
goal typed for this session." directly under the sentence the reader
@@ -1693,7 +1704,13 @@ function nextCockpitReadingStates(annotation, model){
}
if(!String(annotation && annotation.goal || "").trim() &&
!String(annotation && annotation.output || "").trim()){
- return "Nothing has been typed for this session, so there is nothing to read it against.";
+ /* The first sentence verbatim, because `/api/reading` refuses with these
+ same words and the block and the route behind its button cannot word
+ one state two ways. The second is this page's own: the control now
+ renders beside it, so the reader can see the act they are being
+ refused and needs the one step that would permit it (DRC-4588). */
+ return "Nothing has been typed for this session, so there is nothing to read it against. " +
+ "Save a goal above to enable a reading.";
}
if(!model) return "Observer model availability has not been read, so no reading can be offered.";
if(model.enabled !== true){
@@ -1996,16 +2013,32 @@ function nextCockpitReadingDepartures(shape, source){
press count to render beside the control, which could then only ever have
shown zero. A reader looking at the amber "revision 3 is current" line had
no way to ask for a current one. */
-function nextCockpitReadingControl(session, annotation, model){
+/* One reason, read by the control that renders it and by the handler that
+ refuses the press: a handler with a second opinion can refuse a press the
+ button offered, or take one it refused
+ ([NUI-18](docs/design-next-ui.md#nui-18-one-control-primitive-and-an-inert-control-stays-on-the-page)). */
+function nextCockpitReadingRefusal(annotation, model){
const authorized = nextData && ["passed", "accepted"].includes(nextData.reading_check);
/* A stored reading outlives the model option. Only the new request is
gated here; retaining the old account never establishes availability. */
- const reason = nextCockpitReadingStates(annotation, model) || (authorized ? "" :
+ return nextCockpitReadingStates(annotation, model) || (authorized ? "" :
"The abstention check this ruling requires has not been run, so a reading cannot be " +
"asked for yet. The evidence above stays readable without one.");
+}
+
+// One block renders per page -- the focused session's -- so the paragraph the
+// button points at can carry a constant id, as the discard control's warning
+// already does two hundred lines below.
+const NEXT_READING_REFUSED_ID = "next-cockpit-reading-refused";
+
+function nextCockpitReadingControl(session, annotation, model){
+ const reason = nextCockpitReadingRefusal(annotation, model);
const request = nextCockpitReadingRequests.get(sessKey(session));
const pending = request && request.pending;
- const enabled = authorized && !reason && !pending;
+ /* `authorized` is no longer a second term here: an unauthorized check is
+ one of the sentences `nextCockpitReadingRefusal` returns, so `!reason`
+ already carries it. */
+ const enabled = !reason && !pending;
const count = nextNumber(annotation && annotation.reading_count) || 0;
const spent = `${count} model request${count === 1 ? "" : "s"} recorded for this session.`;
/* Before the button, not after the press. The reading spends the reader's
@@ -2015,13 +2048,23 @@ function nextCockpitReadingControl(session, annotation, model){
read this has not been warned. */
const disclosure = nextData && nextData.reading_disclosure
? `
${esc(nextData.reading_disclosure)}
` : "";
+ /* `aria-disabled` rather than `disabled`, so the control keeps its place in
+ the tab order and its reason is announced. The press this lets back in is
+ refused by `nextCockpitAskForReading`, on the reason computed above. */
+ /* The board's one primary. Four of the five cockpit tabs have no action to
+ mark at all, so this is where "the one thing to press" is said (DRC-4590). */
return disclosure +
- '` +
+ '` +
`${pending ? "Reading in progress…" : "Ask for a reading"}` +
(request ? `
${esc(request.message)}
` : "") +
`${esc(spent)}` +
- (reason ? `
${esc(reason)}
` : "");
+ (reason
+ ? `
${esc(reason)}
`
+ : "");
}
const NEXT_READING_OFFER =
@@ -2077,10 +2120,10 @@ function nextCockpitReading(session, annotation, entries, model, observed, unset
conflating the two made a stored reading disappear the moment the
observer model was switched off. */
if(!raw){
- const reason = nextCockpitReadingStates(annotation, model);
- if(reason){
- return close(`
${esc(reason)}
`, null);
- }
+ /* No early return on a reason: the control renders in all four of them and
+ prints the reason after the button, so the sentence moves rather than
+ going. This supersedes the withheld-offer ruling
+ ([NUI-18](docs/design-next-ui.md#nui-18-one-control-primitive-and-an-inert-control-stays-on-the-page)). */
/* A reading the store refused on read-back is not a session nobody
pressed on. `readings` survives a refusal, so without this the block
said "N readings asked for" above "No reading has been made" and left
@@ -2328,7 +2371,8 @@ function nextCockpitHeldDiscardBlock(session, annotation){
looking for. No live region reaches that state, because nothing mutates
when you tab. */
(offer
- ? '" +
`${armed ? "confirm discard" : "discard everything"}`
@@ -2435,9 +2479,19 @@ function nextCockpitHeldTo(group, observation){
answered a disclosure, so it MEANS they consented. Here it is a constant.
What stands in for consent is the press itself, under a disclosure the
control renders above the button. */
-async function nextCockpitAskForReading(session){
+async function nextCockpitAskForReading(session, model){
const key = sessKey(session);
if(nextCockpitReadingRequests.get(key)?.pending) return;
+ /* This press arrives from states the browser used to swallow, and an
+ ungated one spends the reader's own model capacity from a state the page
+ calls unavailable. Answered rather than dropped, because a clicked control
+ that goes silent is indistinguishable from a dead one. */
+ const refusal = nextCockpitReadingRefusal(nextCockpitAnnotation(session), model);
+ if(refusal){
+ nextCockpitReadingRequests.set(key, {pending: false, message: refusal});
+ renderNext();
+ return;
+ }
/* Session-scoped state survives polling and navigation while the model
runs. Another press must not spend capacity on a duplicate request. */
const request = {pending: true, message: "Reading in progress. This may take a minute."};
@@ -2576,6 +2630,13 @@ async function nextCockpitDiscardAnnotation(session){
async function nextCockpitHeldSave(session, kind){
const key = nextCockpitHeldKey(session, kind);
+ /* The same expression `nextCockpitHeldField` decides `shown` with, so the
+ control and the gate cannot disagree. Without it an inert-but-reachable
+ control mints a revision identical to the stored one. */
+ const annotation = nextCockpitAnnotation(session);
+ const stored = String(annotation && annotation[kind] || "");
+ const typed = nextCockpitHeldDrafts.has(key) ? nextCockpitHeldDrafts.get(key) : stored;
+ if(typed === stored) return;
// Only the field that changed. `null` is "leave this one alone" at the
// endpoint, and "" is "clear it": sending both every time would let a stale
// draft of one field overwrite a save of the other.
@@ -3426,9 +3487,9 @@ document.addEventListener("input", event => {
if(!field || !field.querySelector) return;
const count = field.querySelector("[data-next-cockpit-held-count]");
if(count) count.textContent = `${value.length}/${nextCockpitHeldCap()}`;
- nextCockpitHeldToggle(field, "held-clear", Boolean(value));
+ nextCockpitHeldToggle(field, "held-clear", Boolean(value), false);
nextCockpitHeldToggle(field, "held-save",
- value !== String(input.dataset.nextCockpitHeldSaved || ""));
+ value !== String(input.dataset.nextCockpitHeldSaved || ""), true);
// The fourth thing an edit changes. "No goal typed for this session" is an
// answer to "why is this empty", and it stayed under the reader's own
// half-typed sentence until something else forced a redraw.
@@ -3454,7 +3515,7 @@ document.addEventListener("click", event => {
const session = group ? nextCockpitFocusedSession(group) : null;
if(!session) return;
event.preventDefault();
- nextCockpitAskForReading(session);
+ nextCockpitAskForReading(session, nextCockpitObserverModel(group));
return;
}
if(action === "conflict-settle" || action === "conflict-retype"){
diff --git a/cargento/skills/cargento/cargento_runtime/web/next-controls.js b/cargento/skills/cargento/cargento_runtime/web/next-controls.js
index 7ced8e2e..3d63fbcd 100644
--- a/cargento/skills/cargento/cargento_runtime/web/next-controls.js
+++ b/cargento/skills/cargento/cargento_runtime/web/next-controls.js
@@ -124,7 +124,7 @@ function nextProjectSteer(project, state){
`data-next-draft="steer" data-next-controls-project="${esc(project)}" ` +
`data-next-focus="steer-draft:${esc(project)}" ` +
`value="${esc(nextControlsDraft(project, "steer"))}">` +
- 'send ⏎' + history + '';
+ 'send ⏎' + history + '';
}
function nextProjectGuardrailRows(project, state){
@@ -151,9 +151,9 @@ function nextProjectGuardrailAdd(project, state){
`data-next-draft="guardrail" data-next-focus="guardrail-draft:${esc(project)}" ` +
`value="${esc(nextControlsDraft(project, "guardrail"))}" ` +
'name="guardrail" maxlength="500" placeholder="alert me when…">' +
- 'add ↵';
+ 'add ↵';
}
- return `+ set a tripwire`;
}
diff --git a/cargento/skills/cargento/cargento_runtime/web/next-notify.js b/cargento/skills/cargento/cargento_runtime/web/next-notify.js
index 66f2a183..f5832185 100644
--- a/cargento/skills/cargento/cargento_runtime/web/next-notify.js
+++ b/cargento/skills/cargento/cargento_runtime/web/next-notify.js
@@ -164,7 +164,7 @@ function nextNotifyControl(payload){
return 'notifications blocked';
}
- return 'Enable notifications';
}
diff --git a/cargento/skills/cargento/cargento_runtime/web/styles.css b/cargento/skills/cargento/cargento_runtime/web/styles.css
index f86e3dc6..e67a168a 100644
--- a/cargento/skills/cargento/cargento_runtime/web/styles.css
+++ b/cargento/skills/cargento/cargento_runtime/web/styles.css
@@ -37,6 +37,10 @@
--accent:#c6e07a; --accent-dim:#8ea254; --amber:#e8b45c; --clay:#e08a6a;
--sans:'Space Grotesk',system-ui,-apple-system,sans-serif;
--mono:'Space Mono',ui-monospace,monospace;
+ /* The control primitive's three. `--ink3` (5.67:1 on panel) rather than
+ `--line2` (1.61:1) for the resting border, because a box a reader is meant
+ to see has to clear the 3:1 non-text bar. */
+ --radius-control:3px; --control-bd:var(--ink3); --control-pad:9px 14px;
}
*{box-sizing:border-box}
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--ink)}
@@ -44,6 +48,24 @@ body{font-family:var(--sans);-webkit-font-smoothing:antialiased;text-rendering:o
#app a,#app button,#app summary,#app [role="link"]{min-block-size:44px;min-inline-size:44px}
#app a{display:inline-flex;align-items:center;max-inline-size:100%;overflow-wrap:anywhere}
[data-next-body],[data-next-view-body]{min-height:360px}
+
+/* ===== CONTROLS =====
+ One class a control opts into by writing it, not a selector group every new
+ control must be appended to. Placed before every section below so a caller
+ can override a layout declaration; nothing below may take the box back. The
+ sweep this collapses, the enumerated seven and the rejected selector-group
+ alternative are in
+ [NUI-18](docs/design-next-ui.md#nui-18-one-control-primitive-and-an-inert-control-stays-on-the-page). */
+.next-action{display:inline-flex;align-items:center;justify-content:center;min-block-size:44px;padding:var(--control-pad);border:1px solid var(--control-bd);border-radius:var(--radius-control);background:transparent;color:var(--ink);font:var(--fs-sentence)/1.55 var(--sans);cursor:pointer}
+.next-action:hover{border-color:var(--accent);color:var(--ink)}
+.next-action:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
+.next-action--primary{border-color:var(--accent);font-weight:500}
+/* Dashed against solid, because no ink choice can carry this: `--ink3` is the
+ resting colour of the prose these controls sit in, so one ink step down
+ disappears in greyscale. Both spellings, because the primitive does not know
+ which mechanism a caller used -- the reading control refuses in its handler
+ and is `aria-disabled`, the stalled one is genuinely `disabled`. */
+.next-action:disabled,.next-action[aria-disabled="true"]{border-style:dashed;border-color:var(--line2);color:var(--ink3);cursor:not-allowed}
.next-project-row--blocked,
.next-session-row--blocked td:first-child,
.next-project-plan-row--unhealthy,
@@ -62,21 +84,20 @@ body{font-family:var(--sans);-webkit-font-smoothing:antialiased;text-rendering:o
/* ===== CHROME ===== */
#app{max-width:1320px;margin:28px auto 44px;padding:0 26px 34px;border:1px solid var(--line);border-radius:14px;background:var(--bg)}
-.next-header,.next-header-right,.next-tabs-row,.next-tabs{display:flex;align-items:center}
+.next-header,.next-header-right,.next-tabs-row{display:flex;align-items:center}
.next-header{justify-content:space-between;gap:22px;min-height:52px;border-bottom:1px solid var(--line)}
.next-header-right{gap:14px;font-family:var(--mono);font-size:var(--fs-xs);color:var(--ink2)}
.next-header nav[aria-label="Primary"]{display:flex;align-items:center;gap:20px;font-family:var(--mono);font-size:var(--fs-xs);font-weight:700}
.next-header nav[aria-label="Primary"] a{display:inline-flex;align-items:center;min-block-size:44px;min-inline-size:44px;border-bottom:2px solid transparent;color:var(--ink3);text-decoration:none}
.next-header nav[aria-label="Primary"] a[aria-current="page"]{border-bottom-color:var(--accent);color:var(--ink)}
.next-breadcrumb{display:flex;align-items:center;gap:9px;padding-top:14px;font-family:var(--mono);font-size:12px;color:var(--ink3)}
-.next-crumb,.next-tabs button,.next-menu button{border:0;background:none;color:inherit;font:inherit;cursor:pointer}
+.next-crumb,.next-menu button{border:0;background:none;color:inherit;font:inherit;cursor:pointer}
.next-crumb{padding:0;text-decoration:none}
.next-crumb:hover{color:var(--ink)}
.next-running{color:var(--ink2)}
.next-gate{appearance:none;padding:5px 9px;border:1px solid var(--line2);border-radius:999px;background:none;color:var(--amber);font:inherit;cursor:pointer}
.next-gate:focus-visible{outline:2px solid var(--ink);outline-offset:3px}
-.next-notify-button{padding:5px 9px;border:1px solid var(--line2);border-radius:3px;background:transparent;color:var(--ink2);font:inherit;cursor:pointer}
-.next-notify-button:focus-visible{outline:2px solid var(--ink);outline-offset:3px}
+.next-notify-button{color:var(--ink2)}
.next-notify-note{color:var(--ink)}
.next-menu{position:relative}
.next-menu summary{display:flex;align-items:center;justify-content:center;min-block-size:44px;min-inline-size:44px;list-style:none;cursor:pointer;font-size:var(--fs-lg)}
@@ -88,15 +109,12 @@ body{font-family:var(--sans);-webkit-font-smoothing:antialiased;text-rendering:o
.next-stalled{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:18px;padding:10px 12px;border-left:3px solid var(--clay);background:var(--panel);color:var(--ink2);font-size:var(--fs-sentence);font-weight:500;line-height:1.55}
.next-stalled strong{color:var(--ink);font-weight:500}
.next-stalled[data-next-state="history-reset"]{border-left-color:var(--ink3)}
-.next-stalled button{margin-left:auto;padding:5px 9px;border:1px solid var(--line2);border-radius:3px;background:transparent;color:var(--ink);font:inherit;cursor:pointer}
+.next-stalled button{margin-left:auto}
.next-stalled button:disabled{cursor:wait;color:var(--ink3)}
.next-tabs-row{justify-content:space-between;gap:20px}
-.next-tabs{gap:20px;font-family:var(--mono);font-size:var(--fs-sm)}
-.next-tabs button{padding:0 0 7px;color:var(--ink3)}
-.next-tabs button[aria-selected="true"]{border-bottom:2px solid var(--accent);color:var(--ink)}
.next-population{font-family:var(--mono);font-size:var(--fs-xs);color:var(--ink2)}
.next-population span{color:var(--ink3)}
-.next-session-copy{position:relative;z-index:1;display:inline-flex;align-items:center;justify-content:center;margin-top:8px;padding:4px 7px;border:1px solid var(--line2);border-radius:3px;background:transparent;color:var(--ink3);font-family:var(--mono);font-size:var(--fs-2xs);cursor:pointer}
+.next-session-copy{position:relative;z-index:1;margin-top:8px;color:var(--ink3);font-family:var(--mono);font-size:var(--fs-2xs)}
.next-session-copy:hover{border-color:var(--accent);color:var(--ink)}
.next-session-copy[data-next-copy-state="copied"]{border-color:var(--accent);color:var(--ink)}
.next-session-copy[data-next-copy-state="failed"]{border-color:var(--amber);color:var(--ink2)}
@@ -340,10 +358,8 @@ body{font-family:var(--sans);-webkit-font-smoothing:antialiased;text-rendering:o
.next-steer form{display:flex;gap:7px;padding:12px 0}
.next-steer label,.next-guardrail-add-input label{flex:1;min-width:0}
.next-steer input,.next-guardrail-add-input input{width:100%;min-width:0;padding:8px;border:1px solid var(--line2);border-radius:6px;background:var(--panel);color:var(--ink);font:var(--fs-xs) var(--sans)}
-.next-steer button,.next-guardrail-add,.next-guardrail-add-input button{padding:5px 8px;border:1px solid var(--line2);border-radius:6px;background:transparent;color:var(--ink2);font:var(--fs-label) var(--mono);cursor:pointer}
-.next-guardrail-add{align-self:flex-start;padding:0;border:0;color:var(--ink3);font-size:10.5px}
-.next-steer button:hover,.next-guardrail-add-input button:hover{border-color:var(--accent)}
-.next-guardrail-add:hover{color:var(--ink)}
+.next-steer button,.next-guardrail-add,.next-guardrail-add-input button{color:var(--ink2);font-family:var(--mono)}
+.next-guardrail-add{align-self:flex-start;color:var(--ink3)}
.next-steer-receipts,.next-guardrail-rows{display:grid;gap:8px}
.next-steer-receipt{padding:10px;border-left:2px solid var(--amber);background:var(--panel)}
.next-steer-receipt strong{display:block;overflow-wrap:anywhere;font-size:var(--fs-xs);font-weight:500}
@@ -415,8 +431,7 @@ body{font-family:var(--sans);-webkit-font-smoothing:antialiased;text-rendering:o
.next-usage-consent p.next-usage-consent-note{margin-top:11px;color:var(--ink3);font-family:var(--mono);font-size:var(--fs-xs);line-height:1.45}
.next-usage-switch{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin-top:12px;color:var(--ink3);font-family:var(--mono);font-size:var(--fs-2xs)}
.next-usage-switch strong{color:var(--ink2);font-weight:400}
-.next-usage-switch button{min-block-size:44px;padding:4px 10px;border:1px solid var(--line2);border-radius:3px;background:transparent;color:var(--ink2);font:inherit;cursor:pointer}
-.next-usage-switch button:focus-visible{outline:2px solid var(--ink);outline-offset:3px}
+.next-usage-switch button{color:var(--ink2)}
.next-capacity{margin-top:22px;border-top:1px solid var(--line2)}
.next-capacity-head,.next-capacity-row{display:grid;align-items:center;grid-template-columns:190px 56px minmax(0,1fr) 74px 190px 92px;gap:0 14px}
.next-capacity-head{padding:9px 4px 8px;border-bottom:1px solid var(--line2)}
@@ -872,6 +887,10 @@ body{font-family:var(--sans);-webkit-font-smoothing:antialiased;text-rendering:o
.next-cockpit-held-count{color:var(--ink3);font-family:var(--mono);font-size:var(--fs-label)}
.next-cockpit-held-field button{border:0;background:none;color:var(--ink2);font:var(--fs-xs) var(--mono);cursor:pointer}
.next-cockpit-held-field button[hidden]{display:none}
+/* `save` is inert rather than absent, so it needs a resting look that reads
+ as refused. `clear` keeps the rule above it: nothing to clear is nothing
+ to explain. */
+.next-cockpit-held-field button[aria-disabled="true"]{color:var(--ink3);cursor:not-allowed}
.next-cockpit-held-absent{grid-column:1/-1;margin:0;color:var(--ink3);font-size:var(--fs-xs);line-height:1.5}
.next-cockpit-held-cue{grid-column:1/-1;color:var(--ink3);font-size:var(--fs-xs)}
/* Section scope, outside the field grid: the act is per annotation and the
@@ -879,7 +898,13 @@ body{font-family:var(--sans);-webkit-font-smoothing:antialiased;text-rendering:o
No colour on it -- `--clay` reads as an observation about the session under
the design's rule 3, and this is a control. */
.next-cockpit-held-discard{display:grid;gap:4px;justify-items:start;margin-top:10px}
-.next-cockpit-held-discard button{border:0;background:none;color:var(--ink2);font:var(--fs-xs) var(--mono);cursor:pointer;padding:0}
+.next-cockpit-held-discard button{color:var(--ink2);font-family:var(--mono)}
+/* Width, not hue, and not a second colour: the comment above rules colour out
+ on this control, so the armed state -- one press from deleting every
+ revision -- is marked by a heavier boundary. The per-field `clear` stays
+ bare text, which is what makes the difference between dropping one unsaved
+ box and deleting every revision legible at all. */
+.next-cockpit-held-discard button[aria-describedby]{border-width:2px}
/* DRC-4509's work evidence. Mono throughout: every string in a row is one a
source published, including the type. The limit line is the board talking,
so it is sans. */
@@ -951,8 +976,7 @@ body{font-family:var(--sans);-webkit-font-smoothing:antialiased;text-rendering:o
.next-cockpit-reading-limit{color:var(--ink3);font:500 var(--fs-sentence)/1.55 var(--sans);overflow-wrap:anywhere}
.next-cockpit-reading-why{margin:0;color:var(--ink3);font:500 var(--fs-sentence)/1.55 var(--sans);max-width:var(--measure)}
.next-cockpit-reading-stale,.next-session-departure-stale{margin:0;color:var(--amber);font:var(--fs-xs)/1.5 var(--sans)}
-.next-cockpit-reading button{align-self:flex-start;padding:5px 10px;border:1px solid var(--line2);background:var(--panel);color:var(--ink);font:var(--fs-xs) var(--sans);cursor:pointer}
-.next-cockpit-reading button[disabled]{color:var(--ink3);cursor:default}
+.next-cockpit-reading button{align-self:flex-start}
/* HOW IT LANDED. Two cards side by side and never one verdict: what ended and
who says the work finished are different questions. The board is talking
here, not a source and not a model, so sans throughout and no colour under
diff --git a/cargento/skills/cargento/tests/test_focus.py b/cargento/skills/cargento/tests/test_focus.py
index 8a5a8177..10bd444b 100644
--- a/cargento/skills/cargento/tests/test_focus.py
+++ b/cargento/skills/cargento/tests/test_focus.py
@@ -1021,7 +1021,7 @@ def test_the_pinned_assembly_is_untouched(self) -> None:
# would fail on the reader rather than on an injected token.
self.assertNotIn(b' None:
- # `.next-session-copy` has no `:focus-visible` rule, which DRC-4381 left
- # standing; the irreversible control is not going to be the third to inherit
- # that gap.
+ # The DRC-4381 gap this comment used to describe as standing is closed:
+ # `.next-session-copy` carries `.next-action`, whose `:focus-visible`
+ # rule reaches it, so the copy control is no longer a control without a
+ # ring. The raise keeps its own, brighter one -- it is the control this
+ # test is named for and the one whose look must stay distinct.
# The whole declaration, not the property name: stopping at the colon let
# `outline:none` satisfy a test named for the ring (DRC-4017 review).
self.assertIn(
".next-session-raise:focus-visible{outline:2px solid var(--accent);outline-offset:2px}",
NEXT_STYLES,
)
+ self.assertIn(
+ ".next-action:focus-visible{outline:2px solid var(--accent);outline-offset:3px}",
+ NEXT_STYLES,
+ )
copy = re.search(r"\.next-session-copy\{([^}]*)\}", NEXT_STYLES)
raised = re.search(r"\.next-session-raise\{([^}]*)\}", NEXT_STYLES)
self.assertIsNotNone(copy)
@@ -1118,8 +1124,18 @@ def test_the_irreversible_control_has_its_own_look_and_a_focus_ring(self) -> Non
assert copy is not None
assert raised is not None
self.assertNotEqual(copy.group(1), raised.group(1))
- self.assertIn("background:transparent", copy.group(1))
+ # `background:transparent` moved to `.next-action` with the rest of the
+ # resting box (DRC-4590). Asserted where it now lives, because leaving
+ # it asserted on the copy rule would forbid the collapse rather than
+ # check the property the reader sees.
+ action = re.search(r"\.next-action\{([^}]*)\}", NEXT_STYLES)
+ self.assertIsNotNone(action)
+ assert action is not None
+ self.assertIn("background:transparent", action.group(1))
self.assertNotIn("background:transparent", raised.group(1))
+ # And the copy control still does not redeclare it, which is what
+ # "collapsed onto the primitive" has to mean.
+ self.assertNotIn("background:", copy.group(1))
def test_breadcrumb_segments_mark_current_location_and_escape_walks_up(self) -> None:
out = self._run_page_js(
diff --git a/cargento/skills/cargento/tests/test_next_cockpit.py b/cargento/skills/cargento/tests/test_next_cockpit.py
index d3e4aa00..70f983c3 100644
--- a/cargento/skills/cargento/tests/test_next_cockpit.py
+++ b/cargento/skills/cargento/tests/test_next_cockpit.py
@@ -3778,6 +3778,12 @@ class CockpitHeldToTabTest(NextPageJsHarness):
.filter(([key]) => key.startsWith("data-")).map(([key, value]) => [camel(key), value]));
return {dataset, tagName:match[1].toUpperCase(), value:"", attrs, hidden:"hidden" in attrs,
getAttribute(name){ return attrs[name] || null; },
+ // Attributes the handler writes land in `attrs`, the same place the
+ // parsed ones do, so a test cannot tell a handler's write from the
+ // renderer's -- which is the point: a control the renderer drew inert
+ // and a control the handler made inert are the same control.
+ setAttribute(name, value){ attrs[name] = String(value); },
+ removeAttribute(name){ delete attrs[name]; },
focus(){ document.activeElement = this; },
closest(selector){
// The field container the input handler reaches for, synthesised
@@ -5071,6 +5077,10 @@ def test_a_reading_press_shows_progress_and_survives_redraws(self) -> None:
out = self.run_fixture(r"""
__dashboard.reading_check = "accepted";
const session = __dashboard.sessions[0];
+// The gate reads the published annotation, which is where the renderer
+// gets its copy too, so the fixture has to carry the same words as the
+// literal below rather than only the literal.
+session.annotation_goal = "ship it";
const annotation = {goal:"ship it", reading_count:0};
const control = () => nextCockpitReadingControl(session, annotation, {enabled:true});
const releases = [];
@@ -5078,11 +5088,11 @@ def test_a_reading_press_shows_progress_and_survives_redraws(self) -> None:
const upstream = __fetchImpl;
__fetchImpl = (url, init) => String(url) === "/api/reading"
? (calls++, new Promise(resolve => { releases.push(resolve); })) : upstream(url, init);
-const pending = nextCockpitAskForReading(session);
+const pending = nextCockpitAskForReading(session, {enabled:true});
await __settle();
renderNext();
const during = control();
-const duplicate = nextCockpitAskForReading(session);
+const duplicate = nextCockpitAskForReading(session, {enabled:true});
const other = nextCockpitReadingControl(__dashboard.sessions[1], annotation, {enabled:true});
for(const release of releases) release({ok:true, json:async()=>({ok:true, produced:true, reason:""})});
await Promise.all([pending, duplicate]);
@@ -5101,6 +5111,7 @@ def test_a_reading_press_reports_refusal_and_failure_without_retrying(self) -> N
out = self.run_fixture(r"""
__dashboard.reading_check = "accepted";
const session = __dashboard.sessions[0];
+session.annotation_goal = "ship it";
const annotation = {goal:"ship it", reading_count:0, reading_withheld:"No end was observed."};
const upstream = __fetchImpl;
const outcomes = [];
@@ -5116,7 +5127,7 @@ def test_a_reading_press_reports_refusal_and_failure_without_retrying(self) -> N
if(reply === null) throw new Error("network down");
return reply;
};
- await nextCockpitAskForReading(session);
+ await nextCockpitAskForReading(session, {enabled:true});
renderNext();
outcomes.push(nextCockpitReadingControl(session, annotation, {enabled:true}));
}
@@ -5182,8 +5193,17 @@ def test_the_reading_has_three_states_and_the_control_waits_on_a_check(self) ->
const block = html.slice(html.indexOf('class="next-cockpit-reading"'));
return {
text: (block.match(/class="next-cockpit-reading-why">([^<]*)) || [])[1],
+ // The refusal by its id rather than by being first. The offer paragraph
+ // now precedes it in every state, because the control renders in all of
+ // them, and "the first reason paragraph" stopped naming the reason.
+ reason: (block.match(
+ /class="next-cockpit-reading-why" id="next-cockpit-reading-refused">([^<]*)) || [])[1],
control: block.includes('data-next-cockpit-action="reading-ask"'),
- disabled: /data-next-cockpit-action="reading-ask"[^>]*disabled/.test(block),
+ disabled: /data-next-cockpit-action="reading-ask"[^>]*aria-disabled="true"/.test(block),
+ // The bare attribute the browser acts on, kept apart from the aria one:
+ // a single check for "disabled" matches both spellings and so cannot
+ // witness which of the two shipped.
+ bare: /data-next-cockpit-action="reading-ask"[^>]*\\sdisabled[=>\\s]/.test(block),
departures: html.includes("DEPARTURES RAISED TO YOU"),
};
};
@@ -5240,12 +5260,19 @@ def test_the_reading_has_three_states_and_the_control_waits_on_a_check(self) ->
# Then
assert isinstance(out, dict)
self.assertEqual(
- "Nothing has been typed for this session, so there is nothing to read it against.",
- out["empty"]["text"],
- )
- self.assertFalse(out["empty"]["control"])
- self.assertIn("Observer model availability has not been read", out["unread"]["text"])
- self.assertFalse(out["unread"]["control"])
+ "Nothing has been typed for this session, so there is nothing to read it against. "
+ "Save a goal above to enable a reading.",
+ out["empty"]["reason"],
+ )
+ # The control renders in every reason state now, inert and carrying
+ # the sentence that says why (DRC-4588). It used to be deleted here,
+ # which took the tab's only verb off the page in the two states a
+ # newcomer is most likely to arrive in.
+ self.assertTrue(out["empty"]["control"])
+ self.assertTrue(out["empty"]["disabled"])
+ self.assertIn("Observer model availability has not been read", out["unread"]["reason"])
+ self.assertTrue(out["unread"]["control"])
+ self.assertTrue(out["unread"]["disabled"])
# The offer states what a reading may and may not read, before the
# control rather than after it.
self.assertIn("never a verification that the work was done", out["offered"]["text"])
@@ -5263,6 +5290,12 @@ def test_the_reading_has_three_states_and_the_control_waits_on_a_check(self) ->
# journey step 4 had no surface at all.
self.assertTrue(out["offered"]["departures"])
self.assertTrue(out["empty"]["departures"])
+ # And no state uses the bare attribute, in either direction: it would
+ # take the control out of the tab order and silence the description
+ # that carries the reason.
+ for state in ("empty", "unread", "offered", "enabled", "accepted", "unknown"):
+ with self.subTest(state=state):
+ self.assertFalse(out[state]["bare"])
def test_the_block_names_the_session_its_words_are_bound_to(self) -> None:
# Two sessions publishing one title are indistinguishable in the scope
@@ -5576,6 +5609,286 @@ def test_both_absence_explanations_read_as_sentences_not_header_labels(self) ->
self.assertIn("var(--fs-sentence)", rule)
self.assertNotIn("var(--mono)", rule)
+ # ---- DRC-4588 --------------------------------------------------------
+ # The tab's three-step chain loses its third control in exactly the state
+ # a newcomer lands in, and the fix that makes the control reachable is the
+ # one that lets a press through: `aria-disabled` restores the click the
+ # browser's `disabled` was suppressing, so the handler gates below ship
+ # with the attribute rather than after it.
+ UNTOUCHED = """
+__dashboard.annotate = true;
+__dashboard.annotate_cap = 240;
+__dashboard.reading_check = "accepted";
+__dashboard.reading_disclosure = "This spends your own model capacity.";
+__dashboard.sessions[0].annotation_goal = "";
+__dashboard.sessions[0].annotation_goal_why = "No goal typed for this session.";
+__dashboard.sessions[0].annotation_output = "";
+__dashboard.sessions[0].annotation_output_why = "No expected output typed.";
+__dashboard.sessions[0].annotation_revision = null;
+__dashboard.sessions[0].annotation_revision_count = 0;
+__dashboard.sessions[0].annotation_at = null;
+__dashboard.sessions[0].annotation_binding_why = "";
+"""
+
+ # The reading block as the page actually assembles it, so a test cannot
+ # pass against the control called directly while the caller still returns
+ # before reaching it.
+ READ_BLOCK = r"""
+navigateNext({view:"project", project:"cargento", focus:"codex:focus-1", tab:"held-to"});
+await __settle();
+const block = (__els.app.innerHTML.match(
+ /[\s\S]*?<\/section>/) || [""])[0];
+"""
+
+ NOTHING_TYPED = (
+ "Nothing has been typed for this session, so there is nothing to read it against."
+ )
+
+ def test_an_untouched_session_is_still_offered_the_tab_s_only_verb(self) -> None:
+ """DRC-4588 AC-1. One early return deleted four things together --
+ the button, the offer, the sending disclosure and the request counter
+ -- in the one state a reader who has typed nothing is looking at."""
+ out = self.run_fixture(
+ self.FOCUS_DOM
+ + self.UNTOUCHED
+ + self.READ_BLOCK
+ + r"""
+console.log(JSON.stringify({
+ ask: (block.match(/data-next-cockpit-action="reading-ask"/g) || []).length,
+ offer: block.includes("account of the evidence on this page"),
+ disclosure: block.includes("This spends your own model capacity."),
+ counter: /\d+ model requests? recorded for this session\./.test(block),
+}));
+"""
+ )
+
+ assert isinstance(out, dict)
+ self.assertEqual(1, out["ask"])
+ self.assertTrue(out["offer"])
+ self.assertTrue(out["disclosure"])
+ self.assertTrue(out["counter"])
+
+ def test_the_empty_sentence_moves_after_the_button_instead_of_replacing_it(self) -> None:
+ """DRC-4588 AC-2. The control recomputes the same reason and prints it
+ itself, so deleting the caller's copy moves the sentence rather than
+ losing it -- and printing both would be the duplication this guards."""
+ out = self.run_fixture(
+ self.FOCUS_DOM
+ + self.UNTOUCHED
+ + self.READ_BLOCK
+ + f"const sentence = {json.dumps(self.NOTHING_TYPED)};\n"
+ + r"""
+const buttonAt = block.indexOf('data-next-cockpit-action="reading-ask"');
+console.log(JSON.stringify({
+ count: block.split(sentence).length - 1,
+ buttonAt,
+ afterButton: block.indexOf(sentence) > buttonAt,
+ extended: block.includes(sentence + " Save a goal above to enable a reading."),
+}));
+"""
+ )
+
+ assert isinstance(out, dict)
+ # Exactly once: the caller's copy is gone and the control's is the only
+ # one left. Two would mean the early return was deleted without noticing
+ # that the control prints the reason too.
+ self.assertEqual(1, out["count"])
+ # Named before the ordering claim, because "after the button" is
+ # satisfied by a missing button too -- which is the state this issue
+ # exists to end, and would make the assertion below a tautology.
+ self.assertGreater(out["buttonAt"], -1)
+ self.assertTrue(out["afterButton"])
+ # Verbatim, with the next step appended rather than the sentence
+ # rewritten -- the server refuses `/api/reading` with these same words.
+ self.assertTrue(out["extended"])
+
+ def test_the_request_counter_reads_the_published_count(self) -> None:
+ """DRC-4588 AC-3. The figure has to come from `reading_count`, and the
+ state that proves it is the untouched one, where the counter was not
+ rendered at all before this change."""
+ out = self.run_fixture(
+ self.FOCUS_DOM
+ + self.UNTOUCHED
+ + r"""
+const read = () => {
+ const block = (__els.app.innerHTML.match(
+ /[\s\S]*?<\/section>/) || [""])[0];
+ const found = (block.match(/(\d+) model requests? recorded for this session\./) || [])[1];
+ return found === undefined ? null : found;
+};
+navigateNext({view:"project", project:"cargento", focus:"codex:focus-1", tab:"held-to"});
+await __settle();
+const none = read();
+__dashboard.sessions[0].annotation_reading_count = 3;
+renderNext();
+const three = read();
+console.log(JSON.stringify({none, three}));
+"""
+ )
+
+ assert isinstance(out, dict)
+ # A fixture carrying 3 renders 3, and an untouched session renders 0.
+ # A hard-coded figure, or one read off a different field, moves one of
+ # these two and not the other.
+ self.assertEqual("0", out["none"])
+ self.assertEqual("3", out["three"])
+
+ def test_a_refused_reading_is_reachable_and_spends_nothing(self) -> None:
+ """DRC-4588 AC-4. `aria-disabled` is what keeps the control in the tab
+ order and lets its reason be announced -- and it is also what restores
+ the click, so the handler gate is half of this change rather than a
+ refinement of it."""
+ out = self.run_fixture(
+ self.FOCUS_DOM
+ + r"""
+__dashboard.reading_check = "accepted";
+const session = __dashboard.sessions[0];
+const annotation = {goal:"", output:"", reading_count:0};
+const model = {enabled:true};
+const refused = nextCockpitReadingControl(session, annotation, model);
+let calls = 0;
+const upstream = __fetchImpl;
+__fetchImpl = (url, init) => {
+ if(String(url) === "/api/reading") calls += 1;
+ return upstream(url, init);
+};
+await nextCockpitAskForReading(session, model);
+const after = nextCockpitReadingControl(session, annotation, model);
+console.log(JSON.stringify({refused, after, calls}));
+"""
+ )
+
+ assert isinstance(out, dict)
+ refused = out["refused"]
+ assert isinstance(refused, str)
+ # The aria spelling, and NOT the bare attribute. `disabled` takes the
+ # control out of the tab order and silences its `aria-describedby`;
+ # the regex the file already carries at the progress test matches both
+ # spellings and so cannot witness either direction.
+ self.assertRegex(refused, r'reading-ask"[^>]*\saria-disabled="true"')
+ self.assertNotRegex(refused, r'reading-ask"[^>]*\sdisabled[=>\s]')
+ # The description is wired to the paragraph that carries the reason,
+ # by id rather than by proximity.
+ described = re.search(r'reading-ask"[^>]*aria-describedby="([^"]+)"', refused)
+ self.assertIsNotNone(described)
+ assert described is not None
+ self.assertIn(f'id="{described.group(1)}"', refused)
+ self.assertIn(self.NOTHING_TYPED, refused)
+ # And the press the attribute now permits reaches no network at all.
+ # Without the gate this is 1, and each one spends the reader's own
+ # model capacity from a state the page calls unavailable.
+ self.assertEqual(0, out["calls"])
+ after = out["after"]
+ assert isinstance(after, str)
+ self.assertRegex(after, r'role="status"[^>]*>[^<]*Nothing has been typed')
+
+ def test_the_save_control_is_present_and_inert_rather_than_absent(self) -> None:
+ """DRC-4588 AC-5. A keyboard reader tabbing the empty form met no save
+ control at all, because it was emitted `hidden`. The keystroke path is
+ the one a renderer-only fix misses: it clears the attribute in place,
+ without a redraw."""
+ out = self.run_fixture(
+ self.FOCUS_DOM
+ + self.UNTOUCHED
+ + r"""
+navigateNext({view:"project", project:"cargento", focus:"codex:focus-1", tab:"held-to"});
+await __settle();
+const saveTag = () => (__els.app.innerHTML.match(
+ /]*data-next-cockpit-action="held-save" data-arg="goal"[^>]*>/) || [""])[0];
+const clearTag = () => (__els.app.innerHTML.match(
+ /]*data-next-cockpit-action="held-clear" data-arg="goal"[^>]*>/) || [""])[0];
+const saveControl = () => controls.find(control =>
+ control.dataset.nextCockpitAction === "held-save" && control.dataset.arg === "goal");
+const resting = saveTag();
+const restingClear = clearTag();
+// A press while inert, before anything is typed.
+let posts = 0;
+const upstream = __fetchImpl;
+__fetchImpl = (url, init) => {
+ if(String(url) === "/api/annotate") posts += 1;
+ return upstream(url, init);
+};
+__fire("click", {target:saveControl(), preventDefault(){}});
+await __settle();
+// Then a keystroke, which does not redraw: the handler has to reach the
+// element already on the page.
+const before = __els.renders;
+const box = controls.find(control => control.dataset.nextCockpitHeldKind === "goal");
+box.value = "Ship the cockpit";
+__fire("input", {target:box});
+const typed = saveControl();
+console.log(JSON.stringify({
+ resting, restingClear, posts, redrew: __els.renders !== before,
+ typedAria: typed.attrs["aria-disabled"] || null, typedHidden: typed.hidden,
+}));
+"""
+ )
+
+ assert isinstance(out, dict)
+ resting = out["resting"]
+ assert isinstance(resting, str)
+ # Present, and inert by the attribute that keeps it in the tab order.
+ self.assertNotEqual("", resting)
+ self.assertNotRegex(resting, r"\shidden[=>\s]")
+ self.assertIn('aria-disabled="true"', resting)
+ # `clear` keeps `hidden`: there is nothing to clear and nothing to
+ # explain, so an inert control there would be noise rather than an
+ # affordance.
+ resting_clear = out["restingClear"]
+ assert isinstance(resting_clear, str)
+ self.assertRegex(resting_clear, r"\shidden[=>\s]")
+ # The press the attribute permits reaches no endpoint.
+ self.assertEqual(0, out["posts"])
+ # And a keystroke clears the attribute in place rather than the
+ # `hidden` property, which is the path :3430 takes on every keystroke
+ # and the one a renderer-only fix would leave writing the wrong field.
+ self.assertFalse(out["redrew"])
+ self.assertIsNone(out["typedAria"])
+ self.assertFalse(out["typedHidden"])
+
+ # ---- DRC-4590 --------------------------------------------------------
+ def test_one_tab_of_five_carries_a_primary_and_the_rest_carry_none(self) -> None:
+ """DRC-4590 AC-2, narrowed at triage to the one target that exists.
+
+ The issue as filed asked for exactly one primary per tab, naming "Open
+ this session" for three tabs and a registration-copy control for a
+ fourth. Three of those do not exist as controls and the fourth does not
+ exist at all: `Course` and `Decisions` emit no ``, `Now` emits
+ only navigation cards, and `Console`'s two controls are the steer submit
+ and the tripwire add, which this criterion forbids marking. So four tabs
+ have nothing to mark, and what each tab's main action should BE is a
+ product question filed as its own issue rather than answered here.
+ """
+ out = self.run_fixture(
+ self.FOCUS_DOM
+ + self.ANNOTATED
+ + r"""
+__dashboard.reading_check = "accepted";
+const counts = {};
+for(const tab of ["now", "course", "decisions", "console", "held-to"]){
+ navigateNext({view:"project", project:"cargento", focus:"codex:focus-1", tab});
+ await __settle();
+ counts[tab] = (__els.app.innerHTML.match(/next-action--primary/g) || []).length;
+}
+// The two Console controls the criterion names, read from their own emitters
+// rather than from whichever tab happens to render them.
+const steer = nextProjectSteer("cargento", {steers:[]});
+const tripwire = nextProjectGuardrailAdd("cargento", {adding:false});
+console.log(JSON.stringify({counts, steer, tripwire}));
+"""
+ )
+
+ assert isinstance(out, dict)
+ # Counted per tab and not as a page total: a total asserts nothing
+ # about WHERE the primary landed, and would pass with a stray one on
+ # Console and none on Held to.
+ self.assertEqual(
+ {"now": 0, "course": 0, "decisions": 0, "console": 0, "held-to": 1},
+ out["counts"],
+ )
+ self.assertNotIn("next-action--primary", out["steer"])
+ self.assertNotIn("next-action--primary", out["tripwire"])
+
def _node(tag: str, *classes: str, attrs: tuple[str, ...] = ()) -> dict[str, object]:
return {"tag": tag, "classes": set(classes), "attrs": set(attrs)}
@@ -6681,8 +6994,9 @@ def test_a_discard_whose_raises_went_says_nothing_about_a_standing_one(self) ->
self.assertNotIn(annotation_store.DISCARD_RECORD_STANDING, text)
def test_the_reading_block_names_the_discard_rather_than_nothing_typed(self) -> None:
- """AC7. The two states rendered one sentence, and the offer is withheld
- on both -- so the sentence is the whole of what a reader gets."""
+ """AC7. The two states rendered one sentence each, and the claim that
+ survives is which sentence: a discarded session says the discard
+ sentence and not "nothing typed"."""
discarded = self._open(self.DISCARDED)
never = self._open(self.NEVER)
@@ -6693,10 +7007,18 @@ def test_the_reading_block_names_the_discard_rather_than_nothing_typed(self) ->
self.assertIn(annotation_store.DISCARD_SENTENCES["unreadable"].split(", so")[0], said)
self.assertNotIn("Nothing has been typed for this session", said)
self.assertIn("Nothing has been typed for this session", absent)
- # The offer stays withheld on both, which is what makes the sentence
- # load-bearing rather than decoration.
- self.assertEqual(0, discarded["ask"])
- self.assertEqual(0, never["ask"])
+ # These two read 0 until DRC-4588, under a comment saying the withheld
+ # offer was what made the sentence load-bearing rather than decoration.
+ # What superseded it: the control now renders refused, with this very
+ # sentence bound to it through `aria-describedby`. The sentence is what
+ # the control is described BY, so it explains the button rather than
+ # competing with it for the reader's attention -- and withholding the
+ # button was costing the reader the one affordance the tab exists for
+ # in the two states they are most likely to arrive in. The claim above
+ # is untouched and is the one AC7 was really making: the discarded row
+ # says the discard sentence and not "nothing typed".
+ self.assertEqual(1, discarded["ask"])
+ self.assertEqual(1, never["ask"])
class CockpitHeldReEntryTest(NextPageJsHarness):
@@ -7813,7 +8135,12 @@ def test_the_review_surface_does_not_open_a_closed_reading_gate(self) -> None:
control,
)
self.assertIn("Ask for a reading", control)
- self.assertIn(" disabled>", control)
+ # `aria-disabled`, not the bare attribute: the control keeps its place
+ # in the tab order and `nextCockpitAskForReading` refuses the press it
+ # now receives (DRC-4588). Both spellings are named, because a
+ # substring check for "disabled" alone matches either one.
+ self.assertIn('aria-disabled="true"', control)
+ self.assertNotIn(" disabled>", control)
# And the review section itself invites no press of its own.
self.assertNotIn("Ask for a reading", out["block"])
diff --git a/cargento/skills/cargento/tests/test_next_controls.py b/cargento/skills/cargento/tests/test_next_controls.py
index 354d029a..28911514 100644
--- a/cargento/skills/cargento/tests/test_next_controls.py
+++ b/cargento/skills/cargento/tests/test_next_controls.py
@@ -57,7 +57,10 @@ def test_the_inline_add_button_saves_with_the_same_caps_and_no_request(self) ->
)
assert isinstance(out, dict)
self.assertIn("data-next-guardrail-form", out["before"])
- self.assertIn('type="submit">add ↵', out["before"])
+ # The label and its own class, not the exact tag punctuation: the
+ # button carries `.next-action` now, so a literal `>` after the
+ # type attribute asserts the attribute ORDER rather than the control.
+ self.assertRegex(out["before"], r']*>add ↵')
self.assertEqual([], out["calls"])
self.assertEqual("", out["value"])
self.assertEqual(50, len(out["rules"]))
diff --git a/cargento/skills/cargento/tests/test_next_flag.py b/cargento/skills/cargento/tests/test_next_flag.py
index 3ffc6767..ad2b74b8 100644
--- a/cargento/skills/cargento/tests/test_next_flag.py
+++ b/cargento/skills/cargento/tests/test_next_flag.py
@@ -64,9 +64,9 @@ def test_retired_next_query_is_not_a_page_alias(self) -> None:
def test_the_canonical_loader_is_the_released_ui_bundle(self) -> None:
page = frontend_page.load_page()
- self.assertEqual(913_998, len(page))
+ self.assertEqual(918_899, len(page))
self.assertEqual(
- "fabba3bc613af4f76e9c7d327d3a42f4a4e49acf4da73d2e5aa8b3e0f4d19866",
+ "c64dcd86bb7382050fe197a73b3992f3ec7adfc77457db307d7db5bf5d8b6cd7",
hashlib.sha256(page).hexdigest(),
)
diff --git a/cargento/skills/cargento/tests/test_next_page.py b/cargento/skills/cargento/tests/test_next_page.py
index b1f95fad..8fa53387 100644
--- a/cargento/skills/cargento/tests/test_next_page.py
+++ b/cargento/skills/cargento/tests/test_next_page.py
@@ -612,8 +612,8 @@ def test_load_page_preserves_its_byte_oracles(self) -> None:
# is the more useful failure of the two.
expected_parts = {
"next-boot.js": (
- 27_352,
- "b71d627fe8cc06bc4210d23c76c0ee5b2ab644ce15a44c9d617ba66fe398847d",
+ 27_384,
+ "fe0b85a5f87537ce7e1a2a7d8aafaefca675810dff894412530ac9419147df8c",
),
"next-observed.js": (
31_199,
@@ -624,8 +624,8 @@ def test_load_page_preserves_its_byte_oracles(self) -> None:
"cf7eb26d4135f352efe4cd7256e46f26514ba9b8e19422ac32fc840ac9b4e71a",
),
"next-notify.js": (
- 11_092,
- "1a486fb469b06f8b43d5565440e0cfa30d5bbd848dfcc16f92a7aa492bcd23a3",
+ 11_104,
+ "2fdc43bbb9382ce92fe972d628b6bf11e0342f35bfa43e9965133c3315b39ad1",
),
"next-cockpit-compat.js": (
599,
@@ -636,12 +636,12 @@ def test_load_page_preserves_its_byte_oracles(self) -> None:
"8d404a66a0fe5a8a021854b64fc48c80aeed260628efadde80c64862d07ce63e",
),
"next-chrome.js": (
- 40_112,
- "f7d3fc543edb9c9a52be47a7a297fca29ed8482e1f4af35be7a7ee5d7156ba26",
+ 40_141,
+ "fd944ba159be6655d8e0fa8097a22288f7f1042d74c2146bfe1e617a07c70982",
),
"next-capacity.js": (
- 32_192,
- "fccfae64553820ba7da58439694808fdae9275bba00f4d85119db58d36d0ef6b",
+ 32_221,
+ "152eeff23367e7216a500593f07acee293c1bfa1b38401befd173a12d462d835",
),
"next-sessions.js": (
19_745,
@@ -676,12 +676,12 @@ def test_load_page_preserves_its_byte_oracles(self) -> None:
"36ecd098147995ae96b5ca7846c6a4366142da400a27a2dd5dfcef9ace01fdb6",
),
"next-controls.js": (
- 18_071,
- "ed2f27f3e1c9c13fbfecc64389af35744b2148a8f1e008ea9dd8720b31795c96",
+ 18_123,
+ "34646eed0f1890628554fbe9216c937ddca5dd117e69292dfeeb24831a341dbc",
),
"next-cockpit.js": (
- 198_105,
- "16f67be92f133b9e837d137a96847723861a0128d4d91a0164ad63d9ab2190f6",
+ 201_898,
+ "ad12de164d708f3bcad29bc99089098f42a8b8054f296b177101d3281cc61f16",
),
"next-render.js": (
8_960,
@@ -700,16 +700,16 @@ def test_load_page_preserves_its_byte_oracles(self) -> None:
self.assertEqual(digest, hashlib.sha256(data).hexdigest())
styles = frontend_page.asset_path("styles.css").read_bytes()
- self.assertEqual(110_704, len(styles))
+ self.assertEqual(111_658, len(styles))
self.assertEqual(
- "02aa394649d128bef1a44d728e061d643a0535a6060650022931738add0dc194",
+ "19c58ec2c16528d13ef0bc27e7b59a7911770afdda6640a905e672df50d2c303",
hashlib.sha256(styles).hexdigest(),
)
assembled = frontend_page.load_page()
- self.assertEqual(913_998, len(assembled))
+ self.assertEqual(918_899, len(assembled))
self.assertEqual(
- "fabba3bc613af4f76e9c7d327d3a42f4a4e49acf4da73d2e5aa8b3e0f4d19866",
+ "c64dcd86bb7382050fe197a73b3992f3ec7adfc77457db307d7db5bf5d8b6cd7",
hashlib.sha256(assembled).hexdigest(),
)
@@ -854,3 +854,138 @@ def test_the_default_bundle_mounts_primary_project_navigation(self) -> None:
if __name__ == "__main__":
unittest.main()
+
+
+class TheBoardHasOneControlPrimitiveTest(unittest.TestCase):
+ """DRC-4590. The stylesheet had no way to say "this one": no shared control
+ class and no radius token, so every control was its own recipe and the whole
+ range was spent on the secondary tier.
+
+ The sweep that produced this issue found 24 resting control rules over six
+ corner treatments. Seven collapse here; the remaining five are filed as
+ their own issue and the exempt ones are named with reasons in the sheet, so
+ AC-1 and AC-2 are accepted on these enumerated verifiers rather than on a
+ universal reading neither could satisfy.
+ """
+
+ # The seven the primitive absorbs. Enumerated rather than discovered,
+ # because "every control rule" is the claim this issue cannot make.
+ COLLAPSED = (
+ ".next-notify-button",
+ ".next-stalled button",
+ ".next-session-copy",
+ ".next-steer button",
+ ".next-guardrail-add",
+ ".next-usage-switch button",
+ ".next-cockpit-reading button",
+ )
+
+ def setUp(self) -> None:
+ raw = (frontend_page.WEB_DIR / "styles.css").read_text(encoding="utf-8")
+ # Comments out first. They carry commas and selector-shaped text, and
+ # a selector head read straight out of the source picks up whatever
+ # comment precedes the rule -- which matches nothing and reads as a
+ # missing rule rather than as a broken parser.
+ self.styles = re.sub(r"/\*.*?\*/", "", raw, flags=re.DOTALL)
+
+ def rule(self, selector: str) -> str:
+ """The body of the rule whose selector list contains `selector` exactly.
+
+ Matched on the whole comma-separated head, so `.next-steer button` does
+ not silently answer with `.next-steer button:hover`.
+ """
+ for block in re.finditer(r"([^{}]+)\{([^{}]*)\}", self.styles):
+ heads = [head.strip() for head in block.group(1).split(",")]
+ if selector in heads:
+ return block.group(2)
+ return ""
+
+ def test_one_token_and_one_class_own_the_resting_box(self) -> None:
+ """AC-1."""
+ root = re.findall(r"(?:\A|\n):root\{([^}]*)\}", self.styles, re.DOTALL)
+ self.assertTrue(any("--radius-control:" in block for block in root))
+ # Exactly one rule owns `.next-action` on its own, so the primitive has
+ # a single definition rather than a definition per caller.
+ owners = [
+ block.group(1).strip()
+ for block in re.finditer(r"([^{}]+)\{[^{}]*\}", self.styles)
+ if block.group(1).strip() == ".next-action"
+ ]
+ self.assertEqual(1, len(owners))
+ body = self.rule(".next-action")
+ self.assertIn("border-radius:var(--radius-control)", body)
+
+ def test_no_collapsed_rule_keeps_its_own_recipe(self) -> None:
+ """AC-1's falsifier: the collapse done by adding the class to a
+ selector group, leaving the duplicated declarations in place. That
+ reads as passing in a grep for the class name and changes nothing."""
+ for selector in self.COLLAPSED:
+ with self.subTest(selector=selector):
+ body = self.rule(selector)
+ self.assertNotEqual("", body, f"{selector} has no rule to check")
+ self.assertNotIn("border-radius:", body)
+ self.assertNotRegex(body, r"(?:^|;)border:1px")
+
+ def test_a_disabled_control_survives_greyscale(self) -> None:
+ """AC-3. Ink alone cannot carry this: `--ink3` is the resting colour of
+ the prose around these controls, so a disabled one was drawn in the
+ body ink and vanished with colour removed."""
+ body = self.rule('.next-action[aria-disabled="true"]')
+ self.assertIn("border-style:dashed", body)
+ self.assertIn("cursor:not-allowed", body)
+ # The stalled control is waiting, not refusing, and says so with its
+ # own cursor. Collapsing the two loses a distinction a reader acts on.
+ stalled = self.rule(".next-stalled button:disabled")
+ self.assertIn("cursor:wait", stalled)
+ self.assertGreater(
+ self.styles.index(".next-stalled button:disabled"),
+ self.styles.index('.next-action[aria-disabled="true"]'),
+ "the stalled override must come after the primitive to win at equal specificity",
+ )
+
+ def test_the_tripwire_control_gets_the_box_its_hit_area_already_had(self) -> None:
+ """AC-4. It was given the shared outlined recipe and stripped of it on
+ the next line, so it read as a line of prose inside a 44px target: what
+ a reader can see and what they can hit did not agree."""
+ body = self.rule(".next-guardrail-add")
+ self.assertNotEqual("", body)
+ self.assertNotRegex(body, r"(?:^|;)border:0")
+ self.assertNotRegex(body, r"(?:^|;)padding:0")
+ # The 44px band is inherited rather than redeclared, from the one rule
+ # that owns it for every control on the board.
+ self.assertIn("min-block-size:44px", self.rule("#app a"))
+
+ def test_the_irreversible_control_is_heavier_than_the_reversible_one(self) -> None:
+ """AC-5. `clear` drops one unsaved box and `discard everything` deletes
+ every revision, and the two carried the same five declarations."""
+ discard = self.rule(".next-cockpit-held-discard button")
+ armed = self.rule(".next-cockpit-held-discard button[aria-describedby]")
+ self.assertNotEqual("", armed)
+ # Width, not hue. The comment above these rules already rules colour
+ # out here: `--clay` reads as an observation about the session, and
+ # this is a control.
+ self.assertRegex(armed, r"border-width:\d")
+ block = self.styles[self.styles.index(".next-cockpit-held-discard") :][:800]
+ for hue in ("--clay", "--amber", "--accent"):
+ with self.subTest(hue=hue):
+ self.assertNotIn(hue, block)
+ # And the per-field `clear` stays bare text, which is what makes the
+ # weight difference read at all. Boxing both removes the contrast this
+ # criterion exists for.
+ self.assertRegex(self.rule(".next-cockpit-held-field button"), r"(?:^|;)border:0")
+ # The discard control takes its box from the primitive, so its own
+ # rule no longer contradicts it with a borderless recipe.
+ self.assertNotRegex(discard, r"(?:^|;)border:0")
+ self.assertNotIn("border-radius:", discard)
+
+ def test_the_dead_tab_strip_class_is_gone_and_its_neighbours_are_not(self) -> None:
+ """AC-6. `.next-tabs` has zero references in every `.js`, `.py` and
+ `.html` in the repository, but it survives in two shared selector
+ groups carrying live classes -- so a line range deletes live rules."""
+ self.assertEqual([], re.findall(r"\.next-tabs[^-\w]", self.styles))
+ # The four live classes still resolve what they shared with it.
+ self.assertIn("display:flex", self.rule(".next-header"))
+ self.assertIn("display:flex", self.rule(".next-tabs-row"))
+ self.assertIn("display:flex", self.rule(".next-header-right"))
+ self.assertRegex(self.rule(".next-crumb"), r"(?:^|;)border:0")
+ self.assertRegex(self.rule(".next-menu button"), r"(?:^|;)border:0")
diff --git a/docs/design-next-ui.md b/docs/design-next-ui.md
index 938f978f..d0e13271 100644
--- a/docs/design-next-ui.md
+++ b/docs/design-next-ui.md
@@ -797,6 +797,66 @@ while the live region beside it already said the raise was sent. Two channels of
contradicting each other is worse than a cue that was merely missing, which is what the same case
produced before any of this.
+## NUI-18: one control primitive, and an inert control stays on the page
+
+Two rulings, taken together because the second is only safe given the first.
+
+**The stylesheet had no way to say "this one."** There was no shared control class and no radius
+token: 43 literal `border-radius` declarations and zero `--radius` anywhere. A sweep of the resting
+control rules (a selector naming a `button`, a `summary`, or a class the JS puts on one, excluding
+state and `:hover` variants) found **24 rules declaring their own radius or resting border, across
+six corner treatments**: none, 3px, 4px, 6px, 9px and 999px. Because every one was a variation on
+"faint outlined box" or "bare text", the whole range was spent on the secondary tier and nothing was
+left to mark the one control to press. `--accent` never appeared at rest on a control at all.
+
+`.next-action` is that primitive, with `--radius-control`, `--control-bd` and `--control-pad`. It is
+a class a control opts into by writing it, **not** a selector group in the stylesheet. The group was
+tried on paper and rejected: it touches one file instead of seven and satisfies the same grep, but it
+means every new control must be appended to a growing list in the sheet, which is precisely the
+ad-hoc drift that produced the 24 recipes. The resting border is `--ink3` (5.67:1 on `--panel`)
+rather than `--line2` (1.61:1), because a box a reader is meant to see has to clear the 3:1
+non-text bar.
+
+Seven rules collapse onto it. The criterion was stated universally and **is not**: it is accepted on
+an enumerated verifier, with five further action rules filed as their own issue and the exempt ones
+named with their reasons in the sheet: `--amber` state signals, a `role="switch"`, a selection, two
+disclosures, and the legacy project view. `.next-action--primary` reaches exactly one tab, because
+four of the five have no action to mark at all; what each of those tabs' main action should *be* is
+a product question filed separately rather than answered in a restyle.
+
+**Disabled is dashed, not dimmer.** `--ink3` is the resting colour of the prose these controls sit
+in, so a disabled control drawn one ink step down was being drawn in the body ink and disappeared
+entirely in greyscale. `border-style` carries it because no ink choice can. `.next-stalled
+button:disabled` keeps `cursor:wait` as an explicit override: that control is waiting, not refusing,
+and collapsing the two loses a distinction a reader acts on.
+
+### An inert control is present and refusing, never absent
+
+The reading control was deleted outright whenever a reason withheld it, which took the button, the
+offer paragraph, the sending disclosure and the request counter off the page together, in the one
+state a newcomer lands in. It is now rendered in all four reason states, with the reason printed
+after it rather than in place of it.
+
+This **supersedes** the DRC-4565-era ruling that the offer stays withheld on the discarded and
+never-typed rows. That ruling's argument was that withholding is what makes the sentence
+load-bearing rather than decoration. What changed is that the sentence is now bound to the control
+through `aria-describedby`: it explains the button instead of competing with it, and withholding the
+button was costing the reader the tab's only verb in the two states they most often arrive in. The
+narrower claim that ruling was really making, that a discarded row says the discard sentence and
+not "nothing typed", is untouched and still asserted.
+
+**`aria-disabled`, not `disabled`, and the handler gate ships with it.** The browser's attribute
+takes a control out of the tab order and silences its `aria-describedby`, so a reader who cannot
+find the verb would meet a control they cannot reach and a reason they are never told. The same
+ruling was already taken for the RAISE control. But `aria-disabled` **restores the click that
+`disabled` was suppressing**, and the two handlers behind these controls gated on nothing that could
+refuse it: a press would have reached `POST /api/reading` and spent the reader's own model capacity
+from a state the page calls unavailable. So each handler refuses on the *same* expression its
+control renders, from one function, rather than computing its own answer. A handler with a second
+opinion can refuse a press the button offered, or take one the button refused. The refusal is
+answered rather than dropped, because a clicked control that goes silent is indistinguishable from a
+dead one.
+
## What this does not decide
Promotion itself did not create durable history. DRC-4234 subsequently authorized the bounded
diff --git a/docs/design-reader-state.md b/docs/design-reader-state.md
index 713f5413..cc60994f 100644
--- a/docs/design-reader-state.md
+++ b/docs/design-reader-state.md
@@ -45,7 +45,7 @@ For where these files sit and which way their dependencies run, see
| The More menu's copy-briefing result | Kept for the life of the tab with no expiry, so the control keeps reading `Copied` or `Copy unavailable` until the project or session scope changes | `nextCockpitBriefingCopyStates` in `next-cockpit.js`, keyed by `nextCockpitContextKey`; unlike the two confirmation cues it carries no stamp and nothing deletes it |
| Project plan, raw status, earlier Course entries and other directions | Restored independently by project and selected scope | `nextCockpitDisclosureAttr` in `next-cockpit.js` |
| Project-level semantic timeline disclosures | Restored independently per project when no session is selected; summary focus restored | `projectDisclosure` and `projectCaptureDisclosureStates` in `project.js` |
-| A reader-requested reading in `Held to` | Pending state and the latest response survive redraw and scope changes for the life of the tab. A fresh press replaces the response; reload drops this local feedback. The stored reading and model-call count still come from the server | `nextCockpitReadingRequests` in `next-cockpit.js`, keyed by harness and session id; pending requests suppress duplicate presses |
+| A reader-requested reading in `Held to` | Pending state and the latest response survive redraw and scope changes for the life of the tab. A fresh press replaces the response; reload drops this local feedback. The stored reading and model-call count still come from the server. The same lane carries the answer to a press the page refuses: the control is `aria-disabled` rather than `disabled`, so the click reaches the handler, and the handler writes the refusal sentence here with no pending flag rather than dropping the press silently | `nextCockpitReadingRequests` in `next-cockpit.js`, keyed by harness and session id; pending requests suppress duplicate presses, and a refused press is answered on the reason `nextCockpitReadingRefusal` gave the control |
| Observer model consent and request status | Consent survives reload with an in-memory fallback; per-session pending and result state survives redraw | `nextObserverConsent`, `nextObserverRequests` and `nextObserverRequestStates` in `next-render.js` / `next-boot.js`; controls use `data-next-focus` |
| The document scroll offset | Clamped by the browser; focus restoration may move it only when the old target intersected the viewport | No lane of its own; `nextRestoreFocus` passes `preventScroll` for offscreen captured focus, see [Document scroll](#document-scroll) |
| A text selection over rendered text | **Not managed** | Nothing; see [Text selection](#text-selection) |
From bb56beb3977e5041e3aad64d42f8c7d3b4eab0ea Mon Sep 17 00:00:00 2001
From: Jared Scott
Date: Thu, 17 Sep 2026 22:06:48 +0800
Subject: [PATCH 2/4] fix(web): drop a reading refusal once the state it
describes is gone
Correction round 1. The gate stored the refusal in `nextCockpitReadingRequests`
and nothing ever deleted from that map, which this change made reachable: the
early return used to delete the control, so there was no press to store.
Two consequences, both reproduced before the fix. The stored message rendered
as its own paragraph while the reason paragraph rendered the same sentence
again, so a press took the refusal from one occurrence to two, adjacent and
identical. And after the reader did what the sentence asked, the button
rendered enabled with "Nothing has been typed for this session" still beneath
it, clearable only by a reload or by paying for a reading. That second one is
the board asserting an absence after it stopped being true, beside a control
contradicting it, which is the shape this milestone exists to remove.
A refusal is a state rather than an event, so it is now marked as one and
dropped as soon as the reason it names stops holding, and it renders through a
single node: while a refusal stands it IS the reason, so the announcement and
the description are the same paragraph, carrying `role="status"` and the id the
button points at. A response stays an event and is kept.
The regression test asserts the lane and not only the render. Measured: with
the single-node render in place, removing the clear left every rendered
assertion green, because a stale refusal can no longer reach a render path.
That would have shipped the leak back the first time someone added one. Both
halves are mutation-checked: removing the clear fails on the lingering entry,
restoring the duplicate render fails on the occurrence count.
Byte pins regenerated from the assets. Docs: the reader-state row for this lane
now records that a refusal is the one entry that does not survive.
Signed-off-by: Jared Scott
Co-Authored-By: Claude Opus 5 (1M context)
---
.../cargento_runtime/web/next-cockpit.js | 26 +++++--
cargento/skills/cargento/tests/test_focus.py | 2 +-
.../cargento/tests/test_next_cockpit.py | 68 +++++++++++++++++++
.../skills/cargento/tests/test_next_flag.py | 4 +-
.../skills/cargento/tests/test_next_page.py | 8 +--
docs/design-reader-state.md | 2 +-
6 files changed, 98 insertions(+), 12 deletions(-)
diff --git a/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js b/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js
index 93585e80..4b375e2d 100644
--- a/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js
+++ b/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js
@@ -2033,7 +2033,18 @@ const NEXT_READING_REFUSED_ID = "next-cockpit-reading-refused";
function nextCockpitReadingControl(session, annotation, model){
const reason = nextCockpitReadingRefusal(annotation, model);
- const request = nextCockpitReadingRequests.get(sessKey(session));
+ const key = sessKey(session);
+ let request = nextCockpitReadingRequests.get(key);
+ /* A refusal is a state, not an event, and it stops being true the moment the
+ reader does what it asks. Dropped as soon as that state has gone: one that
+ nothing clears leaves "Nothing has been typed for this session" standing
+ under a button that is no longer refused, which is the board asserting an
+ absence after it stopped being true. A response is an event and is kept --
+ "Reading received." describes a press that happened, not a state. */
+ if(request && request.refusal && request.message !== reason){
+ nextCockpitReadingRequests.delete(key);
+ request = undefined;
+ }
const pending = request && request.pending;
/* `authorized` is no longer a second term here: an unauthorized check is
one of the sentences `nextCockpitReadingRefusal` returns, so `!reason`
@@ -2060,10 +2071,17 @@ function nextCockpitReadingControl(session, annotation, model){
`${enabled ? "" : ' aria-disabled="true"'}` +
`${reason ? ` aria-describedby="${NEXT_READING_REFUSED_ID}"` : ""}>` +
`${pending ? "Reading in progress…" : "Ask for a reading"}` +
- (request ? `
${esc(request.message)}
` : "") +
+ (request && !request.refusal
+ ? `
${esc(request.message)}
` : "") +
`${esc(spent)}` +
+ /* The announcement and the description are one node while a refusal
+ stands. Printing the stored message and the reason separately rendered
+ the same sentence twice, adjacent and identical, where the contract is
+ that it renders exactly once. The press is still announced, because this
+ node carries `role="status"` when it is the refusal. */
(reason
- ? `
${esc(reason)}
`
+ ? `
${esc(reason)}
`
: "");
}
@@ -2488,7 +2506,7 @@ async function nextCockpitAskForReading(session, model){
that goes silent is indistinguishable from a dead one. */
const refusal = nextCockpitReadingRefusal(nextCockpitAnnotation(session), model);
if(refusal){
- nextCockpitReadingRequests.set(key, {pending: false, message: refusal});
+ nextCockpitReadingRequests.set(key, {pending: false, message: refusal, refusal: true});
renderNext();
return;
}
diff --git a/cargento/skills/cargento/tests/test_focus.py b/cargento/skills/cargento/tests/test_focus.py
index 10bd444b..f9fc42c1 100644
--- a/cargento/skills/cargento/tests/test_focus.py
+++ b/cargento/skills/cargento/tests/test_focus.py
@@ -1021,7 +1021,7 @@ def test_the_pinned_assembly_is_untouched(self) -> None:
# would fail on the reader rather than on an injected token.
self.assertNotIn(b' None:
self.assertIsNone(out["typedAria"])
self.assertFalse(out["typedHidden"])
+ def test_a_refusal_never_outlives_the_state_it_describes(self) -> None:
+ """DRC-4588 AC-2 and AC-4 at the point they interact.
+
+ The press must leave a `role="status"` message giving the reason, and
+ the sentence must render exactly once. Storing the refusal as a message
+ satisfies the first and breaks the second, because the control already
+ prints that same sentence as the paragraph the button is described by.
+ And a stored refusal nothing clears outlives the state it describes: the
+ reader does what the sentence asks, the button enables, and the sentence
+ underneath still says nothing has been typed. That is the board stating
+ an absence that is no longer true, beside a control contradicting it,
+ which is the defect this milestone exists to remove.
+ """
+ out = self.run_fixture(
+ self.FOCUS_DOM
+ + self.UNTOUCHED
+ + r"""
+navigateNext({view:"project", project:"cargento", focus:"codex:focus-1", tab:"held-to"});
+await __settle();
+// The observer model read, so the only reason left is the one being tested.
+const group = nextProjectGroups().find(g => g.label === "cargento");
+const ctx = nextCockpitContexts.get(
+ nextCockpitContextKey(group, nextCockpitFocusedSession(group)));
+ctx.data = Object.assign({}, ctx.data, {observer_model:{enabled:true, disclosure:"x"}});
+renderNext();
+const block = () => (__els.app.innerHTML.match(
+ /[\s\S]*?<\/section>/) || [""])[0];
+const sentence = "Nothing has been typed for this session, so there is nothing to read it against.";
+const count = () => block().split(sentence).length - 1;
+const before = count();
+// The press `aria-disabled` now permits.
+__fire("click", {target:controls.find(c =>
+ c.dataset.nextCockpitAction === "reading-ask"), preventDefault(){}});
+await __settle();
+const afterPress = count();
+const statuses = (block().match(/role="status"/g) || []).length;
+// And then the reader does exactly what the sentence told them to do.
+__dashboard.sessions[0].annotation_goal = "Ship the cockpit";
+__dashboard.sessions[0].annotation_goal_why = "";
+__dashboard.sessions[0].annotation_revision = 1;
+__dashboard.sessions[0].annotation_revision_count = 1;
+__dashboard.sessions[0].annotation_at = 100;
+renderNext();
+const afterSave = count();
+const stillRefused = /data-next-cockpit-action="reading-ask"[^>]*aria-disabled/.test(block());
+// The lane itself, not only what it renders. With the dedupe in place a stale
+// refusal is invisible, so a render-only assertion passes while the entry
+// lives forever -- measured: removing the clear left every rendered assertion
+// here green. The next render path added for stored messages would bring the
+// defect straight back.
+const lingering = nextCockpitReadingRequests.has(sessKey(__dashboard.sessions[0]));
+console.log(JSON.stringify({before, afterPress, statuses, afterSave, stillRefused, lingering}));
+"""
+ )
+
+ assert isinstance(out, dict)
+ self.assertEqual(1, out["before"])
+ # AC-2 holds across the press: announced, not printed twice.
+ self.assertEqual(1, out["afterPress"])
+ # AC-4 still holds: the press leaves a live-region message.
+ self.assertGreaterEqual(out["statuses"], 1)
+ # And the sentence goes when the state it describes goes. Without a
+ # clear this is 1, sitting under a button that is no longer refused.
+ self.assertEqual(0, out["afterSave"])
+ self.assertFalse(out["stillRefused"])
+ # And the entry is gone from the lane, not merely unrendered.
+ self.assertFalse(out["lingering"])
+
# ---- DRC-4590 --------------------------------------------------------
def test_one_tab_of_five_carries_a_primary_and_the_rest_carry_none(self) -> None:
"""DRC-4590 AC-2, narrowed at triage to the one target that exists.
diff --git a/cargento/skills/cargento/tests/test_next_flag.py b/cargento/skills/cargento/tests/test_next_flag.py
index ad2b74b8..f9c07d92 100644
--- a/cargento/skills/cargento/tests/test_next_flag.py
+++ b/cargento/skills/cargento/tests/test_next_flag.py
@@ -64,9 +64,9 @@ def test_retired_next_query_is_not_a_page_alias(self) -> None:
def test_the_canonical_loader_is_the_released_ui_bundle(self) -> None:
page = frontend_page.load_page()
- self.assertEqual(918_899, len(page))
+ self.assertEqual(920_003, len(page))
self.assertEqual(
- "c64dcd86bb7382050fe197a73b3992f3ec7adfc77457db307d7db5bf5d8b6cd7",
+ "5b00d698c8963956291041ecf448caadcc72a14f5ecc9589af0ab6862bb5fdbb",
hashlib.sha256(page).hexdigest(),
)
diff --git a/cargento/skills/cargento/tests/test_next_page.py b/cargento/skills/cargento/tests/test_next_page.py
index 8fa53387..60e5ec56 100644
--- a/cargento/skills/cargento/tests/test_next_page.py
+++ b/cargento/skills/cargento/tests/test_next_page.py
@@ -680,8 +680,8 @@ def test_load_page_preserves_its_byte_oracles(self) -> None:
"34646eed0f1890628554fbe9216c937ddca5dd117e69292dfeeb24831a341dbc",
),
"next-cockpit.js": (
- 201_898,
- "ad12de164d708f3bcad29bc99089098f42a8b8054f296b177101d3281cc61f16",
+ 203_002,
+ "990f4e4993b0f732e4b3e49dffdc10563c65dd0e7a15b9ac614d27940e308013",
),
"next-render.js": (
8_960,
@@ -707,9 +707,9 @@ def test_load_page_preserves_its_byte_oracles(self) -> None:
)
assembled = frontend_page.load_page()
- self.assertEqual(918_899, len(assembled))
+ self.assertEqual(920_003, len(assembled))
self.assertEqual(
- "c64dcd86bb7382050fe197a73b3992f3ec7adfc77457db307d7db5bf5d8b6cd7",
+ "5b00d698c8963956291041ecf448caadcc72a14f5ecc9589af0ab6862bb5fdbb",
hashlib.sha256(assembled).hexdigest(),
)
diff --git a/docs/design-reader-state.md b/docs/design-reader-state.md
index cc60994f..29584a8a 100644
--- a/docs/design-reader-state.md
+++ b/docs/design-reader-state.md
@@ -45,7 +45,7 @@ For where these files sit and which way their dependencies run, see
| The More menu's copy-briefing result | Kept for the life of the tab with no expiry, so the control keeps reading `Copied` or `Copy unavailable` until the project or session scope changes | `nextCockpitBriefingCopyStates` in `next-cockpit.js`, keyed by `nextCockpitContextKey`; unlike the two confirmation cues it carries no stamp and nothing deletes it |
| Project plan, raw status, earlier Course entries and other directions | Restored independently by project and selected scope | `nextCockpitDisclosureAttr` in `next-cockpit.js` |
| Project-level semantic timeline disclosures | Restored independently per project when no session is selected; summary focus restored | `projectDisclosure` and `projectCaptureDisclosureStates` in `project.js` |
-| A reader-requested reading in `Held to` | Pending state and the latest response survive redraw and scope changes for the life of the tab. A fresh press replaces the response; reload drops this local feedback. The stored reading and model-call count still come from the server. The same lane carries the answer to a press the page refuses: the control is `aria-disabled` rather than `disabled`, so the click reaches the handler, and the handler writes the refusal sentence here with no pending flag rather than dropping the press silently | `nextCockpitReadingRequests` in `next-cockpit.js`, keyed by harness and session id; pending requests suppress duplicate presses, and a refused press is answered on the reason `nextCockpitReadingRefusal` gave the control |
+| A reader-requested reading in `Held to` | Pending state and the latest response survive redraw and scope changes for the life of the tab. A fresh press replaces the response; reload drops this local feedback. The stored reading and model-call count still come from the server. The same lane carries the answer to a press the page refuses: the control is `aria-disabled` rather than `disabled`, so the click reaches the handler, and the handler writes the refusal sentence here with no pending flag rather than dropping the press silently. A refusal is the one entry that does not survive: it is a state rather than an event, so it is dropped as soon as the reason it names stops holding. Kept, it left "Nothing has been typed for this session" standing under a button the same render had already enabled | `nextCockpitReadingRequests` in `next-cockpit.js`, keyed by harness and session id; pending requests suppress duplicate presses, a refused press is answered on the reason `nextCockpitReadingRefusal` gave the control, and `nextCockpitReadingControl` deletes a refusal whose reason no longer matches |
| Observer model consent and request status | Consent survives reload with an in-memory fallback; per-session pending and result state survives redraw | `nextObserverConsent`, `nextObserverRequests` and `nextObserverRequestStates` in `next-render.js` / `next-boot.js`; controls use `data-next-focus` |
| The document scroll offset | Clamped by the browser; focus restoration may move it only when the old target intersected the viewport | No lane of its own; `nextRestoreFocus` passes `preventScroll` for offscreen captured focus, see [Document scroll](#document-scroll) |
| A text selection over rendered text | **Not managed** | Nothing; see [Text selection](#text-selection) |
From 9cc6f64e1e95728cbb85ec368bca79181f4ea9cc Mon Sep 17 00:00:00 2001
From: Jared Scott
Date: Thu, 17 Sep 2026 22:12:30 +0800
Subject: [PATCH 3/4] fix(web): correct three false comments and stop a waiting
control reading as refused
The four small findings from correction round 1, fixed in the PR in flight
rather than filed. Three were false statements sitting in the code and the
fourth was a register saying the wrong thing about a state.
The comment on `nextCockpitHeldControl` said the inert `save` control "says why
it cannot fire". It does not: the gate returns silently. The silence is
defensible, because the box beside the control already shows that the draft
matches what is stored, so the comment is corrected to the behaviour rather
than behaviour invented to match the comment.
The comment on the nothing-typed arm claimed its first sentence was verbatim
what `/api/reading` refuses with. It is not. `reading.REFUSALS` says "Nothing
is typed against this session" where this page says "has been typed for". The
discard arm above it does carry the server's string, which is what made the
claim look plausible. The comment now says which of the two is true of which.
`.next-stalled button:disabled` inherited the primitive's dashed border.
NUI-18 defines dashed as the register for a control refusing a press, and that
control is waiting for a retry it makes itself. Its `cursor:wait` already drew
the distinction and the border was contradicting it, so the override now states
`border-style:solid` and the test that owns the criterion asserts it.
The comment above `.next-session-raise` said `.next-session-copy` never got a
`:focus-visible` ring. It has one now, through `.next-action`, which closes the
DRC-4381 gap that comment was describing. The raise keeps its own brighter
ring, which is the distinction the comment exists for.
Byte pins regenerated from the assets.
Signed-off-by: Jared Scott
Co-Authored-By: Claude Opus 5 (1M context)
---
.../cargento_runtime/web/next-cockpit.js | 18 +++++++++++-------
.../cargento/cargento_runtime/web/styles.css | 12 ++++++++----
cargento/skills/cargento/tests/test_focus.py | 2 +-
.../skills/cargento/tests/test_next_flag.py | 4 ++--
.../skills/cargento/tests/test_next_page.py | 18 ++++++++++++------
5 files changed, 34 insertions(+), 20 deletions(-)
diff --git a/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js b/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js
index 4b375e2d..fe9c7edb 100644
--- a/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js
+++ b/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js
@@ -820,8 +820,10 @@ const NEXT_COCKPIT_HELD_UNSAFE = /[\x00-\x1f\x7f\u200b\u200e\u200f\u202a-\u202e\
/* `inert` picks how a control that does not apply is drawn, and the two here
want different answers. `save` is the field's own verb, so it stays on the
- page and says why it cannot fire; `clear` stays `hidden`, because an empty
- box has nothing to clear and no explanation to offer. */
+ page and reachable rather than vanishing from under a keyboard reader; it
+ refuses the press silently, because the box beside it already shows that the
+ draft matches what is stored. `clear` stays `hidden`, because an empty box
+ has nothing to clear and no explanation to offer. */
function nextCockpitHeldControl(action, label, kind, shown, inert){
const off = inert ? ' aria-disabled="true"' : " hidden";
return ` None:
# would fail on the reader rather than on an injected token.
self.assertNotIn(b' None:
def test_the_canonical_loader_is_the_released_ui_bundle(self) -> None:
page = frontend_page.load_page()
- self.assertEqual(920_003, len(page))
+ self.assertEqual(920_676, len(page))
self.assertEqual(
- "5b00d698c8963956291041ecf448caadcc72a14f5ecc9589af0ab6862bb5fdbb",
+ "1127c59659bf26aaaf6bd92e813048891bdd612f485c0e89391ec1e3635c7347",
hashlib.sha256(page).hexdigest(),
)
diff --git a/cargento/skills/cargento/tests/test_next_page.py b/cargento/skills/cargento/tests/test_next_page.py
index 60e5ec56..82280934 100644
--- a/cargento/skills/cargento/tests/test_next_page.py
+++ b/cargento/skills/cargento/tests/test_next_page.py
@@ -680,8 +680,8 @@ def test_load_page_preserves_its_byte_oracles(self) -> None:
"34646eed0f1890628554fbe9216c937ddca5dd117e69292dfeeb24831a341dbc",
),
"next-cockpit.js": (
- 203_002,
- "990f4e4993b0f732e4b3e49dffdc10563c65dd0e7a15b9ac614d27940e308013",
+ 203_325,
+ "3198b59be319f8fbdde193cef9625b54c8c86d24123bb637c72611d21f82e0af",
),
"next-render.js": (
8_960,
@@ -700,16 +700,16 @@ def test_load_page_preserves_its_byte_oracles(self) -> None:
self.assertEqual(digest, hashlib.sha256(data).hexdigest())
styles = frontend_page.asset_path("styles.css").read_bytes()
- self.assertEqual(111_658, len(styles))
+ self.assertEqual(112_008, len(styles))
self.assertEqual(
- "19c58ec2c16528d13ef0bc27e7b59a7911770afdda6640a905e672df50d2c303",
+ "af33306fe9ecc13445bb6ea0ba2024a7bb33938ddbe5dd175e004f1ed70c9367",
hashlib.sha256(styles).hexdigest(),
)
assembled = frontend_page.load_page()
- self.assertEqual(920_003, len(assembled))
+ self.assertEqual(920_676, len(assembled))
self.assertEqual(
- "5b00d698c8963956291041ecf448caadcc72a14f5ecc9589af0ab6862bb5fdbb",
+ "1127c59659bf26aaaf6bd92e813048891bdd612f485c0e89391ec1e3635c7347",
hashlib.sha256(assembled).hexdigest(),
)
@@ -937,6 +937,12 @@ def test_a_disabled_control_survives_greyscale(self) -> None:
# own cursor. Collapsing the two loses a distinction a reader acts on.
stalled = self.rule(".next-stalled button:disabled")
self.assertIn("cursor:wait", stalled)
+ # And it does not inherit the refusal's border either. Dashed is the
+ # register for a control refusing a press; this one is waiting for a
+ # retry it makes itself, so it states `solid` rather than letting the
+ # primitive say the wrong thing about the state. Without this the
+ # cursor and the border disagree about what the control is doing.
+ self.assertIn("border-style:solid", stalled)
self.assertGreater(
self.styles.index(".next-stalled button:disabled"),
self.styles.index('.next-action[aria-disabled="true"]'),
From 333d478a428a30a0b2369a86007b10c5e508af1e Mon Sep 17 00:00:00 2001
From: Jared Scott
Date: Fri, 18 Sep 2026 00:34:52 +0800
Subject: [PATCH 4/4] fix(web): give the inert save control the reason a reader
can now reach
Two of Copilot's findings. The third, T3, is refuted below.
T1. `aria-disabled` keeps the save control in the tab order where `hidden`
removed it from the page, so a screen-reader user now reaches it and heard only
that it was dimmed. The field's absence sentence, which says why, was not
associated with it. The paragraph gains a per-field id and the control points at
it, matching what the reading control already does.
The pointer is conditional on the sentence rather than on the state: a field
holding saved words renders no absence paragraph, and an `aria-describedby`
resolving to nothing is worse than none. `nextCockpitHeldToggle` drops it on a
keystroke alongside `aria-disabled`, because a live control described by "No
goal typed for this session" is the same defect one state further on.
T4. `if __name__ == "__main__"` sat 140 lines before the end of
test_next_page.py, so a class defined after it would not load on the direct-run
path. Moved to the end. That path fails on imports today for an unrelated
reason, so nothing was actually being skipped, and the 31/31 under `unittest
discover` was honest -- this removes a trap rather than fixing a miscount.
T3 is refuted, with evidence rather than reading. The claim was that `\\s` in a
JS regex inside a Python string reaches the engine as literal backslash-s,
leaving the bare-`disabled` guard unable to fail. The enclosing string is a
plain triple-quote, not a raw one, so Python collapses `\\s` to `\s` and the
engine receives real whitespace. Run directly in node against both spellings:
true for ` disabled`, false for ` aria-disabled`. Mutation-checked end to end by
reverting the emitter to the bare attribute -- `bare` reads true for all four
refused states and the assertion fires. The guard works.
Signed-off-by: Jared Scott
Co-Authored-By: Claude Opus 5 (1M context)
---
.../cargento_runtime/web/next-cockpit.js | 37 ++++++++++++++++---
cargento/skills/cargento/tests/test_focus.py | 2 +-
.../cargento/tests/test_next_cockpit.py | 19 +++++++++-
.../skills/cargento/tests/test_next_flag.py | 4 +-
.../skills/cargento/tests/test_next_page.py | 16 ++++----
5 files changed, 60 insertions(+), 18 deletions(-)
diff --git a/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js b/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js
index fe9c7edb..9293bfc4 100644
--- a/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js
+++ b/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js
@@ -824,10 +824,24 @@ const NEXT_COCKPIT_HELD_UNSAFE = /[\x00-\x1f\x7f\u200b\u200e\u200f\u202a-\u202e\
refuses the press silently, because the box beside it already shows that the
draft matches what is stored. `clear` stays `hidden`, because an empty box
has nothing to clear and no explanation to offer. */
-function nextCockpitHeldControl(action, label, kind, shown, inert){
+// The field's absence sentence, which the inert `save` is described by. One id
+// per field rather than one per page: both fields render at once.
+function nextCockpitHeldAbsentId(kind){
+ return `next-cockpit-held-absent-${kind}`;
+}
+
+/* `describedBy` is emitted only while the control is BOTH inert and has a
+ sentence to point at. `aria-disabled` keeps this control in the tab order
+ where `hidden` removed it from the page, so a screen-reader user now reaches
+ it and would otherwise hear "save, dimmed" and nothing about why. Pointing at
+ an id that is not on the page is worse than pointing at nothing, and a field
+ holding saved words renders no absence sentence, so the attribute is
+ conditional on the sentence rather than on the state alone. */
+function nextCockpitHeldControl(action, label, kind, shown, inert, describedBy){
const off = inert ? ' aria-disabled="true"' : " hidden";
+ const why = !shown && inert && describedBy ? ` aria-describedby="${describedBy}"` : "";
return `${label}`;
+ `${shown ? "" : off}${why}>${label}`;
}
function nextCockpitHeldToggle(field, action, shown, inert){
@@ -837,8 +851,17 @@ function nextCockpitHeldToggle(field, action, shown, inert){
keystroke with no redraw, so whichever of the two the renderer chose is
the one already in the DOM and the one that has to be cleared here. */
if(!inert){ control.hidden = !shown; return; }
- if(shown) control.removeAttribute("aria-disabled");
- else control.setAttribute("aria-disabled", "true");
+ /* The description goes with the state it explains. A keystroke makes the
+ control live, and leaving the pointer behind would describe an active
+ control by the sentence saying its field is empty. */
+ const absent = field.querySelector("[data-next-cockpit-held-absent]");
+ if(shown){
+ control.removeAttribute("aria-disabled");
+ control.removeAttribute("aria-describedby");
+ return;
+ }
+ control.setAttribute("aria-disabled", "true");
+ if(absent && absent.id) control.setAttribute("aria-describedby", absent.id);
}
/* What the last save attempt is still worth saying, and for how long.
@@ -1053,7 +1076,8 @@ function nextCockpitHeldField(session, annotation, spec, cap){
`` +
`${draft.length}/${cap}` +
nextCockpitHeldControl("held-clear", "clear", kind, Boolean(draft), false) +
- nextCockpitHeldControl("held-save", "save", kind, draft !== saved, true) +
+ nextCockpitHeldControl("held-save", "save", kind, draft !== saved, true,
+ why ? nextCockpitHeldAbsentId(kind) : "") +
/* The absence sentence answers "why is this empty", so it goes when the
box stops being empty. It read the SERVER value alone, which put "No
goal typed for this session." directly under the sentence the reader
@@ -1062,7 +1086,8 @@ function nextCockpitHeldField(session, annotation, spec, cap){
the input handler gives: a keystroke does not redraw, so a paragraph
that only the renderer can remove stays under the sentence being
typed. */
- (why ? `
]*>/) || [""])[0];
// A press while inert, before anything is typed.
let posts = 0;
const upstream = __fetchImpl;
@@ -5818,8 +5821,9 @@ def test_the_save_control_is_present_and_inert_rather_than_absent(self) -> None:
__fire("input", {target:box});
const typed = saveControl();
console.log(JSON.stringify({
- resting, restingClear, posts, redrew: __els.renders !== before,
+ resting, restingClear, absentTag, posts, redrew: __els.renders !== before,
typedAria: typed.attrs["aria-disabled"] || null, typedHidden: typed.hidden,
+ typedDescribedBy: typed.attrs["aria-describedby"] || null,
}));
"""
)
@@ -5831,6 +5835,16 @@ def test_the_save_control_is_present_and_inert_rather_than_absent(self) -> None:
self.assertNotEqual("", resting)
self.assertNotRegex(resting, r"\shidden[=>\s]")
self.assertIn('aria-disabled="true"', resting)
+ # And it says WHY, which `hidden` never had to: the control was off the
+ # page entirely, so there was nobody to tell. `aria-disabled` puts it in
+ # the tab order, and a reader who reaches it would otherwise hear only
+ # that it is dimmed. The pointer resolves to the sentence on the page.
+ described = re.search(r'aria-describedby="([^"]+)"', resting)
+ self.assertIsNotNone(described)
+ assert described is not None
+ absent_tag = out["absentTag"]
+ assert isinstance(absent_tag, str)
+ self.assertIn(f'id="{described.group(1)}"', absent_tag)
# `clear` keeps `hidden`: there is nothing to clear and nothing to
# explain, so an inert control there would be noise rather than an
# affordance.
@@ -5845,6 +5859,9 @@ def test_the_save_control_is_present_and_inert_rather_than_absent(self) -> None:
self.assertFalse(out["redrew"])
self.assertIsNone(out["typedAria"])
self.assertFalse(out["typedHidden"])
+ # The description goes with the state it explains. Left behind, it
+ # describes a live control by the sentence saying its field is empty.
+ self.assertIsNone(out["typedDescribedBy"])
def test_a_refusal_never_outlives_the_state_it_describes(self) -> None:
"""DRC-4588 AC-2 and AC-4 at the point they interact.
diff --git a/cargento/skills/cargento/tests/test_next_flag.py b/cargento/skills/cargento/tests/test_next_flag.py
index 7509c24a..ac5167c8 100644
--- a/cargento/skills/cargento/tests/test_next_flag.py
+++ b/cargento/skills/cargento/tests/test_next_flag.py
@@ -64,9 +64,9 @@ def test_retired_next_query_is_not_a_page_alias(self) -> None:
def test_the_canonical_loader_is_the_released_ui_bundle(self) -> None:
page = frontend_page.load_page()
- self.assertEqual(920_676, len(page))
+ self.assertEqual(922_075, len(page))
self.assertEqual(
- "1127c59659bf26aaaf6bd92e813048891bdd612f485c0e89391ec1e3635c7347",
+ "f807d1314497a1acffc5a69235227974d9ead6c3fdf26dbfd5bdf87f2845a100",
hashlib.sha256(page).hexdigest(),
)
diff --git a/cargento/skills/cargento/tests/test_next_page.py b/cargento/skills/cargento/tests/test_next_page.py
index 82280934..e1c8b37b 100644
--- a/cargento/skills/cargento/tests/test_next_page.py
+++ b/cargento/skills/cargento/tests/test_next_page.py
@@ -680,8 +680,8 @@ def test_load_page_preserves_its_byte_oracles(self) -> None:
"34646eed0f1890628554fbe9216c937ddca5dd117e69292dfeeb24831a341dbc",
),
"next-cockpit.js": (
- 203_325,
- "3198b59be319f8fbdde193cef9625b54c8c86d24123bb637c72611d21f82e0af",
+ 204_724,
+ "f4f61ce68045fe557376d20531915ca46c44f5181fd63c3985d0720e46dcdb11",
),
"next-render.js": (
8_960,
@@ -707,9 +707,9 @@ def test_load_page_preserves_its_byte_oracles(self) -> None:
)
assembled = frontend_page.load_page()
- self.assertEqual(920_676, len(assembled))
+ self.assertEqual(922_075, len(assembled))
self.assertEqual(
- "1127c59659bf26aaaf6bd92e813048891bdd612f485c0e89391ec1e3635c7347",
+ "f807d1314497a1acffc5a69235227974d9ead6c3fdf26dbfd5bdf87f2845a100",
hashlib.sha256(assembled).hexdigest(),
)
@@ -852,10 +852,6 @@ def test_the_default_bundle_mounts_primary_project_navigation(self) -> None:
self.assertNotIn("overview", out)
-if __name__ == "__main__":
- unittest.main()
-
-
class TheBoardHasOneControlPrimitiveTest(unittest.TestCase):
"""DRC-4590. The stylesheet had no way to say "this one": no shared control
class and no radius token, so every control was its own recipe and the whole
@@ -995,3 +991,7 @@ def test_the_dead_tab_strip_class_is_gone_and_its_neighbours_are_not(self) -> No
self.assertIn("display:flex", self.rule(".next-header-right"))
self.assertRegex(self.rule(".next-crumb"), r"(?:^|;)border:0")
self.assertRegex(self.rule(".next-menu button"), r"(?:^|;)border:0")
+
+
+if __name__ == "__main__":
+ unittest.main()