diff --git a/.claude/rules/components.md b/.claude/rules/components.md index e29a4ca19..914a783d7 100644 --- a/.claude/rules/components.md +++ b/.claude/rules/components.md @@ -45,22 +45,34 @@ MUST: 2. Respect `prefers-reduced-motion` (the site-wide override in `src/index.css` collapses `animation-duration` to 0.01ms) 3. Be CSS-only — **no JS animation libraries, no Framer Motion, no - `requestAnimationFrame`** — with ONE ratified exception, the - working-model exception (ISSUE 419), which covers the `plate` - (`PlateFeature`), `bore` (`BoreFeature`, ISSUE 420), and - `fourier` (`FourierFeature`, ISSUE 421) shapes — and, as - amended by ISSUE 422 (the APPARATUS REGISTER, merger ruling), - the `audit` (`AuditFeature`) shape, whose whole feature carries - the full artifact contract on-site (springs, proximity masks, - magnetic dispatch) confined to that feature and meaning-first. - Inside those spreads' frames, script may move the model's own - signal (a pulse, a probe, a plate drawing itself), under the - four constraints in `docs/interaction-language.md` rule 3 as - amended: confined to the frame; timer-robust (every step - advances via a rAF-vs-setTimeout race, never rAF alone — rAF - provably stalls in throttled/background tabs and embedded - panes); collapsed by reduced-motion; ambient amplitude even - inside the frame (≤4px) + `requestAnimationFrame`** — with TWO ratified exceptions: + - the working-model exception (ISSUE 419), which covers the + `plate` (`PlateFeature`), `bore` (`BoreFeature`, ISSUE 420), + and `fourier` (`FourierFeature`, ISSUE 421) shapes — and, as + amended by ISSUE 422 (the APPARATUS REGISTER, merger ruling), + the `audit` (`AuditFeature`) shape, whose whole feature carries + the full artifact contract on-site (springs, proximity masks, + magnetic dispatch) confined to that feature and meaning-first. + Inside those spreads' frames, script may move the model's own + signal (a pulse, a probe, a plate drawing itself), under the + four constraints in `docs/interaction-language.md` rule 3 as + amended: confined to the frame; timer-robust (every step + advances via a rAF-vs-setTimeout race, never rAF alone — rAF + provably stalls in throttled/background tabs and embedded + panes); collapsed by reduced-motion; ambient amplitude even + inside the frame (≤4px) + - the walkable-room exception (THE STACKS, `/archive`, 2026-07-20 + design review): a room is inherently a working model — bodies + drift in a WebGL space via `@react-three/fiber`'s `useFrame`. + Confined to `src/stacks/` (never spills into a shared + component); `prefers-reduced-motion` fully collapses the drift + and the scroll-driven camera easing to a static, fully + navigable resting layout — same collapse contract as the + working-model exception, enforced in React state rather than + CSS since the surface is a ``; the real DOM ledger + beside the canvas is the CSS-only resting structure required by + this section and stays present and complete with the canvas + layer entirely absent (no WebGL2, JS disabled) 4. Not trigger layout/repaint hotspots — animate only `opacity` and `transform` diff --git a/KERNEL.md b/KERNEL.md index 02fbd0420..cad99f9e8 100644 --- a/KERNEL.md +++ b/KERNEL.md @@ -172,6 +172,10 @@ kernel.chat/ ← repo root │ │ ├── 360.ts ... 375.ts ← every published issue │ │ └── … │ ├── pages/ ← routes (IssueDetailPage, EnginePage, …) +│ ├── stacks/ ← THE STACKS (/archive): the back catalog +│ │ as a walkable room; bodies resolve in +│ │ bodies.ts (M1 sheets; M2 instruments, +│ │ M3 monuments, M4 scans) │ ├── engine/ ← AI orchestration (when the magazine │ │ hosts an interactive surface) │ └── components/MagazineFrame.tsx← masthead/folio rendering diff --git a/SCRATCHPAD.md b/SCRATCHPAD.md index af8be08df..a76869c83 100644 --- a/SCRATCHPAD.md +++ b/SCRATCHPAD.md @@ -2,6 +2,53 @@ > This file persists context between Claude Code sessions. +## Session 2026-07-20 (cont.) — THE STACKS M1 ships (feat/the-stacks) + +- Isaac showed the SUKIMA project (claygarden.jp/series) — floating + scanned-clay objects in a dark void, scroll-descent into bilingual + spreads — and asked for the equivalent on kernel.chat. Brainstormed + to a spec (docs/superpowers/specs/2026-07-20-the-stacks-design.md): + a new room at `/archive`, volumes grouped by cover month, four body + kinds resolved by one seam (`bodyFor`) — sheets now, instruments/ + monuments/scans in M2–M4. Planned as 7 TDD tasks + (docs/superpowers/plans/2026-07-20-the-stacks-m1.md), executed via + subagent-driven-development on branch `feat/the-stacks` (based on + PR #62's real-URLs branch — the room assumes real paths). +- **Motion-law conflict, resolved by Isaac:** the drifting bodies need + rAF (`useFrame`), but `.claude/rules/components.md` restricts the + editorial surface to CSS-only motion except a named exception list. + Isaac's call: ratify a new "walkable-room exception" alongside the + existing plate/bore/fourier/audit one — same collapse contract + (reduced-motion → static, fully navigable), confined to + `src/stacks/`. Committed to the rules file before Task 5. +- **Two real bugs caught by review, not by me:** (1) PR #62's + `deploy.yml`/`package.json` referenced `scripts/build-sitemap.mjs`, + but the file was never actually git-added — sat untracked on disk + from the earlier session. Would have broken CI. Fixed directly on + `feat/real-urls-cloudflare` (commit 758813af6). (2) The issue + registry isn't uniformly abbreviated — 18 issues spell out `'APRIL'` + and 2 spell out `'JUNE'` instead of the 3-letter form every other + issue uses. `volumes.ts`'s month lookup assumed uniformity, so + ~20 issues (a third of the catalog) got broken JP labels and + miskeyed grouping buckets. Task 6's design-QA audit surfaced it + (`"JUNE 2026二〇二六年undefined"`); fixed by normalizing on the + first 3 letters rather than touching 20 content files, with 2 + regression tests. Verified against all 68 real issues: zero + `undefined`/`NaN` in any label. +- Shipped: `src/stacks/{volumes,bodies,coverPainter,coverTheme, + webgl,Scene}.ts`, `src/pages/ArchivePage.{tsx,css}`, e2e coverage, + colophon entry link. r3f/@react-three/three confined to the lazy + `/archive` chunk — main bundle held at 236.42KB gzip (budget + 300KB), independently confirmed by grepping the built output for + three.js symbols (zero in the main chunk). +- Gates at close: adherence + editorial clean, tsc clean, 810/810 + tests, e2e 4/4, design-QA audit clean on `/archive` (desktop + + mobile). M2 (instruments), M3 (monuments), M4 (first scan — KERNEL + PRESS Edition №001) remain, per the spec's milestone sequence. +- Still blocked on Isaac for PR #62 (unrelated to this branch): create + Cloudflare Pages project `kernel-chat` + 2 repo secrets, then DNS + cutover. + ## Session 2026-07-20 — UX anti-pattern audit → real URLs (PR #62) - Ran the design-QA audit against live kernel.chat. Editorial surface diff --git a/docs/superpowers/plans/2026-07-20-the-stacks-m1.md b/docs/superpowers/plans/2026-07-20-the-stacks-m1.md new file mode 100644 index 000000000..a15f11d05 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-the-stacks-m1.md @@ -0,0 +1,960 @@ +# THE STACKS M1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `/archive` — THE STACKS — the back catalog as a walkable dark room: typographic cover sheets drifting per monthly volume, scroll-descent, NEXT VOLUME gates, click/Enter into the real `/issues/N` routes. + +**Architecture:** All 3D code lives in `src/stacks/` and loads only through the lazy `/archive` route chunk (three.js never touches the main bundle). Pure logic (volume grouping, body resolution, cover painting) is separated from the r3f scene so it tests without WebGL. The accessible resting structure is real DOM — a "ledger" of volume headings and issue links rendered beside the canvas; WebGL is presentational on top. + +**Tech Stack:** React 19, react-router-dom v7, Vite 6, Vitest, three + @react-three/fiber v9 + @react-three/drei v10 (route-chunk only). Cover textures are canvas-painted at runtime from the issue registry — no image assets. + +## Global Constraints + +- Main bundle ≤300KB gzip JS (currently 236KB): three/r3f/drei must appear ONLY in the lazy archive chunk — verify in build output every task that touches deps or routing. +- No raw hex colours in new source (adherence law): colors come from CSS custom properties at runtime or `resolveAccentHex` from `src/content/issues/accents.ts`. +- Magazine vocabulary in user-visible copy: stacks / volume / gate / body / ledger. Never scene, canvas, 3D, viewer. No emojis (★ allowed). +- Bilingual lockups where the catalog has them (EN + JP). +- `prefers-reduced-motion: reduce` → no drift, no camera easing; resting page complete. +- Node scripts and tests must pass: `npm run lint:adherence && npm run lint:editorial && npx tsc --noEmit && npx vitest run`. +- All work on branch `feat/the-stacks` (exists, based on `feat/real-urls-cloudflare` — real paths assumed). + +--- + +### Task 1: Volume grouping + +**Files:** +- Create: `src/stacks/volumes.ts` +- Test: `src/stacks/volumes.test.ts` + +**Interfaces:** +- Consumes: `IssueRecord` (`src/content/issues/schema.ts`) — uses only `{ number, month, year }`. `month` is an uppercase EN abbreviation (`'FEB'`), `year` a 4-digit string (`'2027'`). +- Produces: `interface Volume { label: string; labelJp: string; issues: IssueRecord[] }` and `groupIntoVolumes(issues: IssueRecord[]): Volume[]` — volumes newest-first, issues within a volume newest-first. `label` is `'FEB 2027'`; `labelJp` is `'二〇二七年二月'`. + +- [ ] **Step 1: Write the failing test** + +```ts +// src/stacks/volumes.test.ts +import { describe, it, expect } from 'vitest' +import { groupIntoVolumes } from './volumes' +import type { IssueRecord } from '../content/issues/schema' + +const issue = (number: string, month: string, year: string) => + ({ number, month, year }) as IssueRecord + +describe('groupIntoVolumes', () => { + it('groups by cover month, volumes and issues newest-first', () => { + const vols = groupIntoVolumes([ + issue('425', 'DEC', '2026'), + issue('426', 'JAN', '2027'), + issue('427', 'FEB', '2027'), + ]) + expect(vols.map((v) => v.label)).toEqual(['FEB 2027', 'JAN 2027', 'DEC 2026']) + expect(vols[0].issues.map((i) => i.number)).toEqual(['427']) + }) + + it('keeps multiple issues of one month newest-first', () => { + const vols = groupIntoVolumes([ + issue('398', 'JUL', '2026'), + issue('399', 'JUL', '2026'), + ]) + expect(vols).toHaveLength(1) + expect(vols[0].issues.map((i) => i.number)).toEqual(['399', '398']) + }) + + it('renders the Japanese volume label with kanji digits', () => { + const [v] = groupIntoVolumes([issue('427', 'FEB', '2027')]) + expect(v.labelJp).toBe('二〇二七年二月') + }) + + it('handles all twelve month abbreviations', () => { + const months = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'] + const vols = groupIntoVolumes(months.map((m, i) => issue(String(360 + i), m, '2026'))) + expect(vols[0].labelJp.endsWith('十二月')).toBe(true) // DEC sorts newest + expect(vols[11].labelJp.endsWith('一月')).toBe(true) // JAN sorts oldest + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/stacks/volumes.test.ts` +Expected: FAIL — `Cannot find module './volumes'` + +- [ ] **Step 3: Write the implementation** + +```ts +// src/stacks/volumes.ts +/* THE STACKS — volume grouping. + A volume is one cover month of the catalog; the room walks + newest volume to oldest. Pure over the registry so it tests + without the renderer. */ +import type { IssueRecord } from '../content/issues/schema' + +export interface Volume { + /** EN lockup, e.g. 'FEB 2027' */ + label: string + /** JP lockup with kanji digits, e.g. '二〇二七年二月' */ + labelJp: string + issues: IssueRecord[] +} + +const MONTH_ORDER = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'] +const MONTH_JP = ['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'] +const DIGIT_JP: Record = { + '0': '〇', '1': '一', '2': '二', '3': '三', '4': '四', + '5': '五', '6': '六', '7': '七', '8': '八', '9': '九', +} + +function yearJp(year: string): string { + return year.split('').map((d) => DIGIT_JP[d] ?? d).join('') +} + +function monthIndex(month: string): number { + return MONTH_ORDER.indexOf(month.toUpperCase()) +} + +export function groupIntoVolumes(issues: IssueRecord[]): Volume[] { + const byKey = new Map() + for (const issue of issues) { + const key = `${issue.year}-${String(monthIndex(issue.month)).padStart(2, '0')}` + const bucket = byKey.get(key) + if (bucket) bucket.push(issue) + else byKey.set(key, [issue]) + } + return [...byKey.entries()] + .sort(([a], [b]) => (a < b ? 1 : -1)) + .map(([, group]) => { + const sorted = [...group].sort((a, b) => Number(b.number) - Number(a.number)) + const { month, year } = sorted[0] + return { + label: `${month.toUpperCase()} ${year}`, + labelJp: `${yearJp(year)}年${MONTH_JP[monthIndex(month)]}`, + issues: sorted, + } + }) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/stacks/volumes.test.ts` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/stacks/volumes.ts src/stacks/volumes.test.ts +git commit -m "feat(stacks): volume grouping — one room per cover month" +``` + +--- + +### Task 2: Body resolution + +**Files:** +- Create: `src/stacks/bodies.ts` +- Test: `src/stacks/bodies.test.ts` + +**Interfaces:** +- Consumes: `IssueRecord`. +- Produces: `type BodyKind = 'sheet' | 'instrument' | 'monument' | 'scan'` and `bodyFor(issue: IssueRecord): BodyKind`. M1 always resolves `'sheet'`; the union and function exist now so M2–M4 extend one place. Later tasks import both names exactly. + +- [ ] **Step 1: Write the failing test** + +```ts +// src/stacks/bodies.test.ts +import { describe, it, expect } from 'vitest' +import { bodyFor } from './bodies' +import type { IssueRecord } from '../content/issues/schema' + +describe('bodyFor', () => { + it('resolves every issue to a sheet in M1', () => { + expect(bodyFor({ number: '360' } as IssueRecord)).toBe('sheet') + expect(bodyFor({ number: '427' } as IssueRecord)).toBe('sheet') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/stacks/bodies.test.ts` +Expected: FAIL — `Cannot find module './bodies'` + +- [ ] **Step 3: Write the implementation** + +```ts +// src/stacks/bodies.ts +/* THE STACKS — body resolution. + Every issue floats as a body of one kind. M1 ships sheets only; + instruments (419+ artifact captures), monuments (milestone + sculptural forms), and scans (photogrammetry) land in M2–M4 by + extending this one resolver. */ +import type { IssueRecord } from '../content/issues/schema' + +export type BodyKind = 'sheet' | 'instrument' | 'monument' | 'scan' + +export function bodyFor(_issue: IssueRecord): BodyKind { + return 'sheet' +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/stacks/bodies.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/stacks/bodies.ts src/stacks/bodies.test.ts +git commit -m "feat(stacks): bodyFor resolver — sheets in M1, one seam for M2-M4" +``` + +--- + +### Task 3: Cover painter (typographic sheet texture) + +**Files:** +- Create: `src/stacks/coverPainter.ts` +- Test: `src/stacks/coverPainter.test.ts` + +**Interfaces:** +- Consumes: `IssueRecord` (`number`, `month`, `year`, `price`, `headline.{prefix,emphasis,suffix}`, `featureJp`). +- Produces: + - `interface CoverTheme { stock: string; ink: string; accent: string; serif: string; mono: string }` — all colors are CSS color strings resolved by the CALLER (Task 5 reads CSS custom properties; no hex literals here). + - `interface CoverSurface { width: number; height: number; fillStyle: string; font: string; textAlign: CanvasTextAlign; fillRect(x: number, y: number, w: number, h: number): void; fillText(text: string, x: number, y: number): void }` — the minimal 2D-context slice, so tests inject a recorder and the scene passes a real `CanvasRenderingContext2D` (which satisfies it structurally plus width/height passed via the wrapper below). + - `paintCover(issue: IssueRecord, surface: CoverSurface, theme: CoverTheme): void` — paints stock ground, folio row (`kernel.chat` / `N°` / ` · `), the three-part headline with the emphasis word in accent, and the JP feature line. + +- [ ] **Step 1: Write the failing test** + +```ts +// src/stacks/coverPainter.test.ts +import { describe, it, expect } from 'vitest' +import { paintCover, type CoverSurface, type CoverTheme } from './coverPainter' +import type { IssueRecord } from '../content/issues/schema' + +function recorder(width = 512, height = 720) { + const ops: Array> = [] + const surface: CoverSurface = { + width, height, + fillStyle: '', font: '', textAlign: 'left', + fillRect(x, y, w, h) { ops.push({ op: 'rect', x, y, w, h, fill: this.fillStyle }) }, + fillText(text, x, y) { ops.push({ op: 'text', text, x, y, fill: this.fillStyle, font: this.font }) }, + } + return { surface, ops } +} + +const theme: CoverTheme = { + stock: 'var-stock', ink: 'var-ink', accent: 'var-accent', + serif: 'TestSerif', mono: 'TestMono', +} + +const issue = { + number: '427', month: 'FEB', year: '2027', price: '¥0', + featureJp: 'モートは現実', + headline: { prefix: 'The moat', emphasis: 'is reality', suffix: '.', swash: '' }, +} as IssueRecord + +describe('paintCover', () => { + it('grounds the sheet in the stock colour, full bleed', () => { + const { surface, ops } = recorder() + paintCover(issue, surface, theme) + expect(ops[0]).toMatchObject({ op: 'rect', x: 0, y: 0, w: 512, h: 720, fill: 'var-stock' }) + }) + + it('sets the emphasis word in the accent, the rest in ink', () => { + const { surface, ops } = recorder() + paintCover(issue, surface, theme) + const texts = ops.filter((o) => o.op === 'text') + expect(texts.find((o) => o.text === 'is reality')?.fill).toBe('var-accent') + expect(texts.find((o) => o.text === 'The moat')?.fill).toBe('var-ink') + }) + + it('carries the folio: catalogue number, dateline, and the JP feature line', () => { + const { surface, ops } = recorder() + paintCover(issue, surface, theme) + const texts = ops.filter((o) => o.op === 'text').map((o) => o.text) + expect(texts).toContain('N°427') + expect(texts).toContain('FEB 2027 · ¥0') + expect(texts).toContain('モートは現実') + }) + + it('uses the mono family for folio rows and the serif for the headline', () => { + const { surface, ops } = recorder() + paintCover(issue, surface, theme) + const texts = ops.filter((o) => o.op === 'text') + expect(String(texts.find((o) => o.text === 'N°427')?.font)).toContain('TestMono') + expect(String(texts.find((o) => o.text === 'The moat')?.font)).toContain('TestSerif') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/stacks/coverPainter.test.ts` +Expected: FAIL — `Cannot find module './coverPainter'` + +- [ ] **Step 3: Write the implementation** + +```ts +// src/stacks/coverPainter.ts +/* THE STACKS — typographic cover painter. + Issues have no image covers; the cover IS type on stock. This + paints that grammar onto any 2D surface (a CanvasTexture at + runtime, a recorder in tests). Colors and font families arrive + resolved in the theme — this module never names a hex. */ +import type { IssueRecord } from '../content/issues/schema' + +export interface CoverTheme { + stock: string + ink: string + accent: string + serif: string + mono: string +} + +export interface CoverSurface { + width: number + height: number + fillStyle: string + font: string + textAlign: CanvasTextAlign + fillRect(x: number, y: number, w: number, h: number): void + fillText(text: string, x: number, y: number): void +} + +export function paintCover(issue: IssueRecord, s: CoverSurface, t: CoverTheme): void { + const u = s.width / 100 // layout unit + + // Ground: full-bleed stock. + s.fillStyle = t.stock + s.fillRect(0, 0, s.width, s.height) + + // Folio row — mono caps. + s.font = `${3 * u}px ${t.mono}` + s.textAlign = 'left' + s.fillStyle = t.ink + s.fillText('kernel.chat', 6 * u, 9 * u) + s.fillText(`N°${issue.number}`, 6 * u, 14 * u) + s.textAlign = 'right' + s.fillText(`${issue.month} ${issue.year} · ${issue.price}`, 94 * u, 9 * u) + + // Headline — serif, emphasis word in the issue accent. + s.textAlign = 'left' + s.font = `700 ${9 * u}px ${t.serif}` + const baseline = s.height * 0.52 + s.fillStyle = t.ink + s.fillText(issue.headline.prefix, 6 * u, baseline) + s.fillStyle = t.accent + s.fillText(issue.headline.emphasis, 6 * u, baseline + 11 * u) + s.fillStyle = t.ink + s.fillText(issue.headline.suffix, 6 * u, baseline + 22 * u) + + // JP feature line — mono, under the headline block. + s.font = `${3.4 * u}px ${t.mono}` + s.fillText(issue.featureJp, 6 * u, baseline + 30 * u) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/stacks/coverPainter.test.ts` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/stacks/coverPainter.ts src/stacks/coverPainter.test.ts +git commit -m "feat(stacks): typographic cover painter — stock, folio, headline, JP line" +``` + +--- + +### Task 4: Route, page shell, and the ledger (accessible resting structure) + +**Files:** +- Create: `src/pages/ArchivePage.tsx` +- Create: `src/pages/ArchivePage.css` +- Create: `src/stacks/webgl.ts` +- Test: `src/stacks/webgl.test.ts` +- Modify: `src/router.tsx` (add lazy route under the Layout children, beside the `/issues` route) +- Modify: `src/components/Layout.tsx` (title case) + +**Interfaces:** +- Consumes: `groupIntoVolumes` (Task 1), `ALL_ISSUES` from `src/content/issues`. +- Produces: `ArchivePage` (named export, matching the codebase's page pattern); `webglAvailable(create?: (kind: string) => unknown): boolean`. The Scene (Task 5) mounts INSIDE ArchivePage behind `webglAvailable()` — Task 5 imports nothing new from this task beyond the mount point marked below. + +- [ ] **Step 1: Write the failing WebGL-detection test** + +```ts +// src/stacks/webgl.test.ts +import { describe, it, expect } from 'vitest' +import { webglAvailable } from './webgl' + +describe('webglAvailable', () => { + it('is true when the probe yields a webgl2 context', () => { + expect(webglAvailable(() => ({}))).toBe(true) + }) + it('is false when the probe yields nothing', () => { + expect(webglAvailable(() => null)).toBe(false) + }) + it('is false when the probe throws', () => { + expect(webglAvailable(() => { throw new Error('blocked') })).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/stacks/webgl.test.ts` +Expected: FAIL — `Cannot find module './webgl'` + +- [ ] **Step 3: Implement the probe** + +```ts +// src/stacks/webgl.ts +/* WebGL2 probe. The room's canvas layer mounts only when this is + true; otherwise the ledger stands alone (the resting page is + complete either way). Injectable for tests. */ +export function webglAvailable( + create: (kind: string) => unknown = (kind) => + document.createElement('canvas').getContext(kind), +): boolean { + try { + return Boolean(create('webgl2')) + } catch { + return false + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/stacks/webgl.test.ts` +Expected: PASS (3 tests) + +- [ ] **Step 5: Build the page shell + ledger** + +```tsx +// src/pages/ArchivePage.tsx +/* THE STACKS — the back catalog as a walkable room. + The ledger (real headings + links below) is the resting + structure: complete, keyboard-first, screen-reader-first. The + drifting bodies mount on top only when WebGL2 exists. */ +import { lazy, Suspense, useMemo } from 'react' +import { Link } from 'react-router-dom' +import { ALL_ISSUES } from '../content/issues' +import { groupIntoVolumes } from '../stacks/volumes' +import { webglAvailable } from '../stacks/webgl' +import './ArchivePage.css' + +const StacksScene = lazy(() => + import('../stacks/Scene').then((m) => ({ default: m.StacksScene })), +) + +export function ArchivePage() { + const volumes = useMemo(() => groupIntoVolumes(ALL_ISSUES), []) + const walkable = useMemo(() => webglAvailable(), []) + + return ( +
+ {walkable && ( + + + + )} + +
+

