Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/27347-object-metric-polarity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/spec': minor
'@object-ui/plugin-dashboard': minor
'@objectstack/skills': patch
---

feat(spec): an inline `object-metric` tile declares `polarity`, so a lower-is-better KPI draws a rise red

Clause-②: yes (widening)
Metadata-protocol: added (object-metric polarity)

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.

- **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.
- **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.
- **Undeclared keeps today's colours exactly**: a rise green, a fall red. Nothing infers a polarity from the aggregated field's name.
- **Skills.** `objectstack-ui` `rules/dashboards.md` says an inline `object-metric` declares its own `polarity`.

Nothing to do for existing tiles. To use it, add `polarity: 'lower'` to an `object-metric` node whose number is better when it falls.
1 change: 1 addition & 0 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1221,6 +1221,7 @@ Sort field and direction pair
| **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 |
| **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 |
| **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 |
| **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 |

### Nested Shape: `ObjectMetricProps.aggregate`

Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/components/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ regenerates from the renderer registry. Regenerate it with
| [`object-kanban`](/docs/ui/components/plugins#object-kanban) | `plugin-kanban` | 15 | [Plugin Blocks](/docs/ui/components/plugins) |
| [`object-map`](/docs/ui/components/plugins#object-map) | `plugin-map` | 10 | [Plugin Blocks](/docs/ui/components/plugins) |
| [`object-master-detail-form`](/docs/ui/components/plugins#object-master-detail-form) | `plugin-form` | 15 | [Plugin Blocks](/docs/ui/components/plugins) |
| [`object-metric`](/docs/ui/components/plugins#object-metric) | `plugin-dashboard` | 19 | [Plugin Blocks](/docs/ui/components/plugins) |
| [`object-metric`](/docs/ui/components/plugins#object-metric) | `plugin-dashboard` | 20 | [Plugin Blocks](/docs/ui/components/plugins) |
| [`object-pivot`](/docs/ui/components/plugins#object-pivot) | `plugin-dashboard` | 12 | [Plugin Blocks](/docs/ui/components/plugins) |
| [`object-timeline`](/docs/ui/components/plugins#object-timeline) | `plugin-timeline` | 16 | [Plugin Blocks](/docs/ui/components/plugins) |
| [`object-tree`](/docs/ui/components/plugins#object-tree) | `plugin-tree` | 6 | [Plugin Blocks](/docs/ui/components/plugins) |
Expand Down
1 change: 1 addition & 0 deletions content/docs/ui/components/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ Namespace `plugin-dashboard`.
| `fallbackValue` | `string` | | Value shown when no data source resolves. For static/demo tiles; a bound metric should not need it. |
| `trend` | `object` | | Static trend badge: `{ value, label, direction }`. Use `compareTo` instead when the trend should be computed from data. |
| `compareTo` | `object` | | Period-over-period comparison, `{ kind: "previousPeriod" }` or `{ kind: "previousYear" }` — the computed alternative to a static `trend`. |
| `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. |
| `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`. |
| `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. |

Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,7 @@
"ui/ObjectMetricProps:invert",
"ui/ObjectMetricProps:label",
"ui/ObjectMetricProps:objectName",
"ui/ObjectMetricProps:polarity",
"ui/ObjectMetricProps:prefix",
"ui/ObjectMetricProps:suffix",
"ui/ObjectMetricProps:title",
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/page.json

Large diffs are not rendered by default.

61 changes: 60 additions & 1 deletion packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ import { ChartDrillDownSchema, ChartGroupBySchema } from './chart.zod';
// [#21464] `object-metric.compareTo.kind` is the dashboard widget comparison's
// own `kind` vocabulary, by reference — the executor's two kinds.
import { DashboardWidgetSchema } from './dashboard.zod';
// [#27347] `object-metric.polarity` is the dataset measure's own polarity
// vocabulary, by reference — one spelling of which direction is good news.
import { DatasetMeasurePolarity } from './dataset.zod';
// [#21464] `object-metric.drillDown.report` is the report contract itself, by
// reference (decision card #21704, fork 1, letter B): a joined report refuses a
// block that binds no dataset, so a report this schema admits is one the drill
Expand Down Expand Up @@ -5615,6 +5618,16 @@ const ObjectMetricCompareToSchema = lazySchema(() => strictObject({
kind: DashboardWidgetSchema.shape.compareTo.unwrap().shape.kind,
}));

/**
* [#27347] A boolean "is higher better?" flag has no word for a tile where
* neither direction is good news, so the tile's polarity is the dataset
* measure's one key with three peer values.
*/
const OBJECT_METRIC_POLARITY_NOT_A_FLAG =
'a metric tile declares which direction is good news with `polarity`, not a boolean: '
+ "`polarity: 'higher'` (a rise is good), `polarity: 'lower'` (a fall is good: turnaround days, overdue "
+ "counts, cost) or `polarity: 'neutral'` (neither, the badge is drawn without a good or bad colour).";

/**
* `object-metric` (objectui's `ObjectMetricWidget`; read points recorded in
* `liveness/page.json`, row `regions`). The widget destructures every prop it
Expand All @@ -5627,7 +5640,23 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({
surface: 'this `object-metric`',
history: objectBlockHistory('object-metric'),
guidanceSets: COMPONENT_LEVEL_GUIDANCE,
aliases: FILTERS_TO_FILTER,
// [#27347] The dataset measure's near-misses for `polarity`, less its
// `direction` entry: on this tile `direction` is the static `trend` badge's
// arrow, so the bare word gets a prescription naming both keys instead.
aliases: {
...FILTERS_TO_FILTER,
betterWhen: 'polarity',
goodDirection: 'polarity',
trendDirection: 'polarity',
},
guidance: {
direction:
'`direction` is not a member of the tile: the arrow of a static badge goes INSIDE `trend` '
+ '(`trend: { value, direction }`), and which direction of change is good news is `polarity` '
+ "(`polarity: 'lower'` draws a rise red and a fall green).",
higherIsBetter: OBJECT_METRIC_POLARITY_NOT_A_FLAG,
lowerIsBetter: OBJECT_METRIC_POLARITY_NOT_A_FLAG,
},
}, {
objectName: z.string().optional()
.describe('Object this metric aggregates. Optional because the component-level `dataSource` binding can supply the object instead'),
Expand Down Expand Up @@ -5723,6 +5752,36 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({
*/
compareTo: ObjectMetricCompareToSchema.optional()
.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"),
/**
* [#27347] Which direction of change is good news for the tile's number —
* the dataset measure's {@link DatasetMeasurePolarity}, BY REFERENCE: one
* vocabulary and one spelling. This tile aggregates a field, not a dataset
* measure, so a measure's `polarity` has nothing to attach to here and the
* tile declares its own.
*
* Read points recorded in `liveness/page.json`, row `regions`:
* `ObjectMetricWidget` forwards it to `MetricWidget`, which colours the badge
* it draws by the rule the dataset-bound tile applies to its delta
* (`deltaToneClass`), so one KPI is drawn one way on either surface.
*
* A TOP-LEVEL key, not a `compareTo` member, because the badge it colours is
* `derivedTrend ?? trend`: the static `trend` badge was coloured by its
* `direction` alone too, so one key covers the computed and the static
* badge.
*
* - `higher`, and undeclared: a rise green, a fall red, the colours the tile
* always had.
* - `lower`: a fall green, a rise red. Turnaround, overdue counts, cost.
* - `neutral`: neither; the badge keeps its arrow and percentage, muted.
*
* The arrow always shows which way the NUMBER moved; polarity decides only
* the colour. Nothing infers a polarity from the aggregated field's name.
*/
polarity: DatasetMeasurePolarity.optional().describe(
'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',
),
}));
/** Author state (ADR-0122: the bare name is the author state). */
export type ObjectMetricProps = z.input<typeof ObjectMetricPropsSchema>;
Expand Down
82 changes: 82 additions & 0 deletions packages/spec/src/ui/object-metric-polarity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `object-metric.polarity` — which direction of change is good news for an
* inline KPI tile, so a "lower is better" tile colours a rising delta as bad
* (#27347).
*
* The tile aggregates a field, not a dataset measure, so the measure's own
* `polarity` (#26602) had nothing to attach to. The tile now declares the SAME
* key, by reference to `DatasetMeasurePolarity`: the three peer values parse
* and keep their value, an absent key parses exactly as before (no default is
* materialized, so the renderer's undeclared arm keeps today's colours), any
* other value is refused at the key, and the near-miss spellings are pointed
* at it — except the bare `direction`, which on this tile is the static
* `trend` badge's arrow and gets a prescription naming both keys.
*/

import { describe, it, expect } from 'vitest';
import { ObjectMetricPropsSchema, type ObjectMetricProps } from './component.zod';
import { DatasetMeasurePolarity } from './dataset.zod';

const tile = {
objectName: 'approval',
label: 'Avg. turnaround',
aggregate: { field: 'turnaround_days', function: 'avg' as const },
compareTo: { kind: 'previousPeriod' as const },
};

describe('ObjectMetricPropsSchema.polarity', () => {
it.each(['higher', 'lower', 'neutral'] as const)('accepts %s and keeps the value', (polarity) => {
const parsed = ObjectMetricPropsSchema.safeParse({ ...tile, polarity });
expect(parsed.success).toBe(true);
expect(parsed.data?.polarity).toBe(polarity);
});

it('is the dataset measure\'s vocabulary, by reference — not a second spelling', () => {
expect(ObjectMetricPropsSchema.shape.polarity.unwrap()).toBe(DatasetMeasurePolarity);
});

it('an absent polarity parses exactly as before: no key is materialized', () => {
const parsed = ObjectMetricPropsSchema.safeParse(tile);
expect(parsed.success).toBe(true);
expect(Object.prototype.hasOwnProperty.call(parsed.data, 'polarity')).toBe(false);
});

it.each(['up', 'down', 'good', 'inverse', true])('refuses %j at the key', (polarity) => {
const parsed = ObjectMetricPropsSchema.safeParse({ ...tile, polarity });
expect(parsed.success).toBe(false);
expect(parsed.error?.issues.map((i) => [i.code, i.path])).toEqual([['invalid_value', ['polarity']]]);
});

it.each(['betterWhen', 'goodDirection', 'trendDirection'])('points the near-miss `%s` at `polarity`', (key) => {
const parsed = ObjectMetricPropsSchema.safeParse({ ...tile, [key]: 'lower' });
expect(parsed.success).toBe(false);
const issue = parsed.error?.issues.find((i) => i.code === 'unrecognized_keys');
expect(issue).toBeDefined();
expect(issue?.message).toContain('`polarity`');
});

it('answers a top-level `direction` with BOTH keys it could mean', () => {
const parsed = ObjectMetricPropsSchema.safeParse({ ...tile, direction: 'lower' });
expect(parsed.success).toBe(false);
const issue = parsed.error?.issues.find((i) => i.code === 'unrecognized_keys');
expect(issue?.message).toContain('`trend`');
expect(issue?.message).toContain('`polarity`');
});

it.each(['lowerIsBetter', 'higherIsBetter'])('answers the boolean-flag spelling `%s` with the three values', (key) => {
const parsed = ObjectMetricPropsSchema.safeParse({ ...tile, [key]: true });
expect(parsed.success).toBe(false);
const issue = parsed.error?.issues.find((i) => i.code === 'unrecognized_keys');
expect(issue?.message).toContain("polarity: 'lower'");
expect(issue?.message).toContain("polarity: 'neutral'");
});

it('the authored type admits the three values and refuses any other', () => {
const lower: ObjectMetricProps = { ...tile, polarity: 'lower' };
// @ts-expect-error — `up` is a trend direction, not a polarity.
const wrong: ObjectMetricProps = { ...tile, polarity: 'up' };
expect([lower.polarity, wrong.polarity]).toEqual(['lower', 'up']);
});
});
4 changes: 2 additions & 2 deletions scripts/sdui-manifest.record.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
],
"source": "built-tree",
"generator": "scripts/gen-sdui-manifest-node.mjs",
"generatedAt": "2026-10-28",
"sha256": "dc8b061ccbb918a14547e3d2ef09baba4decbc9687dd9f018c5da18191309afe",
"generatedAt": "2026-10-29",
"sha256": "2ec5933ffaca5ab9cd1767f6d53c0e7eddb3880627c00c3275f71ed03253f3b8",
"components": 106,
"inTree": {
"modulesRoot": "ui/apps/console",
Expand Down
10 changes: 10 additions & 0 deletions sdui.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1388,6 +1388,16 @@
"type": "object",
"description": "Period-over-period comparison, `{ kind: \"previousPeriod\" }` or `{ kind: \"previousYear\" }` — the computed alternative to a static `trend`."
},
{
"name": "polarity",
"type": "enum",
"enum": [
"higher",
"lower",
"neutral"
],
"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."
},
{
"name": "drillDown",
"type": "object",
Expand Down
6 changes: 3 additions & 3 deletions skills/objectstack-ui/rules/dashboards.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,9 @@ compareTo: { kind: 'previousYear', dimension: 'close_date' } // several —
```

* **Metric widgets** — a delta arrow plus a trend label from the kind (`vs
previous period`). Its colour follows the measure's `polarity`: `'lower'`
(turnaround, cost) draws a rise red, `'neutral'` uncoloured; undeclared =
rise green. `options.trend` reaches no renderer; `compareTo` is the trend.
previous period`). Its colour follows `polarity` (the measure's; an inline
`object-metric` declares its own): `'lower'` (turnaround, cost) draws a rise
red, `'neutral'` uncoloured; undeclared = rise green. `compareTo` is the trend.
* **Cartesian charts** (`line` / `area` / `bar` / `horizontal-bar` /
`scatter`) — the comparison series is appended after the primary series
with `variant: 'comparison'`, muted per family (dashed `'4 4'` on
Expand Down
30 changes: 4 additions & 26 deletions ui/packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ import { AlertTriangle, ShieldAlert, Download, ArrowUpIcon, ArrowDownIcon, Minus
// surface (see that component's header for why it is dashboard-local).
import { WidgetEmptyState } from './WidgetEmptyState';
import { useFilterScope } from '@object-ui/react';
import { resolveFilterPlaceholders, computeMetricDelta, formatMetricDeltaPercent, type MetricDelta } from './utils';
import { resolveFilterPlaceholders, computeMetricDelta, formatMetricDeltaPercent, deltaToneClass } from './utils';
import { metricAccentTextClass } from './colorVariants';
import {
METRIC_TILE_CONTAINER_CLASS,
Expand Down Expand Up @@ -265,31 +265,9 @@ const COMPARE_KIND_TREND_KEY = {
previousYear: 'vsLastYear',
} as const satisfies Record<CompareToConfig['kind'], string>;

/**
* The good/bad colour of a period-over-period delta, judged by the measure's
* declared polarity (objectstack#26602). The dataset measure's `polarity`
* reaches this widget on the answer's column descriptor (`fields[].polarity`),
* relayed by the server like `format` and `currency`.
*
* - `lower`: a fall is good news (green) and a rise is bad (red). Turnaround
* days, overdue counts, cost: a slower approval no longer reads as a win.
* - `neutral`: neither direction is good or bad, so no tone at all.
* - `higher`, and an UNDECLARED polarity: a rise green, a fall red, the
* colours every delta had before the key existed. Nothing here guesses a
* polarity from the measure's name or unit.
*
* The arrow is not this function's business: it always shows which way the
* number moved. A flat delta (`direction: 'neutral'`) takes no tone under any
* polarity, as before.
*/
function deltaToneClass(
direction: MetricDelta['direction'],
polarity: DatasetResultField['polarity'],
): string | undefined {
if (direction === 'neutral' || polarity === 'neutral') return undefined;
const good = polarity === 'lower' ? direction === 'down' : direction === 'up';
return good ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400';
}
// The good/bad colour of a delta by the measure's declared polarity is
// `deltaToneClass` in `./utils` (objectstack#26602): one rule shared with the
// inline `object-metric` tile's badge (objectstack#27347).

/**
* ISO calendar date, optionally carrying a time part — `2026-01-15`,
Expand Down
Loading
Loading