konpeki 0.2.0 → 0.3.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 (52) hide show
  1. package/AGENTS.md +1 -1
  2. package/CONTRIBUTING.md +36 -0
  3. package/README.md +81 -28
  4. package/SECURITY.md +22 -0
  5. package/SETUP.md +128 -51
  6. package/composition/README.md +16 -3
  7. package/composition/compile.ts +1 -1
  8. package/composition/schema.json +9 -0
  9. package/composition/schema.ts +3 -1
  10. package/composition/types.ts +3 -1
  11. package/design/themes/README.md +25 -7
  12. package/design/themes/index.ts +15 -4
  13. package/docs/development.md +79 -11
  14. package/docs/workflow.md +11 -4
  15. package/index.html +17 -1
  16. package/package.json +10 -2
  17. package/plugin.json +22 -0
  18. package/public/og.png +0 -0
  19. package/runtime/konpeki.mjs +41 -13
  20. package/skills/konpeki/SKILL.md +186 -0
  21. package/skills/konpeki/assets/blank.json +23 -0
  22. package/skills/konpeki/scripts/ensure-runtime.mjs +69 -0
  23. package/skills/konpeki/scripts/prepare-document.mjs +40 -0
  24. package/slides/README.md +35 -132
  25. package/slides/drawing-references.md +32 -0
  26. package/slides/github-cover/PROMPT.md +21 -0
  27. package/slides/github-cover/README.md +21 -0
  28. package/slides/github-cover/author.ts +67 -0
  29. package/slides/github-cover/composition.json +595 -0
  30. package/slides/introducing-konpeki/PROMPT.md +25 -14
  31. package/slides/introducing-konpeki/README.md +32 -42
  32. package/slides/introducing-konpeki/SOURCE.md +13 -14
  33. package/slides/introducing-konpeki/author.ts +44 -42
  34. package/slides/introducing-konpeki/composition.json +135 -135
  35. package/src/app/App.tsx +149 -56
  36. package/src/components/Canvas.tsx +9 -3
  37. package/src/components/InspectorPanel.tsx +104 -67
  38. package/src/components/RevisionNotes.tsx +6 -7
  39. package/src/components/RightPanel.tsx +42 -10
  40. package/src/components/VectorOverflowWarning.tsx +35 -8
  41. package/src/components/WorkspaceChrome.tsx +125 -10
  42. package/src/components/ui.tsx +95 -1
  43. package/src/lib/storage.ts +38 -10
  44. package/src/lib/use-file-session.ts +60 -38
  45. package/src/main.tsx +3 -0
  46. package/src/styles/base.css +7 -0
  47. package/src/styles/chrome.css +188 -25
  48. package/src/styles/feedback.css +101 -49
  49. package/src/styles/left-panel.css +27 -6
  50. package/src/styles/right-panel.css +114 -30
  51. package/src/styles/shell.css +96 -33
  52. package/.agents/skills/authoring-visuals/SKILL.md +0 -93
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  themeIds,
3
+ typographyIds,
3
4
  type CompositionComponent,
4
5
  type CompositionSlide,
5
6
  type ChartTemplate,
@@ -7,6 +8,7 @@ import {
7
8
  type Rect,
8
9
  type TextColor,
9
10
  type ThemeId,
11
+ type TypographyId,
10
12
  type VectorElement,
11
13
  } from "../../composition/types.ts";
