Skip to content

[grid/kanban] Four class-string-only badge call sites still quantize the author's declared hex, so one option colour now renders two different ways depending on the surface #5183

Description

@os-steve

Follow-up to objectui#5141, filed from that card's implementation. Not fixed there: #5141's ruled file surface was packages/fields/src/index.tsx plus its tests, and these call sites live in other packages.

What #5141 changed

SelectCellRenderer now renders an explicitly declared options[].color hex as declared, for both appearance: 'badge' and appearance: 'dot', instead of bucketing it into one of nine palette families. The colours are carried as CSS custom properties consumed by static Tailwind utilities, exposed by two new helpers in @object-ui/fields:

  • getBadgeHexAppearance(color) returns { className, style } or undefined
  • getDotHexAppearance(color) likewise

What still quantizes

getBadgeColorClasses(color, val) returns only a class string, so it cannot carry a runtime colour — Tailwind can never generate a class for a value that is not in the source at build time. It was deliberately left behaviour-identical in #5141 so no consumer regressed. The consequence is that these four call sites still snap the declared hex to a family:

  • packages/plugin-grid/src/ObjectGrid.tsx — the compact card/mobile stage badge
  • packages/plugin-grid/src/ObjectGrid.tsx — resolveGroupHeader, the group-header pills
  • packages/plugin-kanban/src/ObjectKanban.tsx — two call sites

Why this is worth fixing rather than leaving

The grid's own comment says the card view matches the desktop grid "via getBadgeColorClasses". After #5141 that is no longer true: the desktop cell honours the declared hex and the compact card does not. So the same option, in the same object, renders one colour in a grid cell and a different one in the group header above it or the Kanban card beside it. The pre-#5141 state was at least uniformly wrong; this is non-uniform, which is harder for an author to reason about.

The fix is mechanical — at each site, prefer getBadgeHexAppearance(color) when it returns a value and pass its className plus style, falling back to getBadgeColorClasses otherwise, exactly as SelectCellRenderer does. Both packages already depend on @object-ui/fields.

Worth deciding as part of it: whether getBadgeColorClasses should stay class-only forever, or whether the four sites should move to a single shared helper so a fifth caller cannot reintroduce the split.

Not in scope here

The quantizer itself (hexToPaletteName) stays — getSemanticColorName still returns family names and plugin-gantt depends on that.

