konpeki 0.1.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 (121) hide show
  1. package/.agents/skills/authoring-visuals/SKILL.md +82 -0
  2. package/AGENTS.md +33 -0
  3. package/AUTHORING.md +233 -0
  4. package/LICENSE +201 -0
  5. package/README.md +70 -0
  6. package/SETUP.md +86 -0
  7. package/composition/README.md +166 -0
  8. package/composition/compile.ts +227 -0
  9. package/composition/document.ts +709 -0
  10. package/composition/schema.json +2992 -0
  11. package/composition/schema.ts +435 -0
  12. package/composition/theme-tokens.ts +16 -0
  13. package/composition/types.ts +286 -0
  14. package/composition/validate.ts +684 -0
  15. package/composition/vector.ts +143 -0
  16. package/composition/visualizations.ts +335 -0
  17. package/design/README.md +17 -0
  18. package/design/palettes/README.md +14 -0
  19. package/design/palettes/base.ts +14 -0
  20. package/design/palettes/candidates.ts +19 -0
  21. package/design/palettes/index.ts +78 -0
  22. package/design/review/color-theme.md +44 -0
  23. package/design/review/layout.md +16 -0
  24. package/design/review/text.md +18 -0
  25. package/design/review/typography.md +15 -0
  26. package/design/review/visuals.md +31 -0
  27. package/design/semantic-patterns.md +43 -0
  28. package/design/themes/README.md +22 -0
  29. package/design/themes/index.ts +13 -0
  30. package/design/visual-languages/technical-product.md +17 -0
  31. package/design/visual-review.md +88 -0
  32. package/docs/development.md +140 -0
  33. package/docs/workflow.md +61 -0
  34. package/index.html +17 -0
  35. package/lib/assets.d.ts +8 -0
  36. package/lib/charts.ts +18 -0
  37. package/lib/contrast.ts +16 -0
  38. package/lib/layouts.ts +50 -0
  39. package/lib/slide.tsx +42 -0
  40. package/lib/taste.ts +17 -0
  41. package/lib/text.tsx +89 -0
  42. package/lib/typeface.ts +44 -0
  43. package/package.json +85 -0
  44. package/runtime/konpeki.mjs +1685 -0
  45. package/scripts/migrate-react-page.ts +120 -0
  46. package/slides/README.md +153 -0
  47. package/slides/architecture/PROMPT.md +31 -0
  48. package/slides/architecture/index.tsx +102 -0
  49. package/slides/article-brief/PROMPT.md +35 -0
  50. package/slides/article-brief/index.tsx +71 -0
  51. package/slides/bar-chart/PROMPT.md +39 -0
  52. package/slides/bar-chart/index.tsx +97 -0
  53. package/slides/comparison/PROMPT.md +29 -0
  54. package/slides/comparison/index.tsx +95 -0
  55. package/slides/decision-memo/PROMPT.md +34 -0
  56. package/slides/decision-memo/index.tsx +85 -0
  57. package/slides/delivery-plan/PROMPT.md +45 -0
  58. package/slides/delivery-plan/index.tsx +105 -0
  59. package/slides/experiment/PROMPT.md +44 -0
  60. package/slides/experiment/index.tsx +127 -0
  61. package/slides/incident-workflow/PROMPT.md +57 -0
  62. package/slides/incident-workflow/index.tsx +78 -0
  63. package/slides/introducing-konpeki/PROMPT.md +19 -0
  64. package/slides/introducing-konpeki/README.md +54 -0
  65. package/slides/introducing-konpeki/SOURCE.md +20 -0
  66. package/slides/introducing-konpeki/author.ts +163 -0
  67. package/slides/introducing-konpeki/composition.json +3265 -0
  68. package/slides/line-chart/PROMPT.md +40 -0
  69. package/slides/line-chart/index.tsx +72 -0
  70. package/slides/migration/PROMPT.md +38 -0
  71. package/slides/migration/index.tsx +89 -0
  72. package/slides/og-images/PROMPT.md +21 -0
  73. package/slides/og-images/index.tsx +76 -0
  74. package/slides/product-introduction/PROMPT.md +24 -0
  75. package/slides/product-introduction/index.tsx +105 -0
  76. package/slides/research-brief/PROMPT.md +40 -0
  77. package/slides/research-brief/index.tsx +104 -0
  78. package/slides/results-explanation/PROMPT.md +32 -0
  79. package/slides/results-explanation/index.tsx +96 -0
  80. package/slides/retrospective/PROMPT.md +43 -0
  81. package/slides/retrospective/index.tsx +105 -0
  82. package/slides/sankey/PROMPT.md +11 -0
  83. package/slides/sankey/index.tsx +93 -0
  84. package/slides/teaching/PROMPT.md +45 -0
  85. package/slides/teaching/index.tsx +124 -0
  86. package/slides/vertical-bar-charts/PROMPT.md +13 -0
  87. package/slides/vertical-bar-charts/index.tsx +97 -0
  88. package/src/app/App.tsx +694 -0
  89. package/src/assets/konpeki-mark.png +0 -0
  90. package/src/components/Canvas.tsx +1168 -0
  91. package/src/components/DiagramTypeIcon.tsx +78 -0
  92. package/src/components/InspectorPanel.tsx +687 -0
  93. package/src/components/LeftPanel.tsx +107 -0
  94. package/src/components/PageSizePicker.tsx +30 -0
  95. package/src/components/Presentation.tsx +105 -0
  96. package/src/components/RightPanel.tsx +201 -0
  97. package/src/components/VectorOverflowWarning.tsx +46 -0
  98. package/src/components/WorkspaceChrome.tsx +199 -0
  99. package/src/components/ui.tsx +53 -0
  100. package/src/lib/examples/react-page-migration.json +1295 -0
  101. package/src/lib/examples.ts +42 -0
  102. package/src/lib/export-png.ts +101 -0
  103. package/src/lib/file-session.ts +74 -0
  104. package/src/lib/history.ts +53 -0
  105. package/src/lib/model.ts +188 -0
  106. package/src/lib/page-size.ts +24 -0
  107. package/src/lib/presentation.ts +17 -0
  108. package/src/lib/storage.ts +43 -0
  109. package/src/lib/theme.ts +25 -0
  110. package/src/lib/use-file-session.ts +162 -0
  111. package/src/main.tsx +26 -0
  112. package/src/styles/base.css +105 -0
  113. package/src/styles/canvas.css +268 -0
  114. package/src/styles/chrome.css +214 -0
  115. package/src/styles/component-previews.css +386 -0
  116. package/src/styles/feedback.css +71 -0
  117. package/src/styles/left-panel.css +125 -0
  118. package/src/styles/presentation.css +72 -0
  119. package/src/styles/right-panel.css +1172 -0
  120. package/src/styles/shell.css +247 -0
  121. package/vite.config.ts +6 -0
