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 `` + (granted ? "" : 'Windows above are the last cached read and will lapse.') + "

"; 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.` + - `
`; + `"; } /* 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 ``; + `${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 + - '` + (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 - ? '` @@ -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"))}">` + - '' + history + ''; + '' + 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…">' + - ''; + ''; } - return ``; } 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 ''; } 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">([^<]*)([^<]*)]*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 `", 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'` + - (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 ``; + `${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 ? `

${esc(why)}

` : "") + (cue ? `${esc(cue)}` : "") + ''; } diff --git a/cargento/skills/cargento/tests/test_focus.py b/cargento/skills/cargento/tests/test_focus.py index cdaedf19..170edefe 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' [attr[1], decode(attr[2])])); return {textContent: decode(match[2]), hidden: /(^|\s)hidden(\s|$)/.test(match[1]), + id: attrs.id || "", dataset: Object.fromEntries(Object.entries(attrs) .filter(([key]) => key.startsWith("data-")).map(([key, value]) => [camel(key), value]))}; }); @@ -5801,6 +5802,8 @@ def test_the_save_control_is_present_and_inert_rather_than_absent(self) -> None: control.dataset.nextCockpitAction === "held-save" && control.dataset.arg === "goal"); const resting = saveTag(); const restingClear = clearTag(); +const absentTag = (__els.app.innerHTML.match( + /

]*>/) || [""])[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()