feat(frontend): UI overhaul + hermetic Playwright e2e platform#377
Merged
Conversation
Consolidate every empty / loading / error / not-found / error-boundary state off the bespoke StatusCard onto the canonical shadcn Empty (already used by DataTable) and delete components/ui/status-card.tsx. Empty renders borderless and centered, so the wide-viewport "letterbox" look is fixed at the root instead of capping a solid card. Also in this pass: - DataTable: truncate column headers so plain-string headers in narrow fixed-size columns (e.g. "System Prompt") stop wrapping to two lines. - Settings > Providers / API Tokens: move the Create action into the page header (icon-collapses on mobile) and the API-token Playground/Swagger links into a header overflow menu; drop the cramped description row that needed the earlier truncate/flex-wrap workarounds. Those links use a relative href since the API base path is root-relative. - Settings > Prompts: drop the fixed size on the Name column of both tables so it flexes to fill (matching Providers) and truncates. - Settings > Account: keep "Member since" on one line at mobile widths (min-w-0 + truncate, badge shrink-0). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…block On narrow viewports the editor was rendered between the prompt meta/tabs and the "Available variables" panel, splitting one logical block in two and forcing a different composition per breakpoint. Merge them into a single panel block that the desktop split and the mobile stack both render as a unit, so the variables stay attached to the tabs they belong to. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… mobile
In the stacked layout the "Available variables" cloud sat between the tabs and
the editor, pushing ~530px of chips ahead of the content it annotates. The panel
now keeps its slot and its desktop rendering, and below the split breakpoint the
same cloud moves behind a { } trigger, leaving the editor at the top.
The cloud itself is shared by both wrappers; only the wrapper differs (card vs
non-modal popover). The popover must not trap focus: insert and cycle act on the
editor's stored selection, and cycling only helps if its highlight stays visible.
Chips are real buttons now — they were divs with role=button and a hand-rolled
Enter/Space handler that the native element provides for free.
Verified live at 500px: opening keeps the caret, clicking a used variable selects
the next occurrence in view, the popover closes as the editor takes focus back,
and the desktop split is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ts breakpoint Three follow-ups to the variables palette. The chips composed badgeVariants onto a raw <button>, going around the primitive, because Badge renders a div. Badge now accepts asChild through the same Slot switch button.tsx already uses, so a chip is <Badge asChild><button>: the badge keeps owning the styling, the button keeps native semantics, and the hand-rolled role/tabIndex/Enter-Space handler stays gone. className has to stay on Badge — Slot concatenates classes, so only cn() resolves font-normal against badgeVariants' font-semibold. Variables reads useBreakpoint itself rather than taking isDesktop from the page. Every other consumer here does that, sidebar.tsx included, and it keeps the panel droppable into the next page that needs one. The narrow-width trigger is a full-width labelled button now. It carries no chevron and no count badge: those, not the width, are what make a control read as a value select. Also size the popover by --radix-popover-trigger-width, the way autocomplete.tsx does. It was 100vw wide and spilled across the sidebar between 768 and 1280. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The header and its hint were rendered twice below the split breakpoint: once as the trigger label and again at the top of the popover it opened. The header belongs to the wrapper, not to the shared part. On desktop it stays the card header; on narrow widths it becomes the trigger, and the popover now carries only the chips. Title and hint move to constants so the two wrappers cannot drift apart. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The trigger is a compact secondary button carrying just its label and, on the right, the variable count — same count-badge shape the sheet and template headers already use. The badge is outline, not secondary: a secondary badge paints bg-secondary onto a bg-secondary button, which renders the count as bare text. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rides to the split The count badge sat on a bg-secondary button, where every neutral surface token is within 1.08–1.23 contrast of it: secondary painted the pill invisible and outline drew its border in --border, at 1.18. Only --primary separates (2.76), so the badge is default. The tab overrides exist because the desktop panel sits on its own surface. Below the split they fought the shadcn defaults, so they are xl:-scoped now — the same 1280 the layout branches on. Stacked widths get bg-muted with a bg-background active tab again; 1280+ keeps bg-background with a bg-card active tab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The badge default height is 22px — text-xs' 16px line box, py-0.5 and a 1px border — which crowds the 32px trigger. h-5 pins it to 20px; border-box leaves 18px of content for the 16px line box, so nothing clips. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ages `pnpm run prettier` is a required CI step and it exits 1 on these five files at HEAD while passing on them at 001c6c0, so the branch is currently red. Formatting only — no logic touched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Below the split, preventing the popover's open-autofocus left focus on the trigger while the content sat in a body portal, so the first Tab landed in the editor and the second dismissed the popover: the chips could not be reached by keyboard at all. Before the popover existed they were inline tabbable badges, so this was a WCAG 2.1.1 regression. Both autofocus handlers are gone. They were written to protect the editor caret, but ProseMirror keeps its selection across DOM blur, so they were never needed — verified live at 390: opening now focuses the first chip, Escape returns focus to the trigger, and clicking a variable still inserts at the caret the user left, with the editor focused and the popover closed. Also stop the shared chip cloud painting bg-background over the popover: dark --background matches the page behind it, which flattened the popover to a borderline. The two-tone well is the desktop card's business, so the fill moved onto its wrapper, where it still contrasts bg-card. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Below the split the presets card was a 599px block that sat under the fold, past the editor — on the new-template form the editor is empty and the presets are how you fill it, so the primary tool was hidden behind a scroll. The panel now mirrors the prompt variables one: on desktop it stays the card in the split; below 1280 its header becomes a full-width secondary trigger with the preset count, opening a non-modal popover that holds the same preset list. The list is shared by both wrappers; only the wrapper differs. In the stack the trigger takes the presets' desktop slot — after the title, before the editor. Applying a preset from the popover closes it (the popover is controlled), then either fills the form or raises the existing replace-confirm dialog when the form already has content — verified live at 390: fill, replace-confirm, and expanding a preset preview inside the popover all work, and the desktop split is unchanged. presetsList drops its useMemo to take an onApplied callback; typing does not re-render the form (FormField isolates it), so the 11 collapsibles are not on the keystroke path — confirmed by zero slow input events while typing on desktop with all of them mounted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…unt on desktop The variables and presets popovers scrolled the page once their own scroll hit the end — overscroll-behavior defaulted to auto and a non-modal popover doesn't lock the body. overscroll-contain keeps the momentum inside. Reproduced at 375x640: wheeling past the popover's end moved the body 0 -> 204px; with contain it stays at 0. The prompt variables card now shows the variable count on desktop too, matching the preset-count badge the template card already carries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The provider Type combobox rendered as a Button outline — opaque bg-background, a ChevronsUpDown icon and font-medium text — while every select on the knowledge form is a transparent-bg SelectTrigger with a single ChevronDown. Side by side the Type field read as a different control. The combobox trigger now carries bg-transparent, px-3, font-normal and a ChevronDown size-4, matching SelectTrigger; the model combobox's addon chevron follows suit. Measured against the canon at 1440: transparent bg, chevron-down 16px opacity .5, h-9, px-3, weight 400 — identical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s detail forms Template title was the only single-line field with neither a FormLabel nor a FormMessage, so "Title is required" could never surface and the field was unlabeled while knowledge/provider label theirs. It now has both — verified live: submitting empty shows "Title is required". Also: descriptive placeholders on the template title and editor (matching the sentence-style placeholders elsewhere), the Rename dropdown icon to size-4 (its siblings are size-4), and the new-flow intro to the shared block (h2 + gap-2 instead of h1 + mt-2) so every create/edit intro is identical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… loading prop The four detail-page saves rendered two ways: knowledge as AppHeaderAction (a primary CTA that collapses to an icon on mobile), the other three as a secondary FormSubmitButton. FormSubmitButton buys nothing here — all three sit in the header, outside the form's FormProvider, so its useFormContext read returns null and they already submit via form= + a manual loading prop. Its real value is the in-<Form> subscription, which its 8 dialog/auth consumers keep using untouched. AppHeaderAction gains an optional loading (icon->spinner + disable); the three header saves become AppHeaderAction like knowledge, so all four are now the same primary CTA that collapses to an icon-only button (aria-label preserved) below md. type="submit" is explicit because Button defaults type to "button". Verified at 480px: label hidden, 32px icon button, aria-label "Create"; at 1440: primary fill, label shown. Researched the alternatives first: React 19 useFormStatus can't read a form=-associated button (react.dev; react/react#27980) and targets native form actions not RHF; and a Radix Slot asChild compose throws on FormSubmitButton's two-child array and can't inject the spinner past child-wins prop merging. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Test affordance in each agent accordion header was a raw span reimplementing outline-button chrome by hand (rounded border px-2 py-1 + hover:bg-accent + a manual disabled style). It now composes Button asChild variant="outline" size="xs" over that span, so it inherits the same outline chrome as every other action button. It stays a span, not a button: AccordionTrigger already renders a button and a button-in-button is invalid HTML — verified live, zero nested buttons in the DOM. The disabled styling stays hand-rolled because a span can't be :disabled, and the onClick keeps its stopPropagation so clicking Test still doesn't toggle the row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The remove X was a raw button. It is a ghost Button now, sized to the chip's own 26px so the whole chip height is a hit target instead of the bare 14px glyph. Negative margins keep the geometry identical: -my-[5px] and -ml-1 -mr-1.5 shrink the margin box back to the 14px footprint the raw icon had, and [&_svg]:size-3.5 holds the X at 14px against Button's [&_svg]:size-4 descendant rule, which would otherwise win on specificity. Measured against the old markup on the real stylesheet: chip 26px and 107.3px wide, X 14px, X 21px from the chip's right edge — all unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two mechanical cleanups over the same files. <Loader2 className="size-4 animate-spin"> and <Loader2 className="animate-spin"> appeared 28 times across 20 files while Spinner variant="circle" — the same LoaderCircleIcon with animate-spin already baked in — was the idiom elsewhere. Loader2 spinners with a deliberate size (size-3/5/6/10/16) or a colour are left alone: Spinner sets no size of its own and would fall back to lucide's 24px. Separately, 51 icons carried className="size-4" inside a Button, DropdownMenuItem or AppHeaderAction, all of which already force [&_svg]:size-4 on descendants, so the class was a no-op. Only those 51 are stripped — the sites picked by walking each icon's real JSX ancestors through the TypeScript AST, because an indentation heuristic mistakes multi-line opening tags for the parent. The 40 icons with no forcing ancestor (Alert, TabsTrigger, AppHeaderTitle, a resize handle) keep their size-4: without it they would render at lucide's 24px. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…from JSX
The remaining 20 rendered Loader2 spinners carried a deliberate size or colour, so
the earlier pass left them. They convert cleanly after all: Loader2 is a lucide
alias of LoaderCircle (`LoaderCircle as Loader2` in lucide-react's d.ts) and that
is exactly what Spinner variant="circle" renders, while Circle merges an incoming
className over its built-in animate-spin. So each site keeps its size/colour class
and only drops the now-redundant animate-spin. Every rendered spinner in the app is
the Spinner primitive now.
flow-status-icon and flow-task-status-icon keep Loader2: there it is a value in a
Record<StatusType, { icon: LucideIcon }> map, not a rendered spinner, and Spinner
is a variant wrapper rather than a LucideIcon — it does not fit that slot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The size-4 sweep stripped four icons the sweep had no right to touch, and they rendered at lucide's default 24px: the avatar's UserIcon and the theme switcher's Monitor/Sun/Moon in the sidebar user menu. The sweep assumed every listed container sizes its icons at any depth. Only Button/Toggle/CommandItem do, via [&_svg]. SidebarMenuButton and DropdownMenuItem use [&>svg], which reaches a DIRECT child only — and these four sit deeper, inside an AvatarFallback and inside TabsTrigger, so nothing sized them. Found by measuring rendered icon width across every route with the menus open, not by re-reading the selectors: the avatar icon was the only one a closed-menu pass could see. Verified after: no icon inside a button or menu row renders over 16px except EmptyMedia's, which is meant to be large. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ve intends Empty declares `flex-1 … justify-center` (empty.tsx:9), but the list pages wrapped it in a plain `flex flex-col`, so it collapsed to its natural height and hugged the header while api-tokens — same primitive, same "nothing yet + CTA" shape — centred in its `flex-1` wrapper. At 1920 the CTA jumped ~350px between the two pages. The five wrappers on knowledges, templates and flows (loading + empty states) now carry flex-1 like the eight that already did. SidebarInset is `min-h-svh flex-1 flex-col`, so the height is there to fill. Measured at 1920: knowledges and templates now report the same 564 centre / 24px offset as api-tokens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Loading any page — /login included — fetched the `markdown` and `charts` chunks, ~198KB gzip that the page never used. Vite 8 bundles with rolldown, where `manualChunks` is a compatibility shim over code-splitting groups, and a group captures its modules' dependencies as well as the modules themselves. So `charts` (recharts) swallowed clsx and `markdown` (react-markdown) swallowed react/jsx-runtime — both of which every component needs, which dragged the two chunks into the entry graph and onto index.html's modulepreload list. Returning 'react-vendor' from manualChunks for those modules changed nothing: the group that captured them as dependencies won regardless of the order the branches were written in. Use rolldown's own `codeSplitting.groups` instead and rank the shared modules above the heavy libraries that depend on them. /settings/account now fetches 305KB of JS rather than 502KB; /dashboard 378KB rather than 474KB, still loading `charts` because it draws charts. Verified against the production build served locally: 10 routes render, console clean, charts intact, 993 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`cmd/installer/wizard/logger` opens log.json from its init(), so importing the package is enough to create one in the process's working directory. Running `go test ./cmd/installer/...` therefore drops a log.json into every package directory it runs from — four of them were sitting untracked in the tree, one step away from an accidental `git add -A`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The proxy was only declared under `server`, so `vite preview` served the production build with no route to the backend: every /api/v1 call 404'd and the built app could not get past the login screen. That left the production bundle effectively untestable, which is where chunking — and the bug that made every route download recharts — only ever shows up; `vite dev` does not chunk at all. Reuse the dev proxy for preview, on VITE_PORT+100 so it cannot collide with the dev server. Verified: preview serves on 8100 and /api/v1/info returns 200 through the proxy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nothing here"
A backend that was down rendered as a pristine empty account: every dashboard
chart said "No data for this period" and every metric card showed 0, while the
templates and knowledges lists showed their "nothing here yet" empty states —
all indistinguishable from a genuinely empty account, with no toast, no banner
and no other hint that anything had failed.
None of the three pages read `error` from their queries. The templates and
knowledges providers destructured only `{data, loading}` and never exposed it,
and every dashboard query dropped it on the floor. A failed query leaves `data`
undefined, which the empty-state branch cannot tell apart from an empty result.
Expose `error` from the two providers and render a load-error state distinct
from the empty state: a full-page alert on the lists, a banner above the cards
on both dashboard tabs. The settings pages already did exactly this, so the
shared `DataLoadError` gives those call sites one shape instead of four copies.
Templates also gained the loading state it never had — its empty state used to
show while the very first fetch was still in flight.
Verified by failing each query at the network layer and re-running the check
that caught it: templates and knowledges now surface "Error loading ..." rather
than their empty state, and the dashboard shows the banner on both tabs against
a cold cache. Happy path unchanged; 993 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Five copies of the same destructive Alert — same variant, same AlertCircle, same title-and-message shape — sat inline across the settings pages, and the preceding load-error fix had just added four more call sites of it. Use the shared component at all nine, and rename it to ErrorAlert on the way: the name it shipped with an hour ago, DataLoadError, was too narrow for two of the five, which report a failed delete rather than a failed load. Its `message` now also accepts null, because that is what the delete-error call sites already had in hand and what the inline JSX happily rendered as nothing. Left as they are: the prompt-validation dialog swaps variant between success and error and carries a structured body, and the flow-files notice is a default Alert — a warning, not an error. Neither is this shape. No behaviour change. Verified by re-failing the providers and prompts queries and confirming the same alert still renders and the page still recovers; 993 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ying zeros The banner told the user "something failed" but left every card showing its value — a down backend rendered Total Cost as $0 and each chart as "No data for this period", numbers indistinguishable from a genuinely quiet account. And when a single stat query failed while the other eleven succeeded, the banner alarmed over an otherwise-healthy dashboard. Give MetricCard and ChartCard an error state alongside their loading/empty ones — a muted "—" / "Couldn't load", quiet rather than alarming — and wire each of the dashboard's queries to its own card and table. A failed query now says so in its own tile; the rest keep rendering their real data. The top-of-page banner is gone. Verified live: failing one period query leaves that chart as "Couldn't load" while its neighbours draw real bars; a full stats outage fills every card and table with the quiet error state and no lying zeros; the healthy path is unchanged. 993 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…in banner A failed list query showed a one-line destructive Alert stranded at the top of an otherwise-empty page — it read as leftover chrome rather than the page's state. The detail pages and the route error boundary already render a failure as a centered Empty: a large icon, a title, the message. The lists now match. Add ErrorState (the shared Empty-error the detail pages were inlining) and use it for the load errors on templates, knowledges, resources, and the three settings lists, plus the two detail pages that had inlined the same markup — eight call sites, one shape. Resources previously only toasted its load failure from the provider while the page kept showing "No resources yet"; it now shows the error state like the others, and the provider drops the toast (and the now-unused sonner import). The settings mutation-error banners stay on the inline Alert — those sit above rendered content, where a full Empty would be wrong; they're a separate change. Verified live: a failed templates query (GraphQL) and a failed resources fetch (REST) both render the centered Empty error instead of a banner or a false empty state; the healthy paths are unchanged. 993 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…banner A failed delete/create/update on the provider and API-token lists parked a persistent destructive Alert above the table, which lingered until the next action and, for tokens, dumped the raw Postgres constraint text into a banner. Every other mutation in the app reports failure with a toast; these two lists were the exception. Toast them too — the create/update handlers already swallowed the error into a console.error, so this is the first real feedback they give — and drop the banner, its error state (deleteErrorMessage plus the unread useMutation error tuples), and now-unused ErrorAlert component. Verified live: creating a token whose name already exists surfaces "Failed to create token" as a toast (no second token written), with no standing banner left behind. 993 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Empty error state named the failure but left the user stranded — the only way back was a full browser reload. Give ErrorState an optional onRetry that renders a "Try again" button, and wire every load-error site to re-run its own query: templates, knowledges (the active list-or-search query), resources, the three settings lists, and the two settings detail pages. The templates and knowledges providers now expose refetch for it; the rest already had one. Verified live: failing the templates query shows the error with the button, and clicking it after the backend recovers clears the error and renders the list — no page reload. 993 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Nothing enforced the container requirement — only a comment mentioned it. Running the visual project on a developer machine writes `*-visual-darwin.png` next to the linux baselines: new files CI never reads, so it stays red while the repo collects junk and nothing says why. Fail up front with the command to use instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last two uncovered detail tabs. Files turned out not to be REST-backed at all: it runs its own flowFiles query, separate from FlowDocument, so it needed a keyed cassette entry and paths under the manager's uploads/resources prefixes — a file seeded outside those roots renders nowhere. Screenshots reads FlowDocument like the other four; only its image is fetched over REST. That image is the part worth guarding. It mounts behind an intersection observer and loads lazily, so a spec that merely opens the tab never requests it — the first version passed with the cassette entry removed. It now scrolls the card into view and asserts the decoded width, since toBeVisible is satisfied by an element that failed to decode. Removing the entry fails it twice over: zero width, and the unmatched-call gate. Serving those bytes needed the REST mock to stop assuming JSON, so entries can now declare a content type and have their body passed through verbatim. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…violet A used template variable read as green in the Available-variables panel (its "used" badge) but violet in the editor highlight — the same entity, two colours. Swap the editor variable to green and the <xml-tag> highlight to violet so it stays distinct. A pure hue swap: lightness and chroma are untouched, and every token still clears WCAG AA in both themes (tightest is the dark tag at 5.36). Guard the editor palette in cross/contrast.spec.ts alongside the badges, mounting the real .template-* rule so the shipped CSS is measured, not a copy — verified to fail when a token is pushed under AA. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ailure The sticky PR comment took its header from the whole-workflow conclusion, so a visual-snapshot diff — advisory, never a required check — rendered a red ❌ over a "62 passed, 0 failed" mock-tier stats line, reading as a gate failure it isn't. Track the header on the mock gate instead (✅ pass / ❌ real failure) and surface a differing visual job as a separate⚠️ line pointing at the diff artifact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Success" badge and the all-passed accordion badge were assembled from raw utility classes (`text-green-600` on a green tint, no dark override) instead of the shared variant, so they kept the pre-unification shade and render at about 3:1 in light theme — under the 4.5:1 floor every other colored badge now meets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…the stand Four independent ways the signal misled: Adding a label to a PR skips every gate job, yet the report still ran, found no results file and overwrote a correct green comment with a red "no results". It now returns early when the run carries no completed mock job. A run aborted by the global timeout still uploads a results file whose failure count is zero, so the header could go green over a red job; the verdict now requires the mock job's own conclusion too. The report's concurrency key was the branch name alone, so a fork PR opened from a branch named `main` lost its comment to upstream `main` activity. The gate's key split `opened` from `synchronize`, leaving both runs live so the older one could finish last. Stand failure screenshots carried the stand URL and the login into a public artifact — screenshots are now tier-aware like traces and videos already were. Also guard the Tier-2 teardown: the trap is the script's last statement, so a teardown hiccup turned a passing run red. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The probes carry `transition-colors`, so reading the computed background right after hover() sampled a colour still interpolating away from the rest state — the half of the gate that exists to catch hover regressions could not catch one. Disabling the transition on the probes fixes it: verified by giving one variant a deliberately unreadable hover fill, which the assertion now reports as "badge blue on hover" and previously let through. Key the probe list off the variant union so a new badge variant fails to compile until it is probed, rather than silently going unmeasured. Also correct two route-manifest ownerships that misdirect diff-scoping: the flows provider is mounted by the detail route as well as the list, and the provider detail page was attributed to the list route that never renders it. Doc fixes: the route sweep is tagged @cross, not @coverage; the licenses link in CONTRIBUTING resolved outside the repository; and the frontend prettierignore duplicated entries prettier already reads from gitignore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The listing is a `tree`, not a grid, so there is no columnheader for `aria-sort`
to live on — axe rates it a critical `aria-allowed-attr` violation on every
sortable column. The sort state is already announced through the button's label
("Sort by name (ascending)"), so removing the attribute loses nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seven payload-bearing cassette entries answered success no matter what the app
sent: the four flow mutations, both create mutations, and the mkdir POST. Since
each assertion then reads canned cassette data, a wrong payload — a stale closure
deleting flow 6 while flow 5 is open, an unbound name field — repainted exactly
the asserted UI and the suite stayed green. Verified by flipping a pinned flow id
and a pinned path: both specs now fail on the unmatched request, and both passed
before.
Matching had to grow a level for this: `bodySubset` documented a deep subset but
compared nested values whole, so `{ input: { name } }` demanded the entire input.
It now recurses, which is also what makes the API-token pin possible — its `ttl`
is derived from the clock at submit time and drifts a second between runs, so
only the operator-entered name is pinned.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sweep hand-listed six scans while the manifest holds nine routes, so /dashboard, /resources, /settings/prompts and /settings/providers were never scanned in either theme — and since the pixel matrix cannot see a colour change either, those pages had no automated accessibility instrument at all. It now iterates the manifest, so a route added to the sweep is scanned by construction. The four newly covered routes surfaced real debt on the first run. One was a genuine invalid-ARIA bug and is fixed in its own commit; the rest are recorded as named waivers: the dashboard period switcher points aria-controls at a tab panel it never renders, and the resources tree has 80%-muted row metadata under AA plus an expand toggle and row checkboxes under the 24px pointer-target floor. Each waiver names one defect and matches only its own nodes, so the same rule still fires anywhere else on the page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keying the waivers by route path made the spec restate the manifest's sample flow id, so renaming that id would silently unhook the waiver and redden a route over debt that was already accepted. The waivers now live on the manifest entry and travel with it; scanA11y takes the list directly, which also drops the route-keyed map the hand-written sweep needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`toHaveClass(/destructive/)` on the delete confirm matched every shadcn Button — the base class string carries `border-destructive` and `ring-destructive/*` for the invalid state — so the one guard against an action losing its destructive variant, a regression this repo already shipped once, was dead. Anchored on the variant's own `bg-destructive` fill instead. The message-alignment check took `.items-end` first-match anywhere on the page and never asserted the contrasting case; it now binds to the input message and pins the count, so an inverted ternary fails on both. `not.toHaveClass(/dark/)` also holds before the provider has applied any class, so the light half of the theme switch proved nothing; the provider sets an explicit `light`, so assert that. Same shape as the menu-item absence check, which now anchors on an item that stays. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three ways these gates could go green while measuring nothing: The editor probes name their classes by hand — the one place in the contrast spec that does not import them from the app. A rename, or the rule moving out from under its ancestor chain, would mount a span matching no rule, and the measurement would fall back to the page's default text pair, which clears AA. The probes now reject a transparent background, which only an unmatched class produces (the check lives with the editor probes, not in the shared measure — the outline badge legitimately paints no background). The dark sweeps seed a theme key that mirrors a *default parameter value* in the provider, so it can stop matching without any test file changing; the run would then repeat the light theme under a dark label. Both sweeps now assert the theme actually applied. The resources target-size waiver matched `.rounded` unanchored, so it also covered every `rounded-*` utility — a too-broad waiver never fails, it only hides. Anchored to the class it documents. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each sandbox gets a `<container>-data` volume created straight on the docker socket, outside the compose project, so removing the container left the volume behind and every Tier-2 run added one more. Confirmed on this machine: pentagi-terminal-90001-data and -90002-data outlived their containers. Swept with the same anchored 9xxxx filter the container removal uses, so a dev stack's volumes are left alone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ead comments The mock world's matchers carried five explanatory comments and no test. Cover them directly — order-independent variable matching, nested-object subset, sequenced-then-repeated entries, a mismatched REST body staying unmatched, and flag-gated visibility — and delete the prose they replace. The order test is built key-by-key on purpose: a literal would be re-sorted by the linter, which silently defeated the first version (a stripped key-sort still passed until the value was moved inside an array, where stableStringify actually normalises it). Sweep the rest of e2e for comments that only narrate the test or restate the code — step labels, "happy path", cassette descriptions, field docstrings — and remove them. Kept: framework gotchas whose violation is silent (a probe reading a colour mid-transition, react-hook-form's disabled Submit, a pre-ack frame that storms graphql-ws), security notes, magic-value and source-of-truth pointers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fenced code in the editor rendered as one flat grey block with no colour and no visible language, even though the fence language was already captured into the node and round-tripped. Swap the plain StarterKit codeBlock for CodeBlockLowlight (lowlight + highlight.js are already deps; the tiptap wrapper is the one add), keeping the byte-fidelity parseMarkdown/renderMarkdown tuning verbatim — the highlighting is a view-only ProseMirror decoration and never touches the serialized markdown. renderHTML stamps the language onto the `<pre>` as data-language so a CSS caption bar names the block, matching the read-only viewer's atom-one-dark theme. Verified: all 337 existing editor round-trip tests still pass, plus a new one asserting the fence language reaches attrs.language and survives save for ts/python/bash/json/go. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hift it The prev/next stepping control sat first in the header's right-anchored action cluster, so a per-entity action that appears after load or differs between entities (e.g. the flow Report button) pushed the pager sideways — clicking Next repeatedly landed on whatever slid under the cursor. AppHeaderActions now takes a `pager` slot rendered as the cluster's trailing child: its right edge is pinned to the header edge and its width is constant, so variable actions grow leftward without moving it. flow / template / knowledge detail headers route their DetailNavigationToolbar through the slot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The backend already threaded a per-agent `extra_body` map into the LLM request (pconfig.AgentConfig, openai.WithExtraBody), but it was reachable only from YAML provisioning — the GraphQL type, the converter, and the settings form all dropped it. Editing a YAML-provisioned provider through the UI therefore round- tripped its config and silently stripped extra_body. Add `extraBody: Map` to AgentConfig / AgentConfigInput, carry it through the converter in both directions, and give each agent an "Extra Body" JSON field in the provider form (validated as an object, serialized on load, parsed on save). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Guards the two-way mapping added with the extra_body field: a JSON object survives GraphQL→pconfig→GraphQL intact, and an absent one stays nil rather than becoming an empty map. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keep only the two invisible contracts — pre defers its colours to .hljs, and data-language is DOM-only and never serialized — and drop the prose that restated the CSS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gate subscribed to `labeled` so applying `e2e:stand` could start the stand job, but every gate job carried `if: github.event.action != 'labeled'`. A label event then ran with e2e-mock skipped, and GitHub counts a skipped required check as satisfied on the same head SHA — so adding any label to a PR whose e2e-mock was red published a merge-permitting check over the red one. Move the stand tier into its own workflow (e2e-stand.yml) that owns the `labeled` trigger; e2e.yml now fires only on real code events (opened / synchronize / reopened) and its gate jobs never skip. The stand workflow keeps the protected environment + reviewer gate, and uploads results.json only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The entry answered HTTP 200 with `{error, status}`, but the backend returns 401
with `{status, code, msg}` (develop mode adds the `error` key). The two take
different app branches: the 200 shape resolves and shows "Invalid login or
password"; the real 401 is caught by the axios interceptor, which clears the
auth key and rejects, so the form shows "Login failed. Please try again." — the
branch a real wrong password takes, which no spec exercised.
Serve 401 with the real envelope, pin the submitted credentials with bodySubset,
and assert the message the 401 path actually produces.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The knowledge list query is withContent:false, where the backend returns an empty content string; the entry served "Content for …". - The generator and refiner prompt pairs were inverted vs the backend (System/Human swapped), teaching the wrong contract on two of fifteen agents. - The GraphQL mock dropped an entry's data when it also carried errors, so a partial-error response could not be mocked with its data half. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The seeded /info returned privileges:[] with no OAuth providers — a state the backend never returns. Every privilege-gated control (e.g. the knowledge Anonymize action) and the login page's OAuth buttons rendered in their denied variant in every spec and tier, so a regression there shipped unseen. Seed the admin privilege list and the OAuth providers (authenticated and guest), assert the login page now renders its OAuth buttons, and refresh the /flows/5 baseline, which now shows the flow controls in their real admin state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
Frontend feature line: the reworked UI (flows streaming/reconnect, entity CRUD,
settings, editors) plus the new end-to-end testing platform.
E2E platform (new)
cassette-driven mock of the whole API surface (GraphQL over HTTP,
graphql-transport-ws subscriptions, REST) — no backend, keys, or secrets
needed, fork PRs can run it.
flow lifecycle, CRUD (templates/knowledges/api-tokens), themes, responsive
(touch profile), a11y with a committed policy, terminal via the xterm buffer.
tsc, not the runtime.e2e.yml(read-only, no secrets) +e2e-report.yml(workflow_run sticky comment). Note: the report workflow arms only after
this lands on the default branch.
frontend/docs/e2e.md(bootstrap, tiers, debugging a red run).Draft status
Opened as a draft to shake out the CI gate on real runners before review:
watching the first
e2e-mockruns for artifact wiring and runner-timing flake.🤖 Generated with Claude Code