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..9293bfc4 100644 --- a/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js +++ b/cargento/skills/cargento/cargento_runtime/web/next-cockpit.js @@ -818,14 +818,50 @@ 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 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. */ +// 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 ``; + `${shown ? "" : off}${why}>${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; } + /* 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. @@ -1039,8 +1075,9 @@ 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, + 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 @@ -1049,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)}` : "") + ''; } @@ -1693,7 +1731,15 @@ 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."; + /* Both sentences are this page's. The route refuses the same state in its + own words -- `reading.REFUSALS` says "Nothing is typed against this + session" where this says "has been typed for" -- so unlike the discard + arm above, which carries the server's string, these two are not the same + characters and this comment does not claim they are. The second sentence + exists because the control now renders beside the reason, so a reader + who is being refused can see the one step that would permit it. */ + 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 +2042,43 @@ 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."); - const request = nextCockpitReadingRequests.get(sessKey(session)); +} + +// 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 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; - 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 +2088,30 @@ 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)}

` : "") + + (request && !request.refusal + ? `

${esc(request.message)}

` : "") + `${esc(spent)}` + - (reason ? `

${esc(reason)}

` : ""); + /* 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)}

` + : ""); } const NEXT_READING_OFFER = @@ -2077,10 +2167,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 +2418,8 @@ function nextCockpitHeldDiscardBlock(session, annotation){ looking for. No live region reaches that state, because nothing mutates when you tab. */ (offer - ? '` @@ -2435,9 +2526,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, refusal: true}); + 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 +2677,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 +3534,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 +3562,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..46c92763 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,24 +109,25 @@ 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:disabled{cursor:wait;color:var(--ink3)} +.next-stalled button{margin-left:auto} +/* Solid, against the primitive's dashed. Dashed is the register for a control + refusing a press; this one is waiting for a retry it will make itself, and + inheriting the refusal look says the wrong thing about the state. The cursor + already carried that distinction and the border was contradicting it. */ +.next-stalled button:disabled{cursor:wait;color:var(--ink3);border-style:solid} .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)} /* The irreversible control, and it must not read as the copy beside it: a raise moves a window on somebody's desk and cannot be taken back, where an unwanted copy can simply be ignored. So it rests filled and on the warn line rather than - flat and on the quiet one, and it carries the :focus-visible ring - .next-session-copy never got — DRC-4381 left that gap and the irreversible - control is not going to be the third to inherit it. */ + flat and on the quiet one. It keeps its own brighter :focus-visible ring + rather than the primitive's: both controls have one now, since + .next-session-copy carries .next-action, which closed the gap DRC-4381 left. */ .next-session-raise{position:relative;z-index:1;display:inline-flex;align-items:center;justify-content:center;margin-top:8px;padding:4px 7px;border:1px solid var(--amber);border-radius:3px;background:var(--sunk);color:var(--ink);font-family:var(--mono);font-size:var(--fs-2xs);cursor:pointer} .next-session-raise:hover{border-color:var(--ink);color:var(--ink)} .next-session-raise:focus-visible{outline:2px solid var(--accent);outline-offset:2px} @@ -340,10 +362,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 +435,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 +891,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 +902,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 +980,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..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' 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..b27f4c2d 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 @@ -3807,6 +3813,7 @@ class CockpitHeldToTabTest(NextPageJsHarness): const attrs = Object.fromEntries([...match[1].matchAll(/([\w-]+)="([^"]*)"/g)] .map(attr => [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]))}; }); @@ -5071,6 +5078,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 +5089,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 +5112,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 +5128,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 +5194,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 +5261,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 +5291,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 +5610,370 @@ 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(); +const absentTag = (__els.app.innerHTML.match( + /

]*>/) || [""])[0]; +// 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, absentTag, posts, redrew: __els.renders !== before, + typedAria: typed.attrs["aria-disabled"] || null, typedHidden: typed.hidden, + typedDescribedBy: typed.attrs["aria-describedby"] || null, +})); +""" + ) + + 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) + # 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. + 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"]) + # 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. + + 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. + + 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'