@@ -0,0 +1,687 @@
1
+ import {
2
+ themeIds,
3
+ type CompositionComponent,
4
+ type CompositionSlide,
5
+ type ChartTemplate,
6
+ type DiagramType,
7
+ type Rect,
8
+ type TextColor,
9
+ type ThemeId,
10
+ type VectorElement,
11
+ } from "../../composition/types.ts";
12
+ import {
13
+ chartDefinitions,
14
+ chartTemplates,
15
+ diagramCategories,
16
+ diagramDefinitions,
17
+ } from "../../composition/visualizations.ts";
18
+ import { useEffect, useRef, type CSSProperties } from "react";
19
+ import {
20
+ appearanceOptions,
21
+ tableAppearanceForStyle,
22
+ tableStyleForAppearance,
23
+ tableStyles,
24
+ type TableStyle,
25
+ } from "../../composition/schema.ts";
26
+ import {
27
+ clamp,
28
+ componentLabels,
29
+ defaultAppearance,
30
+ type Draft,
31
+ } from "../lib/model.ts";
32
+ import { composerPalette, themeLabel } from "../lib/theme.ts";
33
+ import { DiagramTypeIcon } from "./DiagramTypeIcon.tsx";
34
+ import { Field, Select, humanize } from "./ui.tsx";
35
+ import { moveVectorElement, removeVectorElement } from "../../composition/vector.ts";
36
+ import { tokensForAttribute } from "../../composition/theme-tokens.ts";
37
+ import { VectorOverflowWarning } from "./VectorOverflowWarning.tsx";
38
+ import { PageSizePicker } from "./PageSizePicker.tsx";
39
+
40
+ const appearanceLabels: Record<string, string> = {
41
+ rule: "Divider",
42
+ style: "Number format",
43
+ };
44
+ const optionLabels: Record<string, string> = {
45
+ "01": "Current page — 01",
46
+ "01/02": "Current / total — 01/02",
47
+ single: "Single",
48
+ "two-column": "2 equal columns",
49
+ "three-column": "3 equal columns",
50
+ "two-plus-two": "2 + 2 grid",
51
+ "four-column": "4 columns",
52
+ "one-plus-three": "1 + 3 blocks",
53
+ "three-plus-one": "3 + 1 blocks",
54
+ "header-rule": "Rule under header",
55
+ "top-header-bottom": "Top + header + bottom",
56
+ "row-rules": "Horizontal rules",
57
+ "full-grid": "Full grid",
58
+ };
59
+
60
+ function previewCount(key: string, value: string) {
61
+ if (key === "tableStyle") return 6;
62
+ if (key !== "layout") return 4;
63
+ if (value === "single") return 1;
64
+ if (value === "two-column") return 2;
65
+ if (value === "three-column") return 3;
66
+ return 4;
67
+ }
68
+
69
+ function AppearancePicker({
70
+ label,
71
+ name,
72
+ value,
73
+ options,
74
+ labels,
75
+ description,
76
+ disabledOptions,
77
+ onChange,
78
+ }: {
79
+ label: string;
80
+ name: string;
81
+ value: string;
82
+ options: readonly string[];
83
+ labels?: Partial<Record<string, string>>;
84
+ description?: string;
85
+ disabledOptions?: ReadonlySet<string>;
86
+ onChange: (value: string) => void;
87
+ }) {
88
+ return (
89
+ <div className="field appearance-field">
90
+ <span>{label}</span>
91
+ <div className="appearance-options" role="group" aria-label={label}>
92
+ {options.map((option) => (
93
+ <button
94
+ key={option}
95
+ type="button"
96
+ className={value === option ? "selected" : ""}
97
+ aria-pressed={value === option}
98
+ disabled={disabledOptions?.has(option)}
99
+ title={disabledOptions?.has(option) ? "Remove the recorded Sankey topology before changing chart type." : undefined}
100
+ onClick={() => onChange(option)}
101
+ >
102
+ <span
103
+ className="appearance-preview"
104
+ data-property={name}
105
+ data-value={option}
106
+ aria-hidden="true"
107
+ >
108
+ {Array.from({ length: previewCount(name, option) }, (_, index) => (
109
+ <i key={index} />
110
+ ))}
111
+ </span>
112
+ <span>{labels?.[option] ?? optionLabels[option] ?? humanize(option)}</span>
113
+ </button>
114
+ ))}
115
+ </div>
116
+ {description && <small className="visualization-description">{description}</small>}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ function ThemePicker({
122
+ value,
123
+ mode,
124
+ onChange,
125
+ }: {
126
+ value: ThemeId;
127
+ mode: "paper" | "night";
128
+ onChange: (value: ThemeId) => void;
129
+ }) {
130
+ return (
131
+ <fieldset className="theme-picker">
132
+ <legend>Theme</legend>
133
+ <div className="theme-options">
134
+ {themeIds.map((id) => {
135
+ const palette = composerPalette(id, mode);
136
+ return (
137
+ <button
138
+ key={id}
139
+ type="button"
140
+ className={value === id ? "selected" : ""}
141
+ data-theme={id}
142
+ aria-pressed={value === id}
143
+ onClick={() => onChange(id)}
144
+ >
145
+ <span>{themeLabel(id)}</span>
146
+ <span className="theme-swatches" aria-hidden="true">
147
+ {[palette.accent, palette.categorical[1], palette.categorical[2]].map(
148
+ (color) => (
149
+ <i key={color} style={{ background: color }} />
150
+ ),
151
+ )}
152
+ </span>
153
+ </button>
154
+ );
155
+ })}
156
+ </div>
157
+ </fieldset>
158
+ );
159
+ }
160
+
161
+ function SegmentedControl({ label, value, options, onChange }: {
162
+ label: string;
163
+ value: string;
164
+ options: readonly string[];
165
+ onChange: (value: string) => void;
166
+ }) {
167
+ return (
168
+ <fieldset className="choice-picker">
169
+ <legend>{label}</legend>
170
+ <div className="segmented-options" style={{ gridTemplateColumns: `repeat(${options.length}, 1fr)` }}>
171
+ {options.map((option) => (
172
+ <button key={option} type="button" aria-pressed={value === option}
173
+ className={value === option ? "selected" : ""}
174
+ onClick={() => onChange(option)}>
175
+ <span>{humanize(option)}</span>
176
+ </button>
177
+ ))}
178
+ </div>
179
+ </fieldset>
180
+ );
181
+ }
182
+
183
+ function DiagramTypePicker({
184
+ value,
185
+ selection,
186
+ onChange,
187
+ }: {
188
+ value: DiagramType;
189
+ selection: "auto" | "explicit";
190
+ onChange: (value: DiagramType, selection: "auto" | "explicit") => void;
191
+ }) {
192
+ return (
193
+ <details className="field diagram-type-field">
194
+ <summary id="diagram-type-label">Template</summary>
195
+ <div className="diagram-type-options" aria-labelledby="diagram-type-label">
196
+ <div className="diagram-type-category">
197
+ <div>
198
+ <button
199
+ type="button"
200
+ className={selection === "auto" ? "selected" : ""}
201
+ aria-pressed={selection === "auto"}
202
+ onClick={() => onChange(value, "auto")}
203
+ >
204
+ <svg className="diagram-type-icon" viewBox="0 0 48 32" aria-hidden="true">
205
+ <rect x="13" y="5" width="22" height="22" rx="4" />
206
+ <circle className="filled" cx="19" cy="11" r="1.5" />
207
+ <circle className="filled" cx="29" cy="11" r="1.5" />
208
+ <circle className="filled" cx="24" cy="16" r="1.5" />
209
+ <circle className="filled" cx="19" cy="21" r="1.5" />
210
+ <circle className="filled" cx="29" cy="21" r="1.5" />
211
+ </svg>
212
+ <span>YOLO</span>
213
+ </button>
214
+ </div>
215
+ </div>
216
+ {diagramCategories.map((category) => (
217
+ <div className="diagram-type-category" role="group" aria-label={category} key={category}>
218
+ <span>{category}</span>
219
+ <div>
220
+ {Object.entries(diagramDefinitions)
221
+ .filter(([, definition]) => definition.category === category)
222
+ .map(([type, definition]) => (
223
+ <button
224
+ type="button"
225
+ className={selection === "explicit" && value === type ? "selected" : ""}
226
+ aria-pressed={selection === "explicit" && value === type}
227
+ key={type}
228
+ onClick={() => onChange(type as DiagramType, "explicit")}
229
+ >
230
+ <DiagramTypeIcon type={type as DiagramType} />
231
+ <span>{definition.label}</span>
232
+ </button>
233
+ ))}
234
+ </div>
235
+ </div>
236
+ ))}
237
+ </div>
238
+ </details>
239
+ );
240
+ }
241
+
242
+ function ChartTypePicker({ value, selection, hasTopology, onChange }: {
243
+ value: ChartTemplate;
244
+ selection: "auto" | "explicit";
245
+ hasTopology: boolean;
246
+ onChange: (value: ChartTemplate, selection: "auto" | "explicit") => void;
247
+ }) {
248
+ return (
249
+ <details className="field chart-type-field diagram-type-field">
250
+ <summary>{selection === "auto" ? "YOLO" : `Required form: ${chartDefinitions[value].label}`}</summary>
251
+ <button type="button" aria-pressed={selection === "auto"} onClick={() => onChange(value, "auto")}>YOLO</button>
252
+ <AppearancePicker
253
+ label="Chart form"
254
+ name="template"
255
+ value={selection === "explicit" ? value : ""}
256
+ options={chartTemplates}
257
+ labels={Object.fromEntries(Object.entries(chartDefinitions).map(([type, definition]) => [type, definition.label]))}
258
+ description={`${chartDefinitions[value].expression}${hasTopology ? " Recorded Sankey topology is preserved; remove it before changing chart type, even in YOLO." : ""}`}
259
+ disabledOptions={hasTopology ? new Set(chartTemplates.filter(type => type !== "sankey")) : undefined}
260
+ onChange={(value) => {
261
+ const template = chartTemplates.find(type => type === value);
262
+ if (template) onChange(template, "explicit");
263
+ }}
264
+ />
265
+ </details>
266
+ );
267
+ }
268
+
269
+ export function InspectorPanel({
270
+ component,
271
+ componentLabel,
272
+ slide,
273
+ draft,
274
+ selectedVectorElementId,
275
+ vectorEditRequest,
276
+ onSelectSlide,
277
+ onSelectVectorElement,
278
+ onComponent,
279
+ onSlide,
280
+ onDraft,
281
+ onEditEnd,
282
+ }: {
283
+ component?: CompositionComponent;
284
+ componentLabel?: string;
285
+ slide: CompositionSlide;
286
+ draft: Draft;
287
+ selectedVectorElementId?: string;
288
+ vectorEditRequest?: number;
289
+ onSelectSlide: () => void;
290
+ onSelectVectorElement: (id?: string) => void;
291
+ onComponent: (component: CompositionComponent, mergeKey?: string) => void;
292
+ onSlide: (slide: CompositionSlide, mergeKey?: string) => void;
293
+ onDraft: (patch: Partial<Draft>, mergeKey?: string) => void;
294
+ onEditEnd: () => void;
295
+ }) {
296
+ const vectorEditor = useRef<HTMLDivElement>(null);
297
+ useEffect(() => {
298
+ if (!vectorEditRequest) return;
299
+ const editor = vectorEditor.current;
300
+ const control = editor?.querySelector<HTMLTextAreaElement | HTMLInputElement>("textarea, input");
301
+ const target = control ?? editor;
302
+ target?.focus();
303
+ target?.scrollIntoView({ block: "nearest" });
304
+ control?.select();
305
+ }, [vectorEditRequest]);
306
+ if (!component)
307
+ return (
308
+ <section className="inspector-content" aria-labelledby="inspector-heading">
309
+ <h2 id="inspector-heading">Page</h2>
310
+ <PageSizePicker key={slide.id} slide={slide} onChange={onSlide} />
311
+ <h2>Document appearance</h2>
312
+ <ThemePicker
313
+ value={draft.theme?.id ?? "plex"}
314
+ mode={draft.theme?.mode ?? "paper"}
315
+ onChange={(value) =>
316
+ onDraft({
317
+ theme: {
318
+ id: value,
319
+ mode: draft.theme?.mode ?? "paper",
320
+ },
321
+ })
322
+ }
323
+ />
324
+ <SegmentedControl
325
+ label="Theme mode"
326
+ value={draft.theme?.mode ?? "paper"}
327
+ options={["paper", "night"]}
328
+ onChange={(value) =>
329
+ onDraft({
330
+ theme: {
331
+ id: draft.theme?.id ?? "plex",
332
+ mode: value as "paper" | "night",
333
+ },
334
+ })
335
+ }
336
+ />
337
+ <VectorOverflowWarning slide={slide} theme={draft.theme} />
338
+ <h2 className="slide-settings-heading">Page numbering</h2>
339
+ <fieldset className="page-number-picker">
340
+ <SegmentedControl
341
+ label="Format"
342
+ value={slide.pageNumber?.style ?? "none"}
343
+ options={["none", "01", "01/02"]}
344
+ onChange={(value) =>
345
+ onSlide({
346
+ ...slide,
347
+ pageNumber: {
348
+ style: value as "none" | "01" | "01/02",
349
+ color: slide.pageNumber?.color ?? "muted",
350
+ },
351
+ })
352
+ }
353
+ />
354
+ {(slide.pageNumber?.style ?? "none") !== "none" && (
355
+ <SegmentedControl
356
+ label="Color"
357
+ value={slide.pageNumber?.color ?? "muted"}
358
+ options={["ink", "muted", "accent"]}
359
+ onChange={(value) =>
360
+ onSlide({
361
+ ...slide,
362
+ pageNumber: {
363
+ style: slide.pageNumber?.style ?? "01",
364
+ color: value as TextColor,
365
+ },
366
+ })
367
+ }
368
+ />
369
+ )}
370
+ </fieldset>
371
+ </section>
372
+ );
373
+ const rect = component.preferredRect;
374
+ const appearance = component.appearance as Record<string, string> | undefined;
375
+ const defaults = defaultAppearance(component.kind);
376
+ const previewPalette = composerPalette(draft.theme?.id ?? "plex", draft.theme?.mode ?? "paper");
377
+ const vectorVisual = component.customVisual?.format === "vector"
378
+ ? component.customVisual
379
+ : undefined;
380
+ const vectorElement = vectorVisual?.elements.find(
381
+ (element) => element.id === selectedVectorElementId,
382
+ );
383
+ function updateVectorElement(
384
+ element: VectorElement,
385
+ mergeKey = `vector:${component!.id}:${element.id}`,
386
+ ) {
387
+ if (!vectorVisual) return;
388
+ onComponent({
389
+ ...component!,
390
+ customVisual: {
391
+ ...vectorVisual,
392
+ elements: vectorVisual.elements.map((item) =>
393
+ item.id === element.id ? element : item,
394
+ ),
395
+ },
396
+ }, mergeKey);
397
+ }
398
+ function geometry(key: keyof Rect, value: number) {
399
+ if (!Number.isFinite(value)) return;
400
+ const next = { ...rect };
401
+ if (key === "x") next.x = clamp(value, 0, slide.canvas.width - rect.width);
402
+ if (key === "y") next.y = clamp(value, 0, slide.canvas.height - rect.height);
403
+ if (key === "width")
404
+ next.width = clamp(value, Math.min(180, slide.canvas.width - rect.x), slide.canvas.width - rect.x);
405
+ if (key === "height")
406
+ next.height = clamp(value, Math.min(72, slide.canvas.height - rect.y), slide.canvas.height - rect.y);
407
+ onComponent(
408
+ { ...component!, preferredRect: next },
409
+ `geometry:${component!.id}`,
410
+ );
411
+ }
412
+ return (
413
+ <section className={`inspector-content${component.kind === "text-block" && !component.customVisual ? " native-text-inspector" : ""}`} aria-labelledby="inspector-heading"
414
+ style={{
415
+ "--preview-ink": previewPalette.fg,
416
+ "--preview-muted": previewPalette.line,
417
+ "--preview-accent": previewPalette.accent,
418
+ "--preview-secondary": previewPalette.categorical[1],
419
+ "--preview-tertiary": previewPalette.categorical[2],
420
+ "--preview-complete": previewPalette.status.complete,
421
+ "--preview-attention": previewPalette.status.attention,
422
+ "--preview-blocked": previewPalette.status.blocked,
423
+ } as CSSProperties}
424
+ >
425
+ <h2 id="inspector-heading" className="inspector-context">
426
+ <button type="button" onClick={onSelectSlide}>Page</button>
427
+ <span aria-hidden="true">/</span>
428
+ <span>{componentLabel ?? componentLabels[component.kind]}</span>
429
+ </h2>
430
+ {component.kind === "text-block" && !component.customVisual && <>
431
+ <VectorOverflowWarning slide={slide} theme={draft.theme} />
432
+ <Field label="Text"><textarea name="text-content" value={component.content ?? ""}
433
+ onChange={(event) => onComponent({ ...component, content: event.target.value }, `content:${component.id}`)} onBlur={onEditEnd} /></Field>
434
+ <div className="text-typography-fields">
435
+ <Field label="Font size"><input type="number" min={8} max={240} value={component.textStyle?.size ?? 36}
436
+ onChange={(event) => { const size = event.target.valueAsNumber; if (size >= 8 && size <= 240) onComponent({ ...component, textStyle: { ...component.textStyle, size } }, `font:${component.id}`); }} onBlur={onEditEnd} /></Field>
437
+ <Select label="Text color" value={component.textStyle?.color ?? "ink"} options={["ink", "muted", "accent"]}
438
+ onChange={(color) => onComponent({ ...component, textStyle: { ...component.textStyle, color: color as "ink" | "muted" | "accent" } })} />
439
+ <Select label="Font weight" value={String(component.textStyle?.weight ?? 400)} options={["400", "500", "600"]}
440
+ onChange={(weight) => onComponent({ ...component, textStyle: { ...component.textStyle, weight: Number(weight) as 400 | 500 | 600 } })} />
441
+ <Field label="Line height"><input type="number" min={1} max={3} step={0.1} value={component.textStyle?.lineHeight ?? 1.4}
442
+ onChange={(event) => { const lineHeight = event.target.valueAsNumber; if (lineHeight >= 1 && lineHeight <= 3) onComponent({ ...component, textStyle: { ...component.textStyle, lineHeight } }, `leading:${component.id}`); }} onBlur={onEditEnd} /></Field>
443
+ </div>
444
+ </>}
445
+ <Field label={component.kind === "diagram" || component.kind === "chart" ? "What should this explain?" : "Content intent"}>
446
+ <textarea
447
+ name="content-intent"
448
+ autoComplete="off"
449
+ rows={4}
450
+ placeholder={component.kind === "diagram" || component.kind === "chart" ? "Describe the relationship or takeaway and supply the facts or data to preserve." : undefined}
451
+ value={component.intent ?? ""}
452
+ onChange={(e) =>
453
+ onComponent(
454
+ { ...component, intent: e.target.value },
455
+ `intent:${component.id}`,
456
+ )
457
+ }
458
+ onBlur={onEditEnd}
459
+ />
460
+ </Field>
461
+ {component.kind === "diagram" && <DiagramTypePicker
462
+ value={component.appearance.type}
463
+ selection={component.appearance.selection ?? "explicit"}
464
+ onChange={(type, selection) => onComponent({ ...component, appearance: { ...component.appearance, type, selection } })}
465
+ />}
466
+ {component.kind === "chart" && <ChartTypePicker
467
+ value={component.appearance.template}
468
+ selection={component.appearance.selection ?? "explicit"}
469
+ hasTopology={Boolean(component.topology)}
470
+ onChange={(template, selection) => {
471
+ if (template === "sankey") {
472
+ onComponent({ ...component, appearance: { ...component.appearance, template, selection } });
473
+ } else if (!component.topology) {
474
+ onComponent({ ...component, topology: undefined, appearance: { ...component.appearance, template, selection } });
475
+ }
476
+ }}
477
+ />}
478
+ <fieldset>
479
+ <legend>Position & size</legend>
480
+ <div className="geometry">
481
+ {(["x", "y", "width", "height"] as const).map((key) => (
482
+ <Field key={key} label={humanize(key)}>
483
+ <span className="parameter-input">
484
+ <input
485
+ name={key}
486
+ autoComplete="off"
487
+ type="number"
488
+ step="1"
489
+ value={Math.round(rect[key])}
490
+ onChange={(e) => {
491
+ if (e.target.value !== "")
492
+ geometry(key, e.target.valueAsNumber);
493
+ }}
494
+ onBlur={onEditEnd}
495
+ />
496
+ <span className="parameter-unit" aria-hidden="true">px</span>
497
+ </span>
498
+ </Field>
499
+ ))}
500
+ </div>
501
+ </fieldset>
502
+ {component.customVisual && (
503
+ <fieldset className="custom-visual-summary">
504
+ <legend>Agent visual</legend>
505
+ <p>{component.customVisual.description}</p>
506
+ <dl>
507
+ <div>
508
+ <dt>Source</dt>
509
+ <dd>
510
+ {component.customVisual.format === "vector"
511
+ ? `${component.customVisual.elements.length} editable vector elements`
512
+ : "Legacy self-contained SVG"}
513
+ </dd>
514
+ </div>
515
+ <div>
516
+ <dt>Local viewport</dt>
517
+ <dd>
518
+ {component.customVisual.viewBox.width} × {component.customVisual.viewBox.height}
519
+ </dd>
520
+ </div>
521
+ <div>
522
+ <dt>Fit</dt>
523
+ <dd>{component.customVisual.fit ?? "contain"}</dd>
524
+ </div>
525
+ </dl>
526
+ <p className="field-help">
527
+ {component.customVisual.format === "vector"
528
+ ? "Select the visual and press Enter or choose Edit elements. Double-click an element to edit its text or attributes here. Only top-level line endpoints have drag handles; paths use attribute controls."
529
+ : "This legacy SVG is an opaque compatibility fallback. Ask an agent to convert it to editable vector elements."}
530
+ </p>
531
+ {vectorVisual && (
532
+ <>
533
+ <Field label="Vector element">
534
+ <select
535
+ value={selectedVectorElementId ?? ""}
536
+ onChange={(event) =>
537
+ onSelectVectorElement(event.target.value || undefined)
538
+ }
539
+ >
540
+ <option value="">Select an element</option>
541
+ {vectorVisual.elements.map((element) => (
542
+ <option key={element.id} value={element.id}>
543
+ {element.kind} · {element.id}
544
+ </option>
545
+ ))}
546
+ </select>
547
+ </Field>
548
+ {vectorElement && (
549
+ <div className="vector-element-editor" ref={vectorEditor} tabIndex={-1}>
550
+ <strong>{humanize(vectorElement.kind)} · {vectorElement.id}</strong>
551
+ <div className="vector-actions">
552
+ {([-1, 1] as const).map((direction) => (
553
+ <button
554
+ key={direction}
555
+ type="button"
556
+ disabled={moveVectorElement(vectorVisual.elements, vectorElement.id, direction) === vectorVisual.elements}
557
+ onClick={() => onComponent({
558
+ ...component,
559
+ customVisual: {
560
+ ...vectorVisual,
561
+ elements: moveVectorElement(vectorVisual.elements, vectorElement.id, direction),
562
+ },
563
+ })}
564
+ >{direction === -1 ? "Send backward" : "Bring forward"}</button>
565
+ ))}
566
+ <button
567
+ type="button"
568
+ onClick={() => {
569
+ onComponent({
570
+ ...component,
571
+ customVisual: {
572
+ ...vectorVisual,
573
+ elements: removeVectorElement(vectorVisual.elements, vectorElement.id),
574
+ },
575
+ });
576
+ onSelectVectorElement(undefined);
577
+ }}
578
+ >Delete element</button>
579
+ </div>
580
+ {(vectorElement.kind === "text" || vectorElement.kind === "tspan") && (
581
+ <Field label="Text">
582
+ <textarea
583
+ rows={3}
584
+ value={vectorElement.text ?? ""}
585
+ onChange={(event) =>
586
+ updateVectorElement({
587
+ ...vectorElement,
588
+ text: event.target.value,
589
+ })
590
+ }
591
+ onBlur={onEditEnd}
592
+ />
593
+ </Field>
594
+ )}
595
+ <div className="vector-attributes">
596
+ {Object.entries(vectorElement.attributes).map(([name, value]) => (
597
+ <Field key={name} label={humanize(name)}>
598
+ <input
599
+ name={`vector-${name}`}
600
+ list={`theme-options-${name}`}
601
+ autoComplete="off"
602
+ value={value}
603
+ onChange={(event) =>
604
+ updateVectorElement({
605
+ ...vectorElement,
606
+ attributes: {
607
+ ...vectorElement.attributes,
608
+ [name]: event.target.value,
609
+ },
610
+ })
611
+ }
612
+ onBlur={onEditEnd}
613
+ />
614
+ <datalist id={`theme-options-${name}`}>
615
+ {tokensForAttribute(name).map((token) => <option key={token} value={`theme:${token}`} />)}
616
+ </datalist>
617
+ </Field>
618
+ ))}
619
+ </div>
620
+ </div>
621
+ )}
622
+ </>
623
+ )}
624
+ </fieldset>
625
+ )}
626
+ {!component.customVisual && <fieldset
627
+ className="appearance-fieldset"
628
+ >
629
+ <legend>Appearance</legend>
630
+ {component.kind === "table" && (
631
+ <AppearancePicker
632
+ label="Table style"
633
+ name="tableStyle"
634
+ value={tableStyleForAppearance(component.appearance)}
635
+ options={tableStyles}
636
+ onChange={(value) =>
637
+ onComponent({
638
+ ...component,
639
+ appearance: {
640
+ ...component.appearance,
641
+ ...tableAppearanceForStyle(value as TableStyle),
642
+ },
643
+ })
644
+ }
645
+ />
646
+ )}
647
+ {Object.entries(appearanceOptions[component.kind])
648
+ .filter(([key]) => !["selection", "type", "template"].includes(key))
649
+ .filter(([key]) => component.kind !== "text-block" || ["alignment", "border", "rule"].includes(key))
650
+ .filter(([key]) => !["diagram", "chart"].includes(component.kind) || !["density", "emphasis", "colorScheme"].includes(key))
651
+ .filter(
652
+ ([key]) =>
653
+ component.kind !== "table" ||
654
+ !["header", "grid", "border", "colorScheme"].includes(key),
655
+ )
656
+ .filter(([key]) => {
657
+ if (key !== "orientation") return true;
658
+ if (component.kind === "chart")
659
+ return appearance?.template === "grouped-bar";
660
+ return true;
661
+ })
662
+ .map(([key, options]) => {
663
+ return (
664
+ <AppearancePicker
665
+ key={key}
666
+ name={key}
667
+ label={appearanceLabels[key] ?? humanize(key)}
668
+ value={appearance?.[key] ?? defaults[key] ?? options[0]}
669
+ options={options}
670
+ onChange={(value) => {
671
+ const nextAppearance = {
672
+ ...component.appearance,
673
+ [key]: value,
674
+ } as Record<string, string>;
675
+ const nextComponent = {
676
+ ...component,
677
+ appearance: nextAppearance,
678
+ } as CompositionComponent;
679
+ onComponent(nextComponent);
680
+ }}
681
+ />
682
+ );
683
+ })}
684
+ </fieldset>}
685
+ </section>
686
+ );
687
+ }