Skip to content

Latest commit

 

History

History
180 lines (124 loc) · 38.9 KB

File metadata and controls

180 lines (124 loc) · 38.9 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

cheoma (처마) — a procedural Joseon-era Korean architecture & village generator in three.js. Parametric hanok (칸 system, 공포, 팔작지붕 curvature), auto-composed villages (배산임수 terrain, 필지·담장·고샅, 다랑이 논·개울, 산사), a scale continuum from a lone house to a walled capital (한양) with multi-곽 palaces, plus time/season/weather and a focus zoom continuum. Live at cheoma.midagedev.com.

Project goals

Five axes. They are the priority test for any task: work that does not advance one of them does not get picked up.

Axis 5 is the current active direction (user decision 2026-08-08). Axis 2 is achieved — the three.js community release happened. Axes 1/3/4 stay in force as quality bars, not as a queue of new work.

  1. The house — detail, editing, and regeneration. Not just something to look at: the quality of touching it and changing it. Close-up hanok fidelity, the edit panel, and rebuild/reroll all count as one axis.
  2. A clip impressive enough to go viral in the three.js community. ✅ Achieved 2026-08-08. That is why this repo originally exists; the reference deliverable was a single shot worth sharing. Preserved here because the look bar it set is still the standard — but a proposal may no longer justify itself by "this would help the clip".
  3. Oriental visual language as the selling point. Golden hour, the atmospheric haze of a mountain valley, the 처마 line — lean into that aesthetic rather than treating it as decoration.
  4. Joseon authenticity at study-material quality. Someone learning hanok should be able to use this as reference, so historical grounding is a quality bar, not trim.
  5. Reusability — other people building games can pick this up. The generator becomes a module and a tool: package boundaries, a CLI, and an agent-loadable skill, so a game developer (working with a coding agent) can consume the plan layer as JSON, the collision solids as data, and the geometry as three.js or glTF. The plan is docs/packaging-plan.md; the design axis is that the primary product is a JSON map contract, not a baked mesh, because an agent can read a 46 KB village plan but cannot see a 3D scene.

The visual genre is fixed and documented in docs/look-grammar.md: painterly stylization unified by light and atmosphere — not cartoon, not low-poly showcase, not realism. Every look-affecting change is judged against that grammar (silhouette-first geometry, saturation discipline, everything participates in the atmosphere).

Two-layer boundary (read this first)

  • src/ — the framework-agnostic ES-module core (pure three.js): all generation, rendering, environment, animation, export. Imports bare three. Never import Svelte or anything from app/ into src/.
  • app/ — a Svelte 5 + Vite SPA that consumes the core through src/api/ only. app/src/engine/engine.js is the imperative wrapper: it wires the core into one three.js scene and exposes window.__engine. Svelte components drive that imperative API only — they hold no three.js state of their own.

three is pinned to 0.185.1. app/vite.config.js aliases bare three → app/node_modules (with dedupe) and sets server.fs.allow = repoRoot so the app can import ../src. A second three instance silently breaks instanceof checks and prototype patches (e.g. accelerated raycast).

Measured caveat (2026-08-08): "framework-agnostic" currently holds in full only for the plan layer. three is not installed at the repo root — only under app/node_modules — so importing a three-touching core module in plain node fails with Cannot find package 'three' imported from src/builder/index.js. That vite alias has been standing in for a real dependency declaration. The plan layer is genuinely portable (26 of 47 src/api/ façades load in node with no three; planVillage() runs there). Fixing this is docs/packaging-plan.md P0.

Commands

cd app
npm install
npm run dev      # vite dev server (default :5173)
npm run build    # → app/dist   (build.target es2022, assetsInlineLimit 0)

Repository contract gates run from the root:

npm run check          # BLOCKING core invariants only: architecture boundary + plan goldens + runner self-test (+ scene share) — ~20s
npm run check:deep     # the full pure suite (former `check`, ~100 feature gates) — opt-in, never blocks a commit
npm run check:pr       # changed-file router: core + affected feature/browser/worker gates
npm run check:app      # isolated full-app browser smoke
npm run check:worker   # sync / real Worker / fallback scene + picking contracts
npm run check:all      # all repository contract groups
npm run check:full     # all + DoF/LOD app flows + production build

