@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,200 @@
1
+ /**
2
+ * Recursive renderer (`layout-spec.md` §8.2).
3
+ *
4
+ * Walks a `LayoutNode`: resolves its registry item, renders each slot's content
5
+ * into a React node under a recomposed child context, computes the physical
6
+ * plan, and hands the pre-rendered `slots` map to the item's renderer.
7
+ *
8
+ * `SlotContentRenderer` is exported so composition renderers (Tabs/Stepper) can
9
+ * render inline `SlotContent` (switch items / stepper steps) with correct child
10
+ * context.
11
+ */
12
+ import * as React from "react";
13
+ import { StyleScope } from "@echothink-ui/style";
14
+ import type {
15
+ ComponentSlotContent,
16
+ LayoutNode,
17
+ LayoutRuntimeContext,
18
+ SlotContent,
19
+ SlotDefinition,
20
+ } from "../schema/types.js";
21
+ import { composeLayoutContext } from "../inference/context.js";
22
+ import { resolvePhysicalPlan } from "../inference/responsive.js";
23
+ import {
24
+ LayoutContextProvider,
25
+ useLayoutContext,
26
+ useLayoutEngine,
27
+ type LayoutEngine,
28
+ } from "./context.js";
29
+
30
+ /* ------------------------------------------------------------------ *
31
+ * Fallback surfaces for unknown types — never throw at render time.
32
+ * ------------------------------------------------------------------ */
33
+
34
+ function UnknownLayout({ type }: { type: string }) {
35
+ return (
36
+ <div data-ls-unknown-layout={type} className="eth-ls-unknown" role="note">
37
+ Unknown layout type: <code>{type}</code>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ function UnknownComponent({ name }: { name: string }) {
43
+ return (
44
+ <div data-ls-unknown-component={name} className="eth-ls-unknown" role="note">
45
+ Unknown component: <code>{name}</code>
46
+ </div>
47
+ );
48
+ }
49
+
50
+ function EmptySlot({ reason }: { reason?: string }) {
51
+ return (
52
+ <div data-ls-empty={reason ?? "not-configured"} className="eth-ls-empty" aria-hidden="true" />
53
+ );
54
+ }
55
+
56
+ /* ------------------------------------------------------------------ *
57
+ * Component leaf — the style boundary.
58
+ * ------------------------------------------------------------------ */
59
+
60
+ function ComponentRenderer({
61
+ content,
62
+ context,
63
+ }: {
64
+ content: ComponentSlotContent;
65
+ context: LayoutRuntimeContext;
66
+ }) {
67
+ const engine = useLayoutEngine();
68
+ const Component = engine.componentResolver(content.component);
69
+ if (!Component) return <UnknownComponent name={content.component} />;
70
+ const s = context.componentStyle;
71
+ // The StyleScope wrapper is the ONLY place style reaches a component: it sets
72
+ // the preset/palette/density/typeScale classes + data-attrs the `@echothink-ui`
73
+ // components read via the CSS cascade. We intentionally do NOT inject
74
+ // styleParams/layoutContext as props (avoids DOM prop leakage).
75
+ return (
76
+ <StyleScope
77
+ preset={s.preset}
78
+ palette={s.palette}
79
+ density={s.density}
80
+ typeScale={s.typeScale}
81
+ global={false}
82
+ className="eth-ls-component"
83
+ data-ls-component={content.component}
84
+ >
85
+ <Component {...(content.props ?? {})} />
86
+ </StyleScope>
87
+ );
88
+ }
89
+
90
+ /* ------------------------------------------------------------------ *
91
+ * Render a SlotContent within an already-composed child context.
92
+ * ------------------------------------------------------------------ */
93
+
94
+ function renderContent(
95
+ content: SlotContent,
96
+ context: LayoutRuntimeContext,
97
+ engine: LayoutEngine,
98
+ ): React.ReactNode {
99
+ switch (content.kind) {
100
+ case "component":
101
+ return <ComponentRenderer content={content} context={context} />;
102
+ case "layout":
103
+ return <LayoutRenderer node={content.layout} />;
104
+ case "template": {
105
+ const resolved = engine.templateResolver?.(content.template);
106
+ if (!resolved) return <UnknownComponent name={`template:${content.template}`} />;
107
+ return <LayoutRenderer node={resolved} />;
108
+ }
109
+ case "fragment": {
110
+ const direction =
111
+ content.composition?.op === "stack" && content.composition.direction === "horizontal"
112
+ ? "row"
113
+ : "column";
114
+ return (
115
+ <div className="eth-ls-fragment" style={{ display: "flex", flexDirection: direction, gap: context.tokens.gap }}>
116
+ {content.items.map((item, i) => (
117
+ <React.Fragment key={i}>{renderContent(item, context, engine)}</React.Fragment>
118
+ ))}
119
+ </div>
120
+ );
121
+ }
122
+ case "empty":
123
+ return <EmptySlot reason={content.reason} />;
124
+ default:
125
+ return null;
126
+ }
127
+ }
128
+
129
+ export interface SlotContentRendererProps {
130
+ node: LayoutNode;
131
+ slot: SlotDefinition;
132
+ content: SlotContent;
133
+ /** Context of the layout node that owns the slot. */
134
+ parentContext: LayoutRuntimeContext;
135
+ }
136
+
137
+ /** Render one slot's content under a freshly composed child context. */
138
+ export function SlotContentRenderer({
139
+ node,
140
+ slot,
141
+ content,
142
+ parentContext,
143
+ }: SlotContentRendererProps) {
144
+ const engine = useLayoutEngine();
145
+ const childContext = React.useMemo(
146
+ () => composeLayoutContext({ parent: parentContext, node, slot, content }),
147
+ [parentContext, node, slot, content],
148
+ );
149
+ return (
150
+ <LayoutContextProvider value={childContext}>
151
+ {renderContent(content, childContext, engine)}
152
+ </LayoutContextProvider>
153
+ );
154
+ }
155
+
156
+ /* ------------------------------------------------------------------ *
157
+ * The recursive layout renderer.
158
+ * ------------------------------------------------------------------ */
159
+
160
+ export function LayoutRenderer({ node }: { node: LayoutNode }) {
161
+ const parentContext = useLayoutContext();
162
+ const engine = useLayoutEngine();
163
+ const item = engine.registry.get(node.type);
164
+
165
+ if (!item) return <UnknownLayout type={node.type} />;
166
+
167
+ const slots: Record<string, React.ReactNode> = {};
168
+ for (const slotDef of item.slots) {
169
+ const content = node.slots[slotDef.name] ?? slotDef.fallback?.empty;
170
+ if (content === undefined) continue;
171
+ slots[slotDef.name] = (
172
+ <SlotContentRenderer
173
+ node={node}
174
+ slot={slotDef}
175
+ content={content}
176
+ parentContext={parentContext}
177
+ />
178
+ );
179
+ }
180
+
181
+ const plan = resolvePhysicalPlan({
182
+ node,
183
+ item,
184
+ viewport: parentContext.viewport,
185
+ state: engine.stateStore.get(node.id),
186
+ });
187
+
188
+ const Renderer = item.renderer;
189
+ return (
190
+ <Renderer
191
+ node={node}
192
+ variant={node.variant}
193
+ props={{ ...item.defaultProps, ...node.props }}
194
+ slots={slots}
195
+ context={parentContext}
196
+ composition={node.composition ?? item.defaultComposition}
197
+ plan={plan}
198
+ />
199
+ );
200
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `LayoutRoot` — the top-level entry that mounts the layout engine.
3
+ *
4
+ * Wires the registry + resolvers + state store into an engine context, builds
5
+ * the root runtime context (optionally observing the live viewport), validates
6
+ * the tree once, and renders the recursive `LayoutRenderer`.
7
+ */
8
+ import * as React from "react";
9
+ import type {
10
+ LayoutDiagnostic,
11
+ LayoutNode,
12
+ LayoutViewport,
13
+ } from "../schema/types.js";
14
+ import { createRootContext, type RootContextInput } from "../inference/context.js";
15
+ import { LayoutRegistry } from "../registry/registry.js";
16
+ import { createDefaultLayoutRegistry } from "../registry/builtins.js";
17
+ import type { LayoutStateStore } from "../runtime/state.js";
18
+ import { useViewport } from "../runtime/viewport.js";
19
+ import { validateLayout } from "../schema/validate.js";
20
+ import {
21
+ LayoutContextProvider,
22
+ LayoutEngineProvider,
23
+ buildEngine,
24
+ type ComponentResolver,
25
+ type TemplateResolver,
26
+ } from "./context.js";
27
+ import { LayoutRenderer } from "./renderer.js";
28
+
29
+ export interface LayoutRootProps {
30
+ /** The layout AST to render. */
31
+ node: LayoutNode;
32
+ /** Resolves component names → React components (host-injected). */
33
+ componentResolver: ComponentResolver;
34
+ /** Resolves template names → layout nodes (host-injected). */
35
+ templateResolver?: TemplateResolver;
36
+ /** Layout registry; defaults to the MVP builtin registry. */
37
+ registry?: LayoutRegistry;
38
+ /** Root context overrides (surface/style/route/permissions/viewport). */
39
+ context?: RootContextInput;
40
+ /** Pluggable panel-state store. */
41
+ stateStore?: LayoutStateStore;
42
+ /** Observe the live viewport and feed it into the root context. Default true. */
43
+ observeViewport?: boolean;
44
+ /** Receives validation diagnostics whenever the tree changes. */
45
+ onDiagnostics?: (diagnostics: LayoutDiagnostic[]) => void;
46
+ }
47
+
48
+ export function LayoutRoot({
49
+ node,
50
+ componentResolver,
51
+ templateResolver,
52
+ registry,
53
+ context,
54
+ stateStore,
55
+ observeViewport = true,
56
+ onDiagnostics,
57
+ }: LayoutRootProps) {
58
+ const resolvedRegistry = React.useMemo(
59
+ () => registry ?? createDefaultLayoutRegistry(),
60
+ [registry],
61
+ );
62
+
63
+ const observed = useViewport({ initial: context?.viewport as LayoutViewport | undefined });
64
+ const viewport = observeViewport ? observed : (context?.viewport as LayoutViewport | undefined);
65
+
66
+ const rootContext = React.useMemo(
67
+ () => createRootContext({ ...context, viewport }),
68
+ [context, viewport],
69
+ );
70
+
71
+ const engine = React.useMemo(
72
+ () =>
73
+ buildEngine({
74
+ registry: resolvedRegistry,
75
+ componentResolver,
76
+ templateResolver,
77
+ stateStore,
78
+ onDiagnostics,
79
+ }),
80
+ [resolvedRegistry, componentResolver, templateResolver, stateStore, onDiagnostics],
81
+ );
82
+
83
+ React.useEffect(() => {
84
+ if (!onDiagnostics) return;
85
+ onDiagnostics(validateLayout(node, resolvedRegistry));
86
+ }, [node, resolvedRegistry, onDiagnostics]);
87
+
88
+ return (
89
+ <LayoutEngineProvider value={engine}>
90
+ <LayoutContextProvider value={rootContext}>
91
+ <LayoutRenderer node={node} />
92
+ </LayoutContextProvider>
93
+ </LayoutEngineProvider>
94
+ );
95
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Layout state persistence (`layout-spec.md` §11).
3
+ *
4
+ * A minimal pluggable store for per-layout panel state (collapse/pin/width/
5
+ * active tab/split ratio) + style preference. Default is an in-memory +
6
+ * localStorage-backed store; the host can inject its own (e.g. server-synced).
7
+ */
8
+ import type { PersistedLayoutState } from "../schema/types.js";
9
+
10
+ export interface LayoutStateStore {
11
+ get(layoutId: string): PersistedLayoutState | undefined;
12
+ set(layoutId: string, state: PersistedLayoutState): void;
13
+ patchPanel(
14
+ layoutId: string,
15
+ slot: string,
16
+ patch: Partial<PersistedLayoutState["panels"][string]>,
17
+ ): PersistedLayoutState;
18
+ }
19
+
20
+ function emptyState(layoutId: string): PersistedLayoutState {
21
+ return {
22
+ schemaVersion: 2,
23
+ layoutId,
24
+ panels: {},
25
+ updatedAt: "",
26
+ };
27
+ }
28
+
29
+ /** In-memory store; optionally mirrors to localStorage when available. */
30
+ export function createMemoryLayoutStateStore(opts?: {
31
+ persistKey?: string;
32
+ }): LayoutStateStore {
33
+ const cache = new Map<string, PersistedLayoutState>();
34
+ const persistKey = opts?.persistKey;
35
+
36
+ function load(): void {
37
+ if (!persistKey || typeof localStorage === "undefined") return;
38
+ try {
39
+ const raw = localStorage.getItem(persistKey);
40
+ if (!raw) return;
41
+ const parsed = JSON.parse(raw) as Record<string, PersistedLayoutState>;
42
+ for (const [id, st] of Object.entries(parsed)) cache.set(id, st);
43
+ } catch {
44
+ /* ignore corrupt state */
45
+ }
46
+ }
47
+
48
+ function flush(): void {
49
+ if (!persistKey || typeof localStorage === "undefined") return;
50
+ try {
51
+ localStorage.setItem(persistKey, JSON.stringify(Object.fromEntries(cache)));
52
+ } catch {
53
+ /* ignore quota errors */
54
+ }
55
+ }
56
+
57
+ load();
58
+
59
+ return {
60
+ get(layoutId) {
61
+ return cache.get(layoutId);
62
+ },
63
+ set(layoutId, state) {
64
+ cache.set(layoutId, state);
65
+ flush();
66
+ },
67
+ patchPanel(layoutId, slot, patch) {
68
+ const current = cache.get(layoutId) ?? emptyState(layoutId);
69
+ const next: PersistedLayoutState = {
70
+ ...current,
71
+ panels: { ...current.panels, [slot]: { ...current.panels[slot], ...patch } },
72
+ // `updatedAt` is set by the caller environment; left as a marker here.
73
+ updatedAt: current.updatedAt || "pending",
74
+ };
75
+ cache.set(layoutId, next);
76
+ flush();
77
+ return next;
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Viewport observation (`layout-spec.md` §10.1, §17.5).
3
+ *
4
+ * A throttled hook that maps `window` size to a `LayoutViewport` (breakpoint,
5
+ * pointer, color scheme). SSR-safe: returns the provided default until mounted.
6
+ */
7
+ import * as React from "react";
8
+ import type { Breakpoint, LayoutViewport } from "../schema/types.js";
9
+ import { defaultViewport } from "../inference/context.js";
10
+
11
+ /** rem-based breakpoint thresholds mirrored from `ethBreakpoints` (px @16). */
12
+ const BREAKPOINTS: Array<{ bp: Breakpoint; minWidth: number }> = [
13
+ { bp: "ultra", minWidth: 1920 },
14
+ { bp: "wide", minWidth: 1536 },
15
+ { bp: "desktop", minWidth: 1280 },
16
+ { bp: "laptop", minWidth: 1024 },
17
+ { bp: "tablet", minWidth: 768 },
18
+ { bp: "mobile", minWidth: 0 },
19
+ ];
20
+
21
+ export function breakpointForWidth(width: number): Breakpoint {
22
+ for (const { bp, minWidth } of BREAKPOINTS) {
23
+ if (width >= minWidth) return bp;
24
+ }
25
+ return "mobile";
26
+ }
27
+
28
+ function readViewport(): LayoutViewport {
29
+ if (typeof window === "undefined") return defaultViewport;
30
+ const width = window.innerWidth;
31
+ const height = window.innerHeight;
32
+ const pointer =
33
+ typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches
34
+ ? "coarse"
35
+ : "fine";
36
+ const colorScheme =
37
+ typeof window.matchMedia === "function" &&
38
+ window.matchMedia("(prefers-color-scheme: dark)").matches
39
+ ? "dark"
40
+ : "light";
41
+ return { breakpoint: breakpointForWidth(width), width, height, pointer, colorScheme };
42
+ }
43
+
44
+ export interface UseViewportOptions {
45
+ /** Initial value used before mount / on the server. */
46
+ initial?: LayoutViewport;
47
+ /** Resize debounce in ms. */
48
+ debounceMs?: number;
49
+ }
50
+
51
+ export function useViewport(opts: UseViewportOptions = {}): LayoutViewport {
52
+ const { initial = defaultViewport, debounceMs = 120 } = opts;
53
+ const [viewport, setViewport] = React.useState<LayoutViewport>(initial);
54
+
55
+ React.useEffect(() => {
56
+ if (typeof window === "undefined") return undefined;
57
+ setViewport(readViewport());
58
+ let timer: ReturnType<typeof setTimeout> | undefined;
59
+ const onResize = () => {
60
+ if (timer) clearTimeout(timer);
61
+ timer = setTimeout(() => setViewport(readViewport()), debounceMs);
62
+ };
63
+ window.addEventListener("resize", onResize);
64
+ return () => {
65
+ if (timer) clearTimeout(timer);
66
+ window.removeEventListener("resize", onResize);
67
+ };
68
+ }, [debounceMs]);
69
+
70
+ return viewport;
71
+ }