Work-surface components: Kanban, Milestone roadmap, Timeline, Dependency graph, People picker — and TUX inside apps without Nuxt #61

Open
A-Guevara wants to merge 17 commits from feat/work-surface-components into main
Owner

Nine components for planning and doing work, built for TTI Code's per-repo Roadmap tab and usable anywhere. That tab is a TUX app mounted in a shadow root inside Forgejo pages; see nis/forgejo-stack docs/design/work-surface-island.md. Plus the fixes TUX needed to run a panel inside an app that isn't Nuxt.

New components

Component Showcase What it is
TuxKanbanBoard / TuxKanbanLane / TuxKanbanCard /components/kanban Lanes of cards you drag between, by pointer or entirely by keyboard, with every step announced. The board holds no data: it emits one move intent. canDrop refuses a lane with a stated reason. Promoted from AI Studio's kanban primitive, which listed keyboard drag and drop as its prerequisite for promotion.
TuxMilestoneRoadmap /components/milestone-roadmap One section per milestone: its due date (overdue said in words), progress, and items.
TuxTimeline /components/timeline Rows against a week, month or quarter axis, with bars, points, milestone lines and a today line. Dependency arrows turn red when the order is impossible, and undated rows are listed rather than dropped.
TuxDependencyGraph /components/dependency-graph "What has to happen first", laid out left to right. Loops are found (Tarjan) and called out in plain words, and cross-repo items are dashed.
TuxPeoplePicker /components/people-picker An ARIA combobox for assigning people: avatars, titles and "Needs access" status, with local or server-side search.

Changed (existing pages keep their current behaviour)

  • TuxStepper:
    • selectable steps that emit select;
    • a per-step locked reason;
    • busyIndex;
    • variant="path", a compact form for panels and cards;
    • a fix so a step with to renders a real link (a :is="'NuxtLink'" string rendered an unknown <nuxtlink> element).
  • TuxSlideover: opens when mounted already open, which deep links need. An Escape meant for a popover inside the sheet no longer also closes the sheet.
  • TuxBreadcrumbs: href crumbs render as plain links, and crumbs with neither to nor href are buttons that emit navigate.
  • TuxTooltip: a tooltip with a title now grows instead of overflowing Nuxt UI's h-6 row.
  • TuxDescriptionList: stacks labels above values in narrow containers.

Rules every new component follows

  • No Nuxt-only APIs: no NuxtLink in the new components, no useRoute, no useHead.
  • Shadow-DOM safe: no document-global queries and no teleports; helpers are imported by relative path.
  • Other: no new dependencies, container queries for narrow layouts, and reduced-motion support.

Checks run on this branch

  • nuxt typecheck: clean.
  • eslint .: clean.
  • vitest: 254 passed, 24 files, including mounted tests for each new component and for the changed contracts.
  • React workspace tests: pass.
  • audit:tokens: OK.
  • The wash ladder, the literal ratchet and the ports ledger (seven new components added) all pass.
  • generate && audit:a11y: every new or changed page passes. The two remaining violations are pre-existing and not touched here: /components/code-maroon (role="error") and /install (empty headings).

Left for 3.0's palette work (not changed here):

  • Under tti-dark, --text-on-brand is white on the light-teal --brand-primary (2.4:1).
  • --wash-brand-* stays maroon, because it is computed on the theme root.
  • Nuxt UI's primary (TuxButton) renders rose-maroon at 3.4:1.

The new components mix their tints from --brand-primary and put --surface-page text on brand fills, so they pass either way.

Merging: this is based on main, not on the status-palette / 3.0 work. The only overlaps with 3.0 are appended rows in tuxCatalog.ts, design/components.md and the CHANGELOG. The forge's island can pin this branch's commit until it's merged.

Nine components for planning and doing work, built for TTI Code's per-repo **Roadmap** tab and usable anywhere. That tab is a TUX app mounted in a shadow root inside Forgejo pages; see nis/forgejo-stack `docs/design/work-surface-island.md`. Plus the fixes TUX needed to run a panel inside an app that isn't Nuxt. **New components** | Component | Showcase | What it is | |---|---|---| | `TuxKanbanBoard` / `TuxKanbanLane` / `TuxKanbanCard` | `/components/kanban` | Lanes of cards you drag between, by pointer or entirely by keyboard, with every step announced. The board holds no data: it emits one `move` intent. `canDrop` refuses a lane with a stated reason. Promoted from AI Studio's kanban primitive, which listed keyboard drag and drop as its prerequisite for promotion. | | `TuxMilestoneRoadmap` | `/components/milestone-roadmap` | One section per milestone: its due date (overdue said in words), progress, and items. | | `TuxTimeline` | `/components/timeline` | Rows against a week, month or quarter axis, with bars, points, milestone lines and a today line. Dependency arrows turn red when the order is impossible, and undated rows are listed rather than dropped. | | `TuxDependencyGraph` | `/components/dependency-graph` | "What has to happen first", laid out left to right. Loops are found (Tarjan) and called out in plain words, and cross-repo items are dashed. | | `TuxPeoplePicker` | `/components/people-picker` | An ARIA combobox for assigning people: avatars, titles and "Needs access" status, with local or server-side search. | **Changed** (existing pages keep their current behaviour) - **TuxStepper:** - `selectable` steps that emit `select`; - a per-step `locked` reason; - `busyIndex`; - `variant="path"`, a compact form for panels and cards; - a fix so a step with `to` renders a real link (a `:is="'NuxtLink'"` string rendered an unknown `<nuxtlink>` element). - **TuxSlideover:** opens when mounted already open, which deep links need. An Escape meant for a popover inside the sheet no longer also closes the sheet. - **TuxBreadcrumbs:** `href` crumbs render as plain links, and crumbs with neither `to` nor `href` are buttons that emit `navigate`. - **TuxTooltip:** a tooltip with a `title` now grows instead of overflowing Nuxt UI's `h-6` row. - **TuxDescriptionList:** stacks labels above values in narrow containers. **Rules every new component follows** - **No Nuxt-only APIs:** no `NuxtLink` in the new components, no `useRoute`, no `useHead`. - **Shadow-DOM safe:** no `document`-global queries and no teleports; helpers are imported by relative path. - **Other:** no new dependencies, container queries for narrow layouts, and reduced-motion support. **Checks run on this branch** - `nuxt typecheck`: clean. - `eslint .`: clean. - `vitest`: **254 passed**, 24 files, including mounted tests for each new component and for the changed contracts. - React workspace tests: pass. - `audit:tokens`: OK. - The wash ladder, the literal ratchet and the ports ledger (seven new components added) all pass. - `generate && audit:a11y`: every new or changed page passes. The two remaining violations are pre-existing and not touched here: `/components/code-maroon` (`role="error"`) and `/install` (empty headings). **Left for 3.0's palette work** (not changed here): - Under `tti-dark`, `--text-on-brand` is white on the light-teal `--brand-primary` (2.4:1). - `--wash-brand-*` stays maroon, because it is computed on the theme root. - Nuxt UI's primary (TuxButton) renders rose-maroon at 3.4:1. The new components mix their tints from `--brand-primary` and put `--surface-page` text on brand fills, so they pass either way. **Merging:** this is based on `main`, not on the `status-palette` / 3.0 work. The only overlaps with 3.0 are appended rows in `tuxCatalog.ts`, `design/components.md` and the CHANGELOG. The forge's island can pin this branch's commit until it's merged.
Promoted from TTI AI Studio's kanban primitive, with what it listed as the
prerequisite for promotion: keyboard drag-and-drop.

- The board holds no data and emits one move({cardId, fromLaneId, toLaneId,
  beforeCardId}) intent; nothing fires for a drop where the card already was.
- Keyboard: Space lifts, arrows choose lane and position, Space/Enter drops,
  Escape (which never reaches a surrounding dialog) or Tab cancels; every
  step is spoken through a live region; the card keeps focus after the
  parent re-renders it in its new lane.
- canDrop(cardId, toLaneId, fromLaneId) returns true or a sentence; a
  refusing lane shows the sentence while the drag is over it and the drop is
  cancelled and announced (e.g. 'Done' for work still waiting on something).
- Pointer: arms after 4px, never from a control inside the card, swallows the
  click that ends a drag, auto-scrolls lane and board; on touch a vertical
  swipe still scrolls the lane. Read-only boards (disabled) open cards only.
- Shadow-DOM safe: hit-testing asks the card's root node and nothing touches
  document.body — the forge mounts TUX inside a shadow root. The drag context
  lives in TuxKanbanBoard.vue's exporting script so hosts that don't
  auto-import TUX composables can use it.
