Skip to content

Commit 72629f3

Browse files
docs(ui-merge): merge the 46 objectui field, Console-behaviour and schema pages into the objectstack docs (K2) (#27170)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoG9P9JXRHobEih4L9R3ef1b)_ Fixes #27141 ## 维护者速读 - 改了什么:把 objectui 文档里 46 页(27 个字段页、14 个 Console/元数据行为页、5 个 schema 页)中 objectstack 文档缺的内容并入现有页面;新增 Console 和 Studio 两页,其余并入 field-types、apps、pages、actions、dashboards、forms、create-vs-edit-form、print-pages、react-pages、ai/skills。 - 为什么改:objectui.org 将并入 objectstack 文档站(方案 B),作者以后只看 objectstack.ai/docs,字段和页面行为不能一半留在要下线的站点上。 - 风险与回滚:纯文档,不动代码和 `ui/content/docs`;与 spec 冲突的说法(如 `menu`、`grid` 字段类型、`${}` 写的 visibleWhen、旧 report/action 组件形状)一律未搬。回滚即还原本 PR。 - 先看:下方 46 行映射表(K4 据此做跳转),以及新页 `content/docs/ui/console.mdx`、`content/docs/ui/studio.mdx`。 - 两分钟验证:`pnpm --filter @objectstack/docs build` 绿;打开 /docs/ui/console、/docs/ui/studio、/docs/data-modeling/field-types 看渲染。 - 需要你做的:无需决策;CI 绿后按常规审阅合并。 ## What changes Card K2 of the objectui.org docs fold. The 46 pages the card names are merged into the existing objectstack pages: only what the target lacked (widget and display behaviour, Console interaction, worked examples) was carried, rewritten as plain code blocks; what the target already says was dropped; where the two disagreed the target's wording was kept; and every carried claim was read against `packages/spec`, so an objectui claim that contradicts the spec was dropped or corrected, not merged. - `data-modeling/field-types.mdx` keeps one page with per-type **In the Console** paragraphs and a new **How the Console renders a field** section, so its existing anchors survive. - Two new pages: `ui/console.mdx` (how the Console renders metadata, per-user state, predicate failures) and `ui/studio.mdx` (access and package scope, drafts, object design, the Flow Designer, metadata diagnostics). Both are listed in `ui/meta.json` and the module index. - `ui/print-pages.mdx` and `ui/react-pages.mdx` are now the single page for their topic. - Nothing under `ui/content/docs/`, `ui/apps/site` or `content/docs/sdk/` is touched; K4 retires the old pages and adds redirects from the table below. ### Old path → target (46 rows) | Old page (`ui/content/docs/…`) | Target (`content/docs/…`) | Carried | | :--- | :--- | :--- | | `fields/auto-number` | `data-modeling/field-types.mdx#autonumber` | The Console assigns the number on insert (the create form shows it empty) and the format-token table | | `fields/boolean` | `data-modeling/field-types.mdx#boolean` | Switch in forms; read-only and grid display reads Yes / No | | `fields/currency` | `data-modeling/field-types.mdx#currency` | Input with the currency symbol; read-only and grid display | | `fields/date` | `data-modeling/field-types.mdx#date`, `#deadlines-duelike-and-settledwhen` | A date is a calendar day shown the same in every timezone; the picker; the `dueLike` / `settledWhen` deadline rendering | | `fields/datetime` | `data-modeling/field-types.mdx#datetime` | A datetime is an instant rendered in the viewer's timezone; the combined picker | | `fields/email` | `data-modeling/field-types.mdx#email` | Email input; `mailto:` link when read-only and in grids | | `fields/file` | `data-modeling/field-types.mdx#media-types`, `#file` | Value is the bare `sys_file` id(s); upload refusal; `maxSize` checked before upload; restricted files (`metadataRefused`) draw a lock; legacy inline objects still render; list display | | `fields/formula` | `data-modeling/field-types.mdx#formula` | Read-only, drawn by `returnType` | | `fields/grid` | `data-modeling/field-types.mdx#master_detail` | `grid` is not a spec `FieldType`, so the page's widget content went to the `master_detail` inline grid (`InlineGridColumnSchema` columns, derived columns, ordering). Dropped: `grid` as a field type | | `fields/image` | `data-modeling/field-types.mdx#image` | Thumbnail grid in forms, three thumbnails plus a count in grids, `accept` defaults to `image/*` | | `fields/location` | `data-modeling/field-types.mdx#location` | Latitude-then-longitude input and its display | | `fields/lookup` | `data-modeling/field-types.mdx#lookup` | Typeahead and Record Picker, `lookupColumns` (string widths), `lookupPageSize`, `displayField` / `descriptionField`, quick create (`allowCreate`), dependent lookups. Dropped: `reference_field`, `avatar_field`, picker `subtitle` (not spec keys) | | `fields/number` | `data-modeling/field-types.mdx#number`, `#what-a-number-input-silently-rewrites` | Stepper (`step`), the `useGrouping` table, what a native number input announces and silently rewrites | | `fields/object` | `data-modeling/field-types.mdx#json` | `json`, `composite` and `record` share a JSON text editor; what it accepts and refuses | | `fields/password` | `data-modeling/field-types.mdx#password` | Masked input with show/hide; stored value masked when read-only | | `fields/percent` | `data-modeling/field-types.mdx#percent` | `scale` as displayed decimals and input step; the fraction-vs-whole storage rule | | `fields/phone` | `data-modeling/field-types.mdx#phone` | `tel` input and link. Dropped: the input mask (the widget reads no `format` mask) | | `fields/rich-text` | `data-modeling/field-types.mdx#richtext` | One shared editor for `markdown` / `html` / `richtext`, and what `type` changes | | `fields/select` | `data-modeling/field-types.mdx#select` | Dropdown and colour badges, empty-label fallback, cascading options (`visibleWhen` / `dependsOn`), server enforcement of the option set | | `fields/summary` | `data-modeling/field-types.mdx#summary` | Read-only roll-up drawn by one rule in form and grid | | `fields/text` | `data-modeling/field-types.mdx#text` | Single-line input; `placeholder` | | `fields/textarea` | `data-modeling/field-types.mdx#textarea` | `rows` as the editor height | | `fields/time` | `data-modeling/field-types.mdx#time` | 24-hour `HH:mm` input | | `fields/url` | `data-modeling/field-types.mdx#url` | URL input; only `http(s)` values become links | | `fields/user` | `data-modeling/field-types.mdx#user` | Search-first people picker delegating to the lookup picker | | `fields/vector` | `data-modeling/field-types.mdx#vector` | Read-only; the form previews the first values and the dimension count | | `fields/widget-props` | `data-modeling/field-types.mdx#how-the-console-renders-a-field` | Metadata keys vs runtime widget props, `widget`, the shared empty-value affordance. Not carried: the `FieldWidgetComponentProps` API detail beyond one sentence (SDK) | | `guide/console` | `ui/console.mdx` (new), `ui/studio.mdx` (new) | Configuration inputs, navigating, record overlay modes and the drawer stack, the Approvals tab, refused dashboards and list views; Studio access, package scope, read-only packages, flows in no package, Create app, copilot drafts, object design panels and why an action can be missing. Not carried: dev quick start and source layout (repo-internal), Setup Positions, the app creation wizard | | `guide/metadata-diagnostics` | `ui/studio.mdx#metadata-diagnostics`, `ui/console.mdx#when-a-predicate-cannot-be-evaluated` | The `_diagnostics` envelope, the sweep endpoint and its response (`stats` includes `locked`), the four Studio surfaces, CEL lint on drafts, the runtime fail-open / fail-closed table, submit refusal, blank rules. Corrected: the `/api/v1/meta/items/…` paths (no such route) and `os check` (the command is `os validate`). Not carried: `MetadataClient` / `useGlobalDiagnostics` (SDK) | | `guide/flow-designer` | `ui/studio.mdx#the-flow-designer` | Canvas gestures, the palette (search, categories, recents, server merge), the inspector (required markers, `configSchema` forms, approver values, decision branches, assignments), the four panels | | `guide/notifications` | `ui/console.mdx#notifications` | The bell reads the server inbox through one shared feed. Not carried: the React notification API (SDK; `NotificationSchema` is not in the spec) | | `guide/slotted-pages` | `ui/pages.mdx#record-pages` | The synthesized default (tab strip, `relatedList: 'primary'`, Attachments, width-based layout), the slot table, the discussion panel and `enable.feeds`, related-list gating, paging and action ids, header action placement and the refresh button. Corrected: `details` + `tabs` are refused together (the spec), not "tabs wins". Dropped: `relatedLayout` (not a spec key) and the `@object-ui/plugin-detail` sub-builders (SDK) | | `guide/record-edit-modes` | `ui/create-vs-edit-form.mdx#modal-or-page-editmode` | `editMode` (`modal` / `page`), the page routes, `navigate_create` / `navigate_edit` buttons with `properties.params` templates | | `guide/designing-app-navigation` | `ui/apps.mdx#designing-the-navigation` | Landing-URL table for plain / `viewName` / `filters` / page entries, the rule of least power, label fallback. Dropped: "`menu` is deprecated legacy" (the spec refuses `menu`) | | `guide/dashboard-filters` | `ui/dashboards.mdx#filter-controls-and-options`, `#in-the-console` | Control-type → condition table, static options and the refused string shorthand, `optionsFrom`, the `object` translation key, symbolic presets, `page.dateRange` shape, nested variable scopes, the Studio bindings inspector, Refresh All. Not carried: the tutorial steps the target already covers | | `guide/public-forms` | `ui/forms.mdx#6-internal-forms-_consoleformsname` | `/apps/:appName/forms/:name`, heading resolution, Cancel. Not carried: `EmbeddableForm` (SDK component, its own security options) | | `guide/user-state-persistence` | `ui/console.mdx#what-each-users-own-state-keeps` | Per-user favorites, pins, recents, list state and overlay width; keys and limits; list state never writes the view; Reset view. Not carried: the React hooks (SDK) | | `guide/expressions` | `ui/pages.mdx#expression-holes-in-element-properties` | `${…}` holes in element properties, their roots, locale maps, what an unresolved hole renders. Dropped: the JS-style `${…}` `visibleWhen` / `hidden` / `disabled` examples (the spec makes those CEL predicates) and the `PredicateScopeProvider` / evaluator API (SDK) | | `guide/print-pages` | `ui/print-pages.mdx#print-in-the-browser` | The Print button and browser print preview, the named record-page route, the key → print CSS table, blocks printing whole. Merged into the one page | | `guide/react-pages` | `ui/react-pages.mdx`, `ui/pages.mdx#list-blocks-in-a-region` | `<box>` as the html-tier wrapper, no raw-HTML block; list-block sizing in regions and tab badges. The rest the target already says. Merged into the one page | | `guide/agent-skills` | `ai/skills.mdx#the-skill-catalog` | The `objectui` skill ships in `@objectstack/skills`, its structure and its boundary. Dropped: the `npx skills add objectstack-ai/objectui` install (the catalog installs from npm) | | `core/app-schema` | `ui/apps.mdx#action-navigation` | How the Console runs an `action` navigation item. Dropped: `AppComponentSchema` (`menu`, `path`, `layout`, `hidden: '${…}'`), which the spec's `AppSchema` refuses | | `core/report-schema` | `ui/reports.mdx` | Nothing new. The page documents `@object-ui/types`' `ReportComponentSchema` (inline `fields`, `filters`, `schedule`, `exportConfigs`), which the spec's dataset-bound `ReportSchema` does not accept | | `core/enhanced-actions` | `ui/actions.mdx#collect-input-and-shape-the-ux` | Param widgets, field-backed params on the selection bar, emitted value shapes, Confirm disabled during upload, param `visible`; `successMessage` `{{ }}` holes, `outcomeMessages`, `onSuccess`. Dropped: `ajax` / `confirm` / `dialog` action types, `chain`, `condition`, `tracking`, `retry` (the `@object-ui/types` `ActionSchema`, not the spec's) | | `core/theme-schema` | `ui/apps.mdx#branding` | `accentColor`, the `--primary` / `--accent` mapping, the single logo asset, no theme document in metadata. Not carried: `ThemeProvider` / `useTheme` / the `Theme` document (React SDK; the spec retired its theme module) | | `layout/page-header` | `ui/pages.mdx#the-page-header`, `ui/actions.mdx#permissions-and-visibility` | The props table, `actions` as the whole id set, the record chip, the one-`<h1>` rule; relation fields bind as their id and `current_user.can` / `hasCapability` gates. Not carried: Tailwind class lists and the React `PageHeader` export. Corrected: `os.user` examples to `current_user` | `scripts/docs-audit/handwritten-docs.json` gains the two new pages, which `check:docs-audit-scope` requires of any new hand-written page. ## Why objectui.org folds into the objectstack docs site (Tesla's option B on the fold evaluation). Metadata authors and Console users read objectstack.ai/docs, and today half of a field's or a page's behaviour lives on a site that goes away with the fold. ## Tests Docs only: no code, no test changes. The carried claims were checked by reading `packages/spec` (Zod schemas and their descriptions) and, for routes and commands, the server and CLI sources; the corrections are named in the rows above. ## Verification - `pnpm --filter @objectstack/docs build`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` derives 53 command families for this diff; all 53 ran locally with exit 0 (`--ran` reconciliation: 53 derived, 53 run, 0 NOT-MEASURED, 0 UNRUN), including `check:docs`, `check:doc-authoring`, `check:doc-anchors`, `check:doc-frontmatter`, `check:docs-redirects`, `check:docs-single-h1`, `check:docs-spec-enumerations`, `check:doc-formula-expressions`, `check:doc-security-posture` and `check:skill-examples` (270 prose examples type-check). - The first sweep found three things, fixed in this branch: the audit-scope list lacked the two new pages, a table cell used a reserved word, and the `editMode` example object declared no `sharingModel`. ## Acceptance notes - Each of the 46 pages has a row above naming its target and what was carried, or "nothing new". - No carried claim contradicts `packages/spec`; where an objectui page did, the claim was dropped or corrected, and the row says so. - Serial constraint with K3 (#27142): this PR adds `console` and `studio` to `content/docs/ui/meta.json`; whichever lands second merges `main`. Clause-②: no Metadata-protocol: none ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran. (`pnpm lint` was not run locally; this diff is MDX, JSON and one audit list, and CI runs the lint lanes.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. (Docs only: `skip-changeset`.) - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibzFEhNgS8KGkqBsQFu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b8615af commit 72629f3

15 files changed

Lines changed: 1186 additions & 2 deletions

File tree

‎content/docs/ai/skills.mdx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,15 @@ ObjectStack provides **nine domain-specific skills**, one per protocol domain
131131

132132
The authoritative list (names, domains, and "use when / do not use" boundaries) is generated from each skill's `SKILL.md` frontmatter. See the **[AI Skills Reference](/docs/ai/skills-reference)** for the full catalog with per-skill detail.
133133

134+
The same package also carries **`objectui`**, the skill for the `@object-ui/*` React
135+
packages rather than for metadata: rendering JSON schemas with `SchemaRenderer`, writing
136+
renderer plugins, data-source integration, and testing, i18n and mobile layout in a React host.
137+
Its source is [`skills/objectui/`](https://github.com/objectstack-ai/objectstack/tree/main/skills/objectui):
138+
a `SKILL.md` entry point, `rules/` the agent always applies, `guides/` it loads per task, and an
139+
`evals/` prompt per guide that catches regressions in agent behaviour. Its frontmatter defers
140+
data modelling, APIs, automation, formulas and agents to the domain skills above, so the two
141+
coexist in one agent. It installs and updates with the rest of `@objectstack/skills`.
142+
134143
<Callout type="info">
135144
Lifecycle hooks, plugin development, and project bootstrap are **not** separate skills — they live in **Data** (record hooks) and **Platform** (plugins, kernel events, `defineStack`).
136145
</Callout>

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 353 additions & 0 deletions
Large diffs are not rendered by default.

‎content/docs/ui/actions.mdx‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,9 +371,26 @@ strict params gate admits either bag without a word.
371371
ADR-0059), so any `FieldType` works — a `file` param shows a real upload
372372
control (`multiple` / `accept` / `maxSize` honored), `lookup` a record
373373
picker, `date` a date picker, `richtext` the rich-text editor, and so on.
374+
A field-backed param resolves the same way when the action is launched from a
375+
list's selection bar. Values reach the action as the widget emits them
376+
(`number` a number, `date` `YYYY-MM-DD`, a lookup the record id(s), a `file`
377+
the uploaded `sys_file` id(s), arrays when `multiple`), and the dialog's
378+
**Confirm** stays disabled while an upload is in flight. A param's `visible`
379+
is a CEL predicate over `features`, `current_user` and `data` that omits the
380+
param when false.
374381
- **`confirmText`** — confirmation dialog before running.
375382
- **`successMessage` / `errorMessage` / `refreshAfter`** — post-run feedback
376383
and an automatic data refresh.
384+
`successMessage` is a template whose holes are `{{ }}` over `result` (the
385+
handler's answer) and `record` (the record the action ran on):
386+
`'Invitation sent to {{ result.email }}'`; a `${…}` token in it is refused. A
387+
`type: 'api'` or `'script'` action whose handler can succeed in more than one
388+
way adds `outcomeMessages`, one entry per snake_case `outcome` the handler
389+
returns, each of which may read `${result.*}`; the toast shows the entry the
390+
answer names, else `successMessage`, else a generic "Action completed
391+
successfully". A `message` in the server's answer is shown only when the copy
392+
asks for it (`{{ result.message }}`). Post-success navigation is
393+
`onSuccess: { navigate, openIn }`.
377394
- **`undoable`** — on a single-record update, the success toast offers an
378395
**Undo** that restores the record's prior field values (`Ctrl+Z` works too).
379396
The runtime only captures the prior values when this flag is set, so an
@@ -437,6 +454,24 @@ is a spec proposal for a properly named key, not a values map under this one.
437454
[formulas guide](/docs/data-modeling/formulas) for CEL syntax.
438455
- **`requiresFeature`** ties visibility to a feature flag (compiled into a
439456
`visible` predicate).
457+
- **Relation fields bind as their id.** A `lookup`, `master_detail`, `user` or
458+
`tree` field reads in a predicate as the id the server stores, never as the
459+
record it points at, on every surface (a record page that expanded the
460+
relation for display collapses it again before evaluating). So "only the
461+
record's manager sees this" is `current_user.id == record.manager`;
462+
`record.manager.id` faults, because `record.manager` is a string. An empty
463+
relation is `null`, a clean `false`.
464+
- **`current_user.can(object, verb)`** asks whether the signed-in user holds an
465+
object permission, from the effective permissions the Console already holds
466+
for its built-in Edit and Delete, so a custom action that replaces one of
467+
them can be gated the same way: `visible: "current_user.can('account', 'delete')"`.
468+
The verb comes from the closed `OBJECT_PERMISSION_VERBS` table in
469+
`@objectstack/spec/security`; a verb outside it is a fault, not `false`.
470+
`current_user.hasCapability('<name>')` asks the capability question from the
471+
same payload. Until that payload has loaded both fault, so a `visible` gate
472+
stays closed and a `disabled` gate renders the button disabled until the
473+
answer arrives. In a UI gate they decide only what is shown: the server
474+
still enforces the request the action sends, as `requiredPermissions` does.
440475

441476
### Authorization inside an action
442477

‎content/docs/ui/apps.mdx‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,13 @@ nested `actionDef` block — `actionName` is **not** a top-level key on the item
192192
design** — the action owns its own parameter contract, so the app schema does
193193
not validate what goes inside it.
194194

195+
This is how an app-level action ("Quick Create", "Log a Call") is placed: the item
196+
names a declared action, it does not carry the action's definition. The Console
197+
renders it in the sidebar, and on click looks the name up among the declared
198+
actions and runs it through the same runner as a toolbar button, so confirmations,
199+
parameter dialogs and permission checks apply. An item that names no declared action
200+
shows an error when clicked rather than doing nothing.
201+
195202
### Component Navigation
196203

197204
Renders a registered component. `componentRef` is a component-registry key, not
@@ -277,6 +284,54 @@ rejected — a divider has nothing to label, gate, or badge.)
277284
| `requiresObject` | `string` | Hide/disable unless the named object is registered |
278285
| `requiresService` | `string` | Hide/disable unless the named kernel service is registered |
279286

287+
`label` is optional. An entry without one shows its target's **current** label,
288+
resolved when the navigation renders: the view's label for an entry that names a
289+
labelled view, else the object's or dashboard's label, else the target's machine
290+
name. A renamed object, view or dashboard therefore shows its new name on the next
291+
load with no navigation edit. Other entries fall back to their target's name
292+
(`pageName`, `reportName`, the `url`) and a group to its `id`, so give those a
293+
`label`. A `label` you write renders verbatim; never write an empty one, omit the
294+
key instead.
295+
296+
## Designing the navigation
297+
298+
The same requirement can often be met three ways: a plain object entry, an object
299+
entry anchored to a named view, or a page that embeds views. They are not
300+
interchangeable. For an app with a `project` object:
301+
302+
| You write | The user lands on | What comes with it |
303+
| :--- | :--- | :--- |
304+
| `{ type: 'object', objectName: 'project' }` | `/apps/my_app/project`, the object's default view | The whole object shell: view switcher, object actions, a create button, record routing, search and recents |
305+
| `… viewName: 'project.by_status'` | `/apps/my_app/project/view/project.by_status` | The same shell, opened on a named view; the user can still switch |
306+
| `… filters: { status: 'open' }` | `/apps/my_app/project/data?filter[status]=open`, the bare data surface | The conditions as removable chips over everything permissions allow, bound to no saved view; **Save as view** turns the slice into a named view |
307+
| `{ type: 'page', pageName: 'project_overview' }` | `/apps/my_app/page/project_overview` | Only the page. View switching, actions and record links are assembled by hand |
308+
309+
A page can imitate the other two, but it loses the object shell, and every later
310+
improvement to that shell (new actions, view switching, permission trimming) skips
311+
it. Use the least powerful construct that expresses the requirement:
312+
313+
1. **One plain `object` entry per core business object**, with no `viewName`: the
314+
default view is the view metadata's business, so navigation stays decoupled from
315+
presentation.
316+
2. **`viewName` when the entry is a named perspective** ("By Status", "Due This
317+
Week"): declare a named view and anchor the entry to it. It sets the entry point;
318+
it does not lock the user in.
319+
3. **`filters` for one-off or parameterized slices** (a drill-through, "assigned to
320+
me"): put the condition in the URL instead of authoring a view, and promote it to
321+
a named view only once it is curated and reused.
322+
4. **A page only for composition one view cannot express**: several objects side by
323+
side, KPI cards beside lists, onboarding content. A page that wraps one object's
324+
one view is an anti-pattern.
325+
5. **A `dashboard` for metrics and charts, a `report` for tabular analysis**, rather
326+
than pages of chart blocks.
327+
6. **One entry per target.** Don't offer an object through both a plain entry and a
328+
page wrapping its default view. If an object needs several entries, make each of
329+
them a named-view entry; mixing styles breaks the sidebar's active-state
330+
highlighting.
331+
332+
In a typical business app most entries are `object` entries; pages are for the home
333+
screen, onboarding and cross-object workbenches.
334+
280335
## Areas
281336

282337
An **area** partitions one app's navigation by business domain — Sales, Service,
@@ -683,9 +738,17 @@ branding: {
683738
| Property | Type | Description |
684739
| :--- | :--- | :--- |
685740
| `primaryColor` | `string` | Primary brand color (hex) |
686-
| `logo` | `string` | Logo image URL |
741+
| `accentColor` | `string` | Accent color (hex), for highlights and active states |
742+
| `logo` | `string` | Logo image URL: one square app icon, SVG preferred, at least 64 px. Each surface scales it; there is no second size or wordmark slot |
687743
| `favicon` | `string` | Favicon URL |
688744

745+
`branding` is the whole of an app's theming in metadata: the Console maps
746+
`primaryColor` and `accentColor` onto its `--primary` and `--accent` CSS variables.
747+
There is no theme document to author (the standalone `themes` collection was retired
748+
in 17.1). Both the app and `branding` are strict: a top-level `logo` or `theme` is
749+
refused with a pointer to `branding`, and a misspelled key inside `branding` is
750+
refused rather than dropped.
751+
689752
## Complete Example
690753

691754
{/* os:check */}

0 commit comments

Comments
 (0)