Gate policy (user decision 2026-08-02 — "게이트 대폭 축소"): only cross-cutting invariants block commits and CI (npm run check = 3 core contracts). All per-feature numeric gates were demoted to opt-in: a round touching a domain runs its own gate directly or via check:pr routing; check:deep exists for occasional whole-suite sweeps. Feature gate files were kept (not deleted), so any of them can be re-promoted by adding it to CORE_CHECKS in tools/lib/fast-checks.mjs. Do not add new gates to the blocking core without a user decision; new feature gates register in FAST_CHECKS (deep/routing) as before.

There is no unit-test framework, linter, typechecker, or formatter (no eslint/prettier/tsconfig — don't hunt for npm run lint/test). Since nothing typechecks the JS, use npx esbuild <file> --bundle --format=esm --outfile=/dev/null as a fast syntax check before running a harness. Verification is visual/behavioral via Playwright: tools/*.mjs each spin up their own static HTTP server, drive headless Chromium, and write PNG screenshots. Playwright is a repo-root devDependency (root package.json — separate from app/), so run the tools with plain node:

npm install                        # at repo root, one-time (chromium reuses the shared Playwright cache)
node tools/shoot-<feature>.mjs

For normal iteration, start with npm run check:pr -- --dry-run, then run npm run check:pr. The router selects the pure feature gates affected by the changed paths, unions the affected browser/worker gates, and fails closed to check:full for unknown paths, verification tooling, dependency manifests, or an unresolved merge base. Runners execute with bounded parallelism (CHEOMA_CHECK_JOBS, default up to 4). The commit boundary is npm run check (core invariants, ~20s) — check:deep/check:full are occasional sweeps, not per-round requirements.

Canonical browser harnesses use CHEOMA_BROWSER=auto: they prefer an installed Chrome, which may use the host GPU, and fall back to Playwright's bundled Chromium. Use CHEOMA_BROWSER=chrome or CHEOMA_BROWSER=chromium to require one backend. Harnesses using this launcher log the selected browser and, when applicable, the WebGL renderer; never compare wall time across different backends. check:worker deliberately stays on Playwright-pinned Chromium because its byte goldens include browser-runtime floating-point behavior and it does not render WebGL.

For a deterministic build snapshot use a clean build (rm -rf dist && vite build) — repeated incremental builds to a dedicated outDir can corrupt output (boot-time null uniforms). When spinning up an extra dev server for isolated verification, bind host: '127.0.0.1' (vite defaults to IPv6 ::1, which Playwright's 127.0.0.1 refuses) with its own cacheDir; leave the user's own dev server alone.

Harnesses & runtime flags

The core runs standalone from the repo-root index.html (plus per-domain harnesses layout.html, props.html, seasons.html, audio.html). Subsystems expose URL params / window.__* hooks for isolated testing — e.g. ?post=0 (disable the post composer), ?worker=0 or window.__villageSync (synchronous village gen), ?rim=pass, window.__wx, window.__viewshift. Prefer a direct-import harness over the full app path when verifying an env/ change (the app path breaks often mid-refactor).

Architecture

Rendering — the flagship look (src/env/post.js): a unified EffectComposer, on by default (?post=0 disables). The full-app pass order is Render → Grade/Rim → Bokeh → Bloom → Flare → Outline → Output (optical defocus forms the aperture image before sensor-like bloom adds its halo). Output stays last so ACES tone mapping and sRGB conversion happen once, after the linear-HDR effects. The signature look is golden-hour backlit rim + bloom haze. The rim is a Fresnel material patch (src/env/rim.js) applied to role-tagged materials, not a screen-space normal pass.

Building types & materials: 궁(palace) / 절(temple) / 기와집(giwa) / 초가(choga). 단청 (dancheong) is type-dependent — palace & temple, plus (user-approved 2026-07-31) the Hanyang city-gate pavilions at a stepped-down moro rank; never giwa/choga. Roof builder dispatch: giwa → roof-skeleton.js; palace/temple/choga → roof.js. src/builder/palette.js#makeMaterials returns role-tagged materials; per-part color variety rides instanceColor at zero extra draw calls (adding material variants is expensive — mind draw-call budgets; town ceiling < 1000). A standalone buildBuilding() result must be released with disposeBuilding() from src/api/building.js; it disposes owned geometry/material/texture resources while preserving caller-owned shared P.mats and module-lifetime prop materials.

Temple compounds are gathered, not spread, and a mountain site is terraced. src/temple/plan.js derives hall coordinates from the real eave rectangle (hallExtent) rather than precinct ratios — the matbae/paljak repertoire differs by 1.9m of eave width on a 5-bay main hall, so hardcoded coordinates drift the spacing by more than the whole gap budget. src/temple/terrace-plan.js is the Three-free owner of the tier stack, the 막돌 rubble risers (south face plus both flanks), and the off-axis stairs; it runs after applyTempleEntrySequence because the 누하 pavilion is added there, and its datum is the worship-court elevation the approach apron already raised. A flat profile stays flat (its stair contract is single-run); the village temple is always mountain. Risers and tier tops borrow mats.stone and the court material, so the whole contract costs zero merged draw calls — never add a terrace-local material. Where the top tier would swallow the precinct wall, the outer wall becomes an open run around the approach and the rubble takes over the uphill boundary; do not restore a closed rectangle, re-pave the mountain approach court, or let a row of halls straddle two tiers. Only a 대찰 (extended) principal hall is two-storey (중층): the upper storey is the same bay module with fewer bays, seated below the lower ridge so the lower roof reads all around it — never add it to the main-hall repertoire, because templeRoleArchitecture hashes on seed:id:role and a third entry re-rolls every temple's principal form. Gate with npm run check:temple and npm run check:temple:browser (which measures both profiles — flat alone never renders the terraces). See docs/temple-generator.md §9.

src/builder/dancheong.js owns the reusable, renderer-free dancheong axes and rank policy. Palace defaults to moro; a temple compound reserves geum for its main worship hall and steps subsidiary/domestic buildings down; the city-gate rank sits one clarity step below the palace default and feeds palette.js#makeCityGateDancheong (wood members of the gate pavilion only — never the masonry or roof tiles). Cached Canvas sources are immutable and bucket-keyed; Texture/Material objects stay palette-owned so concurrent compounds and disposal cannot mutate one another. Never expose dancheong controls or allocate its textures for giwa/choga. See docs/dancheong.md.

Village generation — a deterministic pipeline: src/village/plan.js (pure plan) → src/village/populate.js (step orchestration over src/generators/village/*) → src/runtime/village/create.js and handle.js. src/village/adapter.js is only a compatibility re-export. Convention: +z = south. Scale is a continuum (siteR scalar / tier): lone house → hamlet → village → town → capital → hanyang (성곽 도성 with 사대문·시전, citywall.js). Repeated buildings are instanced (chunks.js, instancing.js).

Village rerolls use an exclusive scenery handoff: src/village/wave.js keeps exactly one static terrain/road/parcel/forest generation visible, swaps ownership only under the peak ink-fog veil, and drives shadows to zero for that frame. Buildings alone use the tofu transform wave. Never crossfade static scenery by mutating transparent, opacity, or depthWrite; dynamic animals, particles, and lights must expose userData.waveFade and compose the weight through a stable uniform or precompiled alphaHash material. The engine rim-patches and prewarms only the incoming subtree before it becomes visible. Gate this contract with npm run check:wave and npm run check:wave:app; inspect the representative npm run shoot:wave output during iteration and npm run shoot:wave:full for the full scale matrix.

Mud-wall close detail follows docs/mud-wall.md. src/village/mud-wall-surface-plan.js is the Three-free, JSON-safe source for bounded packed lifts, shallow irregular joints, sparse embedded fibres, and a static lower damp trace; external plan consumers use src/api/mud-wall-plan.js. src/village/mud-wall-geometry.js keeps every physical point inside the structural ±thickness / 2 envelope and returns only caller-owned geometry through src/api/mud-wall.js. Product walls reuse the existing mud and jipjul palette materials and static material merge, so no wall-local material, texture, program family, or draw group may be added. The detail belongs to FULL walls only because existing Hanyang MID/FAR ownership already omits courtyard walls. Do not turn the joints into regular siding, the fibres into a yellow surface layer, or the static damp trace into live weather. Gate with npm run check:mud-wall, the routed wall/app/worker checks, and direct npm run shoot:mud-wall inspection.

Roadside drainage follows docs/drainage.md. src/village/drainage-plan.js is the Three-free, JSON-safe source for bounded city-road runs, exact terrain surfaceY, and stored-access stone crossings after roads, parcels, paddies, and triangulated terrain are final. Ordinary hamlet/village/town plans stay empty; capital daero/jungno keep at most one successful downhill side, while Hanyang daero/jungno/soro may keep at most both. src/village/drainage-geometry.js renders absolute world records as one terrain-blended six-rail indexed U-channel mesh plus at most one merged three-slab mesh with one texture-free material family and zero shadow casters. Do not infer placement in the renderer, invent rural frequency, add live water simulation, draw a camera-space black line, or re-tessellate the shared terrain for the 0.48m channel. External consumers use src/api/drainage-plan.js and src/api/drainage.js; gate with npm run check:drainage, app/worker, and direct npm run shoot:drainage.

Performance is architectural here, not incidental (this is a large scene):

  • Worker offload (populate.worker.js + forest-crunch.js): forest placement (14k–40k trees, the bulk of generation cost) runs in a Web Worker that returns a transferable Float32Array of matrices + seasonal colors; the main thread only assembles InstancedMesh. createVillageAsync rAF-chunks that assembly. ?worker=0 is the synchronous fallback.
  • Auxiliary/yard hard-object contract: auxiliary-building-plan.js plans one detached storehouse at its authored size against the actual generated or committed edited house roof, parcel, gate approach, 30° solar access, peers, and yard hard objects. auxiliary-building-geometry.js consumes that immutable spec with borrowed palette materials; one global merged root stays physical and identical across FULL/MID/FAR, while focus/edit/export hide only the owner's source range. Temporary source geometry must be released in a finally path even when construction or strict merging fails. focusPlanningBlockers() adds the exact oriented auxiliary volume to both initial and refreshed product camera solves. yard-layout.js shares the exact rotated roof polygon with flora alongside jangdok platforms, stacks, clotheslines, garden beds, and stone ornaments. Tall objects clear the whole canopy; low objects clear the trunk while allowing natural crown overhang. Never reconstruct an auxiliary position in a renderer, miniaturize an invalid request, or restore a FULL-only shed. Gate with check:auxiliary-building, check:yard, check:layout, check:cinematic, check:lod, and worker/export contracts.
  • Seasonal yard-life contract (yard-life-plan.js + yard-life-record-contract.js): one parcel-local seeded decision emits the complete spring/autumn/winter JSON record set and reserves the union of its service/work slots before flora and close-focus grass. generators/village/yard-life.js renders those records as at most six stable role batches; season, weather, shared detail LOD, and wave only change opaque screen-door coverage. Rebuild validates and resolves stored LOD weights before swapping geometry, owns no source material, and must remain atomic on failure. Three-free consumers use src/api/yard-life-plan.js; borrowed-material renderers use src/api/yard-life.js. Gate with check:yard-life, app/worker, shoot:yard-life, and direct shoot:yard-life:app inspection. See docs/yard-life.md.
  • Shader precompile: transition freezes are shader link stalls, not CPU. engine.js calls warmShaders (renderer.compileAsync scoped to the new subtree only — passing the whole scene makes it worse) and flips renderer.debug.checkShaderErrors = false after the first village warm.
  • Terrain radius is clamped to basin + a fixed buffer; the world edge is finished with worldedge.js mist rather than sprawling terrain.

Determinism (critical): village generation swaps global Math.random for a seeded rng across the plan+populate window, then restores it; the worker uses a worker-local rng. Any multi-frame async path must save/reinstall the seed window per rAF slice, or the render loop's Math.random calls pollute the seed stream and break byte-identical reproduction. Gate village changes with a worker-vs-sync full-village hash.

Environment (src/env/): time/season/weather changes crossfade via internal tweens — API signatures stay stable, no hard cut. Snow = a roof white-tint shader (not an accumulation volume); rain = falling-curtain particles. focus.js drives the close-up ambience ring (chickens, chimney smoke, wind grass, lanterns) on the focused parcel. Camera tweens must call camera.lookAt every frame — freezing direction snaps the frame on tween end. A terrain-crossing focus camera normally retains its authored distant 16°/7° telephoto frame: terrainMeshFocusCutaway() traces the same nine camera-facing house samples used by focus visibility and raises the one shared camera near plane beyond the foreground ridge while leaving at least 1.2m before the nearest house face. This automatically clips color, physical particles, DoF depth, and vegetation without a new pass or material. Only a cutaway that would reach the subject may move the real camera into its first terrain-safe interval. When the cutaway is armed, the same near plane also hides the foreground vegetation instances it crosses (setFocusVegetationCut → tree-occluder cutOnly registration), because a fragment-level near plane removes the terrain face while the canopy above it survives and reads as floating trees. Gate the deterministic terrain-crossing case with check:cinematic:app and shoot:focus-level; the fixture is capital/7/p47 (both tools declare it as TERRAIN_FIXTURE; p31 stopped crossing terrain after the #164 ridge gentling, and p8 after the R2 parcel re-lay). If the fixture ever stops being armed, re-scan capital/7 for a parcel with negative minClearance instead of relaxing the assertions. setupEnvironment() and setupAudio() both own explicit dispose() contracts; audio teardown stops/disconnects owned nodes but never closes three's shared AudioContext.

Night light is three separate systems — do not conflate them when a night frame looks wrong: (a) props stone lanterns (src/props/, village-only, driven by the village adapter), (b) 한지 window/door glow, (c) the single-building 처마 eave lanterns in sky.js. Hierarchy: an eave lantern must never outshine the window glow. Window glow is tagged, not patched — doors and 살창 are per-mesh material clones, so patching the shared M.door does nothing. palette.js tags userData.hanjiGlow on the base material (Material.copy deep-copies userData, so clones inherit it) and consumers traverse the tree to patch every tagged material. It is independent of userData.role, so per-part colour variety is unaffected.

Other core dirs: src/layout/ (hanok/compound assembly, offsetPoly), src/anim/assembly.js (the "tofu" drop-in assembly, shared by assembly/expansion/merge), src/camera/, src/cinematic/ (drone + first-person walk), src/export/ (glTF/GLB, EXT_mesh_gpu_instancing), src/render/ (shader warm, screen-door LOD dither, material program keys), src/props/, src/share/.

onBeforeCompile gotchas

Many stock materials are patched via onBeforeCompile. Rules learned the hard way:

  • No dynamically-indexed custom uniform arrays. Vector3.copy(Color) yields NaN → black render.
  • Patch chain order inverts: an earlier-registered patch's color code runs after a later patch's (string-replacement ordering) — exploited deliberately for seasonal multiply overrides.
  • World-normal effects on an InstancedMesh must compose mat3(instanceMatrix) (instance orientation lives in instanceMatrix, not modelMatrix), or up-facing gates read zero.
  • GLSL reserved words (sample, patch, input, output, filter, active) cannot be used as local variable names.

Other recurring pitfalls

  • Light counts recompile everything. three's program cacheKey includes numPointLights, so adding or removing a PointLight recompiles every lit material in the scene — this was the root cause of transition shader storms. Keep a fixed pool resident in the scene and park unused lights at intensity = 0; visible = false drops them from the count and brings the churn back. Similarly, only dispose() frees a program (detaching does not), so disposing overlay materials forces a recompile on the next build — keep one anchor material per overlay kind alive.
  • 팔작지붕 normals: roof.js parameterises the +x side and -z rear faces with a sign flip, so their vertex normals point down (materials are DoubleSide, so it renders fine). World-normal shader effects must use abs() and re-orient the lighting normal, or up-facing gates read zero on exactly those two faces.
  • L.plateY is not the podium top — it is the 평방 top (column-head height). The 기단 top surface is L.podTopY; placing objects at plateY floats them at eave height.
  • populateVillage reassigns root.userData from one whole object literal at the end, so a key assigned before that line is silently dropped. New exposed keys must go inside the literal.
  • Sky geometry (moon, halo) must sit inside the camera far plane and low just above the ridge: the camera rig tilts down, so the visible sky band is only a few degrees tall.
  • Village-mode camera.near is a distance-dependent ramp (distant aerial needs a large near to kill z-fighting). Never set a small constant there.
  • Draw-call measurement: with the composer active renderer.info.calls counts only the last fullscreen pass, so measure with a direct renderer.render, and give the harness a shadow-casting sun or the count comes out roughly 1.7× low.
  • Water glint is high-frequency and AA-nondeterministic. Never judge a no-op by pixel hash — compare a same-code diff against the change diff.
  • Vite dev does not HMR new Worker(url) module graphs. A worker-vs-sync determinism gate that fails with the worker on but passes with ?worker=0 is stale worker code: restart vite clean (kill the process, remove its cacheDir) before suspecting the source. If ?worker=0 fails too, it is a real determinism bug.
  • Playwright vs. the app chrome: the .chroma wrapper fades out after ~3s idle and the canvas then intercepts clicks. Wake it with a real page.mouse.move, and for screenshots force .chroma { opacity: 1 !important; pointer-events: auto !important } instead of waiting on the class.

Stable user decisions

These are standing calls from the user; docs/project-status.md carries the fuller version. A proposal has to serve one of the five goals above, plus the flagship look, a measured bottleneck, or the release.

Scope rule, revised 2026-08-08. The old rule read "the project is in wrap-up, not expansion". That is superseded: the release shipped, and the active direction is now packaging the generator for reuse (axis 5). What the rule was protecting against still holds, so state it precisely — no new generation features (no China/Japan architecture, no general-purpose world expansion, no in-app recording). Packaging work is not an exception to that: docs/packaging-plan.md P0–P2 repackages existing output and adds no generator capability, and only P3 adds new serialization. A proposal to grow what the generator makes still needs a fresh user decision.

  • The look is about light. Bloom haze plus a golden-hour rim is the signature, and the rim must be optically real: it appears only when the sun is genuinely behind the subject and vanishes at noon. The default framing is backlit for that reason.
  • Ground albedo stays darker than the buildings, but bounce light must lift the shadow side — no crushed-black silhouettes; shadow-side 단청 and 창호 still read. Warmth belongs to highlights and rim while shadows and midtones stay neutral, so 뇌록·주홍, foliage green, and the sky gradient stay distinct hues instead of one orange wash. Sun shadows must read parallel.
  • Nothing on screen is fully static (motes drift, lanterns sway) and every such motion is micro-scale — if you notice it, it is too strong.
  • Arrival motion is the exception to "micro-scale", and its elasticity is momentum-continuous. The 2026-07-26 revision withdrew #126's "no rebound": what the user rejected was a decoupled post-landing wobble, not elasticity. The rise must arrive at contact with nonzero velocity and the settle must be a damped spring seeded by that velocity (volume-preserving squash), so a heavy roof reads heavy from its approach speed rather than from a larger authored amplitude. tofuScale/tofuBob in src/anim/assembly.js are the single shared easing for assembly, 칸 expansion, merge and the engine compound path — do not fork a second dialect. Member ripple is ordered by geometry (bottom-up by course, then one sweep along the dominant axis), never by children array order, and must clear the perceptual floor: the old ~25 ms neighbour offset was under two frames and therefore invisible. Assembly may be long (it is a clip beat) but the empty-site dead time may not grow. Gate: npm run check:assembly.
  • The 마당 is proportioned to the building, not to a work-area requirement. Sources state "마당의 크기는 그리 중요하지 않았다" and measured yard area spans 33.9–208.7 m² across 20 surveyed upper-class houses, so yard size is a dependent variable of house scale, wealth and terrain. The one assertable proportion is L/H ≈ 2.55 (yard length ÷ eave height, band 1–3); 멍석's short side 2.1 m is a floor, not a driver. Deriving yard size from required work area models something the sources do not describe. See docs/architectural-authenticity.md §9 — and note §9.3, which records that "the era used land sparsely" and "Hanyang progressively subdivided" are both unsupported, and that the widely cited "1395 한양 1품 35부" is a conflation with a 개성부 record.
  • BGM mute/restore is owned by one policy, and the engine never touches volume directly. hero.arm() mutes BGM; that mute and its restore live in src/audio/intro-policy.js, and the engine's only channel is audio.introEvent('arm'|'enter'|'settle'|'skip'). The first-entry landing must call audio.start() inside the entry gesture and before the village build, or iOS leaves the context suspended and nothing plays at all (that was the real bug — not a missing restore). genesis.mp3 is the first-entry track and hands over to the time-mapped track at landing settle. Gate: npm run check:audio-policy (pure) and npm run check:audio (real context).
  • Every environment change (time, season, weather) crossfades; a visible pop is a failure. Only shot/immediate paths snap.
  • The app default time is sunset backlit, while ?shot=1 keeps day so cross-domain comparison harnesses are not tinted orange. A shot tool that wants the flagship look must pass time=sunset explicitly.
  • Dense mountain forest is part of the Korean-mountain look: never thin trees for performance and never leave bald mountains. Terrain stays tight around the village and the world ends in worldedge mist; size that cut from the default aerial framing (the village fills roughly 65–75% of the frame).
  • Heavy generation happens during the title/loading window and is kept hidden; entering a mode or rerolling plays only the arrival motion. Smoothness is judged by rAF frame times, not by feel.
  • The UI is responsive from the start — tap selection, pinch zoom, bottom sheets on mobile, and a tap fallback for anything hover-driven. No desktop-only hardcoding in new components.
  • Do not build in-app clip recording. Tweet clips are manual OS screen recordings; if an audit flags "no record button", leave it.
  • BGM is generated outside the repo by the user (Suno prompts in docs/suno-prompts.md, audio in assets/audio/); ambience SFX come from separate CC0 sources. hero.arm() mutes BGM, so every landing path must restore the volume — a missing restore leaves music permanently silent while SFX still play.
  • The app is named cheoma and the seal stays the 한글 '처마' 전각 in every locale.

Git, assets & deploy

  • The lead commits and pushes at gate boundaries; subagents must not run git at all (no restore/checkout/stash/commit/push) — ask the lead to recover a file. Commit subjects are short English.
  • shots/ and refs/ stay untracked. refs/ is third-party reference photography and must never be published. Gate evidence belongs in shots/, throwaway captures in a scratch dir.
  • Deploy is Cloudflare Pages. Pushing to main deploys via .github/workflows/deploy.yml: it runs npm run check (the pure contracts — no browser) and only then builds app/ clean and runs wrangler pages deploy dist --project-name=cheoma --branch=main. So a push to main is a release action, not just a save. It needs the CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID repository secrets. The same wrangler command still works locally for an out-of-band deploy. Browser gates are deliberately not in CI (they need the repo-global lock and a real GPU backend, and headless wall-clock is not evidence) — run them locally before merging. If cheoma.pages.dev serves the new build but the custom domain does not, that domain's DNS record is pinned to a per-deployment alias instead of the production one — a DNS fix, not a propagation wait.
  • Continuous main flow (user decision 2026-08-02, revising the earlier per-push approval): the lead merges the working branch into main and pushes (= deploys) at green gate boundaries as work proceeds, without asking per push. The gate bar is unchanged — vision verdict for look-touching changes, lead-rerun affected gates, full pure check green. Stale branches (local and remote) are cleaned up as they are absorbed; before deleting, record the branch→SHA map in the maintainer's private notes (latest snapshot: branch-cleanup-2026-08-02.txt).

Verifying visual changes

Assert the cause in pure node; use the browser once to confirm the effect. Of the ~100 check/shoot scripts here, roughly half take the repo-global browser lock and half do not, and the naming convention already encodes the split: check:X is the pure contract, check:X:browser / :app is the rendered counterpart. Geometry invariants, timing and ordering math, plan-level routing decisions, asset reachability and seeded reproducibility all belong in node — three runs there for Object3D/Vector3/BufferGeometry reads with no GL context, so even an animation that only writes transforms can be stepped and asserted without a renderer. The browser earns its cost for renderer.info draw-call and program counts, shader compile/link, rendered depth artifacts, real Worker byte-identity, AudioContext, and one perceptual verdict. A numeric gate also catches every case; a capture catches only the framing you happened to choose. A gate that passes on broken code is worthless — verify each new gate fails on the pre-fix source before trusting it, and prefer carrying permanent regression fixtures inside the gate over relying on it being green today.

Headless ANGLE serializes shader linking, so absolute frame-ms from headless runs is unreliable — judge perf by program-count deltas and determinism hashes, not wall-clock. Keep gate screenshots minimal; put throwaway captures in a scratch dir, not shots/.

Judging pixels is a separate job from writing code. Screenshot verdicts go to a vision-capable agent or the user, and the orchestrating session reads the text verdict rather than the PNGs — image reads bloat the transcript for the rest of the session. Spawn a fresh agent per review round instead of resuming an image-heavy one, and stop an agent once its round is done. Parallelise by file boundary, but keep concurrent browser-driving agents to about three: benchmarks especially need a quiet machine, and numbers taken while other harnesses run are worthless. A prototype passes the screenshot-vs-drawing/photograph comparison loop before it is shown to the user. When judging a restoration, score two axes — the regression axis (old vs new) and the absolute axis (real golden-hour references and the target mood). The golden build is a floor, not a ceiling.

Rules distilled from the 2026-08-01 workflow audit (each one is a measured incident, not a preference):

  • Before/after pixel comparison is only valid inside one boot. The aerial framing pose varies 230–625 m across boots, so cross-boot captures compare framings, not changes. Same-boot A/B means swapping the variable live (verification-only uniform hooks like window.__rim, or served-file overlays) at an identical camera; when swapping shader code, three's program cache will silently serve the old program unless the A/B variant also changes customProgramCacheKey.
  • Distrust the instrument once. When a gate or harness first pins a perceptual claim (framing, colour, brightness), cross-check one sample through an independent path before building on it. Two instrument bugs were found in one day: a gate whose up-vector sign made it measure the top margin while asserting the bottom, and shoot-village-light capturing months of sunset frames under a day sky because enterVillageMode resets the environment (re-apply the time after entry).
  • Gate-registration files are the collision point for parallel rounds. A new gate touches five files at once (package.json, tools/lib/fast-checks.mjs, tools/lib/verification-plan.mjs, tools/check-verification-plan.mjs, docs/verification.md). Two concurrent rounds that both register gates entangle those hunks and block each other's commits: give the registration files a single owner per round, or isolate parallel rounds in worktrees and let the lead apply diffs serially. Never hunk-split a shared file while another round is still editing it.
  • Gate economy for a review round: affected gates + check:pr during iteration; one full npm run check at the commit boundary, not per round. Capture full gate output to a file and grep the file — piping through tail truncates the failure log and replaces the exit code.

Documentation & current work

  • Start at docs/README.md for the document map and status labels. Not every file in docs/ is a current implementation contract; research and dated snapshots are marked there.
  • docs/project-status.md holds the current wrap-up direction and stable user decisions migrated from Claude Code memory.
  • docs/look-restoration-plan.md drives the current look-restoration round. 5ca668e is the reference golden build for A/B capture. Performance comparisons must use golden-worktree measurements taken with the same harness in the same run — historical baseline numbers (e.g. "village 475 / hanyang 1288") did not reproduce under any current tool and must not be used as targets.
  • docs/architecture-refactor.md records the completed first structure pass and the current reuse/boundary contract. Public consumer entrypoints live in src/api/; internal modules must not import that façade. Run npm run check before browser-heavy gates.
  • docs/verification.md is the canonical harness map. In particular, tools/check-determinism.mjs does not compare worker vs sync and does not hash temple data, while tools/verify-forest.mjs is obsolete.
  • SANSA-HANDOFF.md is the completed temple-relocation record (implemented, reviewed, and published as PR #5; the #12 compound-temple follow-up is also done). The live contract is docs/temple-generator.md — the handoff file is history, not a queue.
  • Historical or visual research that changes the product must be recorded in the relevant self-contained domain document. Also add the selected user-facing sources to docs/credits.md with institution, title, applied-to mapping, canonical URL, and licensing note; app/src/lib/credits.js parses that file for ReferenceModal.svelte, so do not maintain a second hardcoded UI list. Gate new source groups by opening the real Reference UI and checking their rendered links and applied-use text.

Code comments reference design documents directly: mode-integration.md (mode/camera/focus integration — comments cite e.g. "mode-integration §5.5"), palace-layout.md, joseon-city.md, tooling.md (vetted library stack — manifold-3d, three-mesh-bvh, clipper2 offset caveat), perf-webgpu.md, ui-design.md, and references.md. Do not rename these files or renumber referenced sections casually.

Repository docs must be self-contained. The maintainer's private Claude Code notes are useful historical input, but they include superseded implementations, tool-specific routing, credentials, and ephemeral scratch paths. When a memory contains a stable rule that future work needs, migrate the rule into the relevant repository document instead of adding a hard dependency on that private path.