Design language: status tones, StatusChip, CopyId, palette fixes, and four new primitives - #97
Open
timurbazhirov wants to merge 9 commits into
Open
Design language: status tones, StatusChip, CopyId, palette fixes, and four new primitives#97timurbazhirov wants to merge 9 commits into
timurbazhirov wants to merge 9 commits into
Conversation
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
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
This was referenced Aug 17, 2026
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
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.
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
paletteDarkwas skeletal. It spread only the status colors and primary/secondary, neverotherOptions— so in dark modepalette.text,action,background,border,iconandunitTypeswere allundefined. 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
contrastTextwas unreadable.success.mainis a neon green (#72E128) that declared white text — 1.7:1.error.contrastTextandinfo.contrastTextwerergba(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 ownmain. Nomain/light/darkvalue 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.mainalone (as text on white it is 1.7:1). Addedpalette.statusTones: validated{ color, background, border }triples per tone (neutral | primary | info | success | warning | error), per theme.Colors are computed and asserted, not eyeballed —
src/theme/palette/contrast.tsimplements WCAG relative luminance with alpha compositing, andtests/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
StatusChipJobStatusChipCopyIdnavigator.clipboardis absent).SelectableCardMetricTileSegmentedMeterNumericStepperInputjobStatusPresentation.tsholds the single job-status → tone/icon/label mapping. Worth noting why:@mat3ra/jodecurrently ships two mappings —JOB_STATUS_CLS()and theJob.statusClsgetter — and they disagree aboutpre-submission(infovsdefault). Neither coversterminate-queued,timeoutordeletedwith 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 aradiogroupofradios, not buttons, so it is heard as a single choice too, and selection carriesaria-checkedrather 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, never0. 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 atmin/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 througherror— 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
ButtonMultiSelectnever resynced. It snapshottedbuttonConfigs[0]into state on mount, so it kept calling the firstonClickclosure 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 fromlocalStorageKeybut wrote to a hard-coded"selectedSaveOption", so any instance with a different key forgot the choice on reload. EmptybuttonConfigsnow renders nothing instead of dereferencingundefined.tests/never ran in CI.npm testwaslint && transpile; the directory existed but was dead weight. Addedtest:unitand wired it in — this revives the existingschemaUtilstests too.Verification
npm testgreen: 0 eslint errors onsrc,tscclean, 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:CopyIdwrites the full id to the clipboard;radiogroupwith threeradios,aria-checkedmoves on click, and the disabled card is disabled;99.🤖 Generated with Claude Code
https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8