Visual QA for the MODZ kit: /kit-gallery opens a pane drawing every kit component in several states

<img src="docs/img/hero-light.svg" width="520" alt="MODZ">
<h3 align="center">19 mods for Claude Code. One panel.</h3>
<a href="https://partalemre.github.io/MODZ/"><img src="docs/img/film-preview.webp" width="760" alt="16 seconds of the MODZ film: the camera dives through the wordmark into Claude Code, tool calls stream into the MODZ panel, and the Agents row opens into the Agents pane"></a> <sub><a href="https://partalemre.github.io/MODZ/">Watch the whole film</a> · 54 seconds, with sound · <a href="https://github.com/partalemre/MODZ/releases/latest">download it</a></sub>
In Claude Code, add the marketplace, then install the panel and the mods you want:
/plugin marketplace add partalemre/MODZ
/plugin install modz@modz
/plugin install savvy-progress@modz
Or install all of them at once from a terminal:
claude plugin marketplace add partalemre/MODZ
for m in modz savvy-progress savvy-flow filetree plan-steps handoff transceiver blast-radius secret-guard checkpoint \
test-badge replay-theater session-budget cache-tax hud-pro effort-router done-ping reflect-mod skins terminal-browser; do
claude plugin install "$m@modz"
done
Or ask your coding agent: Install MODZ for me. Follow https://partalemre.github.io/MODZ/llms.txt and ask me which mods I want.
Start a new session. The MODZ panel opens on the right with a line from every mod you installed.
terminal-browser draws in the terminal only. Outside the desktop app, done-ping falls back to a macOS banner.modz marketplace, so every mod you installed, transceiver from its own repo included, updates itself in the background a few minutes into a session and loads fresh at the next start. No model call, no command to run. If you already chose under /plugin > Marketplaces, that choice stays; to turn it off, choose Disable auto-update there.MODZ_LANG=tr for Turkish.claude -p, an SDK host), also set CLAUDE_CODE_ARTIFACT=1.Both variables go in the env block of ~/.claude/settings.json:
{ "env": { "MODZ_LANG": "tr", "CLAUDE_CODE_ARTIFACT": "1" } }
<img src="docs/img/panel.jpg" alt="The MODZ panel beside a Claude Code session, showing Agents, Files, Plan, Tests, Checkpoints and Usage">
The panel opens beside the chat when a session starts. Each mod reports one honest line: what it measured, never a guess. One click opens that mod's own pane next to the panel.
<img src="docs/img/agents.jpg" alt="The Agents pane: three subagents as pixel crabs, each with its task, progress, cost and time">
Each subagent gets a crab, a name and a card with its task, step, progress, cost and time, updated as it works. A band above the prompt shows the whole crew while a turn runs.
<img src="docs/img/hold.jpg" alt="A held git reset --hard, listing the uncommitted changes it would throw away, with Run anyway and Keep my work">
rm -rf, git reset --hard, git clean, force pushes and migrations stop before they run. The card shows exactly what each would delete or throw away, and the safe choice has the focus.
<img src="docs/img/team.jpg" alt="Two Transceiver windows: teammates chatting, and one agent's diff question answered by the other machine at zero tokens">
Transceiver lets you chat with teammates without leaving Claude Code. Your agent can ask theirs for status, diff, tests, log or a file. The answer is computed on their machine and costs zero tokens. Only collaborators on the repo can ask, and secrets never leave your machine. Rooms are private claude.ai artifacts; there is no extra server. Transceiver is in beta: it works end to end, and its rough edges are being found. How it works
| Mod | What it does | |
|---|---|---|
| Panel | modz | The panel itself: one live line per mod and one click to each pane |
| Work | savvy-progress | Agents pane and progress band: each subagent's progress, cost and time; other chats waiting on you; a home crab that levels up |
savvy-flow | Orchestrated delivery: the session model plans, tiered worker agents build | |
filetree | Files Claude touched this turn, what it is doing now, git status | |
plan-steps | The plan's steps as Claude works through them; the plan comes back after /clear and compaction, and a turn that ends with steps open offers to go on | |
handoff | Before the context fills, writes a handoff note and hands it once to your next fresh chat | |
transceiver | Team chat and zero-token agent questions between teammates (beta) | |
| Safety | blast-radius | Holds destructive shell commands and shows what they would change; holds an edit to a file another chat just changed; checks other commands against your .claude/house-rules.md |
secret-guard | Stops keys and tokens in writes, edits, git add and commits; keeps pasted keys out of the chat as placeholders; /rec masks keys, env values and e-mail addresses on screen | |
checkpoint | A silent git snapshot before each turn's first write; undo a turn | |
test-badge | Runs tests and lint after edits and shows the result; watches your PR's GitHub CI | |
replay-theater | Replays the last turn's edits step by step, and earlier turns and past sessions | |
| Meter | session-budget | A spend budget per session, with a stop at 100% |
cache-tax | Keeps the prompt cache warm through breaks and prices a cold send, a model change included | |
hud-pro | One line with the 5-hour and 7-day limits, context and cost; when the 5-hour window fills; what fills the context and how many turns are left | |
effort-router | Shows each turn's effort and model; can send light prompts at low effort | |
| Comfort | done-ping | A notification when a long turn ends or Claude waits on you, with optional sound packs |
reflect-mod | Saves corrections worth keeping to your CLAUDE.md; /mine turns friction that repeats across past chats into ideas | |
skins | Themes for the transcript, switched live with /skin | |
terminal-browser | A browser inside the terminal that the agent can drive |
Most mods use no model tokens. These can, each with the cheapest model and never more than one call per event:
reflect-mod: one small check per correction it finds, and one per /mine.cache-tax: warming sends a request, only when you turn it on.handoff: one summary per handoff note (summarize, on by default; the note is written without it too).effort-router: one check per prompt, only in auto mode.blast-radius: one check per command it judges, only when .claude/house-rules.md exists.replay-theater: one call when a step has no description of its own.ask: a small model on the other side, at most ten a day per room.plan-steps (while it brings a plan back) and secret-guard (while placeholders exist) add a short section to the system prompt.
Read from the code, not from a promise. "Store" is the mod's own file in ~/.claude; every mod also keeps session state in memory.
| Mod | Reads | Writes | Runs | Network | Model |
|---|---|---|---|---|---|
modz | marketplace, settings | store; once, autoUpdate for its marketplace in user settings | |||
savvy-progress | transcript | store | agents you start | ||
savvy-flow | its worker agents | ||||
filetree | project files | git, open | |||
plan-steps | task list, plan file | store | |||
transceiver | repo, git | its room | git | claude.ai | free-form ask |
handoff | transcript, plan | .claude/handoff/*.md, store | git status, rm (own notes) | optional | |
blast-radius | .claude/house-rules.md | store | git, a read-only look at what a held command would touch | Jev, opt-in | with house rules |
secret-guard | written files | .env, .gitignore (on your press) | git | ||
checkpoint | store, git refs | git | |||
test-badge | project files | store | your test and lint commands, git, gh | GitHub, through gh | |
replay-theater | transcripts | store | rarely | ||
session-budget | store | ||||
cache-tax | store | when warming | |||
hud-pro | context breakdown | store | |||
effort-router | prompts | store | Jev, opt-in | auto only | |
done-ping | store | osascript, terminal-notifier, afplay | |||
reflect-mod | transcripts, CLAUDE.md | CLAUDE.md (on your press), store | tail | yes | |
skins | settings | store | |||
terminal-browser | web pages | a browser | yes |
Jev is TypeSafe's classifier: set MODZ_JUDGE=jev and TYPESAFE_API_KEY to use it instead of your own model account. What it is sent is masked first.
/modz does the same./plugin configure modz@modz turns off opening at session start.The panel lists any mod that reports to it, with no change to MODZ. Your mod talks to it through $.state:
{ plugin: '<your-mod>', key: 'hub' } value: { pane, detail, tone, at }, plus your row's title, hint, icon (a kit icon name), section (work, safety or meter) and an optional badge. tone is idle, live, ok, warn or bad; write it only when the text changes.state.set on { plugin: 'modz', key: 'open' }; when its pane is yours, call $.ui.open({ id, title, focus: true }).{ plugin: 'modz', key: 'isOn' } is true, draw HomeButton from kit/home.tsx first in your pane; its press writes your own { plugin: '<your-mod>', key: 'home' } as { at }.DESIGN.md §9 has the full contract, and mods/modz/types/index.d.ts the types. bin/modz new <name> starts a mod from the template.
If a person asks you to install MODZ, do exactly this. Every step is safe to repeat.
claude --version must print 2.1.295 or later. If it is older, ask the person to update Claude Code first.claude plugin marketplace add partalemre/MODZ.claude plugin install modz@modz. Then install each mod the person wants, by the names in the table above, as claude plugin install <name>@modz. If they did not choose, install all twenty with the loop under Install.env block of ~/.claude/settings.json, merging with what is already there and never overwriting it:"MODZ_LANG": "tr" for a Turkish interface."CLAUDE_CODE_ARTIFACT": "1" if Transceiver is installed and this Claude Code is driven by an SDK host.claude plugin list shows each <name>@modz as enabled./reload-plugins in an open one.To remove a mod: claude plugin uninstall <name>@modz. To remove everything: also run claude plugin marketplace remove modz. A machine-readable summary is at partalemre.github.io/MODZ/llms.txt.
mods/<name>/ holds each mod; kit/ is the shared drawing kit, copied into the mods by bin/kit-sync.
bin/modz check [name] validates each mod and runs its tests, on both the terminal engine and the desktop app's engine.bin/modz desk starts a real session, speaks the desktop app's own protocol to it, and clicks through every panel. Run both before opening a pull request.version: Claude Code then versions each one by its commit, so every push to main reaches every user. Keep it that way; a pinned version stops updates until someone bumps it..claude-plugin/types/ beside a mod it hot-reloads. Each mod's tsconfig.json extends them, so tsc -p mods/<name> type-checks that mod.tools/kit-gallery draws every kit component; its tests check contrast in both themes.Several mods are adaptations of open-source projects, each under its original license, which lives in that mod's folder:
| Mod | Based on | License |
|---|---|---|
blast-radius, replay-theater | anthropics/claude-code-playground | Apache-2.0 |
cache-tax | karanb192/cache-tax | MIT |
filetree | data-goblin/claude-code-filetree | MIT |
reflect-mod | bayramannakov/claude-reflect | MIT |
savvy-progress, savvy-flow | johnnyvizz/claude-kit | MIT |
skins | hellosverre/claude-skins | MIT |
terminal-browser | zenbu-labs/terminal-browser | MIT |
Everything else is MIT.
hooks/register.tsx 52 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { resolveLight } from './kit/tokens'
4import { cardInnerWidth } from './kit/ui'
5import { SECTIONS } from './sections'
6import type { Ctx } from './sections'
7
8const PANE = 'kit-gallery'
9const COLUMNS = 80
10
11// `kit-gallery` draws every section; `kit-gallery:<section>:<dark|light>` (the preview test)
12// draws one, in a fixed scheme.
13async function isLightNow($: EngineInterface): Promise<boolean> {
14 const rows = await $.config.list()
15
16 return resolveLight({ override: await $.env.get('MODZ_THEME'), theme: rows.find(row => row.key === 'theme')?.value })
17}
18
19export const register: Register = on => {
20 on('session.start', async ($, e, next) => {
21 await $.command.register({ name: 'kit-gallery', description: 'MODZ kit galerisi: her bileşen, birkaç durumda' })
22 return next(e)
23 })
24
25 on('command.run', { command: 'kit-gallery' }, async $ => {
26 await $.ui.open({ id: PANE, title: 'Kit galerisi' })
27 return { text: 'Kit galerisi açıldı.' }
28 })
29
30 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
31 if (!e.requestId.startsWith(PANE)) return next(e)
32
33 const [, only, scheme] = e.requestId.split(':')
34 const ui = $.ui.resolve(e)
35 const { Box } = ui
36 const columns = e.props.bodyColumns || COLUMNS
37 const ctx: Ctx = {
38 ui,
39 surface: e.surface,
40 isLight: scheme ? scheme === 'light' : await isLightNow($),
41 width: cardInnerWidth(columns),
42 }
43 const shown = SECTIONS.filter(section => !only || section.id === only)
44
45 return (
46 <Box flexDirection="column" gap={2}>
47 {shown.map(section => section.draw(ctx))}
48 </Box>
49 )
50 })
51}
52hooks/kit/tokens.ts 263 lines1// 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)
263hooks/kit/ui.tsx 254 lines1// MODZ kit — element helpers. Each takes (draw, opts), where `draw` is { ui, surface, isLight }:
2// `ui` is `$.ui.resolve(e)`, `surface` is `e.surface`, `isLight` comes from resolveLight. On the
3// terminal (or any table without Svg) each draws glyphs and theme keys instead of Svg.
4import type { ElementTable, RenderChildren, RenderElement, RenderSurface } from 'claude-code'
5
6import { GLYPH } from './icons'
7import { headerSvg, statRowSvg } from './svg-compose'
8import type { HeaderOpts } from './svg-compose'
9import { upperTr } from './svg-core'
10import { clampRatio, meter, meterTone, percentText } from './svg-meters'
11import type { MeterOpts } from './svg-meters'
12import { pill } from './svg-shapes'
13import { eyebrow } from './svg-text'
14import type { PillOpts, StatTileOpts } from './svg-shapes'
15import type { PaneMotion } from './moving'
16import { LINE_KEY, palette, ROLE_KEY, ROLES } from './tokens'
17import type { Palette, Role, ThemeKey } from './tokens'
18import { planButtons } from './ui-buttons'
19import type { ButtonSpec, ButtonVariant } from './ui-buttons'
20
21export type Ui = ElementTable
22
23// Where an element draws; a caller's own context with more fields passes as it is. With `motion` (moving.ts), the
24// desktop draws each Svg in a kept frame that enters, sweeps on a change and keeps its loops running.
25export type DrawCtx = { ui: Ui; surface: RenderSurface; isLight: boolean; motion?: PaneMotion }
26
27// Svg px per layout cell on the desktop; card padding and border in cells (DESIGN.md §4).
28export const PX_PER_CELL = 8
29const CARD_PAD_X = 2
30const CARD_PAD_Y = 1
31const CARD_BORDER_CELLS = 2
32const MIN_INNER = 160
33
34export const isTextSurface = (ui: Ui, surface: RenderSurface): boolean => surface === 'terminal' || !('Svg' in ui)
35
36// The px a card's content gets inside `columns` (e.props.bodyColumns), with a little slack.
37export const cardInnerWidth = (columns: number): number =>
38 Math.max(MIN_INNER, (columns - CARD_PAD_X * 2 - CARD_BORDER_CELLS) * PX_PER_CELL)
39
40export const pxWidth = (columns: number): number => Math.max(MIN_INNER, columns * PX_PER_CELL)
41
42const isRole = (paint: string): paint is Role => (ROLES as readonly string[]).includes(paint)
43
44// A Svg paint name (`warn`, `danger`, a role, `mid`) as a terminal theme key; neutral → none.
45export const toneKey = (paint: string): ThemeKey | undefined => (isRole(paint) ? ROLE_KEY[paint] : undefined)
46
47type SvgSite = { key: string; shows?: unknown }
48
49// `key` names the drawing's place in the pane, so its motion follows it from draw to draw; `shows` leaves out a
50// meta line that only ages ("12 dk önce"), so a new age alone is no change to sweep.
51function svgOf(draw: DrawCtx, site: SvgSite, pic: { source: string; width: number; height: number; alt: string }): RenderElement | null {
52 const { ui, motion } = draw
53 if (!('Svg' in ui)) return null
54 const { Svg } = ui
55 if (motion !== undefined) return <Svg {...motion.pic(site.key, pic, { shows: site.shows })} />
56 return <Svg source={pic.source} alt={pic.alt} width={pic.width} height={pic.height} />
57}
58
59// ---------------------------------------------------------------------------
60
61export type EyebrowOpts = { text: string; role?: Role; meta?: string; width: number; hasRule?: boolean }
62
63export function Eyebrow(draw: DrawCtx, o: EyebrowOpts): RenderElement {
64 const { ui, surface } = draw
65 const { Box, Text } = ui
66
67 if (isTextSurface(ui, surface)) {
68 return (
69 <Box flexDirection="row" justifyContent="space-between">
70 <Text color={o.role ? ROLE_KEY[o.role] : undefined} dimColor={!o.role} bold>{`● ${upperTr(o.text)}`}</Text>
71 {o.meta ? <Text dimColor>{o.meta}</Text> : null}
72 </Box>
73 )
74 }
75
76 return svgOf(draw, { key: `eyebrow:${o.text}`, shows: { ...o, meta: o.meta !== undefined } }, eyebrow({ ...o, hasDot: true })) ?? <Box />
77}
78
79export function HeaderBlock(draw: DrawCtx, o: HeaderOpts): RenderElement {
80 const { ui, surface } = draw
81 const { Box, Text } = ui
82
83 if (!isTextSurface(ui, surface)) return svgOf(draw, { key: `header:${o.eyebrow}`, shows: { ...o, meta: o.meta !== undefined } }, headerSvg(o)) ?? <Box />
84
85 const glyph = o.icon ? `${GLYPH[o.icon]} ` : '● '
86 return (
87 <Box flexDirection="column">
88 <Box flexDirection="row" justifyContent="space-between">
89 <Text color={o.role ? ROLE_KEY[o.role] : undefined} dimColor={!o.role} bold>{glyph + upperTr(o.eyebrow)}</Text>
90 {o.meta ? <Text dimColor>{o.meta}</Text> : null}
91 </Box>
92 {o.headline ? <Text bold>{o.headline}</Text> : null}
93 {o.sub ? <Text>{o.sub}</Text> : null}
94 </Box>
95 )
96}
97
98export type CardOpts = { header?: HeaderOpts; role?: Role; isDanger?: boolean; children?: RenderChildren }
99
100function borderOf(isText: boolean, p: Palette, role?: Role): string {
101 if (isText) return role ? ROLE_KEY[role] : LINE_KEY
102
103 return role ? p.roles[role].line : p.line
104}
105
106// Round border informs, single border asks for a consequential decision (Flare line).
107export function Card(draw: DrawCtx, o: CardOpts): RenderElement {
108 const { ui, surface, isLight } = draw
109 const { Box } = ui
110 const isText = isTextSurface(ui, surface)
111 const p = palette(isLight)
112 const role = o.isDanger ? 'danger' : o.role
113 const borderColor = borderOf(isText, p, role)
114 const header = o.header ? HeaderBlock(draw, { role, ...o.header }) : null
115
116 return (
117 <Box
118 flexDirection="column"
119 borderStyle={o.isDanger ? 'single' : 'round'}
120 borderColor={borderColor}
121 backgroundColor={isText ? undefined : p.card}
122 paddingX={CARD_PAD_X}
123 paddingY={CARD_PAD_Y}
124 gap={1}
125 >
126 {header}
127 {o.children}
128 </Box>
129 )
130}
131
132// ---------------------------------------------------------------------------
133
134// A plain button is drawn with the `plain` flag, not a variant.
135const BUTTON_VARIANT: Record<ButtonVariant, 'primary' | 'secondary' | undefined> = { primary: 'primary', secondary: 'secondary', plain: undefined }
136
137export type ButtonRowOpts = { buttons: readonly ButtonSpec[]; isDanger?: boolean }
138
139export function ButtonRow(draw: DrawCtx, o: ButtonRowOpts): RenderElement {
140 const { Box, Button } = draw.ui
141 const planned = planButtons(o.buttons, o.isDanger)
142
143 return (
144 <Box flexDirection="row" gap={1} flexWrap="wrap">
145 {planned.map(b => (
146 <Button
147 key={b.key ?? b.label}
148 label={b.label}
149 onPress={b.onPress}
150 variant={BUTTON_VARIANT[b.variant]}
151 plain={b.variant === 'plain' ? true : undefined}
152 hotkey={b.hotkey}
153 autoFocus={b.isFocused ? true : undefined}
154 />
155 ))}
156 </Box>
157 )
158}
159
160export type HighlightOpts = { text: string; role: Role }
161
162// Tint behind, role colour in front, not bold; one padding space each side on the desktop.
163export function Highlight(draw: DrawCtx, o: HighlightOpts): RenderElement {
164 const { ui, surface, isLight } = draw
165 const { Text } = ui
166
167 if (isTextSurface(ui, surface)) return <Text color={ROLE_KEY[o.role]}>{o.text}</Text>
168
169 const c = palette(isLight).roles[o.role]
170 return <Text backgroundColor={c.tint} color={c.fg}>{` ${o.text} `}</Text>
171}
172
173// ---------------------------------------------------------------------------
174
175export type StatRowOpts = { stats: readonly StatTileOpts[]; width: number }
176
177export function StatRow(draw: DrawCtx, o: StatRowOpts): RenderElement {
178 const { ui, surface } = draw
179 const { Box, Text } = ui
180
181 if (!isTextSurface(ui, surface)) return svgOf(draw, { key: `stats:${o.stats.map(stat => stat.label).join('|')}` }, statRowSvg(o)) ?? <Box />
182
183 return (
184 <Box flexDirection="row" gap={3} flexWrap="wrap">
185 {o.stats.map(stat => (
186 <Text key={stat.label}>
187 <Text dimColor>{`${stat.label} `}</Text>
188 <Text bold color={stat.role ? ROLE_KEY[stat.role] : undefined}>{stat.value}</Text>
189 {stat.unit ? <Text dimColor>{` ${stat.unit}`}</Text> : null}
190 </Text>
191 ))}
192 </Box>
193 )
194}
195
196export type MeterGlyphs = { fill: string; rest: string; pace: string; after: string }
197
198const DEFAULT_CELLS = 20
199
200// The fill's last cell: a half head while the meter runs, a whole one when it is full.
201function headOf(filled: number, cells: number): string {
202 if (filled <= 0) return ''
203
204 return filled < cells ? '╸' : '━'
205}
206
207// `━━━━╸┊────`: the fill, the head, the pace mark (only where it is still ahead), the rest.
208export function meterGlyphs(ratio: number, cells = DEFAULT_CELLS, pace?: number): MeterGlyphs {
209 const filled = Math.round(Math.max(0, Math.min(1, ratio)) * cells)
210 const fill = '━'.repeat(Math.max(0, filled - 1)) + headOf(filled, cells)
211 const at = pace === undefined ? -1 : Math.round(Math.max(0, Math.min(1, pace)) * cells)
212 const empty = cells - filled
213
214 if (at < filled || at >= cells) return { fill, rest: '─'.repeat(empty), pace: '', after: '' }
215
216 return { fill, rest: '─'.repeat(at - filled), pace: '┊', after: '─'.repeat(empty - (at - filled) - 1) }
217}
218
219export type MeterElementOpts = MeterOpts & { cells?: number }
220
221export function Meter(draw: DrawCtx, o: MeterElementOpts): RenderElement {
222 const { ui, surface } = draw
223 const { Box, Text } = ui
224
225 if (!isTextSurface(ui, surface)) return svgOf(draw, { key: `meter:${o.label ?? ''}` }, meter(o)) ?? <Box />
226
227 const ratio = clampRatio(o.value, o.max)
228 const g = meterGlyphs(ratio, o.cells, o.pace)
229 const key = toneKey(meterTone(ratio, o.pace, o.role))
230 return (
231 <Text>
232 {o.label ? <Text>{`${o.label} `}</Text> : null}
233 <Text color={key}>{g.fill}</Text>
234 <Text dimColor>{g.rest}</Text>
235 <Text dimColor>{g.pace}</Text>
236 <Text dimColor>{g.after}</Text>
237 <Text bold color={ratio >= 1 ? 'error' : undefined}>{` ${o.valueText ?? percentText(o.value, o.max)}`}</Text>
238 </Text>
239 )
240}
241
242export function Pill(draw: DrawCtx, o: PillOpts): RenderElement {
243 const { ui, surface } = draw
244 const { Box, Text } = ui
245
246 if (!isTextSurface(ui, surface)) return svgOf(draw, { key: `pill:${o.text}` }, pill(o)) ?? <Box />
247
248 const glyph = o.icon ? `${GLYPH[o.icon]} ` : ''
249 return <Text color={o.role ? ROLE_KEY[o.role] : undefined} dimColor={!o.role}>{`${glyph}${o.text}`}</Text>
250}
251
252export { planButtons } from './ui-buttons'
253export type { ButtonSpec, PlannedButton } from './ui-buttons'
254hooks/sections.tsx 24 lines1import type { RenderElement } from 'claude-code'
2
3import { cardsSection, headersSection, typeSection } from './cards'
4import type { Ctx } from './ctx'
5import { filesSection, loadingSection, pillsSection, stepsSection } from './lists'
6import { dataSection, iconsSection, metersSection } from './visuals'
7
8export type { Ctx } from './ctx'
9
10export type GallerySection = { id: string; draw: (ctx: Ctx) => RenderElement }
11
12export const SECTIONS: readonly GallerySection[] = [
13 { id: 'cards', draw: cardsSection },
14 { id: 'headers', draw: headersSection },
15 { id: 'type', draw: typeSection },
16 { id: 'icons', draw: iconsSection },
17 { id: 'data', draw: dataSection },
18 { id: 'meters', draw: metersSection },
19 { id: 'steps', draw: stepsSection },
20 { id: 'files', draw: filesSection },
21 { id: 'pills', draw: pillsSection },
22 { id: 'loading', draw: loadingSection },
23]
24hooks/kit/icons.ts 70 lines1// MODZ kit — icons. Original drawings on a 24×24 grid, live area 3.5–20.5, stroked at
2// 1.75 rendered px with round caps and joins (the builder scales the stroke with the size).
3// `icon(name)` gives the path markup; `GLYPH` is the one-cell stand-in for the terminal.
4
5export const ICON_GRID = 24
6
7const PATHS = {
8 trash: 'M4 6.5h16M9.5 6.5V5A1.5 1.5 0 0 1 11 3.5h2A1.5 1.5 0 0 1 14.5 5v1.5M6 6.5l.9 12.1a2 2 0 0 0 2 1.9h6.2a2 2 0 0 0 2-1.9L18 6.5M10 10.5v6M14 10.5v6',
9 shield: 'M12 3.5l7 2.6v5.3c0 4.4-2.9 7.6-7 9.1-4.1-1.5-7-4.7-7-9.1V6.1zM9 12l2.2 2.2L15.2 10',
10 key: 'M12 15a4 4 0 1 1-8 0 4 4 0 0 1 8 0zM8 15h.01M10.9 12.1l8.6-8.6M16.5 6.5l2.2 2.2M13.9 9.1l1.7 1.7',
11 eye: 'M3 12c2-3.8 5.1-6 9-6s7 2.2 9 6c-2 3.8-5.1 6-9 6s-7-2.2-9-6zM14.75 12a2.75 2.75 0 1 1-5.5 0 2.75 2.75 0 0 1 5.5 0z',
12 pencil: 'M4.5 19.5l1-4.2L15.9 4.9a2.1 2.1 0 0 1 3 0l.2.2a2.1 2.1 0 0 1 0 3L8.7 18.5zM14 6.8l3.2 3.2',
13 check: 'M5 12.5l4.5 4.5L19 7.5',
14 x: 'M6.5 6.5l11 11M17.5 6.5l-11 11',
15 clock: 'M20.5 12a8.5 8.5 0 1 1-17 0 8.5 8.5 0 0 1 17 0zM12 7.5V12l3 2',
16 cube: 'M12 3.5l7.5 4.2v8.6L12 20.5l-7.5-4.2V7.7zM4.5 7.7L12 12l7.5-4.3M12 12v8.5',
17 bookmark: 'M7.5 3.5h9a1.5 1.5 0 0 1 1.5 1.5v15.5l-6-4-6 4V5a1.5 1.5 0 0 1 1.5-1.5zM12 7.5v5M9.5 10h5',
18 bulb: 'M8.6 14.3a6 6 0 1 1 6.8 0c-.6.5-.9 1.1-.9 1.9v1.3h-5v-1.3c0-.8-.3-1.4-.9-1.9zM10 20.5h4',
19 play: 'M8.5 5.5v13l10-6.5z',
20 pause: 'M9 5.5v13M15 5.5v13',
21 folder: 'M3.5 7.5a2 2 0 0 1 2-2h3.8l2.2 2.5h7a2 2 0 0 1 2 2v7.5a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2zM3.5 10.5h17',
22 file: 'M6.5 3.5h7l4.5 4.5v10.5a2 2 0 0 1-2 2h-9.5a2 2 0 0 1-2-2v-13a2 2 0 0 1 2-2zM13.5 3.5v4.5H18M8.5 12.5h7M8.5 16h4.5',
23 branch: 'M9 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM9 18a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM19 7.5a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM7 8v8M17 9.5c0 3.6-2.6 5-6 5.5-1.2.2-2 .7-2.5 1.6',
24 terminal: 'M3.5 6.5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2zM7.5 9.5l3 2.5-3 2.5M12.5 15h4',
25 bell: 'M5.5 17h13l-1.5-2.2V11a5 5 0 0 0-10 0v3.8zM10.2 19.8a2 2 0 0 0 3.6 0M12 3.5V6',
26 sparkle: 'M11 5.5c.6 4 3 6.4 7 7-4 .6-6.4 3-7 7-.6-4-3-6.4-7-7 4-.6 6.4-3 7-7zM18.5 3.5v3M17 5h3',
27 list: 'M9.5 7h10M9.5 12h10M9.5 17h10M5 7h.01M5 12h.01M5 17h.01',
28 flag: 'M5.5 20.5v-16M5.5 4.5h11.5l-2.5 4 2.5 4H5.5',
29 bolt: 'M13.5 3.5L5.5 13.5h6l-1 7 8-10h-6z',
30 gauge: 'M5.6 18.4a9 9 0 1 1 12.8 0M12 13.5l3.5-4.5M13 13.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z',
31 rewind: 'M11.5 6.5v11L4 12zM20 6.5v11L12.5 12z',
32 plug: 'M9 3.5v4M15 3.5v4M6.5 7.5h11v3a5.5 5.5 0 0 1-11 0zM12 16v4.5',
33} as const
34
35export type IconName = keyof typeof PATHS
36
37export const ICON_NAMES = Object.keys(PATHS) as IconName[]
38
39// The path markup of an icon; the caller wraps it in a stroked, scaled group.
40export const icon = (name: IconName): string => `<path d="${PATHS[name]}"/>`
41
42// One terminal cell per icon, chosen from glyphs every common monospace font carries.
43export const GLYPH: Record<IconName, string> = {
44 trash: '⌫',
45 shield: '◈',
46 key: '⚷',
47 eye: '◉',
48 pencil: '✎',
49 check: '✓',
50 x: '✕',
51 clock: '◷',
52 cube: '▣',
53 bookmark: '❏',
54 bulb: '✧',
55 play: '▸',
56 pause: '‖',
57 folder: '▤',
58 file: '▢',
59 branch: '⑂',
60 terminal: '❯',
61 bell: '◬',
62 sparkle: '✦',
63 list: '≡',
64 flag: '⚑',
65 bolt: 'ϟ',
66 gauge: '◔',
67 rewind: '«',
68 plug: '⏚',
69}
70hooks/kit/svg-compose.ts 70 lines1// MODZ kit — composites drawn as one Svg, so the desktop never wraps their parts apart:
2// a card's header block, and a row of stat tiles.
3import { place, toPic } from './svg-core'
4import type { Part, SvgPic } from './svg-core'
5import type { IconName } from './icons'
6import { iconTilePart, statTilePart, STAT_HEIGHT } from './svg-shapes'
7import type { StatTileOpts } from './svg-shapes'
8import { bodyPart, EYEBROW_H, eyebrowPart, linesPart } from './svg-text'
9import type { Role } from './tokens'
10
11export type HeaderOpts = {
12 eyebrow: string
13 width: number
14 icon?: IconName
15 role?: Role
16 headline?: string
17 sub?: string
18 meta?: string
19 mark?: string
20 hasDot?: boolean
21}
22
23const TILE = 36
24const TILE_GAP = 12
25const EYEBROW_TO_HEADLINE = 4
26const HEADLINE_TO_SUB = 4
27
28// [tile] 12 [EYEBROW ··· meta] / [Headline, two lines at most] / [sub in text.mid]
29export function headerPart(o: HeaderOpts): Part {
30 const textX = o.icon ? TILE + TILE_GAP : 0
31 const textW = o.width - textX
32 const tile = o.icon ? iconTilePart({ icon: o.icon, role: o.role, size: TILE }) : null
33 const eyebrow = eyebrowPart({ text: o.eyebrow, width: textW, role: o.role, meta: o.meta, hasDot: o.hasDot })
34 const headlineTop = EYEBROW_H + EYEBROW_TO_HEADLINE
35 const title = o.headline ? linesPart({ text: o.headline, width: textW, mark: o.mark, markRole: o.role }) : null
36 const subTop = headlineTop + (title ? title.height + HEADLINE_TO_SUB : 0)
37 const sub = o.sub ? bodyPart({ text: o.sub, width: textW }) : null
38 const titleBottom = title ? headlineTop + title.height : EYEBROW_H
39 const bottom = sub ? subTop + sub.height : titleBottom
40 const body = [
41 tile ? tile.body : '',
42 place(eyebrow.body, textX, 0),
43 title ? place(title.body, textX, headlineTop) : '',
44 sub ? place(sub.body, textX, subTop) : '',
45 ]
46 const alt = [eyebrow.alt, o.headline, o.sub].filter(Boolean).join(' — ')
47
48 return { body: body.join(''), width: o.width, height: Math.max(tile ? TILE : 0, bottom), alt }
49}
50
51export const headerSvg = (o: HeaderOpts): SvgPic => toPic(headerPart(o))
52
53// ---------------------------------------------------------------------------
54
55export type StatRowOpts = { stats: readonly StatTileOpts[]; width: number; gap?: number }
56
57const STAT_GAP = 8
58
59export function statRowPart(o: StatRowOpts): Part {
60 const gap = o.gap ?? STAT_GAP
61 const count = Math.max(1, o.stats.length)
62 const tileW = Math.floor((o.width - gap * (count - 1)) / count)
63 const tiles = o.stats.map((stat, i) => place(statTilePart({ ...stat, width: tileW }).body, i * (tileW + gap), 0))
64 const alt = o.stats.map(stat => `${stat.label}: ${stat.value}${stat.unit ? ` ${stat.unit}` : ''}`).join(' · ')
65
66 return { body: tiles.join(''), width: o.width, height: STAT_HEIGHT, alt }
67}
68
69export const statRowSvg = (o: StatRowOpts): SvgPic => toPic(statRowPart(o))
70hooks/kit/svg-core.ts 112 lines1// MODZ kit — what every Svg builder shares: the type scale, the document shell, text and
2// icon elements. Colours are paint names (`hi`, `read`, `read-tint`, …) that svgStyle turns
3// into CSS variables for both schemes; no hex reaches the markup.
4import { icon, ICON_GRID } from './icons'
5import type { IconName } from './icons'
6import { monoWidth, textWidth } from './svg-metrics'
7import type { Measure } from './svg-metrics'
8import { svgStyle } from './tokens'
9
10export type SvgPic = { source: string; width: number; height: number; alt: string }
11
12// A drawing not yet wrapped in its <svg>, so composites can place it.
13export type Part = { body: string; width: number; height: number; alt: string }
14
15export const STROKE = 1.75
16const CAP_EM = 0.7
17const DECIMALS = 100
18const SCALE_DIGITS = 4
19
20export const TYPE = {
21 display: { size: 24, weight: 650 },
22 headline: { size: 17, weight: 620, line: 22 },
23 title: { size: 14, weight: 600, line: 20 },
24 body: { size: 13, weight: 400, line: 18 },
25 eyebrow: { size: 10.5, weight: 700, tracking: 0.1 },
26 label: { size: 12, weight: 500 },
27 micro: { size: 10.5, weight: 500 },
28} as const
29
30export const num = (value: number): string => String(Math.round(value * DECIMALS) / DECIMALS)
31
32export const escapeXml = (text: string): string =>
33 text.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char] ?? char)
34
35// Capitals in the interface language (Turkish keeps its dotted İ; English never gets one).
36export { upper as upperTr } from './lang'
37
38export const sans = (size: number, weight?: number): Measure => text => textWidth(text, size, weight)
39export const mono = (size: number): Measure => text => monoWidth(text, size)
40
41// The baseline that centres a line's capitals in a box `height` tall starting at `top`.
42export const baseline = (top: number, height: number, size: number): number =>
43 Math.round(top + height / 2 + (size * CAP_EM) / 2)
44
45// Drawn in a frame of its own (an interactive Svg), a drawing whose scheme differs from the page's is painted on an
46// opaque sheet; saying both lets the frame take the page's.
47const SCHEME = ':root{color-scheme:light dark}'
48
49export function toPic(part: Part): SvgPic {
50 const { width, height } = part
51 const source = [
52 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"`,
53 ` role="img" aria-label="${escapeXml(part.alt)}"><style>${SCHEME}${svgStyle(part.body)}</style>${part.body}</svg>`,
54 ].join('')
55
56 return { source, width, height, alt: part.alt }
57}
58
59export const place = (body: string, x: number, y: number): string =>
60 x === 0 && y === 0 ? body : `<g transform="translate(${num(x)} ${num(y)})">${body}</g>`
61
62export type TextSpec = {
63 x: number
64 y: number
65 text: string
66 size: number
67 paint: string
68 weight?: number
69 anchor?: 'start' | 'middle' | 'end'
70 isMono?: boolean
71 tracking?: number
72 className?: string
73}
74
75export function textEl(t: TextSpec): string {
76 const classes = [`f-${t.paint}`, t.isMono ? 'mono' : '', t.className ?? ''].filter(Boolean).join(' ')
77 const attrs = [
78 `x="${num(t.x)}" y="${num(t.y)}" class="${classes}" font-size="${t.size}"`,
79 t.weight === undefined ? '' : ` font-weight="${t.weight}"`,
80 t.anchor === undefined || t.anchor === 'start' ? '' : ` text-anchor="${t.anchor}"`,
81 t.tracking === undefined ? '' : ` letter-spacing="${num(t.tracking)}"`,
82 ].join('')
83
84 return `<text ${attrs}>${escapeXml(t.text)}</text>`
85}
86
87export type IconSpec = { name: IconName; x: number; y: number; size: number; paint: string }
88
89// An icon scaled to `size` px with its stroke kept at STROKE rendered px.
90export function iconEl(s: IconSpec): string {
91 const scale = s.size / ICON_GRID
92
93 return [
94 `<g transform="translate(${num(s.x)} ${num(s.y)}) scale(${scale.toFixed(SCALE_DIGITS)})"`,
95 ` class="s-${s.paint}" fill="none" stroke-width="${num(STROKE / scale)}" stroke-linecap="round" stroke-linejoin="round">`,
96 `${icon(s.name)}</g>`,
97 ].join('')
98}
99
100export type RectSpec = { x: number; y: number; width: number; height: number; radius?: number; paint: string; className?: string }
101
102export const rectEl = (r: RectSpec, extra = ''): string =>
103 `<rect x="${num(r.x)}" y="${num(r.y)}" width="${num(Math.max(0, r.width))}" height="${num(r.height)}"${
104 r.radius ? ` rx="${num(r.radius)}"` : ''
105 } class="f-${r.paint}${r.className ? ` ${r.className}` : ''}"${extra}/>`
106
107// A 1 px hairline inside a rect's edge, so it lands on whole device pixels.
108export const outlineEl = (r: RectSpec, opacity = 1): string =>
109 `<rect x="${num(r.x + 0.5)}" y="${num(r.y + 0.5)}" width="${num(r.width - 1)}" height="${num(r.height - 1)}"${
110 r.radius ? ` rx="${num(r.radius - 0.5)}"` : ''
111 } fill="none" class="s-${r.paint}"${opacity < 1 ? ` stroke-opacity="${opacity}"` : ''}/>`
112hooks/kit/svg-meters.ts 192 lines1// MODZ kit — measures: the meter with its pace tick, the segmented 270° arc, the sparkline.
2// The fill never passes 100 %; past it the value reads "full" ("dolu" in Turkish).
3import { baseline, num, rectEl, TYPE, textEl, toPic, upperTr } from './svg-core'
4import type { Part, SvgPic } from './svg-core'
5import type { Role } from './tokens'
6import { pick } from './lang'
7
8const AHEAD_OF_PACE = 0.1
9const NEAR_FULL = 0.95
10const PERCENT = 100
11export const fullText = (): string => pick({ en: 'full', tr: 'dolu' })
12
13// The fill's paint: neutral (text.mid, or the role) on pace, Amber when more than 10 points
14// ahead of the pace, Flare from 95 %.
15export function meterTone(ratio: number, pace?: number, role?: Role): string {
16 if (ratio >= NEAR_FULL) return 'danger'
17 if (pace !== undefined && ratio - pace > AHEAD_OF_PACE) return 'warn'
18
19 return role ?? 'mid'
20}
21
22export const clampRatio = (value: number, max = 1): number =>
23 max > 0 && !Number.isNaN(value) ? Math.max(0, Math.min(1, value / max)) : 0
24
25// "42%" ("%42" in Turkish, which puts % first), or "full" once the value reaches its max.
26export function percentText(value: number, max = 1): string {
27 if (max > 0 && value >= max) return fullText()
28 const percent = Math.round(clampRatio(value, max) * PERCENT)
29
30 return pick({ en: `${percent}%`, tr: `%${percent}` })
31}
32
33// ---------------------------------------------------------------------------
34
35export type MeterOpts = {
36 value: number
37 max?: number
38 width: number
39 pace?: number
40 isHero?: boolean
41 label?: string
42 valueText?: string
43 role?: Role
44 bg?: 'card' | 'inset' | 'sheet'
45}
46
47const TRACK_H = 4
48const HERO_H = 6
49const TICK_H = 10
50const TICK_HALO = 3
51const BAR_ONLY_H = 12
52const LABELLED_H = 28
53const LABEL_BOX = 16
54const BAR_CENTER_LABELLED = 22
55
56export type MeterGeometry = { ratio: number; fill: number; track: number; height: number; paceX: number | null; tone: string }
57
58export function meterGeometry(o: MeterOpts): MeterGeometry {
59 const ratio = clampRatio(o.value, o.max)
60 const height = o.isHero ? HERO_H : TRACK_H
61 const fill = ratio > 0 ? Math.min(o.width, Math.max(height, Math.round(o.width * ratio))) : 0
62 const pace = o.pace === undefined ? null : Math.max(0, Math.min(1, o.pace))
63 const paceX = pace === null ? null : Math.round(Math.min(o.width - 1, pace * o.width))
64
65 return { ratio, fill, track: o.width, height, paceX, tone: meterTone(ratio, o.pace, o.role) }
66}
67
68function meterBar(o: MeterOpts, g: MeterGeometry, centerY: number): string {
69 const top = centerY - g.height / 2
70 const bar = { x: 0, y: top, height: g.height, radius: g.height / 2 }
71 const tick =
72 g.paceX === null
73 ? ''
74 : rectEl({ x: g.paceX - 1, y: top, width: TICK_HALO, height: g.height, paint: o.bg ?? 'card' }) +
75 rectEl({ x: g.paceX, y: centerY - TICK_H / 2, width: 1, height: TICK_H, paint: 'mid' })
76
77 return [
78 rectEl({ ...bar, width: g.track, paint: 'track' }),
79 g.fill > 0 ? rectEl({ ...bar, width: g.fill, paint: g.tone }) : '',
80 tick,
81 ].join('')
82}
83
84export function meterPart(o: MeterOpts): Part {
85 const g = meterGeometry(o)
86 const shown = o.valueText ?? percentText(o.value, o.max)
87 const isLabelled = o.label !== undefined || o.valueText !== undefined
88 const height = isLabelled ? LABELLED_H : BAR_ONLY_H
89 const y = baseline(0, LABEL_BOX, TYPE.label.size)
90 const texts = isLabelled
91 ? [
92 o.label ? textEl({ x: 0, y, text: o.label, paint: 'mid', ...TYPE.label }) : '',
93 textEl({ x: o.width, y, text: shown, paint: g.ratio >= NEAR_FULL ? 'danger' : 'hi', size: TYPE.label.size, weight: 600, anchor: 'end', isMono: true }),
94 ].join('')
95 : ''
96 const bar = meterBar(o, g, isLabelled ? BAR_CENTER_LABELLED : BAR_ONLY_H / 2)
97
98 return { body: texts + bar, width: o.width, height, alt: `${o.label ? `${o.label}: ` : ''}${shown}` }
99}
100
101export const meter = (o: MeterOpts): SvgPic => toPic(meterPart(o))
102
103// ---------------------------------------------------------------------------
104// Segmented arc: 270°, 20 segments, opening at the bottom; value in the middle, and at
105// 72 px a label in the opening.
106
107export type ArcOpts = { value: number; max?: number; size?: number; label?: string; role?: Role; pace?: number }
108
109const ARC_SMALL = 44
110const ARC_LARGE = 72
111const SEGMENTS = 20
112const SWEEP = 270
113const START = 135
114const GAP_DEG = 2.5
115const STROKE_SMALL = 5
116const STROKE_LARGE = 6
117const RAD = Math.PI / 180
118
119function segment(o: { i: number; c: number; r: number; paint: string; width: number }): string {
120 const step = SWEEP / SEGMENTS
121 const a0 = (START + o.i * step + GAP_DEG / 2) * RAD
122 const a1 = (START + (o.i + 1) * step - GAP_DEG / 2) * RAD
123 const at = (a: number): string => `${num(o.c + o.r * Math.cos(a))} ${num(o.c + o.r * Math.sin(a))}`
124
125 return `<path d="M${at(a0)}A${num(o.r)} ${num(o.r)} 0 0 1 ${at(a1)}" class="s-${o.paint}" stroke-width="${o.width}" fill="none"/>`
126}
127
128export function arcPart(o: ArcOpts): Part {
129 const size = o.size ?? ARC_SMALL
130 const isLarge = size >= ARC_LARGE
131 const width = isLarge ? STROKE_LARGE : STROKE_SMALL
132 const ratio = clampRatio(o.value, o.max)
133 const lit = ratio > 0 ? Math.max(1, Math.round(ratio * SEGMENTS)) : 0
134 const tone = meterTone(ratio, o.pace, o.role ?? 'read')
135 const c = size / 2
136 const r = c - width / 2 - 1
137 const segments = Array.from({ length: SEGMENTS }, (_, i) => segment({ i, c, r, width, paint: i < lit ? tone : 'track' }))
138 const value = percentText(o.value, o.max)
139 const type = isLarge ? { size: 17, weight: 650 } : { size: 11, weight: 600 }
140 const center = textEl({ x: c, y: baseline(0, size, type.size), text: value, paint: 'hi', ...type, anchor: 'middle', isMono: true })
141 const label =
142 isLarge && o.label
143 ? textEl({ x: c, y: size - 4, text: upperTr(o.label), paint: 'lo', ...TYPE.eyebrow, size: 9, anchor: 'middle' })
144 : ''
145
146 return { body: segments.join('') + center + label, width: size, height: size, alt: `${o.label ? `${o.label}: ` : ''}${value}` }
147}
148
149export const arc = (o: ArcOpts): SvgPic => toPic(arcPart(o))
150
151// ---------------------------------------------------------------------------
152// Sparkline: a 1.5 px line, a 12 % area under it, a dot on the latest value.
153
154export type SparklineOpts = {
155 values: readonly number[]
156 width?: number
157 height?: number
158 role?: Role
159 hasArea?: boolean
160 bg?: 'card' | 'inset' | 'sheet'
161}
162
163const SPARK_W = 96
164const SPARK_H = 24
165const SPARK_PAD = 3
166const SPARK_STROKE = 1.5
167const AREA_OPACITY = 0.12
168const DOT_R = 2.5
169
170export function sparklinePart(o: SparklineOpts): Part {
171 const width = o.width ?? SPARK_W
172 const height = o.height ?? SPARK_H
173 const values = o.values.length > 1 ? o.values : [o.values[0] ?? 0, o.values[0] ?? 0]
174 const low = Math.min(...values)
175 const span = Math.max(...values) - low
176 const x = (i: number): number => SPARK_PAD + (i * (width - SPARK_PAD * 2)) / (values.length - 1)
177 const y = (v: number): number => (span === 0 ? height / 2 : SPARK_PAD + (1 - (v - low) / span) * (height - SPARK_PAD * 2))
178 const points = values.map((v, i) => `${num(x(i))} ${num(y(v))}`)
179 const line = `M${points.join('L')}`
180 const paint = o.role ?? 'mid'
181 const last = values.length - 1
182 const body = [
183 o.hasArea === false ? '' : `<path d="${line}L${num(x(last))} ${height}L${num(x(0))} ${height}Z" class="f-${paint}" fill-opacity="${AREA_OPACITY}"/>`,
184 `<path d="${line}" class="s-${paint}" fill="none" stroke-width="${SPARK_STROKE}" stroke-linecap="round" stroke-linejoin="round"/>`,
185 `<circle cx="${num(x(last))}" cy="${num(y(values[last] ?? 0))}" r="${DOT_R}" class="f-${paint} s-${o.bg ?? 'card'}" stroke-width="${SPARK_STROKE}"/>`,
186 ]
187
188 return { body: body.join(''), width, height, alt: pick({ en: `${values.length} values, last ${values[last]}`, tr: `${values.length} değer, son ${values[last]}` }) }
189}
190
191export const sparkline = (o: SparklineOpts): SvgPic => toPic(sparklinePart(o))
192hooks/kit/svg-shapes.ts 193 lines1// MODZ kit — small shapes: icon tile, pill, stat tile, file badge, size bar, skeleton.
2import { baseline, iconEl, mono, num, outlineEl, rectEl, sans, TYPE, textEl, toPic } from './svg-core'
3import type { Part, SvgPic } from './svg-core'
4import type { IconName } from './icons'
5import { fitText } from './svg-metrics'
6import { eyebrowPart } from './svg-text'
7import { sparklinePart } from './svg-meters'
8import { badgePaints, fileType } from './tokens'
9import type { Role } from './tokens'
10import { pick } from './lang'
11
12const HAIRLINE_OPACITY = 0.6
13
14// ---------------------------------------------------------------------------
15// Icon tile: rounded square in the role's tint, an 18 px line icon in its fg.
16
17export type IconTileOpts = { icon: IconName; role?: Role; size?: number }
18
19const TILE = 36
20const TILE_RADIUS = 9
21const TILE_ICON = 18
22const SMALL_TILE = 32
23const SMALL_ICON_RATIO = 0.5
24
25export function iconTilePart(o: IconTileOpts): Part {
26 const size = o.size ?? TILE
27 const glyph = size >= SMALL_TILE ? TILE_ICON : Math.round(size * SMALL_ICON_RATIO)
28 const radius = Math.min(TILE_RADIUS, size / 4)
29 const box = { x: 0, y: 0, width: size, height: size, radius }
30 const fill = o.role ? `${o.role}-tint` : 'track'
31 const body = [
32 rectEl({ ...box, paint: fill }),
33 o.role ? outlineEl({ ...box, paint: `${o.role}-line` }, HAIRLINE_OPACITY) : '',
34 iconEl({ name: o.icon, x: (size - glyph) / 2, y: (size - glyph) / 2, size: glyph, paint: o.role ?? 'mid' }),
35 ]
36
37 return { body: body.join(''), width: size, height: size, alt: o.icon }
38}
39
40export const iconTile = (o: IconTileOpts): SvgPic => toPic(iconTilePart(o))
41
42// ---------------------------------------------------------------------------
43// Pill: h20, full radius, tint + hairline + 11/600 fg; an optional 12 px leading icon.
44
45export type PillOpts = { text: string; role?: Role; icon?: IconName }
46
47const PILL_H = 20
48const PILL_PAD = 8
49const PILL_ICON = 12
50const PILL_ICON_GAP = 4
51const PILL_TEXT = { size: 11, weight: 600 }
52
53const evenUp = (value: number): number => Math.ceil(value / 2) * 2
54
55export function pillPart(o: PillOpts): Part {
56 const paint = o.role ?? 'mid'
57 const textX = o.icon ? PILL_PAD - 1 + PILL_ICON + PILL_ICON_GAP : PILL_PAD
58 const width = evenUp(textX + sans(PILL_TEXT.size, PILL_TEXT.weight)(o.text) + PILL_PAD)
59 const box = { x: 0, y: 0, width, height: PILL_H, radius: PILL_H / 2 }
60 const body = [
61 rectEl({ ...box, paint: o.role ? `${o.role}-tint` : 'track' }),
62 outlineEl({ ...box, paint: o.role ? `${o.role}-line` : 'line-strong' }),
63 o.icon ? iconEl({ name: o.icon, x: PILL_PAD - 1, y: (PILL_H - PILL_ICON) / 2, size: PILL_ICON, paint }) : '',
64 textEl({ x: textX, y: baseline(0, PILL_H, PILL_TEXT.size), text: o.text, paint, ...PILL_TEXT }),
65 ]
66
67 return { body: body.join(''), width, height: PILL_H, alt: o.text }
68}
69
70export const pill = (o: PillOpts): SvgPic => toPic(pillPart(o))
71
72// ---------------------------------------------------------------------------
73// Stat tile: inset, eyebrow label, Display value (mono, tabular), unit, optional sparkline.
74
75export type StatTileOpts = {
76 label: string
77 value: string
78 unit?: string
79 role?: Role
80 width?: number
81 spark?: readonly number[]
82}
83
84const STAT_W = 120
85const STAT_H = 58
86const STAT_RADIUS = 10
87const STAT_PAD = 12
88const STAT_LABEL_TOP = 8
89const UNIT_GAP = 4
90const SPARK_MIN_W = 120
91const SPARK_FROM = 0.55
92const SPARK_TOP = 26
93const SPARK_H = 20
94
95export function statTilePart(o: StatTileOpts): Part {
96 const width = o.width ?? STAT_W
97 const inner = width - STAT_PAD * 2
98 const { size, weight } = TYPE.display
99 const hasSpark = o.spark !== undefined && o.spark.length > 1 && width >= SPARK_MIN_W
100 const valueRoom = hasSpark ? inner * SPARK_FROM : inner
101 const value = fitText(o.value, valueRoom, mono(size))
102 const unitX = STAT_PAD + mono(size)(value) + UNIT_GAP
103 const y = STAT_H - STAT_PAD
104 const label = eyebrowPart({ text: o.label, width: inner, role: o.role })
105 const spark = hasSpark
106 ? sparklinePart({ values: o.spark ?? [], width: Math.round(inner * (1 - SPARK_FROM)), height: SPARK_H, role: o.role, bg: 'inset' })
107 : null
108 const body = [
109 rectEl({ x: 0, y: 0, width, height: STAT_H, radius: STAT_RADIUS, paint: 'inset' }),
110 `<g transform="translate(${STAT_PAD} ${STAT_LABEL_TOP})">${label.body}</g>`,
111 textEl({ x: STAT_PAD, y, text: value, paint: 'hi', size, weight, isMono: true }),
112 o.unit && unitX + sans(TYPE.label.size, TYPE.label.weight)(o.unit) <= STAT_PAD + valueRoom
113 ? textEl({ x: unitX, y, text: o.unit, paint: 'mid', ...TYPE.label })
114 : '',
115 spark ? `<g transform="translate(${num(width - STAT_PAD - spark.width)} ${SPARK_TOP})">${spark.body}</g>` : '',
116 ]
117
118 return { body: body.join(''), width, height: STAT_H, alt: `${o.label}: ${o.value}${o.unit ? ` ${o.unit}` : ''}` }
119}
120
121export const statTile = (o: StatTileOpts): SvgPic => toPic(statTilePart(o))
122
123export const STAT_HEIGHT = STAT_H
124
125// ---------------------------------------------------------------------------
126// File badge: 26×16, kind tint + 9.5/700 mono label; a read/edit activity colour wins.
127
128export type FileBadgeOpts = { name: string; activity?: 'read' | 'edit' }
129
130const BADGE_W = 26
131const BADGE_H = 16
132const BADGE_RADIUS = 4
133const BADGE_TEXT = { size: 9.5, weight: 700 }
134
135export function fileBadgePart(o: FileBadgeOpts): Part {
136 const type = fileType(o.name)
137 const paints = o.activity ? { fg: o.activity, tint: `${o.activity}-tint` } : badgePaints(type.kind)
138 const body = [
139 rectEl({ x: 0, y: 0, width: BADGE_W, height: BADGE_H, radius: BADGE_RADIUS, paint: paints.tint }),
140 textEl({ x: BADGE_W / 2, y: baseline(0, BADGE_H, BADGE_TEXT.size), text: type.label, paint: paints.fg, ...BADGE_TEXT, anchor: 'middle', isMono: true }),
141 ]
142
143 return { body: body.join(''), width: BADGE_W, height: BADGE_H, alt: `${type.label} ${o.name}` }
144}
145
146export const fileBadge = (o: FileBadgeOpts): SvgPic => toPic(fileBadgePart(o))
147
148// ---------------------------------------------------------------------------
149// Size bar: 96×6, a share of the whole (never of the largest row); never invisible when > 0.
150
151export type SizeBarOpts = { size: number; total: number; width?: number; isStrong?: boolean }
152
153const SIZE_W = 96
154const SIZE_H = 6
155
156export function sizeBarPart(o: SizeBarOpts): Part {
157 const width = o.width ?? SIZE_W
158 const ratio = o.total > 0 ? Math.max(0, Math.min(1, o.size / o.total)) : 0
159 const fill = ratio > 0 ? Math.max(SIZE_H, Math.round(width * ratio)) : 0
160 const bar = { x: 0, y: 0, height: SIZE_H, radius: SIZE_H / 2 }
161 const body = [
162 rectEl({ ...bar, width, paint: 'track' }),
163 fill > 0 ? rectEl({ ...bar, width: fill, paint: o.isStrong ? 'mid' : 'lo' }) : '',
164 ]
165
166 return { body: body.join(''), width, height: SIZE_H, alt: `%${Math.round(ratio * 100)}` }
167}
168
169export const sizeBar = (o: SizeBarOpts): SvgPic => toPic(sizeBarPart(o))
170
171// ---------------------------------------------------------------------------
172// Loading skeleton: track bars that shimmer in a stagger (still under reduced motion).
173
174export type SkeletonOpts = { width: number; lines?: number }
175
176const SKELETON_H = 8
177const SKELETON_PITCH = 14
178const SKELETON_WIDTHS = [1, 0.82, 0.6]
179const SKELETON_STAGGER_MS = 160
180
181export function skeletonPart(o: SkeletonOpts): Part {
182 const lines = o.lines ?? SKELETON_WIDTHS.length
183 const bars = Array.from({ length: lines }, (_, i) => {
184 const share = SKELETON_WIDTHS[i % SKELETON_WIDTHS.length] ?? 1
185 const style = ` style="animation-delay:${i * SKELETON_STAGGER_MS}ms"`
186 return rectEl({ x: 0, y: i * SKELETON_PITCH, width: Math.round(o.width * share), height: SKELETON_H, radius: SKELETON_H / 2, paint: 'track', className: 'shimmer' }, style)
187 })
188
189 return { body: bars.join(''), width: o.width, height: (lines - 1) * SKELETON_PITCH + SKELETON_H, alt: pick({ en: 'Loading', tr: 'Yükleniyor' }) }
190}
191
192export const skeleton = (o: SkeletonOpts): SvgPic => toPic(skeletonPart(o))
193hooks/kit/svg-text.ts 133 lines1// MODZ kit — type in Svg: the eyebrow row that opens every card, and wrapped lines
2// (headline, body) with an optional highlighted phrase.
3import { baseline, escapeXml, num, rectEl, sans, TYPE, textEl, toPic, upperTr } from './svg-core'
4import type { Part, SvgPic } from './svg-core'
5import { fitText, wrapText } from './svg-metrics'
6import type { Role } from './tokens'
7
8export const EYEBROW_H = 16
9const DOT = 6
10const DOT_GAP = 6
11const META_GAP = 12
12const RULE_GAP = 8
13const RULE_Y = EYEBROW_H / 2 + 0.5
14const META = TYPE.label
15
16export type EyebrowOpts = {
17 text: string
18 width: number
19 role?: Role
20 hasDot?: boolean
21 meta?: string
22 hasRule?: boolean
23}
24
25const eyebrowTracking = (): number => TYPE.eyebrow.size * TYPE.eyebrow.tracking
26
27// Width of an uppercased, tracked eyebrow label (Svg letter-spacing follows every glyph).
28export const eyebrowWidth = (label: string): number =>
29 sans(TYPE.eyebrow.size, TYPE.eyebrow.weight)(label) + [...label].length * eyebrowTracking()
30
31export function eyebrowPart(o: EyebrowOpts): Part {
32 const paint = o.role ?? 'lo'
33 const y = baseline(0, EYEBROW_H, TYPE.eyebrow.size)
34 const left = o.hasDot ? DOT + DOT_GAP : 0
35 const meta = o.meta ? fitText(o.meta, o.width / 2, sans(META.size, META.weight)) : ''
36 const metaW = meta ? sans(META.size, META.weight)(meta) + META_GAP : 0
37 const label = fitText(upperTr(o.text), o.width - left - metaW, eyebrowWidth)
38 const labelEnd = left + eyebrowWidth(label)
39 const ruleEnd = o.width - metaW
40 const parts = [
41 o.hasDot ? `<circle cx="${DOT / 2}" cy="${EYEBROW_H / 2}" r="${DOT / 2}" class="f-${paint}"/>` : '',
42 textEl({ x: left, y, text: label, paint, ...TYPE.eyebrow, tracking: eyebrowTracking() }),
43 o.hasRule && ruleEnd - labelEnd > RULE_GAP * 2
44 ? `<path d="M${num(labelEnd + RULE_GAP)} ${RULE_Y}H${num(ruleEnd)}" class="s-line"/>`
45 : '',
46 meta ? textEl({ x: o.width, y, text: meta, paint: 'lo', ...META, anchor: 'end' }) : '',
47 ]
48
49 return { body: parts.join(''), width: o.width, height: EYEBROW_H, alt: [label, meta].filter(Boolean).join(' · ') }
50}
51
52export const eyebrow = (o: EyebrowOpts): SvgPic => toPic(eyebrowPart(o))
53
54// ---------------------------------------------------------------------------
55
56export type LinesOpts = {
57 text: string
58 width: number
59 size?: number
60 weight?: number
61 lineHeight?: number
62 paint?: string
63 maxLines?: number
64 mark?: string
65 markRole?: Role
66}
67
68const MARK_PAD = 3
69const MARK_RADIUS = 4
70const MARK_INSET = 1
71const LINE_RATIO = 1.3
72
73type Span = { from: number; to: number }
74type Resolved = LinesOpts & { size: number; weight: number; lineHeight: number; paint: string; maxLines: number }
75
76// Where `mark` falls in each line, as the lines were cut from the words of `text`.
77function markSpans(lines: readonly string[], text: string, mark: string | undefined): (Span | null)[] {
78 const flat = text.split(/\s+/).filter(Boolean).join(' ')
79 const start = mark ? flat.indexOf(mark.trim()) : -1
80 const end = start + (mark?.trim().length ?? 0)
81 let offset = 0
82
83 return lines.map(line => {
84 const visible = line.endsWith('…') ? line.length - 1 : line.length
85 const from = Math.max(start, offset) - offset
86 const to = Math.min(end, offset + visible) - offset
87 offset += line.length + 1
88
89 return start >= 0 && to > from ? { from, to } : null
90 })
91}
92
93function lineMarkup(o: { line: string; span: Span | null; top: number; opts: Resolved }): string {
94 const { line, span, top, opts } = o
95 const measure = sans(opts.size, opts.weight)
96 const y = baseline(top, opts.lineHeight, opts.size)
97 const role = opts.markRole ?? 'warn'
98 const font = `font-size="${opts.size}" font-weight="${opts.weight}"`
99
100 if (span === null) return textEl({ x: 0, y, text: line, size: opts.size, weight: opts.weight, paint: opts.paint })
101
102 const x0 = measure(line.slice(0, span.from))
103 const x1 = measure(line.slice(0, span.to))
104 const rect = rectEl({ x: x0 - MARK_PAD, y: top + MARK_INSET, width: x1 - x0 + MARK_PAD * 2, height: opts.lineHeight - MARK_INSET * 2, radius: MARK_RADIUS, paint: `${role}-tint` })
105 const pieces = [line.slice(0, span.from), line.slice(span.from, span.to), line.slice(span.to)].map(escapeXml)
106
107 return `${rect}<text x="0" y="${y}" class="f-${opts.paint}" ${font}>${pieces[0]}<tspan class="f-${role}">${pieces[1]}</tspan>${pieces[2]}</text>`
108}
109
110export function linesPart(o: LinesOpts): Part {
111 const size = o.size ?? TYPE.headline.size
112 const opts: Resolved = {
113 ...o,
114 size,
115 weight: o.weight ?? TYPE.headline.weight,
116 lineHeight: o.lineHeight ?? Math.round(size * LINE_RATIO),
117 paint: o.paint ?? 'hi',
118 maxLines: o.maxLines ?? 2,
119 }
120 const lines = wrapText(o.text, { max: o.width - MARK_PAD, maxLines: opts.maxLines, measure: sans(size, opts.weight) })
121 const spans = markSpans(lines, o.text, o.mark)
122 const body = lines.map((line, i) => lineMarkup({ line, span: spans[i] ?? null, top: i * opts.lineHeight, opts })).join('')
123
124 return { body, width: o.width, height: lines.length * opts.lineHeight, alt: o.text }
125}
126
127// The card title: 17/620, wraps to `width`, two lines at most.
128export const headline = (o: LinesOpts): SvgPic => toPic(linesPart(o))
129
130// Body copy at 13/400 in text.mid, for a line that must align with Svg type.
131export const bodyPart = (o: LinesOpts): Part =>
132 linesPart({ size: TYPE.body.size, weight: TYPE.body.weight, lineHeight: TYPE.body.line, paint: 'mid', ...o })
133hooks/kit/moving.ts 84 lines1// MODZ kit — a pane's drawings on the desktop, in motion (motion.ts). Any finished Svg source goes in under a key
2// naming its place in the pane; out comes the props for an interactive Svg whose source stays byte-identical while
3// what it shows does (stable.ts), so the desktop keeps its frame and its loops run on. The first draw of a key since
4// the pane opened cascades in with the other new ones, a changed one sweeps once, a row's drawing answers hover, and
5// every source says its colour scheme, else the frame is painted on an opaque white sheet.
6import { entering, motionCss, SWEEP, WASH } from './motion'
7import type { Cue } from './motion'
8import { stableDrawings } from './stable'
9
10export type Pic = { source: string; width?: number; height?: number; alt: string }
11
12export type MovingPic<T extends Pic> = T & { isInteractive: true }
13
14// `isRow`: the drawing is one row of a list, so hover washes it. `shows`: what it shows leaving out time that ticks
15// ("2 dk önce"); a new source with the same `shows` is redrawn without the sweep, as is one drawn at a new width
16// (the pane was resized: every drawing reflows, none of them changed).
17export type MoveOpts = { isRow?: boolean; shows?: unknown }
18
19export type PaneMotion = {
20 // The pane opened again: each drawing enters anew.
21 reset: () => void
22 // `pic` as an interactive Svg's props, its source moving per the drawing's life under `key`, which names one
23 // place in the pane (two drawings under one key would flip between their sources on every draw).
24 pic: <T extends Pic>(key: string, pic: T, opts?: MoveOpts) => MovingPic<T>
25}
26
27// The width an svg's own tag gives it, for a drawing passed without one.
28const TAG_WIDTH = /^[\s\S]*?<svg\b[^>]*?\swidth="([\d.]+)"/
29const SVG = /^([\s\S]*?<svg\b[^>]*>)([\s\S]*)(<\/svg>\s*)$/
30const SCHEME = '<style>:root{color-scheme:light dark}</style>'
31// A scheme the drawing already says (not the `prefers-color-scheme` media query).
32const SAYS_SCHEME = /(^|[^-\w])color-scheme\s*:/
33// Draws within this long of each other are one pass of the pane; a new pass starts its cascade over.
34const PASS_MS = 120
35// The cascade stops lengthening here, so a long list is in place soon.
36const MAX_ORDER = 12
37
38// `source` with its scheme said and, per its cue, its entrance or its sweep; the hover wash for a row.
39export function animate(source: string, spec: { cue: Cue; isRow?: boolean }): string {
40 const parts = SVG.exec(source)
41 if (parts === null) return source
42 const [, open, inner, close] = parts
43 const scheme = SAYS_SCHEME.test(inner) ? '' : SCHEME
44 const lit = `${spec.isRow === true ? WASH : ''}${inner}${spec.cue.phase === 'change' ? SWEEP : ''}`
45 const body = entering(lit, spec.cue)
46 const css = motionCss(body)
47
48 return `${open}${scheme}${css === '' ? '' : `<style>${css}</style>`}${body}${close}`
49}
50
51export function paneMotion(): PaneMotion {
52 const drawings = stableDrawings<string>()
53 const shown = new Map<string, { shows: string; width: string }>()
54 const pass = { at: Number.NEGATIVE_INFINITY, entered: 0 }
55 const begin = () => {
56 const now = Date.now()
57 if (now - pass.at > PASS_MS) pass.entered = 0
58 pass.at = now
59 }
60
61 return {
62 reset: () => {
63 drawings.reset()
64 shown.clear()
65 },
66 pic: (key, pic, opts = {}) => {
67 begin()
68 const what = `${opts.isRow === true ? 'row:' : ''}${pic.source}`
69 const now = { shows: opts.shows === undefined ? what : JSON.stringify(opts.shows), width: String(pic.width ?? TAG_WIDTH.exec(pic.source)?.[1]) }
70 const source = drawings.draw(key, what, built => {
71 const order = built === 'enter' ? Math.min(MAX_ORDER, pass.entered++) : 0
72 const before = shown.get(key)
73 const isQuiet = before !== undefined && (before.shows === now.shows || before.width !== now.width)
74 const phase = built === 'change' && isQuiet ? 'still' : built
75 shown.set(key, now)
76
77 return animate(pic.source, { cue: { phase, order }, isRow: opts.isRow })
78 })
79
80 return { ...pic, source, isInteractive: true }
81 },
82 }
83}
84hooks/kit/ui-buttons.ts 66 lines1// MODZ kit — the rules of a button row, as pure data (DESIGN.md §4): at most one primary
2// (the suggested path), then secondary, then plain. On danger the safe choice holds the
3// focus, a destructive button is never the inviting primary, and it gets a letter hotkey.
4import type { UiPressArgument } from 'claude-code'
5
6export type ButtonVariant = 'primary' | 'secondary' | 'plain'
7
8export type ButtonSpec = {
9 label: string
10 onPress: (e: UiPressArgument) => void
11 variant?: ButtonVariant
12 hotkey?: string
13 key?: string
14 isSafe?: boolean
15 isDestructive?: boolean
16}
17
18export type PlannedButton = ButtonSpec & { variant: ButtonVariant; isFocused: boolean }
19
20const RANK: Record<ButtonVariant, number> = { primary: 0, secondary: 1, plain: 2 }
21const ASCII: Record<string, string> = { ç: 'c', ğ: 'g', ı: 'i', ö: 'o', ş: 's', ü: 'u' }
22
23// The first letter of the label that is free as a hotkey, folded to a–z.
24function letterFor(label: string, taken: ReadonlySet<string>): string | undefined {
25 for (const char of label.toLocaleLowerCase('tr')) {
26 const letter = ASCII[char] ?? char
27 if (/^[a-z]$/.test(letter) && !taken.has(letter)) return letter
28 }
29
30 return undefined
31}
32
33function demote(buttons: readonly ButtonSpec[], isDanger: boolean): ButtonSpec[] {
34 let hasPrimary = false
35
36 return buttons.map(button => {
37 const wantsPrimary = (button.variant ?? 'secondary') === 'primary' && !(isDanger && button.isDestructive)
38 const fallback: ButtonVariant = button.variant === 'plain' ? 'plain' : 'secondary'
39 const variant: ButtonVariant = wantsPrimary && !hasPrimary ? 'primary' : fallback
40 hasPrimary = hasPrimary || variant === 'primary'
41 return { ...button, variant }
42 })
43}
44
45function focusIndex(buttons: readonly ButtonSpec[], isDanger: boolean): number {
46 if (!isDanger) return -1
47
48 const safe = buttons.findIndex(button => button.isSafe)
49 return safe >= 0 ? safe : buttons.findIndex(button => !button.isDestructive)
50}
51
52export function planButtons(buttons: readonly ButtonSpec[], isDanger = false): PlannedButton[] {
53 const ordered = demote(buttons, isDanger)
54 .map((button, i) => ({ button, i }))
55 .sort((a, b) => RANK[a.button.variant ?? 'secondary'] - RANK[b.button.variant ?? 'secondary'] || a.i - b.i)
56 .map(({ button }) => button)
57 const focus = focusIndex(ordered, isDanger)
58 const taken = new Set(ordered.map(button => button.hotkey).filter((key): key is string => key !== undefined))
59
60 return ordered.map((button, i) => {
61 const hotkey = button.hotkey ?? (isDanger && button.isDestructive ? letterFor(button.label, taken) : undefined)
62 if (hotkey) taken.add(hotkey)
63 return { ...button, variant: button.variant ?? 'secondary', hotkey, isFocused: i === focus }
64 })
65}
66