konpeki 0.3.1 → 0.4.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 (167) hide show
  1. package/AGENTS.md +21 -28
  2. package/AUTHORING.md +68 -229
  3. package/CONTRIBUTING.md +10 -23
  4. package/README.md +98 -106
  5. package/SETUP.md +49 -129
  6. package/docs/development.md +43 -256
  7. package/docs/workflow.md +6 -108
  8. package/html/README.md +140 -0
  9. package/html/browser.ts +103 -0
  10. package/html/document.ts +38 -0
  11. package/html/floor.ts +34 -0
  12. package/html/index.html +5 -0
  13. package/html/inspect.ts +313 -0
  14. package/html/preview.css +62 -0
  15. package/html/preview.tsx +463 -0
  16. package/html/review-hints.ts +63 -0
  17. package/html/server.ts +99 -0
  18. package/html/source.ts +104 -0
  19. package/html/starter.ts +6 -0
  20. package/html/theme-authoring.md +176 -0
  21. package/html/theme.md +61 -0
  22. package/index.html +9 -9
  23. package/package.json +21 -37
  24. package/plugin.json +1 -1
  25. package/public/og.png +0 -0
  26. package/runtime/browser-B-29TH1a.mjs +595 -0
  27. package/runtime/floor-Cmk7G3pU.mjs +41 -0
  28. package/runtime/konpeki.mjs +90 -1410
  29. package/runtime/server-BAqC_5WD.mjs +2 -0
  30. package/runtime/server-DfRpfcY9.mjs +205 -0
  31. package/runtime/source-B_Ui9tMp.mjs +141 -0
  32. package/runtime/source-CZQq9GUO.mjs +2 -0
  33. package/skills/konpeki/SKILL.md +88 -172
  34. package/skills/konpeki/assets/blank.html +17 -0
  35. package/skills/konpeki/floor.md +76 -0
  36. package/skills/konpeki/references/cover.md +27 -0
  37. package/skills/konpeki/references/long-document.md +40 -0
  38. package/skills/konpeki/references/one-pager.md +27 -0
  39. package/skills/konpeki/references/patterns.md +118 -0
  40. package/skills/konpeki/references/resume.md +28 -0
  41. package/skills/konpeki/references/slides.md +28 -0
  42. package/skills/konpeki/scripts/ensure-runtime.mjs +10 -31
  43. package/skills/konpeki/scripts/prepare-document.mjs +25 -14
  44. package/src/components/PageBoard.tsx +156 -0
  45. package/src/lib/alignment.ts +21 -0
  46. package/src/lib/page-board.ts +25 -0
  47. package/src/lib/review-position.ts +19 -0
  48. package/src/styles/base.css +1 -10
  49. package/src/styles/feedback.css +2 -1
  50. package/src/styles/shell.css +94 -354
  51. package/theme-base.css +90 -0
  52. package/theme.css +56 -0
  53. package/vite.config.ts +2 -5
  54. package/composition/README.md +0 -156
  55. package/composition/compile.ts +0 -227
  56. package/composition/document.ts +0 -600
  57. package/composition/schema.json +0 -3001
  58. package/composition/schema.ts +0 -437
  59. package/composition/theme-tokens.ts +0 -16
  60. package/composition/types.ts +0 -269
  61. package/composition/validate.ts +0 -226
  62. package/composition/vector.ts +0 -143
  63. package/composition/visualizations.ts +0 -319
  64. package/design/README.md +0 -17
  65. package/design/palettes/README.md +0 -14
  66. package/design/palettes/base.ts +0 -14
  67. package/design/palettes/candidates.ts +0 -19
  68. package/design/palettes/index.ts +0 -78
  69. package/design/review/color-theme.md +0 -44
  70. package/design/review/layout.md +0 -16
  71. package/design/review/text.md +0 -18
  72. package/design/review/typography.md +0 -15
  73. package/design/review/visuals.md +0 -31
  74. package/design/semantic-patterns.md +0 -43
  75. package/design/themes/README.md +0 -40
  76. package/design/themes/index.ts +0 -24
  77. package/design/visual-languages/technical-product.md +0 -17
  78. package/design/visual-review.md +0 -88
  79. package/lib/assets.d.ts +0 -8
  80. package/lib/charts.ts +0 -18
  81. package/lib/contrast.ts +0 -16
  82. package/lib/layouts.ts +0 -50
  83. package/lib/slide.tsx +0 -42
  84. package/lib/taste.ts +0 -17
  85. package/lib/text.tsx +0 -89
  86. package/lib/typeface.ts +0 -44
  87. package/scripts/migrate-react-page.ts +0 -120
  88. package/skills/konpeki/assets/blank.json +0 -23
  89. package/slides/README.md +0 -56
  90. package/slides/architecture/PROMPT.md +0 -31
  91. package/slides/architecture/index.tsx +0 -102
  92. package/slides/article-brief/PROMPT.md +0 -35
  93. package/slides/article-brief/index.tsx +0 -71
  94. package/slides/bar-chart/PROMPT.md +0 -39
  95. package/slides/bar-chart/index.tsx +0 -97
  96. package/slides/comparison/PROMPT.md +0 -29
  97. package/slides/comparison/index.tsx +0 -95
  98. package/slides/decision-memo/PROMPT.md +0 -34
  99. package/slides/decision-memo/index.tsx +0 -85
  100. package/slides/delivery-plan/PROMPT.md +0 -45
  101. package/slides/delivery-plan/index.tsx +0 -105
  102. package/slides/drawing-references.md +0 -32
  103. package/slides/experiment/PROMPT.md +0 -44
  104. package/slides/experiment/index.tsx +0 -127
  105. package/slides/github-cover/PROMPT.md +0 -21
  106. package/slides/github-cover/README.md +0 -21
  107. package/slides/github-cover/author.ts +0 -67
  108. package/slides/github-cover/composition.json +0 -595
  109. package/slides/incident-workflow/PROMPT.md +0 -57
  110. package/slides/incident-workflow/index.tsx +0 -78
  111. package/slides/introducing-konpeki/PROMPT.md +0 -30
  112. package/slides/introducing-konpeki/README.md +0 -44
  113. package/slides/introducing-konpeki/SOURCE.md +0 -19
  114. package/slides/introducing-konpeki/author.ts +0 -165
  115. package/slides/introducing-konpeki/composition.json +0 -3270
  116. package/slides/line-chart/PROMPT.md +0 -40
  117. package/slides/line-chart/index.tsx +0 -72
  118. package/slides/migration/PROMPT.md +0 -38
  119. package/slides/migration/index.tsx +0 -89
  120. package/slides/og-images/PROMPT.md +0 -21
  121. package/slides/og-images/index.tsx +0 -76
  122. package/slides/product-introduction/PROMPT.md +0 -24
  123. package/slides/product-introduction/index.tsx +0 -105
  124. package/slides/research-brief/PROMPT.md +0 -40
  125. package/slides/research-brief/index.tsx +0 -104
  126. package/slides/results-explanation/PROMPT.md +0 -32
  127. package/slides/results-explanation/index.tsx +0 -96
  128. package/slides/retrospective/PROMPT.md +0 -43
  129. package/slides/retrospective/index.tsx +0 -105
  130. package/slides/sankey/PROMPT.md +0 -11
  131. package/slides/sankey/index.tsx +0 -93
  132. package/slides/teaching/PROMPT.md +0 -45
  133. package/slides/teaching/index.tsx +0 -124
  134. package/slides/vertical-bar-charts/PROMPT.md +0 -13
  135. package/slides/vertical-bar-charts/index.tsx +0 -97
  136. package/src/app/App.tsx +0 -845
  137. package/src/components/BuildOrb.tsx +0 -40
  138. package/src/components/Canvas.tsx +0 -1191
  139. package/src/components/DiagramTypeIcon.tsx +0 -78
  140. package/src/components/InspectorPanel.tsx +0 -757
  141. package/src/components/LeftPanel.tsx +0 -120
  142. package/src/components/PageSizePicker.tsx +0 -30
  143. package/src/components/Presentation.tsx +0 -105
  144. package/src/components/RevisionNotes.tsx +0 -55
  145. package/src/components/RightPanel.tsx +0 -233
  146. package/src/components/VectorOverflowWarning.tsx +0 -73
  147. package/src/components/WorkspaceChrome.tsx +0 -316
  148. package/src/components/ui.tsx +0 -147
  149. package/src/lib/examples/react-page-migration.json +0 -1295
  150. package/src/lib/examples.ts +0 -42
  151. package/src/lib/export-png.ts +0 -104
  152. package/src/lib/file-session.ts +0 -87
  153. package/src/lib/history.ts +0 -53
  154. package/src/lib/model.ts +0 -188
  155. package/src/lib/page-size.ts +0 -24
  156. package/src/lib/presentation.ts +0 -17
  157. package/src/lib/review.ts +0 -26
  158. package/src/lib/storage.ts +0 -71
  159. package/src/lib/theme.ts +0 -25
  160. package/src/lib/use-file-session.ts +0 -249
  161. package/src/main.tsx +0 -29
  162. package/src/styles/canvas.css +0 -299
  163. package/src/styles/chrome.css +0 -475
  164. package/src/styles/component-previews.css +0 -386
  165. package/src/styles/left-panel.css +0 -187
  166. package/src/styles/presentation.css +0 -72
  167. package/src/styles/right-panel.css +0 -1298
