Skip to content

fix(fields): render the author's declared option hex instead of quantizing it to nine palette families - #5184

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-5141-badge-hex-fidelity
Aug 18, 2026
Merged

fix(fields): render the author's declared option hex instead of quantizing it to nine palette families#5184
os-steve merged 2 commits into
mainfrom
claude/issue-5141-badge-hex-fidelity

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Part of #5141

options[].color accepts any hex, but the badge renderer answered a lossy question with it. hexToPaletteName bucketed the value by hue into nine families and BADGE_COLOR_MAP held exactly one class set per family, so #2ecc71 ("in progress") and #1e8449 ("completed") — 0.1 degree of hue apart — both emitted bg-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 (-50 surface, -700 text, hairline -200, dark -950/40 + -300) — a real constraint about appearance, which this PR preserves. It says nothing about bucketing.
  • packages/fields/src/index.tsx already notes that "Tailwind classes can't be applied to dynamic style={} values" — the repo's own record of why a runtime colour cannot be a class.
  • Git archaeology is inconclusive by construction: this checkout is shallow (51 commits) and the whole cluster traces to one squashed commit.

There is a documented design-system constraint that bears on the route: AGENTS.md section 2 and skills/objectui/rules/styling.md both say no inline styles, and the latter also forbids hard-coded colours and arbitrary values such as bg-[#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; 254 style={{ occurrences across packages/*/src), and data-driven author colour already reaches inline style in three places, including the other end of this exact metadata key: SchemaForm.tsx paints the option swatch in Studio straight from opt.color, and plugin-gantt paints bars from it under the comment this card cites. So an author picks #1e8449, sees #1e8449 while 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: #2ecc71 and #1e8449 differ 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

  1. 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:

    .bg-\[color\:var\(--os-badge-bg\)\] { background-color: var(--os-badge-bg); }
    .dark\:bg-\[color\:var\(--os-badge-bg-dark\)\]:where(.dark, .dark *) { background-color: var(--os-badge-bg-dark); }
    

    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.

  2. 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 -500 shades 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.

  3. appearance: 'dot' is fixed too, not just the badge.

  4. Every non-hex path is untouched — family names, the semantic value map and the hash fallback resolve exactly as before, and getSemanticColorName still 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 passed
  • pnpm exec vitest run packages/fields/src/__tests__/badge-hex-fidelity-5141.test.tsx54 passed
  • pnpm --filter @object-ui/fields type-check — clean (tsc --noEmit && tsc -p tsconfig.test.json)
  • pnpm --filter @object-ui/fields lint0 errors (814 warnings, the pre-existing baseline)
  • check:control-bytes — OK, 4593 files; check:self-import, check:phantom-deps — OK
  • check-changeset-presence, check-changeset-no-major — OK
  • check:published-dist needs a full-farm build and is left to CI; its specific concern — a new test file leaking into published dist/ — was checked directly: packages/fields/dist contains no __tests__, no *.test.*, and the new exports are present in dist/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 reported pair no longer renders identically, and is perceptibly distinct
AssertionError: expected '#eef7f2' not to be '#eef7f2'
Tests 1 failed | 53 passed (54)

The mutation was applied and reverted in source, and the test imports the module under test by relative path (../index), not through the package exports — so no dist/ 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

getBadgeColorClasses returns 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 in ObjectGrid (compact card badge, group-header pills) and two in ObjectKanban. 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 of rather than Fixes for 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

…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.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)25.3 KB350 KB
Entry fileindex-EFDgVH5u.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)25.13KB5.40KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.79KB
auth (createAuthenticatedFetch.js)6.34KB2.43KB
auth (index.js)2.71KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.88KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)502.20KB112.21KB
core (index.js)4.11KB1.62KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.03KB44.08KB
fields (index.js)237.74KB59.60KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.35KB1.38KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.12KB7.62KB
i18n (useDisplayLocale.js)2.84KB1.45KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)39.16KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.75KB18.37KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)127.85KB32.73KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)241.46KB60.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)123.77KB30.07KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)198.05KB53.21KB
plugin-kanban (index.js)52.72KB14.54KB
plugin-list (index.js)111.39KB27.03KB
plugin-map (index.js)20.02KB6.58KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)41.97KB11.33KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)83.81KB20.49KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)31.55KB10.70KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.28KB0.68KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-steve@claude