From 72e1a98f2bc09bef1947943d920ec33c0a62d025 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 00:33:52 +0000 Subject: [PATCH 1/2] docs(ui): add the report and theming guides, and the book spine half of doc-pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three authorable surfaces had a generated schema reference and no page teaching them. Same defect shape, same directory, one meta.json — landed together. - content/docs/ui/reports.mdx (new) — leads with the distinction that sends a reader here (report vs list view vs dashboard widget), then the four report types, drill-through, the embedded chart, app-nav reachability, and what the schema does and does not say about access. Dataset authoring, ordering semantics and filter placeholders are cross-linked to data-modeling/analytics.mdx rather than restated. - content/docs/ui/theming.mdx (new) — the palette/typography/radius/shadow surface, the schema-key -> CSS-variable rename table, the two-theme light/dark idiom the showcase actually ships, and the bare-HSL-triple token contract that makes ui/react-pages.mdx's `hsl(var(--token))` rule what it is. Carries a warn callout stating what authoring a theme does and does not wire up today. - content/docs/ui/doc-pages.mdx — adds the `book` half. The existing `doc` content is untouched; the new section states precisely what derives group membership, the three per-doc keys the spine reads, identity and audience, a worked example, and how `doc` and `book` compose on the tree endpoint. - content/docs/ui/meta.json — `reports` after `dashboards`, `theming` after `reports`. Two insertions; nothing else re-ordered. - .claude/workflows/docs-accuracy-audit.js — the mechanical `--write` regeneration `check:docs-audit-scope` requires when a hand-written page is added, committed exactly as the tool produced it (+2/-0). `.claude/**` is a governed surface, so this PR is human-merge-only. Claude-Session: https://claude.ai/code/session_01GawRwpD44VwBDVy3hs77AX Co-authored-by: Claude Co-authored-by: Claude Opus 5 --- .claude/workflows/docs-accuracy-audit.js | 2 + content/docs/ui/doc-pages.mdx | 166 ++++++++++++++- content/docs/ui/meta.json | 2 + content/docs/ui/reports.mdx | 258 +++++++++++++++++++++++ content/docs/ui/theming.mdx | 227 ++++++++++++++++++++ 5 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 content/docs/ui/reports.mdx create mode 100644 content/docs/ui/theming.mdx diff --git a/.claude/workflows/docs-accuracy-audit.js b/.claude/workflows/docs-accuracy-audit.js index c43448f066..f1d0702957 100644 --- a/.claude/workflows/docs-accuracy-audit.js +++ b/.claude/workflows/docs-accuracy-audit.js @@ -210,7 +210,9 @@ const ALL_HANDWRITTEN = [ "content/docs/ui/pages.mdx", "content/docs/ui/public-data-collection.mdx", "content/docs/ui/react-pages.mdx", + "content/docs/ui/reports.mdx", "content/docs/ui/setup-app.mdx", + "content/docs/ui/theming.mdx", "content/docs/ui/translations.mdx", "content/docs/ui/views.mdx", "content/docs/upgrading.mdx", diff --git a/content/docs/ui/doc-pages.mdx b/content/docs/ui/doc-pages.mdx index e69772e49c..35a22a6a39 100644 --- a/content/docs/ui/doc-pages.mdx +++ b/content/docs/ui/doc-pages.mdx @@ -1,6 +1,6 @@ --- title: Doc Metadata -description: Ship package documentation as metadata — flat src/docs/*.md files compiled into the manifest and rendered in the console +description: Ship package documentation as metadata — flat src/docs/*.md files compiled into the manifest and rendered in the console, ordered by a book navigation spine --- # Doc Metadata @@ -157,6 +157,170 @@ The CRM package manages accounts, contacts, and opportunities. Saved as `src/docs/crm_index.md`, this compiles to a `crm_index` doc and renders at `/docs/crm_index`. +## Navigation: the `book` spine + +A doc is one page. A **Book** is the *spine* of a table of contents over many of them: +an ordered set of groups (sections), plus the book's own identity and access. Flat +`src/docs/*.md` files give you pages with no order; a book is what turns them into a +navigable structure — and it is the only thing that does. + +A package ships **zero or more** books, and a book never owns content: one doc may +surface in two books, or in none. Books are authored in `*.book.ts` files. + +### Membership is derived, never stored + +This is the load-bearing decision of the design ([ADR-0046](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0046-package-docs-as-metadata.md) §6.2.1), +and it is why a book has no member array to keep up to date. A group declares a **rule**; +the tree is computed against whatever docs exist at the moment it is requested. + +Precisely what derives it, in the order it runs: + +1. **Groups are ordered** by `group.order`, ties broken by declaration order. +2. **Each doc joins the first group that claims it** — the first group, in that order, + whose `include` rule matches the doc **or** whose `key` equals the doc's own `group`. + First claim wins, so a doc never appears twice. +3. **Within a group, docs sort by `doc.order`, then by label** (falling back to the doc + name). +4. **Anything claimed by nobody is appended last** in a synthetic *Uncategorized* group. + Nothing is ever dropped. + +So the AI-authoring property the design was built for holds: create a doc whose name +matches a rule and it files itself. There is no central array to read, modify and write +back — which is the edit that drops or reorders siblings when two authors do it at once, +and the one that package overlay cannot merge. + +An `include` rule takes one of two forms: + +- **A glob over doc names** — `'crm_guide_*'`. Only `*` is special and it is anchored to + the whole name. +- **A tag** — `{ tag: 'tutorial' }`, matched against the doc's `tags`. Use it for + membership that cuts across naming; prefer a name convention when one exists. + +`group.package` scopes a rule to one package id (default: the book's own), so a group can +deliberately gather docs another package ships. + +### The three per-doc keys the spine reads + +| Key | Effect | Set from | +| :--- | :--- | :--- | +| `order` | sort position within the group that claims the doc | frontmatter `order:` | +| `group` | explicit placement — the `key` of the group this doc belongs to, used when no rule expresses it | frontmatter `group:` | +| `tags` | the operand of a group's `include: { tag }` rule | **not read from frontmatter** — see below | + + +**`tags` cannot be set from a `src/docs/*.md` file today.** The frontmatter reader +extracts single-line scalars — `title`, `description`, `order`, `group` — and has no case +for a list, so a `tags:` block in a Markdown doc is not collected and the doc reaches the +resolver with no tags. The schema key and the matcher are both live, so a doc declared +programmatically in a stack's `docs` array does carry tags and does match. For the flat +Markdown path, express the grouping with a name glob instead. + + +### Identity and access + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `name` | `string` | ✅ | Machine name (`snake_case`), namespace-prefixed like every metadata name | +| `label` | `string` | — | Display title | +| `description` | `string` | — | One-line summary | +| `slug` | `string` | — | Portal URL segment; defaults to the name without its prefix | +| `icon` | `string` | — | Icon name | +| `order` | `number` | — | Orders this book among the portal's books | +| `audience` | `'org' \| 'public' \| { permissionSet }` | — | Who may read it; defaults to `'org'` | +| `groups` | `BookGroup[]` | ✅ | The spine. Two levels total — groups, then entries | + +`audience` is a reference into the permission model rather than a vocabulary of its own: +`'org'` (the default) inherits the package grant and admits any signed-in principal, +`'public'` is anonymously readable and indexable, and `{ permissionSet: 'crm_admin' }` +admits a signed-in principal holding that named set. A caller whose holdings cannot be +resolved is denied — the gate fails closed. + + +The gate is a **capability** reference, never a distribution one: packages own permission +sets but never positions, so a package gating its own Admin Guide keeps provenance and +uninstall semantics intact. (ADR-0046 §6.7 sketched this as `{ profile }`; the shipped +key is `permissionSet`, per ADR-0090.) + + +### A worked example + +Saved as `src/books/crm_docs.book.ts`, alongside the `crm_index` doc from the previous +section: + +{/* os:check */} +```typescript +import { defineBook } from '@objectstack/spec/system'; + +export const CrmDocsBook = defineBook({ + name: 'crm_docs', + label: 'CRM Documentation', + slug: 'crm', + audience: 'org', + groups: [ + { + key: 'overview', + label: 'Overview', + order: 1, + // Hand-pinned order; `...` sweeps in anything else the rule would match. + include: 'crm_index*', + pages: ['crm_index', '---', '...'], + }, + { + key: 'guides', + label: 'User Guides', + order: 2, + include: 'crm_guide_*', // crm_guide_leads, crm_guide_accounts, … + }, + { + key: 'admin', + label: 'Administration', + order: 3, + include: 'crm_admin_*', + // This section alone is gated; the rest of the book stays 'org'-visible + // because the doc's effective audience is the union over claiming books. + }, + ], +}); +``` + +A group may pin its order by hand instead of deriving it. `pages` wins over `include` for +that group and takes doc names plus two literals: `'---'` renders a separator, and `'...'` +expands to *the rest* — every doc the group's rule would have claimed but that no entry +names, in `order`-then-label sequence. An entry can also be an object to attach a `label` +override, a `badge` or an `icon`, or to point at an external `href` instead of a doc. + + +Inline `translations` on a **book** or a **book group** is rejected: no resolver ever read +it, so a localized spine shipped its authoring-locale strings to every reader. The near +neighbour that *does* work is `doc.translations`, read on every doc render path — localize +the docs themselves. + + +### How `doc` and `book` compose + +The two kinds have a clean split, and the direction of reference only goes one way: + +- A **doc** carries the content and, optionally, three scalars that let a spine place it + (`order`, `group`, `tags`). It names no book. +- A **book** carries the structure and names no docs — except in a `pages` override, which + is the deliberate escape hatch. + +The rendered tree is resolved on read, not on write: +`GET /api/v1/meta/book//tree` fetches the book and the current doc set and returns +the resolved groups and entries. Two behaviours follow from that: + +- **A name that matches no authored book is treated as a package id** and resolved against + the *implicit* per-package book — one group, `include: '*'`, audience `'org'`. There is + no "flat versus book" fork in the model; a package that authors no book still has one, + and that is what renders the flat case. +- **Access is filtered twice.** The book's `audience` gates the whole tree (401 anonymous, + 403 for a missing permission set), and then each entry is filtered by its doc's own + effective audience — the union over every book claiming it, defaulting to `'org'` for a + doc no book claims. An anonymous reader of a public book therefore never sees a nav + entry that would fail on fetch. Orphans in the *Uncategorized* group are deliberately + excluded from what a book "claims", so an unclaimed doc can never ride a public book out + of the tenant. + ## Next Steps - See the in-repo authoring reference in the showcase package: diff --git a/content/docs/ui/meta.json b/content/docs/ui/meta.json index 3643f6e4e1..aee60a70a1 100644 --- a/content/docs/ui/meta.json +++ b/content/docs/ui/meta.json @@ -8,6 +8,8 @@ "views", "actions", "dashboards", + "reports", + "theming", "translations", "forms", "doc-pages", diff --git a/content/docs/ui/reports.mdx b/content/docs/ui/reports.mdx new file mode 100644 index 0000000000..24b1c919bb --- /dev/null +++ b/content/docs/ui/reports.mdx @@ -0,0 +1,258 @@ +--- +title: Report Metadata +description: Analytics reports as metadata — the four report shapes, dataset binding, drill-through, and how a report differs from a list view and a dashboard widget +--- + +# Report Metadata + +A **Report** is an analytics artifact. It groups and aggregates the rows of a +[dataset](/docs/data-modeling/analytics) into a pivot — grouped down-axis rows, +optionally pivoted across a second axis, with measures in the cells — and gives that +pivot its own page in the app. + +Three surfaces in this module look similar from a distance and are not +interchangeable. Pick by **what the reader is looking at**: + +| | A report shows | Bound to | Authored in | Reached at | +| :--- | :--- | :--- | :--- | :--- | +| **List view** | individual records, one row each | an **object** | that object's `views` | the object's nav entry | +| **Dashboard widget** | one aggregate slice, sized into a tile grid | a **dataset** | a dashboard's `widgets[]` | inside its dashboard | +| **Report** | a whole pivot — grouped rows, optional across-axis columns, aggregated cells, an optional chart, drill-through to the records | a **dataset** | `defineStack({ reports })` | its own nav entry and URL | + +The line between the first and the third is the one that actually gets crossed. A flat +list of records is an **object-bound row lens** (ADR-0017), not analytics — so it belongs +in a view, whatever it is called. The showcase package made exactly that move: its former +`TaskListReport` is now the `tabular` list view on `showcase_task`, because a report that +never aggregated anything was a view wearing the wrong kind. + +The line between the second and the third is scale, not capability: a widget is one slice +sized into a dashboard's grid, a report is the full grid with its own page. Both bind +datasets the same way, so the numbers agree by construction. + +## A report binds a dataset — and only a dataset + + +**There is one data path, not two.** Under the **ADR-0021** single-form cutover a report +is dataset-bound, full stop. The legacy inline query — `objectName` plus `columns` plus +`groupings` on the report itself — was removed in that cutover, so there is no +object-bound report to choose between. Writing `objectName`, `object`, `source` or +`dataSet` is rejected at authoring time with a pointer at `dataset`. + + +Every report except a `joined` one must declare `dataset` **and** a non-empty `values`; +the schema refuses it otherwise, with the message *a report needs `dataset` + `values` +(measure names)*. A `joined` report carries its data on `blocks[]` instead and must declare at +least one block. + +The dataset owns the base object, the joins, and the named dimensions and measures. That +is what keeps a number identical across every report, widget and dashboard that selects +it. Dataset authoring — declaring dimensions and measures, and what `rows` / `values` / +`runtimeFilter` select from them — is covered once, from the dataset side, in +[Analytics & Datasets](/docs/data-modeling/analytics). This page covers the report shape +on top of it. + +## The four report types + +`type` defaults to `tabular`. + +| `type` | Renders | Needs | +| :--- | :--- | :--- | +| `tabular` | the dataset's rows as a flat table | `dataset` + `values` | +| `summary` | rows grouped down one or more dimensions, measures aggregated per group | `dataset` + `values`, and `rows` to group by | +| `matrix` | a true pivot: `rows` down × `columns` across, measures in the cells | `dataset` + `values` + `rows` + `columns` | +| `joined` | several independent sub-reports stacked in one page | `blocks[]` (each block dataset-bound) | + +### Summary — grouped totals + +{/* os:check */} +```typescript +import { defineReport } from '@objectstack/spec/ui'; + +export const HoursByStatusReport = defineReport({ + name: 'showcase_hours_by_status', + label: 'Hours by Status', + description: 'Estimated hours grouped by task status.', + type: 'summary', + dataset: 'showcase_task_metrics', + rows: ['status'], // dimension names, down the page + values: ['est_hours'], // measure names, aggregated per group +}); +``` + +### Matrix — a cross-tab + +`columns` is the across-axis and is read only by a `matrix` report; other types ignore it. + +{/* os:check */} +```typescript +import { defineReport } from '@objectstack/spec/ui'; + +export const StatusPriorityMatrixReport = defineReport({ + name: 'showcase_status_priority_matrix', + label: 'Status × Priority', + type: 'matrix', + dataset: 'showcase_task_metrics', + rows: ['status'], // down axis + columns: ['priority'], // across axis + values: ['est_hours'], // in the cells +}); +``` + +### Joined — several sub-reports in one page + +Each block is independently queried and stacked in the container. Use it for comparative +panels over one domain — "open / completed", "new / qualified / closed" — where each panel +is a different slice rather than a different subject. + +A block is a sub-report, so it takes the same `dataset` / `rows` / `columns` / `values` / +`runtimeFilter` / `order` vocabulary. Four things are **container-level only** and are +rejected on a block: nested `blocks` (no recursion — a block's `type` enum excludes +`joined`), `drilldown`, `protection`, and — as below — `order` on a `joined` container. + +{/* os:check */} +```typescript +import { defineReport } from '@objectstack/spec/ui'; + +export const TaskOverviewReport = defineReport({ + name: 'showcase_task_overview', + label: 'Task Overview', + type: 'joined', + blocks: [ + { + name: 'open_block', + label: 'Open Tasks', + type: 'summary', + dataset: 'showcase_task_metrics', + rows: ['status'], + values: ['est_hours'], + runtimeFilter: { done: false }, + }, + { + name: 'done_block', + label: 'Completed Tasks', + type: 'summary', + dataset: 'showcase_task_metrics', + rows: ['status'], + values: ['task_count'], + runtimeFilter: { done: true }, + }, + ], +}); +``` + +## Ordering + +`order` is a **list** of sort keys, most significant first — an array rather than a map, +because the key order is the sort significance and JSON object key order is not a contract +you should have to lean on. Each key is `{ by, direction }`, `direction` defaulting to +`asc`. + +Two rules are enforced when the report is authored, not discovered when it renders: + +- **`by` must name something this report selects** — a `rows` / `columns` dimension or a + `values` measure. Anything else is an authoring error rather than an ordering that + silently does nothing. +- **A `joined` report orders per block.** `order` on the container is rejected with + *a `joined` report orders per block — move `order` onto `blocks[]`*. + +Ordering is optional: a selected date dimension already comes back chronological. What +`order` is for — sorting by a measure, reversing a time axis, ordering a non-time +dimension — and how it is applied server-side over the whole grid is covered in +[Analytics & Datasets](/docs/data-modeling/analytics), which this page does not repeat. + +## Drill-through + +`drilldown` is a **boolean**, on by default (ADR-0021 D2). It turns click-through from an +aggregated row or cell to the underlying records on or off for a `summary` / `matrix` +report; the host resolves the dataset's object and its dimension-to-field mapping. + + +`drillDown` — camelCase — is a **different capability on a different surface**: it is the +react-tier `` prop, a configuration *object* that configures a +chart segment drill. The report key is `drilldown`, all lowercase, and a plain boolean. +The two are one character apart, so a rename suggestion would walk you straight into a +second rejection — write `drilldown: true` / `false` if you mean the report. + + +## An embedded chart + +A report may carry one `chart`. Its `xAxis` and `yAxis` name the **bound dataset's** +dimension and measure — not raw object fields — and are plotted from a second dataset +query, so the chart and the grid cannot disagree. + +{/* os:check */} +```typescript +import { defineReport } from '@objectstack/spec/ui'; + +export const HoursByStatusChartReport = defineReport({ + name: 'showcase_hours_by_status_chart', + label: 'Hours by Status (Chart)', + type: 'summary', + dataset: 'showcase_task_metrics', + rows: ['status'], + values: ['est_hours'], + chart: { + type: 'bar', + xAxis: 'status', // a dataset DIMENSION + yAxis: 'est_hours', // a dataset MEASURE + }, +}); +``` + +## Making a report reachable + +A report is not reachable because it exists. Give it a navigation entry on an app: + +{/* os:check */} +```typescript +import { defineApp } from '@objectstack/spec/ui'; + +export const AnalyticsApp = defineApp({ + name: 'showcase_analytics', + label: 'Analytics', + navigation: [ + { + id: 'nav_hours_by_status', + type: 'report', + reportName: 'showcase_hours_by_status', + label: 'Hours by Status', + icon: 'chart-bar', + }, + ], +}); +``` + +`reportName` is **cross-checked against the stack**: an app navigating to a report the +stack does not define fails validation with `App '' navigation references report +'' which is not defined in reports.` (The check is skipped for a stack that declares +no reports at all, where the target may come from another package.) The entry resolves to +`/report/` in the console. + +## Permissions and protection + +The report schema carries exactly **one** access-shaped block, and it is not about +viewers: + +- **`protection`** — the ADR-0010 package-author lock policy, declared once on the report + (never per block). The loader translates it into the runtime protection envelope at + registration time. It governs what an *installing org* may modify, not who may read the + numbers. + +There is deliberately no viewer-permission key on a report. Who may see what comes from +the two layers underneath it: **row- and tenant-level security is enforced by the runtime +per joined object** when the dataset is queried — never declared on the dataset and +never on the report — and **reachability** comes from the app navigation entry and the +permissions on the app that carries it. A report is a presentation over a dataset, so it +inherits the dataset's enforcement rather than restating it. + +## Related + +- **Schema reference:** [Report](/docs/references/ui/report) — the full generated property + tables for `Report`, `JoinedReportBlock`, `ReportChart` and `ReportSort`. +- [Analytics & Datasets](/docs/data-modeling/analytics) — declaring the dataset a report + binds, and the ordering and filter-placeholder semantics shared with dashboards. +- [Dashboard Metadata](/docs/ui/dashboards) — the same dataset binding, sized into a tile + grid. +- [View Metadata](/docs/ui/views) — the object-bound row lens a flat record list belongs + in. +- [App Metadata](/docs/ui/apps) — navigation entries, including `type: 'report'`. diff --git a/content/docs/ui/theming.mdx b/content/docs/ui/theming.mdx new file mode 100644 index 0000000000..51af758df3 --- /dev/null +++ b/content/docs/ui/theming.mdx @@ -0,0 +1,227 @@ +--- +title: Theming +description: Declare a palette, font family, radii and shadows as theme metadata — the CSS variables a theme emits, how light and dark variants are authored, and how the tokens meet react-page styling +--- + +# Theming + +A **Theme** is a named token bundle declared as metadata: a colour palette, a base font +family, a radius scale, a shadow scale, and any extra CSS custom properties you want on +the document. It compiles to **CSS custom properties** — the same variables the console's +components read, and the same ones a +[react page](/docs/ui/react-pages#styling) writes against. + +## What a theme is wired to today [#wiring] + + +**Authoring a theme validates it; it does not yet install it.** `defineStack({ themes })` +and `defineTheme()` parse a theme strictly and reject an undeclared key by name, and the +build ships it as a `theme` metadata item. What does **not** exist is the last hop: no +first-party surface reads a stored `theme` item back and applies it, and there is no key +on an app or on the stack that selects one. The engine that turns a theme into CSS +variables is real and tested — `generateThemeVars` in `@object-ui/core`, applied by +`` in `@object-ui/react` — but the console mounts a different, +light/dark-only provider and never fetches theme metadata. So a theme in your stack is a +validated, shipped, inert bundle unless *your own host app* mounts that provider and hands +it the themes. + +**What does colour a running console today** is app branding — +`app.branding.primaryColor` / `accentColor` — which the app shell reads on every render +and writes onto `--primary`, `--primary-foreground`, `--ring`, `--sidebar-primary`, +`--sidebar-ring`, `--accent` and `--accent-foreground`, re-deriving a brighter variant +when the mode flips to dark. Reach for branding when you want the shipped console +recoloured; reach for a theme when you are building a host app that mounts the provider, +or when you are declaring the palette now so it is ready when the hop lands. + + +Everything below describes the token contract itself — which is real, is what the engine +emits, and is what the rest of this module's pages assume. + +## What a theme declares + +{/* os:check */} +```typescript +import { defineTheme } from '@objectstack/spec/ui'; + +export const ShowcaseLightTheme = defineTheme({ + name: 'showcase_light', // snake_case, unique + label: 'Showcase Light', + description: 'Default showcase theme — violet accent, light mode.', + mode: 'light', // 'light' | 'dark' | 'auto' (default 'light') + colors: { + primary: '#7C3AED', // the only REQUIRED colour + secondary: '#6C757D', + accent: '#06B6D4', + background: '#FFFFFF', + surface: '#F8F9FA', + text: '#1F2937', + textSecondary: '#6B7280', + border: '#E5E7EB', + success: '#10B981', + warning: '#F59E0B', + error: '#EF4444', + info: '#3B82F6', + }, +}); +``` + +`colors` is the one required block, and `colors.primary` the one required colour. The +other four blocks — `typography`, `borderRadius`, `shadows`, `customVars` — are optional, +and `extends` names another theme to inherit from. + +### The palette keys are not the CSS variable names + +This is the single most surprising thing about the surface, and it is deliberate: the +schema uses **semantic** names, the emitted variables use **shadcn** names, and five of +them are renamed on the way out. + +| You author | The theme emits | +| :--- | :--- | +| `colors.primary` | `--primary` | +| `colors.secondary` | `--secondary` | +| `colors.accent` | `--accent` | +| `colors.success` / `warning` / `info` | `--success` / `--warning` / `--info` | +| `colors.error` | `--destructive` | +| `colors.background` | `--background` | +| `colors.surface` | `--card` | +| `colors.text` | `--foreground` | +| `colors.textSecondary` | `--muted-foreground` | +| `colors.border` | `--border` | +| `colors.disabled` | `--muted` | +| `colors.primaryLight` / `primaryDark` | `--primary-light` / `--primary-dark` | +| `colors.secondaryLight` / `secondaryDark` | `--secondary-light` / `--secondary-dark` | +| `typography.fontFamily.base` | `--font-sans` | +| `borderRadius.base` | `--radius` (bare — the other stops keep their suffix: `--radius-sm`, `--radius-lg`, …) | +| `shadows.base` | `--shadow` (likewise `--shadow-sm`, `--shadow-inner`, …) | +| `customVars: { 'z-modal': '1050' }` | `--z-modal: 1050` — verbatim | + +Because the rename is real, reading a variable name out of the rendered CSS and writing it +back into the palette does **not** round-trip. Writing `card`, `foreground`, +`muted-foreground`, `muted` or `destructive` as a palette key is rejected by name, with the +semantic key it should have been — these are a different *word* for the same intent rather +than a typo, so nothing could have guessed it from spelling alone. + +### `customVars` is the escape hatch, and the only one + +Each entry is emitted as `--: ` exactly as written (a leading `--` is +optional). It is where a custom property goes now that the semantic token *scales* are +gone: + +{/* os:check */} +```typescript +import { defineTheme } from '@objectstack/spec/ui'; + +export const BrandTheme = defineTheme({ + name: 'brand_light', + label: 'Brand', + colors: { primary: '#7C3AED' }, + customVars: { + 'font-size-lg': '1.125rem', // → --font-size-lg + 'z-modal': '1050', // → --z-modal + }, +}); +``` + + +**Keys the schema rejects on purpose.** `spacing`, `breakpoints`, `logo`, `density`, +`wcagContrast`, `rtl`, `touchTarget` and `keyboardNavigation` were removed because the +engine never emitted them — authoring one was a silent no-op. `animation`, `zIndex` and +the `typography` scales `fontSize` / `fontWeight` / `lineHeight` / `letterSpacing` (plus +`fontFamily.heading` / `.mono`) were removed under **ADR-0049** for the opposite reason: +the engine emitted them faithfully for years and nothing ever *read* one. Each rejected key +now answers with its own replacement, and for the second group the replacement is +byte-for-byte — `customVars` emits the identical variable. Only `colors`, `borderRadius`, +`shadows` and `typography.fontFamily.base` have live consumers. + + +## Light and dark are two themes, not one theme with a variant + +There is no nested dark block. `mode` is a scalar on the theme, so a light/dark pair is +**two theme items** that share a palette base — the idiom the showcase package ships: + +{/* os:check */} +```typescript +import { defineTheme } from '@objectstack/spec/ui'; + +const colors = { + primary: '#7C3AED', + secondary: '#6C757D', + accent: '#06B6D4', + background: '#FFFFFF', + surface: '#F8F9FA', + text: '#1F2937', + textSecondary: '#6B7280', + border: '#E5E7EB', +}; + +export const ShowcaseLight = defineTheme({ + name: 'showcase_light', + label: 'Showcase Light', + mode: 'light', + colors, +}); + +export const ShowcaseDark = defineTheme({ + name: 'showcase_dark', + label: 'Showcase Dark', + mode: 'dark', + colors: { + ...colors, + background: '#0B0F19', + surface: '#111827', + text: '#F9FAFB', + textSecondary: '#9CA3AF', + border: '#1F2937', + }, +}); +``` + +`extends` expresses the same relationship by reference instead of by spread: the named +parent is resolved and deep-merged block by block, with the child winning key by key, and +a cycle stops at the theme that closes it. Spread is the showcase's choice because the two +variants live in one file; `extends` is the choice when the parent ships in another +package. + +`mode: 'auto'` is resolved at runtime against `prefers-color-scheme`, and the resolved +mode is also written onto the document as a `light` / `dark` class, so the base +stylesheet's own mode rules stay in step with the tokens. + +## Values are bare HSL triples — which is why pages write `hsl(var(--token))` [#hsl] + +A hex colour you author is converted to a **bare HSL triple** — `#7C3AED` becomes +`262 83% 58%` — and *that* is what lands in the variable. The value is not a colour; it is +the inside of one. Anything that is not hex (`rgb(…)`, `hsl(…)`, `oklch(…)`) is passed +through untouched. + +This is the contract that +[React Pages](/docs/ui/react-pages#styling) is describing from the other end when it tells +page authors to write: + +```jsx +style={{ background: 'hsl(var(--card))', color: 'hsl(var(--foreground))' }} +``` + +The `hsl(…)` wrapper is not a stylistic preference — without it the declaration is +invalid, because the variable holds `0 0% 100%` and not `#FFFFFF`. The console's own base +stylesheet wraps every colour token the same way. Non-colour tokens hold complete values +and take **no** wrapper: `borderRadius: 'var(--radius)'`, `boxShadow: 'var(--shadow-md)'`. + + +**A theme overrides a subset of the token set; it does not define it.** The full shadcn +token surface — including `--primary-foreground`, `--popover`, `--input`, the +`--sidebar-*` family, the `--chart-*` series and the `--space-*` scale — is declared by +the console's base stylesheet, in both modes. A theme replaces the ones in the table +above and leaves the rest standing. So a page may legitimately reference a token no theme +can set; that is the base stylesheet answering, not a gap. To move one that a theme does +not own, emit it from `customVars`. + + +## Related + +- **Schema reference:** [Theme](/docs/references/ui/theme) — the full generated property + tables for `Theme`, `ColorPalette`, `Typography`, `BorderRadius` and `Shadow`. +- [React Pages](/docs/ui/react-pages#styling) — the page-authoring end of the same token + system, and why utility class names produce no CSS in page source. +- [App Metadata](/docs/ui/apps) — `branding.primaryColor` / `accentColor`, the + app-scoped colour override the console applies today. +- [UI Engine](/docs/ui) — where themes sit among the other UI metadata types. From a746b55f275a8775ceea42f6e1d9139bf6c3317d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 03:38:26 +0000 Subject: [PATCH 2/2] docs(ui): withhold theming.mdx per maintainer split ruling Per the maintainer ruling on PR #10483 (issue comment https://github.com/objectstack-ai/objectstack/pull/10483#issuecomment-5364860303), issue #10485 retired the `themes` authoring surface (option B) between this PR's writing and its merge, so its theming guide must not ship while reports.mdx and the doc-pages book half land as planned. - remove content/docs/ui/theming.mdx - drop the "theming" entry from content/docs/ui/meta.json ("reports" stays) - regenerate .claude/workflows/docs-accuracy-audit.js via node scripts/docs-audit/check-audit-scope.mjs --write (ALL_HANDWRITTEN -1) The withheld content stays recoverable in this branch's history; its disposition (deletion vs. a short app.branding pointer page) is owned by the retirement card #10485. --- .claude/workflows/docs-accuracy-audit.js | 1 - content/docs/ui/meta.json | 1 - content/docs/ui/theming.mdx | 227 ----------------------- 3 files changed, 229 deletions(-) delete mode 100644 content/docs/ui/theming.mdx diff --git a/.claude/workflows/docs-accuracy-audit.js b/.claude/workflows/docs-accuracy-audit.js index f1d0702957..a34c5b60b3 100644 --- a/.claude/workflows/docs-accuracy-audit.js +++ b/.claude/workflows/docs-accuracy-audit.js @@ -212,7 +212,6 @@ const ALL_HANDWRITTEN = [ "content/docs/ui/react-pages.mdx", "content/docs/ui/reports.mdx", "content/docs/ui/setup-app.mdx", - "content/docs/ui/theming.mdx", "content/docs/ui/translations.mdx", "content/docs/ui/views.mdx", "content/docs/upgrading.mdx", diff --git a/content/docs/ui/meta.json b/content/docs/ui/meta.json index aee60a70a1..1464d86d2f 100644 --- a/content/docs/ui/meta.json +++ b/content/docs/ui/meta.json @@ -9,7 +9,6 @@ "actions", "dashboards", "reports", - "theming", "translations", "forms", "doc-pages", diff --git a/content/docs/ui/theming.mdx b/content/docs/ui/theming.mdx deleted file mode 100644 index 51af758df3..0000000000 --- a/content/docs/ui/theming.mdx +++ /dev/null @@ -1,227 +0,0 @@ ---- -title: Theming -description: Declare a palette, font family, radii and shadows as theme metadata — the CSS variables a theme emits, how light and dark variants are authored, and how the tokens meet react-page styling ---- - -# Theming - -A **Theme** is a named token bundle declared as metadata: a colour palette, a base font -family, a radius scale, a shadow scale, and any extra CSS custom properties you want on -the document. It compiles to **CSS custom properties** — the same variables the console's -components read, and the same ones a -[react page](/docs/ui/react-pages#styling) writes against. - -## What a theme is wired to today [#wiring] - - -**Authoring a theme validates it; it does not yet install it.** `defineStack({ themes })` -and `defineTheme()` parse a theme strictly and reject an undeclared key by name, and the -build ships it as a `theme` metadata item. What does **not** exist is the last hop: no -first-party surface reads a stored `theme` item back and applies it, and there is no key -on an app or on the stack that selects one. The engine that turns a theme into CSS -variables is real and tested — `generateThemeVars` in `@object-ui/core`, applied by -`` in `@object-ui/react` — but the console mounts a different, -light/dark-only provider and never fetches theme metadata. So a theme in your stack is a -validated, shipped, inert bundle unless *your own host app* mounts that provider and hands -it the themes. - -**What does colour a running console today** is app branding — -`app.branding.primaryColor` / `accentColor` — which the app shell reads on every render -and writes onto `--primary`, `--primary-foreground`, `--ring`, `--sidebar-primary`, -`--sidebar-ring`, `--accent` and `--accent-foreground`, re-deriving a brighter variant -when the mode flips to dark. Reach for branding when you want the shipped console -recoloured; reach for a theme when you are building a host app that mounts the provider, -or when you are declaring the palette now so it is ready when the hop lands. - - -Everything below describes the token contract itself — which is real, is what the engine -emits, and is what the rest of this module's pages assume. - -## What a theme declares - -{/* os:check */} -```typescript -import { defineTheme } from '@objectstack/spec/ui'; - -export const ShowcaseLightTheme = defineTheme({ - name: 'showcase_light', // snake_case, unique - label: 'Showcase Light', - description: 'Default showcase theme — violet accent, light mode.', - mode: 'light', // 'light' | 'dark' | 'auto' (default 'light') - colors: { - primary: '#7C3AED', // the only REQUIRED colour - secondary: '#6C757D', - accent: '#06B6D4', - background: '#FFFFFF', - surface: '#F8F9FA', - text: '#1F2937', - textSecondary: '#6B7280', - border: '#E5E7EB', - success: '#10B981', - warning: '#F59E0B', - error: '#EF4444', - info: '#3B82F6', - }, -}); -``` - -`colors` is the one required block, and `colors.primary` the one required colour. The -other four blocks — `typography`, `borderRadius`, `shadows`, `customVars` — are optional, -and `extends` names another theme to inherit from. - -### The palette keys are not the CSS variable names - -This is the single most surprising thing about the surface, and it is deliberate: the -schema uses **semantic** names, the emitted variables use **shadcn** names, and five of -them are renamed on the way out. - -| You author | The theme emits | -| :--- | :--- | -| `colors.primary` | `--primary` | -| `colors.secondary` | `--secondary` | -| `colors.accent` | `--accent` | -| `colors.success` / `warning` / `info` | `--success` / `--warning` / `--info` | -| `colors.error` | `--destructive` | -| `colors.background` | `--background` | -| `colors.surface` | `--card` | -| `colors.text` | `--foreground` | -| `colors.textSecondary` | `--muted-foreground` | -| `colors.border` | `--border` | -| `colors.disabled` | `--muted` | -| `colors.primaryLight` / `primaryDark` | `--primary-light` / `--primary-dark` | -| `colors.secondaryLight` / `secondaryDark` | `--secondary-light` / `--secondary-dark` | -| `typography.fontFamily.base` | `--font-sans` | -| `borderRadius.base` | `--radius` (bare — the other stops keep their suffix: `--radius-sm`, `--radius-lg`, …) | -| `shadows.base` | `--shadow` (likewise `--shadow-sm`, `--shadow-inner`, …) | -| `customVars: { 'z-modal': '1050' }` | `--z-modal: 1050` — verbatim | - -Because the rename is real, reading a variable name out of the rendered CSS and writing it -back into the palette does **not** round-trip. Writing `card`, `foreground`, -`muted-foreground`, `muted` or `destructive` as a palette key is rejected by name, with the -semantic key it should have been — these are a different *word* for the same intent rather -than a typo, so nothing could have guessed it from spelling alone. - -### `customVars` is the escape hatch, and the only one - -Each entry is emitted as `--: ` exactly as written (a leading `--` is -optional). It is where a custom property goes now that the semantic token *scales* are -gone: - -{/* os:check */} -```typescript -import { defineTheme } from '@objectstack/spec/ui'; - -export const BrandTheme = defineTheme({ - name: 'brand_light', - label: 'Brand', - colors: { primary: '#7C3AED' }, - customVars: { - 'font-size-lg': '1.125rem', // → --font-size-lg - 'z-modal': '1050', // → --z-modal - }, -}); -``` - - -**Keys the schema rejects on purpose.** `spacing`, `breakpoints`, `logo`, `density`, -`wcagContrast`, `rtl`, `touchTarget` and `keyboardNavigation` were removed because the -engine never emitted them — authoring one was a silent no-op. `animation`, `zIndex` and -the `typography` scales `fontSize` / `fontWeight` / `lineHeight` / `letterSpacing` (plus -`fontFamily.heading` / `.mono`) were removed under **ADR-0049** for the opposite reason: -the engine emitted them faithfully for years and nothing ever *read* one. Each rejected key -now answers with its own replacement, and for the second group the replacement is -byte-for-byte — `customVars` emits the identical variable. Only `colors`, `borderRadius`, -`shadows` and `typography.fontFamily.base` have live consumers. - - -## Light and dark are two themes, not one theme with a variant - -There is no nested dark block. `mode` is a scalar on the theme, so a light/dark pair is -**two theme items** that share a palette base — the idiom the showcase package ships: - -{/* os:check */} -```typescript -import { defineTheme } from '@objectstack/spec/ui'; - -const colors = { - primary: '#7C3AED', - secondary: '#6C757D', - accent: '#06B6D4', - background: '#FFFFFF', - surface: '#F8F9FA', - text: '#1F2937', - textSecondary: '#6B7280', - border: '#E5E7EB', -}; - -export const ShowcaseLight = defineTheme({ - name: 'showcase_light', - label: 'Showcase Light', - mode: 'light', - colors, -}); - -export const ShowcaseDark = defineTheme({ - name: 'showcase_dark', - label: 'Showcase Dark', - mode: 'dark', - colors: { - ...colors, - background: '#0B0F19', - surface: '#111827', - text: '#F9FAFB', - textSecondary: '#9CA3AF', - border: '#1F2937', - }, -}); -``` - -`extends` expresses the same relationship by reference instead of by spread: the named -parent is resolved and deep-merged block by block, with the child winning key by key, and -a cycle stops at the theme that closes it. Spread is the showcase's choice because the two -variants live in one file; `extends` is the choice when the parent ships in another -package. - -`mode: 'auto'` is resolved at runtime against `prefers-color-scheme`, and the resolved -mode is also written onto the document as a `light` / `dark` class, so the base -stylesheet's own mode rules stay in step with the tokens. - -## Values are bare HSL triples — which is why pages write `hsl(var(--token))` [#hsl] - -A hex colour you author is converted to a **bare HSL triple** — `#7C3AED` becomes -`262 83% 58%` — and *that* is what lands in the variable. The value is not a colour; it is -the inside of one. Anything that is not hex (`rgb(…)`, `hsl(…)`, `oklch(…)`) is passed -through untouched. - -This is the contract that -[React Pages](/docs/ui/react-pages#styling) is describing from the other end when it tells -page authors to write: - -```jsx -style={{ background: 'hsl(var(--card))', color: 'hsl(var(--foreground))' }} -``` - -The `hsl(…)` wrapper is not a stylistic preference — without it the declaration is -invalid, because the variable holds `0 0% 100%` and not `#FFFFFF`. The console's own base -stylesheet wraps every colour token the same way. Non-colour tokens hold complete values -and take **no** wrapper: `borderRadius: 'var(--radius)'`, `boxShadow: 'var(--shadow-md)'`. - - -**A theme overrides a subset of the token set; it does not define it.** The full shadcn -token surface — including `--primary-foreground`, `--popover`, `--input`, the -`--sidebar-*` family, the `--chart-*` series and the `--space-*` scale — is declared by -the console's base stylesheet, in both modes. A theme replaces the ones in the table -above and leaves the rest standing. So a page may legitimately reference a token no theme -can set; that is the base stylesheet answering, not a gap. To move one that a theme does -not own, emit it from `customVars`. - - -## Related - -- **Schema reference:** [Theme](/docs/references/ui/theme) — the full generated property - tables for `Theme`, `ColorPalette`, `Typography`, `BorderRadius` and `Shadow`. -- [React Pages](/docs/ui/react-pages#styling) — the page-authoring end of the same token - system, and why utility class names produce no CSS in page source. -- [App Metadata](/docs/ui/apps) — `branding.primaryColor` / `accentColor`, the - app-scoped colour override the console applies today. -- [UI Engine](/docs/ui) — where themes sit among the other UI metadata types.