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)