diff --git a/CLAUDE.md b/CLAUDE.md --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,10 @@ This repository uses an automated maintenance system called "AestheticAnts" (AA). The main score lives at `SCORE.md`, and ant-specific mindset/rules live in `ants/mindset-and-rules.md`. Read both before contributing. **Important:** Do not modify `ants/mindset-and-rules.md` unless you are the queen (@jeffrey). +## The Hand (Code Style) + +`HAND.md` at the repo root is the code-style guide — the companion to `papers/VOICE.md` (which governs prose). It codifies the terse, single-mind discipline of AC's hand-written era and how to keep new code legible as an *instrument*, not just runnable. Read it before writing or carving core code. Key idea: leaves (pieces) stay small and can be loop-generated; the foundational libs are the instrument and want knowability over raw terseness. The active "rehandify" campaign and its guardrails live at the bottom of that file. + ## Development Commands ### Running the Development Environment diff --git a/pop/hopehop/bin/fetch-kit.mjs b/pop/hopehop/bin/fetch-kit.mjs --- a/pop/hopehop/bin/fetch-kit.mjs +++ b/pop/hopehop/bin/fetch-kit.mjs @@ -19,16 +19,20 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const KIT_PATH = resolve(HERE, "..", "kit.json"); // role → freesound query + duration filter. Trap-leaning, punchy one-shots. +// classic boom-bap / native-tongues flavor — dusty sampled-record one-shots, +// vintage drum-machine + live hand-perc, NOT modern trap. CC-only. const ROLES = { - kick: { query: "trap kick punchy 808", filter: "duration:[0.1 TO 2]" }, - snare: { query: "trap snare", filter: "duration:[0.1 TO 2]" }, - clap: { query: "hand clap", filter: "duration:[0.05 TO 1.5]" }, - hat: { query: "closed hihat", filter: "duration:[0.02 TO 0.6]" }, - ohat: { query: "open hi hat", filter: "duration:[0.05 TO 1.2]" }, - shaker:{ query: "shaker percussion one shot", filter: "duration:[0.05 TO 1]" }, - rim: { query: "rim click percussion", filter: "duration:[0.02 TO 0.8]" }, - perc: { query: "percussion conga hit", filter: "duration:[0.05 TO 1.2]" }, - crash: { query: "crash cymbal", filter: "duration:[0.5 TO 4]" }, + kick: { query: "boom bap kick drum", filter: "duration:[0.1 TO 1.2]" }, + snare: { query: "boom bap snare", filter: "duration:[0.1 TO 1.5]" }, + clap: { query: "909 clap", filter: "duration:[0.05 TO 1.5]" }, + hat: { query: "lofi closed hihat", filter: "duration:[0.02 TO 0.6]" }, + ohat: { query: "open hihat analog", filter: "duration:[0.05 TO 1.2]" }, + shaker:{ query: "shaker percussion one shot", filter: "duration:[0.05 TO 1]" }, + tamb: { query: "tambourine one shot", filter: "duration:[0.05 TO 1.2]" }, + rim: { query: "rimshot cross stick", filter: "duration:[0.02 TO 0.8]" }, + perc: { query: "conga hit live percussion", filter: "duration:[0.05 TO 1.2]" }, + bongo: { query: "bongo hit", filter: "duration:[0.05 TO 1.2]" }, + crash: { query: "vintage crash cymbal", filter: "duration:[0.5 TO 4]" }, panther:{ query: "panther", filter: "duration:[2 TO 6]" }, // #536331 = clean tonal roar panther2:{ query: "jaguar growl roar", filter: "duration:[1 TO 5]" }, // alt big-cat voice scratch:{ query: "turntable scratch vinyl", filter: "duration:[0.1 TO 1.5]" }, @@ -37,9 +41,16 @@ }; const argv = process.argv.slice(2); const LIST = argv.includes("--list"); -const pickArg = argv.find((a) => a.startsWith("--pick")); +// --pick accepts one or more role=idx pairs, comma- or space-separated: +// --pick clap=2 · --pick "kick=0,clap=2,hat=1" · --pick kick=0 clap=2 const pins = {}; -if (pickArg) { const v = argv[argv.indexOf(pickArg) + 1] || ""; const [r, i] = v.split("="); if (r) pins[r] = parseInt(i, 10) || 0; } +for (let i = 0; i < argv.length; i++) { + if (!argv[i].startsWith("--pick")) continue; + const specs = []; + if (argv[i].includes("=")) specs.push(...argv[i].replace(/^--pick=?/, "").split(",")); + while (argv[i + 1] && !argv[i + 1].startsWith("--")) specs.push(...argv[++i].split(",")); + for (const s of specs) { const [r, n] = s.split("="); if (r && n !== undefined) pins[r.trim()] = parseInt(n, 10) || 0; } +} const kit = existsSync(KIT_PATH) ? JSON.parse(readFileSync(KIT_PATH, "utf8")) : {}; diff --git a/pop/hopehop/bin/gen-illy.mjs b/pop/hopehop/bin/gen-illy.mjs --- a/pop/hopehop/bin/gen-illy.mjs +++ b/pop/hopehop/bin/gen-illy.mjs @@ -1,52 +1,135 @@ #!/usr/bin/env node -// gen-illy.mjs — generate the hopehop album cover (text-to-image). +// gen-illy.mjs — generate the hopehop album cover via gpt-image-2, then +// embed it into the rendered mp3 as ID3 art. // -// Lean standalone generator (no jeffrey-platter identity refs — hopehop's -// cover is a panther/milkshake emblem, not a portrait). Calls gpt-image-2 -// /v1/images/generations with the prompt in hopehop.illy.txt. -// -// node pop/hopehop/bin/gen-illy.mjs # cached; skips if cover exists -// node pop/hopehop/bin/gen-illy.mjs --force # regenerate +// PHOTOGRAPHIC jeffrey cover: same identity-ref path as +// pop/marimba/bin/gen-illy.mjs + pop/chillwave/bin/gen-illy.mjs (jeffrey +// SHOOT + IG-archive refs + the whistlegraph-butterfly scrap), so hopehop +// sits in the same jeffrey singles series. The prompt +// (pop/hopehop/hopehop.illy.txt) drives a summer-jam stoop scene — +// jeffrey + a panther cub + a strawberry milkshake — in a real +// photographic register (golden-hour, 35mm grain), NOT the old +// illustrated panther emblem. // // Output: pop/hopehop/out/hopehop-cover.png (1024x1024) +// Embed: pop/hopehop/out/hopehop.mp3 (ID3v2 attached picture) +// +// node pop/hopehop/bin/gen-illy.mjs # cached if cover exists +// node pop/hopehop/bin/gen-illy.mjs --force # regenerate +// node pop/hopehop/bin/gen-illy.mjs --embed-only # skip gen, just embed -import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; const HERE = dirname(fileURLToPath(import.meta.url)); const LANE = resolve(HERE, ".."); const REPO = resolve(LANE, "..", ".."); -const FORCE = process.argv.includes("--force"); -const PROMPT_PATH = resolve(LANE, "hopehop.illy.txt"); -const OUT_PATH = resolve(LANE, "out", "hopehop-cover.png"); + +const flags = {}; +for (let i = 2; i < process.argv.length; i++) { + if (process.argv[i].startsWith("--")) flags[process.argv[i].slice(2)] = true; +} +const FORCE = flags.force === true; +const SIZE = "1024x1024"; + +const _af = (k, d) => { const i = process.argv.indexOf(k); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d; }; +const PROMPT_PATH = resolve(process.cwd(), _af("--prompt", `${LANE}/hopehop.illy.txt`)); +const OUT_PATH = resolve(process.cwd(), _af("--cover", `${LANE}/out/hopehop-cover.png`)); +const MP3_PATH = resolve(process.cwd(), _af("--mp3", `${LANE}/out/hopehop.mp3`)); +mkdirSync(`${LANE}/out`, { recursive: true }); + +// ── identity refs (mirrors marimba/chillwave gen-illy.mjs) ─────────── +const SHOOT_DIR = `${REPO}/portraits/jeffrey/corpus/shoot-2k`; +const ARCHIVE_DIR = `${REPO}/portraits/jeffrey/ig-archive/whistlegraph`; +const REFS = [ + `${SHOOT_DIR}/jeffery-av--07.jpg`, + `${SHOOT_DIR}/jeffery-av--01.jpg`, + `${SHOOT_DIR}/jeffery-av--04.jpg`, + `${ARCHIVE_DIR}/2018-12-02_Bq4ckGFFNtW.jpg`, + `${ARCHIVE_DIR}/2020-09-02_CEpxlO2FOvD.jpg`, + `${ARCHIVE_DIR}/2021-07-10_CRI095Vl7AO_1.jpg`, + `${ARCHIVE_DIR}/2025-01-25_DFQ2lHPzN_W.jpg`, + // the whistlegraph butterfly scrap — model DRAWS it, never composites + `${REPO}/pop/chillwave/assets/wg-scrap.png`, +].filter((p) => { + if (existsSync(p)) return true; + console.warn(` ⚠ ref missing, dropping: ${p}`); + return false; +}); -function loadKey() { +function loadOpenAIKey() { if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY; - const vault = resolve(REPO, "aesthetic-computer-vault/.devcontainer/envs/devcontainer.env"); + const vault = `${REPO}/aesthetic-computer-vault/.devcontainer/envs/devcontainer.env`; if (existsSync(vault)) { for (const line of readFileSync(vault, "utf8").split("\n")) { - if (line.startsWith("OPENAI_API_KEY=")) return line.slice(15).trim().replace(/^['"]|['"]$/g, ""); + if (line.startsWith("OPENAI_API_KEY=")) { + return line.slice("OPENAI_API_KEY=".length).trim().replace(/^['"]|['"]$/g, ""); + } } } - throw new Error("OPENAI_API_KEY not set and not found in vault devcontainer.env"); + throw new Error("OPENAI_API_KEY not set and not found in vault"); +} + +// ── embed the cover into the mp3 as ID3v2 attached picture ─────────── +function embedCover() { + if (!existsSync(OUT_PATH) || !existsSync(MP3_PATH)) { + console.warn(` ⚠ skip embed — need both ${OUT_PATH} and ${MP3_PATH}`); + return; + } + const tmp = `${MP3_PATH}.embed.mp3`; + const r = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", + "-i", MP3_PATH, "-i", OUT_PATH, + "-map", "0:a", "-map", "1:v", "-c", "copy", "-id3v2_version", "3", + "-metadata:s:v", "title=cover", "-metadata:s:v", "comment=Cover (front)", + "-disposition:v", "attached_pic", tmp], { stdio: "inherit" }); + if (r.status !== 0 || !existsSync(tmp)) { console.error("✗ embed failed"); return; } + renameSync(tmp, MP3_PATH); + console.log(`✓ cover embedded → ${MP3_PATH.replace(REPO + "/", "")}`); } -if (existsSync(OUT_PATH) && !FORCE) { console.log(`✓ cover exists (use --force): ${OUT_PATH}`); process.exit(0); } +if (flags["embed-only"]) { embedCover(); process.exit(0); } +if (existsSync(OUT_PATH) && !FORCE) { + console.log(`✓ cached cover → ${OUT_PATH.replace(REPO + "/", "")} (use --force to regen)`); + embedCover(); + process.exit(0); +} + +const apiKey = loadOpenAIKey(); const prompt = readFileSync(PROMPT_PATH, "utf8").trim(); -const apiKey = loadKey(); -mkdirSync(dirname(OUT_PATH), { recursive: true }); +console.log(`▸ hopehop cover · ${SIZE} · ${REFS.length} jeffrey refs`); +const t0 = Date.now(); -console.log("• generating hopehop cover (gpt-image-2, 1024x1024) …"); -const res = await fetch("https://api.openai.com/v1/images/generations", { +const fd = new FormData(); +fd.append("model", "gpt-image-2"); +fd.append("prompt", prompt); +fd.append("size", SIZE); +fd.append("quality", "high"); +fd.append("n", "1"); +for (const ref of REFS) { + const buf = readFileSync(ref); + const ext = ref.toLowerCase().endsWith(".png") ? "png" + : ref.toLowerCase().endsWith(".webp") ? "webp" : "jpeg"; + fd.append("image[]", new Blob([buf], { type: `image/${ext}` }), ref.split("/").pop()); +} +const res = await fetch("https://api.openai.com/v1/images/edits", { method: "POST", - headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, - body: JSON.stringify({ model: "gpt-image-2", prompt, size: "1024x1024", n: 1 }), + headers: { Authorization: `Bearer ${apiKey}` }, + body: fd, }); -if (!res.ok) { console.error(`✗ image API ${res.status}: ${await res.text()}`); process.exit(1); } +if (!res.ok) { + console.error(`✗ OpenAI ${res.status}: ${(await res.text()).slice(0, 600)}`); + process.exit(1); +} const json = await res.json(); const b64 = json.data?.[0]?.b64_json; -if (!b64) { console.error("✗ no image in response"); process.exit(1); } +if (!b64) { + console.error(`✗ no image: ${JSON.stringify(json).slice(0, 280)}`); + process.exit(1); +} writeFileSync(OUT_PATH, Buffer.from(b64, "base64")); -console.log(`✓ ${OUT_PATH}`); +console.log(`✓ ${((Date.now() - t0) / 1000).toFixed(1)}s → ${OUT_PATH.replace(REPO + "/", "")}`); + +embedCover(); diff --git a/pop/hopehop/bin/render-hopehop.mjs b/pop/hopehop/bin/render-hopehop.mjs --- a/pop/hopehop/bin/render-hopehop.mjs +++ b/pop/hopehop/bin/render-hopehop.mjs @@ -37,10 +37,19 @@ const noOpen = hasFlag("--no-open"); const outPath = (() => { const o = takeFlag("--out"); return o ? expand(o) : resolve(OUT_DIR, "hopehop.mp3"); })(); const SWING = parseFloat(takeFlag("--swing") ?? "0.60"); const SEED = parseInt(takeFlag("--seed") ?? "7", 10); +// jeffrey-pvc lead vocal stem (from bin/say.mjs). When present it becomes +// the lead: the Apple `say` adlibs + the panther HOOK melody drop out so +// jeffrey's voice carries the words (panther stays on the bass + drop roar). +const VOCAL_STEM = (() => { const v = takeFlag("--vocal-stem"); return v ? expand(v) : null; })(); +const VOCAL_GAIN = parseFloat(takeFlag("--vocal-gain") ?? "0.95"); +const VOCAL_OFFSET = takeFlag("--vocal-offset"); // seconds; default = end of 2-bar intro +// tempo — forwarded to the C bed so render + bed stay in lock-step. Lower +// BPM = slower, longer track (more room to stretch the sung vocal). +const BPM = parseFloat(takeFlag("--bpm") ?? "82"); mkdirSync(OUT_DIR, { recursive: true }); // ── transport (must match c/hopehop.c) ─────────────────────────────── -const SR = 48000, BPM = 82, BEAT = 60 / BPM, BAR = BEAT * 4, S16 = BEAT / 4; +const SR = 48000, BEAT = 60 / BPM, BAR = BEAT * 4, S16 = BEAT / 4; // arrangement arc (bar ranges) — mirrors the C bed's SECTIONS for FLOW. const ARC = [["intro", 2], ["groove", 8], ["hook", 8], ["break", 2], ["drop", 8], ["outro", 2]]; const SEC_OF = []; for (const [nm, bars] of ARC) for (let i = 0; i < bars; i++) SEC_OF.push(nm); @@ -57,7 +66,12 @@ if (spawnSync("bash", [resolve(C_DIR, "build-hopehop.sh")], { stdio: "inherit" }).status !== 0) process.exit(1); } console.log("• rendering bed …"); const bedWav = resolve(OUT_DIR, "hopehop.bed.wav"); -if (spawnSync(BIN, ["--out", bedWav, "--swing", String(SWING), "--seed", String(SEED), ...argv], { stdio: "inherit" }).status !== 0) process.exit(1); +// the C bed is buffer-limited to --seconds; size it to the full 30-bar +// arrangement at THIS bpm so lower tempos don't get truncated (kept the +// render + bed bar grids in lock-step). +const ARR_BARS = ARC.reduce((a, [, b]) => a + b, 0); +const bedSeconds = (ARR_BARS * BAR + 0.5).toFixed(2); +if (spawnSync(BIN, ["--out", bedWav, "--swing", String(SWING), "--seed", String(SEED), "--bpm", String(BPM), "--seconds", bedSeconds, ...argv], { stdio: "inherit" }).status !== 0) process.exit(1); // ── audio helpers ──────────────────────────────────────────────────── function decodeF32(path, channels) { @@ -165,7 +179,8 @@ // as a saucy adlib track. Tune the line with --say "..." / voice with // --say-voice . const SAY_VOICE = takeFlag("--say-voice") ?? "Samantha"; const SAY_LINE = takeFlag("--say") ?? "want my,latina,gimme,thicc,milkshake,milkshake,mmm yeah"; -const ADLIBS = SAY_LINE.split(",").map((w) => w.trim()).filter(Boolean); +// when a jeffrey-pvc stem leads, skip the Apple `say` adlib synth entirely +const ADLIBS = VOCAL_STEM ? [] : SAY_LINE.split(",").map((w) => w.trim()).filter(Boolean); const SAY_DIR = resolve(OUT_DIR, "say-tuned"); mkdirSync(SAY_DIR, { recursive: true }); const slugw = (t) => t.replace(/\W+/g, "_"); function loadSafe(p) { try { return loadOneShot(p); } catch { return null; } } @@ -237,7 +252,7 @@ const t0 = t16(bar, s), t1 = t16(bar, (s + 1) % 16) + ((s + 1) >= 16 ? BAR : 0); const span = (t1 > t0 ? t1 - t0 : S16); for (let k = 0; k < subdiv; k++) { const v = baseVel * (subdiv > 1 ? 0.7 + 0.3 * (k / Math.max(1, subdiv - 1)) : 1) * (0.85 + 0.3 * rng()); - place(S.hat, t0 + (span * k) / subdiv + hr(0.003), 0.42 * v, hr(0.3)); + place(S.hat, t0 + (span * k) / subdiv + hr(0.003), 0.30 * v, hr(0.3)); } } @@ -254,6 +269,37 @@ L[i] += lp * env * Math.cos(a); R[i] += lp * env * Math.sin(a); } } +// ── soft LEAD SYNTH — doubles/answers the sung pentatonic melody ────── +// Mellow sine+harmonics with a slow attack + exponential body and a touch +// of vibrato; sits an octave above the vocal (C4–C5) so the two lines +// braid. A short stereo slap gives it air. Kept gentle so the voice leads. +const mtof = (m) => 440 * Math.pow(2, (m - 69) / 12); +function leadNote(midi, startSec, durSec, gain, pan) { + const f = mtof(midi), i0 = Math.round(startSec * SR), nLen = Math.floor(durSec * SR); + const atkN = Math.floor(0.018 * SR), relN = Math.floor(0.12 * SR); + const a = (pan * 0.5 + 0.5) * (Math.PI / 2), gL = Math.cos(a) * gain, gR = Math.sin(a) * gain; + for (let j = 0; j < nLen; j++) { + const i = i0 + j; if (i < 0 || i >= N) break; + const t = j / SR; + const vib = 1 + 0.006 * Math.sin(2 * Math.PI * 5.2 * t) * Math.min(1, t / 0.18); // gentle delayed vibrato + const ph = 2 * Math.PI * f * vib * t; + let s = Math.sin(ph) + 0.3 * Math.sin(2 * ph) + 0.12 * Math.sin(3 * ph); // soft, few harmonics + s *= 0.7; + let env = Math.exp(-2.0 * t); // mellow body decay + if (j < atkN) env *= j / atkN; // soft attack + if (j > nLen - relN) env *= (nLen - j) / relN; // release tail + L[i] += s * env * gL; R[i] += s * env * gR; + } +} +// lead phrase per loop-bar: [step, midi, beats] — C-major pentatonic in +// C4–C5, a call line that complements the vocal contour, resolving to C4. +const LEAD_MEL = { + 0: [[0, 67, 1.0], [8, 64, 1.0]], 1: [[4, 69, 1.2]], + 2: [[0, 72, 1.0], [8, 69, 1.0]], 3: [[4, 67, 1.4]], + 4: [[0, 64, 1.0], [8, 67, 1.0]], 5: [[4, 62, 1.2]], + 6: [[0, 64, 1.0], [8, 67, 1.0]], 7: [[4, 60, 1.6]], +}; + console.log("• sequencing trap layer (chill / flow-aware) …"); for (let bar = 0; bar < TOTAL_BARS; bar++) { const lb = bar % 8, phraseEnd = lb === 7; @@ -298,13 +344,32 @@ hatCell(bar, s, accent * hatGain * build, rolls.get(s) || 1); } if ((lb === 1 || lb === 5) && !rolls.has(14)) place(S.ohat, t16(bar, 14) + hr(0.003), 0.24, 0.25); - // PERC — shaker + rim + conga, panned (kept gentle) + // PERC — native-tongues hand percussion: shaker + rim + conga/quinto, + // tambourine jangle, bongo syncopation. panned, kept gentle. for (const s of [2, 6, 10, 14]) place(S.shaker, t16(bar, s) + hr(0.004), 0.26 * (0.8 + 0.4 * rng()) * build, hr(0.5)); for (const s of (rng() < 0.5 ? [3, 11] : [3, 11, 6])) place(S.rim, t16(bar, s) + hr(0.004), 0.3 * (0.8 + 0.4 * rng()) * build, hr(0.55)); - if (!isOutro && rng() < 0.6) place(S.perc, t16(bar, rng() < 0.5 ? 7 : 13) + hr(0.005), 0.28 * build, hr(0.45)); + if (!isOutro && rng() < 0.6) place(S.perc, t16(bar, rng() < 0.5 ? 7 : 13) + hr(0.005), 0.3 * build, hr(0.45)); + // tambourine — sparse, soft jangle on the backbeat (chill), right side + if (S.tamb && !isIntro && !isOutro) { + const tsteps = (sec === "hook" || isDrop) ? [8, 12] : [8]; + for (const s of tsteps) if (rng() < 0.6) place(S.tamb, t16(bar, s) + hr(0.005), 0.10 * (0.8 + 0.4 * rng()) * build, 0.4 + hr(0.15)); + } + // bongo — syncopated answer hits, panned opposite the conga + if (S.bongo && (sec === "groove" || sec === "hook" || isDrop) && rng() < 0.55) + place(S.bongo, t16(bar, [5, 7, 13, 15][Math.floor(rng() * 4)]) + hr(0.005), 0.26 * build, -0.4 + hr(0.15)); // soft cymbal to mark the drop's downbeat (gentle, not a crash-out) if (isDrop && lb === 0) place(S.crash, t16(bar, 0), 0.28, 0.0); + // ── LEAD SYNTH — braids with the vocal melody through hook + drop ─── + if ((sec === "hook" || isDrop) && !isBreak) { + for (const [s, midi, beats] of (LEAD_MEL[lb] || [])) { + if (rng() < 0.15) continue; // occasional breath + const g = (isDrop ? 0.2 : 0.16) * build; + leadNote(midi, t16(bar, s) + hr(0.004), beats * BEAT, g, hr(0.25)); + leadNote(midi, t16(bar, s) + 0.11 + hr(0.004), beats * BEAT * 0.7, g * 0.3, -0.3); // stereo slap + } + } + // ── PANTHER VOICE — sings the bass + the hook ────────────────────── const po = PANTHER_OCT * 12; if (sec === "groove" || sec === "hook" || isDrop) { @@ -312,8 +377,9 @@ // panther bass — low growl on the roots placePanther(PAN_ROOT[lb] + po, t16(bar, 0) + hr(0.004), 1.7 * BEAT, 0.55, 0); if (rng() < 0.6) placePanther(PAN_ROOT[lb] + po, t16(bar, 8) + hr(0.004), 1.0 * BEAT, 0.4, 0); } - if (sec === "hook" || isDrop) { - // panther hook — the melodic lead (pitched up into a growl-sing) + if (!VOCAL_STEM && (sec === "hook" || isDrop)) { + // panther hook — the melodic lead (pitched up into a growl-sing). + // Silenced when jeffrey's vocal leads, so the voices don't fight. for (const [s, midi, mb] of (PAN_HOOK[lb] || [])) placePanther(midi + po, t16(bar, s) + hr(0.004), mb * BEAT, 0.5, hr(0.2)); } @@ -336,8 +402,8 @@ if (S.scratch && phraseEnd && !isOutro) place(S.scratch, t16(bar, 12) + hr(0.005), 0.45, hr(0.4)); if (S.scratch2 && isDrop && rng() < 0.3) place(S.scratch2, t16(bar, [3, 7, 11][Math.floor(rng() * 3)]) + hr(0.005), 0.38, hr(0.5)); } -// ── sidechain pump (kicks punch through — deeper wiggle) ───────────── -for (let i = 0; i < N; i++) { const d = 0.4 + 0.6 * duck[i]; L[i] *= d; R[i] *= d; } +// ── sidechain pump (gentle — chill, not a hard wiggle) ─────────────── +for (let i = 0; i < N; i++) { const d = 0.66 + 0.34 * duck[i]; L[i] *= d; R[i] *= d; } // ── filter + volume automation (FLOW) ──────────────────────────────── // One-pole LP whose cutoff opens over the intro and dips in the break, @@ -365,24 +431,57 @@ const g = autoGain(t); L[i] = lpL * g; R[i] = lpR * g; } +// ── jeffrey-pvc lead vocal — mixed clean over the finished bed ─────── +// Mixed AFTER the sidechain pump + filter automation so the voice stays +// present and un-ducked (the ffmpeg master still EQs it). Starts at the +// end of the 2-bar intro by default; lands the first "hope" on the groove. +if (VOCAL_STEM) { + if (!existsSync(VOCAL_STEM)) { console.error(`✗ vocal stem not found: ${VOCAL_STEM}`); process.exit(1); } + const vI = decodeF32(VOCAL_STEM, 2); + const vN = Math.floor(vI.length / 2); + // ElevenLabs stems run quiet (~ -33 dB mean). Peak-normalize so VOCAL_GAIN + // is meaningful, then mix the voice forward and DUCK the bed under it. + let vpk = 0; for (let j = 0; j < vN * 2; j++) { const a = Math.abs(vI[j]); if (a > vpk) vpk = a; } + const norm = vpk > 0 ? 0.97 / vpk : 1; + const off = Math.round((VOCAL_OFFSET != null ? parseFloat(VOCAL_OFFSET) : 2 * BAR) * SR); + const fadeN = Math.floor(0.01 * SR); + // envelope follower on the (normalized) vocal → ducks the instrumental + const atk = Math.exp(-1 / (0.005 * SR)), rel = Math.exp(-1 / (0.18 * SR)); + let venv = 0; + console.log(`• mixing jeffrey-pvc vocal: ${VOCAL_STEM.split("/").pop()} (peak-norm ×${norm.toFixed(2)} · gain ${VOCAL_GAIN} · duck bed)`); + for (let j = 0; j < vN; j++) { + const i = off + j; if (i < 0 || i >= N) break; + const vl = vI[j * 2] * norm, vr = vI[j * 2 + 1] * norm; + const mag = Math.max(Math.abs(vl), Math.abs(vr)); + venv = mag > venv ? atk * venv + (1 - atk) * mag : rel * venv + (1 - rel) * mag; + const dip = 1 - 0.45 * Math.min(1, venv / 0.5); // duck bed up to 45% under voice + let env = VOCAL_GAIN; + if (j < fadeN) env *= j / fadeN; // tiny in/out fades to avoid clicks + if (j > vN - fadeN) env *= (vN - j) / fadeN; + L[i] = L[i] * dip + vl * env; R[i] = R[i] * dip + vr * env; + } +} + // ── write mixed f32 → warm (less-bright, less-loud) master → mp3 ────── const mix = new Float32Array(N * 2); for (let i = 0; i < N; i++) { mix[i * 2] = L[i]; mix[i * 2 + 1] = R[i]; } const tmpRaw = resolve(tmpdir(), `hopehop-${SEED}.f32`); writeFileSync(tmpRaw, Buffer.from(mix.buffer)); -// hip-hop master: controlled sub, cleared mud, vocal presence + air, -// bus glue, then loud-but-clean ceiling. +// CHILL master: warm low-mids, mud trimmed gently, harsh presence + air +// rolled OFF (soft / lo-fi), easy glue, quiet + dynamic loudness so the +// track breathes instead of squashing flat. const MASTER = [ - "highpass=f=30", - "equalizer=f=55:t=q:w=0.7:g=2.2", // 808 / sub weight - "equalizer=f=250:t=q:w=1.1:g=-1.8", // clear the mud so vocal + flute sit - "equalizer=f=1800:t=q:w=1.2:g=0.7", // body - "equalizer=f=4200:t=q:w=1.0:g=1.4", // vocal presence (say-vocal sits up) - "equalizer=f=10000:t=h:w=0.7:g=1.8", // air / sheen - "acompressor=threshold=-20dB:ratio=2.5:attack=10:release=150:makeup=2.5", // glue + "highpass=f=32", + "equalizer=f=60:t=q:w=0.8:g=1.6", // gentle sub warmth + "equalizer=f=200:t=q:w=1.0:g=1.1", // low-mid body / coziness + "equalizer=f=320:t=q:w=1.1:g=-1.0", // light mud trim + "equalizer=f=3400:t=h:w=0.8:g=-0.8", // gently tame harsh presence (still smooth) + "equalizer=f=8000:t=h:w=0.7:g=1.2", // restore air → harmonies shimmer (emo sheen) + "equalizer=f=12000:t=h:w=0.7:g=1.0", // top sparkle + "acompressor=threshold=-21dB:ratio=2.4:attack=15:release=200:makeup=2.2", // tighter glue "alimiter=limit=0.97:level=false", - "loudnorm=I=-9.5:TP=-1.0:LRA=9", // hip-hop loudness + "loudnorm=I=-10:TP=-1.0:LRA=9", // loud, lush emo-rap level ].join(","); const ff = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", diff --git a/pop/hopehop/bin/sing.mjs b/pop/hopehop/bin/sing.mjs new file mode 100644 --- /dev/null +++ b/pop/hopehop/bin/sing.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +// sing.mjs — turn the spoken jeffrey-pvc stem into a SUNG, harmonized, +// emo-rapper lead: slow drawn-out melody, vowels held to the notes, +// stacked harmonies. +// +// Pipeline: +// 1. WORLD f0-replace (bin/pitchsnap_world.py): swap each word's pitch +// onto a smooth pentatonic melody (higher register), anchored at the +// word's real start, jeffrey's voice character preserved. +// 2. SLOW + VOWEL HOLD: place words on a time-scaled grid (--slow / or +// derived from --target-dur) so the singing drags; split each word +// into onset/vowel/coda, keep consonants crisp at natural speed, and +// stretch ONLY the vowel to fill its (now larger) note slot. +// 3. HARMONY STACK: formant-preserving pitch-shift copies (octave up +// for shimmer, octave down for body, a fifth) summed under the lead. +// +// Output: hopehop/out/hopehop-vocal-sung.mp3 (feed to render --vocal-stem) +// +// node pop/hopehop/bin/sing.mjs --target-dur 100 --transpose 7 +// node pop/hopehop/bin/sing.mjs --slow 1.3 --harmonies "12:0.32,-12:0.5,7:0.3" + +import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const LANE = resolve(HERE, ".."); +const POP = resolve(LANE, ".."); +const PY = resolve(POP, ".venv", "bin", "python"); +const PSNAP = resolve(POP, "bin", "pitchsnap_world.py"); +const SR = 48000; + +const argv = process.argv.slice(2); +const takeFlag = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; }; +const VOCAL = resolve(process.cwd(), takeFlag("--vocal", `${LANE}/out/hopehop-vocal.mp3`)); +const ALIGN = resolve(process.cwd(), takeFlag("--align", `${VOCAL}.alignment.json`)); +const OUT = resolve(process.cwd(), takeFlag("--out", `${LANE}/out/hopehop-vocal-sung.mp3`)); +const RETAIN = takeFlag("--retain", "1.0"); +const XFADE = takeFlag("--xfade-ms", "120"); +const VIB_HZ = takeFlag("--vibrato-hz", "5.0"); +const VIB_CENTS = takeFlag("--vibrato-cents", "12"); +const TRANSPOSE = parseInt(takeFlag("--transpose", "7"), 10); // semitones to raise the melody +const LEGATO = parseFloat(takeFlag("--legato", "0.97")); // fill this fraction of each note slot +const MAX_HOLD = parseFloat(takeFlag("--max-hold", "2.6")); // cap a single sustained vowel (s) +const CRISP = takeFlag("--crisp", "4"); // rubberband crispness for the vowel body +const TARGET_DUR = parseFloat(takeFlag("--target-dur", "100")); // desired sung length (sets --slow) +const SLOW_FLAG = takeFlag("--slow", ""); // explicit slow factor (overrides target-dur) +// harmony stack: "semis:gain,semis:gain,…" relative to the lead +const HARMONIES = takeFlag("--harmonies", "12:0.32,-12:0.5,7:0.3") + .split(",").map((s) => s.trim()).filter(Boolean) + .map((s) => { const [semi, g] = s.split(":"); return { semi: parseFloat(semi), gain: parseFloat(g) }; }); + +if (!existsSync(VOCAL)) { console.error(`✗ vocal not found: ${VOCAL}`); process.exit(1); } +if (!existsSync(ALIGN)) { console.error(`✗ alignment not found: ${ALIGN}`); process.exit(1); } +mkdirSync(`${LANE}/out`, { recursive: true }); + +const sh = (cmd, args, opt = {}) => spawnSync(cmd, args, { stdio: ["ignore", "inherit", "inherit"], ...opt }); +const TMP = tmpdir(); + +// ── melody contour — C-major pentatonic wave, raised by --transpose ── +const NM = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; +const midiToNote = (m) => NM[((m % 12) + 12) % 12] + (Math.floor(m / 12) - 1); +const BASE = [55, 57, 60, 57, 55, 52, 50, 52, 55, 57, 60, 62, 60, 57, 55, 52]; +const CONTOUR = BASE.map((m) => midiToNote(m + TRANSPOSE)); +console.log(`• melody: ${CONTOUR.length}-note pentatonic wave, ${midiToNote(50 + TRANSPOSE)}–${midiToNote(62 + TRANSPOSE)} (transpose +${TRANSPOSE})`); + +// ── real words → (start, end, note); skip pure-punctuation tokens ──── +const align = JSON.parse(readFileSync(ALIGN, "utf8")); +const raw = (align.words || []).filter((w) => /[a-z0-9]/i.test(w.text || "")); +const W = []; +let ci = 0; +for (const w of raw) { + const s = (w.fromMs ?? 0) / 1000, e = (w.toMs ?? 0) / 1000; + if (W.length && s <= W[W.length - 1].s) continue; // starts must increase + W.push({ s, e: Math.max(e, s + 0.05), note: CONTOUR[ci++ % CONTOUR.length] }); +} +const NAT_SPAN = W[W.length - 1].e - W[0].s; +const SLOW = SLOW_FLAG ? parseFloat(SLOW_FLAG) : Math.max(1, TARGET_DUR / NAT_SPAN); +console.log(`• ${W.length} words · natural ${NAT_SPAN.toFixed(1)}s → slow ×${SLOW.toFixed(2)} (≈${(NAT_SPAN * SLOW).toFixed(0)}s)`); + +// ── 1 · WORLD pitch-replace, anchored per-word ─────────────────────── +const inWav = resolve(TMP, "hh-sing-in.wav"), psWav = resolve(TMP, "hh-sing-ps.wav"); +if (sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", VOCAL, "-ar", String(SR), "-ac", "1", inWav]).status !== 0) process.exit(1); +console.log("• WORLD f0-replace (per-word, jeffrey's voice preserved) …"); +if (sh(PY, [PSNAP, inWav, psWav, + "--notes", W.map((w) => w.note).join(","), + "--note-starts", W.map((w) => w.s.toFixed(3)).join(","), + "--retain", RETAIN, "--xfade-ms", XFADE, + "--vibrato-hz", VIB_HZ, "--vibrato-cents", VIB_CENTS, "--vibrato-onset-ms", "260"]).status !== 0) process.exit(1); + +// ── tiny f32 WAV read/write + rubberband helpers (mono) ────────────── +function readF32(path) { + const r = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-i", path, "-f", "f32le", "-ar", String(SR), "-ac", "1", "-"], { maxBuffer: 1 << 30 }); + return new Float32Array(r.stdout.buffer, r.stdout.byteOffset, Math.floor(r.stdout.length / 4)); +} +function writeF32Wav(buf, path) { + const n = buf.length, b = Buffer.alloc(44 + n * 4); + b.write("RIFF", 0); b.writeUInt32LE(36 + n * 4, 4); b.write("WAVE", 8); + b.write("fmt ", 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(3, 20); b.writeUInt16LE(1, 22); + b.writeUInt32LE(SR, 24); b.writeUInt32LE(SR * 4, 28); b.writeUInt16LE(4, 32); b.writeUInt16LE(32, 34); + b.write("data", 36); b.writeUInt32LE(n * 4, 40); + for (let i = 0; i < n; i++) b.writeFloatLE(buf[i], 44 + i * 4); + writeFileSync(path, b); +} +const rbIn = resolve(TMP, "hh-rb-in.wav"), rbOut = resolve(TMP, "hh-rb-out.wav"); +function stretchSeg(seg, ratio) { + if (seg.length < 64 || Math.abs(ratio - 1) < 0.02) return seg; + writeF32Wav(seg, rbIn); + const r = spawnSync("rubberband", ["-t", ratio.toFixed(4), "-c", CRISP, rbIn, rbOut], { stdio: "ignore" }); + if (r.status !== 0 || !existsSync(rbOut)) return seg; + return readF32(rbOut); +} +function pitchShift(buf, semis) { + if (semis === 0) return buf.slice(); + const pin = resolve(TMP, "hh-harm-in.wav"), pout = resolve(TMP, `hh-harm-${semis}.wav`); + writeF32Wav(buf, pin); + const r = spawnSync("rubberband", ["-p", String(semis), "-F", "--pitch-hq", pin, pout], { stdio: "ignore" }); + try { unlinkSync(pin); } catch {} + if (r.status !== 0 || !existsSync(pout)) return buf.slice(); + return readF32(pout); +} + +// ── 2 · slow + vowel-hold — place words on a ×SLOW grid, hold vowels ─ +console.log(`• slowing + holding vowels (legato ${LEGATO}, max-hold ${MAX_HOLD}s, crisp ${CRISP}) …`); +const src = readF32(psWav); +const t0 = W[0].s; +const outStart = (i) => (W[i].s - t0) * SLOW; +const slot = (i) => (i + 1 < W.length ? (W[i + 1].s - W[i].s) : (W[i].e - W[i].s + 0.6)) * SLOW; +const out = new Float32Array(Math.round((outStart(W.length - 1) + MAX_HOLD + 1.5) * SR)); +let held = 0; +for (let i = 0; i < W.length; i++) { + const w = W[i]; + const a = Math.round(w.s * SR), z = Math.min(src.length, Math.round(w.e * SR)); + if (z <= a) continue; + const word = src.subarray(a, z), wn = word.length; + const onN = Math.min(Math.round(0.05 * SR), Math.floor(wn * 0.3)); // onset consonant + const coN = Math.min(Math.round(0.04 * SR), Math.floor(wn * 0.25)); // coda consonant + const midN = Math.max(0, wn - onN - coN); // vowel body + const target = Math.min(slot(i) * LEGATO, (w.e - w.s) + MAX_HOLD); // total held length (s) + let body = word.subarray(onN, onN + midN); + if (midN > 64 && target * SR > wn) { + const ratio = Math.max(1, (target * SR - onN - coN) / midN); + body = stretchSeg(body, ratio); + if (ratio > 1.05) held++; + } + const piece = new Float32Array(onN + body.length + coN); + piece.set(word.subarray(0, onN), 0); + piece.set(body, onN); + piece.set(word.subarray(onN + midN, onN + midN + coN), onN + body.length); + const j0 = Math.round(outStart(i) * SR), xf = Math.min(96, onN); + for (let k = 0; k < piece.length; k++) { + const o = j0 + k; if (o < 0 || o >= out.length) break; + let g = 1; if (k < xf) g = k / xf; if (k > piece.length - xf) g = Math.min(g, (piece.length - k) / xf); + out[o] += piece[k] * g; + } +} +console.log(`• held ${held}/${W.length} vowels out to their notes`); + +// ── 3 · harmony stack — pitch-shifted copies under the lead ────────── +const mix = new Float32Array(out.length); +for (let i = 0; i < out.length; i++) mix[i] = out[i]; // lead = 1.0 +for (const h of HARMONIES) { + console.log(`• harmony ${h.semi > 0 ? "+" : ""}${h.semi} semis @ ${h.gain}`); + const v = pitchShift(out, h.semi); + for (let i = 0; i < out.length && i < v.length; i++) mix[i] += v[i] * h.gain; +} +// peak-safety +let pk = 0; for (let i = 0; i < mix.length; i++) pk = Math.max(pk, Math.abs(mix[i])); +if (pk > 0.99) { const g = 0.99 / pk; for (let i = 0; i < mix.length; i++) mix[i] *= g; } +const sungWav = resolve(TMP, "hh-sing-sung.wav"); +writeF32Wav(mix, sungWav); + +// ── encode → mp3 ───────────────────────────────────────────────────── +if (sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", sungWav, "-c:a", "libmp3lame", "-b:a", "256k", OUT]).status !== 0) process.exit(1); +for (const f of [inWav, rbIn, rbOut]) { try { unlinkSync(f); } catch {} } +console.log(`✓ sung+harmonized vocal (${(mix.length / SR).toFixed(1)}s) → ${OUT.replace(POP + "/", "pop/")}`); diff --git a/pop/hopehop/hopehop.illy.txt b/pop/hopehop/hopehop.illy.txt --- a/pop/hopehop/hopehop.illy.txt +++ b/pop/hopehop/hopehop.illy.txt @@ -1,1 +1,1 @@ -a cute black panther cub sitting upright, sipping a tall pink strawberry milkshake through a striped straw — eyes half-closed, blissed-out, mid-purr. lo-fi summer hip-hop mood. warm dusk light, soft risograph grain, limited palette of cream / chartreuse / dusty rose / deep teal. a few little sine-bell sparkles and music-note twinkles floating around the cub's head like it is singing. hand-lettered chunky bubble wordmark "hopehop" arched across the top, friendly and round. flat illustration, thick tapered ink outlines, Hockney-ish colored-pencil hatching for shading, gentle optical-mix texture. single centered emblem on a soft solid backdrop — NO real brand logos, NO text other than the wordmark, NO photographic realism. album-cover composition, square. +a warm photographic summer-jam portrait of jeffrey — candid, sun-soaked, real. late golden-hour light raking across a brooklyn brownstone stoop, the whole frame washed cream / chartreuse / dusty rose / deep teal by the low sun (light comes from the real setting sun off-frame, not an overlay). jeffrey sits relaxed on the stone steps, half-lidded summer bliss, mid-sip on a tall pink strawberry milkshake through a striped paper straw, a small easy grin. he wears a real summer outfit — a short-sleeve printed button-down open over a plain tee, nothing costumey. a citrus-green macbook neo sits open beside him on the step, lid facing camera with a torn white-paper scrap taped over the logo, a thick hand-penned whistlegraph butterfly drawn on the scrap (drawn, never a real apple or brand logo). curled up next to him on the warm stone is a small sleepy black panther cub, eyes half-closed, leaning into his leg. a little condensation sweats down the milkshake cup. shallow depth of field, soft summer haze, fine 35mm film grain, gentle halation on the highlights — a real photograph, not an illustration, not neon, not cinematic-noir. top quarter of the frame is open sky / soft wall for a title. square album-cover composition. no text, no wordmarks, no captions. diff --git a/pop/hopehop/hopehop.txt b/pop/hopehop/hopehop.txt new file mode 100644 --- /dev/null +++ b/pop/hopehop/hopehop.txt @@ -0,0 +1,33 @@ +hook +hope — enough to carry on +in the sun, and back again, home +hope — enough to carry on +in the sun, and back again, home + +verse 1 +want my latina, gimme that thick milkshake +sun on the stoop and a loop that the speakers make +strawberry pink in the cup, two straws and a grin +no rush in the city — summer let the slow back in +panther on the bassline, purring like it's paid +i could teach you the recipe, but the loop's already made +hope's a tall glass — i sip it 'til it's gone +that's enough to carry on + +hook + +verse 2 +mmm, yeah — milkshake bring the whole block to the yard +no flex, no flash, just a melody and a card +gimme that summer, gimme that easy afternoon +the math just flipped — every shadow's back by noon +latina laughing on the steps where the breeze go +chartreuse and dusty rose, that's the way the evening glow +i'm not chasing the night — i'm just walking it slow +hope, enough to carry on, and home + +hook + +outro +in the sun, and back again +in the sun, and back again, home diff --git a/pop/hopehop/kit.json b/pop/hopehop/kit.json --- a/pop/hopehop/kit.json +++ b/pop/hopehop/kit.json @@ -1,75 +1,75 @@ { "kick": { - "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/437273-tr_808_kick_wav.wav", - "id": 437273, - "name": "TR-808 kick.wav", - "username": "derickgtwk", + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/509597-boom_bap_kick_1.wav", + "id": 509597, + "name": "Boom Bap Kick 1", + "username": "tripjazz", "license": "http://creativecommons.org/publicdomain/zero/1.0/", - "duration": 0.711542 + "duration": 0.199841 }, "snare": { - "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/278983-snare_trap_wav.wav", - "id": 278983, - "name": "snare trap.wav", - "username": "dflee4", + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/676253-boom_bap_snare.wav", + "id": 676253, + "name": "Boom Bap Snare", + "username": "CrushedLoops", "license": "http://creativecommons.org/publicdomain/zero/1.0/", - "duration": 2 + "duration": 0.545465 }, "ohat": { - "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/317096-hat_open2_wav.wav", - "id": 317096, - "name": "hat open2.wav", - "username": "shpira", + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/638087-rd_6_o_hihat_wav.wav", + "id": 638087, + "name": "rd-6 o hihat.wav", + "username": "laffik", "license": "http://creativecommons.org/publicdomain/zero/1.0/", - "duration": 1.00002 + "duration": 0.768821 }, "shaker": { - "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/375638-shaker_shake7_wav.wav", - "id": 375638, - "name": "Shaker (shake7.wav)", + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/375636-shaker_shake5_wav.wav", + "id": 375636, + "name": "Shaker (shake5.wav)", "username": "sgossner", "license": "http://creativecommons.org/publicdomain/zero/1.0/", - "duration": 0.234694 + "duration": 0.159955 }, "rim": { - "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/662274-playtime_rim.wav", - "id": 662274, - "name": "Playtime Rim", - "username": "adh.dreaming", - "license": "http://creativecommons.org/publicdomain/zero/1.0/", - "duration": 0.0607937 + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/142274-sd14x08tama_vhp_tsn_rm_v14_wav.wav", + "id": 142274, + "name": "SD14x08Tama-VHP,TSn-Rm-v14.wav", + "username": "quartertone", + "license": "https://creativecommons.org/licenses/by/4.0/", + "duration": 0.520833 }, "perc": { - "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/35918-conga_wav.wav", - "id": 35918, - "name": "conga.wav", - "username": "altemark", + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/21387-dtumquintohl2_aif.wav", + "id": 21387, + "name": "dTumQuintoHL2.aif", + "username": "ramjac", "license": "https://creativecommons.org/licenses/by/4.0/", - "duration": 0.0679365 + "duration": 0.529917 }, "clap": { - "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/493711-handclap05_wav.wav", - "id": 493711, - "name": "HandClap05.wav", - "username": "bastianpusch", - "license": "http://creativecommons.org/publicdomain/zero/1.0/", - "duration": 0.489365 + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/3718-909_clap_wav.wav", + "id": 3718, + "name": "909 clap.wav", + "username": "NoiseCollector", + "license": "http://creativecommons.org/licenses/by/3.0/", + "duration": 0.167528 }, "hat": { - "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/509971-hi_hat_closed_9_mp3.wav", - "id": 509971, - "name": "Hi-Hat Closed 9.mp3", - "username": "blakengouda", + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/503778-lo_fi_hi_hat_wav.wav", + "id": 503778, + "name": "lo-fi hi hat.wav", + "username": "reathance", "license": "http://creativecommons.org/publicdomain/zero/1.0/", - "duration": 0.144739 + "duration": 0.153855 }, "crash": { - "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/269905-02_crash_med_cymbals_snares_wav.wav", - "id": 269905, - "name": "02_Crash_Med_Cymbals_&_Snares.wav", - "username": "Theriavirra", - "license": "https://creativecommons.org/licenses/by/4.0/", - "duration": 1.728 + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/261413-crash_wav.wav", + "id": 261413, + "name": "crash.wav", + "username": "Logicogonist", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 2.15946 }, "tiger": { "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/528034-robot_tiger_roar_mp3.wav", @@ -110,5 +110,21 @@ "name": "BassScratch C.mp3", "username": "SaschaRettberg", "license": "http://creativecommons.org/publicdomain/zero/1.0/", "duration": 1.24082 + }, + "tamb": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/171658-tambourine_one_shot_wav.wav", + "id": 171658, + "name": "tambourine one shot.wav", + "username": "dfeltch", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 0.545669 + }, + "bongo": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/219155-bongo03_wav.wav", + "id": 219155, + "name": "Bongo03.wav", + "username": "Jagadamba", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 0.641701 } } diff --git a/portraits/jeffrey/bin/ac_heartbeat.py b/portraits/jeffrey/bin/ac_heartbeat.py new file mode 100644 --- /dev/null +++ b/portraits/jeffrey/bin/ac_heartbeat.py @@ -0,0 +1,94 @@ +"""ac_heartbeat — tiny progress heartbeats for the Slab menubar. + +Stdlib-only Python port of pop/lib/render-progress.mjs. Writes a JSON +heartbeat into ~/.ac-pop-renders/.json that the Swift menubar polls +(every 2s) and renders as a temporary progress bar — then sweeps the +instant the file is gone or the pid dies. + +Heartbeat shape (must match StateSnapshot.readPopRenders): + { id, type, label, pct, done, total, pid, startedAt, updatedAt } + type — free string; the menubar shows it as a 6-char column label + pct — 0..100, or None for an indeterminate "·····" bar + +Usage: + hb = Heartbeat(type="gemma", label="e2b · describe") + hb.begin() + hb.update(42) # pct + hb.update(60, done=12, total=20) + hb.end() # or use as a context manager +""" +import atexit, json, os, signal, time +from pathlib import Path + +RENDERS_DIR = Path.home() / ".ac-pop-renders" + + +class Heartbeat: + def __init__(self, type="render", label=None): + self.type = type + self.label = label or type + self.id = None + self.file = None + self.started = 0 + self._last = 0.0 + + def begin(self, pct=0): + RENDERS_DIR.mkdir(parents=True, exist_ok=True) + self.started = int(time.time() * 1000) + self.id = f"{self.type}-{os.getpid()}-{self.started:x}" + self.file = RENDERS_DIR / f"{self.id}.json" + self._write(pct) + atexit.register(self.end) + for sig in (signal.SIGINT, signal.SIGTERM): + try: + signal.signal(sig, self._on_signal) + except (ValueError, OSError): + pass # not on main thread, etc. + return self.id + + def _on_signal(self, signum, frame): + self.end() + os._exit(130 if signum == signal.SIGINT else 143) + + def update(self, pct=None, done=None, total=None): + if not self.file: + return + # throttle to ~3 writes/s (menubar polls every 2s) + if pct is not None and pct < 100 and time.time() - self._last < 0.3: + return + self._write(pct, done, total) + + def end(self): + if not self.file: + return + try: + self.file.unlink() + except OSError: + pass + self.file = self.id = None + + def _write(self, pct=None, done=None, total=None): + if not self.file: + return + self._last = time.time() + rec = { + "id": self.id, "type": self.type, "label": self.label, + "pct": None if pct is None else max(0, min(100, round(pct))), + "done": None if done is None else max(0, int(done)), + "total": None if total is None else max(0, int(total)), + "pid": os.getpid(), + "startedAt": self.started, + "updatedAt": int(self._last * 1000), + } + try: + self.file.write_text(json.dumps(rec)) + except OSError: + pass + + def __enter__(self): + self.begin() + return self + + def __exit__(self, *exc): + self.end() + return False diff --git a/portraits/jeffrey/bin/generate-neo.py b/portraits/jeffrey/bin/generate-neo.py --- a/portraits/jeffrey/bin/generate-neo.py +++ b/portraits/jeffrey/bin/generate-neo.py @@ -16,11 +16,26 @@ from __future__ import annotations import argparse import base64 +import os import sys +import threading +import time from datetime import datetime from pathlib import Path from openai import OpenAI + +# Progress heartbeat for the Slab menubar (shows a live "image" bar while a +# gen is in flight). Optional — degrade to a no-op if the helper is absent. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +try: + from ac_heartbeat import Heartbeat +except Exception: # pragma: no cover + class Heartbeat: # no-op fallback + def __init__(self, *a, **k): pass + def begin(self, *a, **k): return None + def update(self, *a, **k): pass + def end(self): pass REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent VAULT_ENV = REPO_ROOT / "aesthetic-computer-vault" / ".devcontainer" / "envs" / "devcontainer.env" @@ -226,7 +241,22 @@ print(f"out: {out_dir}", file=sys.stderr) print(f"settings: size={args.size} quality={args.quality} fidelity={args.input_fidelity} n={args.n}", file=sys.stderr) - for variant in selected: + # Slab menubar progress: one indeterminate "image" bar, per-variant counts. + # A keepalive thread refreshes updatedAt so the menubar's 120s staleness + # sweep never drops a bar mid-render (a single gpt-image call can be slow). + total = len(selected) + hb = Heartbeat(type="image", label=f"neo-jeffrey ×{total}") + hb.begin(pct=None) + _alive = {"on": True, "done": 0} + def _keepalive(): + while _alive["on"]: + hb.update(pct=None, done=_alive["done"], total=total) + time.sleep(5) + _t = threading.Thread(target=_keepalive, daemon=True) + _t.start() + + for vi, variant in enumerate(selected): + hb.update(pct=None, done=vi, total=total) print(f"\ngenerating {variant['name']}…", file=sys.stderr) try: files = [open(r, "rb") for r in refs] @@ -264,7 +294,10 @@ op = getattr(usage, "output_tokens", 0) print(f" tokens: input={ip} output={op}", file=sys.stderr) except Exception as e: print(f" ERROR: {type(e).__name__}: {e}", file=sys.stderr) + _alive["done"] = vi + 1 + _alive["on"] = False + hb.end() return 0 diff --git a/slab/bin/claude-prompt-log.sh b/slab/bin/claude-prompt-log.sh --- a/slab/bin/claude-prompt-log.sh +++ b/slab/bin/claude-prompt-log.sh @@ -72,8 +72,11 @@ if [[ -n "$tty" && -e "/dev/$tty" && -n "$summary" ]]; then printf '\033]0;%s\007' "$summary" > "/dev/$tty" 2>/dev/null fi - # User responded — clear any awaiting marker for this session. + # User responded — clear any awaiting marker for this session, plus a + # stale running-tool flag so the fresh turn's interrupt-detection + # starts clean (see claude-tool-heartbeat.sh). rm -f "$AWAITING_DIR/$session_id" + rm -f "$SLAB_HOME/state/running-tools/$session_id" fi mkdir -p "$(dirname "$PROMPT_LOG")" diff --git a/slab/bin/claude-stop.sh b/slab/bin/claude-stop.sh --- a/slab/bin/claude-stop.sh +++ b/slab/bin/claude-stop.sh @@ -35,6 +35,9 @@ # the menubar can keep a bar for this thread; the awaiting marker flips its # render to rainbow-pulse. Cleared on next UserPromptSubmit. if [[ -n "$session_id" ]]; then printf 'turn complete\n' > "$AWAITING_DIR/$session_id" 2>/dev/null + # Turn ended → no tool running; clear the heartbeat flag (see + # claude-tool-heartbeat.sh) so the next idle window detects cleanly. + rm -f "$SLAB_HOME/state/running-tools/$session_id" 2>/dev/null fi # Janitor: drop active-prompts whose claude_pid is dead (terminal closed), @@ -47,7 +50,7 @@ pid=$(jq -r '.claude_pid // 0' "$f" 2>/dev/null) [[ -z "$pid" || "$pid" == "0" ]] && continue if ! kill -0 "$pid" 2>/dev/null; then sid=$(basename "$f") - rm -f "$f" "$AWAITING_DIR/$sid" + rm -f "$f" "$AWAITING_DIR/$sid" "$SLAB_HOME/state/running-tools/$sid" fi done for f in "$AWAITING_DIR"/*; do diff --git a/slab/bin/claude-tool-heartbeat.sh b/slab/bin/claude-tool-heartbeat.sh new file mode 100644 --- /dev/null +++ b/slab/bin/claude-tool-heartbeat.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# PreToolUse / PostToolUse heartbeat. Claude Code fires NO hook when the user +# interrupts (Esc) a turn, so the menubar can't tell "still working" from +# "interrupted, idle at the prompt" — it would stay stuck green. +# +# This refreshes the active-prompts marker's mtime on every tool start/stop +# (so a quiet, Esc'd session goes stale and the menubar flips it to the +# `interrupted` state), and keeps a running-tools/ flag for the +# duration of a tool call (so a long, legit tool stays green even while the +# marker mtime ages). See ClaudeSession.swift isInterrupted(). +# +# Usage: claude-tool-heartbeat.sh (hook JSON on stdin) +set -u +MODE="${1:-pre}" +SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} +ACTIVE_DIR="$SLAB_HOME/state/active-prompts" +RUNNING_DIR="$SLAB_HOME/state/running-tools" + +input=$(cat) +sid=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null) +[[ -z "$sid" ]] && exit 0 + +mkdir -p "$RUNNING_DIR" 2>/dev/null + +# Heartbeat: bump the active marker's mtime (only if it already exists — never +# synthesize a phantom session). The menubar reads this mtime as last-activity. +[[ -f "$ACTIVE_DIR/$sid" ]] && touch "$ACTIVE_DIR/$sid" 2>/dev/null + +if [[ "$MODE" == "post" ]]; then + # Tool finished → clear the running flag so idleness can be detected. + rm -f "$RUNNING_DIR/$sid" 2>/dev/null +else + # Tool starting → pin the session green for the call's duration. + touch "$RUNNING_DIR/$sid" 2>/dev/null +fi + +exit 0 diff --git a/slab/bin/slab-capture-terminal.mjs b/slab/bin/slab-capture-terminal.mjs new file mode 100644 --- /dev/null +++ b/slab/bin/slab-capture-terminal.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +// slab-capture-terminal — re-capture the Slab terminal seed from THIS machine. +// +// Reads the live com.apple.Terminal preferences, pulls out the `Grass` base +// profile + every `Slab-*` settings set, and rewrites: +// seed/terminal-profiles.plist (the profiles, verbatim colors + font) +// seed/terminal-profiles.list (their names, one per line) +// +// Run this on the reference machine after the menubar / you have tweaked the +// Slab-* palettes so they look right, then commit the seed. `slab-seed-terminal` +// reproduces them on any other machine. +// +// Pure macOS tooling: shells out to `defaults export` + `plutil`. No deps. +// +// Usage: node slab-capture-terminal.mjs [--base Grass] [--prefix Slab-] + +import { execFileSync } from "node:child_process"; +import { writeFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const argv = process.argv.slice(2); +const opt = (flag, def) => { + const i = argv.indexOf(flag); + return i >= 0 && argv[i + 1] ? argv[i + 1] : def; +}; +const BASE = opt("--base", "Grass"); +const PREFIX = opt("--prefix", "Slab-"); + +const seedDir = join(dirname(fileURLToPath(import.meta.url)), "..", "seed"); +const tmp = mkdtempSync(join(tmpdir(), "slabcap-")); +const full = join(tmp, "term.plist"); // whole prefs, xml1 +const wsXml = join(tmp, "ws.xml"); // just the Window Settings dict + +try { + // Export the whole Terminal prefs as XML (preserves binary color/font blobs; + // JSON can't represent NSData, so we never go through JSON), then extract the + // Window Settings dict on its own to enumerate the immediate profile names. + execFileSync("/usr/bin/defaults", ["export", "com.apple.Terminal", full]); + execFileSync("/usr/bin/plutil", ["-convert", "xml1", full]); + execFileSync("/usr/bin/plutil", ["-extract", "Window Settings", "xml1", "-o", wsXml, full]); + + // The profile names are the *top-level* s of the Window Settings dict. + // Each profile value is a ; nested keys live deeper. We read only keys + // that sit at the dict's first indentation level (plutil pretty-prints with + // tabs: top dict opens at depth 0, its direct s are one tab in). + const wsText = execFileSync("/bin/cat", [wsXml], { encoding: "latin1" }); + const lines = wsText.split("\n"); + const allNames = []; + // Find the opening then collect lines at exactly one tab indent. + let started = false; + for (const line of lines) { + if (!started) { if (/^\s*/.test(line)) started = true; continue; } + const m = line.match(/^\t([^<]*)<\/key>/); + if (m) allNames.push(m[1]); + } + const names = allNames + .filter((n) => n === BASE || n.startsWith(PREFIX)) + .sort(); + if (!names.includes(BASE)) { + console.error(`warning: base profile "${BASE}" not found on this machine`); + } + + // Build the seed plist as XML: { DefaultProfile, Profiles: { name: } }. + // We extract each profile's dict from the full XML plist to keep blobs exact. + let profilesXml = ""; + for (const name of names) { + const one = join(tmp, "one.xml"); + // plutil keypaths are dot-separated; our profile names contain no dots. + execFileSync("/usr/bin/plutil", [ + "-extract", `Window Settings.${name}`, "xml1", "-o", one, full, + ]); + let body = execFileSync("/bin/cat", [one], { encoding: "utf8" }); + // Strip the XML/plist wrapper, keep the inner .... + body = body.replace(/^[\s\S]*?]*>\s*/, "").replace(/\s*<\/plist>\s*$/, "").trim(); + profilesXml += `\t\t${name}\n${body.replace(/^/gm, "\t\t")}\n`; + } + + const plist = +` + + + +\tDefaultProfile +\t${BASE} +\tProfiles +\t +${profilesXml}\t + + +`; + const seedPlist = join(seedDir, "terminal-profiles.plist"); + const seedList = join(seedDir, "terminal-profiles.list"); + writeFileSync(seedPlist, plist); + // Normalize/validate via plutil. + execFileSync("/usr/bin/plutil", ["-lint", seedPlist]); + writeFileSync(seedList, names.join("\n") + "\n"); + console.log(`captured ${names.length} profiles → ${seedPlist}`); + for (const n of names) console.log(` ${n}`); +} finally { + rmSync(tmp, { recursive: true, force: true }); +} diff --git a/slab/bin/slab-seed-terminal b/slab/bin/slab-seed-terminal new file mode 100644 --- /dev/null +++ b/slab/bin/slab-seed-terminal @@ -0,0 +1,100 @@ +#!/bin/bash +# slab-seed-terminal — install Slab's Terminal.app profiles onto this machine. +# +# Merges the canonical Slab terminal profiles (the "Grass" base + every +# Slab--[-pulse] settings set) from the repo seed into the +# local com.apple.Terminal preferences, so a fresh machine looks EXACTLY like +# the reference machine the seed was captured from. Idempotent: re-running +# overwrites each named profile with the seed version (a palette tweak in the +# seed propagates on the next run). +# +# These named sets are what the Slab menubar switches between per Claude +# session status (see AppDelegate.applyTerminalDecor). The menubar can also +# auto-create them at runtime, but seeding guarantees byte-identical colors + +# the right default profile from first launch. +# +# Usage: +# slab-seed-terminal # merge seed, set Grass as default+startup +# slab-seed-terminal --no-default # merge profiles only, don't touch default +# slab-seed-terminal --seed PATH # use a specific seed plist +# +# Requires: PlistBuddy (ships with macOS), Terminal.app NOT running (we quit it +# first so it doesn't clobber our writes on exit, then you relaunch it). +set -euo pipefail + +PB=/usr/libexec/PlistBuddy +TERM_PLIST="$HOME/Library/Preferences/com.apple.Terminal.plist" + +# Locate the seed relative to this script (works whether run from repo or via +# a symlink in ~/.local/bin — we resolve the symlink target). +self="${BASH_SOURCE[0]}" +while [[ -L "$self" ]]; do self="$(readlink "$self")"; done +SLAB_REPO="$(cd "$(dirname "$self")/.." && pwd)" +SEED="$SLAB_REPO/seed/terminal-profiles.plist" +SET_DEFAULT=1 + +while [[ $# -gt 0 ]]; do + case "$1" in + --no-default) SET_DEFAULT=0 ;; + --seed) SEED="$2"; shift ;; + -h|--help) grep '^# ' "$0" | sed 's/^# //'; exit 0 ;; + esac + shift +done + +SEED_LIST="${SEED%.plist}.list" +[[ -f "$SEED" ]] || { echo "✗ seed not found: $SEED" >&2; exit 1; } +[[ -f "$SEED_LIST" ]] || { echo "✗ seed manifest not found: $SEED_LIST" >&2; exit 1; } + +echo "• seeding Terminal profiles from $SEED" + +# Quit Terminal so its in-memory copy can't overwrite our plist edits on exit. +if osascript -e 'application "Terminal" is running' 2>/dev/null | grep -q true; then + echo "• Terminal.app is running — quitting it so writes stick (relaunch after)" + osascript -e 'tell application "Terminal" to quit' 2>/dev/null || true + sleep 1 +fi + +# Ensure the plist + Window Settings dict exist. +[[ -f "$TERM_PLIST" ]] || $PB -c "Save" "$TERM_PLIST" 2>/dev/null || \ + /usr/bin/defaults write com.apple.Terminal _seedinit -bool true >/dev/null 2>&1 || true +$PB -c "Print :'Window Settings'" "$TERM_PLIST" >/dev/null 2>&1 || \ + $PB -c "Add :'Window Settings' dict" "$TERM_PLIST" + +# Profile names come from the manifest committed beside the seed (avoids +# parsing PlistBuddy output, which embeds binary color blobs). +names=$(cat "$SEED_LIST") + +count=0 +while IFS= read -r name; do + [[ -z "$name" ]] && continue + # Extract this profile from the seed into a standalone plist. plutil keypaths + # are dot-separated; Slab profile names contain no dots, so "Profiles.$name" + # is unambiguous. Then Delete any stale local copy and Merge the fresh one in. + tmp=$(mktemp -t slabseed) + if /usr/bin/plutil -extract "Profiles.$name" xml1 -o "$tmp" "$SEED" 2>/dev/null; then + $PB -c "Delete :'Window Settings':'$name'" "$TERM_PLIST" 2>/dev/null || true + $PB -c "Add :'Window Settings':'$name' dict" "$TERM_PLIST" 2>/dev/null || true + if $PB -c "Merge '$tmp' :'Window Settings':'$name'" "$TERM_PLIST" 2>/dev/null; then + echo " ✓ $name" + count=$((count + 1)) + else + echo " ✗ $name (merge failed)" >&2 + fi + else + echo " ✗ $name (extract failed)" >&2 + fi + rm -f "$tmp" +done <<< "$names" + +if [[ "$SET_DEFAULT" == "1" ]]; then + defprofile=$($PB -c "Print :DefaultProfile" "$SEED" 2>/dev/null || echo "Grass") + /usr/bin/defaults write com.apple.Terminal "Default Window Settings" -string "$defprofile" + /usr/bin/defaults write com.apple.Terminal "Startup Window Settings" -string "$defprofile" + echo "• default + startup profile → $defprofile" +fi + +# Flush the cfprefsd cache so the next Terminal launch reads our edits. +/usr/bin/killall -u "$USER" cfprefsd 2>/dev/null || true + +echo "• seeded $count profiles. Relaunch Terminal.app to see them." diff --git a/slab/install.sh b/slab/install.sh --- a/slab/install.sh +++ b/slab/install.sh @@ -67,6 +67,25 @@ ln -s "$f" "$dest" chmod +x "$f" done +# ------------ Monaspace Argon font (terminal-theming base) ------------ +# The Slab terminal profiles render in Monaspace Argon. Install it into the +# user font dir if present in the repo seed (no-op if the seed omits fonts — +# e.g. a slim checkout — in which case install Monaspace yourself). +FONT_DIR="$HOME_DIR/Library/Fonts" +if compgen -G "$SLAB_REPO/seed/fonts/*.otf" >/dev/null 2>&1; then + say "installing Monaspace Argon → $FONT_DIR" + mkdir -p "$FONT_DIR" + cp "$SLAB_REPO/seed/fonts/"*.otf "$FONT_DIR/" +fi + +# ------------ Terminal profiles (Slab-* status themes + Grass base) ------------ +# Seeds the exact named settings sets the menubar switches between per Claude +# session status, so a new machine looks identical from first launch. +if [[ -f "$SLAB_REPO/seed/terminal-profiles.plist" ]]; then + say "seeding Terminal profiles (Grass + Slab-* status themes)" + "$SLAB_REPO/bin/slab-seed-terminal" || warn "terminal seed failed (continuing)" +fi + # ------------ Swift binary: live ambient synth ------------ say "compiling lid-ambient-synth → $SLAB_BIN/lid-ambient-synth" swiftc -O -o "$SLAB_BIN/lid-ambient-synth" "$SLAB_REPO/bin/lid-ambient-synth.swift" diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -286,7 +286,7 @@ // flipping this to `true`. statusDecor still accepts a `blink` arg // so this only needs to come back on at the timer level. let blinkEnabled = false let needsBlink = blinkEnabled && state.themeByStatus && state.claudeSessions.contains { - $0.state == .complete || $0.state == .awaiting + $0.state == .complete || $0.state == .awaiting || $0.state == .interrupted } if needsBlink { if blinkTimer == nil { @@ -836,11 +836,12 @@ } private func stateName(_ s: ClaudeSession.State) -> String { switch s { - case .blank: return "blank" - case .working: return "working" - case .complete: return "complete" - case .awaiting: return "awaiting" - case .stale: return "stale" + case .blank: return "blank" + case .working: return "working" + case .complete: return "complete" + case .awaiting: return "awaiting" + case .interrupted: return "interrupted" + case .stale: return "stale" } } @@ -961,6 +962,22 @@ ? (Palette(bg: (19000, 10500, 900), text: (65535, 58000, 38000), bold: (65535, 65535, 50000), cursor: (65535, 46000, 10000)), "◉ awaiting") : (Palette(bg: (65535, 59000, 45000), text: (26000, 13000, 0), bold: (18000, 8000, 0), cursor: (65535, 35000, 0)), "◉ awaiting") + // Interrupted = muted violet (Esc'd, idle at the prompt — distinct + // from working-green, complete-slate, awaiting-amber). Blink nudges + // a touch brighter so a cut-off thread reads as "needs a nudge". + case .interrupted: + if blink { + return dark + ? (Palette(bg: (12000, 4000, 18000), text: (54000, 46000, 64000), + bold: (60000, 54000, 65535), cursor: (46000, 26000, 65535)), "✕ interrupted") + : (Palette(bg: (54000, 47000, 64000), text: (22000, 8000, 34000), + bold: (14000, 2000, 26000), cursor: (40000, 12000, 65535)), "✕ interrupted") + } + return dark + ? (Palette(bg: (8500, 2800, 13500), text: (54000, 46000, 64000), + bold: (60000, 54000, 65535), cursor: (40000, 22000, 60000)), "✕ interrupted") + : (Palette(bg: (58000, 52000, 65535), text: (22000, 8000, 34000), + bold: (14000, 2000, 26000), cursor: (34000, 12000, 60000)), "✕ interrupted") // Stale = deep red (process dead, escalate). case .stale: return dark @@ -983,6 +1000,10 @@ case .blank: s = "blank" case .working: s = "working" case .complete: s = "complete" case .awaiting: s = "awaiting" + // No dedicated Terminal.app settings set for interrupted — reuse the + // calm "complete" (slate) profile so Terminal users still leave green. + // iTerm2 + the menubar polygon use the distinct violet palette above. + case .interrupted: s = "complete" case .stale: s = "stale" } let base = "Slab-\(s)-\(dark ? "dark" : "light")" @@ -1037,7 +1058,7 @@ seen.insert(s.sessionId) // Pulse only the attention states — every other state holds steady // so working/blank/stale sessions don't churn osascript on the // 0.6 s blink tick. - let isAttention = (s.state == .complete || s.state == .awaiting) + let isAttention = (s.state == .complete || s.state == .awaiting || s.state == .interrupted) let blink = isAttention && blinkPhase let decor = Self.statusDecor(for: s.state, dark: darkAppearance, blink: blink) var palette = decor.palette diff --git a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift --- a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift @@ -10,8 +10,23 @@ /// `complete` — Stop fired ("turn complete"); session is alive but idle, /// waiting for the next user prompt. Calm, no attention needed. /// `awaiting` — Notification fired; Claude paused mid-task and needs the /// user (permission prompt, idle-on-input). Attention! + /// `interrupted` — the marker says `working` but no tool is mid-flight and + /// there's been no hook activity for a while: Claude was + /// interrupted (Esc) and is idle at the prompt. Claude Code + /// fires NO hook on interrupt, so we infer it from staleness + /// + the running-tool heartbeat (a long real tool keeps the + /// marker fresh and stays `working`/green). /// `stale` — claude_pid is gone; about to be reaped. - enum State { case blank, working, complete, awaiting, stale } + enum State { case blank, working, complete, awaiting, interrupted, stale } + + /// Seconds of hook silence (no tool start/stop, no prompt) before a + /// `working` session is treated as interrupted. Generous so normal + /// between-tool thinking never trips it. + static let interruptIdleSeconds: TimeInterval = 45 + /// A running-tool marker older than this is assumed orphaned (the tool was + /// interrupted mid-flight, so PostToolUse never fired) and no longer pins + /// the session green. Far beyond any real single tool call. + static let runningToolMaxAge: TimeInterval = 900 let sessionId: String let cwd: String @@ -115,6 +130,10 @@ // Marker says blank (SessionStart wrote it, no UserPromptSubmit // has overwritten it yet) — preserve so applyTerminalDecor // paints the appearance-matched bg. // (no-op: keep s.state == .blank) + } else if isInterrupted(sessionId: s.sessionId, markerPath: path) { + // No awaiting marker, but no tool is running and the session + // has gone quiet → interrupted (Esc) and idle. NOT green. + s.state = .interrupted } else { s.state = .working } @@ -130,11 +149,12 @@ // most-recent first within each band. return sessions.sorted { a, b in func rank(_ st: ClaudeSession.State) -> Int { switch st { - case .awaiting: return 0 - case .complete: return 1 - case .working: return 2 - case .blank: return 3 - case .stale: return 4 + case .awaiting: return 0 + case .interrupted: return 1 + case .complete: return 2 + case .working: return 3 + case .blank: return 4 + case .stale: return 5 } } let ra = rank(a.state), rb = rank(b.state) @@ -207,5 +227,28 @@ private static func pidAlive(_ pid: Int) -> Bool { guard pid > 0 else { return false } return kill(pid_t(pid), 0) == 0 || errno == EPERM + } + + private static func mtime(_ path: String) -> Date? { + try? FileManager.default.attributesOfItem(atPath: path)[.modificationDate] as? Date + } + + /// A `working` session is INTERRUPTED when no tool is actively running and + /// the active-prompt marker hasn't been touched (by a tool heartbeat) for + /// a while. The tool hooks (`claude-tool-heartbeat.sh`) `touch` the marker + /// on every PreToolUse/PostToolUse and keep a `running-tools/` flag + /// for the duration of a call — so a long legit tool stays green, but an + /// Esc'd, idle session flips out of green even though Claude fires no + /// interrupt hook. + private static func isInterrupted(sessionId: String, markerPath: String) -> Bool { + let now = Date() + // A fresh running-tool flag means a tool is genuinely mid-flight. + let toolPath = "\(Paths.runningToolsDir)/\(sessionId)" + if let tm = mtime(toolPath), now.timeIntervalSince(tm) < ClaudeSession.runningToolMaxAge { + return false + } + // Otherwise: idle if the marker (bumped by every tool hook) is stale. + guard let mm = mtime(markerPath) else { return false } + return now.timeIntervalSince(mm) > ClaudeSession.interruptIdleSeconds } } diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -285,11 +285,12 @@ for s in sessions { let dot: String switch s.state { - case .awaiting: dot = "◉" - case .working: dot = "●" - case .complete: dot = "✓" - case .blank: dot = "·" // tiny midpoint — fresh, nothing yet - case .stale: dot = "○" + case .awaiting: dot = "◉" + case .working: dot = "●" + case .complete: dot = "✓" + case .interrupted: dot = "✕" // Esc'd, idle at the prompt + case .blank: dot = "·" // tiny midpoint — fresh, nothing yet + case .stale: dot = "○" } let tail = s.cwdLabel.isEmpty ? "" : " · \(s.cwdLabel)" // Blank sessions have no subject yet — substitute a placeholder @@ -406,6 +407,7 @@ // input yet), stale = gray. case .working: dotColor = NSColor(deviceHue: 0.33, saturation: 0.70, brightness: 0.78, alpha: 1.0) case .complete: dotColor = NSColor(deviceHue: 0.58, saturation: 0.30, brightness: 0.70, alpha: 1.0) case .awaiting: dotColor = NSColor(deviceHue: 0.10, saturation: 0.95, brightness: 0.95, alpha: 1.0) + case .interrupted: dotColor = NSColor(deviceHue: 0.78, saturation: 0.55, brightness: 0.80, alpha: 1.0) // violet case .blank: dotColor = NSColor.tertiaryLabelColor case .stale: dotColor = NSColor(deviceWhite: 0.55, alpha: 1.0) } @@ -429,6 +431,8 @@ case .complete: parts.append("turn complete (idle)") case .working: parts.append("working") + case .interrupted: + parts.append("interrupted (Esc'd, idle at prompt)") case .blank: parts.append("blank (no prompt yet)") case .stale: diff --git a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift --- a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift @@ -41,6 +41,10 @@ static var activePromptsDir: String { "\(slabHome)/state/active-prompts" } static var awaitingPromptsDir: String { "\(slabHome)/state/awaiting-prompts" } static var activeSubagentsDir: String { "\(slabHome)/state/active-subagents" } + /// PreToolUse/PostToolUse heartbeat markers (one per session while a tool + /// is mid-execution). Lets the menubar tell "long tool still running" + /// (stay green) apart from "interrupted / idle at the prompt". + static var runningToolsDir: String { "\(slabHome)/state/running-tools" } static var soundsDir: String { "\(slabHome)/sounds" } static var lidLog: String { "\(slabHome)/logs/lidalive.log" } diff --git a/slab/seed/README.md b/slab/seed/README.md new file mode 100644 --- /dev/null +++ b/slab/seed/README.md @@ -0,0 +1,41 @@ +# Slab terminal seed + +Canonical Terminal.app appearance for any machine running Slab. This is the +single source of truth for the look — capture it once on a reference machine, +commit it, and `slab-seed-terminal` reproduces it byte-for-byte on every new +machine. + +## What's here + +- `terminal-profiles.plist` — the `Grass` base profile plus every + `Slab--[-pulse]` settings set (the named profiles the Slab + menubar switches between per Claude Code session status). Colors + font are + captured verbatim. +- `terminal-profiles.list` — plain-text manifest of the profile names in the + plist (the installer reads names from here, not by parsing the binary plist). +- `fonts/` — Monaspace Argon (Regular/Bold/Italic/BoldItalic/Medium/Light), the + font the profiles reference. Installed into `~/Library/Fonts`. + +## Install / redeploy on a machine + +```bash +slab-seed-terminal # merge profiles + set Grass as default & startup +slab-seed-terminal --no-default # profiles only, leave default profile alone +slab-seed-terminal --seed PATH # use an alternate seed plist +``` + +`install.sh` calls this automatically. Quit Terminal first (the script does this +for you) so its in-memory copy doesn't clobber the plist edits on exit; relaunch +Terminal afterward. + +## Re-capture the seed (when you tweak the palette on the reference machine) + +The Slab menubar can create/adjust the `Slab-*` sets live. Once they look right, +re-export them into this seed: + +```bash +node ../bin/slab-capture-terminal.mjs # or the python snippet in that file's header +``` + +This rewrites `terminal-profiles.plist` + `.list` from the current machine's +`com.apple.Terminal` preferences (Grass + all `Slab-*` sets). Commit the result. diff --git a/slab/seed/fonts/MonaspaceArgon-Bold.otf b/slab/seed/fonts/MonaspaceArgon-Bold.otf new file mode 100644 --- /dev/null +++ b/slab/seed/fonts/MonaspaceArgon-Bold.otf diff --git a/slab/seed/fonts/MonaspaceArgon-BoldItalic.otf b/slab/seed/fonts/MonaspaceArgon-BoldItalic.otf new file mode 100644 --- /dev/null +++ b/slab/seed/fonts/MonaspaceArgon-BoldItalic.otf diff --git a/slab/seed/fonts/MonaspaceArgon-Italic.otf b/slab/seed/fonts/MonaspaceArgon-Italic.otf new file mode 100644 --- /dev/null +++ b/slab/seed/fonts/MonaspaceArgon-Italic.otf diff --git a/slab/seed/fonts/MonaspaceArgon-Light.otf b/slab/seed/fonts/MonaspaceArgon-Light.otf new file mode 100644 --- /dev/null +++ b/slab/seed/fonts/MonaspaceArgon-Light.otf diff --git a/slab/seed/fonts/MonaspaceArgon-Medium.otf b/slab/seed/fonts/MonaspaceArgon-Medium.otf new file mode 100644 --- /dev/null +++ b/slab/seed/fonts/MonaspaceArgon-Medium.otf diff --git a/slab/seed/fonts/MonaspaceArgon-Regular.otf b/slab/seed/fonts/MonaspaceArgon-Regular.otf new file mode 100644 --- /dev/null +++ b/slab/seed/fonts/MonaspaceArgon-Regular.otf diff --git a/slab/seed/terminal-profiles.list b/slab/seed/terminal-profiles.list new file mode 100644 --- /dev/null +++ b/slab/seed/terminal-profiles.list @@ -0,0 +1,13 @@ +Grass +Slab-awaiting-dark +Slab-awaiting-dark-pulse +Slab-awaiting-light +Slab-awaiting-light-pulse +Slab-blank-dark +Slab-blank-light +Slab-complete-dark +Slab-complete-dark-pulse +Slab-complete-light +Slab-complete-light-pulse +Slab-working-dark +Slab-working-light diff --git a/slab/seed/terminal-profiles.plist b/slab/seed/terminal-profiles.plist new file mode 100644 --- /dev/null +++ b/slab/seed/terminal-profiles.plist @@ -0,0 +1,728 @@ + + + + + DefaultProfile + Grass + Profiles + + Grass + + BackgroundColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAgMC4wNzQ1MDk4MDcgMC40NjY2NjY3IDAu + MjM5MjE1NwAQAoAC0hQVFhdaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0NvbG9yohYYWE5T + T2JqZWN0CBEaJCkyN0lMUVNXXWRqd36ho6Wqtb7GyQAAAAAAAAEBAAAAAAAAABkAAAAA + AAAAAAAAAAAAAADS + + Bell + + CursorColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAYMC41NTY4NjI3NyAwLjE1Njg2Mjc1IDAA + EAKAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xvcqIWGFhOU09iamVjdAgR + GiQpMjdJTFFTV11kand+mZudoq22vsEAAAAAAAABAQAAAAAAAAAZAAAAAAAAAAAAAAAA + AAAAyg== + + CursorType + 0 + Font + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwVFlUkbnVsbNQNDg8QERIT + FFZOU1NpemVYTlNmRmxhZ3NWTlNOYW1lViRjbGFzcyNAKAAAAAAAABAQgAKAA18QFk1v + bmFzcGFjZUFyZ29uLVJlZ3VsYXLSFxgZGlokY2xhc3NuYW1lWCRjbGFzc2VzVk5TRm9u + dKIZG1hOU09iamVjdAgRGiQpMjdJTFFTWF5nbnd+hY6QkpStsr3GzdAAAAAAAAABAQAA + AAAAAAAcAAAAAAAAAAAAAAAAAAAA2Q== + + FontAntialias + + ProfileCurrentVersion + 2.0899999999999999 + SelectionColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAhMC43MTM3MjU1MSAwLjI4NjI3NDUyIDAu + MTQ5MDE5NjEAEAKAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xvcqIWGFhO + U09iamVjdAgRGiQpMjdJTFFTV11kand+oqSmq7a/x8oAAAAAAAABAQAAAAAAAAAZAAAA + AAAAAAAAAAAAAAAA0w== + + ShowActiveProcessInTitle + + ShowDimensionsInTitle + + ShowRepresentedURLInTitle + + TextBoldColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAXMSAwLjY5MDE5NjEgMC4yMzEzNzI1NwAQ + AoAC0hQVFhdaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0NvbG9yohYYWE5TT2JqZWN0CBEa + JCkyN0lMUVNXXWRqd36YmpyhrLW9wAAAAAAAAAEBAAAAAAAAABkAAAAAAAAAAAAAAAAA + AADJ + + TextColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAYMSAwLjk0MTE3NjUzIDAuNjQ3MDU4ODQA + EAKAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xvcqIWGFhOU09iamVjdAgR + GiQpMjdJTFFTV11kand+mZudoq22vsEAAAAAAAABAQAAAAAAAAAZAAAAAAAAAAAAAAAA + AAAAyg== + + name + Grass + type + Window Settings + useOptionAsMetaKey + + + Slab-awaiting-dark + + BackgroundColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAnMC4yODk5MjE0MTYgMC4xNjAyMTk3Mjk5 + IDAuMDEzNzMzMTE5NzEAEAGAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xv + cqIWGFhOU09iamVjdAgRGiQpMjdJTFFTV11kand+qKqssbzFzdAAAAAAAAABAQAAAAAA + AAAZAAAAAAAAAAAAAAAAAAAA2Q== + + CursorColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAbMSAwLjcwMTkxNTAwNzIgMC4xNTI1OTAy + MTkAEAGAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xvcqIWGFhOU09iamVj + dAgRGiQpMjdJTFFTV11kand+nJ6gpbC5wcQAAAAAAAABAQAAAAAAAAAZAAAAAAAAAAAA + AAAAAAAAzQ== + + Font + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwVFlUkbnVsbNQNDg8QERIT + FFZOU1NpemVYTlNmRmxhZ3NWTlNOYW1lViRjbGFzcyNAKAAAAAAAABAQgAKAA18QFk1v + bmFzcGFjZUFyZ29uLVJlZ3VsYXLSFxgZGlokY2xhc3NuYW1lWCRjbGFzc2VzVk5TRm9u + dKIZG1hOU09iamVjdAgRGiQpMjdJTFFTWF5nbnd+hY6QkpStsr3GzdAAAAAAAAABAQAA + AAAAAAAcAAAAAAAAAAAAAAAAAAAA2Q== + + ProfileCurrentVersion + 2.0899999999999999 + TextBoldColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxARMSAxIDAuNzYyOTUxMDk0OAAQAYAC0hQV + FhdaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0NvbG9yohYYWE5TT2JqZWN0CBEaJCkyN0lM + UVNXXWRqd36SlJabpq+3ugAAAAAAAAEBAAAAAAAAABkAAAAAAAAAAAAAAAAAAADD + + TextColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAaMSAwLjg4NTAyMzI3IDAuNTc5ODQyODMy + MQAQAYAC0hQVFhdaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0NvbG9yohYYWE5TT2JqZWN0 + CBEaJCkyN0lMUVNXXWRqd36bnZ+kr7jAwwAAAAAAAAEBAAAAAAAAABkAAAAAAAAAAAAA + AAAAAADM + + name + Slab-awaiting-dark + type + Window Settings + + Slab-awaiting-dark-pulse + + BackgroundColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAoMC4zNjYyMTY1MjU1IDAuMjA1OTk2Nzk1 + NiAwLjAxODMxMDgyNjI4ABABgALSFBUWF1okY2xhc3NuYW1lWCRjbGFzc2VzV05TQ29s + b3KiFhhYTlNPYmplY3QIERokKTI3SUxRU1ddZGp3fqmrrbK9xs7RAAAAAAAAAQEAAAAA + AAAAGQAAAAAAAAAAAAAAAAAAANo= + + CursorColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAbMSAwLjcwMTkxNTAwNzIgMC4xNTI1OTAy + MTkAEAGAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xvcqIWGFhOU09iamVj + dAgRGiQpMjdJTFFTV11kand+nJ6gpbC5wcQAAAAAAAABAQAAAAAAAAAZAAAAAAAAAAAA + AAAAAAAAzQ== + + Font + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwVFlUkbnVsbNQNDg8QERIT + FFZOU1NpemVYTlNmRmxhZ3NWTlNOYW1lViRjbGFzcyNAKAAAAAAAABAQgAKAA18QFk1v + bmFzcGFjZUFyZ29uLVJlZ3VsYXLSFxgZGlokY2xhc3NuYW1lWCRjbGFzc2VzVk5TRm9u + dKIZG1hOU09iamVjdAgRGiQpMjdJTFFTWF5nbnd+hY6QkpStsr3GzdAAAAAAAAABAQAA + AAAAAAAcAAAAAAAAAAAAAAAAAAAA2Q== + + ProfileCurrentVersion + 2.0899999999999999 + TextBoldColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxARMSAxIDAuNzYyOTUxMDk0OAAQAYAC0hQV + FhdaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0NvbG9yohYYWE5TT2JqZWN0CBEaJCkyN0lM + UVNXXWRqd36SlJabpq+3ugAAAAAAAAEBAAAAAAAAABkAAAAAAAAAAAAAAAAAAADD + + TextColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAaMSAwLjg4NTAyMzI3IDAuNTc5ODQyODMy + MQAQAYAC0hQVFhdaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0NvbG9yohYYWE5TT2JqZWN0 + CBEaJCkyN0lMUVNXXWRqd36bnZ+kr7jAwwAAAAAAAAEBAAAAAAAAABkAAAAAAAAAAAAA + AAAAAADM + + name + Slab-awaiting-dark-pulse + type + Window Settings + + Slab-awaiting-light + + BackgroundColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAcMSAwLjkwMDI4MjI5MTkgMC42ODY2NTU5 + ODU0ABABgALSFBUWF1okY2xhc3NuYW1lWCRjbGFzc2VzV05TQ29sb3KiFhhYTlNPYmpl + Y3QIERokKTI3SUxRU1ddZGp3fp2foaaxusLFAAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAA + AAAAAAAAAM4= + + CursorColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxARMSAwLjUzNDA2NTc2NjQgMAAQAYAC0hQV + FhdaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0NvbG9yohYYWE5TT2JqZWN0CBEaJCkyN0lM + UVNXXWRqd36SlJabpq+3ugAAAAAAAAEBAAAAAAAAABkAAAAAAAAAAAAAAAAAAADD + + Font + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwVFlUkbnVsbNQNDg8QERIT + FFZOU1NpemVYTlNmRmxhZ3NWTlNOYW1lViRjbGFzcyNAKAAAAAAAABAQgAKAA18QFk1v + bmFzcGFjZUFyZ29uLVJlZ3VsYXLSFxgZGlokY2xhc3NuYW1lWCRjbGFzc2VzVk5TRm9u + dKIZG1hOU09iamVjdAgRGiQpMjdJTFFTWF5nbnd+hY6QkpStsr3GzdAAAAAAAAABAQAA + AAAAAAAcAAAAAAAAAAAAAAAAAAAA2Q== + + ProfileCurrentVersion + 2.0899999999999999 + TextBoldColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAcMC4yNzQ2NjIzOTQxIDAuMTIyMDcyMTc1 + MiAwABABgALSFBUWF1okY2xhc3NuYW1lWCRjbGFzc2VzV05TQ29sb3KiFhhYTlNPYmpl + Y3QIERokKTI3SUxRU1ddZGp3fp2foaaxusLFAAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAA + AAAAAAAAAM4= + + TextColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAcMC4zOTY3MzQ1NjkzIDAuMTk4MzY3Mjg0 + NyAwABABgALSFBUWF1okY2xhc3NuYW1lWCRjbGFzc2VzV05TQ29sb3KiFhhYTlNPYmpl + Y3QIERokKTI3SUxRU1ddZGp3fp2foaaxusLFAAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAA + AAAAAAAAAM4= + + name + Slab-awaiting-light + type + Window Settings + + Slab-awaiting-light-pulse + + BackgroundColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAcMSAwLjgyMzk4NzE4MjQgMC41NDkzMjQ3 + ODgzABABgALSFBUWF1okY2xhc3NuYW1lWCRjbGFzc2VzV05TQ29sb3KiFhhYTlNPYmpl + Y3QIERokKTI3SUxRU1ddZGp3fp2foaaxusLFAAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAA + AAAAAAAAAM4= + + CursorColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxARMSAwLjUzNDA2NTc2NjQgMAAQAYAC0hQV + FhdaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0NvbG9yohYYWE5TT2JqZWN0CBEaJCkyN0lM + UVNXXWRqd36SlJabpq+3ugAAAAAAAAEBAAAAAAAAABkAAAAAAAAAAAAAAAAAAADD + + Font + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwVFlUkbnVsbNQNDg8QERIT + FFZOU1NpemVYTlNmRmxhZ3NWTlNOYW1lViRjbGFzcyNAKAAAAAAAABAQgAKAA18QFk1v + bmFzcGFjZUFyZ29uLVJlZ3VsYXLSFxgZGlokY2xhc3NuYW1lWCRjbGFzc2VzVk5TRm9u + dKIZG1hOU09iamVjdAgRGiQpMjdJTFFTWF5nbnd+hY6QkpStsr3GzdAAAAAAAAABAQAA + AAAAAAAcAAAAAAAAAAAAAAAAAAAA2Q== + + ProfileCurrentVersion + 2.0899999999999999 + TextBoldColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAcMC4yNzQ2NjIzOTQxIDAuMTIyMDcyMTc1 + MiAwABABgALSFBUWF1okY2xhc3NuYW1lWCRjbGFzc2VzV05TQ29sb3KiFhhYTlNPYmpl + Y3QIERokKTI3SUxRU1ddZGp3fp2foaaxusLFAAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAA + AAAAAAAAAM4= + + TextColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAcMC4zOTY3MzQ1NjkzIDAuMTk4MzY3Mjg0 + NyAwABABgALSFBUWF1okY2xhc3NuYW1lWCRjbGFzc2VzV05TQ29sb3KiFhhYTlNPYmpl + Y3QIERokKTI3SUxRU1ddZGp3fp2foaaxusLFAAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAA + AAAAAAAAAM4= + + name + Slab-awaiting-light-pulse + type + Window Settings + + Slab-blank-dark + + BackgroundColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzRjAgMCAwABABgALSFBUWF1okY2xhc3NuYW1l + WCRjbGFzc2VzV05TQ29sb3KiFhhYTlNPYmplY3QIERokKTI3SUxRU1ddZGp3foWHiY6Z + oqqtAAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAAAAAAAAAAALY= + + CursorColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAnMC43NjI5NTEwOTQ4IDAuNzYyOTUxMDk0 + OCAwLjc2Mjk1MTA5NDgAEAGAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xv + cqIWGFhOU09iamVjdAgRGiQpMjdJTFFTV11kand+qKqssbzFzdAAAAAAAAABAQAAAAAA + AAAZAAAAAAAAAAAAAAAAAAAA2Q== + + Font + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwVFlUkbnVsbNQNDg8QERIT + FFZOU1NpemVYTlNmRmxhZ3NWTlNOYW1lViRjbGFzcyNAKAAAAAAAABAQgAKAA18QFk1v + bmFzcGFjZUFyZ29uLVJlZ3VsYXLSFxgZGlokY2xhc3NuYW1lWCRjbGFzc2VzVk5TRm9u + dKIZG1hOU09iamVjdAgRGiQpMjdJTFFTWF5nbnd+hY6QkpStsr3GzdAAAAAAAAABAQAA + AAAAAAAcAAAAAAAAAAAAAAAAAAAA2Q== + + ProfileCurrentVersion + 2.0899999999999999 + TextBoldColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzRjEgMSAxABABgALSFBUWF1okY2xhc3NuYW1l + WCRjbGFzc2VzV05TQ29sb3KiFhhYTlNPYmplY3QIERokKTI3SUxRU1ddZGp3foWHiY6Z + oqqtAAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAAAAAAAAAAALY= + + TextColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAnMC45MTU1NDEzMTM4IDAuOTE1NTQxMzEz + OCAwLjkxNTU0MTMxMzgAEAGAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xv + cqIWGFhOU09iamVjdAgRGiQpMjdJTFFTV11kand+qKqssbzFzdAAAAAAAAABAQAAAAAA + AAAZAAAAAAAAAAAAAAAAAAAA2Q== + + name + Slab-blank-dark + type + Window Settings + + Slab-blank-light + + BackgroundColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzRjEgMSAxABABgALSFBUWF1okY2xhc3NuYW1l + WCRjbGFzc2VzV05TQ29sb3KiFhhYTlNPYmplY3QIERokKTI3SUxRU1ddZGp3foWHiY6Z + oqqtAAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAAAAAAAAAAALY= + + CursorColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAnMC4zMDUxODA0Mzc5IDAuMzA1MTgwNDM3 + OSAwLjMwNTE4MDQzNzkAEAGAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xv + cqIWGFhOU09iamVjdAgRGiQpMjdJTFFTV11kand+qKqssbzFzdAAAAAAAAABAQAAAAAA + AAAZAAAAAAAAAAAAAAAAAAAA2Q== + + Font + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwVFlUkbnVsbNQNDg8QERIT + FFZOU1NpemVYTlNmRmxhZ3NWTlNOYW1lViRjbGFzcyNAKAAAAAAAABAQgAKAA18QFk1v + bmFzcGFjZUFyZ29uLVJlZ3VsYXLSFxgZGlokY2xhc3NuYW1lWCRjbGFzc2VzVk5TRm9u + dKIZG1hOU09iamVjdAgRGiQpMjdJTFFTWF5nbnd+hY6QkpStsr3GzdAAAAAAAAABAQAA + AAAAAAAcAAAAAAAAAAAAAAAAAAAA2Q== + + ProfileCurrentVersion + 2.0899999999999999 + TextBoldColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzRjAgMCAwABABgALSFBUWF1okY2xhc3NuYW1l + WCRjbGFzc2VzV05TQ29sb3KiFhhYTlNPYmplY3QIERokKTI3SUxRU1ddZGp3foWHiY6Z + oqqtAAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAAAAAAAAAAALY= + + TextColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAnMC4xMjIwNzIxNzUyIDAuMTIyMDcyMTc1 + MiAwLjEyMjA3MjE3NTIAEAGAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xv + cqIWGFhOU09iamVjdAgRGiQpMjdJTFFTV11kand+qKqssbzFzdAAAAAAAAABAQAAAAAA + AAAZAAAAAAAAAAAAAAAAAAAA2Q== + + name + Slab-blank-light + type + Window Settings + + Slab-complete-dark + + BackgroundColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxApMC4wNDI3MjUyNjEzMSAwLjA2MTAzNjA4 + NzU5IDAuMTE0NDQyNjY0MgAQAYAC0hQVFhdaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0Nv + bG9yohYYWE5TT2JqZWN0CBEaJCkyN0lMUVNXXWRqd36qrK6zvsfP0gAAAAAAAAEBAAAA + AAAAABkAAAAAAAAAAAAAAAAAAADb + + CursorColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAnMC40ODgyODg3MDA3IDAuNjQwODc4OTE5 + NyAwLjg2OTc2NDI0ODEAEAGAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xv + cqIWGFhOU09iamVjdAgRGiQpMjdJTFFTV11kand+qKqssbzFzdAAAAAAAAABAQAAAAAA + AAAZAAAAAAAAAAAAAAAAAAAA2Q== + + Font + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwVFlUkbnVsbNQNDg8QERIT + FFZOU1NpemVYTlNmRmxhZ3NWTlNOYW1lViRjbGFzcyNAKAAAAAAAABAQgAKAA18QFk1v + bmFzcGFjZUFyZ29uLVJlZ3VsYXLSFxgZGlokY2xhc3NuYW1lWCRjbGFzc2VzVk5TRm9u + dKIZG1hOU09iamVjdAgRGiQpMjdJTFFTWF5nbnd+hY6QkpStsr3GzdAAAAAAAAABAQAA + AAAAAAAcAAAAAAAAAAAAAAAAAAAA2Q== + + ProfileCurrentVersion + 2.0899999999999999 + TextBoldColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAcMC45MTU1NDEzMTM4IDAuOTQ2MDU5MzU3 + NiAxABABgALSFBUWF1okY2xhc3NuYW1lWCRjbGFzc2VzV05TQ29sb3KiFhhYTlNPYmpl + Y3QIERokKTI3SUxRU1ddZGp3fp2foaaxusLFAAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAA + AAAAAAAAAM4= + + TextColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAmMC43MzI0MzMwNTEgMC43OTM0NjkxMzg2 + IDAuOTQ2MDU5MzU3NgAQAYAC0hQVFhdaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0NvbG9y + ohYYWE5TT2JqZWN0CBEaJCkyN0lMUVNXXWRqd36nqauwu8TMzwAAAAAAAAEBAAAAAAAA + ABkAAAAAAAAAAAAAAAAAAADY + + name + Slab-complete-dark + type + Window Settings + + Slab-complete-dark-pulse + + BackgroundColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxApMC4wNjg2NjU1OTg1NCAwLjA5OTE4MzY0 + MjMzIDAuMTc1NDc4NzUxOAAQAYAC0hQVFhdaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0Nv + bG9yohYYWE5TT2JqZWN0CBEaJCkyN0lMUVNXXWRqd36qrK6zvsfP0gAAAAAAAAEBAAAA + AAAAABkAAAAAAAAAAAAAAAAAAADb + + CursorColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAnMC40ODgyODg3MDA3IDAuNjQwODc4OTE5 + NyAwLjg2OTc2NDI0ODEAEAGAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xv + cqIWGFhOU09iamVjdAgRGiQpMjdJTFFTV11kand+qKqssbzFzdAAAAAAAAABAQAAAAAA + AAAZAAAAAAAAAAAAAAAAAAAA2Q== + + Font + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwVFlUkbnVsbNQNDg8QERIT + FFZOU1NpemVYTlNmRmxhZ3NWTlNOYW1lViRjbGFzcyNAKAAAAAAAABAQgAKAA18QFk1v + bmFzcGFjZUFyZ29uLVJlZ3VsYXLSFxgZGlokY2xhc3NuYW1lWCRjbGFzc2VzVk5TRm9u + dKIZG1hOU09iamVjdAgRGiQpMjdJTFFTWF5nbnd+hY6QkpStsr3GzdAAAAAAAAABAQAA + AAAAAAAcAAAAAAAAAAAAAAAAAAAA2Q== + + ProfileCurrentVersion + 2.0899999999999999 + TextBoldColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAcMC45MTU1NDEzMTM4IDAuOTQ2MDU5MzU3 + NiAxABABgALSFBUWF1okY2xhc3NuYW1lWCRjbGFzc2VzV05TQ29sb3KiFhhYTlNPYmpl + Y3QIERokKTI3SUxRU1ddZGp3fp2foaaxusLFAAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAA + AAAAAAAAAM4= + + TextColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAmMC43MzI0MzMwNTEgMC43OTM0NjkxMzg2 + IDAuOTQ2MDU5MzU3NgAQAYAC0hQVFhdaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0NvbG9y + ohYYWE5TT2JqZWN0CBEaJCkyN0lMUVNXXWRqd36nqauwu8TMzwAAAAAAAAEBAAAAAAAA + ABkAAAAAAAAAAAAAAAAAAADY + + name + Slab-complete-dark-pulse + type + Window Settings + + Slab-complete-light + + BackgroundColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAcMC44NTQ1MDUyMjYyIDAuOTAwMjgyMjkx + OSAxABABgALSFBUWF1okY2xhc3NuYW1lWCRjbGFzc2VzV05TQ29sb3KiFhhYTlNPYmpl + Y3QIERokKTI3SUxRU1ddZGp3fp2foaaxusLFAAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAA + AAAAAAAAAM4= + + CursorColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAnMC4yMjg4ODUzMjg1IDAuMzgxNDc1NTQ3 + NCAwLjc5MzQ2OTEzODYAEAGAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xv + cqIWGFhOU09iamVjdAgRGiQpMjdJTFFTV11kand+qKqssbzFzdAAAAAAAAABAQAAAAAA + AAAZAAAAAAAAAAAAAAAAAAAA2Q== + + Font + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwVFlUkbnVsbNQNDg8QERIT + FFZOU1NpemVYTlNmRmxhZ3NWTlNOYW1lViRjbGFzcyNAKAAAAAAAABAQgAKAA18QFk1v + bmFzcGFjZUFyZ29uLVJlZ3VsYXLSFxgZGlokY2xhc3NuYW1lWCRjbGFzc2VzVk5TRm9u + dKIZG1hOU09iamVjdAgRGiQpMjdJTFFTWF5nbnd+hY6QkpStsr3GzdAAAAAAAAABAQAA + AAAAAAAcAAAAAAAAAAAAAAAAAAAA2Q== + + ProfileCurrentVersion + 2.0899999999999999 + TextBoldColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAoMC4wNDU3NzcwNjU2OSAwLjEwNjgxMzE1 + MzMgMC4zMjA0Mzk0NTk4ABABgALSFBUWF1okY2xhc3NuYW1lWCRjbGFzc2VzV05TQ29s + b3KiFhhYTlNPYmplY3QIERokKTI3SUxRU1ddZGp3fqmrrbK9xs7RAAAAAAAAAQEAAAAA + AAAAGQAAAAAAAAAAAAAAAAAAANo= + + TextColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAnMC4xMjIwNzIxNzUyIDAuMjEzNjI2MzA2 + NiAwLjQ1Nzc3MDY1NjkAEAGAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xv + cqIWGFhOU09iamVjdAgRGiQpMjdJTFFTV11kand+qKqssbzFzdAAAAAAAAABAQAAAAAA + AAAZAAAAAAAAAAAAAAAAAAAA2Q== + + name + Slab-complete-light + type + Window Settings + + Slab-complete-light-pulse + + BackgroundColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAnMC43NjI5NTEwOTQ4IDAuODIzOTg3MTgy + NCAwLjk3NjU3NzQwMTQAEAGAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xv + cqIWGFhOU09iamVjdAgRGiQpMjdJTFFTV11kand+qKqssbzFzdAAAAAAAAABAQAAAAAA + AAAZAAAAAAAAAAAAAAAAAAAA2Q== + + CursorColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAnMC4yMjg4ODUzMjg1IDAuMzgxNDc1NTQ3 + NCAwLjc5MzQ2OTEzODYAEAGAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xv + cqIWGFhOU09iamVjdAgRGiQpMjdJTFFTV11kand+qKqssbzFzdAAAAAAAAABAQAAAAAA + AAAZAAAAAAAAAAAAAAAAAAAA2Q== + + Font + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwVFlUkbnVsbNQNDg8QERIT + FFZOU1NpemVYTlNmRmxhZ3NWTlNOYW1lViRjbGFzcyNAKAAAAAAAABAQgAKAA18QFk1v + bmFzcGFjZUFyZ29uLVJlZ3VsYXLSFxgZGlokY2xhc3NuYW1lWCRjbGFzc2VzVk5TRm9u + dKIZG1hOU09iamVjdAgRGiQpMjdJTFFTWF5nbnd+hY6QkpStsr3GzdAAAAAAAAABAQAA + AAAAAAAcAAAAAAAAAAAAAAAAAAAA2Q== + + ProfileCurrentVersion + 2.0899999999999999 + TextBoldColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAoMC4wNDU3NzcwNjU2OSAwLjEwNjgxMzE1 + MzMgMC4zMjA0Mzk0NTk4ABABgALSFBUWF1okY2xhc3NuYW1lWCRjbGFzc2VzV05TQ29s + b3KiFhhYTlNPYmplY3QIERokKTI3SUxRU1ddZGp3fqmrrbK9xs7RAAAAAAAAAQEAAAAA + AAAAGQAAAAAAAAAAAAAAAAAAANo= + + TextColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAnMC4xMjIwNzIxNzUyIDAuMjEzNjI2MzA2 + NiAwLjQ1Nzc3MDY1NjkAEAGAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xv + cqIWGFhOU09iamVjdAgRGiQpMjdJTFFTV11kand+qKqssbzFzdAAAAAAAAABAQAAAAAA + AAAZAAAAAAAAAAAAAAAAAAAA2Q== + + name + Slab-complete-light-pulse + type + Window Settings + + Slab-working-dark + + BackgroundColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxApMC4wMTM3MzMxMTk3MSAwLjEyMjA3MjE3 + NTIgMC4wMzY2MjE2NTI1NQAQAYAC0hQVFhdaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0Nv + bG9yohYYWE5TT2JqZWN0CBEaJCkyN0lMUVNXXWRqd36qrK6zvsfP0gAAAAAAAAEBAAAA + AAAAABkAAAAAAAAAAAAAAAAAAADb + + CursorColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAnMC4zNjYyMTY1MjU1IDAuODU0NTA1MjI2 + MiAwLjUxODgwNjc0NDUAEAGAAtIUFRYXWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xv + cqIWGFhOU09iamVjdAgRGiQpMjdJTFFTV11kand+qKqssbzFzdAAAAAAAAABAQAAAAAA + AAAZAAAAAAAAAAAAAAAAAAAA2Q== + + Font + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwVFlUkbnVsbNQNDg8QERIT + FFZOU1NpemVYTlNmRmxhZ3NWTlNOYW1lViRjbGFzcyNAKAAAAAAAABAQgAKAA18QFk1v + bmFzcGFjZUFyZ29uLVJlZ3VsYXLSFxgZGlokY2xhc3NuYW1lWCRjbGFzc2VzVk5TRm9u + dKIZG1hOU09iamVjdAgRGiQpMjdJTFFTWF5nbnd+hY6QkpStsr3GzdAAAAAAAAABAQAA + AAAAAAAcAAAAAAAAAAAAAAAAAAAA2Q== + + ProfileCurrentVersion + 2.0899999999999999 + TextBoldColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAcMC44NTQ1MDUyMjYyIDEgMC45MTU1NDEz + MTM4ABABgALSFBUWF1okY2xhc3NuYW1lWCRjbGFzc2VzV05TQ29sb3KiFhhYTlNPYmpl + Y3QIERokKTI3SUxRU1ddZGp3fp2foaaxusLFAAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAA + AAAAAAAAAM4= + + TextColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAmMC42NDA4Nzg5MTk3IDAuOTQ2MDU5MzU3 + NiAwLjczMjQzMzA1MQAQAYAC0hQVFhdaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0NvbG9y + ohYYWE5TT2JqZWN0CBEaJCkyN0lMUVNXXWRqd36nqauwu8TMzwAAAAAAAAEBAAAAAAAA + ABkAAAAAAAAAAAAAAAAAAADY + + name + Slab-working-dark + type + Window Settings + + Slab-working-light + + BackgroundColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAaMC44MzkyNDYyMDQzIDEgMC44ODUwMjMy + NwAQAYAC0hQVFhdaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0NvbG9yohYYWE5TT2JqZWN0 + CBEaJCkyN0lMUVNXXWRqd36bnZ+kr7jAwwAAAAAAAAEBAAAAAAAAABkAAAAAAAAAAAAA + AAAAAADM + + CursorColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAoMC4wNjEwMzYwODc1OSAwLjY0MDg3ODkx + OTcgMC4yMjg4ODUzMjg1ABABgALSFBUWF1okY2xhc3NuYW1lWCRjbGFzc2VzV05TQ29s + b3KiFhhYTlNPYmplY3QIERokKTI3SUxRU1ddZGp3fqmrrbK9xs7RAAAAAAAAAQEAAAAA + AAAAGQAAAAAAAAAAAAAAAAAAANo= + + Font + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwVFlUkbnVsbNQNDg8QERIT + FFZOU1NpemVYTlNmRmxhZ3NWTlNOYW1lViRjbGFzcyNAKAAAAAAAABAQgAKAA18QFk1v + bmFzcGFjZUFyZ29uLVJlZ3VsYXLSFxgZGlokY2xhc3NuYW1lWCRjbGFzc2VzVk5TRm9u + dKIZG1hOU09iamVjdAgRGiQpMjdJTFFTWF5nbnd+hY6QkpStsr3GzdAAAAAAAAABAQAA + AAAAAAAcAAAAAAAAAAAAAAAAAAAA2Q== + + ProfileCurrentVersion + 2.0899999999999999 + TextBoldColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAoMC4wMTUyNTkwMjE5IDAuMTk4MzY3Mjg0 + NyAwLjA2MTAzNjA4NzU5ABABgALSFBUWF1okY2xhc3NuYW1lWCRjbGFzc2VzV05TQ29s + b3KiFhhYTlNPYmplY3QIERokKTI3SUxRU1ddZGp3fqmrrbK9xs7RAAAAAAAAAQEAAAAA + AAAAGQAAAAAAAAAAAAAAAAAAANo= + + TextColor + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS + AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwTVSRudWxs0w0ODxARElVO + U1JHQlxOU0NvbG9yU3BhY2VWJGNsYXNzTxAoMC4wMzgxNDc1NTQ3NCAwLjMwNTE4MDQz + NzkgMC4xMjIwNzIxNzUyABABgALSFBUWF1okY2xhc3NuYW1lWCRjbGFzc2VzV05TQ29s + b3KiFhhYTlNPYmplY3QIERokKTI3SUxRU1ddZGp3fqmrrbK9xs7RAAAAAAAAAQEAAAAA + AAAAGQAAAAAAAAAAAAAAAAAAANo= + + name + Slab-working-light + type + Window Settings + + + + diff --git a/slab/settings-fragment.json b/slab/settings-fragment.json --- a/slab/settings-fragment.json +++ b/slab/settings-fragment.json @@ -20,6 +20,18 @@ "matcher": "Task", "hooks": [ { "type": "command", "command": "@HOME@/.local/bin/claude-tool-pre.sh", "async": true } ] + }, + { + "hooks": [ + { "type": "command", "command": "@HOME@/.local/bin/claude-tool-heartbeat.sh pre", "async": true } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { "type": "command", "command": "@HOME@/.local/bin/claude-tool-heartbeat.sh post", "async": true } + ] } ], "UserPromptSubmit": [ diff --git a/system/public/kidlisp.com/buy.html b/system/public/kidlisp.com/buy.html --- a/system/public/kidlisp.com/buy.html +++ b/system/public/kidlisp.com/buy.html @@ -3,6 +3,12 @@ + Buy KidLisp Keeps @@ -280,6 +286,8 @@ z-index: 200; flex-direction: column; } .modal-overlay.open { display: flex; } + /* Deep-link: show the opaque modal from first paint so the home grid never flashes */ + html.deeplink .modal-overlay { display: flex; } .modal { flex: 1; @@ -300,7 +308,7 @@ flex-shrink: 0; } .modal-title { - font-size: 16px; + font-size: 21px; font-weight: 700; color: var(--purple); min-width: 0; @@ -345,9 +353,9 @@ flex-wrap: wrap; } .modal-price { - font-size: 18px; + font-size: 23px; font-weight: 700; - color: var(--blue); + color: var(--gold); } .modal-info { @@ -359,15 +367,15 @@ gap: 2px; } .modal-seller { - font-size: 10px; - color: var(--text2); + font-size: 13px; + color: var(--neon-pink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .modal-permalink { - font-size: 10px; + font-size: 14px; font-family: var(--font-mono); color: var(--neon-cyan); cursor: pointer; @@ -396,7 +404,7 @@ .buy-btn:hover { opacity: 0.9; transform: scale(1.03); box-shadow: 0 4px 20px rgba(255, 230, 109, 0.4); } .buy-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; } .buy-status { - font-size: 11px; + font-size: 13px; color: var(--text2); min-height: 16px; width: 100%; @@ -406,14 +414,18 @@ .buy-status.error { color: var(--red); } .modal-links { display: flex; - gap: 12px; - font-size: 11px; + gap: 16px; + font-size: 15px; + font-weight: 600; } .modal-links a { color: var(--text2); text-decoration: none; } - .modal-links a:hover { color: var(--accent); } + /* Color-coded explorer links */ + .modal-links a:nth-child(1) { color: var(--purple); } /* objkt */ + .modal-links a:nth-child(2) { color: var(--neon-cyan); } /* tzkt */ + .modal-links a:hover { text-decoration: underline; filter: brightness(1.2); } /* ── Empty / loading ── */ .loading { @@ -725,7 +737,7 @@ - +