Skip to content

Commit 8b90442

Browse files
refactor(fields)!: retire the hex badge palette pair and UrlCellRenderer from the entry (#31352)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XHcRpTa5WzneF2w7pdWz8QL)_ Part of #25921 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none (none of the three names appears under `skills/**` or `content/docs/**`) ## 维护者速读 - 改了什么:`@object-ui/fields` 的根入口不再导出 3 个名字:`deriveHexBadgePalette`、`HexBadgePalette`、`UrlCellRenderer`;这是 #25921 的第 16 批。 - 为什么改:两个仓库里包外没有任何代码、文档或技能提到这三个名字;它们的功能另有公开入口(`getBadgeHexAppearance` / `getDotHexAppearance`、`getCellRenderer('url')`)。 - 风险与回滚:代码都留在包内,徽章颜色和 url 单元格渲染不变;颜色计算从 `index.tsx` 原样搬到内部模块 `hexBadgePalette.ts`。外部 npm 宿主若从入口 import 这三个名字会编译失败(未实测,changeset 写了 BREAKING 与替代写法)。`!` patch,回滚即 revert 本 PR。 - 先看:`ui/packages/fields/src/hexBadgePalette.ts`(搬出的颜色计算,代码逐字未改,只动了注释)、`ui/packages/fields/src/index.tsx`(删掉的块、新 import、`UrlCellRenderer` 去掉 `export`)、`.changeset/25921-fields-hex-palette-url-renderer.md`。 - 两分钟验证:`node scripts/check-adr-0087-registration.mjs --base origin/main`,预期三个名字都 `verified … (export, ui/packages/fields/src/index.tsx)`、退出 0。 - 需要你做的:无,席位落地。 ## What changes Batch 16 as named in the seat's amendment 6716064637 on claim 6716009586 (census `/mnt/project-files/pm-orders/ui/25921-b16-census.md`, `origin/main` `604a2b3b`, hotcrm `6961ab1`). - **`ui/packages/fields/src/hexBadgePalette.ts`** (new, not re-exported by the entry): the pure colour math of the "Explicit-hex badge rendering (objectui#5141)" block moves here verbatim from `index.tsx`: the two contrast floors, `Rgb`, the two surfaces, `clampNum`, `parseHexColor`, `rgbToHsl`, `hslToRgb`, `rgbToHex`, `relativeLuminance`, `contrastRatio`, `readableOnSurface`, `HexBadgePalette` and `deriveHexBadgePalette`. Every helper was read only inside that span. The only text changes are a new module docblock and the `DOT_CONTRAST_FLOOR` comment, which now says `DOT_COLOR_MAP` lives in `index.tsx` (two lines). - **`ui/packages/fields/src/index.tsx`**: the moved span is replaced by a two-line pointer comment; `deriveHexBadgePalette` is imported from `./hexBadgePalette.js` and not re-exported. The block's rationale comment, the Tailwind class strings, `HexColorAppearance`, `getBadgeHexAppearance` and `getDotHexAppearance` stay. `UrlCellRenderer` loses its `export` in place (it reads `EmptyValue`, `coerceToSafeValue`, `isBlankCellText`, `isPlainObjectValue` and `TruncatedText` from the entry's own scope, so moving it would make an import cycle) and stays the standard map's `url` renderer. - **Tests**: `__tests__/badge-hex-fidelity-5141.test.tsx` imports `deriveHexBadgePalette` from `../hexBadgePalette`; `__tests__/cellRenderers.linkEllipsis-12523.test.tsx` reads the url renderer through `getCellRenderer('url')`, as `cellRenderers.fabricatedValue-8490.test.tsx` already does. No assertion changes. - **Changeset** `.changeset/25921-fields-hex-palette-url-renderer.md`: `!` patch, BREAKING, FROM/TO for each name, `runtime-export-retired` ×3 (FROM spans 1:1), both declaration lines. Every other entry name stays exported. `ColorSwatchCellRenderer` stays (README-taught), and so do `LocationCellRenderer` and `AddressCellRenderer` (prose in `ui/packages/plugin-detail/src/emptiness.ts` names them; seat ruling in 6716064637). ## Tests - Census (one `rg -w -F` pass per repo over the 212 entry names): the three have zero hits outside `ui/packages/fields/`, in objectstack and in hotcrm. Positive controls in the same module (`git grep -l -w -F` on `origin/main` outside the package, files): `getBadgeHexAppearance` 14, `getBadgeColorClasses` 13, `EmailCellRenderer` 4, `PhoneCellRenderer` 3. Rule 3: no kept export's emitted declaration names them. - Reverse probe of the entry (TypeScript checker export list of `src/index.tsx`): 212 names before, 209 after; exactly these three gone, none added. Built `dist/index.js` imported at runtime: none of the three is a key; `getBadgeHexAppearance`, `getDotHexAppearance`, `getCellRenderer` and `EmailCellRenderer` are, and `getBadgeHexAppearance('#1e8449')` still returns the six custom properties. A probe file compiled against the built `dist/index.d.ts` answers TS2305 for `deriveHexBadgePalette` and `HexBadgePalette`, TS2724 for `UrlCellRenderer`, and nothing on the control line. - `pnpm --workspace-concurrency=2 --filter '@object-ui/fields...' build && pnpm --filter @object-ui/fields run type-check` under the lock: VERDICT command-exit 0. - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/fields/` under the lock: VERDICT command-exit 0, Test Files 299 passed | 1 skipped (300), Tests 4501 passed | 7 skipped (4508). - No test added or removed, and no ablation: the diff moves code and stops re-exporting three names; no tested behaviour changes. ## Acceptance notes - Left on the card: `ColorSwatchCellRenderer` (README-taught), `LocationCellRenderer` and `AddressCellRenderer` (plugin-detail prose). After this batch the `@object-ui/fields` entry holds no further free, untaught, zero-hit name. ## Verification <details><summary>Derived gates on HEAD a37cae1 (37/37, all exit 0, --ran reconciled)</summary> `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran` → "37 derived famil(ies) accounted for — 37 run, 0 NOT-MEASURED". - `node scripts/check-adr-0087-registration.mjs --base origin/main` exit 0: all three `verified … (export, ui/packages/fields/src/index.tsx)`; `--self-test` exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main` exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main` exit 0; `--self-test` exit 0 - `node scripts/check-closing-keyword-parity.mjs` exit 0; `--self-test` exit 0 - `node scripts/check-comment-mask-corpus.mjs`, `check-sdui-manifest.mjs` (+ `--self-test`), `ci/snapshot-next.mjs --self-test`, `pm/release-rehearsal-clone.mjs --self-test`, `release-pending-publish.mjs --self-test`: exit 0 - `pnpm --dir ui exec vitest run scripts/` under the lock → Test Files 170 passed | 2 skipped (172), Tests 4996 passed | 2 skipped (4998) - `pnpm --filter @objectstack/spec run check:duration-unit-keys`, `check:error-code-provenance`; `pnpm check:changeset-gate-self-tests`, `check:closed-ceremony-text`, `check:cross-package-test-inputs`, `check:dispatcher-error-vocabulary`, `check:driver-memory-census`, `check:engine-double-contract`, `check:gitlink-declared`, `check:nul-bytes`, `check:published-files`, `check:query-options-erasure`, `check:refd-timer-probe`, `check:registry-log-declared`, `check:rest-log-spy-declared`, `check:skill-identifier-liveness`, `check:test-source-alias`, `check:tier-file-adoption`, `check:type-check-coverage`, `check:undeclared-dep-imports`, `check:watch-hint-literal`, `check:where-matcher`: exit 0 - `pnpm check:type-check-debt`: the first run answered exit 3 PREREQUISITE NOT MET (no lock turn while the dogfood battery held it, nothing measured); re-run under the lock → exit 0, "128/131 workspace packages type-checked … 0 in the DEBT ledger". Outside the derivation: `node scripts/check-platform-checklist.mjs --changed-from origin/main` exit 0 (no anchored file); `pnpm --dir ui run check:new-line-citations` exit 0 (0 new citations). </details> <details><summary>Full dogfood battery on HEAD a37cae1</summary> `bash scripts/pm/os-verify-lock.sh -c "pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test"` → `os-verify-lock: VERDICT command-exit 0` (held 33m52s) · Test Files 435 passed | 3 skipped (438) · Tests 3610 passed | 41 skipped (3651) · no failure. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mAsV1kpjdJfFRKmsF)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5mAsV1kpjdJfFRKmsF Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9b3c29d commit 8b90442

5 files changed

Lines changed: 224 additions & 186 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@object-ui/fields': patch
3+
---
4+
5+
refactor(fields)!: retire three zero-consumer entry exports (the hex badge palette pair and the url cell renderer)
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (runtime-export-retired @object-ui/fields#deriveHexBadgePalette, @object-ui/fields#HexBadgePalette, @object-ui/fields#UrlCellRenderer) the retired operands are a colour helper, its return type and a React cell renderer of the fields package; nothing in the workspace or in hotcrm imports or names any of them outside the package, and no packages/spec schema, registered node type, conversion surface or stored shape names them, so there is nothing for `objectstack migrate meta` to rewrite -->
11+
12+
**BREAKING** for a host that imports any of these three names from `@object-ui/fields`. None had an importer or a mention anywhere outside the package, in the workspace or in hotcrm, so the Console and every `@object-ui/*` package are unaffected. Badges and dots with a declared hex colour and `url` cells render exactly as before.
13+
14+
The code stays in the package; only the entry stops publishing it. The hex colour math moves from `index.tsx` to an internal module that `getBadgeHexAppearance` and `getDotHexAppearance` read, and `UrlCellRenderer` stays the renderer the standard map serves for `url` fields. Every other name the entry exported stays exported unchanged, among them `getBadgeHexAppearance`, `getDotHexAppearance`, the `HexColorAppearance` type, `getBadgeColorClasses`, `getCellRenderer`, `EmailCellRenderer`, `PhoneCellRenderer` and `ColorSwatchCellRenderer`.
15+
16+
What leaves, and what to write instead:
17+
18+
- FROM `deriveHexBadgePalette` TO `getBadgeHexAppearance(hex)` for the pill colours and `getDotHexAppearance(hex)` for the dot colours; both return the derived colours as CSS custom properties on `style`.
19+
- FROM `HexBadgePalette` TO the `HexColorAppearance` type those two functions return.
20+
- FROM `UrlCellRenderer` TO `getCellRenderer('url')`, which returns the same renderer (or the one a host registered for `url` with `registerFieldRenderer`).

‎ui/packages/fields/src/__tests__/badge-hex-fidelity-5141.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ import {
3434
getBadgeHexAppearance,
3535
getDotHexAppearance,
3636
getSemanticColorName,
37-
deriveHexBadgePalette,
3837
} from '../index';
38+
import { deriveHexBadgePalette } from '../hexBadgePalette';
3939

4040
/* ---------------------------------------------------------------- colour math */
4141

‎ui/packages/fields/src/__tests__/cellRenderers.linkEllipsis-12523.test.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@
3636
import { describe, it, expect } from 'vitest';
3737
import { render, screen } from '@testing-library/react';
3838
import * as React from 'react';
39-
import { EmailCellRenderer, PhoneCellRenderer, UrlCellRenderer } from '../index';
39+
import { EmailCellRenderer, PhoneCellRenderer, getCellRenderer } from '../index';
40+
41+
// The url renderer is not exported by name; read it through the registry face
42+
// every list and detail view uses.
43+
const UrlCellRenderer = getCellRenderer('url');
4044

4145
const EMAIL = 'mira.costa@atlasconstruction.example.com';
4246
const PHONE = '+1-555-0117-1119';
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* Explicit-hex badge colour math (objectui#5141): one author-declared hex in,
3+
* the soft-pill and dot palette out. The rendering half (the static Tailwind
4+
* utility strings, `getBadgeHexAppearance`, `getDotHexAppearance`) and the
5+
* block's rationale live in `index.tsx`, the only reader.
6+
*
7+
* Not re-exported from the package entry: `getBadgeHexAppearance` and
8+
* `getDotHexAppearance` are the public faces of this palette.
9+
*/
10+
11+
/** WCAG AA floor for badge label text against its own pill surface. */
12+
const BADGE_TEXT_CONTRAST = 4.5;
13+
14+
/**
15+
* Visibility floor for the `appearance: 'dot'` marker. A dot carries no text,
16+
* so the AA *text* ratio does not apply; this is the measured floor of the
17+
* -500 shades `DOT_COLOR_MAP` (in `index.tsx`) ships today (yellow-500
18+
* `#eab308` is the weakest at 1.92:1 on white). Pinning it here means an author-declared dot is never
19+
* less visible than the palette dot it replaces.
20+
*/
21+
const DOT_CONTRAST_FLOOR = 1.9;
22+
23+
interface Rgb { r: number; g: number; b: number }
24+
25+
/** Surfaces a dot sits on — used only to keep the dot itself visible. */
26+
const LIGHT_SURFACE: Rgb = { r: 255, g: 255, b: 255 };
27+
const DARK_SURFACE: Rgb = { r: 10, g: 10, b: 10 };
28+
29+
const clampNum = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));
30+
31+
function parseHexColor(hex: string): Rgb | undefined {
32+
const m = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex.trim());
33+
if (!m) return undefined;
34+
let h = m[1];
35+
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
36+
return {
37+
r: parseInt(h.slice(0, 2), 16),
38+
g: parseInt(h.slice(2, 4), 16),
39+
b: parseInt(h.slice(4, 6), 16),
40+
};
41+
}
42+
43+
function rgbToHsl({ r, g, b }: Rgb): { h: number; s: number; l: number } {
44+
const rn = r / 255, gn = g / 255, bn = b / 255;
45+
const max = Math.max(rn, gn, bn);
46+
const min = Math.min(rn, gn, bn);
47+
const d = max - min;
48+
const l = (max + min) / 2;
49+
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
50+
let h = 0;
51+
if (d !== 0) {
52+
if (max === rn) h = (((gn - bn) / d) % 6 + 6) % 6;
53+
else if (max === gn) h = (bn - rn) / d + 2;
54+
else h = (rn - gn) / d + 4;
55+
h *= 60;
56+
}
57+
return { h, s, l };
58+
}
59+
60+
function hslToRgb(h: number, s: number, l: number): Rgb {
61+
const hue = ((h % 360) + 360) % 360;
62+
const c = (1 - Math.abs(2 * l - 1)) * s;
63+
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
64+
const m = l - c / 2;
65+
let p: [number, number, number];
66+
if (hue < 60) p = [c, x, 0];
67+
else if (hue < 120) p = [x, c, 0];
68+
else if (hue < 180) p = [0, c, x];
69+
else if (hue < 240) p = [0, x, c];
70+
else if (hue < 300) p = [x, 0, c];
71+
else p = [c, 0, x];
72+
return {
73+
r: Math.round((p[0] + m) * 255),
74+
g: Math.round((p[1] + m) * 255),
75+
b: Math.round((p[2] + m) * 255),
76+
};
77+
}
78+
79+
const rgbToHex = ({ r, g, b }: Rgb): string =>
80+
'#' + [r, g, b].map((v) => clampNum(Math.round(v), 0, 255).toString(16).padStart(2, '0')).join('');
81+
82+
/** WCAG relative luminance of an sRGB color. */
83+
function relativeLuminance({ r, g, b }: Rgb): number {
84+
const channel = (v: number): number => {
85+
const c = v / 255;
86+
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
87+
};
88+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
89+
}
90+
91+
/** WCAG contrast ratio between two colors (1..21). */
92+
function contrastRatio(a: Rgb, b: Rgb): number {
93+
const la = relativeLuminance(a);
94+
const lb = relativeLuminance(b);
95+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
96+
}
97+
98+
/**
99+
* Walk the declared hue for the lightness *closest to the declared one* that
100+
* still clears `target` against `surface`.
101+
*
102+
* Choosing the closest passing lightness rather than simply maximizing
103+
* contrast is what keeps the author's colour recognizable — maximizing would
104+
* collapse every badge label to black or white and re-lose the declaration we
105+
* are here to preserve. Only when the hue cannot reach the target at any
106+
* lightness (a very low-chroma declaration against a mid surface) do we fall
107+
* back to black or white, whichever is further from the surface: legibility
108+
* outranks fidelity.
109+
*/
110+
function readableOnSurface(
111+
h: number,
112+
s: number,
113+
declaredL: number,
114+
surface: Rgb,
115+
target: number,
116+
): Rgb {
117+
let best: Rgb | undefined;
118+
let bestDistance = Infinity;
119+
for (let step = 0; step <= 100; step++) {
120+
const lightness = step / 100;
121+
const candidate = hslToRgb(h, s, lightness);
122+
if (contrastRatio(candidate, surface) < target) continue;
123+
const distance = Math.abs(lightness - declaredL);
124+
if (distance < bestDistance) {
125+
bestDistance = distance;
126+
best = candidate;
127+
}
128+
}
129+
if (best) return best;
130+
const black: Rgb = { r: 0, g: 0, b: 0 };
131+
const white: Rgb = { r: 255, g: 255, b: 255 };
132+
return contrastRatio(black, surface) >= contrastRatio(white, surface) ? black : white;
133+
}
134+
135+
/** The six pill colors plus the two dot colors derived from one declared hex. */
136+
export interface HexBadgePalette {
137+
bg: string;
138+
fg: string;
139+
border: string;
140+
bgDark: string;
141+
fgDark: string;
142+
borderDark: string;
143+
dot: string;
144+
dotDark: string;
145+
}
146+
147+
/**
148+
* Derive a full soft-pill palette from an author-declared hex.
149+
*
150+
* The declared LIGHTNESS is what separates two same-hue tiers (`#2ecc71` is
151+
* l=0.49, `#1e8449` is l=0.32 — their hues differ by 0.1°), so it has to
152+
* survive into the *surface*. A fixed pale tint — the obvious reading of
153+
* "compute a soft pill from the hex" — does not carry it: measured, that
154+
* leaves the reported pair ΔE 2.3 apart in Lab, at the ~2.3 just-noticeable
155+
* threshold, which would close this issue on paper while the user still cannot
156+
* tell the two badges apart. Letting the tint depth track the declared
157+
* lightness puts them ΔE 8.0 apart instead.
158+
*/
159+
export function deriveHexBadgePalette(color: string): HexBadgePalette | undefined {
160+
const rgb = parseHexColor(color);
161+
if (!rgb) return undefined;
162+
const { h, s, l } = rgbToHsl(rgb);
163+
// Keep a usable chroma at both ends: a fully desaturated declaration stays
164+
// neutral instead of being pushed into a hue it never declared, and a neon
165+
// one is reined in so the pill never fights the row it sits in.
166+
const sat = clampNum(s, 0.18, 0.92);
167+
168+
// Light theme: pale for light declarations (the -50 look we ship today),
169+
// deepening as the declared colour darkens.
170+
const bgLightness = 0.95 - 0.32 * (1 - l);
171+
const bg = hslToRgb(h, sat * (0.55 + 0.25 * (1 - l)), bgLightness);
172+
const border = hslToRgb(h, sat * 0.62, clampNum(bgLightness - 0.14, 0, 1));
173+
const fg = readableOnSurface(h, sat, l, bg, BADGE_TEXT_CONTRAST);
174+
175+
// Dark theme: the same idea mirrored — a dark surface whose depth tracks the
176+
// declared lightness, with the label lifted until it clears AA against it.
177+
const bgDarkLightness = 0.10 + 0.30 * l;
178+
const bgDark = hslToRgb(h, sat * 0.6, bgDarkLightness);
179+
const borderDark = hslToRgb(h, sat * 0.55, clampNum(bgDarkLightness + 0.12, 0, 1));
180+
const fgDark = readableOnSurface(h, sat, l, bgDark, BADGE_TEXT_CONTRAST);
181+
182+
return {
183+
bg: rgbToHex(bg),
184+
fg: rgbToHex(fg),
185+
border: rgbToHex(border),
186+
bgDark: rgbToHex(bgDark),
187+
fgDark: rgbToHex(fgDark),
188+
borderDark: rgbToHex(borderDark),
189+
dot: rgbToHex(readableOnSurface(h, sat, l, LIGHT_SURFACE, DOT_CONTRAST_FLOOR)),
190+
dotDark: rgbToHex(readableOnSurface(h, sat, l, DARK_SURFACE, DOT_CONTRAST_FLOOR)),
191+
};
192+
}

0 commit comments

Comments
 (0)