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
10 changes: 10 additions & 0 deletions .changeset/31112-ui-skill-examples-checked.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/skills': patch
---

docs(skills): more of objectstack-ui's self-contained examples are type-checked by `check:skill-examples`

Clause-②: no
Metadata-protocol: none

Ten more fenced TypeScript blocks in `skills/objectstack-ui` carry the opt-in check marker, so 25 of its 33 are now compiled against the spec: the new-tab `defineAction` in `rules/actions.md` and the `kind: 'html'` page in `rules/pages.md` gain their import; the styled pricing card and the doc navigation item in `rules/pages.md` are typed as `PageComponent` and `NavigationItem[]`; four widget examples in `rules/dashboards.md` are typed as `DashboardWidget` (or its `options`); and the kanban and gantt examples in `rules/list-views.md` are typed as `ListView`, all from `@objectstack/spec/ui`. No example was wrong. The rest stay unmarked: bare-key fragments, the blocks whose file had no token headroom left for a wrapper, and the `SKILL.md` subform example, whose fence is indented inside a list item where the gate does not read it. No rule's teaching changes.
9 changes: 6 additions & 3 deletions skills/objectstack-ui/rules/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export const AddToCampaignAction = defineAction({
A server-side `body` (or a registered `handler`) receives `ctx` with `input` (the dialog params), `record` (the target row, when a
`recordId` is in scope), `api` (scoped cross-object CRUD) and the caller. Read
the active organization as `ctx.user?.organizationId ?? ctx.session?.organizationId`
— the blessed name (same axes and `undefined` cases as hooks, objectstack-data).
(as in hooks, objectstack-data).
Bodies execute **trusted** (`ctx.api` /
`ctx.engine` bypass RLS/FLS). `ctx.session.positions` (absent when the caller holds none) is
**not an authorization input** — ask the security service for privilege (ADR-0095).
Expand Down Expand Up @@ -150,7 +150,10 @@ path starting with `/` (`{recordId}` placeholder; an external URL is refused, us
`openIn`), valid only with `opensInNewTab: true`; the endpoint enforces its own
auth (the tab carries no in-app session).

<!-- os:check -->
```typescript
import { defineAction } from '@objectstack/spec/ui';

export const OpenInvoicePdfAction = defineAction({
name: 'crm_open_invoice_pdf',
label: 'Open PDF',
Expand All @@ -166,8 +169,8 @@ export const OpenInvoicePdfAction = defineAction({
> A printable document (an invoice) is a page that declares `print` — [Print pages](./pages.md#print-pages).

> ⚠️ **Never express new-tab behavior via `params`** — it is `ActionParam[]` for
> **user input** only: `params: [{ name: 'newTab', type: 'checkbox' }]` builds and
> draws a checkbox in the dialog.
> **user input** only: `params: [{ name: 'newTab', type: 'checkbox' }]` draws a
> checkbox.

## Action Parameter Patterns

Expand Down
47 changes: 29 additions & 18 deletions skills/objectstack-ui/rules/dashboards.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,19 @@ fails `os validate`.
A widget's presentation-scope `filter` flows into the query as the runtime
filter; keep `filter` on the widget when binding a dataset.

<!-- os:check -->
```typescript
{
import type { DashboardWidget } from '@objectstack/spec/ui';

const widget: DashboardWidget = {
id: 'revenue_by_region',
type: 'bar',
title: 'Revenue by Region',
dataset: 'crm_sales',
dimensions: ['region'],
values: ['revenue'],
layout: { x: 0, y: 0, w: 6, h: 4 },
}
};
```

**The real decision is not "inline vs dataset" — it is "can a dataset express
Expand Down Expand Up @@ -175,14 +178,10 @@ export const SalesDashboard: Dashboard = {
filter: { stage: { $nin: ['closed_won', 'closed_lost'] } },
layout: { x: 0, y: 0, w: 3, h: 2 },
colorVariant: 'success', // the accent only
// Period-over-period: renderer fetches the prior quarter and
// surfaces a secondary value + delta arrow automatically.
compareTo: { kind: 'previousPeriod' },
},

// Chart widget with comparison overlay (M2). The renderer issues a
// second query with the time window shifted by `compareTo` and
// overlays it as a muted/dashed series. The date axis is a dataset
// Chart widget with comparison overlay. The date axis is a dataset
// dimension whose monthly bucketing lives on the dataset (`dateGranularity`).
{
id: 'revenue_vs_last_year', type: 'line',
Expand Down Expand Up @@ -212,30 +211,35 @@ vocabularies.
| `filterBindings: { filterName: false }` | a widget | Opt out — the honest binding when the vocabularies are disjoint and no field can be re-targeted. |
| absent | a widget | Default binding: the filter's own `field`; for the reserved `dateRange`, `dateRange.field ?? 'created_at'`. |

<!-- os:check -->
```typescript
import type { DashboardWidget } from '@objectstack/spec/ui';

// One control, two objects: re-target on one widget, opt the other out.
const widgets: DashboardWidget[] = [
{ id: 'revenue_by_region', dataset: 'crm_order_metrics', values: ['total_sum'],
filterBindings: { region: 'sales_region' } },
{ id: 'projects_at_risk', dataset: 'crm_project_metrics', values: ['project_count'],
filterBindings: { task_status: false } },
];
```

### Period-over-period — `compareTo`

Set `compareTo` on any data-bound widget to add a second query against a
shifted time window. The renderer derives the comparison automatically;
no second `filter` is required.
shifted time window.

`compareTo` is `{ kind, dimension? }` — the same shape the analytics executor
reads (`DatasetSelection.compareTo`), so what a widget declares is exactly what
runs.
reads (`DatasetSelection.compareTo`).

| Key | Value | Behaviour |
|:--|:--|:--|
| `kind` | `'previousPeriod'` | The equal-length window immediately before the resolved one. |
| `kind` | `'previousYear'` | The same window shifted back one calendar year. |
| `dimension` | dimension name, **optional** | Which time dimension's window to shift. Omit it when the selection dates exactly one — the executor resolves it. With zero or several it errors, naming the candidates; it never guesses. |

The two bare `compareTo:` lines are a fragment, so `check:skill-examples` skips them.

```typescript
compareTo: { kind: 'previousPeriod' } // one dated dimension
compareTo: { kind: 'previousYear', dimension: 'close_date' } // several — say which
Expand All @@ -250,26 +254,30 @@ compareTo: { kind: 'previousYear', dimension: 'close_date' } // several —
with `variant: 'comparison'`, muted per family (dashed `'4 4'` on
line/area only; reduced opacity on all). That muting is the renderer's
default; `chartConfig.colors` is the one palette channel that remains.
* **Pie / donut / funnel** — `compareTo` is silently ignored; there is no
meaningful "two-period" composition for part-of-whole charts.
* **Pie / donut / funnel** — `compareTo` is silently ignored.
* **Requirements** — a comparison needs a **dated window** to shift. When the
selection carries no time dimension with a date range (no resolvable date
macro in the widget `filter`, no dashboard `dateRange`), the executor says so
rather than rendering a silently empty comparison column. The shifted query
reuses the original `filter` shape and replaces only the date-bound clauses.

<!-- os:check -->
```typescript
// Metric — WoW delta (binds the crm_task_metrics dataset; filter = runtimeFilter)
import type { DashboardWidget } from '@objectstack/spec/ui';

const widgets: DashboardWidget[] = [
// Metric — WoW delta
{ id: 'done_this_week', type: 'metric', dataset: 'crm_task_metrics', values: ['task_count'],
filter: { assignee: '{current_user_id}', status: 'done',
completed_at: { $gte: '{week_start}' } },
compareTo: { kind: 'previousPeriod' } }
compareTo: { kind: 'previousPeriod' } },

// Bar — YoY overlay on a stable category set
{ id: 'headcount_by_dept', type: 'bar', dataset: 'crm_employee_metrics',
dimensions: ['department'], values: ['headcount'],
filter: { status: { $ne: 'terminated' } },
compareTo: { kind: 'previousYear' } }
compareTo: { kind: 'previousYear' } },
];
```

### Server-side date bucketing — `dateGranularity` (ADR-0021)
Expand Down Expand Up @@ -342,9 +350,12 @@ These five change the dataset SQL; a typo (`sortDirection`) parses too, and draw
| `limit` | positive integer | Max rows, applied **after** ordering — so "top 10 accounts" is `limit` **plus** `sortBy`. Without `sortBy` the runtime orders by the selected dimensions: deterministic, but not the top of anything. |
| `stageOrder` | array of **stored** values | Explicit stage order for `funnel`; no other type reads it. Stored values, not display labels; omit to inherit the field's picklist order. |

<!-- os:check -->
```typescript
// Top 10 accounts by revenue, bucketed monthly for this widget only
options: { sortBy: 'revenue_sum', sortOrder: 'desc', limit: 10, dateGranularity: 'month' }
import type { DashboardWidget } from '@objectstack/spec/ui';

// Top 10 accounts by revenue, bucketed monthly
const options: DashboardWidget['options'] = { sortBy: 'revenue_sum', sortOrder: 'desc', limit: 10, dateGranularity: 'month' };
```

### Drilldown
Expand Down
35 changes: 20 additions & 15 deletions skills/objectstack-ui/rules/list-views.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,19 @@ export const CaseViews = defineView({
```

> **Never export a bare flat view object** (`{ name, label, type, columns }`
> at top level): nothing registers and no view appears in the switcher. Every
> view lives under `list` / `form` / `listViews` / `formViews`, as above.
> at top level): nothing registers and no view appears in the switcher.

### Data Source (`data`)

Every view connects to data via one of two providers:
Bare-key blocks in this file are fragments, so `check:skill-examples` skips
them. Every view connects to data via one of two providers:

```typescript
data: { provider: 'object', object: 'support_case' }
data: { provider: 'value', items: [...] }
```

> Use `provider: 'object'` for an ObjectStack-managed object (CRUD, live
> updates, filtering, pagination). A view never calls an API: an API read is a
> Use `provider: 'object'` for an ObjectStack-managed object. A view never calls an API: an API read is a
> page element's `dataSource` binding.

### Columns
Expand Down Expand Up @@ -137,15 +136,15 @@ Rules:
- Every `field` MUST exist on the source object — reference diagnostics
(`_diagnostics`) flag unknown fields; treat `valid: false` as a failed write.
- **A list view has no `tabs` key** — it was removed (a parse error that
names the fix): nothing ever drew it. Each named preset is a `listViews`
names the fix). Each named preset is a `listViews`
entry, rendered as a tab in the object's saved-view switcher.
- **On an object list view (`*.view.ts` `list` / `listViews`), only
`element: 'dropdown'` (value chips) is allowed — `tabs` is page-only**
(ADR-0047 amendment): the saved-view switcher already owns the tab bar, so
`ObjectUserFiltersSchema` **rejects** `tabs` at parse (not dropped) and the
`validate` list-view-mode lint reports it. The full
`dropdown | tabs | toggle` range applies only to **page lists** /
`interfaceConfig.userFilters` (the block above).
`interfaceConfig.userFilters`.
- **Omit `userFilters` when unsure — omission means a clean toolbar.** Nothing
is auto-derived.
- `userFilters: { element: 'dropdown' }` (no `fields`) is valid shorthand:
Expand Down Expand Up @@ -183,7 +182,7 @@ export const CaseViews = defineView({
| declares `searchableFields` | **that list, verbatim** — any stored field type |
| declares nothing | the auto-default: the name field + the text-like columns (`text` / `email` / `phone` / `url` / `autonumber` / `textarea` / `markdown` / `select`) + every field marked `searchable: true`, of any type except `secret` / `password` / `vector` or a virtual `formula` |

`searchable: false`, the default, removes nothing the type default scans; declare `searchableFields` to choose the set.
`searchable: false`, the default, removes nothing the type default scans.

Modelling side: **objectstack-data → Search Fields**. Query side
(`search.fields` over the API): **objectstack-query → Full-Text Search**.
Expand Down Expand Up @@ -256,19 +255,22 @@ grouping: { fields: [
Board settings nest under `kanban:` (`KanbanConfigSchema`) — there is **no
top-level `groupBy`**; the top-level `columns` stays required.

<!-- os:check -->
```typescript
{
import type { ListView } from '@objectstack/spec/ui';

const board: ListView = {
type: 'kanban',
data: { provider: 'object', object: 'project_task' },
columns: ['title', 'assignee', 'priority'],
kanban: {
groupByField: 'status', // a select field — one lane per option
lanes: ['todo', 'in_progress', 'review'], // option VALUES to draw, in this order
lanes: ['todo', 'in_progress', 'review'],
summarizeField: 'estimate_hours', // optional — summed at the top of each column
columns: ['title', 'assignee', 'priority'], // fields shown on each card
},
sort: [{ field: 'priority', order: 'desc' }],
}
};
```

> `lanes` chooses the lanes and their order, not the records: a record whose
Expand All @@ -290,8 +292,8 @@ says to convert or replace THAT view.
**A board over a field added in the same change opens empty.** Adding a field
writes nothing into existing rows, so when `kanban.groupByField` is a `select`
created in this change every pre-existing record lands in the trailing
`Uncategorized` lane: one empty lane per option, the populated lane last —
past the right edge of a narrow screen — under a non-zero record count. That
`Uncategorized` lane: one empty lane per option, the populated lane last,
under a non-zero record count. That
is the renderer's correct fallback, not lost data, and the hand-over says so
instead of "the board is ready": (a) the existing records are all in the last
`Uncategorized` lane, scroll right; (b) dragging a card out of it onto a real
Expand All @@ -309,8 +311,11 @@ just-added field.
Timeline settings nest under `gantt:` (`GanttConfigSchema`);
`startDateField` / `endDateField` / `titleField` are required in it.

<!-- os:check -->
```typescript
{
import type { ListView } from '@objectstack/spec/ui';

const timeline: ListView = {
type: 'gantt',
data: { provider: 'object', object: 'project_task' },
columns: ['name', 'assigned_to', 'status'], // left-pane tree columns
Expand All @@ -322,7 +327,7 @@ Timeline settings nest under `gantt:` (`GanttConfigSchema`);
dependenciesField: 'depends_on', // FS dependency arrows
parentField: 'parent', // builds the summary-bar tree
},
}
};
```

Rows with children (or `type: 'summary'`) are **summary bars**: dragged as a
Expand Down
26 changes: 16 additions & 10 deletions skills/objectstack-ui/rules/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,14 @@ Tags are the **registered components** (bare names: `<flex>`, `<grid>`, `<card>`
set** (`<h1>`–`<h6>`, `<p>`, `<a>`, `<ul>/<ol>/<li>`, `<img>`, `<blockquote>`, `<strong>`,
…). Props come from each component's registry `inputs` (e.g. `<text content=…>`,
`<badge label=…>`). **No JavaScript** — `onClick`, `{expr}` logic and `.map()` are NOT
available; use `kind:'react'` for those. `os build` parses the source and fails loudly
available. `os build` parses the source and fails loudly
on unknown tags / missing required props / forbidden constructs (event handlers,
`dangerouslySetInnerHTML`).

<!-- os:check -->
```typescript
import { definePage } from '@objectstack/spec/ui';

export const ReleaseNotesPage = definePage({
name: 'crm_release_notes', label: 'Release Notes', type: 'home', kind: 'html',
source: `
Expand Down Expand Up @@ -254,9 +257,11 @@ Rules:
`element:button` — and style each block's root. (`page:card` etc. are fine for
structure but style what you control.)

<!-- os:check -->
```typescript
// A styled pricing card — every block carries responsiveStyles + tokens.
{
import type { PageComponent } from '@objectstack/spec/ui';

const card: PageComponent = {
id: 'plan_solo', type: 'flex',
responsiveStyles: {
large: {
Expand All @@ -277,7 +282,7 @@ Rules:
properties: { label: 'Upgrade', variant: 'primary', size: 'large' } },
],
},
}
};
```

**In the source tiers (`kind:'html'` / `kind:'react'`) the same rule holds — no
Expand Down Expand Up @@ -344,26 +349,27 @@ behind the screen is not.
The viewer is **platform-level**: one global `/docs/<name>` route
resolves any doc regardless of which app you came from. The URL is
**single-coordinate** — no package or app prefix — so a doc has exactly
one URL. Do **not** design per-app or per-package doc URLs; that gives one
doc many addresses and breaks cross-references.
one URL. Do **not** design per-app or per-package doc URLs.

To surface a doc inside an app, add a `doc` navigation item (a `url` item is
for an external or custom URL): `doc: '<name>'` opens that page,
`book: '<book>'` opens the book at its first readable page (both: that page
in that book). It inherits the docs audience gate, and `os build` refuses a
target the package lacks (`docs/nav-target`):

<!-- os:check -->
```typescript
navigation: [
import type { NavigationItem } from '@objectstack/spec/ui';

const navigation: NavigationItem[] = [
{ id: 'nav_help', type: 'doc', doc: 'crm_user_guide',
label: 'User Guide', icon: 'book-open' },
]
];
```

> **Live instances.** For a *live, interactive
> instance* — a dashboard, a report, a record table — **don't embed it**:
> link to it by URL and let the platform render it (one source, never a
> stale copy).
> link to it by URL and let the platform render it.

### Inline metadata views — the `metadata` fence (ADR-0051)

Expand Down
Loading