From adcf9951cab10ea763363ba3c3cfb5fd49238fe3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 03:32:52 +0000 Subject: [PATCH 1/3] fix(types): the flex bag's child list is typed as nodes on the TypeScript face (objectui#11564) `FlexBlockNode` read its bag's `children` off `FLEX_BAG_CHILDREN`, whose list arm is `z.array(z.unknown())`, so the list, the form a `flex` node is authored in, took any entry on the TypeScript face while a single child was judged as a node. The zod faces need the `unknown`: the spec page walk judges each list entry there, once, at its real path, on both faces. `FlexBlockNode` now reads the bag's `children` off the flat `FlexSchema` mirror's own member, by reference (`SchemaNode | SchemaNode[]`, the slot every node slot takes); every other member is still the arm's. The zod arm does not move. The divergence is recorded in the mirror-parity ledger's `EXCLUSIONS` row for `FlexBlockSchema` and pinned in `flex-bag-children-list-11564.test.ts`. Claude-Session: https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ Co-authored-by: Claude --- .changeset/11564-flex-bag-children-list.md | 18 ++ .../flex-bag-children-list-11564.test.ts | 164 ++++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 7 +- packages/types/src/authoring-nodes.ts | 72 +++++++- 4 files changed, 252 insertions(+), 9 deletions(-) create mode 100644 .changeset/11564-flex-bag-children-list.md create mode 100644 packages/types/src/__tests__/flex-bag-children-list-11564.test.ts diff --git a/.changeset/11564-flex-bag-children-list.md b/.changeset/11564-flex-bag-children-list.md new file mode 100644 index 0000000000..e2a9fb6ef9 --- /dev/null +++ b/.changeset/11564-flex-bag-children-list.md @@ -0,0 +1,18 @@ +--- +'@object-ui/types': minor +--- + +`FlexBlockNode`, the TypeScript type of an authored `flex` node, types its bag's child list as nodes: `properties.children` is `SchemaNode | SchemaNode[]`, where it was `unknown[] | SchemaNode` (objectui#11564). + +**Clause-②: yes (narrowing)**, TypeScript authoring face only, shipped as `minor` per this repository's version policy. The list is the form a `flex` node is authored in (`{ type: 'flex', properties: { children: [ … ] } }`), and until now each entry was `unknown`, while a single child was already judged as a node. Each list entry is now judged as a node too, against its own `type`: + +- an entry whose `type` no declaration names, or an object with no `type`, no longer compiles; +- an entry that is itself a list no longer compiles; +- a misspelled key on a node type without an index signature (the spec-derived nodes, such as `element:text`, and a closed `CustomNodeRegistry` entry) no longer compiles. Node types that extend `BaseSchema` keep its index signature until objectui#8347 removes it, so a misspelled key on one of them still compiles, in the list exactly as in a single child; +- a primitive entry (a string, a number, `null`) still compiles, as in every node slot. + +The type is the flat `FlexSchema` mirror's own `children` member, by reference, which is also the member `FlexLayoutProps` declares. Every other member of `FlexBlockNode` and of its bag is still read off the `FlexBlockSchema` arm. + +What does NOT move: every zod face and every runtime path. `FlexBlockSchema` keeps `z.array(z.unknown())` for the list, because `@objectstack/spec`'s page walk already judges each entry there, once, at its real path, on the tolerant and the strict face (objectui#11223). The one entry kind where the faces now differ is a nested list, which the walk passes through unvisited and the TypeScript face refuses; `@object-ui/types`' mirror-parity ledger records the divergence. + +**Migration.** Give each list entry its declared node type (or `DeclaredNode`), declare a custom type in `CustomNodeRegistry`, and flatten a nested list into the one list. diff --git a/packages/types/src/__tests__/flex-bag-children-list-11564.test.ts b/packages/types/src/__tests__/flex-bag-children-list-11564.test.ts new file mode 100644 index 0000000000..2fa115df66 --- /dev/null +++ b/packages/types/src/__tests__/flex-bag-children-list-11564.test.ts @@ -0,0 +1,164 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11564: the `flex` bag's child LIST is typed as nodes on the + * TypeScript face, as a single child already was, pinned. + * + * ## The gap + * + * `FlexBlockNode` was read off the `FlexBlockSchema` arm, whose bag member + * `children` is `FLEX_BAG_CHILDREN`: a single node (`SchemaNodeSchema`) or + * `z.array(z.unknown())`. The zod faces need the `unknown`: a list at + * `properties.children` is a position `@objectstack/spec`'s page walk + * descends, and each entry is judged there, once, at its real path + * (`flex-properties-bag-11276.test.ts` pins that on both faces). On the + * TypeScript face the same member read `unknown[]`, so the list, the form a + * `flex` node is authored in, took any entry while a single child was judged + * as a node. With `BaseSchema`'s index signature present nothing showed, since + * the signature absorbs a misspelled key either way; once objectui#8347 + * removes it, the list would have been the one unchecked node slot. + * + * ## What is pinned here + * + * TYPE-level rows, judged by this package's `type-check` (its + * `tsc -p tsconfig.test.json` leg), never by vitest: + * + * - the bag's `children` is one node or a list of nodes, the flat mirror's + * own member, and it is the only member that differs from the arm; + * - a closed node's misspelling inside the list is refused today. A + * spec-derived node carries no index signature, so it is the shape every + * node has after objectui#8347; the control is the same document typed + * the way the arm reads it, which compiles; + * - an undeclared `type` and a nested array inside the list are refused, + * and a primitive entry compiles, as in every node slot. + * + * A runtime leg reads the two zod faces on the same entries, so the one kind + * where the faces differ (a nested array) is measured here rather than stated. + * That divergence is recorded in `zod-mirror-parity.test.ts`'s `EXCLUSIONS` + * row for `layout.zod.ts#FlexBlockSchema`. + */ + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import type { FlexBlockNode } from '../authoring-nodes.js'; +import type { SchemaNode } from '../base.js'; +import type { FlexLayoutProps } from '../layout.js'; +import { + FlexBlockSchema, + FlexSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; + +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +/** The `flex` bag as `FlexBlockNode` types it. */ +type Bag = NonNullable; + +/** A shape with its `properties` member read closed: the derivation `FlexBlockNode` had before objectui#11564. */ +type ClosedArmShape = { + [K in keyof S]: K extends 'properties' + ? S[K] extends z.ZodOptional + ? z.ZodOptional> + : S[K] + : S[K]; +}; + +/** `FlexBlockSchema` read off its arm alone, its bag closed: what `FlexBlockNode` was. */ +type ArmInput = z.input>>; +type ArmBag = NonNullable; + +// 1. The bag's `children` is one node or a list of nodes: the flat mirror's own member, the slot +// `FlexLayoutProps` declares, and the single-or-list slot every node slot takes (objectui#11466). +type _BagChildrenIsANodeOrAList = Assert>; +type _BagChildrenIsTheMirrorsMember = Assert>>; +type _BagChildrenIsTheDeclaredMember = Assert>; + +// 2. The arm did not move (control): its list is still `unknown[]`, left to the page walk. +type _ControlTheArmsListIsUnknown = Assert>; + +// 3. That is the only member that differs, on the node and in the bag. +type _NodeMembersAreTheArms = Assert, Omit>>; +type _BagMembersAreTheArms = Assert, Omit>>; +type _BagStaysOptional = Assert>; + +type Issue = { code: string; path: string }; +type Result = { success: boolean; error?: { issues: z.core.$ZodIssue[] } }; + +const FACES: ReadonlyArray Result]> = [ + ['tolerant', (document) => safeValidateSchema(document)], + ['strict', (document) => StrictAnyComponentSchema.safeParse(document)], +]; + +const issuesOf = (result: Result): Issue[] => + (result.error?.issues ?? []).map((issue) => ({ code: issue.code, path: issue.path.join('.') })); + +describe('objectui#11564: the flex bag\'s child list is typed as nodes', () => { + it('a closed node\'s misspelling inside the list is refused; the arm\'s own reading let it through', () => { + // The card's probe f02, with a closed node: `contnt` is not a member of `element:text`'s bag. + // @ts-expect-error the list entry is judged as a node, and the misspelling is refused + const inList: FlexBlockNode = { type: 'flex', properties: { children: [{ type: 'element:text', properties: { contnt: 'x' } }] } }; + // Control: typed the way the arm reads it (`unknown[]`), the same document compiles. + const armReading: ArmInput = { type: 'flex', properties: { children: [{ type: 'element:text', properties: { contnt: 'x' } }] } }; + // Control: the same misspelling as a single child was refused before this card too. + // @ts-expect-error `contnt` is not a member of `element:text`'s bag + const single: FlexBlockNode = { type: 'flex', properties: { children: { type: 'element:text', properties: { contnt: 'x' } } } }; + expect([inList, armReading, single]).toHaveLength(3); + }); + + it('a well-formed list compiles: declared nodes, primitives, a nested flex', () => { + const list: FlexBlockNode = { + type: 'flex', + properties: { + direction: 'col', + gap: 4, + children: [ + { type: 'text', content: 'Orders' }, + { type: 'element:text', properties: { content: 'Total' } }, + { type: 'flex', properties: { children: [{ type: 'button', label: 'New order' }] } }, + 'plain text', + 3, + null, + ], + }, + }; + expect(list.properties?.children).toHaveLength(6); + }); + + it('an undeclared `type` and a nested array inside the list are refused', () => { + // @ts-expect-error `no-such-type-11564` names no declared node type + const undeclared: FlexBlockNode = { type: 'flex', properties: { children: [{ type: 'no-such-type-11564' }] } }; + // @ts-expect-error an entry that is itself a list is not a node + const nested: FlexBlockNode = { type: 'flex', properties: { children: [[{ type: 'text', content: 'x' }]] } }; + // @ts-expect-error an object with no `type` is not a node + const untyped: FlexBlockNode = { type: 'flex', properties: { children: [{ content: 'x' }] } }; + expect([undeclared, nested, untyped]).toHaveLength(3); + }); + + it('the zod faces, on the same entries: they agree, except on a nested array', () => { + const doc = (children: unknown[]) => ({ type: 'flex', properties: { children } }); + for (const [, judge] of FACES) { + // Agree: a closed node's misspelling and an undeclared `type` are refused at the entry's own path. + expect(issuesOf(judge(doc([{ type: 'element:text', properties: { contnt: 'x' } }]))).map((issue) => issue.path)).toEqual([ + 'properties.children.0.properties.content', + 'properties.children.0.properties', + ]); + expect(issuesOf(judge(doc([{ type: 'no-such-type-11564' }])))).toEqual([ + { code: 'invalid_union', path: 'properties.children.0.type' }, + ]); + // Agree: primitives are admitted, as in every node slot. + expect(judge(doc(['plain text', 3, null])).success).toBe(true); + // Differ: the page walk passes a nested array through unvisited, so both zod faces admit it, + // while the TypeScript face refuses it (above), as the flat mirror's own member does. + expect(judge(doc([[{ type: 'text', content: 'x' }]])).success).toBe(true); + expect(FlexSchema.safeParse({ type: 'flex', children: [[{ type: 'text', content: 'x' }]] }).success).toBe(false); + } + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 183e457b47..94ac86df28 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4913,9 +4913,12 @@ const EXCLUSIONS: Readonly> = { // objectui#11300) — the authored `flex` arm, built as the `object-chart` arm // with NO spec row: its bag is the flat mirror's own members. Its TS twin is // paired with that FLAT mirror (`FlexSchema`), which stays the renderer's - // post-hoist reading. + // post-hoist reading. objectui#11564 — the arm's TypeScript authoring face, + // `FlexBlockNode` (`../authoring-nodes.ts`), DIVERGES from it on one member: + // the bag's `children` list. The reason's last clause records it, and + // `./flex-bag-children-list-11564.test.ts` re-derives it on both sides. 'layout.zod.ts#FlexBlockSchema': - "mirror-owned BY REFERENCE — `BaseSchema` + the `flex` literal + `NODE_ENVELOPE` + `properties`, whose `direction` / `justify` / `align` / `gap` / `wrap` ARE the flat `layout.zod.ts#FlexSchema` mirror's own members (the same schema objects; `@objectstack/spec` has no `ComponentPropsMap['flex']` row) and whose `children` is the mirror's single-node-or-list slot with the list's entries left to the spec page walk's judgment, + `flatPropRefusals` over the bag (one `aliasKeyRefusal` per bag member, pointed at `properties.KEY`; the key set is read off the bag), + a `body` refusal pointed at `properties.children`; the TS declaration of the node, `FlexSchema` (`../layout.ts`), is the renderer's post-hoist reading and is paired with the flat mirror, not with this arm", + "mirror-owned BY REFERENCE — `BaseSchema` + the `flex` literal + `NODE_ENVELOPE` + `properties`, whose `direction` / `justify` / `align` / `gap` / `wrap` ARE the flat `layout.zod.ts#FlexSchema` mirror's own members (the same schema objects; `@objectstack/spec` has no `ComponentPropsMap['flex']` row) and whose `children` is the mirror's single-node-or-list slot with the list's entries left to the spec page walk's judgment, + `flatPropRefusals` over the bag (one `aliasKeyRefusal` per bag member, pointed at `properties.KEY`; the key set is read off the bag), + a `body` refusal pointed at `properties.children`; the TS declaration of the node, `FlexSchema` (`../layout.ts`), is the renderer's post-hoist reading and is paired with the flat mirror, not with this arm; TS/zod DIVERGENCE (objectui#11564): the arm's authoring face, `FlexBlockNode` (`../authoring-nodes.ts`), is read off this arm's shape except for the bag's `children`, which it reads off the flat mirror's own `children` member (one node or a list of nodes), because this arm's list is `z.array(z.unknown())` and only the page walk judges its entries — so the TS face judges each object entry as a node of its own `type`, as the walk does, admits a primitive entry, as the walk does, and refuses a nested-array entry, which the walk passes through unvisited", 'navigation.zod.ts#NavLinkSchema': "recursive; a `z.lazy` exposes no `.shape` to read, so there is no key set for the per-key comparison. Since objectui#7760 it carries its TS declaration as BOTH type arguments, so the pair IS compared — as a whole type, by `tsc`, at the annotation itself", 'navigation.zod.ts#NavigationMenuItemSchema': diff --git a/packages/types/src/authoring-nodes.ts b/packages/types/src/authoring-nodes.ts index 1fc6961ad5..f9113fffd3 100644 --- a/packages/types/src/authoring-nodes.ts +++ b/packages/types/src/authoring-nodes.ts @@ -45,7 +45,10 @@ * kept `.passthrough()` for the tolerant face (objectui#11276). The * derivation reads a bag's shape the way it reads the arm's, so the type * is closed where the strict authoring face closes it. A spec-row bag is - * already closed, and the derivation changes nothing for it. + * already closed, and the derivation changes nothing for it. One member + * of `flex`'s bag is read off the flat mirror instead of the arm: its + * `children`, whose list the arm leaves to the spec page walk and this + * face types as nodes (objectui#11564, at `FlexBlockShape`). * - {@link ElementTextInputNode} and {@link ElementRecordPickerNode}: no zod * arm exists for these two. `AnyComponentSchema` still refuses both at * `type`, and `registered-types-validate-ratchet-10859` in @@ -99,7 +102,7 @@ import type { PageSchema as SpecPageSchema, } from '@objectstack/spec/ui'; import type { BaseSchema as BaseSchemaMirror } from './zod/base.zod.js'; -import type { FlexBlockSchema } from './zod/layout.zod.js'; +import type { FlexBlockSchema, FlexSchema } from './zod/layout.zod.js'; import type { ObjectQLPublicBlockComponentSchema } from './zod/objectql.zod.js'; import type { NODE_ENVELOPE, PublicBlockComponentSchema } from './zod/public-blocks.zod.js'; import type { retirementTombstone } from './zod/tombstone.zod.js'; @@ -237,14 +240,68 @@ export type ObjectPivotBlockNode = ObjectQLPublicBlockNodeOf<'object-pivot'>; /** An authored `embeddable-form` node: the `EmbeddableFormBlockSchema` arm's input (objectui#11440). */ export type EmbeddableFormBlockNode = ObjectQLPublicBlockNodeOf<'embeddable-form'>; +/** `FlexBlockSchema`'s shape with its `properties` member closed ({@link ClosedArmShape}). */ +type FlexBlockArmShape = ClosedArmShape<(typeof FlexBlockSchema)['shape']>; + +/** + * The shape {@link FlexBlockNode} reads: `FlexBlockSchema`'s, closed, with ONE + * member replaced. The bag's `children` is the flat `FlexSchema` mirror's own + * `children` member, by reference: one node, or a list of nodes + * (`SchemaNode | SchemaNode[]`, the slot every node slot takes since + * objectui#11466). Every other member, of the node and of the bag, is the + * arm's. + * + * ## Why this one member is not the arm's (objectui#11564) + * + * The arm's bag member is `FLEX_BAG_CHILDREN` (`./zod/layout.zod.ts`), whose + * list arm is `z.array(z.unknown())`. That is right for the zod faces: a list + * at `properties.children` is a position `@objectstack/spec`'s page walk + * descends, so each entry is judged as a component there, once, at its real + * path (`./zod/nested-component-walk.ts`, objectui#11223). Its input type is + * `unknown[]`, though, and nothing on the TypeScript face does what the walk + * does. So read off the arm, the list, which is the form a `flex` node is + * authored in, took any entry at all, and stays unchecked after objectui#8347 + * removes `BaseSchema`'s index signature, while a single child was judged as a + * node. The mirror's member is the accept set the walk and the single-node arm + * judge between them. + * + * The zod arm does not move. The divergence is recorded where the mirror + * ledger records this arm (`EXCLUSIONS` in + * `__tests__/zod-mirror-parity.test.ts`) and pinned in + * `__tests__/flex-bag-children-list-11564.test.ts`. Both faces judge an object + * entry as a node of its own `type`: an undeclared `type` is refused by both, + * and a key the node does not declare is refused by the strict zod face and, + * once objectui#8347 removes the index signature, by this type (until then the + * signature absorbs it on the node types that extend `BaseSchema`, as it does + * for a single child). A primitive entry is admitted by both. They differ on + * one entry kind: a nested array, which the walk passes through unvisited and + * this type refuses, as the mirror does and as `renderChildren` + * (`@object-ui/components`) treats it, handing it to `SchemaRenderer` whole. + * + * A `properties` member that is not an optional zod object makes this + * `never`, so every `flex` literal stops compiling rather than silently losing + * the bag. + */ +type FlexBlockShape = ExtendedShape< + FlexBlockArmShape, + { + properties: FlexBlockArmShape['properties'] extends z.ZodOptional + ? z.ZodOptional>> + : never; + } +>; + /** * An authored `flex` node: the `FlexBlockSchema` arm's input (objectui#11468), - * derived as {@link PublicBlockNode} is. Its props, the child list included, + * derived as {@link PublicBlockNode} is, with one member replaced + * ({@link FlexBlockShape}, objectui#11564). Its props, the child list included, * are its `properties` bag: the `FlexSchema` mirror's own members * (`direction`, `justify`, `align`, `gap`, `wrap`, `children`), closed * ({@link ClosedBag}), because `@objectstack/spec` has no `flex` row - * (objectui#11276). The TypeScript `FlexSchema` (`./layout.ts`) stays the node - * as the `flex` renderer reads it after the hoist. + * (objectui#11276). The bag's `children` is one node or a list of nodes, each + * list entry judged as a node, as a single child is. The TypeScript + * `FlexSchema` (`./layout.ts`) stays the node as the `flex` renderer reads it + * after the hoist. * * ⚠️ An interface, not a type alias, for one reason (objectui#11466). The bag * holds node slots, so `FlexBlockSchema`'s inferred type names @@ -254,10 +311,11 @@ export type EmbeddableFormBlockNode = ObjectQLPublicBlockNodeOf<'embeddable-form * reference (TS2456 on this type, `AuthoringNode`, `DeclaredNode` and * `SchemaNode`). An interface's members are resolved only when they are read, * so the loop is never walked while `SchemaNode` is being resolved. The - * interface declares nothing of its own: every member is the arm's, as above. + * interface declares nothing of its own: every member is read off + * {@link FlexBlockShape}, as above. */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- an interface on purpose, to break the SchemaNode cycle its docblock names; it declares no member of its own. -export interface FlexBlockNode extends ClosedArmInput {} +export interface FlexBlockNode extends z.input> {} /** * `element:text_input`: `ComponentPropsMap['element:text_input']` From c5140c185816db54c06b14be31e59476e087f3a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 03:34:43 +0000 Subject: [PATCH 2/3] docs(types): the dashboard example's comment says the flex bag's list is typed as nodes (objectui#11564) The comment said the bag's child list is `unknown[]`, which is no longer true. The nested `flex` node keeps its `satisfies FlexBlockNode`: as a list entry it also matches the post-hoist `FlexSchema`, whose index signature admits any `properties` until objectui#8347. Claude-Session: https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ Co-authored-by: Claude --- packages/types/examples/dashboard.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/types/examples/dashboard.ts b/packages/types/examples/dashboard.ts index 7e24bf3cb3..5f4b6a77f2 100644 --- a/packages/types/examples/dashboard.ts +++ b/packages/types/examples/dashboard.ts @@ -17,9 +17,11 @@ import type { FlexBlockNode, SidebarSchema, HeaderBarSchema, CardSchema, DataTab // An authored `flex` node takes its props, the child list included, in its // `properties` bag (objectui#11276). `FlexBlockNode` is that node, its bag -// closed (objectui#11468). The bag's child list is `unknown[]`, as the zod arm -// declares it (the page walk judges each entry), so the nested `flex` node is -// checked with `satisfies FlexBlockNode`. +// closed (objectui#11468), and each entry of the bag's child list is typed as +// a node (objectui#11564). A `flex` entry also matches the post-hoist +// `FlexSchema`, whose `BaseSchema` index signature admits any `properties` +// until objectui#8347 removes it, so the nested `flex` node is checked with +// `satisfies FlexBlockNode`. export const dashboardSchema: FlexBlockNode = { type: 'flex', className: 'h-screen', From 9107906411daa671f19528d65b8a1055cd624584 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 04:14:49 +0000 Subject: [PATCH 3/3] docs(types): the README's composition example says the flex bag's list is checked as nodes (objectui#11564) The comment said the bag's child list is `unknown[]`, which is no longer true. The code is unchanged: the nested `flex` keeps `satisfies FlexBlockNode`, because as a list entry it also matches the post-hoist `FlexSchema`, whose index signature admits any `properties` until that signature is removed. Claude-Session: https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ Co-authored-by: Claude --- packages/types/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/types/README.md b/packages/types/README.md index 5069098926..8b7ad2189c 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -444,9 +444,11 @@ const main: ContainerSchema = { }; // An authored `flex` node takes its props, the child list included, in its -// `properties` bag. `FlexBlockNode` is that node, its bag closed. The bag's -// child list is `unknown[]`, as `FlexBlockSchema` declares it, so each child -// written inline names its own type with `satisfies`. +// `properties` bag. `FlexBlockNode` is that node, its bag closed, and each +// entry of the bag's child list is checked as a node of its own `type`. The +// nested `flex` still names its type with `satisfies`: as an entry it also +// matches the post-hoist `FlexSchema`, whose `BaseSchema` index signature +// admits any `properties` until that signature is removed. const page: FlexBlockNode = { type: 'flex', properties: {