diff --git a/specs/STATUS.md b/specs/STATUS.md index ae6a11a..78428eb 100644 --- a/specs/STATUS.md +++ b/specs/STATUS.md @@ -7,9 +7,10 @@ _This file is a **card, not a ledger**: an entry that is done and no longer guides the work gets **deleted**, not archived. History lives in git and PR bodies; measured facts live in the spec they verify._ -_Last updated: 2026-08-07 (spec 12 settings layer built: settings.json + the -engine-owned store + two additive wire messages + the TUI `/settings` pane; -seven writable intents, everything hot)_ +_Last updated: 2026-08-07 (spec 10 §6.1 art direction landed: the +quiet-constellation skin — night-ground palette, wide-terminal sky panel with +braille mist + floating pet, narrow fallback unchanged; by-eye tuning stays +issue #79)_ ## Where we are @@ -34,7 +35,8 @@ how it got there: retired 2026-08-07 — spec 07 status note). - spec 10 TUI — the wire + `IpcHost` + OpenTUI client (#71); the visualizer feed + pixel pet + warmth kit (#74). Redesign decisions: #70. The TUI is now - the DEFAULT front-end (spec 10 §6), falling back to plain without bun. + the DEFAULT front-end (spec 10 §6), falling back to plain without bun. The + §6.1 quiet-constellation skin landed 2026-08-07 (spec 10 §6.1 for its terms). - spec 03-03 §7 conversational onboarding — the shell preflight demoted to a reporter, one aggregated setup offer per boot with a ledger-recorded decline, and the guide-written voice endpoint (#88). @@ -63,7 +65,6 @@ hand: CI fails if this section points at an issue that is already closed. - **#83** (watch) Enter during an uncommitted IME composition may submit the line. - **#98** (eng) Steer tool-choice eval (Ollama) owed — the smoke is on-demand only; spec 11 §5. - **#99** (by-ear) Spec 11 acceptance pass — handover feel, slow-pick cover, two-phase off. -- **#111** (by-ear) Settings pane acceptance pass — focus handoff, hot knobs by ear; spec 12 §5.7. ## Pinned — do not relitigate diff --git a/specs/spec10/10-tui.md b/specs/spec10/10-tui.md index f304287..e377551 100644 --- a/specs/spec10/10-tui.md +++ b/specs/spec10/10-tui.md @@ -6,9 +6,12 @@ > item, not a blocker: whether Enter pressed *during* an uncommitted composition > commits the candidate or submits the line (§5.1's note below). Slice 2 > (engine FFT tap + `viz` feed, §3.6) and slice 3 (pet substrate + warmth kit, -> §3.7) landed 2026-07-30; what remains open is sensory, not mechanical — the -> §5.11 human pass and the §6.1 art-direction session, which restyles the -> substrate without reopening these contracts. +> §3.7) landed 2026-07-30. The §6.1 art-direction session chose and landed the +> **quiet-constellation** composition (2026-08-07): the palette discipline, +> the wide-terminal sky panel (§3.3), and the mist rendering of the viz feed +> (§3.6) — contracts untouched. What remains open is sensory: the §5.11 human +> pass and the by-eye tuning of the new skin (issue #79), plus the pet's +> identity, which stays a §6.1 question. > **Part**: Front-end refinement — replaces the CLI Host's plain print/stdin > with a real TUI. The **single richer front-end murmur ever gets**: there is > no GUI, no menu-bar, no web surface (master [`../DESIGN.md`](../DESIGN.md) @@ -344,6 +347,16 @@ Esc. The permanent-focus rule defends against *radio output* stealing focus mid-keystroke; a mode the listener opened is the listener spending their own focus, and the broadcast never pauses under it. +**As built (2026-08-07, §6.1 quiet-constellation): composition has one +breakpoint.** At ≥ 96 columns the alive band recomposes as a **sky panel** +beside the log: the program log keeps the left with one blank line between +entries, the panel takes the right — a seeded starfield, the visualizer's +bins as a floating braille particle mist (§3.6), the pet drifting inside it, +and now-playing as a dim `♪` line under the panel. Below 96 columns the +classic bottom band stands unchanged, and now-playing stays in the status +strip. Same four regions either way; only the composition moves, which is +exactly the §6.1 licence. + **As built (2026-08-06, issue #95): the pet is optional.** `MURMUR_TUI_PET=0` (also `off` / `false` / `no`) drops the creature from the alive band, and the gutter that separated it from the bars goes with it — the spectrum spans the @@ -403,6 +416,14 @@ only the client — one shutdown path. the same strip can render the voice's envelope — the radio visibly *speaks*; on the bed it breathes low. (What ships in v1 is a §6.1 styling call; the feed contract is the same.) +- **As built (2026-08-07, §6.1)**: the bar strip is the narrow composition; + wide terminals re-render the SAME feed as the constellation mist — + braille-cell particles climbing from a floating baseline, density following + the bin level, a sparse fallout drifting below it. The sky paints on the + client's own 12fps clock and viz frames only feed the smoother, so stars + twinkle and the pet animates even when the engine is silent. All of it is + client-side arithmetic (`tui/src/constellation.ts`); the feed contract is + untouched. ### 3.7 The warmth kit (techniques adopted from the case research) @@ -575,11 +596,26 @@ TUI work is built on the framework choice. - **Bun→Node exit**: revisit when OpenTUI's Node FFI support leaves experimental status. -### 6.1 The creative session (deferred, unchanged in spirit) - -Art direction remains its own working session with the user: palette and -overall warmth, the pet's identity and personality (what it is, how it -reacts, what it does when you're gone), visualizer styling, whether typing -deserves a steering affordance (§3.2-A), and how the four regions compose -into something that feels alive. That session styles what this spec builds; -it does not reopen the contracts above. +### 6.1 The creative session + +Art direction is its own working track with the user; it styles what this +spec builds and does not reopen the contracts above. + +**Decided (2026-08-07, concept session over `scratch/ui-concepts/logo-pet`): +the skin is `04-quiet-constellation`.** Its terms: + +- **Palette discipline**: one shared deep blue-charcoal night ground; each + scene is a single near-monochrome accent family on it (dawn cream / + daylight sage / ember / moonlight) — the hour changes the warmth of the + light, never the room. Values live in `tui/src/palette.ts` and stay by-eye + knobs. +- **Composition**: the one-breakpoint sky-panel layout (§3.3) and the mist + rendering of the viz feed (§3.6). +- **Rejected**: the analog-radio dial chrome (01), the framed scene panel + (02 — per-scene hand art, cost without a daily payoff), the zine poster + (03 — display type has no CJK form and no small-terminal degradation). + +Still open, still this session's property: the pet's **identity** (the +whisper-figure logo motif and the stitched texture of concept 03 are the +reference material; today's creature holds the slot), and the by-eye tuning +of all of the above in a real terminal (issue #79). diff --git a/test/tui-constellation.test.ts b/test/tui-constellation.test.ts new file mode 100644 index 0000000..d5889fb --- /dev/null +++ b/test/tui-constellation.test.ts @@ -0,0 +1,126 @@ +// The constellation panel's client half (spec 10 §3.6 / §6.1 quiet-constellation +// art direction): a seeded starfield, the FFT bins re-rendered as a braille +// particle mist, and the pet floating inside it. +// +// Like bars.ts, the module is free of OpenTUI and React on purpose: the frame a +// terminal paints cannot be unit-asserted (§3.9), but "which dots for this +// level" can — and determinism (seeded stars, hashed jitter) is itself part of +// the contract, because a resize must not reshuffle the sky. + +import { describe, expect, it } from 'vitest' + +import { Constellation, panelWidth, WIDE_MIN } from '../tui/src/constellation.ts' +import { accentFor } from '../tui/src/palette.ts' + +const ACCENT = accentFor('late-night') + +const BRAILLE_START = 0x28_00 +const BRAILLE_END = 0x28_ff + +function isBraille(char: string): boolean { + const code = char.codePointAt(0)! + return code >= BRAILLE_START && code <= BRAILLE_END +} + +// Total lit sub-pixels in a frame: each braille cell carries its dots in the +// low byte of the code point. +function litDots(rows: { text: string }[][]): number { + let dots = 0 + for (const row of rows) { + for (const run of row) { + for (const char of run.text) { + if (isBraille(char)) { + let bits = char.codePointAt(0)! - BRAILLE_START + while (bits > 0) { + dots += bits & 1 + bits >>= 1 + } + } + } + } + } + return dots +} + +function rowText(row: { text: string }[]): string { + return row.map((run) => run.text).join('') +} + +describe('Constellation (§6.1: starfield + particle mist)', () => { + it('renders exactly height rows of exactly width cells', () => { + const sky = new Constellation(40, 12, 7) + const rows = sky.frame([0.2, 0.8, 0.5], ACCENT, null) + expect(rows).toHaveLength(12) + for (const row of rows) expect([...rowText(row)]).toHaveLength(40) + }) + + it('draws the same sky for the same seed — a resize must not reshuffle it', () => { + const a = new Constellation(40, 12, 7).frame([], ACCENT, null) + const b = new Constellation(40, 12, 7).frame([], ACCENT, null) + expect(a).toEqual(b) + }) + + it('draws a different sky for a different seed', () => { + const a = new Constellation(40, 12, 1).frame([], ACCENT, null) + const b = new Constellation(40, 12, 2).frame([], ACCENT, null) + expect(a).not.toEqual(b) + }) + + it('has stars but no particles in silence', () => { + const sky = new Constellation(40, 12, 7) + const rows = sky.frame([0, 0, 0], ACCENT, null) + expect(litDots(rows)).toBe(0) + const glyphs = rows.flatMap((row) => [...rowText(row)]).filter((char) => char !== ' ') + expect(glyphs.length).toBeGreaterThan(0) + }) + + it('lights more dots the louder the frame — the mist follows the music', () => { + const quiet = litDots(new Constellation(40, 12, 7).frame([0.2, 0.2, 0.2], ACCENT, null)) + const loud = litDots(new Constellation(40, 12, 7).frame([1, 1, 1], ACCENT, null)) + expect(quiet).toBeGreaterThan(0) + expect(loud).toBeGreaterThan(quiet) + }) + + it('keeps every particle braille and every star a known glyph', () => { + const sky = new Constellation(40, 12, 7) + const rows = sky.frame([1, 0.5, 1], ACCENT, null) + for (const char of rows.flatMap((row) => [...rowText(row)])) { + expect(isBraille(char) || [' ', '·', '+', '✦'].includes(char)).toBe(true) + } + }) + + it('blits the pet into the middle of the sky, background and all', () => { + const pet = [ + [ + { fg: '#111111', bg: '#222222' }, + { fg: '#111111', bg: '#222222' }, + ], + ] + const rows = new Constellation(40, 12, 7).frame([], ACCENT, pet) + const withPet = rows.flat().filter((run) => run.bg === '#222222') + expect(withPet.length).toBeGreaterThan(0) + }) + + it('survives hostile bins and degenerate panels', () => { + const sky = new Constellation(2, 2, 7) + for (const frame of [[], [Number.NaN], [2, -1], [0.5]]) { + const rows = sky.frame(frame, ACCENT, null) + expect(rows).toHaveLength(2) + for (const row of rows) expect([...rowText(row)]).toHaveLength(2) + } + }) +}) + +describe('panelWidth (the one §6.1 breakpoint)', () => { + it('declines a panel below the wide minimum', () => { + expect(panelWidth(WIDE_MIN - 1)).toBeNull() + expect(panelWidth(20)).toBeNull() + }) + + it('grants a panel from the minimum up, clamped to a readable band', () => { + const atMin = panelWidth(WIDE_MIN) + expect(atMin).not.toBeNull() + expect(atMin!).toBeGreaterThanOrEqual(30) + expect(panelWidth(400)!).toBeLessThanOrEqual(64) + }) +}) diff --git a/tui/src/app.tsx b/tui/src/app.tsx index 62da83a..5e4b8a7 100644 --- a/tui/src/app.tsx +++ b/tui/src/app.tsx @@ -8,11 +8,12 @@ // the art direction session (§6.1) can restyle murmur without touching logic. import { useEffect, useRef, useState } from 'react' -import { useKeyboard, type InputProps } from '@opentui/react' +import { useKeyboard, useTerminalDimensions, type InputProps } from '@opentui/react' import type { InputRenderable } from '@opentui/core' import type { EngineMessage, ProgramState, SettingsSnapshot } from '../../src/ipc.ts' import { Bars, render } from './bars.ts' +import { Constellation, panelWidth, type Run } from './constellation.ts' import { accentFor, INK, mix, type Accent } from './palette.ts' import { adjust, paneFacts, paneItems } from './settings-pane.ts' import { @@ -130,6 +131,67 @@ function Pet({ pose, accent }: { pose: PoseName; accent: Accent }): React.ReactN ) } +// The wide-terminal sky (§6.1 quiet-constellation): starfield, particle mist, +// and the pet floating in it. Painted on its own clock — viz frames only feed +// the smoother, so the sky breathes (stars twinkle, the pet animates) even when +// the engine has nothing to say. Constellation is per-mount; the parent keys +// this component on its size, so a resize builds a fresh sky. +const SKY_FPS = 12 + +function SkyPanel({ + sink, + accent, + pose, + showPet, + width, + rows, +}: { + sink: VizSink + accent: Accent + pose: PoseName + showPet: boolean + width: number + rows: number +}): React.ReactNode { + const bars = useRef(new Bars()) + const sky = useRef(null) + const tick = useRef(0) + const [painted, setPainted] = useState([]) + if (sky.current === null) sky.current = new Constellation(width, rows) + + useEffect(() => { + sink.current = (bins) => bars.current.push(bins) + return () => void (sink.current = null) + }, [sink]) + + const frames = POSES[pose] + useEffect(() => { + const timer = setInterval(() => { + tick.current++ + const at = Math.floor(tick.current / (SKY_FPS / POSE_FPS[pose])) % frames.length + const pet = showPet + ? cells(frames[at]!, petPalette(accent, pose === 'doze' ? DOZE_FADE : 0)) + : null + setPainted(sky.current!.frame(bars.current.levels(), accent, pet)) + }, 1000 / SKY_FPS) + return () => clearInterval(timer) + }, [accent, pose, frames, showPet]) + + return ( + + {painted.map((runs, y) => ( + + {runs.map((run, at) => ( + + {run.text} + + ))} + + ))} + + ) +} + export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): React.ReactNode { const [identity, setIdentity] = useState({ persona: '', brain: '', voice: '' }) const [entries, setEntries] = useState([]) @@ -231,13 +293,26 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): // The hour's accent, swapped whenever the engine reports a new scene (§3.7.2). const accent = accentFor(state?.scene) const pose = greeting !== null ? 'wake' : poseFor(state) - const strip = [greeting ?? microcopy ?? 'warming up...', state?.nowPlaying] + // The §6.1 breakpoint: wide terminals compose the alive band as a sky panel + // beside the log; narrow ones keep the classic bottom band. Same four + // regions either way (§3.3) — only the composition moves. + const dims = useTerminalDimensions() + const skyWidth = panelWidth(dims.width) + // In the sky composition now-playing lives under the panel as its own quiet + // line; in the band composition it stays in the strip. + const strip = [ + greeting ?? microcopy ?? 'warming up...', + skyWidth === null ? state?.nowPlaying : undefined, + ] .filter((part) => part !== undefined && part !== '') .join(' ♪ ') // The alive band's composition follows the live pet setting (spec 12 §3.7), // with the env override resolved inside bandLayout. const band = bandLayout(process.env, settings?.values.tuiPet) const items = paneOpen && settings !== null ? paneItems(settings) : null + // Rows left for the sky once the strip, identity, input, and now-playing + // lines take theirs. + const skyRows = Math.max(dims.height - 4, 4) return ( @@ -256,6 +331,7 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): + {items !== null && settings !== null ? ( settings @@ -287,34 +363,64 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): {entries.map((entry) => ( - - {MARKER[entry.kind]} - {entry.text} - + // The sky composition lets the log breathe — one blank line between + // entries, the poem spacing of §6.1. The band composition stays dense. + + + {MARKER[entry.kind]} + {entry.text} + + ))} )} - - {band.pet && } - - + {skyWidth !== null && ( + + + + {state?.nowPlaying !== undefined && state.nowPlaying !== '' + ? `♪ ${state.nowPlaying}` + : ''} + + )} + {skyWidth === null && ( + + {band.pet && } + + + + + )} + {`${identity.brain} · ${identity.voice}`} diff --git a/tui/src/constellation.ts b/tui/src/constellation.ts new file mode 100644 index 0000000..fd23ecb --- /dev/null +++ b/tui/src/constellation.ts @@ -0,0 +1,200 @@ +// The quiet-constellation panel (spec 10 §6.1): a seeded starfield, the FFT +// bins re-rendered as a braille particle mist rising from the panel floor, and +// the pet floating in the middle of it. This is the wide-terminal composition +// of the alive band; bars.ts remains the narrow one. +// +// No OpenTUI and no React in here, same reasoning as bars.ts: the painted frame +// cannot be unit-asserted (§3.9), the arithmetic can. Everything random is +// seeded or hashed — the sky must survive a re-render without reshuffling, and +// a test must be able to hold two instances to the same frame. + +import { INK, mix, type Accent } from './palette.ts' +import type { Cell } from './pet.ts' + +export type Run = { text: string; fg: string; bg?: string | undefined } + +// Below this many columns the two-column composition has no room to breathe and +// the client keeps the classic bottom band (§6.1's single breakpoint). +export const WIDE_MIN = 96 + +// The panel's share of a wide terminal, clamped to where the mist still reads: +// narrower has no sky, wider turns the log into a sidebar. +export function panelWidth(cols: number): number | null { + if (cols < WIDE_MIN) return null + return Math.min(Math.max(Math.round(cols * 0.42), 32), 64) +} + +// Braille dot bits by [row-in-cell][column-in-cell], top row first. +const DOT_BITS = [ + [0x01, 0x08], + [0x02, 0x10], + [0x04, 0x20], + [0x40, 0x80], +] as const + +const BRAILLE_BASE = 0x28_00 + +// The mist floats: its baseline hangs at this fraction of the panel's height, +// dots climb from there and a thin fallout drifts below — glued to the panel +// floor it reads as a bar chart, not a sky. +const BASELINE = 0.78 + +// How much of the panel the mist may climb at full level, in sub-pixel rows. +const MIST_CEILING = 0.55 + +// Peak dot density — the mist is scattered squares with dark between them, +// never a filled bar; density thins toward a column's top edge. +const MIST_DENSITY = 0.32 + +// The sparse trail under the baseline. +const FALLOUT_ROWS = 5 +const FALLOUT_DENSITY = 0.12 + +// Stars per cell, roughly — sparse enough to stay a sky, not a texture. +const STAR_DENSITY = 0.03 + +// The mist shimmers slower than the 24fps feed, or it reads as static noise. +const SHIMMER_DIVISOR = 4 + +type Star = { x: number; y: number; glyph: string; phase: number } + +const STAR_GLYPHS = ['·', '·', '·', '+', '✦'] as const + +function clamp01(value: number): number { + return Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0 +} + +// mulberry32 — one small seeded PRNG so the field is stable per seed. +function prng(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6d_2b_79_f5) >>> 0 + let mixed = state + mixed = Math.imul(mixed ^ (mixed >>> 15), mixed | 1) + mixed ^= mixed + Math.imul(mixed ^ (mixed >>> 7), mixed | 61) + return ((mixed ^ (mixed >>> 14)) >>> 0) / 4_294_967_296 + } +} + +// Deterministic per-dot jitter: same dot, same tick bucket, same verdict. +function hash01(x: number, y: number, tick: number): number { + let mixed = (Math.imul(x, 374_761_393) + Math.imul(y, 668_265_263) + Math.imul(tick, 2_246_822_519)) >>> 0 + mixed = Math.imul(mixed ^ (mixed >>> 13), 1_274_126_177) >>> 0 + return (mixed ^ (mixed >>> 16)) / 4_294_967_296 +} + +export class Constellation { + private readonly stars: Star[] + private readonly width: number + private readonly height: number + private tick = 0 + + constructor(width: number, height: number, seed = 1) { + this.width = width + this.height = height + const rand = prng(seed) + const stars: Star[] = [] + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (rand() < STAR_DENSITY) { + stars.push({ + x, + y, + glyph: STAR_GLYPHS[Math.floor(rand() * STAR_GLYPHS.length)]!, + phase: rand(), + }) + } + } + } + this.stars = stars + } + + // One painted frame: levels are the engine's 0..1 bins (empty = silence), pet + // is pre-folded half-block cells (pet.ts `cells`) or null for a hidden pet. + frame(levels: readonly number[], accent: Accent, pet: Cell[][] | null): Run[][] { + const tick = this.tick++ + const subCols = this.width * 2 + const subRows = this.height * 4 + const bits = Array.from({ length: this.height }, () => Array.from({ length: this.width }, () => 0)) + + // The mist: scattered surviving dots per sub-pixel column, climbing from a + // floating baseline, with a thin fallout drifting below it. + const baseline = Math.floor(subRows * BASELINE) + const bucket = Math.floor(tick / SHIMMER_DIVISOR) + const dot = (sc: number, fromTop: number): void => { + if (fromTop < 0 || fromTop >= subRows) return + bits[Math.floor(fromTop / 4)]![Math.floor(sc / 2)]! |= DOT_BITS[fromTop % 4]![sc % 2]! + } + for (let sc = 0; sc < subCols; sc++) { + const level = + levels.length === 0 ? 0 : clamp01(levels[Math.floor((sc / subCols) * levels.length)]!) + if (level === 0) continue + const target = level * subRows * MIST_CEILING + for (let up = 0; up < target; up++) { + const density = MIST_DENSITY * (1 - (0.85 * up) / target) + if (hash01(sc, up, bucket) < density) dot(sc, baseline - up) + } + for (let down = 1; down <= FALLOUT_ROWS; down++) { + if (hash01(sc, -down, bucket) < FALLOUT_DENSITY * level) dot(sc, baseline + down) + } + } + + // The pet floats above the mist floor, centred — with one clear cell of + // halo so it sits IN the sky rather than on top of it. + const petRows = pet?.length ?? 0 + const petCols = petRows > 0 ? pet![0]!.length : 0 + const fits = petRows > 0 && petCols <= this.width && petRows <= this.height + // Centred in the cloud's own region, not the panel's: the creature drifts + // IN the mist, a little above the baseline. + const petX = fits ? Math.floor((this.width - petCols) / 2) : 0 + const petY = fits ? Math.max(Math.floor(this.height * 0.52) - Math.floor(petRows / 2), 0) : 0 + + const starAt = new Map() + for (const star of this.stars) starAt.set(star.y * this.width + star.x, star) + + const rows: Run[][] = [] + for (let y = 0; y < this.height; y++) { + const runs: Run[] = [] + const put = (text: string, fg: string, bg?: string): void => { + const last = runs.at(-1) + if (last !== undefined && last.fg === fg && last.bg === bg) last.text += text + else runs.push({ text, fg, bg }) + } + for (let x = 0; x < this.width; x++) { + if (fits && y >= petY && y < petY + petRows && x >= petX && x < petX + petCols) { + const cell = pet![y - petY]![x - petX]! + put('▀', cell.fg, cell.bg) + continue + } + const halo = + fits && + y >= petY - 1 && + y <= petY + petRows && + x >= petX - 1 && + x <= petX + petCols + const dots = halo ? 0 : bits[y]![x]! + if (dots !== 0) { + let lit = 0 + for (let bit = dots; bit > 0; bit >>= 1) lit += bit & 1 + put(String.fromCodePoint(BRAILLE_BASE + dots), mix(accent.dim, accent.bright, lit / 8)) + continue + } + const star = halo ? undefined : starAt.get(y * this.width + x) + if (star === undefined) { + put(' ', INK.dim) + continue + } + // A slow triangle-wave twinkle, phase-shifted per star. + const beat = (tick / 48 + star.phase) % 1 + const twinkle = beat < 0.5 ? beat * 2 : (1 - beat) * 2 + const ink = + star.glyph === '✦' + ? mix(accent.dim, accent.bright, twinkle) + : mix(INK.dim, INK.text, twinkle * 0.6) + put(star.glyph, ink) + } + rows.push(runs) + } + return rows + } +} diff --git a/tui/src/palette.ts b/tui/src/palette.ts index cdbad88..30e2f40 100644 --- a/tui/src/palette.ts +++ b/tui/src/palette.ts @@ -14,25 +14,29 @@ export type Accent = { bright: string } -// Deep warm ink, constant across scenes — the room the accents light up. +// The quiet-constellation ground (§6.1 art direction): a deep blue-charcoal +// night sky, constant across scenes, with warm paper ink floating on it. export const INK = { - bg: '#161310', - dim: '#7d7166', - text: '#e8ded2', - user: '#9fc3a8', - notice: '#8d8378', + bg: '#0d0f16', + dim: '#565c6b', + text: '#d9d2c4', + user: '#c9a878', + notice: '#6e7280', } -const DEFAULT_ACCENT: Accent = { dim: '#8a5a3c', bright: '#f2c078' } +const DEFAULT_ACCENT: Accent = { dim: '#68625a', bright: '#d9c9a8' } // Scene keys are spec 04's (`morning` | `afternoon` | `evening` | `late-night`), // but this is the far side of a wire: an unknown scene from a newer engine gets // the default rather than a missing color. +// +// The §6.1 discipline: each scene is ONE near-monochrome family on the shared +// night ground — the hour changes the warmth of the light, never the room. const ACCENTS: Record = { - morning: { dim: '#7c6a3f', bright: '#ffd98a' }, - afternoon: { dim: '#6f6a4a', bright: '#e8dfa8' }, - evening: { dim: '#8a4a3c', bright: '#f29a6b' }, - 'late-night': { dim: '#3f4a7c', bright: '#9fb4f2' }, + morning: { dim: '#6e6656', bright: '#e8d8b0' }, + afternoon: { dim: '#67705f', bright: '#cfd9b8' }, + evening: { dim: '#7c5f4e', bright: '#e5b98c' }, + 'late-night': { dim: '#49527a', bright: '#a9b9e8' }, } export function accentFor(scene: string | undefined): Accent {