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)