12
14
  import {
@@ -30,11 +32,11 @@ import {
30
32
  type Draft,
31
33
  } from "../lib/model.ts";
32
34
  import { composerPalette, themeLabel } from "../lib/theme.ts";
35
+ import { getTheme, typographyLabels } from "../../design/themes/index.ts";
33
36
  import { DiagramTypeIcon } from "./DiagramTypeIcon.tsx";
34
- import { Field, Select, humanize } from "./ui.tsx";
37
+ import { Field, NumberField, Select, humanize } from "./ui.tsx";
35
38
  import { moveVectorElement, removeVectorElement } from "../../composition/vector.ts";
36
39
  import { tokensForAttribute } from "../../composition/theme-tokens.ts";
37
- import { VectorOverflowWarning } from "./VectorOverflowWarning.tsx";
38
40
  import { PageSizePicker } from "./PageSizePicker.tsx";
39
41
 
40
42
  const appearanceLabels: Record<string, string> = {
@@ -141,7 +143,7 @@ function AppearancePicker({
141
143
  );
142
144
  }
143
145
 
144
- function ThemePicker({
146
+ function PalettePicker({
145
147
  value,
146
148
  mode,
147
149
  onChange,
@@ -152,7 +154,7 @@ function ThemePicker({
152
154
  }) {
153
155
  return (
154
156
  <fieldset className="theme-picker">
155
- <legend>Theme</legend>
157
+ <legend>Color palette</legend>
156
158
  <div className="theme-options">
157
159
  {themeIds.map((id) => {
158
160
  const palette = composerPalette(id, mode);
@@ -203,6 +205,22 @@ function SegmentedControl({ label, value, options, onChange }: {
203
205
  );
204
206
  }
205
207
 
208
+ function AutoTemplateButton({ selected, onClick }: { selected: boolean; onClick: () => void }) {
209
+ return (
210
+ <button type="button" className={selected ? "selected" : ""} aria-pressed={selected} onClick={onClick}>
211
+ <svg className="diagram-type-icon" viewBox="0 0 48 32" aria-hidden="true">
212
+ <rect x="13" y="5" width="22" height="22" rx="4" />
213
+ <circle className="filled" cx="19" cy="11" r="1.5" />
214
+ <circle className="filled" cx="29" cy="11" r="1.5" />
215
+ <circle className="filled" cx="24" cy="16" r="1.5" />
216
+ <circle className="filled" cx="19" cy="21" r="1.5" />
217
+ <circle className="filled" cx="29" cy="21" r="1.5" />
218
+ </svg>
219
+ <span>YOLO</span>
220
+ </button>
221
+ );
222
+ }
223
+
206
224
  function DiagramTypePicker({
207
225
  value,
208
226
  selection,
@@ -218,22 +236,7 @@ function DiagramTypePicker({
218
236
  <div className="diagram-type-options" aria-labelledby="diagram-type-label">
219
237
  <div className="diagram-type-category">
220
238
  <div>
221
- <button
222
- type="button"
223
- className={selection === "auto" ? "selected" : ""}
224
- aria-pressed={selection === "auto"}
225
- onClick={() => onChange(value, "auto")}
226
- >
227
- <svg className="diagram-type-icon" viewBox="0 0 48 32" aria-hidden="true">
228
- <rect x="13" y="5" width="22" height="22" rx="4" />
229
- <circle className="filled" cx="19" cy="11" r="1.5" />
230
- <circle className="filled" cx="29" cy="11" r="1.5" />
231
- <circle className="filled" cx="24" cy="16" r="1.5" />
232
- <circle className="filled" cx="19" cy="21" r="1.5" />
233
- <circle className="filled" cx="29" cy="21" r="1.5" />
234
- </svg>
235
- <span>YOLO</span>
236
- </button>
239
+ <AutoTemplateButton selected={selection === "auto"} onClick={() => onChange(value, "auto")} />
237
240
  </div>
238
241
  </div>
239
242
  {diagramCategories.map((category) => (
@@ -270,21 +273,60 @@ function ChartTypePicker({ value, selection, hasTopology, onChange }: {
270
273
  }) {
271
274
  return (
272
275
  <details className="field chart-type-field diagram-type-field">
273
- <summary>{selection === "auto" ? "YOLO" : `Required form: ${chartDefinitions[value].label}`}</summary>
274
- <button type="button" aria-pressed={selection === "auto"} onClick={() => onChange(value, "auto")}>YOLO</button>
275
- <AppearancePicker
276
- label="Chart form"
277
- name="template"
278
- value={selection === "explicit" ? value : ""}
279
- options={chartTemplates}
280
- labels={Object.fromEntries(Object.entries(chartDefinitions).map(([type, definition]) => [type, definition.label]))}
281
- description={`${chartDefinitions[value].expression}${hasTopology ? " Recorded Sankey topology is preserved; remove it before changing chart type, even in YOLO." : ""}`}
282
- disabledOptions={hasTopology ? new Set(chartTemplates.filter(type => type !== "sankey")) : undefined}
283
- onChange={(value) => {
284
- const template = chartTemplates.find(type => type === value);
285
- if (template) onChange(template, "explicit");
286
- }}
287
- />
276
+ <summary id="chart-type-label">Template</summary>
277
+ <div className="diagram-type-options chart-type-options" aria-labelledby="chart-type-label">
278
+ <div className="diagram-type-category">
279
+ <div>
280
+ <AutoTemplateButton selected={selection === "auto"} onClick={() => onChange(value, "auto")} />
281
+ </div>
282
+ </div>
283
+ <div className="diagram-type-category" role="group" aria-label="Chart templates">
284
+ <div>
285
+ {chartTemplates.map((template) => (
286
+ <button
287
+ key={template}
288
+ type="button"
289
+ className={selection === "explicit" && value === template ? "selected" : ""}
290
+ aria-pressed={selection === "explicit" && value === template}
291
+ disabled={hasTopology && template !== "sankey"}
292
+ title={hasTopology && template !== "sankey"
293
+ ? "Remove the recorded Sankey topology before changing chart type."
294
+ : chartDefinitions[template].expression}
295
+ onClick={() => onChange(template, "explicit")}
296
+ >
297
+ <span className="appearance-preview" data-property="template" data-value={template} aria-hidden="true">
298
+ {template === "scatter" ? (
299
+ <svg viewBox="0 0 36 24">
300
+ <path d="M3 2v20h31" fill="none" stroke="var(--preview-muted)" strokeWidth="1.2" />
301
+ {[[7, 6], [10, 11], [14, 9], [17, 13], [20, 16], [24, 15], [28, 18], [29, 7], [32, 19]].map(([cx, cy], index) => (
302
+ <circle key={index} cx={cx} cy={cy} r="1.35" fill="var(--preview-accent)" />
303
+ ))}
304
+ </svg>
305
+ ) : template === "sankey" ? (
306
+ <svg viewBox="0 0 36 24" fill="var(--preview-accent)">
307
+ <g fillOpacity="0.3">
308
+ <path d="M4 4C10.5 4 10.5 2 17 2V12.5C10.5 12.5 10.5 14.5 4 14.5Z" />
309
+ <path d="M4 14.5C10.5 14.5 10.5 17.5 17 17.5V22C10.5 22 10.5 19 4 19Z" />
310
+ <path d="M19 8C25.5 8 25.5 10 32 10V14.5C25.5 14.5 25.5 12.5 19 12.5Z" />
311
+ <path d="M19 17.5C25.5 17.5 25.5 18 32 18V22.5C25.5 22.5 25.5 22 19 22Z" />
312
+ </g>
313
+ <path d="M19 2C25.5 2 25.5 1 32 1V7C25.5 7 25.5 8 19 8Z" fillOpacity="0.65" />
314
+ <rect x="2" y="4" width="2" height="15" />
315
+ <rect x="17" y="2" width="2" height="10.5" />
316
+ <rect x="17" y="17.5" width="2" height="4.5" />
317
+ <rect x="32" y="1" width="2" height="6" />
318
+ <rect x="32" y="10" width="2" height="4.5" />
319
+ <rect x="32" y="18" width="2" height="4.5" />
320
+ </svg>
321
+ ) : Array.from({ length: 4 }, (_, index) => <i key={index} />)}
322
+ </span>
323
+ <span>{chartDefinitions[template].label}</span>
324
+ </button>
325
+ ))}
326
+ </div>
327
+ </div>
328
+ </div>
329
+ {hasTopology && <small className="visualization-description">Recorded Sankey topology is preserved; remove it before changing chart type, even in YOLO.</small>}
288
330
  </details>
289
331
  );
290
332
  }
@@ -326,38 +368,47 @@ export function InspectorPanel({
326
368
  target?.scrollIntoView({ block: "nearest" });
327
369
  control?.select();
328
370
  }, [vectorEditRequest]);
371
+ const theme = draft.theme ?? { id: "plex", mode: "paper" };
372
+ const typography = getTheme(themeLabel(theme.id), theme.mode, theme.typography);
329
373
  if (!component)
330
374
  return (
331
375
  <section className="inspector-content" aria-labelledby="inspector-heading">
332
376
  <h2 id="inspector-heading">Page</h2>
333
377
  <PageSizePicker key={slide.id} slide={slide} onChange={onSlide} />
334
378
  <h2>Document appearance</h2>
335
- <ThemePicker
336
- value={draft.theme?.id ?? "plex"}
337
- mode={draft.theme?.mode ?? "paper"}
379
+ <Select
380
+ label="Font"
381
+ value={typography.typographyId}
382
+ options={typographyIds}
383
+ optionLabels={typographyLabels}
384
+ onChange={(value) => onDraft({ theme: { ...theme, typography: value as TypographyId } })}
385
+ />
386
+ <PalettePicker
387
+ value={theme.id}
388
+ mode={theme.mode}
338
389
  onChange={(value) =>
339
390
  onDraft({
340
391
  theme: {
392
+ ...theme,
341
393
  id: value,
342
- mode: draft.theme?.mode ?? "paper",
394
+ typography: typography.typographyId,
343
395
  },
344
396
  })
345
397
  }
346
398
  />
347
399
  <SegmentedControl
348
- label="Theme mode"
349
- value={draft.theme?.mode ?? "paper"}
400
+ label="Background"
401
+ value={theme.mode}
350
402
  options={["paper", "night"]}
351
403
  onChange={(value) =>
352
404
  onDraft({
353
405
  theme: {
354
- id: draft.theme?.id ?? "plex",
406
+ ...theme,
355
407
  mode: value as "paper" | "night",
356
408
  },
357
409
  })
358
410
  }
359
411
  />
360
- <VectorOverflowWarning slide={slide} theme={draft.theme} />
361
412
  <h2 className="slide-settings-heading">Page numbering</h2>
362
413
  <fieldset className="page-number-picker">
363
414
  <SegmentedControl
@@ -424,9 +475,9 @@ export function InspectorPanel({
424
475
  if (key === "x") next.x = clamp(value, 0, slide.canvas.width - rect.width);
425
476
  if (key === "y") next.y = clamp(value, 0, slide.canvas.height - rect.height);
426
477
  if (key === "width")
427
- next.width = clamp(value, Math.min(180, slide.canvas.width - rect.x), slide.canvas.width - rect.x);
478
+ next.width = clamp(value, Math.min(180, rect.width, slide.canvas.width - rect.x), slide.canvas.width - rect.x);
428
479
  if (key === "height")
429
- next.height = clamp(value, Math.min(72, slide.canvas.height - rect.y), slide.canvas.height - rect.y);
480
+ next.height = clamp(value, Math.min(72, rect.height, slide.canvas.height - rect.y), slide.canvas.height - rect.y);
430
481
  onComponent(
431
482
  { ...component!, preferredRect: next },
432
483
  `geometry:${component!.id}`,
@@ -451,18 +502,17 @@ export function InspectorPanel({
451
502
  <span>{componentLabel ?? componentLabels[component.kind]}</span>
452
503
  </h2>
453
504
  {component.kind === "text-block" && !component.customVisual && <>
454
- <VectorOverflowWarning slide={slide} theme={draft.theme} />
455
505
  <Field label="Text"><textarea name="text-content" value={component.content ?? ""}
456
506
  onChange={(event) => onComponent({ ...component, content: event.target.value }, `content:${component.id}`)} onBlur={onEditEnd} /></Field>
457
507
  <div className="text-typography-fields">
458
- <Field label="Font size"><input type="number" min={8} max={240} value={component.textStyle?.size ?? 36}
459
- onChange={(event) => { const size = event.target.valueAsNumber; if (size >= 8 && size <= 240) onComponent({ ...component, textStyle: { ...component.textStyle, size } }, `font:${component.id}`); }} onBlur={onEditEnd} /></Field>
508
+ <NumberField label="Font size" min={8} max={240} value={component.textStyle?.size ?? 36}
509
+ onCommit={size => onComponent({ ...component, textStyle: { ...component.textStyle, size } }, `font:${component.id}`)} onEditEnd={onEditEnd} />
460
510
  <Select label="Text color" value={component.textStyle?.color ?? "ink"} options={["ink", "muted", "accent"]}
461
511
  onChange={(color) => onComponent({ ...component, textStyle: { ...component.textStyle, color: color as "ink" | "muted" | "accent" } })} />
462
512
  <Select label="Font weight" value={String(component.textStyle?.weight ?? 400)} options={["400", "500", "600"]}
463
513
  onChange={(weight) => onComponent({ ...component, textStyle: { ...component.textStyle, weight: Number(weight) as 400 | 500 | 600 } })} />
464
- <Field label="Line height"><input type="number" min={1} max={3} step={0.1} value={component.textStyle?.lineHeight ?? 1.4}
465
- onChange={(event) => { const lineHeight = event.target.valueAsNumber; if (lineHeight >= 1 && lineHeight <= 3) onComponent({ ...component, textStyle: { ...component.textStyle, lineHeight } }, `leading:${component.id}`); }} onBlur={onEditEnd} /></Field>
514
+ <NumberField label="Line height" min={1} max={3} step={0.1} value={component.textStyle?.lineHeight ?? 1.4}
515
+ onCommit={lineHeight => onComponent({ ...component, textStyle: { ...component.textStyle, lineHeight } }, `leading:${component.id}`)} onEditEnd={onEditEnd} />
466
516
  </div>
467
517
  </>}
468
518
  <Field label={component.kind === "diagram" || component.kind === "chart" ? "What should this explain?" : "Content intent"}>
@@ -502,23 +552,10 @@ export function InspectorPanel({
502
552
  <legend>Position & size</legend>
503
553
  <div className="geometry">
504
554
  {(["x", "y", "width", "height"] as const).map((key) => (
505
- <Field key={key} label={humanize(key)}>
506
- <span className="parameter-input">
507
- <input
508
- name={key}
509
- autoComplete="off"
510
- type="number"
511
- step="1"
512
- value={Math.round(rect[key])}
513
- onChange={(e) => {
514
- if (e.target.value !== "")
515
- geometry(key, e.target.valueAsNumber);
516
- }}
517
- onBlur={onEditEnd}
518
- />
519
- <span className="parameter-unit" aria-hidden="true">px</span>
520
- </span>
521
- </Field>
555
+ <NumberField key={key} name={key} label={humanize(key)} unit="px" value={rect[key]}
556
+ min={key === "width" ? Math.min(180, rect.width, slide.canvas.width - rect.x) : key === "height" ? Math.min(72, rect.height, slide.canvas.height - rect.y) : 0}
557
+ max={key === "x" ? slide.canvas.width - rect.width : key === "y" ? slide.canvas.height - rect.height : key === "width" ? slide.canvas.width - rect.x : slide.canvas.height - rect.y}
558
+ onCommit={value => geometry(key, value)} onEditEnd={onEditEnd} />
522
559
  ))}
523
560
  </div>
524
561
  </fieldset>
@@ -3,7 +3,7 @@ import type { CompositionDocument } from "../../composition/types.ts";
3
3
  import { componentInstanceLabel } from "../lib/model.ts";
4
4
  import { activeRequest, type ReviewState, type ReviewTarget } from "../lib/review.ts";
5
5
 
6
- export function RevisionNotes({ document, target, review, disabled, onAdd, onRemove, onSelect }: {
6
+ export function RevisionNotes({ document, target, review, disabled, onAdd, onRemove, onSelect, onNotice }: {
7
7
  document: CompositionDocument;
8
8
  target: ReviewTarget;
9
9
  review: ReviewState;
@@ -11,10 +11,10 @@ export function RevisionNotes({ document, target, review, disabled, onAdd, onRem
11
11
  onAdd: (target: ReviewTarget, text: string) => Promise<void>;
12
12
  onRemove: (id: string) => Promise<void>;
13
13
  onSelect: (target: ReviewTarget) => void;
14
+ onNotice: (message: string) => void;
14
15
  }) {
15
16
  const [drafts, setDrafts] = useState<Record<string, string>>({});
16
17
  const [saving, setSaving] = useState(false);
17
- const [error, setError] = useState("");
18
18
  const key = JSON.stringify(target);
19
19
  const text = drafts[key] ?? "";
20
20
  function label(value: ReviewTarget) {
@@ -28,16 +28,16 @@ export function RevisionNotes({ document, target, review, disabled, onAdd, onRem
28
28
  return `${slide.name} · ${name}${value.elementId ? ` · ${element ? value.elementId : "Deleted element"}` : ""}`;
29
29
  }
30
30
  async function run(action: () => Promise<void>) {
31
- setSaving(true); setError("");
32
- try { await action(); }
33
- catch (error) { setError(error instanceof Error ? error.message : "Could not save revision note"); }
31
+ setSaving(true);
32
+ try { await action(); onNotice(""); }
33
+ catch (error) { onNotice(error instanceof Error ? error.message : "Could not save revision note"); }
34
34
  finally { setSaving(false); }
35
35
  }
36
36
  const pending = review.notes.filter(n => !n.resolved);
37
37
  return <section className="revision-notes" aria-label="Revision notes">
38
38
  <form onSubmit={event => { event.preventDefault(); void run(async () => {
39
39
  await onAdd(target, text);
40
- setDrafts(current => ({ ...current, [key]: "" }));
40
+ setDrafts(current => ({ ...current, [key]: current[key] === text ? "" : current[key] }));
41
41
  }); }}>
42
42
  <label htmlFor="revision-note">Request a change</label>
43
43
  <p className="revision-note-scope">{label(target)}</p>
@@ -45,7 +45,6 @@ export function RevisionNotes({ document, target, review, disabled, onAdd, onRem
45
45
  onChange={event => setDrafts(current => ({ ...current, [key]: event.target.value }))} />
46
46
  <div className="revision-note-submit"><button type="submit" disabled={disabled || saving || !text.trim()}>{saving ? "Saving…" : "Add note"}</button></div>
47
47
  </form>
48
- {error && <p role="alert" className="revision-note-error">{error}</p>}
49
48
  <p className="revision-note-count">{pending.length} pending</p>
50
49
  {pending.length > 0 && <ol className="revision-note-list">{pending.map((note, i) => <li key={note.id} id={`note-${note.id}`}>
51
50
  <button type="button" className="revision-note-target" onClick={() => onSelect(note)}><span>{i + 1}</span>{label(note)}</button>
@@ -5,6 +5,7 @@ import type {
5
5
  } from "../../composition/types.ts";
6
6
  import { componentInstanceLabel, type Draft } from "../lib/model.ts";
7
7
  import { InspectorPanel } from "./InspectorPanel.tsx";
8
+ import { VectorOverflowWarning } from "./VectorOverflowWarning.tsx";
8
9
 
9
10
  export type RightPanelView = "settings" | "layers";
10
11
 
@@ -20,6 +21,7 @@ function LayersPanel({
20
21
  onReorderPaintOrder: (ids: string[]) => void;
21
22
  }) {
22
23
  const [dragging, setDragging] = useState<string>();
24
+ const [dropIndex, setDropIndex] = useState<number>();
23
25
  const layers = [...slide.paintOrder].reverse();
24
26
  function moveLayer(id: string, toIndex: number) {
25
27
  const next = layers.filter((item) => item !== id);
@@ -33,7 +35,36 @@ function LayersPanel({
33
35
  <span>Front to back</span>
34
36
  </div>
35
37
  {layers.length ? (
36
- <div className="layer-list">
38
+ <div className="layer-list"
39
+ onDragOver={(event) => {
40
+ if (!dragging) return;
41
+ event.preventDefault();
42
+ const rows = [...event.currentTarget.querySelectorAll<HTMLElement>(".layer-row")];
43
+ const next = rows.findIndex((row) => {
44
+ const rect = row.getBoundingClientRect();
45
+ return event.clientY < rect.top + rect.height / 2;
46
+ });
47
+ const boundary = next < 0 ? layers.length : next;
48
+ const sourceIndex = layers.indexOf(dragging);
49
+ const unchanged = boundary === sourceIndex || boundary === sourceIndex + 1;
50
+ event.dataTransfer.dropEffect = unchanged ? "none" : "move";
51
+ setDropIndex(unchanged ? undefined : boundary);
52
+ }}
53
+ onDragLeave={(event) => {
54
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
55
+ setDropIndex(undefined);
56
+ }
57
+ }}
58
+ onDrop={(event) => {
59
+ if (!dragging) return;
60
+ event.preventDefault();
61
+ if (dropIndex !== undefined) {
62
+ moveLayer(dragging, dropIndex > layers.indexOf(dragging) ? dropIndex - 1 : dropIndex);
63
+ }
64
+ setDragging(undefined);
65
+ setDropIndex(undefined);
66
+ }}
67
+ >
37
68
  {layers.map((id, index) => {
38
69
  const component = slide.components.find((item) => item.id === id)!;
39
70
  const groups = slide.groups
@@ -44,23 +75,18 @@ function LayersPanel({
44
75
  <div
45
76
  key={id}
46
77
  data-layer={id}
78
+ data-drop-edge={dropIndex === index ? "before" : dropIndex === layers.length && index === layers.length - 1 ? "after" : undefined}
47
79
  className={`layer-row ${selectedComponentId === id ? "selected" : ""} ${dragging === id ? "dragging" : ""}`}
48
80
  draggable
49
81
  onDragStart={(event) => {
50
82
  setDragging(id);
83
+ setDropIndex(undefined);
51
84
  event.dataTransfer.effectAllowed = "move";
52
85
  event.dataTransfer.setData("text/plain", id);
53
86
  }}
54
- onDragEnd={() => setDragging(undefined)}
55
- onDragOver={(event) => {
56
- event.preventDefault();
57
- event.dataTransfer.dropEffect = "move";
58
- }}
59
- onDrop={(event) => {
60
- event.preventDefault();
61
- const source = dragging || event.dataTransfer.getData("text/plain");
62
- if (source && source !== id) moveLayer(source, index);
87
+ onDragEnd={() => {
63
88
  setDragging(undefined);
89
+ setDropIndex(undefined);
64
90
  }}
65
91
  >
66
92
  <span className="layer-drag" aria-hidden="true">⠿</span>
@@ -112,6 +138,7 @@ export function RightPanel({
112
138
  onSelectSlide,
113
139
  onSelectComponent,
114
140
  onSelectVectorElement,
141
+ onSelectOverflow,
115
142
  onReorderPaintOrder,
116
143
  onComponent,
117
144
  onSlide,
@@ -132,6 +159,7 @@ export function RightPanel({
132
159
  onSelectSlide: () => void;
133
160
  onSelectComponent: (id: string) => void;
134
161
  onSelectVectorElement: (id?: string) => void;
162
+ onSelectOverflow: (componentId: string, elementId?: string) => void;
135
163
  onReorderPaintOrder: (ids: string[]) => void;
136
164
  onComponent: (component: CompositionComponent, mergeKey?: string) => void;
137
165
  onSlide: (slide: CompositionSlide, mergeKey?: string) => void;
@@ -154,6 +182,9 @@ export function RightPanel({
154
182
  </button>
155
183
  ))}
156
184
  </div>
185
+ <div className="overflow-control" inert={collapsed}>
186
+ <VectorOverflowWarning slide={slide} theme={draft.theme} onSelect={onSelectOverflow} />
187
+ </div>
157
188
  <button
158
189
  type="button"
159
190
  className="panel-toggle"
@@ -174,6 +205,7 @@ export function RightPanel({
174
205
  <div className="right-panel-body" inert={collapsed}>
175
206
  {view === "settings" ? (
176
207
  <InspectorPanel
208
+ key={component?.id ?? slide.id}
177
209
  component={component}
178
210
  componentLabel={componentLabel}
179
211
  slide={slide}
@@ -1,22 +1,31 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useId, useRef, useState } from "react";
2
2
  import type { CompositionSlide, CompositionDocument } from "../../composition/types.ts";
3
+ import { componentInstanceLabel } from "../lib/model.ts";
3
4
 
4
5
  /** Measure rendered text, including nested SVG transforms, after fonts settle. */
5
- export function VectorOverflowWarning({ slide, theme }: {
6
+ export function VectorOverflowWarning({ slide, theme, onSelect }: {
6
7
  slide: CompositionSlide;
7
8
  theme: CompositionDocument["theme"];
9
+ onSelect: (componentId: string, elementId?: string) => void;
8
10
  }) {
9
- const [count, setCount] = useState(0);
11
+ const [issues, setIssues] = useState<{ componentId: string; elementId?: string; text: string }[]>([]);
12
+ const id = useId();
13
+ const popover = useRef<HTMLDivElement>(null);
14
+ useEffect(() => { popover.current?.hidePopover(); }, [slide.id]);
10
15
  useEffect(() => {
11
16
  let cancelled = false;
12
17
  let frame = 0;
13
18
  function measure() {
14
19
  frame = requestAnimationFrame(() => {
15
20
  if (cancelled) return;
16
- let overflow = 0;
21
+ const overflow: typeof issues = [];
22
+ function add(text: Element) {
23
+ const componentId = text.closest<HTMLElement>("[data-component]")?.dataset.component;
24
+ if (componentId) overflow.push({ componentId, elementId: text.getAttribute("data-vector-element") ?? undefined, text: text.textContent?.trim().slice(0, 80) ?? "Text" });
25
+ }
17
26
  document.querySelectorAll<HTMLElement>(".stage .text-block-content").forEach((text) => {
18
27
  const surface = text.parentElement;
19
- if (surface && (text.scrollHeight > surface.clientHeight + 1 || text.scrollWidth > surface.clientWidth + 1)) overflow++;
28
+ if (surface && (text.scrollHeight > surface.clientHeight + 1 || text.scrollWidth > surface.clientWidth + 1)) add(text);
20
29
  });
21
30
  document.querySelectorAll<SVGSVGElement>(".stage .custom-vector-art").forEach((svg) => {
22
31
  const root = svg.getScreenCTM();
@@ -32,15 +41,33 @@ export function VectorOverflowWarning({ slide, theme }: {
32
41
  if (corners.some(([x, y]) => {
33
42
  const p = new DOMPoint(x, y).matrixTransform(transform);
34
43
  return p.x < view.x - 1 || p.y < view.y - 1 || p.x > view.x + view.width + 1 || p.y > view.y + view.height + 1;
35
- })) overflow++;
44
+ })) add(text);
36
45
  });
37
46
  });
38
- setCount(overflow);
47
+ setIssues(overflow);
48
+ if (!overflow.length) popover.current?.hidePopover();
39
49
  });
40
50
  }
41
51
  void document.fonts.ready.then(measure);
42
52
  document.fonts.addEventListener("loadingdone", measure);
43
53
  return () => { cancelled = true; cancelAnimationFrame(frame); document.fonts.removeEventListener("loadingdone", measure); };
44
54
  }, [slide, theme]);
45
- return count ? <p role="status" className="field-error">{count} text {count === 1 ? "element exceeds" : "elements exceed"} its component viewport on this slide. Revise the text or geometry; nothing was resized.</p> : null;
55
+ return <>
56
+ <span className="sr-only" role="status">{issues.length ? `${issues.length} text overflow ${issues.length === 1 ? "warning" : "warnings"} on this page` : ""}</span>
57
+ <button type="button" className="overflow-trigger" popoverTarget={id} disabled={!issues.length}
58
+ aria-label={`${issues.length} text overflow warnings`} title="Text overflow warnings">
59
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 3 18 17H2ZM10 8v4m0 2v.5" /></svg>
60
+ <span>{issues.length}</span>
61
+ </button>
62
+ <div id={id} ref={popover} popover="auto" className="overflow-popover">
63
+ <strong>Text outside its frame</strong>
64
+ <p>Select an item to revise its text or size. Nothing was resized automatically.</p>
65
+ <ul>{issues.map(issue => <li key={`${issue.componentId}:${issue.elementId ?? "native"}`}>
66
+ <button type="button" onClick={() => { popover.current?.hidePopover(); onSelect(issue.componentId, issue.elementId); }}>
67
+ <strong>{componentInstanceLabel(slide.components, issue.componentId)}{issue.elementId ? ` · ${issue.elementId}` : ""}</strong>
68
+ <span>{issue.text}</span>
69
+ </button>
70
+ </li>)}</ul>
71
+ </div>
72
+ </>;
46
73
  }