Skip to content

[fields] 徽标把选项显式 hex 颜色有损量化成 9 个色族——同族两档语义色(绿/深绿)渲染逐字相同无法区分,与 plugin-gantt 的 metadata-wins 口径相反 #5141

Description

@baozhoutao

一句话:选项集里作者明确写的 hex 颜色,徽标渲染时被有损量化成 9 个固定色族,同一色族里的两个档次(比如「绿」和「深绿」)渲染出来逐字节相同,用户无法区分

Part of steedos-labs/os-project-titanwind-ehr#1504

现象

业务对象的 select 字段选项集声明了两档绿色:

options: [
  { label: '进行中', value: 'in_progress', color: '#2ecc71' },  // 绿
  { label: '已完成', value: 'completed',   color: '#1e8449' },  // 深绿
]

列表/详情里两个状态的徽标渲染结果完全相同——class 逐字相同(bg-green-50 text-green-700 border-green-200),computed style 三项(color / background / border)逐值相同。把深绿再压深到 #145A32 复测,输出仍一字不变。终端用户在列表上无法区分「进行中」和「已完成」。

根因(main 分支源码定位)

packages/fields/src/index.tsx(main,getBadgeColorClasses 一带):

  1. resolveColorName(color):hex 声明交给 hexToPaletteName();
  2. hexToPaletteName():算 HSL,饱和度 sat < 0.22 归 gray,否则按色相分桶——hue < 15 red / < 30 orange / < 60 yellow / < 170 green / < 238 blue / < 250 indigo / < 295 purple / else pink,共 9 个色族;
  3. BADGE_COLOR_MAP 每族只有唯一一套 Tailwind 类,green = bg-green-50 text-green-700 border-green-200 …。

#2ecc71 与 #1e8449 色相都 ≈ 145°,双双落进 green 桶 → 输出相同。appearance: 'dot' 走 DOT_COLOR_MAP,同样每族一档,同病。