@@ -1,316 +0,0 @@
1
- import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
2
- import type { CompositionComponent } from "../../composition/types.ts";
3
- import { componentLabels } from "../lib/model.ts";
4
- import mark from "../assets/konpeki-mark.png";
5
- import { BuildOrb } from "./BuildOrb.tsx";
6
-
7
- const dockItems: {
8
- kind: CompositionComponent["kind"];
9
- }[] = [
10
- { kind: "text-block" },
11
- { kind: "diagram" },
12
- { kind: "chart" },
13
- { kind: "image" },
14
- { kind: "table" },
15
- ];
16
-
17
- function DockIcon({
18
- kind,
19
- }: {
20
- kind: CompositionComponent["kind"] | "select";
21
- }) {
22
- return (
23
- <svg className="dock-glyph" viewBox="0 0 24 24" aria-hidden="true">
24
- {kind === "select" ? (
25
- <path d="M5 3.8 18 12l-6.2 1.2L8.6 19Z" />
26
- ) : kind === "text-block" ? (
27
- <path d="M5 6h14M5 10h14M5 14h10M5 18h12" />
28
- ) : kind === "chart" ? (
29
- <>
30
- <path d="M5 19V5M5 19h14" />
31
- <path d="M8 16v-4M12 16V8M16 16v-7" />
32
- </>
33
- ) : kind === "diagram" ? (
34
- <>
35
- <rect x="3" y="5" width="6" height="5" rx="1" />
36
- <rect x="15" y="16" width="6" height="5" rx="1" />
37
- <path d="M9 8h9v8m-2.5-2.5L18 16l2.5-2.5" />
38
- </>
39
- ) : kind === "image" ? (
40
- <>
41
- <rect x="4" y="5" width="16" height="14" rx="1" />
42
- <circle cx="15.5" cy="9" r="1.5" />
43
- <path d="m6 17 4.5-5 3 3 2-2 2.5 4" />
44
- </>
45
- ) : kind === "table" ? (
46
- <>
47
- <rect x="3" y="5" width="18" height="14" rx="1" />
48
- <path d="M3 10h18M9 5v14M15 5v14" />
49
- </>
50
- ) : (
51
- <path d="M5 7h14M5 12h10M5 17h14" />
52
- )}
53
- </svg>
54
- );
55
- }
56
-
57
- export function WorkspaceChrome({
58
- title,
59
- titleRef,
60
- titleError,
61
- selectedKind,
62
- onTitle,
63
- onEditEnd,
64
- onExportPNG,
65
- exporting,
66
- onPresent,
67
- fileStatus,
68
- building,
69
- buildState,
70
- onBuild,
71
- browserTools,
72
- onSelectTool,
73
- onComponentTool,
74
- leftCollapsed,
75
- onToggleLeftPanel,
76
- children,
77
- }: {
78
- children: ReactNode;
79
- leftCollapsed: boolean;
80
- onToggleLeftPanel: () => void;
81
- title: string;
82
- titleRef: RefObject<HTMLInputElement | null>;
83
- titleError: boolean;
84
- selectedKind?: CompositionComponent["kind"];
85
- onTitle: (title: string, mergeKey?: string) => void;
86
- onEditEnd: () => void;
87
- onExportPNG: () => void;
88
- exporting: boolean;
89
- onPresent: () => void;
90
- fileStatus?: "loading" | "saved" | "saving" | "conflict" | "error";
91
- building: boolean;
92
- buildState?: "ready" | "working";
93
- onBuild?: () => void;
94
- browserTools?: {
95
- example: boolean;
96
- saveMessage: string;
97
- onImportJSON: (file: File) => void;
98
- onDownloadJSON: () => void;
99
- onStartBlank: () => void;
100
- onReset: () => void;
101
- };
102
- onSelectTool: () => void;
103
- onComponentTool: (kind: CompositionComponent["kind"]) => void;
104
- }) {
105
- const browserMenu = useRef<HTMLDetailsElement>(null);
106
- const [browserMenuOpen, setBrowserMenuOpen] = useState(false);
107
- const importInput = useRef<HTMLInputElement>(null);
108
- function closeBrowserMenu(restoreFocus = true) {
109
- const menu = browserMenu.current;
110
- if (!menu?.open) return;
111
- setBrowserMenuOpen(false);
112
- if (restoreFocus) menu.querySelector("summary")?.focus();
113
- }
114
- useEffect(() => {
115
- if (leftCollapsed) closeBrowserMenu(false);
116
- }, [leftCollapsed]);
117
- useEffect(() => {
118
- function dismissOutside(event: Event) {
119
- if (event.target instanceof Node && !browserMenu.current?.contains(event.target)) {
120
- closeBrowserMenu(false);
121
- }
122
- }
123
- document.addEventListener("pointerdown", dismissOutside);
124
- document.addEventListener("focusin", dismissOutside);
125
- return () => {
126
- document.removeEventListener("pointerdown", dismissOutside);
127
- document.removeEventListener("focusin", dismissOutside);
128
- };
129
- }, []);
130
- return (
131
- <>
132
- <div className={`left-sidebar${leftCollapsed ? " collapsed" : ""}`}>
133
- <header className="document-island" aria-label="Document controls">
134
- <img src={mark} alt="Konpeki" />
135
- <label className="document-title" inert={leftCollapsed}>
136
- <span className="sr-only">Composition title</span>
137
- <input
138
- ref={titleRef}
139
- aria-invalid={titleError || undefined}
140
- aria-describedby={titleError ? "title-error" : undefined}
141
- name="composition-title"
142
- autoComplete="off"
143
- disabled={building}
144
- value={title}
145
- onChange={(event) => onTitle(event.target.value, "title")}
146
- onBlur={onEditEnd}
147
- />
148
- </label>
149
- {titleError && (
150
- <span className="document-title-error" id="title-error">
151
- Enter a title.
152
- </span>
153
- )}
154
- {fileStatus && (
155
- <span
156
- className={`file-status ${fileStatus}`}
157
- role="status"
158
- aria-live="polite"
159
- title={fileStatus === "saved" ? "Saved to file" : fileStatus === "saving" ? "Saving to file" : fileStatus === "loading" ? "Opening file" : "Changes are not saved — see the recovery notice"}
160
- >
161
- <span className="sr-only">{fileStatus === "saved" ? "Saved" : fileStatus === "loading" ? "Opening"
162
- : fileStatus === "saving" ? "Saving"
163
- : fileStatus === "conflict" ? "Conflict" : "Save error"}</span>
164
- </span>
165
- )}
166
- <button type="button" className="panel-toggle"
167
- aria-label={leftCollapsed ? "Expand left panel" : "Collapse left panel"}
168
- aria-expanded={!leftCollapsed}
169
- onClick={onToggleLeftPanel}>
170
- <svg className="panel-collapse-icon" viewBox="0 0 24 24" aria-hidden="true">
171
- <rect x="3" y="4" width="18" height="16" rx="2" />
172
- <path d={leftCollapsed ? "M9 4v16M13 9l3 3-3 3" : "M9 4v16M16 9l-3 3 3 3"} />
173
- </svg>
174
- </button>
175
- </header>
176
-
177
- <div className="editor-content" inert={building} aria-busy={building}>
178
- {children}
179
- </div>
180
-
181
- <div className="action-island" aria-label="Document actions" inert={leftCollapsed}>
182
- <button
183
- type="button"
184
- className="icon-only"
185
- aria-label="Export PNG"
186
- title="Export active page as PNG"
187
- disabled={exporting || building}
188
- onClick={onExportPNG}
189
- >
190
- <svg viewBox="0 0 24 24" aria-hidden="true">
191
- <rect x="4" y="4" width="16" height="12" rx="2" />
192
- <path d="m7 14 3.5-4 3 3 2-2 2.5 3M12 16v5m-3-3 3 3 3-3" />
193
- </svg>
194
- </button>
195
- <button type="button" className="icon-only" aria-label="Present" title="Present" onClick={onPresent} disabled={building}>
196
- <svg viewBox="0 0 24 24" aria-hidden="true">
197
- <rect x="3" y="4" width="18" height="12" rx="2" />
198
- <path d="M12 16v5m-4 0h8M10 7l5 3-5 3Z" />
199
- </svg>
200
- </button>
201
- {browserTools && (
202
- <details className="browser-menu" ref={browserMenu} open={browserMenuOpen} onKeyDown={(event) => {
203
- if (event.key === "Escape" && browserMenu.current?.open) {
204
- event.preventDefault();
205
- event.stopPropagation();
206
- closeBrowserMenu();
207
- }
208
- }}>
209
- <summary onClick={(event) => {
210
- event.preventDefault();
211
- setBrowserMenuOpen(open => !open);
212
- }}>
213
- Demo Mode
214
- <svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 10 4-4 4 4" /></svg>
215
- </summary>
216
- <div className="browser-menu-popover" role="region" aria-label="Demo Mode" inert={!browserMenuOpen}>
217
- <div className="browser-menu-intro">
218
- <strong>Demo Mode</strong>
219
- <p>
220
- Explore the canvas here. No agent connection or file sync.{" "}
221
- <span role="status">{browserTools.saveMessage}</span>
222
- </p>
223
- </div>
224
- <div className="browser-menu-actions">
225
- <button type="button" onClick={() => importInput.current?.click()}>
226
- Import
227
- </button>
228
- <input
229
- ref={importInput}
230
- hidden
231
- type="file"
232
- accept="application/json,.json"
233
- aria-label="Choose composition JSON"
234
- onChange={(event) => {
235
- const file = event.target.files?.[0];
236
- event.target.value = "";
237
- if (!file) return;
238
- closeBrowserMenu();
239
- browserTools.onImportJSON(file);
240
- }}
241
- />
242
- <button type="button" onClick={() => {
243
- closeBrowserMenu();
244
- browserTools.onDownloadJSON();
245
- }}>
246
- Export
247
- </button>
248
- <button type="button" className="secondary" onClick={() => {
249
- closeBrowserMenu();
250
- browserTools.onStartBlank();
251
- }}>
252
- Start blank
253
- </button>
254
- <button type="button" className="secondary" onClick={() => {
255
- closeBrowserMenu();
256
- browserTools.onReset();
257
- }}>
258
- {browserTools.example ? "Reset example" : "Reset local draft"}
259
- </button>
260
- </div>
261
- <div className="browser-agent-handoff">
262
- <strong>Continue with an agent</strong>
263
- <p>Export the JSON file, then ask your coding agent to open it with Konpeki.</p>
264
- <a
265
- href="https://github.com/vcfgdev/konpeki/blob/main/SETUP.md"
266
- target="_blank"
267
- rel="noreferrer"
268
- >
269
- Setup guide
270
- </a>
271
- </div>
272
- </div>
273
- </details>
274
- )}
275
- {onBuild && (
276
- <button
277
- type="button"
278
- className={`primary build-button ${building ? "building" : ""}`}
279
- disabled={fileStatus !== "saved" || building}
280
- aria-busy={building}
281
- onClick={onBuild}
282
- >
283
- <BuildOrb active={building} />
284
- <span className="build-label">{buildState === "ready" ? "Request ready" : buildState === "working" ? "Agent working" : "Build it"}</span>
285
- </button>
286
- )}
287
- </div>
288
- </div>
289
-
290
- <nav className="component-dock" aria-label="Canvas tools" inert={building}>
291
- <button
292
- type="button"
293
- className={!selectedKind ? "active" : ""}
294
- aria-pressed={!selectedKind}
295
- onClick={onSelectTool}
296
- >
297
- <DockIcon kind="select" />
298
- <span>Select</span>
299
- </button>
300
- {dockItems.map(({ kind }) => (
301
- <button
302
- key={kind}
303
- type="button"
304
- className={selectedKind === kind ? "active" : ""}
305
- aria-pressed={selectedKind === kind}
306
- title={`Add or select ${componentLabels[kind]}`}
307
- onClick={() => onComponentTool(kind)}
308
- >
309
- <DockIcon kind={kind} />
310
- <span>{componentLabels[kind]}</span>
311
- </button>
312
- ))}
313
- </nav>
314
- </>
315
- );
316
- }
@@ -1,147 +0,0 @@
1
- import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
2
- import { createPortal } from "react-dom";
3
-
4
- /** Retain the last content while CSS transitions out; hidden notices are inert. */
5
- export function FeedbackNotice({ kind, children, tone = "neutral", onDismiss, onPauseChange }: {
6
- kind: "toast" | "recovery";
7
- children: ReactNode;
8
- tone?: "neutral" | "error";
9
- onDismiss?: () => void;
10
- onPauseChange?: (paused: boolean) => void;
11
- }) {
12
- const visible = Boolean(children);
13
- const [last, setLast] = useState({ children, tone });
14
- if (visible && (children !== last.children || tone !== last.tone)) setLast({ children, tone });
15
- return <div className={`feedback-notice ${kind}${visible ? " visible" : ""}${last.tone === "error" ? " error" : ""}`}
16
- inert={!visible} aria-hidden={!visible} role={kind === "recovery" && visible ? "alert" : undefined}
17
- onMouseEnter={() => onPauseChange?.(true)} onMouseLeave={() => onPauseChange?.(false)}
18
- onFocus={() => onPauseChange?.(true)} onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget)) onPauseChange?.(false); }}>
19
- {kind === "toast" ? <span role={visible ? last.tone === "error" ? "alert" : "status" : undefined}
20
- aria-live={visible ? last.tone === "error" ? "assertive" : "polite" : "off"}>{last.children}</span> : last.children}
21
- {onDismiss && last.children && <button type="button" className="toast-dismiss" aria-label="Dismiss notification" onClick={onDismiss}>×</button>}
22
- </div>;
23
- }
24
-
25
- /** Keep incomplete input out of the document, without silently correcting it. */
26
- export function NumberField({ label, name, value, min, max, step = 1, unit, onCommit, onEditEnd }: {
27
- label: string;
28
- name?: string;
29
- value: number;
30
- min: number;
31
- max: number;
32
- step?: number;
33
- unit?: string;
34
- onCommit: (value: number) => void;
35
- onEditEnd: () => void;
36
- }) {
37
- const [text, setText] = useState(String(value));
38
- const [error, setError] = useState("");
39
- const [showError, setShowError] = useState(false);
40
- const [position, setPosition] = useState({ top: 0, left: 0 });
41
- const input = useRef<HTMLInputElement>(null);
42
- const id = useId();
43
- useEffect(() => { setText(String(value)); setError(""); }, [value]);
44
- useLayoutEffect(() => {
45
- if (!error || !showError) return;
46
- function place() {
47
- const rect = input.current!.getBoundingClientRect();
48
- const panel = input.current!.closest(".right-panel")!.getBoundingClientRect();
49
- const beside = panel.left >= 276;
50
- setPosition({ top: Math.max(8, Math.min(beside ? rect.top : rect.bottom + 6, window.innerHeight - 80)), left: beside ? panel.left - 268 : Math.max(8, Math.min(rect.left, window.innerWidth - 268)) });
51
- if (rect.bottom < panel.top + 58 || rect.top > panel.bottom) setShowError(false);
52
- }
53
- function focus(event: FocusEvent) {
54
- if (event.target !== input.current) setShowError(false);
55
- }
56
- place();
57
- window.addEventListener("resize", place);
58
- window.addEventListener("scroll", place, true);
59
- document.addEventListener("focusin", focus);
60
- return () => {
61
- window.removeEventListener("resize", place);
62
- window.removeEventListener("scroll", place, true);
63
- document.removeEventListener("focusin", focus);
64
- };
65
- }, [error, showError]);
66
- function commit() {
67
- const number = Number(text);
68
- if (!text.trim() || !Number.isFinite(number) || number < min || number > max) {
69
- setError(`${label} must be ${min}–${max}${unit ? ` ${unit}` : ""}. Escape restores the previous value.`);
70
- setShowError(true);
71
- } else {
72
- setError("");
73
- if (number !== value) onCommit(number);
74
- }
75
- onEditEnd();
76
- }
77
- return <Field label={label}>
78
- <span className={unit ? "parameter-input" : "number-input"}>
79
- <input ref={input} name={name} type="number" autoComplete="off" min={min} max={max} step={step}
80
- value={text} aria-invalid={error ? true : undefined} aria-describedby={error ? id : undefined}
81
- onChange={event => setText(event.target.value)} onFocus={() => setShowError(true)} onBlur={commit}
82
- onKeyDown={event => {
83
- if ((event.metaKey || event.ctrlKey) && ["z", "y"].includes(event.key.toLowerCase()) && text !== String(value)) event.stopPropagation();
84
- if (event.key === "Enter") { event.preventDefault(); commit(); }
85
- if (event.key === "Escape") {
86
- event.preventDefault(); event.stopPropagation();
87
- setText(String(value)); setError(""); onEditEnd();
88
- }
89
- }} />
90
- {unit && <span className="parameter-unit" aria-hidden="true">{unit}</span>}
91
- </span>
92
- {error && createPortal(<span id={id} role="alert" className={showError ? "parameter-error" : "sr-only"} style={showError ? position : undefined}>{error}</span>, document.body)}
93
- </Field>;
94
- }
95
-
96
- export function Field({
97
- label,
98
- children,
99
- }: {
100
- label: string;
101
- children: ReactNode;
102
- }) {
103
- return (
104
- <label className="field">
105
- <span>{label}</span>
106
- {children}
107
- </label>
108
- );
109
- }
110
- export function Select({
111
- label,
112
- value,
113
- options,
114
- optionLabels,
115
- onChange,
116
- }: {
117
- label: string;
118
- value: string;
119
- options: readonly string[];
120
- optionLabels?: Partial<Record<string, string>>;
121
- onChange: (value: string) => void;
122
- }) {
123
- const id = useId();
124
- return (
125
- <Field label={label}>
126
- <select
127
- id={id}
128
- name={label}
129
- autoComplete="off"
130
- value={value}
131
- onChange={(e) => onChange(e.target.value)}
132
- >
133
- {options.map((option) => (
134
- <option key={option} value={option}>
135
- {optionLabels?.[option] ?? humanize(option)}
136
- </option>
137
- ))}
138
- </select>
139
- </Field>
140
- );
141
- }
142
- export function humanize(value: string) {
143
- return value
144
- .replace(/([a-z])([A-Z])/g, "$1 $2")
145
- .replaceAll("-", " ")
146
- .replace(/^./, (c) => c.toUpperCase());
147
- }