SLOPSHOPPER

handoff

Before the context window fills, writes a handoff note and hands it once to your next fresh chat in this repo

newpanebandguardcommandprompt
★ 2v?MITupdated 2026-10-16partalemre/MODZ/mods/handoff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · handoff
│ ┃ handoff ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ⏺ Read(src/auth.ts) │ ┃ │ ⚑ LATEST NOTE just now ⎿ Read 6 lines │ ┃ │ 2025-10-09-0853.md ⏺ Update(src/auth.ts) │ ┃ │ The next fresh chat here gets it once ⎿ Added 2 lines, removed 1 line │ ┃ │ · with summary ⏺ Bash(bun test) │ ┃ │ ⎿ 3 pass, 1 fail │ ┃ │ Context ━━━━━━━━━╸────────── 49% │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Handoff · 2025-10-09 08:53 │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ Repo: /work/app │ ┃ │ Context 49% full › /handoff │ ┃ │ ⎿ handoff: handoff: saved /work/app/.claude/handoff/2025-10-09-085 │ ┃ │ Where we are │ ┃ │ OK │ ┃ │ │ ┃ │ Goal (latest prompts) │ ┃ │ • fix the failing auth test │ ┃ │ │ ┃ │ Files touched │ ┃ │ • src/auth.ts │ ┃ │ • src/audit.ts │ ┃ │ • src/cache.ts │ ┃ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ ✓ HANDOFF │ │ Handoff saved │ │ Start fresh with /clear: the next chat in this repo gets the note once. │ │ │ │ .claude/handoff/2025-10-09-0853.md │ │ │ │ [ Show note ] Got it │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ ✓ HANDOFF │ │ Handoff saved │ │ Start fresh with /clear: the next chat in this repo gets the note once. │ │ │ │ .claude/handoff/2025-10-09-0853.md │ │ │ │ [ Show note ] Got it │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩
Pane · handoff
╭──────────────────────────────────────────────────────────╮ │ │ │ ⚑ LATEST NOTE just now │ │ 2025-10-09-0853.md │ │ The next fresh chat here gets it once · with summary │ │ │ │ Context ━━━━━━━━━╸────────── 49% │ │ │ │ Handoff · 2025-10-09 08:53 │ │ │ │ Repo: /work/app │ │ Context 49% full │ │ │ │ Where we are │ │ OK │ │ │ │ Goal (latest prompts) │ │ • fix the failing auth test │ │ │ │ Files touched │ │ • src/auth.ts │ │ • src/audit.ts │ │ • src/cache.ts │ │ │ │ Git status │ │ M src/auth.ts │ │ ?? src/auth.test.ts │ │ │ │ Last error │ │ Bash: src/auth.test.ts: │ │ │ │ [ Don't hand it on ] [ Write handoff now ] Delete note │ │ │ ╰──────────────────────────────────────────────────────────╯
README

handoff

Before the context window fills, writes a handoff note and hands it once to your next fresh chat in this repo, so you can start clean without losing the thread.

Install

/plugin marketplace add partalemre/MODZ
/plugin install handoff@modz

Or in one step: /plugin install handoff --marketplace partalemre/MODZ.

What you see

  • At the threshold (80% by default): one card above the prompt, only between turns and never while a question is open: HANDOFF · Context is 82% full, a context meter, and three buttons. Write handoff writes the note. Compact instead runs the same compaction /compact does. Not now waits until the fill has grown another 10 points. Once the fill drops back under the threshold (after a compaction), the next climb asks again.
  • After writing: the card says Handoff saved, where the file is, and to start fresh with /clear. The mod never runs /clear for you. Show note opens the Handoff pane.
  • The next fresh chat in the same repo (after /clear, or a new claude in that folder) gets the note in its system prompt, once: the first conversation that has no reply yet takes it and keeps it for each of its requests; no other conversation sees it after that. The chat that wrote the note never gets it, and a note older than a day is not handed on.
  • The Handoff pane (from the MODZ hub's row, the card's Show note, or /handoff show; it never opens by itself): the latest note's preview and age, whether the next chat gets it, and Don't hand it on / Hand it to the next chat, Write handoff now and Delete note (removes the file and the kept copy).
  • The MODZ hub: a row "Handoff" with Context 42% · note ready.

What a note holds

Written to .claude/handoff/<yyyy-mm-dd-hhmm>.md in the repo (add .claude/handoff/ to .gitignore if you do not want the notes committed):

  • the last three prompts you typed (the goal), trimmed;
  • the open steps of the plan, when plan-steps runs;
  • the files Claude wrote or edited in this conversation (Write, Edit, NotebookEdit);
  • git status --short --branch, when the folder is a git repo;
  • the last failed tool call, one line;
  • with summarizing on, a "where we are / next step" summary of at most 12 lines.

Commands

CommandEffect
/handoffWrites a handoff note now and prints its path
/handoff showOpens the Handoff pane

Settings

SettingDefaultEffect
threshold80The context fill (in %, 50 to 99) at which the card asks
summarizetrueAdds the model's short summary to each note

Token cost

At most one Haiku call per handoff, and only with summarize on: it reads the newest 30 messages, cut to 12,000 characters, and writes at most 500 tokens. The call goes through your own session's client. If it fails or is off, the note is built from the session alone and costs nothing. Handing the note on adds it (at most 8,000 characters) to the next conversation's system prompt, once.

Language

The interface and the note's headings are English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.

Source 33 files
hooks/register.tsx 259 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, Register } from 'claude-code'
3
4import { compact, dismiss, measured, noteSection, remove, resetConversation, snooze, toggleInject, write, writeHub } from './actions'
5import { bandCard, withTheirs } from './band'
6import { cardOf, isBandQuiet } from './gate'
7import { isOpenFor } from './hub'
8import { reasonOf } from './io'
9import type { Io, ValueKey, Values } from './io'
10import { withHome } from './kit/home'
11import { setLang } from './kit/lang'
12import { pinned } from './kit/pin'
13import { isSame } from './kit/same'
14import { isOffStage, shownOf } from './kit/stage'
15import { resolveLight } from './kit/tokens'
16import type { DrawCtx } from './kit/ui'
17import { COMMAND, PANE, settingsOf } from './limits'
18import type { Settings } from './limits'
19import { motion, paneTree } from './pane'
20import { loadNote } from './state'
21import { errorLine, touchedPath, withFile } from './track'
22import { words } from './words'
23
24// Watches the context fill; at the threshold one card above the prompt offers a handoff note. The note goes to
25// `.claude/handoff/` and, once, into the system prompt of the next fresh conversation in the same repo.
26
27const percentAtom = atom({ plugin: 'handoff', key: 'percent' } as const, null)
28const snoozeAtom = atom({ plugin: 'handoff', key: 'snoozeUntil' } as const, null)
29const stageAtom = atom({ plugin: 'handoff', key: 'stage' } as const, 'idle')
30const failureAtom = atom({ plugin: 'handoff', key: 'failure' } as const, null)
31const latestAtom = atom({ plugin: 'handoff', key: 'latest' } as const, null)
32const filesAtom = atom({ plugin: 'handoff', key: 'files' } as const, [])
33const errorAtom = atom({ plugin: 'handoff', key: 'lastError' } as const, null)
34const noticeAtom = atom({ plugin: 'handoff', key: 'notice' } as const, null)
35const hubRef = { plugin: 'handoff', key: 'hub' } as const
36const homeRef = { plugin: 'handoff', key: 'home' } as const
37const hubOnRef = { plugin: 'modz', key: 'isOn' } as const
38const tasksRef = { plugin: 'plan-steps', key: 'tasks' } as const
39// Louder notices share the band's notice slot (DESIGN.md §6): while one shows, the handoff card waits.
40const cacheTaxNotice = { plugin: 'cache-tax', key: 'notice' } as const
41const knowNotice = { plugin: 'you-should-know', key: 'notice' } as const
42const reflectNotice = { plugin: 'reflect', key: 'notice' } as const
43const budgetNotice = { plugin: 'session-budget', key: 'notice' } as const
44
45type Cells = { [K in ValueKey]: { get: () => Promise<Values[K]>; set: (value: Values[K]) => Promise<unknown> } }
46
47// Each value of this mod, read and written through its own atom (the scan reads each by name).
48function cellsOf($: EngineInterface): Cells {
49  return {
50    percent: { get: () => read($, percentAtom), set: value => update($, percentAtom, () => value) },
51    snoozeUntil: { get: () => read($, snoozeAtom), set: value => update($, snoozeAtom, () => value) },
52    stage: { get: () => read($, stageAtom), set: value => update($, stageAtom, () => value) },
53    failure: { get: () => read($, failureAtom), set: value => update($, failureAtom, () => value) },
54    latest: { get: () => read($, latestAtom), set: value => update($, latestAtom, () => value) },
55    files: { get: () => read($, filesAtom), set: value => update($, filesAtom, () => value) },
56    lastError: { get: () => read($, errorAtom), set: value => update($, errorAtom, () => value) },
57    notice: { get: () => read($, noticeAtom), set: value => update($, noticeAtom, () => value) },
58  }
59}
60
61// Every open of the pane is a person's (the card's button, /handoff show, the hub's row). Placed, it takes a running
62// hub's one panel, so the hub and every other pane step aside (kit/stage.ts); a failed note of that is only logged.
63async function openPane($: EngineInterface): Promise<void> {
64  motion.reset()
65  const opened = await $.ui.open({ id: PANE, title: words().title, focus: true })
66  try {
67    if (!opened.isPlaced || (await $.state.get({ plugin: 'modz', key: 'isOn' })).value !== true) return
68    await $.state.set({ plugin: 'handoff', key: 'shown' }, shownOf(PANE, await $.clock.now()))
69  } catch (error) {
70    $.ui.log(`handoff: shown failed (${reasonOf(error)})`, { to: 'debug' })
71  }
72}
73
74// The engine calls the other files use, as closures over this hook's `$`.
75function ioOf($: EngineInterface): Io {
76  const cells = cellsOf($)
77
78  return {
79    get: key => cells[key].get(),
80    put: async (key, value) => {
81      if (!isSame(await cells[key].get(), value)) await cells[key].set(value)
82    },
83    hub: { get: async () => (await $.state.get(hubRef)).value, set: async card => void (await $.state.set(hubRef, card)) },
84    tasks: async () => (await $.state.get(tasksRef)).value,
85    store: { get: key => $.store.get(key), set: (key, note) => $.store.set(key, note), delete: key => $.store.delete(key) },
86    now: () => $.clock.now(),
87    root: () => $.session.root(),
88    sessionId: () => $.session.id(),
89    messages: () => $.session.messages(),
90    complete: request => $.model.complete(request),
91    run: (argv, options) => $.process.run(argv, options),
92    writeFile: (path, text) => $.fs.write(path, text),
93    compact: () => $.session.compact(),
94    openPane: () => openPane($),
95    log: text => $.ui.log(`handoff: ${text}`, { to: 'debug' }),
96  }
97}
98
99// Light or dark for Box/Text hex: MODZ_THEME wins, then Claude's own theme setting.
100async function isLightNow($: EngineInterface): Promise<boolean> {
101  const override = await $.env.get('MODZ_THEME').catch(() => undefined)
102  const rows = await $.config.list().catch(() => [])
103
104  return resolveLight({ override, theme: rows.find(row => row.key === 'theme')?.value })
105}
106
107function hookSession(on: On, settings: Settings): void {
108  on('session.start', async ($, e, next) => {
109    setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
110    const io = ioOf($)
111    try {
112      await $.command.register({ name: COMMAND, description: words().commandHelp, argumentHint: '[show]' })
113      await loadNote(io, await $.session.root())
114      await writeHub(io, settings)
115    } catch (error) {
116      io.log(`could not start (${reasonOf(error)})`)
117    }
118
119    return next(e)
120  })
121
122  on('session.end', async ($, e, next) => {
123    const io = ioOf($)
124    if (e.reason === 'clear') await resetConversation(io, settings).catch(error => io.log(`reset skipped (${reasonOf(error)})`))
125
126    return next(e)
127  })
128
129  on('session.measure', async ($, e, next) => {
130    const done = await next(e)
131    if (e.changed.includes('context')) await measured(ioOf($), settings, e.context.percent === undefined ? null : Math.round(e.context.percent))
132
133    return done
134  }).catch(($, e, next) => next(e))
135}
136
137function hookTools(on: On): void {
138  on('tool.call', async ($, e, next) => {
139    const result = await next(e)
140    if (result.deny !== undefined) return result
141    const tool = String(e.tool)
142    const path = touchedPath(tool, e)
143    if (result.isError === true) await ioOf($).put('lastError', errorLine(tool, result.text))
144    else if (path !== null) await update($, filesAtom, files => withFile(files, path))
145
146    return result
147  }).catch(($, e, next) => next(e))
148}
149
150async function runCommand(io: Io, settings: Settings, arg: string): Promise<string> {
151  if (arg === 'show') {
152    await io.openPane()
153
154    return words().commandShown
155  }
156  try {
157    const path = await write(io, settings)
158
159    return path === null ? words().writingHeadline : words().commandSaved(path)
160  } catch (error) {
161    return words().commandFailed(reasonOf(error))
162  }
163}
164
165function hookCommands(on: On, settings: Settings): void {
166  on('command.run', { command: COMMAND }, async ($, e) => ({ text: await runCommand(ioOf($), settings, e.args.trim().toLowerCase()) }))
167
168  // The main panel's open request opens this pane, or brings it forward; it never closes it.
169  on('state.set', { plugin: 'modz', key: 'open' }, async ($, e, next) => {
170    const done = await next(e)
171    if (isOpenFor(e.value, PANE)) await ioOf($).openPane()
172
173    return done
174  }).catch(($, e, next) => next(e))
175
176  // Another pane took the hub's one panel: this one steps aside rather than wait as a tab.
177  on('state.set', { plugin: 'modz', key: 'stage' }, async ($, e, next) => {
178    const done = await next(e)
179    if (isOffStage(e.value, PANE)) await $.ui.close({ id: PANE })
180
181    return done
182  }).catch(($, e, next) => next(e))
183
184  on('prompt.compose', async ($, e, next) => {
185    const composed = await next(e)
186    const section = await noteSection(ioOf($), settings)
187
188    return section === null ? composed : { sections: [...composed.sections, section] }
189  }).catch(($, e, next) => next(e))
190}
191
192async function hasLouderNotice($: EngineInterface): Promise<boolean> {
193  const others: { value?: unknown }[] = [
194    await $.state.get(cacheTaxNotice as never),
195    await $.state.get(knowNotice as never),
196    await $.state.get(reflectNotice as never),
197    await $.state.get(budgetNotice as never),
198  ]
199
200  return others.some(other => other.value !== undefined && other.value !== null)
201}
202
203// A press runs its action in the background; a failure only goes to the debug log.
204const pressed = (io: Io, run: () => Promise<unknown>) => () => void run().catch(error => io.log(`press failed (${reasonOf(error)})`))
205
206function hookBand(on: On, settings: Settings): void {
207  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
208    const theirs = await next(e)
209    if (isBandQuiet({ isWorking: e.props.isWorking, hasSurvey: e.props.hasSurvey })) return theirs
210    if (await hasLouderNotice($)) return theirs
211    const io = ioOf($)
212    const percent = await io.get('percent')
213    const kind = cardOf({ percent, threshold: settings.threshold, snoozeUntil: await io.get('snoozeUntil'), stage: await io.get('stage') })
214    if (kind === null) return theirs
215    const draw: DrawCtx = { ui: $.ui.resolve(e), surface: e.surface, isLight: await isLightNow($) }
216    const card = bandCard(draw, {
217      kind, percent, threshold: settings.threshold, note: await io.get('latest'), failure: await io.get('failure'), columns: e.props.bodyColumns,
218      actions: {
219        onWrite: pressed(io, () => write(io, settings)),
220        onCompact: pressed(io, () => compact(io, settings)),
221        onSnooze: pressed(io, () => snooze(io, settings)),
222        onShow: pressed(io, () => io.openPane()),
223        onDismiss: pressed(io, () => dismiss(io, settings)),
224      },
225    })
226
227    return pinned(e, withTheirs(draw, card, theirs))
228  })
229}
230
231function hookPane(on: On, settings: Settings): void {
232  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
233    const io = ioOf($)
234    const draw: DrawCtx = { ui: $.ui.resolve(e), surface: e.surface, isLight: await isLightNow($), motion }
235    const isHubOn = (await $.state.get(hubOnRef)).value === true
236    const goHome = pressed(io, async () => $.state.set(homeRef, { at: await $.clock.now() }))
237    const tree = paneTree(draw, {
238      note: await io.get('latest'), now: await io.now(), percent: await io.get('percent'), threshold: settings.threshold,
239      stage: await io.get('stage'), columns: e.props.bodyColumns,
240      actions: {
241        onWrite: pressed(io, () => write(io, settings)),
242        onToggle: pressed(io, () => toggleInject(io, settings)),
243        onDelete: pressed(io, () => remove(io, settings)),
244      },
245    })
246
247    return pinned(e, withHome(draw.ui, tree, isHubOn ? goHome : null))
248  })
249}
250
251export const register: Register = (on, options) => {
252  const settings = settingsOf(options)
253  hookSession(on, settings)
254  hookTools(on)
255  hookCommands(on, settings)
256  hookBand(on, settings)
257  hookPane(on, settings)
258}
259
hooks/actions.ts 132 lines
1import type { PromptComposeSection } from 'claude-code'
2
3import { isAskDue, rearmed, snoozeFrom } from './gate'
4import { hubCard, hubOf } from './hub'
5import { injectMove, sectionText } from './inject'
6import { reasonOf } from './io'
7import type { Io } from './io'
8import { NOTICE_FOREVER, NOTICE_PRIORITY, SECTION_ID } from './limits'
9import type { Settings } from './limits'
10import { forgetNote, loadNote, saveNote } from './state'
11import { words } from './words'
12import { deleteNote, writeHandoff } from './write'
13
14// What a press, the command, a measurement and the prompt do. Each runs from a handler or an event, never
15// while drawing.
16
17// The MODZ main panel's card, written only when what it shows changes (DESIGN.md §9).
18export async function writeHub(io: Io, settings: Settings): Promise<void> {
19  const shown = hubOf({
20    percent: await io.get('percent'), threshold: settings.threshold, note: await io.get('latest'), stage: await io.get('stage'), now: await io.now(),
21  })
22  const before = await io.hub.get()
23  const isUnchanged = before !== undefined && before.detail === shown.detail && before.tone === shown.tone && before.title === words().title
24  if (!isUnchanged) await io.hub.set(hubCard(shown, await io.now()))
25}
26
27// The shared notice line holds this mod's place while the ask is due, and lets go once it is answered.
28async function syncNotice(io: Io, settings: Settings): Promise<void> {
29  const isDue = isAskDue({ percent: await io.get('percent'), threshold: settings.threshold, snoozeUntil: await io.get('snoozeUntil'), stage: await io.get('stage') })
30  await io.put('notice', isDue ? { priority: NOTICE_PRIORITY, expiresAt: NOTICE_FOREVER } : null)
31}
32
33export async function measured(io: Io, settings: Settings, percent: number | null): Promise<void> {
34  await io.put('percent', percent)
35  const next = rearmed({ percent, threshold: settings.threshold, snoozeUntil: await io.get('snoozeUntil'), stage: await io.get('stage') })
36  await io.put('snoozeUntil', next.snoozeUntil)
37  await io.put('stage', next.stage)
38  await syncNotice(io, settings)
39  await writeHub(io, settings)
40}
41
42// A /clear starts a new conversation in the same process: what the last one tracked starts over.
43export async function resetConversation(io: Io, settings: Settings): Promise<void> {
44  await io.put('percent', null)
45  await io.put('snoozeUntil', null)
46  await io.put('stage', 'idle')
47  await io.put('failure', null)
48  await io.put('files', [])
49  await io.put('lastError', null)
50  await io.put('notice', null)
51  await writeHub(io, settings)
52}
53
54// Writes the note; a second press while one is being written does nothing. Resolves the note's path, or null.
55export async function write(io: Io, settings: Settings): Promise<string | null> {
56  if ((await io.get('stage')) === 'writing') return null
57  await io.put('stage', 'writing')
58  await syncNotice(io, settings)
59  await writeHub(io, settings)
60  try {
61    const note = await writeHandoff(io, settings)
62    await io.put('failure', null)
63    await io.put('stage', 'saved')
64
65    return note.path
66  } catch (error) {
67    io.log(`note not written (${reasonOf(error)})`)
68    await io.put('failure', reasonOf(error))
69    await io.put('stage', 'failed')
70    throw new Error(reasonOf(error), { cause: error })
71  } finally {
72    await writeHub(io, settings)
73  }
74}
75
76export async function snooze(io: Io, settings: Settings): Promise<void> {
77  await io.put('snoozeUntil', snoozeFrom((await io.get('percent')) ?? settings.threshold))
78  await io.put('stage', 'idle')
79  await syncNotice(io, settings)
80}
81
82// Compaction runs between turns only, and the band offers it only then. The ask is done for this climb.
83export async function compact(io: Io, settings: Settings): Promise<void> {
84  await dismiss(io, settings)
85  try {
86    await io.compact()
87  } catch (error) {
88    io.log(`compaction refused (${reasonOf(error)})`)
89  }
90}
91
92export async function dismiss(io: Io, settings: Settings): Promise<void> {
93  await io.put('stage', 'done')
94  await syncNotice(io, settings)
95}
96
97export async function toggleInject(io: Io, settings: Settings): Promise<void> {
98  const note = await io.get('latest')
99  if (note === null) return
100  await saveNote(io, { ...note, isInjectOn: !note.isInjectOn })
101  await writeHub(io, settings)
102}
103
104export async function remove(io: Io, settings: Settings): Promise<void> {
105  const note = await io.get('latest')
106  if (note === null) return
107  try {
108    await deleteNote(io, note)
109  } catch (error) {
110    io.log(`note file kept (${reasonOf(error)})`)
111  }
112  await forgetNote(io, note.root)
113  await writeHub(io, settings)
114}
115
116// The note's section for this conversation, or null: a fresh one claims it once, then keeps it for every request,
117// so its system prompt stays the same from the first request on.
118export async function noteSection(io: Io, settings: Settings): Promise<PromptComposeSection | null> {
119  const note = await loadNote(io, await io.root())
120  const sessionId = await io.sessionId()
121  const move = injectMove({ note, sessionId, now: await io.now() })
122  if (note === null || move === 'skip') return null
123  if (move === 'claim') {
124    const isFresh = (await io.messages()).every(message => message.role !== 'assistant')
125    if (!isFresh) return null
126    await saveNote(io, { ...note, consumedBy: sessionId })
127    await writeHub(io, settings)
128  }
129
130  return { id: SECTION_ID, text: sectionText(note), scope: 'session' }
131}
132
hooks/band.tsx 106 lines
1import type { RenderElement } from 'claude-code'
2
3import { ButtonRow, Card, Meter, cardInnerWidth } from './kit/ui'
4import type { ButtonSpec, DrawCtx } from './kit/ui'
5import type { CardKind } from './gate'
6import { relativeTo } from './note'
7import { words } from './words'
8import type { HandoffNote } from '../types'
9
10// The one card this mod puts above the prompt (DESIGN.md §6, slot 2, a notification): the ask at the threshold,
11// then what became of it. It sits above `theirs`, which it never swallows.
12
13const PERCENT = 100
14
15export type BandActions = {
16  onWrite: () => void
17  onCompact: () => void
18  onSnooze: () => void
19  onShow: () => void
20  onDismiss: () => void
21}
22
23export type BandView = {
24  kind: Exclude<CardKind, null>
25  percent: number | null
26  threshold: number
27  note: HandoffNote | null
28  failure: string | null
29  columns: number
30  actions: BandActions
31}
32
33function askCard(draw: DrawCtx, view: BandView, width: number): RenderElement {
34  const says = words()
35  const percent = view.percent ?? view.threshold
36  const buttons: ButtonSpec[] = [
37    { key: 'handoff-write', label: says.write, variant: 'primary', onPress: view.actions.onWrite },
38    { key: 'handoff-compact', label: says.compact, onPress: view.actions.onCompact },
39    { key: 'handoff-snooze', label: says.notNow, variant: 'plain', onPress: view.actions.onSnooze },
40  ]
41
42  return Card(draw, {
43    role: 'warn',
44    header: { icon: 'flag', eyebrow: says.eyebrow, meta: says.askMeta(view.threshold), headline: says.askHeadline(percent), sub: says.askSub, width },
45    children: [
46      Meter(draw, { label: says.context, value: percent / PERCENT, width }),
47      ButtonRow(draw, { buttons }),
48    ],
49  })
50}
51
52function savedCard(draw: DrawCtx, view: BandView, width: number): RenderElement {
53  const says = words()
54  const { Text } = draw.ui
55  const path = view.note === null ? '' : relativeTo(view.note.root, view.note.path)
56  const buttons: ButtonSpec[] = [
57    { key: 'handoff-show', label: says.showNote, variant: 'primary', onPress: view.actions.onShow },
58    { key: 'handoff-dismiss', label: says.gotIt, variant: 'plain', onPress: view.actions.onDismiss },
59  ]
60
61  return Card(draw, {
62    role: 'ok',
63    header: { icon: 'check', eyebrow: says.eyebrow, headline: says.savedHeadline, sub: says.savedSub, width },
64    children: [<Text key="handoff-path" dimColor wrap="truncate-middle">{path}</Text>, ButtonRow(draw, { buttons })],
65  })
66}
67
68function failedCard(draw: DrawCtx, view: BandView, width: number): RenderElement {
69  const says = words()
70  const buttons: ButtonSpec[] = [
71    { key: 'handoff-retry', label: says.tryAgain, variant: 'primary', onPress: view.actions.onWrite },
72    { key: 'handoff-snooze', label: says.notNow, variant: 'plain', onPress: view.actions.onSnooze },
73  ]
74
75  return Card(draw, {
76    role: 'danger',
77    header: { icon: 'x', eyebrow: says.failedEyebrow, headline: says.failedHeadline, sub: view.failure ?? undefined, width },
78    children: [ButtonRow(draw, { buttons })],
79  })
80}
81
82function writingCard(draw: DrawCtx, width: number): RenderElement {
83  return Card(draw, { role: 'run', header: { icon: 'pencil', eyebrow: words().eyebrow, headline: words().writingHeadline, width } })
84}
85
86export function bandCard(draw: DrawCtx, view: BandView): RenderElement {
87  const width = cardInnerWidth(view.columns)
88  if (view.kind === 'ask') return askCard(draw, view, width)
89  if (view.kind === 'saved') return savedCard(draw, view, width)
90  if (view.kind === 'failed') return failedCard(draw, view, width)
91
92  return writingCard(draw, width)
93}
94
95// The card above whatever the other mods drew.
96export function withTheirs(draw: DrawCtx, card: RenderElement, theirs: RenderElement | null): RenderElement {
97  const { Box } = draw.ui
98
99  return (
100    <Box flexDirection="column">
101      {card}
102      {theirs}
103    </Box>
104  )
105}
106
hooks/gate.ts 48 lines
1import { SNOOZE_POINTS } from './limits'
2import type { HandoffStage } from '../types'
3
4// When the band asks, and what "Not now" waits for. Pure, so the rules are tested alone.
5
6export type AskFacts = {
7  percent: number | null
8  threshold: number
9  snoozeUntil: number | null
10  stage: HandoffStage
11}
12
13// The fill has reached the threshold, no snooze holds it back, and nothing else is on the card.
14export function isAskDue(facts: AskFacts): boolean {
15  if (facts.percent === null || facts.stage !== 'idle') return false
16  if (facts.percent < facts.threshold) return false
17
18  return facts.snoozeUntil === null || facts.percent >= facts.snoozeUntil
19}
20
21// "Not now" at 82 % waits for 92 %.
22export const snoozeFrom = (percent: number): number => percent + SNOOZE_POINTS
23
24export type Rearm = { snoozeUntil: number | null; stage: HandoffStage }
25
26// Once the fill falls back under the threshold (a compaction), the snooze and a seen handoff are let go,
27// so the next climb asks again.
28export function rearmed(facts: AskFacts): Rearm {
29  const isBelow = facts.percent !== null && facts.percent < facts.threshold
30  if (!isBelow) return { snoozeUntil: facts.snoozeUntil, stage: facts.stage }
31
32  return { snoozeUntil: null, stage: facts.stage === 'done' ? 'idle' : facts.stage }
33}
34
35export type BandFacts = { isWorking: boolean; hasSurvey: boolean }
36
37// The band shows nothing of this mod while a turn runs or a survey is open (DESIGN.md §6).
38export const isBandQuiet = (facts: BandFacts): boolean => facts.isWorking || facts.hasSurvey
39
40// What the card draws now: the ask, a stage of its own, or nothing.
41export type CardKind = 'ask' | 'writing' | 'saved' | 'failed' | null
42
43export function cardOf(facts: AskFacts): CardKind {
44  if (facts.stage === 'writing' || facts.stage === 'saved' || facts.stage === 'failed') return facts.stage
45
46  return isAskDue(facts) ? 'ask' : null
47}
48
hooks/hub.ts 39 lines
1import { isReady } from './inject'
2import { HUB_DETAIL_MAX, PANE } from './limits'
3import { words } from './words'
4import type { HandoffNote, HandoffStage, HubCard, HubTone } from '../types'
5
6// The MODZ main panel's side of this mod (DESIGN.md §9): its one line ("Context 42% · note ready") and its own
7// row's title, hint, icon and section, since the hub's catalog does not know this mod yet.
8
9export const HUB_ICON = 'flag'
10export const HUB_SECTION = 'meter'
11
12export type HubFacts = { percent: number | null; threshold: number; note: HandoffNote | null; stage: HandoffStage; now: number }
13
14export type HubShown = { detail: string; tone: HubTone }
15
16function toneOf(facts: HubFacts): HubTone {
17  if (facts.stage === 'writing') return 'live'
18  if (facts.stage === 'failed') return 'bad'
19  if (facts.percent !== null && facts.percent >= facts.threshold) return 'warn'
20
21  return isReady(facts.note, facts.now) ? 'ok' : 'idle'
22}
23
24export function hubOf(facts: HubFacts): HubShown {
25  const says = words()
26  const parts = [facts.percent === null ? '' : says.hubContext(facts.percent), isReady(facts.note, facts.now) ? says.hubReady : '']
27  const detail = parts.filter(part => part !== '').join(' · ').slice(0, HUB_DETAIL_MAX)
28
29  return { detail, tone: toneOf(facts) }
30}
31
32export const hubCard = (shown: HubShown, at: number): HubCard => ({
33  pane: PANE, ...shown, at, title: words().title, hint: words().hint, icon: HUB_ICON, section: HUB_SECTION,
34})
35
36export function isOpenFor(value: unknown, pane: string): boolean {
37  return typeof value === 'object' && value !== null && (value as { pane?: unknown }).pane === pane
38}
39
hooks/io.ts 33 lines
1import type { ModelCompleteRequest, ModelCompleteResult, PluginState, SessionMessage } from 'claude-code'
2
3import type { HandoffNote, HubCard } from '../types'
4
5// The engine calls the rest of the mod needs, as closures: only register.tsx holds `$`.
6
7export type Values = Omit<PluginState['handoff'], 'hub' | 'home'>
8export type ValueKey = keyof Values
9
10export type RunOut = { exitCode: number; stdout: string }
11
12export type Io = {
13  get: <K extends ValueKey>(key: K) => Promise<Values[K]>
14  // Writes only a value that changed (kit/same.ts).
15  put: <K extends ValueKey>(key: K, value: Values[K]) => Promise<void>
16  hub: { get: () => Promise<HubCard | undefined>; set: (card: HubCard) => Promise<void> }
17  // plan-steps' task list, unchecked: another mod's value.
18  tasks: () => Promise<unknown>
19  store: { get: (key: string) => Promise<unknown>; set: (key: string, note: HandoffNote) => Promise<void>; delete: (key: string) => Promise<void> }
20  now: () => Promise<number>
21  root: () => Promise<string>
22  sessionId: () => Promise<string>
23  messages: () => Promise<readonly SessionMessage[]>
24  complete: (request: ModelCompleteRequest) => Promise<ModelCompleteResult>
25  run: (argv: string[], options: { cwd?: string; timeoutMs: number }) => Promise<RunOut>
26  writeFile: (path: string, text: string) => Promise<void>
27  compact: () => Promise<unknown>
28  openPane: () => Promise<unknown>
29  log: (text: string) => void
30}
31
32export const reasonOf = (error: unknown): string => (error instanceof Error ? error.message : 'unknown')
33
hooks/kit/home.tsx 33 lines
1// MODZ kit — the way back to the hub from a mod's pane (DESIGN.md §9). While the hub runs it keeps
2// `{ plugin: 'modz', key: 'isOn' }` true; a pane then draws `HomeButton` first, and its press writes the mod's
3// own `{ plugin: '<mod>', key: 'home' }` value (`{ at }`). The hub hears it, comes forward, and opens again if
4// it was closed, so the person always has a way back, even with one pane left and no tab to click.
5
6// The resolved element table of the surface that draws the pane.
7type Table = Record<string, any>
8
9export const HOME_LABEL = '← MODZ'
10
11export function HomeButton(ui: Table, onPress: () => void) {
12  const { Box, Button } = ui
13
14  return (
15    <Box key="modz-home-row" flexDirection="row">
16      <Button key="modz-home" label={HOME_LABEL} variant="secondary" onPress={onPress} />
17    </Box>
18  )
19}
20
21// The pane's tree with the way back above it; null while the hub is off draws the pane as it is.
22export function withHome<T>(ui: Table, tree: T, onHome: (() => void) | null) {
23  if (onHome === null) return tree
24  const { Box } = ui
25
26  return (
27    <Box flexDirection="column" rowGap={1}>
28      {HomeButton(ui, onHome)}
29      {tree}
30    </Box>
31  )
32}
33
hooks/kit/lang.ts 24 lines
1// MODZ kit — the interface language: English, or Turkish when the person sets MODZ_LANG=tr (the "env" of
2// ~/.claude/settings.json). One value per mod: its register file reads the variable at session.start with
3// `setLang(await $.env.get('MODZ_LANG'))` (the engine only admits an env read whose name is a literal), and every
4// drawing reads it back through `pick` and `upper`.
5export type Lang = 'en' | 'tr'
6
7let current: Lang = 'en'
8
9export const langOf = (value: unknown): Lang => (typeof value === 'string' && /^tr/i.test(value.trim()) ? 'tr' : 'en')
10
11export function setLang(value: unknown): Lang {
12  current = langOf(value)
13
14  return current
15}
16
17export const lang = (): Lang => current
18
19// The current language's entry of a table that fills in both: `pick({ en: 'Open', tr: 'Aç' })`.
20export const pick = <T>(table: Readonly<Record<Lang, T>>): T => table[current]
21
22// Capitals for eyebrows and labels: Turkish keeps its dotted İ, English never gets one.
23export const upper = (text: string): string => text.toLocaleUpperCase(current)
24
hooks/kit/pin.ts 48 lines
1// MODZ kit — pinned press handles. Every element the JSX runtime builds gets a fresh handle, and the
2// host releases a drawing's old handles the moment it redraws. A desktop holds the tree it last
3// painted, so a click that lands just after any redraw (a state write, a focus flip, a poll) names a
4// released handle and the engine drops it ("ui_press not handled"). The host keeps a handle alive
5// across redraws when the new tree carries the same one, so each element here keeps the first handle
6// its site and key ever got: every tree a surface still holds stays pressable, with the latest closure.
7import type { RenderElement } from 'claude-code'
8
9export type PinSite = { surface: string; component?: string; requestId?: string }
10
11type Node = { type?: string; props?: { key?: unknown }; press?: { plugin: string; handle: number }; children?: unknown[] }
12
13// Far above the runtime's own counter, which starts at 1 and climbs by one per element drawn.
14const FIRST_PINNED = 1_000_000_000
15const pins = new Map<string, number>()
16
17function handleFor(id: string): number {
18  let handle = pins.get(id)
19  if (handle === undefined) {
20    handle = FIRST_PINNED + pins.size
21    pins.set(id, handle)
22  }
23
24  return handle
25}
26
27function pinNode(node: unknown, ctx: { site: string; seen: Map<string, number> }): unknown {
28  if (typeof node !== 'object' || node === null) return node
29  const el = node as Node
30  const children = el.children?.map(child => pinNode(child, ctx))
31  const withKids = children === undefined ? el : { ...el, children }
32  // Only this plugin's own elements: the runtime names the plugin of every tree `next` returned.
33  if (el.press?.plugin !== '' || typeof el.props?.key !== 'string') return withKids
34  const base = `${ctx.site}\u0000${el.type}\u0000${el.props.key}`
35  const nth = ctx.seen.get(base) ?? 0
36  ctx.seen.set(base, nth + 1)
37
38  return { ...withKids, press: { plugin: '', handle: handleFor(`${base}\u0000${nth}`) } }
39}
40
41// Return a render hook's tree through this: `return pinned(e, tree)`.
42export function pinned<T extends RenderElement | null | undefined>(site: PinSite, tree: T): T {
43  if (tree === null || tree === undefined) return tree
44  const ctx = { site: `${site.surface}\u0000${site.component ?? ''}\u0000${site.requestId ?? ''}`, seen: new Map<string, number>() }
45
46  return pinNode(tree, ctx) as T
47}
48
hooks/kit/same.ts 15 lines
1// MODZ kit — structural equality for plain state values (JSON-like: objects, arrays, primitives).
2// A `$.state` write redraws every reader, and every redraw hands out new press handles, so a
3// value that did not change is not written again.
4export function isSame(a: unknown, b: unknown): boolean {
5  if (Object.is(a, b)) return true
6  if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
7  if (Array.isArray(a) !== Array.isArray(b)) return false
8  const left = a as Record<string, unknown>
9  const right = b as Record<string, unknown>
10  const keys = Object.keys(left)
11  if (keys.length !== Object.keys(right).length) return false
12
13  return keys.every(key => Object.prototype.hasOwnProperty.call(right, key) && isSame(left[key], right[key]))
14}
15
hooks/kit/stage.ts 30 lines
1// MODZ kit — one panel (DESIGN.md §9). The hub and every mod's pane take turns in one place instead of
2// piling up as tabs: a mod that opens its pane writes its own `{ plugin: '<mod>', key: 'shown' }` (`{ pane, at }`);
3// the hub then steps aside and writes `{ plugin: 'modz', key: 'stage' }` naming that pane, and every other mod
4// whose pane is open closes it. The hub coming back (its row, "← MODZ", /modz) writes the stage as `modz`.
5
6export const HUB_PANE = 'modz'
7
8export type Stage = { pane: string; at: number }
9
10// The pane a `modz.open`, `modz.stage` or `shown` value names; null for anything malformed.
11export const paneOf = (value: unknown): string | null => {
12  if (typeof value !== 'object' || value === null) return null
13  const pane = (value as { pane?: unknown }).pane
14
15  return typeof pane === 'string' && pane !== '' ? pane : null
16}
17
18// Whether a `modz.open` write asks for this pane.
19export const isAskedFor = (value: unknown, pane: string): boolean => paneOf(value) === pane
20
21// Whether a `modz.stage` write puts another pane in the panel, so this one closes.
22export function isOffStage(value: unknown, pane: string): boolean {
23  const staged = paneOf(value)
24
25  return staged !== null && staged !== pane
26}
27
28// What a mod writes under its own `shown` key once its pane is open.
29export const shownOf = (pane: string, at: number): Stage => ({ pane, at })
30
hooks/kit/tokens.ts 263 lines
1// MODZ kit (Tidemark) — source of truth is kit/ in the MODZ repo; bin/kit-sync copies it
2// into each mod's hooks/kit/. Edit there, never in a copy. Pure: no `$`, no state.
3
4// ---------------------------------------------------------------------------
5// Palettes: DESIGN.md §2, both themes, exactly.
6
7export const ROLES = ['read', 'edit', 'run', 'ok', 'warn', 'danger', 'plan'] as const
8export type Role = (typeof ROLES)[number]
9export type RoleColors = { fg: string; tint: string; line: string }
10
11export type Neutrals = {
12  sheet: string
13  card: string
14  inset: string
15  line: string
16  lineStrong: string
17  track: string
18  hi: string
19  mid: string
20  lo: string
21}
22
23export type Palette = Neutrals & { roles: Record<Role, RoleColors> }
24
25const role = (fg: string, tint: string, line: string): RoleColors => ({ fg, tint, line })
26
27export const DARK: Palette = {
28  sheet: '#191917',
29  card: '#1F1F1C',
30  inset: '#171715',
31  line: '#2E2E2A',
32  lineStrong: '#45443F',
33  track: '#34332F',
34  hi: '#F0EEE8',
35  mid: '#C2BEB4',
36  lo: '#8E8A80',
37  roles: {
38    read: role('#7FB6E8', '#1E2B37', '#2F4A63'),
39    edit: role('#E58A5F', '#38251D', '#6A3E2D'),
40    run: role('#B19CF3', '#29243D', '#4A3F75'),
41    ok: role('#6FC593', '#1B2F25', '#2F5A42'),
42    warn: role('#E9B949', '#332D19', '#66552A'),
43    danger: role('#F2727F', '#3B2023', '#74343C'),
44    plan: role('#E07BC0', '#351F2E', '#6B3A5C'),
45  },
46}
47
48export const LIGHT: Palette = {
49  sheet: '#F4F2EC',
50  card: '#FFFFFF',
51  inset: '#F6F4EF',
52  line: '#E4E0D6',
53  lineStrong: '#CFC9BC',
54  track: '#ECE8DF',
55  hi: '#1C1B19',
56  mid: '#4F4B44',
57  lo: '#77726A',
58  roles: {
59    read: role('#2464A0', '#E7F0F9', '#B9D3EC'),
60    edit: role('#A94A26', '#FBEDE6', '#EFC6B4'),
61    run: role('#6243C4', '#F0ECFC', '#CFC3F3'),
62    ok: role('#1F6E43', '#E4F4EA', '#B5DEC4'),
63    warn: role('#8A6000', '#FBF2D9', '#EBD48F'),
64    danger: role('#C0263A', '#FCE8EA', '#F1B9C0'),
65    plan: role('#A8337F', '#FBE9F4', '#EDBFDC'),
66  },
67}
68
69export const palette = (isLight: boolean): Palette => (isLight ? LIGHT : DARK)
70
71// ---------------------------------------------------------------------------
72// File-type badges: soft, one colour per kind; the activity colour (read/edit) wins on a row.
73
74export type FileKind = 'code' | 'py' | 'doc' | 'data' | 'pdf' | 'img' | 'misc'
75export type BadgeColors = { fg: string; tint: string }
76
77const KIND_ROLE: Record<FileKind, Role | null> = {
78  code: 'warn',
79  py: 'read',
80  doc: null,
81  data: 'ok',
82  pdf: 'danger',
83  img: 'plan',
84  misc: null,
85}
86
87// Kinds with no role of their own: md/txt are a pale Iris, sh/git and the unknown are text.lo.
88// Light misc uses text.mid on track: text.lo misses 4.5:1 on every light tint.
89const OWN_BADGES: Record<'doc' | 'misc', { dark: BadgeColors; light: BadgeColors }> = {
90  doc: { dark: { fg: '#B8AADA', tint: '#252230' }, light: { fg: '#5A4691', tint: '#F6F4FD' } },
91  misc: { dark: { fg: DARK.lo, tint: DARK.inset }, light: { fg: LIGHT.mid, tint: LIGHT.track } },
92}
93
94const EXT_KIND: Record<string, FileKind> = {
95  ts: 'code', tsx: 'code', js: 'code', jsx: 'code', mjs: 'code', cjs: 'code', mts: 'code', cts: 'code',
96  py: 'py', pyi: 'py',
97  md: 'doc', mdx: 'doc', txt: 'doc', rst: 'doc',
98  json: 'data', csv: 'data', yaml: 'data', yml: 'data', toml: 'data', xml: 'data',
99  pdf: 'pdf',
100  png: 'img', jpg: 'img', jpeg: 'img', gif: 'img', svg: 'img', webp: 'img', ico: 'img',
101}
102
103const EXT_LABEL: Record<string, string> = { json: '{}', yaml: 'YML', jpeg: 'JPG' }
104const BADGE_CHARS = 3
105
106export type FileType = { kind: FileKind; label: string }
107
108// `src/api/client.ts` → code "TS"; `.gitignore` → misc "GIT"; `Makefile` → misc "MAK".
109export function fileType(name: string): FileType {
110  const base = name.split('/').pop() ?? name
111  const dot = base.lastIndexOf('.')
112  const ext = (dot >= 0 ? base.slice(dot + 1) : base).toLowerCase()
113  const isGit = base.toLowerCase().startsWith('.git')
114  const kind = isGit ? 'misc' : EXT_KIND[ext] ?? 'misc'
115  const label = isGit ? 'GIT' : EXT_LABEL[ext] ?? ext.slice(0, BADGE_CHARS).toUpperCase()
116
117  return { kind, label: label || '·' }
118}
119
120export function badgeColors(kind: FileKind, isLight: boolean): BadgeColors {
121  const owner = KIND_ROLE[kind]
122
123  if (owner !== null) {
124    const { fg, tint } = palette(isLight).roles[owner]
125    return { fg, tint }
126  }
127
128  return OWN_BADGES[kind === 'doc' ? 'doc' : 'misc'][isLight ? 'light' : 'dark']
129}
130
131// The CSS variable names a badge paints with inside an Svg (see svgStyle).
132export function badgePaints(kind: FileKind): { fg: string; tint: string } {
133  const owner = KIND_ROLE[kind]
134  const name = owner ?? `file-${kind === 'doc' ? 'doc' : 'misc'}`
135
136  return { fg: name, tint: `${name}-tint` }
137}
138
139// ---------------------------------------------------------------------------
140// Terminal: theme keys, never hex (DESIGN.md §2). Meta text is `dimColor`, not a key.
141
142export type ThemeKey = 'suggestion' | 'claude' | 'permission' | 'success' | 'warning' | 'error' | 'merged' | 'subtle'
143
144export const ROLE_KEY: Record<Role, ThemeKey> = {
145  read: 'suggestion',
146  edit: 'claude',
147  run: 'permission',
148  ok: 'success',
149  warn: 'warning',
150  danger: 'error',
151  plan: 'merged',
152}
153
154export const LINE_KEY: ThemeKey = 'subtle'
155
156// ---------------------------------------------------------------------------
157// Motion (DESIGN.md §5).
158
159export const MOTION = {
160  quickMs: 120,
161  stateMs: 200,
162  travelMs: 320,
163  ease: 'cubic-bezier(.2,.8,.2,1)',
164  breatheS: 1.8,
165  shimmerS: 1.4,
166  breatheLow: 0.45,
167} as const
168
169// ---------------------------------------------------------------------------
170// Svg style: every colour as a CSS variable for both schemes, a fill (`f-*`) and stroke
171// (`s-*`) class per variable, the two loops, and the reduced-motion rule last.
172
173const kebab = (name: string): string => name.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`)
174
175export function paintVars(isLight: boolean): Record<string, string> {
176  const p = palette(isLight)
177  const vars: Record<string, string> = {}
178  const { roles, ...neutrals } = p
179
180  for (const [name, hex] of Object.entries(neutrals)) vars[kebab(name)] = hex
181  for (const name of ROLES) {
182    vars[name] = roles[name].fg
183    vars[`${name}-tint`] = roles[name].tint
184    vars[`${name}-line`] = roles[name].line
185  }
186  for (const kind of ['doc', 'misc'] as const) {
187    const own = OWN_BADGES[kind][isLight ? 'light' : 'dark']
188    vars[`file-${kind}`] = own.fg
189    vars[`file-${kind}-tint`] = own.tint
190  }
191
192  return vars
193}
194
195export const PAINTS = Object.keys(paintVars(false))
196
197const LOOPS: Record<string, string> = {
198  breathe: `@keyframes breathe{50%{opacity:${MOTION.breatheLow}}}.breathe{animation:breathe ${MOTION.breatheS}s ease-in-out infinite}`,
199  shimmer: `@keyframes shimmer{0%,100%{opacity:1}50%{opacity:.4}}.shimmer{animation:shimmer ${MOTION.shimmerS}s linear infinite}`,
200}
201
202export const FONT = `-apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', system-ui, sans-serif`
203export const MONO = `ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace`
204
205const REDUCED = '@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}'
206
207const varBlock = (vars: Record<string, string>, names: readonly string[]): string =>
208  `svg{${names.map(name => `--${name}:${vars[name]}`).join(';')}}`
209
210// The <style> body for an Svg. Given the markup it styles, only the paints it names are
211// written, so a small pill does not carry the whole palette.
212export function svgStyle(body?: string): string {
213  const used = body === undefined ? PAINTS : PAINTS.filter(name => new RegExp(`[fs]-${name}\\b(?!-)`).test(body))
214  const rules = used.map(name => `.f-${name}{fill:var(--${name})}.s-${name}{stroke:var(--${name})}`).join('')
215  const loops = Object.keys(LOOPS)
216    .filter(name => body === undefined || body.includes(name))
217    .map(name => LOOPS[name])
218    .join('')
219
220  return [
221    varBlock(paintVars(false), used),
222    `@media (prefers-color-scheme: light){${varBlock(paintVars(true), used)}}`,
223    `text{font-family:${FONT}}.mono{font-family:${MONO};font-variant-numeric:tabular-nums}`,
224    rules,
225    loops,
226    REDUCED,
227  ].join('')
228}
229
230// ---------------------------------------------------------------------------
231// Light or dark, for Box/Text hex. Adapted from skins' light.ts (mods/skins/hooks/light.ts,
232// MODZ): an override (`MODZ_THEME`) wins, then a theme name that says light or dark, then
233// for `auto` the terminal's COLORFGBG, then the system's appearance. Dark when nothing says.
234
235export type ThemeHints = {
236  override?: string
237  theme?: unknown
238  colorfgbg?: string
239  systemDark?: boolean
240}
241
242const LIGHT_ANSI_BACKGROUNDS = [7, 15]
243
244const named = (value: unknown): boolean | undefined => {
245  const name = typeof value === 'string' ? value.trim().toLowerCase() : ''
246
247  if (name.includes('light')) return true
248
249  return name.includes('dark') ? false : undefined
250}
251
252const terminalLight = (colorfgbg: string | undefined): boolean | undefined => {
253  const background = Number(colorfgbg?.split(';').at(-1))
254
255  return colorfgbg === undefined || Number.isNaN(background) ? undefined : LIGHT_ANSI_BACKGROUNDS.includes(background)
256}
257
258export const resolveLight = (hints: ThemeHints): boolean =>
259  named(hints.override) ??
260  named(hints.theme) ??
261  terminalLight(hints.colorfgbg) ??
262  (hints.systemDark === undefined ? false : !hints.systemDark)
263