From 4834d98cb8b589c77eb2f602581f9f34677d8544 Mon Sep 17 00:00:00 2001 From: Zach Guo Date: Wed, 12 Aug 2026 17:07:28 +0800 Subject: [PATCH] feat(tui): stack the sky over the log at 2:1, retire the starfield [spec 10] The wide composition rearranges: the scene band (radial wave + raster figure + now-playing) spans the frame's top and the program log sits beneath it at scene:log ~= 2:1 (sceneSplit, log floored at six rows) -- the listener is here for the radio, not the transcript. While a spotlight card or the settings pane is up, the band steps off the stage so short terminals keep every interactive row. The starfield goes: at character resolution the scatter read as noise, so the night stays empty and the wave keeps its full particle treatment (arc bases, heat tiers, frayed tips, ghost echoes) untouched. Narrow terminals keep the classic bottom band unchanged. Co-Authored-By: Claude Fable 5 --- specs/spec10/10-tui.md | 48 +++++---- test/tui-constellation.test.ts | 122 +++++++++-------------- tui/src/app.tsx | 177 +++++++++++++++++++-------------- tui/src/constellation.ts | 122 +++-------------------- 4 files changed, 196 insertions(+), 273 deletions(-) diff --git a/specs/spec10/10-tui.md b/specs/spec10/10-tui.md index 6f2c71b..a813841 100644 --- a/specs/spec10/10-tui.md +++ b/specs/spec10/10-tui.md @@ -8,7 +8,8 @@ > (engine FFT tap + `viz` feed, §3.6) and slice 3 (pet substrate + warmth kit, > §3.7) landed 2026-07-30. The §6.1 art-direction session chose and landed the > **quiet-constellation** composition (2026-08-07): the sampled palette, the -> centered wide-terminal sky panel (§3.3), the octant sub-pixel rendering of +> centered wide-terminal sky (§3.3 — recomposed 2026-08-12 as a stacked +> scene-over-log frame, starfield retired), the octant sub-pixel rendering of > the viz feed (§3.6), and the raster whisper-figure (§3.7) — contracts > untouched. What remains open is sensory: the §5.11 human pass and the > by-eye tuning of the new skin (issue #79). @@ -420,18 +421,29 @@ focus, and the broadcast never pauses under it. (`tui/src/logo.ts`, half-block letters + tagline) opens every program log — the first thing a boot shows, scrolled away by the program itself. -**As built (2026-08-07, §6.1 quiet-constellation): composition has one -breakpoint, and one max width.** 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 and the newest broadcast line carrying a bullet, -the panel takes the right — a seeded ring-biased starfield, the visualizer's -bins as a radial wave riding an implied circle (§3.6), the whisper-figure at -the circle's center (§3.7), and now-playing as a centered tricolor `♪` line -under the panel. The strip becomes one centered line over a full-width rule. +**As built (2026-08-12, stacked recomposition — supersedes the 2026-08-07 +side-panel): composition has one breakpoint, one max width, and one vertical +split.** At ≥ 96 columns the alive band recomposes as a full-width **scene +band over the log**: the sky spans the frame's top — the visualizer's bins as +a radial wave riding an implied circle (§3.6) with the whisper-figure at the +circle's center (§3.7) over an otherwise empty night — with now-playing as a +centered tricolor `♪` line under the band, and the program log beneath at +scene:log ≈ 2:1 (`sceneSplit`: the log takes a third of the usable rows, +floored at six — the listener is here for the radio, not the transcript). +The log keeps one blank line between entries and the newest broadcast line +carrying a bullet. The strip is one centered line over a full-width rule. Past 184 columns the whole frame centers with symmetric margins instead of stretching. 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. +the composition moves, which is exactly the §6.1 licence. **Overlays and the +band**: the settings pane reclaims the band's rows outright (a mode the +listener opened is their own full attention); a spotlight card keeps the sky +on stage dimmed per §3.2-B, and the band steps off only when the card's top +row (the renderer's own math, `cardTopRow`) would climb into the scene — the +short-terminal case where both cannot fit. **The starfield is +retired** (2026-08-12 design session): at character resolution the scatter +read as noise, so the night behind the wave and the figure stays empty and +the scene's texture budget goes to the wave alone. **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 @@ -501,14 +513,14 @@ only the client — one shutdown path. wave is dashed columns of square blocks whose **bases ride the lower arc of one implied circle** (deep at the center, shallow at the arms), three temperatures by level (peach-ember peaks / cream mids / warm-grey quiet), - fraying tips, ghost echoes past the tip. The starfield is concentric - **ripple rings** off the same circle — quasi-regular arc spacing, jittered, - gap-broken, near-dark — under a thin free scatter with rare warm accents. - A near-covered cell holding exactly two inks keeps both (majority ink on - the glyph, the other behind it). The sky paints on the client's own 12fps - clock and viz frames only feed the smoother, so stars twinkle and the - figure animates even when the engine is silent. All of it is client-side - arithmetic (`tui/src/constellation.ts`); the feed contract is untouched. + fraying tips, ghost echoes past the tip — over an otherwise **empty night** + (the 2026-08-12 stacked recomposition retired the starfield: character-cell + scatter read as noise, §3.3). A near-covered cell holding exactly two inks + keeps both (majority ink on the glyph, the other behind it). The sky paints + on the client's own 12fps clock and viz frames only feed the smoother, so + the figure 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) diff --git a/test/tui-constellation.test.ts b/test/tui-constellation.test.ts index 3dff5f4..d604b83 100644 --- a/test/tui-constellation.test.ts +++ b/test/tui-constellation.test.ts @@ -1,22 +1,21 @@ -// The constellation panel's client half (spec 10 §3.6 / §6.1): the seeded -// ring-biased starfield, the FFT bins as a radial wave of square blocks, and -// the whisper figure at the circle's center. +// The constellation panel's client half (spec 10 §3.6 / §6.1): the FFT bins +// as a radial wave of square blocks riding the implied circle, and the whisper +// figure at the circle's center. The night behind them is empty on purpose — +// character-cell scatter reads as noise, so the sky is the wave and the +// figure alone. // // 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 pixels for this -// level" can — and determinism (seeded stars, hashed jitter) is itself part of -// the contract, because a resize must not reshuffle the sky. +// level" can — and determinism (hashed jitter only) is itself part of the +// contract, because a re-render must not reshuffle the frame. import { describe, expect, it } from 'vitest' import { - circleOf, Constellation, hash01, OCTANTS, - panelWidth, - seedStars, - STAR_RINGS, + sceneSplit, waveBinAt, WIDE_MIN, } from '../tui/src/constellation.ts' @@ -41,7 +40,7 @@ function litCells(rows: Run[][]): number { } // Per-column deepest lit cell row, over cells NOT present in the silence -// frame — which isolates the wave from the stars. +// frame (blank on a starless night; kept as a guard). function waveDepth(rows: Run[][], silence: Run[][], fromCol: number, toCol: number): number { let deepest = -1 rows.forEach((row, y) => { @@ -54,9 +53,9 @@ function waveDepth(rows: Run[][], silence: Run[][], fromCol: number, toCol: numb return deepest } -describe('Constellation (§6.1: ring of stars, radial wave, square pixels)', () => { +describe('Constellation (§6.1: radial wave and figure over an empty night)', () => { it('renders exactly height rows of exactly width cells', () => { - const sky = new Constellation(40, 12, 7) + const sky = new Constellation(40, 12) 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) @@ -64,35 +63,35 @@ describe('Constellation (§6.1: ring of stars, radial wave, square pixels)', () it('paints with the octant pen by default — every glyph from the mosaic table', () => { const allowed = new Set([...OCTANTS, ' ']) - const rows = new Constellation(40, 12, 7).frame([1, 0.5, 1], ACCENT, null) + const rows = new Constellation(40, 12).frame([1, 0.5, 1], ACCENT, null) for (const char of rows.flatMap((row) => [...rowText(row)])) { expect(allowed.has(char), char).toBe(true) } }) it('falls back to the universal half-block pen on request', () => { - const rows = new Constellation(40, 12, 7, 'half').frame([1, 0.5, 1], ACCENT, null) + const rows = new Constellation(40, 12, 'half').frame([1, 0.5, 1], ACCENT, null) for (const char of rows.flatMap((row) => [...rowText(row)])) { expect([' ', '▀']).toContain(char) } }) - 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) + it('paints the same frame twice for the same inputs — a re-render must not reshuffle', () => { + const a = new Constellation(40, 12).frame([], ACCENT, null) + const b = new Constellation(40, 12).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('paints an empty night on silence with no figure — no starfield', () => { + const rows = new Constellation(48, 20).frame([], ACCENT, null) + expect(litCells(rows)).toBe(0) }) it('lights more pixels the louder the frame — the wave follows the music', () => { - const silence = litCells(new Constellation(48, 20, 7).frame([], ACCENT, null)) - const quiet = litCells(new Constellation(48, 20, 7).frame([0.3, 0.3, 0.3], ACCENT, null)) - const loud = litCells(new Constellation(48, 20, 7).frame([1, 1, 1], ACCENT, null)) + const silence = litCells(new Constellation(48, 20).frame([], ACCENT, null)) + const quiet = litCells(new Constellation(48, 20).frame([0.3, 0.3, 0.3], ACCENT, null)) + const loud = litCells(new Constellation(48, 20).frame([1, 1, 1], ACCENT, null)) + expect(silence).toBe(0) expect(quiet).toBeGreaterThan(silence) expect(loud).toBeGreaterThan(quiet) }) @@ -107,10 +106,10 @@ describe('Constellation (§6.1: ring of stars, radial wave, square pixels)', () }) it('lights both arms alike on a bass-only frame — the wave grows outward, not left-first', () => { - const silence = new Constellation(48, 24, 7).frame([], ACCENT, null) + const silence = new Constellation(48, 24).frame([], ACCENT, null) // Bass alone: only the center of the arc may rise; the left arm must not // light up ahead of the right the way an edge-anchored mapping does. - const rows = new Constellation(48, 24, 7).frame([1, 0, 0, 0, 0, 0, 0, 0], ACCENT, null) + const rows = new Constellation(48, 24).frame([1, 0, 0, 0, 0, 0, 0, 0], ACCENT, null) const left = waveDepth(rows, silence, 2, 12) const right = waveDepth(rows, silence, 36, 46) const center = waveDepth(rows, silence, 20, 28) @@ -121,8 +120,8 @@ describe('Constellation (§6.1: ring of stars, radial wave, square pixels)', () it('rides the circle: center columns bottom out deeper than the arms', () => { const levels = Array.from({ length: 24 }, () => 0.7) - const silence = new Constellation(48, 24, 7).frame([], ACCENT, null) - const rows = new Constellation(48, 24, 7).frame(levels, ACCENT, null) + const silence = new Constellation(48, 24).frame([], ACCENT, null) + const rows = new Constellation(48, 24).frame(levels, ACCENT, null) const arms = Math.max( waveDepth(rows, silence, 4, 10), waveDepth(rows, silence, 38, 44), @@ -133,7 +132,7 @@ describe('Constellation (§6.1: ring of stars, radial wave, square pixels)', () it('draws the figure at the center in cream ink', () => { const figure = ['xx', 'xx'] - const rows = new Constellation(40, 12, 7).frame([], ACCENT, figure) + const rows = new Constellation(40, 12).frame([], ACCENT, figure) const cream = rows.flat().filter((run) => run.fg === INK.text && run.text.trim() !== '') expect(cream.length).toBeGreaterThan(0) }) @@ -142,7 +141,7 @@ describe('Constellation (§6.1: ring of stars, radial wave, square pixels)', () // 2 sub-cols x 4 sub-rows per cell: an 'xw' column pair over 4 rows fills a // cell with two colors and no gaps — the fold must not flatten it to one. const figure = Array.from({ length: 8 }, () => 'xwxw') - const rows = new Constellation(40, 12, 7).frame([], ACCENT, figure) + const rows = new Constellation(40, 12).frame([], ACCENT, figure) const twoTone = rows .flat() .filter((run) => run.fg === INK.text && run.bg === WARM && run.text.trim() !== '') @@ -151,8 +150,8 @@ describe('Constellation (§6.1: ring of stars, radial wave, square pixels)', () it('fades a dozing figure toward the room instead of swapping assets', () => { const figure = ['xx', 'xx'] - const awake = new Constellation(40, 12, 7).frame([], ACCENT, figure, 0) - const dozing = new Constellation(40, 12, 7).frame([], ACCENT, figure, 0.5) + const awake = new Constellation(40, 12).frame([], ACCENT, figure, 0) + const dozing = new Constellation(40, 12).frame([], ACCENT, figure, 0.5) const creamCells = (rows: Run[][]): number => rows.flat().filter((run) => run.fg === INK.text && run.text.trim() !== '').length expect(creamCells(awake)).toBeGreaterThan(0) @@ -160,7 +159,7 @@ describe('Constellation (§6.1: ring of stars, radial wave, square pixels)', () }) it('survives hostile bins and degenerate panels', () => { - const sky = new Constellation(2, 2, 7) + const sky = new Constellation(2, 2) for (const frame of [[], [Number.NaN], [2, -1], [0.5]]) { const rows = sky.frame(frame, ACCENT, null) expect(rows).toHaveLength(2) @@ -169,38 +168,6 @@ describe('Constellation (§6.1: ring of stars, radial wave, square pixels)', () }) }) -describe('seedStars (§6.1: ripple rings of stars + a loose free scatter)', () => { - const subCols = 200 - const subRows = 240 - const stars = seedStars(subCols, subRows, 7) - const { cx, cy, radius } = circleOf(subCols, subRows) - const offRing = (star: { x: number; y: number }): number => - Math.min(...STAR_RINGS.map((frac) => Math.abs(Math.hypot(star.x - cx, star.y - cy) - radius * frac))) - - it('is deterministic per seed', () => { - expect(seedStars(subCols, subRows, 7)).toEqual(stars) - expect(seedStars(subCols, subRows, 8)).not.toEqual(stars) - }) - - it('lays the majority of stars along the concentric rings', () => { - const onRings = stars.filter((star) => offRing(star) <= 4).length - expect(onRings / stars.length).toBeGreaterThan(0.55) - }) - - it('spreads each ring around the circle instead of clumping', () => { - const octants = new Set( - stars - .filter((star) => offRing(star) <= 4) - .map((star) => Math.floor(((Math.atan2(star.y - cy, star.x - cx) + Math.PI) / (2 * Math.PI)) * 8)), - ) - expect(octants.size).toBeGreaterThanOrEqual(6) - }) - - it('keeps a free scatter clear of the rings — order AND looseness', () => { - expect(stars.filter((star) => offRing(star) > 6).length).toBeGreaterThan(5) - }) -}) - describe('hash01 (the survival dice behind every density knob)', () => { it('stays inside 0..1 — a signed hash passes every threshold and defeats the knobs', () => { for (let x = 0; x < 200; x++) { @@ -213,16 +180,23 @@ describe('hash01 (the survival dice behind every density knob)', () => { }) }) -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() +describe('sceneSplit (the stacked composition: scene over log, 2:1)', () => { + it('gives the scene about two thirds and the log the rest, summing exactly', () => { + for (const rows of [19, 24, 45, 60]) { + const { scene, log } = sceneSplit(rows) + expect(scene + log).toBe(rows) + expect(scene / log).toBeGreaterThanOrEqual(1.8) + expect(scene / log).toBeLessThanOrEqual(2.4) + } + }) + + it('keeps the log readable on short terminals — never below six rows', () => { + for (const rows of [14, 16, 19]) { + expect(sceneSplit(rows).log).toBeGreaterThanOrEqual(6) + } }) - 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(110) + it('keeps the breakpoint: WIDE_MIN still gates the wide composition', () => { + expect(WIDE_MIN).toBe(96) }) }) diff --git a/tui/src/app.tsx b/tui/src/app.tsx index f38b108..174d330 100644 --- a/tui/src/app.tsx +++ b/tui/src/app.tsx @@ -14,7 +14,7 @@ import type { InputRenderable } from '@opentui/core' import type { EngineMessage, ProgramState, SettingsSnapshot } from '../../src/ipc.ts' import { Bars, render } from './bars.ts' import { cardLines, cardTitle, cardTopRow, outbound, type Ask } from './dock.ts' -import { circleOf, Constellation, panelWidth, penFor, type Run } from './constellation.ts' +import { circleOf, Constellation, penFor, sceneSplit, WIDE_MIN, type Run } from './constellation.ts' import { cellSizeFrom, deleteFigures, @@ -164,9 +164,9 @@ function Pet({ pose }: { pose: PoseName }): React.ReactNode { return {rows} } -// 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 wide-terminal sky (§6.1 quiet-constellation): the radial wave and the +// pet floating over the empty night. Painted on its own clock — viz frames +// only feed the smoother, so the sky breathes (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 @@ -185,7 +185,7 @@ function SkyPanel({ pose: PoseName showPet: boolean // The block wave yields to the raster ripple when the graphics channel - // carries the spectrum instead; the stars and the sprite stay either way. + // carries the spectrum instead; the sprite stays either way. charWave: boolean width: number rows: number @@ -194,7 +194,7 @@ function SkyPanel({ const sky = useRef(null) const tick = useRef(0) const [painted, setPainted] = useState([]) - if (sky.current === null) sky.current = new Constellation(width, rows, 1, penFor(process.env)) + if (sky.current === null) sky.current = new Constellation(width, rows, penFor(process.env)) useEffect(() => { sink.current = (bins) => bars.current.push(bins) @@ -370,25 +370,26 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): const hushRef = useRef(hushed) hushRef.current = hushed const pose = greeting !== null ? 'wake' : poseFor(state) - // 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 + // The §6.1 breakpoint: wide terminals stack the sky as a full-width scene + // band over the log (scene:log ≈ 2:1 — the listener is here for the radio, + // not the transcript); narrow ones keep the classic bottom band. Same four // regions either way (§3.3) — only the composition moves. const dims = useTerminalDimensions() // The composition has a max width: past it, a wide terminal gets symmetric - // margins instead of a log pinned to the left edge and a stretched sky. + // margins instead of a stretched scene. const cols = Math.min(dims.width, MAX_COLS) const gutter = Math.floor((dims.width - cols) / 2) - const skyWidth = panelWidth(cols) + const wide = cols >= WIDE_MIN // Where the spotlight card begins, for the raster paint loops: they keep // the stage hushed above this row and yield it below (null = no card). const cardTop = asks.length > 0 ? cardTopRow(asks[0]!.text, cols, dims.height) : null const cardTopRef = useRef(cardTop) cardTopRef.current = cardTop // In the sky composition the strip is one centred line over a full-width - // rule (concept 04), and now-playing lives under the panel; in the band + // rule (concept 04), and now-playing lives under the scene; in the band // composition the strip stays two-sided and carries now-playing itself. const strip = - skyWidth === null + !wide ? [greeting ?? microcopy ?? 'warming up...', state?.nowPlaying] .filter((part) => part !== undefined && part !== '') .join(' ♪ ') @@ -403,9 +404,20 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): // 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, its rule, identity, input, and - // now-playing take theirs. - const skyRows = Math.max(dims.height - 5, 4) + // Rows left once the strip, its rule, now-playing, identity, and input take + // theirs, split scene-over-log at 2:1. The scene spans the frame minus its + // one-cell side padding. + const { scene: sceneRows } = sceneSplit(Math.max(dims.height - 5, 10)) + const sceneWidth = cols - 2 + // Whether the scene band holds the stage. The settings pane always reclaims + // its rows (a mode the listener opened is their own full attention); a + // spotlight card keeps the sky dimmed beside it (§3.2-B: a sky going dark + // under every consent reads as broken) unless the terminal is too short to + // host both — the card's own top row, from the renderer's math, is the + // judge. The paint loops read this through a ref, like the card row. + const sceneShown = wide && !paneOpen && (cardTop === null || cardTop > 3 + sceneRows) + const sceneShownRef = useRef(sceneShown) + sceneShownRef.current = sceneShown // The newest broadcast line carries the bullet (concept 04); older lines // stand back. const latestSegment = entries.findLast((entry) => entry.kind === 'segment')?.id @@ -430,7 +442,7 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): const poseRef = useRef(pose) poseRef.current = pose useEffect(() => { - if (skyWidth === null || figMode !== 'image' || !band.pet) return + if (!wide || figMode !== 'image' || !band.pet) return let loop: ReturnType | undefined const settle = setTimeout(() => { const cell = cellSizeFrom(renderer.resolution, dims.width, dims.height) @@ -451,9 +463,9 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): } const imgCols = Math.ceil((spriteCols * scale) / (cell?.width ?? 8)) const imgRows = Math.ceil((POSES.idle[0]!.length * scale) / (cell?.height ?? 16)) - const centerRow = 2 + circleOf((skyWidth - 1) * 2, skyRows * 4).cy / 4 - const panelLeft = gutter + cols - skyWidth - const col = Math.max(1, Math.round(panelLeft + (skyWidth - 1) / 2 - imgCols / 2) + 1) + const centerRow = 2 + circleOf(sceneWidth * 2, sceneRows * 4).cy / 4 + const sceneLeft = gutter + 1 + const col = Math.max(1, Math.round(sceneLeft + sceneWidth / 2 - imgCols / 2) + 1) const row = Math.max(1, Math.round(centerRow - imgRows / 2) + 1) // Retransmitting under one id replaces the frame in place — the pose // loop is a stream of tiny PNGs, each pose advancing at its own rate @@ -461,7 +473,9 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): const started = performance.now() let shown = '' const paint = (): void => { - const plan = stagePlan(hushRef.current, cardTopRef.current, row, imgRows) + const plan = sceneShownRef.current + ? stagePlan(hushRef.current, cardTopRef.current, row, imgRows) + : 'off' if (plan === 'off') { if (shown !== '') { shown = '' @@ -485,7 +499,7 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): clearInterval(loop) rawOut.writeOut(deleteFigures()) } - }, [skyWidth, cols, gutter, skyRows, figMode, band.pet, renderer, dims.width, dims.height]) + }, [wide, cols, gutter, sceneRows, sceneWidth, figMode, band.pet, renderer, dims.width, dims.height]) // The grain-ripple wave (§6.1): on a kitty-graphics terminal the spectrum // rides the same channel as the figure — stardust ripples in device pixels @@ -499,7 +513,7 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): // scale), so it keeps the character wave instead. const [rasterWave, setRasterWave] = useState(false) useEffect(() => { - if (skyWidth === null || figMode !== 'image') return + if (!wide || figMode !== 'image') return let loop: ReturnType | undefined let tick = 0 let wasSilent = false @@ -507,13 +521,15 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): const cell = cellSizeFrom(renderer.resolution, dims.width, dims.height) if (cell === null) return setRasterWave(true) - const geom = waveGeomFor(skyWidth - 1, skyRows, cell) - const col = gutter + cols - skyWidth + 1 + const geom = waveGeomFor(sceneWidth, sceneRows, cell) + const col = gutter + 2 const row = 3 let hidden = false const paint = (): void => { const hushedNow = hushRef.current - const rows = waveRowsFor(hushedNow, cardTopRef.current, row, skyRows) + const rows = sceneShownRef.current + ? waveRowsFor(hushedNow, cardTopRef.current, row, sceneRows) + : 0 if (rows === 0) { if (!hidden) { hidden = true @@ -530,7 +546,7 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): wasSilent = silent // The clipped geometry ends the raster above the card; the hushed // color is the room's own step down, so the ripple dims with it. - const g = rows === skyRows ? geom : waveGeomFor(skyWidth - 1, rows, cell) + const g = rows === sceneRows ? geom : waveGeomFor(sceneWidth, rows, cell) const bright = hushedNow ? hush(accentRef.current.bright) : accentRef.current.bright rawOut.writeOut(placeFigure(encodeWavePng(levels, tick++, g, bright), row, col, 2, 0)) } @@ -545,7 +561,7 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): // repaints, and a stray delete self-heals within one 12fps beat. rawOut.writeOut(deleteFigures()) } - }, [skyWidth, cols, gutter, skyRows, figMode, renderer, dims.width, dims.height]) + }, [wide, cols, gutter, sceneRows, sceneWidth, figMode, renderer, dims.width, dims.height]) return ( - {skyWidth === null ? ( + {!wide ? ( )} + {/* The scene band (§3.3 stacked): the sky spans the frame with the log + beneath it — the radio's face first, its words second. Now-playing + sits under the scene as the §6.1 centred tricolor line. The band + steps off the stage only when sceneShown says its rows belong to an + overlay instead. */} + {sceneShown && ( + + + + {state?.nowPlaying !== undefined && state.nowPlaying !== '' ? ( + (() => { + const split = splitNowPlaying(state.nowPlaying) + return ( + + {'♪ '} + {split !== null ? ( + <> + {split.head} + {' —— '} + {split.rest} + + ) : ( + {state.nowPlaying} + )} + + ) + })() + ) : ( + + )} + + + )} + {items !== null && settings !== null ? ( @@ -615,7 +685,7 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): stickyStart="bottom" style={{ flexGrow: 1, - paddingLeft: skyWidth === null ? 1 : 2, + paddingLeft: !wide ? 1 : 2, paddingRight: 1, rootOptions: { backgroundColor: INK.bg }, }} @@ -644,7 +714,7 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): // entries, the poem spacing of §6.1, no icon markers (the speaker // lives in the color), the newest broadcast line carrying a bullet. // The band composition stays dense with its marker column. - + - {skyWidth === null + {!wide ? MARKER[entry.kind] : entry.id === latestSegment ? '● ' @@ -670,48 +740,9 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): )} - {skyWidth !== null && ( - - - {/* Centred, in the concept's colors: violet note, ember artist, - cool title. A title without a dash stays one cool phrase. */} - - {state?.nowPlaying !== undefined && state.nowPlaying !== '' ? ( - (() => { - const split = splitNowPlaying(state.nowPlaying) - return ( - - {'♪ '} - {split !== null ? ( - <> - {split.head} - {' —— '} - {split.rest} - - ) : ( - {state.nowPlaying} - )} - - ) - })() - ) : ( - - )} - - - )} - {skyWidth === null && ( + {!wide && ( {band.pet && } @@ -843,7 +874,7 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): // The sky composition bounds the field and lets a quiet rule carry // the rest of the row (concept 04's input line); long input scrolls // inside the field. The band composition keeps the full width. - ...(skyWidth === null ? { flexGrow: 1 } : { width: Math.min(56, cols - 8) }), + ...(!wide ? { flexGrow: 1 } : { width: Math.min(56, cols - 8) }), textColor: PERIWINKLE, placeholderColor: mix(PERIWINKLE, INK.bg, 0.4), backgroundColor: INK.bg, @@ -853,7 +884,7 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): // Textarea's event-shaped signature on top of it (upstream, 0.4.5). onSubmit={submit as InputProps['onSubmit']} /> - {skyWidth !== null && ( + {wide && ( {'─'.repeat(cols)} diff --git a/tui/src/constellation.ts b/tui/src/constellation.ts index c619003..0646dda 100644 --- a/tui/src/constellation.ts +++ b/tui/src/constellation.ts @@ -1,7 +1,8 @@ -// The quiet-constellation panel (spec 10 §6.1): one big implied circle — a -// starfield with a faint ring bias, the FFT bins as columns of small square -// blocks whose bases ride the circle's lower arc, and the whisper figure -// floating at the circle's center. +// The quiet-constellation scene (spec 10 §6.1): one big implied circle — the +// FFT bins as columns of small square blocks whose bases ride the circle's +// lower arc, and the whisper figure floating at the circle's center. The night +// behind them stays empty: at character resolution a scattered starfield reads +// as noise, so the scene spends its texture budget on the wave alone. // // Everything is drawn on ONE canvas of square sub-pixels — two per character // width, four per character height — and folded to glyphs by the chosen pen: @@ -11,7 +12,7 @@ // // 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. +// is hashed — the sky must survive a re-render without reshuffling. import { EMBER, INK, mix, QUIET, WARM, type Accent } from './palette.ts' import type { Sprite } from './pet.ts' @@ -39,14 +40,16 @@ export function penFor(env: NodeJS.ProcessEnv): Pen { return 'half' } -// Below this many columns the two-column composition has no room to breathe and +// Below this many columns the stacked 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 ring still reads. -export function panelWidth(cols: number): number | null { - if (cols < WIDE_MIN) return null - return Math.min(Math.max(Math.round(cols * 0.45), 32), 110) +// The stacked composition's vertical split (§3.3): the scene band takes about +// two thirds of the usable rows, the program log the rest — with a floor so a +// short terminal never starves the log below readability. +export function sceneSplit(rows: number): { scene: number; log: number } { + const log = Math.max(Math.round(rows / 3), 6) + return { scene: Math.max(rows - log, 0), log } } // Where the circle sits and how far it reaches. By-eye knobs, all of them. @@ -66,29 +69,9 @@ const WARM_LEVEL = 0.3 const GHOST_ROWS = 4 const GHOST_DENSITY = 0.25 -// The starfield (concept 04): concentric ripple rings expanding from the -// circle's center — quasi-regular arc spacing with jitter and gaps — plus a -// thin free scatter, so the sky reads ordered AND loose at once. -export const STAR_RINGS: readonly number[] = [0.5, 0.72, 0.94, 1.16, 1.38] -const RING_ARC_STEP = 8 -const RING_SURVIVAL = 0.68 -const RING_JITTER = 1.6 -const SCATTER_DENSITY = 0.0012 -const BIG_SHARE = 0.12 - // The wave shimmers slower than the 24fps feed, or it reads as static noise. const SHIMMER_DIVISOR = 4 -// 'faint' is the ring ink: the ripples sit BEHIND the free scatter, close to -// the room's dark, or they read as noise instead of order. -export type Star = { - x: number - y: number - ink: 'faint' | 'cool' | 'cream' | 'ember' - big: boolean - phase: number -} - // The one implied circle everything hangs off — the wave's arc, the figure's // seat, and the star rings all share it. export function circleOf( @@ -100,49 +83,6 @@ export function circleOf( return { cx, cy, radius: Math.min(cx, subRows - cy) * RADIUS } } -export function seedStars(subCols: number, subRows: number, seed: number): Star[] { - const rand = prng(seed) - const { cx, cy, radius } = circleOf(subCols, subRows) - const stars: Star[] = [] - for (const frac of STAR_RINGS) { - const r = radius * frac - const count = Math.max(Math.round((2 * Math.PI * r) / RING_ARC_STEP), 6) - for (let i = 0; i < count; i++) { - // Every slot draws its dice even when skipped, so one gap never - // reshuffles the rest of the ring. - const keep = rand() < RING_SURVIVAL - const angle = ((i + (rand() - 0.5) * 0.9) / count) * Math.PI * 2 - const rr = r + (rand() - 0.5) * 2 * RING_JITTER - const inkRoll = rand() - const phase = rand() - if (!keep) continue - const x = Math.round(cx + Math.cos(angle) * rr) - const y = Math.round(cy + Math.sin(angle) * rr) - if (x < 0 || x >= subCols || y < 0 || y >= subRows) continue - // Ring stars are always single sub-pixels, near-dark: the ripple is a - // texture, not a set of landmarks. - stars.push({ - x, - y, - ink: inkRoll < 0.86 ? 'faint' : inkRoll < 0.96 ? 'cool' : 'cream', - big: false, - phase, - }) - } - } - const scatter = Math.round(subCols * subRows * SCATTER_DENSITY) - for (let i = 0; i < scatter; i++) { - const x = Math.floor(rand() * subCols) - const y = Math.floor(rand() * subRows) - const roll = rand() - const bigRoll = rand() - const ink = roll < 0.55 ? 'cool' : roll < 0.85 ? 'cream' : 'ember' - // Only the warm accents earn 2x2: big grey blocks read as noise. - stars.push({ x, y, ink, big: ink !== 'cool' && bigRoll < BIG_SHARE, phase: rand() }) - } - return stars -} - function clamp01(value: number): number { return Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0 } @@ -154,18 +94,6 @@ export function waveBinAt(span: number, count: number): number { return Math.min(Math.floor(Math.abs(span) * count), count - 1) } -// 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-block jitter: same block, same tick bucket, same verdict. // Exported for its regression test: the final XOR must be forced back to // unsigned, or half of all hashes come out negative and pass any threshold. @@ -176,19 +104,17 @@ export function hash01(x: number, y: number, tick: number): number { } export class Constellation { - private readonly stars: Star[] private readonly width: number private readonly subCols: number private readonly subRows: number private readonly pen: Pen private tick = 0 - constructor(width: number, height: number, seed = 1, pen: Pen = 'octant') { + constructor(width: number, height: number, pen: Pen = 'octant') { this.width = width this.subCols = width * 2 this.subRows = height * 4 this.pen = pen - this.stars = seedStars(this.subCols, this.subRows, seed) } // One painted frame: levels are the engine's 0..1 bins (empty = silence), @@ -209,26 +135,6 @@ export class Constellation { if (x >= 0 && x < this.subCols && y >= 0 && y < this.subRows) canvas[y]![x] = ink } - // Stars first — the wave and the figure may cover them. - for (const star of this.stars) { - const beat = (tick / 48 + star.phase) % 1 - const twinkle = beat < 0.5 ? beat * 2 : (1 - beat) * 2 - const ink = - star.ink === 'ember' - ? mix(QUIET, EMBER, 0.35 + twinkle * 0.65) - : star.ink === 'cream' - ? mix(QUIET, INK.text, 0.3 + twinkle * 0.7) - : star.ink === 'cool' - ? mix(INK.bg, QUIET, 0.45 + twinkle * 0.55) - : mix(INK.bg, QUIET, 0.34 + twinkle * 0.3) - paint(star.x, star.y, ink) - if (star.big) { - paint(star.x + 1, star.y, ink) - paint(star.x, star.y + 1, ink) - paint(star.x + 1, star.y + 1, ink) - } - } - // The wave: dashed columns of square blocks riding the circle's lower arc, // every other sub-column dark so the columns keep air between them. const { cx, cy, radius } = circleOf(this.subCols, this.subRows)