Skip to content

Commit 3a734e0

Browse files
feat(spec)!: a page region named main takes only width: 'full' (#24362)
Fixes #24326 Clause-②: yes (narrowing) The seat's ruling (route: refuse) is carried out here. A page region named `main` now takes only `width: 'full'`. `'small'`, `'medium'` and `'large'` on `main` are refused at publish, with a located message. `'full'` keeps its page-wide meaning, and every other region accepts the same widths as before. ## What changes | Door | Before | Now | | :--- | :--- | :--- | | `definePage` / `PageSchema` parse | `main` with `width: 'large'` parses | refused at `regions.N.width` (`code: 'custom'`), naming the region and `'full'` | | `defineStack` (`os validate`, `os build`) | accepted | `STACK_SCHEMA_INVALID` 422 at `pages.N.regions.M.width` | | metadata save door | accepted | 422 `INVALID_METADATA` (the same `PageSchema` parse) | | a stored `sys_metadata` page row, an artifact inside its declared-floor window, a package body registered with its declared range | accepted | converted on read: the width is deleted, with one `OS_METADATA_CONVERTED` notice per site, and the page renders as before | | `width: 'full'` on `main`; any width on `sidebar`, `aside`, `header`, `footer` or a dashboard cell | accepted | unchanged | - `packages/spec/src/ui/page.zod.ts`: a new exported check, `checkPageMainRegionWidth`. It is attached to `PageSchema` by identifier, after `checkPageRecordBindingPlacement`, so every door that runs `checkPageRegionPlacement` runs it too. It judges every page kind and template, because no layout reads a non-`'full'` width on `main` on any of them. It matches the region name case-insensitively, as the renderer does (`name.toLowerCase() === 'main'`). `PageRegionSchema.width` gains a `.describe()` that states the rule. - The message, as it ships: "Region `main` is refused with `width: 'large'` (since @objectstack/spec 18): a `main` region takes only `width: 'full'`. It is drawn at the width its template leaves it — beside `sidebar` and `aside` it takes the space they leave, and on `dashboard` it is one grid cell — so `'small'`, `'medium'` and `'large'` change nothing there. `'full'` is page-wide: the page drops its max-width cap and fills the screen. Delete `width` to keep the page exactly as it renders, or write `width: 'full'` for a full-width page." - ADR-0087 D3: `packages/spec/src/migrations/entries/semantic/18.page-main-region-width-refused.ts`. - ADR-0087 D2, the data-at-rest half: `page-main-region-width-removed` in `packages/spec/src/conversions/registry.ts`. It sets `toMajor: 18`, `retiredFromLoadPath: true`, `retiredAfter: '17.7.0'` and `order: 69`. It is inserted at its id-sorted position and defined directly above `pageRequiresNonCompiledKindRemoved`, the entry after it. It is a pure lossless delete, scoped exactly as the refusal is. - Generated: `packages/spec/api-surface/ui.json` and `packages/spec/export-origins/ui.json` (one added export each), and `content/docs/references/ui/page.mdx` (the new `width` description). All three came from `check:generated`'s stale list, regenerated by its own commands. ### Why the D2 entry is in this PR (outside the claim's listed file surface) The claim lists one semantic entry. The D2 conversion lives in `packages/spec/src/conversions/registry.ts`, and its pins live in a new `conversions/page-main-region-width-removed.test.ts` and in a `JOIN` row in `shared/retired-key-removal-version.pin.test.ts`. Those three paths are not on the claim's list. They are here because the narrowing alone breaks data at rest: - Every page with a `main` region in `packages/objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json` declares `width: 'large'` (5 of its 8 pages). Without a conversion, the strict body parse in `resolveArtifactPackageOrder` refuses them. That is the door the whole option-B artifact case in `registry-installed-package-retired-page-declarations.test.ts` drives. It is the same kernel-build stop that the isDefault and full-width narrowing had to fix after it landed. - The delete is lossless (no layout read the value), so this is the precedent's own shape: a D3 entry plus a retired D2 entry that data at rest replays. Dispatch note G1 named "a conversion that drops a non-'full' width from main" as the possible owed entry. ## Premises (verified before code) - **P1: true.** At the console pin `.objectui-sha` `0707b4ae3712`, objectui `packages/components/src/renderers/layout/page.tsx` draws `main` as `flex-1 min-w-0`. That happens in `RegionLayout` (`:434`, used by the `default` template and by every template-less page type), in `HeaderSidebarMainTemplate` (`:507`) and in `ThreeColumnTemplate` (`:541`). `DashboardTemplate` draws `main` as one grid cell with no width class (`:574`). The only read of a `main` region's `width` is `PageRenderer`'s full-bleed, for `'full'` (`:889`–`:894`). It removes the max-width cap. Re-read at objectui `origin/main` `43efd078aa`, as fetched in this container: the same lines. - **P2: true.** `git grep -n "width" 0707b4a -- packages | grep -i main` gives 21 hits. Every hit outside `page.tsx` is a test, a CHANGELOG, a comment, or an unrelated `width` (Gantt geometry, mobile copy). A wider read of region width readers at the pin (`region.width`, `main.width` and similar) finds `page.tsx` (`header`/`footer` via `getRegionWidthClass`, `sidebar`/`aside` via `getSidebarWidthClass`, and the `main` full-bleed `=== 'full'`) and one display-only label in Studio's `PageBlockCanvas.tsx:760`. That label prints the value and does not lay anything out. No renderer honours `small`, `medium` or `large` on `main`. - **P3: true.** `PageRegionSchema.width` is `z.enum(['small','medium','large','full'])` (`page.zod.ts:49`), shared by every region. The refusal is a `PageSchema`-level check keyed on the region's name, so `PageRegionSchema` itself accepts exactly what it did, and so does every other region. `findRegion` matches case-insensitively (`r.name?.toLowerCase() === name.toLowerCase()`, `page.tsx:90`), and so does the full-bleed read. The check uses the same `toLowerCase()` comparison against `'main'`. ## In-repo sites that declared a non-`'full'` width on `main` (G2) Each change drops `width: 'large'` from `main` and nothing else. The page renders the same. None was changed to `'full'`, which would make the page full-bleed. - `examples/app-crm/src/pages/welcome.page.ts` - `examples/app-showcase/src/ui/pages/contact-form.page.ts`, `index.ts`, `my-work.page.ts`, `new-project-wizard.page.ts`, `page-variables.page.ts`, `project-workspace.page.ts` - `packages/mcp/src/connect-ui.ts` (the shipped Connect an Agent page) and `packages/cloud-connection/src/cloud-connection-ui.ts` (the shipped Cloud Connection page). Both are published, so both carry a `patch` in the changeset. - `content/docs/ui/pages.mdx`: three snippets, one of them `os:check`, plus the `width` row of the region table, which now states the rule. - `packages/spec/src/ui/page.test.ts`: the two parse fixtures (`:187`, `:377`). - Left as is: `packages/objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json`. It is a byte-identical published artifact, and the D2 entry converts it at the data-at-rest doors. - Searched with no hit: `skills/**`. In `content/docs/protocol/objectui/layout-dsl.mdx`, the multiline search matched a `sidebar` width only. ## Tests - `packages/spec/src/ui/page-default-and-region-placement.test.ts`, new block "a region named `main` takes only `width: 'full'`": - one refusal per refused value on a `three-column` `main`, asserting `path` `regions.2.width`, `code: 'custom'`, the message's first sentence, `'full'`, `@objectstack/spec 18`, and no tracker number; - the case-insensitive match (`Main`); - the refusal on a template-less page, on `header-sidebar-main`, on `dashboard`, on a `slotted` page and on an `html` page; - the stack door: `{ code: 'STACK_SCHEMA_INVALID', status: 422 }` at `pages.1.regions.1.width`; - controls: `'full'` on `main` is accepted with its value kept; `large` on `header`, every width on `sidebar` and `aside`, `small` on a dashboard cell, and a `main` with no width are all accepted; - the ledger: the D3 entry is in step 18, and a stored row with a `large` main converts on read with the named notice and then parses. - `packages/spec/src/ui/object-refinement-check-exports.test.ts`: `checkPageMainRegionWidth` joins the catalogue with its own failure matrix (parity across three runners, count and bijection, attached by identifier, barrel identity). - `packages/spec/src/conversions/page-main-region-width-removed.test.ts`: registration, copy-on-write, idempotence, the stored-row seam, and an agreement matrix. Over every width, name casing, kind and template in it, the conversion deletes a width exactly where the parse refuses one. - `packages/spec/src/shared/retired-key-removal-version.pin.test.ts`: the `JOIN` row. The prescription names `@objectstack/spec 18`, which is what `retiredAfter: '17.7.0'` derives. ## Verification (head `7263210e1`, the final head; every reading below is from this run) - `pnpm --filter @objectstack/spec build`: exit 0. - `pnpm --filter @objectstack/spec check:generated`: exit 0. "All 14 generated artifacts are up to date". - `pnpm --filter @objectstack/spec test`: exit 0. 695 files passed, 21225 tests passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck`: exit 0. That covers `tsc --noEmit`, `check:scripts-typecheck` and `check:test-typecheck`. The test layer compiles under `tsconfig.test.json`, and the debt ledger is unchanged (52 files, 242 errors, 132 pinned signatures). None of the new test files is in it. - Build of the dependency closure of `@objectstack/mcp`, `@objectstack/cloud-connection`, `@objectstack/example-crm` and `@objectstack/example-showcase`: 63 of 63 tasks, exit 0. - `@objectstack/mcp`: test 40 files, 479 passed; typecheck exit 0. - `@objectstack/cloud-connection`: test 52 files, 614 passed (this includes `marketplace-install-local-convert-forward.test.ts`, which reads the hotcrm 4.0.1 artifact); typecheck exit 0. - `@objectstack/example-crm`: test 6 files, 46 passed; typecheck exit 0. `@objectstack/example-showcase`: test 36 files, 431 passed; typecheck exit 0. - The suites that read the hotcrm 4.0.1 artifact fixture: objectql `registry-installed-package-retired-page-declarations.test.ts` (8 passed) and service-package `hydration-convert-forward.test.ts` (5 passed). - `pnpm check:adr-0087-registration`: exit 0. Its verdict line: "`.changeset/24326-main-region-width-refused.md` [BREAKING+bang+clause-②-narrowing] registered page-main-region-width-refused, page-main-region-width-removed (new here: page-main-region-width-refused, page-main-region-width-removed)". - Gate families: `node scripts/pm/dispatch-gates.mjs --commands` (no paths, merge base `dd72a047f`, 22 paths) derived 114 commands. All 114 were run, and every one exited 0. The reconciliation with `--ran` and exit codes recorded: "114 derived famil(ies) accounted for — 114 run, 0 NOT-MEASURED (a DERIVED zero — all 114 recorded an exit code and none of them is 3)". - `pnpm check:dev-prereqs` (a local-only family): exit 0. - Left to CI: `pnpm lint`, `test:repo`, and the rest of the farm. ### Ablations (each committed first, restored from `HEAD` and proved by blob hash) - **A1: the check refuses nothing.** In `checkPageMainRegionWidth`, the guard was replaced so that every string width returns early. The anchor count went from 1 to 0 and the marker count to 1. The four pinning suites then failed: 89 failed, 422 passed. Restored: the blob is `448b9617687d`, equal to `HEAD`, and `git diff HEAD` is empty. - **A2: the D2 entry never fires.** The conversion's width guard was replaced, and the spec package was rebuilt. The marker was present in 8 `dist/` files. The spec conversion and placement suites failed (58 failed, 430 passed). The objectql option-B artifact case failed too (1 failed, 7 passed: "Release artifact `packages[0]` is …" refused). That is the kernel-build stop this entry exists to prevent. Restored: the blob is `4f56b2223e64`, equal to `HEAD`. After a rebuild the marker is absent from all 232 `dist/` files. ## Landing order with the seat's other narrowing This seat's other narrowing in this window is PR 24347 (`stack.zod.ts`), and it is in review. If both touch the migration registry, whichever lands second merges `main` through `scripts/pm/os-regen-merge.sh`. This branch already merged `main` that way at `7263210e1`. ## Acceptance notes - **The known external author.** `objectstack-ai/hotcrm` `src/sales/pages/home.page.ts` declares `width: 'large'` on its three-column page's `main` (the card's measurement). It must drop the key when it takes the next spec release. Its built artifact, if it was built against a floor at or below 17.7.0, and its stored rows convert on read. hotcrm is not touched here. - **An objectui fixture that pins the refused shape as spec-valid.** objectui `packages/types/src/__tests__/passing-keys-arms-11440.test.ts:83` asserts that `SpecPageSchema.safeParse(page).success` is `true` for a `main` region with `width: 'large'` (three cases: `record`, `home`, `utility`). It is at the pin `0707b4ae37` and at objectui `origin/main` `43efd078aa`. It goes red at objectui's next `@objectstack/spec` bump, not before. The Console Pin Gate builds the pin against this tree and runs no objectui tests. Its build reads `PageRegionSchema.width`'s type, and that type is unchanged. The same document appears in `packages/cli/src/__tests__/validate-passing-keys-11440.test.ts:98`, run through `objectui validate`. That command judges with objectui's own authoring face, so whether it goes red is NOT MEASURED. The edit is the same one-key delete. No objectui change is made here, per the claim. The finding is in the report for the seat, to be filed in objectui. Owner: none. - **Same family, not filed (no producer pulls it).** At the pin, `width` on `header` and `footer` is read only by `RegionLayout`, which serves the `default` template and template-less pages. `ThreeColumnTemplate`, `HeaderSidebarMainTemplate` and `DashboardTemplate` draw `header` and `footer` without a width class. `DashboardTemplate` draws every grid cell without one too. The spec accepts a width on all of them. No in-repo or measured external page declares a non-`'full'` `header`/`footer` width on those templates, or any width on a dashboard cell, so nothing on screen differs today. Owner: none. - `os migrate meta` replays the retired D2 entry against sources by id, as for every step-18 retirement. This PR makes no claim about `--write` rewriting a TypeScript source. --- _Generated by [Claude Code](https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a2a3777 commit 3a734e0

22 files changed

Lines changed: 492 additions & 26 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/mcp': patch
4+
'@objectstack/cloud-connection': patch
5+
---
6+
7+
feat(spec)!: a page region named `main` takes only `width: 'full'` — `'small'`, `'medium'` and `'large'` there are refused at publish (#24326)
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: registered page-main-region-width-refused, page-main-region-width-removed -->
12+
13+
**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention.
14+
15+
`PageRegionSchema.width` is one enum shared by every region, so a `main` region accepted `'small'`, `'medium'` and `'large'`. The console never read them: every layout draws `main` at the width the layout leaves it (the flexible column beside `sidebar` and `aside`; one grid cell on `dashboard`), and the only reader of `main.width` is the page itself, for `'full'`, which lifts the page's max-width cap. A three-column page that asked for a `large` main got the space the two rails left, and every door reported success.
16+
17+
- `PageSchema` now refuses `width: 'small' | 'medium' | 'large'` on a region named `main` (matched case-insensitively, as the console matches it), at `regions.N.width`, on every page kind and template. The message names the region and `'full'` as the one width it takes. The doors are `definePage`, `defineStack` (so `os validate` and `os build`, `STACK_SCHEMA_INVALID` 422) and the metadata save door (422 `INVALID_METADATA`).
18+
- `'full'` on `main` is unchanged, with its page-wide meaning. Every other region accepts the same widths as before.
19+
- The check is exported as `checkPageMainRegionWidth` from `@objectstack/spec/ui`, so a mirror built from `PageSchema.shape` can attach the same rule.
20+
- Data at rest keeps loading: the retired ADR-0087 conversion `page-main-region-width-removed` deletes those widths on read: a stored page row, a compiled artifact inside the window its declared protocol range opens, and a package body registered with its declared range. The page renders exactly as before.
21+
- `@objectstack/mcp` (the Connect an Agent page) and `@objectstack/cloud-connection` (the Cloud Connection page) drop `width: 'large'` from their `main` region. Both render the same.
22+
23+
### Migration: FROM → TO
24+
25+
| Wrote | Write instead |
26+
| :--- | :--- |
27+
| `width: 'small' \| 'medium' \| 'large'` on a region named `main` | drop it; `'full'` stays |
28+
29+
Deleting the key changes nothing on screen. Write `width: 'full'` only when you want a full-bleed page, which drops the page's max-width cap.

‎content/docs/references/ui/page.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ View filter rule
250250
| Property | Type | Required | Description |
251251
| :--- | :--- | :--- | :--- |
252252
| **name** | `string` | ✅ | Region name — a slot the page's `template` places: header, sidebar, main, aside or footer on 'default' and 'three-column'; header, sidebar or main on 'header-sidebar-main'; any name on 'dashboard' (header and footer span it, every other region is a grid cell). One region per slot. A name the template does not place, and a second region filling a slot already filled, are refused. |
253-
| **width** | `Enum<'small' \| 'medium' \| 'large' \| 'full'>` | optional | |
253+
| **width** | `Enum<'small' \| 'medium' \| 'large' \| 'full'>` | optional | Region width. A region named `main` takes only 'full', which is page-wide: the page drops its max-width cap and fills the screen. 'small', 'medium' and 'large' are refused on `main`, because a `main` region is drawn at the width its template leaves it: beside `sidebar` and `aside` it takes the space they leave, and on 'dashboard' it is one grid cell. |
254254
| **components** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record<string, string>; properties?: Record<string, any>; … }[]` | ✅ | Components in this region |
255255

256256
### Nested Shape: `Page.interfaceConfig`
@@ -468,7 +468,7 @@ Sort field and direction pair
468468
| Property | Type | Required | Description |
469469
| :--- | :--- | :--- | :--- |
470470
| **name** | `string` | ✅ | Region name — a slot the page's `template` places: header, sidebar, main, aside or footer on 'default' and 'three-column'; header, sidebar or main on 'header-sidebar-main'; any name on 'dashboard' (header and footer span it, every other region is a grid cell). One region per slot. A name the template does not place, and a second region filling a slot already filled, are refused. |
471-
| **width** | `Enum<'small' \| 'medium' \| 'large' \| 'full'>` | optional | |
471+
| **width** | `Enum<'small' \| 'medium' \| 'large' \| 'full'>` | optional | Region width. A region named `main` takes only 'full', which is page-wide: the page drops its max-width cap and fills the screen. 'small', 'medium' and 'large' are refused on `main`, because a `main` region is drawn at the width its template leaves it: beside `sidebar` and `aside` it takes the space they leave, and on 'dashboard' it is one grid cell. |
472472
| **components** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record<string, string>; properties?: Record<string, any>; … }[]` | ✅ | Components in this region |
473473

474474
### Nested Shape: `PageRegion.components[number]`

‎content/docs/ui/pages.mdx‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ const homePage = {
3434
},
3535
{
3636
name: 'main',
37-
width: 'large',
3837
components: [
3938
{
4039
type: 'list_view',
@@ -141,7 +140,6 @@ regions: [
141140
},
142141
{
143142
name: 'main',
144-
width: 'large',
145143
components: [/* ... */],
146144
},
147145
]
@@ -150,7 +148,7 @@ regions: [
150148
| Property | Type | Required | Description |
151149
| :--- | :--- | :--- | :--- |
152150
| `name` | `string` | ✅ | The template slot the region fills (see [Templates](#templates)); a name the template does not place is refused |
153-
| `width` | `enum` | optional | `'small'`, `'medium'`, `'large'`, `'full'` |
151+
| `width` | `enum` | optional | `'small'`, `'medium'`, `'large'`, `'full'`. A region named `main` takes only `'full'`, which is page-wide: the page drops its max-width cap and fills the screen. `main` is drawn at the width its template leaves it, so `'small'`, `'medium'` and `'large'` are refused there |
154152
| `components` | `PageComponent[]` | ✅ | Components in this region |
155153

156154
### Templates
@@ -336,7 +334,6 @@ const accountRecordPage = {
336334
},
337335
{
338336
name: 'main',
339-
width: 'large',
340337
components: [
341338
{
342339
type: 'record:related_list',

‎examples/app-crm/src/pages/welcome.page.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ export const CrmWelcomePage = definePage({
2828
},
2929
{
3030
name: 'main',
31-
width: 'large',
3231
components: [
3332
{
3433
type: 'element:text',

‎examples/app-showcase/src/ui/pages/contact-form.page.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ export const ContactFormPage = definePage({
5454
},
5555
{
5656
name: 'main',
57-
width: 'large',
5857
components: [
5958
{
6059
type: 'element:text',

‎examples/app-showcase/src/ui/pages/index.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ export const ComponentGalleryPage = definePage({
6363
},
6464
{
6565
name: 'main',
66-
width: 'large',
6766
components: [
6867
{ type: 'element:text', properties: { content: 'A working project-delivery workspace that exercises every metadata type, view, chart, and capability chain. Use the navigation to explore — start with My Work, the Delivery Operations dashboard, or the eight Task visualizations.' } },
6968
// Live KPI row over the seeded data (object-metric in the layout grid).

‎examples/app-showcase/src/ui/pages/my-work.page.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ export const MyWorkPage = definePage({
2727
},
2828
{
2929
name: 'main',
30-
width: 'large',
3130
components: [
3231
// KPI hero ROW — equal-width tiles in a 3-col grid (layout grid, not ObjectGrid).
3332
{

‎examples/app-showcase/src/ui/pages/new-project-wizard.page.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ export const NewProjectWizardPage = definePage({
2121
regions: [
2222
{
2323
name: 'main',
24-
width: 'large',
2524
components: [
2625
{
2726
type: 'object-form',

‎examples/app-showcase/src/ui/pages/page-variables.page.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ export const PageVariablesPage = definePage({
4545
},
4646
{
4747
name: 'main',
48-
width: 'large',
4948
components: [
5049
{
5150
type: 'element:text',

‎examples/app-showcase/src/ui/pages/project-workspace.page.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ export const ProjectWorkspacePage = definePage({
3535
},
3636
{
3737
name: 'main',
38-
width: 'large',
3938
components: [
4039
{
4140
type: 'object-master-detail-form',

0 commit comments

Comments
 (0)