From a2cd68bcd865c3fc61513d0b3794dd857613ee49 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Thu, 15 Oct 2026 16:03:07 +0100 Subject: [PATCH 1/9] Surf: the barrel draws again (the pixel loop's own lip flag, renamed onLip, had hidden the wave's lip since curl was renamed, so the tube, the lit lip and its shadow never drew) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KqZeF1bhJidhjtVDZGBLgJ --- hooks/surf.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hooks/surf.ts b/hooks/surf.ts index 069ddc9..81a651b 100644 --- a/hooks/surf.ts +++ b/hooks/surf.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v173-directory +// REVISION: flow-v184-surf-barrel // // Surf (the `surf` style): a surfer and the ocean on the same dials as the // fire; the level is the swell. At 1 the sea is glassy under a dawn sky and @@ -767,7 +767,7 @@ export class Surf { // Water. let wet = clamp(y + 1 - surf) let inTube = false - let lip = 0 + let onLip = false if (lip > 0 && isMain && Math.abs(xc - this.ex) < this.rx + 1 && yc < seaY + 1) { const dx = (xc - this.ex) / this.rx const dy = (yc - this.ey) / this.ry @@ -779,11 +779,11 @@ export class Surf { const sa = Math.sin((phi * Math.PI) / 180) const R = Math.hypot(this.rx * ca, 2 * this.ry * sa) const th = ((vertical ? 5 : 2.6) * (1 - 0.45 * prog) * (0.6 + 0.4 * lip)) / R - if (phi <= this.phiEnd && r > 1 - th && r < 1 && (yc < surf || phi > -140)) lip = 1 + if (phi <= this.phiEnd && r > 1 - th && r < 1 && (yc < surf || phi > -140)) onLip = true else if (yc < surf && r < 1) inTube = true } else if (yc >= hood) wet = 1 } else if (yc >= hood && yc < surf) wet = clamp(y + 1 - hood) - if (lip) { + if (onLip) { // The lip: lit through, pale green-blue; frothing at the tip. const dyc = (yc - this.ey) / this.ry c = mix(P.faceLip, P.foam, clamp(0.25 + dyc * 0.5) * 0.7) From 3c117c83e8b8a918bcce1d567b56473aa51a2b85 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Thu, 15 Oct 2026 16:06:43 +0100 Subject: [PATCH 2/9] opencode: the view's colour cache is bounded (waiting's breath, the night and tints easing make new colours every frame: earthrise reached 55,000 in a quarter of an hour) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KqZeF1bhJidhjtVDZGBLgJ --- AGENTS.md | 1 + opencode/colors.ts | 24 ++++++++++++++++++++++++ opencode/view.ts | 12 +++--------- tests/flow.test.ts | 20 +++++++++++++++++++- 4 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 opencode/colors.ts diff --git a/AGENTS.md b/AGENTS.md index acb1ef4..62b51cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,7 @@ opencode/ settings in its storage, the soundscape) mapping.ts opencode's events in the heat model's terms, its subagents (child sessions) on the roster (pure) view.ts the scene as an OpenTUI renderable painting its cells, on the running opencode's own classes + colors.ts the view's colours, made once while in use and bounded (pure) types.ts opentui.d.ts what Flow uses of opencode's and OpenTUI's API, declared (nothing installed) node/ player.ts the soundscape's player where the host has none (opencode, pi): afplay run in the background diff --git a/opencode/colors.ts b/opencode/colors.ts new file mode 100644 index 0000000..6f15bc4 --- /dev/null +++ b/opencode/colors.ts @@ -0,0 +1,24 @@ +// REVISION: flow-v1-color-cache +// +// The view's colours as OpenTUI has them, made once while in use (pure: the +// view hands it how to make one, from the running opencode's own RGBA). + +/** How many colours `colorCache` keeps before it starts afresh. */ +export const COLOR_CACHE_MAX = 4096 + +/** + * Each colour as OpenTUI has it, made once and kept while it's in use. Most frames repeat the last one's + * colours, but waiting's breath, the night and a tint easing in make new ones every frame, without end + * (earthrise reaches tens of thousands in a quarter of an hour), so past `max` it starts afresh. + */ +export function colorCache(make: (c: number) => T, max = COLOR_CACHE_MAX): (c: number) => T { + const kept = new Map() + return c => { + let v = kept.get(c) + if (v === undefined) { + if (kept.size >= max) kept.clear() + kept.set(c, (v = make(c))) + } + return v + } +} diff --git a/opencode/view.ts b/opencode/view.ts index 7eecd7e..d8d02c9 100644 --- a/opencode/view.ts +++ b/opencode/view.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v2-opencode-cards +// REVISION: flow-v3-color-cache // // A scene in opencode's terminal UI: an OpenTUI renderable as wide as its // place (above the prompt, or the sidebar) and as tall as Flow asks, that @@ -15,6 +15,7 @@ import type { OptimizedBuffer, RenderContext, Renderable, RGBA } from '@opentui/core' import { type Cells, DEFAULT_COLOR } from '../hooks/cells' +import { colorCache } from './colors' /** A scene's frame at a size, or none to draw nothing. */ export type FrameAt = (columns: number, rows: number) => Cells | undefined @@ -44,14 +45,7 @@ export function sceneViewClass(root: Renderable, theme: unknown): SceneViewClass const Rgba = rgbaIn(theme) if (!Rgba) throw new Error("flow: opencode's theme has no colours to draw with") const clear = Rgba.fromInts(0, 0, 0, 0) - /** Each colour as OpenTUI has it, made once (a scene uses a few hundred at most). */ - const colors = new Map() - const color = (c: number): RGBA => { - if (c === DEFAULT_COLOR) return clear - let rgba = colors.get(c) - if (!rgba) colors.set(c, (rgba = Rgba.fromInts((c >> 16) & 255, (c >> 8) & 255, c & 255, 255))) - return rgba - } + const color = colorCache(c => (c === DEFAULT_COLOR ? clear : Rgba.fromInts((c >> 16) & 255, (c >> 8) & 255, c & 255, 255))) return class extends Base { constructor( diff --git a/tests/flow.test.ts b/tests/flow.test.ts index ffc879c..a4dc0e7 100644 --- a/tests/flow.test.ts +++ b/tests/flow.test.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v183-pi-sound +// REVISION: flow-v185-hygiene-bugs import type { EngineInterface, On } from 'claude-code' import { expect, mock, test } from 'claude-code/testing' @@ -36,6 +36,7 @@ import { rampAt, rampStops, retain } from '../hooks/pixels' import { freshSeed } from '../hooks/cells' import { type Player, Soundscape } from '../hooks/soundscape' import { effortOf as ocEffortOf, linesWritten as ocLinesWritten, OpenCodeWork } from '../opencode/mapping' +import { colorCache } from '../opencode/colors' /** A test that walks every scene (each at several sizes or levels): a shared CI runner can take several times a laptop's 5 s default. */ const SLOW_MS = 30_000 @@ -4475,6 +4476,23 @@ test("opencode: a subagent's session deleted before its run ends is gone, not le expect(a.roster.dials()[0]!.ok).toBe(false) }) +test("opencode: each colour is made once while in use, but what's kept stays bounded however many a scene goes through", async () => { + let made = 0 + const color = colorCache(c => ({ c, n: made++ }), 8) + const first = color(0x102030) + expect(color(0x102030)).toBe(first) + expect(made).toBe(1) + // A breathing wait: a new colour every frame, without end. What's kept never passes the bound. + for (let c = 0; c < 1000; c++) color(c) + expect(made).toBe(1001) + const again = color(999) + expect(made).toBe(1001) + expect(again.c).toBe(999) + // The first went long ago (it started afresh past the bound): made again. + expect(color(0x102030)).not.toBe(first) + expect(made).toBe(1002) +}) + test('a host older than Flow is built against is told so (it still runs); as new, newer or unreadable, nothing', async () => { expect(olderThan('2.1.280', '2.1.289')).toBe(true) expect(olderThan('2.1.289', '2.1.289')).toBe(false) From 02bffd82ac584c72155e1fc9b43b4dcb0fd452cb Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Thu, 15 Oct 2026 16:06:43 +0100 Subject: [PATCH 3/9] opencode: a frame is made once a step, not on every redraw (each key and streamed word redrew it: up to 1.5 ms each, and engine and the rockets, which flicker at random as they draw, flickered faster) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KqZeF1bhJidhjtVDZGBLgJ --- opencode/tui.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/opencode/tui.ts b/opencode/tui.ts index 79978b1..a6c259f 100644 --- a/opencode/tui.ts +++ b/opencode/tui.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v5-shared-player +// REVISION: flow-v6-hygiene // // Flow for opencode (2.0), by Rob Macrae: the same ambient scenes as the // Claude Code mod and the pi extension, in opencode's terminal UI. A terminal @@ -31,7 +31,8 @@ import { staleSessions } from '../hooks/sessions' import { firstTips, type FlowConfig, type Host, parseFlowArgs, readConfig, replyTo, storedValue, tipChance, type Tips, versionNote } from '../hooks/settings' import { SceneDriver } from '../hooks/scene' import { Soundscape } from '../hooks/soundscape' -import { SCENES } from '../hooks/styles' +import { type Scene, SCENES } from '../hooks/styles' +import type { Cells } from '../hooks/cells' import { FLOW_ENTRY } from '../pi/mapping' import { PiSettings, type SessionEntries } from '../pi/session' import { OpenCodeWork } from './mapping' @@ -157,12 +158,20 @@ function setup(ctx: OcContext): () => void { return driver.scene.taller ? Math.max(ROWS, Math.min(TALL_ROWS, Math.floor(terminal * TALL_SHARE))) : ROWS } - /** The scene at a view's size, stepped by the frame loop: the view only draws it. */ + /** + * The scene at a view's size, stepped by the frame loop: the view only draws it. opencode redraws far more + * often than a frame (each key, each streamed word), so a frame is made once a step and drawn as often as + * asked; made again on every redraw it would cost a frame's work each time, and the scenes that flicker at + * random as they draw (engine, the rockets) would flicker faster. + */ + let made: { scene: Scene; cells: Cells } | undefined const frameAt = (columns: number, rows: number) => { const f = driver.scene if (!driver.isShown()) return undefined + if (made?.scene === f && made.cells.columns === columns && made.cells.rows === rows) return made.cells f.ensure(columns, rows) - return f.grid() + made = { scene: f, cells: f.grid() } + return made.cells } /** * A press on the scene: a companion there (or a cell beside it: they're small) shows its subagent's card, as @@ -257,6 +266,7 @@ function setup(ctx: OcContext): () => void { if (live) { f.ensure(live.width, live.height) f.step() + made = undefined live.requestRender() } sound.step(elapsed, { From 408c69b8932407ef3ea57cd0ff69588f09e9e652 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Thu, 15 Oct 2026 16:10:13 +0100 Subject: [PATCH 4/9] pi: the quit listener is held only while a session is (each /reload left one behind, keeping the old Flow: 13 after 12 reloads), and a picture being made stops at shutdown Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KqZeF1bhJidhjtVDZGBLgJ --- pi/index.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pi/index.ts b/pi/index.ts index e58d533..b357eec 100644 --- a/pi/index.ts +++ b/pi/index.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v177-pi-sound +// REVISION: flow-v185-pi-reload // // Flow for pi (badlogic/pi-mono), by Rob Macrae: the same ambient // scenes as the Claude Code mod, in a widget above pi's editor. pi's events @@ -418,8 +418,13 @@ export default function flow(pi: PiApi) { if (timer) clearTimeout(timer) timer = undefined } + // pi quitting: the clips playing stop with it (else each would play out, up to a bed's 14 s). Held only while a + // session is: /reload loads Flow afresh in the same process, so a listener left behind would keep the old one. + const quitting = () => sound.stop() pi.on('session_start', async (_e, ctx) => { + process.off('exit', quitting) + process.once('exit', quitting) // A new session starts on the defaults; a resumed, forked or reloaded one on its own over them. (In every // mode: `/flow` runs in any, and compares with the defaults as they're kept.) await settings.open(entriesOf(ctx)) @@ -456,6 +461,9 @@ export default function flow(pi: PiApi) { pi.on('session_shutdown', (_e, ctx) => { stop() sound.stop() + process.off('exit', quitting) + // A picture being made a slice at a time stops with it. + making = undefined if (isMounted && ctx.hasUI) ctx.ui.setWidget(KEY, undefined) isMounted = false ctxRef = undefined @@ -570,8 +578,6 @@ export default function flow(pi: PiApi) { // (pi shows a reply as it is: no `!` before what was wrong.) ctx.ui.notify(say((cmd.kind === 'error' ? reply.text.replace(/^! /, '') : reply.text) + (tip ? `\n\n${tip}` : '')), reply.level) } - // pi quitting: the clips playing stop with it (else each would play out, up to a bed's 14 s). - process.once('exit', () => sound.stop()) pi.registerCommand('flow', { description: From 6c34545fac333a85386cf5ebca584333fdd71f69 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Thu, 15 Oct 2026 16:10:30 +0100 Subject: [PATCH 5/9] opencode: what's kept of a session (its parent, tokens, variant) goes when it's deleted Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KqZeF1bhJidhjtVDZGBLgJ --- opencode/mapping.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/opencode/mapping.ts b/opencode/mapping.ts index 7ab98e4..6a57b7c 100644 --- a/opencode/mapping.ts +++ b/opencode/mapping.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v3-opencode-deleted +// REVISION: flow-v4-hygiene // // opencode's events in the heat model's terms, for the session on screen and // the subagents it runs (child sessions, each with its `parentID`). Pure (no @@ -177,6 +177,12 @@ export class OpenCodeWork { return } case 'session.deleted': { + // What's kept of any session goes with it: these hear the whole server, every session it ever makes. + if (typeof data.sessionID === 'string') { + this.parents.delete(data.sessionID) + this.used.delete(data.sessionID) + this.variants.delete(data.sessionID) + } // A subagent's session deleted before its run ended (else it would stay working, quiet, for good). const child = typeof data.sessionID === 'string' ? this.children.get(data.sessionID) : undefined if (child?.status !== 'running') return From aa23c5723d8040dbb9ec56a8a68e49ae75792be2 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Thu, 15 Oct 2026 16:17:19 +0100 Subject: [PATCH 6/9] opencode: back on the home screen no session is shown (the last one stayed open, so /flow there changed it unseen instead of saying to open one) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KqZeF1bhJidhjtVDZGBLgJ --- opencode/tui.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/opencode/tui.ts b/opencode/tui.ts index a6c259f..9e5c842 100644 --- a/opencode/tui.ts +++ b/opencode/tui.ts @@ -208,8 +208,14 @@ function setup(ctx: OcContext): () => void { /** A session has shown since Flow started: the first is a chance for a tip. */ let tipChanceTaken = false const follow = () => { - const id = shownSession?.id() - if (!id) return + // A session shows while one of its views is up: back on the home screen they're gone, and there's none. + const up = (v: SceneView | undefined) => v !== undefined && !v.isDestroyed + const id = up(views.band) || up(views.spine) ? shownSession?.id() : undefined + if (!id) { + if (opened !== undefined) work.show(undefined) + opened = openedAt = undefined + return + } if (!tipChanceTaken) { tipChanceTaken = true setTimeout(() => { From 46a6fc2fa3f466c66be29bbf8d3d404e334b2a77 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Thu, 15 Oct 2026 16:19:23 +0100 Subject: [PATCH 7/9] Scripts: the clips in sounds/ listed once (scripts/clips.ts) for make-sounds and check alike; a .DS_Store there no longer breaks make-sounds or lands in the manifest, and is ignored Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KqZeF1bhJidhjtVDZGBLgJ --- .gitignore | 3 +++ scripts/check.ts | 8 +++----- scripts/clips.ts | 17 +++++++++++++++++ scripts/make-sounds.ts | 7 +++---- 4 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 scripts/clips.ts diff --git a/.gitignore b/.gitignore index babd15a..3cc1b9d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# macOS Finder's folder settings +.DS_Store + # Written by the Claude Code engine on each load .claude-plugin/types/ diff --git a/scripts/check.ts b/scripts/check.ts index a17c6a1..b8ad519 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v174-taller-scenes +// REVISION: flow-v185-clips // // What every scene must hold to (AGENTS.md), measured: plugin.json lists // the scenes in SCENES, hooks/sound-files.ts lists the clips in sounds/, @@ -13,10 +13,10 @@ // npm run check -- surf ski just these // FLOW_MAX_MS=6 npm run check a looser time budget, as CI runs it -import { readdirSync } from 'node:fs' import { join } from 'node:path' import { SOUND_FILES } from '../hooks/sound-files' import { makeScene } from '../hooks/styles' +import { listClips } from './clips' import { syncManifest } from './sync-manifest' import { backdropsOf, build, LOOKS, nightsOf, pairs, scenesFrom } from './scene-lab' @@ -50,9 +50,7 @@ if (syncManifest(false)) bad('plugin.json is out of step with SCENES: run `npm r // The clips on disk and the ones the manifest names, as scripts/make-sounds.ts lists them. const SOUNDS = join(import.meta.dirname, '..', 'sounds') -const clips = readdirSync(SOUNDS, { withFileTypes: true }) - .filter(d => d.isDirectory()) - .flatMap(d => readdirSync(join(SOUNDS, d.name)).filter(f => !f.startsWith('.')).map(f => `sounds/${d.name}/${f}`)) +const clips = listClips(SOUNDS) const named = new Set(SOUND_FILES) const unnamed = clips.filter(f => !named.has(f)) const gone = SOUND_FILES.filter(f => !clips.includes(f)) diff --git a/scripts/clips.ts b/scripts/clips.ts new file mode 100644 index 0000000..4635997 --- /dev/null +++ b/scripts/clips.ts @@ -0,0 +1,17 @@ +// REVISION: flow-v185-clips +// +// The clips in sounds/, as make-sounds writes them into hooks/sound-files.ts +// and check compares them with it: each scene's folder's files, sorted; +// anything else there (Finder's .DS_Store, a stray file) left out. Not part +// of the mod (Node). + +import { readdirSync } from 'node:fs' +import { join } from 'node:path' + +/** Every clip in `dir` (sounds/), as the manifest names them: `sounds//`. */ +export function listClips(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }) + .filter(d => d.isDirectory() && !d.name.startsWith('.')) + .flatMap(d => readdirSync(join(dir, d.name)).filter(f => !f.startsWith('.')).map(f => `sounds/${d.name}/${f}`)) + .sort() +} diff --git a/scripts/make-sounds.ts b/scripts/make-sounds.ts index b34ff98..9a46c57 100644 --- a/scripts/make-sounds.ts +++ b/scripts/make-sounds.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v181-doom-volleys +// REVISION: flow-v185-clips // // Builds the soundscapes' clips into sounds/ (AAC, mono 22.05 kHz) and the // manifest hooks/sound-files.ts. Each recipe makes a WAV with sox (and Node, @@ -18,6 +18,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync import { join } from 'node:path' import { BED_FADE_MS, BED_MS, BED_TAKES, type BedMood, LAYERS, layerGain, master, MOODS } from '../hooks/sound' import { makeScene } from '../hooks/styles' +import { listClips } from './clips' const ROOT = join(import.meta.dirname, '..') const OUT = join(ROOT, 'sounds') @@ -1291,9 +1292,7 @@ for (const scene of Object.keys(LAYERS)) { writeFileSync(gainsFile, JSON.stringify(gains, null, 1)) // The manifest: every clip there is, and each mood's gain. -const files = readdirSync(OUT) - .flatMap(folder => readdirSync(join(OUT, folder)).map(f => `sounds/${folder}/${f}`)) - .sort() +const files = listClips(OUT) const sorted = Object.keys(gains).sort() writeFileSync( join(ROOT, 'hooks', 'sound-files.ts'), From 7f5d55584f56a595a4e52950b87afd6cfa54507b Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Thu, 15 Oct 2026 18:13:54 +0100 Subject: [PATCH 8/9] =?UTF-8?q?surf:=20under=20smoke=20the=20barrel=20(its?= =?UTF-8?q?=20lip,=20its=20tube)=20dulls=20further=20toward=20an=20overcas?= =?UTF-8?q?t=20grey,=20so=20smoke=20stays=20clear=20to=20tritan=20eyes=20a?= =?UTF-8?q?t=20the=20spine's=20level=2010=20(normal=E2=86=94smoke=20=CE=94?= =?UTF-8?q?E=209.7=20=E2=86=92=2010.5,=20as=20before=20the=20barrel=20drew?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KqZeF1bhJidhjtVDZGBLgJ --- hooks/surf.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/hooks/surf.ts b/hooks/surf.ts index 81a651b..dce6a2a 100644 --- a/hooks/surf.ts +++ b/hooks/surf.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v184-surf-barrel +// REVISION: flow-v186-surf-smoke // // Surf (the `surf` style): a surfer and the ocean on the same dials as the // fire; the level is the swell. At 1 the sea is glassy under a dawn sky and @@ -138,6 +138,8 @@ const NIGHT: Palette = { const NIGHT_SHADE = 0x0a1428 /** Moonlight caught along the top of the water by night: the line between sea and sky. */ const MOON_RIM = 0x4a7c94 +/** What the barrel (its lip, its tube) dulls toward under smoke: a grey sea in overcast light. */ +const SMOKE_LIP = 0x5d6466 /** The band's sea by night: one even teal under the black sky (darkening a little with depth). */ const BAND_SEA = 0x0c3448 @@ -787,6 +789,9 @@ export class Surf { // The lip: lit through, pale green-blue; frothing at the tip. const dyc = (yc - this.ey) / this.ry c = mix(P.faceLip, P.foam, clamp(0.25 + dyc * 0.5) * 0.7) + // Under smoke the barrel dulls further than the sea's greying alone: + // pale and bright, half-greyed it keeps its lightness (tritan eyes lose it). + if (this.kGrey > 0) c = mix(c, SMOKE_LIP, 0.6 * this.kGrey) if (bandNight > 0.01) c = mix(c, mix(BAND_SEA, MOON_RIM, 0.45), 0.7 * bandNight) this.water[k] = 1 } else if (inTube) { @@ -796,6 +801,7 @@ export class Surf { // the lip, close to the face's own color toward the mouth. const lit = clamp((dx + dy) * 0.4 + 0.55) c = mix(mix(P.tube, P.seaTop, 0.4), mix(P.seaTop, P.faceLip, 0.5), lit * 0.8) + if (this.kGrey > 0) c = mix(c, SMOKE_LIP, 0.6 * this.kGrey) if (bandNight > 0.01) c = mix(c, mix(BAND_SEA, P.seaDeep, 0.3), 0.7 * bandNight) this.water[k] = 0 } else if (wet > 0) { From c03f683f0eb92637a497ddf5fd67a7f161f268ff Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Thu, 15 Oct 2026 18:16:57 +0100 Subject: [PATCH 9/9] surf: the water the crest throws over the barrel is shaded as the back of the wave curling over, its surface its own top edge, not as a pale wedge of the face's crest light between the back and the lip Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KqZeF1bhJidhjtVDZGBLgJ --- hooks/surf.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/hooks/surf.ts b/hooks/surf.ts index dce6a2a..432c057 100644 --- a/hooks/surf.ts +++ b/hooks/surf.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v186-surf-smoke +// REVISION: flow-v187-surf-hood // // Surf (the `surf` style): a surfer and the ocean on the same dials as the // fire; the level is the swell. At 1 the sea is glassy under a dawn sky and @@ -770,6 +770,9 @@ export class Surf { let wet = clamp(y + 1 - surf) let inTube = false let onLip = false + // Water the crest throws forward over the barrel (above the face's own + // surface): the back of the wave curling over, so shaded as the back. + let hooded = false if (lip > 0 && isMain && Math.abs(xc - this.ex) < this.rx + 1 && yc < seaY + 1) { const dx = (xc - this.ex) / this.rx const dy = (yc - this.ey) / this.ry @@ -783,8 +786,14 @@ export class Surf { const th = ((vertical ? 5 : 2.6) * (1 - 0.45 * prog) * (0.6 + 0.4 * lip)) / R if (phi <= this.phiEnd && r > 1 - th && r < 1 && (yc < surf || phi > -140)) onLip = true else if (yc < surf && r < 1) inTube = true - } else if (yc >= hood) wet = 1 - } else if (yc >= hood && yc < surf) wet = clamp(y + 1 - hood) + } else if (yc >= hood) { + wet = 1 + hooded = yc < surf + } + } else if (yc >= hood && yc < surf) { + wet = clamp(y + 1 - hood) + hooded = true + } if (onLip) { // The lip: lit through, pale green-blue; frothing at the tip. const dyc = (yc - this.ey) / this.ry @@ -814,7 +823,7 @@ export class Surf { // crest, so the face never starts as a hard-edged dark block. const backC = mix(P.seaTop, P.back, clamp(hf)) const faceC = mix(mix(P.faceDeep, P.seaTop, 0.45), P.faceLip, Math.pow(clamp(hf), 1.25)) - const fk = clamp((xc - cx) / (vertical ? Math.max(4, this.Wf * 0.45) : 5) + 0.35) + const fk = hooded ? 0 : clamp((xc - cx) / (vertical ? Math.max(4, this.Wf * 0.45) : 5) + 0.35) wc = mix(backC, faceC, fk) // The barrel's shadow fades out ahead of it rather than stopping on a line. if (lip > 0) { @@ -829,7 +838,8 @@ export class Surf { const even = mix(BAND_SEA, P.seaDeep, clamp((yc - surf) / Math.max(2, ph - surf)) * 0.35) wc = mix(wc, even, 0.85 * bandNight) } - const depth = yc - surf + // (The hood's surface is its own top edge, not the face's below it.) + const depth = hooded ? yc - hood : yc - surf if (depth < 1) wc = mix(wc, P.foamShade, hump > 0.4 && hf > 0.5 ? 0.18 + 0.4 * clamp(hf - 0.5) : 0.18 * (1 - kNight)) // Moonlight along the crests, strongest on the tallest faces; on a calm // sea only a faint line, or the swell reads as dark blocks on a bright stripe.