silvery 0.3.0 → 0.4.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 (120) hide show
  1. package/README.md +41 -145
  2. package/dist/chalk.js +3 -0
  3. package/dist/chalk.js.map +11 -0
  4. package/dist/index.js +340 -0
  5. package/dist/index.js.map +282 -0
  6. package/dist/ink.js +129 -0
  7. package/dist/ink.js.map +140 -0
  8. package/dist/runtime.js +394 -0
  9. package/dist/runtime.js.map +286 -0
  10. package/dist/theme.js +343 -0
  11. package/dist/theme.js.map +286 -0
  12. package/dist/ui/animation.js +3 -0
  13. package/dist/ui/animation.js.map +15 -0
  14. package/dist/ui/ansi.js +3 -0
  15. package/dist/ui/ansi.js.map +10 -0
  16. package/dist/ui/cli.js +8 -0
  17. package/dist/ui/cli.js.map +14 -0
  18. package/dist/ui/display.js +4 -0
  19. package/dist/ui/display.js.map +10 -0
  20. package/dist/ui/image.js +4 -0
  21. package/dist/ui/image.js.map +15 -0
  22. package/dist/ui/input.js +3 -0
  23. package/dist/ui/input.js.map +11 -0
  24. package/dist/ui/progress.js +8 -0
  25. package/dist/ui/progress.js.map +20 -0
  26. package/dist/ui/react.js +3 -0
  27. package/dist/ui/react.js.map +15 -0
  28. package/dist/ui/utils.js +3 -0
  29. package/dist/ui/utils.js.map +10 -0
  30. package/dist/ui/wrappers.js +14 -0
  31. package/dist/ui/wrappers.js.map +19 -0
  32. package/dist/ui.js +17 -0
  33. package/dist/ui.js.map +20 -0
  34. package/package.json +67 -15
  35. package/src/index.ts +67 -1
  36. package/src/runtime.ts +4 -0
  37. package/src/theme.ts +4 -0
  38. package/src/ui/animation.ts +2 -0
  39. package/src/ui/ansi.ts +2 -0
  40. package/src/ui/cli.ts +2 -0
  41. package/src/ui/display.ts +2 -0
  42. package/src/ui/image.ts +2 -0
  43. package/src/ui/input.ts +2 -0
  44. package/src/ui/progress.ts +2 -0
  45. package/src/ui/react.ts +2 -0
  46. package/src/ui/utils.ts +2 -0
  47. package/src/ui/wrappers.ts +2 -0
  48. package/src/ui.ts +4 -0
  49. package/examples/CLAUDE.md +0 -75
  50. package/examples/_banner.tsx +0 -60
  51. package/examples/cli.ts +0 -228
  52. package/examples/index.md +0 -101
  53. package/examples/inline/inline-nontty.tsx +0 -98
  54. package/examples/inline/inline-progress.tsx +0 -79
  55. package/examples/inline/inline-simple.tsx +0 -63
  56. package/examples/inline/scrollback.tsx +0 -185
  57. package/examples/interactive/_input-debug.tsx +0 -110
  58. package/examples/interactive/_stdin-test.ts +0 -71
  59. package/examples/interactive/_textarea-bare.tsx +0 -45
  60. package/examples/interactive/aichat/components.tsx +0 -468
  61. package/examples/interactive/aichat/index.tsx +0 -207
  62. package/examples/interactive/aichat/script.ts +0 -460
  63. package/examples/interactive/aichat/state.ts +0 -326
  64. package/examples/interactive/aichat/types.ts +0 -19
  65. package/examples/interactive/app-todo.tsx +0 -198
  66. package/examples/interactive/async-data.tsx +0 -208
  67. package/examples/interactive/cli-wizard.tsx +0 -332
  68. package/examples/interactive/clipboard.tsx +0 -183
  69. package/examples/interactive/components.tsx +0 -463
  70. package/examples/interactive/data-explorer.tsx +0 -506
  71. package/examples/interactive/dev-tools.tsx +0 -379
  72. package/examples/interactive/explorer.tsx +0 -747
  73. package/examples/interactive/gallery.tsx +0 -652
  74. package/examples/interactive/inline-bench.tsx +0 -136
  75. package/examples/interactive/kanban.tsx +0 -267
  76. package/examples/interactive/layout-ref.tsx +0 -185
  77. package/examples/interactive/outline.tsx +0 -171
  78. package/examples/interactive/paste-demo.tsx +0 -198
  79. package/examples/interactive/scroll.tsx +0 -77
  80. package/examples/interactive/search-filter.tsx +0 -240
  81. package/examples/interactive/task-list.tsx +0 -279
  82. package/examples/interactive/terminal.tsx +0 -798
  83. package/examples/interactive/textarea.tsx +0 -103
  84. package/examples/interactive/theme.tsx +0 -336
  85. package/examples/interactive/transform.tsx +0 -256
  86. package/examples/interactive/virtual-10k.tsx +0 -413
  87. package/examples/kitty/canvas.tsx +0 -519
  88. package/examples/kitty/generate-samples.ts +0 -236
  89. package/examples/kitty/image-component.tsx +0 -273
  90. package/examples/kitty/images.tsx +0 -604
  91. package/examples/kitty/input.tsx +0 -371
  92. package/examples/kitty/keys.tsx +0 -378
  93. package/examples/kitty/paint.tsx +0 -1017
  94. package/examples/layout/dashboard.tsx +0 -551
  95. package/examples/layout/live-resize.tsx +0 -290
  96. package/examples/layout/overflow.tsx +0 -51
  97. package/examples/playground/README.md +0 -69
  98. package/examples/playground/build.ts +0 -61
  99. package/examples/playground/index.html +0 -420
  100. package/examples/playground/playground-app.tsx +0 -416
  101. package/examples/runtime/elm-counter.tsx +0 -206
  102. package/examples/runtime/hello-runtime.tsx +0 -73
  103. package/examples/runtime/pipe-composition.tsx +0 -184
  104. package/examples/runtime/run-counter.tsx +0 -78
  105. package/examples/runtime/runtime-counter.tsx +0 -197
  106. package/examples/screenshots/generate.tsx +0 -563
  107. package/examples/scrollback-perf.tsx +0 -230
  108. package/examples/viewer.tsx +0 -654
  109. package/examples/web/build.ts +0 -365
  110. package/examples/web/canvas-app.tsx +0 -80
  111. package/examples/web/canvas.html +0 -89
  112. package/examples/web/dom-app.tsx +0 -81
  113. package/examples/web/dom.html +0 -113
  114. package/examples/web/showcase-app.tsx +0 -107
  115. package/examples/web/showcase.html +0 -34
  116. package/examples/web/showcases/index.tsx +0 -56
  117. package/examples/web/viewer-app.tsx +0 -555
  118. package/examples/web/viewer.html +0 -30
  119. package/examples/web/xterm-app.tsx +0 -105
  120. package/examples/web/xterm.html +0 -118