- Cards are named groups, not buttons: card bodies often hold their own
  buttons (nested interactive controls). Narrow containers snap one lane per
  screen.

Showcase at /components/kanban; mounted tests for the keyboard contract,
refusals, read-only, Escape containment and one pointer drag.
A GitLab/GitHub-Projects-style roadmap view, built as the forge Roadmap
tab's "Timeline" view: areas of work and their items as rows, bars from
when work started to when its milestone is due.

- Sticky label column (buttons, emit `select`) beside a horizontally
  scrolling track: week / month ("Oct 2026") / quarter ("2026-Q4") ticks,
  gridlines, dashed milestone lines flagged in the header, a today line.
  Scale is auto from the visible span; the track fills wide containers
  and scrolls in narrow ones; on mount it scrolls today into view.
- start+end → bar (progress fill, done muted); end only → diamond;
  start only → open-ended fading bar; neither → the Unscheduled block
  (never dropped). Bars cut by the range get dashed edges; rows fully
  outside it become a focusable chevron at that edge. Markers emit
  `selectMarker`.
- Dependencies: SVG elbow arrows from the end of `from` to the start of
  `to`. When `from` ends after `to` starts the arrow is red + dashed with
  a <title> ("… can't start before … ends"), both bar edges turn red, and
  both labels carry a warning icon and "schedule conflict" text. Arrows
  with missing rows are skipped.
- Every bar / point / marker is a button whose accessible name carries
  its dates ("Forgejo v17 hop — Oct 1 to Oct 29, 2026, 40% done") and
  what it waits on. Hover/focus shows a tooltip clamped inside the
  component and flipped above near the bottom. Arrow keys move between
  rows and along a row; Escape hides the tooltip.
- Container queries (labels never exceed ~42% of a narrow container;
  truncated labels show in full on hover/focus), reduced-motion aware,
  TUX tokens only.
- Island-safe: no Nuxt-only APIs, no document-global queries, no
  teleports; the pure math (date → x, ticks, range, sections,
  impossible-order detection, tooltip / flag / arrow geometry) is
  exported from the component's plain <script> block, so a host imports
  it with the component and nothing relies on Nuxt auto-imports.
- Optional props beyond the brief: selected, loading, ariaLabel,
  maxHeight. Slots: #label="{ row }", #empty.

Showcase /components/timeline (roadmap with an impossible dependency,
week sprint, quarter plan with an explicit range, 340px, empty /
loading / all-unscheduled, and how it differs from TuxActivityTimeline).
Tests: tests/components/tux-timeline.nuxt.test.ts (pure helpers +
mounted emits, keyboard, conflict drawing, states).
Found building the forge's work-surface panel on TUX (a shadow-DOM island in
Forgejo pages, no Nuxt router):

- TuxSlideover opens when it is MOUNTED open (the watch only saw changes, so
  a deep link to a record never called showModal), and an Escape meant for a
  popover/listbox/menu inside the sheet no longer also closes the sheet (the
  native dialog treats Escape as its own close request).
- TuxStepper: selectable steps (buttons emitting select(index, step)), a
  per-step locked reason (lock icon, aria-disabled, the reason in its name),
  busyIndex, and variant="path": compact segments for panels and cards,
  labels wrapping below ~26rem. Default stepper unchanged.
- TuxBreadcrumbs: a crumb with href is a plain link and one with neither to
  nor href is a button emitting navigate(crumb, index), for hosts without a
  router. Unchanged for existing pages (a home crumb with no to still links /).
- TuxTooltip: a titled tooltip grows instead of overflowing Nuxt UI's fixed
  h-6 content row.
- TuxDescriptionList: inline lists stack labels above values in narrow
  containers, and the value column can't overflow.

Mounted tests for the slideover, stepper and breadcrumbs contracts.
A search box over a list of people (TuxAvatar, name, a subtitle such as
"Student Technician · NET", a TuxBadge status such as "Needs access"),
built for the forge's Roadmap item panel, where it sits in a UPopover
inside a modal <dialog> side panel.

- WAI-ARIA APG combobox with an always-visible listbox: focus stays in
  the input, aria-activedescendant tracks the active option; options
  carry aria-selected / aria-disabled.
- Keyboard: arrows wrap, Home/End (modifier keys stay caret keys),
  Enter toggles (never submits a form), Escape emits close with
  propagation stopped and default prevented so the enclosing dialog
  stays open. IME composition is left alone.
- v-model (ids) + v-model:query (emitted on every keystroke). Local
  filtering (name/subtitle/login, token- and accent-insensitive) unless
  `remote`, where the picker never filters and nothing stale is made
  active until the new results land.
- toggle(id, selected) per changed id; single select replaces (the old
  person reported deselected first).
- selectedFirst sorts by the selection as of the last query / outside
  v-model change, so rows never jump under the pointer mid-pick.
- Loading keeps the list (aria-busy, spinner); empty text or #empty slot
  only when not loading; #footer slot; polite status line.
- Directory-order initials ("Rojo, Alex" -> AR) via tuxPersonInitials.
- 20rem default width (--tux-people-picker-width), inline-size
  container: status badges drop under the text below 21rem.
- Island-safe: no Nuxt-only APIs, no document queries, no Teleport;
  helpers in app/utils/tuxPeople.ts imported by relative path.

Catalog entry, components.md row, showcase page (popover in a mock
item panel with a read-only switch, remote search with a fake delay and
a directory-wide footer, single select, loading/empty/failed/disabled
states, phone widths, keyboard table), mounted contract tests and
helper unit tests.
A layered, left-to-right graph of work items where an arrow means
"waits on" (edge {from, to}: `to` waits on `from`). Built for the forge's
Roadmap tab, where the data are Forgejo issue dependencies, cross-repo
links included. Separate from TuxDiagram (Mermaid source written by hand):
this one is data-driven, lays itself out, and every card is a button.

Layout (app/utils/tuxDependencyGraph.ts, pure, no DOM):
- links cleaned first: self-links, links to unknown ids, malformed and
  repeated links are dropped and counted, never thrown on
- unlinked items go to a "Not linked" strip, not the graph
- loops = Tarjan SCCs of 2+ items; drawn in a framed Loop band under the
  graph in cycle order, red links, a plain-words banner naming each
  member; items downstream of a loop are placed normally, flagged
- columns by longest path (Kahn over the condensation, a loop taking one
  column per member), placeholders for column-skipping links, barycenter
  sweeps keeping the order with the fewest crossings
- orthogonal routing with per-gap lanes; a long link that changes rows
  runs off the row centre line so it can't be read as another card's link

Component: cards are real buttons (select on click/Enter/Space), Tab in
step order, arrow keys to the nearest card, hover/focus/`selected` traces
the whole upstream + downstream chain and dims the rest, "Waiting on N"
(open blockers only), "Unblocked", done/external/loop cues that don't rely
on colour, a hidden per-card description of what it waits on and what
needs it. Optional props beyond the brief: loading, ariaLabel; #empty slot.
Island-safe: explicit relative imports, no Nuxt APIs, no document queries,
no teleports; container queries; reduced motion.

Registered in the catalog, components.md (both tables) and a showcase page.
One section per milestone: due wording ("due Oct 29 · in 36 days",
"12 days overdue"), "4 of 9 done" + progress bar, and the items as
buttons that emit select(item.id). Open milestones by due date, then
undated, then closed (latest first); unscheduled items form a final
"No milestone" section. Closed and 100%-done milestones start collapsed;
the start state then sticks across re-renders.

Header is a heading + disclosure button (aria-expanded/controls,
described by due + progress); collapsed bodies use hidden="until-found".
Arrow/Home/End move between headers and rows. Overdue is said in words,
done/area carried by visually-hidden text or the row's name. Plain-Vue
safe for the forge island: no Nuxt APIs, no document queries, no
teleport. Container queries from ~340px up.

Pure helpers (tuxMilestoneOrder, tuxMilestoneDue, tuxMilestoneProgress,
tuxMilestoneStartsCollapsed) and the TuxMilestone* types are exported
from the SFC's plain <script> block. Showcase page covers flagship,
340px, read-only, #item slot, odd data, loading and empty; mounted test
covers ordering, due wording, emits, keyboard, collapse, slots, states.
The node was <component :is="'NuxtLink'">: a string in :is is resolved at
runtime against globally registered components, and NuxtLink isn't one (Nuxt
wires it in at compile time), so it rendered an unknown <nuxtlink> element.
Static NuxtLink branch, same fix as TuxBreadcrumbs.
- color-mix washes on the ladder {4,6,8,12,18,22,35,50}: 5→6, 10→8, 25→22,
  45/55→50 across the new components and the stepper's path variant.