+ THE STACKS · 書庫 — every issue, shelved in the dark.{' '} + Prefer the flat catalog → +

+
+ + +
+ ) +} +``` + +```css +/* src/pages/ArchivePage.css — THE STACKS room chrome. + Tokens only; the dark ground is the ink stock. */ +.stacks-room { + position: relative; + min-height: 100vh; + background: var(--pop-ink); + color: var(--pop-cream); +} +.stacks-room canvas { + position: fixed; + inset: 0; + z-index: 0; +} +.stacks-masthead { + position: relative; + z-index: 2; + padding: 24px; +} +.stacks-masthead a { color: var(--pop-cream); } +.stacks-ledger { + position: relative; + z-index: 2; + max-width: 420px; + padding: 24px; + display: flex; + flex-direction: column; + gap: 32px; +} +.stacks-volume-lockup { + display: flex; + gap: 12px; + align-items: baseline; + font-family: var(--font-mono); + font-size: 13px; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.stacks-ledger ul { list-style: none; margin: 0; padding: 0; } +.stacks-ledger a { + display: inline-block; + padding: 8px 0; + color: var(--pop-cream); + text-decoration: none; +} +.stacks-ledger a:hover, +.stacks-ledger a:focus-visible { color: var(--pop-tomato); } +``` + +- [ ] **Step 6: Wire the route and the title** + +In `src/router.tsx`, beside the other lazy page constants: + +```tsx +const ArchivePage = lazyRetry(() => import('./pages/ArchivePage').then(m => ({ default: m.ArchivePage }))) +``` + +and inside the Layout `children` array, after the `/issues` entries: + +```tsx +{ + path: '/archive', + element: withErrorBoundary( + }> + + + ), +}, +``` + +In `src/components/Layout.tsx`'s `titleForPath` switch, add: + +```tsx +case 'archive': return 'The Stacks · kernel.chat' +``` + +- [ ] **Step 7: Gates + chunk assertion** + +Run: `npx tsc --noEmit && npx vitest run && npm run build` +Expected: clean; in the build output the main `dist/assets/index-*.js` stays ≈236KB gzip (unchanged — three is not yet installed, and ArchivePage is its own chunk). + +- [ ] **Step 8: Commit** + +```bash +git add src/pages/ArchivePage.tsx src/pages/ArchivePage.css src/stacks/webgl.ts src/stacks/webgl.test.ts src/router.tsx src/components/Layout.tsx +git commit -m "feat(stacks): /archive room shell — ledger resting structure, WebGL probe, lazy route" +``` + +--- + +### Task 5: The scene — sheets, drift, descent, gates + +**Files:** +- Create: `src/stacks/Scene.tsx` +- Create: `src/stacks/coverTheme.ts` +- Modify: `package.json` (deps) + +**Interfaces:** +- Consumes: `Volume` + `groupIntoVolumes` (Task 1), `bodyFor` (Task 2 — imported and used in the body switch even though M1 has one arm), `paintCover`/`CoverTheme` (Task 3), `resolveAccentHex` from `src/content/issues/accents.ts`. +- Produces: `StacksScene({ volumes }: { volumes: Volume[] })` named export — exactly what Task 4's lazy import expects. `readCoverTheme(issue)` in `coverTheme.ts` resolves CSS custom properties to a `CoverTheme`. + +- [ ] **Step 1: Install the 3D stack (route-chunk only)** + +```bash +npm install three@^0.182.0 @react-three/fiber@^9.3.0 @react-three/drei@^10.7.0 +npm install -D @types/three@^0.182.0 +``` + +- [ ] **Step 2: Theme reader** + +```ts +// src/stacks/coverTheme.ts +/* Resolves the painter's theme from the live stylesheet — the + room borrows the exact inks the flat catalog prints with. No + hex literals here (adherence law); the tokens own the values. */ +import type { IssueRecord } from '../content/issues/schema' +import { resolveAccentHex } from '../content/issues/accents' +import type { CoverTheme } from './coverPainter' + +const STOCK_VAR: Record = { + cream: '--pop-cream', butter: '--pop-butter', kraft: '--pop-kraft', + ivory: '--pop-ivory', ink: '--pop-ink', ledger: '--pop-ledger', +} + +export function readCoverTheme(issue: IssueRecord): CoverTheme { + const css = getComputedStyle(document.documentElement) + const read = (name: string, fallbackVar: string) => + css.getPropertyValue(name).trim() || css.getPropertyValue(fallbackVar).trim() + return { + stock: read(STOCK_VAR[issue.coverStock ?? 'cream'] ?? '--pop-cream', '--pop-cream'), + ink: css.getPropertyValue('--pop-ink').trim(), + accent: resolveAccentHex(issue.accent, issue.spread?.type), + serif: css.getPropertyValue('--font-serif').trim() || 'serif', + mono: css.getPropertyValue('--font-mono').trim() || 'monospace', + } +} +``` + +(If any `--pop-butter`/`--pop-kraft`/`--pop-ledger`/`--font-serif` token does not exist in `src/index.css`, check the actual token names there first — `grep -n "pop-butter\|pop-kraft\|pop-ledger\|font-serif" src/index.css` — and use the real names; the fallback chain keeps missing stocks on cream.) + +- [ ] **Step 3: The scene** + +```tsx +// src/stacks/Scene.tsx +/* THE STACKS — the canvas layer. One vertical run of volume rooms; + page scroll drives the camera down through them. Bodies drift + unless the reader asked for stillness. Activating a body lands + on the issue's real route — the spread is the destination. */ +import { useMemo, useRef, useEffect, useState } from 'react' +import { Canvas, useFrame } from '@react-three/fiber' +import { useNavigate } from 'react-router-dom' +import * as THREE from 'three' +import type { Volume } from './volumes' +import type { IssueRecord } from '../content/issues/schema' +import { bodyFor } from './bodies' +import { paintCover, type CoverSurface } from './coverPainter' +import { readCoverTheme } from './coverTheme' + +const ROOM_HEIGHT = 14 // world units between volume rooms +const SHEET_W = 2.1 +const SHEET_H = 3 + +function coverTexture(issue: IssueRecord): THREE.CanvasTexture { + const canvas = document.createElement('canvas') + canvas.width = 512 + canvas.height = 720 + const ctx = canvas.getContext('2d')! + const surface: CoverSurface = Object.assign(ctx, { width: 512, height: 720 }) + paintCover(issue, surface, readCoverTheme(issue)) + const texture = new THREE.CanvasTexture(canvas) + texture.anisotropy = 4 + return texture +} + +/** Deterministic per-issue drift seed — no Math.random, so the + * room composes identically on every visit. */ +function seed(issue: IssueRecord, salt: number): number { + return ((Number(issue.number) * 2654435761 + salt * 40503) % 1000) / 1000 +} + +function SheetBody({ issue, home, still, onOpen }: { + issue: IssueRecord + home: [number, number, number] + still: boolean + onOpen: (n: string) => void +}) { + const mesh = useRef(null) + const texture = useMemo(() => coverTexture(issue), [issue]) + const geometry = useMemo(() => { + const g = new THREE.PlaneGeometry(SHEET_W, SHEET_H, 12, 1) + const pos = g.attributes.position + for (let i = 0; i < pos.count; i++) { + pos.setZ(i, Math.sin((pos.getX(i) / SHEET_W) * Math.PI) * 0.09) // paper curl + } + g.computeVertexNormals() + return g + }, []) + + useFrame(({ clock }) => { + if (!mesh.current) return + const t = still ? 0 : clock.elapsedTime + const [x, y, z] = home + mesh.current.position.set( + x + Math.sin(t * 0.21 + seed(issue, 1) * 6.28) * 0.25, + y + Math.cos(t * 0.17 + seed(issue, 2) * 6.28) * 0.2, + z, + ) + mesh.current.rotation.set( + Math.sin(t * 0.1 + seed(issue, 3) * 6.28) * 0.12, + Math.sin(t * 0.13 + seed(issue, 4) * 6.28) * 0.3 + seed(issue, 5) - 0.5, + 0, + ) + }) + + // bodyFor is the M2-M4 seam; every kind renders a sheet until + // its body component lands. + switch (bodyFor(issue)) { + case 'sheet': + default: + return ( + { e.stopPropagation(); onOpen(issue.number) }} + onPointerOver={() => { document.body.style.cursor = 'pointer' }} + onPointerOut={() => { document.body.style.cursor = '' }} + > + + + ) + } +} + +/** Sheet homes fan around the volume lockup, deterministic. */ +function homes(volume: Volume, roomY: number): Array<[number, number, number]> { + return volume.issues.map((issue, i) => { + const angle = (i / Math.max(volume.issues.length, 1)) * Math.PI * 2 + seed(issue, 6) * 0.8 + const radius = 3.4 + seed(issue, 7) * 1.6 + return [ + Math.cos(angle) * radius, + roomY + (seed(issue, 8) - 0.5) * 2.4, + -1.5 - seed(issue, 9) * 2.5, + ] + }) +} + +function Rig({ still }: { still: boolean }) { + useFrame(({ camera }) => { + const progress = window.scrollY / Math.max(document.body.scrollHeight - window.innerHeight, 1) + const targetY = -progress * ((document.body.dataset.stacksVolumes + ? Number(document.body.dataset.stacksVolumes) - 1 + : 0) * ROOM_HEIGHT) + camera.position.y = still + ? targetY + : camera.position.y + (targetY - camera.position.y) * 0.08 + camera.position.z = 8 + }) + return null +} + +export function StacksScene({ volumes }: { volumes: Volume[] }) { + const navigate = useNavigate() + const [still, setStill] = useState(false) + + useEffect(() => { + const query = window.matchMedia('(prefers-reduced-motion: reduce)') + setStill(query.matches) + const onChange = () => setStill(query.matches) + query.addEventListener('change', onChange) + document.body.dataset.stacksVolumes = String(volumes.length) + return () => { + query.removeEventListener('change', onChange) + delete document.body.dataset.stacksVolumes + } + }, [volumes.length]) + + return ( + + ) +} +``` + +- [ ] **Step 4: Give the page its scroll run and center the lockups** + +Append to `src/pages/ArchivePage.css`: + +```css +/* One viewport of descent per volume; the ledger scrolls with it. + The lockup echoes the canvas layer's room positions. */ +.stacks-volume { min-height: 100vh; display: flex; flex-direction: column; justify-content: center; } +@media (prefers-reduced-motion: reduce) { + .stacks-volume { min-height: 0; } +} +``` + +- [ ] **Step 5: Gates + chunk assertion** + +Run: `npx tsc --noEmit && npx vitest run && npm run build 2>&1 | grep -E "index-.*gzip|Scene|three"` +Expected: all tests pass; main `index-*.js` gzip unchanged (≈236KB); a separate chunk (containing Scene/three, several hundred KB raw) exists. If three appears in the main chunk, the lazy boundary is broken — fix before committing. + +- [ ] **Step 6: Verify in the browser (preview server, not curl)** + +Start the `preview-dist` launch config after `npm run build`. Check at `/archive`: +- Sheets drift around volume space; scroll descends; clicking a sheet lands on `/issues/N`. +- With reduced motion emulated: no drift, ledger fully usable. +- Console: zero errors. + +- [ ] **Step 7: Commit** + +```bash +git add package.json package-lock.json src/stacks/Scene.tsx src/stacks/coverTheme.ts src/pages/ArchivePage.css +git commit -m "feat(stacks): the room — drifting sheet bodies, scroll descent, issue navigation" +``` + +--- + +### Task 6: e2e, design QA, and the entry link + +**Files:** +- Create: `e2e/tests/stacks.spec.ts` +- Modify: `src/components/IssueColophon.tsx` (add the room's entry link beside "Back Issues") + +**Interfaces:** +- Consumes: the `/archive` route (Task 4/5); the colophon's existing link row. +- Produces: nothing new for later tasks — this closes M1. + +- [ ] **Step 1: Write the e2e spec** + +```ts +// e2e/tests/stacks.spec.ts +import { test, expect } from '@playwright/test' + +test.describe('THE STACKS', () => { + test('the ledger lands on the real issue route', async ({ page }) => { + await page.goto('/archive') + await page.getByRole('link', { name: /N°427/ }).click() + await expect(page).toHaveURL(/\/issues\/427/) + }) + + test('keyboard: tab reaches a ledger link and Enter opens it', async ({ page }) => { + await page.goto('/archive') + const first = page.locator('.stacks-ledger a').first() + await first.focus() + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/\/issues\/\d+/) + }) + + test('reduced motion: the room rests and stays navigable', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.goto('/archive') + await expect(page.locator('.stacks-ledger')).toBeVisible() + }) + + test('the flat catalog stays one link away', async ({ page }) => { + await page.goto('/archive') + await page.getByRole('link', { name: /flat catalog/i }).click() + await expect(page).toHaveURL(/\/issues$/) + }) +}) +``` + +- [ ] **Step 2: Run the e2e spec** + +Run: `npx playwright test e2e/tests/stacks.spec.ts` (against the preview server per the project's playwright config) +Expected: 4 passed. (If the project config expects a dev server, use the same invocation the existing `issue-415-close.spec.ts` runs under.) + +- [ ] **Step 3: Add the entry link to the colophon** + +In `src/components/IssueColophon.tsx`, in the link row that has `Back Issues`, add after it: + +```tsx +The Stacks +``` + +- [ ] **Step 4: Run the design-QA audit locally** + +Run: `npm run build && mkdir -p dist/artifacts && cp artifacts/*.html dist/artifacts/`, start `preview-dist`, then: +`node .claude/skills/kernel-chat-design-qa/scripts/audit-page.mjs http://localhost:4173/archive /audit-stacks` +Expected: exit 0; inspect desktop + mobile screenshots by eye — lockups legible, no overflow, no small controls. + +- [ ] **Step 5: Full gates** + +Run: `npm run lint:adherence && npm run lint:editorial && npx tsc --noEmit && npx vitest run && npm run build` +Expected: all clean; main bundle ≤300KB gzip. + +- [ ] **Step 6: Commit** + +```bash +git add e2e/tests/stacks.spec.ts src/components/IssueColophon.tsx +git commit -m "feat(stacks): e2e coverage, design-QA pass, colophon entry link" +``` + +--- + +### Task 7: Documentation and close-out + +**Files:** +- Modify: `KERNEL.md` (directory map: add `src/stacks/` one-liner) +- Modify: `src/content/issues/PUBLISHING.md` (one line in the site-surfaces section pointing at /archive and the M2–M4 seam in `bodies.ts`) +- Modify: `SCRATCHPAD.md` (session entry) + +- [ ] **Step 1: Add the `src/stacks/` line to KERNEL.md's directory map** + +Find the directory map section (`grep -n "src/" KERNEL.md | head`) and add, in place, matching the table/list style used there: + +``` +src/stacks/ — THE STACKS (/archive): the back catalog as a walkable room; bodies resolve in bodies.ts (M1 sheets; M2 instruments, M3 monuments, M4 scans) +``` + +- [ ] **Step 2: Add the PUBLISHING.md pointer** + +In the section that lists reader-facing surfaces (near where /issues is described), add one sentence: + +``` +The back catalog also stands as THE STACKS at /archive — a walkable +room over the same registry; the flat catalog remains first-class. +``` + +- [ ] **Step 3: SCRATCHPAD entry** + +Prepend a short session block (existing format) noting: M1 shipped on `feat/the-stacks`, the bodies.ts seam, and that M2–M4 remain per the spec. + +- [ ] **Step 4: Final gates and commit** + +```bash +npm run lint:editorial && npx tsc --noEmit && npx vitest run +git add KERNEL.md src/content/issues/PUBLISHING.md SCRATCHPAD.md +git commit -m "docs: file THE STACKS M1 — room shipped, M2-M4 seam documented" +``` + +--- + +## Verification (end-to-end, after all tasks) + +1. `npm run lint:adherence && npm run lint:editorial && npx tsc --noEmit && npx vitest run` — all clean. +2. `npm run build` — main `index-*.js` ≤300KB gzip; three only in the archive chunk. +3. Preview server: `/archive` walks (drift, descent, gates), clicking and Enter both land on `/issues/N`, reduced motion rests complete, no console errors. +4. `audit-page.mjs` against `/archive` — desktop + mobile + print, visually inspected. +5. Push `feat/the-stacks`; PR against `feat/real-urls-cloudflare` (or main once PR #62 merges). diff --git a/docs/superpowers/specs/2026-07-20-the-stacks-design.md b/docs/superpowers/specs/2026-07-20-the-stacks-design.md new file mode 100644 index 000000000..14eacf6bb --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-the-stacks-design.md @@ -0,0 +1,101 @@ +# THE STACKS — the back catalog as a walkable room + +**Date:** 2026-07-20 +**Status:** Approved (design review with Isaac, this session) +**Reference:** claygarden.jp/series (The SUKIMA project) — floating +scanned clay objects in a dark void, scroll-descent into bilingual +editorial spreads. Maker's walkthrough: "We Built a Journal You Can +Walk Through (CLAY → 3D → WEB)", Designer In Japan, YouTube oWT7il2EKAo. + +## Why + +The magazine's grammar (bilingual lockups, dossiers, plates, +series-as-issues) already matches SUKIMA's editorial layer. The delta +is one thing: a spatial, inhabitable layer over the archive. SUKIMA's +power is that its objects are real scanned clay; kernel.chat's issues +need equivalent bodies that are honest to what the magazine is. + +## Concept + +A new room at **`/archive`** — THE STACKS. Ink-stock dark. Organized +as **volumes by cover month** (the grouping the back catalog already +uses). Entering a volume: the bilingual volume lockup ("FEB 2027 · +二〇二七年二月") floats center while that month's issues drift around +it. Scrolling descends through the field to a colophon strip and a +**NEXT VOLUME gate** that walks into the previous month. Activating an +issue pushes the camera toward it and lands on the real `/issues/N` +route — the existing spread IS the descent's destination. The room is +a way in, not a duplicate archive. + +## The objects — four body kinds, one system + +Resolved by `bodyFor(issue)` (pure function over the issue record): + +| Kind | Milestone | Which issues | Body | +|---|---|---|---| +| **Sheet** | M1 (default) | all | Cover art as a printed signature: gently curved plane, paper thickness, ink sheen; back face uses back-cover art where it exists. Zero new assets. | +| **Instrument** | M2 | 419+ (artifact era) | Small glowing framed capture of the issue's artifact edition, emissive against the dark. Static capture textures, not live iframes. | +| **Monument** | M3 | milestones (360, 390, 400, 419…) | Procedural sculptural form seeded deterministically from accent + stock + layout. No hand-modeling. | +| **Scan** | M4 | when real objects exist | Photogrammetry. First clay: KERNEL PRESS Edition №001 (the physical A5 booklet). | + +## Technical approach (decided) + +**react-three-fiber + drei, entirely inside the lazy `/archive` route +chunk.** three.js (~160KB gzip) never enters the main bundle, so the +300KB main-bundle budget is untouched. Fits the existing React + Vite ++ `lazyRetry` architecture; Suspense integrates with existing loading +states. + +Rejected: vanilla three.js (manual lifecycle grows poorly with body +kinds); standalone artifact-style static page (duplicates the issue +registry outside the app, loses SPA navigation into spreads). + +## The laws, kept + +- **Motion:** reduced-motion renders the room composed and at rest — + no drift, no scroll-jacking; the resting page is complete and fully + navigable. +- **Keyboard:** every body focusable in catalog order; Enter opens the + issue; the folio always carries a plain link to `/issues` (the fast + catalog stays first-class). +- **Degradation:** no WebGL2 → graceful redirect to `/issues`. +- **Vocabulary:** stacks / volume / gate / body — never scene, canvas, + 3D viewer in user-visible copy. No emojis. Bilingual lockups. +- **Performance:** DPR capped at 2; rendering paused when the tab is + hidden; cover textures downscaled from existing art; max ~12 bodies + resident per volume room. + +## Data + +Everything derives from the existing `ALL_ISSUES` registry +(`src/content/issues`) — number, feature, featureJp, cover month, +accent, stock, cover art paths. No new content files. New pure +modules: volume grouping (month → issues, newest first) and +`bodyFor`. + +## Testing + +- Unit: volume grouping, `bodyFor` resolution, deterministic monument + seeding (M3). +- e2e: keyboard path through a volume; `/issues` fallback link; no- + WebGL redirect. +- Design QA: `audit-page.mjs` against `/archive` (desktop + mobile + + reduced motion) before ship. +- Gates: lint:adherence, lint:editorial, tsc, vitest, main bundle + ≤300KB gzip (three confined to route chunk — assert via build + output). + +## Sequence + +- **M1 — the room:** sheets + volumes + descent + gates + issue + navigation. Complete, shippable experience with one body kind. +- **M2 — instruments:** artifact-era captures. +- **M3 — monuments:** procedural milestone bodies. +- **M4 — the first scan:** Edition №001 photogrammetry. + +Each milestone independently shippable behind the same gates. + +## Dependencies + +Builds on PR #62 (`feat/real-urls-cloudflare`) — real paths are +assumed for `/archive` and camera-push navigation into `/issues/N`. diff --git a/e2e/tests/stacks.spec.ts b/e2e/tests/stacks.spec.ts new file mode 100644 index 000000000..e79762906 --- /dev/null +++ b/e2e/tests/stacks.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from '@playwright/test' + +test.describe('THE STACKS', () => { + test('the ledger lands on the real issue route', async ({ page }) => { + await page.goto('/archive') + await page.getByRole('link', { name: /N°427/ }).click() + await expect(page).toHaveURL(/\/issues\/427/) + }) + + test('keyboard: tab reaches a ledger link and Enter opens it', async ({ page }) => { + await page.goto('/archive') + const first = page.locator('.stacks-ledger a').first() + await first.focus() + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/\/issues\/\d+/) + }) + + test('reduced motion: the room rests and stays navigable', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.goto('/archive') + await expect(page.locator('.stacks-ledger')).toBeVisible() + }) + + test('the flat catalog stays one link away', async ({ page }) => { + await page.goto('/archive') + await page.getByRole('link', { name: /flat catalog/i }).click() + await expect(page).toHaveURL(/\/issues$/) + }) +}) diff --git a/package-lock.json b/package-lock.json index 8b3a62a45..5d20fc404 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "@capacitor/haptics": "^8.0.1", "@capacitor/ios": "^8.1.0", "@capacitor/push-notifications": "^8.0.1", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.6.1", "@sentry/react": "^10.40.0", "@supabase/supabase-js": "^2.110.7", "docx": "^9.7.1", @@ -30,6 +32,7 @@ "react-i18next": "^16.5.4", "react-markdown": "^10.1.0", "react-router-dom": "^7.18.1", + "three": "^0.182.0", "zustand": "^5.0.11" }, "devDependencies": { @@ -42,6 +45,7 @@ "@types/prismjs": "^1.26.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/three": "^0.182.0", "@vitejs/plugin-react": "^4.7.0", "blessed": "^0.1.81", "canvas": "^3.2.1", @@ -1992,6 +1996,12 @@ "node": ">=20.19.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@discordjs/builders": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", @@ -3326,6 +3336,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.27.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", @@ -3367,6 +3383,18 @@ } } }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3498,6 +3526,118 @@ "streamx": "^2.15.0" } }, + "node_modules/@react-three/drei": { + "version": "10.7.7", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", + "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz", + "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -4284,6 +4424,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -4374,6 +4520,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4431,6 +4583,12 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/prismjs": { "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", @@ -4457,6 +4615,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -4470,6 +4637,33 @@ "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", "license": "MIT" }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", + "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.22.0" + } + }, + "node_modules/@types/three/node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4483,6 +4677,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -4510,6 +4710,24 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -4655,6 +4873,12 @@ "npm": ">=7.0.0" } }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "license": "BSD-3-Clause" + }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -5087,7 +5311,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -5349,6 +5572,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001779", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", @@ -5647,6 +5883,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-fetch": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", @@ -5963,6 +6217,15 @@ "node": ">=6" } }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -6109,6 +6372,12 @@ "url": "https://dotenvx.com" } }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -7291,6 +7560,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -7444,6 +7719,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hls.js": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", + "license": "Apache-2.0" + }, "node_modules/hono": { "version": "4.12.31", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", @@ -7621,7 +7902,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -8264,6 +8544,18 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, "node_modules/jackspeak": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", @@ -8573,6 +8865,16 @@ "lz-string": "bin/bin.js" } }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, "node_modules/magic-bytes.js": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", @@ -8819,6 +9121,21 @@ "node": ">= 8" } }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -10072,6 +10389,12 @@ "web-vitals": "^5.3.0" } }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, "node_modules/preact": { "version": "10.29.7", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", @@ -10197,6 +10520,22 @@ "node": ">=0.4.0" } }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/promise-worker-transferable/node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -10557,6 +10896,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -10720,7 +11074,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11487,6 +11840,32 @@ "dev": true, "license": "MIT" }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -11758,6 +12137,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -11927,6 +12315,44 @@ "b4a": "^1.6.4" } }, + "node_modules/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", + "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz", + "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==", + "license": "MIT" + }, "node_modules/through2": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", @@ -12135,6 +12561,36 @@ "node": ">=0.10.0" } }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, "node_modules/trough": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", @@ -12171,6 +12627,43 @@ "node": "*" } }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/type-fest": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", @@ -12562,6 +13055,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -12882,6 +13384,17 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/package.json b/package.json index 77567d217..620ab67be 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test": "vitest run", "test:e2e": "playwright test", "test:e2e:update": "playwright test --update-snapshots", - "deploy": "echo 'RETIRED (ISSUE 399): main is the only publisher \u2014 push to main and CI deploys (.github/workflows/deploy.yml, ~90s). Emergency: gh workflow run deploy.yml. See PUBLISHING.md \u00a7VII.' && exit 1", + "deploy": "echo 'RETIRED (ISSUE 399): main is the only publisher — push to main and CI deploys (.github/workflows/deploy.yml, ~90s). Emergency: gh workflow run deploy.yml. See PUBLISHING.md §VII.' && exit 1", "monitor": "npx tsx tools/kernel-monitor.ts", "discord": "npx tsx tools/discord-bot.ts", "slack": "npx tsx tools/slack-bot.ts", @@ -54,6 +54,8 @@ "@capacitor/haptics": "^8.0.1", "@capacitor/ios": "^8.1.0", "@capacitor/push-notifications": "^8.0.1", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.6.1", "@sentry/react": "^10.40.0", "@supabase/supabase-js": "^2.110.7", "docx": "^9.7.1", @@ -69,6 +71,7 @@ "react-i18next": "^16.5.4", "react-markdown": "^10.1.0", "react-router-dom": "^7.18.1", + "three": "^0.182.0", "zustand": "^5.0.11" }, "overrides": { @@ -87,6 +90,7 @@ "@types/prismjs": "^1.26.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/three": "^0.182.0", "@vitejs/plugin-react": "^4.7.0", "blessed": "^0.1.81", "canvas": "^3.2.1", diff --git a/src/components/IssueColophon.tsx b/src/components/IssueColophon.tsx index ed7b196b2..301574f3a 100644 --- a/src/components/IssueColophon.tsx +++ b/src/components/IssueColophon.tsx @@ -40,6 +40,7 @@ export function IssueColophon({ issue }: IssueColophonProps) { About Made to Order Back Issues + The Stacks Figma Spec The Refusals Privacy diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 3155eb38c..72e2b05cd 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -28,6 +28,7 @@ function titleForPath(pathname: string): string { : `${issueLabel(param)} · kernel.chat` case 'launch': return `${issueLabel(param)} · Launch · kernel.chat` + case 'archive': return 'The Stacks · kernel.chat' case 'refusals': return 'Refusals · kernel.chat' case 'pressroom': return 'The Pressroom · kernel.chat' case 'about': return 'About · kernel.chat' diff --git a/src/content/issues/PUBLISHING.md b/src/content/issues/PUBLISHING.md index 0eacf3485..fb485bec5 100644 --- a/src/content/issues/PUBLISHING.md +++ b/src/content/issues/PUBLISHING.md @@ -355,6 +355,12 @@ Browse: - `/issues/` — the prior issue, which now shows as `PREVIOUSLY` - Check mobile breakpoint (≤640px) — the cover should still read +The back catalog also stands as THE STACKS at `/archive` — a walkable +room over the same registry; the flat catalog at `/issues` remains +first-class. New issues need no extra work to appear there: bodies +resolve from `ALL_ISSUES` via `src/stacks/bodies.ts`, which is the one +seam later milestones (instruments, monuments, scans) extend. + --- ## VII. Publishing diff --git a/src/pages/ArchivePage.css b/src/pages/ArchivePage.css new file mode 100644 index 000000000..75a6165bf --- /dev/null +++ b/src/pages/ArchivePage.css @@ -0,0 +1,59 @@ +/* src/pages/ArchivePage.css — THE STACKS room chrome. + Tokens only; the dark ground is the ink stock. */ +.stacks-room { + position: relative; + min-height: 100vh; + background: var(--pop-ink); + color: var(--pop-cream); +} +.stacks-room canvas { + position: fixed; + inset: 0; + z-index: 0; +} +.stacks-masthead { + position: relative; + z-index: 2; + padding: 24px; +} +.stacks-masthead a { color: var(--pop-cream); } +.stacks-ledger { + position: relative; + z-index: 2; + max-width: 420px; + padding: 24px; + display: flex; + flex-direction: column; + gap: 32px; +} +.stacks-volume-lockup { + display: flex; + gap: 12px; + align-items: baseline; + font-family: var(--font-mono); + font-size: 13px; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.stacks-ledger ul { list-style: none; margin: 0; padding: 0; } +.stacks-ledger a { + display: inline-block; + padding: 8px 0; + color: var(--pop-cream); + text-decoration: none; +} +.stacks-ledger a:hover, +.stacks-ledger a:focus-visible { color: var(--pop-tomato); } + +/* One viewport of descent per volume; the ledger scrolls with it. + The lockup echoes the canvas layer's room positions. */ +.stacks-volume { min-height: 100vh; display: flex; flex-direction: column; justify-content: center; } +@media (prefers-reduced-motion: reduce) { + .stacks-volume { min-height: 0; } +} + +/* .pop-folio hardcodes ink-on-cream; the room is ink-on-ink, so + override it here rather than in the shared editorial stylesheet. */ +.stacks-room .pop-folio { + color: var(--pop-cream); +} diff --git a/src/pages/ArchivePage.tsx b/src/pages/ArchivePage.tsx new file mode 100644 index 000000000..4bac1805f --- /dev/null +++ b/src/pages/ArchivePage.tsx @@ -0,0 +1,57 @@ +/* THE STACKS — the back catalog as a walkable room. + The ledger (real headings + links below) is the resting + structure: complete, keyboard-first, screen-reader-first. The + drifting bodies mount on top only when WebGL2 exists. */ +import { lazy, Suspense, useMemo } from 'react' +import { Link } from 'react-router-dom' +import { ALL_ISSUES } from '../content/issues' +import { groupIntoVolumes } from '../stacks/volumes' +import { webglAvailable } from '../stacks/webgl' +import './ArchivePage.css' + +const StacksScene = lazy(() => + import('../stacks/Scene').then((m) => ({ default: m.StacksScene })), +) + +export function ArchivePage() { + const volumes = useMemo(() => groupIntoVolumes(ALL_ISSUES), []) + const walkable = useMemo(() => webglAvailable(), []) + + return ( +
+ {walkable && ( + + + + )} + +
+

