You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit dbf576b
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
Copy file name to clipboardExpand all lines: content/docs/references/ui/component.mdx
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1221,6 +1221,7 @@ Sort field and direction pair
1221
1221
|**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 |
1222
1222
|**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 |
1223
1223
|**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 |
Copy file name to clipboardExpand all lines: content/docs/ui/components/plugins.mdx
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -85,6 +85,7 @@ Namespace `plugin-dashboard`.
85
85
|`fallbackValue`|`string`|| Value shown when no data source resolves. For static/demo tiles; a bound metric should not need it. |
86
86
|`trend`|`object`|| Static trend badge: `{ value, label, direction }`. Use `compareTo` instead when the trend should be computed from data. |
87
87
|`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. |
88
89
|`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`. |
89
90
|`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. |
.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.
Copy file name to clipboardExpand all lines: sdui.manifest.json
+10Lines changed: 10 additions & 0 deletions
Original file line number
Diff line number
Diff line change
@@ -1388,6 +1388,16 @@
1388
1388
"type": "object",
1389
1389
"description": "Period-over-period comparison, `{ kind: \"previousPeriod\" }` or `{ kind: \"previousYear\" }` — the computed alternative to a static `trend`."
1390
1390
},
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."
0 commit comments