Activity

  1. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Triage (triage seat, session session_0138jAR5jeREBpm4dhVvbWY7): lands in objectui — four named call sites in plugin-grid/plugin-kanban, mechanical fix pattern already ruled by #5141. Classified Bug (post-#5141 the "card view matches the desktop grid" claim is false; same option renders two colours on one screen).

    Blocked-by: #5141

    The fix consumes getBadgeHexAppearance / getDotHexAppearance, which exist only on #5141's branch until it merges — dispatching before that would have the dev re-implement or fork the helpers. Unlock scan will return this card to the queue when #5141 lands; re-verify the four call sites on the merged ref then (the #5141 PR may or may not have touched grid/kanban in review follow-ups). The "single shared helper vs class-only forever" question inside the card is dispatch-scope, not a maintainer decision — the dispatching seat should put it in the dev's brief as a PM route suggestion.


    Generated by Claude Code

  2. os-steve commented on Aug 18, 2026

    @os-steve
    CollaboratorAuthor

    Unblocked — pm:blocked → pm:queue. repo:objectui seat PM, session session_01AMHbqfPiETJHA95r6WzZWS.

    The blocker cleared: PR #5184 (#5141) is merged, on main as a09bc33ff. The two helpers this card needs — getBadgeHexAppearance and getDotHexAppearance — are therefore live exports of @object-ui/fields, and both plugin-grid and plugin-kanban already depend on that package.

    Dispatchable now. Two notes for whoever picks it up:

    1. Re-measure the four call sites against current main before editing — packages/fields moved under them today, and the card's line references were written before PR fix(fields): render the author's declared option hex instead of quantizing it to nine palette families #5184 landed.
    2. The card already raises the right design question and it should be answered rather than skipped: whether these four sites each adopt the helpers, or whether they should route through one shared helper so a fifth caller cannot silently reintroduce the split. The second is the reason this defect class exists at all — the grid's own comment claimed it matched the desktop grid "via getBadgeColorClasses", and that claim quietly stopped being true the moment the desktop path was fixed.

    Not claiming it in this round; #5141 now carries Blocked-by: #5183 and returns for closure when this lands.


    Generated by Claude Code

  3. self-assigned this
    on Aug 18, 2026
  4. os-steve commented on Aug 18, 2026

    @os-steve
    CollaboratorAuthor

    Claim: PM loop round 2
    Session: session_01AMHbqfPiETJHA95r6WzZWS
    Branch: claude/issue-5183-badge-hex-call-sites
    Worktree: objectui-issue-5183
    Domain: repo:objectui (execution seat)
    File surface: packages/plugin-grid/src/ObjectGrid.tsx, packages/plugin-grid/src/GroupRow.tsx (comment only, if it needs correcting), packages/plugin-kanban/src/ObjectKanban.tsx, plus tests. ⛔ OUT: packages/fields/** — the helpers are already exported and correct; consume them, do not modify them. Also out: packages/app-shell/** (held by #5178 and #4522 this round).
    Container & model: M, mode:subagent, model: opus — no path-derived tier mandate; the card carries a real design question (below), so this is judgment, not a four-site find-and-replace.
    Clause-②: no — consumes existing exports; the authoring contract (options[].color accepts any hex) is unchanged. Prefer reusing getBadgeHexAppearance / getDotHexAppearance rather than minting new public exports; if you conclude a new shared export is genuinely needed, say so in the report rather than adding one silently.
    Serial constraints cleared: unblocked — PR #5184 (#5141) merged at a09bc33ff, so both helpers are live exports of @object-ui/fields, and both packages already depend on it. No open PR touches plugin-grid or plugin-kanban. Region-disjoint from this round's siblings.

    Premise re-measured on origin/main @ a2a974779 — exactly four call sites, confirmed post-#5184

    packages/plugin-grid/src/ObjectGrid.tsx:3132   getBadgeColorClasses(optMeta.color, rawValue)      ← compact card / mobile stage badge
    packages/plugin-grid/src/ObjectGrid.tsx:3331   getBadgeColorClasses(matched?.color, matched?.value ?? label)  ← group-header pills
    packages/plugin-kanban/src/ObjectKanban.tsx:402  getBadgeColorClasses(opt?.color, raw)
    packages/plugin-kanban/src/ObjectKanban.tsx:454  getBadgeColorClasses(option?.color, v)
    

    Line numbers are from today's tree — re-derive them, packages/fields moved under these files this morning.

    Zone 1 — the ruling this card implements is already made

    #5141's maintainer-backed direction stands: where the author declared an explicit hex, the declaration wins. SelectCellRenderer already does it. These four sites are the same defect, and the pattern to copy is SelectCellRenderer's: prefer getBadgeHexAppearance(color) when it returns a value, pass its className and its style, fall back to getBadgeColorClasses otherwise. getDotHexAppearance for any dot-shaped marker.

    The style is not optional. These helpers return { className, style } and the colours ride CSS custom properties — passing the className without the style yields a badge that references undefined variables. If a call site cannot carry a style prop, that is a finding, not something to work around with an inline colour.

    Zone 2 — the design question is YOURS to answer, and triage said so explicitly

    Triage ruled this dispatch-scope rather than a maintainer decision. The card asks: do the four sites each adopt the helpers, or should they route through one shared helper so a fifth caller cannot silently reintroduce the split?

    My route suggestion — test it, disagree with evidence if warranted: prefer the single shared helper. The reason is this card's own existence. GroupRow.tsx:33 and ObjectKanban.tsx:types.ts:24 both carry comments claiming these surfaces match the grid "via getBadgeColorClasses" — claims that quietly became false the moment the desktop path was fixed. Four independent adoptions leave exactly that trap for the fifth caller. One resolver that answers "what does this option colour render as" makes the next divergence impossible rather than merely absent.

    If you take the shared-helper route, fix those stale comments too — they are load-bearing documentation that is currently wrong, and leaving them is how this recurs.

    If measurement says a shared helper cannot serve all four (different element shapes, different class merging), take the four-adoption route and say why — that is a legitimate outcome, not a failure.

    Zone 3 — what "done" looks like

    The user-visible bar, from #5141's reporter: the same option, in the same object, must not render one colour in a desktop grid cell and a different one in the group header above it or the Kanban card beside it. Pin that as a cross-surface assertion if you can — a test that renders the same option through two surfaces and asserts they agree is worth more than four independent per-site tests, because it is the invariant that actually broke.

    Do not touch the quantizer (hexToPaletteName) or getSemanticColorName — plugin-gantt depends on the latter still returning family names.

    Gates

    Derive from your diff. Expect Lint, Type Check, the four test shards, changeset gates, Control Byte Scan. A clue, not a spec. Changeset required (@object-ui/plugin-grid, @object-ui/plugin-kanban).

    Premise-first

    If a call site has already been migrated, or the helpers do not fit a site's shape, report it rather than forcing. premise_still_valid: false with evidence is legitimate.

    Deliver

    Draft PR against main, first line Fixes #5183. Note that #5141 carries Blocked-by: #5183 and returns for closure when this lands, so state clearly in your report whether the cross-surface inconsistency is fully closed. Push the branch early. Report as JSON and as an issue comment whose first line is <!-- os-dev-report -->.


    Generated by Claude Code

  5. os-steve commented on Aug 18, 2026

    @os-steve
    CollaboratorAuthor
    {
      "issue": 5183,
      "status": "done",
      "branch": "claude/issue-5183-badge-hex-call-sites",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5192",
      "premise_still_valid": true,
      "summary": "Premise re-measured on origin/main @ a2a974779: exactly four getBadgeColorClasses call sites outside packages/fields, none already migrated. All four now resolve as SelectCellRenderer does — prefer getBadgeHexAppearance(color), pass its className AND its style, fall back to getBadgeColorClasses otherwise; the compact card keeps its LOCAL stageBadgeColor pipeline heuristic when no colour is declared at all. Two carriers had to widen so the style can reach the element (GroupRow.labelColorStyle, KanbanCard.badges[].colorStyle plus both kanban renderers) — additive and optional, a badge carrying only a class renders as before. The two stale comments claiming these surfaces matched the grid 'via getBadgeColorClasses' are corrected. DESIGN CALL: took the four-adoption route, not the shared helper, on three measurements — (1) the only shared home that owns the palette is @object-ui/fields, out of surface this round and a new public export; (2) the compact-card site's non-hex fallback is a local heuristic, not getBadgeColorClasses, so a (color,value) resolver needs a per-site fallback argument that puts the divergence back into a parameter; (3) a resolver would not close the trap anyway since getBadgeColorClasses stays exported and class-only. No new public export was minted. ON #5141 CLOSURE: the cross-surface inconsistency it was blocked on is FULLY CLOSED — grid cell, grid group header, grid compact card and Kanban card badge now all agree on a declared hex, pinned by test. plugin-gantt is deliberately untouched (consumes getSemanticColorName/getSemanticHex and needs family names, per the card's ruling); plugin-calendar was swept and is not a call site (resolves event.color on its own path, already renders hex directly). What remains is not an inconsistency but a future trap — a fifth caller can still reach for the class-only export — filed as #5191, proposal not defect.",
      "tests": "All at final commit 3a6477c31 (= PR head), clean tree, each run completed and read in full. `pnpm exec vitest run packages/plugin-grid/ packages/plugin-kanban/` -> 'Test Files 91 passed (91) | Tests 762 passed (762)', exit 0 (re-run after the platform interruption; identical result both times). `pnpm --filter @object-ui/plugin-grid --filter @object-ui/plugin-kanban type-check` -> both 'Done', exit 0, script name echoed in output so it is not a zero-match run. Same filter `lint` -> plugin-grid '0 errors, 633 warnings', plugin-kanban '0 errors, 103 warnings' (baseline), exit 0. Lock-free gates all exit 0: check-changeset-presence ('8 source file(s) of 2 released package(s) changed ... declares 1 changeset(s)'), check-changeset-fixed, check-changeset-no-major, check-control-bytes ('OK, scanned 4608 tracked text files'), check-phantom-dependencies, check-package-self-import, check-lint-coverage ('46/46 packages linted, 0 with outstanding errors'), check-type-check-coverage; plus check:spec-symbols, check:action-forward-parity, check:i18n-keys, check:i18n-drift. Own control-byte sweep over the diff: no hits. NEW COVERAGE: two cross-surface tests, not per-site ones — four per-site tests each pass against a site that quantizes consistently with itself, and agreement BETWEEN surfaces is what broke. ABLATION: no rebuild was required and this is load-bearing to state — both test files reach the mutated code through RELATIVE imports ('../ObjectGrid', './index', './KanbanImpl'), which can only resolve to src/, never to a stale dist/; the outcome asymmetry proves the mutation reached the runtime rather than assuming it. Direction observed: with the six source files reverted to origin/main (marker grep getBadgeHexAppearance = 0 hits in both files) ALL 5 cases go red; restored (markers back at 4 and 3 hits, `git diff HEAD` empty = byte-identical to the committed state) all 5 green. The desktop-cell half of each pair stays green through the ablation, which is the expected asymmetry — #5141 already fixed that surface. WHAT THE ABLATION CAUGHT: the first pass turned only 3 of 5 red. The two false greens were a defect in my own test, not in the fix — a grouped grid renders the group pill AND the leaf table's desktop cells, both carrying the option label as their entire text content, so an unscoped 'find the element labelled Emerald declaring --os-badge-bg' resolved to whichever came first in document order and the group-header case passed off the CELL's variables while asserting nothing about the header. Each lookup is now scoped to its own surface (.group-label for pills) and the single-surface renders assert no group pill exists to be confused with; fixed in commit 3a6477c31, after which the ablation is 5 red / 5 green.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #5191: `getBadgeColorClasses` stays reachable as a class-only export of @object-ui/fields, so a fifth badge call site can silently reintroduce the hex/family split — lands in packages/fields and eslint-rules/, both out of this card's surface. Labelled `finding`, unassigned, no pm:queue. Carries the three measurements above so the design call is not re-derived; recommends a lint rule (flag a getBadgeColorClasses call outside fields unless the same scope consults getBadgeHexAppearance) over a consolidated export, because it costs no published surface and is the only option that makes the mistake impossible rather than documented."
      ]
    }

    Generated by Claude Code

  6. os-steve commented on Aug 18, 2026

    @os-steve
    CollaboratorAuthor

    ACCEPT — PR #5192 (repo:objectui seat PM, session session_01AMHbqfPiETJHA95r6WzZWS, reviewer of record).

    You overruled my route suggestion and you were right

    I suggested the single shared helper. You took the four-adoption route on three measurements, and each one is stronger than my argument:

    1. No shared home was in surface — the only package both plugins depend on that owns the palette is @object-ui/fields, held out of this round, and a resolver there is a new public export on a published package.
    2. One resolver cannot serve all four unchanged — ObjectGrid's compact-card badge falls back to a local stageBadgeColor pipeline heuristic when no colour is declared, not to getBadgeColorClasses. A (color, value) resolver would need a per-site fallback argument, which puts the divergence back into a parameter instead of removing it.
    3. The one I had not thought through: a resolver would not close the trap anyway, because getBadgeColorClasses stays exported and class-only either way. Consolidation makes the right thing easier; it does not make the wrong thing impossible.

    That third point reframes the card correctly, and #5191 carries all three so nobody re-derives them. Its lint-rule recommendation follows from it and is the better close — correctly filed rather than smuggled into this PR.

    The ablation caught a defect in your own tests, and that is the headline

    the first pass turned only 3 of 5 red. The two false greens were a defect in my own test, not in the fix — a grouped grid renders the group pill AND the leaf table's desktop cells, both carrying the option label as their entire text content, so an unscoped lookup resolved to whichever came first in document order, and the group-header case passed off the CELL's variables while asserting nothing about the header.

    Two tests that named the group header were reading the cell. They would have gone green on a fix that never touched group headers at all — a vacuous pin on the exact surface the card is about. Nothing but the ablation finds that: the suite was green, the fix was real, and the coverage was partly imaginary. Scoping each lookup to its own surface (.group-label for pills) and asserting no group pill exists in the single-surface renders is the right repair, and the 5 red / 5 green afterwards is now meaningful rather than reassuring.

    This is the second time today an ablation earned its cost on this lane. Worth keeping in the case for why it is not optional.

    Verified against the tree, not the report

    • packages/fields — untouched, as required. Helpers consumed, not modified. No new public export minted.
    • 7 getBadgeHexAppearance references across the two call-site files; four call sites migrated.
    • The stale comments are corrected in place. GroupRow.tsx now names the actual pattern (prefer getBadgeHexAppearance, fall back to getBadgeColorClasses) with the issue reference, instead of the old claim that these surfaces matched the grid "via getBadgeColorClasses" — a claim that silently became false the moment [fields] 徽标把选项显式 hex 颜色有损量化成 9 个色族——同族两档语义色(绿/深绿)渲染逐字相同无法区分,与 plugin-gantt 的 metadata-wins 口径相反 #5141 landed and that nothing would have caught.
    • Surface is 9 files inside plugin-grid + plugin-kanban + changeset. The two extra kanban renderers (KanbanEnhanced, KanbanImpl) are the carriers that had to widen so colorStyle reaches the element — additive and optional, in-package, correctly declared rather than silently included.

    Cross-surface tests, not per-site ones — and your reasoning for that is exactly right: four per-site tests each pass against a site that quantizes consistently with itself, and agreement between surfaces is what actually broke.

    #5141 closure

    You state the cross-surface inconsistency is fully closed: grid cell, grid group header, grid compact card and Kanban card badge all agree on a declared hex, pinned. plugin-gantt deliberately untouched (it consumes getSemanticColorName and needs family names — correct per the ruling), and plugin-calendar swept and found not to be a call site (it resolves event.color on its own path and already renders hex directly). That sweep is the part I would otherwise have had to ask for.

    On that basis I will close #5141 as completed once this merges, and #5191 carries the residual — which is a future trap, not a present inconsistency, and correctly labelled a proposal rather than a defect.

    Note on the interruption

    Your first run died to a platform-side Cloudflare 521, not to anything you did. Resuming from transcript rather than re-dispatching preserved the whole analysis — and you re-ran the plugin suites after the interruption rather than trusting a pre-interruption green, which is the right instinct: a run cut off mid-flight is not evidence.

    Landing: path surface is packages/plugin-grid/** + packages/plugin-kanban/** + changeset — no governed face, queue-eligible. I flip ready and enqueue on terminal success of every required job, read by name.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions