@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,168 @@
1
+ /**
2
+ * Layout validation (`layout-spec.md` §16, §20).
3
+ *
4
+ * Hand-written structural + semantic validation that walks a `LayoutNode`
5
+ * against a registry and emits `LayoutDiagnostic[]`. (zod can be layered on top
6
+ * later for untrusted JSON; kept dependency-free here.)
7
+ */
8
+ import type {
9
+ LayoutDiagnostic,
10
+ LayoutNode,
11
+ NodeAcceptSpec,
12
+ SlotContent,
13
+ SlotDefinition,
14
+ } from "./types.js";
15
+ import type { LayoutRegistry } from "../registry/registry.js";
16
+
17
+ const DEFAULT_MAX_DEPTH = 8;
18
+
19
+ function acceptsContent(accepts: NodeAcceptSpec[], content: SlotContent): boolean {
20
+ return accepts.some((spec) => {
21
+ switch (content.kind) {
22
+ case "component":
23
+ return (
24
+ spec.kind === "component" &&
25
+ (!spec.componentTypes || spec.componentTypes.includes(content.component))
26
+ );
27
+ case "layout":
28
+ return (
29
+ spec.kind === "layout" &&
30
+ (!spec.layoutTypes || spec.layoutTypes.includes(content.layout.type))
31
+ );
32
+ case "template":
33
+ return (
34
+ spec.kind === "template" &&
35
+ (!spec.templateTypes || spec.templateTypes.includes(content.template))
36
+ );
37
+ case "fragment":
38
+ return spec.kind === "fragment";
39
+ case "empty":
40
+ return spec.kind === "empty";
41
+ default:
42
+ return false;
43
+ }
44
+ });
45
+ }
46
+
47
+ function* childLayouts(content: SlotContent): Generator<LayoutNode> {
48
+ if (content.kind === "layout") {
49
+ yield content.layout;
50
+ } else if (content.kind === "fragment") {
51
+ for (const item of content.items) yield* childLayouts(item);
52
+ }
53
+ }
54
+
55
+ export interface ValidateOptions {
56
+ maxDepth?: number;
57
+ }
58
+
59
+ /** Validate a layout tree, returning diagnostics (empty ⇒ valid). */
60
+ export function validateLayout(
61
+ root: LayoutNode,
62
+ registry: LayoutRegistry,
63
+ opts: ValidateOptions = {},
64
+ ): LayoutDiagnostic[] {
65
+ const diagnostics: LayoutDiagnostic[] = [];
66
+ const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
67
+ const visiting = new Set<string>();
68
+
69
+ function walk(node: LayoutNode, path: string[], depth: number): void {
70
+ const nodePath = [...path, node.id];
71
+
72
+ if (depth > maxDepth) {
73
+ diagnostics.push({
74
+ level: "error",
75
+ code: "MAX_DEPTH_EXCEEDED",
76
+ path: nodePath,
77
+ message: `Layout nesting exceeds maxDepth=${maxDepth}.`,
78
+ });
79
+ return;
80
+ }
81
+
82
+ if (visiting.has(node.id)) {
83
+ diagnostics.push({
84
+ level: "error",
85
+ code: "CYCLE_DETECTED",
86
+ path: nodePath,
87
+ message: `Cycle detected at node "${node.id}".`,
88
+ });
89
+ return;
90
+ }
91
+ visiting.add(node.id);
92
+
93
+ const item = registry.get(node.type);
94
+ if (!item) {
95
+ diagnostics.push({
96
+ level: "error",
97
+ code: "UNKNOWN_LAYOUT_TYPE",
98
+ path: nodePath,
99
+ message: `Unknown layout type "${node.type}".`,
100
+ suggestion: `Register "${node.type}" or use one of: ${registry.types().slice(0, 8).join(", ")}…`,
101
+ });
102
+ visiting.delete(node.id);
103
+ return;
104
+ }
105
+
106
+ const slotByName = new Map<string, SlotDefinition>(
107
+ item.slots.map((s) => [s.name, s]),
108
+ );
109
+
110
+ // Unknown slots present on the node.
111
+ for (const slotName of Object.keys(node.slots)) {
112
+ if (!slotByName.has(slotName)) {
113
+ diagnostics.push({
114
+ level: "warning",
115
+ code: "UNKNOWN_SLOT",
116
+ path: [...nodePath, slotName],
117
+ message: `Slot "${slotName}" is not declared by "${node.type}".`,
118
+ });
119
+ }
120
+ }
121
+
122
+ // Required slots + accepts + cardinality.
123
+ for (const slot of item.slots) {
124
+ const content = node.slots[slot.name];
125
+ if (content === undefined) {
126
+ if (slot.required && !slot.fallback?.empty) {
127
+ diagnostics.push({
128
+ level: "error",
129
+ code: "SLOT_REQUIRED",
130
+ path: [...nodePath, slot.name],
131
+ message: `Required slot "${slot.name}" of "${node.type}" has no content or fallback.`,
132
+ });
133
+ }
134
+ continue;
135
+ }
136
+ if (!acceptsContent(slot.accepts, content)) {
137
+ diagnostics.push({
138
+ level: "error",
139
+ code: "SLOT_ACCEPTS_VIOLATION",
140
+ path: [...nodePath, slot.name],
141
+ message: `Slot "${slot.name}" of "${node.type}" does not accept ${content.kind} content.`,
142
+ });
143
+ }
144
+ if (content.kind === "fragment" && slot.cardinality?.max && slot.cardinality.max !== "unbounded") {
145
+ if (content.items.length > slot.cardinality.max) {
146
+ diagnostics.push({
147
+ level: "error",
148
+ code: "SLOT_CARDINALITY_VIOLATION",
149
+ path: [...nodePath, slot.name],
150
+ message: `Slot "${slot.name}" allows at most ${slot.cardinality.max} items.`,
151
+ });
152
+ }
153
+ }
154
+ for (const child of childLayouts(content)) {
155
+ walk(child, [...nodePath, slot.name], depth + 1);
156
+ }
157
+ }
158
+
159
+ visiting.delete(node.id);
160
+ }
161
+
162
+ walk(root, [], 0);
163
+ return diagnostics;
164
+ }
165
+
166
+ export function hasErrors(diagnostics: LayoutDiagnostic[]): boolean {
167
+ return diagnostics.some((d) => d.level === "error");
168
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Preset → Tier-2 token map (`layout-spec.md` §13.2).
3
+ *
4
+ * Per TOKEN_CONTRACT.md every value is a Tier-2 `--eth-*` CSS variable; presets
5
+ * override Tier-2, layout never reads raw `--cds-*`. These resolve through the
6
+ * `@echothink-ui/style` preset stylesheets that are already on the page.
7
+ */
8
+ import type {
9
+ ComponentStyleParams,
10
+ ResolvedLayoutTokens,
11
+ StylePresetTokenMap,
12
+ } from "../schema/types.js";
13
+
14
+ export const presetTokenMap: StylePresetTokenMap = {
15
+ "carbon-like": {
16
+ surface: "var(--eth-surface, var(--eth-color-surface))",
17
+ surfaceMuted: "var(--eth-surface-muted, var(--eth-color-surface-muted))",
18
+ border: "var(--eth-border-subtle, var(--eth-color-border))",
19
+ text: "var(--eth-text-primary, var(--eth-color-text))",
20
+ textMuted: "var(--eth-text-secondary, var(--eth-color-text-secondary))",
21
+ accent: "var(--eth-accent, var(--eth-color-accent))",
22
+ radius: "var(--eth-radius-sm, 0.125rem)",
23
+ shadow: "var(--eth-shadow-none, none)",
24
+ },
25
+ "soft-card": {
26
+ surface: "var(--eth-surface, var(--eth-color-surface))",
27
+ surfaceMuted: "var(--eth-surface-muted, var(--eth-color-surface-muted))",
28
+ border: "var(--eth-border-soft, var(--eth-color-border))",
29
+ text: "var(--eth-text-primary, var(--eth-color-text))",
30
+ textMuted: "var(--eth-text-secondary, var(--eth-color-text-secondary))",
31
+ accent: "var(--eth-accent, var(--eth-color-accent))",
32
+ radius: "var(--eth-radius-lg, 0.75rem)",
33
+ shadow: "var(--eth-shadow-card, 0 1px 2px rgba(15,23,42,0.08))",
34
+ },
35
+ glass: {
36
+ surface: "var(--eth-surface-glass, var(--eth-color-surface))",
37
+ surfaceMuted: "var(--eth-surface-glass-muted, var(--eth-color-surface-muted))",
38
+ border: "var(--eth-border-glass, var(--eth-color-border))",
39
+ text: "var(--eth-text-on-glass, var(--eth-color-text))",
40
+ textMuted: "var(--eth-text-on-glass-muted, var(--eth-color-text-secondary))",
41
+ accent: "var(--eth-accent, var(--eth-color-accent))",
42
+ radius: "var(--eth-radius-xl, 1rem)",
43
+ shadow: "var(--eth-shadow-floating, 0 8px 32px rgba(15,23,42,0.18))",
44
+ backdrop: "blur(16px)",
45
+ },
46
+ bright: {
47
+ surface: "var(--eth-surface, var(--eth-color-surface))",
48
+ surfaceMuted: "var(--eth-surface-muted, var(--eth-color-surface-muted))",
49
+ border: "var(--eth-border-bright, var(--eth-color-border))",
50
+ text: "var(--eth-text-primary, var(--eth-color-text))",
51
+ textMuted: "var(--eth-text-secondary, var(--eth-color-text-secondary))",
52
+ accent: "var(--eth-accent, var(--eth-color-accent))",
53
+ radius: "var(--eth-radius-md, 0.5rem)",
54
+ shadow: "var(--eth-shadow-sm, 0 1px 2px rgba(15,23,42,0.06))",
55
+ },
56
+ "studio-dark": {
57
+ surface: "var(--eth-surface-studio, var(--eth-color-surface))",
58
+ surfaceMuted: "var(--eth-surface-studio-muted, var(--eth-color-surface-muted))",
59
+ border: "var(--eth-border-studio, var(--eth-color-border))",
60
+ text: "var(--eth-text-on-dark, var(--eth-color-text))",
61
+ textMuted: "var(--eth-text-on-dark-muted, var(--eth-color-text-secondary))",
62
+ accent: "var(--eth-accent, var(--eth-color-accent))",
63
+ radius: "var(--eth-radius-sm, 0.125rem)",
64
+ shadow: "var(--eth-shadow-none, none)",
65
+ },
66
+ };
67
+
68
+ const densityGap: Record<ComponentStyleParams["density"], string> = {
69
+ compact: "var(--eth-space-sm, 0.5rem)",
70
+ standard: "var(--eth-space-md, 0.75rem)",
71
+ comfortable: "var(--eth-space-lg, 1rem)",
72
+ };
73
+
74
+ /** Resolve the token set for a final `ComponentStyleParams`. */
75
+ export function resolveTokens(style: ComponentStyleParams): ResolvedLayoutTokens {
76
+ return { ...presetTokenMap[style.preset], gap: densityGap[style.density] };
77
+ }
package/src/styles.css ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * @echothink-ui/layout — layout system styles.
3
+ *
4
+ * Structural CSS for the recursive renderer's primitives/regions/page-layouts.
5
+ * Colors/spacing/radius come from Tier-2 `--eth-*` tokens (TOKEN_CONTRACT.md);
6
+ * preset stylesheets (`@echothink-ui/style`) supply the values.
7
+ */
8
+
9
+ :where(.eth-ls-unknown) {
10
+ padding: var(--eth-space-md, 0.75rem);
11
+ border: 1px dashed var(--eth-color-border, #cbd5e1);
12
+ border-radius: var(--eth-radius-sm, 0.25rem);
13
+ color: var(--eth-color-text-secondary, #64748b);
14
+ font: var(--eth-type-body-sm, 0.8125rem/1.4 system-ui, sans-serif);
15
+ }
16
+ :where(.eth-ls-empty) {
17
+ min-height: 1.5rem;
18
+ }
19
+ :where(.eth-ls-component) {
20
+ display: contents;
21
+ }
22
+ :where(.eth-ls-fragment) {
23
+ min-width: 0;
24
+ }
25
+
26
+ /* ----------------------------- Panel ----------------------------- */
27
+ :where(.eth-ls-panel) {
28
+ display: flex;
29
+ flex-direction: column;
30
+ min-height: 0;
31
+ overflow: hidden;
32
+ }
33
+ :where(.eth-ls-panel__header) {
34
+ padding: var(--eth-space-sm, 0.5rem) var(--eth-space-md, 0.75rem);
35
+ border-bottom: 1px solid var(--eth-color-border, #e2e8f0);
36
+ font-weight: 600;
37
+ }
38
+ :where(.eth-ls-panel__body) {
39
+ padding: var(--eth-space-md, 0.75rem);
40
+ overflow: auto;
41
+ flex: 1;
42
+ min-height: 0;
43
+ }
44
+ :where(.eth-ls-panel__footer) {
45
+ padding: var(--eth-space-sm, 0.5rem) var(--eth-space-md, 0.75rem);
46
+ border-top: 1px solid var(--eth-color-border, #e2e8f0);
47
+ }
48
+
49
+ /* --------------------------- SplitPane --------------------------- */
50
+ :where(.eth-ls-split) {
51
+ width: 100%;
52
+ }
53
+ :where(.eth-ls-split__pane) {
54
+ overflow: auto;
55
+ }
56
+
57
+ /* ----------------------------- Tabs ------------------------------ */
58
+ :where(.eth-ls-tabs) {
59
+ display: flex;
60
+ flex-direction: column;
61
+ min-height: 0;
62
+ }
63
+ :where(.eth-ls-tabs__list) {
64
+ display: flex;
65
+ gap: var(--eth-space-xs, 0.25rem);
66
+ border-bottom: 1px solid var(--eth-color-border, #e2e8f0);
67
+ }
68
+ :where(.eth-ls-tabs__tab) {
69
+ appearance: none;
70
+ background: transparent;
71
+ border: 0;
72
+ border-bottom: 2px solid transparent;
73
+ padding: var(--eth-space-sm, 0.5rem) var(--eth-space-md, 0.75rem);
74
+ color: var(--eth-color-text-secondary, #64748b);
75
+ cursor: pointer;
76
+ font: inherit;
77
+ }
78
+ :where(.eth-ls-tabs__tab--active) {
79
+ color: var(--eth-color-text, #0f172a);
80
+ border-bottom-color: var(--eth-color-accent, #2563eb);
81
+ }
82
+ :where(.eth-ls-tabs__panel) {
83
+ padding: var(--eth-space-md, 0.75rem) 0;
84
+ min-height: 0;
85
+ }
86
+
87
+ /* ---------------------------- Stepper ---------------------------- */
88
+ :where(.eth-ls-stepper__steps) {
89
+ list-style: none;
90
+ margin: 0;
91
+ padding: 0;
92
+ display: flex;
93
+ gap: var(--eth-space-md, 0.75rem);
94
+ }
95
+ :where(.eth-ls-stepper--vertical) .eth-ls-stepper__steps {
96
+ flex-direction: column;
97
+ }
98
+ :where(.eth-ls-stepper__step-button) {
99
+ display: inline-flex;
100
+ align-items: center;
101
+ gap: var(--eth-space-sm, 0.5rem);
102
+ appearance: none;
103
+ background: transparent;
104
+ border: 0;
105
+ cursor: pointer;
106
+ color: var(--eth-color-text-secondary, #64748b);
107
+ font: inherit;
108
+ }
109
+ :where(.eth-ls-stepper__step--current) .eth-ls-stepper__step-button,
110
+ :where(.eth-ls-stepper__step--active) .eth-ls-stepper__step-button {
111
+ color: var(--eth-color-text, #0f172a);
112
+ font-weight: 600;
113
+ }
114
+ :where(.eth-ls-stepper__index) {
115
+ display: inline-grid;
116
+ place-items: center;
117
+ width: 1.5rem;
118
+ height: 1.5rem;
119
+ border-radius: 999px;
120
+ border: 1px solid var(--eth-color-border, #cbd5e1);
121
+ }
122
+ :where(.eth-ls-stepper__step--valid) .eth-ls-stepper__index,
123
+ :where(.eth-ls-stepper__step--complete) .eth-ls-stepper__index {
124
+ background: var(--eth-color-success, #16a34a);
125
+ color: #fff;
126
+ border-color: transparent;
127
+ }
128
+ :where(.eth-ls-stepper__step--invalid) .eth-ls-stepper__index {
129
+ background: var(--eth-color-danger, #dc2626);
130
+ color: #fff;
131
+ border-color: transparent;
132
+ }
133
+ :where(.eth-ls-stepper__content) {
134
+ flex: 1;
135
+ min-width: 0;
136
+ }
137
+
138
+ /* ---------------------------- Toolbar ---------------------------- */
139
+ :where(.eth-ls-toolbar) {
140
+ padding: var(--eth-space-sm, 0.5rem) var(--eth-space-md, 0.75rem);
141
+ min-height: 3rem;
142
+ }
143
+
144
+ /* --------------------------- Inspector --------------------------- */
145
+ :where(.eth-ls-inspector__header) {
146
+ padding: var(--eth-space-sm, 0.5rem) var(--eth-space-md, 0.75rem);
147
+ border-bottom: 1px solid var(--eth-color-border, #e2e8f0);
148
+ font-weight: 600;
149
+ }
150
+ :where(.eth-ls-inspector__body) {
151
+ padding: var(--eth-space-md, 0.75rem);
152
+ }
153
+ :where(.eth-ls-inspector__footer) {
154
+ padding: var(--eth-space-sm, 0.5rem) var(--eth-space-md, 0.75rem);
155
+ border-top: 1px solid var(--eth-color-border, #e2e8f0);
156
+ }
157
+
158
+ /* ------------------------- Page layouts -------------------------- */
159
+ :where(
160
+ .eth-ls-admin-shell,
161
+ .eth-ls-datagrid,
162
+ .eth-ls-workbench,
163
+ .eth-ls-wizard,
164
+ .eth-ls-monitoring,
165
+ .eth-ls-canvas
166
+ ) {
167
+ width: 100%;
168
+ }
169
+ :where(.eth-ls-monitoring__metrics) {
170
+ padding: var(--eth-space-md, 0.75rem);
171
+ }
172
+
173
+ /* Narrow screens: collapse multi-pane page grids to a single column. */
174
+ @media (max-width: 48rem) {
175
+ :where(.eth-ls-datagrid, .eth-ls-workbench, .eth-ls-wizard, .eth-ls-canvas) {
176
+ grid-template-columns: 1fr !important;
177
+ }
178
+ }