+ THE STACKS · 書庫 — every issue, shelved in the dark.{' '} + Prefer the flat catalog → +

+
+ + +
+ ) +} diff --git a/src/router.tsx b/src/router.tsx index e0855425e..282290eb8 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -23,6 +23,7 @@ const CreativeCanvasPage = lazyRetry(() => import('./pages/CreativeCanvasPage'). const MotionSheetPage = lazyRetry(() => import('./pages/MotionSheetPage').then(m => ({ default: m.MotionSheetPage }))) const PalmierSuitePage = lazyRetry(() => import('./pages/PalmierSuitePage').then(m => ({ default: m.PalmierSuitePage }))) const AtelierPage = lazyRetry(() => import('./pages/AtelierPage').then(m => ({ default: m.AtelierPage }))) +const ArchivePage = lazyRetry(() => import('./pages/ArchivePage').then(m => ({ default: m.ArchivePage }))) function withErrorBoundary(element: React.ReactNode) { return {element} @@ -103,6 +104,14 @@ export const router = createBrowserRouter([ ) }, + { + path: 'archive', + element: withErrorBoundary( + }> + + + ), + }, { path: 'launch/:number', element: withErrorBoundary( Loading the launch...}> diff --git a/src/stacks/Scene.tsx b/src/stacks/Scene.tsx new file mode 100644 index 000000000..e8f863306 --- /dev/null +++ b/src/stacks/Scene.tsx @@ -0,0 +1,166 @@ +/* THE STACKS — the canvas layer. One vertical run of volume rooms; + page scroll drives the camera down through them. Bodies drift + unless the reader asked for stillness. Activating a body lands + on the issue's real route — the spread is the destination. */ +import { useMemo, useRef, useEffect, useState } from 'react' +import { Canvas, useFrame } from '@react-three/fiber' +import { useNavigate } from 'react-router-dom' +import * as THREE from 'three' +import type { Volume } from './volumes' +import type { IssueRecord } from '../content/issues/schema' +import { bodyFor } from './bodies' +import { paintCover, type CoverSurface } from './coverPainter' +import { readCoverTheme } from './coverTheme' + +const ROOM_HEIGHT = 14 // world units between volume rooms +const SHEET_W = 2.1 +const SHEET_H = 3 + +function coverTexture(issue: IssueRecord): THREE.CanvasTexture { + const canvas = document.createElement('canvas') + canvas.width = 512 + canvas.height = 720 + const ctx = canvas.getContext('2d')! + const surface = Object.assign(ctx, { width: canvas.width, height: canvas.height }) as unknown as CoverSurface + paintCover(issue, surface, readCoverTheme(issue)) + const texture = new THREE.CanvasTexture(canvas) + texture.anisotropy = 4 + return texture +} + +/** Deterministic per-issue drift seed — no Math.random, so the + * room composes identically on every visit. */ +function seed(issue: IssueRecord, salt: number): number { + return ((Number(issue.number) * 2654435761 + salt * 40503) % 1000) / 1000 +} + +function SheetBody({ issue, home, still, onOpen }: { + issue: IssueRecord + home: [number, number, number] + still: boolean + onOpen: (n: string) => void +}) { + const mesh = useRef(null) + const texture = useMemo(() => coverTexture(issue), [issue]) + const geometry = useMemo(() => { + const g = new THREE.PlaneGeometry(SHEET_W, SHEET_H, 12, 1) + const pos = g.attributes.position + for (let i = 0; i < pos.count; i++) { + pos.setZ(i, Math.sin((pos.getX(i) / SHEET_W) * Math.PI) * 0.09) // paper curl + } + g.computeVertexNormals() + return g + }, []) + + useEffect(() => { + return () => { + texture.dispose() + geometry.dispose() + } + }, [texture, geometry]) + + useFrame(({ clock }) => { + if (!mesh.current) return + const t = still ? 0 : clock.elapsedTime + const [x, y, z] = home + mesh.current.position.set( + x + Math.sin(t * 0.21 + seed(issue, 1) * 6.28) * 0.25, + y + Math.cos(t * 0.17 + seed(issue, 2) * 6.28) * 0.2, + z, + ) + mesh.current.rotation.set( + Math.sin(t * 0.1 + seed(issue, 3) * 6.28) * 0.12, + Math.sin(t * 0.13 + seed(issue, 4) * 6.28) * 0.3 + seed(issue, 5) - 0.5, + 0, + ) + }) + + // bodyFor is the M2-M4 seam; every kind renders a sheet until + // its body component lands. + switch (bodyFor(issue)) { + case 'sheet': + default: + return ( + { e.stopPropagation(); onOpen(issue.number) }} + onPointerOver={() => { document.body.style.cursor = 'pointer' }} + onPointerOut={() => { document.body.style.cursor = '' }} + > + + + ) + } +} + +/** Sheet homes fan around the volume lockup, deterministic. */ +function homes(volume: Volume, roomY: number): Array<[number, number, number]> { + return volume.issues.map((issue, i) => { + const angle = (i / Math.max(volume.issues.length, 1)) * Math.PI * 2 + seed(issue, 6) * 0.8 + const radius = 3.4 + seed(issue, 7) * 1.6 + return [ + Math.cos(angle) * radius, + roomY + (seed(issue, 8) - 0.5) * 2.4, + -1.5 - seed(issue, 9) * 2.5, + ] + }) +} + +function Rig({ still }: { still: boolean }) { + useFrame(({ camera }) => { + const progress = window.scrollY / Math.max(document.body.scrollHeight - window.innerHeight, 1) + const targetY = -progress * ((document.body.dataset.stacksVolumes + ? Number(document.body.dataset.stacksVolumes) - 1 + : 0) * ROOM_HEIGHT) + camera.position.y = still + ? targetY + : camera.position.y + (targetY - camera.position.y) * 0.08 + camera.position.z = 8 + }) + return null +} + +export function StacksScene({ volumes }: { volumes: Volume[] }) { + const navigate = useNavigate() + const [still, setStill] = useState(false) + + useEffect(() => { + const query = window.matchMedia('(prefers-reduced-motion: reduce)') + setStill(query.matches) + const onChange = () => setStill(query.matches) + query.addEventListener('change', onChange) + document.body.dataset.stacksVolumes = String(volumes.length) + return () => { + query.removeEventListener('change', onChange) + delete document.body.dataset.stacksVolumes + } + }, [volumes.length]) + + return ( + + ) +} diff --git a/src/stacks/bodies.test.ts b/src/stacks/bodies.test.ts new file mode 100644 index 000000000..5f2168efc --- /dev/null +++ b/src/stacks/bodies.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest' +import { bodyFor } from './bodies' +import type { IssueRecord } from '../content/issues/schema' + +describe('bodyFor', () => { + it('resolves every issue to a sheet in M1', () => { + expect(bodyFor({ number: '360' } as IssueRecord)).toBe('sheet') + expect(bodyFor({ number: '427' } as IssueRecord)).toBe('sheet') + }) +}) diff --git a/src/stacks/bodies.ts b/src/stacks/bodies.ts new file mode 100644 index 000000000..18e86fb6d --- /dev/null +++ b/src/stacks/bodies.ts @@ -0,0 +1,12 @@ +/* THE STACKS — body resolution. + Every issue floats as a body of one kind. M1 ships sheets only; + instruments (419+ artifact captures), monuments (milestone + sculptural forms), and scans (photogrammetry) land in M2–M4 by + extending this one resolver. */ +import type { IssueRecord } from '../content/issues/schema' + +export type BodyKind = 'sheet' | 'instrument' | 'monument' | 'scan' + +export function bodyFor(_issue: IssueRecord): BodyKind { + return 'sheet' +} diff --git a/src/stacks/coverPainter.test.ts b/src/stacks/coverPainter.test.ts new file mode 100644 index 000000000..b064e48ab --- /dev/null +++ b/src/stacks/coverPainter.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import { paintCover, type CoverSurface, type CoverTheme } from './coverPainter' +import type { IssueRecord } from '../content/issues/schema' + +function recorder(width = 512, height = 720) { + const ops: Array> = [] + const surface: CoverSurface = { + width, height, + fillStyle: '', font: '', textAlign: 'left', + fillRect(x, y, w, h) { ops.push({ op: 'rect', x, y, w, h, fill: this.fillStyle }) }, + fillText(text, x, y) { ops.push({ op: 'text', text, x, y, fill: this.fillStyle, font: this.font }) }, + } + return { surface, ops } +} + +const theme: CoverTheme = { + stock: 'var-stock', ink: 'var-ink', accent: 'var-accent', + serif: 'TestSerif', mono: 'TestMono', +} + +const issue = { + number: '427', month: 'FEB', year: '2027', price: '¥0', + featureJp: 'モートは現実', + headline: { prefix: 'The moat', emphasis: 'is reality', suffix: '.', swash: '' }, +} as IssueRecord + +describe('paintCover', () => { + it('grounds the sheet in the stock colour, full bleed', () => { + const { surface, ops } = recorder() + paintCover(issue, surface, theme) + expect(ops[0]).toMatchObject({ op: 'rect', x: 0, y: 0, w: 512, h: 720, fill: 'var-stock' }) + }) + + it('sets the emphasis word in the accent, the rest in ink', () => { + const { surface, ops } = recorder() + paintCover(issue, surface, theme) + const texts = ops.filter((o) => o.op === 'text') + expect(texts.find((o) => o.text === 'is reality')?.fill).toBe('var-accent') + expect(texts.find((o) => o.text === 'The moat')?.fill).toBe('var-ink') + }) + + it('carries the folio: catalogue number, dateline, and the JP feature line', () => { + const { surface, ops } = recorder() + paintCover(issue, surface, theme) + const texts = ops.filter((o) => o.op === 'text').map((o) => o.text) + expect(texts).toContain('N°427') + expect(texts).toContain('FEB 2027 · ¥0') + expect(texts).toContain('モートは現実') + }) + + it('uses the mono family for folio rows and the serif for the headline', () => { + const { surface, ops } = recorder() + paintCover(issue, surface, theme) + const texts = ops.filter((o) => o.op === 'text') + expect(String(texts.find((o) => o.text === 'N°427')?.font)).toContain('TestMono') + expect(String(texts.find((o) => o.text === 'The moat')?.font)).toContain('TestSerif') + }) +}) diff --git a/src/stacks/coverPainter.ts b/src/stacks/coverPainter.ts new file mode 100644 index 000000000..8efb04503 --- /dev/null +++ b/src/stacks/coverPainter.ts @@ -0,0 +1,56 @@ +/* THE STACKS — typographic cover painter. + Issues have no image covers; the cover IS type on stock. This + paints that grammar onto any 2D surface (a CanvasTexture at + runtime, a recorder in tests). Colors and font families arrive + resolved in the theme — this module never names a hex. */ +import type { IssueRecord } from '../content/issues/schema' + +export interface CoverTheme { + stock: string + ink: string + accent: string + serif: string + mono: string +} + +export interface CoverSurface { + width: number + height: number + fillStyle: string + font: string + textAlign: CanvasTextAlign + fillRect(x: number, y: number, w: number, h: number): void + fillText(text: string, x: number, y: number): void +} + +export function paintCover(issue: IssueRecord, s: CoverSurface, t: CoverTheme): void { + const u = s.width / 100 // layout unit + + // Ground: full-bleed stock. + s.fillStyle = t.stock + s.fillRect(0, 0, s.width, s.height) + + // Folio row — mono caps. + s.font = `${3 * u}px ${t.mono}` + s.textAlign = 'left' + s.fillStyle = t.ink + s.fillText('kernel.chat', 6 * u, 9 * u) + s.fillText(`N°${issue.number}`, 6 * u, 14 * u) + s.textAlign = 'right' + s.fillText(`${issue.month} ${issue.year} · ${issue.price}`, 94 * u, 9 * u) + + // Headline — serif, emphasis word in the issue accent. + s.textAlign = 'left' + s.font = `700 ${9 * u}px ${t.serif}` + const baseline = s.height * 0.52 + s.fillStyle = t.ink + s.fillText(issue.headline.prefix, 6 * u, baseline) + s.fillStyle = t.accent + s.fillText(issue.headline.emphasis, 6 * u, baseline + 11 * u) + s.fillStyle = t.ink + s.fillText(issue.headline.suffix, 6 * u, baseline + 22 * u) + + // JP feature line — mono, under the headline block. + s.font = `${3.4 * u}px ${t.mono}` + s.fillText(issue.featureJp, 6 * u, baseline + 30 * u) +} diff --git a/src/stacks/coverTheme.ts b/src/stacks/coverTheme.ts new file mode 100644 index 000000000..9f8a7c5fe --- /dev/null +++ b/src/stacks/coverTheme.ts @@ -0,0 +1,24 @@ +/* Resolves the painter's theme from the live stylesheet — the + room borrows the exact inks the flat catalog prints with. No + hex literals here (adherence law); the tokens own the values. */ +import type { IssueRecord } from '../content/issues/schema' +import { resolveAccentHex } from '../content/issues/accents' +import type { CoverTheme } from './coverPainter' + +const STOCK_VAR: Record = { + cream: '--pop-cream', butter: '--pop-butter', kraft: '--pop-kraft', + ivory: '--pop-ivory', ink: '--pop-ink', ledger: '--pop-ledger', +} + +export function readCoverTheme(issue: IssueRecord): CoverTheme { + const css = getComputedStyle(document.documentElement) + const read = (name: string, fallbackVar: string) => + css.getPropertyValue(name).trim() || css.getPropertyValue(fallbackVar).trim() + return { + stock: read(STOCK_VAR[issue.coverStock ?? 'cream'] ?? '--pop-cream', '--pop-cream'), + ink: css.getPropertyValue('--pop-ink').trim(), + accent: resolveAccentHex(issue.accent, issue.spread?.type), + serif: css.getPropertyValue('--font-serif').trim() || 'serif', + mono: css.getPropertyValue('--font-mono').trim() || 'monospace', + } +} diff --git a/src/stacks/volumes.test.ts b/src/stacks/volumes.test.ts new file mode 100644 index 000000000..d0bd4853c --- /dev/null +++ b/src/stacks/volumes.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest' +import { groupIntoVolumes } from './volumes' +import type { IssueRecord } from '../content/issues/schema' + +const issue = (number: string, month: string, year: string) => + ({ number, month, year }) as IssueRecord + +describe('groupIntoVolumes', () => { + it('groups by cover month, volumes and issues newest-first', () => { + const vols = groupIntoVolumes([ + issue('425', 'DEC', '2026'), + issue('426', 'JAN', '2027'), + issue('427', 'FEB', '2027'), + ]) + expect(vols.map((v) => v.label)).toEqual(['FEB 2027', 'JAN 2027', 'DEC 2026']) + expect(vols[0].issues.map((i) => i.number)).toEqual(['427']) + }) + + it('keeps multiple issues of one month newest-first', () => { + const vols = groupIntoVolumes([ + issue('398', 'JUL', '2026'), + issue('399', 'JUL', '2026'), + ]) + expect(vols).toHaveLength(1) + expect(vols[0].issues.map((i) => i.number)).toEqual(['399', '398']) + }) + + it('renders the Japanese volume label with kanji digits', () => { + const [v] = groupIntoVolumes([issue('427', 'FEB', '2027')]) + expect(v.labelJp).toBe('二〇二七年二月') + }) + + it('handles all twelve month abbreviations', () => { + const months = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'] + const vols = groupIntoVolumes(months.map((m, i) => issue(String(360 + i), m, '2026'))) + expect(vols[0].labelJp.endsWith('十二月')).toBe(true) // DEC sorts newest + expect(vols[11].labelJp.endsWith('一月')).toBe(true) // JAN sorts oldest + }) + + it('normalizes full-word months to their 3-letter abbreviation (registry has APRIL/JUNE alongside APR/JUN)', () => { + const vols = groupIntoVolumes([issue('370', 'APRIL', '2026')]) + expect(vols[0].label).toBe('APR 2026') + expect(vols[0].labelJp).toBe('二〇二六年四月') + }) + + it('groups a full-word month issue with its abbreviated-month siblings, not into a separate bucket', () => { + const vols = groupIntoVolumes([ + issue('392', 'JUNE', '2026'), + issue('395', 'JUN', '2026'), + ]) + expect(vols).toHaveLength(1) + expect(vols[0].label).toBe('JUN 2026') + expect(vols[0].issues.map((i) => i.number)).toEqual(['395', '392']) + }) +}) diff --git a/src/stacks/volumes.ts b/src/stacks/volumes.ts new file mode 100644 index 000000000..7b8908d89 --- /dev/null +++ b/src/stacks/volumes.ts @@ -0,0 +1,58 @@ +/* THE STACKS — volume grouping. + A volume is one cover month of the catalog; the room walks + newest volume to oldest. Pure over the registry so it tests + without the renderer. */ +import type { IssueRecord } from '../content/issues/schema' + +export interface Volume { + /** EN lockup, e.g. 'FEB 2027' */ + label: string + /** JP lockup with kanji digits, e.g. '二〇二七年二月' */ + labelJp: string + issues: IssueRecord[] +} + +const MONTH_ORDER = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'] +const MONTH_JP = ['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'] +const DIGIT_JP: Record = { + '0': '〇', '1': '一', '2': '二', '3': '三', '4': '四', + '5': '五', '6': '六', '7': '七', '8': '八', '9': '九', +} + +function yearJp(year: string): string { + return year.split('').map((d) => DIGIT_JP[d] ?? d).join('') +} + +/** The registry is not uniform: most issues carry a 3-letter month + * ('JUL'), but a run of 18 April issues and 2 June issues spell the + * month out in full ('APRIL', 'JUNE'). Every full name's first three + * letters equal the standard abbreviation, so normalize on that + * instead of touching twenty content files. */ +function normalizeMonth(month: string): string { + return month.toUpperCase().slice(0, 3) +} + +function monthIndex(month: string): number { + return MONTH_ORDER.indexOf(normalizeMonth(month)) +} + +export function groupIntoVolumes(issues: IssueRecord[]): Volume[] { + const byKey = new Map() + for (const issue of issues) { + const key = `${issue.year}-${String(monthIndex(issue.month)).padStart(2, '0')}` + const bucket = byKey.get(key) + if (bucket) bucket.push(issue) + else byKey.set(key, [issue]) + } + return [...byKey.entries()] + .sort(([a], [b]) => (a < b ? 1 : -1)) + .map(([, group]) => { + const sorted = [...group].sort((a, b) => Number(b.number) - Number(a.number)) + const { month, year } = sorted[0] + return { + label: `${normalizeMonth(month)} ${year}`, + labelJp: `${yearJp(year)}年${MONTH_JP[monthIndex(month)]}`, + issues: sorted, + } + }) +} diff --git a/src/stacks/webgl.test.ts b/src/stacks/webgl.test.ts new file mode 100644 index 000000000..5224373c4 --- /dev/null +++ b/src/stacks/webgl.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest' +import { webglAvailable } from './webgl' + +describe('webglAvailable', () => { + it('is true when the probe yields a webgl2 context', () => { + expect(webglAvailable(() => ({}))).toBe(true) + }) + it('is false when the probe yields nothing', () => { + expect(webglAvailable(() => null)).toBe(false) + }) + it('is false when the probe throws', () => { + expect(webglAvailable(() => { throw new Error('blocked') })).toBe(false) + }) +}) diff --git a/src/stacks/webgl.ts b/src/stacks/webgl.ts new file mode 100644 index 000000000..8965431fd --- /dev/null +++ b/src/stacks/webgl.ts @@ -0,0 +1,13 @@ +/* WebGL2 probe. The room's canvas layer mounts only when this is + true; otherwise the ledger stands alone (the resting page is + complete either way). Injectable for tests. */ +export function webglAvailable( + create: (kind: string) => unknown = (kind) => + document.createElement('canvas').getContext(kind), +): boolean { + try { + return Boolean(create('webgl2')) + } catch { + return false + } +}