Skip to content

Commit dbf576b

Browse files
feat(spec,ui): an inline object-metric tile declares polarity, so a lower-is-better KPI draws a rise red (#27361)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ84mGMAxEKq4h2AUS2QAB3o4)_ Fixes #27347 Clause-②: yes (widening) Metadata-protocol: added (object-metric polarity) ## 维护者速读 - 改了什么:页面级 `object-metric` 指标卡新增可声明的 `polarity`(`higher` / `lower` / `neutral`,与数据集度量同一词表),趋势徽标(`compareTo` 计算的或静态 `trend`)按它着色。 - 为什么改:内联指标卡只按方向着色,"越低越好"的指标(平均周转天数)上涨 23% 被画成绿色,与同一看板上数据集指标卡的颜色相反,是读错数。 - 风险与回滚:不声明时颜色与现在逐字节一致(测试钉住类名字符串);只新增一个可选键,回滚即还原本 PR。 - 先看:`packages/spec/src/ui/component.zod.ts` 的 `object-metric` 段(新键与拒收提示);`ui/packages/plugin-dashboard/src/MetricWidget.tsx` 的徽标着色一行;`ui/packages/plugin-dashboard/src/utils.ts` 的共享 `deltaToneClass`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.polarity-27347.test.tsx`,预期 7 个用例全绿。 - 需要你做的:涉及发布的 spec 与 `skills/**`(Tier H),契约复核 PASS 与席位 ACCEPT 之后再批准合入。 ## What changes - **Spec** (`ObjectMetricPropsSchema`, the `ComponentPropsMap['object-metric']` row): an optional top-level `polarity`, the dataset measure's own `DatasetMeasurePolarity` by reference (no second spelling, no new export). Any other value is refused at the key. `betterWhen` / `goodDirection` / `trendDirection` are aliased to `polarity`. A top-level `direction` gets a prescription naming both keys it could mean (`trend.direction` for the static badge's arrow, `polarity` for good news), because on this tile the dataset measure's `direction` alias would send an author who meant the badge arrow to the wrong key. `higherIsBetter` / `lowerIsBetter` get the three values. - **Top-level, not inside `compareTo`**: the badge `MetricWidget` draws is `derivedTrend ?? trend`, and the static `trend` badge was coloured by direction alone too, so one key covers the computed and the static badge. The reason is in the key's docblock. - **Renderer** (`@object-ui/plugin-dashboard`): `deltaToneClass` moved from `DatasetWidget.tsx` to `utils.ts` (one colouring rule, now also answering "no tone" for a static badge with no `direction`). `ObjectMetricWidget` forwards `polarity`; `MetricWidget` colours the badge through `deltaToneClass` and mutes it under `neutral`. Both prop types name the key by reference (`ObjectMetricProps['polarity']`). The `object-metric` registration publishes an enum input; `sdui.manifest.json` regenerated. - **Undeclared keeps today's colours exactly**: the class string is pinned byte for byte. Nothing infers a polarity from the aggregated field's name. - **Generated**: `authorable-surface/ui.json`, `content/docs/references/ui/component.mdx`, `sdui.manifest.json` + its record, and `content/docs/ui/components/index.mdx` / `plugins.mdx` (`gen-sdui-reference`). `liveness/page.json` row `regions` gains evidence entry `[121] object-metric.polarity` (read points cited in-repo with symbol anchors). - **Skills** (`skills/objectstack-ui/rules/dashboards.md`, Tier H): the metric-widget bullet says an inline `object-metric` declares its own `polarity`. - **Changeset**: `@objectstack/spec` minor, `@object-ui/plugin-dashboard` minor, `@objectstack/skills` patch. ## Tests - `packages/spec/src/ui/object-metric-polarity.test.ts`: the three values parse and keep their value; the member IS `DatasetMeasurePolarity` (identity); absent materializes nothing; `up` / `down` / `good` / `inverse` / `true` refused at the key; the three aliases point at `polarity`; top-level `direction` names `trend` and `polarity`; the two boolean-flag spellings get the values; a tsc pin (`@ts-expect-error` on `polarity: 'up'`). - `ui/packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.polarity-27347.test.tsx`, through the registered `object-metric` block: `lower` rise drawn bad and fall drawn good with the arrow kept; `higher` as before; `neutral` muted both ways; CONTROL undeclared class strings byte-identical (field named `turnaround_days`); the static `trend` badge coloured by the same key; CONTROL a static badge with no `direction` takes no tone. ## Acceptance notes - `@object-ui/types` has no diff: `ObjectMetricBlockSchema` takes the spec row by reference (`propsBag('object-metric', SpecObjectMetricPropsSchema)`), and `ObjectMetricBlockNode` derives from that arm, so the key reaches the ui zod face and the authoring type with no edit there. No `@object-ui/types` changeset row, since the package's own files do not change. - The near-miss alias map and the not-a-flag prescription are declared on the tile rather than shared with `dataset.zod.ts`: a shared constant would be a new public export (`ui/index.ts` re-exports `dataset.zod` whole) or a new module outside the claimed file surface. - Skills token ratchet: `rules/dashboards.md` 6214 to 6216 tokens of ceiling 6221 (no raise). Paid by cutting the bullet's `options.trend` reaches no renderer clause, whose surviving home is the same file's Widget `options` section (a dataset-bound widget's `trend` styles nothing). ## Verification Readings taken on the merged tree (spec and plugin-dashboard closure built first). <details><summary>Ablation (one-time, restored)</summary> `scripts/ablation-replace.mjs` on `MetricWidget.tsx`: `deltaToneClass(shownTrend.direction, polarity),` to `deltaToneClass(shownTrend.direction, undefined),` (anchor 1 to 0, blob changed); the pin went 3 failed / 4 passed: the two `lower` cases and the static-badge case red. Restored: blob equals HEAD, `git diff HEAD` empty. The `neutral` and CONTROL rows stay green under the mutation by design. Reverse check of the tsc pin: replacing `polarity: 'up'` with `'neutral'` under the `@ts-expect-error` turned `check:test-typecheck` red (1 type error in `object-metric-polarity.test.ts`); restored to HEAD. </details> <details><summary>Suites and type-checks</summary> - spec `src/ui/` + `type-alias-convention.pin.test.ts`: 165 files, 5854 tests passed. - `pnpm --dir ui exec vitest run packages/plugin-dashboard/`: 192 files, 1874 passed, 6 skipped. - `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`: 261 passed. - `@object-ui/plugin-dashboard` type-check (src + tests) exit 0; `@objectstack/spec` typecheck exit 0. - `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts up to date after the last merge. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 122 families (`check:sdui-reference` joined with the regenerated pages), all run, all exit 0 on head `ea930c31` (`--ran`: 122 derived, 122 run, 0 NOT-MEASURED). Three first exited 3 on prerequisites, not findings, and were re-run green: the plugin-teardown self-test (shallow clone; fixture commit fetched), `check:skill-examples` (client-react built) and `check:dual-build-cjs-loads` (full build). Also local: `check-dev-prereqs`, `check:skill-frame-freshness`, ui `check:new-line-citations` (0 new), eslint on the changed files (0 errors). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWhSYPUK8z26ntsh8g)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cf218ee commit dbf576b

17 files changed

Lines changed: 375 additions & 38 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@object-ui/plugin-dashboard': minor
4+
'@objectstack/skills': patch
5+
---
6+
7+
feat(spec): an inline `object-metric` tile declares `polarity`, so a lower-is-better KPI draws a rise red
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: added (object-metric polarity)
11+
12+
A dataset measure has declared `polarity` since the dataset-bound KPI tile learned to colour its delta by it, but a page-level `object-metric` tile aggregates a field, not a dataset measure, so it had nothing to attach to and coloured its trend badge by direction alone: an average turnaround that rose 23% was drawn green.
13+
14+
- **Spec.** `ObjectMetricPropsSchema` (`ComponentPropsMap['object-metric']`) declares an optional top-level `polarity`, the dataset measure's own `DatasetMeasurePolarity` by reference: `higher`, `lower` or `neutral`. Any other value is refused at the key. `betterWhen`, `goodDirection` and `trendDirection` are pointed at `polarity`; a top-level `direction` is answered with both keys it could mean (`trend.direction` for the static badge's arrow, `polarity` for good news); `higherIsBetter` / `lowerIsBetter` get the three values.
15+
- **Renderer.** `ObjectMetricWidget` forwards `polarity` to `MetricWidget`, which colours the badge it draws, the `compareTo`-derived one or the static `trend`, by the same rule the dataset-bound tile uses (`deltaToneClass`, now one shared helper): `lower` draws a rise red and a fall green, `neutral` draws it muted. The arrow always shows which way the number moved. The `object-metric` registration publishes the input.
16+
- **Undeclared keeps today's colours exactly**: a rise green, a fall red. Nothing infers a polarity from the aggregated field's name.
17+
- **Skills.** `objectstack-ui` `rules/dashboards.md` says an inline `object-metric` declares its own `polarity`.
18+
19+
Nothing to do for existing tiles. To use it, add `polarity: 'lower'` to an `object-metric` node whose number is better when it falls.

‎content/docs/references/ui/component.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1221,6 +1221,7 @@ Sort field and direction pair
12211221
| **trend** | `{ value: number; label?: string \| Record<string, string>; direction?: Enum<'up' \| 'down' \| 'neutral'> }` | optional | Static trend badge `{ value, label?, direction? }` — `value` painted as a percentage, `direction` up / down / neutral. A `compareTo`-derived trend replaces it |
12221222
| **drillDown** | `{ enabled?: boolean; title?: string; target?: Enum<'drawer' \| 'dialog' \| 'navigate'>; columns?: string[]; … }` | optional | Click-through drill config `{ enabled?, title?, target?, columns?, maxRows?, report? }` — opens the records behind the number, scoped by the metric's own `filter` — or draws the report `report` defines; a present block is on unless `enabled: false`. `filter` and `mode` are refused: a metric tile has no click context and no row |
12231223
| **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'> }` | optional | Period-over-period comparison `{ kind }` — `previousPeriod` or `previousYear`, shifting the date macros in the tile's own `filter`. `dimension` is refused: this tile never reads a dataset time dimension |
1224+
| **polarity** | `Enum<'higher' \| 'lower' \| 'neutral'>` | optional | Which direction of change is good news: higher (rise green, fall red), lower (fall green, rise red) or neutral (no good/bad colour). Colours the trend badge, computed (`compareTo`) or static (`trend`); undeclared keeps rise green, fall red |
12241225

12251226
### Nested Shape: `ObjectMetricProps.aggregate`
12261227

‎content/docs/ui/components/index.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ regenerates from the renderer registry. Regenerate it with
100100
| [`object-kanban`](/docs/ui/components/plugins#object-kanban) | `plugin-kanban` | 15 | [Plugin Blocks](/docs/ui/components/plugins) |
101101
| [`object-map`](/docs/ui/components/plugins#object-map) | `plugin-map` | 10 | [Plugin Blocks](/docs/ui/components/plugins) |
102102
| [`object-master-detail-form`](/docs/ui/components/plugins#object-master-detail-form) | `plugin-form` | 15 | [Plugin Blocks](/docs/ui/components/plugins) |
103-
| [`object-metric`](/docs/ui/components/plugins#object-metric) | `plugin-dashboard` | 19 | [Plugin Blocks](/docs/ui/components/plugins) |
103+
| [`object-metric`](/docs/ui/components/plugins#object-metric) | `plugin-dashboard` | 20 | [Plugin Blocks](/docs/ui/components/plugins) |
104104
| [`object-pivot`](/docs/ui/components/plugins#object-pivot) | `plugin-dashboard` | 12 | [Plugin Blocks](/docs/ui/components/plugins) |
105105
| [`object-timeline`](/docs/ui/components/plugins#object-timeline) | `plugin-timeline` | 16 | [Plugin Blocks](/docs/ui/components/plugins) |
106106
| [`object-tree`](/docs/ui/components/plugins#object-tree) | `plugin-tree` | 6 | [Plugin Blocks](/docs/ui/components/plugins) |

‎content/docs/ui/components/plugins.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ Namespace `plugin-dashboard`.
8585
| `fallbackValue` | `string` | | Value shown when no data source resolves. For static/demo tiles; a bound metric should not need it. |
8686
| `trend` | `object` | | Static trend badge: `{ value, label, direction }`. Use `compareTo` instead when the trend should be computed from data. |
8787
| `compareTo` | `object` | | Period-over-period comparison, `{ kind: "previousPeriod" }` or `{ kind: "previousYear" }` — the computed alternative to a static `trend`. |
88+
| `polarity` | `enum`: `higher`, `lower`, `neutral` | | Which direction of change is good news. Colours the trend badge, computed (`compareTo`) or static (`trend`): `lower` draws a rise red and a fall green (turnaround, overdue counts, cost), `neutral` draws it muted. Omit it for rise green, fall red. The arrow always shows which way the number moved. |
8889
| `drillDown` | `object` | | Click-through config that opens the records behind the number. `drillDown.filter` and `drillDown.mode` do not apply to a metric, which has no clicked point to filter by and no row to open, so the list is always scoped by this block’s own `filter`; a drill `filter` belongs on `object-chart` or `object-pivot`, and `mode` on `object-data-table`. |
8990
| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. |
9091

‎packages/spec/authorable-surface/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,7 @@
985985
"ui/ObjectMetricProps:invert",
986986
"ui/ObjectMetricProps:label",
987987
"ui/ObjectMetricProps:objectName",
988+
"ui/ObjectMetricProps:polarity",
988989
"ui/ObjectMetricProps:prefix",
989990
"ui/ObjectMetricProps:suffix",
990991
"ui/ObjectMetricProps:title",

‎packages/spec/liveness/page.json‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎packages/spec/src/ui/component.zod.ts‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ import { ChartDrillDownSchema, ChartGroupBySchema } from './chart.zod';
8989
// [#21464] `object-metric.compareTo.kind` is the dashboard widget comparison's
9090
// own `kind` vocabulary, by reference — the executor's two kinds.
9191
import { DashboardWidgetSchema } from './dashboard.zod';
92+
// [#27347] `object-metric.polarity` is the dataset measure's own polarity
93+
// vocabulary, by reference — one spelling of which direction is good news.
94+
import { DatasetMeasurePolarity } from './dataset.zod';
9295
// [#21464] `object-metric.drillDown.report` is the report contract itself, by
9396
// reference (decision card #21704, fork 1, letter B): a joined report refuses a
9497
// block that binds no dataset, so a report this schema admits is one the drill
@@ -5615,6 +5618,16 @@ const ObjectMetricCompareToSchema = lazySchema(() => strictObject({
56155618
kind: DashboardWidgetSchema.shape.compareTo.unwrap().shape.kind,
56165619
}));
56175620

5621+
/**
5622+
* [#27347] A boolean "is higher better?" flag has no word for a tile where
5623+
* neither direction is good news, so the tile's polarity is the dataset
5624+
* measure's one key with three peer values.
5625+
*/
5626+
const OBJECT_METRIC_POLARITY_NOT_A_FLAG =
5627+
'a metric tile declares which direction is good news with `polarity`, not a boolean: '
5628+
+ "`polarity: 'higher'` (a rise is good), `polarity: 'lower'` (a fall is good: turnaround days, overdue "
5629+
+ "counts, cost) or `polarity: 'neutral'` (neither, the badge is drawn without a good or bad colour).";
5630+
56185631
/**
56195632
* `object-metric` (objectui's `ObjectMetricWidget`; read points recorded in
56205633
* `liveness/page.json`, row `regions`). The widget destructures every prop it
@@ -5627,7 +5640,23 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({
56275640
surface: 'this `object-metric`',
56285641
history: objectBlockHistory('object-metric'),
56295642
guidanceSets: COMPONENT_LEVEL_GUIDANCE,
5630-
aliases: FILTERS_TO_FILTER,
5643+
// [#27347] The dataset measure's near-misses for `polarity`, less its
5644+
// `direction` entry: on this tile `direction` is the static `trend` badge's
5645+
// arrow, so the bare word gets a prescription naming both keys instead.
5646+
aliases: {
5647+
...FILTERS_TO_FILTER,
5648+
betterWhen: 'polarity',
5649+
goodDirection: 'polarity',
5650+
trendDirection: 'polarity',
5651+
},
5652+
guidance: {
5653+
direction:
5654+
'`direction` is not a member of the tile: the arrow of a static badge goes INSIDE `trend` '
5655+
+ '(`trend: { value, direction }`), and which direction of change is good news is `polarity` '
5656+
+ "(`polarity: 'lower'` draws a rise red and a fall green).",
5657+
higherIsBetter: OBJECT_METRIC_POLARITY_NOT_A_FLAG,
5658+
lowerIsBetter: OBJECT_METRIC_POLARITY_NOT_A_FLAG,
5659+
},
56315660
}, {
56325661
objectName: z.string().optional()
56335662
.describe('Object this metric aggregates. Optional because the component-level `dataSource` binding can supply the object instead'),
@@ -5723,6 +5752,36 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({
57235752
*/
57245753
compareTo: ObjectMetricCompareToSchema.optional()
57255754
.describe("Period-over-period comparison `{ kind }` — `previousPeriod` or `previousYear`, shifting the date macros in the tile's own `filter`. `dimension` is refused: this tile never reads a dataset time dimension"),
5755+
/**
5756+
* [#27347] Which direction of change is good news for the tile's number —
5757+
* the dataset measure's {@link DatasetMeasurePolarity}, BY REFERENCE: one
5758+
* vocabulary and one spelling. This tile aggregates a field, not a dataset
5759+
* measure, so a measure's `polarity` has nothing to attach to here and the
5760+
* tile declares its own.
5761+
*
5762+
* Read points recorded in `liveness/page.json`, row `regions`:
5763+
* `ObjectMetricWidget` forwards it to `MetricWidget`, which colours the badge
5764+
* it draws by the rule the dataset-bound tile applies to its delta
5765+
* (`deltaToneClass`), so one KPI is drawn one way on either surface.
5766+
*
5767+
* A TOP-LEVEL key, not a `compareTo` member, because the badge it colours is
5768+
* `derivedTrend ?? trend`: the static `trend` badge was coloured by its
5769+
* `direction` alone too, so one key covers the computed and the static
5770+
* badge.
5771+
*
5772+
* - `higher`, and undeclared: a rise green, a fall red, the colours the tile
5773+
* always had.
5774+
* - `lower`: a fall green, a rise red. Turnaround, overdue counts, cost.
5775+
* - `neutral`: neither; the badge keeps its arrow and percentage, muted.
5776+
*
5777+
* The arrow always shows which way the NUMBER moved; polarity decides only
5778+
* the colour. Nothing infers a polarity from the aggregated field's name.
5779+
*/
5780+
polarity: DatasetMeasurePolarity.optional().describe(
5781+
'Which direction of change is good news: higher (rise green, fall red), lower (fall green, rise red) '
5782+
+ 'or neutral (no good/bad colour). Colours the trend badge, computed (`compareTo`) or static (`trend`); '
5783+
+ 'undeclared keeps rise green, fall red',
5784+
),
57265785
}));
57275786
/** Author state (ADR-0122: the bare name is the author state). */
57285787
export type ObjectMetricProps = z.input<typeof ObjectMetricPropsSchema>;
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `object-metric.polarity` — which direction of change is good news for an
5+
* inline KPI tile, so a "lower is better" tile colours a rising delta as bad
6+
* (#27347).
7+
*
8+
* The tile aggregates a field, not a dataset measure, so the measure's own
9+
* `polarity` (#26602) had nothing to attach to. The tile now declares the SAME
10+
* key, by reference to `DatasetMeasurePolarity`: the three peer values parse
11+
* and keep their value, an absent key parses exactly as before (no default is
12+
* materialized, so the renderer's undeclared arm keeps today's colours), any
13+
* other value is refused at the key, and the near-miss spellings are pointed
14+
* at it — except the bare `direction`, which on this tile is the static
15+
* `trend` badge's arrow and gets a prescription naming both keys.
16+
*/
17+
18+
import { describe, it, expect } from 'vitest';
19+
import { ObjectMetricPropsSchema, type ObjectMetricProps } from './component.zod';
20+
import { DatasetMeasurePolarity } from './dataset.zod';
21+
22+
const tile = {
23+
objectName: 'approval',
24+
label: 'Avg. turnaround',
25+
aggregate: { field: 'turnaround_days', function: 'avg' as const },
26+
compareTo: { kind: 'previousPeriod' as const },
27+
};
28+
29+
describe('ObjectMetricPropsSchema.polarity', () => {
30+
it.each(['higher', 'lower', 'neutral'] as const)('accepts %s and keeps the value', (polarity) => {
31+
const parsed = ObjectMetricPropsSchema.safeParse({ ...tile, polarity });
32+
expect(parsed.success).toBe(true);
33+
expect(parsed.data?.polarity).toBe(polarity);
34+
});
35+
36+
it('is the dataset measure\'s vocabulary, by reference — not a second spelling', () => {
37+
expect(ObjectMetricPropsSchema.shape.polarity.unwrap()).toBe(DatasetMeasurePolarity);
38+
});
39+
40+
it('an absent polarity parses exactly as before: no key is materialized', () => {
41+
const parsed = ObjectMetricPropsSchema.safeParse(tile);
42+
expect(parsed.success).toBe(true);
43+
expect(Object.prototype.hasOwnProperty.call(parsed.data, 'polarity')).toBe(false);
44+
});
45+
46+
it.each(['up', 'down', 'good', 'inverse', true])('refuses %j at the key', (polarity) => {
47+
const parsed = ObjectMetricPropsSchema.safeParse({ ...tile, polarity });
48+
expect(parsed.success).toBe(false);
49+
expect(parsed.error?.issues.map((i) => [i.code, i.path])).toEqual([['invalid_value', ['polarity']]]);
50+
});
51+
52+
it.each(['betterWhen', 'goodDirection', 'trendDirection'])('points the near-miss `%s` at `polarity`', (key) => {
53+
const parsed = ObjectMetricPropsSchema.safeParse({ ...tile, [key]: 'lower' });
54+
expect(parsed.success).toBe(false);
55+
const issue = parsed.error?.issues.find((i) => i.code === 'unrecognized_keys');
56+
expect(issue).toBeDefined();
57+
expect(issue?.message).toContain('`polarity`');
58+
});
59+
60+
it('answers a top-level `direction` with BOTH keys it could mean', () => {
61+
const parsed = ObjectMetricPropsSchema.safeParse({ ...tile, direction: 'lower' });
62+
expect(parsed.success).toBe(false);
63+
const issue = parsed.error?.issues.find((i) => i.code === 'unrecognized_keys');
64+
expect(issue?.message).toContain('`trend`');
65+
expect(issue?.message).toContain('`polarity`');
66+
});
67+
68+
it.each(['lowerIsBetter', 'higherIsBetter'])('answers the boolean-flag spelling `%s` with the three values', (key) => {
69+
const parsed = ObjectMetricPropsSchema.safeParse({ ...tile, [key]: true });
70+
expect(parsed.success).toBe(false);
71+
const issue = parsed.error?.issues.find((i) => i.code === 'unrecognized_keys');
72+
expect(issue?.message).toContain("polarity: 'lower'");
73+
expect(issue?.message).toContain("polarity: 'neutral'");
74+
});
75+
76+
it('the authored type admits the three values and refuses any other', () => {
77+
const lower: ObjectMetricProps = { ...tile, polarity: 'lower' };
78+
// @ts-expect-error — `up` is a trend direction, not a polarity.
79+
const wrong: ObjectMetricProps = { ...tile, polarity: 'up' };
80+
expect([lower.polarity, wrong.polarity]).toEqual(['lower', 'up']);
81+
});
82+
});

‎scripts/sdui-manifest.record.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
],
1414
"source": "built-tree",
1515
"generator": "scripts/gen-sdui-manifest-node.mjs",
16-
"generatedAt": "2026-10-28",
17-
"sha256": "dc8b061ccbb918a14547e3d2ef09baba4decbc9687dd9f018c5da18191309afe",
16+
"generatedAt": "2026-10-29",
17+
"sha256": "2ec5933ffaca5ab9cd1767f6d53c0e7eddb3880627c00c3275f71ed03253f3b8",
1818
"components": 106,
1919
"inTree": {
2020
"modulesRoot": "ui/apps/console",

‎sdui.manifest.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,6 +1388,16 @@
13881388
"type": "object",
13891389
"description": "Period-over-period comparison, `{ kind: \"previousPeriod\" }` or `{ kind: \"previousYear\" }` — the computed alternative to a static `trend`."
13901390
},
1391+
{
1392+
"name": "polarity",
1393+
"type": "enum",
1394+
"enum": [
1395+
"higher",
1396+
"lower",
1397+
"neutral"
1398+
],
1399+
"description": "Which direction of change is good news. Colours the trend badge, computed (`compareTo`) or static (`trend`): `lower` draws a rise red and a fall green (turnaround, overdue counts, cost), `neutral` draws it muted. Omit it for rise green, fall red. The arrow always shows which way the number moved."
1400+
},
13911401
{
13921402
"name": "drillDown",
13931403
"type": "object",

0 commit comments

Comments
 (0)