@rohal12/spindle 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 (65) hide show
  1. package/README.md +66 -0
  2. package/dist/pkg/format.js +1 -0
  3. package/dist/pkg/index.js +12 -0
  4. package/dist/pkg/types/globals.d.ts +18 -0
  5. package/dist/pkg/types/index.d.ts +158 -0
  6. package/package.json +71 -0
  7. package/src/components/App.tsx +53 -0
  8. package/src/components/Passage.tsx +36 -0
  9. package/src/components/PassageLink.tsx +35 -0
  10. package/src/components/SaveLoadDialog.tsx +403 -0
  11. package/src/components/SettingsDialog.tsx +106 -0
  12. package/src/components/StoryInterface.tsx +31 -0
  13. package/src/components/macros/Back.tsx +23 -0
  14. package/src/components/macros/Button.tsx +49 -0
  15. package/src/components/macros/Checkbox.tsx +41 -0
  16. package/src/components/macros/Computed.tsx +100 -0
  17. package/src/components/macros/Cycle.tsx +39 -0
  18. package/src/components/macros/Do.tsx +46 -0
  19. package/src/components/macros/For.tsx +113 -0
  20. package/src/components/macros/Forward.tsx +25 -0
  21. package/src/components/macros/Goto.tsx +23 -0
  22. package/src/components/macros/If.tsx +63 -0
  23. package/src/components/macros/Include.tsx +52 -0
  24. package/src/components/macros/Listbox.tsx +42 -0
  25. package/src/components/macros/MacroLink.tsx +107 -0
  26. package/src/components/macros/Numberbox.tsx +43 -0
  27. package/src/components/macros/Print.tsx +48 -0
  28. package/src/components/macros/QuickLoad.tsx +33 -0
  29. package/src/components/macros/QuickSave.tsx +22 -0
  30. package/src/components/macros/Radiobutton.tsx +59 -0
  31. package/src/components/macros/Repeat.tsx +53 -0
  32. package/src/components/macros/Restart.tsx +27 -0
  33. package/src/components/macros/Saves.tsx +25 -0
  34. package/src/components/macros/Set.tsx +36 -0
  35. package/src/components/macros/SettingsButton.tsx +29 -0
  36. package/src/components/macros/Stop.tsx +12 -0
  37. package/src/components/macros/StoryTitle.tsx +20 -0
  38. package/src/components/macros/Switch.tsx +69 -0
  39. package/src/components/macros/Textarea.tsx +41 -0
  40. package/src/components/macros/Textbox.tsx +40 -0
  41. package/src/components/macros/Timed.tsx +63 -0
  42. package/src/components/macros/Type.tsx +83 -0
  43. package/src/components/macros/Unset.tsx +25 -0
  44. package/src/components/macros/VarDisplay.tsx +44 -0
  45. package/src/components/macros/Widget.tsx +18 -0
  46. package/src/components/macros/option-utils.ts +14 -0
  47. package/src/expression.ts +93 -0
  48. package/src/index.tsx +120 -0
  49. package/src/markup/ast.ts +284 -0
  50. package/src/markup/markdown.ts +21 -0
  51. package/src/markup/render.tsx +537 -0
  52. package/src/markup/tokenizer.ts +581 -0
  53. package/src/parser.ts +72 -0
  54. package/src/registry.ts +21 -0
  55. package/src/saves/idb.ts +165 -0
  56. package/src/saves/save-manager.ts +317 -0
  57. package/src/saves/types.ts +40 -0
  58. package/src/settings.ts +96 -0
  59. package/src/store.ts +317 -0
  60. package/src/story-api.ts +129 -0
  61. package/src/story-init.ts +67 -0
  62. package/src/story-variables.ts +166 -0
  63. package/src/styles.css +780 -0
  64. package/src/utils/parse-delay.ts +14 -0
  65. package/src/widgets/widget-registry.ts +15 -0
