Skip to content

Commit 9846f27

Browse files
fix(app-showcase): give the task Grid rowColor a colours map the renderer actually resolves (#18787)
Fixes #15100 `examples/app-showcase`'s task **Grid** view declared `rowColor: { field: 'priority' }` with no `colors` map, so objectui `plugin-grid`'s `useRowColor` returned before it read a record and every row kept the default background, while the metadata parsed, validated and published clean. ```ts rowColor: { field: 'priority', colors: { low: 'slate', medium: 'blue', high: 'amber', urgent: 'red' }, }, ``` ## The card's stated reason for not fixing this was half right, and the other half is the point The card says the map "is not mechanical: which colour each `priority` value should get is a design choice … not a shape pinned by existing evidence". The evidence does exist — the `priority` field declares a colour per option in the same app (`src/data/objects/task.object.ts`: `low #94A3B8`, `medium #3B82F6`, `high #F59E0B`, `urgent #EF4444`) — **but copying those hexes straight across would have been the same defect one layer deeper.** Read at `objectstack-ai/objectui` @ `cfcc17d9`, `packages/plugin-grid/src/useRowColor.ts`: `colorToClass` hands a value that already begins `bg-` through untouched, otherwise looks the lower-cased value up in its own `COLOR_TO_CLASS` map of **colour names**, and returns `undefined` for anything else. It never fabricates a class string — the sibling module `packages/plugin-detail/src/headerColor.ts` records why in measured detail: Tailwind v4 has no runtime, it builds the stylesheet by scanning source text for complete class tokens, so a concatenated `bg-` + value names no rule at all. A hex map therefore **clears the resolver's `if (!config?.field || !config.colors)` guard, silences the author-time diagnostic `view/row-color-without-colors`, and still colours no row** — the defect made invisible rather than fixed. Demonstrated below (ablation leg B). So the values are colour **names**, chosen by one stated uniform rule rather than by taste: each is the entry of the renderer's own vocabulary nearest to the colour the option already declares, measured in OKLab against this workspace's pinned `tailwindcss@4.3.3` palette over every shade of every vocabulary family. ``` low #94A3B8 -> slate | nearest slate-400 Δ0.0083 (runner-up gray-400 Δ0.0138) medium #3B82F6 -> blue | nearest blue-500 Δ0.0260 (runner-up sky-600 Δ0.0706) high #F59E0B -> amber | nearest amber-500 Δ0.0233 (runner-up orange-400 Δ0.0501) urgent #EF4444 -> red | nearest red-500 Δ0.0292 (runner-up rose-500 Δ0.0525) ``` Every winner is its own family's shade at the hue the field already declares, so no palette was invented here. It also answers the full-row contrast question: the renderer paints the `-100` tint of the name (`red` -> `bg-red-100`), never the saturated fill — the desaturation is the renderer's, not a judgement call made in the app. ## Evidence **Author-time diagnostic, before and after** — tree `objectstack-issue-15100`, the branch head, `pnpm --filter @objectstack/example-showcase validate`: | | occurrences of `view/row-color-without-colors` | total advisories | exit | |---|---|---|---| | before (`922923b8a` content) | 1 — `view container [0] › listViews.grid` | 84 | 0 | | after | **0** | 83 | 0 | Exactly one advisory moved. The rule is WARNING severity, so `validate` exits 0 either way — which is why the new pin asserts on the findings, not on the exit code. **Regression pin** — `examples/app-showcase/test/inert-wirings.test.ts`, the file whose charter is this app's declared-but-inert wirings. Two arms: the shipped ADR-0078 rule (`validateFunctionalCompleteness`, aliased to `packages/lint/src` by this app's vitest config) must report no `view/row-color-without-colors` finding, and every authored row colour must be a value `colorToClass` can resolve. The vocabulary itself is deliberately **not** transcribed into this repo — a hand-copy of another repo's 23-entry map is a second opinion that drifts; the arm asserts the shape rule that separates a resolvable value from a hex. **Reverse verification**, run from the committed state, each leg proved on disk before it was read, restored with `git checkout HEAD --` on the target path and the restored blob hash compared against `HEAD:examples/app-showcase/src/ui/views/task.view.ts` (`cb97b5e23…`, matched): | leg | mutation | result | |---|---|---| | A | drop the `colors` map (the card's original shape) | 1 failed / 37 passed — arm ① `reports no view/row-color-without-colors finding` | | B | replace the names with the declared option **hexes** | 1 failed / 37 passed — arm ② only; **arm ① stayed green**, which is the "silences the diagnostic and still colours nothing" claim demonstrated | | C | control, unmutated | 38 passed | ## How far this could be proved here The renderer lives in `objectstack-ai/objectui`, which this container can read but not run — no `node_modules` there, and this session has no write access to it. So the renderer half is a **source reading at a named revision** (`cfcc17d9`), plus a mechanical membership check run against that file's real bytes: each of the four declared hexes resolves to `undefined` through `colorToClass`, each of the four authored names resolves to a `bg-NAME-100` class. It is not a rendered screenshot, and is not claimed as one. One thing that reading does settle: `bg-slate-100`, `bg-blue-100`, `bg-amber-100` and `bg-red-100` are complete literals inside `packages/plugin-grid/src/useRowColor.ts`, and `apps/console/src/index.css` scans `packages/plugin-grid/src/**`, so those four classes are in the compiled stylesheet by the same mechanism the renderer relies on. ## Changeset None — measured, not defaulted. `@objectstack/example-showcase` declares `"private": true`, and a probe over all 70 non-private workspace packages grepped every root their `files[]` actually ships for a symbol unique to this diff (`urgent: 'red'`): **0 hits**. Positive control on the same probe, `RowColorConfigSchema`: 4 hits, including `packages/spec/dist/view.zod-*.d.ts`, so the probe does reach shipped `dist`. Nothing published moves. ## Acceptance notes - `packages/lint/src/showcase-shape.fixtures.ts` still carries the inert `rowColor: { field: 'priority' }` line. That is deliberate and untouched: its header records the 2026-08-13 maintainer ruling that it must not be re-synced with the live app. Both the card and triage name that boundary. - `RowColorConfigSchema` is unchanged — making `colors` required is spec-side and belongs to #14111, which is not addressed here and remains open on its own terms. - Reported back to the dispatching seat rather than filed from here: `RowColorConfigSchema.colors` describes itself as a "Map of field value to color (hex/token)", while the only renderer resolves neither a hex nor an arbitrary token. An author who follows that description — or who copies the colours the same object's own select field declares — writes metadata the runtime silently drops, and the diagnostic added for #14111 reports presence only, so it goes on reading green. --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7572329 commit 9846f27

2 files changed

Lines changed: 119 additions & 1 deletion

File tree

‎examples/app-showcase/src/ui/views/task.view.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,33 @@ export const TaskViews = defineView({
228228
{ field: 'estimate_hours' },
229229
{ field: 'due_date' },
230230
],
231-
rowColor: { field: 'priority' },
231+
// Row colouring by priority. The `colors` map is what does the work:
232+
// `rowColor: { field }` alone parses and publishes clean and colours
233+
// nothing (author-time diagnostic `view/row-color-without-colors`).
234+
//
235+
// The values are COLOUR NAMES, not hex. objectui `plugin-grid`'s
236+
// `useRowColor` resolves each authored value through its own
237+
// `COLOR_TO_CLASS` vocabulary to a literal Tailwind class
238+
// (`red` -> `bg-red-100`), hands a `bg-*` value through untouched, and
239+
// returns `undefined` for everything else — Tailwind v4 has no runtime,
240+
// so a fabricated class string would name no rule in the compiled
241+
// stylesheet. A hex here would clear the resolver's `!config.colors`
242+
// guard, silence the diagnostic above, and still colour no row.
243+
//
244+
// Each name is the entry of that vocabulary nearest to the colour
245+
// `priority` already declares on the option itself (`task.object.ts`):
246+
// #94A3B8 slate, #3B82F6 blue, #F59E0B amber, #EF4444 red. The renderer
247+
// paints the `-100` tint of the name, so the row background stays light
248+
// enough to read the row's own text on.
249+
rowColor: {
250+
field: 'priority',
251+
colors: {
252+
low: 'slate',
253+
medium: 'blue',
254+
high: 'amber',
255+
urgent: 'red',
256+
},
257+
},
232258
// List-level inline edit — cells become editable in place, with a
233259
// per-row edit affordance and a save-all/cancel-all toolbar (view-level
234260
// master switch; distinct from the master-detail `inlineEdit` on fields).

‎examples/app-showcase/test/inert-wirings.test.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import { stripComments } from '../../../scripts/js-comment-mask.mjs';
1414
import stack from '../objectstack.config.js';
1515
import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security';
1616
import { FILE_REFERENCE_TYPES, valueSchemaFor } from '@objectstack/spec/data';
17+
import { VIEW_ROW_COLOR_WITHOUT_COLORS } from '@objectstack/spec/kernel';
18+
// The SHIPPED ADR-0078 completeness gate, never a re-derivation of it —
19+
// `vitest.config.ts` aliases `@objectstack/lint` to its `src/`, so the verdict
20+
// is about the rule in this checkout rather than about `packages/lint/dist`.
21+
import { validateFunctionalCompleteness } from '@objectstack/lint';
1722
// The job handler's argument type, from the package that BUILDS it
1823
// (`AppPlugin`) — not a local re-description of it. A fake context typed to a
1924
// hand-written approximation is how a double drifts looser than the contract it
@@ -741,3 +746,90 @@ describe('sharing rules target an object under record-sharing enforcement (#9237
741746
).toEqual([]);
742747
});
743748
});
749+
750+
/**
751+
* #15100 — the task Grid view's `rowColor` named a field and declared no
752+
* `colors` map, so objectui `plugin-grid`'s `useRowColor` returned before it
753+
* read a record and every row kept the default background, while the metadata
754+
* parsed, validated and published clean. The same class as every guard above:
755+
* a declaration the platform accepts at authoring time and then does not
756+
* honour, announced only by an advisory line nobody reads.
757+
*
758+
* Two arms, because the wiring has two independent ways to be inert and only
759+
* the first has a shipped rule:
760+
*
761+
* ① NO MAP — the author-time diagnostic `view/row-color-without-colors`
762+
* names it. WARNING severity, so `os validate` prints it and still exits
763+
* 0; a pin asserting "validate succeeds" would have passed on the broken
764+
* tree, so this asserts on the FINDINGS.
765+
*
766+
* ② A MAP THE RENDERER CANNOT RESOLVE — invisible to ①. `colorToClass`
767+
* hands a value that already starts with `bg-` through untouched,
768+
* otherwise looks it up in its own `COLOR_TO_CLASS` vocabulary of colour
769+
* names, and returns `undefined` for anything else; it never fabricates a
770+
* class string, because Tailwind v4 has no runtime and only complete class
771+
* literals present in scanned source reach the compiled stylesheet. So a
772+
* hex — the spelling the `priority` field uses for its own option colours,
773+
* and the obvious thing to copy — clears the `!config.colors` guard,
774+
* silences ①, and still colours no row.
775+
*/
776+
describe("the task Grid's rowColor is wired all the way to a colour (#15100)", () => {
777+
it('reports no `view/row-color-without-colors` finding anywhere in this stack', () => {
778+
const offenders = validateFunctionalCompleteness(stack)
779+
.filter((f) => f.rule === VIEW_ROW_COLOR_WITHOUT_COLORS)
780+
.map((f) => `${f.where} (${f.path})`);
781+
expect(
782+
offenders,
783+
`\`rowColor\` block(s) with no \`colors\` map — the row-className resolver `
784+
+ `returns before it reads a record: ${offenders.join(', ')}`,
785+
).toEqual([]);
786+
});
787+
788+
it('spells every authored colour as a value the renderer can resolve', () => {
789+
// A colour NAME (`red`) or a complete Tailwind class (`bg-red-200`). The
790+
// vocabulary itself is not transcribed here: a hand-copy of another repo's
791+
// 23-entry map is a second opinion that drifts, and the shape rule is what
792+
// separates a resolvable value from a hex.
793+
const RESOLVABLE = /^(?:[a-z]+|bg-[a-z0-9/[\]().,%#-]+)$/;
794+
const offenders: string[] = [];
795+
for (const { path, block } of rowColorBlocks(stack)) {
796+
for (const [value, colour] of Object.entries(block.colors ?? {})) {
797+
if (typeof colour !== 'string' || !RESOLVABLE.test(colour)) {
798+
offenders.push(`${path}.colors.${value} = ${JSON.stringify(colour)}`);
799+
}
800+
}
801+
}
802+
expect(
803+
offenders,
804+
`authored row colour(s) \`colorToClass\` resolves to \`undefined\` — the block passes `
805+
+ `the completeness rule above and still colours no row: ${offenders.join(', ')}`,
806+
).toEqual([]);
807+
});
808+
});
809+
810+
/** Every `rowColor` block anywhere in this app's stack, with where it sits. */
811+
function rowColorBlocks(
812+
root: unknown,
813+
): Array<{ path: string; block: { colors?: Record<string, unknown> } }> {
814+
const out: Array<{ path: string; block: { colors?: Record<string, unknown> } }> = [];
815+
const seen = new WeakSet<object>();
816+
const walk = (node: unknown, path: string): void => {
817+
if (!node || typeof node !== 'object') return;
818+
if (seen.has(node)) return;
819+
seen.add(node);
820+
if (Array.isArray(node)) {
821+
node.forEach((child, i) => walk(child, `${path}[${i}]`));
822+
return;
823+
}
824+
for (const [key, child] of Object.entries(node as Record<string, unknown>)) {
825+
// The BOOLEAN toolbar toggle `userActions.rowColor` shares this key name
826+
// and is a different thing entirely — only an object is the config.
827+
if (key === 'rowColor' && child && typeof child === 'object' && !Array.isArray(child)) {
828+
out.push({ path: `${path}.rowColor`, block: child as { colors?: Record<string, unknown> } });
829+
}
830+
walk(child, `${path}.${key}`);
831+
}
832+
};
833+
walk(root, '$');
834+
return out;
835+
}

0 commit comments

Comments
 (0)