@changfenhuang/dsh-genui 0.9.1

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 (106) hide show
  1. package/CHANGELOG.md +367 -0
  2. package/LICENSE +21 -0
  3. package/README.md +261 -0
  4. package/README.zh-CN.md +261 -0
  5. package/SKILL.md +115 -0
  6. package/cordis.patch.yml +9 -0
  7. package/demo-prompts.md +118 -0
  8. package/lib/assets/echarts.js +64 -0
  9. package/lib/assets/mermaid.js +3591 -0
  10. package/lib/assets/three.js +3844 -0
  11. package/lib/client.js +5 -0
  12. package/lib/index.js +1895 -0
  13. package/lib/invariant.js +19 -0
  14. package/lib/types/client/EChartNode.d.ts +4 -0
  15. package/lib/types/client/ErrorBoundary.d.ts +30 -0
  16. package/lib/types/client/GenuiBlock.d.ts +7 -0
  17. package/lib/types/client/PlotBlock.d.ts +44 -0
  18. package/lib/types/client/action-context.d.ts +7 -0
  19. package/lib/types/client/asset-echarts.d.ts +15 -0
  20. package/lib/types/client/asset-loader.d.ts +31 -0
  21. package/lib/types/client/asset-mermaid.d.ts +1 -0
  22. package/lib/types/client/asset-three.d.ts +1 -0
  23. package/lib/types/client/blocks/advanced.d.ts +104 -0
  24. package/lib/types/client/blocks/basic.d.ts +27 -0
  25. package/lib/types/client/blocks/charts.d.ts +38 -0
  26. package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
  27. package/lib/types/client/blocks/diagram/index.d.ts +5 -0
  28. package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
  29. package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
  30. package/lib/types/client/blocks/forms.d.ts +83 -0
  31. package/lib/types/client/blocks/render-node.d.ts +10 -0
  32. package/lib/types/client/blocks/state.d.ts +58 -0
  33. package/lib/types/client/dom-fence.d.ts +14 -0
  34. package/lib/types/client/echarts-lazy.d.ts +17 -0
  35. package/lib/types/client/fence-render.d.ts +58 -0
  36. package/lib/types/client/gallery.d.ts +9 -0
  37. package/lib/types/client/guard.d.ts +126 -0
  38. package/lib/types/client/index.d.ts +36 -0
  39. package/lib/types/client/interaction-store.d.ts +43 -0
  40. package/lib/types/client/mermaid-core.d.ts +8 -0
  41. package/lib/types/client/mermaid-lazy.d.ts +9 -0
  42. package/lib/types/client/mermaid-safe.d.ts +52 -0
  43. package/lib/types/client/panel-command.d.ts +29 -0
  44. package/lib/types/client/panel-store.d.ts +94 -0
  45. package/lib/types/client/panel.d.ts +15 -0
  46. package/lib/types/client/parse-partial.d.ts +37 -0
  47. package/lib/types/client/safe-math.d.ts +43 -0
  48. package/lib/types/client/scene3d-core.d.ts +21 -0
  49. package/lib/types/client/scene3d-lazy.d.ts +8 -0
  50. package/lib/types/client/spec.d.ts +610 -0
  51. package/lib/types/client/toolview.d.ts +7 -0
  52. package/lib/types/index.d.ts +1 -0
  53. package/lib/types/plugin/index.d.ts +25 -0
  54. package/lib/types/plugin/invariant.d.ts +15 -0
  55. package/lib/types/plugin/tool.d.ts +27 -0
  56. package/lib/types/shared/fence-repair.d.ts +67 -0
  57. package/package.json +121 -0
  58. package/src/client/EChartNode.tsx +242 -0
  59. package/src/client/ErrorBoundary.tsx +66 -0
  60. package/src/client/GenuiBlock.module.css +1263 -0
  61. package/src/client/GenuiBlock.tsx +166 -0
  62. package/src/client/PlotBlock.module.css +155 -0
  63. package/src/client/PlotBlock.tsx +431 -0
  64. package/src/client/action-context.ts +32 -0
  65. package/src/client/asset-echarts.ts +25 -0
  66. package/src/client/asset-loader.ts +79 -0
  67. package/src/client/asset-mermaid.ts +13 -0
  68. package/src/client/asset-three.ts +12 -0
  69. package/src/client/blocks/advanced.tsx +435 -0
  70. package/src/client/blocks/basic.tsx +116 -0
  71. package/src/client/blocks/charts.tsx +319 -0
  72. package/src/client/blocks/diagram/geometry.ts +151 -0
  73. package/src/client/blocks/diagram/index.tsx +284 -0
  74. package/src/client/blocks/diagram/layout.ts +199 -0
  75. package/src/client/blocks/diagram/theme.ts +108 -0
  76. package/src/client/blocks/forms.tsx +523 -0
  77. package/src/client/blocks/render-node.tsx +260 -0
  78. package/src/client/blocks/state.ts +61 -0
  79. package/src/client/dom-fence.tsx +641 -0
  80. package/src/client/echarts-lazy.ts +35 -0
  81. package/src/client/fence-render.tsx +230 -0
  82. package/src/client/gallery.ts +128 -0
  83. package/src/client/guard.ts +1450 -0
  84. package/src/client/index.tsx +195 -0
  85. package/src/client/interaction-store.ts +122 -0
  86. package/src/client/mermaid-core.ts +121 -0
  87. package/src/client/mermaid-lazy.ts +31 -0
  88. package/src/client/mermaid-safe.ts +122 -0
  89. package/src/client/panel-command.ts +119 -0
  90. package/src/client/panel-store.ts +471 -0
  91. package/src/client/panel.tsx +185 -0
  92. package/src/client/parse-partial.ts +162 -0
  93. package/src/client/safe-math.ts +270 -0
  94. package/src/client/scene3d-core.ts +192 -0
  95. package/src/client/scene3d-lazy.ts +26 -0
  96. package/src/client/spec.ts +767 -0
  97. package/src/client/toolview.tsx +66 -0
  98. package/src/css-modules.d.ts +5 -0
  99. package/src/index.ts +12 -0
  100. package/src/plugin/index.ts +173 -0
  101. package/src/plugin/invariant.ts +30 -0
  102. package/src/plugin/node-shims.d.ts +31 -0
  103. package/src/plugin/tool.ts +330 -0
  104. package/src/shared/fence-repair.ts +244 -0
  105. package/tsconfig.json +35 -0
  106. package/tsdown.config.ts +193 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Editorial diagram renderer: `diagram` node → inline SVG. The browser-side
