SLOPSHOPPER

kit-gallery

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

newpanecommand
★ 2v0.1.0MITupdated 2026-10-16partalemre/MODZ/tools/kit-gallery
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kit-gallery
│ ┃ Kit galerisi ✕ › fix the failing auth test and add an audit log call │ ┃ ● KARTLAR okuma · tehlike · uyarı · durumlar │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ │ ⏺ Update(src/auth.ts) │ ┃ │ ◉ ŞU AN 2 dk önce │ ⎿ Added 2 lines, removed 1 line │ ┃ │ Claude client.test.js dosyasını okuyor │ ⏺ Bash(bun test) │ ┃ │ tests klasöründe · bu turda 3 dosya │ ⎿ 3 pass, 1 fail │ ┃ │ açıldı │ │ ┃ │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Bağlam ━━━━━━━╸──────────── 42% │ │ ┃ │ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ [ Dosyayı aç ] [ Geçmiş ] Şimdi değil │ │ ┃ │ │ › /kit-gallery │ ┃ ╰──────────────────────────────────────────╯ ⎿ kit-gallery: Kit galerisi açıldı. │ ┃ │ ┃ ┌─────────────────────────────────────────── │ ┃ │ │ ┃ │ ⌫ GERI ALINAMAZ demo-app │ ┃ │ rm -rf build 9 dosyayı silecek · 286 │ ┃ │ KB │ ┃ │ Hiçbiri git'te izlenmiyor; silinince │ ┃ │ geri getirilemez. │ ┃ │ │ ┃ │ vendor.81d3aa.js 79 KB │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Kit galerisi
● KARTLAR okuma · tehlike · uyarı · durumlar ╭──────────────────────────────────────────────────────────╮ │ │ │ ◉ ŞU AN 2 dk önce │ │ Claude client.test.js dosyasını okuyor │ │ tests klasöründe · bu turda 3 dosya açıldı │ │ │ │ Bağlam ━━━━━━━╸──────────── 42% │ │ │ │ [ Dosyayı aç ] [ Geçmiş ] Şimdi değil │ │ │ ╰──────────────────────────────────────────────────────────╯ ┌──────────────────────────────────────────────────────────┐ │ │ │ ⌫ GERI ALINAMAZ demo-app │ │ rm -rf build 9 dosyayı silecek · 286 KB │ │ Hiçbiri git'te izlenmiyor; silinince geri getirilemez. │ │ │ │ vendor.81d3aa.js 79 KB │ │ │ │ app.4f9c2e.js 63 KB │ │ │ │ vendor.81d3aa.js.map 35 KB │ │ │ │ inter-var.woff2 47 KB │ │ │ │ [ 9 dosyayı sil ] [ İptal ] m: 3 dosya daha │ │ │ └──────────────────────────────────────────────────────────┘ ╭─────────────────────────────────────────────────────────── │ │ ◷ ÖNBELLEK SOĞUDU 6 sa 38 dk önce │ Mesajın bekletiliyor │ Şimdi göndermek tüm bağlamı yeniden yazar. │ │ Şimdi gönder $0,52 Sıcakken $0,03 Yeniden yazılır 51, │ │ [ Öde ve gönder ] [ Yeni başla ] Önbellek durumu │ ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ │ │ ❏ KURAL KAYDEDILDI %95 emin │ │ Bu projeye kaydedildi │ │ │ │ Claude bir sonraki mesajdan itibaren pnpm kullanacak. │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ │ │ ● PLAN YOK │ │ │ │ Bu oturumda henüz bir plan yok. │ │ │ │ [ Plan başlat ] │ │ │ ╰──────────────────────────────────────────────────────────╯ ┌──────────────────────────────────────────────────────────┐ │ │ │ ✕ HATA │ │ Testler çalıştırılamadı │ │ │ │ npm test 127 ile çıktı: komut bulunamadı. │ │ │ │ [ Tekrar dene ] │ │ │ └──────────────────────────────────────────────────────────┘ ● BAŞLIK BLOKLARI 7 rol + nötr ╭──────────────────────────────────────────────────────────╮ │ │ │ ❯ AJAN ÇALIŞIYOR 0:43 │ │ Açılış sayfası hazırlanıyor │ │ 4/5 · Kayıt formu │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ │ │ ✎ BITTI │ │ Claude 1 dosyayı düzenledi │ │ src/api/client.js +1 −1 │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ │ │ ≡ DÜZENLEME 2 / 4 │ │ greet.js │ │ src/greet.js · Edit │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ │ │ ✧ BILMEN GEREKEN │ │ Her Kling klibi yaklaşık $0,45 tutuyor; uzun bir │ │ başlık iki satırı aşarsa üç nokta ile kesilir ve kart │ │ düzeni bozulmaz │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ │ │ ◈ SIR BULUNDU .env:3 │ │ sk-…9f2A anahtarı commit edilmek üzere │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ │ │ ◔ DÜŞÜNÜYOR │ │ Henüz dosyaya dokunulmadı │ │ │ ╰──────────────────────────────────────────────────────────╯ ● TIPOGRAFI eyebrow · headline ╭──────────────────────────────────────────────────────────╮ │ │ │ ● TÜM DOSYALAR WEATHER-APP │ │ │ │ Tek satırlık başlık │ │ │ │ Vurgulu başlık: her klip yaklaşık $0,45 tutuyor │ │ │ │ Başlık 14/600 boyutunda da kullanılabilir │ │ │ ╰──────────────────────────────────────────────────────────╯ ● İKONLAR 25 çizim · 24 ızgara · 1.75 ╭──────────────────────────────────────────────────────────╮ │ │ │ ⌫ trash ◈ shield ⚷ key ◉ eye ✎ pencil ✓ check ✕ │ │ x ◷ clock ▣ cube ❏ bookmark ✧ bulb ▸ play ‖ │ │ pause ▤ folder ▢ file ⑂ branch ❯ terminal ◬ bell │ │ ✦ sparkle ≡ list ⚑ flag ϟ bolt ◔ gauge « rewind │ │ ⏚ plug │ │ │ │ gauge sparkle sparkle sparkle │ │ │ ╰──────────────────────────────────────────────────────────╯ ● SAYILAR stat · sparkline ╭─────────────────────────────────────────────────────────── │ │ Maliyet ≈$0,33 Token 342k Süre 1:01 dk │ │ Testler: 41 / 42 Lint: 2 hata Bağlam: %84 │ │ 10 values, last 21 10 values, last 21 10 values, last 21 │ ╰─────────────────────────────────────────────────────────── ● ÖLÇERLER tempo çentiği · halka ╭──────────────────────────────────────────────────────────╮ │ │ │ Tempoda (nötr) ━━━━━━━╸┊─────────── 38% │ │ │ │ Tempodan önde → Amber ━━━━━━━━━━━╸──────── 62% │ │ │ │ %95 ve üstü → Flare ━━━━━━━━━━━━━━━━━━╸─ 97% │ │ │ │ Dolu (asla >%100) ━━━━━━━━━━━━━━━━━━━━ full │ │ │ │ Rol rengi, tempo yok ━━━━━━━━━━╸───────── 55% │ │ │ │ Kahraman (h6) ━━━━━╸────┊───────── 30% │ │ │ │ Boş ──────────────────── 0% │ │ │ ╰──────────────────────────────────────────────────────────╯ ● ADIMLAR noktalar · numaralı daire ╭──────────────────────────────────────────────────────────╮ │ │ │ 2/5 · 3/5 · Testler çalışıyor │ │ │ │ 1/4 · Lint başarısız │ │ │ │ 3/4 · Gözden geçirme │ │ │ │ 4/4 · Hepsi bitti │ │ │ │ step 1, done README.md │ │ step 2, done greet.js src içinde │ │ step 3, current cli.js src içinde │ │ step 4, next index.js src içinde │ │ │ ╰──────────────────────────────────────────────────────────╯ ● DOSYA ROZETLERI tür · etkinlik ╭─────────────────────────────────────────────────────────── │ │ TS client.ts TSX App.tsx PY train.py MD NOTES.md TXT todo │ │ TS client.ts TS client.ts │ ╰─────────────────────────────────────────────────────────── ● PILL VE VURGU 7 rol ╭─────────────────────────────────────────────────────────── │ │ Okunuyor Düzenlendi 2 ajan 3 geçti Bekliyor 1 başarısız 3 │ │ ✓ 3 geçti ✕ 1 başarısız ⑂ Commit Nötr │ │ Her klip yaklaşık $0,45 tutuyor; kaynak Kie AI. │ ╰─────────────────────────────────────────────────────────── ● YÜKLENIYOR 1.4 sn parıltı ╭──────────────────────────────────────────────────────────╮ │ │ │ ● YÜKLENIYOR │ │ │ │ ◐ Yükleniyor… │ │ │ ╰──────────────────────────────────────────────────────────╯
README