- No new raw colour literals: the shadow tokens need no rgba() fallback, a
  mask gradient uses the black keyword, and example issue refs in comments
  (#184 reads as a hex literal to the ratchet) became #n / #12.
- kit/ports/manifest.json gains the seven new components (react: never
  ported); the ports-sync workflow queues them after merge. Pre-existing
  drift on main is left to that workflow.
- TuxKanbanBoard: lane stepping without an unchecked index (nuxt typecheck).
fix(TuxKanbanLane): a named group, not a landmark per lane (axe landmark-unique)
Some checks failed
baseline-security / baseline (push) Failing after 1m18s
scan / trivy-fs (push) Failing after 1m5s
ai-review / review (pull_request) Successful in 1m12s
scan / trivy-fs (pull_request) Failing after 1m11s
baseline-security / baseline (pull_request) Failing after 1m34s
a6f949284a

🔧 Security-gate fix map

The gate failed on these dependency findings — fastest path to green for each:

finding package installed → fixed do this
CVE-2026-63671 (HIGH) @nuxtjs/mdc 0.21.1 → 0.22.1 merge #59 — fix(security): @nuxtjs/mdc ^0.22.2 (CVE-2026-63671) — hold f
GHSA-j95f-988m-3j2f (HIGH) @tiptap/core 3.28.0 → 3.30.5 no fix PR yet — npm update core --package-lock-only
CVE-2026-84375 (HIGH) js-yaml 4.3.1 → 4.3.2, 3.15.2 no fix PR yet — npm update js-yaml --package-lock-only
GHSA-rgj7-g3m4-5g8c (HIGH) sharp 0.35.3 → 0.35.4 no fix PR yet — npm update sharp --package-lock-only
CVE-2026-84370 (HIGH) svgo 4.0.2 → 2.8.4, 3.3.5, 4.1.0 no fix PR yet — npm update svgo --package-lock-only

⚠ main is itself red right now — this PR likely inherits the backlog rather than adding it. Fixing main (rows above) unblocks every open PR at once.

Posted once per head commit by the baseline gate (M2). A Renovate PR that only touches a manifest with no lockfile change is a broken pre-2026-08-06 artifact — check its diff before merging.

### 🔧 Security-gate fix map <!-- tti-fixmap:a6f949284a81888858266174d2e1d1676a50e8e9 --> The gate failed on these dependency findings — fastest path to green for each: | finding | package | installed → fixed | do this | |---|---|---|---| | CVE-2026-63671 (HIGH) | `@nuxtjs/mdc` | 0.21.1 → 0.22.1 | merge #59 — fix(security): @nuxtjs/mdc ^0.22.2 (CVE-2026-63671) — hold f | | GHSA-j95f-988m-3j2f (HIGH) | `@tiptap/core` | 3.28.0 → 3.30.5 | no fix PR yet — `npm update core --package-lock-only` | | CVE-2026-84375 (HIGH) | `js-yaml` | 4.3.1 → 4.3.2, 3.15.2 | no fix PR yet — `npm update js-yaml --package-lock-only` | | GHSA-rgj7-g3m4-5g8c (HIGH) | `sharp` | 0.35.3 → 0.35.4 | no fix PR yet — `npm update sharp --package-lock-only` | | CVE-2026-84370 (HIGH) | `svgo` | 4.0.2 → 2.8.4, 3.3.5, 4.1.0 | no fix PR yet — `npm update svgo --package-lock-only` | > ⚠ `main` is itself red right now — this PR likely **inherits** the backlog rather than adding it. Fixing `main` (rows above) unblocks every open PR at once. <sub>Posted once per head commit by the baseline gate (M2). A Renovate PR that only touches a manifest with no lockfile change is a broken pre-2026-08-06 artifact — check its diff before merging.</sub>
ai-review-bot left a comment

AI review · advisory

Verdict: 5 things worth fixing (1 high · 3 medium · 1 low).

Findings that didn't map to a diff line:

app/utils/tuxCatalog.ts:280 · HIGH — New components missing from the component catalog
The newly added components (TuxDependencyGraph, TuxKanbanBoard, TuxKanbanCard, TuxKanbanLane, TuxMilestoneRoadmap, etc.) are not listed in tuxCatalog, causing the catalog consistency test (tests/tux-catalog.test.ts) to fail and breaking the single source of truth for navigation, showcase, and documentation.

Fix: Add catalog entries for each new component with appropriate metadata (name, route, icon, family, kind, wraps, blurb).

⚑ panel: GPT-OSS 120B · Gemma 4 31B · Devstral 2 123B · Laguna S 2.1 — 16 distinct (from 18 reviewer findings), 5 confirmed, 3 below threshold, 8 refuted · web: not used · context: 8 files under review · 89 codebase · 5 standards chunks (best-grounded: Gemma 4 31B; smaller windows saw less)

Panel debate — how this review was reached

Grounding — context: 8 files under review · 89 codebase · 5 standards chunks (best-grounded: Gemma 4 31B; smaller windows saw less)

Round 1 — independent reviews

  • GPT-OSS 120B (4 findings, confidence 0.93 · thought 1.6k + wrote 1.5k of 16.0k tokens · 33 s): The changes introduce new components that are not yet registered in the catalog, causing consistency failures, and a minor HTML attribute misuse in breadcrumbs, but otherwise the code follows existing
  • Gemma 4 31B (3 findings, confidence 1.0 · thought 5.1k + wrote 1.1k of 16.0k tokens · 178 s): The new work-surface components are well-architected and follow accessibility and shadow-DOM safety patterns. However, there are two critical hydration bugs where Math.random() is used for accessibi
  • Devstral 2 123B (8 findings, confidence 0.85 · wrote 1.3k of 16.0k tokens · 71 s): The changes introduce several new components and enhance existing ones, but there are some missing validations and potential issues that need to be addressed.
  • Laguna S 2.1 (3 findings, confidence 0.85 · thought 50.5k + wrote 1.4k of 65.5k tokens · 423 s): Two issues: Math.random()-based ID generation in TuxKanbanBoard.vue and TuxKanbanLane.vue (inconsistent with useId() in sibling components, risks SSR hydration mismatch), and a custom focus ring in Tu

Grouping — 18 reviewer findings describe 16 distinct defects; reviewers who found the same defect independently count as support.

Round 2 — cross-examination

  • GPT-OSS 120B#1 New components missing from the component catalog · confirmed: Gemma 4 31B, Devstral 2 123B, Laguna S 2.1 · refuted: — · support 3
  • GPT-OSS 120B#2 href attribute applied to <button> elements · confirmed: Gemma 4 31B, Devstral 2 123B · refuted: Laguna S 2.1 · support 2
  • Laguna S 2.1#2 Math.random() used for aria-labelledby ID — SSR hydration mismatch and inconsist · also raised by: Gemma 4 31B · confirmed: GPT-OSS 120B, Devstral 2 123B · refuted: — · support 3
  • Devstral 2 123B#1 Missing validation for crumb.to and crumb.href · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0
  • Devstral 2 123B#2 Missing validation for tagOf function · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0
  • Laguna S 2.1#1 Math.random() used for aria-describedby ID — SSR hydration mismatch and inconsis · also raised by: Gemma 4 31B · confirmed: GPT-OSS 120B, Devstral 2 123B · refuted: — · support 3
  • Devstral 2 123B#3 Missing validation for nodes and edges props · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0
  • Laguna S 2.1#3 Custom focus ring conflicts with the universal --shadow-focus system · confirmed: GPT-OSS 120B, Gemma 4 31B, Devstral 2 123B · refuted: — · support 3
  • GPT-OSS 120B#4 Missing container-type on the root element · confirmed: Devstral 2 123B · refuted: Gemma 4 31B, Laguna S 2.1 · support 1
  • GPT-OSS 120B#3 Potential stale detection of navigate listener · confirmed: Devstral 2 123B · refuted: Gemma 4 31B, Laguna S 2.1 · support 1
  • Gemma 4 31B#3 Use of internal Vue instance API · confirmed: Devstral 2 123B · refuted: GPT-OSS 120B, Laguna S 2.1 · support 1
  • Devstral 2 123B#8 Missing validation for unscheduled prop · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0

Not posted (support < 2, or contested at a severity where one refutation vetoes)

  • GPT-OSS 120B#4 Missing container-type on the root element (support 1)
  • GPT-OSS 120B#3 Potential stale detection of navigate listener (support 1)
  • Gemma 4 31B#3 Use of internal Vue instance API (support 1)

Raised but refuted (left out of the review above)

  • Devstral 2 123B#1 Missing validation for crumb.to and crumb.href — The toOf function simply returns c.to or defaults to "/" for the home crumb. Vue‑Router tolerates undefined or missing strings, and the compon
  • Devstral 2 123B#2 Missing validation for tagOf function — tagOf relies on the truthiness of toOf and the presence of c.href to decide which element to render. An empty string evaluates to false, which g
  • Devstral 2 123B#3 Missing validation for nodes and edges props — Props nodes and edges are typed as arrays and the component already handles empty collections (e.g., showing a placeholder when there are no nodes
  • Devstral 2 123B#8 Missing validation for unscheduled prop — The unscheduled prop is optional and may be omitted; when provided it is typed as an array of items. Empty or missing values are already handled by

Synthesis — Laguna S 2.1 wrote the final review from 5 confirmed findings (+3 below threshold) · promotion: support ≥ 2, and no refutation at high severity.

Transcript rv-20260924163107-e87577 — full round outputs, web results, and model reasoning are viewable by anyone with access to this repository via the AI gateway.

Advisory — never a merge gate. Findings are ordered by how well the panel's own evidence checks out, strongest first. React 👍/👎 on any inline comment to tell it whether it was worth flagging — that is the only feedback this system gets, and every threshold in it is tuned from those reactions. Transcript rv-20260924163107-e87577.

### AI review · advisory <!-- tti-rv:rv-20260924163107-e87577: --> **Verdict: 5 things worth fixing** (1 high · 3 medium · 1 low). Findings that didn't map to a diff line: **`app/utils/tuxCatalog.ts:280`** · HIGH — New components missing from the component catalog The newly added components (TuxDependencyGraph, TuxKanbanBoard, TuxKanbanCard, TuxKanbanLane, TuxMilestoneRoadmap, etc.) are not listed in `tuxCatalog`, causing the catalog consistency test (`tests/tux-catalog.test.ts`) to fail and breaking the single source of truth for navigation, showcase, and documentation. > **Fix:** Add catalog entries for each new component with appropriate metadata (name, route, icon, family, kind, wraps, blurb). <sub>⚑ panel: GPT-OSS 120B · Gemma 4 31B · Devstral 2 123B · Laguna S 2.1 — 16 distinct (from 18 reviewer findings), 5 confirmed, 3 below threshold, 8 refuted · web: not used · context: 8 files under review · 89 codebase · 5 standards chunks (best-grounded: Gemma 4 31B; smaller windows saw less)</sub> <details> <summary>Panel debate — how this review was reached</summary> **Grounding** — context: 8 files under review · 89 codebase · 5 standards chunks (best-grounded: Gemma 4 31B; smaller windows saw less) **Round 1 — independent reviews** - **GPT-OSS 120B** (4 findings, confidence 0.93 · thought 1.6k + wrote 1.5k of 16.0k tokens · 33 s): The changes introduce new components that are not yet registered in the catalog, causing consistency failures, and a minor HTML attribute misuse in breadcrumbs, but otherwise the code follows existing - **Gemma 4 31B** (3 findings, confidence 1.0 · thought 5.1k + wrote 1.1k of 16.0k tokens · 178 s): The new work-surface components are well-architected and follow accessibility and shadow-DOM safety patterns. However, there are two critical hydration bugs where `Math.random()` is used for accessibi - **Devstral 2 123B** (8 findings, confidence 0.85 · wrote 1.3k of 16.0k tokens · 71 s): The changes introduce several new components and enhance existing ones, but there are some missing validations and potential issues that need to be addressed. - **Laguna S 2.1** (3 findings, confidence 0.85 · thought 50.5k + wrote 1.4k of 65.5k tokens · 423 s): Two issues: Math.random()-based ID generation in TuxKanbanBoard.vue and TuxKanbanLane.vue (inconsistent with useId() in sibling components, risks SSR hydration mismatch), and a custom focus ring in Tu **Grouping** — 18 reviewer findings describe 16 distinct defects; reviewers who found the same defect independently count as support. **Round 2 — cross-examination** - `GPT-OSS 120B#1` New components missing from the component catalog · confirmed: Gemma 4 31B, Devstral 2 123B, Laguna S 2.1 · refuted: — · support 3 - `GPT-OSS 120B#2` `href` attribute applied to `<button>` elements · confirmed: Gemma 4 31B, Devstral 2 123B · refuted: Laguna S 2.1 · support 2 - `Laguna S 2.1#2` Math.random() used for aria-labelledby ID — SSR hydration mismatch and inconsist · also raised by: Gemma 4 31B · confirmed: GPT-OSS 120B, Devstral 2 123B · refuted: — · support 3 - `Devstral 2 123B#1` Missing validation for `crumb.to` and `crumb.href` · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0 - `Devstral 2 123B#2` Missing validation for `tagOf` function · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0 - `Laguna S 2.1#1` Math.random() used for aria-describedby ID — SSR hydration mismatch and inconsis · also raised by: Gemma 4 31B · confirmed: GPT-OSS 120B, Devstral 2 123B · refuted: — · support 3 - `Devstral 2 123B#3` Missing validation for `nodes` and `edges` props · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0 - `Laguna S 2.1#3` Custom focus ring conflicts with the universal --shadow-focus system · confirmed: GPT-OSS 120B, Gemma 4 31B, Devstral 2 123B · refuted: — · support 3 - `GPT-OSS 120B#4` Missing `container-type` on the root element · confirmed: Devstral 2 123B · refuted: Gemma 4 31B, Laguna S 2.1 · support 1 - `GPT-OSS 120B#3` Potential stale detection of `navigate` listener · confirmed: Devstral 2 123B · refuted: Gemma 4 31B, Laguna S 2.1 · support 1 - `Gemma 4 31B#3` Use of internal Vue instance API · confirmed: Devstral 2 123B · refuted: GPT-OSS 120B, Laguna S 2.1 · support 1 - `Devstral 2 123B#8` Missing validation for `unscheduled` prop · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0 **Not posted** (support < 2, or contested at a severity where one refutation vetoes) - `GPT-OSS 120B#4` Missing `container-type` on the root element (support 1) - `GPT-OSS 120B#3` Potential stale detection of `navigate` listener (support 1) - `Gemma 4 31B#3` Use of internal Vue instance API (support 1) **Raised but refuted** (left out of the review above) - `Devstral 2 123B#1` Missing validation for `crumb.to` and `crumb.href` — The `toOf` function simply returns `c.to` or defaults to `"/"` for the home crumb. Vue‑Router tolerates `undefined` or missing strings, and the compon - `Devstral 2 123B#2` Missing validation for `tagOf` function — `tagOf` relies on the truthiness of `toOf` and the presence of `c.href` to decide which element to render. An empty string evaluates to false, which g - `Devstral 2 123B#3` Missing validation for `nodes` and `edges` props — Props `nodes` and `edges` are typed as arrays and the component already handles empty collections (e.g., showing a placeholder when there are no nodes - `Devstral 2 123B#8` Missing validation for `unscheduled` prop — The `unscheduled` prop is optional and may be omitted; when provided it is typed as an array of items. Empty or missing values are already handled by **Synthesis** — Laguna S 2.1 wrote the final review from 5 confirmed findings (+3 below threshold) · promotion: support ≥ 2, and no refutation at high severity. <sub>Transcript `rv-20260924163107-e87577` — full round outputs, web results, and model reasoning are viewable by anyone with access to this repository via the AI gateway.</sub> </details> <sub>Advisory — never a merge gate. Findings are ordered by how well the panel's own evidence checks out, strongest first. React 👍/👎 on any inline comment to tell it whether it was worth flagging — that is the only feedback this system gets, and every threshold in it is tuned from those reactions. Transcript `rv-20260924163107-e87577`.</sub>
@ -84,14 +95,37 @@ withDefaults(defineProps<Props>(), {
/>
<span>{{ crumb.label }}</span>
</NuxtLink>
<component
Member

app/components/TuxBreadcrumbs.vue:98 · MEDIUM — href attribute applied to <button> elements
The home crumb template always binds :href="crumb.href" even when the rendered tag is a <button>, producing an invalid href attribute on a non-anchor element that browsers ignore and that fails accessibility checks.

Fix: Bind href only when the rendered tag is an anchor, e.g. v-bind="tagOf(crumb, idx) === 'a' ? { href: crumb.href } : {}".

Proposed replacement (one-click ⚡ Apply on the findings board at the top of this PR):

<component
  :is="tagOf(crumb, idx)"
  v-else-if="idx === 0"
  v-bind="tagOf(crumb, idx) === 'a' ? { href: crumb.href } : {}"
  :type="tagOf(crumb, idx) === 'button' ? 'button' : undefined"
  class="tux-breadcrumbs__home"
  @click="tagOf(crumb, idx) === 'button' && emit('navigate', crumb, idx)"
>

Evidence: partly verified — two or more reviewers, quote or anchor verified (panel 3/4).
👍 if this was worth flagging · 👎 if it was not — a reaction on this comment is the whole feedback loop.

**`app/components/TuxBreadcrumbs.vue:98`** · MEDIUM — `href` attribute applied to `<button>` elements The home crumb template always binds `:href="crumb.href"` even when the rendered tag is a `<button>`, producing an invalid `href` attribute on a non-anchor element that browsers ignore and that fails accessibility checks. > **Fix:** Bind `href` only when the rendered tag is an anchor, e.g. `v-bind="tagOf(crumb, idx) === 'a' ? { href: crumb.href } : {}"`. **Proposed replacement** (one-click ⚡ Apply on the findings board at the top of this PR): ``` <component :is="tagOf(crumb, idx)" v-else-if="idx === 0" v-bind="tagOf(crumb, idx) === 'a' ? { href: crumb.href } : {}" :type="tagOf(crumb, idx) === 'button' ? 'button' : undefined" class="tux-breadcrumbs__home" @click="tagOf(crumb, idx) === 'button' && emit('navigate', crumb, idx)" > ``` <sub>Evidence: **partly verified** — two or more reviewers, quote or anchor verified (panel 3/4).<br>👍 if this was worth flagging · 👎 if it was not — a reaction on this comment is the whole feedback loop.</sub> <!-- tti-rv:rv-20260924163107-e87577:GPT-OSS 120B#2 -->
@ -0,0 +301,4 @@
case "ArrowUp":
case "ArrowDown": {
e.preventDefault();
const ids = cardsIn(lane, cardId);
Member

app/components/TuxKanbanBoard.vue:304 · MEDIUM — Math.random() used for aria-describedby ID, risking SSR hydration mismatch
hintId is generated with Math.random(), producing mismatched server/client IDs after hydration and breaking the aria-describedby linkage for screen readers, while sibling components in the same PR correctly use useId().

Fix: Replace Math.random() with useId()—add useId to the vue import in the <script lang="ts"> block and write const hintId = \tux-kanban-hint-${useId()}``;.

Proposed replacement (one-click ⚡ Apply on the findings board at the top of this PR):

import { inject, provide, ref, toRef, useId, type InjectionKey, type Ref } from "vue";
...
const hintId = `tux-kanban-hint-${useId()}`;

Evidence: strong evidence — two or more reviewers, quote verified against the diff, anchored on a changed line (panel 4/4).
👍 if this was worth flagging · 👎 if it was not — a reaction on this comment is the whole feedback loop.

**`app/components/TuxKanbanBoard.vue:304`** · MEDIUM — `Math.random()` used for `aria-describedby` ID, risking SSR hydration mismatch `hintId` is generated with `Math.random()`, producing mismatched server/client IDs after hydration and breaking the `aria-describedby` linkage for screen readers, while sibling components in the same PR correctly use `useId()`. > **Fix:** Replace `Math.random()` with `useId()`—add `useId` to the `vue` import in the `<script lang="ts">` block and write `const hintId = \`tux-kanban-hint-${useId()}\``;. **Proposed replacement** (one-click ⚡ Apply on the findings board at the top of this PR): ``` import { inject, provide, ref, toRef, useId, type InjectionKey, type Ref } from "vue"; ... const hintId = `tux-kanban-hint-${useId()}`; ``` <sub>Evidence: **strong evidence** — two or more reviewers, quote verified against the diff, anchored on a changed line (panel 4/4).<br>👍 if this was worth flagging · 👎 if it was not — a reaction on this comment is the whole feedback loop.</sub> <!-- tti-rv:rv-20260924163107-e87577:Laguna S 2.1#1 -->
@ -0,0 +91,4 @@
position: absolute;
top: -6px;
left: 0;
right: 0;
Member

app/components/TuxKanbanCard.vue:94 · LOW — Custom focus outline conflicts with the universal --shadow-focus system
The .tux-kanban-card:focus-visible rule defines a custom maroon outline instead of using the project-wide focus ring (--shadow-focus), creating a redundant and visually inconsistent focus indicator; the v2.1.0 changelog specifies all components should inherit the universal two-ring focus style.

Fix: Replace the custom outline with the universal pattern—outline: 2px solid transparent; box-shadow: var(--shadow-focus);—matching the sibling TuxDependencyGraph.vue in the same PR.

Proposed replacement (one-click ⚡ Apply on the findings board at the top of this PR):

.tux-kanban-card:focus-visible {
  outline: 2px solid transparent;
  box-shadow: var(--shadow-focus);
}

Evidence: strong evidence — two or more reviewers, quote verified against the diff, anchored on a changed line (panel 4/4).
👍 if this was worth flagging · 👎 if it was not — a reaction on this comment is the whole feedback loop.

**`app/components/TuxKanbanCard.vue:94`** · LOW — Custom focus outline conflicts with the universal `--shadow-focus` system The `.tux-kanban-card:focus-visible` rule defines a custom maroon outline instead of using the project-wide focus ring (`--shadow-focus`), creating a redundant and visually inconsistent focus indicator; the v2.1.0 changelog specifies all components should inherit the universal two-ring focus style. > **Fix:** Replace the custom outline with the universal pattern—`outline: 2px solid transparent; box-shadow: var(--shadow-focus);`—matching the sibling `TuxDependencyGraph.vue` in the same PR. **Proposed replacement** (one-click ⚡ Apply on the findings board at the top of this PR): ``` .tux-kanban-card:focus-visible { outline: 2px solid transparent; box-shadow: var(--shadow-focus); } ``` <sub>Evidence: **strong evidence** — two or more reviewers, quote verified against the diff, anchored on a changed line (panel 4/4).<br>👍 if this was worth flagging · 👎 if it was not — a reaction on this comment is the whole feedback loop.</sub> <!-- tti-rv:rv-20260924163107-e87577:Laguna S 2.1#3 -->
@ -0,0 +43,4 @@
const titleId = `tux-kanban-lane-${props.laneId.replace(/[^\w-]/g, "_")}-${Math.random().toString(36).slice(2, 6)}`;
</script>
<template>
Member

app/components/TuxKanbanLane.vue:46 · MEDIUM — Math.random() used for aria-labelledby ID, risking SSR hydration mismatch
titleId is built with Math.random(), which returns a different value on the server versus the client; because this component lives in Nuxt's auto-import app/components/ directory and may be prerendered, the aria-labelledby ID won't match after hydration and can break screen-reader label associations.

Fix: Replace Math.random() with Vue's useId()—import it from vue and write const titleId = \tux-kanban-lane-${useId()}`; —matching the pattern already used by TuxDependencyGraph.vueandTuxMilestoneRoadmap.vue`.

Proposed replacement (one-click ⚡ Apply on the findings board at the top of this PR):

import { computed, useId } from "vue";
...
const titleId = `tux-kanban-lane-${useId()}`;

Evidence: strong evidence — two or more reviewers, quote verified against the diff, anchored on a changed line (panel 4/4).
👍 if this was worth flagging · 👎 if it was not — a reaction on this comment is the whole feedback loop.

**`app/components/TuxKanbanLane.vue:46`** · MEDIUM — `Math.random()` used for `aria-labelledby` ID, risking SSR hydration mismatch `titleId` is built with `Math.random()`, which returns a different value on the server versus the client; because this component lives in Nuxt's auto-import `app/components/` directory and may be prerendered, the `aria-labelledby` ID won't match after hydration and can break screen-reader label associations. > **Fix:** Replace `Math.random()` with Vue's `useId()`—import it from `vue` and write `const titleId = \`tux-kanban-lane-${useId()}\``; —matching the pattern already used by `TuxDependencyGraph.vue` and `TuxMilestoneRoadmap.vue`. **Proposed replacement** (one-click ⚡ Apply on the findings board at the top of this PR): ``` import { computed, useId } from "vue"; ... const titleId = `tux-kanban-lane-${useId()}`; ``` <sub>Evidence: **strong evidence** — two or more reviewers, quote verified against the diff, anchored on a changed line (panel 4/4).<br>👍 if this was worth flagging · 👎 if it was not — a reaction on this comment is the whole feedback loop.</sub> <!-- tti-rv:rv-20260924163107-e87577:Laguna S 2.1#2 -->
fix: panel findings on #61 — SSR-stable ids, href only on links, TUX's shared focus ring
Some checks failed
scan / trivy-fs (push) Failing after 1m6s
ai-review / review (pull_request) Successful in 1m10s
baseline-security / baseline (push) Failing after 1m16s
scan / trivy-fs (pull_request) Failing after 1m6s
baseline-security / baseline (pull_request) Failing after 1m32s
5c8197832f
- TuxKanbanLane / TuxKanbanBoard: aria ids from useId(), not Math.random()
  (a prerendered page would hydrate with ids that no longer match).
- TuxBreadcrumbs: href is bound only when the crumb renders as <a>.
- TuxKanbanCard: :focus-visible uses --shadow-focus like every other control.

Not taken: 'new components missing from the catalog' — they are there, and
tests/tux-catalog.test.ts passes (the reviewer saw a truncated file).

🔧 Security-gate fix map

The gate failed on these dependency findings — fastest path to green for each:

finding package installed → fixed do this
CVE-2026-63671 (HIGH) @nuxtjs/mdc 0.21.1 → 0.22.1 merge #59 — fix(security): @nuxtjs/mdc ^0.22.2 (CVE-2026-63671) — hold f
GHSA-j95f-988m-3j2f (HIGH) @tiptap/core 3.28.0 → 3.30.5 no fix PR yet — npm update core --package-lock-only
CVE-2026-84375 (HIGH) js-yaml 4.3.1 → 4.3.2, 3.15.2 no fix PR yet — npm update js-yaml --package-lock-only
GHSA-rgj7-g3m4-5g8c (HIGH) sharp 0.35.3 → 0.35.4 no fix PR yet — npm update sharp --package-lock-only
CVE-2026-84370 (HIGH) svgo 4.0.2 → 2.8.4, 3.3.5, 4.1.0 no fix PR yet — npm update svgo --package-lock-only

⚠ main is itself red right now — this PR likely inherits the backlog rather than adding it. Fixing main (rows above) unblocks every open PR at once.

Posted once per head commit by the baseline gate (M2). A Renovate PR that only touches a manifest with no lockfile change is a broken pre-2026-08-06 artifact — check its diff before merging.

### 🔧 Security-gate fix map <!-- tti-fixmap:5c8197832f80db3fa321d4adce34ae60a205f353 --> The gate failed on these dependency findings — fastest path to green for each: | finding | package | installed → fixed | do this | |---|---|---|---| | CVE-2026-63671 (HIGH) | `@nuxtjs/mdc` | 0.21.1 → 0.22.1 | merge #59 — fix(security): @nuxtjs/mdc ^0.22.2 (CVE-2026-63671) — hold f | | GHSA-j95f-988m-3j2f (HIGH) | `@tiptap/core` | 3.28.0 → 3.30.5 | no fix PR yet — `npm update core --package-lock-only` | | CVE-2026-84375 (HIGH) | `js-yaml` | 4.3.1 → 4.3.2, 3.15.2 | no fix PR yet — `npm update js-yaml --package-lock-only` | | GHSA-rgj7-g3m4-5g8c (HIGH) | `sharp` | 0.35.3 → 0.35.4 | no fix PR yet — `npm update sharp --package-lock-only` | | CVE-2026-84370 (HIGH) | `svgo` | 4.0.2 → 2.8.4, 3.3.5, 4.1.0 | no fix PR yet — `npm update svgo --package-lock-only` | > ⚠ `main` is itself red right now — this PR likely **inherits** the backlog rather than adding it. Fixing `main` (rows above) unblocks every open PR at once. <sub>Posted once per head commit by the baseline gate (M2). A Renovate PR that only touches a manifest with no lockfile change is a broken pre-2026-08-06 artifact — check its diff before merging.</sub>
ai-review-bot left a comment

AI review · advisory

Verdict: 4 things worth fixing (4 medium).

Findings that didn't map to a diff line:

app/components/TuxMilestoneRoadmap.vue:0 · MEDIUM — Row click handler fires onSelect even when the row is a non-interactive div, bypassing the interactive guard at the click level
The row <component :is="interactive ? 'button' : 'div'"> has @click="onSelect(item)" unconditionally bound. While onSelect checks props.interactive before emitting, the click handler still fires the function on a div in read-only mode; the guard is inside the handler rather than at the DOM level. This is a minor inconsistency rather than a bug since no emit occurs, but it means keyboard users in read-only mode get no feedback and the click target is semantically inert. Low impact but worth noting for a read-only roadmap.

Fix: Bind the click handler conditionally on interactive so read-only rows never attach a click listener, keeping the DOM and the intent aligned.

⚑ panel: GPT-OSS 120B · Gemma 4 31B · Devstral 2 123B · Laguna S 2.1 — 15 distinct, 4 confirmed, 1 below threshold, 10 refuted · web: not used · context: 8 files under review · 89 codebase · 5 standards chunks (best-grounded: Gemma 4 31B; smaller windows saw less)

Panel debate — how this review was reached

Grounding — context: 8 files under review · 89 codebase · 5 standards chunks (best-grounded: Gemma 4 31B; smaller windows saw less)

Round 1 — independent reviews

  • GPT-OSS 120B (4 findings, confidence 0.93 · thought 1.2k + wrote 989 of 16.0k tokens · 26 s): The changes introduce new components and enhance breadcrumbs, but a few low‑severity issues need attention: ensure non‑submit buttons are explicitly typed, add appropriate ARIA attributes for button‑b
  • Gemma 4 31B (0 findings, confidence 1.0 · thought 4.2k + wrote 493 of 16.0k tokens · 142 s): The changes are high-quality and strictly adhere to the project's architectural requirements for Nuxt-free, shadow-DOM safe, and keyboard-accessible components. The implementation of complex interacti
  • Devstral 2 123B (5 findings, confidence 0.85 · wrote 1.3k of 16.0k tokens · 71 s): The diff introduces several new components and updates existing ones, but there are some missing validations and potential issues that need to be addressed.
  • Laguna S 2.1 (6 findings · wrote 2.4k of 65.5k tokens · 28 s):

Round 2 — cross-examination

  • Laguna S 2.1#3 Button crumbs emit navigate on click but lack a type attribute, yielding `role · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Devstral 2 123B · support 0
  • Laguna S 2.1#1 Pointer-drag ghost is only reparented to hostEl but never restored on non-comple · confirmed: Gemma 4 31B, Devstral 2 123B · refuted: GPT-OSS 120B · support 2
  • Laguna S 2.1#4 Row click handler fires onSelect even when the row is a non-interactive div, · confirmed: GPT-OSS 120B, Gemma 4 31B, Devstral 2 123B · refuted: — · support 3
  • Laguna S 2.1#6 Arrow-key focus navigation on isolated strip allows ArrowUp/ArrowDown to navigat · confirmed: GPT-OSS 120B, Gemma 4 31B, Devstral 2 123B · refuted: — · support 3
  • Devstral 2 123B#4 Missing validation for tagOf function in template · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0
  • Devstral 2 123B#3 Missing validation for tagOf function in template · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0
  • Laguna S 2.1#5 Pointer drag hover hit-test against the dragged card's own slot can land on th · confirmed: Gemma 4 31B, Devstral 2 123B · refuted: GPT-OSS 120B · support 2
  • Laguna S 2.1#2 Keyboard Escape path calls cancelKeyboard which checks mode !== 'keyboard' but t · confirmed: Gemma 4 31B, Devstral 2 123B · refuted: GPT-OSS 120B · support 2
  • Devstral 2 123B#1 Missing validation for crumb.to and crumb.href · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0
  • Devstral 2 123B#2 Missing validation for tagOf function · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0
  • GPT-OSS 120B#3 Potential missing ARIA attributes for button navigation · confirmed: — · refuted: Gemma 4 31B, Devstral 2 123B, Laguna S 2.1 · support 0
  • Devstral 2 123B#5 Missing validation for button styling · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0

Not posted (support < 2, or contested at a severity where one refutation vetoes)

  • Laguna S 2.1#1 Pointer-drag ghost is only reparented to hostEl but never restored on non-comple (support 2, vetoed by a refutation)

Raised but refuted (left out of the review above)

  • Laguna S 2.1#3 Button crumbs emit navigate on click but lack a type attribute, yielding role — The updated breadcrumb template binds type: 'button'for button crumbs, so a defaulttype="submit"` is no longer possible.
  • Devstral 2 123B#4 Missing validation for tagOf function in template — tagOf only returns known tag strings ('NuxtLink', 'a', 'button'); no validation is required and the template uses it correctly.
  • Devstral 2 123B#3 Missing validation for tagOf function in template — Same reasoning as above; the template safely uses tagOf without additional validation.
  • Devstral 2 123B#1 Missing validation for crumb.to and crumb.href — toOf gracefully handles undefined crumb.to; explicit validation of non‑empty strings is unnecessary.

Synthesis — Laguna S 2.1 wrote the final review from 4 confirmed findings (+1 below threshold) · promotion: support ≥ 2, and no refutation at high severity.

Transcript rv-20260924164509-2eb4bb — full round outputs, web results, and model reasoning are viewable by anyone with access to this repository via the AI gateway.

Advisory — never a merge gate. Findings are ordered by how well the panel's own evidence checks out, strongest first. React 👍/👎 on any inline comment to tell it whether it was worth flagging — that is the only feedback this system gets, and every threshold in it is tuned from those reactions. Transcript rv-20260924164509-2eb4bb.

### AI review · advisory <!-- tti-rv:rv-20260924164509-2eb4bb: --> **Verdict: 4 things worth fixing** (4 medium). Findings that didn't map to a diff line: **`app/components/TuxMilestoneRoadmap.vue:0`** · MEDIUM — Row click handler fires `onSelect` even when the row is a non-interactive `div`, bypassing the `interactive` guard at the click level The row `<component :is="interactive ? 'button' : 'div'">` has `@click="onSelect(item)"` unconditionally bound. While `onSelect` checks `props.interactive` before emitting, the click handler still fires the function on a `div` in read-only mode; the guard is inside the handler rather than at the DOM level. This is a minor inconsistency rather than a bug since no emit occurs, but it means keyboard users in read-only mode get no feedback and the click target is semantically inert. Low impact but worth noting for a read-only roadmap. > **Fix:** Bind the click handler conditionally on `interactive` so read-only rows never attach a click listener, keeping the DOM and the intent aligned. <sub>⚑ panel: GPT-OSS 120B · Gemma 4 31B · Devstral 2 123B · Laguna S 2.1 — 15 distinct, 4 confirmed, 1 below threshold, 10 refuted · web: not used · context: 8 files under review · 89 codebase · 5 standards chunks (best-grounded: Gemma 4 31B; smaller windows saw less)</sub> <details> <summary>Panel debate — how this review was reached</summary> **Grounding** — context: 8 files under review · 89 codebase · 5 standards chunks (best-grounded: Gemma 4 31B; smaller windows saw less) **Round 1 — independent reviews** - **GPT-OSS 120B** (4 findings, confidence 0.93 · thought 1.2k + wrote 989 of 16.0k tokens · 26 s): The changes introduce new components and enhance breadcrumbs, but a few low‑severity issues need attention: ensure non‑submit buttons are explicitly typed, add appropriate ARIA attributes for button‑b - **Gemma 4 31B** (0 findings, confidence 1.0 · thought 4.2k + wrote 493 of 16.0k tokens · 142 s): The changes are high-quality and strictly adhere to the project's architectural requirements for Nuxt-free, shadow-DOM safe, and keyboard-accessible components. The implementation of complex interacti - **Devstral 2 123B** (5 findings, confidence 0.85 · wrote 1.3k of 16.0k tokens · 71 s): The diff introduces several new components and updates existing ones, but there are some missing validations and potential issues that need to be addressed. - **Laguna S 2.1** (6 findings · wrote 2.4k of 65.5k tokens · 28 s): **Round 2 — cross-examination** - `Laguna S 2.1#3` Button crumbs emit navigate on click but lack a `type` attribute, yielding `role · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Devstral 2 123B · support 0 - `Laguna S 2.1#1` Pointer-drag ghost is only reparented to hostEl but never restored on non-comple · confirmed: Gemma 4 31B, Devstral 2 123B · refuted: GPT-OSS 120B · support 2 - `Laguna S 2.1#4` Row click handler fires `onSelect` even when the row is a non-interactive `div`, · confirmed: GPT-OSS 120B, Gemma 4 31B, Devstral 2 123B · refuted: — · support 3 - `Laguna S 2.1#6` Arrow-key focus navigation on isolated strip allows ArrowUp/ArrowDown to navigat · confirmed: GPT-OSS 120B, Gemma 4 31B, Devstral 2 123B · refuted: — · support 3 - `Devstral 2 123B#4` Missing validation for `tagOf` function in template · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0 - `Devstral 2 123B#3` Missing validation for `tagOf` function in template · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0 - `Laguna S 2.1#5` Pointer drag `hover` hit-test against the dragged card's own slot can land on th · confirmed: Gemma 4 31B, Devstral 2 123B · refuted: GPT-OSS 120B · support 2 - `Laguna S 2.1#2` Keyboard Escape path calls cancelKeyboard which checks mode !== 'keyboard' but t · confirmed: Gemma 4 31B, Devstral 2 123B · refuted: GPT-OSS 120B · support 2 - `Devstral 2 123B#1` Missing validation for `crumb.to` and `crumb.href` · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0 - `Devstral 2 123B#2` Missing validation for `tagOf` function · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0 - `GPT-OSS 120B#3` Potential missing ARIA attributes for button navigation · confirmed: — · refuted: Gemma 4 31B, Devstral 2 123B, Laguna S 2.1 · support 0 - `Devstral 2 123B#5` Missing validation for `button` styling · confirmed: — · refuted: GPT-OSS 120B, Gemma 4 31B, Laguna S 2.1 · support 0 **Not posted** (support < 2, or contested at a severity where one refutation vetoes) - `Laguna S 2.1#1` Pointer-drag ghost is only reparented to hostEl but never restored on non-comple (support 2, vetoed by a refutation) **Raised but refuted** (left out of the review above) - `Laguna S 2.1#3` Button crumbs emit navigate on click but lack a `type` attribute, yielding `role — The updated breadcrumb template binds `type: 'button'` for button crumbs, so a default `type="submit"` is no longer possible. - `Devstral 2 123B#4` Missing validation for `tagOf` function in template — `tagOf` only returns known tag strings ('NuxtLink', 'a', 'button'); no validation is required and the template uses it correctly. - `Devstral 2 123B#3` Missing validation for `tagOf` function in template — Same reasoning as above; the template safely uses `tagOf` without additional validation. - `Devstral 2 123B#1` Missing validation for `crumb.to` and `crumb.href` — `toOf` gracefully handles undefined `crumb.to`; explicit validation of non‑empty strings is unnecessary. **Synthesis** — Laguna S 2.1 wrote the final review from 4 confirmed findings (+1 below threshold) · promotion: support ≥ 2, and no refutation at high severity. <sub>Transcript `rv-20260924164509-2eb4bb` — full round outputs, web results, and model reasoning are viewable by anyone with access to this repository via the AI gateway.</sub> </details> <sub>Advisory — never a merge gate. Findings are ordered by how well the panel's own evidence checks out, strongest first. React 👍/👎 on any inline comment to tell it whether it was worth flagging — that is the only feedback this system gets, and every threshold in it is tuned from those reactions. Transcript `rv-20260924164509-2eb4bb`.</sub>
@ -0,0 +633,4 @@
}
.tux-dependency-graph__banner-ref:hover {
border-color: color-mix(in srgb, var(--color-error) 50%, transparent);
Member

app/components/TuxDependencyGraph.vue:636 · MEDIUM — Arrow-key focus navigation on isolated strip allows ArrowUp/ArrowDown to navigate the list but the onStripKeydown treats up/down and left/right identically, which is non-standard for a horizontal chip strip
The onStripKeydown handler maps ArrowLeft/ArrowUp to index-1 and ArrowRight/ArrowDown to index+1 for the isolated strip chips. For a horizontally-arranged chip list, the WAI-ARIA convention is that Left/Right move between items and Up/Down are reserved for within-item navigation or have no effect — mirroring ArrowRight/ArrowLeft is a common deviation, but here the handler treats them as identical, which can disorient keyboard users expecting vertical arrows to do nothing or move to a related control. The graph nodes use tuxNearestGraphNode (direction-aware), but the strip does not.

Fix: Restrict the strip to horizontal arrows only (Left/Right), letting Up/Down pass through to the browser for natural tab-order behavior, matching the WAI-ARIA authoring practice for horizontal radio/toolbar patterns.

Proposed replacement (one-click ⚡ Apply on the findings board at the top of this PR):

function onStripKeydown(e: KeyboardEvent, index: number) {
  if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
  e.preventDefault();
  const list = layout.value.isolated;
  const next = list[index + (e.key === "ArrowRight" ? 1 : -1)];
  if (next) stripEls.get(next.id)?.focus();
}

Evidence: strong evidence — two or more reviewers, quote verified against the diff, anchored on a changed line (panel 4/4).
👍 if this was worth flagging · 👎 if it was not — a reaction on this comment is the whole feedback loop.

**`app/components/TuxDependencyGraph.vue:636`** · MEDIUM — Arrow-key focus navigation on isolated strip allows ArrowUp/ArrowDown to navigate the list but the `onStripKeydown` treats up/down and left/right identically, which is non-standard for a horizontal chip strip The `onStripKeydown` handler maps ArrowLeft/ArrowUp to index-1 and ArrowRight/ArrowDown to index+1 for the isolated strip chips. For a horizontally-arranged chip list, the WAI-ARIA convention is that Left/Right move between items and Up/Down are reserved for within-item navigation or have no effect — mirroring ArrowRight/ArrowLeft is a common deviation, but here the handler treats them as identical, which can disorient keyboard users expecting vertical arrows to do nothing or move to a related control. The graph nodes use `tuxNearestGraphNode` (direction-aware), but the strip does not. > **Fix:** Restrict the strip to horizontal arrows only (Left/Right), letting Up/Down pass through to the browser for natural tab-order behavior, matching the WAI-ARIA authoring practice for horizontal radio/toolbar patterns. **Proposed replacement** (one-click ⚡ Apply on the findings board at the top of this PR): ``` function onStripKeydown(e: KeyboardEvent, index: number) { if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; e.preventDefault(); const list = layout.value.isolated; const next = list[index + (e.key === "ArrowRight" ? 1 : -1)]; if (next) stripEls.get(next.id)?.focus(); } ``` <sub>Evidence: **strong evidence** — two or more reviewers, quote verified against the diff, anchored on a changed line (panel 4/4).<br>👍 if this was worth flagging · 👎 if it was not — a reaction on this comment is the whole feedback loop.</sub> <!-- tti-rv:rv-20260924164509-2eb4bb:Laguna S 2.1#6 -->
@ -0,0 +162,4 @@
if ((y - r.top) / r.height <= 0.5) beforeCardId.value = slot.dataset.kanbanCard ?? null;
else {
const next = slot.nextElementSibling as HTMLElement | null;
beforeCardId.value = next?.dataset?.kanbanCard ?? null;
Member

app/components/TuxKanbanBoard.vue:165 · MEDIUM — Pointer drag hover hit-test against the dragged card's own slot can land on the ghost, causing beforeCardId to be set to the moving card's id
When dragging by pointer, hover() calls root.elementFromPoint(x, y) which, because the absolutely-positioned ghost is appended to hostEl and visually overlaps the card's origin, can return the ghost element itself. The code then walks el?.closest("[data-kanban-card]") — but the ghost is a clone of the card and cloneNode(true) preserves data-kanban-card attributes. The guard slot.dataset.kanbanCard === cardId then keeps beforeCardId unchanged rather than resetting it, but in the else branch (where the slot's card differs) the logic reads slot.dataset.kanbanCard which for a ghost-over-lane scenario is the dragged card's own id — the closest [data-kanban-card] ancestor of the ghost is the ghost itself (since the ghost is in hostEl, not in a lane). This means slot could be the ghost, and slot.dataset.kanbanCard === cardId matches, leaving stale beforeCardId from wherever the card came from, rather than resetting to null as the !slot branch intends.

Fix: Exclude cloned ghost elements from the hit-test by checking that the matched card is not the ghost (which carries the same data-kanban-card), or better, query the live DOM card list rather than the hit-test result for the card under the pointer.

Evidence: partly verified — two or more reviewers, quote or anchor verified (panel 3/4).
👍 if this was worth flagging · 👎 if it was not — a reaction on this comment is the whole feedback loop.

**`app/components/TuxKanbanBoard.vue:165`** · MEDIUM — Pointer drag `hover` hit-test against the dragged card's own slot can land on the ghost, causing `beforeCardId` to be set to the moving card's id When dragging by pointer, `hover()` calls `root.elementFromPoint(x, y)` which, because the absolutely-positioned ghost is appended to `hostEl` and visually overlaps the card's origin, can return the ghost element itself. The code then walks `el?.closest("[data-kanban-card]")` — but the ghost is a clone of the card and `cloneNode(true)` preserves `data-kanban-card` attributes. The guard `slot.dataset.kanbanCard === cardId` then keeps `beforeCardId` unchanged rather than resetting it, but in the `else` branch (where the slot's card differs) the logic reads `slot.dataset.kanbanCard` which for a ghost-over-lane scenario is the dragged card's own id — the closest `[data-kanban-card]` ancestor of the ghost is the ghost itself (since the ghost is in `hostEl`, not in a lane). This means `slot` could be the ghost, and `slot.dataset.kanbanCard === cardId` matches, leaving stale `beforeCardId` from wherever the card came from, rather than resetting to `null` as the `!slot` branch intends. > **Fix:** Exclude cloned ghost elements from the hit-test by checking that the matched card is not the ghost (which carries the same `data-kanban-card`), or better, query the live DOM card list rather than the hit-test result for the card under the pointer. <sub>Evidence: **partly verified** — two or more reviewers, quote or anchor verified (panel 3/4).<br>👍 if this was worth flagging · 👎 if it was not — a reaction on this comment is the whole feedback loop.</sub> <!-- tti-rv:rv-20260924164509-2eb4bb:Laguna S 2.1#5 -->
@ -0,0 +194,4 @@
if (!armed) {
if (Math.hypot(e.clientX - startX, e.clientY - startY) < ARM_DISTANCE) return;
armed = true;
fromLane = laneId;
Member

app/components/TuxKanbanBoard.vue:197 · MEDIUM — Keyboard Escape path calls cancelKeyboard which checks mode !== 'keyboard' but the Tab handler calls cancelKeyboard without checking mode, leaving a stale mode for non-keyboard drags
The onCardKey Tab case calls cancelKeyboard() directly, but cancelKeyboard() early-returns when mode.value !== 'keyboard'. During a pointer drag (mode.value === 'pointer'), pressing Tab while a card is lifted via keyboard is not possible, but if a pointer drag is in progress and the user tabs away, cancelKeyboard is a no-op and the pointer drag state (draggingId, overLaneId, etc.) is never reset — the board remains in a --dragging state with no cleanup of the window-level pointer listeners added in beginDrag.

Fix: Make the Tab case always reset state when a drag is active, not just for keyboard mode, by calling a shared reset path.

Evidence: partly verified — two or more reviewers, quote or anchor verified (panel 3/4).
👍 if this was worth flagging · 👎 if it was not — a reaction on this comment is the whole feedback loop.

**`app/components/TuxKanbanBoard.vue:197`** · MEDIUM — Keyboard Escape path calls cancelKeyboard which checks mode !== 'keyboard' but the Tab handler calls cancelKeyboard without checking mode, leaving a stale mode for non-keyboard drags The `onCardKey` `Tab` case calls `cancelKeyboard()` directly, but `cancelKeyboard()` early-returns when `mode.value !== 'keyboard'`. During a pointer drag (`mode.value === 'pointer'`), pressing Tab while a card is lifted via keyboard is not possible, but if a pointer drag is in progress and the user tabs away, `cancelKeyboard` is a no-op and the pointer drag state (`draggingId`, `overLaneId`, etc.) is never reset — the board remains in a `--dragging` state with no cleanup of the window-level pointer listeners added in `beginDrag`. > **Fix:** Make the `Tab` case always reset state when a drag is active, not just for keyboard mode, by calling a shared reset path. <sub>Evidence: **partly verified** — two or more reviewers, quote or anchor verified (panel 3/4).<br>👍 if this was worth flagging · 👎 if it was not — a reaction on this comment is the whole feedback loop.</sub> <!-- tti-rv:rv-20260924164509-2eb4bb:Laguna S 2.1#2 -->
Some checks failed
scan / trivy-fs (push) Failing after 1m6s
ai-review / review (pull_request) Successful in 1m10s
baseline-security / baseline (push) Failing after 1m16s
scan / trivy-fs (pull_request) Failing after 1m6s
baseline-security / baseline (pull_request) Failing after 1m32s
Required
Details
This pull request doesn't have enough approvals yet. 0 of 1 approvals granted.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/work-surface-components:feat/work-surface-components
git switch feat/work-surface-components
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Blocks
You do not have permission to read 1 dependency
Reference
tti/tti-ux!61
No description provided.