SLOPSHOPPER

mods

One /mods pane listing every installed mod: each mod's state, settings and actions, drawn in Claude Code's own idiom

newpanecommandtoaststatustimer
v0.7.0MITupdated 2026-10-15mvacoimbra/claude-mods-hub
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mods
│ ┃ Mods ✕ › fix the failing auth test and add an audit log call │ ┃ No mod that joins this pane is installed. │ ┃ Install one from the claude-mods ⏺ Read(src/auth.ts) │ ┃ marketplace and it shows up here. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ More ⎿ Added 2 lines, removed 1 line │ ┃ ╭─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ │ ⎿ 3 pass, 1 fail │ ┃ │ Components [ │ ┃ │ Every element a mod can draw, and each ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ mod’s named parts │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ Settings [ O │ ┃ │ Language, and where settings are kept › /mods │ ┃ │ │ ┃ ╰─────────────────────────────────────────── │ ┃ │ ┃ Not installed: Keep warm, Skins, Files, │ ┃ Agents, Replay, Reflect │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mods
No mod that joins this pane is installed. Install one from the claude-mods marketplace and it shows up here. More ╭─────────────────────────────────────────────────────────── │ │ Components │ Every element a mod can draw, and each mod’s named parts │ │ Settings [ Open ] │ Language, and where settings are kept │ ╰─────────────────────────────────────────────────────────── Not installed: Keep warm, Skins, Files, Agents, Replay, Reflect
README

mods

One /mods pane for every installed Claude Code mod. The pane lists every mod with its live state; open one for its settings and actions, all drawn alike from the pack's shared kit, in Claude Code's own idiom and the skin chosen in skins. The mods themselves register no slash commands, and every setting is the user's: the same in every session and project.

ViewModWhat it holds
OverviewEvery installed mod with its live state, and a way into its view
Keep warmcache-taxWhether the prompt cache is warm and when it goes cold, keep-warm windows, every-session switch, the cold-send guard
SkinsskinsThe skin with a live preview, the look, the content cards, the usage band, the colours, copy and gallery
FilesfiletreeOpen or close the file tree, show another folder, and every filetree setting
Agentssavvy-progressRunning, finished and planned subagents, the current flow, and the agents panel
Replayreplay-theaterStep through the last turn's edits
Reflectreflect-modRules found in your corrections, to save to CLAUDE.md or skip, and the pause switch
ComponentsEverything a mod can draw or do, named and live: every element with every prop and value (Box, Text, Button, Input, Select, Link, Code, Markdown, Svg, Raster, Image, Client), the theme colours, the sites a mod draws in, the calls outside a drawing (toast, notify, ask, status, copy, panes), and every named component of each installed mod, from its catalog
SettingsThe language of the pane and of every view (English or Português), and where settings live

A mod that is not installed is not listed. 1 to 9 open a mod, 0 goes back, Tab and the arrows move, Esc closes.

Install

From the claude-mods marketplace:

claude plugin marketplace add mvacoimbra/claude-mods
claude plugin install mods@claude-mods

How a mod joins

The hub draws every tab itself from a description the mod hands it, since a press cannot cross from one mod's tree to another's. PROTOCOL.md has the contract: a noun on $ with panel and act, and its name in the hub's MODS table.

The kit

kit/ is the pack's component library: tones from the skin (tone.ts), stroke icons (icons.ts) and the rows, groups, controls, meters and notices every mod draws with (kit.tsx). Edit it here, then copy it into every mod:

dev/sync-kit.sh

Develop

claude plugin validate .
claude plugin test .

dev/preview/ draws captured trees as an HTML approximation of the desktop app (render.mjs) and screenshots it (shot.sh), to look at a change outside a live session.

Source 8 files
hooks/register.tsx 529 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import { action, choice, dot, empty, field, icon, kitOf, meter, picker, row, section, stack, staticTree, status, swatch, text, toggle } from '../kit/kit'
5import type { Kit } from '../kit/kit'
6import type { IconName } from '../kit/icons'
7import { toneOf } from '../kit/tone'
8import type { KitSkin, Tone } from '../kit/tone'
9import type { ModsActArgs, ModsActResult, ModsCatalogEntry, ModsItem, ModsLang, ModsPanel, ModsPanelArgs } from '../types'
10import { componentsTab, DEMO_START, DEMO_WORDS } from './components'
11import type { DemoCatalog, DemoState, DemoUi } from './components'
12import { HUB_STRINGS } from './i18n'
13import type { HubStrings } from './i18n'
14
15const PANE = 'mods'
16
17// The mods that join the pane, by the noun each adds to `$`, in list order: the plugin's
18// name, the name a person sees (a mod's own panel title wins once it is loaded), its icon.
19type ModInfo = { noun: string; name: string; title: Readonly<Record<ModsLang, string>>; icon: IconName }
20
21export const MODS: readonly ModInfo[] = [
22  { noun: 'cachetax', name: 'cache-tax', title: { en: 'Keep warm', pt: 'Manter aquecido' }, icon: 'cache' },
23  { noun: 'skins', name: 'skins', title: { en: 'Skins', pt: 'Skins' }, icon: 'skins' },
24  { noun: 'filetree', name: 'filetree', title: { en: 'Files', pt: 'Arquivos' }, icon: 'folder' },
25  { noun: 'savvy', name: 'savvy-progress', title: { en: 'Agents', pt: 'Agentes' }, icon: 'agents' },
26  { noun: 'replay', name: 'replay-theater', title: { en: 'Replay', pt: 'Replay' }, icon: 'replay' },
27  { noun: 'reflect', name: 'reflect-mod', title: { en: 'Reflect', pt: 'Reflect' }, icon: 'reflect' },
28]
29
30// The mods whose components the Components tab names; blast-radius has a catalog and no view.
31export const CATALOGS: readonly ModInfo[] = [...MODS, { noun: 'blast', name: 'blast-radius', title: { en: 'Blast radius', pt: 'Blast radius' }, icon: 'shield' }]
32
33type Loaded = { noun: string; panel: ModsPanel; failed?: string }
34
35const tabAtom = atom({ plugin: 'mods', key: 'tab' } as const, 'overview')
36const revisionAtom = atom({ plugin: 'mods', key: 'revision' } as const, 0)
37const langAtom = atom({ plugin: 'mods', key: 'lang' } as const, 'en' as ModsLang)
38const demoAtom = atom({ plugin: 'mods', key: 'demo' } as const, DEMO_START as DemoState)
39
40// Each mod's noun, spelled out: `$` is only ever called by name. A mod that is not
41// installed has no noun on `$`, so the call throws before reaching it.
42async function panelOf($: EngineInterface, noun: string, args: ModsPanelArgs): Promise<ModsPanel> {
43  switch (noun) {
44    case 'cachetax':
45      return $.cachetax.panel(args)
46    case 'skins':
47      return $.skins.panel(args)
48    case 'filetree':
49      return $.filetree.panel(args)
50    case 'savvy':
51      return $.savvy.panel(args)
52    case 'replay':
53      return $.replay.panel(args)
54    case 'reflect':
55      return $.reflect.panel(args)
56    default:
57      throw new Error(`no mod named ${noun}`)
58  }
59}
60
61async function actOn($: EngineInterface, noun: string, args: ModsActArgs): Promise<ModsActResult> {
62  switch (noun) {
63    case 'cachetax':
64      return $.cachetax.act(args)
65    case 'skins':
66      return $.skins.act(args)
67    case 'filetree':
68      return $.filetree.act(args)
69    case 'savvy':
70      return $.savvy.act(args)
71    case 'replay':
72      return $.replay.act(args)
73    case 'reflect':
74      return $.reflect.act(args)
75    default:
76      throw new Error(`no mod named ${noun}`)
77  }
78}
79
80async function catalogOf($: EngineInterface, noun: string, args: ModsPanelArgs): Promise<ModsCatalogEntry[]> {
81  switch (noun) {
82    case 'cachetax':
83      return $.cachetax.catalog(args)
84    case 'skins':
85      return $.skins.catalog(args)
86    case 'filetree':
87      return $.filetree.catalog(args)
88    case 'savvy':
89      return $.savvy.catalog(args)
90    case 'replay':
91      return $.replay.catalog(args)
92    case 'reflect':
93      return $.reflect.catalog(args)
94    case 'blast':
95      return $.blast.catalog(args)
96    default:
97      throw new Error(`no mod named ${noun}`)
98  }
99}
100
101// What a call to a noun nobody added throws: reading its method off undefined, or calling a
102// method a mod's noun does not have.
103const isAbsent = (error: unknown): boolean => error instanceof TypeError && /\b(panel|act|catalog|palette)\b/.test(error.message)
104
105// The skin every mod wears, from skins; Claude Code's own colours without it.
106async function skinOf($: EngineInterface): Promise<KitSkin | null> {
107  try {
108    return await $.skins.palette()
109  } catch (error) {
110    if (!isAbsent(error)) {
111      $.ui.log(`mods: no palette from skins: ${messageOf(error)}`, { to: 'debug' })
112    }
113
114    return null
115  }
116}
117
118// The open Mods section's catalog: null entries when the mod is absent or has none.
119async function loadCatalog($: EngineInterface, noun: string, args: ModsPanelArgs, name: string, t?: Tone): Promise<DemoCatalog> {
120  try {
121    // Another mod's drawing reaches the hub as data: anything that would need a handler
122    // (a Button, a field) is drawn as its label, so one careless entry cannot blank the pane.
123    const entries = await catalogOf($, noun, args)
124
125    return { noun, name, entries: entries.map(entry => ({ ...entry, tree: staticTree(entry.tree, t) })) }
126  } catch (error) {
127    return isAbsent(error) ? { noun, name, entries: null } : { noun, name, entries: null, failed: messageOf(error) }
128  }
129}
130
131const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error))
132
133async function loadAll($: EngineInterface, args: ModsPanelArgs): Promise<Loaded[]> {
134  const loaded = await Promise.all(
135    MODS.map(async ({ noun, name }): Promise<Loaded | null> => {
136      try {
137        return { noun, panel: await panelOf($, noun, args) }
138      } catch (error) {
139        return isAbsent(error) ? null : { noun, panel: { title: name, sections: [] }, failed: messageOf(error) }
140      }
141    }),
142  )
143
144  return loaded.filter((mod): mod is Loaded => mod !== null)
145}
146
147// The pane's language lives in the user's store, so every session and project shows the
148// same one; the atom only carries it between draws of one session.
149async function langOf($: EngineInterface): Promise<ModsLang> {
150  const stored = await $.store.get('lang')
151
152  return stored === 'pt' ? 'pt' : 'en'
153}
154
155// The redraw tick of a view that counts down, and how often it runs.
156let ticker: Timer | undefined
157let tickMs = 0
158
159function setTicker($: EngineInterface, ms: number): void {
160  if (ms === tickMs) {
161    return
162  }
163
164  ticker?.cancel()
165  ticker = undefined
166  tickMs = ms
167
168  if (ms > 0) {
169    ticker = $.clock.every(ms, () => void update($, revisionAtom, n => n + 1))
170  }
171}
172
173// Swatches next to each other share one wrapped grid, and so do actions with no help, as one
174// row of buttons; every other item is a row of its own.
175type Swatch = Extract<ModsItem, { kind: 'swatch' }>
176type Action = Extract<ModsItem, { kind: 'action' }>
177type Run = { kind: 'one'; item: ModsItem; index: number } | { kind: 'swatches'; items: Swatch[] } | { kind: 'actions'; items: Action[] }
178
179const isBare = (item: ModsItem): item is Action => item.kind === 'action' && item.help === undefined
180
181function runsOf(list: readonly ModsItem[]): Run[] {
182  const runs: Run[] = []
183
184  list.forEach((item, index) => {
185    const last = runs.at(-1)
186
187    if (item.kind === 'swatch') {
188      if (last?.kind === 'swatches') {
189        last.items.push(item)
190      } else {
191        runs.push({ kind: 'swatches', items: [item] })
192      }
193    } else if (isBare(item)) {
194      if (last?.kind === 'actions') {
195        last.items.push(item)
196      } else {
197        runs.push({ kind: 'actions', items: [item] })
198      }
199    } else {
200      runs.push({ kind: 'one', item, index })
201    }
202  })
203
204  return runs
205}
206
207// Only a live state earns colour and a dot: ok, warn or err. Accent and muted read as plain.
208const isState = (tone: string | undefined): tone is 'ok' | 'warn' | 'err' => tone === 'ok' || tone === 'warn' || tone === 'err'
209
210// Choices short enough to sit at a row's end; the rest wrap under the label.
211const fitsAtEnd = (options: readonly { label: string }[], columns: number): boolean =>
212  options.length <= 3 && options.reduce((sum, option) => sum + option.label.length + 5, 0) <= Math.max(16, columns - 34)
213
214export const register: Register = on => {
215  on('session.start', async ($, e, next) => {
216    const lang = await langOf($)
217    await update($, langAtom, () => lang)
218    await $.command.register({ name: 'mods', description: HUB_STRINGS[lang].command, immediate: true })
219
220    // The user's choice to see the pane from the first moment, in every session.
221    if ((await $.store.get('openAtStart')) === true) {
222      await $.ui.open({ id: PANE, title: HUB_STRINGS[lang].title }).catch(() => undefined)
223    }
224
225    return next(e)
226  })
227
228  on('command.run', { command: 'mods' }, async $ => {
229    const lang = await langOf($)
230    await $.ui.open({ id: PANE, title: HUB_STRINGS[lang].title, focus: true, closeOnEscape: true })
231
232    return {}
233  })
234
235  on('ui.close', async ($, e, next) => {
236    if (e.id === PANE) {
237      setTicker($, 0)
238    }
239
240    return next(e)
241  })
242
243  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
244    const ui = $.ui.resolve(e)
245
246    if (e.surface === 'mobile' || !('Input' in ui)) {
247      return <ui.Text>Open /mods in the terminal or the desktop app.</ui.Text>
248    }
249
250    await read($, revisionAtom)
251    const lang = await langOf($)
252    const w: HubStrings = HUB_STRINGS[lang]
253    const columns = e.props.bodyColumns
254    const k: Kit = kitOf(ui, toneOf(await skinOf($)), e.surface, columns)
255    const { Box } = ui
256    const mods = await loadAll($, { lang, surface: e.surface, columns: Math.max(20, columns - 4) })
257    const stored = await read($, tabAtom)
258    const tab = stored === 'overview' || stored === 'settings' || stored === 'components' || mods.some(mod => mod.noun === stored) ? stored : 'overview'
259    const current = mods.find(mod => mod.noun === tab)
260
261    // A countdown in view draws again on its own; the overview shows every summary.
262    const refresh = tab === 'overview' ? Math.min(...mods.map(mod => mod.panel.refreshMs ?? Infinity)) : (current?.panel.refreshMs ?? Infinity)
263    setTicker($, Number.isFinite(refresh) ? Math.max(1000, refresh) : 0)
264
265    const go = (next: string) => void update($, tabAtom, () => next)
266
267    const press = (noun: string, id: string, value?: string) =>
268      void (async () => {
269        try {
270          const result = await actOn($, noun, value === undefined ? { id } : { id, value })
271
272          if (result?.message) {
273            $.ui.toast(result.message)
274          }
275        } catch (error) {
276          $.ui.toast(messageOf(error))
277        }
278
279        await update($, revisionAtom, n => n + 1)
280      })()
281
282    const toggleWords = { on: w.on, off: w.off, turnOn: w.turnOn, turnOff: w.turnOff }
283
284    // Help, with the session-only mark where an item is not the user's setting.
285    const helpOf = (entry: { help?: string; scope?: string }) =>
286      entry.scope === 'session' ? [entry.help, w.sessionOnly].filter(Boolean).join(' · ') : entry.help
287
288    const item = (noun: string, entry: ModsItem, index: number): RenderElement | null => {
289      const key = `item-${noun}-${'id' in entry ? entry.id : index}`
290
291      switch (entry.kind) {
292        case 'toggle':
293          return row(k, { key, label: entry.label, help: helpOf(entry), end: toggle(k, key, entry.value, next => press(noun, entry.id, next ? 'on' : 'off'), toggleWords) })
294        case 'choice': {
295          const picked = picker(k, key, entry.value, entry.options, value => press(noun, entry.id, value), w.choose)
296
297          return fitsAtEnd(entry.options, columns)
298            ? row(k, { key, label: entry.label, help: helpOf(entry), end: picked })
299            : row(k, { key, label: entry.label, help: helpOf(entry), below: picked })
300        }
301        case 'action':
302          return row(k, {
303            key,
304            label: entry.title ?? helpOf(entry) ?? entry.label,
305            help: entry.title === undefined ? undefined : helpOf(entry),
306            end: action(k, key, entry.label, () => press(noun, entry.id), { primary: entry.primary === true }),
307          })
308        case 'input':
309          return field(k, key, entry.label, entry.placeholder ?? '', value => press(noun, entry.id, value), {
310            submitLabel: entry.submitLabel ?? w.apply,
311            ...(helpOf(entry) ? { help: helpOf(entry)! } : {}),
312          })
313        case 'info':
314          return row(k, { key, label: entry.label, muted: true, end: text(k, entry.value, entry.tone), endWraps: true })
315        case 'text':
316          return (
317            <Box key={key} paddingX={1}>
318              {text(k, entry.text, entry.tone ?? 'muted')}
319            </Box>
320          )
321        case 'tree':
322          return (
323            <Box key={key} flexDirection="column" paddingX={1}>
324              {staticTree(entry.tree, k.t) as RenderElement}
325            </Box>
326          )
327        case 'swatch':
328          return null
329      }
330    }
331
332    const items = (noun: string, list: readonly ModsItem[]) =>
333      runsOf(list).map(run => {
334        switch (run.kind) {
335          case 'one':
336            return item(noun, run.item, run.index)
337          case 'swatches':
338            return (
339              <Box key={`swatches-${noun}-${run.items[0]!.id}`} flexDirection="row" flexWrap="wrap" columnGap={2} paddingX={1}>
340                {run.items.map(entry => swatch(k, `item-${noun}-${entry.id}`, entry.label, entry.color, entry.selected, () => press(noun, entry.id)))}
341              </Box>
342            )
343          case 'actions':
344            return (
345              <Box key={`actions-${noun}-${run.items[0]!.id}`} flexDirection="row" flexWrap="wrap" columnGap={1} paddingX={1}>
346                {run.items.map(entry => action(k, `item-${noun}-${entry.id}`, entry.label, () => press(noun, entry.id), { primary: entry.primary === true }))}
347              </Box>
348            )
349        }
350      })
351
352    // The way back from a mod, Components or Settings, and the view's own title.
353    const header = (title: string, iconName: IconName, state?: RenderElement | null) => (
354      <Box key="header" flexDirection="column">
355        <Box flexDirection="row" columnGap={1} alignItems="center">
356          {icon(k, 'chevronLeft', 'muted', 14)}
357          {action(k, 'tab-overview', w.allMods, () => go('overview'), { hotkey: '0' })}
358        </Box>
359        <Box flexDirection="row" columnGap={1} alignItems="center" paddingX={1} paddingTop={1}>
360          {icon(k, iconName, undefined, 18)}
361          {text(k, title, undefined, { bold: true })}
362          <Box flexGrow={1} />
363          {state ?? null}
364        </Box>
365      </Box>
366    )
367
368    // A progress the mod reports, as the pack's ring.
369    const ringOf = (mod: Loaded) => (mod.panel.percent === undefined ? null : meter(k, `ring-${mod.noun}`, '', mod.panel.percent, undefined, 'accent'))
370
371    const stateOf = (mod: Loaded) =>
372      mod.failed ? (
373        status(k, w.couldNotDraw, 'err')
374      ) : !mod.panel.summary ? (
375        ringOf(mod)
376      ) : (
377        <Box flexDirection="row" columnGap={1} alignItems="center" flexShrink={1}>
378          {ringOf(mod)}
379          {isState(mod.panel.tone) ? status(k, mod.panel.summary, mod.panel.tone) : text(k, mod.panel.summary, 'muted')}
380        </Box>
381      )
382
383    const iconOf = (noun: string): IconName => CATALOGS.find(mod => mod.noun === noun)?.icon ?? 'mods'
384
385    const missing = MODS.filter(mod => !mods.some(present => present.noun === mod.noun)).map(mod => mod.title[lang])
386
387    // One name per mod everywhere: its own panel title, or the table's when it has no view.
388    const titleOf = (noun: string): string => mods.find(mod => mod.noun === noun)?.panel.title || CATALOGS.find(mod => mod.noun === noun)?.title[lang] || noun
389
390    const overview = stack(k, 'overview', [
391      mods.length === 0
392        ? empty(k, 'no-mods', w.noMods, w.noModsHint)
393        : section(
394            k,
395            'list-mods',
396            w.installed,
397            mods.map((mod, i) =>
398              row(k, {
399                key: `mod-${mod.noun}`,
400                label: mod.panel.title,
401                icon: iconOf(mod.noun),
402                help: mod.failed ? w.failed(mod.failed) : mod.panel.summary,
403                ...(mod.failed ? { helpTone: 'err' as const } : isState(mod.panel.tone) ? { helpTone: mod.panel.tone } : {}),
404                end: [
405                  ...(mod.failed ? [] : [ringOf(mod)]),
406                  ...(!mod.failed && isState(mod.panel.tone) ? [dot(k, mod.panel.tone)] : []),
407                  action(k, `open-${mod.noun}`, w.open, () => go(mod.noun), i < 9 ? { hotkey: String(i + 1) } : {}),
408                ],
409              }),
410            ),
411            mods.length === 1 ? w.oneMod : w.manyMods(mods.length),
412          ),
413      section(k, 'list-more', w.more, [
414        row(k, { key: 'components', label: w.components, help: w.componentsHelp, icon: 'components', end: action(k, 'tab-components', w.open, () => go('components')) }),
415        row(k, { key: 'settings', label: w.settings, help: w.settingsHelp, icon: 'settings', end: action(k, 'tab-settings', w.open, () => go('settings')) }),
416      ]),
417      missing.length > 0 ? <Box key="missing" paddingX={1}>{text(k, w.missing(missing.join(', ')), 'faint')}</Box> : null,
418    ])
419
420    const setLang = (value: string) =>
421      void (async () => {
422        const next: ModsLang = value === 'pt' ? 'pt' : 'en'
423        await $.store.set('lang', next)
424        await update($, langAtom, () => next)
425        await update($, revisionAtom, n => n + 1)
426      })()
427
428    const openAtStart = (await $.store.get('openAtStart')) === true
429
430    const settings = stack(k, 'settings', [
431      header(w.settings, 'settings'),
432      section(k, 'settings-language', w.general, [
433        row(k, {
434          key: 'mods-start',
435          label: w.openAtStart,
436          help: w.openAtStartHelp,
437          end: toggle(
438            k,
439            'item-mods-start',
440            openAtStart,
441            next =>
442              void (async () => {
443                await $.store.set('openAtStart', next)
444                await update($, revisionAtom, n => n + 1)
445              })(),
446            toggleWords,
447          ),
448        }),
449        row(k, {
450          key: 'mods-lang',
451          label: w.language,
452          help: w.languageHelp,
453          end: choice(k, 'item-mods-lang', lang, [{ value: 'en', label: 'English' }, { value: 'pt', label: 'Português' }], setLang),
454        }),
455      ]),
456      section(k, 'settings-scope', w.scopeTitle, [
457        row(k, { key: 'scope-user', label: w.scopeUser, help: w.scopeUserHelp, icon: 'settings' }),
458        row(k, { key: 'scope-session', label: w.scopeSession, help: w.scopeSessionHelp, icon: 'clock' }),
459      ]),
460      section(k, 'settings-keys', w.keysTitle, [row(k, { key: 'keys', label: w.footer, muted: true })]),
461    ])
462
463    const modView = (mod: Loaded) => {
464      const { panel } = mod
465
466      return stack(k, `view-${mod.noun}`, [
467        header(panel.title, iconOf(mod.noun), stateOf(mod)),
468        mod.failed ? <Box key="failed" paddingX={1}>{text(k, w.failed(mod.failed), 'err')}</Box> : null,
469        ...panel.sections.map(entry =>
470          section(k, `section-${mod.noun}-${entry.id}`, entry.title, items(mod.noun, entry.items), undefined, entry.items.every(one => one.kind === 'info')),
471        ),
472        panel.sections.length === 0 && !mod.failed ? empty(k, 'nothing', w.nothingToSet) : null,
473      ])
474    }
475
476    // The Components tab draws a mod's catalog only while its Mods section is open.
477    const demo = await read($, demoAtom)
478    const catalogMods = CATALOGS.filter(mod => mod.noun === 'blast' || mods.some(present => present.noun === mod.noun)).map(mod => ({ noun: mod.noun, name: titleOf(mod.noun), slug: mod.name }))
479    const openCatalog = demo.mod !== '' && catalogMods.some(mod => mod.noun === demo.mod) ? demo.mod : catalogMods[0]?.noun
480    const catalog =
481      tab === 'components' && demo.group === 'mods' && openCatalog !== undefined
482        ? await loadCatalog($, openCatalog, { lang, surface: e.surface, columns: Math.max(20, columns - 4) }, titleOf(openCatalog), k.t)
483        : null
484
485    const components = () =>
486      stack(k, 'components', [
487        header(w.components, 'components'),
488        componentsTab(
489          ui as unknown as DemoUi,
490          k,
491          e.surface,
492          columns,
493          lang,
494          demo,
495          {
496            group: id => void update($, demoAtom, d => ({ ...d, group: id })),
497            element: id => void update($, demoAtom, d => ({ ...d, element: id })),
498            mod: noun => void update($, demoAtom, d => ({ ...d, mod: noun })),
499            click: () => void update($, demoAtom, d => ({ ...d, clicks: d.clicks + 1 })),
500            type: value => void update($, demoAtom, d => ({ ...d, typed: value })),
501            submit: value => void update($, demoAtom, d => ({ ...d, submitted: value, typed: '' })),
502            pick: value => void update($, demoAtom, d => ({ ...d, picked: value })),
503            link: () => void update($, demoAtom, d => ({ ...d, links: d.links + 1 })),
504            toast: () => $.ui.toast(w.toast),
505            notify: () => void $.ui.notify(w.toast, { title: 'mods' }).catch(error => $.ui.toast(messageOf(error))),
506            ask: () =>
507              void $.ui
508                .ask(DEMO_WORDS[lang].outside.askQuestion, ['Box', 'Text', 'Button'])
509                .then(answer => $.ui.toast(`$.ui.ask → ${answer}`))
510                .catch(error => $.ui.toast(messageOf(error))),
511            status: () =>
512              void (async () => {
513                const time = new Date(await $.clock.now()).toTimeString().slice(0, 8)
514                $.ui.status(w.status(time))
515                $.clock.after(8000, () => $.ui.status(undefined))
516              })(),
517            copy: () => void $.ui.copy({ text: 'copied by /mods' }).then(result => $.ui.toast(result.isCopied ? '$.ui.copy → ok' : '$.ui.copy → not here')),
518          },
519          catalogMods,
520          catalog,
521        ),
522      ])
523
524    const view = tab === 'settings' ? settings : tab === 'components' ? components() : current === undefined ? overview : modView(current)
525
526    return view
527  })
528}
529
kit/kit.tsx 468 lines
1import type { RenderElement, RenderSurface } from 'claude-code'
2
3import { iconSvg } from './icons'
4import type { IconName } from './icons'
5import { hexOf, inkOf } from './tone'
6import type { KitTone, Tone } from './tone'
7
8// The pack's components: one grammar for every pane, band and dialog the mods draw, in
9// Claude Code's own idiom. Native Buttons, the app's text colours, hairline groups that
10// never nest, one accent for the current choice and the one main action, stroke icons
11// instead of glyphs, and the ring meter for anything with a percent.
12//
13// Pure functions of the resolved elements, the tone and the surface: nothing here takes `$`.
14// Shared kit: edit in claude-mods-hub/kit, then run dev/sync-kit.sh; never edit a copy.
15
16// The elements the kit draws with; `Svg` only where the surface draws vectors.
17export type KitUi = {
18  Box: any
19  Text: any
20  Button: any
21  Input?: any
22  Select?: any
23  Svg?: any
24}
25
26export type Kit = {
27  readonly ui: KitUi
28  readonly t: Tone
29  readonly surface: RenderSurface
30  // The desktop app, or another surface that draws Svg: icons, switches and rings.
31  readonly vector: boolean
32  // Columns the drawing may take.
33  readonly columns: number
34}
35
36export const kitOf = (ui: KitUi, t: Tone, surface: RenderSurface, columns: number): Kit => ({
37  ui,
38  t,
39  surface,
40  vector: surface !== 'terminal' && ui.Svg !== undefined,
41  columns,
42})
43
44type Child = RenderElement | string | null | undefined | false
45
46// An icon, 16px; nothing on the terminal unless a fallback glyph is given.
47export function icon(k: Kit, name: IconName, tone?: KitTone | 'faint', size = 16, fallback?: string): RenderElement | null {
48  if (k.vector) {
49    const { Svg } = k.ui
50    const color = tone === 'faint' ? k.t.hex.faint : hexOf(k.t, tone ?? 'muted')
51
52    return <Svg source={iconSvg(name, color, size)} alt={name} width={size} height={size} />
53  }
54
55  return fallback === undefined ? null : <k.ui.Text color={tone === 'faint' ? k.t.ink.faint : inkOf(k.t, tone ?? 'muted')}>{fallback}</k.ui.Text>
56}
57
58// A small filled circle in a state's colour: the dot beside a live state.
59export function dot(k: Kit, tone: KitTone, size = 8): RenderElement {
60  if (k.vector) {
61    const { Svg } = k.ui
62    const r = size / 2
63
64    return (
65      <Svg
66        source={`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><circle cx="${r}" cy="${r}" r="${r - 0.5}" fill="${hexOf(k.t, tone)}"/></svg>`}
67        alt={tone}
68        width={size}
69        height={size}
70      />
71    )
72  }
73
74  return <k.ui.Text color={inkOf(k.t, tone)}>●</k.ui.Text>
75}
76
77// Text in a role: body, muted (secondary), faint (tertiary), or a state.
78export function text(k: Kit, content: Child | Child[], tone?: KitTone | 'faint', opts: { bold?: boolean; wrap?: string; key?: string } = {}): RenderElement {
79  const color = tone === 'faint' ? k.t.ink.faint : inkOf(k.t, tone)
80
81  return (
82    <k.ui.Text {...(opts.key ? { key: opts.key } : {})} {...(color === undefined ? {} : { color })} {...(opts.bold ? { bold: true } : {})} {...(opts.wrap ? { wrap: opts.wrap } : {})}>
83      {content}
84    </k.ui.Text>
85  )
86}
87
88// A group's title, with optional muted detail and a control at the right:
89// `Finished              2  [Hide]`.
90export function heading(k: Kit, title: string, detail?: string, key?: string, end?: Child | Child[]): RenderElement {
91  const { Box } = k.ui
92
93  return (
94    <Box {...(key ? { key } : {})} flexDirection="row" columnGap={2} paddingX={1} alignItems="center">
95      <Box flexGrow={1} flexShrink={1}>
96        {text(k, title, undefined, { bold: true })}
97      </Box>
98      {detail ? text(k, detail, 'muted') : null}
99      {end ?? null}
100    </Box>
101  )
102}
103
104// Rows held in one hairline rounded outline, a line apart. Groups never nest. A dense group
105// (values, a list of readings) keeps its rows together.
106export function group(k: Kit, key: string, rows: Child[], dense = false): RenderElement {
107  const { Box } = k.ui
108
109  return (
110    <Box key={key} flexDirection="column" borderStyle="round" borderColor={k.t.ink.line} paddingY={1} rowGap={dense ? 0 : 1}>
111      {rows.filter(Boolean)}
112    </Box>
113  )
114}
115
116// A titled group: the heading, then the outlined rows. `end` puts a control in the heading.
117export function section(k: Kit, key: string, title: string, rows: Child[], detail?: string, dense = false, end?: Child | Child[]): RenderElement {
118  const { Box } = k.ui
119
120  return (
121    <Box key={`section-box-${key}`} flexDirection="column">
122      {heading(k, title, detail, undefined, end)}
123      {group(k, key, rows, dense)}
124    </Box>
125  )
126}
127
128export type RowArgs = {
129  key: string
130  label: string
131  help?: string | undefined
132  icon?: IconName | undefined
133  iconTone?: KitTone | undefined
134  // Any leading element in place of the icon: a mascot, an avatar, a ring.
135  lead?: Child
136  // Right-aligned: a control, a value, a state.
137  end?: Child | Child[]
138  // A long value at the end wraps instead of pushing the row wider.
139  endWraps?: boolean
140  // Under the label: a full-width control, a list of options that wraps.
141  below?: Child | Child[]
142  tone?: KitTone | undefined
143  // The help line in a state's colour: a mod's live state under its name.
144  helpTone?: KitTone | undefined
145  muted?: boolean
146}
147
148// One line of a group: icon, label and help at the left, the control at the right. The
149// row lights under the pointer, as the app's own list rows do.
150export function row(k: Kit, args: RowArgs): RenderElement {
151  const { Box } = k.ui
152  const narrow = k.columns < 44
153  const end = args.end === undefined || args.end === null ? null : (
154    <Box flexDirection="row" columnGap={1} alignItems="center" flexShrink={args.endWraps ? 1 : 0} {...(args.endWraps ? { justifyContent: 'flex-end' } : {})}>
155      {args.end}
156    </Box>
157  )
158
159  return (
160    <Box key={`row-${args.key}`} flexDirection="column" paddingX={1} hover={{ backgroundColor: k.t.ink.hover }}>
161      <Box flexDirection={narrow && end !== null && args.help ? 'column' : 'row'} columnGap={2} alignItems={narrow ? 'flex-start' : 'center'}>
162        <Box flexDirection="row" columnGap={1} flexGrow={1} flexShrink={1} alignItems="flex-start">
163          {args.lead ?? (args.icon ? icon(k, args.icon, args.iconTone) : null)}
164          <Box flexDirection="column" flexShrink={1}>
165            {text(k, args.label, args.muted ? 'muted' : args.tone)}
166            {args.help ? text(k, args.help, args.helpTone ?? 'muted') : null}
167          </Box>
168        </Box>
169        {end}
170      </Box>
171      {args.below === undefined || args.below === null ? null : (
172        <Box flexDirection="column" paddingTop={0} paddingLeft={(args.icon || args.lead) && k.vector ? 3 : 0}>
173          {args.below}
174        </Box>
175      )}
176    </Box>
177  )
178}
179
180export type Option = { value: string; label: string }
181
182// A choice among a few: a row of native buttons, the current one in the accent. The
183// options wrap under the label when there are many.
184export function choice(k: Kit, key: string, value: string, options: readonly Option[], pick: (value: string) => void): RenderElement {
185  const { Box, Button } = k.ui
186
187  return (
188    <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
189      {options.map(option =>
190        option.value === value ? (
191          <Button key={`${key}-${option.value}`} label={option.label} variant="primary" onPress={() => pick(option.value)} />
192        ) : (
193          <Button key={`${key}-${option.value}`} label={option.label} dimColor onPress={() => pick(option.value)} />
194        ),
195      )}
196    </Box>
197  )
198}
199
200// A choice of any size: a few options as native buttons, more than three as the native
201// select, so a long list never becomes a wall of buttons. `value` '' selects nothing.
202// With nothing selected, `prompt` is the first entry and the one shown ("Choose a length"),
203// never the select's generic placeholder; picking it does nothing.
204export function picker(k: Kit, key: string, value: string, options: readonly Option[], pick: (value: string) => void, prompt?: string): RenderElement {
205  const { Select } = k.ui
206
207  if (options.length <= 3 || Select === undefined) {
208    return choice(k, key, value, options, pick)
209  }
210
211  const none = value === '' && prompt !== undefined
212
213  return (
214    <Select
215      key={key}
216      options={[...(none ? [{ value: '', label: prompt }] : []), ...options.map(option => ({ value: option.value, label: option.label }))]}
217      {...(value === '' && !none ? {} : { value })}
218      onSelect={(next: string) => next !== '' && pick(next)}
219    />
220  )
221}
222
223const SWITCH_W = 28
224const SWITCH_H = 16
225
226// The drawn state of a switch: a pill track, the knob at the right and the track in the
227// accent when on. A picture only; the Button beside it does the switching.
228export function switchSvg(k: Kit, on: boolean): string {
229  const track = on ? k.t.hex.accent : k.t.hex.line
230  const knob = on ? '#ffffff' : k.t.hex.muted
231  const x = on ? SWITCH_W - SWITCH_H / 2 : SWITCH_H / 2
232
233  return `<svg xmlns="http://www.w3.org/2000/svg" width="${SWITCH_W}" height="${SWITCH_H}" viewBox="0 0 ${SWITCH_W} ${SWITCH_H}"><rect width="${SWITCH_W}" height="${SWITCH_H}" rx="${SWITCH_H / 2}" fill="${track}"/><circle cx="${x}" cy="${SWITCH_H / 2}" r="${SWITCH_H / 2 - 2.5}" fill="${knob}"/></svg>`
234}
235
236// An on/off setting: the switch shows the state, the button names what pressing does. On
237// the terminal, one plain button with a filled or hollow mark: `● On`, `○ Off`.
238export function toggle(k: Kit, key: string, on: boolean, flip: (next: boolean) => void, words: { on: string; off: string; turnOn: string; turnOff: string }): RenderElement {
239  const { Box, Button, Svg } = k.ui
240
241  if (!k.vector) {
242    return <Button key={key} plain dimColor={!on} label={on ? `● ${words.on}` : `○ ${words.off}`} onPress={() => flip(!on)} />
243  }
244
245  return (
246    <Box flexDirection="row" columnGap={1} alignItems="center">
247      <Svg source={switchSvg(k, on)} alt={on ? words.on : words.off} width={SWITCH_W} height={SWITCH_H} />
248      <Button key={key} label={on ? words.turnOff : words.turnOn} dimColor onPress={() => flip(!on)} />
249    </Box>
250  )
251}
252
253// A press: the group's one main action in the accent, the rest native and quiet.
254// `focus` starts the keyboard there (Enter presses it): the safe choice of a dialog. `plain`
255// draws the terminal's `1: Label` survey row; the desktop draws its native button either way.
256export function action(
257  k: Kit,
258  key: string,
259  label: string,
260  press: () => void,
261  opts: { primary?: boolean; hotkey?: string; dismiss?: boolean; focus?: boolean; plain?: boolean; dim?: boolean } = {},
262): RenderElement {
263  const { Button } = k.ui
264
265  return (
266    <Button
267      key={key}
268      label={label}
269      {...(opts.primary ? { variant: 'primary' } : {})}
270      {...(opts.hotkey ? { hotkey: opts.hotkey } : {})}
271      {...(opts.dismiss ? { role: 'dismiss' } : {})}
272      {...(opts.focus ? { autoFocus: true } : {})}
273      {...(opts.plain ? { plain: true } : {})}
274      {...(opts.dim ? { dimColor: true } : {})}
275      onPress={press}
276    />
277  )
278}
279
280// A value with its label, the value in its state's colour: `Cache   warm · 42m`.
281export function value(k: Kit, label: string, shown: string, tone?: KitTone, key?: string): RenderElement {
282  return row(k, { key: key ?? `value-${label}`, label, muted: true, end: text(k, shown, tone) })
283}
284
285const RING_R = 7
286
287// A percent as a ring, the pack's one data glyph, with its label beside it: `◔ 42% context`.
288// `detail` follows in muted text, such as a reset countdown. The terminal draws a bar.
289// A usage fills toward warn (80%) and err (95%); a progress passes `tone` and keeps it, so
290// finishing never reads as a warning.
291export function meter(k: Kit, key: string, label: string, percent: number, detail?: string, fixed?: KitTone): RenderElement {
292  const { Box, Svg } = k.ui
293  const p = Math.max(0, Math.min(100, Math.round(percent)))
294  const tone: KitTone = fixed ?? (p >= 95 ? 'err' : p >= 80 ? 'warn' : 'accent')
295
296  if (!k.vector) {
297    const filled = Math.round(p / 12.5)
298
299    return (
300      <Box key={key} flexDirection="row" columnGap={1}>
301        {text(k, '█'.repeat(filled) + '░'.repeat(8 - filled), tone)}
302        {text(k, `${p}%`, undefined, { bold: true })}
303        {text(k, detail ? `${label} · ${detail}` : label, 'muted')}
304      </Box>
305    )
306  }
307
308  const size = RING_R * 2 + 4
309  const c = 2 * Math.PI * RING_R
310  const ring = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><circle cx="${size / 2}" cy="${size / 2}" r="${RING_R}" fill="none" stroke="${k.t.hex.muted}" stroke-opacity=".25" stroke-width="2.5"/><circle cx="${size / 2}" cy="${size / 2}" r="${RING_R}" fill="none" stroke="${hexOf(k.t, tone)}" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="${(c * p) / 100} ${c}" transform="rotate(-90 ${size / 2} ${size / 2})"/></svg>`
311
312  return (
313    <Box key={key} flexDirection="row" columnGap={1} alignItems="center">
314      <Svg source={ring} alt={`${label} ${p}%`} width={size} height={size} />
315      {text(k, `${p}%`, undefined, { bold: true })}
316      {text(k, detail ? `${label} · ${detail}` : label, 'muted')}
317    </Box>
318  )
319}
320
321// A short state word in its colour, with its dot: `● warm`.
322export function status(k: Kit, word: string, tone: KitTone): RenderElement {
323  const { Box } = k.ui
324
325  return (
326    <Box flexDirection="row" columnGap={1} alignItems="center">
327      {dot(k, tone)}
328      {text(k, word, tone === 'muted' ? 'muted' : tone)}
329    </Box>
330  )
331}
332
333// A notice inside a pane or dialog: an icon in the state's colour, a title, a body. No
334// coloured side bar: the outline and the icon carry the state.
335export function notice(k: Kit, key: string, tone: KitTone, title: string, body?: Child | Child[], actions?: Child[], iconName?: IconName): RenderElement {
336  const { Box } = k.ui
337  const name: IconName = iconName ?? (tone === 'err' || tone === 'warn' ? 'alert' : tone === 'ok' ? 'check' : 'info')
338
339  return (
340    <Box key={key} flexDirection="column" borderStyle="round" borderColor={k.t.ink.line} paddingX={1} rowGap={0}>
341      <Box flexDirection="row" columnGap={1} alignItems="center">
342        {icon(k, name, tone, 16, tone === 'err' || tone === 'warn' ? '!' : 'i')}
343        {text(k, title, tone === 'muted' ? undefined : tone, { bold: true })}
344      </Box>
345      {body === undefined ? null : <Box flexDirection="column">{typeof body === 'string' ? text(k, body, 'muted') : body}</Box>}
346      {actions && actions.length > 0 ? (
347        <Box flexDirection="row" columnGap={1} paddingTop={1} justifyContent="flex-end">
348          {actions}
349        </Box>
350      ) : null}
351    </Box>
352  )
353}
354
355// What a list shows when it has nothing yet: what it will hold, and how it gets there.
356export function empty(k: Kit, key: string, title: string, hint?: string): RenderElement {
357  const { Box } = k.ui
358
359  return (
360    <Box key={key} flexDirection="column" paddingX={1}>
361      {text(k, title, 'muted')}
362      {hint ? text(k, hint, 'faint') : null}
363    </Box>
364  )
365}
366
367// A colour sample beside its name and value, as a palette editor lists them.
368export function swatch(k: Kit, key: string, label: string, color: string, selected: boolean, press: () => void): RenderElement {
369  const { Box, Button, Svg } = k.ui
370  const chip = k.vector ? (
371    <Svg
372      source={`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect x=".5" y=".5" width="15" height="15" rx="4" fill="${color}" stroke="${k.t.hex.line}"/></svg>`}
373      alt={color}
374      width={16}
375      height={16}
376    />
377  ) : (
378    <k.ui.Text backgroundColor={color}>{'  '}</k.ui.Text>
379  )
380
381  return (
382    <Box key={`swatch-${key}`} flexDirection="row" columnGap={1} alignItems="center" width={26}>
383      {chip}
384      {selected ? <Button key={key} label={label} variant="primary" onPress={press} /> : <Button key={key} label={label} dimColor onPress={press} />}
385      {text(k, color, 'faint')}
386    </Box>
387  )
388}
389
390// A single-line field with its label above, as the app's own forms set them.
391export function field(k: Kit, key: string, label: string, placeholder: string, submit: (value: string) => void, opts: { submitLabel?: string; help?: string; value?: string } = {}): RenderElement {
392  const { Box, Input } = k.ui
393
394  if (Input === undefined) {
395    return text(k, label, 'muted')
396  }
397
398  return (
399    <Box key={`field-${key}`} flexDirection="column" paddingX={1}>
400      {text(k, label)}
401      {opts.help ? text(k, opts.help, 'muted') : null}
402      <Input key={key} placeholder={placeholder} value={opts.value ?? ''} submitLabel={opts.submitLabel ?? 'OK'} onSubmit={(v: string) => submit(v.trim())} />
403    </Box>
404  )
405}
406
407// Space between groups: one empty line, the pack's whole vertical rhythm.
408export function stack(k: Kit, key: string, children: Child[], gap = 1): RenderElement {
409  return (
410    <k.ui.Box key={key} flexDirection="column" rowGap={gap}>
411      {children.filter(Boolean)}
412    </k.ui.Box>
413  )
414}
415
416// A drawing made plain data, for a catalog another mod draws: a press cannot cross mods, so
417// handlers are dropped and each control is drawn as its shape without its press. A Button is
418// a round hairline box around its label (filled with the accent when primary), a field or a
419// select a hairline box around its placeholder or value, a Client its props' summary.
420export function staticTree(node: unknown, t?: Tone): unknown {
421  if (Array.isArray(node)) {
422    return node.map(child => staticTree(child, t))
423  }
424
425  if (node === null || typeof node !== 'object') {
426    return typeof node === 'function' ? undefined : node
427  }
428
429  const element = node as { type?: unknown; props?: Record<string, unknown> }
430  const p = element.props ?? {}
431  const line = t?.ink.line ?? 'subtle'
432
433  if (element.type === 'Button') {
434    const label = String(p.label ?? p.key ?? '')
435    const primary = p.variant === 'primary' && p.plain !== true
436
437    return {
438      type: 'Box',
439      props: { borderStyle: 'round', borderColor: primary ? (t?.ink.accent ?? 'claude') : line, paddingX: 1, ...(primary ? { backgroundColor: t?.hex.accent ?? 'claude' } : {}) },
440      children: [{ type: 'Text', props: primary ? { color: '#ffffff', bold: true } : p.dimColor === true ? { color: t?.ink.muted ?? 'inactive' } : {}, children: [label] }],
441    }
442  }
443
444  if (element.type === 'Input' || element.type === 'Select') {
445    const options = Array.isArray(p.options) ? (p.options as { value: string; label?: string }[]) : []
446    const shown = element.type === 'Select' ? (options.find(option => option.value === p.value)?.label ?? String(p.value ?? options[0]?.label ?? '')) : String(p.value || p.placeholder || '')
447
448    return {
449      type: 'Box',
450      props: { flexDirection: 'row', columnGap: 1 },
451      children: [
452        ...(p.label ? [{ type: 'Text', props: {}, children: [String(p.label)] }] : []),
453        { type: 'Box', props: { borderStyle: 'round', borderColor: line, paddingX: 1 }, children: [{ type: 'Text', props: { color: t?.ink.muted ?? 'inactive' }, children: [element.type === 'Select' ? `${shown} ⌄` : shown] }] },
454      ],
455    }
456  }
457
458  if (element.type === 'Client') {
459    return { type: 'Text', props: { color: t?.ink.muted ?? 'inactive' }, children: [String(p.key ?? p.module ?? '')] }
460  }
461
462  return Object.fromEntries(
463    Object.entries(element)
464      .filter(([key, value]) => key !== 'press' && typeof value !== 'function')
465      .map(([key, value]) => [key, key === 'props' && value !== null && typeof value === 'object' ? Object.fromEntries(Object.entries(value).filter(([, prop]) => typeof prop !== 'function')) : staticTree(value, t)]),
466  )
467}
468
kit/icons.ts 61 lines
1// The pack's icons: outline paths on a 24-unit grid, drawn with one stroke (1.8, round caps
2// and joins), the set skins' tool rows started. An icon is a whole SVG document, for the
3// `Svg` element; the terminal draws no Svg, so callers pass a fallback there or nothing.
4//
5// Shared kit: edit in claude-mods-hub/kit, never in a copy.
6
7export const ICON_PATHS = {
8  // Tool kinds, as skins draws them.
9  run: '<path d="M5 7l5 5-5 5"/><path d="M12 18h7"/>',
10  write: '<path d="M4 20h4L18.5 9.5a2.83 2.83 0 0 0-4-4L4 16v4z"/><path d="M13.5 6.5l4 4"/>',
11  read: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"/><path d="M9 13h6M9 17h4"/>',
12  search: '<circle cx="10" cy="10" r="6.5"/><path d="M20 20l-5.2-5.2"/>',
13  web: '<circle cx="12" cy="12" r="8.5"/><path d="M3.8 9.5h16.4M3.8 14.5h16.4"/><path d="M11.5 3.5a15 15 0 0 0 0 17M12.5 3.5a15 15 0 0 1 0 17"/>',
14  mcp: '<path d="M9.8 6.2l8 8-2 2a5.66 5.66 0 0 1-8-8z"/><path d="M4 20l3.6-3.6M15 4l-3.4 3.4M20 9l-3.4 3.4"/>',
15  other: '<circle cx="12" cy="12" r="3"/>',
16
17  // Navigation and state.
18  chevronRight: '<path d="M9 5l7 7-7 7"/>',
19  chevronLeft: '<path d="M15 5l-7 7 7 7"/>',
20  chevronDown: '<path d="M5 9l7 7 7-7"/>',
21  check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
22  close: '<path d="M6 6l12 12M18 6L6 18"/>',
23  alert: '<path d="M12 4l9 16H3z"/><path d="M12 10v4.5"/><path d="M12 17.5v.01"/>',
24  info: '<circle cx="12" cy="12" r="8.5"/><path d="M12 11v5.5"/><path d="M12 7.5v.01"/>',
25  clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
26  refresh: '<path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/>',
27  eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="2.8"/>',
28  eyeOff: '<path d="M4 4l16 16"/><path d="M9.9 6A9.6 9.6 0 0 1 12 5.5c6 0 9.5 6.5 9.5 6.5a16 16 0 0 1-2.6 3.4M6.4 7.6A15.5 15.5 0 0 0 2.5 12S6 18.5 12 18.5a9.4 9.4 0 0 0 4.2-1"/>',
29  copy: '<rect x="8.5" y="8.5" width="11" height="11" rx="2"/><path d="M15.5 8.5V6.5a2 2 0 0 0-2-2h-7a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h2"/>',
30  external: '<path d="M13.5 4.5h6v6"/><path d="M19.5 4.5l-8 8"/><path d="M17.5 14v4a1.5 1.5 0 0 1-1.5 1.5H6A1.5 1.5 0 0 1 4.5 18V8A1.5 1.5 0 0 1 6 6.5h4"/>',
31  play: '<path d="M8 5.5v13l10-6.5z"/>',
32  pause: '<path d="M8.5 5.5v13M15.5 5.5v13"/>',
33  stepBack: '<path d="M16 6l-7 6 7 6"/><path d="M7 6v12"/>',
34  stepForward: '<path d="M8 6l7 6-7 6"/><path d="M17 6v12"/>',
35
36  // The mods.
37  cache: '<path d="M12 3.5c3.5 3.2 5.5 6 5.5 9a5.5 5.5 0 0 1-11 0c0-1.6.6-3 1.6-4.3.4 1.4 1.3 2.3 2.4 2.6-.4-2.8.2-5 1.5-7.3z"/>',
38  skins: '<path d="M12 3.5a8.5 8.5 0 1 0 0 17c1.2 0 1.8-.8 1.8-1.7 0-1.1-.9-1.5-.9-2.4 0-.9.7-1.6 1.6-1.6h2a4 4 0 0 0 4-4C20.5 6.8 16.7 3.5 12 3.5z"/><circle cx="7.8" cy="11" r=".9"/><circle cx="10.5" cy="7.5" r=".9"/><circle cx="15" cy="7.8" r=".9"/>',
39  folder: '<path d="M3.5 7.5a2 2 0 0 1 2-2h4l2 2.5h7a2 2 0 0 1 2 2v7.5a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z"/>',
40  file: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"/>',
41  branch: '<circle cx="7" cy="5.5" r="2"/><circle cx="7" cy="18.5" r="2"/><circle cx="17" cy="8" r="2"/><path d="M7 7.5v9"/><path d="M17 10c0 4-10 2.5-10 6.5"/>',
42  agents: '<rect x="4.5" y="8" width="15" height="11" rx="3"/><path d="M12 8V4.5"/><circle cx="12" cy="4" r=".8"/><path d="M9.5 13v1.5M14.5 13v1.5"/><path d="M2.5 12.5v2.5M21.5 12.5v2.5"/>',
43  replay: '<path d="M4.5 12a7.5 7.5 0 1 0 2.2-5.3"/><path d="M4.5 4.5v4h4"/><path d="M11 9.5v5l3.5-2.5z"/>',
44  shield: '<path d="M12 3.5l7 2.8v5.2c0 4.3-3 7.6-7 9-4-1.4-7-4.7-7-9V6.3z"/><path d="M12 8.5v4"/><path d="M12 15.5v.01"/>',
45  components: '<rect x="4" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1.5"/>',
46  settings: '<circle cx="12" cy="12" r="2.8"/><path d="M12 3.5v2.2M12 18.3v2.2M3.5 12h2.2M18.3 12h2.2M6 6l1.6 1.6M16.4 16.4L18 18M6 18l1.6-1.6M16.4 7.6L18 6"/>',
47  mods: '<path d="M4 7h16M4 12h16M4 17h10"/>',
48  reflect: '<path d="M9 18h6M10 21h4"/><path d="M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.2 1 2V16h5v-.1c0-.8.4-1.5 1-2A6 6 0 0 0 12 3z"/>',
49} as const
50
51export type IconName = keyof typeof ICON_PATHS
52
53// The whole SVG document: `size` CSS pixels, stroked in `color`.
54export function iconSvg(name: IconName, color: string, size = 16): string {
55  return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><g fill="none" stroke="${color}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${ICON_PATHS[name]}</g></svg>`
56}
57
58// The same paths placed inside a larger drawing, as skins' cards place theirs.
59export const iconAt = (name: IconName, x: number, y: number, size: number, color: string): string =>
60  `<g transform="translate(${x} ${y}) scale(${size / 24})" fill="none" stroke="${color}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${ICON_PATHS[name]}</g>`
61
kit/tone.ts 131 lines
1// The pack's colours. One skin themes every mod: skins hands its palette out through
2// `$.skins.palette()`, and a mod without skins installed falls back to Claude Code's own.
3//
4// Two forms of every colour: `hex`, for SVG markup and mixing, and `ink`, for an element's
5// `color`. On the native skin `ink` holds Claude Code's theme keys, so text follows the app
6// exactly, light or dark; on any other skin it holds the skin's hex.
7//
8// This file is the pack's shared kit: the source lives in claude-mods-hub/kit and
9// `dev/sync-kit.sh` copies it into every mod. Edit it there, never in a copy.
10
11export type KitSlot =
12  | 'read' | 'write' | 'run' | 'search' | 'web' | 'mcp' | 'other'
13  | 'user' | 'fg' | 'muted' | 'surface' | 'zebra' | 'ok' | 'err' | 'warn'
14
15export type KitPalette = Readonly<Record<KitSlot, string>>
16
17// What `$.skins.palette()` answers.
18export type KitSkin = { name: string; palette: KitPalette; isLight: boolean }
19
20export type KitTone = 'ok' | 'warn' | 'err' | 'muted' | 'accent'
21
22export type Tone = {
23  readonly name: string
24  readonly isNative: boolean
25  readonly isLight: boolean
26  // For SVG and mixing.
27  readonly hex: KitPalette & { readonly accent: string; readonly line: string; readonly hover: string; readonly faint: string; readonly selected: string }
28  // For an element's colour. `fg` is undefined on the native skin: the app's own text colour.
29  readonly ink: {
30    readonly fg: string | undefined
31    readonly muted: string
32    readonly faint: string
33    readonly accent: string
34    readonly ok: string
35    readonly warn: string
36    readonly err: string
37    readonly line: string
38    readonly hover: string
39    // The background of the selected row: a tint of the accent.
40    readonly selected: string
41  }
42}
43
44// Claude Code's own colours, as the native skin carries them.
45export const NATIVE: { readonly dark: KitPalette; readonly light: KitPalette } = {
46  dark: {
47    read: '#c9c7bd', write: '#c9c7bd', run: '#c9c7bd', search: '#c9c7bd', web: '#c9c7bd', mcp: '#c9c7bd', other: '#c9c7bd',
48    user: '#d97757', fg: '#e8e6dc', muted: '#9c9a92', surface: '#262624', zebra: '#2d2c2a',
49    ok: '#4eba65', err: '#ff6b80', warn: '#ffc107',
50  },
51  light: {
52    read: '#3d3c38', write: '#3d3c38', run: '#3d3c38', search: '#3d3c38', web: '#3d3c38', mcp: '#3d3c38', other: '#3d3c38',
53    user: '#c96442', fg: '#1f1e1d', muted: '#6f6d66', surface: '#faf9f5', zebra: '#f0eee6',
54    ok: '#2c7a39', err: '#ab2b3f', warn: '#966c1e',
55  },
56}
57
58export const NATIVE_NAME = 'claude'
59
60const channels = (hex: string): [number, number, number] => {
61  const h = /^#[0-9a-f]{6}$/i.test(hex) ? hex : '#808080'
62
63  return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]
64}
65
66// `amount` of the way from `from` to `to`.
67export function mix(from: string, to: string, amount: number): string {
68  const a = channels(from)
69  const b = channels(to)
70
71  return `#${a.map((value, i) => Math.round(value + (b[i]! - value) * amount).toString(16).padStart(2, '0')).join('')}`
72}
73
74export function toneOf(skin: KitSkin | null): Tone {
75  const isLight = skin?.isLight ?? false
76  const palette = skin?.palette ?? (isLight ? NATIVE.light : NATIVE.dark)
77  const name = skin?.name ?? NATIVE_NAME
78  const isNative = name === NATIVE_NAME
79  const line = mix(palette.surface, palette.fg, isLight ? 0.14 : 0.16)
80  const hover = mix(palette.surface, palette.fg, isLight ? 0.05 : 0.07)
81  const faint = mix(palette.muted, palette.surface, 0.35)
82  const selected = mix(palette.surface, palette.user, isLight ? 0.14 : 0.22)
83  const hex = { ...palette, accent: palette.user, line, hover, faint, selected }
84
85  return {
86    name,
87    isNative,
88    isLight,
89    hex,
90    ink: isNative
91      ? { fg: undefined, muted: 'inactive', faint: 'subtle', accent: 'claude', ok: 'success', warn: 'warning', err: 'error', line, hover, selected }
92      : { fg: palette.fg, muted: palette.muted, faint, accent: palette.user, ok: palette.ok, warn: palette.warn, err: palette.err, line, hover, selected },
93  }
94}
95
96// A tone word as an element colour; no tone is the body text.
97export function inkOf(t: Tone, tone: KitTone | undefined): string | undefined {
98  switch (tone) {
99    case 'ok':
100      return t.ink.ok
101    case 'warn':
102      return t.ink.warn
103    case 'err':
104      return t.ink.err
105    case 'muted':
106      return t.ink.muted
107    case 'accent':
108      return t.ink.accent
109    default:
110      return t.ink.fg
111  }
112}
113
114// The same, as hex for markup.
115export function hexOf(t: Tone, tone: KitTone | undefined): string {
116  switch (tone) {
117    case 'ok':
118      return t.hex.ok
119    case 'warn':
120      return t.hex.warn
121    case 'err':
122      return t.hex.err
123    case 'muted':
124      return t.hex.muted
125    case 'accent':
126      return t.hex.accent
127    default:
128      return t.hex.fg
129  }
130}
131
hooks/components.tsx 604 lines
1import type { ElementTable, RenderElement, RenderSurface } from 'claude-code'
2
3import { choice, text } from '../kit/kit'
4import type { Kit } from '../kit/kit'
5import type { ModsCatalogEntry, ModsLang } from '../types'
6import type { DemoClientProps } from './demo-client'
7
8// The elements every pane surface draws, and the ones only some do.
9export type DemoUi = Pick<ElementTable<'desktop'>, 'Box' | 'Text' | 'Button' | 'Input' | 'Select' | 'Link' | 'Code' | 'Markdown'> & {
10  Svg?: ElementTable<'desktop'>['Svg']
11  Client?: ElementTable<'desktop'>['Client']
12  Raster?: ElementTable<'terminal'>['Raster']
13  Image?: ElementTable<'terminal'>['Image']
14}
15
16export type DemoState = {
17  group: string
18  element: string
19  mod: string
20  clicks: number
21  typed: string
22  submitted: string
23  picked: string
24  links: number
25}
26
27export const DEMO_START: DemoState = { group: 'elements', element: 'box', mod: '', clicks: 0, typed: '', submitted: '', picked: 'b', links: 0 }
28
29export type DemoActions = {
30  group: (id: string) => void
31  element: (id: string) => void
32  mod: (noun: string) => void
33  click: () => void
34  type: (value: string) => void
35  submit: (value: string) => void
36  pick: (value: string) => void
37  link: () => void
38  toast: () => void
39  notify: () => void
40  ask: () => void
41  status: () => void
42  copy: () => void
43}
44
45// A mod's catalog as the hub loaded it for the open Mods section.
46export type DemoCatalog = { noun: string; name: string; entries: readonly ModsCatalogEntry[] | null; failed?: string }
47
48const GROUPS = ['elements', 'colors', 'sites', 'outside', 'mods'] as const
49const ELEMENTS = ['box', 'text', 'button', 'input', 'select', 'link', 'code', 'markdown', 'svg', 'raster', 'image', 'client'] as const
50
51// The colours a tree may name by meaning; each follows the person's theme.
52const THEME_KEYS = [
53  'text', 'inverseText', 'inactive', 'subtle', 'suggestion', 'remember', 'success', 'error', 'warning', 'merged', 'claude',
54  'permission', 'planMode', 'autoAccept', 'promptBorder', 'bashBorder', 'ide', 'diffAdded', 'diffRemoved', 'diffAddedDimmed',
55  'diffRemovedDimmed', 'diffAddedWord', 'diffRemovedWord',
56] as const
57
58const SITES = [
59  'Pane', 'AbovePrompt', 'UserMessage', 'AssistantMessage', 'ToolUse', 'ToolResult', 'ToolGroup', 'ToolProgress', 'CommandOutput',
60  'Spinner', 'TurnDuration', 'InfoNotice', 'SessionMode', 'PromptHint', 'AskUserQuestion',
61] as const
62
63type Site = (typeof SITES)[number]
64
65const BORDERS = ['single', 'double', 'round', 'bold', 'singleDouble', 'doubleSingle', 'classic', 'arrow'] as const
66const JUSTIFY = ['flex-start', 'center', 'flex-end', 'space-between', 'space-around', 'space-evenly'] as const
67const ALIGN = ['flex-start', 'center', 'flex-end', 'stretch'] as const
68const WRAPS = ['wrap', 'end', 'middle', 'truncate', 'truncate-start', 'truncate-middle', 'truncate-end'] as const
69
70type Words = {
71  intro: string
72  groups: Record<(typeof GROUPS)[number], string>
73  notHere: (surface: string, surfaces: string) => string
74  helps: Record<(typeof ELEMENTS)[number], string>
75  hoverMe: string
76  card: string
77  wrapSample: string
78  pressed: (n: number) => string
79  typed: (v: string) => string
80  submitted: (v: string) => string
81  picked: (v: string) => string
82  linkPresses: (n: number) => string
83  autoFocusNote: string
84  actionNote: string
85  pngNote: string
86  clientHint: string
87  colorsHelp: string
88  sitesHelp: string
89  sites: Record<Site, string>
90  outside: { help: string; rows: readonly [string, string][]; try: string; toast: string; notify: string; ask: string; askQuestion: string; status: string; copy: string }
91  mods: { help: string; none: string; noCatalog: (name: string) => string; failed: (e: string) => string; count: (n: number) => string }
92}
93
94const en: Words = {
95  intro: 'Everything a mod can draw or do, live on this surface and named, so you can point at any of it when asking for a change.',
96  groups: { elements: 'Elements', colors: 'Colors', sites: 'Sites', outside: 'Outside a drawing', mods: 'Your mods' },
97  notHere: (surface, surfaces) => `Not drawn on the ${surface} surface. Only on: ${surfaces}.`,
98  helps: {
99    box: 'The flex container every layout is made of',
100    text: 'A run of text and its styles',
101    button: 'A control whose press runs code in the mod',
102    input: 'A text field',
103    select: 'One of several options',
104    link: 'A hyperlink',
105    code: 'Source code with highlighting, or a diff',
106    markdown: 'Markdown drawn the way Claude Code draws a reply',
107    svg: 'A vector image from an SVG string',
108    raster: 'A box of cells, each a glyph with its own colours',
109    image: 'A picture over a box of cells',
110    client: "A region drawn by the mod's own surface module",
111  },
112  hoverMe: 'point at this',
113  card: 'a card shown on hover',
114  wrapSample: 'The quick brown fox jumps over the lazy dog, then keeps running well past the end of the box it was given.',
115  pressed: n => `pressed ${n} time${n === 1 ? '' : 's'}`,
116  typed: v => `onInput: "${v}"`,
117  submitted: v => `onSubmit: "${v}"`,
118  picked: v => `onSelect: ${v}`,
119  linkPresses: n => `onLinkPress: ${n}`,
120  autoFocusNote: 'autoFocus: true puts the keyboard on this control when the site takes the keys (not drawn here, so it does not steal focus).',
121  actionNote: 'action: "app:<binding>" presses the button with the person’s own chord for an engine keybinding.',
122  pngNote: 'source: { png } takes a whole PNG file in base64; { rgba, width, height } takes raw pixels (shown).',
123  clientHint: 'point at this line',
124  colorsHelp: 'Theme keys follow the person’s theme; any hex (#rrggbb) works too, in color, backgroundColor, borderColor.',
125  sitesHelp: 'A ui.render hook matches one of these components and draws its own tree, or passes. Pane is the one a mod opens itself.',
126  sites: {
127    Pane: 'a pane like this one: docked beside the transcript, or above the prompt',
128    AbovePrompt: 'the band above the prompt',
129    UserMessage: 'your messages, task notifications, messages from other agents',
130    AssistantMessage: 'each block of Claude’s reply',
131    ToolUse: 'a tool call’s row',
132    ToolResult: 'a tool call’s answer',
133    ToolGroup: 'a run of folded tool calls',
134    ToolProgress: 'a running tool’s progress row',
135    CommandOutput: 'a slash command’s output',
136    Spinner: 'the spinner row while Claude works',
137    TurnDuration: 'the line that closes a turn (terminal)',
138    InfoNotice: 'the dim lines under the logo (terminal)',
139    SessionMode: 'the mode labels in the prompt footer',
140    PromptHint: 'the hint line under the prompt',
141    AskUserQuestion: 'the dialog the AskUserQuestion tool opens',
142  },
143  outside: {
144    help: 'What a mod does without drawing a tree, from any hook.',
145    rows: [
146      ['$.ui.toast(text)', 'a passing notice'],
147      ['$.ui.notify(text, { title })', 'a system notification'],
148      ['$.ui.ask(question, options)', 'a question dialog; resolves to the answer'],
149      ['$.ui.status(text)', 'an entry in the status line; undefined clears it'],
150      ['$.ui.copy({ text })', 'puts text on the clipboard'],
151      ['$.ui.open / close / panes / focus / scroll', 'open, close, list, focus and scroll panes'],
152      ['$.ui.selection()', 'the text the person selected'],
153      ['$.ui.blit(...)', 'repaint a Raster in place, without a render pass'],
154      ['$.ui.notice(toolUseId, text)', 'context on a permission prompt'],
155      ['$.ui.log(text)', 'a dim line in the transcript, or the debug log'],
156      ['$.ui.invalidate(event)', 'ask for a redraw'],
157      ['$.audio.play / speak', 'play a sound file of the mod, or speak text'],
158    ],
159    try: 'Try them',
160    toast: 'toast',
161    notify: 'notify',
162    ask: 'ask',
163    askQuestion: 'Which part should change?',
164    status: 'status',
165    copy: 'copy',
166  },
167  mods: {
168    help: 'Every component your mods draw, by name and id: say "the <id> of <mod>" to ask for a change. Controls are drawn in their shape here, without their press.',
169    none: 'No installed mod has a catalog.',
170    noCatalog: name => `${name} is not installed, or has no catalog.`,
171    failed: e => `This catalog could not be drawn: ${e}`,
172    count: n => `${n} component${n === 1 ? '' : 's'}`,
173  },
174}
175
176const pt: Words = {
177  intro: 'Tudo que um mod pode desenhar ou fazer, ao vivo nesta superfície e com nome, para você apontar qualquer coisa ao pedir uma mudança.',
178  groups: { elements: 'Elementos', colors: 'Cores', sites: 'Lugares', outside: 'Fora do desenho', mods: 'Seus mods' },
179  notHere: (surface, surfaces) => `Não é desenhado na superfície ${surface}. Só em: ${surfaces}.`,
180  helps: {
181    box: 'O contêiner flex de que todo layout é feito',
182    text: 'Um trecho de texto e seus estilos',
183    button: 'Um controle cujo clique roda código no mod',
184    input: 'Um campo de texto',
185    select: 'Uma entre várias opções',
186    link: 'Um hyperlink',
187    code: 'Código com realce, ou um diff',
188    markdown: 'Markdown desenhado como o Claude Code desenha uma resposta',
189    svg: 'Uma imagem vetorial a partir de uma string SVG',
190    raster: 'Uma caixa de células, cada uma com um caractere e as próprias cores',
191    image: 'Uma imagem sobre uma caixa de células',
192    client: 'Uma região desenhada pelo módulo de superfície do próprio mod',
193  },
194  hoverMe: 'aponte aqui',
195  card: 'um card que aparece no hover',
196  wrapSample: 'A raposa marrom pula sobre o cachorro preguiçoso e continua correndo bem além do fim da caixa que recebeu.',
197  pressed: n => `apertado ${n} vez${n === 1 ? '' : 'es'}`,
198  typed: v => `onInput: "${v}"`,
199  submitted: v => `onSubmit: "${v}"`,
200  picked: v => `onSelect: ${v}`,
201  linkPresses: n => `onLinkPress: ${n}`,
202  autoFocusNote: 'autoFocus: true põe o teclado neste controle quando o lugar recebe as teclas (não desenhado aqui, para não roubar o foco).',
203  actionNote: 'action: "app:<atalho>" aperta o botão com o atalho da própria pessoa para uma ação do Claude Code.',
204  pngNote: 'source: { png } recebe um arquivo PNG inteiro em base64; { rgba, width, height } recebe pixels crus (mostrado).',
205  clientHint: 'aponte para esta linha',
206  colorsHelp: 'As chaves do tema seguem o tema da pessoa; qualquer hex (#rrggbb) também funciona, em color, backgroundColor, borderColor.',
207  sitesHelp: 'Um hook ui.render escolhe um destes componentes e desenha a própria árvore, ou passa adiante. Pane é o único que o mod abre sozinho.',
208  sites: {
209    Pane: 'um painel como este: ao lado da conversa, ou acima do prompt',
210    AbovePrompt: 'a faixa acima do prompt',
211    UserMessage: 'suas mensagens, avisos de tarefas, mensagens de outros agentes',
212    AssistantMessage: 'cada bloco da resposta do Claude',
213    ToolUse: 'a linha de uma chamada de ferramenta',
214    ToolResult: 'a resposta de uma chamada de ferramenta',
215    ToolGroup: 'um grupo de chamadas dobradas',
216    ToolProgress: 'a linha de progresso de uma ferramenta rodando',
217    CommandOutput: 'a saída de um comando de barra',
218    Spinner: 'a linha do spinner enquanto o Claude trabalha',
219    TurnDuration: 'a linha que fecha um turno (terminal)',
220    InfoNotice: 'as linhas discretas sob o logo (terminal)',
221    SessionMode: 'os rótulos de modo no rodapé do prompt',
222    PromptHint: 'a linha de dica sob o prompt',
223    AskUserQuestion: 'o diálogo que a ferramenta AskUserQuestion abre',
224  },
225  outside: {
226    help: 'O que um mod faz sem desenhar uma árvore, de qualquer hook.',
227    rows: [
228      ['$.ui.toast(texto)', 'um aviso passageiro'],
229      ['$.ui.notify(texto, { title })', 'uma notificação do sistema'],
230      ['$.ui.ask(pergunta, opções)', 'um diálogo de pergunta; devolve a resposta'],
231      ['$.ui.status(texto)', 'um item na linha de status; undefined limpa'],
232      ['$.ui.copy({ text })', 'põe texto na área de transferência'],
233      ['$.ui.open / close / panes / focus / scroll', 'abrir, fechar, listar, focar e rolar painéis'],
234      ['$.ui.selection()', 'o texto que a pessoa selecionou'],
235      ['$.ui.blit(...)', 'repintar um Raster no lugar, sem redesenhar'],
236      ['$.ui.notice(toolUseId, texto)', 'contexto num pedido de permissão'],
237      ['$.ui.log(texto)', 'uma linha discreta na conversa, ou no log de debug'],
238      ['$.ui.invalidate(evento)', 'pedir um redesenho'],
239      ['$.audio.play / speak', 'tocar um arquivo de som do mod, ou falar um texto'],
240    ],
241    try: 'Experimente',
242    toast: 'toast',
243    notify: 'notificar',
244    ask: 'perguntar',
245    askQuestion: 'Qual parte deve mudar?',
246    status: 'status',
247    copy: 'copiar',
248  },
249  mods: {
250    help: 'Cada componente que os seus mods desenham, com nome e id: diga "o <id> do <mod>" para pedir uma mudança. Aqui os controles aparecem no formato, sem a ação.',
251    none: 'Nenhum mod instalado tem catálogo.',
252    noCatalog: name => `${name} não está instalado, ou não tem catálogo.`,
253    failed: e => `Este catálogo não pôde ser desenhado: ${e}`,
254    count: n => `${n} componente${n === 1 ? '' : 's'}`,
255  },
256}
257
258export const DEMO_WORDS: Readonly<Record<ModsLang, Words>> = { en, pt }
259
260const SVG_DEMO = `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="64" viewBox="0 0 240 64">
261<rect x="1" y="1" width="238" height="62" rx="10" fill="none" stroke="#8c8c8c"/>
262<circle cx="32" cy="32" r="16" fill="none" stroke="#d97757" stroke-width="6" stroke-dasharray="70 101" transform="rotate(-90 32 32)"/>
263<text x="62" y="28" font-family="sans-serif" font-size="13" fill="#d97757">42% context</text>
264<rect x="62" y="38" width="160" height="8" rx="4" fill="#8c8c8c" opacity="0.3"/><rect x="62" y="38" width="67" height="8" rx="4" fill="#d97757"/>
265</svg>`
266
267const SVG_HOVER = `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="48" viewBox="0 0 240 48">
268<style>rect{fill:#7aa2f7;transition:fill .2s}rect:hover{fill:#d97757}</style>
269<rect x="4" y="8" width="70" height="32" rx="6"><title>hover: CSS :hover and a tooltip</title></rect>
270<rect x="84" y="8" width="70" height="32" rx="6"><title>second</title></rect>
271<rect x="164" y="8" width="70" height="32" rx="6"><title>third</title></rect>
272</svg>`
273
274const toBase64 = (bytes: Uint8Array): string => (bytes as unknown as { toBase64: () => string }).toBase64()
275
276// A two-row gradient of half blocks: each cell's foreground paints its top half, its background the bottom.
277function rasterCells(columns: number): string {
278  const words: number[] = []
279  const shade = (t: number, phase: number) => Math.round(127 + 127 * Math.sin(t * Math.PI * 2 + phase))
280
281  for (let row = 0; row < 2; row++) {
282    for (let x = 0; x < columns; x++) {
283      const t = (x + row * columns * 0.5) / columns
284      const top = (shade(t, 0) << 16) | (shade(t, 2) << 8) | shade(t, 4)
285      const bottom = (shade(t + 0.25, 0) << 16) | (shade(t + 0.25, 2) << 8) | shade(t + 0.25, 4)
286      words.push(0x2580, top, bottom)
287    }
288  }
289
290  return toBase64(new Uint8Array(Uint32Array.from(words).buffer))
291}
292
293// A small RGBA gradient, `width` by `height` pixels.
294function imagePixels(width: number, height: number): string {
295  const bytes = new Uint8Array(width * height * 4)
296
297  for (let y = 0; y < height; y++) {
298    for (let x = 0; x < width; x++) {
299      const i = (y * width + x) * 4
300      bytes[i] = Math.round((255 * x) / (width - 1))
301      bytes[i + 1] = Math.round((255 * y) / (height - 1))
302      bytes[i + 2] = 200
303      bytes[i + 3] = 255
304    }
305  }
306
307  return toBase64(bytes)
308}
309
310// The Components tab of /mods: every element with every prop, the colours, the sites, what a mod
311// does outside a drawing, and the named components of each installed mod.
312export function componentsTab(
313  ui: DemoUi,
314  k: Kit,
315  surface: RenderSurface,
316  columns: number,
317  lang: ModsLang,
318  state: DemoState,
319  actions: DemoActions,
320  mods: readonly { noun: string; name: string; slug?: string }[],
321  catalog: DemoCatalog | null,
322): RenderElement {
323  const { Box, Text, Button, Input, Select, Link, Code, Markdown } = ui
324  const w = DEMO_WORDS[lang]
325  const group = GROUPS.find(id => id === state.group) ?? 'elements'
326  const element = ELEMENTS.find(id => id === state.element) ?? 'box'
327  const inner = Math.max(24, columns - 6)
328  const chip = (label: string) => (
329    <Box key={`chip-${label}`} borderStyle="round" borderColor="subtle" paddingX={1}>
330      <Text>{label}</Text>
331    </Box>
332  )
333
334  // One prop in one value, named the way to ask for it: `Box · justifyContent: center`.
335  const demo = (name: string, sample: RenderElement) => (
336    <Box key={`demo-${name}`} flexDirection="column">
337      {text(k, name, 'muted')}
338      <Box paddingLeft={2} paddingTop={0} flexDirection="column">
339        {sample}
340      </Box>
341    </Box>
342  )
343  const note = (line: string) => (
344    <Box key={`note-${line.slice(0, 24)}`}>{text(k, line, 'muted')}</Box>
345  )
346  const picker = (prefix: string, ids: readonly string[], open: string, labels: (id: string) => string, press: (id: string) => void) => (
347    <Box key={`picker-${prefix}`}>{choice(k, prefix, open, ids.map(id => ({ value: id, label: labels(id) })), press)}</Box>
348  )
349
350  const elementBody = (): RenderElement[] => {
351    switch (element) {
352      case 'box':
353        return [
354          ...(['row', 'column', 'row-reverse', 'column-reverse'] as const).map(direction =>
355            demo(`Box · flexDirection: ${direction}`, <Box flexDirection={direction} columnGap={1}>{['1', '2', '3'].map(chip)}</Box>),
356          ),
357          ...JUSTIFY.map(justify =>
358            demo(`Box · justifyContent: ${justify}`, <Box flexDirection="row" justifyContent={justify} width={Math.min(inner, 48)} borderStyle="single" borderColor="subtle">{['a', 'b', 'c'].map(chip)}</Box>),
359          ),
360          ...ALIGN.map(align =>
361            demo(`Box · alignItems: ${align}`, (
362              <Box flexDirection="row" alignItems={align} columnGap={1} height={5} borderStyle="single" borderColor="subtle">
363                {chip('short')}
364                <Box borderStyle="round" borderColor="subtle" paddingX={1} paddingY={1}>
365                  <Text>tall</Text>
366                </Box>
367              </Box>
368            )),
369          ),
370          demo('Box · alignSelf: center', <Box flexDirection="row" height={5} borderStyle="single" borderColor="subtle"><Box alignSelf="center">{chip('self')}</Box>{chip('rest')}</Box>),
371          demo('Box · flexWrap: wrap', <Box flexDirection="row" flexWrap="wrap" columnGap={1} width={Math.min(inner, 30)}>{['one', 'two', 'three', 'four', 'five', 'six'].map(chip)}</Box>),
372          demo('Box · flexWrap: nowrap', <Box flexDirection="row" flexWrap="nowrap" columnGap={1} width={Math.min(inner, 30)} overflow="hidden">{['one', 'two', 'three', 'four', 'five', 'six'].map(chip)}</Box>),
373          demo('Box · flexGrow: 1 (middle)', <Box flexDirection="row" width={Math.min(inner, 48)}>{chip('fixed')}<Box flexGrow={1} borderStyle="round" borderColor="claude"><Text>grows</Text></Box>{chip('fixed')}</Box>),
374          demo('Box · flexShrink: 0 / 1', <Box flexDirection="row" width={Math.min(inner, 30)}><Box flexShrink={0}><Text>flexShrink 0 keeps its width</Text></Box><Box flexShrink={1}><Text wrap="truncate-end">flexShrink 1 gives way</Text></Box></Box>),
375          demo('Box · gap / columnGap / rowGap', <Box flexDirection="row" flexWrap="wrap" columnGap={4} rowGap={1} width={Math.min(inner, 40)}>{['a', 'b', 'c', 'd', 'e', 'f'].map(chip)}</Box>),
376          demo('Box · padding / paddingX / paddingY', <Box borderStyle="single" borderColor="subtle" paddingX={4} paddingY={1}><Text>paddingX 4, paddingY 1</Text></Box>),
377          demo('Box · margin / marginX / marginY / marginTop / marginLeft', <Box borderStyle="single" borderColor="subtle"><Box marginLeft={6} marginY={1}><Text>marginLeft 6, marginY 1</Text></Box></Box>),
378          demo('Box · width / height (number)', <Box width={20} height={3} borderStyle="single" borderColor="subtle"><Text>20 × 3</Text></Box>),
379          demo('Box · width: "50%"', <Box width="50%" borderStyle="single" borderColor="subtle"><Text>50%</Text></Box>),
380          demo('Box · minWidth / minHeight', <Box minWidth={24} minHeight={3} borderStyle="single" borderColor="subtle"><Text>min 24 × 3</Text></Box>),
381          ...BORDERS.map(style => demo(`Box · borderStyle: ${style}`, <Box borderStyle={style} paddingX={1}><Text>{style}</Text></Box>)),
382          demo('Box · borderColor', <Box borderStyle="round" borderColor="#d97757" paddingX={1}><Text>borderColor="#d97757"</Text></Box>),
383          demo('Box · borderDimColor', <Box borderStyle="round" borderDimColor paddingX={1}><Text>borderDimColor</Text></Box>),
384          demo('Box · backgroundColor', <Box backgroundColor="#2f3b52" paddingX={2}><Text color="#e6edf3">backgroundColor="#2f3b52"</Text></Box>),
385          demo('Box · overflow: hidden', <Box width={16} overflow="hidden" borderStyle="single" borderColor="subtle"><Text wrap="truncate">{w.wrapSample}</Text></Box>),
386          demo('Box · key + hover: { backgroundColor }', <Box key="hover-bg" paddingX={1} hover={{ backgroundColor: '#3b3d41' }}><Text hover={{ color: 'claude', bold: true }}>{`▸ ${w.hoverMe}`}</Text></Box>),
387          demo('Box · key + hover: { borderColor }', <Box key="hover-border" borderStyle="round" borderColor="subtle" paddingX={1} hover={{ borderColor: 'claude' }}><Text>{w.hoverMe}</Text></Box>),
388          demo('Box · hover: { scope } (a group lit together)', (
389            <Box flexDirection="row" columnGap={2}>
390              <Box paddingX={1} hover={{ scope: 'demo-scope', backgroundColor: '#3b3d41' }}><Text>{w.hoverMe}</Text></Box>
391              <Box paddingX={1} hover={{ scope: 'demo-scope', backgroundColor: '#3b3d41' }}><Text>…and this lights too</Text></Box>
392            </Box>
393          )),
394          demo('Box · position: absolute + top/left + display: none + hover: { display: flex }', (
395            <Box key="reveal" flexDirection="column">
396              <Text>{`▸ ${w.hoverMe}`}</Text>
397              <Box position="absolute" top={-1} left={16} display="none" hover={{ display: 'flex' }} backgroundColor="#6b7280" paddingX={1}>
398                <Text color="#ffffff">{w.card}</Text>
399              </Box>
400            </Box>
401          )),
402        ]
403      case 'text':
404        return [
405          ...(['bold', 'italic', 'underline', 'strikethrough', 'inverse', 'dimColor'] as const).map(style =>
406            demo(`Text · ${style}`, <Text {...{ [style]: true }}>{style}</Text>),
407          ),
408          demo('Text · color (hex)', <Text color="#d97757">color="#d97757"</Text>),
409          demo('Text · color (theme key)', <Text color="success">color="success"</Text>),
410          demo('Text · backgroundColor', <Text backgroundColor="#d97757" color="#ffffff">backgroundColor="#d97757"</Text>),
411          demo('Text · inline Text inside Text', <Text>plain, <Text bold color="claude">bold accent</Text>, plain again</Text>),
412          ...WRAPS.map(mode => demo(`Text · wrap: ${mode}`, <Box width={Math.min(inner, 40)}><Text wrap={mode}>{w.wrapSample}</Text></Box>)),
413          demo('Text · hover: { color, bold, underline }', <Box key="text-hover"><Text hover={{ color: 'claude', bold: true, underline: true }}>{w.hoverMe}</Text></Box>),
414        ]
415      case 'button':
416        return [
417          demo('Button · label (default chrome)', <Button key="demo-default" label="label" onPress={actions.click} />),
418          demo('Button · variant: primary', <Button key="demo-primary" label="primary" variant="primary" onPress={actions.click} />),
419          demo('Button · variant: secondary', <Button key="demo-secondary" label="secondary" variant="secondary" onPress={actions.click} />),
420          demo('Button · plain', <Button key="demo-plain" label="plain" plain onPress={actions.click} />),
421          demo('Button · plain + dimColor', <Button key="demo-dim" label="dimColor" plain dimColor onPress={actions.click} />),
422          demo('Button · hotkey: "z"', <Button key="demo-hotkey" label="hotkey z" hotkey="z" plain onPress={actions.click} />),
423          demo('Button · role: dismiss', <Button key="demo-dismiss" label="dismiss" role="dismiss" onPress={actions.click} />),
424          demo('Button · hover: { color } (inside a keyed Box)', <Box key="button-hover"><Button key="demo-hover" label="hover" plain hover={{ color: 'claude' }} onPress={actions.click} /></Box>),
425          <Text key="pressed" color="success">{w.pressed(state.clicks)}</Text>,
426          note(w.autoFocusNote),
427          note(w.actionNote),
428        ]
429      case 'input':
430        return [
431          demo('Input · label + placeholder + submitLabel + onInput + onSubmit', (
432            <Box flexDirection="column">
433              <Input key="demo-input" label="label " placeholder="placeholder" value={state.typed} submitLabel="submitLabel" onInput={actions.type} onSubmit={actions.submit} />
434              <Text color="subtle">{w.typed(state.typed)}</Text>
435              <Text color="success">{w.submitted(state.submitted)}</Text>
436            </Box>
437          )),
438          demo('Input · value (prefilled)', <Input key="demo-input-value" label="value " value="prefilled text" onSubmit={actions.submit} />),
439          note(w.autoFocusNote),
440        ]
441      case 'select':
442        return [
443          demo('Select · label + options + value + onSelect', (
444            <Box flexDirection="column">
445              <Select key="demo-select" label="label" value={state.picked} options={[{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }, { value: 'c', label: 'Gamma' }]} onSelect={actions.pick} />
446              <Text color="success">{w.picked(state.picked)}</Text>
447            </Box>
448          )),
449          demo('Select · options without labels', <Select key="demo-select-bare" options={[{ value: 'one' }, { value: 'two' }]} value="one" onSelect={actions.pick} />),
450          note(w.autoFocusNote),
451        ]
452      case 'link':
453        return [
454          demo('Link · href + label', <Link href="https://github.com/mvacoimbra/claude-mods" label="claude-mods on GitHub" />),
455          demo('Link · href alone', <Link href="https://github.com/mvacoimbra/claude-mods-hub" />),
456        ]
457      case 'code':
458        return [
459          demo('Code · source + language: ts', <Code language="ts" source={"export const limit = (n: number) => n <= 10"} />),
460          demo('Code · language: python', <Code language="python" source={'def limit(n: int) -> bool:\n    return n <= 10'} />),
461          demo('Code · language: json', <Code language="json" source={'{ "name": "mods", "version": "0.3.0" }'} />),
462          demo('Code · language: bash', <Code language="bash" source={'claude plugin install mods@claude-mods'} />),
463          demo('Code · path + startLine', <Code language="ts" path="hooks/register.tsx" startLine={42} source={'on(\'session.start\', async ($, e, next) => next(e))'} />),
464          demo('Code · format: diff', <Code format="diff" language="ts" source={'@@ -1,2 +1,2 @@\n-const limit = 10\n+const limit = 20\n export { limit }'} />),
465          demo('Code · wrap: wrap', <Box width={Math.min(inner, 40)}><Code language="ts" wrap="wrap" source={'const message = "a line long enough that it has to wrap onto the next line"'} /></Box>),
466          demo('Code · wrap: truncate-end', <Box width={Math.min(inner, 40)}><Code language="ts" wrap="truncate-end" source={'const message = "a line long enough that it has to wrap onto the next line"'} /></Box>),
467        ]
468      case 'markdown':
469        return [
470          demo('Markdown · text', <Markdown text={'### Heading\n\n- **bold**, *italic*, `code`, ~~strike~~\n- [a link](https://github.com/mvacoimbra/claude-mods)\n\n> a quote\n\n| Mod | Tab |\n|---|---|\n| cache-tax | Keep warm |\n\n```ts\nconst x = 1\n```'} />),
471          demo('Markdown · dimColor', <Markdown text={'Dimmed **markdown**, for secondary text.'} dimColor />),
472          demo('Markdown · pressableLinks + onLinkPress', (
473            <Box flexDirection="column">
474              <Markdown key="demo-markdown-links" text={'Press [this link](https://example.com/press) in the pane.'} pressableLinks={['https://example.com/press']} onLinkPress={actions.link} />
475              <Text color="success">{w.linkPresses(state.links)}</Text>
476            </Box>
477          )),
478        ]
479      case 'svg':
480        return ui.Svg === undefined
481          ? [note(w.notHere(surface, 'desktop, vscode, mobile'))]
482          : [
483              demo('Svg · source + alt + width + height', <ui.Svg source={SVG_DEMO} alt="a sample ring and bar" width={240} height={64} />),
484              demo('Svg · isInteractive (CSS :hover, <title> tooltips, SMIL)', <ui.Svg source={SVG_HOVER} alt="three boxes that light on hover" width={240} height={48} isInteractive />),
485            ]
486      case 'raster':
487        return ui.Raster === undefined
488          ? [note(w.notHere(surface, 'terminal'))]
489          : [demo('Raster · columns + rows + cells', <ui.Raster key="demo-raster" columns={Math.min(48, inner)} rows={2} cells={rasterCells(Math.min(48, inner))} />)]
490      case 'image':
491        return ui.Image === undefined
492          ? [note(w.notHere(surface, 'terminal'))]
493          : [demo('Image · source: { rgba, width, height } + columns + rows + alt', <ui.Image key="demo-image" source={{ rgba: imagePixels(64, 16), width: 64, height: 16 }} columns={16} rows={2} alt="[a gradient]" />), note(w.pngNote)]
494      case 'client':
495        return ui.Client === undefined
496          ? [note(w.notHere(surface, 'terminal, desktop'))]
497          : [demo('Client · module + props (pointer events, its own state)', <ui.Client key="demo-client" module="./demo-client.tsx" props={{ hint: w.clientHint } satisfies DemoClientProps} />)]
498    }
499  }
500
501  const body = (): RenderElement[] => {
502    switch (group) {
503      case 'elements':
504        return [
505          picker('element', ELEMENTS, element, id => id[0]!.toUpperCase() + id.slice(1), actions.element),
506          note(w.helps[element]),
507          ...elementBody(),
508        ]
509      case 'colors':
510        return [
511          note(w.colorsHelp),
512          <Box key="colors-grid" flexDirection="row" flexWrap="wrap" columnGap={2}>
513            {THEME_KEYS.map(key => (
514              <Box key={`color-${key}`} flexDirection="row" columnGap={1} width={24}>
515                <Text backgroundColor={key}>{'    '}</Text>
516                <Text color={key}>{key}</Text>
517              </Box>
518            ))}
519          </Box>,
520        ]
521      case 'sites':
522        return [
523          note(w.sitesHelp),
524          <Box key="sites-list" flexDirection="column">
525            {SITES.map(site => (
526              <Box key={`site-${site}`} flexDirection="row" columnGap={2}>
527                <Box width={18} flexShrink={0}>
528                  {text(k, site, 'accent')}
529                </Box>
530                <Box flexShrink={1}>
531                  {text(k, w.sites[site], 'muted')}
532                </Box>
533              </Box>
534            ))}
535          </Box>,
536        ]
537      case 'outside':
538        return [
539          note(w.outside.help),
540          <Box key="outside-list" flexDirection="column">
541            {w.outside.rows.map(([call, what]) => (
542              <Box key={`outside-${call}`} flexDirection={columns >= 72 ? 'row' : 'column'} columnGap={2}>
543                <Box width={columns >= 72 ? 40 : undefined} flexShrink={0}>
544                  {text(k, call, 'accent')}
545                </Box>
546                <Box flexShrink={1}>
547                  {text(k, what, 'muted')}
548                </Box>
549              </Box>
550            ))}
551          </Box>,
552          <Box key="outside-try">{text(k, w.outside.try, undefined, { bold: true })}</Box>,
553          <Box key="outside-actions" flexDirection="row" flexWrap="wrap" columnGap={2}>
554            <Button key="demo-toast" label={w.outside.toast} onPress={actions.toast} />
555            <Button key="demo-notify" label={w.outside.notify} onPress={actions.notify} />
556            <Button key="demo-ask" label={w.outside.ask} onPress={actions.ask} />
557            <Button key="demo-status" label={w.outside.status} onPress={actions.status} />
558            <Button key="demo-copy" label={w.outside.copy} onPress={actions.copy} />
559          </Box>,
560        ]
561      case 'mods': {
562        if (mods.length === 0) {
563          return [note(w.mods.none)]
564        }
565
566        const open = catalog?.noun ?? mods[0]!.noun
567
568        return [
569          note(w.mods.help),
570          picker('catalog', mods.map(mod => mod.noun), open, noun => mods.find(mod => mod.noun === noun)?.name ?? noun, actions.mod),
571          ...(catalog === null || catalog.entries === null
572            ? [note(catalog?.failed === undefined ? w.mods.noCatalog(catalog?.name ?? open) : w.mods.failed(catalog.failed))]
573            : [
574                <Box key="catalog-count" flexDirection="row" columnGap={2}>
575                  {text(k, w.mods.count(catalog.entries.length), 'muted')}
576                  {text(k, mods.find(mod => mod.noun === catalog.noun)?.slug ?? '', 'faint')}
577                </Box>,
578                ...catalog.entries.map((entry, i) => (
579                  <Box key={`catalog-${catalog.noun}-${entry.id}`} flexDirection="column">
580                    <Box flexDirection="row" columnGap={2} flexWrap="wrap">
581                      {text(k, `${i + 1}. ${entry.name}`, undefined, { bold: true })}
582                      {text(k, entry.id, 'faint')}
583                    </Box>
584                    {entry.help === undefined ? null : text(k, entry.help, 'muted')}
585                    <Box paddingLeft={2} flexDirection="column">
586                      {entry.tree as RenderElement}
587                    </Box>
588                  </Box>
589                )),
590              ]),
591        ]
592      }
593    }
594  }
595
596  return (
597    <Box flexDirection="column" rowGap={1} paddingX={1}>
598      {text(k, w.intro, 'muted')}
599      {picker('group', GROUPS, group, id => w.groups[id as (typeof GROUPS)[number]], actions.group)}
600      {body()}
601    </Box>
602  )
603}
604
hooks/i18n.ts 131 lines
1import type { ModsLang } from '../types'
2
3// Every word the hub draws itself; a mod's view brings its own words in the language asked.
4export type HubStrings = {
5  title: string
6  settings: string
7  components: string
8  componentsHelp: string
9  settingsHelp: string
10  toast: string
11  status: (time: string) => string
12  open: string
13  allMods: string
14  installed: string
15  oneMod: string
16  manyMods: (count: number) => string
17  more: string
18  on: string
19  off: string
20  turnOn: string
21  turnOff: string
22  apply: string
23  choose: string
24  general: string
25  openAtStart: string
26  openAtStartHelp: string
27  language: string
28  languageHelp: string
29  scopeTitle: string
30  scopeUser: string
31  scopeUserHelp: string
32  scopeSession: string
33  scopeSessionHelp: string
34  sessionOnly: string
35  keysTitle: string
36  noMods: string
37  noModsHint: string
38  nothingToSet: string
39  couldNotDraw: string
40  failed: (error: string) => string
41  missing: (names: string) => string
42  footer: string
43  command: string
44}
45
46const en: HubStrings = {
47  title: 'Mods',
48  settings: 'Settings',
49  components: 'Components',
50  componentsHelp: 'Every element a mod can draw, and each mod’s named parts',
51  settingsHelp: 'Language, and where settings are kept',
52  toast: 'This is a toast: $.ui.toast(text)',
53  status: time => `mods: $.ui.status set at ${time}`,
54  open: 'Open',
55  allMods: 'All mods',
56  installed: 'Installed',
57  oneMod: '1 mod',
58  manyMods: count => `${count} mods`,
59  more: 'More',
60  on: 'On',
61  off: 'Off',
62  turnOn: 'Turn on',
63  turnOff: 'Turn off',
64  apply: 'Apply',
65  choose: 'Choose…',
66  general: 'General',
67  openAtStart: 'Open at start',
68  openAtStartHelp: 'Opens this pane whenever a session starts',
69  language: 'Language',
70  languageHelp: 'For this pane and every mod view that speaks it',
71  scopeTitle: 'Where settings live',
72  scopeUser: 'Your settings',
73  scopeUserHelp: 'Saved for your user: the same in every session and project, and picked up by open sessions within seconds',
74  scopeSession: 'This session',
75  scopeSessionHelp: 'A few things only make sense for the session they run in, such as keeping its prompt cache warm. They are marked “this session only”.',
76  sessionOnly: 'this session only',
77  keysTitle: 'Keys',
78  noMods: 'No mod that joins this pane is installed.',
79  noModsHint: 'Install one from the claude-mods marketplace and it shows up here.',
80  nothingToSet: 'Nothing to set here.',
81  couldNotDraw: 'could not draw',
82  failed: error => `This mod could not draw its view: ${error}`,
83  missing: names => `Not installed: ${names}`,
84  footer: 'Esc closes · Tab and the arrows move · 1–9 open a mod · 0 goes back',
85  command: 'Open the mods pane: every installed mod in one place',
86}
87
88const pt: HubStrings = {
89  title: 'Mods',
90  settings: 'Ajustes',
91  components: 'Componentes',
92  componentsHelp: 'Cada elemento que um mod desenha, e as peças nomeadas de cada mod',
93  settingsHelp: 'Idioma, e onde os ajustes ficam guardados',
94  toast: 'Isto é um toast: $.ui.toast(texto)',
95  status: time => `mods: $.ui.status definido às ${time}`,
96  open: 'Abrir',
97  allMods: 'Todos os mods',
98  installed: 'Instalados',
99  oneMod: '1 mod',
100  manyMods: count => `${count} mods`,
101  more: 'Mais',
102  on: 'Ligado',
103  off: 'Desligado',
104  turnOn: 'Ligar',
105  turnOff: 'Desligar',
106  apply: 'Aplicar',
107  choose: 'Escolher…',
108  general: 'Geral',
109  openAtStart: 'Abrir ao iniciar',
110  openAtStartHelp: 'Abre este painel sempre que uma sessão começa',
111  language: 'Idioma',
112  languageHelp: 'Deste painel e de cada mod que o fala',
113  scopeTitle: 'Onde os ajustes ficam',
114  scopeUser: 'Seus ajustes',
115  scopeUserHelp: 'Guardados para o seu usuário: iguais em toda sessão e projeto, e as sessões abertas pegam a mudança em segundos',
116  scopeSession: 'Esta sessão',
117  scopeSessionHelp: 'Algumas coisas só fazem sentido na sessão em que rodam, como manter o cache dela aquecido. Elas vêm marcadas “só nesta sessão”.',
118  sessionOnly: 'só nesta sessão',
119  keysTitle: 'Teclas',
120  noMods: 'Nenhum mod que entra neste painel está instalado.',
121  noModsHint: 'Instale um pelo marketplace claude-mods e ele aparece aqui.',
122  nothingToSet: 'Nada para ajustar aqui.',
123  couldNotDraw: 'não desenhou',
124  failed: error => `Este mod não conseguiu desenhar a tela: ${error}`,
125  missing: names => `Não instalados: ${names}`,
126  footer: 'Esc fecha · Tab e as setas navegam · 1–9 abrem um mod · 0 volta',
127  command: 'Abre o painel de mods: todos os mods instalados num lugar só',
128}
129
130export const HUB_STRINGS: Readonly<Record<ModsLang, HubStrings>> = { en, pt }
131
hooks/demo-client.tsx 16 lines
1import type { ClientModule } from 'claude-code'
2
3export type DemoClientProps = { hint: string }
4
5// A region drawn by a surface module rather than the hooks module: it hears the pointer itself,
6// which no element does, and draws where it is.
7const DemoClient: ClientModule<DemoClientProps, { x: number; y: number; over: boolean }> = (props, surface) => {
8  const { Text } = surface.elements
9  surface.onPointer(e => surface.setState({ x: Math.round(e.x), y: Math.round(e.y), over: e.type !== 'leave' }))
10  const state = surface.state
11
12  return <Text color={state?.over ? 'claude' : 'subtle'}>{state?.over ? `pointer at column ${state.x}, row ${state.y}` : props.hint}</Text>
13}
14
15export default DemoClient
16
types/index.d.ts 58 lines
1// What a mod's noun hands the hub (PROTOCOL.md): one tab, drawn by the hub.
2
3export type ModsLang = 'en' | 'pt'
4
5export type ModsSurface = 'terminal' | 'desktop' | 'vscode' | 'mobile'
6
7export type ModsPanelArgs = { lang: ModsLang; surface: ModsSurface; columns: number }
8
9export type ModsTone = 'ok' | 'warn' | 'err' | 'muted' | 'accent'
10
11// Where a setting lives: the user's (every session and project, the default) or this session's
12// alone. The hub marks the session's.
13export type ModsScope = 'user' | 'session'
14
15export type ModsItem =
16  | { kind: 'toggle'; id: string; label: string; help?: string; value: boolean; scope?: ModsScope }
17  | { kind: 'choice'; id: string; label: string; help?: string; value: string; options: { value: string; label: string }[]; scope?: ModsScope }
18  // `label` is the button's; `title`, when given, names the row, with `help` under it.
19  | { kind: 'action'; id: string; label: string; title?: string; help?: string; primary?: boolean; scope?: ModsScope }
20  | { kind: 'input'; id: string; label: string; help?: string; placeholder?: string; submitLabel?: string; scope?: ModsScope }
21  | { kind: 'info'; label: string; value: string; tone?: ModsTone }
22  | { kind: 'text'; text: string; tone?: ModsTone }
23  | { kind: 'swatch'; id: string; label: string; color: string; selected: boolean }
24  | { kind: 'tree'; tree: unknown }
25
26export type ModsSection = { id: string; title: string; items: ModsItem[] }
27
28export type ModsPanel = {
29  title: string
30  summary?: string
31  tone?: ModsTone
32  // A progress the summary reports, drawn as a ring beside it.
33  percent?: number
34  sections: ModsSection[]
35  refreshMs?: number
36}
37
38export type ModsActArgs = { id: string; value?: string }
39
40export type ModsActResult = { message?: string }
41
42// One named component a mod draws, for the Components tab (PROTOCOL.md, the catalog).
43export type ModsCatalogEntry = { id: string; name: string; help?: string; tree: unknown }
44
45declare module 'claude-code' {
46  interface PluginState {
47    mods: {
48      // The open view: 'overview', 'components', 'settings' or a mod's noun.
49      tab: string
50      // Bumped after a press or a refresh tick, so the pane draws again from fresh panels.
51      revision: number
52      lang: ModsLang
53      // The Components tab's open section and what its demos were last given.
54      demo: { group: string; element: string; mod: string; clicks: number; typed: string; submitted: string; picked: string; links: number }
55    }
56  }
57}
58