You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 586705d
Browse filesBrowse the repository at this point in the historyBrowse files
feat(spec): the bound record blocks accept an authored emptyState, drawn only when their dataSource binding answers no row (#24270)
Fixes#24266
Clause-②: yes (widening)
## What changes
The three bound record blocks, `record:details`, `record:highlights` and
`record:quick_actions`, each accept an optional authored `emptyState`.
This is the spec half of objectstack-ai/objectui#12932.
- **The key.** `RecordDetailsProps`, `RecordHighlightsProps` and
`RecordQuickActionsProps` each gain `emptyState:
EmptyStateSchema.optional()`. The schema is referenced, not re-spelled:
it is the list view's own `EmptyStateSchema` (`view.zod.ts`), already
imported into `component.zod.ts` for `object-grid.emptyState` (#20694).
Importing it adds no new module edge, so there is no `lazySchema` cycle.
- **The condition.** All three rows share one describe,
`BOUND_RECORD_BLOCK_EMPTY_STATE_DESCRIPTION`, which is not exported, the
same way `RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION` is shared. It
says the empty state is drawn when the block's `dataSource` binding
answers no row, and only then. It is never drawn on a block with no
binding, which reads the record page's record instead. A loading read
and a failed read are not "no row" either. On `record:quick_actions`, a
bound record whose bar resolves no action keeps the existing empty
placeholder.
- **Declared ahead of its renderer (AGENTS.md Prime Directive #10).** No
renderer reads the key yet. Each describe opens with `[PLANNED — not yet
rendered]`, the marker `check-liveness.mts` `MARKER_RE.planned` reads.
`liveness/page.json`'s `_note` gains a PLANNED RECORD sentence naming
the carrier, objectstack-ai/objectui#12932. A component-level key gets
no ledger row because the walk stops at the undrilled `page/regions`
container. This is the same pattern
`RecordRelatedListProps.parentDataSource` (#23134) followed.
- **Ledger.** `dropped-refinements.baseline.json` declares
`emptyState.link.url` on the three rows. The build refused until it did.
It is the same `EmptyStateLink` URL refinement that `ui/ObjectGridProps`
and `ui/ListView` already declare.
- **Generated.** `authorable-surface/ui.json` gains three `emptyState`
rows, and `content/docs/references/ui/component.mdx` is regenerated.
Both were written by `pnpm --filter @objectstack/spec build` and
`gen:docs`, not by hand.
## Measurements (mechanism hypotheses)
- **H1 (premise holds).** On `origin/main` `a422f04fe`, all three rows
are `strictObject`s with no `emptyState`, and the file's only
`emptyState` is `ObjectGridPropsSchema`'s.
- **H2 (no fourth block).** In objectui `main` `79cb8c03e`, `git grep
recordBindingBlock` matches exactly three call sites:
`record-details.tsx:1132`, `record-highlights.tsx:199` and
`record-quick-actions.tsx:469`. `record:related_list`'s
`parentDataSource` is a second binding that already declares its own
no-row answer (a loud absence), so this key does not go on that row.
- **H4 (no lint change).**
`packages/lint/src/validate-component-props.ts` derives known keys from
`ComponentPropsMap`, and no lint pin enumerates these rows' key sets.
## Tests
The pin file is
`packages/spec/src/ui/component-record-blocks-empty-state-24266.test.ts`.
It runs 25 cases in four parts:
- §1: each block parses a full `emptyState` and a one-member one, and a
block without the key still parses with no default added.
- §2: an unknown inner key is refused with `unrecognized_keys` at path
`emptyState`, and a re-spelled member is refused inside `emptyState`. As
a lit control, an unknown top-level key is still refused at the row.
`record:related_list` still refuses the key.
- §3: each member unwraps to `EmptyStateSchema`'s own def, by identity,
which is also `object-grid`'s.
- §4: each describe opens with the planned marker.
All runs below were on the tree of head `40ef667d`. The WIP commits were
squashed into that head and their tree is byte-identical.
- `pnpm --filter @objectstack/spec test`: 694 files and 21027 tests
pass, exit 0.
- `pnpm --filter @objectstack/spec typecheck`: exit 0.
- `pnpm --filter @objectstack/spec test:repo`: 67 of 68 files passed.
The 68th, `view-data-api-provider-retirement.test.ts`, failed with
ENOENT on `packages/spec/json-schema/qa`. A concurrently running gate
(`check:authorable-surface`) was regenerating that gitignored tree while
the test walked it. Re-run alone, the file passes 10 of 10.
- **Ablation.** I deleted `record:quick_actions`' `emptyState` line
through `scripts/ablation-replace.mjs`. The anchor went from 1 hit to 0,
the blob changed from `2365c6de` to `778818d7`, and 6 cases turned red.
The file was then restored: blob equals HEAD and `git diff HEAD` is
empty.
- `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts
are up to date. The 111 commands from `dispatch-gates --commands` came
back with 108 at exit 0. Three are NOT MEASURED locally, all with exit 3
for an unmet prerequisite:
- `check:dual-build-cjs-loads` and `check:lean-entry-closure` need a
whole-workspace build.
- `check-plugin-teardown-shape --self-test` needs an unshallow clone.
CI runs all three.
## Acceptance notes
- The key validates and is not yet drawn until
objectstack-ai/objectui#12932 lands against a spec release that carries
it. The describe marker and the `_note` sentence state that, and both
come out when that card lands.
- The `measured.refinementSitesThatDidProject` field in
`dropped-refinements.baseline.json` reads 369 on `main`, while the build
prints 467. That drift was already on `main` and is not touched here. I
only moved the two counts this diff changes: published schemas 236 to
238, and dropped sites 767 to 770.
---
_Generated by [Claude
Code](https://claude.ai/code/session_019vZVMKXRwb7NiBZj6pseeF)_
Co-authored-by: Claude <noreply@anthropic.com>
feat(spec): the bound record blocks `record:details`, `record:highlights` and `record:quick_actions` accept an authored `emptyState`, drawn only when the block's `dataSource` binding answers no row
6
+
7
+
Clause-②: yes (widening)
8
+
9
+
-**The key.**`RecordDetailsProps`, `RecordHighlightsProps` and `RecordQuickActionsProps` each gain an optional `emptyState`: the list view's own `EmptyStateSchema` by reference (`{ title, message, icon, link }`), the same shape `object-grid.emptyState` takes. Until now all three rows refused the key, so a page whose binding can legitimately answer no row (for example an environment block on an organisation with no production environment) could only show the block's own placeholder: "record not found", nothing, or an empty action bar.
10
+
-**When it is drawn.** Only when the block carries a node-level `dataSource` binding and that read answers no row. A block with no binding reads the record page's record and never draws it, a loading read draws the loading state, and a failed read draws its error. On `record:quick_actions`, a bound record whose bar resolves no action keeps the existing empty placeholder.
11
+
-**Planned, not yet rendered.** No renderer reads the key yet; each describe opens with `[PLANNED — not yet rendered]`, and the renderer half is objectstack-ai/objectui#12932, which ships once a spec release carrying the key is installable. Until then an authored `emptyState` validates and is not drawn.
12
+
-**No other row moves.** Every other `ComponentPropsMap` row still refuses `emptyState` (`record:related_list`, whose `parentDataSource` has its own declared answer for no row, included), so `os validate` keeps reporting it there as `component-props-unknown-key`. Nothing narrows.
Copy file name to clipboardExpand all lines: content/docs/references/ui/component.mdx
+30Lines changed: 30 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1685,6 +1685,7 @@ View filter rule
1685
1685
|**enforceFieldSecurity**|`boolean`| optional | Fold this block's field list through the caller's FIELD-read permissions before rendering, so a field the permission set denies leaves no empty row behind (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — with it off a denied field still arrives masked or stripped, and with it on the server still decides every value. |
1686
1686
|**redactFields**|`string[]`| optional | Field names this block never renders, whatever the permission answer (renderer default: render everything authored). Presentation only, evaluated in the browser after the record is fetched — the values are still in the page, so this is NOT a data-access control and NOT the object's `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. Neighbours `hideFields`, which is the dedupe channel the renderer also writes to. |
1687
1687
|**requiredPermissions**|`string[]`| optional |[ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open. |
1688
+
|**emptyState**|`{ title?: string \| Record<string, string>; message?: string \| Record<string, string>; icon?: string; link?: object }`| optional |[PLANNED — not yet rendered] What this block draws when its `dataSource` binding answers no row: `{ title, message, icon, link }`, the list view's own empty-state shape. Drawn then and only then — never on a block with no `dataSource` (it reads the record page's record instead), never while the bound read is loading, and never in place of the error a failed read shows. Omitted, a binding with no row keeps the block's own placeholder. |
|**headerColor**|`Enum<'muted' \| 'muted/50' \| 'accent' \| 'primary/10' \| 'secondary/10' \| 'destructive/10'>`| optional | Section-header background tint, from the closed six-token vocabulary rendered by objectui's `record:details` header (`muted`\|`muted/50`\|`accent`\|`primary/10`\|`secondary/10`\|`destructive/10`). A value outside the enum is refused at authoring time rather than silently not painting. Omit for an untinted header. |
1706
1707
|**count**|`{ field: string }`| optional | Show how many values a multi-value field holds on the current record in this section's heading: `{ field: 'tags' }`, with `label` writing the `{{ count }}` hole where the number goes (`'{{ count }} tags'`). The field must be a multi-value field the bound object declares — `multiselect`, `checkboxes`, `tags`, or a `select` / `lookup` / `user` / `file` / `image` field with `multiple: true` — and an absent or empty value counts 0. Every locale of an inline label map, and a translated `objects.<object>._sections.<name>.label`, writes the same `{{ count }}` hole. Not allowed beside `group`. The record detail page fills every hole in the heading after translating it. |
1707
1708
1709
+
### Nested Shape: `RecordDetailsProps.emptyState`
1710
+
1711
+
| Property | Type | Required | Description |
1712
+
| :--- | :--- | :--- | :--- |
1713
+
|**title**|`string \| Record<string, string>`| optional | Heading of the empty state |
1714
+
|**message**|`string \| Record<string, string>`| optional | Line of text below the heading |
1715
+
|**icon**|`string`| optional | Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph |
1716
+
|**link**|`{ label: string \| Record<string, string>; url: string }`| optional | The one next step an empty list names: a link the user follows, `{ label, url }` — for example to the how-to page that explains what belongs here when nobody can add a row. It opens a page or an address and runs no action. The console draws it in a list's empty state (the `grid` and `gallery` visualizations) and in an `object-grid` block's, beside the add-record action where one is offered. |
1717
+
1708
1718
### Nested Shape: `RecordDetailsProps.aria`
1709
1719
1710
1720
| Property | Type | Required | Description |
@@ -1757,6 +1767,7 @@ Type: `string`
1757
1767
|**enforceFieldSecurity**|`boolean`| optional | Fold this block's highlight chips through the caller's FIELD-read permissions before rendering, so a field the permission set denies leaves no empty chip behind (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — the record is fetched either way and the server still decides every value. |
1758
1768
|**redactFields**|`string[]`| optional | Field names this block never renders as a chip, whatever the permission answer (renderer default: render every field authored). Presentation only, evaluated in the browser after the record is fetched — the values are still in the page, so this is NOT a data-access control and NOT the object's `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. |
1759
1769
|**requiredPermissions**|`string[]`| optional |[ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open. |
1770
+
|**emptyState**|`{ title?: string \| Record<string, string>; message?: string \| Record<string, string>; icon?: string; link?: object }`| optional |[PLANNED — not yet rendered] What this block draws when its `dataSource` binding answers no row: `{ title, message, icon, link }`, the list view's own empty-state shape. Drawn then and only then — never on a block with no `dataSource` (it reads the record page's record instead), never while the bound read is loading, and never in place of the error a failed read shows. Omitted, a binding with no row keeps the block's own placeholder. |
|**type**|`string`| optional | Override cell renderer type (rare) |
1769
1780
|**readonly**|`boolean`| optional | Render this chip read-only — suppresses inline editing on the highlight card. Use for hook/automation-maintained columns that must not be hand-edited from the record header. |
|**title**|`string \| Record<string, string>`| optional | Heading of the empty state |
1787
+
|**message**|`string \| Record<string, string>`| optional | Line of text below the heading |
1788
+
|**icon**|`string`| optional | Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph |
1789
+
|**link**|`{ label: string \| Record<string, string>; url: string }`| optional | The one next step an empty list names: a link the user follows, `{ label, url }` — for example to the how-to page that explains what belongs here when nobody can add a row. It opens a page or an address and runs no action. The console draws it in a list's empty state (the `grid` and `gallery` visualizations) and in an `object-grid` block's, beside the add-record action where one is offered. |
1790
+
1771
1791
### Nested Shape: `RecordHighlightsProps.aria`
1772
1792
1773
1793
| Property | Type | Required | Description |
@@ -1903,6 +1923,16 @@ Sort field and direction pair
1903
1923
|**inline**|`boolean`| optional | Render in the flow instead of pulling up into the record-header band. The page header sets this itself when it hosts the bar in its own action slot. |
1904
1924
|**variant**|`Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link'>`| optional | Button variant for every action — the Button primitive's own vocabulary; a per-action `variant` on the resolved def wins (renderer default: `default`). |
1905
1925
|**size**|`Enum<'default' \| 'sm' \| 'lg' \| 'icon'>`| optional | Button size for every action — the Button primitive's own vocabulary; a per-action `size` wins (renderer default: `sm`). |
1926
+
|**emptyState**|`{ title?: string \| Record<string, string>; message?: string \| Record<string, string>; icon?: string; link?: object }`| optional |[PLANNED — not yet rendered] What this block draws when its `dataSource` binding answers no row: `{ title, message, icon, link }`, the list view's own empty-state shape. Drawn then and only then — never on a block with no `dataSource` (it reads the record page's record instead), never while the bound read is loading, and never in place of the error a failed read shows. Omitted, a binding with no row keeps the block's own placeholder. |
|**title**|`string \| Record<string, string>`| optional | Heading of the empty state |
1933
+
|**message**|`string \| Record<string, string>`| optional | Line of text below the heading |
1934
+
|**icon**|`string`| optional | Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph |
1935
+
|**link**|`{ label: string \| Record<string, string>; url: string }`| optional | The one next step an empty list names: a link the user follows, `{ label, url }` — for example to the how-to page that explains what belongs here when nobody can add a row. It opens a page or an address and runs no action. The console draws it in a list's empty state (the `grid` and `gallery` visualizations) and in an `object-grid` block's, beside the add-record action where one is offered. |
Copy file name to clipboardExpand all lines: packages/spec/dropped-refinements.baseline.json
+13-2Lines changed: 13 additions & 2 deletions
Original file line number
Diff line number
Diff line change
@@ -2,8 +2,8 @@
2
2
"description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.",
0 commit comments