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
67 changes: 67 additions & 0 deletions .changeset/ui-vocabularies-derive-not-restate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
"@objectstack/spec": minor
"@objectstack/lint": patch
---

feat(spec,lint): the `ui` vocabularies admit what the renderers implement, and derive instead of restating (objectui#2945)

Additions-only follow-up to the vocabulary audit
(objectstack-ai/objectui#2901, #2945). Nothing here narrows a vocabulary, so no
already-stored metadata changes meaning — three of the four `ui/` enums that had
drifted from what is actually implemented, plus the fork that drift had made
invisible.

**`ChartTypeSchema` admits `combo`.** The taxonomy could not name the one chart
family the rest of `chart.zod.ts` is written for: `ChartSeriesSchema.type`
exists to override a series' type — its doc comment literally says *"combo
charts"* — and `ChartSeriesSchema.yAxis` binds a series to the left or right
axis, which is only meaningful for mixed marks. objectui's renderer draws it
distinctly (mixed bar/line/area on dual axes, per-series type) and had to carry
`combo` in a local fork of this list, whose own comment claimed to mirror it.

**`WidgetActionTypeSchema` is `ActionType`.** The two disagreed by one member,
`form`, and the disagreement was backwards: a dashboard header or widget action
button dispatches through the same `ActionRunner` that implements `form` —
objectui's `DashboardRenderer` deliberately routes everything except a raw `url`
into it, so a `flow` header action works (#3528). The narrower enum therefore
rejected at validation exactly what the shared dispatcher then executes.
Derived, so the next type the runner implements needs one edit, not two.

**`ListChartConfigSchema.chartType` is `ChartTypeSchema.extract([...])`.** Same
five members as before — a de-duplication, not a widening. A member renamed in
the taxonomy now fails at build time instead of leaving a second list quietly
disagreeing.

**`@objectstack/lint`'s chart-family set is derived from the taxonomy.**
`validate-widget-bindings` decides which widgets need a `chartConfig` measure
mapping from a hand-written list of families, and its omissions fail in the
worst direction: an unlisted family reads as *"not a chart"*, so a widget
missing its mapping **passes** validation. `combo` was exactly that case —
verified by pinning the old list back, where a `combo` widget with no
`chartConfig` produced zero findings. The set is now the taxonomy minus an
explicit `MEASURE_EXEMPT_CHART_TYPES` (single-value and tabular families), so a
family added to the spec is covered without editing the rule.

Guards: `packages/spec/src/ui/vocabulary-derivation.test.ts` asserts both
derivations still hold (a restated list fails silently — it keeps validating,
just not what the other list says), and the lint suite now walks every
multi-series family in the taxonomy rather than a list of its own.

A third ratchet already existed and did its job: `app-showcase`'s coverage test
requires a gallery widget for every distinctly-renderable `ChartType`, and it
failed the moment `combo` was admitted. The Chart Gallery dashboard now
demonstrates it — a task count as bars on the left axis, an average as a line on
the right, which is the configuration `series[].type` / `series[].yAxis` exist
for.

`ActionType` deliberately does **not** gain `navigation`, which the audit
suggested. `ActionRunner.executeNavigation` is a strictly weaker
`executeUrl` — no `${param.X}` interpolation, no `apiBase` promotion, no
`openIn` — differing only by a `replace` option, and its one live producer is
the SDUI `element:button` `action` prop, which `ElementButtonPropsSchema` does
not model at all. Promoting the name would add a second spelling of *navigate*
to a closed authorable vocabulary (members cannot be removed later) without
closing the gap that actually exists. Tracked separately.

Verified: `@objectstack/spec` **6944 tests / 267 files**, `@objectstack/lint`
**544 tests / 37 files**, both green; `tsc --noEmit` clean on both.
7 changes: 4 additions & 3 deletions content/docs/references/ui/chart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,13 @@ Inline aggregation for an object-bound chart

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>` | ✅ | |
| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'combo' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>` | ✅ | |
| **title** | `string` | optional | Chart title |
| **subtitle** | `string` | optional | Chart subtitle |
| **description** | `string` | optional | Accessibility description — announced to screen readers as the chart’s label |
| **xAxis** | `{ field: string; title?: string; format?: string; min?: number; … }` | optional | X-Axis configuration |
| **yAxis** | `{ field: string; title?: string; format?: string; min?: number; … }[]` | optional | Y-Axis configuration (support dual axis) |
| **series** | `{ name: string; label?: string; type?: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>; color?: string; … }[]` | optional | Defined series configuration |
| **series** | `{ name: string; label?: string; type?: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'combo' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>; color?: string; … }[]` | optional | Defined series configuration |
| **colors** | `string[] \| Record<string, string>` | optional | Color palette (string[]) or value→color map (`{ value: color }`) |
| **height** | `number` | optional | Fixed plot height in pixels (overrides the container default) |
| **showLegend** | `boolean` | ✅ | Display legend |
Expand Down Expand Up @@ -164,7 +164,7 @@ Type: `string`
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Field name or series identifier |
| **label** | `string` | optional | Series display label |
| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>` | optional | Override chart type for this series |
| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'combo' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>` | optional | Override chart type for this series |
| **color** | `string` | optional | Series color (hex/rgb/token) |
| **stack** | `string` | optional | Stack identifier to group series |
| **yAxis** | `Enum<'left' \| 'right'>` | ✅ | Bind to specific Y-Axis |
Expand All @@ -190,6 +190,7 @@ Type: `string`
* `scatter`
* `treemap`
* `sankey`
* `combo`
* `gauge`
* `solid-gauge`
* `metric`
Expand Down
15 changes: 8 additions & 7 deletions content/docs/references/ui/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ const result = Dashboard.parse(data);
| **name** | `string` | ✅ | Dashboard unique name |
| **label** | `string` | ✅ | Dashboard label |
| **description** | `string` | optional | Dashboard description |
| **header** | `{ showTitle: boolean; showDescription: boolean; actions?: { label: string; actionUrl: string; actionType?: Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api'>; icon?: string }[] }` | optional | Dashboard header configuration |
| **widgets** | `{ id: string; title?: string; description?: string; type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>; … }[]` | ✅ | Widgets to display |
| **header** | `{ showTitle: boolean; showDescription: boolean; actions?: { label: string; actionUrl: string; actionType?: Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>; icon?: string }[] }` | optional | Dashboard header configuration |
| **widgets** | `{ id: string; title?: string; description?: string; type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'combo' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>; … }[]` | ✅ | Widgets to display |
| **columns** | `integer` | optional | Number of grid columns (default 12) |
| **gap** | `integer` | optional | Grid gap in Tailwind spacing units |
| **refreshInterval** | `number` | optional | Auto-refresh interval in seconds |
Expand Down Expand Up @@ -63,7 +63,7 @@ Dashboard header configuration
| :--- | :--- | :--- | :--- |
| **showTitle** | `boolean` | ✅ | Show dashboard title in header |
| **showDescription** | `boolean` | ✅ | Show dashboard description in header |
| **actions** | `{ label: string; actionUrl: string; actionType?: Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api'>; icon?: string }[]` | optional | Header action buttons |
| **actions** | `{ label: string; actionUrl: string; actionType?: Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>; icon?: string }[]` | optional | Header action buttons |


---
Expand All @@ -78,7 +78,7 @@ Dashboard header action
| :--- | :--- | :--- | :--- |
| **label** | `string` | ✅ | Action button label |
| **actionUrl** | `string` | ✅ | URL or target for the action |
| **actionType** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api'>` | optional | Type of action |
| **actionType** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional | Type of action |
| **icon** | `string` | optional | Icon identifier for the action button |


Expand All @@ -93,13 +93,13 @@ Dashboard header action
| **id** | `string` | ✅ | Unique widget identifier (snake_case) |
| **title** | `string` | optional | Widget title |
| **description** | `string` | optional | Widget description text below the header |
| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>` | ✅ | Visualization type |
| **chartConfig** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>; title?: string; subtitle?: string; description?: string; … }` | optional | Chart visualization configuration |
| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'combo' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>` | ✅ | Visualization type |
| **chartConfig** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'combo' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>; title?: string; subtitle?: string; description?: string; … }` | optional | Chart visualization configuration |
| **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Widget color variant for theming |
| **requiresObject** | `string` | optional | Hide the widget unless the named object is registered |
| **requiresService** | `string` | optional | Hide the widget unless the named kernel service is registered |
| **actionUrl** | `string` | optional | URL or target for the widget action button |
| **actionType** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api'>` | optional | Type of action for the widget action button |
| **actionType** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional | Type of action for the widget action button |
| **actionIcon** | `string` | optional | Icon identifier for the widget action button |
| **filter** | `any` | optional | Presentation-scope filter (runtimeFilter) |
| **compareTo** | `'previousPeriod' \| 'previousYear' \| { offset: string }` | optional | Period-over-period comparison window |
Expand Down Expand Up @@ -179,6 +179,7 @@ Widget action type
* `modal`
* `flow`
* `api`
* `form`


---
Expand Down
8 changes: 4 additions & 4 deletions content/docs/references/ui/report.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const result = JoinedReportBlock.parse(data);
| **label** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) |
| **description** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) |
| **type** | `Enum<'tabular' \| 'summary' \| 'matrix'>` | ✅ | |
| **chart** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>; title?: string; subtitle?: string; description?: string; … }` | optional | |
| **chart** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'combo' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>; title?: string; subtitle?: string; description?: string; … }` | optional | |
| **dataset** | `string` | optional | Dataset name to bind (ADR-0021) |
| **rows** | `string[]` | optional | Dimension names down (dataset-bound) |
| **columns** | `string[]` | optional | Dimension names across (matrix, dataset-bound) |
Expand Down Expand Up @@ -61,7 +61,7 @@ const result = JoinedReportBlock.parse(data);
| **runtimeFilter** | `any` | optional | Render-time scope filter |
| **order** | `{ by: string; direction: Enum<'asc' \| 'desc'> }[]` | optional | Result ordering, most significant key first |
| **drilldown** | `boolean` | ✅ | Click-through to underlying records |
| **chart** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>; title?: string; subtitle?: string; description?: string; … }` | optional | Embedded chart configuration |
| **chart** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'combo' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>; title?: string; subtitle?: string; description?: string; … }` | optional | Embedded chart configuration |
| **blocks** | `{ name: string; label?: string; description?: string; type: Enum<'tabular' \| 'summary' \| 'matrix'>; … }[]` | optional | Sub-reports for type=joined |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this report. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
Expand All @@ -81,13 +81,13 @@ const result = JoinedReportBlock.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>` | ✅ | |
| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'combo' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>` | ✅ | |
| **title** | `string` | optional | Chart title |
| **subtitle** | `string` | optional | Chart subtitle |
| **description** | `string` | optional | Accessibility description — announced to screen readers as the chart’s label |
| **xAxis** | `string` | ✅ | Dataset dimension name for the X-axis (bound-dataset dimension, not a raw field) |
| **yAxis** | `string` | ✅ | Dataset measure name for the Y-axis (bound-dataset measure, not a raw field) |
| **series** | `{ name: string; label?: string; type?: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>; color?: string; … }[]` | optional | Defined series configuration |
| **series** | `{ name: string; label?: string; type?: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'combo' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>; color?: string; … }[]` | optional | Defined series configuration |
| **colors** | `string[] \| Record<string, string>` | optional | Color palette (string[]) or value→color map (`{ value: color }`) |
| **height** | `number` | optional | Fixed plot height in pixels (overrides the container default) |
| **showLegend** | `boolean` | ✅ | Display legend |
Expand Down
19 changes: 19 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,18 @@ export const ShowcaseTranslationBundle = {
successMessage: 'Portfolio snapshot taken.',
},
},
// Translated at birth for the same reason as `globalActions` above: this
// example is ratcheted at its current untranslated count, so a declared
// label that skips zh-CN widens the debt and fails check-i18n-coverage.
// The gallery's other widget titles predate that ratchet and remain part of
// the frozen baseline. objectui#2945.
dashboards: {
showcase_chart_gallery: {
widgets: {
combo_count_vs_progress: { title: 'Task Count vs Avg Progress' },
},
},
},
},
'zh-CN': {
objects: {
Expand Down Expand Up @@ -308,5 +320,12 @@ export const ShowcaseTranslationBundle = {
successMessage: '已生成业务概览。',
},
},
dashboards: {
showcase_chart_gallery: {
widgets: {
combo_count_vs_progress: { title: '任务数与平均进度' },
},
},
},
},
};
Loading
Loading