<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>

Install

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.

  • Where it draws. The Claude desktop app's Code tab draws everything. The terminal draws the same panels as text.
  • Requirements. Claude Code 2.1.295 or later, tested on 2.1.295 and 2.1.296. terminal-browser draws in the terminal only. Outside the desktop app, done-ping falls back to a macOS banner.
  • Updates. Automatic and free. The first session after you install turns on Claude Code's own auto-update for the 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.
  • Language. The interface is in English. Set MODZ_LANG=tr for Turkish.
  • Transceiver needs a claude.ai login. If Claude Code runs inside another app (an IDE, 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" } }

One panel. Every mod, live.

<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.

Every agent gets a face.

<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.

Risky commands wait for your OK.

<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.

Your team, inside Claude. <sup>beta</sup>

<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

The mods

ModWhat it does
PanelmodzThe panel itself: one live line per mod and one click to each pane
Worksavvy-progressAgents pane and progress band: each subagent's progress, cost and time; other chats waiting on you; a home crab that levels up
savvy-flowOrchestrated delivery: the session model plans, tiered worker agents build
filetreeFiles Claude touched this turn, what it is doing now, git status
plan-stepsThe 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
handoffBefore the context fills, writes a handoff note and hands it once to your next fresh chat
transceiverTeam chat and zero-token agent questions between teammates (beta)
Safetyblast-radiusHolds 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-guardStops 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
checkpointA silent git snapshot before each turn's first write; undo a turn
test-badgeRuns tests and lint after edits and shows the result; watches your PR's GitHub CI
replay-theaterReplays the last turn's edits step by step, and earlier turns and past sessions
Metersession-budgetA spend budget per session, with a stop at 100%
cache-taxKeeps the prompt cache warm through breaks and prices a cold send, a model change included
hud-proOne 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-routerShows each turn's effort and model; can send light prompts at low effort
Comfortdone-pingA notification when a long turn ends or Claude waits on you, with optional sound packs
reflect-modSaves corrections worth keeping to your CLAUDE.md; /mine turns friction that repeats across past chats into ideas
skinsThemes for the transcript, switched live with /skin
terminal-browserA 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.
  • Transceiver's free-form 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.

What each mod can touch

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.

ModReadsWritesRunsNetworkModel
modzmarketplace, settingsstore; once, autoUpdate for its marketplace in user settings
savvy-progresstranscriptstoreagents you start
savvy-flowits worker agents
filetreeproject filesgit, open
plan-stepstask list, plan filestore
transceiverrepo, gitits roomgitclaude.aifree-form ask
handofftranscript, plan.claude/handoff/*.md, storegit status, rm (own notes)optional
blast-radius.claude/house-rules.mdstoregit, a read-only look at what a held command would touchJev, opt-inwith house rules
secret-guardwritten files.env, .gitignore (on your press)git
checkpointstore, git refsgit
test-badgeproject filesstoreyour test and lint commands, git, ghGitHub, through gh
replay-theatertranscriptsstorerarely
session-budgetstore
cache-taxstorewhen warming
hud-procontext breakdownstore
effort-routerpromptsstoreJev, opt-inauto only
done-pingstoreosascript, terminal-notifier, afplay
reflect-modtranscripts, CLAUDE.mdCLAUDE.md (on your press), storetailyes
skinssettingsstore
terminal-browserweb pagesa browseryes

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.

Make it yours

  • Arrange the panel. Press Customize at the bottom of the MODZ panel: move rows up, hide the ones you do not use, and bring them back from Hidden. Reset puts everything back. Your layout is kept across sessions.
  • Get back from any pane. Every pane the panel opens has a ← MODZ button at the top. It brings the panel forward, and opens it again if it was closed. /modz does the same.
  • Choose what loads. Each mod is its own plugin: install or uninstall any of them, and the panel follows. /plugin configure modz@modz turns off opening at session start.

Add your own mod to the panel

The panel lists any mod that reports to it, with no change to MODZ. Your mod talks to it through $.state:

  1. Report a line. Write your own { 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.
  2. Open on request. Hook state.set on { plugin: 'modz', key: 'open' }; when its pane is yours, call $.ui.open({ id, title, focus: true }).
  3. Offer the way back. While { 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.

For AI agents

If a person asks you to install MODZ, do exactly this. Every step is safe to repeat.

  1. Check the version: claude --version must print 2.1.295 or later. If it is older, ask the person to update Claude Code first.
  2. Add the marketplace: claude plugin marketplace add partalemre/MODZ.
  3. Install the panel: 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.
  4. Optional settings: put them in the env block of ~/.claude/settings.json, merging with what is already there and never overwriting it:
  5. "MODZ_LANG": "tr" for a Turkish interface.
  6. "CLAUDE_CODE_ARTIFACT": "1" if Transceiver is installed and this Claude Code is driven by an SDK host.
  7. Verify: claude plugin list shows each <name>@modz as enabled.
  8. Tell the person to start a new session, or run /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.

Develop

mods/<name>/ holds each mod; kit/ is the shared drawing kit, copied into the mods by bin/kit-sync.

  • Unit checks. bin/modz check [name] validates each mod and runs its tests, on both the terminal engine and the desktop app's engine.
  • End to end. 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.
  • Releases. No mod sets 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.
  • Types. Claude Code writes its API types into .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.
  • Kit gallery. tools/kit-gallery draws every kit component; its tests check contrast in both themes.
  • Design rules. DESIGN.md (in Turkish) covers colour, type, components and the rules every mod follows.

Credits

Several mods are adaptations of open-source projects, each under its original license, which lives in that mod's folder:

ModBased onLicense
blast-radius, replay-theateranthropics/claude-code-playgroundApache-2.0
cache-taxkaranb192/cache-taxMIT
filetreedata-goblin/claude-code-filetreeMIT
reflect-modbayramannakov/claude-reflectMIT
savvy-progress, savvy-flowjohnnyvizz/claude-kitMIT
skinshellosverre/claude-skinsMIT
terminal-browserzenbu-labs/terminal-browserMIT

Everything else is MIT.

Source 22 files
hooks/register.tsx 52 lines
1import 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}
52
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
hooks/kit/ui.tsx 254 lines
1// 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'
254
hooks/sections.tsx 24 lines
1import 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]
24
hooks/kit/icons.ts 70 lines
1// 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}
70
hooks/kit/svg-compose.ts 70 lines
1// 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))
70
hooks/kit/svg-core.ts 112 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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}"` : ''}/>`
112
hooks/kit/svg-meters.ts 192 lines
1// 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))
192
hooks/kit/svg-shapes.ts 193 lines
1// 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))
193
hooks/kit/svg-text.ts 133 lines
1// 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 })
133
hooks/kit/moving.ts 84 lines
1// 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}
84
hooks/kit/ui-buttons.ts 66 lines
1// 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