commit 18e9f45dce9a9126f5aab0732fb4187f1eec5dfe
parent 91fe41ea0ca5497221c890afda7aa580fef60db8
Author: DAEMON <zer0sec.xp@icloud.com>
Date: Sun, 4 Oct 2026 06:16:07 +0100
Design the signal-field port onto the section banners
The interactive dot field from daemon-sec's ambient-signal.tsx, at
DEFAULT_STYLE = 2, lifted out of React and onto the cheatsheet's dark
banner plate.
Settled during brainstorming: banners only, so HeroLoop keeps the home
hero and its 21.4s canvas to itself; the constellation sits behind the
existing fuzz field rather than replacing it, with --fuzz-gain pulled
down in CSS so the lattice reads under the streaks; and the palette
comes from the theme-relative tokens the plate already pins, which
retires the tone prop.
Records three premises from the task brief that the repo contradicted —
the dawn/night tokens do exist, app.ts does not drive the banners, and
the home hero is HeroLoop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat:
1 file changed, 305 insertions(+), 0 deletions(-)
diff --git a/docs/superpowers/specs/2026-10-04-signal-field-banners-design.md b/docs/superpowers/specs/2026-10-04-signal-field-banners-design.md
@@ -0,0 +1,305 @@
+# Signal field on the section banners — Design
+
+**Date:** 2026-10-04
+**Source:** `/Users/daemon1/git/daemon-sec/client/src/components/daemon/ambient-signal.tsx` (716 lines, React + WebGL)
+**Prior audit:** [`docs/design-pass-findings.md`](../../design-pass-findings.md) §1
+**Bookmark:** `signal-field-banners`, off `main`
+
+---
+
+## 1. Intent
+
+Bring daemon-sec's interactive dot field — `ambient-signal.tsx` at `DEFAULT_STYLE = 2`, the
+"constellation" — onto the cheatsheet, which is Astro 7 with no React and a contract
+(`DESIGN.md`) forbidding new dependencies.
+
+The field is a network map: hosts on an irregular lattice that drift on their own and are pushed
+away from the pointer. Hosts within the cursor's reach lock to the archive's REC red and the links
+between them light, so pointing at the page draws an attack path across it. In style 2 the dots are
+dense and plain, links appear *only* near the cursor, and the cursor reaches out to them.
+
+Success looks like: landing on any category page, the dark banner plate carries a lattice of faint
+dots, and moving the pointer across it draws lit paths that settle when the pointer leaves — with
+the banner title no less legible than it is today, and no regression to the fuzz field already
+painting that plate.
+
+### What the user asked for, verbatim
+
+> "Port daemon-sec's interactive dot field to the daemon-sec-cheatsheet Astro site."
+
+> "Open question to settle with the user: dots on every page, or only the home hero and category
+> banners?"
+
+On the composition question, asked during brainstorming:
+
+> "i dunno you choose whatever looks cooler"
+
+### Decisions taken during brainstorming
+
+| Question | Decision |
+|---|---|
+| Where it mounts | **Section banners only** — every route below home. Home page untouched. |
+| Relationship to the existing fuzz field | **Both**, constellation behind, fuzz gain reduced on the banner so the lattice reads |
+| Palette source | **Theme-relative tokens** read off the canvas (`--text`, `--*-soft`), not the absolute `--color-night-*` set |
+| `tone` prop | **Dropped** — `.plate` already pins night for the subtree |
+| Shader | **Lifted verbatim**, all four styles retained, `?field=N` override kept |
+| Fallback | **No canvas** — the fuzz field carries the plate alone. `ambient-field.tsx` is not ported. |
+
+### Premises in the original task brief that did not survive contact with the repo
+
+Recorded because each one changed the design:
+
+1. **"`--color-dawn-*` / `--color-night-*` do not exist in the cheatsheet."** They do —
+ `src/styles/tokens.css` carries the whole "DÆMON main-site palette" block, extracted verbatim
+ from daemon-sec, and `HeroLoop.astro` already reads it. Only the three dawn `-soft` variants are
+ absent, and the original's `SOURCES` table already falls through to full strength when a `-soft`
+ is missing. The mapping problem the brief anticipated does not exist. §4 takes a different route
+ again, for reasons of its own.
+2. **"`src/scripts/app.ts` drives SectionBanner, Marquee and Hero."** It does not. Each of those
+ components carries its own inline `<script>` with its own `astro:page-load` / `astro:before-swap`
+ pair; `app.ts` has no `before-swap` teardown at all. The pattern to follow is
+ `SectionBanner.astro:240-289`, not `app.ts`.
+3. **"The home hero."** The home page uses `HeroLoop`, not `Hero`, and `HeroLoop` is already a
+ heavy canvas animation — an authored 1920×1080 bitmap on a 21.4s rAF clock filling the whole
+ band. This is the main reason the scope question resolved to banners only.
+
+### Assumptions
+
+- The banner is near the top of every page it appears on, so the original's `u_scroll` term
+ (`window.scrollY / box.height`, written for a hero at page top) carries over unchanged.
+- Nothing in the banner needs the `data-index-zone` clearing; see §6.
+- No ambient switch exists on the cheatsheet (`motion-settings` is not ported), so
+ `prefers-reduced-motion` is the only motion gate. This satisfies DESIGN.md's "all motion gated on
+ `prefers-reduced-motion`".
+
+---
+
+## 2. Why both fields, rather than a replacement
+
+The banner plate is already painted by the fuzz field (`SectionBanner.astro:79-290`): six
+pixel-sorted streak bands plus 54 bright filaments on a seamless 9-second loop, composited
+`lighter` on dark at `--fuzz-gain: 1`.
+
+Layered naively the two fight, and the arithmetic says so. Fuzz streak alpha reaches
+`(0.06 + 0.34) × env × hx × 1.7 × gain` — around 0.6 at peak, *additive* — while the constellation
+draws its dots at `hostA * 0.38` and its resting links at `linkA * 0.03`. The streaks would eat the
+lattice, and the cursor lock, which is the piece's entire gesture, needs quiet ground to read
+against.
+
+The fix is not to retire the fuzz field. DESIGN.md lists "`SectionBanner` canvas animation reading
+`--fuzz-*` tokens" under **behaviour that must keep working (do not regress)**. Instead:
+`--fuzz-gain` is already a CSS custom property the fuzz canvas re-reads at draw time
+(`SectionBanner.astro:136`), so lowering it on `.banner` in CSS alone rebalances the two with no
+JavaScript change and no edit to the fuzz code. The plate then reads as two depths — dots as the
+substrate, streaks as interference drifting across them.
+
+A pointer-driven cross-fade was considered and rejected on a substantive point rather than cost: the
+constellation has an idle wanderer (`IDLE_MS = 2800`) that walks the cursor on its own precisely so
+touch screens still see hosts being locked. Gating the field on pointer-enter would suppress exactly
+that behaviour.
+
+---
+
+## 3. Module — `src/scripts/signal-field.ts`
+
+```ts
+export function mountSignalField(canvas: HTMLCanvasElement): () => void
+```
+
+Returns its own teardown. Everything the React `useEffect` body did, minus React.
+
+`VERT` and `FRAG` are lifted **byte-identical** from `ambient-signal.tsx:75-340`. All four styles
+stay in the fragment shader even though only style 2 ships, and `styleFromUrl()` (`?field=N`) comes
+with them. Two reasons: the `st()` branches are uniform-constant, so they are coherent across every
+fragment and cost effectively nothing on the GPU; and a byte-identical shader stays diffable against
+the original when daemon-sec moves. Stripping three styles would save no measurable frame time and
+would cost the port its provenance.
+
+Carried over unchanged from the original:
+
+- **DPR-1 backing store.** Lines are one pixel wide and fragment cost is per pixel.
+- **No layout reads inside a frame.** The canvas box is measured by `ResizeObserver` into a `box`
+ record; pointer events are converted using that record, never a fresh `getBoundingClientRect`.
+- **Pointer listened for on `window`**, not the canvas, so content in front keeps its hover. The
+ canvas itself takes `pointer-events: none`.
+- **The 2800ms idle wanderer**, so touch screens see hosts being locked.
+- **Eased cursor** at a 0.07 step per frame — weight, not lag.
+- **`HELD_TIME = 31.0`** as the still-frame phase.
+
+Deliberately dropped: the `tone` prop and its `SOURCES` table (§4), the React `failed` state (§7),
+and `AmbientField` (§7).
+
+---
+
+## 4. Palette
+
+The field reads four colours off its own canvas with `getComputedStyle`, re-reading on
+`daemonmodechange`, because a GPU uniform does not inherit a CSS variable.
+
+| Uniform | Token | Resolves to inside `.banner.plate` |
+|---|---|---|
+| `u_ink` | `--text` | `#e0def4` |
+| `u_love` | `--love-soft` | `#eb6f92` |
+| `u_foam` | `--foam-soft` | `#9ccfd8` |
+| `u_iris` | `--iris-soft` | `#c4a7e7` |
+
+These are the **theme-relative** tokens, not the absolute `--color-night-*` set, and that is the
+design decision this section exists to record. The banner carries `.plate`, which pins the night
+palette for its whole subtree in both modes — so the cascade has already answered the question and
+the canvas only has to ask the right element. Three consequences:
+
+- The `tone: "page" | "plate"` prop disappears. There is one tone here, and CSS decides it.
+- No new tokens are needed. The three missing `--color-dawn-*-soft` variants stay missing.
+- If the plate is ever retuned, or a banner is ever placed in a `.plate-dawn` scope, the field
+ follows without a code change.
+
+This is also exactly how the fuzz field already resolves its palette (`SectionBanner.astro:129-151`
+and its comment), so the two canvases on the plate answer the colour question the same way.
+
+All four resolve to plain `#rrggbb`, which the original's `hexToRgb` requires — no `color-mix()`
+values are involved. The fallbacks are the original's `FALLBACK` table, whose values are already
+these exact night hexes.
+
+---
+
+## 5. Mount — `SectionBanner.astro`
+
+Markup gains one element, as the **first** child so it sits at the bottom of the stack:
+
+```astro
+<header class="banner plate bleed" style={`--acc: var(--${accent});`}>
+ <canvas class="banner__signal" data-signal aria-hidden="true"></canvas> <!-- NEW -->
+ <canvas class="banner__field" data-fuzz aria-hidden="true"></canvas>
+ <span class="banner__scrim" aria-hidden="true"></span>
+ <span class="grain grain--local" aria-hidden="true"></span>
+ <span class="scanlines" aria-hidden="true"></span>
+ <div class="wrap banner__body"> … </div>
+```
+
+Both canvases sit under the existing `.banner__scrim`, so the title's two-layer legibility story —
+the fuzz canvas erasing its own top and bottom bands by `destination-out`, **and** the scrim
+gradient over it — is untouched. The new field adds no third layer to that story and takes no part
+in it.
+
+The component's existing `<style>` block gains:
+
+```css
+/* The constellation is the substrate; the streaks are interference across
+ it. At full gain the additive streaks eat a lattice drawn at 0.38 alpha,
+ so the fuzz field steps back on the banner. Read at draw time by the
+ fuzz canvas, so this is the whole adjustment — no JS change. */
+.banner { --fuzz-gain: 0.45; }
+.banner__signal { position: absolute; inset: 0; width: 100%; height: 100%; display: block; pointer-events: none; }
+```
+
+The `0.45` is a starting value to be confirmed against the rendered plate during implementation, in
+both modes. It is the one number in this design arrived at by reasoning rather than by reading the
+original.
+
+The component's `<script>` imports the module and wraps it in the lifecycle the fuzz script already
+uses — `data-bound` guard, `astro:page-load` init, `astro:before-swap` teardown, plus the
+`DOMContentLoaded` / `readyState` pair for the first load:
+
+```ts
+import { mountSignalField } from '../scripts/signal-field';
+
+function initSignal(): void {
+ document.querySelectorAll<HTMLCanvasElement>('[data-signal]:not([data-bound])').forEach((cv) => {
+ cv.dataset.bound = '1';
+ const teardown = mountSignalField(cv);
+ document.addEventListener('astro:before-swap', teardown, { once: true });
+ });
+}
+```
+
+Routes affected, via the seven pages that use `SectionBanner`: `/[category]`, `/payloads`,
+`/internal`, `/tags`, `/tags/[tag]`, `/hacktricks`, `/404`.
+
+---
+
+## 6. The clearing
+
+The `data-index-zone` mechanism — hosts inside a marked box pushed out through the nearest edge,
+links and locks faded to nothing inside — is **kept in the module and left unused**.
+
+Nothing in the banner carries the attribute, so `u_zone` stays at its empty sentinel
+`(0, 0, -1, -1)`, `zoneSd` returns `1e3` on its first line, and the keep-out costs one uniform and
+one early return per fragment.
+
+It is kept rather than stripped for two reasons. It is load-bearing behaviour in the original, so
+removing it would make the port no longer a port. And the home page already has a
+`<section id="index" class="dsection" data-index>` that is the structural analogue of daemon-sec's
+index rows — if the field is ever extended there (the "banners + home bands" option considered and
+set aside during brainstorming), the clearing is what it will need, and it should not have to be
+re-derived.
+
+In the banner itself the scrim already does this job, better and more cheaply.
+
+---
+
+## 7. Motion, pausing and degradation
+
+**Reduced motion.** `prefers-reduced-motion: reduce` means the loop never starts. One frame is drawn
+at `HELD_TIME = 31.0` — probes spread, hosts settled — and redrawn only on resize and on
+`daemonmodechange`. A still frame has to be redrawn by hand on a mode flip, or the plate keeps the
+old palette until something else resizes it.
+
+**Pausing.** The loop stops when the tab is hidden (`visibilitychange`) and when the banner is
+scrolled out of view (`IntersectionObserver`), and restarts on either returning. Both route through
+one `sync()`, as in the original.
+
+**Degradation.** No WebGL, a shader that will not compile, a program that will not link, or a
+`webglcontextlost` event all end the same way: tear down and remove the canvas from the DOM. The
+fuzz field is still painting the plate, so the banner degrades to exactly what ships today.
+
+This is the one place the port deliberately diverges in structure rather than only in idiom. The
+original falls back to `ambient-field.tsx` (141 lines of CSS orbs, rings and drifting dots) because
+on daemon-sec the field is the *only* thing on that ground and removing it would leave the hero
+bare. Here the ground is already occupied. Porting a second fallback component to sit behind a
+working animation would add 141 lines to render something nobody sees.
+
+**Context teardown.** The context is left to the GC rather than lost by hand, carrying over the
+original's closing comment: a forced loss fires `webglcontextlost` *after* cleanup has run, and the
+next mount of the same canvas would hear it as a real loss and degrade for no reason. This matters
+more here than in the original, because view-transition navigation between two banner pages mounts
+and unmounts in quick succession.
+
+---
+
+## 8. Testing
+
+No test framework runs against components in this repo (`test/` holds content checks), so
+verification is by inspection, and the spec names what to inspect rather than pretending otherwise.
+
+Type checking: `npx tsc --noEmit`. Permitted by DESIGN.md; `npm run build` is **not**, while other
+agents share the working copy.
+
+Checked by hand on a category page, in both modes:
+
+1. Dots visible on the plate at rest; the lattice reads *through* the fuzz streaks rather than
+ under them. Tune `--fuzz-gain` here if not.
+2. Pointer across the banner lights links and locks hosts to love; the field settles when it leaves.
+3. Banner title legibility unchanged against `main` — the failure mode to look for is the title on a
+ bright filament, which is what the two-layer scrim exists to prevent.
+4. Theme toggle repaints the field rather than leaving it on the previous palette, both while
+ animating and while held still under reduced motion.
+5. Reduced motion (`Emulate CSS prefers-reduced-motion` in devtools) shows a still frame, not a
+ blank plate, and that frame repaints on a theme toggle.
+6. Navigating between two banner pages under view transitions leaves exactly one rAF loop running —
+ no accumulation, no context-loss fallback triggered by the swap.
+7. Scrolling the banner out of view and back, and backgrounding the tab, stop and restart the loop.
+8. Touch: the idle wanderer walks the cursor and hosts lock without a pointer.
+9. WebGL disabled (`chrome://flags`, or forcing the context to fail): the canvas is removed and the
+ banner looks like `main`.
+
+---
+
+## 9. Files
+
+| File | Change |
+|---|---|
+| `src/scripts/signal-field.ts` | **New.** Shader, uniform wiring, lifecycle, `mountSignalField`. |
+| `src/components/SectionBanner.astro` | Canvas element, two CSS rules, an import and the init block. |
+
+No token changes. No new dependencies. No edits to `src/content/`, `scripts/`, the manifests or
+`astro.config.mjs`.