@@ -1,654 +0,0 @@
1
- /**
2
- * silvery Examples Viewer
3
- *
4
- * Storybook-style TUI for browsing and running silvery examples.
5
- * Left: nav sidebar. Right: tabbed content (View / Source).
6
- *
7
- * Examples are auto-discovered from category directories (layout/, interactive/,
8
- * runtime/, inline/). Each example exports a `meta` object with name and description.
9
- * Category is inferred from the directory name.
10
- *
11
- * Usage: bun examples (or: bun examples/viewer.tsx)
12
- *
13
- * Controls:
14
- * j/k or arrows - Navigate examples
15
- * Ctrl+K - Command palette (switch examples)
16
- * s - Settings (theme picker)
17
- * Tab - Toggle View / Source tab
18
- * Enter - Run selected example standalone
19
- * q/Escape - Quit
20
- */
21
-
22
- import React, { useState, useCallback, useMemo, useEffect } from "react"
23
- import { readFileSync } from "node:fs"
24
- import { resolve } from "node:path"
25
- import {
26
- render,
27
- renderStatic,
28
- Box,
29
- Text,
30
- Spacer,
31
- ThemeProvider,
32
- builtinThemes,
33
- useInput,
34
- useApp,
35
- useContentRect,
36
- createTerm,
37
- PickerDialog,
38
- type Key,
39
- type Theme,
40
- } from "../src/index.js"
41
-
42
- // Ctrl+K is the universal command palette shortcut in terminals
43
- // (Cmd+K requires Kitty protocol which isn't always available)
44
- const MOD_KEY = "Ctrl"
45
-
46
- // =============================================================================
47
- // Auto-Discovery
48
- // =============================================================================
49
-
50
- interface Example {
51
- name: string
52
- file: string
53
- description: string
54
- category: string
55
- /** Export name of the main component (enables live preview) */
56
- component?: string
57
- /** API features showcased */
58
- features?: string[]
59
- }
60
-
61
- const CATEGORY_DIRS = ["layout", "interactive", "runtime", "inline", "kitty"] as const
62
-
63
- const CATEGORY_ORDER: Record<string, number> = {
64
- Layout: 0,
65
- Interactive: 1,
66
- Runtime: 2,
67
- Inline: 3,
68
- "Kitty Protocol": 4,
69
- }
70
-
71
- const CATEGORY_COLOR: Record<string, string> = {
72
- Layout: "magenta",
73
- Interactive: "cyan",
74
- Runtime: "green",
75
- Inline: "yellow",
76
- "Kitty Protocol": "blue",
77
- }
78
-
79
- async function discoverExamples(): Promise<Example[]> {
80
- const baseDir = new URL(".", import.meta.url).pathname
81
- const results: Example[] = []
82
-
83
- const CATEGORY_DISPLAY: Record<string, string> = { kitty: "Kitty Protocol" }
84
-
85
- for (const dir of CATEGORY_DIRS) {
86
- const category = CATEGORY_DISPLAY[dir] ?? dir.charAt(0).toUpperCase() + dir.slice(1)
87
- const dirPath = resolve(baseDir, dir)
88
- const files = [
89
- ...new Bun.Glob("*.tsx").scanSync({ cwd: dirPath }),
90
- ...new Bun.Glob("*/index.tsx").scanSync({ cwd: dirPath }),
91
- ]
92
-
93
- for (const file of files) {
94
- try {
95
- const mod = await import(resolve(dirPath, file))
96
- if (!mod.meta?.name || !mod.meta?.demo) continue
97
-
98
- // Find first exported function that isn't meta or default
99
- let component: string | undefined
100
- for (const [key, value] of Object.entries(mod)) {
101
- if (key === "meta" || key === "default") continue
102
- if (typeof value === "function") {
103
- component = key
104
- break
105
- }
106
- }
107
-
108
- results.push({
109
- name: mod.meta.name,
110
- description: mod.meta.description ?? "",
111
- file: `${dir}/${file}`,
112
- category,
113
- component,
114
- features: mod.meta.features,
115
- })
116
- } catch {
117
- // Skip files that fail to import
118
- }
119
- }
120
- }
121
-
122
- results.sort((a, b) => {
123
- const catDiff = (CATEGORY_ORDER[a.category] ?? 99) - (CATEGORY_ORDER[b.category] ?? 99)
124
- if (catDiff !== 0) return catDiff
125
- return a.name.localeCompare(b.name)
126
- })
127
-
128
- return results
129
- }
130
-
131
- // =============================================================================
132
- // Syntax Highlighting
133
- // =============================================================================
134
-
135
- const KEYWORDS = new Set([
136
- "import",
137
- "from",
138
- "export",
139
- "default",
140
- "function",
141
- "const",
142
- "let",
143
- "var",
144
- "return",
145
- "if",
146
- "else",
147
- "for",
148
- "while",
149
- "switch",
150
- "case",
151
- "break",
152
- "new",
153
- "typeof",
154
- "instanceof",
155
- "async",
156
- "await",
157
- "yield",
158
- "class",
159
- "extends",
160
- "implements",
161
- "interface",
162
- "type",
163
- "enum",
164
- "true",
165
- "false",
166
- "null",
167
- "undefined",
168
- "this",
169
- "super",
170
- "of",
171
- "in",
172
- "as",
173
- "using",
174
- ])
175
-
176
- const REACT_KEYWORDS = new Set([
177
- "useState",
178
- "useEffect",
179
- "useCallback",
180
- "useMemo",
181
- "useRef",
182
- "useInput",
183
- "useApp",
184
- "useTerm",
185
- "useContentRect",
186
- "useScrollback",
187
- ])
188
-
189
- function highlightLine(line: string): React.ReactNode {
190
- if (line.trimStart().startsWith("//") || line.trimStart().startsWith("*") || line.trimStart().startsWith("/*")) {
191
- return (
192
- <Text dim color="gray">
193
- {line}
194
- </Text>
195
- )
196
- }
197
-
198
- const parts: React.ReactNode[] = []
199
- const regex =
200
- /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(<\/?[A-Z]\w*)|(\b[a-zA-Z_]\w*\b)|(\s+)|([^\s"'`<\w]+)/g
201
- let match: RegExpExecArray | null
202
- let i = 0
203
-
204
- while ((match = regex.exec(line)) !== null) {
205
- const [full, str, jsxTag, word] = match
206
- if (str) {
207
- parts.push(
208
- <Text key={i++} color="green">
209
- {str}
210
- </Text>,
211
- )
212
- } else if (jsxTag) {
213
- parts.push(
214
- <Text key={i++} color="cyan">
215
- {jsxTag}
216
- </Text>,
217
- )
218
- } else if (word && KEYWORDS.has(word)) {
219
- parts.push(
220
- <Text key={i++} color="magenta" bold>
221
- {word}
222
- </Text>,
223
- )
224
- } else if (word && REACT_KEYWORDS.has(word)) {
225
- parts.push(
226
- <Text key={i++} color="yellow">
227
- {word}
228
- </Text>,
229
- )
230
- } else {
231
- parts.push(<Text key={i++}>{full}</Text>)
232
- }
233
- }
234
-
235
- return parts.length > 0 ? <>{parts}</> : <Text>{line}</Text>
236
- }
237
-
238
- // =============================================================================
239
- // Components
240
- // =============================================================================
241
-
242
- function Sidebar({ examples, cursor, theme }: { examples: Example[]; cursor: number; theme: Theme }) {
243
- const { groups, scrollToChild } = useMemo(() => {
244
- const result: {
245
- category: string
246
- items: { example: Example; globalIdx: number }[]
247
- }[] = []
248
- let currentCat = ""
249
- let childIdx = 0
250
- let targetChild = 0
251
-
252
- for (let i = 0; i < examples.length; i++) {
253
- const ex = examples[i]!
254
- if (ex.category !== currentCat) {
255
- currentCat = ex.category
256
- result.push({ category: currentCat, items: [] })
257
- childIdx++
258
- }
259
- if (i === cursor) targetChild = childIdx
260
- result[result.length - 1]!.items.push({ example: ex, globalIdx: i })
261
- childIdx++
262
- }
263
- return { groups: result, scrollToChild: targetChild }
264
- }, [examples, cursor])
265
-
266
- return (
267
- <Box
268
- flexDirection="column"
269
- width={28}
270
- borderStyle="round"
271
- borderColor="$border"
272
- overflow="scroll"
273
- scrollTo={scrollToChild}
274
- >
275
- {groups.map((group) => (
276
- <React.Fragment key={group.category}>
277
- <Box paddingX={1}>
278
- <Text bold color={CATEGORY_COLOR[group.category] ?? "$text"} dim>
279
- {group.category}
280
- </Text>
281
- </Box>
282
- {group.items.map(({ example, globalIdx }) => {
283
- const selected = globalIdx === cursor
284
- return (
285
- <Box key={example.name} paddingX={1} backgroundColor={selected ? "$primary" : undefined}>
286
- <Text color={selected ? "$text" : "$text"} bold={selected} wrap="truncate">
287
- {selected ? "\u25B8 " : " "}
288
- {example.name}
289
- </Text>
290
- </Box>
291
- )
292
- })}
293
- </React.Fragment>
294
- ))}
295
- </Box>
296
- )
297
- }
298
-
299
- /** Pad content lines to fill the full height — prevents stale pixel artifacts
300
- * from the incremental renderer when switching between previews of different heights. */
301
- function padLines(contentLines: string[], totalHeight: number): string[] {
302
- if (contentLines.length >= totalHeight) return contentLines.slice(0, totalHeight)
303
- return [...contentLines, ...Array<string>(totalHeight - contentLines.length).fill("")]
304
- }
305
-
306
- function Preview({ example, theme }: { example: Example; theme: Theme }) {
307
- const { width, height } = useContentRect()
308
- const [lines, setLines] = useState<string[] | null>(null)
309
- const [error, setError] = useState<string | null>(null)
310
-
311
- useEffect(() => {
312
- setLines(null)
313
- setError(null)
314
-
315
- if (!example.component) {
316
- setError("no-component")
317
- return
318
- }
319
-
320
- // Wait for layout dimensions
321
- if (width === 0 || height === 0) return
322
-
323
- let cancelled = false
324
- const path = new URL(example.file, import.meta.url).pathname
325
-
326
- import(path)
327
- .then(async (mod: Record<string, unknown>) => {
328
- if (cancelled) return
329
- const Comp = mod[example.component!] as React.ComponentType | undefined
330
- if (!Comp) {
331
- setError(`Export "${example.component}" not found`)
332
- return
333
- }
334
-
335
- // Render in sandboxed static mode — useInput becomes a no-op,
336
- // useApp gets a stub exit(), no terminal needed.
337
- // Wrap in ThemeProvider so previews pick up the active theme.
338
- const output = await renderStatic(React.createElement(ThemeProvider, { theme }, React.createElement(Comp)), {
339
- width,
340
- height,
341
- })
342
- if (!cancelled) setLines(output.split("\n"))
343
- return undefined
344
- })
345
- .catch((e: Error) => {
346
- if (!cancelled) setError(e.message || String(e))
347
- })
348
-
349
- return () => {
350
- cancelled = true
351
- }
352
- }, [example.file, example.component, width, height])
353
-
354
- // All paths pad to full height to clear stale pixels from prior previews
355
- const renderLines = (contentLines: string[]) => (
356
- <Box flexDirection="column" flexGrow={1}>
357
- {padLines(contentLines, height).map((line, i) => (
358
- <Text key={i} wrap="truncate">
359
- {line}
360
- </Text>
361
- ))}
362
- </Box>
363
- )
364
-
365
- if (error === "no-component") {
366
- return renderLines(["", " No live preview — uses non-React API.", " Press Enter to run standalone."])
367
- }
368
-
369
- if (error) {
370
- return renderLines(["", ` Error: ${error}`])
371
- }
372
-
373
- if (!lines) {
374
- return renderLines(["", " Loading preview..."])
375
- }
376
-
377
- return renderLines(lines)
378
- }
379
-
380
- function SourceCode({ example }: { example: Example }) {
381
- const lines = useMemo(() => {
382
- try {
383
- const path = new URL(example.file, import.meta.url).pathname
384
- return readFileSync(path, "utf-8").split("\n")
385
- } catch {
386
- return ["// Could not load file"]
387
- }
388
- }, [example.file])
389
-
390
- return (
391
- <Box flexDirection="column" flexGrow={1} paddingX={1}>
392
- {lines.map((line, i) => (
393
- <Text key={i} wrap="truncate">
394
- <Text dim color="gray">
395
- {String(i + 1).padStart(3)}{" "}
396
- </Text>
397
- {highlightLine(line)}
398
- </Text>
399
- ))}
400
- </Box>
401
- )
402
- }
403
-
404
- const THEME_NAMES = Object.keys(builtinThemes)
405
-
406
- type Dialog = "none" | "command-palette" | "settings"
407
-
408
- function Viewer({ examples }: { examples: Example[] }) {
409
- const { exit } = useApp()
410
- const [cursor, setCursor] = useState(0)
411
- const [tab, setTab] = useState<"view" | "source">("view")
412
- const [running, setRunning] = useState<string | null>(null)
413
- const [themeIdx, setThemeIdx] = useState(THEME_NAMES.indexOf("ansi16-dark"))
414
- const [dialog, setDialog] = useState<Dialog>("none")
415
- const [paletteQuery, setPaletteQuery] = useState("")
416
- const [themeQuery, setThemeQuery] = useState("")
417
-
418
- const theme = builtinThemes[THEME_NAMES[themeIdx]!]!
419
- const maxCursor = examples.length - 1
420
- const selected = examples[cursor]!
421
-
422
- const runExample = useCallback(
423
- (idx: number) => {
424
- const example = examples[idx]
425
- if (!example) return
426
- setRunning(example.name)
427
- exit()
428
-
429
- const file = new URL(example.file, import.meta.url).pathname
430
- const proc = Bun.spawn(["bun", "run", file], {
431
- stdio: ["inherit", "inherit", "inherit"],
432
- env: { ...process.env, SILVERY_THEME: theme.name },
433
- })
434
- void proc.exited.then(() => process.exit(0))
435
- },
436
- [examples, exit, theme.name],
437
- )
438
-
439
- // --- Command palette items ---
440
- const paletteItems = useMemo(() => {
441
- const q = paletteQuery.toLowerCase()
442
- return examples
443
- .map((ex, idx) => ({ ...ex, idx }))
444
- .filter((ex) => !q || ex.name.toLowerCase().includes(q) || ex.category.toLowerCase().includes(q))
445
- }, [examples, paletteQuery])
446
-
447
- // --- Theme picker items ---
448
- const themeItems = useMemo(() => {
449
- const q = themeQuery.toLowerCase()
450
- return THEME_NAMES.filter((name) => !q || name.toLowerCase().includes(q))
451
- }, [themeQuery])
452
-
453
- useInput((input: string, key: Key) => {
454
- if (running || dialog !== "none") return
455
-
456
- if (input === "q" || key.escape) {
457
- exit()
458
- return
459
- }
460
-
461
- // Ctrl+K — command palette
462
- if (input === "k" && key.ctrl) {
463
- setPaletteQuery("")
464
- setDialog("command-palette")
465
- return
466
- }
467
-
468
- if (key.tab) {
469
- setTab((t) => (t === "view" ? "source" : "view"))
470
- return
471
- }
472
-
473
- if (key.downArrow || input === "j") {
474
- setCursor((prev) => Math.min(maxCursor, prev + 1))
475
- }
476
- if (key.upArrow || input === "k") {
477
- setCursor((prev) => Math.max(0, prev - 1))
478
- }
479
- if (key.home || input === "g") {
480
- setCursor(0)
481
- }
482
- if (key.end || input === "G") {
483
- setCursor(maxCursor)
484
- }
485
- if (key.return) {
486
- runExample(cursor)
487
- }
488
- if (input === "s") {
489
- setThemeQuery("")
490
- setDialog("settings")
491
- }
492
- })
493
-
494
- if (running) {
495
- return (
496
- <ThemeProvider theme={theme}>
497
- <Box padding={1}>
498
- <Text color="$muted">Launching {running}...</Text>
499
- </Box>
500
- </ThemeProvider>
501
- )
502
- }
503
-
504
- // Derive URL key from file path (e.g., "interactive/kanban.tsx" → "kanban")
505
- const exampleKey = selected.file.replace(/^.*\//, "").replace(/\.tsx$/, "")
506
-
507
- return (
508
- <ThemeProvider theme={theme}>
509
- <Box flexDirection="column" flexGrow={1}>
510
- {/* Header */}
511
- <Box paddingX={1}>
512
- <Text bold color="$warning">
513
- {" silvery"}
514
- </Text>
515
- <Text color="$muted"> examples </Text>
516
- <Text color="$muted">
517
- ({cursor + 1}/{examples.length})
518
- </Text>
519
- <Spacer />
520
- <Text color="$muted">
521
- theme:{" "}
522
- <Text color="$primary" bold>
523
- {theme.name}
524
- </Text>
525
- </Text>
526
- </Box>
527
-
528
- {/* Main: sidebar + content */}
529
- <Box flexDirection="row" flexGrow={1} gap={1}>
530
- <Sidebar examples={examples} cursor={cursor} theme={theme} />
531
-
532
- {/* Content area with tabs */}
533
- <Box flexDirection="column" flexGrow={1} borderStyle="round" borderColor="$border" overflow="hidden">
534
- {/* Info banner */}
535
- <Box paddingX={1} flexDirection="column">
536
- <Text wrap="truncate">
537
- <Text bold color="$text">
538
- {selected.name}
539
- </Text>
540
- <Text color="$muted"> — {selected.description}</Text>
541
- </Text>
542
- {selected.features && selected.features.length > 0 && (
543
- <Text color="$muted" wrap="truncate">
544
- {selected.features.join(" · ")}
545
- </Text>
546
- )}
547
- <Text color="$muted" dim wrap="truncate">
548
- silvery.dev/examples/{exampleKey}
549
- </Text>
550
- </Box>
551
-
552
- {/* Tab bar */}
553
- <Box paddingX={1}>
554
- <Text>
555
- <Text bold={tab === "view"} color={tab === "view" ? "$primary" : "$muted"}>
556
- View
557
- </Text>
558
- <Text color="$border"> | </Text>
559
- <Text bold={tab === "source"} color={tab === "source" ? "$primary" : "$muted"}>
560
- Source
561
- </Text>
562
- </Text>
563
- </Box>
564
-
565
- {/* Tab content — key forces full teardown on example switch */}
566
- {tab === "view" ? (
567
- <Box key={selected.file} flexDirection="column" flexGrow={1} overflow="hidden">
568
- <Preview example={selected} theme={theme} />
569
- </Box>
570
- ) : (
571
- <SourceCode key={selected.file} example={selected} />
572
- )}
573
- </Box>
574
- </Box>
575
-
576
- {/* Bottom bar */}
577
- <Box paddingX={1}>
578
- <Text color="$muted">
579
- <Text bold>{MOD_KEY}-K</Text> switch <Text bold>s</Text> settings <Text bold>Tab</Text>{" "}
580
- {tab === "view" ? "source" : "view"} <Text bold>Enter</Text> run <Text bold>q</Text> quit
581
- </Text>
582
- </Box>
583
-
584
- {/* Command palette (Cmd-K) */}
585
- {dialog === "command-palette" && (
586
- <PickerDialog
587
- title="Switch Example"
588
- placeholder="Type to search..."
589
- items={paletteItems}
590
- renderItem={(item, sel) => (
591
- <Text color={sel ? "$primary" : "$text"} bold={sel}>
592
- <Text color="$muted" dim>
593
- {item.category}
594
- {" / "}
595
- </Text>
596
- {item.name}
597
- </Text>
598
- )}
599
- keyExtractor={(item) => item.file}
600
- onSelect={(item) => {
601
- setCursor(item.idx)
602
- setDialog("none")
603
- }}
604
- onCancel={() => setDialog("none")}
605
- onChange={setPaletteQuery}
606
- />
607
- )}
608
-
609
- {/* Settings / theme picker (s key) */}
610
- {dialog === "settings" && (
611
- <PickerDialog
612
- title="Theme"
613
- placeholder="Type to filter themes..."
614
- items={themeItems}
615
- renderItem={(name, sel) => {
616
- const t = builtinThemes[name]!
617
- return (
618
- <Text color={sel ? "$primary" : "$text"} bold={sel}>
619
- {name === THEME_NAMES[themeIdx] ? "* " : " "}
620
- {name}
621
- <Text color="$muted" dim>
622
- {" "}
623
- {t.dark ? "dark" : "light"}
624
- </Text>
625
- </Text>
626
- )
627
- }}
628
- keyExtractor={(name) => name}
629
- onSelect={(name) => {
630
- setThemeIdx(THEME_NAMES.indexOf(name))
631
- setDialog("none")
632
- }}
633
- onCancel={() => setDialog("none")}
634
- onChange={setThemeQuery}
635
- />
636
- )}
637
- </Box>
638
- </ThemeProvider>
639
- )
640
- }
641
-
642
- // =============================================================================
643
- // Main
644
- // =============================================================================
645
-
646
- async function main() {
647
- const examples = await discoverExamples()
648
-
649
- using term = createTerm()
650
- const { waitUntilExit } = await render(<Viewer examples={examples} />, term)
651
- await waitUntilExit()
652
- }
653
-
654
- main().catch(console.error)