@echothink-ui/layout 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +92 -0
  2. package/dist/index.cjs +1620 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.css +149 -0
  5. package/dist/index.css.map +1 -0
  6. package/dist/index.d.ts +24 -0
  7. package/dist/index.js +1546 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/layout-system/builders.d.ts +13 -0
  10. package/dist/layout-system/index.d.ts +24 -0
  11. package/dist/layout-system/inference/context.d.ts +33 -0
  12. package/dist/layout-system/inference/responsive.d.ts +21 -0
  13. package/dist/layout-system/inference/style.d.ts +15 -0
  14. package/dist/layout-system/page-layouts/index.d.ts +8 -0
  15. package/dist/layout-system/primitives/index.d.ts +6 -0
  16. package/dist/layout-system/regions/index.d.ts +4 -0
  17. package/dist/layout-system/registry/builtins.d.ts +8 -0
  18. package/dist/layout-system/registry/registry.d.ts +20 -0
  19. package/dist/layout-system/renderer/context.d.ts +41 -0
  20. package/dist/layout-system/renderer/region.d.ts +10 -0
  21. package/dist/layout-system/renderer/renderer.d.ts +13 -0
  22. package/dist/layout-system/renderer/root.d.ts +24 -0
  23. package/dist/layout-system/runtime/state.d.ts +17 -0
  24. package/dist/layout-system/runtime/viewport.d.ts +9 -0
  25. package/dist/layout-system/schema/types.d.ts +488 -0
  26. package/dist/layout-system/schema/validate.d.ts +15 -0
  27. package/dist/layout-system/tokens/preset-tokens.d.ts +11 -0
  28. package/package.json +47 -0
  29. package/src/index.tsx +42 -0
  30. package/src/layout-system/__tests__/layout-system.test.tsx +169 -0
  31. package/src/layout-system/builders.ts +46 -0
  32. package/src/layout-system/index.ts +87 -0
  33. package/src/layout-system/inference/context.ts +158 -0
  34. package/src/layout-system/inference/responsive.ts +147 -0
  35. package/src/layout-system/inference/style.ts +128 -0
  36. package/src/layout-system/page-layouts/index.tsx +405 -0
  37. package/src/layout-system/primitives/index.tsx +266 -0
  38. package/src/layout-system/regions/index.tsx +90 -0
  39. package/src/layout-system/registry/builtins.ts +19 -0
  40. package/src/layout-system/registry/registry.ts +47 -0
  41. package/src/layout-system/renderer/context.tsx +89 -0
  42. package/src/layout-system/renderer/region.tsx +34 -0
  43. package/src/layout-system/renderer/renderer.tsx +200 -0
  44. package/src/layout-system/renderer/root.tsx +95 -0
  45. package/src/layout-system/runtime/state.ts +80 -0
  46. package/src/layout-system/runtime/viewport.ts +71 -0
  47. package/src/layout-system/schema/types.ts +706 -0
  48. package/src/layout-system/schema/validate.ts +168 -0
  49. package/src/layout-system/tokens/preset-tokens.ts +77 -0
  50. package/src/styles.css +178 -0
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as React from "react";
3
+ import { renderToStaticMarkup } from "react-dom/server";
4
+ import {
5
+ LayoutRoot,
6
+ createDefaultLayoutRegistry,
7
+ validateLayout,
8
+ inferComponentStyle,
9
+ resolvePhysicalPlan,
10
+ createRootContext,
11
+ layout,
12
+ componentSlot,
13
+ layoutSlot,
14
+ type LayoutNode,
15
+ type ComponentResolver,
16
+ } from "../index.js";
17
+
18
+ const registry = createDefaultLayoutRegistry();
19
+
20
+ /* -------------------------------- Validation ------------------------------- */
21
+
22
+ describe("validateLayout", () => {
23
+ it("flags unknown layout type", () => {
24
+ const node = layout({ id: "x", type: "PageLayout.DoesNotExist", slots: {} });
25
+ const diags = validateLayout(node, registry);
26
+ expect(diags.some((d) => d.code === "UNKNOWN_LAYOUT_TYPE")).toBe(true);
27
+ });
28
+
29
+ it("flags a required slot that is missing", () => {
30
+ const node = layout({ id: "ds", type: "PageLayout.DataGridInspector", slots: {} });
31
+ const diags = validateLayout(node, registry);
32
+ expect(diags.some((d) => d.code === "SLOT_REQUIRED" && d.path.includes("grid"))).toBe(true);
33
+ });
34
+
35
+ it("accepts a well-formed tree", () => {
36
+ const node = layout({
37
+ id: "ds",
38
+ type: "PageLayout.DataGridInspector",
39
+ slots: { grid: componentSlot("EntityTable", { entityType: "Order" }) },
40
+ });
41
+ const diags = validateLayout(node, registry);
42
+ expect(diags.filter((d) => d.level === "error")).toHaveLength(0);
43
+ });
44
+
45
+ it("detects accepts violations", () => {
46
+ const node = layout({
47
+ id: "p",
48
+ type: "Primitive.Panel",
49
+ // body only accepts component/layout/fragment, not template
50
+ slots: { body: { kind: "template", template: "Anything" } },
51
+ });
52
+ const diags = validateLayout(node, registry);
53
+ expect(diags.some((d) => d.code === "SLOT_ACCEPTS_VIOLATION")).toBe(true);
54
+ });
55
+ });
56
+
57
+ /* ----------------------------- Style inference ----------------------------- */
58
+
59
+ describe("inferComponentStyle", () => {
60
+ const base = {
61
+ surface: "page" as const,
62
+ depth: 1,
63
+ breakpoint: "desktop" as const,
64
+ };
65
+ it("workbench/console → studio-dark + compact", () => {
66
+ const s = inferComponentStyle({ ...base, layoutType: "PageLayout.Workbench", slotRole: "console" });
67
+ expect(s.preset).toBe("studio-dark");
68
+ expect(s.density).toBe("compact");
69
+ });
70
+ it("wizard → bright + comfortable", () => {
71
+ const s = inferComponentStyle({ ...base, layoutType: "PageLayout.WizardComposer", taskMode: "wizard", slotRole: "form" });
72
+ expect(s.preset).toBe("bright");
73
+ });
74
+ it("admin shell → carbon-like", () => {
75
+ const s = inferComponentStyle({ ...base, layoutType: "PageLayout.AdminShell", slotRole: "content" });
76
+ expect(s.preset).toBe("carbon-like");
77
+ });
78
+ it("explicit override wins", () => {
79
+ const s = inferComponentStyle({ ...base, layoutType: "PageLayout.AdminShell", explicit: { preset: "glass" } });
80
+ expect(s.preset).toBe("glass");
81
+ });
82
+ });
83
+
84
+ /* ---------------------------- Responsive plan ------------------------------ */
85
+
86
+ describe("resolvePhysicalPlan", () => {
87
+ const item = registry.get("PageLayout.AdminShell")!;
88
+ const node = layout({
89
+ id: "shell",
90
+ type: "PageLayout.AdminShell",
91
+ slots: {
92
+ navigation: componentSlot("Nav"),
93
+ content: componentSlot("Content"),
94
+ },
95
+ });
96
+
97
+ it("keeps navigation on desktop", () => {
98
+ const plan = resolvePhysicalPlan({
99
+ node,
100
+ item,
101
+ viewport: { breakpoint: "desktop", width: 1440, height: 900, pointer: "fine", colorScheme: "light" },
102
+ });
103
+ const nav = plan.regions.find((r) => r.slot === "navigation")!;
104
+ expect(nav.visible).toBe(true);
105
+ expect(plan.mode).toBe("multi-pane");
106
+ });
107
+
108
+ it("moves navigation to a drawer on mobile", () => {
109
+ const plan = resolvePhysicalPlan({
110
+ node,
111
+ item,
112
+ viewport: { breakpoint: "mobile", width: 390, height: 800, pointer: "coarse", colorScheme: "light" },
113
+ });
114
+ const nav = plan.regions.find((r) => r.slot === "navigation")!;
115
+ expect(nav.layer).toBe("drawer");
116
+ expect(plan.mode).toBe("drawer-assisted");
117
+ // required content slot is never dropped
118
+ expect(plan.regions.find((r) => r.slot === "content")!.visible).toBe(true);
119
+ });
120
+ });
121
+
122
+ /* ------------------------------- Rendering --------------------------------- */
123
+
124
+ describe("LayoutRoot rendering", () => {
125
+ const resolver: ComponentResolver = (name) => {
126
+ const C = (props: Record<string, unknown>) =>
127
+ React.createElement("div", { "data-component": name }, String(props.text ?? name));
128
+ return C;
129
+ };
130
+
131
+ it("renders a page layout with nested layout + components", () => {
132
+ const node: LayoutNode = layout({
133
+ id: "root",
134
+ type: "PageLayout.AdminShell",
135
+ slots: {
136
+ topbar: componentSlot("Brand", { text: "EchoThink" }),
137
+ navigation: componentSlot("Nav"),
138
+ content: layoutSlot(
139
+ layout({
140
+ id: "panel",
141
+ type: "Primitive.Panel",
142
+ slots: { body: componentSlot("Table", { text: "rows" }) },
143
+ }),
144
+ ),
145
+ },
146
+ });
147
+ const html = renderToStaticMarkup(
148
+ React.createElement(LayoutRoot, {
149
+ node,
150
+ componentResolver: resolver,
151
+ observeViewport: false,
152
+ context: createRootContext(),
153
+ }),
154
+ );
155
+ expect(html).toContain('data-component="Brand"');
156
+ expect(html).toContain("EchoThink");
157
+ expect(html).toContain('data-component="Table"');
158
+ expect(html).toContain("eth-ls-admin-shell");
159
+ expect(html).toContain("eth-ls-panel");
160
+ });
161
+
162
+ it("renders an unknown layout placeholder rather than throwing", () => {
163
+ const node = layout({ id: "x", type: "Nope", slots: {} });
164
+ const html = renderToStaticMarkup(
165
+ React.createElement(LayoutRoot, { node, componentResolver: resolver, observeViewport: false }),
166
+ );
167
+ expect(html).toContain("Unknown layout type");
168
+ });
169
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Ergonomic builders for hand-authoring `LayoutNode` ASTs (used by templates
3
+ * and tests). Pure helpers — no runtime behavior.
4
+ */
5
+ import type {
6
+ ComponentSlotContent,
7
+ EmptySlotContent,
8
+ FragmentSlotContent,
9
+ LayoutNode,
10
+ LayoutSlotContent,
11
+ SlotContent,
12
+ TemplateSlotContent,
13
+ } from "./schema/types.js";
14
+
15
+ export interface LayoutNodeInput extends Omit<LayoutNode, "kind"> {}
16
+
17
+ export function layout(input: LayoutNodeInput): LayoutNode {
18
+ return { kind: "layout", ...input };
19
+ }
20
+
21
+ export function componentSlot(
22
+ component: string,
23
+ props?: Record<string, unknown>,
24
+ styleOverride?: ComponentSlotContent["styleOverride"],
25
+ ): ComponentSlotContent {
26
+ return { kind: "component", component, props, styleOverride };
27
+ }
28
+
29
+ export function layoutSlot(node: LayoutNode): LayoutSlotContent {
30
+ return { kind: "layout", layout: node };
31
+ }
32
+
33
+ export function templateSlot(template: string, props?: Record<string, unknown>): TemplateSlotContent {
34
+ return { kind: "template", template, props };
35
+ }
36
+
37
+ export function fragmentSlot(
38
+ items: SlotContent[],
39
+ composition?: FragmentSlotContent["composition"],
40
+ ): FragmentSlotContent {
41
+ return { kind: "fragment", items, composition };
42
+ }
43
+
44
+ export function emptySlot(reason?: EmptySlotContent["reason"]): EmptySlotContent {
45
+ return { kind: "empty", reason };
46
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `@echothink-ui/layout` — the layout system (layout-spec.md).
3
+ *
4
+ * A recursive, typed page-structure engine: Typed Layout AST → registry resolve
5
+ * → validation → context inference → responsive physical plan → recursive render.
6
+ */
7
+
8
+ // Schema / AST types
9
+ export * from "./schema/types.js";
10
+ export { validateLayout, hasErrors } from "./schema/validate.js";
11
+ export type { ValidateOptions } from "./schema/validate.js";
12
+
13
+ // Tokens
14
+ export { presetTokenMap, resolveTokens } from "./tokens/preset-tokens.js";
15
+
16
+ // Inference
17
+ export {
18
+ inferComponentStyle,
19
+ inferDensity,
20
+ inferTypeScale,
21
+ defaultComponentStyle,
22
+ } from "./inference/style.js";
23
+ export {
24
+ composeLayoutContext,
25
+ buildStyleInferenceInput,
26
+ createRootContext,
27
+ defaultContextPolicy,
28
+ defaultViewport,
29
+ type RootContextInput,
30
+ } from "./inference/context.js";
31
+ export {
32
+ resolvePhysicalPlan,
33
+ regionFor,
34
+ breakpointRank,
35
+ isNarrow,
36
+ type ResolvePlanInput,
37
+ } from "./inference/responsive.js";
38
+
39
+ // Registry
40
+ export { LayoutRegistry } from "./registry/registry.js";
41
+ export { builtinLayouts, createDefaultLayoutRegistry } from "./registry/builtins.js";
42
+
43
+ // Runtime
44
+ export {
45
+ createMemoryLayoutStateStore,
46
+ type LayoutStateStore,
47
+ } from "./runtime/state.js";
48
+ export {
49
+ useViewport,
50
+ breakpointForWidth,
51
+ type UseViewportOptions,
52
+ } from "./runtime/viewport.js";
53
+
54
+ // Renderer
55
+ export { LayoutRoot, type LayoutRootProps } from "./renderer/root.js";
56
+ export {
57
+ LayoutRenderer,
58
+ SlotContentRenderer,
59
+ type SlotContentRendererProps,
60
+ } from "./renderer/renderer.js";
61
+ export {
62
+ LayoutContextProvider,
63
+ LayoutEngineProvider,
64
+ useLayoutContext,
65
+ useLayoutEngine,
66
+ buildEngine,
67
+ type ComponentResolver,
68
+ type TemplateResolver,
69
+ type LayoutEngine,
70
+ type BuildEngineInput,
71
+ } from "./renderer/context.js";
72
+
73
+ // Builtin layout items (for direct registry composition / introspection)
74
+ export { primitiveLayouts } from "./primitives/index.js";
75
+ export { regionLayouts } from "./regions/index.js";
76
+ export { pageLayouts } from "./page-layouts/index.js";
77
+
78
+ // Builders
79
+ export {
80
+ layout,
81
+ componentSlot,
82
+ layoutSlot,
83
+ templateSlot,
84
+ fragmentSlot,
85
+ emptySlot,
86
+ type LayoutNodeInput,
87
+ } from "./builders.js";
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Context propagation (`layout-spec.md` §6, §8).
3
+ *
4
+ * Builds the root `LayoutRuntimeContext` and composes a child context for each
5
+ * slot: it derives the child surface/taskMode/etc. and runs `inferComponentStyle`
6
+ * so the descendant components receive consistent, inferred style params.
7
+ */
8
+ import type {
9
+ ComponentStyleParams,
10
+ ContextPolicy,
11
+ LayoutNode,
12
+ LayoutRuntimeContext,
13
+ LayoutViewport,
14
+ SlotContent,
15
+ SlotDefinition,
16
+ StyleInferenceInput,
17
+ SurfaceRole,
18
+ } from "../schema/types.js";
19
+ import { defaultComponentStyle, inferComponentStyle } from "./style.js";
20
+ import { resolveTokens } from "../tokens/preset-tokens.js";
21
+
22
+ export const defaultContextPolicy: ContextPolicy = {
23
+ inheritStyle: true,
24
+ inheritDensity: true,
25
+ inheritTypeScale: true,
26
+ inheritSurface: false,
27
+ isolateState: false,
28
+ isolateScroll: true,
29
+ allowComponentOverride: true,
30
+ maxDepth: 8,
31
+ };
32
+
33
+ export const defaultViewport: LayoutViewport = {
34
+ breakpoint: "desktop",
35
+ width: 1440,
36
+ height: 900,
37
+ pointer: "fine",
38
+ colorScheme: "light",
39
+ };
40
+
41
+ export interface RootContextInput {
42
+ viewport?: Partial<LayoutViewport>;
43
+ surface?: SurfaceRole;
44
+ style?: Partial<ComponentStyleParams>;
45
+ route?: LayoutRuntimeContext["route"];
46
+ permissions?: string[];
47
+ }
48
+
49
+ /** Build the top-level context an app mounts the renderer with. */
50
+ export function createRootContext(input: RootContextInput = {}): LayoutRuntimeContext {
51
+ const viewport: LayoutViewport = { ...defaultViewport, ...input.viewport };
52
+ const componentStyle: ComponentStyleParams = {
53
+ ...defaultComponentStyle,
54
+ ...input.style,
55
+ };
56
+ return {
57
+ path: [],
58
+ depth: 0,
59
+ ancestry: [],
60
+ viewport,
61
+ container: { width: viewport.width, height: viewport.height },
62
+ route: input.route,
63
+ surface: input.surface ?? "app",
64
+ componentStyle,
65
+ tokens: resolveTokens(componentStyle),
66
+ stateScope: "session",
67
+ permissions: input.permissions ? { granted: new Set(input.permissions) } : undefined,
68
+ };
69
+ }
70
+
71
+ function inferChildSurface(
72
+ parent: LayoutRuntimeContext,
73
+ node: LayoutNode,
74
+ slot: SlotDefinition,
75
+ ): SurfaceRole {
76
+ return (
77
+ slot.contextTransform?.surface ??
78
+ node.styleIntent?.surface ??
79
+ (parent.surface === "app" ? "page" : parent.surface)
80
+ );
81
+ }
82
+
83
+ /** §7.1 — assemble the inference input from parent context + node + slot. */
84
+ export function buildStyleInferenceInput(args: {
85
+ parent: LayoutRuntimeContext;
86
+ node: LayoutNode;
87
+ slot: SlotDefinition;
88
+ content: SlotContent;
89
+ }): StyleInferenceInput {
90
+ const { parent, node, slot, content } = args;
91
+ const surface = inferChildSurface(parent, node, slot);
92
+ const explicit: Partial<ComponentStyleParams> = {
93
+ ...node.styleIntent,
94
+ ...slot.contextTransform?.styleIntent,
95
+ ...(slot.contextTransform?.densityHint ? { density: slot.contextTransform.densityHint } : {}),
96
+ ...(slot.contextTransform?.typeScaleHint ? { typeScale: slot.contextTransform.typeScaleHint } : {}),
97
+ ...(content.kind === "component" ? content.styleOverride : undefined),
98
+ };
99
+ return {
100
+ parent: parent.componentStyle,
101
+ explicit,
102
+ layoutType: node.type,
103
+ layoutTier: node.tier,
104
+ layoutVariant: node.variant,
105
+ surface,
106
+ slotRole: slot.role,
107
+ taskMode: node.styleIntent?.taskMode ?? parent.taskMode,
108
+ dataIntensity: node.styleIntent?.dataIntensity ?? parent.dataIntensity,
109
+ interactionMode: node.styleIntent?.interactionMode ?? parent.interactionMode,
110
+ visualEmphasis: node.styleIntent?.visualEmphasis ?? parent.visualEmphasis,
111
+ depth: parent.depth + 1,
112
+ breakpoint: parent.viewport.breakpoint,
113
+ containerWidth: parent.container.width,
114
+ };
115
+ }
116
+
117
+ /** §8.1 — compose the child runtime context for a slot. */
118
+ export function composeLayoutContext(args: {
119
+ parent: LayoutRuntimeContext;
120
+ node: LayoutNode;
121
+ slot: SlotDefinition;
122
+ content: SlotContent;
123
+ }): LayoutRuntimeContext {
124
+ const { parent, node, slot, content } = args;
125
+ const styleInput = buildStyleInferenceInput(args);
126
+ const componentStyle = inferComponentStyle(styleInput);
127
+ const surface = styleInput.surface;
128
+
129
+ return {
130
+ ...parent,
131
+ path: [...parent.path, node.id, slot.name],
132
+ depth: parent.depth + 1,
133
+ ancestry: [
134
+ ...parent.ancestry,
135
+ {
136
+ id: node.id,
137
+ type: node.type,
138
+ tier: node.tier,
139
+ surface: node.styleIntent?.surface,
140
+ slotRole: slot.role,
141
+ },
142
+ ],
143
+ slot: {
144
+ name: slot.name,
145
+ role: slot.role,
146
+ priority: slot.priority?.value,
147
+ surface: slot.contextTransform?.surface,
148
+ },
149
+ surface,
150
+ taskMode: node.styleIntent?.taskMode ?? parent.taskMode,
151
+ dataIntensity: node.styleIntent?.dataIntensity ?? parent.dataIntensity,
152
+ interactionMode: node.styleIntent?.interactionMode ?? parent.interactionMode,
153
+ visualEmphasis: node.styleIntent?.visualEmphasis ?? parent.visualEmphasis,
154
+ componentStyle,
155
+ tokens: resolveTokens(componentStyle),
156
+ stateScope: node.state?.scope ?? parent.stateScope,
157
+ };
158
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Responsive physical plan (`layout-spec.md` §10).
3
+ *
4
+ * Maps a logical layout node + its registry slot definitions + the viewport (and
5
+ * optional user collapse/pin state) to a `PhysicalLayoutPlan` describing each
6
+ * slot's placement, visibility and collapse. Page-layout renderers consume the
7
+ * plan so the same AST yields different desktop/tablet/mobile arrangements.
8
+ */
9
+ import type {
10
+ Breakpoint,
11
+ LayoutNode,
12
+ LayoutRegistryItem,
13
+ LayoutViewport,
14
+ PersistedLayoutState,
15
+ PhysicalLayoutPlan,
16
+ PhysicalRegion,
17
+ SlotDefinition,
18
+ SlotRole,
19
+ } from "../schema/types.js";
20
+
21
+ const BREAKPOINT_ORDER: Breakpoint[] = [
22
+ "mobile",
23
+ "tablet",
24
+ "laptop",
25
+ "desktop",
26
+ "wide",
27
+ "ultra",
28
+ ];
29
+
30
+ export function breakpointRank(bp: Breakpoint): number {
31
+ return BREAKPOINT_ORDER.indexOf(bp);
32
+ }
33
+
34
+ export function isNarrow(bp: Breakpoint): boolean {
35
+ return breakpointRank(bp) <= breakpointRank("tablet");
36
+ }
37
+
38
+ const PLACEMENT_BY_ROLE: Partial<Record<SlotRole, PhysicalRegion["placement"]>> = {
39
+ navigation: "inline-start",
40
+ filter: "inline-start",
41
+ inspector: "inline-end",
42
+ secondary: "inline-end",
43
+ toolbar: "block-start",
44
+ search: "block-start",
45
+ status: "block-start",
46
+ console: "block-end",
47
+ footer: "block-end",
48
+ timeline: "block-end",
49
+ summary: "inline-end",
50
+ preview: "inline-end",
51
+ drawer: "overlay",
52
+ modal: "overlay",
53
+ overlay: "overlay",
54
+ };
55
+
56
+ function placementFor(slot: SlotDefinition): PhysicalRegion["placement"] {
57
+ return PLACEMENT_BY_ROLE[slot.role] ?? "center";
58
+ }
59
+
60
+ export interface ResolvePlanInput {
61
+ node: LayoutNode;
62
+ item: LayoutRegistryItem;
63
+ viewport: LayoutViewport;
64
+ state?: PersistedLayoutState;
65
+ }
66
+
67
+ /** Compute the physical plan for a single layout node. */
68
+ export function resolvePhysicalPlan(input: ResolvePlanInput): PhysicalLayoutPlan {
69
+ const { node, item, viewport, state } = input;
70
+ const narrow = isNarrow(viewport.breakpoint);
71
+ const bpRank = breakpointRank(viewport.breakpoint);
72
+
73
+ const regions: PhysicalRegion[] = item.slots.map((slot) => {
74
+ const present = node.slots[slot.name] !== undefined;
75
+ const persisted = state?.panels?.[slot.name];
76
+ const placement = placementFor(slot);
77
+
78
+ let visible = present;
79
+ let collapsed = persisted?.collapsed ?? false;
80
+ let layer: PhysicalRegion["layer"] | undefined;
81
+ let regionPlacement = placement;
82
+
83
+ // Apply responsive slot policy.
84
+ if (slot.responsive?.hideBelow && bpRank < breakpointRank(slot.responsive.hideBelow)) {
85
+ visible = false;
86
+ }
87
+ if (slot.responsive?.collapseBelow && bpRank < breakpointRank(slot.responsive.collapseBelow)) {
88
+ collapsed = true;
89
+ }
90
+
91
+ // Apply narrow-screen behavior from slot priority (never drop a required
92
+ // primary/content slot — demote supporting panes to drawers/sheets instead).
93
+ if (narrow && slot.priority) {
94
+ switch (slot.priority.behaviorOnNarrow) {
95
+ case "collapse":
96
+ collapsed = true;
97
+ break;
98
+ case "hide":
99
+ if (!slot.required) visible = false;
100
+ break;
101
+ case "move-to-drawer":
102
+ regionPlacement = "overlay";
103
+ layer = "drawer";
104
+ break;
105
+ case "move-to-bottom-sheet":
106
+ regionPlacement = "overlay";
107
+ layer = "sheet";
108
+ break;
109
+ case "route-stack":
110
+ case "tabs":
111
+ case "keep":
112
+ default:
113
+ break;
114
+ }
115
+ }
116
+
117
+ return {
118
+ slot: slot.name,
119
+ placement: regionPlacement,
120
+ visible,
121
+ collapsed,
122
+ mounted: present,
123
+ layer,
124
+ size: persisted?.width
125
+ ? { width: persisted.width }
126
+ : persisted?.height
127
+ ? { height: persisted.height }
128
+ : undefined,
129
+ };
130
+ });
131
+
132
+ const mode: PhysicalLayoutPlan["mode"] = narrow
133
+ ? regions.some((r) => r.layer === "drawer" || r.layer === "sheet")
134
+ ? "drawer-assisted"
135
+ : "single-pane"
136
+ : "multi-pane";
137
+
138
+ return { layoutId: node.id, mode, regions };
139
+ }
140
+
141
+ /** Convenience: get the region for a slot from a plan. */
142
+ export function regionFor(
143
+ plan: PhysicalLayoutPlan,
144
+ slot: string,
145
+ ): PhysicalRegion | undefined {
146
+ return plan.regions.find((r) => r.slot === slot);
147
+ }