Skip to content

Design language: status tones, StatusChip, CopyId, palette fixes, and four new primitives - #97

Open
timurbazhirov wants to merge 9 commits into
mainfrom
feature/SOF-8023-design-language-groundwork
Open

Design language: status tones, StatusChip, CopyId, palette fixes, and four new primitives#97
timurbazhirov wants to merge 9 commits into
mainfrom
feature/SOF-8023-design-language-groundwork

Conversation

@timurbazhirov

@timurbazhirov timurbazhirov commented Aug 16, 2026

Copy link
Copy Markdown
Member

Summary

Phases 1.5 and 2.3 of the Job Designer UX update (SOF-8023, plan) — but everything here is generic, and the parallel Materials Designer work needs the same pieces. It lands first because the other phases consume it.

Palette defects fixed

paletteDark was skeletal. It spread only the status colors and primary/secondary, never otherOptions — so in dark mode palette.text, action, background, border, icon and unitTypes were all undefined. Anything theming itself from those tokens silently kept light-mode styling, which is the root cause of light panes inside a dark shell. Now completed with dark counterparts of every slot (unit-type hues lightened so they keep their identity on a dark surface).

Status contrastText was unreadable. success.main is a neon green (#72E128) that declared white text — 1.7:1. error.contrastText and info.contrastText were rgba(0, 0, 0, 0.23), i.e. 23%-alpha black, invisible on anything. Each is now whichever ink actually clears 4.5:1 on its own main. No main/light/dark value changed, so this is not a recolor.

No soft surfaces existed. Chips, badges, meters and flag rows need a tinted background plus an ink that survives on it — you cannot get that from success.main alone (as text on white it is 1.7:1). Added palette.statusTones: validated { color, background, border } triples per tone (neutral | primary | info | success | warning | error), per theme.

Colors are computed and asserted, not eyeballedsrc/theme/palette/contrast.ts implements WCAG relative luminance with alpha compositing, and tests/palette.tests.ts (73 assertions) holds every ink to 4.5:1 against the surface it is actually painted on, in both themes. My first pass at the chip outlines failed the check and had to be re-derived.

New components

Component What it is
StatusChip State pill on the new tones. Icon and label are always present, so status is never color-alone.
JobStatusChip Same chip with tone/icon/wording resolved from a job status string.
CopyId An identifier folded behind a copy affordance — short label, full value on hover, copies on click (with a fallback for non-secure contexts where navigator.clipboard is absent).
SelectableCard One option in a set as a card rather than a row in a dropdown.
MetricTile A number with what it measures and what it means.
SegmentedMeter A bar split into named parts of a known whole.
NumericStepperInput Bounded number entry whose steppers stop at the bounds.

jobStatusPresentation.ts holds the single job-status → tone/icon/label mapping. Worth noting why: @mat3ra/jode currently ships two mappings — JOB_STATUS_CLS() and the Job.statusCls getter — and they disagree about pre-submission (info vs default). Neither covers terminate-queued, timeout or deleted with anything but a default, and neither carries an icon or a human label. This resolves that in favour of a neutral draft and covers all ten statuses.

The four new primitives, and the judgement in each

They exist for the compute redesign (mat3ra/ive#6) but none of them is compute-specific.

  • SelectableCard — a dropdown shows one option at a time and hides everything a reader would actually choose between. It renders as a radiogroup of radios, not buttons, so it is heard as a single choice too, and selection carries aria-checked rather than living in the border colour. The selected state thickens the edge with an inset shadow rather than a second border pixel, so choosing does not shift the layout.
  • MetricTile — an absent value renders as an em dash, never 0. In an estimate the difference between "we do not know what this costs" and "this costs nothing" is the whole point.
  • SegmentedMeter — overflow rescales instead of clipping. Clamping at 100% would draw an over-quota job identically to one that exactly fits, which is the one comparison the meter exists to make. The projected slice is hatched: it has not happened yet.
  • NumericStepperInput — the steppers stop at min/max, so nudging cannot produce an invalid value; the helper text can then state the range instead of complaining about it afterwards. Typing past the bounds is still allowed and reported through error — clamping mid-keystroke would turn a typed "64" into "6".

The bound arithmetic and meter geometry are extracted as pure modules (numeric-stepper/bounds.ts, metric/meterGeometry.ts) and unit-tested, because those two rules are exactly what regresses silently.

Other fixes

  • ButtonMultiSelect never resynced. It snapshotted buttonConfigs[0] into state on mount, so it kept calling the first onClick closure it ever received — a parent re-rendering with fresh handlers was ignored for the component's lifetime. (job-designer carries a long comment and a workaround for exactly this; that can now come out.) It now tracks the selected id and resolves against the live prop each render. Also: it read from localStorageKey but wrote to a hard-coded "selectedSaveOption", so any instance with a different key forgot the choice on reload. Empty buttonConfigs now renders nothing instead of dereferencing undefined.
  • tests/ never ran in CI. npm test was lint && transpile; the directory existed but was dead weight. Added test:unit and wired it in — this revives the existing schemaUtils tests too.

Verification

npm test green: 0 eslint errors on src, tsc clean, 89/89 unit tests. Every new component has a gallery entry and was checked in the running gallery under Light (default) and Dark (MD). Driven in a browser rather than assumed:

  • CopyId writes the full id to the clipboard;
  • the card group reports one radiogroup with three radios, aria-checked moves on click, and the disabled card is disabled;
  • the Nodes stepper clamps at 4 and 1, disables at each bound, and still accepts a typed 99.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8

Prerequisites for the guided Job Designer (SOF-8023, phase 1.5), useful to
any surface that shows state.

Palette:
- complete paletteDark: it carried only primary/secondary and the four status
  colors, so text/action/background/border/icon/unitTypes all read undefined
  in dark mode and consumers silently fell back to light styling
- fix status contrastText: success declared white on neon green (1.7:1) and
  error/info declared rgba(0,0,0,0.23), invisible on any background; each is
  now whichever ink actually clears 4.5:1
- add statusTones: validated tint + ink + outline triples per tone, the shape
  chips/badges/meters need and could not previously get from the palette

Components:
- StatusChip / JobStatusChip: state pill built on the tones, icon+label always
  present so status is never color-alone
- CopyId: identifier folded behind a copy affordance, with a clipboard
  fallback for non-secure contexts
- jobStatusPresentation: single job-status -> tone/icon/label mapping; jode
  ships two mappings that disagree about pre-submission and neither covers
  terminate-queued, timeout or deleted

Fixes:
- ButtonMultiSelect snapshotted buttonConfigs[0] on mount and never resynced,
  so it kept calling the first onClick closure it ever received; it now tracks
  the selected id and resolves against the live prop. It also persisted to a
  hard-coded localStorage key instead of the one it reads back
- wire tests/ into npm test; the directory existed but never ran in CI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8
Four components the compute redesign needs, none of which cove had:

- SelectableCard — one option as a card rather than a row in a dropdown.
  A dropdown shows one option at a time and hides everything a reader
  would choose between (queue, price, whether it is busy). Renders as a
  radio group, so it is heard as one choice too, and selection carries
  aria-checked rather than living in the border colour.
- MetricTile — a number with what it measures and what it means. An
  absent value renders as an em dash, never 0: in an estimate, "we do
  not know what this costs" and "this costs nothing" must not look the
  same.
- SegmentedMeter — a bar split into named parts of a known whole, with
  the projected slice hatched because it has not happened yet. Overflow
  rescales instead of clipping: clamping at 100% would draw an
  over-quota job identically to one that exactly fits.
- NumericStepperInput — bounded number entry whose steppers stop at the
  bounds, so nudging cannot produce an invalid value. Typing past them
  is still allowed and reported through `error`; clamping mid-keystroke
  would turn a typed "64" into "6".

The bound arithmetic and meter geometry are extracted as pure modules
and unit-tested (16 new assertions, 89 total). All four are in the
gallery, verified live: the card group reports one radiogroup with three
radios and selection moves between them; the stepper clamps at 4 and 1,
disables at each bound, and still accepts a typed 99.

Refs SOF-8023.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8
@timurbazhirov timurbazhirov changed the title Design-language groundwork: status tones, StatusChip, CopyId, palette fixes Design language: status tones, StatusChip, CopyId, palette fixes, and four new primitives Aug 17, 2026
claude added 2 commits August 17, 2026 03:26
Phase 3.1. The header said where a job was with a single tinted icon:
one glyph carrying "queued", "running" and "errored" alike, silent about
what had already happened or when. This is the sequence instead.

Each stage keeps its own icon from the job-status mapping, so the run
reads without relying on colour, and carries its timestamp from the
status track (earliest entry per stage — a requeued job entered "Queued"
once).

Two judgements worth review, both in the pure `getLifecycleStages`:

- A failed job does not show a finish it never reached. The last stage
  becomes the failure itself, named for what actually happened —
  "Terminated", "Timed out" — not a generic error.
- Stages the job never got to render as *skipped*, not *upcoming*.
  "Upcoming" would suggest a terminated job might still run.

`JobLifecycleTimeline` derives its stages from a status and track,
mirroring `JobStatusChip`. 15 new unit tests (104 total), gallery entry
covering draft / running / finished / errored / terminated / compact,
verified in the running gallery.

Refs SOF-8023.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8
Phase 3.2 needs a log tail in the run monitor, and there was none.

The pinning is the whole point: it follows the end until the reader
scrolls up, then stops and offers to resume. A log that always jumps to
the bottom cannot be read while it is being written; one that never does
makes the reader chase it.

`aria-live` is off by default — a log tailing a running job would
otherwise read itself aloud continuously — and opt-in through `isLive`
for the cases where announcing new lines is the point. Height is set in
rows rather than pixels so it scales with the reader's font size.

Gallery entry covers a long log and an empty one.

Refs SOF-8023.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8
claude added 5 commits August 17, 2026 06:57
The plan's risk table asks for a regression test on this: the component
used to hold the selected *config object* in state, snapshotted from
whichever buttonConfigs[0] was passed on mount and never resynced, so it
kept calling the very first onClick closure it ever received.
job-designer's Job.jsx still carries a long comment about working around
it by reading its own state at click time.

The resolution rule is now a pure module rather than an expression
inside the render, with 8 assertions covering what the object-holding
version got wrong: resolving against the options given *now*, falling
back when a persisted id names an option a later release removed, and
not dereferencing an empty list.

Refs SOF-8023.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8
main stopped committing dist/ (3374a52) and published WIP builds via CI
instead. All four conflicts were modify/delete on dist/ files this branch had
regenerated; resolved in main's favour, and dist/ is now gitignored here too.
282 files drop out of the branch as a result.

Everything else auto-merged correctly, verified rather than assumed:
main's newer @mat3ra/code and @mat3ra/esse pins and its prepublishOnly are
taken, while this branch's test:unit script and the test script that runs it
are kept — that wiring is what made cove's tests execute in CI at all, so
losing it to the merge would have silently switched them back off.

The pre-commit hook took main's version, which drops `git add dist`. cove has
husky, lint-staged and a lint-staged config, so unlike job-designer its hook
actually runs; nothing to disarm.

Verified after resolution: `npm test` green end to end — eslint clean, tsc
clean, 112/112 unit tests — and 0 files tracked under dist/.

This unblocks the chain: cove#97 could not be released while it would not
merge, and job-designer#19's bundle and Netlify preview cannot build until
these primitives exist in a published cove.

Ref: SOF-8023

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8
job-designer#19's bundle and its Netlify deploy preview cannot build until
the six primitives on this branch exist in an installable cove: the published
2026.8.19-4 has none of them, and this PR has not merged yet.

Per mat3ra/agents#5, that no longer requires waiting for a merge and a
registry publish. This empty commit triggers .github/workflows/release-wip.yml,
which publishes this commit's build as a GitHub pre-release tarball tagged
wip-<short-sha>. Consumers point package.json at that URL until cove#97
merges and a normal version ships, at which point they move back to a semver
range.

Ref: SOF-8023

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8
The previous WIP release attempt failed before it built anything:

    npm error code EUSAGE
    Missing: eslint-plugin-jsonc@2.21.1 from lock file
    ... (18 more, all eslint transitives)

My fault, and worth naming precisely so it does not recur. Merging main
auto-merged package-lock.json without reporting a conflict, and I reconciled
it afterwards with `npm install --legacy-peer-deps`. cove does not need that
flag and its CI does not use it — every workflow here runs a plain `npm ci`.
Legacy peer resolution produced a different tree than strict resolution
expects, so the lockfile I committed satisfied my local install and no other.

Regenerated with a plain `npm install --package-lock-only`, matching how CI
installs. `npm ci --dry-run` now resolves where it previously exited at once.

Carries [release] to retrigger release-wip. This is a new commit, so it mints
a new wip-<sha> tag rather than overwriting the previous asset — which is the
documented behaviour and avoids the npm-caches-by-URL trap in mat3ra/agents#5
step 3.

Ref: SOF-8023

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8
Second failed WIP release, different cause, also mine:

    node_modules/@types/node/ffi.d.ts(273,35): error TS1005: ',' expected.

The previous commit fixed the EUSAGE by running `npm install
--package-lock-only`, which resolved dependencies afresh and floated
@types/node from the 22.10.7 main pins up to 26.2.0. cove's TypeScript
cannot parse that version's .d.ts syntax, so `tsc` — the release workflow's
build-script — died on a dependency rather than on our code.

Regenerating was the wrong instrument. This branch changes no dependencies
at all: its dependency, devDependency and peerDependency sets are identical
to main's, and the only package.json changes are the test/test:unit scripts.
So it needs no lockfile of its own. Restored main's, which is byte-identical
and already CI-green there.

Verified locally rather than on CI this time, having burned two runs guessing:
`npm ci` exits 0, `npm run transpile` exits 0, and `npm test` is green.

Ref: SOF-8023

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8
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.

2 participants