From 0b8a06730aed4effcd0dcc7178747306bddf6062 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 7 Oct 2026 15:00:47 +0300 Subject: [PATCH 01/11] feat(catalog): parse, version-gate and cache the pack index Signed-off-by: NovusEdge --- hooks/catalog.ts | 73 ++++++++++++++++++++++++++++++++++++++ test/catalog.test.ts | 84 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 hooks/catalog.ts create mode 100644 test/catalog.test.ts diff --git a/hooks/catalog.ts b/hooks/catalog.ts new file mode 100644 index 0000000..ebe89c0 --- /dev/null +++ b/hooks/catalog.ts @@ -0,0 +1,73 @@ +// JSX-free: the docs site may import it. +import type { Host } from './host.ts' +import { parseJsonc, isUnsafe, shown } from './themes.ts' +import { packNameProblem } from './packs.ts' + +export type CatalogEntry = { name: string; description: string; pack: string; themes: string[]; minGlowup: string } + +export const CATALOG_URL = 'https://glowup.khimani.dev/packs.json' +export const CATALOG_FILE = (configDir: string) => `${configDir}/glowup/catalog.json` + +// The pack subcommands: a pack with one of these names could never be picked by name. +const RESERVED = ['list', 'save', 'update'] +const MAX_ENTRIES = 100 +const VERSION = /^\d+(\.\d+)*$/ +const isHttps = (v: unknown): v is string => typeof v === 'string' && v.startsWith('https://') +const printable = (s: string) => [...s].every(c => !isUnsafe(c.codePointAt(0)!)) + +function entryOf(v: unknown): CatalogEntry | string { + if (typeof v !== 'object' || v === null || Array.isArray(v)) return 'an entry is not an object' + const e = v as Record + const name = typeof e.name === 'string' ? e.name : '' + const label = `entry "${shown(name)}"` + const problem = packNameProblem(name) ?? (RESERVED.includes(name) ? `"${name}" is a /glowup pack subcommand` : undefined) + if (problem) return `${label}: ${problem}` + if (typeof e.description !== 'string' || e.description.length > 80 || !printable(e.description)) return `${label}: description must be printable text of at most 80 characters` + if (!isHttps(e.pack)) return `${label}: pack must be an https:// URL` + const themes = e.themes ?? [] + if (!Array.isArray(themes) || !themes.every(isHttps)) return `${label}: themes must be https:// URLs` + if (typeof e.minGlowup !== 'string' || !VERSION.test(e.minGlowup)) return `${label}: minGlowup must be a version such as 0.10.0` + return { name, description: e.description, pack: e.pack, themes, minGlowup: e.minGlowup } +} + +export function parseCatalog(json: unknown): { entries: CatalogEntry[]; errors: string[] } | undefined { + if (typeof json !== 'object' || json === null || Array.isArray(json)) return undefined + const { format, packs } = json as { format?: unknown; packs?: unknown } + if (format !== 1 || !Array.isArray(packs)) return undefined + const entries: CatalogEntry[] = [], errors: string[] = [] + for (const p of packs.slice(0, MAX_ENTRIES)) { + const e = entryOf(p) + if (typeof e === 'string') errors.push(e) + else if (entries.some(x => x.name === e.name)) errors.push(`entry "${e.name}" is listed twice; the first is kept`) + else entries.push(e) + } + return { entries, errors } +} + +export function compareVersions(a: string, b: string): number { + const pa = a.split('.').map(Number), pb = b.split('.').map(Number) + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] ?? 0) - (pb[i] ?? 0) + if (d) return d + } + return 0 +} + +// A glowup that cannot read its own version runs no catalog pack: it may be older than any of them. +export const canRun = (entry: CatalogEntry, own: string | undefined) => + own !== undefined && VERSION.test(own) && compareVersions(own, entry.minGlowup) >= 0 + +export async function loadCatalog(host: Host): Promise { + try { return parseCatalog(parseJsonc(await host.readFile(CATALOG_FILE(host.configDir))))?.entries ?? [] } catch { return [] } +} + +export async function refreshCatalog(host: Host): Promise { + let r: Awaited> + try { r = await host.fetchText(CATALOG_URL) } catch { return undefined } + if (!r.ok) return undefined + let parsed: ReturnType + try { parsed = parseCatalog(parseJsonc(r.text)) } catch { return undefined } + if (!parsed) return undefined + await host.writeFile(CATALOG_FILE(host.configDir), r.text) + return parsed.entries +} diff --git a/test/catalog.test.ts b/test/catalog.test.ts new file mode 100644 index 0000000..493bdad --- /dev/null +++ b/test/catalog.test.ts @@ -0,0 +1,84 @@ +import { test, expect } from 'claude-code/testing' +import { fakeHost } from './kit.ts' +import { parseCatalog, compareVersions, canRun, loadCatalog, refreshCatalog, CATALOG_URL, CATALOG_FILE, type CatalogEntry } from '../hooks/catalog.ts' + +const OXIDE = { name: 'oxide', description: 'bone text and red oxide on warm ink', pack: 'https://example.com/oxide.json', themes: ['https://example.com/themes/oxide.json'], minGlowup: '0.8.1' } +const index = (packs: unknown[], format = 1) => ({ format, packs }) + +test('a valid index gives its entries', async () => { + expect(parseCatalog(index([OXIDE]))).toEqual({ entries: [OXIDE], errors: [] }) +}) + +test('a non-object, a missing packs array or another format is no index', async () => { + for (const bad of [null, [], 'x', { format: 1 }, index([OXIDE], 2), { packs: [OXIDE] }]) expect(parseCatalog(bad)).toBeUndefined() +}) + +test('bad entries are dropped one by one, the rest kept', async () => { + const r = parseCatalog(index([ + OXIDE, + { ...OXIDE, name: 'Bad Name' }, + { ...OXIDE, name: 'classic' }, + { ...OXIDE, name: 'update' }, + { ...OXIDE, name: 'plain', pack: 'http://example.com/p.json' }, + { ...OXIDE, name: 'long', description: 'x'.repeat(81) }, + { ...OXIDE, name: 'nover', minGlowup: undefined }, + { ...OXIDE, name: 'badtheme', themes: ['ftp://x'] }, + ]))! + expect(r.entries.map(e => e.name)).toEqual(['oxide']) + expect(r.errors.length).toBe(7) +}) + +test('unknown entry keys are ignored and themes default to empty', async () => { + const { themes: _, ...noThemes } = OXIDE + expect(parseCatalog(index([{ ...noThemes, preview: 'x' }]))!.entries).toEqual([{ ...noThemes, themes: [] }]) +}) + +test('a repeated name keeps the first entry', async () => { + const r = parseCatalog(index([OXIDE, { ...OXIDE, description: 'second' }]))! + expect(r.entries).toEqual([OXIDE]) + expect(r.errors.length).toBe(1) +}) + +test('at most 100 entries are read', async () => { + const many = Array.from({ length: 120 }, (_, i) => ({ ...OXIDE, name: `p${i}` })) + expect(parseCatalog(index(many))!.entries.length).toBe(100) +}) + +test('versions compare per dotted number', async () => { + expect(compareVersions('0.9.0', '0.10.0')).toBeLessThan(0) + expect(compareVersions('0.10.0', '0.9.9')).toBeGreaterThan(0) + expect(compareVersions('1.0', '1.0.0')).toBe(0) +}) + +test('an entry runs only on a glowup at least its minGlowup', async () => { + const e: CatalogEntry = { ...OXIDE, minGlowup: '0.10.0' } + expect([canRun(e, '0.10.0'), canRun(e, '0.10.1'), canRun(e, '0.9.0'), canRun(e, undefined), canRun(e, 'dev')]).toEqual([true, true, false, false, false]) +}) + +test('refresh writes a valid index to the cache and returns it', async () => { + const text = JSON.stringify(index([OXIDE])) + const { host, files } = fakeHost({ fetches: { [CATALOG_URL]: text } }) + expect(await refreshCatalog(host)).toEqual([OXIDE]) + expect(files[CATALOG_FILE(host.configDir)]).toBe(text) + expect(await loadCatalog(host)).toEqual([OXIDE]) +}) + +test('a failed or invalid fetch leaves the cache', async () => { + const cached = JSON.stringify(index([OXIDE])) + for (const fetches of [{} as Record, { [CATALOG_URL]: 'not json' }, { [CATALOG_URL]: JSON.stringify(index([OXIDE], 9)) }]) { + const { host, files } = fakeHost({ fetches, files: { [CATALOG_FILE('/home/u/.claude')]: cached } }) + expect(await refreshCatalog(host)).toBeUndefined() + expect(files[CATALOG_FILE(host.configDir)]).toBe(cached) + } +}) + +test('a thrown fetch is a failed refresh, not an error', async () => { + const { host } = fakeHost() + host.fetchText = async () => { throw new Error('timed out after 10 s') } + expect(await refreshCatalog(host)).toBeUndefined() +}) + +test('no cache, or a broken one, loads as an empty catalog', async () => { + expect(await loadCatalog(fakeHost().host)).toEqual([]) + expect(await loadCatalog(fakeHost({ files: { [CATALOG_FILE('/home/u/.claude')]: '{' } }).host)).toEqual([]) +}) From 0e7a1e5a31d728f7e43d67c55dd6d99b56f4c91d Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 7 Oct 2026 15:11:50 +0300 Subject: [PATCH 02/11] feat(catalog): publish the oxide packs in packs.json Signed-off-by: NovusEdge --- docs/web/public/packs.json | 19 +++++++++++++++++++ package.json | 2 +- test/catalog-index.check.ts | 13 +++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 docs/web/public/packs.json create mode 100644 test/catalog-index.check.ts diff --git a/docs/web/public/packs.json b/docs/web/public/packs.json new file mode 100644 index 0000000..2adf005 --- /dev/null +++ b/docs/web/public/packs.json @@ -0,0 +1,19 @@ +{ + "format": 1, + "packs": [ + { + "name": "oxide", + "description": "bone text and red oxide on warm ink", + "pack": "https://raw.githubusercontent.com/NovusEdge/glowup-oxide/main/oxide.json", + "themes": ["https://raw.githubusercontent.com/NovusEdge/glowup-oxide/main/themes/oxide.json"], + "minGlowup": "0.8.1" + }, + { + "name": "oxide-paper", + "description": "red oxide and ink on bone, with a dithered orb", + "pack": "https://raw.githubusercontent.com/NovusEdge/glowup-oxide/main/oxide-paper.json", + "themes": [], + "minGlowup": "0.8.1" + } + ] +} diff --git a/package.json b/package.json index d2e60fd..3db9a93 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "engines": { "node": ">=24" }, "scripts": { "check": "claude plugin validate --strict . && claude plugin validate --strict .claude-plugin/plugin.json && tsc -p .", - "test": "claude plugin test . && node test/pack-skill.check.ts && node test/statusline-script.check.ts" + "test": "claude plugin test . && node test/pack-skill.check.ts && node test/statusline-script.check.ts && node test/catalog-index.check.ts" }, "devDependencies": { "typescript": "^7.0.2" } } diff --git a/test/catalog-index.check.ts b/test/catalog-index.check.ts new file mode 100644 index 0000000..7a259d5 --- /dev/null +++ b/test/catalog-index.check.ts @@ -0,0 +1,13 @@ +// Run by `pnpm test` under node: the engine's test runner cannot read files in the repo. +import { readFileSync } from 'node:fs' +import { parseCatalog, compareVersions } from '../hooks/catalog.ts' + +const dir = decodeURIComponent(import.meta.url.replace(/^file:\/\//, '')).replace(/[^/]*$/, '') +const index = JSON.parse(readFileSync(`${dir}../docs/web/public/packs.json`, 'utf8')) +const own = JSON.parse(readFileSync(`${dir}../.claude-plugin/plugin.json`, 'utf8')).version as string +const parsed = parseCatalog(index) +const fail = (m: string): never => { throw new Error(`docs/web/public/packs.json: ${m}`) } +if (!parsed) fail('not a format 1 index') +if (parsed!.errors.length) fail(parsed!.errors.join('; ')) +if (parsed!.entries.length !== index.packs.length) fail('an entry was dropped') +for (const e of parsed!.entries) if (compareVersions(e.minGlowup, own) > 0) fail(`${e.name} needs glowup ${e.minGlowup}, newer than this ${own}`) From 9ad76819834b30e4a57a4e6c24a5c1d4039b66c3 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 7 Oct 2026 15:27:06 +0300 Subject: [PATCH 03/11] feat(catalog): install a catalog pack only after every file passes Signed-off-by: NovusEdge --- hooks/catalog.ts | 66 ++++++++++++++++++++++++++++++++++++++++++ hooks/userpacks.ts | 14 +++++++-- hooks/userthemes.ts | 23 ++++++++++----- test/catalog.test.ts | 69 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 160 insertions(+), 12 deletions(-) diff --git a/hooks/catalog.ts b/hooks/catalog.ts index ebe89c0..5396807 100644 --- a/hooks/catalog.ts +++ b/hooks/catalog.ts @@ -2,6 +2,8 @@ import type { Host } from './host.ts' import { parseJsonc, isUnsafe, shown } from './themes.ts' import { packNameProblem } from './packs.ts' +import { checkThemeText, THEME_DIR } from './userthemes.ts' +import { checkPackText, PACK_DIR } from './userpacks.ts' export type CatalogEntry = { name: string; description: string; pack: string; themes: string[]; minGlowup: string } @@ -71,3 +73,67 @@ export async function refreshCatalog(host: Host): Promise `${configDir}/glowup/catalog-installed.json` +export type CatalogRecord = { packs: Record; themes: Record } + +// FNV-1a: detects "the file is still what glowup wrote", not tampering. +export function textHash(text: string): string { + let h = 0x811c9dc5 + for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 0x01000193) } + return (h >>> 0).toString(16).padStart(8, '0') +} + +export async function loadRecord(host: Host): Promise { + const out: CatalogRecord = { packs: {}, themes: {} } + let v: any + try { v = parseJsonc(await host.readFile(RECORD_FILE(host.configDir))) } catch { return out } + for (const [k, p] of Object.entries(v?.packs ?? {})) if (typeof (p as any)?.url === 'string' && typeof (p as any)?.hash === 'string') out.packs[k] = { url: (p as any).url, hash: (p as any).hash } + for (const [k, t] of Object.entries(v?.themes ?? {})) if (typeof (t as any)?.hash === 'string') out.themes[k] = { hash: (t as any).hash } + return out +} + +async function download(host: Host, url: string, what: string): Promise { + try { + const r = await host.fetchText(url) + return r.ok ? r.text : { message: `Could not download ${what} (HTTP ${r.status}).` } + } catch (err) { return { message: `Could not download ${what}: ${err instanceof Error ? err.message : String(err)}` } } +} + +// Unchanged since glowup wrote it: the only state in which glowup may replace a file. +async function untouched(host: Host, path: string, hash: string | undefined) { + if (hash === undefined) return false + try { return textHash(await host.readFile(path)) === hash } catch { return false } +} + +// Everything is downloaded and checked before the first write, so a refused install leaves no file behind. +export async function installEntry(host: Host, entry: CatalogEntry, opts: { force: boolean }): Promise<{ name?: string; message: string }> { + const packText = await download(host, entry.pack, entry.name) + if (typeof packText !== 'string') return packText + let named: unknown + try { named = (parseJsonc(packText) as { name?: unknown } | null)?.name } catch { named = undefined } + if (typeof named !== 'string' || named.toLowerCase() !== entry.name) return { message: `The catalog's ${entry.name} downloads a pack named "${shown(String(named))}"; nothing was installed.` } + const record = await loadRecord(host) + const themes: { name: string; text: string; file: unknown }[] = [] + for (const url of entry.themes) { + const text = await download(host, url, `${entry.name}'s theme`) + if (typeof text !== 'string') return text + const checked = await checkThemeText(host, text, Object.fromEntries(themes.map(t => [t.name, t.file]))) + if ('error' in checked) return { message: `${entry.name}: ${checked.error}` } + themes.push({ name: checked.name, text, file: checked.file }) + } + const pack = await checkPackText(host, packText, opts.force, Object.fromEntries(themes.map(t => [t.name, t.file]))) + if ('message' in pack) return pack + const dir = THEME_DIR(host.configDir) + for (const t of themes) { + const path = `${dir}/${t.name}.json` + const own = Object.hasOwn(record.themes, t.name) ? record.themes[t.name]!.hash : undefined + if (await host.exists(path) && !(opts.force && await untouched(host, path, own))) continue + await host.writeFile(path, t.text) + record.themes[t.name] = { hash: textHash(t.text) } + } + await host.writeFile(`${PACK_DIR(host.configDir)}/${pack.name}.json`, packText) + record.packs[pack.name] = { url: entry.pack, hash: textHash(packText) } + await host.writeFile(RECORD_FILE(host.configDir), JSON.stringify(record, null, 2) + '\n') + return { name: pack.name, message: `Installed pack "${pack.name}".` } +} diff --git a/hooks/userpacks.ts b/hooks/userpacks.ts index 5c7da36..1efef5d 100644 --- a/hooks/userpacks.ts +++ b/hooks/userpacks.ts @@ -39,18 +39,26 @@ export async function addPack(host: Host, url: string, force: boolean): Promise< // The checks every installed pack passes, from a URL or a studio link. export async function installPackText(host: Host, text: string, force: boolean): Promise<{ name?: string; message: string }> { + const checked = await checkPackText(host, text, force) + if ('message' in checked) return checked + const { name } = checked + await host.writeFile(`${PACK_DIR(host.configDir)}/${name}.json`, text) + return { name, message: `Installed pack "${name}". Apply it with /glowup pack ${name}` } +} + +// `themes`: themes downloaded alongside the pack, not on disk yet, that its look may name. +export async function checkPackText(host: Host, text: string, force: boolean, themes: Record = {}): Promise<{ name: string } | { message: string }> { if (text.length > MAX_BYTES) return { message: 'The pack is over 64 KB.' } let file: unknown try { file = parseJsonc(text); validatePack(file) } catch (err) { return { message: msg(err) } } const name = file.name.toLowerCase() const problem = await nameProblem(host, name, force) if (problem) return { message: problem } - const check = resolveLook({ colors: name, motion: name }, { ...(await loadUserPacks(host)), [name]: file }, await loadUserThemes(host)) + const check = resolveLook({ colors: name, motion: name }, { ...(await loadUserPacks(host)), [name]: file }, { ...(await loadUserThemes(host)), ...themes }) // A spinner this build lacks is a warning: the pack was made for a newer glowup and falls back to stock. const fatal = check.errors.filter(e => !isNewerSpinner(e)) if (fatal.length) return { message: fatal[0]! } - await host.writeFile(`${PACK_DIR(host.configDir)}/${name}.json`, text) - return { name, message: `Installed pack "${name}". Apply it with /glowup pack ${name}` } + return { name } } export async function savePack(host: Host, file: PackFile, force = false): Promise { diff --git a/hooks/userthemes.ts b/hooks/userthemes.ts index 96f7390..c16f094 100644 --- a/hooks/userthemes.ts +++ b/hooks/userthemes.ts @@ -21,14 +21,21 @@ export async function addTheme(host: Host, url: string): Promise { let r: Awaited> try { r = await host.fetchText(url) } catch (err) { return `Could not download the theme: ${err instanceof Error ? err.message : String(err)}` } if (!r.ok) return `Could not download the theme (HTTP ${r.status}).` + const checked = await checkThemeText(host, r.text) + if ('error' in checked) return checked.error + await host.writeFile(`${THEME_DIR(host.configDir)}/${checked.name}.json`, r.text) + return `Installed theme "${checked.name}". Switch with /glowup theme ${checked.name}` +} + +// `others`: themes downloaded in the same install, not on disk yet, that this one may extend. +export async function checkThemeText(host: Host, text: string, others: Record = {}): Promise<{ name: string; file: unknown } | { error: string }> { let file: unknown - try { file = parseJsonc(r.text) } catch (err) { return (err as Error).message } - if (typeof file !== 'object' || file === null || Array.isArray(file)) return 'A theme must be a JSON object.' + try { file = parseJsonc(text) } catch (err) { return { error: (err as Error).message } } + if (typeof file !== 'object' || file === null || Array.isArray(file)) return { error: 'A theme must be a JSON object.' } const name = String((file as { name?: unknown }).name ?? '').toLowerCase() - if (!SAFE_NAME.test(name)) return 'A theme needs a "name" of lowercase letters, digits and dashes.' - if (Object.hasOwn(PRESETS, name)) return `"${name}" is a built-in theme name; pick another.` - const check = resolveTheme(name, { ...(await loadUserThemes(host)), [name]: file }) - if (check.error) return check.error - await host.writeFile(`${THEME_DIR(host.configDir)}/${name}.json`, r.text) - return `Installed theme "${name}". Switch with /glowup theme ${name}` + if (!SAFE_NAME.test(name)) return { error: 'A theme needs a "name" of lowercase letters, digits and dashes.' } + if (Object.hasOwn(PRESETS, name)) return { error: `"${name}" is a built-in theme name; pick another.` } + const check = resolveTheme(name, { ...(await loadUserThemes(host)), ...others, [name]: file }) + if (check.error) return { error: check.error } + return { name, file } } diff --git a/test/catalog.test.ts b/test/catalog.test.ts index 493bdad..4193ad2 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -1,6 +1,6 @@ import { test, expect } from 'claude-code/testing' import { fakeHost } from './kit.ts' -import { parseCatalog, compareVersions, canRun, loadCatalog, refreshCatalog, CATALOG_URL, CATALOG_FILE, type CatalogEntry } from '../hooks/catalog.ts' +import { parseCatalog, compareVersions, canRun, loadCatalog, refreshCatalog, CATALOG_URL, CATALOG_FILE, installEntry, loadRecord, textHash, RECORD_FILE, type CatalogEntry } from '../hooks/catalog.ts' const OXIDE = { name: 'oxide', description: 'bone text and red oxide on warm ink', pack: 'https://example.com/oxide.json', themes: ['https://example.com/themes/oxide.json'], minGlowup: '0.8.1' } const index = (packs: unknown[], format = 1) => ({ format, packs }) @@ -82,3 +82,70 @@ test('no cache, or a broken one, loads as an empty catalog', async () => { expect(await loadCatalog(fakeHost().host)).toEqual([]) expect(await loadCatalog(fakeHost({ files: { [CATALOG_FILE('/home/u/.claude')]: '{' } }).host)).toEqual([]) }) + +const PACK = (name: string, extra: Record = {}) => JSON.stringify({ format: 1, name, colors: { theme: 'oxide' }, ...extra }) +const THEME = JSON.stringify({ name: 'oxide', extends: 'classic' }) +const PACKS_DIR = '/home/u/.claude/glowup/packs', THEMES_DIR = '/home/u/.claude/glowup/themes' +const fetchesFor = (pack = PACK('oxide'), theme = THEME) => ({ [OXIDE.pack]: pack, [OXIDE.themes[0]!]: theme }) + +test('an install writes the theme and the pack and records both with hashes', async () => { + const { host, files } = fakeHost({ fetches: fetchesFor() }) + expect((await installEntry(host, OXIDE, { force: false })).name).toBe('oxide') + expect(files[`${THEMES_DIR}/oxide.json`]).toBe(THEME) + expect(files[`${PACKS_DIR}/oxide.json`]).toBe(PACK('oxide')) + expect(await loadRecord(host)).toEqual({ packs: { oxide: { url: OXIDE.pack, hash: textHash(PACK('oxide')) } }, themes: { oxide: { hash: textHash(THEME) } } }) +}) + +test('refused installs write nothing at all', async () => { + const cases: Record[] = [ + fetchesFor(PACK('oxide-dark')), // the file names another pack + fetchesFor(PACK('oxide', { colors: { rows: 'nope' } })), // valid name, invalid pack + fetchesFor(PACK('oxide'), '{'), // the theme does not parse + { [OXIDE.themes[0]!]: THEME }, // the pack does not download + ] + for (const fetches of cases) { + const { host, files } = fakeHost({ fetches }) + const r = await installEntry(host, OXIDE, { force: false }) + expect(r.name).toBeUndefined() + expect(Object.keys(files)).toEqual([]) + } +}) + +test('a second theme that fails leaves the first unwritten', async () => { + const two = { ...OXIDE, themes: [OXIDE.themes[0]!, 'https://example.com/themes/bad.json'] } + const { host, files } = fakeHost({ fetches: { ...fetchesFor(), 'https://example.com/themes/bad.json': '[]' } }) + expect((await installEntry(host, two, { force: false })).name).toBeUndefined() + expect(files[`${THEMES_DIR}/oxide.json`]).toBeUndefined() +}) + +test('a message about a mismatched name shows the downloaded name safely', async () => { + const { host } = fakeHost({ fetches: fetchesFor(PACK('evil\u001b[31m')) }) + expect((await installEntry(host, OXIDE, { force: false })).message).not.toContain('\u001b') +}) + +test('an existing theme is reused on a first install', async () => { + const mine = JSON.stringify({ name: 'oxide', extends: 'dusk' }) + const { host, files } = fakeHost({ fetches: fetchesFor(), files: { [`${THEMES_DIR}/oxide.json`]: mine } }) + expect((await installEntry(host, OXIDE, { force: false })).name).toBe('oxide') + expect(files[`${THEMES_DIR}/oxide.json`]).toBe(mine) + expect((await loadRecord(host)).themes).toEqual({}) +}) + +test('force refreshes a recorded, unchanged theme but never an unrecorded or changed one', async () => { + const newTheme = JSON.stringify({ name: 'oxide', extends: 'aurora' }) + const recorded = { packs: { oxide: { url: OXIDE.pack, hash: textHash(PACK('oxide')) } }, themes: { oxide: { hash: textHash(THEME) } } } + const base = { [`${PACKS_DIR}/oxide.json`]: PACK('oxide'), [`${THEMES_DIR}/oxide.json`]: THEME } + const ok = fakeHost({ fetches: fetchesFor(PACK('oxide'), newTheme), files: { ...base, [RECORD_FILE('/home/u/.claude')]: JSON.stringify(recorded) } }) + await installEntry(ok.host, OXIDE, { force: true }) + expect(ok.files[`${THEMES_DIR}/oxide.json`]).toBe(newTheme) + const mine = JSON.stringify({ name: 'oxide', extends: 'dusk' }) + const kept = fakeHost({ fetches: fetchesFor(PACK('oxide'), newTheme), files: { ...base, [`${THEMES_DIR}/oxide.json`]: mine, [RECORD_FILE('/home/u/.claude')]: JSON.stringify(recorded) } }) + await installEntry(kept.host, OXIDE, { force: true }) + expect(kept.files[`${THEMES_DIR}/oxide.json`]).toBe(mine) +}) + +test('a name taken by an installed pack is refused without force, and nothing is written', async () => { + const { host, files } = fakeHost({ fetches: fetchesFor(), files: { [`${PACKS_DIR}/oxide.json`]: PACK('oxide', { description: 'mine' }) } }) + expect((await installEntry(host, OXIDE, { force: false })).message).toContain('--force') + expect(files[`${THEMES_DIR}/oxide.json`]).toBeUndefined() +}) From 9bf888eec74674e7d7908a6c96133d7626f3ec4c Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 7 Oct 2026 15:56:04 +0300 Subject: [PATCH 04/11] feat(catalog): pick, list and update official packs by name Signed-off-by: NovusEdge --- hooks/command.ts | 43 +++++++++++++-- hooks/help.ts | 1 + hooks/host.ts | 2 + test/command.test.ts | 126 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 4 deletions(-) diff --git a/hooks/command.ts b/hooks/command.ts index 47651ed..4763d63 100644 --- a/hooks/command.ts +++ b/hooks/command.ts @@ -5,7 +5,8 @@ import { takeOver, restore } from './statusline.ts' import { resolveLook, exportMix, normalizeHex, cleanOverrides, SPINNER_IDS, type Mix } from './packs.ts' import { PACKS } from './packpresets.ts' import { decodeLink, isStudioLink } from './link.ts' -import { loadUserPacks, addPack, savePack, installPackText, SAFE_NAME } from './userpacks.ts' +import { loadUserPacks, addPack, savePack, installPackText, SAFE_NAME, PACK_DIR } from './userpacks.ts' +import { loadCatalog, canRun, installEntry, loadRecord, textHash } from './catalog.ts' import { parseScheme } from './schemes.ts' import { konsoleScheme } from './konsole.ts' import { eggSheet, type PetSetting, type PetSheet } from './pets.ts' @@ -64,6 +65,9 @@ async function packList(host: Host, ctl: Ctl): Promise { const { look } = resolveLook(m, user, await loadUserThemes(host)) const whole = m.colors === m.motion && m.theme === undefined && m.spinner === undefined && look.colorsFrom === m.colors && look.motionFrom === m.motion const lines = names.map(n => `${whole && n === m.colors ? '●' : '○'} ${n}`) + for (const e of await loadCatalog(host)) { + if (!names.includes(e.name)) lines.push(`○ ${e.name} (${canRun(e, host.version) ? 'not installed' : `needs glowup ${e.minGlowup}`}) ${e.description}`) + } if (!whole) lines.push(`custom mix: colors ${m.colors}, motion ${m.motion}${m.theme ? `, theme ${m.theme}` : ''}${m.spinner ? `, spinner ${m.spinner}` : ''}`) return lines.join('\n') } @@ -103,15 +107,45 @@ async function resetColor(host: Host, ctl: Ctl, role: string | undefined): Promi return `Color ${role}: back to the look's own.` } -async function usePack(host: Host, ctl: Ctl, name: string): Promise { +async function usePack(host: Host, ctl: Ctl, name: string, force = false): Promise { + const user = await loadUserPacks(host) + const local = Object.hasOwn(PACKS, name) || Object.hasOwn(user, name) + const entry = !Object.hasOwn(PACKS, name) && (force || !local) ? (await loadCatalog(host)).find(e => e.name === name) : undefined + if (entry) { + if (!canRun(entry, host.version)) return `${name} needs glowup ${entry.minGlowup}; this is ${host.version ?? 'an unknown version'}.` + const r = await installEntry(host, entry, { force }) + if (!r.name) return r.message + } const keep = ctl.mix().overrides const mix: Mix = { colors: name, motion: name, ...(keep && { overrides: keep }) } const { errors } = resolveLook(mix, await loadUserPacks(host), await loadUserThemes(host)) - if (errors.length) return errors.join('\n') + if (errors.length) { + const official = local || entry ? [] : (await loadCatalog(host)).map(e => e.name) + return [...errors, ...(official.length ? [`Official packs: ${official.join(', ')}`] : [])].join('\n') + } await applyMix(host, ctl, mix) return `Pack: ${name}` } +export async function updatePacks(host: Host, only: string | undefined): Promise { + const record = await loadRecord(host), catalog = await loadCatalog(host) + if (only !== undefined && !Object.hasOwn(record.packs, only)) return `${only} was not installed from the catalog.` + const names = only !== undefined ? [only] : Object.keys(record.packs) + if (!names.length) return 'No packs were installed from the catalog.' + const lines: string[] = [] + for (const n of names) { + const e = catalog.find(x => x.name === n) + if (!e) { lines.push(`${n} is no longer in the catalog.`); continue } + if (!canRun(e, host.version)) { lines.push(`${n} needs glowup ${e.minGlowup}; not updated.`); continue } + let current = '' + try { current = await host.readFile(`${PACK_DIR(host.configDir)}/${n}.json`) } catch {} + if (current && textHash(current) !== record.packs[n]!.hash) { lines.push(`${n} changed since glowup installed it; not updated. Use /glowup pack ${n} --force to replace it.`); continue } + const r = await installEntry(host, e, { force: true }) + lines.push(r.name ? `Updated ${n}.` : r.message) + } + return lines.join('\n') +} + async function importScheme(host: Host, ctl: Ctl, rawPath: string, force: boolean): Promise { const read = await readLocal(host, rawPath, MAX_SCHEME_BYTES) if ('error' in read) return read.error @@ -229,7 +263,8 @@ export async function runCommand(host: Host, args: string, ctl: Ctl): Promise `${n === look.motion.spinner ? '●' : '○'} ${n}`).join('\n') diff --git a/hooks/help.ts b/hooks/help.ts index 789f041..16a8016 100644 --- a/hooks/help.ts +++ b/hooks/help.ts @@ -22,6 +22,7 @@ export const SECTIONS: readonly (readonly [title: string, rows: HelpRow[]])[] = ['Look', [ ['/glowup pack ', 'switch the whole look'], ['/glowup pack list', 'packs you have'], + ['/glowup pack update [name]', 'fetch official packs again'], ['/glowup spinner ', 'just the spinner'], ['/glowup theme ', 'just the colors'], ['/glowup color <#hex>', 'override one color, kept across packs'], diff --git a/hooks/host.ts b/hooks/host.ts index 40ef1d2..bb5d5c2 100644 --- a/hooks/host.ts +++ b/hooks/host.ts @@ -22,4 +22,6 @@ export type Host = { dataHome: string // $HOME home: string + // glowup's own version from its plugin.json; undefined when it could not be read + version?: string } diff --git a/test/command.test.ts b/test/command.test.ts index c9fa8d5..1a1a866 100644 --- a/test/command.test.ts +++ b/test/command.test.ts @@ -8,6 +8,7 @@ import { DEFAULT_FIELDS, type FieldId } from '../hooks/fields.ts' import { DEFAULT_SETUP, type Setup } from '../hooks/setup.ts' import { encodeLink } from '../hooks/link.ts' import { ROLE_LABELS } from '../hooks/themes.ts' +import { CATALOG_FILE, RECORD_FILE, textHash } from '../hooks/catalog.ts' test('/glowup and /glowup help print the short card', { timeoutMs: 20000 }, async ($, on) => { fakeFs(on) @@ -633,3 +634,128 @@ test('a link pet whose name is installed needs --force', async () => { expect(await runCommand(host, `pack ${link}`, ctl().ctl)).toBe('Pet not installed: A pet named "mochi" is installed already. Add --force to replace it.') expect(await runCommand(host, `pack ${link} --force`, ctl().ctl)).toBe('Pet: mochi') }) + +const CONFIG = '/home/u/.claude' +const OXIDE_ENTRY = { name: 'oxide', description: 'bone text and red oxide on warm ink', pack: 'https://example.com/oxide.json', themes: ['https://example.com/themes/oxide.json'], minGlowup: '0.8.1' } +const FUTURE_ENTRY = { name: 'future', description: 'from a newer glowup', pack: 'https://example.com/future.json', themes: [], minGlowup: '9.0.0' } +const OXIDE_PACK = JSON.stringify({ format: 1, name: 'oxide', colors: { theme: 'oxide' } }) +const OXIDE_THEME = JSON.stringify({ name: 'oxide', extends: 'classic' }) +const CATALOG_JSON = JSON.stringify({ format: 1, packs: [OXIDE_ENTRY, FUTURE_ENTRY] }) +const oxideFetches = (pack = OXIDE_PACK) => ({ [OXIDE_ENTRY.pack]: pack, [OXIDE_ENTRY.themes[0]!]: OXIDE_THEME }) +const catalogHost = (opts: { files?: Record; fetches?: Record; version?: string | null } = {}) => { + const r = fakeHost({ files: { [CATALOG_FILE(CONFIG)]: CATALOG_JSON, ...opts.files }, fetches: opts.fetches ?? oxideFetches() }) + if (opts.version !== null) r.host.version = opts.version ?? '0.10.0' + return r +} +const recordOf = (pack: string) => JSON.stringify({ packs: { oxide: { url: OXIDE_ENTRY.pack, hash: textHash(pack) } }, themes: {} }) + +test('pack installs an official pack that is not installed and switches to it', async () => { + const { host, files } = catalogHost() + const { calls, ctl: c } = ctl() + expect(await runCommand(host, 'pack oxide', c)).toBe('Pack: oxide') + expect(files[`${PACKS_DIR}/oxide.json`]).toBe(OXIDE_PACK) + expect(c.mix()).toEqual({ colors: 'oxide', motion: 'oxide' }) + expect(calls).toEqual(['mix:oxide/oxide']) +}) + +test('installing an official pack keeps the color overrides', async () => { + const { host } = catalogHost() + const { ctl: c } = ctl() + await c.setMix({ colors: 'classic', motion: 'classic', overrides: { accent: '#ffffff' } }) + expect(await runCommand(host, 'pack oxide', c)).toBe('Pack: oxide') + expect(c.mix()).toEqual({ colors: 'oxide', motion: 'oxide', overrides: { accent: '#ffffff' } }) +}) + +test('a pack that needs a newer glowup is refused and nothing is written', async () => { + const { host, files } = catalogHost({ fetches: { [FUTURE_ENTRY.pack]: JSON.stringify({ format: 1, name: 'future' }) } }) + const before = { ...files } + const { calls, ctl: c } = ctl() + expect(await runCommand(host, 'pack future', c)).toBe('future needs glowup 9.0.0; this is 0.10.0.') + expect(files).toEqual(before) + expect(calls).toEqual([]) +}) + +test('an unreadable glowup version runs no official pack', async () => { + const { host, files } = catalogHost({ version: null }) + const before = { ...files } + expect(await runCommand(host, 'pack oxide', ctl().ctl)).toBe('oxide needs glowup 0.8.1; this is an unknown version.') + expect(files).toEqual(before) +}) + +test('an unknown pack name lists the official packs after the usual error', async () => { + const { host } = catalogHost() + const out = await runCommand(host, 'pack nosuch', ctl().ctl) + expect(out).toContain('no pack named "nosuch"') + expect(out.endsWith('\nOfficial packs: oxide, future')).toBe(true) +}) + +test("the person's own pack of the same name is used without a download", async () => { + const mine = JSON.stringify({ format: 1, name: 'oxide' }) + const { host, files } = catalogHost({ files: { [`${PACKS_DIR}/oxide.json`]: mine }, fetches: {} }) + expect(await runCommand(host, 'pack oxide', ctl().ctl)).toBe('Pack: oxide') + expect(files[`${PACKS_DIR}/oxide.json`]).toBe(mine) +}) + +test('--force replaces the same-named pack of the person with the official one', async () => { + const mine = JSON.stringify({ format: 1, name: 'oxide' }) + const { host, files } = catalogHost({ files: { [`${PACKS_DIR}/oxide.json`]: mine } }) + expect(await runCommand(host, 'pack oxide --force', ctl().ctl)).toBe('Pack: oxide') + expect(files[`${PACKS_DIR}/oxide.json`]).toBe(OXIDE_PACK) +}) + +test('--force on a built-in pack is not a catalog lookup', async () => { + const { host, files } = catalogHost({ fetches: {} }) + expect(await runCommand(host, 'pack classic --force', ctl().ctl)).toBe('Pack: classic') + expect(Object.keys(files)).toEqual([CATALOG_FILE(CONFIG)]) +}) + +test('pack list shows official packs that are not installed, then drops them once installed', async () => { + const { host } = catalogHost() + const { ctl: c } = ctl() + const before = (await runCommand(host, 'pack list', c)).split('\n') + expect(before.slice(-2)).toEqual([ + '○ oxide (not installed) bone text and red oxide on warm ink', + '○ future (needs glowup 9.0.0) from a newer glowup', + ]) + await runCommand(host, 'pack oxide', c) + const after = (await runCommand(host, 'pack list', c)).split('\n') + expect(after.filter(l => l.includes('oxide'))).toEqual(['● oxide']) + expect(after.at(-1)).toBe('○ future (needs glowup 9.0.0) from a newer glowup') +}) + +test('pack update fetches the official pack again', async () => { + const { host, files } = catalogHost() + await runCommand(host, 'pack oxide', ctl().ctl) + const newer = JSON.stringify({ format: 1, name: 'oxide', description: 'newer', colors: { theme: 'oxide' } }) + Object.assign(host, { fetchText: async (url: string) => ({ ok: true, status: 200, text: url === OXIDE_ENTRY.pack ? newer : OXIDE_THEME }) }) + expect(await runCommand(host, 'pack update oxide', ctl().ctl)).toBe('Updated oxide.') + expect(files[`${PACKS_DIR}/oxide.json`]).toBe(newer) +}) + +test('pack update leaves a pack the person edited', async () => { + const { host, files } = catalogHost() + await runCommand(host, 'pack oxide', ctl().ctl) + const edited = OXIDE_PACK + '\n' + files[`${PACKS_DIR}/oxide.json`] = edited + expect(await runCommand(host, 'pack update oxide', ctl().ctl)).toBe('oxide changed since glowup installed it; not updated. Use /glowup pack oxide --force to replace it.') + expect(files[`${PACKS_DIR}/oxide.json`]).toBe(edited) +}) + +test('pack update with no name reports each recorded pack, including one gone from the catalog', async () => { + const gone = JSON.stringify({ format: 1, name: 'gone' }) + const record = JSON.stringify({ packs: { oxide: { url: OXIDE_ENTRY.pack, hash: textHash(OXIDE_PACK) }, gone: { url: 'https://example.com/gone.json', hash: textHash(gone) } }, themes: {} }) + const { host, files } = catalogHost({ files: { [`${PACKS_DIR}/oxide.json`]: OXIDE_PACK, [`${PACKS_DIR}/gone.json`]: gone, [RECORD_FILE(CONFIG)]: record } }) + expect(await runCommand(host, 'pack update', ctl().ctl)).toBe('Updated oxide.\ngone is no longer in the catalog.') + expect(files[`${PACKS_DIR}/gone.json`]).toBe(gone) +}) + +test('pack update names a pack that was not installed from the catalog', async () => { + const { host } = catalogHost({ files: { [RECORD_FILE(CONFIG)]: recordOf(OXIDE_PACK) } }) + expect(await runCommand(host, 'pack update mine', ctl().ctl)).toBe('mine was not installed from the catalog.') + expect(await runCommand(host, 'pack update constructor', ctl().ctl)).toBe('constructor was not installed from the catalog.') +}) + +test('pack update with nothing recorded says so', async () => { + const { host } = catalogHost() + expect(await runCommand(host, 'pack update', ctl().ctl)).toBe('No packs were installed from the catalog.') +}) From 888b51df43dff67e1f0be801b02b0147299f6540 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 7 Oct 2026 15:55:20 +0300 Subject: [PATCH 05/11] feat(installer): list official packs and gate them on the installed glowup Signed-off-by: NovusEdge --- .claude-plugin/plugin.json | 2 +- installer/gen/packs.ts | 8 +++- installer/internal/claude/plan.go | 15 ++++++- installer/internal/claude/plan_test.go | 35 ++++++++++++++- installer/internal/cli/flags.go | 15 +++++-- installer/internal/cli/flags_test.go | 10 +++++ installer/internal/packs/packs.go | 37 +++++++++++++--- installer/internal/packs/packs.json | 12 +++++- installer/internal/packs/packs_test.go | 23 ++++++++++ installer/internal/tui/model.go | 10 +++-- installer/internal/tui/preview.go | 27 +++++++++--- installer/internal/tui/render_test.go | 2 +- installer/internal/tui/tui_test.go | 59 +++++++++++++++++++++++++- 13 files changed, 229 insertions(+), 26 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index bc80d3e..1a94fb6 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -14,7 +14,7 @@ "userConfig": { "theme": { "type": "string", "title": "Theme", "description": "Theme name: glowup, aurora, dusk, classic, cyberpunk, vaporwave, high-contrast, or one in ~/.claude/glowup/themes", "default": "classic" }, "reducedMotion": { "type": "boolean", "title": "Reduced motion", "description": "No shimmer or spinner animation from glowup", "default": false }, - "pack": { "type": "string", "title": "Pack", "description": "Pack name: classic, crt, cozy, arcade, or one in ~/.claude/glowup/packs", "default": "classic" }, + "pack": { "type": "string", "title": "Pack", "description": "Pack name: classic, crt, cozy, arcade, one in ~/.claude/glowup/packs, or an official pack", "default": "classic" }, "spinner": { "type": "string", "title": "Spinner", "description": "Spinner name: stock, comet, eyes, orb-states, clawd, shimmer, scanline, ring, glitch, signal, or pack for the pack's own", "default": "pack" }, "pet": { "type": "string", "title": "Pet", "description": "clawd, robot, off, or a pet in ~/.claude/glowup/pets", "default": "clawd" }, "bubbles": { "type": "string", "title": "Speech bubbles", "description": "on (template lines), off, or haiku (Claude Haiku writes some lines; small paid calls on your account)", "default": "on" }, diff --git a/installer/gen/packs.ts b/installer/gen/packs.ts index 1af56c4..04b970b 100644 --- a/installer/gen/packs.ts +++ b/installer/gen/packs.ts @@ -1,10 +1,14 @@ // Writes installer/internal/packs/packs.json from the mod's pack presets. -// `--check` compares instead of writing, so CI fails when packpresets.ts changed and the JSON did not. +// It also lists the official catalog from docs/web/public/packs.json. +// `--check` compares instead of writing, so CI fails when packpresets.ts or the index changed and the JSON did not. import { readFileSync, writeFileSync } from 'node:fs' import { packsJson } from '../../hooks/packexport.ts' const out = new URL('../internal/packs/packs.json', import.meta.url) -const want = packsJson() +const index = JSON.parse(readFileSync(new URL('../../docs/web/public/packs.json', import.meta.url), 'utf8')) +const built = JSON.parse(packsJson()) +built.catalog = index.packs.map(({ name, description }: { name: string; description: string }) => ({ name, description })) +const want = JSON.stringify(built, null, 2) + '\n' if (process.argv.includes('--check')) { let have = '' diff --git a/installer/internal/claude/plan.go b/installer/internal/claude/plan.go index 76e45d4..3152742 100644 --- a/installer/internal/claude/plan.go +++ b/installer/internal/claude/plan.go @@ -11,6 +11,8 @@ import ( "slices" "strconv" "strings" + + "github.com/novusedge/glowup/installer/internal/packs" ) // Step is one claude command the installer runs. @@ -75,9 +77,14 @@ func PlanKeys(c Choice, s State, keys []string) []Step { // count as declared only through a "key=value" entry in Declared's result. var ValueGated = map[string]string{"bubbles": "haiku"} +// officialPack is Declared's entry for a glowup whose pack option takes catalog names. +// An older one accepts any text for pack but falls back to classic on a name it lacks. +const officialPack = "pack=official" + // Declared asks the installed glowup which userConfig options it has: the keys of // `plugin configure --json`'s "schema", plus "key=value" for each ValueGated value -// the option's description names. A schema that carries no description for the +// the option's description names, plus "pack=official" when the pack option's +// description names official packs. A schema that carries no description for the // option is given the benefit of the doubt. func Declared(ctx context.Context, r Runner) ([]string, error) { var out struct { @@ -97,6 +104,9 @@ func Declared(ctx context.Context, r Runner) ([]string, error) { if v, ok := ValueGated[k]; ok && (o.Description == nil || strings.Contains(strings.ToLower(*o.Description), v)) { keys = append(keys, k+"="+v) } + if k == "pack" && (o.Description == nil || strings.Contains(strings.ToLower(*o.Description), "official pack")) { + keys = append(keys, officialPack) + } } slices.Sort(keys) return keys, nil @@ -112,7 +122,8 @@ func Restrict(st Step, declared []string) (Step, []Dropped) { var dropped []Dropped for _, k := range slices.Sorted(maps.Keys(m)) { gated := ValueGated[k] != "" && ValueGated[k] == m[k] - if !slices.Contains(declared, k) || (gated && !slices.Contains(declared, k+"="+m[k])) { + catalog := k == "pack" && packs.InCatalog(m[k]) && !slices.Contains(declared, officialPack) + if !slices.Contains(declared, k) || catalog || (gated && !slices.Contains(declared, k+"="+m[k])) { dropped = append(dropped, Dropped{k, m[k]}) delete(m, k) } diff --git a/installer/internal/claude/plan_test.go b/installer/internal/claude/plan_test.go index 0e18af4..47f5299 100644 --- a/installer/internal/claude/plan_test.go +++ b/installer/internal/claude/plan_test.go @@ -110,7 +110,7 @@ func TestDeclared(t *testing.T) { const cmd = "claude plugin configure glowup@glowup --json" old := `{"pluginId":"glowup@glowup","schema":{"theme":{"type":"string"},"reducedMotion":{"type":"boolean"},"pack":{},"pet":{},"bubbles":{}},"inputs":{}}` got, err := Declared(context.Background(), &fakeRunner{answers: map[string]Result{cmd: {Stdout: old}}}) - if err != nil || !slices.Equal(got, []string{"bubbles", "bubbles=haiku", "pack", "pet", "reducedMotion", "theme"}) { + if err != nil || !slices.Equal(got, []string{"bubbles", "bubbles=haiku", "pack", "pack=official", "pet", "reducedMotion", "theme"}) { t.Fatalf("got %q, %v", got, err) } for name, res := range map[string]Result{ @@ -153,6 +153,39 @@ func TestDeclaredValueGate(t *testing.T) { } } +func TestDeclaredOfficialPack(t *testing.T) { + const cmd = "claude plugin configure glowup@glowup --json" + for name, c := range map[string]struct { + schema string + want bool + }{ + "old": {`{"schema":{"pack":{"description":"Pack name: classic, crt, cozy, arcade, or one in ~/.claude/glowup/packs"}}}`, false}, + "new": {`{"schema":{"pack":{"description":"Pack name: classic, or an Official Pack"}}}`, true}, + "no text": {`{"schema":{"pack":{"type":"string"}}}`, true}, + "no pack": {`{"schema":{"pet":{}}}`, false}, + } { + got, err := Declared(context.Background(), &fakeRunner{answers: map[string]Result{cmd: {Stdout: c.schema}}}) + if err != nil || slices.Contains(got, "pack=official") != c.want { + t.Errorf("%s: got %q, %v", name, got, err) + } + } +} + +func TestRestrictCatalogPack(t *testing.T) { + st := Step{Argv: []string{"x"}, Stdin: `{"pack":"oxide","pet":"clawd"}`, Configure: true} + got, dropped := Restrict(st, []string{"pack", "pet"}) + if got.Stdin != `{"pet":"clawd"}` || len(dropped) != 1 || dropped[0] != (Dropped{"pack", "oxide"}) { + t.Fatalf("an older glowup drops a catalog pack: %s %+v", got.Stdin, dropped) + } + if got, dropped := Restrict(st, []string{"pack", "pack=official", "pet"}); got.Stdin != st.Stdin || len(dropped) != 0 { + t.Fatalf("a glowup that declares official packs keeps it: %s %+v", got.Stdin, dropped) + } + builtin := Step{Stdin: `{"pack":"crt"}`} + if got, dropped := Restrict(builtin, []string{"pack"}); got.Stdin != builtin.Stdin || len(dropped) != 0 { + t.Fatalf("a built-in pack is never dropped: %s %+v", got.Stdin, dropped) + } +} + func TestRestrict(t *testing.T) { st := Step{Argv: []string{"x"}, Stdin: `{"bubbles":"on","spinner":"eyes","theme":"dusk"}`, Configure: true} got, dropped := Restrict(st, BaseKeys) diff --git a/installer/internal/cli/flags.go b/installer/internal/cli/flags.go index a8c5749..61e32b9 100644 --- a/installer/internal/cli/flags.go +++ b/installer/internal/cli/flags.go @@ -36,7 +36,7 @@ func Parse(args []string, out io.Writer) (Options, error) { fs.PrintDefaults() } fs.BoolVar(&o.Yes, "yes", false, "install without asking: the defaults, or the values of the flags below") - fs.StringVar(&o.Choice.Pack, "pack", o.Choice.Pack, "pack: "+strings.Join(packs.Names(), ", ")) + fs.StringVar(&o.Choice.Pack, "pack", o.Choice.Pack, "pack: "+strings.Join(allPackNames(), ", ")) fs.StringVar(&o.Choice.Theme, "theme", "", "theme colors on top of the pack: "+strings.Join(packs.ThemeNames(), ", ")+" (classic keeps the pack's own)") fs.StringVar(&o.Choice.Spinner, "spinner", "", "spinner: "+strings.Join(packs.SpinnerIDs(), ", ")+", or pack for the pack's own") fs.StringVar(&o.Choice.Pet, "pet", o.Choice.Pet, "pet: clawd or off") @@ -62,8 +62,8 @@ func Parse(args []string, out io.Writer) (Options, error) { o.Choice.Bubbles = strings.ToLower(o.Choice.Bubbles) o.Choice.Theme = strings.ToLower(o.Choice.Theme) o.Choice.Spinner = strings.ToLower(o.Choice.Spinner) - if !slices.Contains(packs.Names(), o.Choice.Pack) { - return o, usageErr(out, "there is no pack called %q. Pick one of: %s", o.Choice.Pack, strings.Join(packs.Names(), ", ")) + if !packs.Known(o.Choice.Pack) { + return o, usageErr(out, "there is no pack called %q. Pick one of: %s", o.Choice.Pack, strings.Join(allPackNames(), ", ")) } if o.Choice.Pet != "clawd" && o.Choice.Pet != "off" { return o, usageErr(out, "--pet takes clawd or off, not %q", o.Choice.Pet) @@ -80,6 +80,15 @@ func Parse(args []string, out io.Writer) (Options, error) { return o, nil } +// allPackNames is the built-in packs, then the official catalog's. +func allPackNames() []string { + names := packs.Names() + for _, e := range packs.Catalog() { + names = append(names, e.Name) + } + return names +} + func usageErr(out io.Writer, format string, a ...any) error { err := fmt.Errorf(format, a...) fmt.Fprintln(out, err) diff --git a/installer/internal/cli/flags_test.go b/installer/internal/cli/flags_test.go index a03b9d6..9036e65 100644 --- a/installer/internal/cli/flags_test.go +++ b/installer/internal/cli/flags_test.go @@ -43,6 +43,16 @@ func TestParseThemeAndSpinner(t *testing.T) { } } +func TestParseCatalogPack(t *testing.T) { + if o, err := Parse([]string{"--yes", "--pack", "oxide"}, io.Discard); err != nil || o.Choice.Pack != "oxide" { + t.Fatalf("a catalog pack is refused: %+v, %v", o, err) + } + var out strings.Builder + if _, err := Parse([]string{"--pack", "nosuch"}, &out); err == nil || !strings.Contains(out.String(), "classic, crt, cozy, arcade, oxide") { + t.Fatalf("the refusal should list built-in and catalog names: %v, %q", err, out.String()) + } +} + func TestParseRejects(t *testing.T) { for _, c := range []struct { args []string diff --git a/installer/internal/packs/packs.go b/installer/internal/packs/packs.go index 443bc54..de02f0d 100644 --- a/installer/internal/packs/packs.go +++ b/installer/internal/packs/packs.go @@ -83,11 +83,19 @@ type Pack struct { Spinner Spinner `json:"spinner"` } +// CatalogEntry is an official pack the mod downloads on a person's first session. The +// installer knows only its name and description; the colors arrive with the download. +type CatalogEntry struct { + Name string `json:"name"` + Description string `json:"description"` +} + type file struct { - Packs []Pack `json:"packs"` - Themes []Theme `json:"themes"` - Spinners []SpinnerAnim `json:"spinners"` - Clawd Clawd `json:"clawd"` + Packs []Pack `json:"packs"` + Catalog []CatalogEntry `json:"catalog"` + Themes []Theme `json:"themes"` + Spinners []SpinnerAnim `json:"spinners"` + Clawd Clawd `json:"clawd"` } var data = mustParse(raw) @@ -117,7 +125,26 @@ func Names() []string { return out } -// ByName returns the named pack, or false when there is none. +// Catalog returns the official packs from docs/web/public/packs.json, in index order. +func Catalog() []CatalogEntry { return append([]CatalogEntry(nil), data.Catalog...) } + +// InCatalog reports whether name is an official catalog pack. +func InCatalog(name string) bool { + for _, e := range data.Catalog { + if e.Name == name { + return true + } + } + return false +} + +// Known reports whether name is a built-in or a catalog pack. +func Known(name string) bool { + _, ok := ByName(name) + return ok || InCatalog(name) +} + +// ByName returns the named built-in pack, or false when there is none. func ByName(name string) (Pack, bool) { for _, p := range all { if p.Name == name { diff --git a/installer/internal/packs/packs.json b/installer/internal/packs/packs.json index 3ecf73c..9f66d48 100644 --- a/installer/internal/packs/packs.json +++ b/installer/internal/packs/packs.json @@ -10796,5 +10796,15 @@ } ] ] - } + }, + "catalog": [ + { + "name": "oxide", + "description": "bone text and red oxide on warm ink" + }, + { + "name": "oxide-paper", + "description": "red oxide and ink on bone, with a dithered orb" + } + ] } diff --git a/installer/internal/packs/packs_test.go b/installer/internal/packs/packs_test.go index 61eaa4a..bd2b714 100644 --- a/installer/internal/packs/packs_test.go +++ b/installer/internal/packs/packs_test.go @@ -71,6 +71,29 @@ func TestThemesSpinnersAndClawd(t *testing.T) { } } +func TestCatalog(t *testing.T) { + if len(Catalog()) == 0 { + t.Fatal("the catalog is empty") + } + for _, e := range Catalog() { + if e.Name == "" || e.Description == "" { + t.Errorf("incomplete catalog entry %+v", e) + } + if !InCatalog(e.Name) || !Known(e.Name) { + t.Errorf("%s is not found as a catalog pack", e.Name) + } + } + if InCatalog("classic") || InCatalog("nosuch") { + t.Error("InCatalog accepts a built-in or an unknown name") + } + if !Known("classic") || !Known("oxide") || Known("nosuch") { + t.Error("Known should accept built-ins and catalog names only") + } + if slices.Contains(Names(), "oxide") { + t.Error("Names() lists a catalog pack") + } +} + func TestAllReturnsACopy(t *testing.T) { a := All() a[0].Name = "changed" diff --git a/installer/internal/tui/model.go b/installer/internal/tui/model.go index 47de08f..ef221b3 100644 --- a/installer/internal/tui/model.go +++ b/installer/internal/tui/model.go @@ -65,6 +65,9 @@ func NewModel(in claude.Choice, installed bool) Model { for _, n := range packs.Names() { packOpts = append(packOpts, huh.NewOption(n, n)) } + for _, e := range packs.Catalog() { + packOpts = append(packOpts, huh.NewOption(e.Name, e.Name)) + } themeOpts := []huh.Option[string]{huh.NewOption("Pack's own", "classic")} for _, n := range packs.ThemeNames() { if n != "classic" { @@ -75,7 +78,8 @@ func NewModel(in claude.Choice, installed bool) Model { for _, id := range packs.SpinnerIDs() { spinOpts = append(spinOpts, huh.NewOption(id, id)) } - custom := func() bool { return mode != "custom" } + // A catalog pack's colors and spinner come with the download, so there is nothing to customize. + custom := func() bool { return mode != "custom" || packs.InCatalog(c.Pack) } groups := []*huh.Group{ huh.NewGroup(huh.NewSelect[string]().Key("pack").Title("Pick a pack"). @@ -86,7 +90,7 @@ func NewModel(in claude.Choice, installed bool) Model { Description("Customize picks colors and spinner on their own."). OptionsFunc(func() []huh.Option[string] { return []huh.Option[string]{huh.NewOption("Use "+c.Pack+" as is", "asis"), huh.NewOption("Customize", "custom")} - }, &c.Pack).Value(&mode)), + }, &c.Pack).Value(&mode)).WithHideFunc(func() bool { return packs.InCatalog(c.Pack) }), huh.NewGroup(huh.NewSelect[string]().Key("colors").Title("Pick the colors"). Description("The pack's own, or a theme."). Options(themeOpts...).Value(&colors)).WithHideFunc(custom), @@ -137,7 +141,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Customize is the pick. func (m Model) current() claude.Choice { c := *m.choice - if *m.mode == "custom" { + if *m.mode == "custom" && !packs.InCatalog(c.Pack) { c.Theme, c.Spinner = *m.colors, *m.spinner } return c diff --git a/installer/internal/tui/preview.go b/installer/internal/tui/preview.go index 6da0797..94b3f76 100644 --- a/installer/internal/tui/preview.go +++ b/installer/internal/tui/preview.go @@ -22,6 +22,7 @@ const inner = PreviewWidth - 4 // Look is what the preview draws: a pack, with a theme's colors and a spinner on top when picked. type Look struct { Pack packs.Pack + Catalog *packs.CatalogEntry // set for an official pack, whose own colors the installer does not have Colors packs.Colors Bg string BorderColor string @@ -34,13 +35,23 @@ type Look struct { // LookOf resolves the look the mod would show for pack, theme and spinner, the way // hooks/packs.ts resolveLook does. An empty or "classic" theme and an empty or "pack" -// spinner mean the pack's own. An unknown pack falls back to classic. +// spinner mean the pack's own. An unknown pack falls back to classic. A catalog pack +// is drawn on classic's frame and ignores the theme and spinner. func LookOf(pack, theme, spinner string) Look { p, ok := packs.ByName(pack) + var entry *packs.CatalogEntry if !ok { p, _ = packs.ByName("classic") + for _, e := range packs.Catalog() { + if e.Name == pack { + // The colors arrive with the download, so the frame is classic's under the entry's name. + p.Name, p.Description = e.Name, e.Description + entry = &e + theme, spinner = "", "" + } + } } - l := Look{Pack: p, Colors: p.Colors, Bg: p.Bg, BorderColor: p.BorderColor, Word: p.Spinner.Word, OwnColors: true, OwnSpinner: true} + l := Look{Pack: p, Catalog: entry, Colors: p.Colors, Bg: p.Bg, BorderColor: p.BorderColor, Word: p.Spinner.Word, OwnColors: true, OwnSpinner: true} if t, ok := packs.ThemeByName(theme); ok && theme != "classic" { l.Colors, l.Bg, l.BorderColor, l.Word, l.OwnColors = t.Colors, t.Colors.Panel, t.Colors.Faint, t.Word, false } @@ -198,9 +209,13 @@ func Preview(l Look, c claude.Choice, tick int) string { spin := l.spinnerLines(p, tick, c.ReducedMotion) spin[0] += sp + p.on(l.SpinColor, l.Word+"…") - var sw []string - for _, hex := range []string{col.Accent, col.Text, col.Read, col.Edit, col.Shell, col.Agent, col.Pass, col.Fail} { - sw = append(sw, p.on(hex, "██")) + swatches := p.on(col.Dim, "downloads in your first session") + if l.Catalog == nil { + var sw []string + for _, hex := range []string{col.Accent, col.Text, col.Read, col.Edit, col.Shell, col.Agent, col.Pass, col.Fail} { + sw = append(sw, p.on(hex, "██")) + } + swatches = strings.Join(sw, sp) } pet := "Clawd" @@ -211,7 +226,7 @@ func Preview(l Look, c claude.Choice, tick int) string { rows := []string{title, "", user, reply, "", top, del, add, bottom, ""} rows = append(rows, spin...) - rows = append(rows, "", strings.Join(sw, sp), footer) + rows = append(rows, "", swatches, footer) line := func(s string) string { return bd.Render(box.Left) + p.blank(1) + p.fit(s, inner) + p.blank(1) + bd.Render(box.Right) diff --git a/installer/internal/tui/render_test.go b/installer/internal/tui/render_test.go index cf12509..f06b9ff 100644 --- a/installer/internal/tui/render_test.go +++ b/installer/internal/tui/render_test.go @@ -89,7 +89,7 @@ func TestPackListStaysVisibleUnderEveryProfile(t *testing.T) { for name, p := range profiles { t.Run(name, func(t *testing.T) { runs := styledRuns(downsample(view, p)) - for _, item := range packs.Names() { + for _, item := range append(packs.Names(), catalogNames()...) { var found []styled for _, r := range runs { if strings.Contains(r.text, item) { diff --git a/installer/internal/tui/tui_test.go b/installer/internal/tui/tui_test.go index e9db237..f3d813c 100644 --- a/installer/internal/tui/tui_test.go +++ b/installer/internal/tui/tui_test.go @@ -16,6 +16,14 @@ import ( "github.com/novusedge/glowup/installer/internal/packs" ) +func catalogNames() []string { + var out []string + for _, e := range packs.Catalog() { + out = append(out, e.Name) + } + return out +} + func previewOf(pack, theme, spinner string, c claude.Choice) string { return Preview(LookOf(pack, theme, spinner), c, 0) } @@ -67,6 +75,9 @@ func looks() [][3]string { } } } + for _, n := range catalogNames() { + all = append(all, [3]string{n, "", ""}) + } return all } @@ -82,6 +93,9 @@ func looksOneAtATime() [][3]string { all = append(all, [3]string{p, "", sp}) } } + for _, n := range catalogNames() { + all = append(all, [3]string{n, "", ""}) + } return all } @@ -345,9 +359,52 @@ func TestPickerInstalledLeaveItDoesNotProceed(t *testing.T) { } } +func TestCatalogPackPreview(t *testing.T) { + e := packs.Catalog()[0] + l := LookOf(e.Name, "", "") + if l.Catalog == nil || *l.Catalog != e { + t.Fatalf("look carries %+v, want %+v", l.Catalog, e) + } + if LookOf("crt", "", "").Catalog != nil { + t.Fatal("a built-in pack has a catalog entry") + } + plain := ansi.Strip(Preview(l, claude.Defaults(), 0)) + if !strings.Contains(plain, "downloads in your first session") { + t.Errorf("preview lacks the download note:\n%s", plain) + } + if strings.Contains(plain, "██") { + t.Errorf("a catalog pack has no swatches to draw:\n%s", plain) + } +} + +func TestPickerCatalogPack(t *testing.T) { + e := packs.Catalog()[0] + m := resize(start(NewModel(claude.Defaults(), false)), 120, 40) + for range len(packs.Names()) { + m = down(m) + } + if m.choice.Pack != e.Name { + t.Fatalf("pack %q after moving past the built-ins, want %q", m.choice.Pack, e.Name) + } + // the bubble wraps the caption, so only its first words are checked + if v := view(m); !strings.Contains(v, e.Name+" — "+strings.Fields(e.Description)[0]) { + t.Errorf("caption lacks the description:\n%s", v) + } + m = enter(m) + if got := steps[m.stepIndex()]; got != "Pet" { + t.Fatalf("Customize is offered for a catalog pack: the form is on %s", got) + } + m = enter(down(m)) // no pet + m = enter(enter(m)) + c, ok := m.Result() + if !ok || c != (claude.Choice{Pack: e.Name, Pet: "off", Bubbles: "on"}) { + t.Fatalf("got %+v ok=%v", c, ok) + } +} + func TestPickerShowsEveryPackOnARealTerminal(t *testing.T) { m := resize(start(NewModel(claude.Defaults(), false)), 120, 40) - for _, name := range packs.Names() { + for _, name := range append(packs.Names(), catalogNames()...) { if !strings.Contains(view(m), name) { t.Errorf("pack %s not listed:\n%s", name, view(m)) } From 8466167471f6d89a49f0dcf8a4f2a36b94084574 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 7 Oct 2026 16:00:51 +0300 Subject: [PATCH 06/11] chore(ledger): migrate to per-kind ids docket 0.26.0 renumbers claims, decisions and questions each from 1; every record keeps its old id in migrated_from. Signed-off-by: NovusEdge --- .docket/ledger.jsonl | 230 +++++++++++++++++++++---------------------- 1 file changed, 115 insertions(+), 115 deletions(-) diff --git a/.docket/ledger.jsonl b/.docket/ledger.jsonl index 6dcd3df..bba3d65 100644 --- a/.docket/ledger.jsonl +++ b/.docket/ledger.jsonl @@ -1,115 +1,115 @@ -{"schema":2,"kind":"decision","id":"d1","text":"Project name","state":"adopted","ts":"2026-10-03T22:34:01+00:00","author":"claude-code","session":"","branch":"main","scope":[],"rationale":"npm name free, no Claude Code project uses it, strongest headline hook","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"gh search + npm view, 2026-10-04"}],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"glowup","alternatives":["zhuzh","jazzhands","eyecandy"],"decided_by":"user"} -{"schema":2,"kind":"question","id":"q2","text":"What does the launch ship?","state":"open","ts":"2026-10-03T22:34:08+00:00","author":"claude-code","session":"","branch":"main","scope":[],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"question","id":"q3","text":"How do users customize the look?","state":"open","ts":"2026-10-03T22:34:08+00:00","author":"claude-code","session":"","branch":"main","scope":[],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"decision","id":"d4","text":"Launch ships the live session visualizer plus a written plan for a full skin","state":"adopted","ts":"2026-10-03T22:34:09+00:00","author":"claude-code","session":"","branch":"main","scope":["launch"],"rationale":"One clear demo clip; the full skin follows on the same theme format","supports":[],"depends_on":[],"answers":["q2"],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"If the clip doesn't land alone, the skin plan has to be pulled forward","pinned":false,"choice":"visualizer + skin plan","alternatives":["full skin at launch","visualizer alone"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d5","text":"Users customize through theme files plus built-in presets, switched live by a slash command","state":"adopted","ts":"2026-10-03T22:34:09+00:00","author":"claude-code","session":"","branch":"main","scope":["theming"],"rationale":"Anyone can edit a file; shared themes keep the launch spreading; the full skin extends the same file","supports":[],"depends_on":[],"answers":["q3"],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Theme schema becomes a public contract; breaking it breaks shared themes","pinned":false,"choice":"theme files + presets","alternatives":["presets only","scriptable scenes"],"decided_by":"user"} -{"schema":2,"kind":"question","id":"q6","text":"What is the hero piece and where does it live?","state":"open","ts":"2026-10-03T22:47:19+00:00","author":"claude-code","session":"","branch":"main","scope":[],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"decision","id":"d7","text":"Hero piece is a 4-row companion band above the prompt, shown only during a turn; the transcript keeps Claude Code's layout with a light restyle","state":"adopted","ts":"2026-10-03T22:47:19+00:00","author":"claude-code","session":"","branch":"main","scope":["hero"],"rationale":"Panels were spectacle without use; restyle alone read as a themed Claude Code; 9 rows took too much space","supports":[],"depends_on":[],"answers":["q6"],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"If the band reads as clutter, the hero shrinks to restyle + moments","pinned":false,"choice":"companion band","alternatives":["full-panel visualizer (constellation/skyline/lanes)","restyle only","9-row stage"],"decided_by":"user"} -{"schema":2,"kind":"question","id":"q8","text":"Where does the character art come from?","state":"open","ts":"2026-10-03T22:47:19+00:00","author":"claude-code","session":"","branch":"main","scope":[],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"decision","id":"d9","text":"Ship the hand-drawn block-character sprites as a built-in set that works in every terminal; spritesheet art is an additional set for terminals that show images","state":"adopted","ts":"2026-10-03T22:47:19+00:00","author":"claude-code","session":"","branch":"main","scope":["sprites"],"rationale":"User likes the hand-drawn look; block art is the only option in terminals without image support","supports":[],"depends_on":[],"answers":["q8"],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Two art pipelines to keep in sync per action","pinned":false,"choice":"hand-drawn blocks + optional spritesheet","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"question","id":"q10","text":"Is the pet required, and can it talk?","state":"open","ts":"2026-10-03T22:52:08+00:00","author":"claude-code","session":"","branch":"main","scope":[],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"decision","id":"d11","text":"Pet and speech bubbles are both optional toggles; with the pet off the band collapses to one text line","state":"adopted","ts":"2026-10-03T22:52:09+00:00","author":"claude-code","session":"","branch":"main","scope":["hero"],"rationale":"Not everyone wants a character; the text line keeps the useful part","supports":[],"depends_on":[],"answers":["q10"],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Every pet feature needs a text-only equivalent in the one-line band","pinned":false,"choice":"pet toggle + bubble toggle","alternatives":["pet always on"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d12","text":"Bubble text comes from per-event templates in the theme file (with variants); an opt-in chatty mode has Haiku write quips","state":"adopted","ts":"2026-10-03T22:53:32+00:00","author":"claude-code","session":"","branch":"main","scope":["bubbles"],"rationale":"Free, instant and accurate by default; themes give the pet a voice","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Template sets per theme are content work; chatty mode adds token cost and can misstate facts","pinned":false,"choice":"templates + opt-in LLM","alternatives":["templates only","Claude's own words","always LLM"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d13","text":"Use the hand-drawn Clawd block sprites for now; the user is still looking for shippable spritesheet alternatives","state":"adopted","ts":"2026-10-03T22:53:32+00:00","author":"claude-code","session":"","branch":"main","scope":["sprites"],"rationale":"Unblocks the build; art can be swapped through the theme file","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"before public launch: Clawd is Anthropic's mascot","cost_if_wrong":"If Clawd can't ship as default, the default sprite set changes before launch","pinned":false,"choice":"Clawd blocks for now","alternatives":["Pixel Adventure 1 (CC0)","Puny Characters (CC0)"],"decided_by":"user"} -{"schema":2,"kind":"question","id":"q14","text":"What happens to the user's existing status line?","state":"open","ts":"2026-10-03T22:55:05+00:00","author":"claude-code","session":"","branch":"main","scope":[],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"decision","id":"d15","text":"glowup replaces the user's status line on first run, backs up the old setting, and a command switches back","state":"adopted","ts":"2026-10-03T22:55:05+00:00","author":"claude-code","session":"","branch":"main","scope":["statusline"],"rationale":"Full look on install; the old one is one command away","supports":[],"depends_on":[],"answers":["q14"],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Changing user settings unasked can draw backlash; an uninstall that leaves a dead statusLine path would break their status line","pinned":false,"choice":"replace + restore command","alternatives":["coexist + opt-in","wrap theirs","never offer one"],"decided_by":"user"} -{"schema":2,"kind":"claim","id":"c16","text":"Mods can read settings ($.settings.read, read-only) and write any file with $.fs.write, so changing statusLine means rewriting ~/.claude/settings.json; $.ui.status only adds a line beside the engine's notices","state":"unassessed","ts":"2026-10-03T22:55:06+00:00","author":"claude-code","session":"","branch":"main","scope":[],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"types/claude-code.d.ts lines 2263-2273, 3015-3041, 3355-3370 (plugin-authoring 2.1.288)"}],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"decision","id":"d17","text":"Status line takeover is opt-in via /glowup statusline on (backup + restore); by default glowup adds its own $.ui.status entry beside the user's status line","state":"adopted","ts":"2026-10-03T23:05:21+00:00","author":"claude-code","session":"","branch":"main","scope":["statusline"],"rationale":"settings.json is also written by Claude Code (race), project statusLine overrides user-level, concurrent sessions would share one file, and silent config changes draw backlash","supports":[],"depends_on":[],"answers":[],"supersedes":["d15"],"evidence":[{"ref":"Fable review 2026-10-04; d.ts:2263"}],"revisit":"","cost_if_wrong":"Users of the stock status line won't get a matching one unless they opt in","pinned":false,"supersede_reason":"revise","choice":"opt-in takeover","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d18","text":"Design an original mascot in the hand-drawn block style for launch; Clawd stays in local development only","state":"adopted","ts":"2026-10-03T23:05:21+00:00","author":"claude-code","session":"","branch":"main","scope":["sprites"],"rationale":"A mascot-based theme risks a trademark takedown on launch day","supports":[],"depends_on":[],"answers":[],"supersedes":["d13"],"evidence":[],"revisit":"","cost_if_wrong":"Mascot design work before launch","pinned":false,"supersede_reason":"revise","choice":"original mascot","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d19","text":"Adopt review fixes: text-tree fallback off-terminal, blit on change + 4-8 fps idle tick, route subagent tool calls by agentId, flag only with a task list, test stars only for recognized test commands, 1.5 s linger, yield to survey, bubbles default to done/fail/needs-you","state":"adopted","ts":"2026-10-03T23:05:22+00:00","author":"claude-code","session":"","branch":"main","scope":["architecture"],"rationale":"Verified against the mod API types","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"Fable review 2026-10-04; d.ts:8614, 8579, 174-181, 9580-9586"}],"revisit":"","cost_if_wrong":"Each is small; none blocks the clip","pinned":false,"choice":"adopt review fixes","alternatives":[],"decided_by":"agent"} -{"schema":2,"kind":"decision","id":"d20","text":"Clawd is the default pet; Kit (cat) ships as an option; a third option is still being chosen (Quack the rubber duck proposed)","state":"adopted","ts":"2026-10-03T23:14:30+00:00","author":"claude-code","session":"","branch":"main","scope":["sprites"],"rationale":"User prefers Clawd's familiarity; Kit gives broad appeal","supports":[],"depends_on":[],"answers":[],"supersedes":["d18"],"evidence":[],"revisit":"before public launch: trademark risk of Clawd as default","cost_if_wrong":"A takedown request would force a default swap after launch","pinned":false,"supersede_reason":"reverse","choice":"Clawd default + Kit","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d21","text":"Reduced motion turns the pet off: the band shows the one-line text version","state":"adopted","ts":"2026-10-03T23:14:31+00:00","author":"claude-code","session":"","branch":"main","scope":["accessibility"],"rationale":"A pet that cannot move adds nothing; the text line carries the same information","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"None significant","pinned":false,"choice":"reduced motion = pet off","alternatives":["static pet frames"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d22","text":"Pet sprites use half blocks (1x2 near-square pixels per cell, any color per pixel), feet on the track row, so an 8-pixel sprite fills the 4-row band; Clawd keeps quadrant blocks like the Claude Code logo","state":"adopted","ts":"2026-10-03T23:14:31+00:00","author":"claude-code","session":"","branch":"main","scope":["sprites"],"rationale":"Quadrant pixels are about 2.3x taller than wide and allow only 2 colors per 2x2 block, which stretched and broke multi-color sprites","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Half the horizontal resolution of quadrants","pinned":false,"choice":"half blocks","alternatives":["quadrant blocks"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d23","text":"Third built-in pet is a non-animal (Blip proposed); a hidden Egg pet is unlocked by the Konami code, cracks with each passing test run, and hatches into a per-user secret creature","state":"adopted","ts":"2026-10-03T23:15:27+00:00","author":"claude-code","session":"","branch":"main","scope":["easter-egg"],"rationale":"Classic HN-friendly easter egg; hatch results are shareable","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Hatch sprites are extra art; unlock may need a fallback input","pinned":false,"choice":"Konami-code egg","alternatives":["appears after 100th passing test run","secret command","date-based"],"decided_by":"user"} -{"schema":2,"kind":"question","id":"q24","text":"Do arrow keys pressed in the prompt reach a mod? prompt.edit carries the key only for keys that change the draft, so left/right (cursor moves) may never arrive. Spike before building the unlock; fallback is typing uuddlrlrba as a prompt.","state":"open","ts":"2026-10-03T23:15:28+00:00","author":"claude-code","session":"","branch":"main","scope":[],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"decision","id":"d25","text":"The pet keeps its own colors under every theme; themes style everything else; only monochrome greys it out","state":"adopted","ts":"2026-10-03T23:16:09+00:00","author":"claude-code","session":"","branch":"main","scope":["sprites"],"rationale":"The pet's identity should stay recognizable across themes","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A pet can clash with a theme's palette","pinned":false,"choice":"fixed pet colors","alternatives":["theme recolors the pet"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d26","text":"Ship several easter eggs, not one: the Konami-code Egg plus more (candidates: a surprise after the 100th passing test run, an unlisted command, seasonal pet outfits)","state":"adopted","ts":"2026-10-03T23:16:09+00:00","author":"claude-code","session":"","branch":"main","scope":["easter-egg"],"rationale":"Hidden extras give people reasons to post discoveries after launch","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Each egg is extra art and triggers to test","pinned":false,"choice":"multiple easter eggs","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d27","text":"Easter eggs at launch: Konami-code Egg, shiny pet after 100th passing test run, Friday-deploy sweat + sign, night-owl nightcap (2-5 a.m.), seasonal outfits (late Dec, Halloween, install anniversary)","state":"adopted","ts":"2026-10-03T23:16:44+00:00","author":"claude-code","session":"","branch":"main","scope":["easter-egg"],"rationale":"Each one is a screenshot moment after launch","supports":[],"depends_on":[],"answers":[],"supersedes":["d26"],"evidence":[],"revisit":"","cost_if_wrong":"Overlay sprites (hat, nightcap, sign, sweat) per pet: 3 pets x 5 overlays","pinned":false,"supersede_reason":"restate","choice":"all five","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d28","text":"Third built-in pet is Blip (tiny terminal on legs); built-ins are Clawd (default), Kit, Blip","state":"adopted","ts":"2026-10-03T23:17:39+00:00","author":"claude-code","session":"","branch":"main","scope":["sprites"],"rationale":"Non-animal, reads as a dev tool, face-on-screen expressions stay legible at band size","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"None beyond its art","pinned":false,"choice":"Blip","alternatives":["Lumo","Quack","Glim"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d29","text":"Two data-only JSON (comments allowed) file kinds: themes (colors, glyphs, spinner, band; extends) and pets (palette, frames, say lines); pet choice and toggles live in mod settings; /glowup theme add installs shared files","state":"adopted","ts":"2026-10-03T23:18:08+00:00","author":"claude-code","session":"","branch":"main","scope":["theming"],"rationale":"JSON parses natively (no npm imports in mods); splitting pets from themes lets any pet pair with any theme since pets keep fixed colors","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Theme schema is a public contract; changes need versioning","pinned":false,"choice":"theme + pet JSON files","alternatives":["single combined file","YAML/TOML"],"decided_by":"user"} -{"schema":2,"kind":"question","id":"q30","text":"What should the install flow and the first launch after install look like (welcome moment, theme and pet picker)? Follow-up after the launch spec.","state":"open","ts":"2026-10-03T23:20:14+00:00","author":"claude-code","session":"","branch":"main","scope":[],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"decision","id":"d31","text":"Build order: the non-pet core first (event translation, state, text band, restyle, themes, status entry, /glowup); pets, bubbles and easter eggs come in a later plan","state":"adopted","ts":"2026-10-03T23:22:54+00:00","author":"claude-code","session":"","branch":"main","scope":["plan"],"rationale":"User wants the actual product working before the pet layer","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"The launch clip needs the pet, so the core alone isn't launchable","pinned":false,"choice":"core first, pets later","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"question","id":"q32","text":"Should glowup change Claude Code's layout beyond the band (panes, panels, drawers)?","state":"open","ts":"2026-10-03T23:30:51+00:00","author":"claude-code","session":"","branch":"main","scope":[],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"decision","id":"d33","text":"Add a cockpit layout: a tabbed side pane docked beside the transcript (Changes, Agents, Plan & context, Timeline & map); on narrow terminals each tab opens as a drawer (focused pane, closes on Esc)","state":"adopted","ts":"2026-10-03T23:30:52+00:00","author":"claude-code","session":"","branch":"main","scope":["layout"],"rationale":"Panels can be useful, not just decorative: what changed, what subagents are doing, the plan, what fills context","supports":[],"depends_on":[],"answers":["q32"],"supersedes":[],"evidence":[{"ref":"d.ts 2275-2296 (ui.open placement), 9622-9672 (Pane: dock|inline, tabs, view)"}],"revisit":"","cost_if_wrong":"Panes dock only in fullscreen from 110 columns; below that the cockpit is drawers only","pinned":false,"choice":"cockpit with four tabs","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d34","text":"Cockpit tabs are Changes, Agents, and Plan & context; Timeline and Map are dropped","state":"adopted","ts":"2026-10-03T23:40:25+00:00","author":"claude-code","session":"","branch":"main","scope":["layout"],"rationale":"User found timeline and map meh after seeing the mockup","supports":[],"depends_on":[],"answers":[],"supersedes":["d33"],"evidence":[],"revisit":"","cost_if_wrong":"No eye-candy tab in the pane; the band and pets carry the eye candy","pinned":false,"supersede_reason":"revise","choice":"three tabs","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d35","text":"Layout by width: wide fullscreen (110+) docks the pane with the pet at its bottom and no band; medium (80-109) uses the 4-row band plus a drawer; compact (<80) uses a one-line status band starting with a one-row Clawd plus a compact drawer","state":"adopted","ts":"2026-10-03T23:53:17+00:00","author":"claude-code","session":"","branch":"main","scope":["layout"],"rationale":"Docked pane already shows the band's content; narrow terminals had too much on screen","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Three render paths for band and pane to test","pinned":false,"choice":"three width tiers","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"claim","id":"c36","text":"In Claude Code 2.1.288 the subagent tool is named Agent (input: description, prompt, subagent_type?, name?), and both TodoWrite and TaskCreate/TaskUpdate exist; $.session.usage({breakdown:'summary'}) returns context.breakdown.categories {name,tokens} and maxTokens","state":"unassessed","ts":"2026-10-04T00:01:18+00:00","author":"claude-code","session":"","branch":"main","scope":[],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"types/vendor d.ts: BuiltinToolInputs Agent/TaskCreate/TaskUpdate/TodoWrite; SessionContextUsage 10265, SessionContextBreakdown 10181, ContextCategory 1940"}],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"claim","id":"c37","text":"A Button's action prop binds an existing engine keybinding action (unknown names refused), so a mod cannot invent a new global shortcut like ctrl+g; the drawer opens from /glowup pane","state":"unassessed","ts":"2026-10-04T00:01:19+00:00","author":"claude-code","session":"","branch":"main","scope":[],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"d.ts ButtonProps 919-927"}],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"decision","id":"d38","text":"Docs live in docs/ (guides, theme reference); the GitHub Pages site lives in docs/web and deploys with a pages workflow; built after the core so it documents real behavior","state":"adopted","ts":"2026-10-04T00:13:32+00:00","author":"claude-code","session":"","branch":"main","scope":["docs"],"rationale":"User's layout convention, matching a2a-mod's direction","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Site work waits on the core build","pinned":false,"choice":"docs/ + docs/web","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d39","text":"Releases are cut from version tags (vX.Y.Z) by a release workflow modeled on a2a-mod's (tag must match plugin.json version, run check/test, notes from CHANGELOG, plugin archive); ship v0.1.0 once the core build, README and docs are done","state":"adopted","ts":"2026-10-04T00:20:33+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"main","scope":["release"],"rationale":"User's release convention","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A bad tag publishes a broken release; the workflow's version check and tests guard it","pinned":false,"choice":"tag-driven release, first v0.1.0","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"question","id":"q40","text":"What does glowup look like right after install?","state":"open","ts":"2026-10-04T09:36:42+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"main","scope":[],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"decision","id":"d41","text":"Ship a signature 'glowup' preset as the default theme (classic stays available), chosen from mocked palettes","state":"adopted","ts":"2026-10-04T09:36:42+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"main","scope":["theming"],"rationale":"classic made the first impression 'Claude Code with a sidebar'","supports":[],"depends_on":[],"answers":["q40"],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A bold default may clash with some terminal backgrounds; classic is one command away","pinned":false,"choice":"signature default theme","alternatives":["classic default"],"decided_by":"user"} -{"schema":2,"kind":"question","id":"q42","text":"How does a new user get glowup's status line?","state":"open","ts":"2026-10-04T09:36:43+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"main","scope":[],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"decision","id":"d43","text":"Next release asks once, on the first launch after install, whether glowup should draw the status line (undo with /glowup statusline restore)","state":"adopted","ts":"2026-10-04T09:36:43+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"main","scope":["statusline"],"rationale":"Opt-in-only left the user's status line under the pane, repeating context; asking keeps consent","supports":[],"depends_on":[],"answers":["q42"],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"One prompt on first launch","pinned":false,"choice":"first-run question","alternatives":["auto takeover on install","opt-in command only"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d44","text":"Classic stays the default theme; glowup (ember), aurora and dusk ship as switchable presets","state":"adopted","ts":"2026-10-04T09:45:36+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"feat/signature-themes","scope":[],"rationale":"User liked all three mocked palettes and wants classic kept as the default","supports":[],"depends_on":[],"answers":["q40"],"supersedes":["d41"],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"supersede_reason":"revise","choice":"keep classic default, add three presets","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d45","text":"Packs bundle colors + row styles and motion; sound and voice layers wait for later patches","state":"adopted","ts":"2026-10-04T09:45:36+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"feat/signature-themes","scope":[],"rationale":"User picked all four layers, then deferred sound and voice","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"after the packs release","cost_if_wrong":"","pinned":false,"choice":"two layers first","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d46","text":"Clawd moves into the next release: Clawd at the bottom of the docked pane plus a Clawd spinner option; Kit, Blip, bubbles and easter eggs stay in the later pets release","state":"adopted","ts":"2026-10-04T10:09:55+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"feat/signature-themes","scope":["pets"],"rationale":"User wants Clawd now","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"next release grows by one sprite renderer","pinned":false,"choice":"pull Clawd forward","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d47","text":"Next release ships the whole pet system with Clawd as the only built-in pet: pet is its own configurable layer (pick a pet, on/off, independent of packs), reactions, optional bubbles; Kit and Blip follow as extra pets","state":"adopted","ts":"2026-10-04T10:10:58+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"feat/signature-themes","scope":["pets"],"rationale":"User wants pets mix-and-match and Clawd now","supports":[],"depends_on":[],"answers":[],"supersedes":["d46"],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"supersede_reason":"revise","choice":"full pet system, Clawd only","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d48","text":"Pet placement follows the layout tiers: wide fullscreen = bottom of the docked pane; medium = band above the prompt; compact = one-row Clawd at the start of the status line; and on medium/compact the pet steps aside (text band only) while Claude Code's own task list is showing","state":"adopted","ts":"2026-10-04T10:13:27+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"feat/signature-themes","scope":["pets"],"rationale":"User wants the narrow vs full conditional, and the task list + pet + spinner overcrowds the working area","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"tiered pet that yields to the task list","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"claim","id":"c49","text":"A mod cannot hide or move Claude Code's own task list: no ui.render component covers it (checked 2.1.288 types); glowup's Plan & context tab already mirrors TodoWrite/TaskCreate/TaskUpdate","state":"unassessed","ts":"2026-10-04T10:13:28+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"feat/signature-themes","scope":["pets"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"decision","id":"d50","text":"glowup gets a Go installer with a Charm-based TUI, built after 0.2","state":"adopted","ts":"2026-10-04T10:42:27+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"main","scope":["install"],"rationale":"User wants a fancy install flow","supports":[],"depends_on":[],"answers":["q30"],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"Go + Charm installer","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d51","text":"Clawd lives only at the bottom of the glowup pane (docked or inline), at every width; the band and status line never show the pet","state":"adopted","ts":"2026-10-04T10:47:55+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"main","scope":["pets"],"rationale":"Keeps the working area above the prompt clear; removes the clash with Claude Code's task list","supports":[],"depends_on":[],"answers":[],"supersedes":["d48"],"evidence":[],"revisit":"","cost_if_wrong":"no pet when the pane is closed on narrow terminals","pinned":false,"supersede_reason":"revise","choice":"pet in pane only","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d52","text":"Animation uses Client surface modules at the Spinner, AbovePrompt and Pane sites (option A); Raster+blit is not needed","state":"adopted","ts":"2026-10-04T10:58:25+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"main","scope":["motion"],"rationale":"Spike on 2.1.289: Client drew and counted at all three sites and from an imported module; two-hop relative imports load; state.d.ts must be self-contained with a types key; bordered ToolUse rows wrap inside the border","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"docs/superpowers/plans/0.2-spike-report.md"}],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"option A","alternatives":[],"decided_by":"spike"} -{"schema":2,"kind":"decision","id":"d53","text":"Clawd is hand-drawn half-block pixel art, 24x12 px (6 rows), at least 8 frames per animation; movement may run ~12 fps, only color/brightness flashes are held to 400 ms","state":"adopted","ts":"2026-10-04T10:58:25+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"main","scope":["pets"],"rationale":"Owner rejected the logo-glyph poses and the procedural sprite as too static","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"hand-authored sprite sheets","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d54","text":"Clawd ships a hybrid: hand-drawn walk, alert, sleep and fail frames plus the artist's outfits, procedural idle, hop and done; one character, the procedural body is the reference","state":"adopted","ts":"2026-10-04T11:14:22+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"main","scope":["pets"],"rationale":"Owner preferred the procedural idle/hop/done and the artist's walk/alert/sleep/fail and outfits","supports":[],"depends_on":[],"answers":[],"supersedes":["d53"],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"supersede_reason":"revise","choice":"hybrid sprite set","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d55","text":"Clawd uses the artist's hand-drawn sprite sheet for every animation (idle, walk, hop, alert, done, sleep, working, fail) plus its outfits","state":"adopted","ts":"2026-10-04T11:15:45+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"main","scope":["pets"],"rationale":"Owner's final pick","supports":[],"depends_on":[],"answers":[],"supersedes":["d54"],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"supersede_reason":"revise","choice":"all hand-drawn","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d56","text":"Clawd animations transition: each starts at frame 0, pose changes wait for the next marked exit frame (≤ 300 ms) except alert which cuts in, hand-drawn lie-down/wake-up/stop/startle clips, walking position carries over","state":"adopted","ts":"2026-10-04T11:21:31+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"main","scope":["pets"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"exit frames + transition clips","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d57","text":"Clawd's sprite sheet is locked as approved: docs/superpowers/mockups/clawd-final.json (source art in mockups/clawd-src)","state":"adopted","ts":"2026-10-04T11:33:53+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"main","scope":["pets"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"lock the sheet","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d58","text":"Release 0.2.0 now; owner does the live check after; Konami egg and iTerm2 import move to 0.2.x","state":"adopted","ts":"2026-10-04T12:38:53+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"main","scope":["release"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"ship without 4C/4D","alternatives":[],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d59","text":"Park the Konami egg until a mod can read arrow keys","state":"adopted","ts":"2026-10-04T14:18:14+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"main","scope":[],"rationale":"Spike: in a focused pane, up/down step the focus ring linearly, left/right and Client onKey deliver nothing, letters return focus to the prompt","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://github.com/NovusEdge/glowup/issues/5"}],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"Park it; tracked in GitHub issue #5","alternatives":["Build an ENTER CODE dialog with a typed uuddlrlrba input"],"decided_by":"owner"} -{"schema":2,"kind":"decision","id":"d60","text":"/glowup config is a question wizard, not a pane view","state":"adopted","ts":"2026-10-04T15:03:18+00:00","author":"claude-code","session":"session_01NShjxWatJ5wM4vP8ondpPW","branch":"main","scope":["glowup config"],"rationale":"Owner preferred the native look; ask() gives labels only, 2-4 options, no defaults","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"If the wizard feels too limited, the boxed layout must be rebuilt (branch feat/config-layout was deleted)","pinned":false,"choice":"Three native AskUserQuestion steps (pack, pet, extras toggles), each applied on pick, ending in a summary card","alternatives":["Boxed form in the pane or in the band above the prompt, with live preview"],"decided_by":"owner"} -{"schema":2,"kind":"decision","id":"d61","text":"The docs site at docs/web renders guides with Fumadocs v16 on its existing React Router 8 prerender setup","state":"adopted","ts":"2026-10-04T17:26:46+00:00","author":"claude-code","session":"","branch":"feat/docs-fumadocs","scope":["docs/web"],"rationale":"fumadocs-core peers react-router 7||8; the site's interactive pieces import hooks/*.ts through Vite, which a Next build handles worse","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"npm view fumadocs-core peerDependencies (16.16.1)"}],"revisit":"","cost_if_wrong":"Swapping frameworks later means re-porting the landing components and search","pinned":false,"choice":"Fumadocs on React Router 8","alternatives":["Fumadocs on Next.js static export","keep the hand-built renderer"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d62","text":"The landing page replaces the /spinners and /gallery pages; their content lives in landing sections built from glowup's real modules","state":"adopted","ts":"2026-10-04T17:26:46+00:00","author":"claude-code","session":"","branch":"feat/docs-fumadocs","scope":["docs/web/app"],"rationale":"docs/*.md stay plain Markdown so GitHub renders them, and Markdown cannot embed components","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"delete /spinners and /gallery, landing sections import hooks/sprites/clawd.ts, motion.ts, presets.ts, packpresets.ts","alternatives":["fold the lab and gallery into the guides as embedded components"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d63","text":"The landing page follows the approved A+C mockup: dithered WebGL background in muted pack colors, floating nav, Clawd over the headline, full-width scripted terminal, then Clawd, spinners, packs/themes and theme-maker sections","state":"adopted","ts":"2026-10-04T17:26:47+00:00","author":"claude-code","session":"","branch":"feat/docs-fumadocs","scope":["docs/web/app"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"A+C mockup, docs/superpowers/mockups/landing/hero.html","alternatives":["A stage only","B split layout","C mascot only"],"decided_by":"user"} -{"schema":2,"kind":"correction","id":"d63.1","corrects":"d63","fields":{"rationale":"user: the landing page sells it as what it is, a mod that makes Claude look nicer"},"reason":"subtitle reworded: glowup is pitched as a mod that makes Claude Code look nicer, not as a way to see what Claude does","ts":"2026-10-04T17:28:36+00:00","author":"claude-code","session":"","branch":"feat/docs-fumadocs"} -{"schema":2,"kind":"decision","id":"d64","text":"The Pages install skips the one-day pnpm release-age gate for fumadocs-core/ui 16.16.1 and approves esbuild's build script, so the site ships before the gate clears","state":"adopted","ts":"2026-10-04T19:25:46+00:00","author":"claude-code","session":"","branch":"feat/docs-fumadocs","scope":[".github/workflows/pages.yml","docs/web"],"rationale":"owner chose to ship now; esbuild via fumadocs-mdx needs build approval under pnpm 12 regardless","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"--config.minimum-release-age-exclude for both packages plus --allow-build=esbuild in pages.yml","alternatives":["wait until 2026-10-05 14:04Z"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d65","text":"glowup's status line is one line of fields the person picks and orders, in theme colors, including 5-hour and weekly usage.","state":"adopted","ts":"2026-10-04T21:30:01+00:00","author":"claude-code","session":"","branch":"feat/statusline-fields","scope":["hooks/statusline.ts","hooks/fields.ts","hooks/register.tsx","hooks/command.ts","docs/statusline.md"],"rationale":"The pane already shows plan and context detail, so one line suffices; picking fields beats a template that is easy to break","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"Ordered field list (activity, ctx, 5h, week, cost, model, agents, plan, branch, changes, cwd) in $.store 'statusline' over a userConfig default, set by /glowup statusline fields and a /glowup config question","alternatives":["Multi-line layout like ccstatusline","Format-string template","Status line defined per pack"],"decided_by":"owner"} -{"schema":2,"kind":"decision","id":"d66","text":"Clawd gains juggle, scrunch, pant and pant-walk, a stretch wake-up, and a waving alert marked with a question mark","state":"adopted","ts":"2026-10-04T22:29:12+00:00","author":"claude-code","session":"","branch":"worktree-pets-animations","scope":["hooks/pets.ts, hooks/sprites/clawd.ts"],"rationale":"Owner picked these from the roadmap ideas and approved the art in the clawd-next viewer; every trigger uses a signal the model already tracks","supports":[],"depends_on":[],"answers":[],"supersedes":["d57"],"evidence":[{"ref":"docs/superpowers/mockups/clawd-next.html (gitignored)"}],"revisit":"","cost_if_wrong":"","pinned":false,"supersede_reason":"revise","choice":"Juggle at 3+ running subagents; scrunch for 1.5 s after any compaction; pant and pant-walk replace idle and walk at 80%+ context; wake-up is a stretch and yawn; alert waves with a red question mark so it no longer repeats the startle jump","alternatives":[],"decided_by":"owner"} -{"schema":2,"kind":"decision","id":"d67","text":"Docked pane tabs are text-drawn boxed sections whose glyphs follow the pack's border, in the theme's faint color; the status box keeps borderColor; the compact drawer is unchanged","state":"adopted","ts":"2026-10-04T20:45:03+00:00","author":"claude-code","session":"","branch":"feat/pane-sections","scope":["hooks/pane.tsx, hooks/ctxchart.ts"],"rationale":"Owner picked boxed sections from mockups. Faint borders stop arcade and crt from stacking four loud frames. Text-drawn boxes fit a title in the edge and avoid box props the engine refuses.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"boxed sections, faint, border-driven","alternatives":["A rule line styled by border and row style","One fixed layout, colors only","All boxes in borderColor"],"decided_by":"owner"} -{"schema":2,"kind":"decision","id":"d68","text":"The pet's status row in the pane shows usage left (the tighter of the 5-hour and weekly windows), session spend on API keys, and context only before the first usage reading; the band's hearts stay on context","state":"adopted","ts":"2026-10-04T22:08:57+00:00","author":"claude-code","session":"","branch":"main","scope":["pane"],"rationale":"Context is already charted in the Plan & context tab; usage left is what the person needs at a glance. The band stays on context because it is the only context view when the pane is closed.","supports":[],"depends_on":["d65"],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A few lines in lifeRow to revert","pinned":false,"choice":"usage, then spend, then context","alternatives":["context left (previous)","usage in the band too"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d69","text":"User guides are prose with rendered images; tables and code values live in reference pages","state":"adopted","ts":"2026-10-05T08:27:27+00:00","author":"claude-code","session":"","branch":"docs/plain-prose","scope":["README.md, docs/*.md"],"rationale":"Owner found the docs AI-sounding, table-heavy and full of obvious statements; tables are for technical reference and llms.txt only","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"Guides use prose plus demo stills per pack and Clawd GIFs; tables, code values and file formats go in reference pages (pack-reference, theme-reference, commands, install options), which also feed llms-full.txt","alternatives":["Keep tables and code values throughout the guides"],"decided_by":"owner"} -{"schema":2,"kind":"decision","id":"d70","text":"The CONTEXT box shows the auto-compact point, growth per turn and turns left, the heaviest trimmable sources, and cache hit, drawn with a braille trend chart","state":"adopted","ts":"2026-10-05T08:37:22+00:00","author":"claude-code","session":"","branch":"feat/context-chart","scope":["hooks/ctxchart.ts","hooks/pane.tsx","hooks/register.tsx","docs/layout.md"],"rationale":"The breakdown already carries autoCompactThreshold, mcpTools, memoryFiles, skills, agents and apiUsage; the tab only used the categories, so it showed what fills the window but nothing to act on","supports":[["d68"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Taller CONTEXT box; on a short pane the tab falls back to whole-tab scrolling sooner","pinned":false,"choice":"Bar gets a ┊ auto-compact mark; a two-row braille area chart with a dashed auto-compact rule replaces the sparkline; growth line, top three heavy sources (MCP server, memory file, skill or agent listing), cache hit / peak / compactions line; all from the breakdown feedContext already fetches","alternatives":["Keep the one-row sparkline and add only the compaction marker and turns-left line"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d71","text":"/glowup config opens a dedicated glowup pane with swatches and a live preview, replacing the question wizard","state":"adopted","ts":"2026-10-05T08:55:39+00:00","author":"claude-code","session":"","branch":"feat/config-studio","scope":["hooks/command.ts","hooks/pane.tsx"],"rationale":"Owner found the dialog wizard janky; dialogs take 2-4 text options and cannot show color","supports":[],"depends_on":[],"answers":[],"supersedes":["d60"],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"supersede_reason":"reverse","choice":"Pane driven by up/down + enter: value rows cycle and apply at once, color rows are 5-swatch strips with role labels, Band/Tabs sub-lists toggle items, a Fine-tune on web link","alternatives":["Keep the native question wizard"],"decided_by":"owner"} -{"schema":2,"kind":"decision","id":"d72","text":"glowup.khimani.dev gains a /studio page for building a look, setup and pet with live preview","state":"adopted","ts":"2026-10-05T08:55:40+00:00","author":"claude-code","session":"","branch":"feat/config-studio","scope":["docs/web"],"rationale":"Colors were shown only as hex; a picker plus preview explains each role","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"Per-role color pickers with plain labels that highlight in a terminal preview, setup controls, PNG pet upload; the landing's JSON textarea becomes a teaser","alternatives":["Extend the landing's JSON textarea"],"decided_by":"owner"} -{"schema":2,"kind":"decision","id":"d73","text":"Looks travel between the studio and Claude Code as a studio link with the data in its fragment","state":"adopted","ts":"2026-10-05T08:55:40+00:00","author":"claude-code","session":"","branch":"feat/config-studio","scope":["hooks/link.ts"],"rationale":"One link opens in a browser, installs in Claude, and shares without hosting","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"deflate+base64url JSON in #p= (look), s= (setup), pet= (small pet); /glowup pack decodes locally with the file validators and asks before applying a setup","alternatives":["Paste code with a new pack load command","Download JSON only"],"decided_by":"owner"} -{"schema":2,"kind":"decision","id":"d74","text":"Band items, pane tabs, meter thresholds, bubble timing and pet sleep become a personal setup kept out of packs","state":"adopted","ts":"2026-10-05T08:55:41+00:00","author":"claude-code","session":"","branch":"feat/config-studio","scope":["hooks/setup.ts"],"rationale":"Installing someone's look must not rearrange your layout","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"Setup object in $.store (band items and order, tabs and order, meter warn/danger, bubble cadence and duration, pet sleep); share links carry only the look","alternatives":["Put them in the pack format"],"decided_by":"owner"} -{"schema":2,"kind":"decision","id":"d75","text":"Custom pets come from a PNG sprite sheet that follows a published spec","state":"adopted","ts":"2026-10-05T08:55:42+00:00","author":"claude-code","session":"","branch":"feat/config-studio","scope":["hooks/userpets.ts","docs/pet-sprites.md"],"rationale":"A fixed layout lets the studio take a PNG with no mapping screen; mods cannot decode images","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"24x12 px frames, one fixed-order row per animation, idle required, <=60 colors, auto-mirrored left walks, template PNG; studio converts PNG to PetSheet; pets live in ~/.claude/glowup/pets and install with /glowup pet add","alternatives":["Studio upload with free-form row mapping","Hand-written pet JSON only"],"decided_by":"owner"} -{"schema":2,"kind":"correction","id":"d73.1","corrects":"d73","fields":{"cost_if_wrong":"The link fragment is a public format; changing it breaks links people have shared, so it needs a version field"},"reason":"","ts":"2026-10-05T08:55:47+00:00","author":"claude-code","session":"","branch":"feat/config-studio"} -{"schema":2,"kind":"correction","id":"d75.1","corrects":"d75","fields":{"cost_if_wrong":"The sprite layout is a public contract; reordering rows breaks every sheet made from the template"},"reason":"","ts":"2026-10-05T08:55:48+00:00","author":"claude-code","session":"","branch":"feat/config-studio"} -{"schema":2,"kind":"decision","id":"d76","text":"Studio link parts are plain base64url JSON, encoded by a codec in link.ts, with a required v=1","state":"adopted","ts":"2026-10-05T09:03:21+00:00","author":"claude-code","session":"","branch":"feat/config-studio","scope":["hooks/link.ts"],"rationale":"The mod runtime's CompressionStream and atob are unverified; a pack is under 1 KB","supports":[],"depends_on":[],"answers":[],"supersedes":["d73"],"evidence":[],"revisit":"","cost_if_wrong":"The fragment is a public format; v lets it change later","pinned":false,"supersede_reason":"revise","choice":"No compression; hand-written base64url over UTF-8; #v=1&p=&s=&pet=; pet rides only under 4 KB encoded","alternatives":["deflate-raw via CompressionStream"],"decided_by":"claude"} -{"schema":2,"kind":"decision","id":"d77","text":"Setup meter defaults to warn 50 / danger 80, bubbles pick moods, and /glowup setup edits it","state":"adopted","ts":"2026-10-05T09:03:21+00:00","author":"claude-code","session":"","branch":"feat/config-studio","scope":["hooks/setup.ts"],"rationale":"The code's thresholds are 50/80 tones, a 70 pane warning and an 80 pant, not 60/80; bubbles fire on moods with no timer to tune","supports":[],"depends_on":[],"answers":[],"supersedes":["d74"],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"supersede_reason":"revise","choice":"meter.warn/danger drive status-line tones, the pane's context warning (70 -> danger) and the pet's pant; bubbles.moods is a subset of needs-you, fail, done; /glowup setup [ |reset]","alternatives":["warn 60 / danger 80 and a bubble cadence setting"],"decided_by":"claude"} -{"schema":2,"kind":"decision","id":"d78","text":"A studio-link setup with unknown ids or bad fields is applied after the confirm, naming what it dropped; only a non-object setup part is refused","state":"adopted","ts":"2026-10-05T12:54:17+00:00","author":"claude-code","session":"session_01XHDkeQ4Bf8Ua9nABU9BiBt","branch":"feat/config-studio","scope":["hooks/command.ts","hooks/setup.ts"],"rationale":"The spec says a setup from a later glowup still loads; link text is untrusted, so notices are filtered before reaching the terminal","supports":[],"depends_on":["d77"],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A bad field in a link resets that field to its default","pinned":false,"choice":"packFromLink applies parseSetup's result with notices listed as ' dropped: …'; notices pass through shown()","alternatives":["Refuse the whole setup on any notice"],"decided_by":"claude"} -{"schema":2,"kind":"decision","id":"d79","text":"Packs get custom drawing through renderer plugins that hook glowup.field, glowup.meter and glowup.divider","state":"adopted","ts":"2026-10-05T09:14:43+00:00","author":"claude-code","session":"session_01CJ5TH6CtY5ysoqEkjnQWq6","branch":"feat/render-events","scope":["hooks/register.tsx, hooks/pane.tsx, hooks/rows.tsx"],"rationale":"The mod runtime refuses import() and runs mods isolated, so glowup cannot run downloaded code; plugin dependencies are the sanctioned path and Claude Code's /plugin trust covers the code. A Client node a guest returns runs as the plugin that renders the tree, so animation has to cross as frames, not as the guest's Client.","supports":[],"depends_on":["d29","d52","d45"],"answers":[],"supersedes":[],"evidence":[{"ref":"Spike 2026-10-05 with --plugin-dir hostx+guestx on Claude Code 2.1.289: guest hook on hostx.field ran; returning a guest Client failed ('the plugin loaded no surface module'); returning 48 warp frames (48,481 chars of props) played at 83 ms in the host's Client."}],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"glowup adds a $.glowup noun whose methods field, meter and divider are events; a separate Claude Code plugin that depends on glowup hooks them and answers with data (colored text segments; the field as a looping list of frames). glowup plays field frames in its own Client on its own timer and falls back to its own drawing when nothing answers.","alternatives":["Generic data-only pack fields in glowup (meters: dither, field: warp)","glowup downloads and runs a pack's renderer code"],"decided_by":"user"} -{"schema":2,"kind":"claim","id":"c80","text":"On 2.1.289 a focused pane takes Input and Select: an Input takes typing, backspace and Enter, and up/down move past it; a Select opens when focused, holds up/down inside its options until Enter picks or Tab/Shift+Tab leave, and Escape there closes a closeOnEscape pane","state":"accepted","ts":"2026-10-05T13:23:12+00:00","author":"claude-code","session":"","branch":"feat/config-studio","scope":["hooks/pane.tsx"],"rationale":"tmux spike with a throwaway mod (scratchpad/spike) driving Button, Select, Input in one focused pane","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"pane rows built on a control that misbehaves in a live session","pinned":false} -{"schema":2,"kind":"decision","id":"d81","text":"/glowup config opens a dedicated glowup pane with swatches and a live preview, replacing the question wizard","state":"adopted","ts":"2026-10-05T13:23:33+00:00","author":"claude-code","session":"","branch":"feat/config-studio","scope":["hooks/pane.tsx"],"rationale":"Input works in a focused pane on 2.1.289 (c80), so exact hex and list order no longer need the studio","supports":[],"depends_on":["c80"],"answers":[],"supersedes":["d71"],"evidence":[],"revisit":"","cost_if_wrong":"pane rows rebuilt if Input behaves differently on desktop","pinned":false,"supersede_reason":"revise","choice":"Value rows (pack, spinner, pet, meter, bubbles) are Buttons Enter cycles; each color row is a swatch plus a hex Input; Band and Tabs are comma-list Inputs, so the pane can reorder them; no Select, so up/down walk the whole pane; a Fine-tune on web link","alternatives":["Selects for value rows: they hold up/down while focused, so rows would need Tab","Buttons only with 5-swatch color strips (the original choice)"],"decided_by":"owner"} -{"schema":2,"kind":"decision","id":"d82","text":"The built-in pack field is computed each tick in the pane's Client instead of played from precomputed frames","state":"adopted","ts":"2026-10-05T14:05:03+00:00","author":"claude-code","session":"session_01CJ5TH6CtY5ysoqEkjnQWq6","branch":"feat/live-field","scope":["hooks/client/field.tsx","hooks/effects.ts","hooks/packs.ts"],"rationale":"Frames cost about half a second per resize and stuttered past ~60 columns; live drawing costs a few ms a frame","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Packs using the object form need glowup 0.8.0; 0.7.0 refuses it","pinned":false,"choice":"client/field.tsx samples the field every tick (lattice every 4 dots, one colour per field so each row is one segment); motion.field takes none|warp|simplex or an object with Paper's dithering knobs","alternatives":["precompute a looping frame list per pane size, as renderer plugins answer"],"decided_by":""} -{"schema":2,"kind":"claim","id":"c83","text":"In the mod runtime the field's cost tracks rendered element count, not compute: per-cell shading (~40 runs a row) took 84% of a core, one segment a row costs about 20 points at fps 10 on 88x24","state":"accepted","ts":"2026-10-05T14:05:04+00:00","author":"claude-code","session":"session_01CJ5TH6CtY5ysoqEkjnQWq6","branch":"feat/live-field","scope":["hooks/client/field.tsx"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"live session A/B with and without the field, load avg 10-16"}],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"correction","id":"c83.1","corrects":"c83","fields":{"text":"In the mod runtime the field's cost tracks rendered element count, not compute: per-cell shading (~40 runs a row) put the whole process at 84% of a core, while one segment a row adds about 25 points over the same pack without a field at fps 10 on 88x24"},"reason":"match the 25-point figure in hooks/effects.ts and the docs","ts":"2026-10-05T14:12:43+00:00","author":"claude-code","session":"session_01CJ5TH6CtY5ysoqEkjnQWq6","branch":"feat/live-field"} -{"schema":2,"kind":"decision","id":"d84","text":"The simplex field reproduces Paper's dithering shader so Paper settings copy into a pack unchanged","state":"adopted","ts":"2026-10-05T14:37:56+00:00","author":"claude-code","session":"session_01CJ5TH6CtY5ysoqEkjnQWq6","branch":"fix/paper-field","scope":["hooks/effects.ts","docs/pack-reference.md"],"rationale":"Owner tunes looks in Paper's browser tool; a 24-row pane is 96 dots tall against Paper's ~257 dither pixels at size 2.8, so one dot per pixel is the closest the pane can come","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Packs written for 0.8.0's simplex framing (offsets in pane heights, size 3) look different after the change","pinned":false,"choice":"Port Paper's snoise, smoothstep(0.3,0.9) contrast, vertical drift, framing (y up, offsets in the short side, before scale and rotation) and Bayer lighting order; the pane height stands for Paper's 720 px canvas; size stays braille dots per dither pixel","alternatives":["keep glowup's value-noise simplex and tune it by eye","map Paper's size in pixels onto dots"],"decided_by":""} -{"schema":2,"kind":"decision","id":"d85","text":"The status line shows the effort level as a field, on by default","state":"adopted","ts":"2026-10-05T13:45:23+00:00","author":"claude-code","session":"","branch":"feat/config-pane","scope":["hooks/fields.ts"],"rationale":"glowup's status line replaces Claude Code's footer, which showed the effort level; owner noticed it missing","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"one more field in every default status line","pinned":false,"choice":"Field id effort, drawn as '◐ ', read from main-loop turn.step requests (subagent steps ignored); default fields become activity, ctx, effort, 5h, week","alternatives":[],"decided_by":"owner"} -{"schema":2,"kind":"decision","id":"d86","text":"The crt pack's mascot is a hand-drawn CRT-monitor robot at 32x16, front-facing, with a dark plum outline","state":"adopted","ts":"2026-10-06T11:15:00+00:00","author":"claude-code","session":"","branch":"art/crt","scope":["art/crt/","hooks/sprites/crt.ts"],"rationale":"A cream case vanishes on light terminals (#f4efe6) without an outline; a 2-px shadow between planted feet reads as a dark block, not a shadow","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"owner rejects the style test","cost_if_wrong":"","pinned":false,"choice":"Cream case with plum outline (#3a2e3f), phosphor-green screen face with scanline rows, amber antenna tip, metal limbs; feet on row 15; no ground shadow under planted feet, shadow only under lifted feet and when airborne","alternatives":["No outline, like Clawd"],"decided_by":""} -{"schema":2,"kind":"decision","id":"d87","text":"art/build.py also writes -sheet.png (committed) and -sheet@8x.png (gitignored) in the public PNG pet layout","state":"adopted","ts":"2026-10-06T11:15:00+00:00","author":"claude-code","session":"","branch":"art/crt","scope":["art/build.py",".gitignore"],"rationale":"A gamma or color-profile chunk shifts colors so palette hexes stop round-tripping through the studio","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"Rows in order idle, walk, working, hop, alert, done, sleep, fail, juggle, pant, pant-walk, scrunch; cells are the sheet's own WxH; RGBA with only IHDR/IDAT/IEND chunks; stdlib zlib+struct only","alternatives":[],"decided_by":""} -{"schema":2,"kind":"decision","id":"d88","text":"No pet animation other than idle may contain idle's base frame; art/check_distinct.py enforces it on the generated sheet","state":"adopted","ts":"2026-10-06T11:46:19+00:00","author":"claude-code","session":"","branch":"art/crt","scope":["art/crt/","art/check_distinct.py"],"rationale":"The owner rejected rows that read as idle with a face swap","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Clawd fails it today (hop, fail and scrunch end on his base frame), so it cannot gate every pet without a Clawd exemption","pinned":false,"choice":"Every crt animation is restaged with its own silhouette and props; art/check_distinct.py exits non-zero when a non-idle frame equals idle[0]","alternatives":[],"decided_by":"owner"} -{"schema":2,"kind":"decision","id":"d89","text":"The studio page's background dither follows the look being built instead of having its own settings","state":"adopted","ts":"2026-10-06T09:06:02+00:00","author":"claude-code","session":"","branch":"main","scope":["docs/web/"],"rationale":"motion.field already carries Paper's dithering knobs, so the background can show them at no UI cost","supports":[["d72","d84"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"Redraw the background in the edited pack's colors and motion.field settings, so it previews the pane's field; no separate control","alternatives":["Fixed decorative background","Own sidebar controls for the background dither","Fixed background with an on/off toggle"],"decided_by":"owner"} -{"schema":2,"kind":"decision","id":"d90","text":"glowup ships a fox and a white stoat as built-in pets beside Clawd","state":"adopted","ts":"2026-10-06T09:24:56+00:00","author":"claude-code","session":"","branch":"main","scope":["art/","hooks/sprites/","hooks/pets.ts"],"rationale":"Owner asked for a playful red/orange fox and a white stoat in the custom-pets release","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"Hand-drawn in the clawd.art text format by an art agent, built by a generalized art/build.py into hooks/sprites/.ts; side-on facing right, the 12 spec animations only, no outfits, transitions or -left animations, so they also satisfy the user-pet rules","alternatives":["Ship them as example user pet files instead of built-ins"],"decided_by":"owner"} -{"schema":2,"kind":"decision","id":"d91","text":"The pet file format lives in a JSX-free hooks/petfile.ts and disk handling in hooks/userpets.ts, split like packs.ts and userpacks.ts","state":"adopted","ts":"2026-10-06T09:24:56+00:00","author":"claude-code","session":"","branch":"main","scope":["hooks/petfile.ts","hooks/userpets.ts"],"rationale":"The studio imports the validator, and userpets.ts needs the Host; fewer knobs keeps hand-written files simple","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Pet files written before a later format change would need format 2","pinned":false,"choice":"PetFile = {format 1, name, description?, palette, animations: {: frames[]}}; loop comes from the animation name (hop, fail, scrunch once); frames carry px, ms 80-10000, optional dx and exit; all frames share the first idle frame's size, at most 24x12","alternatives":["Validation inside userpets.ts as the spec names it","Let files carry loop, head and outfits"],"decided_by":""} -{"schema":2,"kind":"decision","id":"d92","text":"The studio decodes pet PNGs with the browser canvas and converts pixels with a pure function tested against a template PNG","state":"adopted","ts":"2026-10-06T09:24:56+00:00","author":"claude-code","session":"","branch":"main","scope":["docs/web/app/studio/petpng.ts","docs/web/app/studio/PetSection.tsx","scripts/pet-template.ts"],"rationale":"The canvas reads every PNG variant (indexed, interlaced, 16-bit) for free; only our own template needs the test decoder","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"createImageBitmap with colorSpaceConversion none, then getImageData; petFromPixels in docs/web/app/studio/petpng.ts; node tests decode docs/assets/pet-template.png with a test-only decoder","alternatives":["A hand-written PNG decoder shared by browser and tests"],"decided_by":""} -{"schema":2,"kind":"decision","id":"d93","text":"A studio pet rides only in the Send to my Claude command and the page hash, never in the share link","state":"adopted","ts":"2026-10-06T09:24:56+00:00","author":"claude-code","session":"","branch":"main","scope":["docs/web/app/studio/model.ts","docs/web/app/studio/Actions.tsx"],"rationale":"The share link is look-only per the spec and goes to chat apps; the send command is pasted into Claude Code, where length is not cut","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"If Claude Code mangles a 5-7 KB pasted command, pets would have to travel as files only","pinned":false,"choice":"Pet part included when it validates and encodes to at most PET_LINK_MAX (4096); the 2048-character long-link note applies to the share link only","alternatives":[],"decided_by":""} -{"schema":2,"kind":"decision","id":"d94","text":"The studio stage backdrop draws the edited look's own motion.field with PaneField, and nothing when the field is none","state":"adopted","ts":"2026-10-06T09:24:57+00:00","author":"claude-code","session":"","branch":"main","scope":["docs/web/app/studio/Studio.tsx","docs/web/app/landing/PaneField.tsx"],"rationale":"The landing shader is not Paper's field, so only PaneField shows what the pane would draw","supports":[["d89"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"PaneField with raised maxCols/maxRows behind .studio-preview","alternatives":["Recolor the landing's WebGL dither from the look"],"decided_by":""} -{"schema":2,"kind":"claim","id":"c95","text":"The pet sprite sheet is 144 px tall (12 rows of 12 px frames); the config-studio spec's 288 px is an arithmetic slip","state":"accepted","ts":"2026-10-06T09:24:57+00:00","author":"claude-code","session":"","branch":"main","scope":["docs/pet-sprites.md","scripts/pet-template.ts"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"decision","id":"d96","text":"Pet frames go up to 32x16 px and the pane's pet strip is as tall as the pet in use","state":"adopted","ts":"2026-10-06T09:56:53+00:00","author":"claude-code","session":"","branch":"main","scope":["hooks/petfile.ts","hooks/pets.ts","hooks/client/pet.tsx","docs/web/app/studio/petpng.ts"],"rationale":"The 24x12 fox and stoat had 1-px eyes and legs, no room to jump, and poses that read alike; 40x20 would leave 8 columns to walk in a 48-column pane","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Pet files and PNG sheets made for 32x16 would need redrawing if the cap ever shrinks","pinned":false,"choice":"FRAME_W 32, FRAME_H 16; PNG cells 32x16, converter crops to the box all frames share; strip rows = ceil(h/2), plus 2 while Clawd wears a hat; Clawd stays 24x12","alternatives":["Keep 24x12 and redesign the fox and stoat as front-facing chibis","40x20 frames"],"decided_by":"owner"} -{"schema":2,"kind":"claim","id":"c97","text":"The pet sprite sheet is 192 px tall: 12 rows of 32x16 cells","state":"accepted","ts":"2026-10-06T09:56:53+00:00","author":"claude-code","session":"","branch":"main","scope":["docs/pet-sprites.md","scripts/pet-template.ts"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":["c95"],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"supersede_reason":"revise"} -{"schema":2,"kind":"decision","id":"d98","text":"glowup ships the CRT robot as a built-in pet beside Clawd; the fox and stoat are dropped","state":"adopted","ts":"2026-10-06T12:05:31+00:00","author":"claude-code","session":"","branch":"main","scope":["hooks/petfile.ts","hooks/pets.ts"],"rationale":"Owner rejected two fox/stoat rounds and the duck, then approved the robot's full sheet; robot is the word a person types after /glowup pet","supports":[],"depends_on":[],"answers":[],"supersedes":["d90"],"evidence":[],"revisit":"","cost_if_wrong":"A stored pet setting of robot would need migrating if renamed","pinned":false,"supersede_reason":"reverse","choice":"Pet id robot, drawn from hooks/sprites/crt.ts (CRT_SHEET); front-facing, the 12 spec animations, mirrored for walking left","alternatives":["Pet id crt, matching the sprite file"],"decided_by":"owner"} -{"schema":2,"kind":"decision","id":"d99","text":"The studio's per-animation speed control is a range slider over fixed speed steps","state":"adopted","ts":"2026-10-06T12:32:34+00:00","author":"claude-code","session":"","branch":"feat/custom-pets","scope":["docs/web/app/studio/PetSection.tsx"],"rationale":"The spec's constraint names a slider; the steps stay the same data","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":" over SPEEDS indices, showing the current multiplier","alternatives":["A of the six steps"], "answers": [], "author": "claude-code", "branch": "feat/custom-pets", "choice": " over SPEEDS indices, showing the current multiplier", "cost_if_wrong": "", "decided_by": "", "depends_on": [], "evidence": [], "id": "d80", "kind": "decision", "migrated_from": "d99", "pinned": false, "rationale": "The spec's constraint names a slider; the steps stay the same data", "revisit": "", "schema": 3, "scope": ["docs/web/app/studio/PetSection.tsx"], "session": "", "state": "adopted", "supersedes": [], "supports": [], "text": "The studio's per-animation speed control is a range slider over fixed speed steps", "ts": "2026-10-06T12:32:34+00:00"} +{"alternatives": ["Each task edits its own docs"], "answers": [], "author": "claude-code", "branch": "feat/custom-pets", "choice": "Task 9 carries every doc change; the branch merges with docs complete", "cost_if_wrong": "", "decided_by": "", "depends_on": [], "evidence": [], "id": "d81", "kind": "decision", "migrated_from": "d100", "pinned": false, "rationale": "Intermediate commits never ship alone; splitting doc edits across tasks churns the same pages five times", "revisit": "", "schema": 3, "scope": ["docs/"], "session": "", "state": "adopted", "supersedes": [], "supports": [], "text": "Custom-pets docs land in one docs task on the feature branch instead of with each code task", "ts": "2026-10-06T12:32:34+00:00"} +{"alternatives": ["Lazy import() of the robot in the studio"], "answers": [], "author": "claude-code", "branch": "feat/custom-pets", "choice": "Static import of hooks/sprites/crt.ts from pets.ts", "cost_if_wrong": "Move the import behind import() if the landing bundle budget matters", "decided_by": "", "depends_on": [], "evidence": [], "id": "d82", "kind": "decision", "migrated_from": "d101", "pinned": false, "rationale": "A few KB gzipped; a lazy split adds async loading to a JSX-free shared module", "revisit": "", "schema": 3, "scope": ["hooks/pets.ts", "docs/web/app/landing/data.ts"], "session": "", "state": "adopted", "supersedes": [], "supports": [], "text": "The site bundle carries the robot's sheet because pets.ts imports it", "ts": "2026-10-06T12:32:34+00:00"} +{"alternatives": ["Run the live session check first"], "answers": [], "author": "claude-code", "branch": "feat/custom-pets", "choice": "Release after the final branch review and its fix wave; automated suites and browser checks stand in for the live paste/add/cycle/fallback run", "cost_if_wrong": "A wiring bug only a real session shows reaches users in 0.9.0 and needs a patch release", "decided_by": "owner", "depends_on": [], "evidence": [], "id": "d83", "kind": "decision", "migrated_from": "d102", "pinned": false, "rationale": "Owner chose to release once the reviewer completes; the live check was blocked by the environment", "revisit": "", "schema": 3, "scope": [], "session": "", "state": "adopted", "supersedes": [], "supports": [], "text": "Custom pets ship as 0.9.0 without the live Claude Code session check", "ts": "2026-10-06T13:55:57+00:00"} +{"answers": [], "author": "claude-code", "branch": "feat/custom-pets", "cost_if_wrong": "A user pet restarts its animation at x=0 on every pane redraw, or a real pet change is missed", "depends_on": [], "evidence": [{"ref": "types/vendor/claude-code.d.ts"}], "id": "c9", "kind": "claim", "migrated_from": "c103", "pinned": false, "rationale": "types/vendor/claude-code.d.ts JsonValue: plain data crosses between the hooks module and the surface module whole, and the engine drops non-plain values where it clones; an identity check reset the player on every redraw in the cloned-props test", "revisit": "The live session shows a user pet freezing or not switching", "schema": 3, "scope": ["hooks/client/pet.tsx"], "session": "", "state": "accepted", "supersedes": [], "supports": [], "text": "A Client's props reach its surface module as a clone, so a user pet's sheet is a new object on every pane redraw and the pet Client must compare sheet content, not identity, to know the pet changed", "ts": "2026-10-06T20:58:14+00:00"} +{"answers": [], "author": "claude-code", "branch": "feat/custom-pets", "cost_if_wrong": "Studio links that carry a pet or a large pack go to the model and install nothing", "depends_on": [], "evidence": [], "id": "c10", "kind": "claim", "migrated_from": "c104", "pinned": false, "rationale": "tmux bracketed-paste probe: 600 chars ran as a command, 800 collapsed; the full send command with a pet reached the model; prompt.submit received the wrapped text with origin composer", "revisit": "Claude Code expands pastes before reading slash commands", "schema": 3, "scope": ["hooks/command.ts", "hooks/register.tsx"], "session": "", "state": "accepted", "supersedes": [], "supports": [], "text": "On Claude Code 2.1.291 a paste over about 700 characters sits in the prompt as [Pasted text #N] and is sent to the model wrapped in instead of running as a slash command, so a pasted studio send command never reached /glowup", "ts": "2026-10-06T21:14:27+00:00"} +{"alternatives": ["Studio copies only the link and tells people to type /glowup pack first"], "answers": [], "author": "claude-code", "branch": "feat/custom-pets", "choice": "glowup runs a prompt that is one /glowup line, bare or as one pasted block from the person's own Enter, through prompt.submit and drops it with the command's output as the reason", "cost_if_wrong": "A real prompt that is only a /glowup line is swallowed instead of reaching the model", "decided_by": "", "depends_on": [], "evidence": [], "id": "d84", "kind": "decision", "migrated_from": "d105", "pinned": false, "rationale": "The studio's Send to my Claude copies a command past the paste-collapse length whenever it carries a pet; typing /glowup pack and pasting only the link is a step studio users would miss", "revisit": "", "schema": 3, "scope": ["hooks/register.tsx"], "session": "", "state": "adopted", "supersedes": [], "supports": [["c10"]], "text": "A pasted /glowup command runs as the command", "ts": "2026-10-06T21:53:49+00:00"} +{"answers": [], "author": "claude-code", "branch": "feat/custom-pets", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "c11", "kind": "claim", "migrated_from": "c106", "pinned": false, "rationale": "tmux run with --plugin-dir on feat/custom-pets at 7ab5216, screenshots via aha and headless chromium", "revisit": "", "schema": 3, "scope": [], "session": "", "state": "accepted", "supersedes": [], "supports": [["d83"]], "text": "The custom-pets live session check passed on Claude Code 2.1.291: pasted send command (pet; pack+setup+pet with the confirm), pet switches with the pane open, a user pet walking through a turn, pet add --force over the pet in use, the config Pet row skipping a broken file, and the deleted-pet fallback notice", "ts": "2026-10-06T21:54:05+00:00"} +{"answers": [], "author": "claude-code", "branch": "main", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "c12", "kind": "claim", "migrated_from": "c107", "pinned": false, "rationale": "Timestamped render asks against 30 ms screen samples on 2.1.291: the hook gets the new bodyRows at once; with its state reads capped at 40 ms the box still lost its bottom for 1.4 s, and sizing the field Client changed nothing", "revisit": "a Claude Code release that re-asks pane hooks before drawing a resized pane", "schema": 3, "scope": ["hooks/pane.tsx", "hooks/register.tsx"], "session": "", "state": "accepted", "supersedes": [], "supports": [], "text": "When the agent list opens under the prompt, Claude Code draws the docked pane's previous tree in the shrunken room for 0.2-1.4 s, cutting off the status box; glowup's hook answering late is not the cause and glowup cannot prevent it", "ts": "2026-10-06T16:57:02+00:00"} +{"author": "claude-code", "branch": "main", "corrects": "c12", "fields": {"cost_if_wrong": "a glowup-side fix exists and the pane keeps flashing a cut status box whenever subagents start"}, "id": "c12.1", "kind": "correction", "reason": "", "schema": 3, "session": "", "ts": "2026-10-06T17:00:30+00:00"} +{"answers": ["q7"], "author": "claude-code", "branch": "main", "cost_if_wrong": "The Konami egg is built on a key path that does not reach the mod", "depends_on": [], "evidence": [], "id": "c13", "kind": "claim", "migrated_from": "c108", "pinned": false, "rationale": "tmux spike with a throwaway mod (scratchpad/keyspike): after an SGR mouse click on the Client, ↑↑↓↓←→←→BA arrived as all ten keys; without the click, b and a landed in the prompt", "revisit": "A Claude Code release changes Client focus or onKey", "schema": 3, "scope": [], "session": "", "state": "accepted", "supersedes": [], "supports": [], "text": "On Claude Code 2.1.291 a Client's surface.onKey receives arrow keys (up, down, left, right) and letters once the person clicks the Client; before a click the keys go to the prompt, and Escape returns the focus", "ts": "2026-10-07T06:40:08+00:00"} +{"alternatives": ["Crack overlays on one sheet", "Typing konami at the prompt to open a code dialog"], "answers": [], "author": "claude-code", "branch": "main", "choice": "Click Clawd's pet Client (it hops), type up up down down left right left right b a; the Client matches locally and posts once; the egg is a hidden built-in pet with four hand-drawn sheets, one per crack stage, cracking every 10 passing runs to stage 3", "cost_if_wrong": "Four sheets of art for one egg, and the code needs a terminal with mouse events", "decided_by": "owner", "depends_on": [], "evidence": [], "id": "d85", "kind": "decision", "migrated_from": "d109", "pinned": false, "rationale": "Owner picked 3 stages of 10 runs, full sheets per stage so cracks tilt with the wobble, and a hop on click with a quiet code", "revisit": "", "schema": 3, "scope": ["hooks/client/pet.tsx", "hooks/eggs.ts"], "session": "", "state": "adopted", "supersedes": [], "supports": [["c13"]], "text": "The Konami egg unlocks from a click on Clawd and the code typed into his Client", "ts": "2026-10-07T07:02:03+00:00"} +{"answers": [], "author": "claude-code", "branch": "feat/konami-egg", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "q12", "kind": "question", "migrated_from": "q110", "pinned": false, "rationale": "", "revisit": "", "schema": 3, "scope": ["hooks/eggs.ts"], "session": "", "state": "open", "supersedes": [], "supports": [], "text": "What does the Konami egg hatch into, and when?", "ts": "2026-10-07T08:54:10+00:00"} From 7960dbdebde4eede6878dbdb3f2085bc3b8e0043 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 7 Oct 2026 16:16:40 +0300 Subject: [PATCH 07/11] feat(catalog): read the version, refresh the catalog, install a configured pack in the background Signed-off-by: NovusEdge --- hooks/register.tsx | 41 ++++++++++++++- test/catalog-wiring.test.ts | 100 ++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 test/catalog-wiring.test.ts diff --git a/hooks/register.tsx b/hooks/register.tsx index 0f43daa..c3c0ae3 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -2,10 +2,11 @@ import type { EngineInterface, PaneOpenArgs, Register, RenderElement, RenderSurf import type { Host } from './host.ts' import { initialModel, normalizeModel, applyEvent, mergeCounts, isBusy, agentsRunning, type Model, type Ev } from './model.ts' import { approvalLabel, dialogCall, modeAsksPerson, shortPath } from './events.ts' -import { shown, type Theme } from './themes.ts' +import { shown, parseJsonc, type Theme } from './themes.ts' +import { loadCatalog, refreshCatalog, installEntry, canRun, type CatalogEntry } from './catalog.ts' import { resolveLook, cleanOverrides, exportMix, exportName, DEFAULT_MIX, SPINNER_IDS, type Mix, type Look } from './packs.ts' import { PACKS } from './packpresets.ts' -import { loadUserPacks, SAFE_NAME } from './userpacks.ts' +import { loadUserPacks, SAFE_NAME, PACK_DIR } from './userpacks.ts' import { BUILTIN_SHEETS, CLAWD_SHEET, eggSheet, stripRows, type PetSetting, type PetInput, type PetKind, type PetSheet } from './pets.ts' import { loadUserPet, userPetNames, PET_DIR } from './userpets.ts' import { bubbleFor, BUBBLE_SETTINGS, daypart, fitsBubble, haikuLimit, haikuMaxTokens, haikuPrompt, kindWords, HaikuGate, HAIKU_MODEL, HAIKU_TIMEOUT_MS, sanitizeLine, speaks, type BubbleSetting, type BubbleVars, type HaikuContext, type Mood } from './bubbles.ts' @@ -102,6 +103,9 @@ let turnNo = 0 let bubbleCap = 40 // false in a plain -p run, where nobody sees a bubble let interactive = true +// glowup's own version from plugin.json, read at session start; catalog packs are gated on it +let ownVersion: string | undefined +let catalog: CatalogEntry[] = [] let friday = false let failed = false let tzOffset = 0 @@ -146,6 +150,7 @@ function hostOf($: Engine): Host { configDir, dataHome, home, + version: ownVersion, } } @@ -631,6 +636,23 @@ async function initialMix(host: Host, options: Readonly> return { ...DEFAULT_MIX, colors: pack, motion: pack, theme, spinner } } +// A pack the installer chose arrives as a name only; its files come from the catalog in the background. +async function installConfigured($: Engine, host: Host, name: string) { + let entry = catalog.find(e => e.name === name) + if (!entry) { const fresh = await refreshCatalog(host).catch(() => undefined); if (fresh) catalog = fresh; entry = catalog.find(e => e.name === name) } + const r = !entry ? { message: `${name} is not an installed or official pack.` } + : !canRun(entry, ownVersion) ? { message: `${name} needs glowup ${entry.minGlowup}.` } + : await installEntry(host, entry, { force: false }) + if ('name' in r && r.name) { + await host.storeDelete('catalogMissing') + mix = { ...mix, colors: name, motion: name } + await loadLook($) + return + } + if (interactive && (await host.storeGet('catalogMissing')) !== name) $.ui.toast(`${r.message} Showing the default look.`) + await host.storeSet('catalogMissing', name) +} + function ctlOf($: Engine): Ctl { return { current: () => theme.name, @@ -718,6 +740,7 @@ export const register: Register = (on, options) => { configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${home}/.claude` const xdg = await $.env.get('XDG_DATA_HOME') dataHome = xdg?.startsWith('/') ? xdg : `${home}/.local/share` + try { const v = (parseJsonc(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: unknown }).version; ownVersion = typeof v === 'string' ? v : undefined } catch { ownVersion = undefined } const host = hostOf($) off = false guardSid = safeId(await $.session.id()) @@ -741,6 +764,20 @@ export const register: Register = (on, options) => { void checkStale($) await $.command.register({ name: 'glowup', description: 'Themes, the glowup pane and status line', argumentHint: 'config|theme|pack|spinner|color|import|export|pet|bubbles|pane|motion|statusline on|fields|setup|restore' }) mix = await initialMix(host, options) + catalog = await loadCatalog(host) + const wanted = mix.colors + const configured = (await host.storeGet('mix')) === undefined && wanted !== DEFAULT_MIX.colors && !Object.hasOwn(PACKS, wanted) && + !(await host.exists(`${PACK_DIR(configDir)}/${wanted}.json`)) + if (configured) { + mix = { ...mix, colors: DEFAULT_MIX.colors, motion: DEFAULT_MIX.motion } + void installConfigured($, host, wanted).catch(() => {}) + } + const last = await host.storeGet('catalogFetchedAt') + const nowMs = await $.clock.now() + if (interactive && !(typeof last === 'number' && nowMs - last < 86_400_000)) { + await host.storeSet('catalogFetchedAt', nowMs) + void refreshCatalog(host).then(e => { if (e) catalog = e }).catch(() => {}) + } const storedPet = await host.storeGet('pet') const eggs = await host.storeGet('eggs') as EggStore | undefined const want = typeof storedPet === 'string' ? storedPet : typeof options.pet === 'string' && options.pet ? options.pet : 'clawd' diff --git a/test/catalog-wiring.test.ts b/test/catalog-wiring.test.ts new file mode 100644 index 0000000..e5e5922 --- /dev/null +++ b/test/catalog-wiring.test.ts @@ -0,0 +1,100 @@ +import { expect, mock } from 'claude-code/testing' +import { runGlowup, fakeFs, test } from './kit.ts' + +const ROOT = decodeURIComponent(new URL('..', import.meta.url).pathname).replace(/\/$/, '') +const MANIFEST = `${ROOT}/.claude-plugin/plugin.json` +const CONFIG = '/fake/.claude/glowup' +const OXIDE = { name: 'oxide', description: 'bone text and red oxide on warm ink', pack: 'https://example.com/oxide.json', themes: ['https://example.com/oxide-theme.json'], minGlowup: '0.1.0' } +const INDEX = JSON.stringify({ format: 1, packs: [OXIDE] }) +const PACK = JSON.stringify({ format: 1, name: 'oxide', colors: { theme: 'oxide' } }) +const THEME = JSON.stringify({ name: 'oxide', extends: 'classic' }) +const SERVED: Record = { 'https://glowup.khimani.dev/packs.json': INDEX, [OXIDE.pack]: PACK, [OXIDE.themes[0]!]: THEME } +const ok = (text: string) => ({ value: { ok: true, status: 200, headers: {}, text } }) as never + +// Wires the engine; `answer` decides each fetch, and every fetched url is recorded. +function boot(on: any, o: { files?: Record; store?: Record; answer?: (url: string) => unknown; beforeWrite?: () => void } = {}) { + const fs = fakeFs(on, { [MANIFEST]: '{"name":"glowup","version":"0.9.0"}', ...o.files }, undefined, {}, o.beforeWrite) + const clock = mock.clock(on) + mock.store(on, o.store ?? {}) + on('command.register', async () => ({ value: undefined }) as never) + on('session.start', async (_$: unknown, e: any) => ({ cwd: e.cwd }) as never) + on('ui.status', async () => ({ value: undefined }) as never) + on('session.id', async () => ({ value: 's1' })) + on('tool.call', async () => ({ result: {}, text: '' }) as never) + const fetched: string[] = [] + on('http.fetch', async (_$: unknown, e: any) => { + fetched.push(e.url) + return (o.answer ?? ((u: string) => u in SERVED ? ok(SERVED[u]!) : { value: { ok: false, status: 404, headers: {}, text: '' } }))(e.url) + }) + const toasts: string[] = [] + on('ui.toast', async (_$: unknown, e: any) => { toasts.push(e.text); return { value: undefined } as never }) + return { ...fs, clock, fetched, toasts } +} +const start = ($: any, isInteractive = true) => $.session.start({ cwd: '/r', surface: 'terminal', isInteractive }) +const settle = async (clock: { advance(ms: number): Promise }) => { for (let i = 0; i < 5; i++) await clock.advance(1) } +const catalogFetches = (fetched: string[]) => fetched.filter(u => u.endsWith('/packs.json')) + +test('a failed index fetch at session start shows nothing and keeps the cache', async ($, on) => { + const { files, toasts, clock } = boot(on, { files: { [`${CONFIG}/catalog.json`]: INDEX }, answer: () => { throw new Error('offline') } }) + await start($); await settle(clock) + expect(files[`${CONFIG}/catalog.json`]).toBe(INDEX) + expect(toasts.filter(t => /catalog|official|oxide/i.test(t))).toEqual([]) + expect((await runGlowup($, 'pack list')).text).toContain('classic') +}) + +test('a failed cache write at session start shows nothing', async ($, on) => { + const { toasts, clock, fetched } = boot(on, { beforeWrite: () => { throw new Error('EACCES') } }) + await start($); await settle(clock) + expect(catalogFetches(fetched)).toHaveLength(1) + expect(toasts.filter(t => /catalog|EACCES/i.test(t))).toEqual([]) +}) + +test('the first session start fetches and caches the index; a second within 24 hours does not', { timeoutMs: 60000 }, async ($, on) => { + const { files, fetched, clock } = boot(on) + await start($); await settle(clock) + expect(files[`${CONFIG}/catalog.json`]).toBe(INDEX) + expect(catalogFetches(fetched)).toHaveLength(1) + await clock.advance(3_600_000) + await start($); await settle(clock) + expect(catalogFetches(fetched)).toHaveLength(1) + await clock.advance(24 * 3_600_000) + await start($); await settle(clock) + expect(catalogFetches(fetched)).toHaveLength(2) +}) + +test('a non-interactive session with no configured catalog pack does not fetch', async ($, on) => { + const { fetched, clock } = boot(on) + await start($, false); await settle(clock) + expect(fetched).toEqual([]) +}) + +test('a configured catalog pack installs in the background and then loads', { options: { pack: 'oxide' } }, async ($, on) => { + let release!: () => void + const gate = new Promise(r => { release = r }) + const { files, clock } = boot(on, { answer: url => gate.then(() => url in SERVED ? ok(SERVED[url]!) : ok('')) }) + await start($) + // start has returned while every download still waits + expect(`${CONFIG}/packs/oxide.json` in files).toBe(false) + expect((await runGlowup($, 'pack list')).text).toContain('● classic') + release(); await settle(clock) + expect(files[`${CONFIG}/packs/oxide.json`]).toBe(PACK) + expect((await runGlowup($, 'pack list')).text).toContain('● oxide') +}) + +test('a configured catalog pack that cannot download shows the default and one toast, once', { options: { pack: 'oxide' } }, async ($, on) => { + const { toasts, clock, files } = boot(on, { answer: () => { throw new Error('offline') } }) + await start($); await settle(clock) + expect(toasts.filter(t => t.includes('oxide'))).toHaveLength(1) + expect(`${CONFIG}/packs/oxide.json` in files).toBe(false) + expect((await runGlowup($, 'pack list')).text).toContain('● classic') + await start($); await settle(clock) + expect(toasts.filter(t => t.includes('oxide'))).toHaveLength(1) +}) + +test('/glowup pack after start installs: the command host knows the version', async ($, on) => { + const { files, clock } = boot(on, { files: { [`${CONFIG}/catalog.json`]: INDEX } }) + await start($); await settle(clock) + const out = (await runGlowup($, 'pack oxide')).text as string + expect(out).not.toContain('needs glowup') + expect(files[`${CONFIG}/packs/oxide.json`]).toBe(PACK) +}) From c4b4368e409072cbbfabc0bca9d80aeb6c5f68da Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 7 Oct 2026 16:29:39 +0300 Subject: [PATCH 08/11] feat(catalog): show official packs in the config pane, install with a press Signed-off-by: NovusEdge --- hooks/configpane.tsx | 6 ++- hooks/configrows.ts | 10 ++++- hooks/register.tsx | 68 +++++++++++++++++++++++++--------- test/configpane-wiring.test.ts | 67 +++++++++++++++++++++++++++++++++ test/configpane.test.ts | 14 ++++++- test/configrows.test.ts | 15 +++++++- 6 files changed, 154 insertions(+), 26 deletions(-) diff --git a/hooks/configpane.tsx b/hooks/configpane.tsx index de290f4..dfc6376 100644 --- a/hooks/configpane.tsx +++ b/hooks/configpane.tsx @@ -4,7 +4,7 @@ import { fit, hearts, renderSegs, type Seg } from './layout.tsx' import { cycleValue, inputValue, type ConfigState, type CycleId, type InputId, type Role } from './configrows.ts' export type ConfigNote = { text: string; tone: 'ok' | 'error' } -export type ConfigHandlers = { cycle(id: CycleId): void; input(id: InputId, text: string): void; done(): void; reset(): void; copyLink(): void } +export type ConfigHandlers = { cycle(id: CycleId): void; install(name: string): void; input(id: InputId, text: string): void; done(): void; reset(): void; copyLink(): void } type Els = { Box: any; Text: any; Button: any; Input?: any; Link?: any } const LABEL = 11 @@ -57,7 +57,9 @@ export function renderConfig(els: Els, s: ConfigState, look: Look, width: number return ( {title('LOOK')} - {cycle('pack', 'Pack', true)} + {s.pick + ? {cycle('pack', 'Pack', true)}