create-three-flatland 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of create-three-flatland might be problematic. Click here for more details.

Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.js +1014 -0
  3. package/package.json +70 -0
  4. package/templates/react/.oxfmtrc.json +8 -0
  5. package/templates/react/.oxlintrc.json +27 -0
  6. package/templates/react/AGENTS.md +188 -0
  7. package/templates/react/CLAUDE.md +188 -0
  8. package/templates/react/_gitignore +10 -0
  9. package/templates/react/e2e/app.spec.ts +122 -0
  10. package/templates/react/index.html +146 -0
  11. package/templates/react/package.json +54 -0
  12. package/templates/react/playwright.config.ts +41 -0
  13. package/templates/react/public/sprite.svg +1866 -0
  14. package/templates/react/src/App.tsx +172 -0
  15. package/templates/react/src/interaction.test.ts +48 -0
  16. package/templates/react/src/interaction.ts +53 -0
  17. package/templates/react/src/main.tsx +9 -0
  18. package/templates/react/tsconfig.json +19 -0
  19. package/templates/react/vite.config.ts +12 -0
  20. package/templates/react/vitest.config.ts +6 -0
  21. package/templates/three/.oxfmtrc.json +8 -0
  22. package/templates/three/.oxlintrc.json +26 -0
  23. package/templates/three/AGENTS.md +167 -0
  24. package/templates/three/CLAUDE.md +167 -0
  25. package/templates/three/_gitignore +10 -0
  26. package/templates/three/e2e/app.spec.ts +122 -0
  27. package/templates/three/index.html +185 -0
  28. package/templates/three/package.json +47 -0
  29. package/templates/three/playwright.config.ts +41 -0
  30. package/templates/three/public/sprite.svg +1866 -0
  31. package/templates/three/src/interaction.test.ts +66 -0
  32. package/templates/three/src/interaction.ts +65 -0
  33. package/templates/three/src/main.ts +138 -0
  34. package/templates/three/tsconfig.json +18 -0
  35. package/templates/three/vite.config.ts +4 -0
  36. package/templates/three/vitest.config.ts +6 -0