3
+ * port of the diagram-design editorial design system — semantic tokens,
4
+ * dotted-paper ground, orthogonal connectors, 4px grid, complexity budget,
5
+ * zone grouping, legend strip — as a white-listed dsh-genui component.
6
+ *
7
+ * Layout is resolved by `resolveLayout`, routing by `routeEdge`, and every
8
+ * value is sanitized by the guard before this component ever sees it.
9
+ * @module @changfenhuang/dsh-genui/client/blocks/diagram
10
+ */
11
+ import { useMemo } from 'react'
12
+ import type { GenuiDiagram } from '../../spec.ts'
13
+ import { GENUI_LIMITS } from '../../guard.ts'
14
+ import { resolveLayout } from './layout.ts'
15
+ import { routeEdge, labelGeometry, type Box } from './geometry.ts'
16
+ import { resolvePalette, nodeTreatment, edgeStroke, inkAt } from './theme.ts'
17
+
18
+ const FONT_SANS = "'Geist', -apple-system, 'Segoe UI', sans-serif"
19
+ const FONT_MONO = "'Geist Mono', ui-monospace, 'SF Mono', monospace"
20
+
21
+ /** Editorial constants (diagram-design §5–7). */
22
+ const NODE_H = 64 // node box height (48→64 for the editorial ramp)
23
+ const LEGEND_H = 56
24
+
25
+ /**
26
+ * Compute a deterministic parent map for tree-like kinds: the first edge
27
+ * into each node is its parent (a stable convention the model can rely on).
28
+ */
29
+ function buildParentMap(edges: Array<{ from: string; to: string }>): (id: string) => string | undefined {
30
+ const parents = new Map<string, string>()
31
+ for (const e of edges) {
32
+ if (!parents.has(e.to)) parents.set(e.to, e.from)
33
+ }
34
+ return id => parents.get(id)
35
+ }
36
+
37
+ /** Render one node box + its text content (tag / name / sublabel / index). */
38
+ function renderNodeBox(
39
+ box: Box,
40
+ label: string,
41
+ sub: string | undefined,
42
+ type: string | undefined,
43
+ tag: string | undefined,
44
+ index: number,
45
+ palette: ReturnType<typeof resolvePalette>,
46
+ ): React.ReactNode {
47
+ const treatment = nodeTreatment(type, palette)
48
+ const cx = box.x + box.w / 2
49
+ // Editorial 64px ramp: name centered, sublabel below, tag top-left, index bottom-right.
50
+ const nameY = box.y + (box.h - NODE_H) / 2 + 28
51
+ const subY = box.y + (box.h - NODE_H) / 2 + 44
52
+ const tagW = Math.min(44, Math.max(24, (tag ?? '').length * 7 + 8))
53
+ return (
54
+ <g>
55
+ {/* opaque paper mask: arrows must not bleed through transparent fills */}
56
+ <rect x={box.x} y={box.y} width={box.w} height={box.h} rx={6} fill={palette.paper} />
57
+ <rect
58
+ x={box.x}
59
+ y={box.y}
60
+ width={box.w}
61
+ height={box.h}
62
+ rx={6}
63
+ fill={treatment.fill}
64
+ stroke={treatment.stroke}
65
+ strokeWidth={1}
66
+ strokeDasharray={treatment.dashed === true ? '5,4' : undefined}
67
+ />
68
+ {tag !== undefined && (
69
+ <>
70
+ <rect x={box.x + 8} y={box.y + 6} width={tagW} height={12} rx={2} fill="transparent" stroke={inkAt(treatment.stroke, 0.4)} strokeWidth={0.8} />
71
+ <text x={box.x + 8 + tagW / 2} y={box.y + 15} fill={inkAt(treatment.stroke, 0.8)} fontSize={7} fontFamily={FONT_MONO} textAnchor="middle" letterSpacing={0.8}>{tag}</text>
72
+ </>
73
+ )}
74
+ {/* Index number: bottom-right, half-transparent, editorial big numeral */}
75
+ <text x={box.x + box.w - 6} y={box.y + box.h - 6} fill={inkAt(treatment.stroke, 0.12)} fontSize={24} fontWeight={600} fontFamily={FONT_MONO} textAnchor="end">{String(index).padStart(2, '0')}</text>
76
+ <text x={cx} y={nameY} fill={palette.ink} fontSize={12} fontWeight={600} fontFamily={FONT_SANS} textAnchor="middle">{label}</text>
77
+ {sub !== undefined && (
78
+ <text x={cx} y={subY} fill={palette.soft} fontSize={9} fontFamily={FONT_MONO} textAnchor="middle">{sub}</text>
79
+ )}
80
+ </g>
81
+ )
82
+ }
83
+
84
+ /** Render one routed edge: path, optional label mask + text. */
85
+ function renderEdge(edge: { from: Box; to: Box; label?: string | undefined; kind?: string | undefined }, key: number, palette: ReturnType<typeof resolvePalette>, markerId: string): React.ReactNode {
86
+ const routed = routeEdge(edge.from, edge.to)
87
+ const stroke = edgeStroke(edge.kind, palette)
88
+ const dashed = edge.kind === 'dashed'
89
+ // Accent arrows are the focal signal: slightly heavier stroke (1.4).
90
+ const width = edge.kind === 'accent' ? 1.4 : 1.2
91
+ const lg = labelGeometry(routed.a, routed.b)
92
+ return (
93
+ <g key={key}>
94
+ <path
95
+ d={routed.d}
96
+ fill="none"
97
+ stroke={stroke}
98
+ strokeWidth={width}
99
+ strokeDasharray={dashed ? '5,4' : undefined}
100
+ markerEnd={`url(#${markerId})`}
101
+ />
102
+ {edge.label !== undefined && (
103
+ <g>
104
+ <rect
105
+ x={lg.cx - (edge.label.length * 5 + 8) / 2}
106
+ y={lg.maskY}
107
+ width={edge.label.length * 5 + 8}
108
+ height={12}
109
+ rx={2}
110
+ fill={palette.paper}
111
+ />
112
+ <text x={lg.cx} y={lg.maskY + 9} fill={palette.soft} fontSize={8} fontFamily={FONT_MONO} textAnchor="middle" letterSpacing={0.5}>{edge.label}</text>
113
+ </g>
114
+ )}
115
+ </g>
116
+ )
117
+ }
118
+
119
+ /** Arrow marker defs (default / accent / link), per-diagram unique ids. */
120
+ function renderMarkers(uid: string, palette: ReturnType<typeof resolvePalette>): React.ReactNode {
121
+ return (
122
+ <defs>
123
+ <marker id={`${uid}-arrow`} markerWidth={8} markerHeight={6} refX={7} refY={3} orient="auto">
124
+ <polygon points="0 0, 8 3, 0 6" fill={palette.muted} />
125
+ </marker>
126
+ <marker id={`${uid}-arrow-accent`} markerWidth={8} markerHeight={6} refX={7} refY={3} orient="auto">
127
+ <polygon points="0 0, 8 3, 0 6" fill={palette.accent} />
128
+ </marker>
129
+ <marker id={`${uid}-arrow-link`} markerWidth={8} markerHeight={6} refX={7} refY={3} orient="auto">
130
+ <polygon points="0 0, 8 3, 0 6" fill={palette.link} />
131
+ </marker>
132
+ </defs>
133
+ )
134
+ }
135
+
136
+ /** Dotted-paper ground: the editorial backdrop behind every diagram. */
137
+ function renderGround(uid: string, palette: ReturnType<typeof resolvePalette>): React.ReactNode {
138
+ return (
139
+ <>
140
+ <defs>
141
+ <pattern id={`${uid}-dots`} width={22} height={22} patternUnits="userSpaceOnUse">
142
+ <circle cx={1} cy={1} r={0.9} fill={inkAt(palette.ink, 0.10)} />
143
+ </pattern>
144
+ </defs>
145
+ <rect width="100%" height="100%" fill={palette.paper} />
146
+ <rect width="100%" height="100%" fill={`url(#${uid}-dots)`} opacity={0.55} />
147
+ </>
148
+ )
149
+ }
150
+
151
+ /** Zone container: hairline dashed rect + mono uppercase eyebrow label. */
152
+ function renderZone(z: { label: string; x: number; y: number; w: number; h: number }, palette: ReturnType<typeof resolvePalette>): React.ReactNode {
153
+ const labelW = z.label.length * 7 + 12
154
+ const cx = z.x + z.w / 2
155
+ return (
156
+ <g>
157
+ <rect x={z.x} y={z.y} width={z.w} height={z.h} rx={8} fill={inkAt(palette.ink, 0.02)} stroke={inkAt(palette.ink, 0.10)} strokeWidth={0.8} />
158
+ <rect x={cx - labelW / 2} y={z.y + 4} width={labelW} height={12} rx={2} fill={palette.paper} />
159
+ <text x={cx} y={z.y + 13} fill={inkAt(palette.ink, 0.40)} fontSize={7} fontFamily={FONT_MONO} textAnchor="middle" letterSpacing={1.2}>{z.label}</text>
160
+ </g>
161
+ )
162
+ }
163
+
164
+ /** Legend strip at the bottom: hairline separator + treatment swatches. */
165
+ function renderLegend(palette: ReturnType<typeof resolvePalette>): React.ReactNode {
166
+ const items: Array<{ label: string; type: string | undefined }> = [
167
+ { label: 'Focal', type: 'focal' },
168
+ { label: 'Backend', type: 'backend' },
169
+ { label: 'Store', type: 'store' },
170
+ { label: 'External', type: 'external' },
171
+ { label: 'Optional', type: 'optional' },
172
+ { label: 'Security', type: 'security' },
173
+ ]
174
+ return (
175
+ <g>
176
+ <line x1={40} y1={0} x2={1000} y2={0} stroke={inkAt(palette.ink, 0.10)} strokeWidth={0.8} />
177
+ <text x={40} y={16} fill={palette.soft} fontSize={8} fontFamily={FONT_MONO} letterSpacing={1.6}>LEGEND</text>
178
+ {items.map((item, i) => {
179
+ const t = nodeTreatment(item.type, palette)
180
+ const x = 40 + i * 170
181
+ return (
182
+ <g key={item.label}>
183
+ <rect x={x} y={32} width={14} height={10} rx={2} fill={t.fill} stroke={t.stroke} strokeWidth={1} strokeDasharray={t.dashed === true ? '3,2' : undefined} />
184
+ <text x={x + 20} y={41} fill={palette.soft} fontSize={8.5} fontFamily={FONT_SANS}>{item.label}</text>
185
+ </g>
186
+ )
187
+ })}
188
+ </g>
189
+ )
190
+ }
191
+
192
+ function markerFor(kind: string | undefined, uid: string): string {
193
+ if (kind === 'accent') return `${uid}-arrow-accent`
194
+ if (kind === 'link') return `${uid}-arrow-link`
195
+ return `${uid}-arrow`
196
+ }
197
+
198
+ /** Stable per-render uid (avoid id collisions across diagrams on one page). */
199
+ let uidCounter = 0
200
+ function nextUid(): string {
201
+ uidCounter += 1
202
+ return `genui-diagram-${uidCounter}`
203
+ }
204
+
205
+ /** The `diagram` node renderer. */
206
+ export function DiagramNode({ node }: { node: GenuiDiagram }) {
207
+ const uid = useMemo(nextUid, [])
208
+ const palette = useMemo(() => resolvePalette(node.variant, node.theme), [node.variant, node.theme])
209
+ const layout = useMemo(() => resolveLayout(node, buildParentMap(node.edges ?? [])), [node])
210
+
211
+ const byId = useMemo(() => new Map(layout.nodes.map(l => [l.node.id, l.box])), [layout])
212
+ // figcaption gets its own id so aria-labelledby can point at the SVG <title>
213
+ // alone — sharing one id between the two renders duplicates ids in the DOM.
214
+ const titleId = `${uid}-title`
215
+ const captionId = `${uid}-caption`
216
+ const descId = `${uid}-desc`
217
+
218
+ // Complexity budget is enforced by the guard; belt-and-suspenders here.
219
+ const nodes = layout.nodes.slice(0, GENUI_LIMITS.maxDiagramNodes)
220
+ const edges = layout.edges.slice(0, GENUI_LIMITS.maxDiagramEdges)
221
+ const zones = (node.zones ?? []).slice(0, GENUI_LIMITS.maxDiagramZones)
222
+
223
+ // Focal budget: accent on 1–2 nodes max; extra focal nodes downgrade to backend.
224
+ let focalSeen = 0
225
+ const focalBudget = GENUI_LIMITS.maxDiagramFocal
226
+
227
+ // Canvas: content bounds + editorial chrome (bottom legend strip).
228
+ const contentH = layout.height
229
+ const canvasH = contentH + LEGEND_H + 24
230
+
231
+ return (
232
+ <figure className="genui-diagram" data-genui-diagram>
233
+ {node.title !== undefined && (
234
+ <figcaption id={captionId} style={{ fontFamily: "Instrument Serif, 'Times New Roman', serif", fontSize: 20, marginBottom: 8, color: palette.ink }}>
235
+ {node.title}
236
+ </figcaption>
237
+ )}
238
+ <svg
239
+ role="img"
240
+ aria-labelledby={titleId}
241
+ aria-describedby={descId}
242
+ viewBox={`0 0 ${layout.width} ${canvasH}`}
243
+ style={{ maxWidth: '100%', height: 'auto', background: palette.paper }}
244
+ >
245
+ <title id={titleId}>{node.title ?? node.kind}</title>
246
+ <desc id={descId}>{`${node.kind} diagram with ${nodes.length} nodes and ${edges.length} connectors`}</desc>
247
+ {renderMarkers(uid, palette)}
248
+ {renderGround(uid, palette)}
249
+ {/* zones drawn before arrows and nodes */}
250
+ <g>
251
+ {zones.map((z, i) => (
252
+ <g key={`zone-${i}`}>
253
+ {renderZone({ label: z.label, x: z.x ?? 0, y: z.y ?? 0, w: z.w ?? 100, h: z.h ?? 100 }, palette)}
254
+ </g>
255
+ ))}
256
+ </g>
257
+ <g>
258
+ {edges.map((e, i) => {
259
+ const from = byId.get(e.fromId)
260
+ const to = byId.get(e.toId)
261
+ if (from === undefined || to === undefined) return null
262
+ return renderEdge({ from, to, label: e.edge.label, kind: e.edge.kind }, i, palette, markerFor(e.edge.kind, uid))
263
+ })}
264
+ </g>
265
+ {/* nodes after edges */}
266
+ <g>
267
+ {nodes.map((l, idx) => {
268
+ const effectiveType = l.node.type === 'focal' && focalSeen >= focalBudget ? undefined : l.node.type
269
+ if (l.node.type === 'focal') focalSeen += 1
270
+ return (
271
+ <g key={l.node.id}>
272
+ {renderNodeBox(l.box, l.node.label, l.node.sub, effectiveType, l.node.tag, idx + 1, palette)}
273
+ </g>
274
+ )
275
+ })}
276
+ </g>
277
+ {/* legend strip */}
278
+ <g transform={`translate(0, ${contentH + 8})`}>
279
+ {renderLegend(palette)}
280
+ </g>
281
+ </svg>
282
+ </figure>
283
+ )
284
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Editorial diagram layout: resolves a `diagram` spec into positioned boxes
3
+ * and routed edges. Two layout modes mirror diagram-design's grammar:
4
+ *
5
+ * - Coordinate kinds (architecture, it-state, high-level, process,
6
+ * medallion, data-flow, dp-integration): the spec carries x/y/w/h per
7
+ * node; this module passes them through (4px-grid already enforced by the
8
+ * guard) and routes edges.
9
+ * - Rule kinds (flowchart, sequence, state, er, timeline, swimlane,
10
+ * quadrant, radar, loop, nested, tree, org-chart, layers, venn, pyramid,
11
+ * bar, line, gantt, scatter, dp-security-matrix): the spec carries data
12
+ * only; this module lays the nodes out on the editorial grid.
13
+ *
14
+ * The output is a plain layout object consumed by the SVG renderer — no
15
+ * React, no DOM, fully unit-testable.
16
+ * @module @changfenhuang/dsh-genui/client/blocks/diagram/layout
17
+ */
18
+ import type { GenuiDiagram, GenuiDiagramEdge, GenuiDiagramKind, GenuiDiagramNode } from '../../spec.ts'
19
+ import { Box } from './geometry.ts'
20
+
21
+ export interface LayoutNode {
22
+ node: GenuiDiagramNode
23
+ box: Box
24
+ }
25
+
26
+ export interface LayoutEdge {
27
+ edge: GenuiDiagramEdge
28
+ /** Resolved box references for routing. */
29
+ fromId: string
30
+ toId: string
31
+ }
32
+
33
+ export interface DiagramLayout {
34
+ nodes: LayoutNode[]
35
+ edges: LayoutEdge[]
36
+ /** Canvas size (width/height). */
37
+ width: number
38
+ height: number
39
+ }
40
+
41
+ /** Coordinate-mode kinds: nodes carry explicit positions. */
42
+ const COORDINATE_KINDS: readonly GenuiDiagramKind[] = [
43
+ 'architecture', 'it-state', 'high-level', 'process', 'medallion', 'data-flow', 'dp-integration',
44
+ ]
45
+
46
+ const GAP = 32 // editorial gap between nodes
47
+ const NODE_W = 128
48
+ const NODE_H = 64
49
+ const MARGIN = 40
50
+
51
+ function isCoord(kind: GenuiDiagramKind): boolean {
52
+ return COORDINATE_KINDS.includes(kind)
53
+ }
54
+
55
+ /** Default box for a node without coordinates. */
56
+ function defaultBox(i: number, cols: number): Box {
57
+ const col = i % cols
58
+ const row = Math.floor(i / cols)
59
+ return {
60
+ x: MARGIN + col * (NODE_W + GAP),
61
+ y: MARGIN + row * (NODE_H + GAP),
62
+ w: NODE_W,
63
+ h: NODE_H,
64
+ }
65
+ }
66
+
67
+ /** Simple top-down flow layout: nodes in a column, one per row. */
68
+ function columnLayout(nodes: GenuiDiagramNode[]): LayoutNode[] {
69
+ return nodes.map((node, i) => ({
70
+ node,
71
+ box: { x: MARGIN, y: MARGIN + i * (NODE_H + GAP), w: NODE_W, h: NODE_H },
72
+ }))
73
+ }
74
+
75
+ /** Simple left-right flow layout: nodes in a row. */
76
+ function rowLayout(nodes: GenuiDiagramNode[]): LayoutNode[] {
77
+ return nodes.map((node, i) => ({
78
+ node,
79
+ box: { x: MARGIN + i * (NODE_W + GAP), y: MARGIN, w: NODE_W, h: NODE_H },
80
+ }))
81
+ }
82
+
83
+ /** Layered horizontal layout (layers, pyramid, gantt phases): stacked rows. */
84
+ function layerLayout(nodes: GenuiDiagramNode[], perRow = 3): LayoutNode[] {
85
+ return nodes.map((node, i) => {
86
+ const row = Math.floor(i / perRow)
87
+ const col = i % perRow
88
+ return {
89
+ node,
90
+ box: {
91
+ x: MARGIN + col * (NODE_W + GAP),
92
+ y: MARGIN + row * (NODE_H + GAP),
93
+ w: NODE_W,
94
+ h: NODE_H,
95
+ },
96
+ }
97
+ })
98
+ }
99
+
100
+ /** Tree layout: level = depth, children fan out horizontally under the parent. */
101
+ function treeLayout(nodes: GenuiDiagramNode[], parentOf: (id: string) => string | undefined): LayoutNode[] {
102
+ const byId = new Map(nodes.map(n => [n.id, n]))
103
+ const childrenOf = new Map<string, string[]>()
104
+ const roots: string[] = []
105
+ for (const n of nodes) {
106
+ const p = parentOf(n.id)
107
+ if (p === undefined || !byId.has(p)) {
108
+ roots.push(n.id)
109
+ } else {
110
+ const list = childrenOf.get(p) ?? []
111
+ list.push(n.id)
112
+ childrenOf.set(p, list)
113
+ }
114
+ }
115
+ const placed = new Map<string, Box>()
116
+ const depthOf = new Map<string, number>()
117
+
118
+ // measure: leaf width = 1; parent width = sum of children widths.
119
+ const measure = (id: string, depth: number): number => {
120
+ depthOf.set(id, depth)
121
+ const kids = childrenOf.get(id) ?? []
122
+ if (kids.length === 0) return 1
123
+ return kids.reduce((sum, k) => sum + measure(k, depth + 1), 0)
124
+ }
125
+
126
+ // assign: place children left→right, then center the parent above them.
127
+ const assign = (id: string, left: number): number => {
128
+ const depth = depthOf.get(id) ?? 0
129
+ const kids = childrenOf.get(id) ?? []
130
+ const w = NODE_W
131
+ let cursor = left
132
+ if (kids.length === 0) {
133
+ placed.set(id, { x: left, y: MARGIN + depth * (NODE_H + GAP * 1.5), w, h: NODE_H })
134
+ return left + w
135
+ }
136
+ for (const k of kids) cursor = assign(k, cursor)
137
+ const first = placed.get(kids[0]!)!
138
+ const last = placed.get(kids[kids.length - 1]!)!
139
+ const cx = (first.x + last.x + last.w) / 2 - w / 2
140
+ placed.set(id, { x: Math.max(left, cx), y: MARGIN + depth * (NODE_H + GAP * 1.5), w, h: NODE_H })
141
+ return cursor
142
+ }
143
+
144
+ let left = MARGIN
145
+ for (const r of roots) {
146
+ left = assign(r, left) + GAP
147
+ }
148
+ return nodes.map(n => ({ node: n, box: placed.get(n.id) ?? { x: MARGIN, y: MARGIN, w: NODE_W, h: NODE_H } }))
149
+ }
150
+
151
+ /**
152
+ * Resolve a diagram spec into a positioned layout. `parentOf` lets tree-like
153
+ * kinds pass their hierarchy; other rule kinds use a generic grid.
154
+ */
155
+ export function resolveLayout(
156
+ diagram: GenuiDiagram,
157
+ parentOf?: (id: string) => string | undefined,
158
+ ): DiagramLayout {
159
+ const nodes = diagram.nodes
160
+ const edges = diagram.edges ?? []
161
+
162
+ let placed: LayoutNode[]
163
+ if (isCoord(diagram.kind)) {
164
+ placed = nodes.map((node, i) => ({
165
+ node,
166
+ box: {
167
+ x: node.x ?? MARGIN + (i % 3) * (NODE_W + GAP),
168
+ y: node.y ?? MARGIN + Math.floor(i / 3) * (NODE_H + GAP),
169
+ w: node.w ?? NODE_W,
170
+ h: node.h ?? NODE_H,
171
+ },
172
+ }))
173
+ } else if (diagram.kind === 'tree' || diagram.kind === 'org-chart' || diagram.kind === 'nested') {
174
+ placed = treeLayout(nodes, parentOf ?? (() => undefined))
175
+ } else if (diagram.kind === 'layers' || diagram.kind === 'pyramid' || diagram.kind === 'gantt') {
176
+ placed = layerLayout(nodes)
177
+ } else if (diagram.kind === 'flowchart' || diagram.kind === 'process' || diagram.kind === 'sequence') {
178
+ // Directed flows read top-down.
179
+ placed = columnLayout(nodes)
180
+ } else if (diagram.kind === 'timeline' || diagram.kind === 'line' || diagram.kind === 'scatter' || diagram.kind === 'bar') {
181
+ placed = rowLayout(nodes)
182
+ } else {
183
+ placed = nodes.map((node, i) => ({ node, box: defaultBox(i, 3) }))
184
+ }
185
+
186
+ // Canvas bounds.
187
+ let width = 0
188
+ let height = 0
189
+ for (const l of placed) {
190
+ width = Math.max(width, l.box.x + l.box.w)
191
+ height = Math.max(height, l.box.y + l.box.h)
192
+ }
193
+ width = Math.max(320, Math.ceil((width + MARGIN) / 4) * 4)
194
+ height = Math.max(200, Math.ceil((height + MARGIN) / 4) * 4)
195
+
196
+ const edgesOut: LayoutEdge[] = edges.map(e => ({ edge: e, fromId: e.from, toId: e.to }))
197
+
198
+ return { nodes: placed, edges: edgesOut, width, height }
199
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Editorial diagram theme: the diagram-design semantic-token system ported
3
+ * into the dsh-genui renderer. Every diagram draws from these roles — never
4
+ * from inline hex in the layout code — and a spec-level `theme` override
5
+ * merges over the defaults, mirroring diagram-design's style-guide.md.
6
+ *
7
+ * Two skins ship: light and dark (the dark palette is the jet-black
8
+ * inversion, same roles). The `editorial` variant is the full skin and
9
+ * defaults to light unless the host theme is dark.
10
+ * @module @changfenhuang/dsh-genui/client/blocks/diagram/theme
11
+ */
12
+ import type { GenuiDiagramTheme, GenuiDiagramVariant } from '../../spec.ts'
13
+
14
+ export interface DiagramPalette {
15
+ paper: string
16
+ paper2: string
17
+ ink: string
18
+ muted: string
19
+ soft: string
20
+ rule: string
21
+ accent: string
22
+ accentTint: string
23
+ link: string
24
+ }
25
+
26
+ /** diagram-design default skin (style-guide.md § Tokens). */
27
+ const LIGHT: DiagramPalette = {
28
+ paper: '#f5f5f5',
29
+ paper2: '#ececec',
30
+ ink: '#2d3142',
31
+ muted: '#4f5d75',
32
+ soft: '#7a8399',
33
+ rule: 'rgba(45,49,66,0.12)',
34
+ accent: '#eb6c36',
35
+ accentTint: 'rgba(235,108,54,0.08)',
36
+ link: '#2e5aa8',
37
+ }
38
+
39
+ /** Dark inversion: jet-black paper, white-smoke ink, silver muted. */
40
+ const DARK: DiagramPalette = {
41
+ paper: '#2d3142',
42
+ paper2: '#393e53',
43
+ ink: '#f5f5f5',
44
+ muted: '#bfc0c0',
45
+ soft: '#8e98ac',
46
+ rule: 'rgba(245,245,245,0.12)',
47
+ accent: '#f08a59',
48
+ accentTint: 'rgba(240,138,89,0.10)',
49
+ link: '#6a95d8',
50
+ }
51
+
52
+ /** Resolve the active palette from variant + optional theme overrides. */
53
+ export function resolvePalette(variant: GenuiDiagramVariant | undefined, theme: GenuiDiagramTheme | undefined): DiagramPalette {
54
+ const base = variant === 'dark' ? DARK : LIGHT
55
+ if (theme === undefined) return base
56
+ return {
57
+ paper: theme.paper ?? base.paper,
58
+ paper2: theme['paper-2'] ?? base.paper2,
59
+ ink: theme.ink ?? base.ink,
60
+ muted: theme.muted ?? base.muted,
61
+ soft: theme.soft ?? base.soft,
62
+ rule: theme.rule ?? base.rule,
63
+ accent: theme.accent ?? base.accent,
64
+ accentTint: theme['accent-tint'] ?? base.accentTint,
65
+ link: theme.link ?? base.link,
66
+ }
67
+ }
68
+
69
+ /** Node treatment → { fill, stroke, dashed } per diagram-design §5. */
70
+ export function nodeTreatment(type: string | undefined, p: DiagramPalette): { fill: string; stroke: string; dashed?: boolean } {
71
+ switch (type) {
72
+ case 'focal': return { fill: p.accentTint, stroke: p.accent }
73
+ case 'store': return { fill: inkAt(p.ink, 0.05), stroke: p.muted }
74
+ case 'external': return { fill: inkAt(p.ink, 0.03), stroke: inkAt(p.ink, 0.30) }
75
+ case 'input': return { fill: inkAt(p.muted, 0.10), stroke: p.soft }
76
+ case 'optional': return { fill: inkAt(p.ink, 0.02), stroke: inkAt(p.ink, 0.20), dashed: true }
77
+ case 'security': return { fill: inkAt(p.accent, 0.05), stroke: inkAt(p.accent, 0.50), dashed: true }
78
+ default: return { fill: '#ffffff', stroke: p.ink }
79
+ }
80
+ }
81
+
82
+ /** Edge stroke color per semantic kind. */
83
+ export function edgeStroke(kind: string | undefined, p: DiagramPalette): string {
84
+ switch (kind) {
85
+ case 'accent': return p.accent
86
+ case 'link': return p.link
87
+ default: return p.muted
88
+ }
89
+ }
90
+
91
+ /** Helper: a color at a given opacity (accepts #hex and rgba() strings). */
92
+ export function inkAt(color: string, opacity: number): string {
93
+ if (color.startsWith('#')) {
94
+ const hex = color.slice(1)
95
+ const full = hex.length === 3 ? hex.split('').map(c => c + c).join('') : hex
96
+ const r = parseInt(full.slice(0, 2), 16)
97
+ const g = parseInt(full.slice(2, 4), 16)
98
+ const b = parseInt(full.slice(4, 6), 16)
99
+ return `rgba(${r},${g},${b},${opacity})`
100
+ }
101
+ // rgba(...) input: replace the alpha component.
102
+ return color.replace(/rgba?\(([^)]*)\)/, (_, inner: string) => {
103
+ const parts = inner.split(',').map(s => s.trim())
104
+ if (parts.length === 3) return `rgba(${parts.join(',')},${opacity})`
105
+ if (parts.length === 4) return `rgba(${parts[0]},${parts[1]},${parts[2]},${opacity})`
106
+ return color
107
+ })
108
+ }