Conversation
…in, and a tier it does not reach fails the run (#2888) Fixes #2888 The unclassified-directory guard globbed `tests/*/` after the script had already `cd`'d into `tests/`, so it looked for `tests/tests/*/`, matched nothing, stayed a literal `*` under default globbing, failed classification and `exit 1`'d the run at the api boundary. Every full-suite run since a646de8 (2026-08-27) stopped before the `api`, `standalone` and `postgres` tiers — ~1,900 tests — and nothing said so. - The guard is now `tests/harness/check_test_dirs.py`, handed TESTS_DIR explicitly instead of globbing relative to the cwd, and testable: only a directory that HOLDS test files is a finding (`__pycache__`, `reports/`, a stale local checkout with only `node_modules` are on every machine and collected by nothing), and an owner entry with no directory on disk is flagged too (pytest ignores a missing `--ignore=` path silently). - Tier ledger: DECLARED_TIERS is checked against the recorded rows at the end. A declared tier with no row that the caller did not deselect is reported `NEVER RAN` and fails the run; deselected tiers (`--tier`, `--no-pg`) are listed as SKIP and the verdict says `partial`. - Abort trap: any exit before the summary — including SIGINT/SIGTERM — prints an ABORTED banner naming the tiers left unrun and exits non-zero. The disposable postgres container is torn down on that path too (the old per-tier `trap ... EXIT` is folded into it). - The alembic step of the postgres tier now carries the same dummy Redis/encryption env pg-migrations.yml sets: revision 0041 (#2330) imports `services` → `config`, which raises without Redis credentials. Broken since 2026-08-20, a week before the tier stopped being reached. Guards in tests/unit/test_2080_harness_contract.py: the glob is matched as code (not the comment that names it), the guard passes on the real tree with the real owner lists, fires on an unwired directory holding tests, ignores test-less directories, every `run_tier` name is in DECLARED_TIERS, and the NEVER-RAN / abort paths are present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A 503 from POST /chat or /task was skipped as "Agent server not ready" at ~50 live-test sites, so an exhausted credit balance — the agent ran the turn and failed on the credential — read as a benign readiness skip while the run stayed green. The two causes shared one reason string because the sync 503 body is prose reconstructed from the agent's error text; the backend already computed TaskExecutionErrorCode and dropped it at _map_task_failure. Backend: an ADDITIVE X-Trinity-Error-Code header on every sync /chat|/task failure the backend classifies (auth / billing / network / agent_error / capacity / timeout). /task threads TaskExecutionResult.error_code; /chat derives it from agent_status_code with the same producer-side rule task_execution_service applies. Bodies are byte-identical to before. Tests: tests/testkit/readiness.py holds the one classifier — require_agent_answer reads the header first, falls back to transport vocabulary pinned to the backend source, and FAILS on anything the agent answered; only `network` skips, naming its evidence. A `requires_model` marker opts a test into the session model_provider_preflight fixture (claude_auth_configured flag, then one real /task probe on TEST_AGENT_NAME), so a stack whose credential cannot execute fails once with one cause; a credential-class verdict seen mid-session fails later model turns at setup instead of spending a 120 s call each. 53 sites converted across the six files in the issue plus test_fan_out.py and test_dynamic_thinking_status.py. An AST guard fails CI on a bare `if status == 503: pytest.skip` after a model-turn POST. Fixes #2889 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…urn (#1923) Six bespoke overlays each re-implemented the same markup and each omitted the same two behaviours: Esc did nothing, and Tab walked out behind the overlay. Verified still true on `dev` before starting — **zero** `Escape` references across all six files (the `@keydown` hits there are Enter-to-submit), no shell primitive, no focus trap anywhere. `BaseModal` owns exactly four things — overlay, Esc, focus, scroll lock — and nothing about content, so adopting it is deleting two wrapper divs rather than rewriting a dialog. It teleports to `<body>`: several of these modals are declared inside panels that establish a stacking context, where a `z-50` overlay nested in one renders BEHIND its siblings. **The decidable half is a separate pure module, and that is the point.** This repo's vitest runs `environment: 'node'` with no jsdom/happy-dom and no `@vue/test-utils` — I checked, because a focus trap written entirely inside an SFC would be a rule no unit test could reach, and source-text assertions would prove only that it had been TYPED. `utils/focusTrap.js` holds every decision as a function over plain data (tabbable filtering, the Tab wrap, the dismiss-key predicate, safe-action selection, backdrop identity) with 25 tests. What that leaves uncovered is the wiring itself — listener attachment, the focus() calls — which needs a browser and belongs to e2e. Said plainly rather than implied by a green suite. Three decisions worth naming: * initial focus goes to the SAFE action, and destructiveness is DECLARED (`data-destructive`) rather than guessed from label text, which would be wrong in every language but English. A dialog that opens with Delete focused turns a reflexive Enter into data loss. * backdrop dismissal compares identity against the overlay node, not a rectangle — a rectangle test mis-fires for a select popup or date picker rendered at the document root and closes the modal under the user. * a modified Escape (Ctrl/Cmd/Alt/Shift) does not dismiss; that is a browser or OS gesture, not an intent to close. Migrated in this commit: `SystemViewEditor.vue` and `NavBar.vue`'s build-info modal. The remaining four files are enumerated in the PR with their exact overlay bounds; they are 80-230 line tag surgeries and are deliberately left for a reviewed pass rather than done blind in one go. One acceptance-criteria item is stale: `views/Agents.vue` was deleted by ent#260 (the Agents page folded into the Dashboard list view), so its bulk-tag popover no longer exists. Related to #1923 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…irm() sites move onto it (#1924) Stacked on #1923 — `ConfirmDialog` adopts the `BaseModal` shell built there. The issue reads as "replace confirm() calls", but the thread rules otherwise (webmixgamer, 2026-08-21), and I re-verified it still holds on dev: ConfirmDialog.vue has no focus trap, no initial focus on the safe action, no Esc handler, and its DOM order is confirm-first — so this issue's AC "Initial focus lands on the safe action" needs the primitive itself fixed, not just the call sites. Confirmed: zero Escape / @keydown / focus() / role="dialog" in the file, and `variant="danger"` at :58 ahead of `variant="secondary"` at :66. **The confirm-first DOM order is fixed WITHOUT reordering the buttons.** The confirm is marked `data-destructive`, which is what `focusTrap.initialFocusIndex` skips, so focus lands on Cancel while the DOM order and `sm:flex-row-reverse` stay exactly as they were and nothing moves on screen. Reordering was the obvious fix and the worse one: it would have changed the rendered layout of a dialog seven components already use. Adopting BaseModal gives all seven existing callers Esc-to-close, a focus trap and focus return at once — none of them change. Every `data-testid` is preserved. `confirm-dialog-backdrop` is the one that goes (BaseModal owns the overlay now); checked first — no test or component referenced it, and the two that ARE used (`confirm-dialog`, `confirm-dialog-confirm`) are untouched. Call sites migrated here: `SystemViewEditor` delete-view and `GitPanel` clear-PAT. Each names the verb ("Delete view", "Clear token"), restates the consequence, and offers a named safe action instead of "Cancel/OK". Remaining on the issue and NOT done here: Settings.vue (3) and SchedulesPanel.vue (3), plus MobileAdmin's bespoke overlay and the two unconfirmed single-click actions. Not touched, by the thread's ruling: `/m`'s approval submit step (#2370) is an inline p19-shaped flow by design and must not be re-modalised. **Baseline edited by hand, not regenerated.** ConfirmDialog IMPROVED (raw_gray 9 -> 7 — its bespoke backdrop and the gray-500/gray-900 overlay went away), and a stale ceiling fails the guard too. A wholesale `--baseline` run deleted the entire `refrozen` provenance block and absorbed canvas/CanvasDocument.vue and views/SharedCanvas.vue — two files that landed on dev after this branch was cut — so the edit is scoped to the two entries that actually moved, per the rule the 2638 note already states. Related to #1924 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…ent#242) The six admin endpoints (`enabled`, `provision`, `sync`, and the three `access` routes) shipped with no writer outside curl: the consumer tools `run_skill` / `list_runnable_skills` are advertised over MCP but gate on an ACL table that nothing could populate. An entitled operator could activate the feature and grant access only by hand-rolling HTTP. This is that writer — one entitlement-gated Settings tab covering enable -> provision -> sync -> per-(caller, skill) grants. **No backend change.** All six endpoints already exist, are typed, and are `require_human_admin`-gated (admin AND a human principal — widening an allow-list must not be reachable by a prompt-injected agent's own key). That premise was checked before planning rather than assumed, because it decides the PR shape: had anything needed the private submodule, this would have been three PRs in the ent#545 order instead of one. Gated with `requires: 'skill_runner'`, the same declarative seam `sso` and `credential-vault` use, so an unentitled install renders no tab rather than a panel whose every control 404s. That gate is UX, not containment — the server refuses regardless — which is what keeps a stale entitlement list from becoming an escalation. Decidable rules live in `skillRunnerPanel.js`, not the SFC: vitest runs `environment: 'node'` with no mount harness, so a rule inside a component is one no test can reach (the ent#392 precedent). 32 tests cover them. Two details worth naming: * `SkillRunnerStatus` reports each fact independently, and the panel keeps it that way: "no runner yet", "provisioned but not running" and "running" stay three states with three badges, and the library's own unconfigured / never-pulled / unreadable states are three more. None collapse into a spinner, and a blocked operator is told which screen fixes it. * There is no endpoint listing the library for a grant picker — `/available` is agent-facing (ACL ∩ library for ONE caller, and 422s for a user-scoped key with no `caller_agent`). The picker uses the OSS `GET /api/skills/library`. Worth knowing for whoever builds the MCP half. The revoke confirmation names the CALLER, not just the skill: "Revoke summarise" reads harmlessly, while the decision being made is which agent loses the ability to execute it. Loading uses a skeleton keyed on "no data yet", not `ScanlineReveal` — #2540 ruled the beam is chart-loading only, and that primitive's importer allowlist caught this panel reaching for it. Fixed the panel rather than widening the allowlist, and pinned the choice with a test. No feature-flow doc: a public doc describing this module would name enterprise internals under `docs/**`, which is the enterprise-docs guard's scope and the CLAUDE.md standing rule. No API or architecture changed, so tiered docs do not require one. Deferred, recorded on the issue: the MCP admin tools half (Invariant #13's third surface), so an ops agent still cannot enable or grant without curl. Fixes Abilityai/trinity-enterprise#242 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
… and NavBar (#1925) Three audited surfaces hand-rolled a tab/nav strip instead of using the `OverflowTabs` primitive (#1114). Settings and Operations now render their tabs through it; NavBar gets the same measured priority+ split without mounting it, because its items are `<router-link>`s that must keep a real href and the primitive's are `<button>`s that emit a selection. NavBar is the case the audit called worst. Its link row was an `overflow-x-auto` scroller with the scrollbar suppressed inside the 64px bar (#1789), so an overflowed link stayed reachable but became invisible and undiscoverable — the bar gave no signal that more navigation existed. It now renders the links that fit plus a counted "N more ▾" disclosure, with the active state reflected on the trigger when the current route's link is one of the hidden ones. The fit arithmetic is SHARED, not re-derived: `utils/overflowFit.js` holds the rule verbatim from `OverflowTabs::recompute`, and both surfaces call it. It also gained a `gap` term, which NavBar needs and the tab strip does not — the tabs are padding-spaced, while the nav row carries a real flex gap that is invisible to the per-item rects, and a gap-blind sum hides ~120px on a six-link row (one whole link) and so keeps the last one inline to clip. Both new modules are pure because vitest runs `environment: 'node'` with no mount harness, so a rule inside an SFC is one no test can reach (the ent#392 precedent). `utils/navLinks.js` likewise derives the link set once — a strip that can hide a link renders the same link three times (inline, menu, hidden mirror), and three hand-written copies is three places for an active predicate or a badge to drift. Colours: the new code is semantic-token only, so the active underline moves from raw `blue-500/400` to `action-primary` (the primitive's own active token) and the Enterprise PRO pill from raw `purple-*` to `accent-purple-*`. Visible as a slight indigo shift on the nav's active indicator. Triage of the audit's nine scanner candidates: every one is a bounded wide table or a preformatted code block, where horizontal scroll inside the container is the correct treatment (design-system principle 7) — collapsing columns into a "More" menu would hide data, not navigation. None migrate; each is marked in place so a later audit does not re-flag it. The advisory two-pill strip in Audit.vue is a segmented control, a different primitive, and is recorded as such. `workspaceNewTab.spec.js` (ent#456) asserted `target`/`rel` against NavBar's template text; that truth moved into `navLinks.js`, so the NavBar half now drives the data and additionally pins that the strip binds both fields onto its links — the half a data-driven refactor can get wrong is correct data that never reaches the DOM. Baseline: NavBar raw_nongray 35 -> 19, raw_gray 125 -> 105. Edited by hand rather than regenerated, per the `refrozen` note — a wholesale run deletes that block. Fixes #1925 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
Caught by a live check, not by the suite: at 1440px the strip hid four of five links behind "4 more". The row and its parent cluster sized to content, so `clientWidth` reported the row's own min-content — and because the inner nav is `overflow-hidden`, that is near zero — making the fit rule correct about a container that was lying to it. The old #1789 row never needed this: a row that SCROLLS sizes to its content and lets the overflow happen, while a row that COLLAPSES has to be told how much space it actually has. `flex-1` on both supplies it; `justify-between` plus the controls' `flex-shrink-0` keeps the right-hand cluster where it was. Verified against the local dev instance across 1440/1300/1200/1100/1000/900/800/700 and back up again: one row at every width, never clipped, collapsing at 800 and 700 and re-expanding on the way back up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…ll row (#1925) `navbar-overflow.spec.js` is #1789's guard, and its third test asserts `overflow-x: auto` on the link row — the hidden-scrollbar scroller this issue deliberately replaced. It went red the moment the strip started collapsing, and I missed it because I grepped `src/` for that pattern and never `e2e/`. Confirmed it is this branch and not the tree: the spec passes against plain `origin/dev` built and served the same way, and fails here. Properties 1 and 2 — links never collide with the controls, controls stay on-screen and the user menu stays hit-testable — are unchanged and still pass; they were never about the mechanism. Property 3 now pins what #1925 actually guarantees: the links that fit render inline, the rest collapse into a counted "N more" disclosure, the count agrees with what the menu holds, every hidden link is a real href, and Escape closes it. It also pins the ABSENCE of the old mechanism — no `overflow-x: auto`, no scrollWidth past the box. A future refactor reintroducing the scroll row would restore exactly the undiscoverable-link failure this issue removed, and this is where that gets caught. `settleNav` is not politeness: the strip re-measures on a rAF after a resize, so a snapshot taken in the same tick as `setViewportSize` reports the previous split — "it fits" one frame before the links move into the menu. That is how the first version of this rewrite failed, and the fix is to sample until two reads agree rather than to widen a timeout. Verified against this branch's build: 4/4 pass, and the collapsed branch is genuinely exercised — at 640px the trigger reads "4 more" with 1 link inline and 4 in the menu, at 1440px there is no trigger and 5 links inline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…#242)
`GET /api/enterprise/skill-runner/access` answers `{ grants: [...] }`
(`service.list_access` wraps `db.list_access`), and the panel read the
payload AS the array — so `grants` was always `[]`, every grant rendered as
"No agent can run any skill yet", `isDuplicateGrant` never fired, and the
only human writer for the ACL had no revoke path. The PR's own "the entitled
render is unexercised" note is exactly where it hid.
`grantsFrom(payload)` is the one unwrap rule, pure so the node-env suite can
pin the envelope shape; a bare list is still accepted, anything else is an
empty list rather than a throw (the status half of the same load must still
render). A source guard pins that the panel reads through it — reverting the
call site turns the spec red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…segment (#2890) `test_no_service_parses_yaml_without_the_shared_loader` excluded virtualenvs with `"/venv/" in rel`, where `rel` is RELATIVE to the scanned root — so a venv at the root of that tree (`src/backend/venv/`, i.e. where one actually lives) yields `venv/lib/...` with no leading slash, never matches, and every vendored `safe_load` in `site-packages` is reported as an unguarded parse on a clean checkout. Only a venv nested a directory deep was ever excluded. The scan is now `_bare_safe_load_offenders(roots)`, pure over the filesystem, with the exclusion matched on `path.relative_to(root).parts` against `_VENDORED_PARTS` ({venv, .venv, site-packages, node_modules, __pycache__}) — position-independent, and a directory merely NAMED like one (`my-venv-tool/`) is still scanned. `git ls-files` was rejected: it would hide an untracked first-party file from the pre-commit scan. Proven both ways on the real tree: a planted `src/backend/venv/lib/python3.12/site-packages/starlette/mod.py` with a bare parse no longer fails the guard, and a planted `services/_tmp_bare.py` still does. The new fixture tests pin both directions across root/nested/.venv/ node_modules/__pycache__ placements. The other 34 `rglob("*.py")` guards under tests/unit were audited with the same planted venv: none false-fires. Fixes #2890 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…anical, per the merge-train note on the PR CodeQL js/incomplete-multi-character-sanitization (train #2896, alert 364) flagged the one-shot replace in a source-text guard. A fixpoint loop is the shape the rule accepts; behaviour on every real input is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#2202) Two papercuts found together, on the one page a 31-page sweep flagged as the only source of a page-level console error — and it produced one on every tab. **The 404.** Settings read `public_chat_url` through the generic `GET /api/settings/{key}`, which answers 404 for a key nobody has written. The store already treated 404 as "unset", so nothing was broken; what was lost was the signal — a real failure on that call looked exactly like the ordinary case — and no client-side handling can suppress the browser's own network log, which is why the fix is a route and not a try/catch. `GET /api/settings/public-chat-url` answers 200 with `value: null` when unset, following the `/mcp-url` precedent: a named route for a named setting, declared above `/{key}` (Invariant #4). The generic route's 404 is deliberately unchanged — it is the documented contract for every other key and is read outside this repo. Writing the spec found a SECOND key with the same defect, unnamed in the issue: `platform_default_model`, 404ing eight times per Settings load. It needed no new route — `/api/settings/feature-flags` already carries the resolved value — so the page now reads it from there, which is also more correct: the control shows what the platform will actually use instead of blank. **The unbounded list.** MCP Keys rendered every key an instance had ever minted: measured 306, of which 294 revoked (96%), ~71KB of DOM text, no filter, no bound. Agent keys accumulate structurally — one per agent, one per #1854 rotation, one per ephemeral ghost — so the page grows for the life of the instance and the 12 keys that still work are buried in the 294 that do not. Revoked keys are now hidden behind an explicit toggle that STATES the count, the list is searchable by name/prefix/agent, and rendering is bounded at 25 rows with a "Show more". The rules are pure (`utils/mcpKeyList.js`) because vitest runs `environment: 'node'`; the non-admin agent-key filter is carried through unchanged and asserted, since it is an access rule wearing a filter's clothes. Three empties, three next actions — "No API keys" was a lie to an operator holding 294 revoked ones — but ONE piece of chrome: the wording is computed and only the action row branches, because three copies of the markup would have tripled this file's palette-class count. The new controls are built from the Base* primitives (#2122), and the two container borders they need are paid for by converting the create form's hand-rolled name input and description textarea to `BaseInput`/`BaseTextarea` in the same component: `McpKeysTab.vue` raw_gray 97 -> 83. Baseline edited by hand, not regenerated. Verified against a local dev instance (a key created and revoked to exercise the toggle, removed afterwards): every tab loads with zero 404s and zero console errors, the list renders 2 of 3 keys with "Show revoked (1)" and "2 active", the toggle reveals the revoked row while staying bounded, search narrows to a named empty state that offers the way back, and the converted create form renders and binds in both themes. Red without the fix on both halves. Frontend unit suite 134 files / 2949 tests green. Fixes #2202 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…g the page (#2197) Three rigid flex rows scrolled the page body below 1024px, against the design-system rule that wide content scrolls inside its own container. The report blamed the tab strips, and that is not what was wrong. `OverflowTabs` was collapsing correctly the whole time, at 375px included: the hidden mirror row it measures against lives in a 0x0 `overflow: hidden` box and is invisible to the user and to `scrollWidth` alike, but a probe that does not skip clipped subtrees reports it as the widest thing on the page — which is how a measurement sweep came to name `NAV.-mb-px.flex` and the "More" button. Measured with clipped subtrees excluded, the real offenders are: * **Agent Detail** — three `AgentHeader` rows, each a non-wrapping flex row of two rigid clusters: identity + Workspace/Talk/Running, toggles + Tags + the live CPU/MEM/uptime readout, and the lifetime-cost line. Together ~790px of min-content, so the page widened at every width below ~1024px (+496px at 375). * **Settings** — the Default Model `<select>`, `flex-1` with the default `min-width: auto`. A select's min-content is its widest OPTION ("Claude Opus 5 — Most capable Opus (latest) (recommended)", ~417px), so one control scrolled the page by 157px at 375. `min-w-0` is the whole fix. The Dashboard overlap is a different failure with the same root shape. #1830 made the stats cluster elastic so it degrades instead of being clipped — but elastic with no floor means it collapses to ZERO, so the pressure never reached the controls and the row never wrapped. Measured at 640px: stats 0px wide (its clipped children still sitting under the controls' first button — the 8px the spec reports), controls pinned at their 692px max-content and running ~70px past the row's right edge. The row now wraps, the stats cluster carries the ladder's own agents-only floor (~71px) so the overflow becomes a wrap, and the controls may shrink and wrap their own buttons rather than being pinned wider than the row. Rendering above ~900px is unchanged — measured single 30px row at 1600/1440/1280/ 1024/900, wrapping to 91px at 768 and below, zero overlap at every width. `space-x-*` becomes `gap-*` on each row that can now wrap: a wrapped `space-x` row mis-indents every line after the first. Verified against a local dev instance in BOTH themes at 375 and 640 (light and dark, `dark` class asserted): `scrollWidth <= clientWidth` on Settings, Agent Detail, Dashboard, Operations and Library at 375/640/768/1024. `e2e/dashboard-stats-overflow.spec.js` (#1830), red on `dev`, is green. The new `e2e/body-horizontal-overflow.spec.js` measures the one thing the contract states — the document scroll width — and names the widest UNCLIPPED element on failure, because an element-shaped assertion is exactly what produced a report naming the wrong element. Proven to bite: with these three fixes reverted it fails Settings at +157px naming the `<select>`, and Agent Detail at +496px naming the action cluster. Its `ready` predicates are load-bearing, not politeness. The widest markup on both pages is gated behind a later render — Settings' admin-only `<select>` behind `/api/users/me`, Agent Detail's live stats behind the stats poll — and on a fixed settle the Settings arm PASSED with the fix reverted. That vacuous green was caught by running the negative control, not by reading the test. Fixes #2197 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
A `table` block's cells were plain text, so `**Deploy**`, `` `done` `` and `[runbook](https://example.com)` — what agents routinely put in status and reference columns — showed their literal characters. The same canvas rendered a GFM pipe table written in `markdown` prose correctly, so an agent saw two tables behaving differently, and the one the MCP tool guide recommends was the broken one. Both structured paths are fixed at once: `CanvasBlock` delegates `table` to `ReportRenderer`, and a ```table fence inside a `markdown` block delegates to the same component. Cells render INLINE markdown only. `marked.parseInline` never emits a block element, and the cell policy drops the ones raw HTML could smuggle in, so a heading or a list in a cell degrades to its own text (DOMPurify's `KEEP_CONTENT`) rather than breaking the row the #2583 gallery pins. The split is about testability, not taste. `markdown.js` cannot be imported without a DOM — DOMPurify's DOM-less stub has no `addHook` — and vitest runs `environment: 'node'`, so anything decided inside it is unreachable by a unit test. `utils/inlineMarkdown.js` holds the decidable half (the parse, the markdown-vs-text decision, the escape, the allowlist) and is executed by `inlineMarkdown.spec.js`; `renderInlineMarkdown` stays in `markdown.js` beside every other DOMPurify call, on the SAME instance and hooks, so a cell link inherits the app-wide `target="_blank"` / `rel="noopener noreferrer"` hardening and there is no second sanitizer (H-005). Only strings are parsed. A number, boolean or object keeps exactly its pre-#2771 rendering (`JSON.stringify` for an object, `String()` otherwise) and is escaped rather than parsed — running a JSON blob through a markdown parser would let its own `*` and `_` italicise a value nobody wrote as prose. Headers render on the same terms as cells: an agent that bolds a column name and bolds the values under it should not get two behaviours. `ReportTable` is shared with reports (ent#537 / #1535), so report tables gain the same rendering — the same defect, fixed once. `ReportRenderer.vue` is untouched, so the `display_hint` / `shapeOk` pins in `test_1535_report_prompt_guidance.py` are unaffected. Verified: 17 unit cases over the real configured parser; a new e2e seeds a canvas through the real `PUT .../canvas/{id}` route and asserts the rendered DOM — bold, code, a hardened link, em/del, the fenced path, and the half a unit test structurally cannot reach: a `<script>` + `<img onerror>` payload in a cell leaves `window.__2771` undefined with zero `script`/`img` nodes in the table. Red without the fix (the `<strong>` never appears), green with it. Full frontend unit suite 133 files / 2933 tests green. Fixes #2771 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
On every cold load the conversation column rendered full width, then lost the rail's width the moment the roster arrived — sliding the composer, the thread and the header left. Since #2676 that is a 300ms animated slide rather than a one-frame jump, which is prettier and still a shift: the contract's layout-stability rule is that loading and loaded share ONE footprint and nothing moves on arrival. Measured on a local instance before the fix, the Send button at 1440px goes 1327 → 1300 (27px) as the roster lands, and 916 → 884 (32px) at 1024px. `railVisibleFor` answers "may the rail RENDER", and false for a non-ready stage is right — its tabs need the roster. But the column's WIDTH does not need the roster: it comes from the persisted rail state and is known synchronously at first paint. So the wrapper now renders on `railHasColumn || railColumnReserved` and gates only `PortalRail` inside. Space is reserved; content is not faked. `railColumnReservedFor` is deliberately NARROWER than `railVisibleFor`'s route set, and the exclusions are the interesting part: * an agent page never carries a rail, so reserving there would invent a gap; * a ROOM route is excluded even though a ready room usually has a rail, because that depends on `roomsAvailable`, which arrives ON the roster payload (#2128). Reserving against a capability we have not been told about yet would trade this shift for the opposite one on every install without rooms. So it reserves for exactly the case the bug is about: a 1:1 conversation route, mid-load. If the rail then turns out not to render, the column leaves through the existing width transition — a shrink, not a jump. Nothing about the motion changes: the `<Transition>` classes, the #2676 voice-canvas swap and `motion-reduce` are untouched, and the reserved column is present from the first frame so its enter never runs. Verified: the composer's x is stable within 1px across 24 samples through roster arrival at 1440/1024/768/640, the column keeps the same width when the rail lands, and `workspace-model-choice.spec.js` — the spec whose measurements the #2676 transition split — stays green. Reverted, the same spec fails with the 27px/32px slides above, so it bites. Unit suite 135 files / 2957 tests. One e2e case was written and then removed rather than left skipping: "an agent page carries no rail column" asserted a premise the router does not hold — `/workspace/a/:name` REDIRECTS into a conversation, which legitimately has a rail. The rule it meant to check is covered in the unit spec, which does not depend on which URL the router settles on. Fixes #2711 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
#2711) Review finding, reproduced: the reservation fires for any non-room, non-agent- page route while the stage loads, but `stageZone` keys on the ROSTER — so a caller with no rostered agents settles `empty` and a failed roster fetch settles `failed`. In both the rail never arrives, the reservation drops, and the column left through the 300ms width transition: EMPTY roster -> none -> reserved(48) -> reserved(22) -> reserved(3) -> none FAILED roster -> none -> reserved(48) -> reserved(10) -> reserved(1) -> none A 48px animated shift on first run — nobody has shared an agent yet — and on every roster error, landing beside an error state. That is the same defect this PR removes, handed back, and it is exactly the hazard the docblock reasons about for ROOMS without applying it to `empty` / `failed`. The fix is not to reserve less. Narrowing to `/workspace/c/:id` would protect the AC's route and leave the common path — the nav's `/workspace`, which redirects into a conversation — shifting as before, because the redirect happens after the roster lands. Instead the LEAVE is animated only when the column actually held a rail (`railEverHeldRail`): a reservation that paid off animates as before, one that did not is given back in a single frame. Measured after: EMPTY roster -> none -> reserved(48) -> none FAILED roster -> none -> reserved(48) -> none and the normal load is unchanged: `reserved(48) -> rail(48)`, no shift. The ENTER keeps its transition unconditionally — it only ever runs for the voice-canvas swap (#2676), since a reserved column is present from the first frame and never enters. The flag resets per route, because the question is about THIS stage: navigating from a conversation to an empty roster must not inherit the conversation's verdict. Two spec arms added for the second finding — the suite measured bare `/workspace`, not the `/workspace/c/<session>` the AC names, so it was right only by accident of the fixture: * a direct load of `/workspace/c/<id>` (resolved from the sessions route) keeps Send within 1px; * an empty and a failed roster give the column back with NO intermediate width. The assertion is about the shape of the removal, not its speed: every width observed must be the full reserved width or nothing, because an intermediate width IS an animation frame. Negative control: forcing the old unconditional leave fails it with "animated away through 6,22px". `portalVoiceLayoutMotion.spec.js` pinned the literal `leave-to-class="!w-0"`, which is now a binding. Updated rather than deleted: the #2676 property it protects is intact, since the canvas only ever takes the row from a rail that was on screen, so `railEverHeldRail` is true wherever that spec cares. Frontend unit suite 135 files / 2957 tests; the rail and model-choice e2e suites 12/12. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…route-set guard (#2202) — mechanical, per the merge-train note on the PR Train #2896's regression diff: the four route tests raised 'no current event loop' on the CI interpreter (get_event_loop() on a fresh main thread), and test_1028_settings_package pinned the post-split route set, so the new GET /api/settings/public-chat-url read as a route invented by the split. Neither could surface on this PR's own CI — a PR against a feature branch does not run backend-unit-test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
) Caught by chasing a red CI run rather than dismissing it. The reset watcher added with the reservation read `route.value.fullPath`, but `useRoute()` returns a reactive OBJECT, not a ref — every other route read in this 2,000-line file is the plain form, and this line was the only `route.value.` in it. It is not a silent no-op, it is a silent THROW: Vue routes a watch-getter error to its error handler instead of aborting setup, so the page rendered, the rail e2e passed 12/12, the unit suite passed 2957, and the watcher was dead the whole time while every Workspace load logged TypeError: Cannot read properties of undefined (reading 'fullPath') Verified in a browser before and after: the error is on the previous build and gone from this one. The measured rail behaviour is unchanged (`reserved(48) -> none` on an empty or failed roster, `reserved(48) -> rail(48)` normally) because the flag is only ever SET by the other watcher on a cold load; what was broken is the per-route reset, which is the in-session case. Guarded rather than just fixed: `portalRailReserve.spec.js` now fails on any `route.value.` in the shell, and the guard was proven to bite by reintroducing the bug (it fails) — a dead watcher is invisible to every test that does not read the console, so the spelling is worth pinning in the one file that mixes it. Unit suite 135 files / 2959 tests. The three workspace e2e suites CI flagged — rail-reserved, model-choice, stick-to-bottom — 15/15 locally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
… rest (#2201) Several recurring treatments fell below WCAG AA. Fixed at the tier, not per component, and the tiers are now measured rather than asserted in prose. **The inverted tertiary pair.** `text-gray-400 dark:text-gray-500` is below AA on BOTH sides — 2.54:1 on white, 2.13:1 on gray-700 — and it is the contract's own ladder written backwards: light tertiary is gray-500, dark tertiary gray-400, which is what 890 other call sites already use. 187 occurrences across 62 files swapped. The class COUNT per file is unchanged, so the raw-colour ratchet does not move. **The warm families' light text tier.** On a light surface the 600 tier fails for success (3.30), warning (2.94), autonomous (3.19) and urgent (3.56) while the cool families pass (danger 4.83, info 5.17, purple 5.38, primary 6.29) — so the rule is per TIER, not per family: light text is 700 (4.92–7.90 across all nine), dark text is 400 (4.92–9.59 on gray-800). 128 paired occurrences across 52 files. **The Operations count badge.** White ink on a 500 solid is 2.80:1 (urgent) and 3.76:1 (danger). Now urgent-700 (5.18) and danger-600 (4.83). It is small, high-salience and carries a number someone is meant to read, so AA-normal is the bar rather than the 3:1 large-text allowance. **Host telemetry percentages** are read as numbers, so they are text: the 500 tier they used measures 2.28 (success) through 3.56 (urgent) on white. Raised to 700/400. The meter separators between them are decoration and are now marked `aria-hidden` rather than darkened — styling a dot nobody reads would be the wrong fix — and their one class string is hoisted, which pays for the dark half the no-data placeholder was missing: HostTelemetry raw_gray 7 -> 4. **The mechanical half (AC 6), in two layers.** `utils/contrast.js` is pure WCAG arithmetic — linearized, because a channel-average scan is wrong in both directions, which is why this issue's own first numbers had to be recalculated. `tests/unit/contrast.spec.js` drives it over the REAL Tailwind palette the tokens alias, so a palette bump or a token remap turns red instead of silently darkening the app below AA; it pins both ink ladders, all nine status families at both tiers, the badge recipe, and — deliberately — the two grounds the tertiary tier does NOT clear, so a future palette change that fixes them prompts a revisit instead of leaving a stale prohibition in the doc. `e2e/contrast-ratchet.spec.js` is the page-level layer: distinct failing text treatments per page and theme against a checked-in baseline, counted per TREATMENT rather than per node so the number does not move with how much data an instance holds. A page with no entry is held to zero, and an improvement that is not banked fails as a stale ceiling — the #2605 rules. What it freezes rather than fixes, stated plainly: a long tail of `text-gray-400` written with no `dark:` sibling. Adding the missing half at ~1,500 sites would grow the raw-colour ratchet by ~1,500 raw classes, so the contrast guard and the palette guard pull against each other and the way out is a semantic ink token, not a baseline bump. That is written into the contract beside the measured ladders. Verified in both themes against a local instance. Reverting the sweeps grows the ratchet on all ten page/theme pairs (light dashboard 13 -> 16, settings 6 -> 10, dark settings 2 -> 5), so it bites. Frontend unit suite 136 files / 2973 tests. Fixes #2201 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
Contributor
|
Closing this dry-run train surface — its members have since landed or moved on, and a lingering |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Integration surface for #2778, #2780, #2781, #2782, #2783, #2784, #2785, #2787, #2772, #2893, #2894, #2895 after the #2896 findings were fixed on #2781 and #2784. Dry run, never merged.