Skip to content

feat(manual): manual test management, with a themeable token layer and brand mark - #100

Merged
snevesbarros merged 37 commits into
masterfrom
feature/manual-test-cases
Sep 12, 2026
Merged

snevesbarros merged 37 commits into
masterfrom
feature/manual-test-cases

Conversation

@snevesbarros

Copy link
Copy Markdown
Contributor

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:

  • Test cases — authoring with a step editor (reordering, attachments,
    shared-step inclusions), version history and a change log.
  • Folders — a tree to file cases into, with drag-and-drop between folders
    and per-folder counts.
  • Test runs — execute a set of cases and record per-step results, written
    into a manual-tagged build so existing dashboards and metrics pick them up.
  • Shared steps and custom fields, with an admin screen for the latter.
  • Dashboard and Metrics gain an automated/manual toggle.

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:

  • Themes are now primitive blocks behind a documented contract
    (_theme-contract.less); semantic tokens derive from them, so a theme
    supplies colours and gets the rest for free.
  • Keyed on data-theme with a registry (utility/Themes.js) driving the
    picker, 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.
  • Twelve themes, including Forest, Slate, Sepia, Midnight, High Contrast,
    and a colour-blind-safe Clay pair using the Okabe-Ito palette.
  • The picker previews each theme's surface, accent and text, so choosing one
    no longer means applying it to find out what it looks like.
  • rsuite's own CSS variables are bridged to the tokens, so the component
    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

currentTeam was already global state, but three pages each carried their own
picker 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.json had fallen behind — 379 keys against en.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-translations fails the build on a
missing key, a stale extra, a dropped ICU placeholder, or a FormattedMessage
id absent from en.json. AGENTS.md no longer hardcodes the language list —
that hardcoding is why hi.json was 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.json shows unrelated peer metadata churn from local installs;
    it is deliberately not included.
  • GitHub reports 11 Dependabot vulnerabilities on the default branch. They
    pre-date this branch and are untouched here.

🤖 Generated with Claude Code

snevesbarros and others added 30 commits August 23, 2026 15:47
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>
snevesbarros and others added 7 commits September 6, 2026 15:43
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>
@snevesbarros

Copy link
Copy Markdown
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.

@snevesbarros
snevesbarros merged commit a6adfba into master Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant