Uh oh!
There was an error while loading. Please reload this page.
fix(fields): render the author's declared option hex instead of quantizing it to nine palette families - #5184
Merged
Merged
Conversation
…to 9 families Derive the soft-pill surface/label/border from the declared hex via CSS custom properties consumed by static Tailwind utilities, so light/dark stay `dark:` variants. Foreground is pinned to WCAG AA against the derived surface.
…aths (objectui#5141) Pins measured properties rather than colour identity: a CIE76 deltaE threshold so an implementation that merely un-breaks byte-identity cannot pass, WCAG AA for the label in both themes across a stress sample, the measured dot visibility floor, and the untouched family/semantic/hash paths.
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
os-steve
marked this pull request as ready for review
August 18, 2026 10:22
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Aug 18, 2026
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 freeto 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.
Part of #5141
options[].coloraccepts any hex, but the badge renderer answered a lossy question with it.hexToPaletteNamebucketed the value by hue into nine families andBADGE_COLOR_MAPheld exactly one class set per family, so#2ecc71("in progress") and#1e8449("completed") — 0.1 degree of hue apart — both emittedbg-green-50 text-green-700 border-green-200. The declaration was discarded and users could not tell the two states apart.The stop-clause: I looked for a recorded rationale, and report a measured absence
The triage comment made this binding: "If the implementer finds the quantization is a deliberate design system constraint with a recorded rationale, stop and flag back instead of forcing the fix."
There is no recorded rationale for the quantization. What the repo actually records points the other way:
hexToPaletteName's own docstring says it exists so that "the explicit author color is [not] ignored" — it is an approximation in service of metadata-wins, not a decision to discard precision.BADGE_COLOR_MAP's docstring records a rationale for the soft-pill look (-50surface,-700text, hairline-200, dark-950/40+-300) — a real constraint about appearance, which this PR preserves. It says nothing about bucketing.packages/fields/src/index.tsxalready notes that "Tailwind classes can't be applied to dynamicstyle={}values" — the repo's own record of why a runtime colour cannot be a class.There is a documented design-system constraint that bears on the route: AGENTS.md section 2 and
skills/objectui/rules/styling.mdboth say no inline styles, and the latter also forbids hard-coded colours and arbitrary values such asbg-[#3b82f6]. I treated that as constraining how, not whether — because it is about component-authored static styling, it is not mechanically enforced (no ESLint rule; 254style={{occurrences acrosspackages/*/src), and data-driven author colour already reaches inline style in three places, including the other end of this exact metadata key:SchemaForm.tsxpaints the option swatch in Studio straight fromopt.color, andplugin-ganttpaints bars from it under the comment this card cites. So an author picks#1e8449, sees#1e8449while authoring, and saw a different colour when rendered.Route chosen, and the measurement that chose it
The card named two routes. I took (a) — render the declared hex — but not the naive form of it, because measurement rejected that form.
The obvious reading of "compute a soft pill from the hex" is a fixed pale tint. Measured against the reported pair, that fails: at
-50-equivalent lightness the chroma is so small that hue and saturation differences round away in 8-bit, and both colours produce the byte-identical background#eef7f2. It would have closed this issue while reproducing the exact defect. This is pinned as an ablation, below.The reason is structural and worth stating:
#2ecc71and#1e8449differ only in lightness (l=0.49 vs l=0.32; hue 145.4 vs 145.3). Lightness is therefore the one dimension that must survive. It cannot survive in the label, because a WCAG AA floor on a near-white pill compresses every sufficiently light colour down to the same floor. So the declared lightness is carried by the surface, whose tint depth tracks it, and the label is derived against that surface.Route (b) (two tiers per family) was rejected: it still discards the declaration, just at 18 buckets instead of 9, and two mid-greens still collide.
What the implementation keeps
Theming stays inside the design system. The derived colours are published as CSS custom properties and consumed by static Tailwind utilities, so light and dark remain ordinary
dark:variants. Verified in the built sheet — all eight utilities are emitted and the dark ones are correctly gated:This is the main cost the PM flagged, and it is largely bought back: a hard-coded inline background would have rendered identically in dark mode.
Contrast is pinned, not colour identity. The label is the lightness along the declared hue nearest the declared one that still clears 4.5:1 against the surface actually rendered — nearest rather than maximal, because maximising collapses every badge to black or white and re-loses the declaration. Across a 20-colour stress sample including pure white, pure black, neon yellow and a near-white cream, the worst measured ratio is 4.51:1 in dark and 4.52:1 in light. Dots are held to 1.9:1 against the row, which is the measured floor of the
-500shades shipped today (yellow-500 is the weakest at 1.92:1), so a declared dot is never less visible than the palette dot it replaces.appearance: 'dot'is fixed too, not just the badge.Every non-hex path is untouched — family names, the semantic value map and the hash fallback resolve exactly as before, and
getSemanticColorNamestill returns family names, so the Gantt path is unaffected.Verification
Union re-run at
26e78af0f, working tree clean at that sha.pnpm exec vitest run packages/fields/— 106 files, 1795 tests passedpnpm exec vitest run packages/fields/src/__tests__/badge-hex-fidelity-5141.test.tsx— 54 passedpnpm --filter @object-ui/fields type-check— clean (tsc --noEmit && tsc -p tsconfig.test.json)pnpm --filter @object-ui/fields lint— 0 errors (814 warnings, the pre-existing baseline)check:control-bytes— OK, 4593 files;check:self-import,check:phantom-deps— OKcheck-changeset-presence,check-changeset-no-major— OKcheck:published-distneeds a full-farm build and is left to CI; its specific concern — a new test file leaking into publisheddist/— was checked directly:packages/fields/distcontains no__tests__, no*.test.*, and the new exports are present indist/index.d.ts.Ablation. Replacing the lightness-tracking tint with the fixed pale tint (the naive route) turns the pin red, and reproduces the original defect exactly:
The mutation was applied and reverted in source, and the test imports the module under test by relative path (
../index), not through the packageexports— so nodist/rebuild is involved in either leg and neither leg can read a stale artifact. The fix was committed before the ablation, so the restore came from the branch, byte-identical.Note that the byte-inequality assertion still passed under ablation while the perceptual one failed — which is why the test pins a CIE76 deltaE threshold (5.0, against a just-noticeable difference of about 2.3) rather than
not.toBe, and computes the colour maths independently of the implementation.Deliberately not in this PR
getBadgeColorClassesreturns a class string, which cannot carry a runtime colour, so it was left behaviour-identical and four call sites outside this card's ruled file surface still quantize: two inObjectGrid(compact card badge, group-header pills) and two inObjectKanban. That leaves one option colour rendering two ways depending on surface, so it is filed as #5183 rather than left silent. That issue is not resolved by this PR.This is
Part ofrather thanFixesfor that reason: the reported symptom (list and detail select cells, badge and dot) is fixed, the mechanism repo-wide is not.Generated by Claude Code