SLOPSHOPPER

lab

Experiments that probe what the rendering engine accepts: SVG, animation, interaction and every element, each in its own pane

newpanecommand
v0.2.5MITupdated 2026-10-14satoramoto/awesome-claude/mods/lab
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lab
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /lab ⎿ lab: Lab opened: 77 experiments. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

awesome-claude

Mods for Claude Code. A mod is a Claude Code plugin that adds interface to Claude Code itself (a pane, a slash command) through the engine's hooks API. This repository holds our own mods, forks of others' (each kept under its own license, with a link back), and is a plugin marketplace that installs them.

The panes draw in the desktop app (as a side pane, with charts) and in the terminal (a simpler pane of text cells). Tested on Claude Code 2.1.296, the version CI pins.

The mods

ModWhat it showsOpens withNeeds
overviewthe current session at a glance: context, limits, activity, agents, the branches it started and their pull requests, coston its own when a session starts; /overview opens it againgit for Workspaces; gh signed in for pull requests
issuesthe open GitHub issues of the session's repository, newest change first, with actions on eachon its own when a session starts; /issues opens it againa GitHub repository; gh signed in
launcherthe project's dev servers from .claude/launch.json (the desktop app's preview list), each with its state, URL, Start, Stop and Restart, and its outputon its own when a session starts; /launcher (or /preview) opens it again; /launcher start <name>, /launcher stop <name>a .claude/launch.json; lsof to tell which ports answer
labfor mod authors: 77 experiments probing what the engine draws (SVG gradients and filters, animation, hover, Client modules, every element), with the engine's verdict on each surface in mods/lab/verdicts.jsonthe index on its own when a session starts; /lab, /lab <id>, /lab gallerynothing

Forked mods

Copies of other people's mods, changed here to fit these. Each keeps its original license and author; its README opens with where it came from, the upstream commit it was taken at, and what this fork changed.

ModWhat it doesOriginalLicense
filetreean IDE-style file tree of the session's folder: git status and lines changed per file, and a shimmer on what Claude reads, writes and commits. /filetree opens itdata-goblin/claude-code-filetree, by Kurt BuhlerMIT
blast-radiusholds a risky shell command (rm -rf, git reset --hard, git clean, force push, migrations) and shows what it would change, with Proceed and Cancelanthropics/claude-code-playground, by AnthropicApache-2.0

Requirements

  • Claude Code: tested on 2.1.296.
  • git, for overview's Workspaces section.
  • The GitHub CLI, signed in (gh auth login), for issues and for the pull requests in overview's Workspaces. Without it overview still draws everything else, and its Workspaces section shows a note in place of pull requests (gh not installed, or gh not authenticated (run gh auth login)); issues shows the same note in place of the list.
  • Bun, only to build from source (bun install, then bun build.ts). Installing from the marketplace needs nothing else.

What the panes do

/overview

Opens by itself when a session starts; /overview brings it back after you close it. Its sections, each folding open and shut:

  • Context: how full the context window is, by kind, and the room left before compaction.
  • Limits: your plan's usage limits and when they reset.
  • Activity: tool calls per turn, sliced by tool, with failures.
  • Agents: the session's subagents, each with its status, tokens and time.
  • Workspaces: the branches the session started, each with its pull request's state, checks and lines changed. Pull requests are read with gh every 30 seconds while any is open.
  • Accounting: the session's cost and model.

A footer shows the session's share of the machine's cpu, memory and network.

/issues

Opens by itself when a session starts; /issues brings it back. Lists the open issues of the session's repository (read with gh issue list).

  • Quick add creates an issue from a title alone, with an empty body (gh issue create).
  • Refine, Start and Sprint send a prompt to the session, as if you'd typed it, and the session does the work with gh:
  • Refine: the session reads the issue, asks you about what's unclear, rewrites its body and labels it refined.
  • Start: one subagent takes the issue in its own worktree and branch off the default branch, and reports back when it's pushed. An issue not yet labelled refined asks first whether to refine it.
  • Sprint (select several issues): one subagent per issue in parallel, each in its own worktree, integrated on one sprint/<date> branch, then one PR for your approval with auto-merge off.

These prompts encode an opinionated way of working (worktrees, a subagent per issue, an integration branch reviewed by you). Read mods/issues/hooks/register.tsx for their exact wording before you rely on them.

/launcher