也就是说:作者显式声明的精确色值在徽标链路上必然有损,信息量上限是 9 档;同族内的任何语义分档(浅绿/深绿、亮红/暗红……)都表达不出来,且 app 侧没有任何通道能下发精确色值(非 # 且非族名的值被丢弃,退回按值名的语义映射)。

期望(引用的契约)

spec 里 options[].color 接受任意 hex,语义是「作者为该选项声明的颜色」;且同仓库 plugin-gantt 对 colorField 的处理已经是「原值直用 inline style」,源码注释明写 explicit colorField value (hex or semantic name) — metadata wins。徽标与甘特对同一类作者声明采取了相反的口径。

诉求:声明了精确 hex 时按原值渲染(inline style,soft-pill 观感可以由 hex 现算浅底/深字),与 plugin-gantt 的 metadata-wins 口径对齐;或至少每色族提供深/浅两档。

复现版本

  • @objectstack/console@17.0.0(GA,打包产物 dist/assets/ui-components-*.js 中量化逻辑与 main 源码一致,已逐字核对);
  • main 分支 packages/fields/src/index.tsx 现行代码同样成立(2026-08-18 拉取核对)。

我们没有做的

app 侧未做任何 workaround(换色族=改业务口径,需求方不接受;conditionalFormatting 是整行 CSS 且优先级盖不过徽标类;列声明无单元格样式位)。app 侧 issue 已按「平台能力受限」挂起等本单。

Activity

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

    @os-support-ai
    Collaborator

    Triage: queued as Bug — an author's explicit hex option colors are lossily quantized to 9 color families so two same-family semantic colors render byte-identical, which both loses declared metadata and contradicts the metadata-wins line plugin-gantt established. 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. (Triage seat, session session_019gCKd9EZfQ6MbGTnMHHJvW.)


    Generated by Claude Code

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

    @os-steve
    Collaborator

    Claim: PM loop round 1
    Session: session_01AMHbqfPiETJHA95r6WzZWS
    Branch: claude/issue-5141-badge-hex-fidelity
    Worktree: objectui-issue-5141
    Domain: repo:objectui (execution seat; this repo has no domain:* lanes)
    File surface: packages/fields/src/index.tsx (the hexToPaletteName / resolveColorName / getBadgeColorClasses / BADGE_COLOR_MAP / DOT_COLOR_MAP cluster, :1080-1280) plus new/changed tests in packages/fields (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus — dispatch-gates.mjs --tier packages/fields/src/index.tsx reports no path-derived mandate, so this is my judgment: the card asks for a rendering-contract decision with two named routes, which is design judgment, not a mechanical edit.
    Clause-②: no — options[].color already accepts any hex; this changes how an already-accepted value is rendered, not what is accepted or refused, and widens no public surface.
    Serial constraints cleared: packages/fields/src/index.tsx last moved in 8396656 (#5027, merged); no open PR and no in-flight claim touches packages/fields. Disjoint from this round's other two cards (packages/app-shell, packages/plugin-map).

    Premise check, run now against origin/main @ 072085de — the card's root-cause reading is confirmed verbatim:

    packages/fields/src/index.tsx:1134  function hexToPaletteName(hex: string): string | undefined
    packages/fields/src/index.tsx:1145    if (sat < 0.22) return 'gray';
    packages/fields/src/index.tsx:1151    if (hue >= 345 || hue < 15) return 'red';
    :1152 orange   :1153 yellow   :1154 green (hue < 170)   :1155 blue   :1156 indigo   :1157 purple
    packages/fields/src/index.tsx:1166    if (color.charAt(0) === '#') return hexToPaletteName(color);
    packages/fields/src/index.tsx:1080  const BADGE_COLOR_MAP  (one class set per family)
    packages/fields/src/index.tsx:1096  const DOT_COLOR_MAP    (same, for appearance: 'dot')
    

    #2ecc71 and #1e8449 both land in the single green bucket, so the reporter's byte-identical render follows directly. The comparator the card cites is also real: packages/plugin-gantt/src/ObjectGantt.tsx:736 — explicit colorField value (hex or semantic name) — metadata wins.

    Carrying the triage stop-clause (comment-5326107092) as non-negotiable:

    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.

    Look for that rationale first and report what you find either way — an absence measured and stated is worth as much as a hit. Note the shape of the risk: the quantizer exists so badges inherit theme-consistent Tailwind classes, and a hex-driven inline style leaves the design system's control. That is a real cost, not a bug; the question is whether it outranks losing declared author metadata.

    PM ruling (this part is not up for re-litigation): where the author declared an explicit hex, the declaration wins — the platform must not silently discard authored metadata, and the gantt line already sets the house口径. The route to get there is NOT ruled — the card names two (inline style computed from the hex; or two tiers per family) and either is open to you, as is a third you can argue for on measurement. Pick on evidence and say why.

    Explicit fork clause: if you find the quantization is load-bearing for theming/contrast in a way that makes honoring the hex actively wrong (e.g. dark-mode contrast falls apart, or a documented design-system constraint exists), stop and report needs_decision with the evidence — do not force the fix, and do not quietly fall back to the other route. A fork reported is a good run.

    Do not lose the accessibility half: whatever route you take, a soft-pill badge needs a readable foreground against its computed background. If you derive light background / dark text from the hex, pin contrast, not just colour identity. The appearance: 'dot' path (DOT_COLOR_MAP) has the same defect and is in surface — the card names it.


    Generated by Claude Code

  4. os-steve commented on Aug 18, 2026

    @os-steve
    Collaborator
    {
      "issue": 5141,
      "status": "done",
      "branch": "claude/issue-5141-badge-hex-fidelity",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5184",
      "premise_still_valid": true,
      "summary": "Stop-clause discharged: NO recorded rationale for the quantization exists. hexToPaletteName's own docstring says it exists so the explicit author colour is not ignored (approximation in service of metadata-wins); BADGE_COLOR_MAP's docstring records a rationale for the soft-pill LOOK only, which this change preserves; git archaeology is inconclusive because the checkout is shallow (51 commits). A documented constraint DOES exist (AGENTS.md section 2 and skills/objectui/rules/styling.md forbid inline styles and arbitrary colour values), but I read it as constraining HOW not WHETHER: it is not mechanically enforced (no ESLint rule; 254 style={{ in packages/*/src), it targets component-authored static styling, and data-driven author colour already reaches inline style in plugin-gantt bars, conditionalFormatting, and SchemaForm.tsx's Studio option swatch which paints this exact metadata key from the raw hex. Route (a) chosen but NOT its naive form: measurement rejected the fixed pale tint, which renders the reported pair byte-identical (#eef7f2 for both) because at -50 lightness hue/saturation differences round away in 8-bit. The declared lightness (the only dimension separating #2ecc71 l=0.49 from #1e8449 l=0.32) is therefore carried by the SURFACE, with the label derived against it. Colours ship as CSS custom properties consumed by static Tailwind utilities, so light/dark remain real dark: variants (verified in the built sheet) rather than a theme-blind inline background. Both appearance:'badge' and appearance:'dot' fixed. Non-hex paths (family names, semantic map, hashToColor) and getSemanticColorName/Gantt untouched. PR says 'Part of' because getBadgeColorClasses is class-only and cannot carry a runtime colour, so four call sites outside the ruled file surface still quantize.",
      "tests": "All at HEAD 26e78af0f, working tree clean at that sha (git status --porcelain empty). (1) pnpm exec vitest run packages/fields/ -> 'Test Files 106 passed (106) / Tests 1795 passed (1795)'. (2) new file: pnpm exec vitest run packages/fields/src/__tests__/badge-hex-fidelity-5141.test.tsx -> 'Test Files 1 passed (1) / Tests 54 passed (54)'. (3) pnpm --filter @object-ui/fields type-check -> echoed 'tsc --noEmit && tsc -p tsconfig.test.json', exit 0 (script name echoed, so not a zero-match silent pass). (4) pnpm --filter @object-ui/fields lint -> '814 problems (0 errors, 814 warnings)', pre-existing baseline; my two files add 2 no-explicit-any warnings matching the file's own test conventions and 3 react-refresh/only-export-components of the 87 the file already emits. (5) check:control-bytes -> OK, 4593 tracked files; plus grep -naP self-scan of both changed files: clean. (6) check:self-import, check:phantom-deps -> OK. (7) check-changeset-presence -> OK (1 changeset for 2 changed source files of 1 released package); check-changeset-no-major -> OK. (8) check:published-dist exceeded 300s (needs a full-farm build) and is left to CI; its specific concern was checked directly instead: packages/fields/dist has no __tests__ and no *.test.* entries, and dist/index.d.ts carries the new exports. ABLATION: replacing the lightness-tracking tint with the fixed pale tint turned the pin red with 'AssertionError: expected #eef7f2 not to be #eef7f2 / Tests 1 failed | 53 passed (54)', reproducing the original defect exactly. NO REBUILD NEEDED OR DONE for either leg: the test imports the subject by relative path ('../index'), not through the package exports, so vitest transforms source directly and no dist artifact participates. The fix was committed before the ablation, so the restore was git checkout of the branch (byte-identical, confirmed clean). Note the byte-inequality assertion still PASSED under ablation while the perceptual one failed, which is why the pin is a CIE76 deltaE threshold (5.0 vs a ~2.3 JND) with the colour maths written independently of the implementation. Contrast stress sample of 20 declarations incl. pure white/black/neon yellow: worst measured 4.52:1 light, 4.51:1 dark, both above the 4.5 AA floor; dot floor 1.9:1 derived by measuring today's -500 shades (yellow-500 weakest at 1.92:1). Tailwind emission verified in packages/fields/dist/index.css: all 8 utilities present, dark ones gated as ':where(.dark, .dark *)'.",
      "gates_run": [
        "vitest packages/fields (106 files / 1795 tests) - pass",
        "vitest new regression file (54 tests) - pass",
        "type-check @object-ui/fields - pass",
        "lint @object-ui/fields - pass (0 errors)",
        "check:control-bytes - pass",
        "check:self-import - pass",
        "check:phantom-deps - pass",
        "check-changeset-presence - pass",
        "check-changeset-no-major - pass",
        "check:published-dist - NOT RUN locally (full-farm build, >300s); concern verified directly on packages/fields/dist"
      ],
      "files_changed": [
        "packages/fields/src/index.tsx",
        "packages/fields/src/__tests__/badge-hex-fidelity-5141.test.tsx",
        ".changeset/badge-declared-hex-fidelity-5141.md"
      ],
      "deviations": [
        "scripts/pm/dispatch-gates.mjs does not exist in objectui (it is an objectstack script), so the gate list was re-derived by hand from the 3-file diff rather than machine-derived.",
        "PR first line is 'Part of #5141', not 'Fixes': getBadgeColorClasses returns a class string and cannot carry a runtime colour, so four call sites outside the ruled file surface (ObjectGrid compact card badge, ObjectGrid group-header pills, ObjectKanban x2) still quantize. Filed as #5183. PM may flip to Fixes if the reported symptom alone is the bar.",
        "Three new exports added to @object-ui/fields (deriveHexBadgePalette, getBadgeHexAppearance, getDotHexAppearance) so the out-of-surface call sites can adopt this without duplicating the colour maths.",
        "check:published-dist not run locally - see gates_run."
      ],
      "open_questions": [
        {
          "question": "NON-BLOCKING, maintainer policy: AGENTS.md section 2 and skills/objectui/rules/styling.md state a flat 'no inline styles / no hard-coded colour values' rule, but the repo already carries 254 style={{ uses and three data-driven author-colour paths (gantt bars, conditionalFormatting, Studio's option swatch), and this change adds a fourth. The written rule and the practised rule disagree.",
          "options": [
            "A: amend the styling docs to carve out data-driven AUTHOR-DECLARED colour explicitly (inline style permitted only to publish CSS custom properties consumed by static utilities, as done here), keeping the ban on component-authored static colour",
            "B: leave the docs flat and treat every such case as a one-off exception argued in its own PR",
            "C: add an ESLint rule enforcing the flat ban, which would require reverting this fix and re-opening the ruling"
          ],
          "recommendation": "A. The rule's stated rationale is that semantic tokens support theming and dark mode automatically, and that rationale is fully satisfied here (colours ride CSS custom properties; light/dark stay real dark: variants). Leaving the doc flat while practice diverges is what made this card's stop-clause ambiguous in the first place, and B guarantees the same ambiguity for the next agent. C contradicts the PM ruling that declared metadata wins."
        }
      ],
      "out_of_scope_findings": [
        "filed as #5183: ObjectGrid (compact card badge, group-header pills) and ObjectKanban (x2) call getBadgeColorClasses, which is class-string-only, so they still quantize the declared hex - after this PR one option colour renders two ways depending on surface"
      ]
    }

    Generated by Claude Code

  5. os-steve commented on Aug 18, 2026

    @os-steve
    Collaborator

    ACCEPT (engineering) — but HELD out of the merge queue pending a maintainer answer. PR #5184 (repo:objectui seat PM, session session_01AMHbqfPiETJHA95r6WzZWS, reviewer of record).

    The stop-clause was discharged correctly

    Triage's clause was: "If the implementer finds the quantization is a deliberate design system constraint with a recorded rationale, stop and flag back." That was checked against the right target and answered no — and the evidence cuts the other way: hexToPaletteName's own docstring says it exists so the author's explicit colour is not ignored, i.e. the quantization was already an attempt at metadata-wins, not a decision to discard it. BADGE_COLOR_MAP's docstring records a rationale for the soft-pill look only, which this change preserves. No recorded rationale for the quantization exists. Correctly discharged, not waved past.

    What makes this better than the obvious implementation

    The naive reading of route (a) — "compute a pale pill from the hex" — was rejected on measurement, not taste: a fixed pale tint renders the reported pair as #eef7f2 for both, because at −50 lightness the hue/saturation difference rounds away in 8-bit. That would have closed this card on paper with the user-visible bug fully intact. The declared lightness is the only dimension separating #2ecc71 (l=0.49) from #1e8449 (l=0.32) — their hues differ by 0.1° — so lightness is carried by the surface, putting the pair ΔE 8.0 apart instead of 2.3.

    The pin follows from that and is the part I'd single out: it asserts a CIE76 ΔE ≥ 5.0 (against a ~2.3 JND), with the colour maths written out independently of the implementation so it cannot agree with the code by construction. The ablation then proved the pin's own necessity — with the fix reverted, the byte-inequality assertion still passed while the perceptual one failed. A !== pin would have shipped the bug. That is the difference between testing the fix and testing the property.

    Both concerns I made non-negotiable are honoured, and measured rather than asserted:

    • Theming stayed with the design system. Colours ride CSS custom properties consumed by static Tailwind utilities, so light/dark remain real dark: variants — verified in the built sheet (:where(.dark, .dark *)), not inferred. An inline background would have rendered identically in dark mode; this doesn't.
    • Contrast is pinned in both themes, across 20 declarations including pure white, pure black and neon yellow: worst measured 4.52:1 light and 4.51:1 dark against the AA 4.5 floor. The dot floor (1.9:1) was derived by measuring today's -500 shades (yellow-500 weakest at 1.92:1) rather than invented — so a declared dot can never be less visible than the palette dot it replaces. appearance: 'dot' was fixed too, as the card required.

    Non-hex paths (family names, semantic map, hashToColor) are untouched and pinned as such, and getSemanticColorName is unchanged so the Gantt path is unaffected.

    Honesty markers I want on the record: check:published-dist was not run locally (>300s full-farm build) and is declared as not-run with its specific concern verified directly instead; and git archaeology was reported as inconclusive because the checkout is shallow rather than as a clean negative — the same 51-commit trap that produced false zeros for me earlier today.

    Part of #5141 is the correct linkage, not Fixes: getBadgeColorClasses is class-string-only and cannot carry a runtime colour, so four call sites in plugin-grid / plugin-kanban still quantize. Well filed as #5183 (pm:blocked), and the honest consequence is stated there — after this lands, one option colour renders two ways depending on surface, which is non-uniformly wrong where before it was uniformly wrong. That is a real cost of the split and it is named rather than hidden.

    Why it is held rather than enqueued

    The blocking reason is a governed-surface policy conflict the dev raised and cannot resolve. AGENTS.md §2 and skills/objectui/rules/styling.md state a flat "no inline styles / no hard-coded colour values" rule. The dev read it as constraining how rather than whether, with real evidence: it is not mechanically enforced (no ESLint rule; 254 style={{ in packages/*/src), it targets component-authored static styling, and data-driven author colour already reaches inline style in three existing paths — plugin-gantt bars, conditionalFormatting, and Studio's own option swatch, which paints this very metadata key from the raw hex. That reasoning is defensible, and this change satisfies the rule's stated rationale (theming and dark mode still work automatically, via custom properties).

    But it is still a written rule on a governed surface, and one of the options on the table (enforce the flat ban) would require reverting this work. Landing first and asking after would be the wrong order. Escalated to the maintainer; I am not deciding it.

    Clause ② — recorded rather than glossed, because my own declaration is implicated. I declared Clause-②: no at claim time on the reasoning that this changes how an already-accepted value is rendered. The rendering half of that still holds. What the diff added that my prediction did not anticipate is three new public exports (deriveHexBadgePalette, getBadgeHexAppearance, getDotHexAppearance, plus two interfaces), correctly declared as a minor changeset.

    My determination: the no stands, because clause ②'s limbs and its stated purpose both target the contract face — what an author may declare and what is accepted or refused. That is unchanged: options[].color accepted any hex before and after; the fix makes the renderer honour what was already accepted. The new exports are sibling-package helper API, not authoring surface, and cannot poison a downstream author. Stating it explicitly so the call is auditable rather than silent: had the diff altered what options[].color accepts, this would be a breach and would need a contract review by a seat other than mine.

    Noting an infrastructure gap while I am here: needs:contract-review does not exist as a label in this repo, so the enqueue gate has no carrier in objectui even when it should fire. I have not minted it — a label with no established reader is the skip-changeset trap — but it means the clause-② gate is currently unenforceable here. Reported for the triage seat.

    Status: PR #5184 stays draft, not enqueued. CI convergence and landing follow the maintainer's answer on the styling rule.


    Generated by Claude Code

  6. os-steve commented on Aug 18, 2026

    @os-steve
    Collaborator

    Maintainer ruling recorded — PR #5184 is UNBLOCKED.

    Maintainer, 2026-08-18, answering the styling-rule conflict this card's implementation surfaced. Option A selected, verbatim as presented: 「A:修订文档,明写数据驱动豁免(推荐)」 — amend the styling docs to carve out data-driven author-declared colour explicitly (inline style permitted only to publish CSS custom properties consumed by static utilities, as done here), keeping the ban on component-authored static colour. Provenance: repo:objectui seat PM session session_01AMHbqfPiETJHA95r6WzZWS, maintainer answering in-session.

    Consequences, both of which I am acting on:

    1. This PR's approach is sanctioned. The hold is lifted. PR fix(fields): render the author's declared option hex instead of quantizing it to nine palette families #5184 lands by the normal route once CI converges (currently 17 terminal / 4 running — three test shards and Type Check). No change to the diff is required: it already implements exactly the shape the ruling blesses — the colour rides a CSS custom property, the utilities stay static, and dark: variants remain real.
    2. The docs must now be brought into line, or the next agent hits the identical ambiguity and the triage stop-clause stays blunt for the whole class. Filed as styling rules: carve out data-driven author-declared colour, so the written rule matches the practised one #5185 and dispatched in this same round: AGENTS.md §2 and skills/objectui/rules/styling.md get the carve-out. That is a governed surface — I will review it and push it, but it goes to the maintainer for a human merge and I will not enqueue it.

    The dev's own recommendation matched the ruling and its reasoning was the deciding evidence: the rule's stated rationale is that semantic tokens make theming and dark mode work automatically, and this implementation satisfies that rationale rather than evading it. Leaving the doc flat while practice diverged is what made this card's stop-clause ambiguous in the first place.


    Generated by Claude Code

  7. removed their assignment
    on Aug 18, 2026
  8. os-steve commented on Aug 18, 2026

    @os-steve
    Collaborator

    LANDED (partial) — pm:dispatched → pm:blocked, assignee cleared. repo:objectui seat PM, session session_01AMHbqfPiETJHA95r6WzZWS.

    Blocked-by: #5183

    Delivered: PR #5184, merged and on main as a09bc33ff. Verified by commit, not by report. SelectCellRenderer now renders an explicitly declared options[].color hex as declared — for both appearance: 'badge' and appearance: 'dot' — with contrast pinned in both themes and the colours carried by CSS custom properties so dark: stays a real variant.

    This card stays open deliberately, and is blocked rather than queued. The PR was correctly linked Part of, not Fixes, so merging did not close it — and nothing on this card is directly dispatchable right now, which is why pm:queue would be a false entry. Its remaining work is #5183, so the honest state is blocked on that card, with a machine-readable Blocked-by: line above for the unlock sweep to find.

    What is still not true for the reporter, stated plainly rather than declared victory: getBadgeColorClasses returns a class string and cannot carry a runtime colour, so four call sites in other packages still quantize — plugin-grid's compact card badge and group-header pills, and two in plugin-kanban. The consequence is that the same option colour now renders one way in a desktop grid cell and another in the group header above it or a Kanban card beside it. Before this PR the repo was uniformly wrong; it is now non-uniformly wrong, which is arguably harder for an author to reason about. That is a real cost of splitting the work and it is why this card does not close yet.

    @baozhoutao — the list/detail badge case from the original report is fixed on main; if your app also renders these options in a Kanban board, a grid group header, or the compact/mobile card view, those follow in #5183.

    Also delivered from this card's implementation: the styling-rule conflict it surfaced went to the maintainer, who ruled (option A) that the docs should carve out data-driven author-declared colour. That is #5185, drafted as PR #5188 and now awaiting a human merge as a governed surface.


    Generated by Claude Code

  9. os-steve commented on Aug 18, 2026

    @os-steve
    Collaborator

    Closing as completed — the blocker cleared and the residual is closed. repo:objectui seat PM, session session_01AMHbqfPiETJHA95r6WzZWS.

    Blocked-by: #5183 is discharged: PR #5192 merged, on main as feb6b161d. Verified by commit, not by report.

    What is now true end to end, which is what this card actually asked for: an author's explicitly declared options[].color hex renders as declared on every badge surface — the desktop grid cell (PR #5184), the grid group-header pill, the grid compact/mobile card badge, and the Kanban card badge (PR #5192) — pinned by cross-surface tests rather than four independent per-site ones, because agreement between surfaces is the invariant that broke.

    The reporter's original symptom is gone in both directions: #2ecc71 and #1e8449 no longer render byte-identically, and they are separated by a measured perceptual margin (ΔE ≥ 5.0 against a ~2.3 just-noticeable difference) rather than merely being unequal. Contrast is pinned in both themes, and appearance: 'dot' got the same treatment.

    Two things deliberately not done, recorded so they are not mistaken for oversights:

    • plugin-gantt is untouched — it consumes getSemanticColorName and needs family names, per this card's own ruling.
    • plugin-calendar was swept and is not a call site; it resolves event.color on its own path and already renders the hex directly.

    Residual is a different thing and is tracked separately. #5191 records that getBadgeColorClasses remains exported and class-only, so a fifth badge surface could still reintroduce the split. That is a future trap, not a present inconsistency, and it is filed as a proposal with a recommended lint rule rather than as a defect — which is why it does not hold this card open.

    @baozhoutao — this is complete on main for the list/detail badges, Kanban cards, grid group headers and the compact/mobile card view. Reopening is free if your deployment still shows two same-family colours rendering alike anywhere.

    Also delivered out of this card: the styling-rule conflict its implementation surfaced went to the maintainer, who ruled option A; the docs carve-out is #5185 / PR #5188, reviewed green and awaiting a human merge as a governed surface.


    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

No one assigned

    Labels

    bugSomething isn't working

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions