Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 30 additions & 18 deletions specs/spec10/10-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
122 changes: 48 additions & 74 deletions test/tui-constellation.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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) => {
Expand All @@ -54,45 +53,45 @@ 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)
})

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)
})
Expand All @@ -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)
Expand All @@ -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),
Expand All @@ -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)
})
Expand All @@ -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() !== '')
Expand All @@ -151,16 +150,16 @@ 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)
expect(creamCells(dozing)).toBe(0)
})

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)
Expand All @@ -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++) {
Expand All @@ -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)
})
})
Loading
Loading