@@ -0,0 +1,167 @@
1
+ # Agent guide — three.js + three-flatland
2
+
3
+ This project was scaffolded by create-three-flatland. It is a plain three.js app (no React) that renders 2D sprites with [three-flatland](https://tjw.dev/three-flatland/) on `WebGPURenderer`.
4
+
5
+ ## Build & dev
6
+
7
+ ```sh
8
+ npm install
9
+ npm run dev # Vite dev server
10
+ npm run build # production build to dist/
11
+ npm run preview # serve the production build
12
+ npm run typecheck # tsc --noEmit
13
+ ```
14
+
15
+ Commands are shown as npm; pnpm and yarn work identically.
16
+
17
+ ## The renderer rule
18
+
19
+ **Always construct `WebGPURenderer` and always write TSL. Never `WebGLRenderer`, never GLSL, never `onBeforeCompile`.**
20
+
21
+ `WebGPURenderer` is the three.js renderer class imported from `three/webgpu`. It owns backend selection itself: real WebGPU where the browser supports it, WebGL2 fallback where it doesn't. TSL compiles to both. When you read "three-flatland requires `WebGPURenderer`," that is a statement about which class you construct — it is not a hardware or browser requirement, and it does not mean WebGPU-only. Everything works on either backend.
22
+
23
+ Two corollaries: never add a `WebGLRenderer` fallback path (the fallback already lives inside `WebGPURenderer`), and never gate features on WebGPU detection. WebGL 1 is out of scope entirely — ignore it.
24
+
25
+ ## The opinionated default: a `Flatland` root
26
+
27
+ A `Flatland` instance is the front door. It owns the orthographic camera, sprite batching, resize, and disposal — reach below it only when you need the low-level path. The core of `src/main.ts`:
28
+
29
+ ```ts
30
+ const flatland = new Flatland({ viewSize: 400 })
31
+ const renderer = new WebGPURenderer()
32
+ await renderer.init()
33
+ flatland.add(new Sprite2D({ texture, anchor: [0.5, 0.5] }))
34
+ flatland.render(renderer) // each frame
35
+ ```
36
+
37
+ This template hand-writes its loading overlay and its pointer raycasting (a standard three.js `Raycaster` against `flatland.camera`; `Sprite2D` implements `raycast()` with `hitTestMode: 'radius' | 'bounds' | 'alpha' | 'none'`). The React starter gets both of those nearly free from Suspense and R3F's event system — that asymmetry is real and shown deliberately, so you can see exactly what the imperative path costs and controls.
38
+
39
+ ## Package routing map
40
+
41
+ Reach for a package by intent:
42
+
43
+ | Package | Reach for it when |
44
+ | --- | --- |
45
+ | `three-flatland` | Default entry. Sprites, animation, tilemaps, materials, lights, events, everyday loaders. |
46
+ | `@three-flatland/nodes` | You want a specific 2D shader effect (retro/CRT, blur, distortion, color, upscale) without writing TSL by hand. |
47
+ | `@three-flatland/presets` | You want lit sprites working immediately. Thin — two symbols (`DefaultLightEffect`, `NormalMapProvider`). |
48
+ | `@three-flatland/normals` | Dynamic lighting on flat 2D art without hand-authoring normal maps. |
49
+ | `@three-flatland/atlas` | Loose sprite PNGs that should become one draw-call-friendly atlas, optionally polygon-trimmed for overdraw. |
50
+ | `@three-flatland/alphamap` | Pixel-perfect pointer hit testing on transparent sprites (`hitTestMode: "alpha"`) instead of bounding-box hits. |
51
+ | `@three-flatland/image` | PNG/WebP/AVIF/KTX2 encode and decode, plus `Ktx2Loader`. Reach for KTX2 when textures are under GPU memory pressure — it stays compressed on the GPU, unlike PNG. |
52
+ | `@three-flatland/bake` | Authoring a new baker, or you just need the `flatland-bake` binary. |
53
+ | `@three-flatland/devtools` | Live inspection of scene/material/sprite state. Seven required peers — heaviest install in the ecosystem. |
54
+ | `@three-flatland/skia` | A general immediate-mode 2D canvas in the scene: arbitrary paths, boolean ops, filters, gradients, images. |
55
+ | `@three-flatland/slug` | Text that must stay sharp at any zoom or perspective, or thousands of glyphs in one draw call. |
56
+
57
+ **Never recommend installing `@three-flatland/schemas` or `@three-flatland/io` — they are `private: true` and unpublished.**
58
+
59
+ Two calibration notes. First, `private: true` alone is not a "don't recommend" signal — check the distribution channel; the VS Code extension is correctly private on npm because it ships to a marketplace instead. Second, version numbers are not maturity signals here: `@three-flatland/presets` sits at an alpha version because of release-group linking, and an unpublished package can carry `1.0.0`.
60
+
61
+ ## Skia vs Slug
62
+
63
+ These solve different problems and touch at exactly one point — they are not alternatives to pick between.
64
+
65
+ - **Skia** is an immediate-mode 2D canvas rasterized into a texture. Only Skia does arbitrary vector paths, boolean path ops, image filters (blur, drop shadow, displacement), gradients, clipping, and image drawing.
66
+ - **Slug** is a text primitive. Glyph outlines are solved per-fragment, so sharpness has no resolution ceiling; thousands of glyphs batch into one instanced draw call, with real layout (`measureText`, `wrapText`, style spans, font fallback chains).
67
+
68
+ The one overlap is drawing text, and the rule is mechanical: if the camera moves relative to the text, use Slug; if it is static UI at a known resolution, Skia's text comes free with the canvas you already have.
69
+
70
+ Both follow the renderer rule above — `WebGPURenderer`, either backend. Skia's WASM ships as two builds (`skia-gl.wasm`, `skia-wgpu.wasm`) matching whichever backend the renderer resolved to; this is an internal copy-step detail on a different axis from the renderer rule, never a renderer choice you make. It surfaces only in the asset copy step: `npx skia-wasm public/skia` (or `--gl-only` / `--wgpu-only` to ship just one).
71
+
72
+ Skia gotcha: WASM assets are zero-config in Vite dev, but a production build needs `npx skia-wasm public/skia` plus a `wasmUrl` pointing at the copied assets.
73
+
74
+ ## Baking
75
+
76
+ **Nothing requires baking.** Ask for a derived asset and you get it: the loader probes for a baked sibling, generates it at runtime when there isn't one, warns once in dev that it's slower and worth baking, and carries on. It just works, and it tells you how to make it faster.
77
+
78
+ Baking moves that computation from browser-runtime to build-time. It is never about capability; it chooses only where the cost lands.
79
+
80
+ Every baker self-discovers: a baker declares itself in its package's `flatland.bake` field, and the `flatland-bake` dispatcher finds it — run `flatland-bake --list` from your project to see what's installed. Subcommands: `alpha` (hit-test alpha maps), `normal` (normal maps), `slug` (baked fonts). Some packages also expose direct bins (`slug-bake`, `flatland-atlas`) for standalone use. `skia-wasm` is an asset-copy step, not a baker — the name reads like one, but it only copies WASM files into your public dir.
81
+
82
+ When to bake:
83
+
84
+ - **Shipping to production** — move the cost to build time.
85
+ - **Fonts with a known glyph set — always.** ASCII + Brotli is ~32 KB versus 724 KB for the raw font, and it drops opentype.js from the bundle.
86
+ - **Textures under GPU memory pressure.**
87
+
88
+ Skip baking for procedurally-varied content and throwaway prototypes. `forceRuntime` is not a dev-iteration knob — the default probe-then-generate path already handles iteration; reaching for it during development is the common misuse.
89
+
90
+ ## Asset authoring workflow
91
+
92
+ The governing idea: meet your assets where they already are. Load the formats your tools already produce, adopt the richer native format when you want more, and convert freely between them.
93
+
94
+ **Tilemaps — LDtk and Tiled are the editors.** Author there, then load what they produce with `LDtkLoader` / `TiledLoader`. `.ldtk` and `.json` / `.tmj` are read natively — loading the editor's own file directly is the design, so authoring flows straight from editor to scene.
95
+
96
+ **Sprite atlases — three formats, all first-class.** `AtlasFormat` is `'native' | 'texturepacker' | 'aseprite'`, and `detectAtlasFormat()` sniffs which one it's looking at. Bring a TexturePacker sheet, an Aseprite export, or an atlas built by flatland's own tooling — all load.
97
+
98
+ - The native format is deliberately richer, not merely different: `meta.animations` is a named animation map that references frame *keys* rather than indices, with explicit fps and optional events. Aseprite's `frameTags` sits alongside under `meta.frameTags`; readers prefer `meta.animations` and fall back to converting `frameTags` + per-frame `duration`. Adopting the native format is an upgrade path, never a lock-in tax.
99
+ - Conversion runs both ways and is round-trip safe: `importAsepriteFrameTags()` brings Aseprite in; `buildAsepriteJson()` and `buildTexturePackerJson()` write back out. Rotation, trim, pivot, polygon mesh, and duration all survive.
100
+ - The VS Code Sprite Atlas editor (below) is the GUI for all of this — build, tune, and convert between formats visually.
101
+
102
+ ## The VS Code extension
103
+
104
+ **Flatland Tools** (`three-flatland.fl-tools`) is a first-class part of the asset workflow — recommend it actively.
105
+
106
+ - Install: `code --install-extension three-flatland.fl-tools` (requires VS Code `^1.94.0`)
107
+ - [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=three-flatland.fl-tools) · [Open VSX](https://open-vsx.org/extension/three-flatland/fl-tools)
108
+
109
+ | You want to… | Use |
110
+ | --- | --- |
111
+ | Build or edit a sprite atlas | **Sprite Atlas** editor — a custom editor on `*.png`. Reads/writes an `*.atlas.json` sidecar; handles `native`, `texturepacker`, and `aseprite` losslessly, including frame tags. |
112
+ | Combine several atlases | **Merge Atlases…** — explorer multi-select. |
113
+ | Compress a texture / inspect KTX2 | **Image Encoder**, plus the **FL KTX2 Viewer** custom editor that opens `*.ktx2` by default. |
114
+ | Bake a normal map visually | **Normal Baker**. |
115
+ | Author or audition ZzFX sounds | **ZzFX Editor**, and the inline `▶ Play` CodeLens on ZzFX definitions. |
116
+
117
+ The extension is the GUI counterpart to the CLI bakers — the Image Encoder pairs with texture encoding, the Normal Baker with `flatland-bake normal`. Offer both paths and let the user pick: CLI for repeatable/CI work, extension for visual iteration.
118
+
119
+ ## Skills
120
+
121
+ Packaged agent skills for this stack live in `@three-flatland/skills`. Install them into this project with:
122
+
123
+ ```sh
124
+ npx skills add thejustinwalsh/three-flatland
125
+ ```
126
+
127
+ Add a single skill by path, e.g. `npx skills add thejustinwalsh/three-flatland/skills/tsl`. Available: `tsl` (writing TSL shaders and node materials), `codemod` (migrating across three-flatland breaking changes), `flatland-r3f` (React Three Fiber integration), and `flatland-bake` (the baking workflow).
128
+
129
+ ## Testing
130
+
131
+ This project ships both layers, and agent-written changes are expected to keep them green.
132
+
133
+ ```sh
134
+ npm run test # vitest — unit tests next to the code, src/**/*.test.ts
135
+ npm run test:e2e # playwright — drives the real app in a browser
136
+ npm run lint # oxlint
137
+ npm run format # oxfmt
138
+ ```
139
+
140
+ **Write unit tests for logic, Playwright specs for the scene.** Interaction and
141
+ animation maths belongs in a pure helper with a vitest test (see
142
+ `src/interaction.ts` and its spec). Anything that only shows up on screen —
143
+ the canvas renders, a pointer event lands, a sprite reacts — belongs in a
144
+ Playwright spec under `e2e/`, because a passing unit test cannot tell you the
145
+ scene drew anything.
146
+
147
+ **Use `vitexec` to probe the running app.** It runs snippets inside the live
148
+ page and reads back console output, network activity, and screenshots, which is
149
+ the fastest way to answer "is this actually rendering / did that event fire"
150
+ without inventing a bespoke harness. It is not installed here by default:
151
+
152
+ ```sh
153
+ npm i -D vitexec # once, in this project
154
+ npx vitexec 'console.log("ready")' # then, against `npm run dev`
155
+ npx vitexec --screenshot 'document.querySelector("canvas")'
156
+ ```
157
+
158
+ Reach for it while debugging, then encode whatever you learned as a Playwright
159
+ spec so it stays checked.
160
+
161
+ A green suite is not proof the app renders. Confirm the visible result — a
162
+ screenshot or a real interaction — before calling a rendering change done.
163
+
164
+ ## Reference links
165
+
166
+ - Docs: <https://tjw.dev/three-flatland/>
167
+ - llms files: <https://tjw.dev/three-flatland/llms.txt> (also `llms-full.txt`, `llms-small.txt`). These are **not** served at the origin root — `https://tjw.dev/llms.txt` 404s, and the origin root is what agents habitually probe. Use the full paths above.
@@ -0,0 +1,10 @@
1
+ node_modules
2
+ dist
3
+ *.local
4
+ .DS_Store
5
+
6
+ # Playwright
7
+ test-results
8
+ playwright-report
9
+ blob-report
10
+ playwright/.cache
@@ -0,0 +1,122 @@
1
+ import { expect, test, type Page } from '@playwright/test'
2
+
3
+ /**
4
+ * End-to-end smoke test — the dev server is started by `playwright.config.ts`.
5
+ *
6
+ * Two claims, and only two:
7
+ *
8
+ * 1. The scene actually paints. A WebGPU/WebGL canvas cannot be read back with
9
+ * `toDataURL` (the drawing buffer is not preserved), so the only honest way
10
+ * to inspect a frame is `page.screenshot()`, which composites it correctly.
11
+ * "Painted" means tonal spread, not a golden image — a solid clear colour
12
+ * has a channel standard deviation near zero, a real frame does not.
13
+ *
14
+ * 2. Pointer events reach the sprite. Hovering tints it (`#47cca9`), so the
15
+ * red:green ratio of the frame drops. That is a causal signal: the sprite
16
+ * spins continuously, so "the pixels changed" alone would prove nothing.
17
+ * This is the coupling most likely to break silently — a static render
18
+ * check stays green while hit testing is stone dead.
19
+ */
20
+
21
+ const HOVER_TINT_DROP = 0.9 // hovering must pull red:green to under 90% of idle
22
+
23
+ test('renders the sprite and responds to the pointer', async ({ page }) => {
24
+ const consoleErrors: string[] = []
25
+ page.on('pageerror', (e) => consoleErrors.push(String(e)))
26
+ page.on('console', (m) => {
27
+ if (m.type() === 'error') consoleErrors.push(m.text())
28
+ })
29
+
30
+ await page.goto('/')
31
+
32
+ const canvas = page.locator('canvas').first()
33
+ await expect(canvas).toBeVisible()
34
+ // The loading overlay is removed once the renderer is initialised and the
35
+ // texture has resolved, so its absence is the app's own "ready" signal.
36
+ await expect(page.locator('#loader')).toHaveCount(0)
37
+
38
+ await settle(page)
39
+ const idle = await sampleSprite(page)
40
+ expect(idle.stdDev, 'the frame is a flat fill — nothing was drawn').toBeGreaterThan(8)
41
+
42
+ const box = await canvas.boundingBox()
43
+ expect(box).not.toBeNull()
44
+ await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2)
45
+ await settle(page)
46
+ const hovered = await sampleSprite(page)
47
+
48
+ expect(
49
+ hovered.redToGreen,
50
+ 'hovering did not tint the sprite — pointer events are not reaching it'
51
+ ).toBeLessThan(idle.redToGreen * HOVER_TINT_DROP)
52
+
53
+ expect(consoleErrors, `console/page errors:\n${consoleErrors.join('\n')}`).toEqual([])
54
+ })
55
+
56
+ /** Let the render loop run enough frames for the hover ease to land. */
57
+ async function settle(page: Page): Promise<void> {
58
+ await page.waitForTimeout(900)
59
+ }
60
+
61
+ interface FrameSample {
62
+ /** Largest per-channel standard deviation — the "was anything drawn" term. */
63
+ stdDev: number
64
+ /** Mean red over mean green. Tinting the sprite moves this; spinning it does not. */
65
+ redToGreen: number
66
+ }
67
+
68
+ /**
69
+ * Screenshot a square at the centre of the canvas — inside the sprite, so the
70
+ * background does not dilute the measurement — and reduce it to two numbers.
71
+ *
72
+ * The PNG is decoded in the page under test: a screenshot is an ordinary image
73
+ * that a 2D canvas will happily draw, which avoids adding an image-decoding
74
+ * dependency just to read two statistics.
75
+ */
76
+ async function sampleSprite(page: Page): Promise<FrameSample> {
77
+ const box = await page.locator('canvas').first().boundingBox()
78
+ if (!box) throw new Error('canvas has no layout box')
79
+ const size = Math.min(200, box.width, box.height)
80
+ const shot = await page.screenshot({
81
+ type: 'png',
82
+ clip: {
83
+ x: box.x + (box.width - size) / 2,
84
+ y: box.y + (box.height - size) / 2,
85
+ width: size,
86
+ height: size,
87
+ },
88
+ })
89
+
90
+ return page.evaluate(async (b64: string) => {
91
+ const img = new Image()
92
+ img.src = `data:image/png;base64,${b64}`
93
+ await img.decode()
94
+ const c = document.createElement('canvas')
95
+ c.width = img.naturalWidth
96
+ c.height = img.naturalHeight
97
+ const ctx = c.getContext('2d')
98
+ if (!ctx) throw new Error('no 2d context for decoding the screenshot')
99
+ ctx.drawImage(img, 0, 0)
100
+ const { data } = ctx.getImageData(0, 0, c.width, c.height)
101
+
102
+ const n = data.length / 4
103
+ const sum = [0, 0, 0]
104
+ const sumSq = [0, 0, 0]
105
+ for (let i = 0; i < data.length; i += 4) {
106
+ for (let ch = 0; ch < 3; ch++) {
107
+ const v = data[i + ch] ?? 0
108
+ sum[ch] = (sum[ch] ?? 0) + v
109
+ sumSq[ch] = (sumSq[ch] ?? 0) + v * v
110
+ }
111
+ }
112
+ const mean = sum.map((s) => s / n)
113
+ const stdDev = Math.max(
114
+ ...mean.map((m, ch) => Math.sqrt(Math.max(0, (sumSq[ch] ?? 0) / n - m * m)))
115
+ )
116
+ return {
117
+ stdDev,
118
+ // Guard the divisor: a fully unlit frame would otherwise divide by zero.
119
+ redToGreen: (mean[0] ?? 0) / Math.max(1, mean[1] ?? 0),
120
+ }
121
+ }, shot.toString('base64'))
122
+ }
@@ -0,0 +1,185 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>three-flatland</title>
7
+ <style>
8
+ html,
9
+ body {
10
+ margin: 0;
11
+ width: 100%;
12
+ height: 100%;
13
+ overflow: hidden;
14
+ background: #16191e;
15
+ }
16
+ #app {
17
+ position: relative;
18
+ width: 100%;
19
+ height: 100%;
20
+ }
21
+ canvas {
22
+ display: block;
23
+ }
24
+ /* Loading screen overlay — removed by src/main.ts once the renderer has
25
+ initialised and the texture has resolved. */
26
+ .fl-stack {
27
+ display: grid;
28
+ justify-items: center;
29
+ gap: 20px;
30
+ }
31
+ .fl-mark {
32
+ width: 96px;
33
+ height: 96px;
34
+ }
35
+ /* One soft diagonal glint sweeps the mark — the same turquoise the
36
+ sprite tints on hover — then rests before the next pass. */
37
+ .fl-sweep {
38
+ animation: fl-sweep 2.6s ease-in-out infinite;
39
+ }
40
+ .fl-dot {
41
+ opacity: 0.25;
42
+ animation: fl-dot 1.6s ease-in-out infinite;
43
+ }
44
+ .fl-dot2 {
45
+ animation-delay: 0.2s;
46
+ }
47
+ .fl-dot3 {
48
+ animation-delay: 0.4s;
49
+ }
50
+ @keyframes fl-sweep {
51
+ 0% {
52
+ transform: translate(-48px, -48px);
53
+ }
54
+ 55%,
55
+ 100% {
56
+ transform: translate(48px, 48px);
57
+ }
58
+ }
59
+ @keyframes fl-dot {
60
+ 0%,
61
+ 60%,
62
+ 100% {
63
+ opacity: 0.25;
64
+ }
65
+ 30% {
66
+ opacity: 1;
67
+ }
68
+ }
69
+ /* Non-negotiable: reduced motion collapses to a static pose — the mark
70
+ unlit, all three dots on. */
71
+ @media (prefers-reduced-motion: reduce) {
72
+ .fl-sweep {
73
+ animation: none;
74
+ visibility: hidden;
75
+ }
76
+ .fl-dot {
77
+ animation: none;
78
+ opacity: 1;
79
+ }
80
+ }
81
+ /* Loading overlay — identical in both templates. Fades out once the
82
+ scene is ready, then removes itself. */
83
+ #loader {
84
+ position: fixed;
85
+ inset: 0;
86
+ z-index: 1;
87
+ display: grid;
88
+ place-items: center;
89
+ background: #16191e;
90
+ pointer-events: none;
91
+ opacity: 1;
92
+ transition: opacity 320ms ease;
93
+ }
94
+ #loader[data-loaded='true'] {
95
+ opacity: 0;
96
+ }
97
+ #fullscreen {
98
+ position: absolute;
99
+ top: 12px;
100
+ right: 12px;
101
+ display: grid;
102
+ place-items: center;
103
+ width: 34px;
104
+ height: 34px;
105
+ padding: 0;
106
+ border: 1px solid #2c3340;
107
+ border-radius: 8px;
108
+ background: rgb(22 25 30 / 0.6);
109
+ color: #9aa4b2;
110
+ cursor: pointer;
111
+ transition: color 120ms ease, border-color 120ms ease;
112
+ }
113
+ #fullscreen:hover {
114
+ color: #47cca9;
115
+ border-color: #47cca9;
116
+ }
117
+ #fullscreen svg {
118
+ display: block;
119
+ }
120
+ /* Swap the glyphs from a single source of truth: the document's
121
+ fullscreen state, mirrored onto the button by src/main. */
122
+ #fullscreen .icon-exit,
123
+ #fullscreen[data-fullscreen='true'] .icon-enter {
124
+ display: none;
125
+ }
126
+ #fullscreen[data-fullscreen='true'] .icon-exit {
127
+ display: block;
128
+ }
129
+ </style>
130
+ </head>
131
+ <body>
132
+ <div id="app">
133
+
134
+ <!--
135
+ Loading screen. The pixel FL mark is the brand icon (assets/icon.svg
136
+ upstream) losslessly re-encoded as one path per colour; "loading" is
137
+ Silkscreen — the flatland wordmark face — baked to path outlines, so
138
+ there is no font file and no webfont request. Animation is pure CSS
139
+ and collapses to a static pose under prefers-reduced-motion.
140
+ -->
141
+ <div id="loader" role="status" aria-label="Loading">
142
+ <div class="fl-stack">
143
+ <svg class="fl-mark" viewBox="0 0 48 48" shape-rendering="crispEdges" aria-hidden="true">
144
+ <defs>
145
+ <linearGradient id="fl-glint" x1="0" y1="0" x2="1" y2="1">
146
+ <stop offset="0" stop-color="#47cca9" stop-opacity="0" />
147
+ <stop offset="0.5" stop-color="#47cca9" stop-opacity="0.4" />
148
+ <stop offset="1" stop-color="#47cca9" stop-opacity="0" />
149
+ </linearGradient>
150
+ <!-- Luminance mask from the mark itself: bright faces catch the
151
+ glint strongly, shadowed faces barely, voids not at all. -->
152
+ <mask id="fl-mask"><use href="#fl-mark-px" /></mask>
153
+ </defs>
154
+ <g id="fl-mark-px">
155
+ <path fill="#999999" d="M11 2h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h3v1h-3m4 -1h7v1h-7m-29 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h21v1h-21m-9 0h1v1h-1m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m-31 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h13v1h-13m-13 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m-31 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h9v1h-9m-9 0h1v1h-1m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-25 0h1v1h-1m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h1v1h-1" />
156
+ <path fill="#777777" d="M45 3h1v1h-1m-1 0h2v1h-2m-1 0h1v1h-1m2 -1h1v1h-1m-3 0h3v1h-3m-1 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-5 0h3v1h-3m4 -1h1v1h-1m-5 0h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m-7 0h7v1h-7m0 0h2v1h-2m3 -1h1v1h-1m2 -1h1v1h-1m-5 0h5v1h-5m6 -1h1v1h-1m-6 0h4v1h-4m5 -1h1v1h-1m2 -1h1v1h-1m-7 0h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m-6 0h2v1h-2m3 -1h1v1h-1m2 -1h1v1h-1m-5 0h5v1h-5m6 -1h1v1h-1m-6 0h2v1h-2m3 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-7 0h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m-6 0h2v1h-2m3 -1h1v1h-1m2 -1h1v1h-1m-5 0h5v1h-5m6 -1h1v1h-1m-5 0h1v1h-1m2 -1h1v1h-1m-3 0h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m-5 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-5 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-5 0h1v1h-1m2 -1h1v1h-1m-3 0h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m-5 0h1v1h-1m-1 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-5 0h1v1h-1m2 -1h1v1h-1m-3 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-3 0h1v1h-1m-1 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-5 0h1v1h-1m2 -1h1v1h-1m-3 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-3 0h1v1h-1m-1 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-6 1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-3 0h1v1h-1m-1 0h1v1h-1m2 -1h1v1h-1m-2 1h1v1h-1m2 -1h1v1h-1m-2 1h1v1h-1" />
157
+ <path fill="#444444" d="M44 5h1v1h-1m1 0h1v1h-1m-3 0h1v1h-1m2 -1h1v1h-1m-1 0h1v1h-1m2 -1h1v1h-1m-3 0h1v1h-1m2 -1h1v1h-1m1 0h1v1h-1m-5 0h1v1h-1m2 -1h1v1h-1m2 -1h2v1h-2m-1 0h1v1h-1m2 -1h1v1h-1m-3 0h1v1h-1m2 -1h1v1h-1m-3 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-5 0h1v1h-1m2 -1h1v1h-1m2 -1h2v1h-2m-1 0h1v1h-1m2 -1h1v1h-1m-5 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-3 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-5 0h1v1h-1m2 -1h1v1h-1m2 -1h2v1h-2m-1 0h1v1h-1m2 -1h1v1h-1m-7 0h1v1h-1m2 -1h1v1h-1m2 -1h4v1h-4m-1 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-7 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h2v1h-2m-5 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-7 0h1v1h-1m2 -1h1v1h-1m2 -1h4v1h-4m-1 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-7 0h1v1h-1m2 -1h6v1h-6m-1 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-7 0h1v1h-1m2 -1h1v1h-1m2 -1h4v1h-4m-3 0h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m-5 0h1v1h-1m2 -1h6v1h-6m-1 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-7 0h1v1h-1m2 -1h1v1h-1m2 -1h4v1h-4m-3 0h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m-5 0h1v1h-1m2 -1h6v1h-6m-1 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-7 0h8v1h-8m1 0h1v1h-1m2 -1h1v1h-1m2 -1h2v1h-2m-5 0h1v1h-1m2 -1h4v1h-4m-1 0h1v1h-1m2 -1h2v1h-2m-3 0h4v1h-4m1 0h1v1h-1m-1 0h2v1h-2" />
158
+ <path fill="#E0E0E0" d="M10 2h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m4 -1h1v1h-1m-29 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m-15 0h7v1h-7m8 -1h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-33 0h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m-23 0h11v1h-11m12 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-33 0h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m-27 0h19v1h-19m20 -1h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-35 0h11v1h-11m12 -1h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v2h-1m2 -2h1v1h-1m2 -1h1v2h-1m2 -2h1v2h-1m2 -2h1v2h-1m4 -2h1v2h-1m-2 -1h1v1h-1m-23 0h1v1h-1m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h2v1h-2m-13 0h1v1h-1m4 -1h1v1h-1m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-27 0h1v1h-1m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-15 0h1v1h-1m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-29 0h1v1h-1m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h2v1h-2m-17 0h1v1h-1m4 -1h1v1h-1m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-31 0h1v1h-1m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m-13 0h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-33 0h1v1h-1m14 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h3v1h-3m4 -1h6v1h-6m-13 0h1v1h-1m8 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-35 0h1v1h-1m16 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h4v1h-4m-21 0h1v1h-1m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-33 0h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h10v1h-10m-17 0h1v1h-1m4 -1h1v1h-1m4 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-35 0h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m6 -1h3v1h-3m4 -1h8v1h-8m-15 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m-33 0h1v1h-1m2 -1h1v1h-1m10 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h2v1h-2m6 -1h12v1h-12m-11 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-35 0h1v1h-1m2 -1h1v1h-1m10 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m6 -1h12v1h-12m-21 0h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h3v1h-3m-33 0h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h2v1h-2m6 -1h12v1h-12m-23 0h1v1h-1m8 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-35 0h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m8 -1h12v1h-12m-21 0h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m14 -1h3v1h-3m-33 0h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h3v1h-3m4 -1h6v1h-6m18 -1h4v1h-4m-31 0h1v1h-1m8 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m13 -1h2v1h-2m3 -1h1v1h-1m-35 0h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h4v1h-4m16 -1h4v1h-4m-31 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h3v1h-3m4 -1h3v1h-3m4 -1h3v1h-3m-33 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h26v1h-26m-9 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h3v1h-3m4 -1h1v1h-1m-35 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h24v1h-24m-11 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h3v1h-3m4 -1h3v1h-3m4 -1h7v1h-7m-29 0h1v1h-1m2 -1h3v1h-3m4 -1h30v1h-30m-5 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h3v1h-3m4 -1h3v1h-3m4 -1h1v1h-1m-35 0h1v1h-1m2 -1h1v1h-1m2 -1h3v1h-3m4 -1h28v1h-28" />
159
+ <path fill="#FFFFFF" d="M2 10h23v1h-23m24 -1h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-34 0h10v1h-10m11 -1h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-33 0h21v1h-21m22 -1h3v1h-3m4 -1h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-34 0h8v1h-8m9 -1h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-35 0h19v1h-19m20 -1h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-34 0h6v1h-6m7 -1h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m-33 0h17v1h-17m18 -1h3v1h-3m4 -1h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-34 0h4v2h-4m5 -2h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m-19 0h3v1h-3m8 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-34 0h2v1h-2m3 -1h1v1h-1m14 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m4 -1h1v1h-1m-29 0h4v1h-4m16 -1h1v1h-1m2 -1h2v1h-2m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-33 0h3v1h-3m16 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m-31 0h4v1h-4m8 -1h3v1h-3m4 -1h3v1h-3m4 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-34 0h2v1h-2m3 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m-25 0h4v1h-4m8 -1h1v1h-1m2 -1h3v1h-3m4 -1h3v1h-3m4 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-33 0h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m8 -1h1v1h-1m-27 0h4v1h-4m12 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-31 0h1v1h-1m2 -1h1v1h-1m10 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-17 0h4v1h-4m12 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-33 0h1v1h-1m2 -1h1v1h-1m10 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-19 0h3v1h-3m8 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m-31 0h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m-17 0h1v1h-1m2 -1h2v1h-2m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m-33 0h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m-19 0h3v1h-3m8 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m14 -1h1v1h-1m-31 0h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m4 -1h1v1h-1m-13 0h1v1h-1m2 -1h2v1h-2m6 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m16 -1h1v1h-1m-33 0h1v1h-1m2 -1h1v1h-1m6 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m-15 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m4 -1h1v1h-1m4 -1h1v1h-1m-31 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m-9 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m4 -1h1v1h-1m-33 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m-11 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m4 -1h1v1h-1m4 -1h1v1h-1m-27 0h1v1h-1m4 -1h1v1h-1m-5 0h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1m4 -1h1v1h-1m4 -1h1v1h-1m-33 0h1v1h-1m2 -1h1v1h-1m4 -1h1v1h-1" />
160
+ <path fill="#000000" d="M6 18h12v4h-12m16 -4h4v16h-4m-16 -12h4v4h-4m0 0h8v4h-8m0 0h4v8h-4m16 -4h12v4h-12" />
161
+ </g>
162
+ <g mask="url(#fl-mask)">
163
+ <rect class="fl-sweep" width="48" height="48" fill="url(#fl-glint)" />
164
+ </g>
165
+ </svg>
166
+ <svg class="fl-word" viewBox="0 -13 120 14" width="120" height="14" aria-hidden="true">
167
+ <path fill="#9aa4b2" d="M10 0L2.5 0L2.5-12.5L5-12.5L5-2.5L10-2.5L10 0ZM22.5 0L17.5 0L17.5-2.5L15-2.5L15-10L17.5-10L17.5-12.5L22.5-12.5L22.5-10L25-10L25-2.5L22.5-2.5L22.5 0ZM17.5-9.9L17.5-2.6L22.5-2.6L22.5-9.9L17.5-9.9ZM32.5 0L30 0L30-10L32.5-10L32.5-12.5L37.5-12.5L37.5-10L40-10L40 0L37.5 0L37.5-5L32.5-5L32.5 0ZM32.5-9.9L32.5-7.5L37.5-7.5L37.5-9.9L32.5-9.9ZM52.5 0L45 0L45-12.5L52.5-12.5L52.5-10L55-10L55-2.5L52.5-2.5L52.5 0ZM47.5-10L47.5-2.5L52.4-2.5L52.4-10L47.5-10ZM62.5 0L60 0L60-12.5L62.5-12.5L62.5 0ZM70 0L67.5 0L67.5-12.5L70-12.5L70-10L72.5-10L72.5-7.5L75-7.5L75-5L77.5-5L77.5-12.5L80-12.5L80 0L77.5 0L77.5-2.5L75-2.5L75-5L72.5-5L72.5-7.5L70-7.5L70 0ZM95-10L87.5-10L87.5-12.5L95-12.5L95-10ZM87.5-2.5L85-2.5L85-10L87.5-10L87.5-2.5ZM95-2.5L92.5-2.5L92.5-5L90-5L90-7.5L95-7.5L95-2.5ZM92.5 0L87.5 0L87.5-2.5L92.5-2.5L92.5 0Z" />
168
+ <path class="fl-dot" fill="#47cca9" d="M102.5 0L100 0L100-2.5L102.5-2.5L102.5 0Z" />
169
+ <path class="fl-dot fl-dot2" fill="#47cca9" d="M110 0L107.5 0L107.5-2.5L110-2.5L110 0Z" />
170
+ <path class="fl-dot fl-dot3" fill="#47cca9" d="M117.5 0L115 0L115-2.5L117.5-2.5L117.5 0Z" />
171
+ </svg>
172
+ </div>
173
+ </div>
174
+ <button id="fullscreen" type="button" aria-label="Enter fullscreen">
175
+ <svg class="icon-enter" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
176
+ <path d="M6 1.5H1.5V6M10 1.5h4.5V6M6 14.5H1.5V10M10 14.5h4.5V10" />
177
+ </svg>
178
+ <svg class="icon-exit" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
179
+ <path d="M1.5 5.5H6V1M14.5 5.5H10V1M1.5 10.5H6V15M14.5 10.5H10V15" />
180
+ </svg>
181
+ </button>
182
+ </div>
183
+ <script type="module" src="/src/main.ts"></script>
184
+ </body>
185
+ </html>
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "flatland-template-three",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview",
10
+ "typecheck": "tsc --noEmit",
11
+ "lint": "oxlint .",
12
+ "format": "oxfmt .",
13
+ "format:check": "oxfmt --check .",
14
+ "test": "vitest run",
15
+ "test:watch": "vitest",
16
+ "test:e2e": "playwright test",
17
+ "test:e2e:install": "playwright install chromium"
18
+ },
19
+ "dependencies": {
20
+ "koota": "^0.6.5",
21
+ "three": "^0.183.1",
22
+ "three-flatland": "^0.1.0-alpha.8"
23
+ },
24
+ "devDependencies": {
25
+ "@playwright/test": "^1.61.1",
26
+ "@types/three": "^0.183.1",
27
+ "oxfmt": "^0.59.0",
28
+ "oxlint": "^1.74.0",
29
+ "typescript": "^5.7.3",
30
+ "vite": "^7.3.6",
31
+ "vitest": "^3.2.6"
32
+ },
33
+ "nx": {
34
+ "tags": [
35
+ "type:template"
36
+ ],
37
+ "targets": {
38
+ "lint": {
39
+ "executor": "nx:run-commands",
40
+ "options": {
41
+ "command": "oxlint --type-aware .",
42
+ "cwd": "{projectRoot}"
43
+ }
44
+ }
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,41 @@
1
+ import { defineConfig, devices } from '@playwright/test'
2
+
3
+ /**
4
+ * End-to-end config. `webServer` starts `npm run dev` on a fixed port and tears
5
+ * it down when the run finishes, so `npm run test:e2e` is the whole command —
6
+ * there is no separate "start the server first" step to forget.
7
+ *
8
+ * One-time setup: `npm run test:e2e:install` downloads the Chromium build
9
+ * Playwright drives.
10
+ */
11
+
12
+ const PORT = 5183
13
+ const HOST = '127.0.0.1'
14
+ const IS_CI = !!process.env.CI
15
+
16
+ export default defineConfig({
17
+ testDir: './e2e',
18
+ fullyParallel: true,
19
+ forbidOnly: IS_CI,
20
+ retries: IS_CI ? 2 : 0,
21
+ workers: IS_CI ? 1 : undefined,
22
+ reporter: 'list',
23
+ timeout: 60_000,
24
+ expect: { timeout: 15_000 },
25
+ use: {
26
+ baseURL: `http://${HOST}:${PORT}/`,
27
+ trace: 'on-first-retry',
28
+ screenshot: 'only-on-failure',
29
+ },
30
+ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
31
+ webServer: {
32
+ // `--strictPort` so a squatter on the port fails loudly instead of Vite
33
+ // quietly picking another one and the tests hitting a stale app.
34
+ command: `npm run dev -- --port ${PORT} --strictPort --host ${HOST}`,
35
+ url: `http://${HOST}:${PORT}/`,
36
+ reuseExistingServer: !IS_CI,
37
+ timeout: 60_000,
38
+ stdout: 'ignore',
39
+ stderr: 'pipe',
40
+ },
41
+ })