The desktop app's preview launcher, in a pane that works in the terminal too. Opens by itself when a session starts; /launcher brings it back. It lists every configuration in the project's .claude/launch.json (the session's folder, or the nearest one above it up to the git root), one row each: its name, its state, its URL once the port answers, and Start, Stop and Restart.

  • States: stopped (grey); starting (blue: started here, its port not answering yet); running (green, with http://localhost:<port>); running elsewhere (green: something this session didn't start answers on the port, such as the desktop app or another session; it can't be started twice); failed (red: it exited with an error, and its row unfolds to show why).
  • Start runs runtimeExecutable runtimeArgs… in the configuration's cwd (the project root by default) with PORT set to its port and its env over that. With autoPort, a taken port moves it to the next free one.
  • Unfold a row for its command, its exit code, and the tail of its output (the last 200 lines are kept, colours stripped).
  • From the prompt: /launcher start <name>, /launcher stop <name>, /launcher restart <name>, /launcher list. Each answers in text, so they work without the pane. /preview does the same.
  • Which ports answer comes from lsof (an HTTP request to each port where lsof isn't installed), every 2 s while the pane is shown, and not at all while it isn't.
  • Stop frees the port: checked with npm run serve starting a node server, ending the spawn loop ends the grandchild node too, so its port was free at once.

The servers it starts are children of the mod: they end when the session ends, and when the mod reloads (/reload-plugins, or a rebuild with hot reloading on). Servers started elsewhere aren't touched; Stop is only for the ones this session started.

Install

From the marketplace

At the prompt of a Claude Code session in a terminal:

/plugin marketplace add satoramoto/awesome-claude
/plugin install overview@awesome-claude
/plugin install issues@awesome-claude

Pick a scope when asked (user scope loads the mod in every session). Each mod is active straight away. Or from a shell: claude plugin marketplace add satoramoto/awesome-claude, then claude plugin install overview@awesome-claude.

Run these from a terminal session; a mod installed at user scope then loads in the desktop app too.

Getting updates

An installed mod updates only when its version changes; every release of a mod raises it. To fetch the newest versions by hand, at the prompt:

/plugin marketplace update awesome-claude
/plugin update overview@awesome-claude

or from a shell: claude plugin marketplace update awesome-claude, then claude plugin update overview@awesome-claude (and the same for each mod you installed). The new version loads after a restart or /reload-plugins.

Auto-update is off by default for marketplaces other than Anthropic's. Turn it on in /plugin → Marketplaces → awesome-claude → Enable auto-update, or with "autoUpdate": true next to source in extraKnownMarketplaces in ~/.claude/settings.json:

"extraKnownMarketplaces": {
  "awesome-claude": {
    "source": { "source": "github", "repo": "satoramoto/awesome-claude" },
    "autoUpdate": true
  }
}

Loading from a clone (--plugin-dir or CLAUDE_CODE_PLUGIN_DIRS, below) gets no marketplace updates: git pull the clone instead. To pull at every session start, a SessionStart hook in ~/.claude/settings.json:

"hooks": {
  "SessionStart": [
    { "hooks": [{ "type": "command", "command": "git -C ~/Code/satoramoto/awesome-claude pull -q --ff-only" }] }
  ]
}

Try it from a clone, for one session

The compiled mods are committed, so a clone runs as is. A folder of plugins passed to --plugin-dir loads each one in it:

git clone https://github.com/satoramoto/awesome-claude ~/Code/satoramoto/awesome-claude
claude --plugin-dir ~/Code/satoramoto/awesome-claude/plugins

From source, while changing the mods

git clone https://github.com/satoramoto/awesome-claude ~/Code/satoramoto/awesome-claude
cd ~/Code/satoramoto/awesome-claude
bun install
bun build.ts

Then point every session at the compiled folder, in ~/.claude/settings.json, with the full path to your clone's plugins folder:

"env": {
  "CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/Code/satoramoto/awesome-claude/plugins",
  "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}

With bun run watch running, a save under mods/ (or in zen's kit, with ZEN set) recompiles and the session reloads the mod. These two variables are what this setup uses on Claude Code 2.1.296; they may change in later versions. Don't also install the mods from the marketplace, or each loads twice.

Making a mod

AGENTS.md lists the commands and checks. The project's skills, which Claude Code loads when working in this repository, hold the how-to:

FolderWhat it is
mods/<name>/a mod's source: .claude-plugin/plugin.json, hooks/, types/. Imports the kit as @kit/…
zen's src/harness/claude-code/kit/the kit, from the pinned @satoramoto/zen dev dependency: components (ui.tsx), colors and glyphs (tokens.ts), number formatting (format.ts), icons (icons.ts), timing a mod's own work (perf.ts), skins (skin.ts, skins.ts)
zen.tswhere zen's kit and bench are: node_modules/@satoramoto/zen, or the zen checkout ZEN names
plugins/<name>/the compiled mods: what Claude Code loads and the marketplace installs. Generated by bun build.ts; don't edit
.claude-plugin/marketplace.jsonthe marketplace: each mod in plugins/, at its plugin.json version
playground/a browser page that draws every mod live with sample data, on each surface, through zen's bench; playground/fixtures/ holds the sample data
stories/Storybook stories: each mod's scenarios, the lab and the engine experiments

Each mod is versioned on its own in its plugin.json. A new mod also gets an entry in .claude-plugin/marketplace.json; CI fails if a mod is missing there or its version differs from its plugin.json. Any change to a mod's compiled output in plugins/<mod>/ (a new zen pin that changes the kit included) raises its version, in plugin.json and the marketplace, since installs only update when it changes; CI fails a pull request that changes a mod's output without raising its version.

The playground

bun run playground

At http://localhost:5173 (set PORT for another). Every mod's ui.render hooks, drawn with the scenarios in playground/fixtures/<mod>.ts, side by side in desktop light, desktop dark and terminal, at any pane width. On each save it also compiles and runs claude plugin validate plugins/<mod>, and shows the result in the bar.

Storybook

bun run storybook

At http://localhost:6006 (set PORT for another). Each mod scenario (stories/<mod>.stories.ts, from the playground's fixtures), drawn by zen's bench. The toolbar picks the surface (desktop or terminal), the theme, the frame and the pane width. The kit's own components and the skins have their stories in zen's Storybook, under Harness/Claude Code. Storybook is for how things look; the playground is for wiring up a whole mod with the engine's checks running.

Why a compiler

The engine reads a mod's source as it is and refuses any import from outside the mod's folder (symlinks included). build.ts copies zen's kit into each compiled mod and rewrites @kit/… to relative paths; nothing else changes, so the output is still readable source and passes claude plugin validate.

Drawing for the desktop

What the desktop accepts, found by probing the engine's validator (first on 2.1.293; the mods are tested on 2.1.296): whole percentages only (25.5% is refused); flexGrow up to 10000; fractional heights and margins; no Box hover. Borders: round draws rounded corners, and single, double, bold and classic draw; dashed doesn't. SVG is drawn as an image the page scales, so it blurs as the pane resizes: draw with boxes and text.

What it paints (measured on the real pane, 2.1.295): a horizontal unit is a column (~7.8px), height a row (~19px), but vertical margin, padding and row gap half a row (~9.5px); a transparent border colour draws in the text's colour; a Button draws its label alone, never its children. zen's docs/harness-claude-code.md ("What the engine and the desktop accept") has the details, and Storybook draws by the same rules.

License

MIT, except the forked mods, which keep their own licenses: mods/filetree is MIT, copyright Kurt Buhler (LICENSE), and mods/blast-radius is Apache-2.0, copyright Anthropic PBC (LICENSE).

Source 12 files
hooks/register.tsx 204 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import { flow, flowColumns, holdButtons, kit } from '@kit/ui'
4
5import { GROUPS, byId, experiments, galleryExperiments } from './experiments'
6import type { Experiment } from './experiments'
7
8const INDEX = 'lab'
9const PREFIX = 'lab-'
10const GALLERY = 'lab-gallery'
11
12/** Whether a wide app pane flows the index's groups and the gallery into columns; the footer's switch flips it. */
13const columns = atom({ plugin: 'lab', key: 'columns' } as const, true)
14
15/** The column view's switch for a render: as the state holds it, and a press that flips it. */
16const flowSwitch = async ($: EngineInterface) => ({
17  on: await read($, columns),
18  toggle: () => void update($, columns, was => !was).catch(() => undefined),
19})
20
21/** Every experiment the desktop accepts, in one pane, for checking paint by eye. */
22const openGallery = ($: EngineInterface) => $.ui.open({ id: GALLERY, title: 'Lab: gallery' })
23
24const openExperiment = ($: EngineInterface, id: string) => {
25  const x = byId(id)
26  return $.ui.open({ id: PREFIX + id, title: x ? `Lab: ${x.title}` : id })
27}
28
29/**
30 * The elements each surface's table declares (the types' `Elements`). The
31 * table `$.ui.resolve` hands back answers every name on every surface: one a
32 * surface lacks is a stand-in that draws an empty column Box, so asking it
33 * says nothing, and this list is how the pane tells.
34 */
35const TABLES: Record<string, string[]> = {
36  terminal: ['Box', 'Text', 'Button', 'Input', 'Select', 'Link', 'Code', 'Markdown', 'Client', 'Raster', 'Image'],
37  desktop: ['Box', 'Text', 'Button', 'Input', 'Select', 'Svg', 'Link', 'Code', 'Markdown', 'Client'],
38  vscode: ['Box', 'Text', 'Button', 'Input', 'Select', 'Svg', 'Link', 'Code', 'Markdown'],
39  mobile: ['Box', 'Text', 'Button', 'Svg', 'Link', 'Code', 'Markdown'],
40}
41
42/**
43 * The surface's elements, noting each one the experiment asked for that the
44 * surface's table does not declare. For an element with no constructor at all
45 * it hands the bare tag name, so the engine names it in its refusal rather
46 * than the hook failing on an undefined constructor.
47 */
48const elementsOf = (table: object, surface: string, absent: Set<string>): any => {
49  const listed = new Set(TABLES[surface] ?? Object.keys(table))
50  return new Proxy(table, {
51    get: (t: any, k) => {
52      if (typeof k !== 'string') return t[k]
53      if (!listed.has(k)) absent.add(k)
54      return t[k] ?? k
55    },
56  })
57}
58
59export const register: Register = on => {
60  on('session.start', async ($, e, next) => {
61    await $.command.register({
62      name: 'lab',
63      description: 'Open the rendering lab: experiments on what the engine draws (/lab <id> opens one)',
64      argumentHint: '[experiment id]',
65    })
66    void $.ui.open({ id: INDEX, title: 'Lab' })
67    return next(e)
68  })
69
70  on('command.run', { command: 'lab' }, async ($, e) => {
71    const id = e.args.trim()
72    if (!id) {
73      await $.ui.open({ id: INDEX, title: 'Lab' })
74      return { text: `Lab opened: ${experiments.length} experiments.` }
75    }
76    if (id === 'gallery') {
77      await openGallery($)
78      return { text: `Lab gallery opened: ${galleryExperiments().length} experiments the desktop accepts.` }
79    }
80    if (!byId(id)) return { text: `No experiment "${id}". Run /lab for the list.` }
81    await openExperiment($, id)
82    return { text: `Lab experiment ${id} opened.` }
83  })
84
85  // The index: every experiment by group, a row each that opens its pane.
86  on('ui.render', { component: 'Pane', requestId: INDEX }, async ($, e) => {
87    const sw = await flowSwitch($)
88    // Buttons keep their press handles across redraws, so a click made just before one still lands.
89    const table = holdButtons($.ui.resolve(e), e)
90    const { Box, Button, Text } = table
91    // The kit draws the footer with the column view's switch (wide app panes only).
92    const k = kit(table as never, e.surface, e.props.bodyColumns, { flow: sw })
93    return (
94      <Box flexDirection="column" gap={1}>
95        <Button key="gallery" variant="primary" label="Gallery" onPress={() => void openGallery($)} />
96        <Text dimColor>
97          {experiments.length} experiments on what the {e.surface} surface draws. Each opens in a pane of its own.
98        </Text>
99        {/* The groups in columns when the desktop pane is wide; one stack otherwise. */}
100        {flow(Box, e.surface, e.props.bodyColumns, 'groups', GROUPS.map(group => (
101          <Box key={`group-${group}`} flexDirection="column">
102            <Text bold>{group}</Text>
103            {experiments
104              .filter(x => x.group === group)
105              .map(x => {
106                const note = `${x.id}${x.surfaces.length ? ` · ${x.surfaces.join(', ')}` : ' · expected refusal'}`
107                // The desktop draws a Button's label alone, never a dim child Text: there the
108                // title is the label and the note a dim Text beside it.
109                return e.surface === 'terminal' ? (
110                  <Button key={`open-${x.id}`} plain onPress={() => void openExperiment($, x.id)}>
111                    {x.title}{' '}
112                    <Text dimColor>{note}</Text>
113                  </Button>
114                ) : (
115                  <Box key={`row-${x.id}`} flexDirection="row" gap={1}>
116                    <Button key={`open-${x.id}`} plain label={x.title} onPress={() => void openExperiment($, x.id)} />
117                    <Text dimColor wrap="truncate">
118                      {note}
119                    </Text>
120                  </Box>
121                )
122              })}
123          </Box>
124        )), { gap: 1, single: !sw.on })}
125        {k.footer()}
126      </Box>
127    )
128  })
129
130  // One pane per experiment, and the gallery, all drawn by this hook.
131  on('ui.render', { component: 'Pane', requestId: /^lab-/ }, async ($, e) => {
132    // One table for the whole render, the experiment's included, its Buttons keeping their press handles.
133    const table = holdButtons($.ui.resolve(e), e)
134    const { Box, Button, Text } = table
135    const absent = new Set<string>()
136    const els = elementsOf(table, e.surface, absent)
137    // An element constructor may refuse its props as it is called (a bad
138    // hotkey): that is a verdict too, drawn in place of the experiment.
139    const drawBody = (x: Experiment) => {
140      try {
141        return x.draw(els, e.surface)
142      } catch (err) {
143        return <Text color="error">{`draw threw: ${String((err as Error)?.message ?? err)}`}</Text>
144      }
145    }
146    if (e.requestId === GALLERY) {
147      const sw = await flowSwitch($)
148      const k = kit(table as never, e.surface, e.props.bodyColumns, { flow: sw })
149      return (
150        <Box flexDirection="column" gap={1}>
151          {/* The experiments in columns when the desktop pane is wide, each clipped to its column; one stack otherwise. */}
152          {flow(
153            Box,
154            e.surface,
155            e.props.bodyColumns,
156            'gallery',
157            galleryExperiments().map((x, _i, all) => (
158              <Box
159                key={`x-${x.id}`}
160                flexDirection="column"
161                {...(flowColumns(e.surface, e.props.bodyColumns, all.length, { single: !sw.on }).count > 1 ? { minWidth: 0, overflow: 'hidden' } : {})}
162              >
163                <Text dimColor wrap="truncate-end">{`${x.id} — ${x.question}`}</Text>
164                {drawBody(x)}
165              </Box>
166            )),
167            { gap: 1, single: !sw.on },
168          )}
169          {k.footer()}
170        </Box>
171      )
172    }
173    const x = byId(e.requestId.slice(PREFIX.length))
174    if (!x) return <Text dimColor>No such experiment.</Text>
175    const body = drawBody(x)
176    const listed = x.surfaces.includes(e.surface)
177    return (
178      <Box flexDirection="column" gap={1}>
179        <Box flexDirection="column">
180          <Text bold>{x.title}</Text>
181          <Text dimColor>{x.question}</Text>
182          <Text dimColor>
183            {x.group} · {x.id} ·{' '}
184            {listed ? `meant for ${x.surfaces.join(', ')}` : x.surfaces.length ? `not meant for ${e.surface}` : 'expected refusal'}
185          </Text>
186          {absent.size > 0 && (
187            <Text color="warning">
188              {`absent from the ${e.surface} table: ${[...absent].join(', ')}`}
189            </Text>
190          )}
191          <Button key="index" plain dimColor label="All experiments" onPress={() => void $.ui.open({ id: INDEX, title: 'Lab' })} />
192        </Box>
193        {body}
194      </Box>
195    )
196  })
197
198  // The Client round trip: the echo module posts a ping, this answers its next props.
199  on('ui.message', { element: 'echo' }, async (_$, e) => {
200    const data = e.data as { ping?: number } | null
201    return { props: { pong: data?.ping ?? 0, via: e.surface } }
202  })
203}
204
hooks/experiments.tsx 1115 lines
1import type { RenderElement } from 'claude-code'
2
3import * as S from './svg'
4
5/*
6 * The lab's catalogue: one experiment per question about what the rendering
7 * engine accepts. Each `draw` builds its tree from the elements it is handed
8 * (`$.ui.resolve(e)` in the engine, Storybook's own in a story) and the global
9 * JSX `h`, nothing else, so the same code runs in both.
10 */
11
12export type Surface = 'desktop' | 'terminal' | 'vscode' | 'mobile'
13export type Group = 'Graphics' | 'Animation' | 'Interaction' | 'Layout' | 'Text' | 'Elements'
14
15export type Experiment = {
16  id: string
17  group: Group
18  title: string
19  /** What it tests, one line. */
20  question: string
21  /** Where it is meant to draw; the tests mount it on the others too, to record refusals. */
22  surfaces: Surface[]
23  draw: (els: any, surface: string) => RenderElement
24}
25
26export const SURFACES: Surface[] = ['desktop', 'terminal', 'vscode', 'mobile']
27const REMOTE: Surface[] = ['desktop', 'vscode', 'mobile']
28const ALL: Surface[] = SURFACES
29const CLIENT: Surface[] = ['terminal', 'desktop']
30const FIELDS: Surface[] = ['desktop', 'terminal', 'vscode']
31
32/** Standard padded base64 of bytes; the environment's own `toBase64` is not everywhere. */
33const ABC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
34export const base64 = (bytes: Uint8Array): string => {
35  let out = ''
36  for (let i = 0; i < bytes.length; i += 3) {
37    const a = bytes[i] ?? 0
38    const b = bytes[i + 1] ?? 0
39    const c = bytes[i + 2] ?? 0
40    const n = (a << 16) | (b << 8) | c
41    out += ABC[(n >> 18) & 63]! + ABC[(n >> 12) & 63]!
42    out += i + 1 < bytes.length ? ABC[(n >> 6) & 63]! : '='
43    out += i + 2 < bytes.length ? ABC[n & 63]! : '='
44  }
45  return out
46}
47
48/** Raster cells: `[codePoint, fg, bg]` u32 triplets, little-endian, base64. */
49const cells = (triplets: number[]) => {
50  const words = Uint32Array.from(triplets)
51  return base64(new Uint8Array(words.buffer))
52}
53
54/** A hue ramp across `columns` x `rows` cells, half blocks for two pixels a cell. */
55const rampCells = (columns: number, rows: number) => {
56  const t: number[] = []
57  const rgb = (h: number) => {
58    const f = (n: number) => {
59      const k = (n + h * 6) % 6
60      return Math.round(255 * (1 - Math.max(0, Math.min(k, 4 - k, 1))))
61    }
62    return (f(5) << 16) | (f(3) << 8) | f(1)
63  }
64  for (let y = 0; y < rows; y++) {
65    for (let x = 0; x < columns; x++) t.push(0x2580, rgb((x + y * 2) / columns), rgb((x + y * 2 + 1) / columns))
66  }
67  return cells(t)
68}
69
70/** 16 x 16 RGBA pixels: a radial blob. */
71const rgbaBlob = () => {
72  const px = new Uint8Array(16 * 16 * 4)
73  for (let y = 0; y < 16; y++) {
74    for (let x = 0; x < 16; x++) {
75      const d = Math.hypot(x - 7.5, y - 7.5) / 8
76      const i = (y * 16 + x) * 4
77      px[i] = 47
78      px[i + 1] = 128
79      px[i + 2] = 237
80      px[i + 3] = Math.max(0, Math.round(255 * (1 - d)))
81    }
82  }
83  return base64(px)
84}
85
86/** A 1 x 1 PNG pixel. */
87const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
88
89const svg = (els: any, source: string, alt: string, isInteractive?: boolean, extra?: object) => {
90  const { Svg } = els
91  return <Svg source={source} alt={alt} isInteractive={isInteractive || undefined} {...extra} />
92}
93
94/** A labelled row: a dim label, then what it shows. */
95const row = (els: any, label: string, child: any, key?: string) => {
96  const { Box, Text } = els
97  return (
98    <Box key={key} flexDirection="row" gap={1}>
99      <Box width={18} flexShrink={0}>
100        <Text dimColor wrap="truncate-end">
101          {label}
102        </Text>
103      </Box>
104      {child}
105    </Box>
106  )
107}
108
109const col = (els: any, ...children: any[]) => {
110  const { Box } = els
111  return (
112    <Box flexDirection="column" gap={1}>
113      {children}
114    </Box>
115  )
116}
117
118const note = (els: any, text: string) => {
119  const { Text } = els
120  return <Text dimColor>{text}</Text>
121}
122
123const SAMPLE_TS = `export const total = (xs: number[]) =>
124  xs.reduce((sum, x) => sum + x, 0)
125
126// a very long line to see wrapping: ${'abcdefghij '.repeat(12)}
127const answer = total([40, 2])`
128
129const SAMPLE_DIFF = `@@ -1,5 +1,6 @@
130 import { a } from './a'
131-const b = a + 1
132+const b = a + 2
133+const c = b * 2
134 export default b
135 // end`
136
137const MARKDOWN = `## Markdown, as a reply draws it
138
139Some **bold**, _italic_, \`code\`, ~~struck~~ and a [link](https://example.com).
140
141| Surface | Svg | Client |
142| --- | --- | --- |
143| desktop | yes | yes |
144| terminal | no | yes |
145
146- one
147- two
148  - nested
149
150\`\`\`ts
151const x: number = 42
152\`\`\`
153
154> a quote
155
156<context>a context block, drawn as written here</context>`
157
158/** The colour strings the backgroundColor experiments try, by id. */
159const BACKGROUNDS: [string, string][] = [
160  ['hex', '#2f80ed'],
161  ['hex-short', '#28e'],
162  ['hex-alpha', '#2f80ed55'],
163  ['named', 'tomato'],
164  ['ansi-name', 'blueBright'],
165  ['theme-key', 'success'],
166  ['rgb', 'rgb(47, 128, 237)'],
167  ['rgba', 'rgba(47, 128, 237, 0.3)'],
168  ['transparent', 'transparent'],
169  ['hsl', 'hsl(140 60% 40%)'],
170  ['linear-gradient', 'linear-gradient(90deg, #2f80ed, #9b51e0)'],
171  ['radial-gradient', 'radial-gradient(circle, #f2994a, transparent)'],
172  ['css-var', 'var(--accent)'],
173]
174
175export const experiments: Experiment[] = [
176  // Graphics
177  {
178    id: 'svg-linear-gradient',
179    group: 'Graphics',
180    title: 'Linear gradients',
181    question: 'Does an Svg draw linearGradient fills, horizontal and vertical, with stop-opacity?',
182    surfaces: REMOTE,
183    draw: els => svg(els, S.LINEAR, 'Two linear gradient bars and a vertical gradient'),
184  },
185  {
186    id: 'svg-radial-gradient',
187    group: 'Graphics',
188    title: 'Radial gradients',
189    question: 'Does an Svg draw radialGradient fills (an off-centre sphere, a fading glow)?',
190    surfaces: REMOTE,
191    draw: els => svg(els, S.RADIAL, 'A shaded sphere and a glow'),
192  },
193  {
194    id: 'svg-blur',
195    group: 'Graphics',
196    title: 'Blur filter',
197    question: 'Does feGaussianBlur draw, at stdDeviation 1 and 4?',
198    surfaces: REMOTE,
199    draw: els => svg(els, S.BLUR, 'A square sharp, slightly blurred and very blurred'),
200  },
201  {
202    id: 'svg-drop-shadow',
203    group: 'Graphics',
204    title: 'Drop shadow',
205    question: 'Does feDropShadow draw, and the older blur, offset and merge chain?',
206    surfaces: REMOTE,
207    draw: els => svg(els, S.SHADOW, 'Two cards with drop shadows'),
208  },
209  {
210    id: 'svg-masks',
211    group: 'Graphics',
212    title: 'Clip, mask, opacity',
213    question: 'Do clipPath, a gradient mask, opacity and fill-opacity draw?',
214    surfaces: REMOTE,
215    draw: els => svg(els, S.MASKS, 'A clipped circle, a masked fade and translucent bars'),
216  },
217  {
218    id: 'svg-text',
219    group: 'Graphics',
220    title: 'SVG text and fonts',
221    question: 'Which font families, weights, styles, anchors and textPath does Svg text draw?',
222    surfaces: REMOTE,
223    draw: els => svg(els, S.FONTS, 'Text in sans, serif and mono, and text along a path'),
224  },
225  {
226    id: 'svg-pattern-use',
227    group: 'Graphics',
228    title: 'Pattern, marker, use',
229    question: 'Do pattern fills, markers and symbol + use (internal href) draw?',
230    surfaces: REMOTE,
231    draw: els => svg(els, S.PATTERN, 'A hatched box, an arrow and two reused dots'),
232  },
233  {
234    id: 'svg-size',
235    group: 'Graphics',
236    title: 'Svg width and height',
237    question: 'Do the width and height props scale the same markup (CSS pixels)?',
238    surfaces: REMOTE,
239    draw: els =>
240      col(
241        els,
242        row(els, 'markup size', svg(els, S.RADIAL, 'Sphere at its own size')),
243        row(els, 'width 120', svg(els, S.RADIAL, 'Sphere at 120 wide', false, { width: 120 })),
244        row(els, '480 x 40', svg(els, S.RADIAL, 'Sphere stretched', false, { width: 480, height: 40 })),
245      ),
246  },
247  {
248    id: 'svg-limit-ok',
249    group: 'Graphics',
250    title: 'Svg at 131072 characters',
251    question: 'Is an Svg source of exactly 131072 characters accepted?',
252    surfaces: REMOTE,
253    draw: els => svg(els, S.sized(131072), 'A source exactly at the cap'),
254  },
255  {
256    id: 'svg-limit-over',
257    group: 'Graphics',
258    title: 'Svg at 131073 characters',
259    question: 'Is an Svg source one character past the cap refused, and with what reason?',
260    surfaces: [],
261    draw: els => svg(els, S.sized(131073), 'A source one past the cap'),
262  },
263  {
264    id: 'svg-not-svg',
265    group: 'Graphics',
266    title: 'Svg with no <svg> root',
267    question: 'Does the validator check that source is an SVG document at all?',
268    surfaces: [],
269    draw: els => svg(els, '<div>not svg</div>', 'An HTML fragment'),
270  },
271  {
272    id: 'svg-no-alt',
273    group: 'Graphics',
274    title: 'Svg with an empty alt',
275    question: 'Is alt required to be non-empty?',
276    surfaces: [],
277    draw: els => svg(els, S.RADIAL, ''),
278  },
279  {
280    id: 'scrub-script',
281    group: 'Graphics',
282    title: 'Scrub: <script>',
283    question: 'Is a <script> inside an interactive Svg stripped or blocked? (green text means it did not run)',
284    surfaces: REMOTE,
285    draw: els => svg(els, S.SCRUB_SCRIPT, 'A script that would rewrite the text', true),
286  },
287  {
288    id: 'scrub-onload',
289    group: 'Graphics',
290    title: 'Scrub: event attributes',
291    question: 'Are onload and onclick attributes stripped in an interactive Svg?',
292    surfaces: REMOTE,
293    draw: els => svg(els, S.SCRUB_ONLOAD, 'Event attributes that would rewrite the text', true),
294  },
295  {
296    id: 'scrub-href',
297    group: 'Graphics',
298    title: 'Scrub: external href',
299    question: 'Does an <image> or <a> with an external https href load, or is it stripped?',
300    surfaces: REMOTE,
301    draw: els => svg(els, S.SCRUB_HREF, 'An external image and link', true),
302  },
303  {
304    id: 'scrub-data-href',
305    group: 'Graphics',
306    title: 'Scrub: data: href',
307    question: 'Does an <image> with a data: URI draw?',
308    surfaces: REMOTE,
309    draw: els => svg(els, S.SCRUB_DATA_HREF, 'A data URI image'),
310  },
311  {
312    id: 'scrub-foreign',
313    group: 'Graphics',
314    title: 'Scrub: <foreignObject>',
315    question: 'Does <foreignObject> HTML draw inside an interactive Svg?',
316    surfaces: REMOTE,
317    draw: els => svg(els, S.SCRUB_FOREIGN, 'HTML inside foreignObject', true),
318  },
319
320  // Animation
321  {
322    id: 'smil-interactive',
323    group: 'Animation',
324    title: 'SMIL, interactive',
325    question: 'With isInteractive, do SMIL animate/animateTransform blink, fade, rotate, morph d and grow?',
326    surfaces: REMOTE,
327    draw: els => svg(els, S.SMIL, 'Five SMIL animations', true),
328  },
329  {
330    id: 'smil-image',
331    group: 'Animation',
332    title: 'SMIL as an image',
333    question: 'Without isInteractive (drawn as an image), does SMIL still animate?',
334    surfaces: REMOTE,
335    draw: els => svg(els, S.SMIL, 'Five SMIL animations, as an image'),
336  },
337  {
338    id: 'css-interactive',
339    group: 'Animation',
340    title: 'CSS @keyframes, interactive',
341    question: 'With isInteractive, do CSS @keyframes in a <style> animate (spin, pulse, slide)?',
342    surfaces: REMOTE,
343    draw: els => svg(els, S.CSS_KEYFRAMES, 'Three CSS animations', true),
344  },
345  {
346    id: 'css-image',
347    group: 'Animation',
348    title: 'CSS @keyframes as an image',
349    question: 'Without isInteractive, do CSS @keyframes still animate?',
350    surfaces: REMOTE,
351    draw: els => svg(els, S.CSS_KEYFRAMES, 'Three CSS animations, as an image'),
352  },
353  {
354    id: 'client-blink',
355    group: 'Animation',
356    title: 'Client: blinking dot',
357    question: 'Does a Client surface module blink a dot with surface.every(500) + setState?',
358    surfaces: CLIENT,
359    draw: els => {
360      const { Client } = els
361      return <Client key="blink" module="./clients/blink.tsx" props={{ ms: 500 }} />
362    },
363  },
364  {
365    id: 'client-fade',
366    group: 'Animation',
367    title: 'Client: colour fade',
368    question: 'Does stepping a Text colour every 80 ms read as a fade, and dimColor toggle?',
369    surfaces: CLIENT,
370    draw: els => {
371      const { Client } = els
372      return <Client key="fade" module="./clients/fade.tsx" props={{}} />
373    },
374  },
375  {
376    id: 'client-bar',
377    group: 'Animation',
378    title: 'Client: growing bar',
379    question: 'Does a bar grow smoothly in eighth-cell steps every 50 ms, sized from surface.columns?',
380    surfaces: CLIENT,
381    draw: els => {
382      const { Client } = els
383      return <Client key="bar" module="./clients/bar.tsx" props={{}} width="100%" />
384    },
385  },
386  {
387    id: 'client-spinner',
388    group: 'Animation',
389    title: 'Client: spinner',
390    question: 'Does a braille spinner turn at 80 ms a frame?',
391    surfaces: CLIENT,
392    draw: els => {
393      const { Client } = els
394      return <Client key="spinner" module="./clients/spinner.tsx" props={{ label: 'spinning on the frame clock' }} />
395    },
396  },
397  {
398    id: 'client-fps',
399    group: 'Animation',
400    title: 'Client: frame-clock ceiling',
401    question: 'Asked for a tick every 1 ms and every 16 ms, how many ticks a second actually arrive?',
402    surfaces: CLIENT,
403    draw: els => {
404      const { Box, Client } = els
405      return (
406        <Box flexDirection="column" gap={1}>
407          <Client key="fps1" module="./clients/fps.tsx" props={{ ms: 1 }} />
408          <Client key="fps16" module="./clients/fps.tsx" props={{ ms: 16 }} />
409        </Box>
410      )
411    },
412  },
413
414  // Interaction
415  {
416    id: 'svg-hover',
417    group: 'Interaction',
418    title: 'SVG CSS :hover',
419    question: 'With isInteractive, does CSS :hover (with transitions) restyle SVG shapes?',
420    surfaces: REMOTE,
421    draw: els => svg(els, S.HOVER, 'Three cells and a circle that react to hover', true),
422  },
423  {
424    id: 'svg-title',
425    group: 'Interaction',
426    title: 'SVG <title> tooltip',
427    question: 'With isInteractive, does a <title> child show a native tooltip on hover?',
428    surfaces: REMOTE,
429    draw: els => svg(els, S.TITLE, 'Shapes with tooltips', true),
430  },
431  {
432    id: 'box-hover',
433    group: 'Interaction',
434    title: 'Box hover',
435    question: 'In a keyed Box, do hover backgroundColor, borderColor and a display none to flex reveal apply?',
436    surfaces: ALL,
437    draw: els => {
438      const { Box, Text } = els
439      return (
440        <Box flexDirection="column" gap={1}>
441          <Box key="bg" paddingX={1} hover={{ backgroundColor: '#2f80ed' }}>
442            <Text hover={{ color: '#ffffff', bold: true }}>hover me: background and text</Text>
443          </Box>
444          <Box key="border" borderStyle="round" borderColor="#3a3a3c" paddingX={1} hover={{ borderColor: '#27ae60' }}>
445            <Text>hover me: border colour</Text>
446          </Box>
447          <Box key="reveal" flexDirection="row" gap={1}>
448            <Text>hover me to reveal:</Text>
449            <Box display="none" hover={{ display: 'flex' }}>
450              <Text color="#eb5757">revealed</Text>
451            </Box>
452          </Box>
453        </Box>
454      )
455    },
456  },
457  {
458    id: 'hover-scope',
459    group: 'Interaction',
460    title: 'Hover scope groups',
461    question: 'Do elements sharing a hover scope light together, without a keyed Box?',
462    surfaces: ALL,
463    draw: els => {
464      const { Box, Text } = els
465      return (
466        <Box flexDirection="column" gap={1}>
467          <Text hover={{ scope: 'alpha', color: '#2f80ed', bold: true }}>alpha: row one</Text>
468          <Text>unrelated row</Text>
469          <Text hover={{ scope: 'alpha', color: '#2f80ed', bold: true }}>alpha: row two (lights with one)</Text>
470          <Box flexDirection="row" gap={1}>
471            <Text hover={{ scope: 'beta', inverse: true }}>beta</Text>
472            <Box display="none" hover={{ scope: 'beta', display: 'flex' }}>
473              <Text color="#27ae60">beta revealed</Text>
474            </Box>
475          </Box>
476        </Box>
477      )
478    },
479  },
480  {
481    id: 'box-absolute',
482    group: 'Interaction',
483    title: 'Absolute hover card',
484    question: 'Does a position absolute Box revealed on hover paint over the rows above without moving them?',
485    surfaces: ALL,
486    draw: els => {
487      const { Box, Text } = els
488      return (
489        <Box flexDirection="column">
490          <Text>row one</Text>
491          <Text>row two</Text>
492          <Text>row three</Text>
493          <Box key="anchor" flexDirection="row">
494            <Text color="#2f80ed">◆ hover this glyph</Text>
495            <Box
496              position="absolute"
497              top={-3}
498              left={4}
499              display="none"
500              hover={{ display: 'flex' }}
501              borderStyle="round"
502              backgroundColor="#1c1c1e"
503              paddingX={1}
504            >
505              <Text color="#ffffff">a card over the rows above</Text>
506            </Box>
507          </Box>
508          <Text>row after</Text>
509        </Box>
510      )
511    },
512  },
513  {
514    id: 'buttons',
515    group: 'Interaction',
516    title: 'Button variants',
517    question: 'How do plain, primary, secondary, dismiss, hotkey, dimColor and Text children draw?',
518    surfaces: ALL,
519    draw: els => {
520      const { Box, Button, Text } = els
521      const nop = () => undefined
522      return (
523        <Box flexDirection="column" gap={1}>
524          <Box flexDirection="row" gap={1}>
525            <Button key="default" label="Default" onPress={nop} />
526            <Button key="primary" label="Primary" variant="primary" onPress={nop} />
527            <Button key="secondary" label="Secondary" variant="secondary" onPress={nop} />
528            <Button key="dismiss" label="Dismiss" role="dismiss" onPress={nop} />
529          </Box>
530          <Box flexDirection="row" gap={1}>
531            <Button key="plain" plain label="Plain" onPress={nop} />
532            <Button key="hot" plain hotkey="1" label="Plain with hotkey" onPress={nop} />
533            <Button key="hot2" hotkey="w" label="Hotkey w" onPress={nop} />
534            <Button key="dim" dimColor label="Dim" onPress={nop} />
535          </Box>
536          <Button key="rowbtn" plain onPress={nop}>
537            A whole row <Text dimColor>2m ago</Text> <Text color="#27ae60">chip</Text>
538          </Button>
539        </Box>
540      )
541    },
542  },
543  {
544    id: 'button-bad-hotkey',
545    group: 'Interaction',
546    title: 'Button with a two-letter hotkey',
547    question: 'Is a hotkey other than one digit or lowercase letter refused?',
548    surfaces: [],
549    draw: els => {
550      const { Button } = els
551      return <Button key="bad" hotkey="ab" label="ab" onPress={() => undefined} />
552    },
553  },
554  {
555    id: 'button-box-child',
556    group: 'Interaction',
557    title: 'Button holding a Box',
558    question: 'Is a Button with a Box child refused?',
559    surfaces: [],
560    draw: els => {
561      const { Box, Button, Text } = els
562      return (
563        <Button key="boxed" onPress={() => undefined}>
564          <Box>
565            <Text>inside</Text>
566          </Box>
567        </Button>
568      )
569    },
570  },
571  {
572    id: 'input-select',
573    group: 'Interaction',
574    title: 'Input and Select',
575    question: 'Do Input (label, placeholder, value, submitLabel) and Select draw and take input?',
576    surfaces: FIELDS,
577    draw: els => {
578      const { Box, Input, Select } = els
579      return (
580        <Box flexDirection="column" gap={1}>
581          <Input key="name" label="Name" placeholder="type here" submitLabel="save" onSubmit={() => undefined} onInput={() => undefined} />
582          <Input key="prefilled" label="Prefilled" value="hello" onSubmit={() => undefined} />
583          <Select
584            key="pick"
585            label="Pick"
586            value="b"
587            options={[
588              { value: 'a', label: 'Alpha' },
589              { value: 'b', label: 'Beta' },
590              { value: 'c', label: 'Gamma' },
591            ]}
592            onSelect={() => undefined}
593          />
594        </Box>
595      )
596    },
597  },
598  {
599    id: 'select-duplicate',
600    group: 'Interaction',
601    title: 'Select with duplicate values',
602    question: 'Are duplicate option values refused?',
603    surfaces: [],
604    draw: els => {
605      const { Select } = els
606      return (
607        <Select
608          key="dup"
609          options={[
610            { value: 'a', label: 'One' },
611            { value: 'a', label: 'Two' },
612          ]}
613          onSelect={() => undefined}
614        />
615      )
616    },
617  },
618  {
619    id: 'links',
620    group: 'Interaction',
621    title: 'Links by scheme',
622    question: 'Which hrefs draw as links (https, http, file, mailto, javascript) and which as plain text?',
623    surfaces: ALL,
624    draw: els => {
625      const { Box, Link } = els
626      return (
627        <Box flexDirection="column">
628          {row(els, 'https', <Link href="https://example.com" label="https://example.com" />)}
629          {row(els, 'http', <Link href="http://example.com" label="http://example.com" />)}
630          {row(els, 'file', <Link href="file:///etc/hosts" label="file:///etc/hosts" />)}
631          {row(els, 'mailto', <Link href="mailto:someone@example.com" label="mailto:" />)}
632          {row(els, 'javascript', <Link href="javascript:alert(1)" label="javascript:" />)}
633          {row(els, 'no label', <Link href="https://example.com/bare" />)}
634        </Box>
635      )
636    },
637  },
638  {
639    id: 'link-too-long',
640    group: 'Interaction',
641    title: 'Link past 2048 characters',
642    question: 'Is a Link whose href is longer than 2048 characters refused?',
643    surfaces: [],
644    draw: els => {
645      const { Link } = els
646      return <Link href={`https://example.com/${'a'.repeat(2100)}`} label="long" />
647    },
648  },
649  {
650    id: 'markdown-links',
651    group: 'Interaction',
652    title: 'Markdown answering its links',
653    question: 'Does onLinkPress take a link press, and pressableLinks narrow which?',
654    surfaces: ALL,
655    draw: els => {
656      const { Markdown } = els
657      return (
658        <Markdown
659          key="md-links"
660          text={'Press [this one](https://example.com/mine) or [this other](https://example.com/theirs), which opens normally.'}
661          pressableLinks={['https://example.com/mine']}
662          onLinkPress={() => undefined}
663        />
664      )
665    },
666  },
667  {
668    id: 'client-pointer',
669    group: 'Interaction',
670    title: 'Client: pointer following',
671    question: 'Does onPointer report down, move, up, enter and leave in cells (and fine sub-cell positions)?',
672    surfaces: CLIENT,
673    draw: els => {
674      const { Client } = els
675      return <Client key="pointer" module="./clients/pointer.tsx" props={{}} width="100%" height={6} />
676    },
677  },
678  {
679    id: 'client-keys',
680    group: 'Interaction',
681    title: 'Client: keys',
682    question: 'Once clicked, does a Client receive keys through onKey?',
683    surfaces: CLIENT,
684    draw: els => {
685      const { Client } = els
686      return <Client key="keys" module="./clients/keys.tsx" props={{}} />
687    },
688  },
689  {
690    id: 'client-post',
691    group: 'Interaction',
692    title: 'Client: post round trip',
693    question: "Does surface.post reach the hooks module's ui.message, and its { props } answer come back?",
694    surfaces: CLIENT,
695    draw: els => {
696      const { Client } = els
697      return <Client key="echo" module="./clients/echo.tsx" props={{}} />
698    },
699  },
700
701  // Layout
702  {
703    id: 'borders',
704    group: 'Layout',
705    title: 'Border styles',
706    question: 'Which borderStyle values draw: single, double, round, bold, singleDouble, doubleSingle, classic, arrow, dashed, quote?',
707    surfaces: ALL,
708    draw: els => {
709      const { Box, Text } = els
710      const styles = ['single', 'double', 'round', 'bold', 'singleDouble', 'doubleSingle', 'classic', 'arrow', 'dashed', 'quote']
711      return (
712        <Box flexDirection="row" flexWrap="wrap" gap={1}>
713          {styles.map(style => (
714            <Box borderStyle={style} borderColor="#2f80ed" paddingX={1}>
715              <Text>{style}</Text>
716            </Box>
717          ))}
718          <Box borderStyle="round" borderDimColor paddingX={1}>
719            <Text>dim border</Text>
720          </Box>
721        </Box>
722      )
723    },
724  },
725  {
726    id: 'border-unknown',
727    group: 'Layout',
728    title: 'Unknown border style',
729    question: 'Is borderStyle "wavy" refused, or drawn without a border?',
730    surfaces: ALL,
731    draw: els => {
732      const { Box, Text } = els
733      return (
734        <Box borderStyle="wavy" paddingX={1}>
735          <Text>borderStyle wavy</Text>
736        </Box>
737      )
738    },
739  },
740  // One experiment per colour string, so each refusal is its own.
741  ...BACKGROUNDS.map(
742    ([id, color]): Experiment => ({
743      id: `bg-${id}`,
744      group: 'Layout',
745      title: `backgroundColor ${color}`,
746      question: `Is a Box backgroundColor of "${color}" accepted (Box and Text), and what paints?`,
747      surfaces: ALL,
748      draw: els => {
749        const { Box, Text } = els
750        return (
751          <Box flexDirection="column" gap={1}>
752            <Box width={24} height={2} backgroundColor={color} paddingX={1}>
753              <Text>Box</Text>
754            </Box>
755            <Text backgroundColor={color}> Text with it </Text>
756          </Box>
757        )
758      },
759    }),
760  ),
761  {
762    id: 'pct-whole',
763    group: 'Layout',
764    title: 'Whole percentages',
765    question: 'Do width percentages (25%, 50%, 100%) lay out?',
766    surfaces: ALL,
767    draw: els => {
768      const { Box, Text } = els
769      return (
770        <Box flexDirection="column" width="100%">
771          {['25%', '50%', '100%'].map(w => (
772            <Box width={w} backgroundColor="#2f80ed">
773              <Text color="#ffffff">{w}</Text>
774            </Box>
775          ))}
776        </Box>
777      )
778    },
779  },
780  {
781    id: 'pct-fraction',
782    group: 'Layout',
783    title: 'Fractional percentage',
784    question: 'Is width "25.5%" refused?',
785    surfaces: [],
786    draw: els => {
787      const { Box, Text } = els
788      return (
789        <Box width="25.5%">
790          <Text>25.5%</Text>
791        </Box>
792      )
793    },
794  },
795  {
796    id: 'flexgrow-limit',
797    group: 'Layout',
798    title: 'flexGrow past 10000',
799    question: 'Is flexGrow 10001 refused?',
800    surfaces: [],
801    draw: els => {
802      const { Box, Text } = els
803      return (
804        <Box flexGrow={10001}>
805          <Text>flexGrow 10001</Text>
806        </Box>
807      )
808    },
809  },
810  {
811    id: 'fractional-cells',
812    group: 'Layout',
813    title: 'Fractional heights and margins',
814    question: 'Are fractional height and margin values accepted?',
815    surfaces: ALL,
816    draw: els => {
817      const { Box, Text } = els
818      return (
819        <Box flexDirection="column">
820          <Box height={1.5} marginTop={0.5} backgroundColor="#27ae60">
821            <Text>height 1.5, marginTop 0.5</Text>
822          </Box>
823          <Text>after</Text>
824        </Box>
825      )
826    },
827  },
828  {
829    id: 'flex',
830    group: 'Layout',
831    title: 'Flex layout',
832    question: 'Do gap, wrap, justifyContent, alignItems, flexGrow and overflow hidden lay out as Ink does?',
833    surfaces: ALL,
834    draw: els => {
835      const { Box, Text } = els
836      return (
837        <Box flexDirection="column" gap={1}>
838          <Box flexDirection="row" justifyContent="space-between">
839            <Text>left</Text>
840            <Text>centre</Text>
841            <Text>right</Text>
842          </Box>
843          <Box flexDirection="row" gap={2} flexWrap="wrap">
844            {Array.from({ length: 12 }, (_, i) => (
845              <Text>item{i}</Text>
846            ))}
847          </Box>
848          <Box flexDirection="row">
849            <Box flexGrow={1} backgroundColor="#2f80ed">
850              <Text>grow 1</Text>
851            </Box>
852            <Box flexGrow={3} backgroundColor="#9b51e0">
853              <Text>grow 3</Text>
854            </Box>
855          </Box>
856          <Box width={12} height={1} overflow="hidden">
857            <Text>overflow hidden cuts this long text</Text>
858          </Box>
859        </Box>
860      )
861    },
862  },
863
864  // Text
865  {
866    id: 'text-styles',
867    group: 'Text',
868    title: 'Text styles',
869    question: 'Do bold, italic, underline, strikethrough, inverse, dimColor, color and backgroundColor draw?',
870    surfaces: ALL,
871    draw: els => {
872      const { Box, Text } = els
873      return (
874        <Box flexDirection="column">
875          <Text bold>bold</Text>
876          <Text italic>italic</Text>
877          <Text underline>underline</Text>
878          <Text strikethrough>strikethrough</Text>
879          <Text inverse>inverse</Text>
880          <Text dimColor>dimColor</Text>
881          <Text color="#eb5757">color #eb5757</Text>
882          <Text color="warning">color theme key warning</Text>
883          <Text backgroundColor="#2f80ed" color="#ffffff">
884            backgroundColor
885          </Text>
886          <Text>
887            nested: <Text bold>bold</Text> and <Text color="#27ae60">green</Text> in one line
888          </Text>
889        </Box>
890      )
891    },
892  },
893  {
894    id: 'text-wrap',
895    group: 'Text',
896    title: 'Text wrap modes',
897    question: 'How do wrap, end, middle, truncate, truncate-start, truncate-middle and truncate-end cut a long line?',
898    surfaces: ALL,
899    draw: els => {
900      const { Box, Text } = els
901      const long = 'The quick brown fox jumps over the lazy dog and keeps running past the edge of the pane'
902      const modes = ['wrap', 'end', 'middle', 'truncate', 'truncate-start', 'truncate-middle', 'truncate-end']
903      return (
904        <Box flexDirection="column">
905          {modes.map(m =>
906            row(
907              els,
908              m,
909              <Box width={30}>
910                <Text wrap={m}>{long}</Text>
911              </Box>,
912            ),
913          )}
914        </Box>
915      )
916    },
917  },
918  {
919    id: 'text-hover',
920    group: 'Text',
921    title: 'Text hover',
922    question: 'Does Text hover (colour, bold, underline, inverse) apply inside a keyed Box?',
923    surfaces: ALL,
924    draw: els => {
925      const { Box, Text } = els
926      return (
927        <Box key="texthover" flexDirection="column">
928          <Text hover={{ color: '#2f80ed' }}>colour on hover</Text>
929          <Text hover={{ bold: true, underline: true }}>bold and underline on hover</Text>
930          <Text dimColor hover={{ dimColor: false, inverse: true }}>dim to inverse on hover</Text>
931        </Box>
932      )
933    },
934  },
935  {
936    id: 'text-hover-unkeyed',
937    group: 'Text',
938    title: 'Text hover outside a keyed Box',
939    question: 'Is a Text hover with no keyed Box and no scope refused?',
940    surfaces: [],
941    draw: els => {
942      const { Text } = els
943      return <Text hover={{ color: '#2f80ed' }}>unkeyed hover</Text>
944    },
945  },
946  {
947    id: 'text-long',
948    group: 'Text',
949    title: 'Text past 100000 characters',
950    question: 'Is a 120000-character Text accepted, and cut at 100000?',
951    surfaces: ALL,
952    draw: els => {
953      const { Text } = els
954      return <Text>{'0123456789'.repeat(12000)}</Text>
955    },
956  },
957  {
958    id: 'text-glyphs',
959    group: 'Text',
960    title: 'Glyphs and emoji',
961    question: 'Do box drawing, blocks, braille, CJK and emoji draw in Text?',
962    surfaces: ALL,
963    draw: els => {
964      const { Box, Text } = els
965      return (
966        <Box flexDirection="column">
967          <Text>box ┌─┬─┐ ╭─╮ ═ ║ blocks ▁▂▃▄▅▆▇█ ░▒▓</Text>
968          <Text>braille ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ⣿⣀</Text>
969          <Text>CJK 漢字 かな 한글</Text>
970          <Text>emoji 🚀 ✅ 👩‍💻 🏳️‍🌈</Text>
971        </Box>
972      )
973    },
974  },
975
976  // Elements
977  {
978    id: 'code-source',
979    group: 'Elements',
980    title: 'Code: source',
981    question: 'Does Code highlight TypeScript with a startLine gutter, and wrap vs truncate-end long lines?',
982    surfaces: ALL,
983    draw: els => {
984      const { Box, Code } = els
985      return (
986        <Box flexDirection="column" gap={1}>
987          <Code source={SAMPLE_TS} language="typescript" startLine={41} />
988          <Code source={SAMPLE_TS} path="example.ts" wrap="truncate-end" />
989        </Box>
990      )
991    },
992  },
993  {
994    id: 'code-diff',
995    group: 'Elements',
996    title: 'Code: diff',
997    question: "Does Code format 'diff' draw a unified diff with gutters and colours?",
998    surfaces: ALL,
999    draw: els => {
1000      const { Code } = els
1001      return <Code source={SAMPLE_DIFF} format="diff" path="b.ts" />
1002    },
1003  },
1004  {
1005    id: 'code-bad-diff',
1006    group: 'Elements',
1007    title: 'Code: diff with no hunk header',
1008    question: "Is a 'diff' with no @@ header refused, or drawn as plain code?",
1009    surfaces: ALL,
1010    draw: els => {
1011      const { Code } = els
1012      return <Code source={'-old\n+new'} format="diff" />
1013    },
1014  },
1015  {
1016    id: 'code-bad-wrap',
1017    group: 'Elements',
1018    title: "Code: wrap 'truncate'",
1019    question: "Is Code wrap 'truncate' (a Text spelling) refused?",
1020    surfaces: [],
1021    draw: els => {
1022      const { Code } = els
1023      return <Code source="x" wrap="truncate" />
1024    },
1025  },
1026  {
1027    id: 'markdown',
1028    group: 'Elements',
1029    title: 'Markdown',
1030    question: 'Does Markdown draw headings, emphasis, tables, lists, fences, quotes and a <context> block?',
1031    surfaces: ALL,
1032    draw: els => {
1033      const { Markdown } = els
1034      return <Markdown text={MARKDOWN} />
1035    },
1036  },
1037  {
1038    id: 'raster',
1039    group: 'Elements',
1040    title: 'Raster',
1041    question: 'Does a Raster draw a hue ramp of half-block cells with per-cell fg and bg?',
1042    surfaces: ['terminal'],
1043    draw: els => {
1044      const { Raster } = els
1045      return <Raster key="ramp" columns={48} rows={4} cells={rampCells(48, 4)} />
1046    },
1047  },
1048  {
1049    id: 'raster-wide-cell',
1050    group: 'Elements',
1051    title: 'Raster with a wide cell',
1052    question: 'Is a Raster cell holding a width-2 character refused, naming its index?',
1053    surfaces: [],
1054    draw: els => {
1055      const { Raster } = els
1056      return <Raster key="wide" columns={2} rows={1} cells={cells([0x41, 0xffffff, 0x01000000, 0x6f22, 0xffffff, 0x01000000])} />
1057    },
1058  },
1059  {
1060    id: 'image-png',
1061    group: 'Elements',
1062    title: 'Image: PNG',
1063    question: 'Does an Image of a base64 PNG draw (kitty graphics) or fall back to alt?',
1064    surfaces: ['terminal'],
1065    draw: els => {
1066      const { Image } = els
1067      return <Image key="png" source={{ png: PNG }} columns={8} rows={4} alt="a one-pixel PNG, scaled" />
1068    },
1069  },
1070  {
1071    id: 'image-rgba',
1072    group: 'Elements',
1073    title: 'Image: RGBA',
1074    question: 'Does an Image of raw RGBA bytes (16 x 16, with alpha) draw?',
1075    surfaces: ['terminal'],
1076    draw: els => {
1077      const { Image } = els
1078      return <Image key="rgba" source={{ rgba: rgbaBlob(), width: 16, height: 16 }} columns={8} rows={4} alt="a blue blob" />
1079    },
1080  },
1081]
1082
1083/** The experiments by group, in catalogue order. */
1084export const GROUPS: Group[] = ['Graphics', 'Animation', 'Interaction', 'Layout', 'Text', 'Elements']
1085
1086export const byId = (id: string) => experiments.find(x => x.id === id)
1087
1088/**
1089 * The experiments verdicts.json records the desktop refusing; the gallery
1090 * leaves them out so its one tree validates. lab.test.ts checks this list
1091 * against the run's own verdicts.
1092 */
1093export const DESKTOP_REFUSED = [
1094  'svg-limit-over',
1095  'button-bad-hotkey',
1096  'button-box-child',
1097  'select-duplicate',
1098  'link-too-long',
1099  'bg-radial-gradient',
1100  'pct-fraction',
1101  'flexgrow-limit',
1102  'text-hover-unkeyed',
1103  'code-bad-wrap',
1104]
1105
1106/**
1107 * What the gallery draws, in group order: every experiment the desktop
1108 * accepts. `text-long` goes last: a tree draws its first 100000 characters of
1109 * text, and its 120000 would cut every experiment after it.
1110 */
1111export const galleryExperiments = () => {
1112  const shown = GROUPS.flatMap(g => experiments.filter(x => x.group === g && !DESKTOP_REFUSED.includes(x.id)))
1113  return [...shown.filter(x => x.id !== 'text-long'), ...shown.filter(x => x.id === 'text-long')]
1114}
1115
hooks/svg.ts 178 lines
1/*
2 * The SVG documents the experiments draw, as strings: each one self-contained,
3 * well under the engine's 131072-character cap unless it probes the cap.
4 */
5
6const NS = 'xmlns="http://www.w3.org/2000/svg"'
7const W = 240
8const H = 80
9
10/** An `<svg>` of the usual size around `body`. */
11export const doc = (body: string, w = W, h = H) => `<svg ${NS} width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`
12
13const label = (x: number, y: number, s: string) =>
14  `<text x="${x}" y="${y}" font-family="-apple-system, system-ui, sans-serif" font-size="10" fill="#8e8e93">${s}</text>`
15
16export const LINEAR = doc(
17  `<defs>
18    <linearGradient id="h" x1="0" x2="1"><stop offset="0" stop-color="#2f80ed"/><stop offset="1" stop-color="#9b51e0"/></linearGradient>
19    <linearGradient id="v" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#27ae60"/><stop offset="0.5" stop-color="#f2c94c" stop-opacity="0.6"/><stop offset="1" stop-color="#eb5757"/></linearGradient>
20  </defs>
21  <rect x="8" y="8" width="140" height="14" rx="7" fill="url(#h)"/>
22  <rect x="8" y="30" width="140" height="6" rx="3" fill="url(#h)" opacity="0.5"/>
23  <rect x="168" y="8" width="24" height="64" rx="4" fill="url(#v)"/>
24  ${label(8, 60, 'horizontal, vertical, stop-opacity')}`,
25)
26
27export const RADIAL = doc(
28  `<defs>
29    <radialGradient id="r" cx="0.35" cy="0.35" r="0.65"><stop offset="0" stop-color="#ffffff"/><stop offset="0.4" stop-color="#56ccf2"/><stop offset="1" stop-color="#2f80ed"/></radialGradient>
30    <radialGradient id="g" fx="0.5" fy="0.5"><stop offset="0" stop-color="#f2994a" stop-opacity="1"/><stop offset="1" stop-color="#f2994a" stop-opacity="0"/></radialGradient>
31  </defs>
32  <circle cx="40" cy="40" r="30" fill="url(#r)"/>
33  <circle cx="120" cy="40" r="34" fill="url(#g)"/>
34  ${label(170, 44, 'sphere, glow')}`,
35)
36
37export const BLUR = doc(
38  `<defs>
39    <filter id="b1"><feGaussianBlur stdDeviation="1"/></filter>
40    <filter id="b4" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="4"/></filter>
41  </defs>
42  <rect x="10" y="20" width="40" height="40" fill="#2f80ed"/>
43  <rect x="70" y="20" width="40" height="40" fill="#2f80ed" filter="url(#b1)"/>
44  <rect x="130" y="20" width="40" height="40" fill="#2f80ed" filter="url(#b4)"/>
45  ${label(10, 74, 'sharp, 1, 4')}`,
46)
47
48export const SHADOW = doc(
49  `<defs>
50    <filter id="ds" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="2" dy="3" stdDeviation="2.5" flood-color="#000" flood-opacity="0.35"/></filter>
51    <filter id="old" x="-30%" y="-30%" width="160%" height="160%">
52      <feGaussianBlur in="SourceAlpha" stdDeviation="2"/><feOffset dx="2" dy="2" result="o"/>
53      <feComponentTransfer><feFuncA type="linear" slope="0.4"/></feComponentTransfer>
54      <feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
55    </filter>
56  </defs>
57  <rect x="14" y="14" width="80" height="40" rx="6" fill="#fff" stroke="#ddd" filter="url(#ds)"/>
58  <rect x="124" y="14" width="80" height="40" rx="6" fill="#fff" stroke="#ddd" filter="url(#old)"/>
59  ${label(14, 72, 'feDropShadow, blur+offset+merge')}`,
60)
61
62export const MASKS = doc(
63  `<defs>
64    <clipPath id="c"><circle cx="40" cy="36" r="26"/></clipPath>
65    <linearGradient id="fade" x1="0" x2="1"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#000"/></linearGradient>
66    <mask id="m"><rect x="80" y="10" width="70" height="52" fill="url(#fade)"/></mask>
67  </defs>
68  <g clip-path="url(#c)"><rect x="0" y="0" width="80" height="80" fill="#9b51e0"/><rect x="0" y="30" width="80" height="12" fill="#f2c94c"/></g>
69  <rect x="80" y="10" width="70" height="52" fill="#27ae60" mask="url(#m)"/>
70  <rect x="160" y="10" width="30" height="52" fill="#eb5757" opacity="0.25"/>
71  <rect x="196" y="10" width="30" height="52" fill="#eb5757" fill-opacity="0.6" stroke="#eb5757" stroke-opacity="1"/>
72  ${label(8, 76, 'clipPath, mask, opacity, fill-opacity')}`,
73)
74
75export const FONTS = doc(
76  `<text x="8" y="16" font-family="-apple-system, system-ui, sans-serif" font-size="13">System sans 13</text>
77  <text x="8" y="32" font-family="Georgia, serif" font-style="italic" font-size="13">Serif italic</text>
78  <text x="8" y="48" font-family="ui-monospace, Menlo, monospace" font-size="12" font-weight="700">Mono bold 0123</text>
79  <text x="8" y="66" font-family="system-ui" font-size="10" letter-spacing="2" text-decoration="underline" fill="#2f80ed">tracked, underlined</text>
80  <text x="232" y="30" font-family="system-ui" font-size="20" text-anchor="end" fill="#eb5757">end</text>
81  <path id="arc" d="M150 70 Q190 40 232 70" fill="none"/>
82  <text font-size="9" font-family="system-ui"><textPath href="#arc">along a path</textPath></text>`,
83)
84
85export const PATTERN = doc(
86  `<defs>
87    <pattern id="p" width="8" height="8" patternUnits="userSpaceOnUse"><path d="M0 8 L8 0" stroke="#8e8e93" stroke-width="1"/></pattern>
88    <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0 0 L10 5 L0 10z" fill="#2f80ed"/></marker>
89    <symbol id="dot" viewBox="0 0 10 10"><circle cx="5" cy="5" r="4" fill="#27ae60"/></symbol>
90  </defs>
91  <rect x="8" y="8" width="70" height="50" fill="url(#p)" stroke="#8e8e93"/>
92  <line x1="96" y1="50" x2="150" y2="14" stroke="#2f80ed" stroke-width="2" marker-end="url(#arrow)"/>
93  <use href="#dot" x="170" y="10" width="20" height="20"/><use href="#dot" x="196" y="30" width="30" height="30"/>
94  ${label(8, 74, 'pattern, marker, symbol+use')}`,
95)
96
97/** SMIL: a blink (discrete opacity), a fade, a rotation and a path morph. */
98const SMIL_BODY = `
99  <circle cx="20" cy="30" r="8" fill="#eb5757"><animate attributeName="opacity" values="1;0" dur="1s" calcMode="discrete" repeatCount="indefinite"/></circle>
100  <circle cx="60" cy="30" r="8" fill="#2f80ed"><animate attributeName="opacity" values="1;0.1;1" dur="2s" repeatCount="indefinite"/></circle>
101  <g transform="translate(100 30)"><rect x="-9" y="-9" width="18" height="18" fill="#9b51e0"><animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="3s" repeatCount="indefinite"/></rect></g>
102  <path fill="#27ae60" d="M140 20 L160 20 L160 40 L140 40 Z"><animate attributeName="d" dur="2s" repeatCount="indefinite" values="M140 20 L160 20 L160 40 L140 40 Z;M150 18 L162 30 L150 42 L138 30 Z;M140 20 L160 20 L160 40 L140 40 Z"/></path>
103  <rect x="180" y="26" width="0" height="8" fill="#f2994a"><animate attributeName="width" values="0;50;0" dur="2s" repeatCount="indefinite"/></rect>
104  ${label(8, 66, 'blink, fade, rotate, morph d, grow')}`
105
106export const SMIL = doc(SMIL_BODY)
107
108/** CSS animation inside the document's own `<style>`. */
109const CSS_BODY = `
110  <style>
111    @keyframes spin { to { transform: rotate(360deg) } }
112    @keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.15 } }
113    @keyframes slide { from { transform: translateX(0) } to { transform: translateX(80px) } }
114    .spin { transform-origin: 30px 30px; animation: spin 1.5s linear infinite }
115    .pulse { animation: pulse 1.2s ease-in-out infinite }
116    .slide { animation: slide 2s ease-in-out infinite alternate }
117  </style>
118  <rect class="spin" x="20" y="20" width="20" height="20" fill="#9b51e0"/>
119  <circle class="pulse" cx="80" cy="30" r="10" fill="#2f80ed"/>
120  <rect class="slide" x="110" y="24" width="12" height="12" rx="6" fill="#27ae60"/>
121  ${label(8, 66, 'CSS @keyframes: spin, pulse, slide')}`
122
123export const CSS_KEYFRAMES = doc(CSS_BODY)
124
125export const HOVER = doc(
126  `<style>
127    .cell { fill: #e0e0e0; transition: fill 120ms }
128    .cell:hover { fill: #2f80ed }
129    .grow { transform-origin: 200px 30px; transition: transform 150ms }
130    .grow:hover { transform: scale(1.4) }
131  </style>
132  <rect class="cell" x="8" y="10" width="36" height="36" rx="4"/>
133  <rect class="cell" x="50" y="10" width="36" height="36" rx="4"/>
134  <rect class="cell" x="92" y="10" width="36" height="36" rx="4"/>
135  <circle class="grow" cx="200" cy="30" r="14" fill="#f2994a"/>
136  ${label(8, 66, 'CSS :hover with transitions')}`,
137)
138
139export const TITLE = doc(
140  `<rect x="8" y="10" width="60" height="40" fill="#2f80ed"><title>Blue: 42 tokens</title></rect>
141  <circle cx="110" cy="30" r="20" fill="#27ae60"><title>Green circle tooltip</title></circle>
142  ${label(8, 66, 'hover for a native <title> tooltip')}`,
143)
144
145export const SCRUB_SCRIPT = doc(
146  `<text id="t" x="8" y="30" font-size="14" fill="#27ae60">script did not run (stripped or blocked)</text>
147  <script>document.getElementById('t').textContent = 'SCRIPT RAN'; document.getElementById('t').setAttribute('fill', 'red')</script>`,
148  320,
149)
150
151export const SCRUB_ONLOAD = `<svg ${NS} width="320" height="40" onload="document.getElementById('t').textContent='ONLOAD RAN'"><text id="t" x="8" y="24" font-size="14" fill="#27ae60" onclick="this.textContent='ONCLICK RAN'">onload did not run (click me too)</text></svg>`
152
153export const SCRUB_HREF = doc(
154  `<image href="https://github.githubassets.com/favicons/favicon.png" x="8" y="8" width="32" height="32"/>
155  <a href="https://example.com"><text x="48" y="24" font-size="12" fill="#2f80ed">external a href</text></a>
156  ${label(48, 44, 'an external image shows left if not stripped')}`,
157  320,
158)
159
160export const SCRUB_DATA_HREF = doc(
161  `<image x="8" y="8" width="32" height="32" href="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Ccircle cx='5' cy='5' r='5' fill='%23eb5757'/%3E%3C/svg%3E"/>
162  ${label(48, 28, 'a data: image (red dot) shows left if kept')}`,
163  320,
164)
165
166export const SCRUB_FOREIGN = doc(
167  `<foreignObject x="8" y="8" width="300" height="40"><div xmlns="http://www.w3.org/1999/xhtml" style="font: 13px system-ui; color: #eb5757">FOREIGN OBJECT HTML DRAWN <b>bold</b></div></foreignObject>
168  ${label(8, 66, 'blank above: foreignObject stripped')}`,
169  320,
170)
171
172/** A document of exactly `n` characters, padded with a comment. */
173export const sized = (n: number) => {
174  const head = `<svg ${NS} width="240" height="24"><text x="4" y="16" font-size="12">${n} characters</text><!--`
175  const tail = '--></svg>'
176  return head + 'x'.repeat(Math.max(0, n - head.length - tail.length)) + tail
177}
178
hooks/clients/blink.tsx 27 lines
1import type { ClientModule } from 'claude-code'
2
3type Props = { ms?: number }
4type State = { on: boolean; ticks: number }
5
6/** A dot that blinks on the surface's frame clock, every `ms` (500 by default). */
7const Blink: ClientModule<Props, State> = (props, surface) => {
8  const { Box, Text } = surface.elements
9  if (surface.state === undefined) {
10    let ticks = 0
11    surface.every(props.ms ?? 500, () => {
12      ticks++
13      surface.setState({ on: ticks % 2 === 0, ticks })
14    })
15    surface.setState({ on: true, ticks: 0 })
16  }
17  const on = surface.state?.on ?? true
18  return (
19    <Box flexDirection="row" gap={1}>
20      <Text key="dot" color={on ? '#eb5757' : '#3a3a3c'}>●</Text>
21      <Text dimColor>{on ? 'on ' : 'off'} tick {surface.state?.ticks ?? 0}</Text>
22    </Box>
23  )
24}
25
26export default Blink
27
hooks/clients/fade.tsx 27 lines
1import type { ClientModule } from 'claude-code'
2
3type State = { step: number }
4
5/** Twelve steps of blue, up and down, every 80 ms: a fade made of colour changes. */
6const RAMP = ['#0b1a2e', '#102a4a', '#163a66', '#1c4a82', '#225a9e', '#2f80ed', '#4a90f0', '#66a3f3', '#82b5f5', '#9ec8f8', '#bad9fa', '#d6eafc']
7
8const Fade: ClientModule<Record<string, never>, State> = (_props, surface) => {
9  const { Box, Text } = surface.elements
10  if (surface.state === undefined) {
11    let step = 0
12    surface.every(80, () => surface.setState({ step: ++step }))
13    surface.setState({ step: 0 })
14  }
15  const step = surface.state?.step ?? 0
16  const i = step % (RAMP.length * 2 - 2)
17  const at = i < RAMP.length ? i : RAMP.length * 2 - 2 - i
18  return (
19    <Box flexDirection="column">
20      <Text key="swatch" color={RAMP[at]}>████████ colour step {at}</Text>
21      <Text key="dim" dimColor={step % 10 < 5}>dimColor toggled every 5 steps</Text>
22    </Box>
23  )
24}
25
26export default Fade
27
hooks/clients/bar.tsx 31 lines
1import type { ClientModule } from 'claude-code'
2
3type State = { step: number }
4
5const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
6
7/** A bar that grows to the region's width and back, in eighths of a cell, every 50 ms. */
8const Bar: ClientModule<Record<string, never>, State> = (_props, surface) => {
9  const { Box, Text } = surface.elements
10  if (surface.state === undefined) {
11    let step = 0
12    surface.every(50, () => surface.setState({ step: ++step }))
13    surface.setState({ step: 0 })
14  }
15  const width = Math.max(8, Math.min(surface.columns || 40, 60) - 8)
16  const span = width * 8
17  const step = surface.state?.step ?? 0
18  const i = step % (span * 2)
19  const eighths = i < span ? i : span * 2 - i
20  const full = Math.floor(eighths / 8)
21  const bar = '█'.repeat(full) + EIGHTHS[eighths % 8]
22  return (
23    <Box flexDirection="row">
24      <Text key="bar" color={eighths > span * 0.8 ? '#eb5757' : '#2f80ed'}>{bar.padEnd(width, ' ')}</Text>
25      <Text dimColor> {Math.round((eighths / span) * 100)}%</Text>
26    </Box>
27  )
28}
29
30export default Bar
31
hooks/clients/spinner.tsx 25 lines
1import type { ClientModule } from 'claude-code'
2
3type State = { step: number }
4
5const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
6
7/** A braille spinner at 80 ms a frame. */
8const Spinner: ClientModule<{ label?: string }, State> = (props, surface) => {
9  const { Box, Text } = surface.elements
10  if (surface.state === undefined) {
11    let step = 0
12    surface.every(80, () => surface.setState({ step: ++step }))
13    surface.setState({ step: 0 })
14  }
15  const step = surface.state?.step ?? 0
16  return (
17    <Box flexDirection="row" gap={1}>
18      <Text key="frame" color="#9b51e0">{FRAMES[step % FRAMES.length]}</Text>
19      <Text>{props.label ?? 'working'}</Text>
20    </Box>
21  )
22}
23
24export default Spinner
25
hooks/clients/fps.tsx 33 lines
1import type { ClientModule } from 'claude-code'
2
3type Props = { ms?: number }
4type State = { ticks: number; started: number; now: number }
5
6/**
7 * Asks for a tick every `ms` (1 by default) and counts what arrives: the
8 * ticks per second it shows are the surface's real frame-clock ceiling.
9 */
10const Fps: ClientModule<Props, State> = (props, surface) => {
11  const { Box, Text } = surface.elements
12  if (surface.state === undefined) {
13    let ticks = 0
14    const started = Date.now()
15    surface.every(props.ms ?? 1, () => surface.setState({ ticks: ++ticks, started, now: Date.now() }))
16    surface.setState({ ticks: 0, started, now: started })
17  }
18  const s = surface.state ?? { ticks: 0, started: 0, now: 0 }
19  const secs = Math.max(0.001, (s.now - s.started) / 1000)
20  return (
21    <Box flexDirection="column">
22      <Text key="ticks">
23        {s.ticks} ticks in {secs.toFixed(1)} s
24      </Text>
25      <Text key="rate" color="#2f80ed">
26        {(s.ticks / secs).toFixed(1)} per second (asked every {props.ms ?? 1} ms)
27      </Text>
28    </Box>
29  )
30}
31
32export default Fps
33
hooks/clients/pointer.tsx 49 lines
1import type { ClientModule, ClientPointerEvent } from 'claude-code'
2
3type State = { x: number; y: number; type: string; fine?: string; down: boolean }
4
5const ROWS = 5
6
7/** A grid that draws a marker where the pointer is; drag to paint the trail's head. */
8const Pointer: ClientModule<Record<string, never>, State> = (_props, surface) => {
9  const { Box, Text } = surface.elements
10  if (surface.state === undefined) {
11    let down = false
12    surface.onPointer((e: ClientPointerEvent) => {
13      if (e.type === 'down') down = true
14      if (e.type === 'up') down = false
15      surface.setState({
16        x: e.x,
17        y: e.y,
18        type: e.type,
19        fine: e.fine ? `${e.fine.x.toFixed(2)},${e.fine.y.toFixed(2)}` : undefined,
20        down,
21      })
22    })
23    surface.setState({ x: -1, y: -1, type: 'none', down: false })
24  }
25  const s = surface.state ?? { x: -1, y: -1, type: 'none', down: false }
26  const cols = Math.max(10, Math.min(surface.columns || 40, 80))
27  const rows = []
28  for (let y = 0; y < ROWS; y++) {
29    let line = ''
30    for (let x = 0; x < cols; x++) line += x === s.x && y === s.y ? (s.down ? '█' : '+') : '·'
31    rows.push(
32      <Text key={`r${y}`} dimColor={y !== s.y}>
33        {line}
34      </Text>,
35    )
36  }
37  return (
38    <Box flexDirection="column">
39      {rows}
40      <Text key="last">
41        {s.type} at {s.x},{s.y}
42        {s.fine ? ` fine ${s.fine}` : ''}
43      </Text>
44    </Box>
45  )
46}
47
48export default Pointer
49
hooks/clients/keys.tsx 27 lines
1import type { ClientKeyEvent, ClientModule } from 'claude-code'
2
3type State = { last: string; count: number }
4
5/** Shows the last key the region received once a click gave it the focus. */
6const Keys: ClientModule<Record<string, never>, State> = (_props, surface) => {
7  const { Box, Text } = surface.elements
8  if (surface.state === undefined) {
9    let count = 0
10    surface.onKey((e: ClientKeyEvent) => {
11      const mods = [e.ctrl && 'ctrl', e.shift && 'shift', e.meta && 'meta'].filter(Boolean).join('+')
12      surface.setState({ last: mods ? `${mods}+${e.key}` : e.key, count: ++count })
13    })
14    surface.setState({ last: 'none', count: 0 })
15  }
16  return (
17    <Box flexDirection="column">
18      <Text dimColor>click here, then type (Esc hands the keys back)</Text>
19      <Text key="last">
20        last key {surface.state?.last ?? 'none'} ({surface.state?.count ?? 0})
21      </Text>
22    </Box>
23  )
24}
25
26export default Keys
27
hooks/clients/echo.tsx 28 lines
1import type { ClientModule } from 'claude-code'
2
3type Props = { pong?: number; via?: string }
4type State = { sent: number }
5
6/** Posts a ping to the hooks module, whose `ui.message` hook answers new props: a round trip. */
7const Echo: ClientModule<Props, State> = (props, surface) => {
8  const { Box, Text, Button } = surface.elements
9  const sent = surface.state?.sent ?? 0
10  return (
11    <Box flexDirection="row" gap={1}>
12      <Button
13        key="ping"
14        label={`ping ${sent + 1}`}
15        onPress={() => {
16          surface.setState({ sent: sent + 1 })
17          surface.post({ ping: sent + 1 })
18        }}
19      />
20      <Text key="pong">
21        {props.pong === undefined ? 'no answer yet' : `pong ${props.pong} via ${props.via ?? '?'}`}
22      </Text>
23    </Box>
24  )
25}
26
27export default Echo
28
types/index.d.ts 13 lines
1// The state contract: what the lab keeps in `$.state`.
2
3/** Whether a wide app pane flows the index's groups and the gallery into columns (the footer's switch); on until flipped. */
4export type ColumnsView = boolean
5
6declare module 'claude-code' {
7  interface PluginState {
8    lab: {
9      columns: ColumnsView
10    }
11  }
12}
13