Repository navigation
feat(manual): manual test management, with a themeable token layer and brand mark - #100
Merged
Merged
Conversation
First vertical slice of the manual test management UI: the test case list and detail screens, plus the four shared components the remaining screens will reuse. ManualStepEditor renders a case's steps, distinguishing a literal step from an inclusion of a shared step - the latter's own text is ignored, so showing them identically would mislead an author about which content they control here. It also renders read-only, which is how a frozen version and (later) a run's execution view display their steps. Order is managed with explicit move buttons rather than drag-and-drop: keyboard-navigable, no new dependency. CustomFieldForm generates inputs from the team's field definitions, so an admin adding a field makes it appear with no UI change. An archived field is rendered only when the case already holds a value for one: archived fields are on their way out and should not be offered for new input, but an existing value still has to be visible. ChangeHistory shows who changed what, including the two things the version list cannot express - a status change that burns no version, and a case re-versioned because somebody edited a shared step it includes, which names the shared step as the cause. VersionBadge marks which version is on screen and how far behind latest, since "v3" alone does not tell a QA whether what they are reading is current. The detail page fetches history only when its tab is opened - it is paginated and unbounded, and most visits are to read or edit rather than audit. After a save the version list is refetched rather than assumed to have moved, because only a content change burns a version. Creating a test case makes an empty draft and opens it, rather than prompting for a title: authoring is incremental and a draft need not be complete. Adds 84 translation keys to all four language files, and registers the new styles in index.less. No hard-coded colours - all semantic tokens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…trics Completes the filtering half of the manual test case feature: manual runs already appeared in the dashboards, but nothing could narrow to one type. - Shared ALL_EXECUTION_TYPES sentinel, toExecutionTypeParam and getExecutionTypeOptions in GeneralUtilities, so both pages share one definition rather than duplicating the mapping - Execution-type SelectPicker on DashboardPage and MetricsPage - 5 translation keys across en/cn/nl/th The picker needs a concrete value for "no filter" but the API takes the absence of the parameter to mean both, so the sentinel is mapped back to undefined before the request. Defaulting to All keeps exactly the pre-3.0 behaviour. Also moves off the npm-linked client onto the published 3.0.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes phase 8: the five outstanding routes and the two shared components the plan called for. Routes - shared-steps: library, editor, usage drawer and history drawer - manual-test-runs: run list with status filter and a creation wizard - manual-test-runs/[runId]: the execution view - manual-test-cases/[caseId]/version/[version]: frozen version plus diff - admin/custom-fields: field configuration, beside admin/settings Components - attachment-upload: drop zone, thumbnails, markdown reference copy - version-diff: field, custom field and positional step comparison The execution view renders the bound version throughout, never the live case. The version badge shows when that version has fallen behind, linking to the frozen view; rebind is offered only for an entry with no result yet, since the API rejects re-pointing a recorded execution. Editing a shared step re-versions every case that includes it, so the editor looks up usage first and warns with the count before the author commits. A case result defaults to the worst step outcome rather than making the tester state it twice, and stays overridable. Nav groups the three manual screens under a "Manual testing" submenu now that there is more than one, and custom fields joins the admin menu. 154 translation keys across en/cn/nl/th (602 -> 756 each). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s pages Both pages read ALL_EXECUTION_TYPES to map "no filter" back to the picker's value, but neither imported it - the extraction into GeneralUtilities removed the local const and only carried over the two functions. The dashboard is the home page, so it threw ReferenceError on load and the page never rendered. Not caught earlier because next build resolves an unknown identifier as a runtime global rather than failing, and the route still returns 200 - the error surfaces only when the client bundle executes in the browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The step editor holds action: '' for the disabled input on a shared step row, and the draft was sent to the API verbatim. An inclusion has no action of its own, so the empty string is now stripped from the payload rather than sent as content. The API rejected it, which is what surfaced as the duplicate key error when saving a test case that included a shared step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Saving gave only a transient corner toast, rendered without an icon and not dismissible - easy to miss entirely, and it could not say what the save actually did. - An inline banner beside the Save button reports the outcome and stays until the next edit, alongside the toast rather than replacing it. - Success distinguishes the two real outcomes: "Saved as version 4" versus "Saved. The content did not change, so no new version was created." Both are successes but they mean different things to whoever reads the history later, and only a content change burns a version. - Failure shows the API's own message - which names the offending step or custom field - instead of a generic "could not save". - Editing any field clears the banner, so it never describes a state the form no longer reflects. Shared steps and custom fields had no success feedback at all; both now confirm, and the shared step save reports a cascade count when editing re-versioned other test cases. All 12 manual-feature toasts are now showIcon + closable, matching what the Message component needs to be visible and dismissible. Adds .app-alert-success to the shared alert set rather than a page-level rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a folder sidebar to the manual test case list, nesting to any depth, so cases can be filed by component, feature or any structure a team prefers. - FolderTree renders the nested tree the API returns, with a per-folder case count. Add / rename / delete sit on each row and appear on hover, so a deep tree reads as structure rather than a wall of buttons. - Two synthetic rows above the real folders: "All test cases" and "Not in a folder". Cases that have never been filed must stay reachable, and a tree with no way back to everything is a trap. - Selecting a folder shows everything beneath it, not just its direct children - otherwise a parent looks empty while its sub-folders hold the cases. "Not in a folder" is deliberately exact. - Adding a sub-folder is the + on the folder it nests under; the dialog names that parent so the depth being added is never ambiguous. - The detail page gains a folder picker - a flat indented list rather than a tree widget, since a case belongs to exactly one folder. Clearing it files the case back at the team root. Deleting is refused by the API while a folder holds anything; the toast shows its message, which names the counts. 20 translation keys across en/cn/nl/th (761 -> 781). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The folder work was developed against a locally linked client. Moving to the published 3.1.1 takes angles-ui off the npm link, so a clean checkout resolves the same client as CI rather than depending on a sibling working copy. No source changes: 3.1.1 is 3.1.0 plus the release workflow's version bump, and every symbol the UI imports was already present. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes to how steps are authored. Action and expected on one row - A step is a single "do this, expect that" pair, so stacking the two inputs made a ten-step case scroll for no reason. They now sit side by side under one set of column headings, rather than each carrying its own label. - The read-only view pairs them the same way, so a frozen version reads how the editor looked when it was written. - Below 768px the pair stacks: two textareas in half a phone width each is worse than scrolling. Attachments - There was no way to attach an image from the UI at all. Each literal step now has an attachment toggle opening the existing AttachmentUpload. - Collapsed by default, and auto-opened when a step already has attachments: most steps have none, and a drop zone on every row would bury the text. - The editor takes an `attachmentOwner` rather than a test case id, so shared steps get attachments too - the API already accepts sharedStepId as an owner. - An unsaved entity has nothing to key uploads on, so that case explains itself rather than failing on upload. Drag to reorder - Replaces the up/down arrows. Uses the native HTML5 drag API rather than a library: this is a single flat column, which the native API handles well, and it avoids a dependency for one interaction. - Remove-and-reinsert rather than swapping neighbours, so dragging across several positions lands where the drop marker showed. - The dragged row fades and the target shows a top border, so the marker reads as a position between rows rather than another selected row. 3 translation keys added, 2 removed (the arrow labels lost their consumer). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…edentials Two bugs made step attachments unusable. Saving failed with "Cast to ObjectId failed for value [object Object]" - The editor holds whole attachment documents so it can render thumbnails, but the API stores references. The save payload sent the objects straight through, and mongoose tried to cast one to an ObjectId. - Both the test case and shared step payloads now map attachments to ids. - AttachmentUpload also accepts either shape on the way in: a freshly uploaded attachment is a whole document, while a case read back gives id strings, and the list has to render both. Thumbnails showed as broken images - A plain <img src> is a separate browser request that carries no credentials to another origin, so it came back 401 - the API and UI are not the same origin in any real deployment. - Thumbnails now load through axios (configured with withCredentials) into a blob URL, with the object URL revoked on unmount so a re-rendering list does not leak one per image. - Opening the full size image had the same problem and takes the same route. - Screenshots avoid this by embedding base64 in their JSON, but an attachment list would then carry every image in full on every read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…filing
The case list was one flat table, so the folder tree beside it claimed a
folder held twelve cases while the list gave no sign of where anything
lived. Cases are now grouped into one table per folder, mirroring the
tree, with collapsible sections and a breadcrumb heading ("Checkout /
Refunds") - a bare leaf name is ambiguous once two folders share one.
Filing a case previously meant opening it and changing a form field. A
case can now be dragged onto a folder, either in the tree or onto another
section's header. The tree accepts drops as well as the sections because
it reaches folders that have no cases on the current page and therefore
no section to aim at.
Moving uses the existing bulk-move endpoint, which records the change in
the history without burning a new version - filing is organisation, not
content, and re-filing a case must not leave every bound execution
pointing at a stale version.
CASE_MIME lives in its own module: both the tree and the suite list need
it, and importing it from either would make the two require each other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Picks up the attachment URL fix. 3.1.1 concatenated the configured baseURL straight onto the path, so with the ".../rest/api/v1.0" this app sets (no trailing slash) every thumbnail resolved to ".../v1.0attachment/ <id>/thumbnail" and returned 404 for an image that had uploaded fine. Verified through the installed package: the URL it now builds returns 401 against a running API rather than 404, so the request reaches the route and only the session cookie is outstanding, which the authenticated fetch in AttachmentUpload supplies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A run of UI fixes across the manual test case management pages, plus the vendor overrides they exposed. Custom field key validation - The API rejects a key that is not lowercase-letter-first, alphanumeric with underscores, max 40 - but nothing said so until the save failed. The key input now normalises as you type, Save is disabled while the key is invalid, and the help text explains the rule in place. - The page used a bare <div className="page-panel">, which has no padding of its own - that comes from RSuite's Panel - so it sat flush against its edges while every sibling page was inset. Now a Panel. Panel actions are icons - The add buttons sat in an awkward band above each panel because .page-panel-header was typography only, with no layout. It is now a flex row, so the action sits on the header's baseline opposite the title, and the same fix lands on every page that uses it. - Each is an icon button with a tooltip and an aria-label, and each page gets a distinct glyph - task, play, combination, member, table column - so the pages are not five copies of one plus sign. An overlapped icon-plus-badge was tried first and abandoned: at 18px it was mud. - The save action on the test case detail page joins them, so its header is three uniform icons rather than two icons and a button. Row action links - .link-action / .link-danger only set a colour, so the ones rendered as <button> came out with native control chrome - grey boxes in the middle of four tables. The shared classes now strip it. Users table - Fixed pixel widths with no flexGrow capped it around 850px inside a full-width panel. Now flexGrow columns with autoHeight, matching the other tables. Dark mode: one root cause, four symptoms RSuite ships its theme variables as ":root, .rs-theme-light" with the dark values under ".rs-theme-dark". This app themes via [data-theme] on :root and never sets either class, so RSuite's dark values are inert and its light values apply in both modes. That produced, separately reported: - subtle icon buttons in a near-black --rs-gray-800, invisible on a dark panel; - a near-white hover tint on table body rows, which also implied a row click that does not exist - these tables are not row-clickable; - the same white bleeding around header cells on hover, from --rs-bg-card behind our themed cell content; - blue tab labels and a blue active-tab underline, from --rs-navs-selected. The existing .tabs-container rules only covered .rs-nav-tabs, while these render .rs-nav-subtle. Each is fixed by pointing the RSuite variable at the app's own semantic token, which already themes at the primitive layer - so none of them needs a dark-mode override block. Subtle icon buttons also now take the ember accent on hover and while pressed, landing on exactly the colour .link-action uses, so an icon action highlights like every other action in the app. The hover surface stays the sub-panel tone: a filled orange block at that size overwhelmed the glyph. Their focus ring is overridden too - RSuite hard-codes that one as a blue box-shadow, out of reach of the variables. 1 translation key added across en/cn/nl/th. Verified by computed-style probes and headless renders in both themes rather than in the running app: `next build` fails on this branch for a pre-existing Turbopack/webpack config reason, unrelated to these changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a Feature Management panel to the admin settings page, and hides the manual testing navigation and pages when an admin turns the feature off. The toggle arrives with the auth config, which is fetched before login, so a feature counts as off only when the API says so. While the config is loading, and if it fails to load entirely, a feature is treated as enabled - otherwise a slow or failed request would make the app look like an admin had disabled half of it. The guard is applied through a layout per route segment rather than per page, so nested routes (a case, a version, a run) are covered without each page having to remember to guard itself. A disabled page explains itself instead of redirecting, so a bookmarked or shared link says why it no longer works. This is presentation only. The API rejects the underlying routes independently, which is what actually makes the feature inaccessible. Feature Management gets its own panel because the existing one is titled Authentication Settings and saves against a different endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RSuite drives every widget through its own `--rs-*` custom properties and
ships a full dark palette for them under `.rs-theme-dark`. This app never
sets that class — it themes via `[data-theme]` on :root — so any variable
left unmapped kept rsuite's *light* default in every theme: `--rs-bg-card`
stayed #fff, `--rs-text-primary` stayed near-black, `--rs-state-hover-bg`
stayed #f2faff. Widgets nobody had hand-patched yet were unreadable on a
dark surface.
Remap rsuite's variables to the Angles semantic tokens on :root, growing
that block from 6 variables to ~120. Because the tokens theme at the
primitive layer, one mapping fixes every theme at once and covers widgets
that were never styled by hand — Drawer and Popover both resolve from
`--rs-bg-overlay`, and Divider, Timeline and Breadcrumb from the text and
border tokens. This replaces per-class patching rather than adding to it:
the modal title/body/close, form label and help text, and the colour half
of `.rs-input` rules are all removed as redundant, and `--rs-navs-selected`
moves out of `.rs-nav-subtle` so every nav gets it.
Message needs a rule as well as variables: rsuite paints the outer
`.rs-message-{variant}` a literal #fff with no variable behind it, and only
draws the variant's accent border when the `bordered` prop is set, which
this app never passes. Clear that fill, let the container own the surface,
and carry the status hue as a left stripe, matching `.app-alert`.
Close the token gaps this exposed:
- Add `--modal-overlay-background` for the dialog scrim.
- Move the screenshot-view magenta/orange to `--overlay-*` tokens. These
stay outside Ember & Sand and do not theme by design — they annotate
user screenshots, not app surfaces.
- Move the chart categorical palette to `--chart-series-1..10` with
lightened dark variants, so it follows the theme like STATUS_COLORS
already did. Amber and lime are a step darker than their siblings to
clear 3:1 on the pale panel.
- Delete `--master-icon-color`, a fixed saturated blue that belonged to no
palette and had no dark value. Actionable icons now take the ember
accent; `--info-color` takes the skipped-status hue.
- Add `--master-color-muted` for inert chrome. Disabled icons need their
own primitive: no existing one stays dimmer than the accent in both
themes, and reusing the secondary text tone made disabled icons brighter
than enabled ones on dark.
Verified by rendering real rsuite components against the compiled CSS in
both themes. That caught two things static checks missed: Progress bars
were still rsuite blue (`--rs-progress-*` was unmapped), and Messages
showed a dark band where the container did not fill the cleared outer
element.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding a theme should be filling in a short list, not auditing 80 tokens. It was closer to the latter: the primitives a theme owes were implicit in whatever `.theme-dark-primitives()` happened to set, and several tokens were hand-written literals in *both* themes, so a new theme had to restate each one and could silently get it wrong. A missed primitive does not fail loudly — the token keeps the previous theme's value — which is exactly how a half-applied theme happens. Add `tokens/_theme-contract.less`: documentation only, no rules. It names the 14 required primitives grouped by the decision each represents, lists what is derived (and so must not be restated), states the contrast obligations a theme owes, records what is deliberately theme-independent, and carries a template to copy. Derive what was previously duplicated per theme, in a block at the end of `_color.less`: --border-subtle ink channel at 0.12 alpha, inverts with polarity --focus-ring the theme's accent at 0.45 alpha --modal-overlay-background black wash, deepened on dark themes --card-shadow fades to none as --theme-is-dark goes 0 -> 1 --card-border-color fades in as --theme-is-dark goes 0 -> 1 This needs two new primitives — `--theme-ink-rgb` and `--theme-is-dark` for polarity, plus `--accent-rgb` alongside the accent, since the channels cannot be extracted from an hsl()/hex value without `color-mix()`, which the browserslist targets (Chrome 109, iOS Safari 11) predate. Elevation stops branching on the selector: `--card-shadow` was set only in the dark mixin and relied on `_elevation.less` having defined it first. Both halves of the pair now fade on the polarity flag, so they cannot drift apart. `_elevation.less` keeps the raw ramp, which is theme-independent. The chart ramps move to `.chart-series-light()` / `.chart-series-dark()`. They are a property of the polarity, not of Ember & Sand: any dark theme wants the lifted ramp, so a new theme picks one instead of authoring ten hues. Behaviour is unchanged. Verified by resolving every custom property in a browser before and after, in both themes: 1 of 137 differs — the dark focus ring moves 0.5 -> 0.45 alpha, now consistent with light. Also verified the contract is sufficient by defining a new theme from only the 14 required primitives: 136 of 137 semantic tokens resolve (the 137th, --kpi-accent, is scoped per-card by design), and the checklist correctly flagged two contrast failures in that draft theme. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Restructure the dark theme into a registry entry, and fix the guard that
would have broken every theme added after the third.
The OS-preference fallback read `:root:not([data-theme='light'])` — "apply
Ember Dark unless the user explicitly chose light". That is correct only
while `light` and `dark` are the only values. A user on any third theme
also matches "not light", so on a machine set to dark mode they would get
Ember Dark's primitives layered over their chosen theme: near-black panels
under sepia's warm text. Specificity does not save it either, since
`[data-theme="sepia"]` and `:not([data-theme='light'])` tie, and the media
block comes later in the file.
The guard now keys on the *absence* of the attribute, `:not([data-theme])`,
so any explicit choice stays authoritative.
Rename `.theme-dark-primitives()` to `.theme-ember-dark()` — themes are now
named by family — and register it as one selector per theme:
:root[data-theme='dark'],
:root[data-theme='ember-dark'] { .theme-ember-dark(); }
`light` and `dark` are kept as aliases deliberately: they are already
stored in users' `theme` cookies, so dropping them would silently reset
everyone's preference. It stays a LESS mixin rather than a bare rule set
because it applies at two selectors (the explicit choice and the OS
fallback) and LESS cannot alias one rule set to two selectors without
duplicating it.
Update `.agents/AGENTS.md` §3 and §9 to match: point at the contract as the
spec, state the guard rule and why, and tell authors to prefer *deriving* a
theme-dependent token over adding a per-theme value, since a derived token
costs every future theme nothing.
No token values change: resolving all 137 custom properties in a browser
before and after, in both themes, reports 0 differences. The guard fix is
verified against a 7-case matrix (no choice / light / dark / alias / third
theme, crossed with OS light and dark) run against both the old and new
guard — the old one fails the third-theme-under-OS-dark case, the new one
passes all seven.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The picker hardcoded two `Nav.Item`s calling `setTheme('light'|'dark')`,
and the cookie was written to `data-theme` unvalidated. Both block adding
themes: the menu cannot grow without editing JSX, and a cookie naming a
theme that was later renamed or removed would set an attribute matching no
selector — leaving the page with no theme's primitives at all rather than
falling back.
Add `src/utility/Themes.js` as the single source of truth: the themes that
exist, their polarity, and their label ids. `applyTheme()` validates
against it and removes `data-theme` for an unknown id, so a stale cookie
degrades to the OS preference instead of a dead value. `providers.js` and
`Shell.js` both go through it.
Render the menu from the registry, grouped into Light / Dark sections with
headings. Two themes do not need grouping, but six will, and the grouping
is what makes the list scannable once step 6 lands. The headings are
labels, not options: no hover, no pointer events, muted and uppercased so
they read as structure. Group order comes from the polarity list, so a new
theme appears in the right section by virtue of its registry entry.
Adding a theme is now: a primitives block in `_color.less`, an entry here,
and a label string per locale.
Two fixes found by driving the real app rather than the harness:
- Right-hand navbar menus are placed `bottom-start` by rsuite (anchored to
the toggle's LEFT edge), so a menu wider than its toggle grows off-screen.
The theme picker is the first one wide enough to hit this — it overflowed
by 3px, and would have been far worse with names like "Slate Light".
Re-anchor `.rs-navbar-right` menus to the toggle's right edge.
- `nav.theme.group.light` / `.dark` are added to all FIVE locale files.
`hi.json` exists but is not listed in AGENTS.md §10, which says four.
Verified end to end against the running app: the menu renders grouped,
clicking a theme updates `data-theme`, the cookie and the painted
background together, the choice survives a reload, and a cookie naming a
removed theme clears `data-theme` and falls back. Registry logic covered by
11 assertions over validation, grouping and apply/clear behaviour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four new themes, each a primitives block satisfying the contract plus a
registry entry and a label per locale — no semantic tokens, no derived
tokens, no component CSS touched. That the additions are this small is the
payoff from steps 3 and 4.
Slate Light cool neutral, white sub-panels, blue accent — for people who
find the warm palette distracting
Sepia warm paper tones, terracotta accent, low glare for long reads
Slate Dark the cool counterpart to Ember Dark, sky accent
Midnight near-black, pure neutral greys, amber the only chrome colour
All four satisfy every contrast obligation in `_theme-contract.less` §3
with zero waivers, verified by resolving the compiled CSS in a browser:
text 10.9–19.4:1, on-accent 5.3–11.5:1, borders and status colours all
clear 3:1. They are measurably better than the Ember pair, which needed
`--master-color-2-relative` at 43% rather than 42% in Slate Dark to clear
3:1 — the checklist caught that at 2.99:1.
Two contract corrections came out of writing them:
- The ORDERING rule was stated as "muted must be dimmer than the accent",
which silently assumed a dark theme. Prominence is distance from the
panel, so the direction flips: on a light theme muted must be LIGHTER
than the accent. Checking luminance unconditionally would fail every
light theme for the wrong reason.
- §3's known-shortfall list was incomplete. Measuring the shipped themes
found on-accent text at 2.46:1 light / 1.80:1 dark — cream on ember, the
colour of every primary button label including Login. Recorded rather
than changed, since fixing it means dark text on the brand orange. Ember
Light also fails the ordering rule as a consequence: the accent is light
enough (luminance 0.36) that no muted grey can be both lighter than it
and reach 3:1 on the pale panel. Slate Light and Sepia both satisfy the
rule, so it is the palette that is constrained, not the rule.
Verified in the running app: all six themes apply under an OS dark
preference with the explicit choice winning — the case the step-4 guard was
written for, now exercised with six themes rather than a hypothetical
third — and the picker lists all six grouped, still inside the viewport
with the longer names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four more themes, taking the picker to ten (five light, five dark). Each is
a primitives block plus a registry entry plus a label per locale — the
contract's shape held, so nothing else changed.
Forest Light / Dark Muted sage and deep pine with green accents. The
only green in the set and the furthest from the
existing Ember, Slate and Sepia families.
High Contrast L / D Pure white and true black grounds for low vision
and bright sunlight. Every pairing clears AAA
(7:1) rather than the AA the contract requires,
with text at 21:1 — the theoretical maximum.
Chosen over the teal and violet options that were also on the table: teal
sits close enough to Slate to add little separation, and High Contrast
serves an accessibility need rather than a preference.
High Contrast Light deliberately uses pure white for BOTH panel and
sub-panel. Surfaces there are separated by borders rather than by tone,
which is what keeps every text pairing at maximum contrast; the contract
does not require the two surfaces to differ.
All four clear every contrast obligation with zero waivers. The checklist
caught two Forest Light values while drafting — muted at 2.91:1 and the
error hue at 2.91:1, both against a sage panel lighter than Sepia's — fixed
by darkening each one step.
Verified in the running app: all ten themes apply under an OS dark
preference with the explicit choice winning, and the picker lists all ten
grouped, still inside the viewport with "High Contrast Light" as the
longest label yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`hi.json` had 392 of 804 keys — 432 missing. The bulk was the manual test case feature set (`app.manual` 110 keys, the five `page.manual-*` areas 95, `page.admin` 104), so those screens rendered raw dotted ids for Hindi users. It also carried 20 stale keys for an Okta provider setting that no longer exists. Translate all 432 missing keys and drop the 20 stale ones. Dutch, Chinese and Thai were already complete. The rule in AGENTS.md §10 is why this happened: it named four specific files, and `hi.json` was added afterwards without the rule being updated. Rewrite it to derive the language list from the directory instead of hardcoding one, state that adding a language means translating every key already in `en.json` rather than only the current feature's, and require ICU placeholders to survive translation. Add `npm run check-translations` to enforce it. It fails when a catalogue is missing keys, carries stale ones, drops an ICU argument, or when a `FormattedMessage` id used in the code has no entry in `en.json`. The language list comes from the directory, so a new catalogue is covered with no change to the script or the document. That last check found a real bug: BaselineImageView referenced `ccommon.component.…` rather than `common.component.…`, so a baseline error message rendered as a raw id in every language. Fixed. Plural categories are deliberately not compared — Thai and Chinese have no grammatical plural and correctly use `other` alone, so only the argument names are required to match. Verified: all four catalogues report complete, no locale drops a placeholder, and the check exits 1 when a key is removed, a stale key added or a placeholder broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems, both confirmed by measurement rather than impression. THE LIGHT THEMES WERE ALL WHITE. Every light panel sat between 92% and 100% lightness — an 8-point spread, four of five above 93%. Ember Light and Slate Light measured 1.00:1 against each other: identical in lightness, differing only in tint. Push them apart to a 22-point spread: Contrast Light 100% Slate 93% Ember 92% Sepia 89% Forest 84% Clay 78% Darkening the panels broke contrast for values tuned against near-white, so muted, error and skipped are re-derived per theme, and the categorical chart ramp is a step deeper — one ramp that now clears 3:1 across the whole 78–100% range rather than only the top of it. STATUS COLOUR WAS UNUSABLE WITH COLOUR VISION DEFICIENCY. Simulating the shipped palette (Brettel/Viénot) puts PASS and FAIL at 1.18:1 under deuteranopia — effectively identical, against a 3:1 bar, on a page whose entire job is telling passes from failures. Protanopia and tritanopia are worse, with pairs at 1.00:1. Re-picking hues does not fix this, and the commit does not pretend otherwise: an optimisation sweep over hue and lightness, including the Okabe-Ito palette designed for dichromats, tops out near 1.2:1 on its closest pair. Four categories cannot be separated by colour alone. So the new Clay pair encodes status twice — Okabe-Ito hues (blue / vermillion / amber / purple, with no green-versus-red pair anywhere) plus a symbol before each label: ✓ PASS, ✗ FAIL, ! ERROR, – SKIPPED. The symbol is what actually carries the distinction; the colour reinforces it for everyone else. It also survives greyscale printing. The symbols are scoped to the two Clay themes rather than applied globally, since the other ten were signed off on their current appearance, and are `aria-hidden` — the visible label already carries the meaning for a screen reader. Each mode now offers six themes rather than five: the accessible option serves a need the others cannot, so it is additive rather than competing for a slot. All twelve pass every contract obligation; the picker still fits with no overflow and 335px of headroom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The logo stayed orange on every theme. Transparency was not the problem — the PNGs already have an alpha channel — the problem is hue: measured against the logo's orange (25°), six of the twelve accents now sit 125–177° away. On Slate, Forest, Clay and Contrast the wordmark was the only orange element on the page, reading as a stray asset rather than a brand anchor. Replace the `angles-icon.png` / `angles-text-logo.png` pair with an inline SVG component whose colour comes from tokens, so a theme needs no new asset and a future theme is covered automatically. One file replaces what would otherwise have been a per-theme export. The cube's three faces are the accent at three lighting levels rather than three separate tokens: `--master-font-color-2` was tried for the left face and is near-black on Slate Dark, which turned one face into a hole. Fading the accent keeps the shading consistent on any ground. The two accessible themes render the mark in a single tone via currentColor. On Clay the accent is the same Okabe-Ito blue as PASS, and a two-tone logo competes with the status colours it should stay clear of. Two bugs found by running it rather than trusting the markup: - `BrandLogo.defaultProps` was silently ignored, so `showText` came through undefined and the wordmark never rendered — the login page showed a bare cube. defaultProps is deprecated for function components; the defaults are now parameter defaults. This also restored the missing aria-label. - `.login-header` had a fixed 60px height sized for the old PNG pair, which clipped the taller SVG wordmark. The PNGs are left in place: `angles-logo.png` and `angles-header.png` are still referenced elsewhere, and the icon remains the favicon source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SVG wordmark inherited the app's system font stack, which read as default UI text rather than a brand mark and lost the character of the PNG it replaced — a condensed geometric sans with a heavy dark outline. Set it in Oswald, the closest of the candidates in proportion to the original's tall narrow letterforms, and restore the outline. Both were picked by rendering the alternatives beside the original PNG rather than from font names. Loaded through `next/font/google`, which downloads and self-hosts the file at build time: no runtime request to Google, so no third-party dependency on the critical path, nothing to allow through a CSP and no request carrying user IPs to a third party. Verified zero `fonts.g*` requests in the running app. One weight (600), latin subset only. Scoped to `.brand-wordmark` via a `--font-brand` variable, so body text keeps the system stack — AGENTS.md §4 forbids new font families for UI text, and this is the mark, not UI. The fallback is a condensed system face so a font failure degrades to roughly the right proportions. The outline uses `paint-order: stroke fill` so the stroke sits behind the glyph and thickens its edge rather than eating into it, and is 1.4 rather than the 2.5 first tried — a centred stroke spends half its width inside the letter, and at 2.5 the counters in 'g' and 'e' closed up at sidebar size. Because it takes `currentColor` it reads as a dark outline on light themes and a light halo on dark ones, which is what keeps the mark legible on the mid-tone Sepia and Clay panels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… cube Swaps Oswald for Saira Condensed, whose squared terminals sit closer to the original artwork's geometry and pair better with the cube. Also fixes the wordmark riding high against the cube. Both marks are centred as boxes by `align-items: center`, so any padding baked into one viewBox and not the other reads as a vertical offset. The cube filled its box (ink y=2..106 of 108) while the text sat in `0 0 300 64` with its ink at y=7..55 — 15 units of slack below it. The text viewBox is now measured to the glyph extents (cap-height 39 above the baseline, descender 9 below, advance ~125 at font-size 52), so the two boxes hug their ink and centring them centres the marks. Drops `textLength="292"`, which was stretching this face to 2.3x its design width. Removing that stretch left the 1.4 stroke covering far more of each glyph and closing the counters in 'g' and 'e', so the stroke comes down to 0.8 and the wordmark up to 30px to hold its weight beside the cube. Verified in-browser: box centres agree exactly, and 0 external font requests — next/font self-hosts the file at build time.
Because the text viewBox now hugs the glyph ink, 44px is the wordmark's true cap-to-descender height and sets it level with the cube beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings the outline closer to the weight of the original artwork. The value is in user units against a 50-unit viewBox rendered at 44px, so it paints ~1.8 CSS px and stays proportional at any logo size. The counters in 'g' and 'e' stay open at this weight — the closing seen earlier at 1.4 was caused by `textLength` stretching the glyphs, which has since been removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
30% heavier than 2, paints ~2.3 CSS px at the 44px logo size. Verified on light and dark grounds and at 1x that the counters in 'g' and 'e' stay open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The text viewBox was fitted to the glyph ink alone, from before the mark carried a heavy outline. A centred stroke extends half its width past the glyph edge, so at 2.6 units the outline was being clipped on all four sides — most visibly under the descender of 'g'. The box is now measured from the painted pixels, stroke included: with the baseline at y=0 the outlined wordmark paints 40 units above, 11.5 below and 126.25 wide from 0.75 left of the origin. `-1.5 -41.5 129.5 55` wraps that with ~1.5 units of margin all round. Growing the box 50 -> 55 units would have shrunk the glyphs at a fixed rendered height, so the height is corrected to 48.4px (44 x 55/50) to hold the previous size, then to 53.2px for the requested 10% increase. Cap-height goes 35.2px -> 38.7px. Verified: all four margins positive, centring still exact, and the lockup still fits the 60px sidebar header. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sidebar header was a plain block, so the logo — an inline-flex — sat on
its text baseline. Collapsing the menu drops the wordmark via
`showText={expand}`, shrinking the logo box from 53.2px to 44px, and on a
baseline-aligned inline element that dragged the cube 4.59px upwards.
Centring the header pins the cube to the same y in both states, measured 11px
either way, so it no longer depends on whether its sibling is present.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Team is already global state — a single `currentTeam` in Redux that Shell persists to a cookie and restores on mount — but the only way to set it was a picker on Dashboard, Metrics and Team Settings. Six pages consume it and three could change it, so the manual test case pages dead-ended on a 'select a team' message with no way to select one. Surfaces the existing `changeCurrentTeam` in the header beside the language and theme menus, which are the other global controls. No new state: the cookie and `?teamId=` deep links keep working as before. The no-team messages now say where the picker is, and the three that were missing the `.page` wrapper get it so the alert is not flush to the chrome. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dashboard and Metrics each carried a team SelectPicker that wrote the same global `currentTeam`, so they read as page filters while actually being three copies of one app-wide switch. The header picker replaces them. Team Settings keeps its control: there the team is the object being edited rather than a filter over the page, so it belongs beside the fields it governs. It now follows the header picker in both directions instead of only seeding itself on mount, so the page cannot edit a team other than the one the app is scoped to. Also makes `?teamId=` one-shot in Shell. It was re-read on every run of the sync effect, so with the in-page pickers gone a link-supplied team would re-assert itself and silently revert any later choice from the header. Metrics worked around this by rewriting the param on each change; the param is now cleared once applied, and the cookie stays the source of truth. Verified: deep link selects then clears, a header switch sticks and survives navigation, and no page-level team control remains outside Team Settings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The picker was gated on `teams.length` alone, but the teams list survives logout in the redux store, so the menu stayed on the login screen. Gates on `user` as well, matching the sidebar and profile menu. The toggle also showed only the team name, which does not say what the menu selects. A persistent 'Team' caption now sits before the name, sized and muted so the pair reads as one control; the caption never shrinks, so a long team name is still what ellipsises. Adds a derived `--nav-muted-font-color` for that caption. `--nav-font-color` and `--main-panel-secondary-font-color` resolve to the same value in every shipped theme, so neither could express 'quieter than the nav's own text'. Deriving it from the ink channel means future themes inherit it for free. Measured 6.3:1 to 7.8:1 across the eight themes checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Added as a `::after` on the label rather than inside the `nav.team` string, so the catalogues keep holding the word alone and a locale that punctuates differently — a space before the colon in French, a full-width colon in CJK — can override the rule instead of smuggling a glyph into its translation. The label's letter-spacing adds a trailing gap after the colon, so a negative margin cancels it and the spacing stays even on both sides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The theme menu named twelve themes but showed none of them, so choosing one meant applying it to find out what it looked like. Each row now carries three chips — surface, accent and text — drawn from the theme it names. They are the values that actually separate these themes; the status colours are deliberately left out because they are near-identical by design and would add width without information. The colours are not restated anywhere. The registry selectors in _color.less drop their `:root` qualifier, so they still match the root element exactly as before but also match a nested element carrying `data-theme` — and since custom properties inherit, such an element becomes a live island of that theme's primitives. A new theme therefore gets a preview the moment it is registered. Two consequences of that mechanism are worth recording: Ember Light moves into a `.theme-ember-light()` mixin, like every other theme. It is the one theme with no registry selector of its own (`:root` carries it), which on the root is fine but nested means a light swatch would inherit the active theme and preview the wrong colours on every dark theme. The chips read primitives rather than the semantic tokens they correspond to. Only the primitive layer is per-theme; the semantic layer is declared once at `:root` and inherited already-resolved, so chips asking for `--main-panel-background` all render the active theme's surface. That was the first cut's behaviour: all twelve rows previewed an identical surface and text, and only the accent — a primitive — varied. The same reasoning applies to the hairline, which is built from `--theme-ink-rgb` so it inverts with the theme being previewed and stays visible on light and dark chips alike. The compiled CSS is otherwise unchanged: no colour value differs, and the only altered selectors are the twelve that lost `:root`. The swatch is decorative — the row already names the theme in words — so it is aria-hidden and adds no translation keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Configuring a team and working in a team are separate decisions, but the page treated them as one and coupled them in both directions. Page to global: choosing a team to configure wrote `currentTeam` and set the `teamId` cookie for a year. Opening this page and selecting Team B to rename it moved the entire app — dashboards, metrics, test runs — onto Team B, and kept it there across sessions. The handler is now purely local state. Global to page: the sync effect followed `currentTeam` on every change, so switching the header picker swapped the form out from under whatever was being edited. It now seeds once, guarded on "nothing chosen yet" rather than on a comparison against `currentTeam`. The header is still the sensible starting point, so it remains what the page opens on — it just stops steering afterwards. One case does still touch global state, and only this one: renaming the team you are currently scoped to also updates the name held in `currentTeam`. The header renders its own copy from the store, which `refreshTeams` does not touch, so without this the header would show the old name until a reload. It updates the selection's NAME, never the selection. The picker is relabelled "Team to configure" with help text saying the choice is local. Left as a bare "Team" it reads as the current team, which is exactly the confusion the coupling used to create. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An expanded folder heading was bare text on the page background, with the
table starting immediately below it at full width. Two stacked full-width
blocks with nothing enclosing them read as siblings, so the cases did not
look like they belonged to the folder above them.
Each folder is now one card: the heading is its top bar, the table its
body, clipped to the card's corners. An accent rail runs down the open
card from the heading past the last row, so the eye follows one
continuous edge from folder name to contents. Collapsed cards drop the
rail and close up into a single quiet bar, which also makes open and shut
tell apart at a glance.
The nested table header is quietened to a subordinate label. The app-wide
convention paints a table header in the accent, which is right where the
table IS the panel's content — but here it sits inside a card that
already has a heading, and a solid accent fill out-weighs text, so the
rows looked like the start of a new section. Scoped to this component;
tables elsewhere keep the convention.
Sub-folders are indented by tree depth. The heading already spelled the
ancestry out ("Checkout / Refunds"), but reading a path is not the same
as seeing one. The indent is computed from `folder.path` rather than a
class per level, so it stays truthful however deep the folders go.
The header also gains aria-expanded, which it should have had as a
role="button" that toggles a region.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Author
|
Backend dependency: AnglesHQ/angles#127 adds the manual test management API this PR calls. That needs to merge first — until it does, these screens have no endpoints to talk to. |
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.
Adds manual test management to the UI, and — because the new screens needed
colours the palette did not have — reworks theming and the brand mark along
the way.
Manual test management
New screens for authoring and running manual tests:
shared-step inclusions), version history and a change log.
and per-folder counts.
into a manual-tagged build so existing dashboards and metrics pick them up.
The whole area sits behind an admin feature toggle (
manualTestingEnabled),so it can be rolled out per install.
Theming
The manual screens needed surfaces and states the two-theme palette could not
express, so the token layer was formalised rather than extended ad hoc:
(
_theme-contract.less); semantic tokens derive from them, so a themesupplies colours and gets the rest for free.
data-themewith a registry (utility/Themes.js) driving thepicker, replacing the hardcoded light/dark pair. The OS-preference fallback
guards on the absence of the attribute — the old
:not([data-theme="light"])would have layered dark over any third theme.
and a colour-blind-safe Clay pair using the Okabe-Ito palette.
no longer means applying it to find out what it looks like.
library themes with the app instead of against it.
Branding
The logo was a fixed-orange PNG pair, which read as a stray asset once half
the themes accent on blue or green. It is now inline SVG coloured from tokens,
with the wordmark set in Saira Condensed. The accessible themes render it
monochrome so it stays out of the way of the status colours.
Team selection
currentTeamwas already global state, but three pages each carried their ownpicker and six had none — so the same switch existed in triplicate while most
of the app dead-ended with no way to set it. There is now one picker in the
header. Team Settings keeps a local picker, deliberately: which team you are
configuring is a separate question from which team the app is scoped to.
Translations
hi.jsonhad fallen behind — 379 keys againsten.json's 513 on master —which surfaces as raw dotted ids in the UI because react-intl falls back
silently. All five catalogues are now at
parity (809 keys), and
npm run check-translationsfails the build on amissing key, a stale extra, a dropped ICU placeholder, or a
FormattedMessageid absent from
en.json. AGENTS.md no longer hardcodes the language list —that hardcoding is why
hi.jsonwas missed.Testing
Built and verified in-browser across light and dark themes. Translation parity
is enforced by the new check. No automated tests were added — the repo has no
test suite for components yet.
Notes for the reviewer
package-lock.jsonshows unrelatedpeermetadata churn from local installs;it is deliberately not included.
pre-date this branch and are untouched here.
🤖 Generated with Claude Code