@@ -0,0 +1,403 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
2
+ import { useStoryStore } from '../store';
3
+ import type { SaveRecord } from '../saves/types';
4
+ import {
5
+ getSavesGrouped,
6
+ createSave,
7
+ overwriteSave,
8
+ deleteSaveById,
9
+ renameSave,
10
+ exportSave,
11
+ importSave,
12
+ type PlaythroughGroup,
13
+ } from '../saves/save-manager';
14
+
15
+ interface SaveLoadDialogProps {
16
+ onClose: () => void;
17
+ }
18
+
19
+ function relativeTime(iso: string): string {
20
+ const diff = Date.now() - new Date(iso).getTime();
21
+ const secs = Math.floor(diff / 1000);
22
+ if (secs < 60) return 'just now';
23
+ const mins = Math.floor(secs / 60);
24
+ if (mins < 60) return `${mins}m ago`;
25
+ const hours = Math.floor(mins / 60);
26
+ if (hours < 24) return `${hours}h ago`;
27
+ const days = Math.floor(hours / 24);
28
+ if (days < 30) return `${days}d ago`;
29
+ return new Date(iso).toLocaleDateString();
30
+ }
31
+
32
+ function formatDate(iso: string): string {
33
+ return new Date(iso).toLocaleDateString(undefined, {
34
+ month: 'short',
35
+ day: 'numeric',
36
+ year: 'numeric',
37
+ });
38
+ }
39
+
40
+ export function SaveLoadDialog({ onClose }: SaveLoadDialogProps) {
41
+ const [mode, setMode] = useState<'save' | 'load'>('load');
42
+ const [groups, setGroups] = useState<PlaythroughGroup[]>([]);
43
+ const [loading, setLoading] = useState(true);
44
+ const [status, setStatus] = useState<{
45
+ text: string;
46
+ type: 'success' | 'error';
47
+ } | null>(null);
48
+ const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
49
+ const [renamingId, setRenamingId] = useState<string | null>(null);
50
+ const [renameValue, setRenameValue] = useState('');
51
+ const renameInputRef = useRef<HTMLInputElement>(null);
52
+ const fileInputRef = useRef<HTMLInputElement>(null);
53
+
54
+ const storyData = useStoryStore((s) => s.storyData);
55
+ const playthroughId = useStoryStore((s) => s.playthroughId);
56
+ const getSavePayload = useStoryStore((s) => s.getSavePayload);
57
+ const loadFromPayload = useStoryStore((s) => s.loadFromPayload);
58
+ const ifid = storyData?.ifid ?? '';
59
+
60
+ const refresh = useCallback(async () => {
61
+ if (!ifid) return;
62
+ const data = await getSavesGrouped(ifid);
63
+ setGroups(data);
64
+ setLoading(false);
65
+ }, [ifid]);
66
+
67
+ useEffect(() => {
68
+ refresh();
69
+ }, [refresh]);
70
+
71
+ useEffect(() => {
72
+ if (renamingId && renameInputRef.current) {
73
+ renameInputRef.current.focus();
74
+ renameInputRef.current.select();
75
+ }
76
+ }, [renamingId]);
77
+
78
+ const showStatus = (text: string, type: 'success' | 'error' = 'success') => {
79
+ setStatus({ text, type });
80
+ setTimeout(() => setStatus(null), 3000);
81
+ };
82
+
83
+ const toggleCollapse = (id: string) => {
84
+ setCollapsed((prev) => {
85
+ const next = new Set(prev);
86
+ if (next.has(id)) next.delete(id);
87
+ else next.add(id);
88
+ return next;
89
+ });
90
+ };
91
+
92
+ const handleNewSave = async () => {
93
+ if (!ifid || !playthroughId) return;
94
+ try {
95
+ const payload = getSavePayload();
96
+ await createSave(ifid, playthroughId, payload);
97
+ showStatus('Save created');
98
+ await refresh();
99
+ } catch {
100
+ showStatus('Failed to create save', 'error');
101
+ }
102
+ };
103
+
104
+ const handleOverwrite = async (saveId: string) => {
105
+ try {
106
+ const payload = getSavePayload();
107
+ await overwriteSave(saveId, payload);
108
+ showStatus('Save overwritten');
109
+ await refresh();
110
+ } catch {
111
+ showStatus('Failed to overwrite save', 'error');
112
+ }
113
+ };
114
+
115
+ const handleLoad = async (save: SaveRecord) => {
116
+ try {
117
+ loadFromPayload(save.payload);
118
+ showStatus('Game loaded');
119
+ setTimeout(onClose, 500);
120
+ } catch {
121
+ showStatus('Failed to load save', 'error');
122
+ }
123
+ };
124
+
125
+ const handleDelete = async (saveId: string) => {
126
+ if (!confirm('Delete this save?')) return;
127
+ try {
128
+ await deleteSaveById(saveId);
129
+ showStatus('Save deleted');
130
+ await refresh();
131
+ } catch {
132
+ showStatus('Failed to delete save', 'error');
133
+ }
134
+ };
135
+
136
+ const handleRenameStart = (save: SaveRecord) => {
137
+ setRenamingId(save.meta.id);
138
+ setRenameValue(save.meta.title);
139
+ };
140
+
141
+ const handleRenameConfirm = async () => {
142
+ if (!renamingId || !renameValue.trim()) return;
143
+ try {
144
+ await renameSave(renamingId, renameValue.trim());
145
+ setRenamingId(null);
146
+ showStatus('Save renamed');
147
+ await refresh();
148
+ } catch {
149
+ showStatus('Failed to rename', 'error');
150
+ }
151
+ };
152
+
153
+ const handleRenameKeyDown = (e: KeyboardEvent) => {
154
+ if (e.key === 'Enter') handleRenameConfirm();
155
+ else if (e.key === 'Escape') setRenamingId(null);
156
+ };
157
+
158
+ const handleExport = async (saveId: string) => {
159
+ try {
160
+ const data = await exportSave(saveId);
161
+ if (!data) return;
162
+ const blob = new Blob([JSON.stringify(data, null, 2)], {
163
+ type: 'application/json',
164
+ });
165
+ const url = URL.createObjectURL(blob);
166
+ const a = document.createElement('a');
167
+ a.href = url;
168
+ a.download = `save-${data.save.meta.title.replace(/[^a-z0-9]/gi, '_')}.json`;
169
+ a.click();
170
+ URL.revokeObjectURL(url);
171
+ showStatus('Save exported');
172
+ } catch {
173
+ showStatus('Failed to export save', 'error');
174
+ }
175
+ };
176
+
177
+ const handleImport = () => {
178
+ fileInputRef.current?.click();
179
+ };
180
+
181
+ const handleFileSelected = async (e: Event) => {
182
+ const input = e.target as HTMLInputElement;
183
+ const file = input.files?.[0];
184
+ if (!file) return;
185
+ input.value = '';
186
+
187
+ try {
188
+ const text = await file.text();
189
+ const data = JSON.parse(text);
190
+ await importSave(data, ifid);
191
+ showStatus('Save imported');
192
+ await refresh();
193
+ } catch (err) {
194
+ showStatus(
195
+ err instanceof Error ? err.message : 'Failed to import save',
196
+ 'error',
197
+ );
198
+ }
199
+ };
200
+
201
+ const handleBackdrop = (e: MouseEvent) => {
202
+ if ((e.target as HTMLElement).classList.contains('saves-overlay')) {
203
+ onClose();
204
+ }
205
+ };
206
+
207
+ const totalSaves = groups.reduce((n, g) => n + g.saves.length, 0);
208
+
209
+ return (
210
+ <div
211
+ class="saves-overlay"
212
+ onClick={handleBackdrop}
213
+ >
214
+ <div class="saves-panel">
215
+ <div class="saves-header">
216
+ <div class="saves-header-left">
217
+ <div class="saves-mode-toggle">
218
+ <button
219
+ class={mode === 'save' ? 'active' : ''}
220
+ onClick={() => setMode('save')}
221
+ >
222
+ Save
223
+ </button>
224
+ <button
225
+ class={mode === 'load' ? 'active' : ''}
226
+ onClick={() => setMode('load')}
227
+ >
228
+ Load
229
+ </button>
230
+ </div>
231
+ </div>
232
+ <button
233
+ class="saves-close"
234
+ onClick={onClose}
235
+ >
236
+
237
+ </button>
238
+ </div>
239
+
240
+ <div class="saves-toolbar">
241
+ <button
242
+ class="saves-toolbar-button"
243
+ onClick={handleImport}
244
+ >
245
+ Import
246
+ </button>
247
+ <input
248
+ ref={fileInputRef}
249
+ type="file"
250
+ accept=".json"
251
+ style="display:none"
252
+ onChange={handleFileSelected}
253
+ />
254
+ </div>
255
+
256
+ <div class="saves-body">
257
+ {loading ? (
258
+ <div class="saves-empty">Loading...</div>
259
+ ) : totalSaves === 0 && mode === 'load' ? (
260
+ <div class="saves-empty">No saves yet</div>
261
+ ) : (
262
+ groups.map((group) => {
263
+ const isCollapsed = collapsed.has(group.playthrough.id);
264
+ const isCurrentPt = group.playthrough.id === playthroughId;
265
+
266
+ // Save mode: only show current playthrough
267
+ // Load mode: hide empty non-current playthroughs (irrecoverable)
268
+ if (mode === 'save' && !isCurrentPt) return null;
269
+ if (mode === 'load' && group.saves.length === 0 && !isCurrentPt)
270
+ return null;
271
+
272
+ return (
273
+ <div
274
+ class="playthrough-group"
275
+ key={group.playthrough.id}
276
+ >
277
+ <div
278
+ class="playthrough-header"
279
+ onClick={() => toggleCollapse(group.playthrough.id)}
280
+ >
281
+ <span
282
+ class={`playthrough-chevron ${isCollapsed ? '' : 'open'}`}
283
+ >
284
+
285
+ </span>
286
+ <span class="playthrough-label">
287
+ {group.playthrough.label}
288
+ {isCurrentPt ? ' (current)' : ''}
289
+ </span>
290
+ <span class="playthrough-date">
291
+ {formatDate(group.playthrough.createdAt)}
292
+ </span>
293
+ </div>
294
+
295
+ {!isCollapsed && (
296
+ <div class="playthrough-saves">
297
+ {group.saves.map((save) => (
298
+ <div
299
+ class="save-slot"
300
+ key={save.meta.id}
301
+ >
302
+ <div class="save-slot-info">
303
+ {renamingId === save.meta.id ? (
304
+ <input
305
+ ref={renameInputRef}
306
+ class="save-rename-input"
307
+ value={renameValue}
308
+ onInput={(e) =>
309
+ setRenameValue(
310
+ (e.target as HTMLInputElement).value,
311
+ )
312
+ }
313
+ onKeyDown={handleRenameKeyDown}
314
+ onBlur={handleRenameConfirm}
315
+ />
316
+ ) : (
317
+ <div class="save-slot-title">
318
+ {save.meta.title}
319
+ </div>
320
+ )}
321
+ <div class="save-slot-meta">
322
+ <span>{save.meta.passage}</span>
323
+ <span>{relativeTime(save.meta.updatedAt)}</span>
324
+ </div>
325
+ </div>
326
+ <div class="save-slot-actions">
327
+ {mode === 'save' ? (
328
+ <button
329
+ class="save-slot-action primary"
330
+ onClick={() => handleOverwrite(save.meta.id)}
331
+ >
332
+ Save Here
333
+ </button>
334
+ ) : (
335
+ <button
336
+ class="save-slot-action primary"
337
+ onClick={() => handleLoad(save)}
338
+ >
339
+ Load
340
+ </button>
341
+ )}
342
+ <button
343
+ class="save-slot-action"
344
+ onClick={() => handleRenameStart(save)}
345
+ >
346
+ Rename
347
+ </button>
348
+ <button
349
+ class="save-slot-action"
350
+ onClick={() => handleExport(save.meta.id)}
351
+ >
352
+ Export
353
+ </button>
354
+ <button
355
+ class="save-slot-action danger"
356
+ onClick={() => handleDelete(save.meta.id)}
357
+ >
358
+ Delete
359
+ </button>
360
+ </div>
361
+ </div>
362
+ ))}
363
+
364
+ {mode === 'save' && isCurrentPt && (
365
+ <button
366
+ class="save-slot-new"
367
+ onClick={handleNewSave}
368
+ >
369
+ + New Save
370
+ </button>
371
+ )}
372
+ </div>
373
+ )}
374
+ </div>
375
+ );
376
+ })
377
+ )}
378
+
379
+ {mode === 'save' &&
380
+ !loading &&
381
+ !groups.some((g) => g.playthrough.id === playthroughId) && (
382
+ <div class="playthrough-group">
383
+ <div class="playthrough-saves">
384
+ <button
385
+ class="save-slot-new"
386
+ onClick={handleNewSave}
387
+ >
388
+ + New Save
389
+ </button>
390
+ </div>
391
+ </div>
392
+ )}
393
+ </div>
394
+
395
+ {status && (
396
+ <div class={`saves-status ${status.type === 'error' ? 'error' : ''}`}>
397
+ {status.text}
398
+ </div>
399
+ )}
400
+ </div>
401
+ </div>
402
+ );
403
+ }
@@ -0,0 +1,106 @@
1
+ import { useState } from 'preact/hooks';
2
+ import { settings, type SettingDef } from '../settings';
3
+
4
+ interface SettingsDialogProps {
5
+ onClose: () => void;
6
+ }
7
+
8
+ function SettingControl({ name, def }: { name: string; def: SettingDef }) {
9
+ const [value, setValue] = useState(() => settings.get(name));
10
+
11
+ const update = (newValue: unknown) => {
12
+ setValue(newValue);
13
+ settings.set(name, newValue);
14
+ };
15
+
16
+ switch (def.type) {
17
+ case 'toggle':
18
+ return (
19
+ <label class="settings-row">
20
+ <span>{def.config.label}</span>
21
+ <input
22
+ type="checkbox"
23
+ checked={!!value}
24
+ onChange={(e) => update((e.target as HTMLInputElement).checked)}
25
+ />
26
+ </label>
27
+ );
28
+
29
+ case 'list':
30
+ return (
31
+ <label class="settings-row">
32
+ <span>{def.config.label}</span>
33
+ <select
34
+ value={String(value)}
35
+ onChange={(e) => update((e.target as HTMLSelectElement).value)}
36
+ >
37
+ {def.config.options.map((opt) => (
38
+ <option
39
+ key={opt}
40
+ value={opt}
41
+ >
42
+ {opt}
43
+ </option>
44
+ ))}
45
+ </select>
46
+ </label>
47
+ );
48
+
49
+ case 'range':
50
+ return (
51
+ <label class="settings-row">
52
+ <span>
53
+ {def.config.label}: {String(value)}
54
+ </span>
55
+ <input
56
+ type="range"
57
+ min={def.config.min}
58
+ max={def.config.max}
59
+ step={def.config.step}
60
+ value={Number(value)}
61
+ onInput={(e) =>
62
+ update(parseFloat((e.target as HTMLInputElement).value))
63
+ }
64
+ />
65
+ </label>
66
+ );
67
+ }
68
+ }
69
+
70
+ export function SettingsDialog({ onClose }: SettingsDialogProps) {
71
+ const defs = settings.getDefinitions();
72
+
73
+ const handleBackdrop = (e: MouseEvent) => {
74
+ if ((e.target as HTMLElement).classList.contains('settings-overlay')) {
75
+ onClose();
76
+ }
77
+ };
78
+
79
+ return (
80
+ <div
81
+ class="settings-overlay"
82
+ onClick={handleBackdrop}
83
+ >
84
+ <div class="settings-panel">
85
+ <div class="settings-header">
86
+ <span>Settings</span>
87
+ <button
88
+ class="settings-close"
89
+ onClick={onClose}
90
+ >
91
+
92
+ </button>
93
+ </div>
94
+ <div class="settings-body">
95
+ {Array.from(defs.entries()).map(([name, def]) => (
96
+ <SettingControl
97
+ key={name}
98
+ name={name}
99
+ def={def}
100
+ />
101
+ ))}
102
+ </div>
103
+ </div>
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,31 @@
1
+ import { useMemo } from 'preact/hooks';
2
+ import { useStoryStore } from '../store';
3
+ import { tokenize } from '../markup/tokenizer';
4
+ import { buildAST } from '../markup/ast';
5
+ import { renderNodes } from '../markup/render';
6
+
7
+ const DEFAULT_MARKUP = '{story-title}{back}{forward}{restart}{quicksave}{quickload}{saves}{settings}';
8
+
9
+ export function StoryInterface() {
10
+ const storyData = useStoryStore((s) => s.storyData);
11
+
12
+ const overridePassage = storyData?.passages.get('StoryInterface');
13
+ const markup =
14
+ overridePassage !== undefined ? overridePassage.content : DEFAULT_MARKUP;
15
+
16
+ const content = useMemo(() => {
17
+ try {
18
+ const tokens = tokenize(markup);
19
+ const ast = buildAST(tokens);
20
+ return renderNodes(ast);
21
+ } catch (err) {
22
+ return (
23
+ <span class="error">
24
+ Error in StoryInterface: {(err as Error).message}
25
+ </span>
26
+ );
27
+ }
28
+ }, [markup]);
29
+
30
+ return <>{content}</>;
31
+ }
@@ -0,0 +1,23 @@
1
+ import { useStoryStore } from '../../store';
2
+
3
+ interface BackProps {
4
+ className?: string;
5
+ id?: string;
6
+ }
7
+
8
+ export function Back({ className, id }: BackProps) {
9
+ const goBack = useStoryStore((s) => s.goBack);
10
+ const canGoBack = useStoryStore((s) => s.historyIndex > 0);
11
+ const cls = className ? `menubar-button ${className}` : 'menubar-button';
12
+
13
+ return (
14
+ <button
15
+ id={id}
16
+ class={cls}
17
+ onClick={goBack}
18
+ disabled={!canGoBack}
19
+ >
20
+ ← Back
21
+ </button>
22
+ );
23
+ }
@@ -0,0 +1,49 @@
1
+ import { useStoryStore } from '../../store';
2
+ import { execute } from '../../expression';
3
+ import { renderInlineNodes } from '../../markup/render';
4
+ import type { ASTNode } from '../../markup/ast';
5
+
6
+ interface ButtonProps {
7
+ rawArgs: string;
8
+ children: ASTNode[];
9
+ className?: string;
10
+ id?: string;
11
+ }
12
+
13
+ export function Button({ rawArgs, children, className, id }: ButtonProps) {
14
+ const handleClick = () => {
15
+ const state = useStoryStore.getState();
16
+ const vars = structuredClone(state.variables);
17
+ const temps = structuredClone(state.temporary);
18
+
19
+ try {
20
+ execute(rawArgs, vars, temps);
21
+ } catch (err) {
22
+ console.error(`spindle: Error in {button ${rawArgs}}:`, err);
23
+ return;
24
+ }
25
+
26
+ for (const key of Object.keys(vars)) {
27
+ if (vars[key] !== state.variables[key]) {
28
+ state.setVariable(key, vars[key]);
29
+ }
30
+ }
31
+ for (const key of Object.keys(temps)) {
32
+ if (temps[key] !== state.temporary[key]) {
33
+ state.setTemporary(key, temps[key]);
34
+ }
35
+ }
36
+ };
37
+
38
+ const cls = className ? `macro-button ${className}` : 'macro-button';
39
+
40
+ return (
41
+ <button
42
+ id={id}
43
+ class={cls}
44
+ onClick={handleClick}
45
+ >
46
+ {renderInlineNodes(children)}
47
+ </button>
48
+ );
49
+ }
@@ -0,0 +1,41 @@
1
+ import { useStoryStore } from '../../store';
2
+
3
+ interface CheckboxProps {
4
+ rawArgs: string;
5
+ className?: string;
6
+ id?: string;
7
+ }
8
+
9
+ function parseArgs(rawArgs: string): { varName: string; label: string } {
10
+ const match = rawArgs.match(/^\s*(["']?\$\w+["']?)\s+["']?(.+?)["']?\s*$/);
11
+ if (!match) {
12
+ return { varName: rawArgs.trim(), label: '' };
13
+ }
14
+ const varName = match[1].replace(/["']/g, '');
15
+ const label = match[2];
16
+ return { varName, label };
17
+ }
18
+
19
+ export function Checkbox({ rawArgs, className, id }: CheckboxProps) {
20
+ const { varName, label } = parseArgs(rawArgs);
21
+ const name = varName.startsWith('$') ? varName.slice(1) : varName;
22
+
23
+ const value = useStoryStore((s) => s.variables[name]);
24
+ const setVariable = useStoryStore((s) => s.setVariable);
25
+
26
+ const cls = className ? `macro-checkbox ${className}` : 'macro-checkbox';
27
+
28
+ return (
29
+ <label
30
+ id={id}
31
+ class={cls}
32
+ >
33
+ <input
34
+ type="checkbox"
35
+ checked={!!value}
36
+ onChange={() => setVariable(name, !value)}
37
+ />
38
+ {label ? ` ${label}` : null}
39
+ </label>
40
+ );
41
+ }