@@ -518,6 +518,162 @@ export function checkDashboardWidgetStageOrder(
518518 } ) ;
519519}
520520
521+ /**
522+ * The widget `type`s that render exactly ONE number — the metric FAMILY.
523+ *
524+ * Read off `ChartTypeSchema`'s own "Performance (single value)" group, which
525+ * is the taxonomy's word for the same set: `metric`/`kpi` render a number and
526+ * `gauge`/`solid-gauge`/`bullet` "render a value today and gain a dial when a
527+ * gauge renderer lands". A dial is still one value; nothing in the group has a
528+ * second mark to put a second measure on.
529+ *
530+ * Declared here beside the check rather than exported from `chart.zod.ts`: the
531+ * taxonomy groups by RENDERER FAMILY in a comment, and a comment is not a set.
532+ * Widening it later (a real gauge that draws a target band, say) is a one-line
533+ * edit here plus a relaxation of this rule — the direction that costs an author
534+ * nothing.
535+ */
536+ const SINGLE_MEASURE_WIDGET_TYPES = [ 'metric' , 'kpi' , 'gauge' , 'solid-gauge' , 'bullet' ] as const ;
537+
538+ /**
539+ * objectui#8894 ruling D — a metric-family widget declares EXACTLY ONE measure.
540+ *
541+ * ## What was wrong
542+ *
543+ * `values` is `z.array(z.string()).min(1)` with no upper bound, so a `metric`
544+ * tile could declare three measures. All three were selected, the analytics
545+ * query ran all three, and the tile rendered `values[0]`: the other two were
546+ * queried and thrown away. That is the declared≠delivered shape ADR-0049 exists
547+ * to end, and it had been kept alive by a runtime warning — objectui#8887
548+ * landed a sub-caption saying the extra measures are not rendered, which makes
549+ * the tile HONEST about dropping them without making the document legal.
550+ *
551+ * The maintainer's standing ruling on this class is 「协议不正确的应该先修改协议。」
552+ * and objectui#8894 decision batch #119 item 4 (2026-09-12) took option **D**
553+ * on this instance: judge the protocol wrong. A single-value card is one
554+ * measure on every mainstream dashboard product; several numbers is a different
555+ * visual, not a variant of this one.
556+ *
557+ * ## Why an object-level check and not a per-`type` union arm — MEASURED
558+ *
559+ * The card left the spelling to this seat. Both spellings refuse the same
560+ * document; they differ in what the author is told about EVERY OTHER mistake.
561+ * Measured on this tree, eight widget bodies through
562+ * `z.union([metricArm, otherArm])` (arms built with `.safeExtend()`, since zod
563+ * 4.4.3 throws `Cannot overwrite keys on object schemas containing refinements`
564+ * on a plain `.extend()` that redeclares a key) versus one more `.superRefine`
565+ * on this strict object:
566+ *
567+ * | body | union arms | this spelling |
568+ * |---|---|---|
569+ * | `bogusProp` on a widget | `(root) invalid_union: Invalid input` | the strict-object refusal, naming the key + the history sentence |
570+ * | `categoryField`/`valueField` | `(root) invalid_union: Invalid input` | the {@link WIDGET_GUIDANCE_SETS} ADR-0021 prescription |
571+ * | `titel` | `(root) invalid_union: Invalid input` | `Did you mean \`titel\` → \`title\`?` |
572+ * | `type: 'ziggurat'` | `(root) invalid_union: Invalid input` | `invalid_value` at `type`, listing all twenty |
573+ *
574+ * Four of eight bodies lose their whole diagnostic to one bare `Invalid input`.
575+ * That is not a new observation on this file — the `compareTo` docblock above
576+ * records the same measurement for the same reason (#5014: "a union collapses
577+ * into one bare `Invalid input` on the wire … A plain strict object's errors
578+ * reach the author"), and `view-union-diagnostics.test.ts` is the whole
579+ * apparatus objectui needed because `ViewMetadataSchema` IS a union. Adding a
580+ * second union to this file would be commissioning that apparatus again to buy
581+ * a refusal the object-level form gives for free.
582+ *
583+ * So: one more check on the same door, attached by identifier, exactly as
584+ * {@link checkDashboardWidgetStageOrder} is.
585+ *
586+ * ## What the refusal says
587+ *
588+ * It names the widget (its `id` and its `type`), states the rule in the ruling's
589+ * own words — one measure per tile, make N tiles for N measures — and names the
590+ * shapes that DO render several numbers, so "I really do want three" has an
591+ * answer that is not "delete two".
592+ *
593+ * ## What this check deliberately does NOT reach
594+ *
595+ * Five shapes, named so the gate is not read as complete:
596+ *
597+ * 1. **The EMPTY array.** `values: []` is refused by the field's own `.min(1)`
598+ * with `too_small`, and this check returns on it rather than adding a
599+ * second issue about a tile with no measure at all. "Exactly one" is the
600+ * CONJUNCTION of that `.min(1)` and this upper bound, not this check alone
601+ * — a mirror that re-attaches this export onto a shape whose `values`
602+ * carries no `.min(1)` gets the upper bound only.
603+ * 2. **A widget that declares no `type`.** `type` carries
604+ * `.default(WIDGET_TYPE_DEFAULT)`, which is `metric` — a member of this
605+ * family — and zod applies defaults BEFORE object-level checks, so an
606+ * omitted `type` arrives here as `metric` and is refused like an authored
607+ * one. The verdict is right either way; the message carries an extra
608+ * sentence in that ambiguous case rather than claiming the author wrote it.
609+ * 3. **A `type` outside `ChartTypeSchema`.** zod treats that `invalid_value`
610+ * as aborting and skips every object-level check for the input, so
611+ * `type: 'ziggurat'` plus four measures reports the type refusal alone.
612+ * 4. **Whether the measures EXIST in the bound dataset.** Still a fact about
613+ * the dataset, not about the widget, and unreachable from this schema — a
614+ * tile naming one measure nobody declared parses exactly as before.
615+ * 5. **objectui's CLIENT-SIDE authoring door**, a `.shape` mirror that runs no
616+ * object-level check of this schema's: at the `.objectui-sha` pin,
617+ * `@object-ui/types` builds its own `DashboardWidgetSchema` from
618+ * `specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict()`
619+ * and re-attaches none of this file's exported checks. Until it imports and
620+ * chains this one, the dashboard EDITOR keeps accepting three measures on a
621+ * `metric` and the author meets the refusal at PUBLISH. That mirror also
622+ * redeclares `type` with no default, so a typeless widget reaches a
623+ * re-attached check as `undefined`; this function defaults it itself for
624+ * exactly that caller.
625+ */
626+ export function checkDashboardWidgetMetricMeasureArity (
627+ widget : { id ?: unknown ; type ?: unknown ; values ?: unknown } ,
628+ ctx : z . RefinementCtx ,
629+ ) : void {
630+ const values = widget . values ;
631+ // Not an array, or empty, or already the one measure the family takes: the
632+ // field's own `z.array(z.string()).min(1)` owns both of the first two
633+ // verdicts and says them better (`too_small` at `values`), and the third is
634+ // the legal document. See non-coverage 1.
635+ if ( ! Array . isArray ( values ) || values . length <= 1 ) return ;
636+
637+ // `?? WIDGET_TYPE_DEFAULT` is UNREACHABLE through this schema's own door —
638+ // zod applies `type`'s default before object-level checks. It is here for the
639+ // mirror that re-attaches this export onto a shape whose `type` carries no
640+ // default (non-coverage 5), so the export never refuses LESS than the door it
641+ // is exported from; `object-refinement-check-exports.test.ts` pins that
642+ // equivalence on the raw fixture.
643+ const type = widget . type ?? WIDGET_TYPE_DEFAULT ;
644+ if ( typeof type !== 'string' ) return ;
645+ if ( ! ( SINGLE_MEASURE_WIDGET_TYPES as readonly string [ ] ) . includes ( type ) ) return ;
646+
647+ // Same ambiguity the stage-order check carries, and the same repair: a widget
648+ // that declared NO type arrives here as `metric` and cannot be told apart
649+ // from one that wrote it, so the extra sentence is added only in that case.
650+ const defaultedTypeNote = type === WIDGET_TYPE_DEFAULT
651+ ? ' (`' + WIDGET_TYPE_DEFAULT + '` is also what a widget that declares no `type` at all '
652+ + 'resolves to — if you meant a chart, the `type` key is missing rather than wrong.)'
653+ : '' ;
654+ const widgetName = typeof widget . id === 'string' && widget . id . length > 0
655+ ? '`' + widget . id + '`'
656+ : 'this widget' ;
657+
658+ ctx . addIssue ( {
659+ code : 'custom' ,
660+ path : [ 'values' ] ,
661+ message :
662+ 'Widget ' + widgetName + ' declares ' + values . length + ' measures on `type: '
663+ + `'${ type } '`
664+ + '`, and a metric-family widget ('
665+ + SINGLE_MEASURE_WIDGET_TYPES . map ( ( t ) => '`' + t + '`' ) . join ( ' / ' )
666+ + ') renders exactly ONE number: one measure per tile, so make N tiles for N '
667+ + 'measures. Every measure after `values[0]` was queried and then dropped on the '
668+ + 'floor by the renderer — keep the one this tile is for, and give each of the '
669+ + 'others its own widget with its own `id` (and `layout`, if you pin positions). '
670+ + 'If you meant several numbers in ONE widget, that is a different visual: '
671+ + "`type: 'table'` renders a row of measures, and the chart families "
672+ + "(`bar` / `line` / `area` / `combo`) render one mark per measure."
673+ + defaultedTypeNote ,
674+ } ) ;
675+ }
676+
521677/**
522678 * Dashboard Widget Schema
523679 * A single component on the dashboard grid.
@@ -702,8 +858,19 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({
702858 dataset : SnakeCaseIdentifierSchema . describe ( 'Dataset name to bind (ADR-0021)' ) . meta ( { title : 'Dataset' } ) ,
703859 /** Dimension names (from the dataset) for X / group / split. */
704860 dimensions : z . array ( z . string ( ) ) . optional ( ) . describe ( 'Dimension names — X/group/split' ) . meta ( { title : 'Dimensions' } ) ,
705- /** Measure names (from the dataset) for the value axis. */
706- values : z . array ( z . string ( ) ) . min ( 1 ) . describe ( 'Measure names — Y (at least one)' ) . meta ( { title : 'Values' } ) ,
861+ /**
862+ * Measure names (from the dataset) for the value axis.
863+ *
864+ * At least one, always. For the METRIC FAMILY — `metric` / `kpi` / `gauge` /
865+ * `solid-gauge` / `bullet`, and the `metric` default a widget with no `type`
866+ * resolves to — exactly one: those types render a single number and dropped
867+ * every measure after `values[0]` on the floor, so the second one is now a
868+ * parse error rather than a queried-and-discarded column
869+ * ({@link checkDashboardWidgetMetricMeasureArity}).
870+ */
871+ values : z . array ( z . string ( ) ) . min ( 1 )
872+ . describe ( 'Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family)' )
873+ . meta ( { title : 'Values' } ) ,
707874
708875 /**
709876 * Layout Position (React-Grid-Layout style)
@@ -853,7 +1020,11 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({
8531020 // ADR-0049 enforce-or-remove on `options.stageOrder`. Attached by identifier
8541021 // rather than inlined, the way `GlobalFilterSchema` attaches its own check:
8551022 // the exported function IS the rule this door runs.
856- . superRefine ( checkDashboardWidgetStageOrder ) ) ;
1023+ . superRefine ( checkDashboardWidgetStageOrder )
1024+ // objectui#8894 ruling D — the metric FAMILY takes exactly one measure. Same
1025+ // idiom, same reason: `values`'s arity is decided by its sibling `type` one
1026+ // level up, so the rule has to run where both keys are in scope.
1027+ . superRefine ( checkDashboardWidgetMetricMeasureArity ) ) ;
8571028
8581029/**
8591030 * Dashboard date-range presets — the named windows a dashboard date filter may
0 commit comments