diff --git a/PATTERN_GUIDE.md b/PATTERN_GUIDE.md index a32a39d07..3ba06a3da 100644 --- a/PATTERN_GUIDE.md +++ b/PATTERN_GUIDE.md @@ -216,8 +216,7 @@ Now for the making side. Click **Pattern Lab ↗** at the community's top right. Pattern Lab has a lot of panels, but **this guide uses exactly two things: pattern generation and the color ramp.** They're enough to take a pattern from -nothing to your device. Layers, pixel art, the gallery — all of that is for a -later guide. +nothing to your device. Layers and pixel art are for a later guide. ### 4-1. Pick a resolution first @@ -384,5 +383,5 @@ onto their device. The loop has come all the way around. --- -*The rest of Pattern Lab — layer compositing, pixel art, the gallery — gets -its own guide later.* +*The rest of Pattern Lab, layer compositing and pixel art, gets its own guide +later.* diff --git a/README.md b/README.md index 31b6f71fe..5df970569 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ You don't need hardware to start. The **[Live Editor](https://patternflow.work/p Live Editor: a Patternflow in the browser, code beside the device

-**[Pattern Lab](https://patternflow.work/pattern-lab)** is the studio, and where a pattern reaches the hardware. Make variations in batches, with your own Gemini key or through any AI chat, then shape the color ramps and retune the knob ranges. Send it to your Patternflow and it builds into a small `.pfm` module that installs over Wi-Fi in about ten seconds, with no cable, no reflash and no IDE. Graphic Export renders the same pattern as a print-size PNG for a business card or an MP4 loop for a post, in your browser, with nothing uploaded. +**[Pattern Lab](https://patternflow.work/pattern-lab)** is the studio, and where a pattern reaches the hardware. Make variations through any AI chat, then shape the color ramps and retune the knob ranges. Send it to your Patternflow and it builds into a small `.pfm` module that installs over Wi-Fi in about ten seconds, with no cable, no reflash and no IDE. Graphic Export renders the same pattern as a print-size PNG for a business card or an MP4 loop for a post, in your browser, with nothing uploaded. Then publish it to the **[Community](https://community.patternflow.work/community)**: more than a hundred patterns, from quiet waves to chaos-theory studies, all playable under the same four knobs. Collect them into a deck and send it to your board in one click. Browsing needs no account, and publishing asks only a username and password, no email. New studies go up on **[Instagram](https://www.instagram.com/patternflow.work)** almost daily; publish something that stands out and it can get featured there, or ask for a collab and I'll post yours with you. The [Make guide](https://patternflow.work/guide/make) walks the whole loop as a tutorial you do as you read, no hardware needed. diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index 8abee783e..ccf040cde 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -61,23 +61,22 @@ Mirrors the firmware; the same JS runs in the browser, in the community's sandbo - **`src/lib/pattern/controls.ts`** — knob-scale constants shared with the firmware. - **`src/lib/pattern/share.ts`** — licence header + attribution footer. The community bakes them into the stored code when a pattern is published or its details are edited (`src/lib/community/license.ts`), so the pattern page shows them, and a download wraps the code it hands out (`src/lib/community/download.ts`). The Live Editor and the Lab never add them while you write. - **`src/lib/pattern/packs.ts`** — the Basics pack that ships with the firmware (`public/packs/`, built by `firmware/toolchain/make_pack.py`). -- **`src/lib/ai/gemini.ts`** — bring-your-own-key Gemini generation. The key lives in `localStorage` and calls go straight from the browser to Google; no server proxy, no bundled key. +- **`src/lib/ai/variantPrompt.ts`** — the prompt the Lab's Copy prompt puts on the clipboard, for whatever AI chat you paste it into. The site calls no AI itself. The Lab's Gemini gallery, which did, came out on 23 October 2026. ## Pattern Lab (`src/app/pattern-lab/` + `src/lib/lab/`) -A layered, dockable workspace: nine panels over a layer-stack project. A panel is one entry in `panels/registry.tsx` (id, title, component, where it docks by default) plus its component file — the shell derives the dockview component map, the Panels menu and the default layout from that list, and the four heavy editors load on demand. The project is one Zustand store, `src/lib/lab/store.ts`, which the render engine (`engine.ts`) reads imperatively each frame and the panels subscribe to; its actions are composed from one slice per concern under `src/lib/lab/store/` (project, layers, knobs, ramp, director, gallery), with the helpers they share in `store/shared.ts`. Everything the lab keeps in `localStorage` is listed in `src/lib/lab/persist.ts`, with the read/write helpers every persistence site uses. Pixel undo stacks are `src/lib/lab/pixelHistory.ts`. +A layered, dockable workspace: eight panels over a layer-stack project. A panel is one entry in `panels/registry.tsx` (id, title, component, where it docks by default) plus its component file — the shell derives the dockview component map, the Panels menu and the default layout from that list, and the three heavy editors load on demand. The project is one Zustand store, `src/lib/lab/store.ts`, which the render engine (`engine.ts`) reads imperatively each frame and the panels subscribe to; its actions are composed from one slice per concern under `src/lib/lab/store/` (project, layers, knobs, ramp, director, gallery), with the helpers they share in `store/shared.ts`. Everything the lab keeps in `localStorage` is listed in `src/lib/lab/persist.ts`, with the read/write helpers every persistence site uses. Pixel undo stacks are `src/lib/lab/pixelHistory.ts`. | Panel | Where | What | | :--- | :--- | :--- | | Preview, Layers, Code, Knobs, Color Ramp | `panels/` | The editor proper. Layers are code (JS) or pixel (RGBA buffer, edited in place and versioned by `rev`); ramps are per layer, knobs are one shared set | | Pixel | `panels/PixelPanel.tsx` + `panels/pixel/` + `lib/lab/pixelTools.ts` | Pixel-art editor. The panel composes one hook per concern (`usePixelToolState`, `usePixelSelection`, `usePixelViewport`, `usePixelCanvases`, `usePixelPointer`, `usePixelKeyboard`) and two toolbar components; `pixelToCode.ts` turns a sprite into standalone pattern code | -| Gallery | `panels/GalleryPanel.tsx` | Gemini variant generation queue | | Graphic Export | `panels/CapturePanel.tsx` + `lib/lab/capture/` | Stills and clips at print sizes, rendered by a second engine in a Web Worker (WebCodecs + `mediabunny`, all client-side), plus a GLSL shader twin for posters. `probe.ts` decides whether a pattern can be re-rendered at another size or must be upscaled | | Director | `panels/DirectorPanel.tsx` + `panels/director/` + `lib/lab/director/` | Knob automation over time — keyframes, bezier eases — baked to `.pfs` show files (`src/lib/pattern/pfst.ts` is the codec) and exported as MIDI. The panel composes `useTransportView`, `useTimelineZoom`, `useShowEditing`, the file helpers and two bars; the lanes are `DirectorLanes.tsx` | Each panel owns its stylesheet (`panels/.module.css`); `PatternLab.module.css` keeps only what two or more files draw with, and `LabPanels.module.css` the dock chrome. The lab reaches the community through exactly one file, `community.ts` (the publish and send modals, the configured switches, the handoff, the `.pfs` reader) — ESLint refuses any other import of `@/components/community` or `@/lib/community` from under the lab. -Exports flatten the visible stack to one standalone pattern (`flatten.ts`); `hExport.ts` writes the C++ header; `HardwareModal.tsx` sends it to the build service or the panel. Persistence is `localStorage`: the project (`serialize.ts`, `patternflow_lab_project_v2`), the dock layout, a ring of parked sessions (`sessions.ts`), capture settings and shaders under their own keys, the generated gallery (`patternflow_lab_gallery_v1`, still the live key, read and written by `src/lib/lab/legacyDraft.ts`), and the draft and ramp keys the lab stopped writing at Pattern Lab v2 on 27 July 2026, which `migrateLegacyDraft` in `serialize.ts` lifts into a project when none restores. +Exports flatten the visible stack to one standalone pattern (`flatten.ts`); `hExport.ts` writes the C++ header; `HardwareModal.tsx` sends it to the build service or the panel. Persistence is `localStorage`: the project (`serialize.ts`, `patternflow_lab_project_v2`), the dock layout, a ring of parked sessions (`sessions.ts`), capture settings and shaders under their own keys, and the draft and ramp keys the lab stopped writing at Pattern Lab v2 on 27 July 2026, which `migrateLegacyDraft` in `serialize.ts` lifts into a project when none restores. The AI gallery's patterns and Gemini key, written until 23 October 2026, are removed on open (`dropAiGallery` in `src/lib/lab/legacyDraft.ts`). ## Community (`src/lib/community/` + `src/components/community/`) diff --git a/web/package-lock.json b/web/package-lock.json index 6337a0467..4c2f27389 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,7 +8,6 @@ "name": "version2", "version": "0.1.0", "dependencies": { - "@google/genai": "^2.9.0", "@mdx-js/loader": "^3.1.1", "@monaco-editor/react": "^4.7.0", "@next/mdx": "^16.2.4", @@ -1631,30 +1630,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@google/genai": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.30.0.tgz", - "integrity": "sha512-Gkv1ix8iZey/V3WF4lHaRS8C71P533jenDT/lA+fdPTCfXYfmCVHvo+0H5jTgzonqsmROQG7QpzLJkpoP/x7bg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2704,70 +2679,6 @@ "integrity": "sha512-0/9+02s/0uDyrH4G/qUg1yyl6gXUg8Uuxw/PlDad3KLzo9FFS9zS/kAQaEshx/saVGupj1aAy/4EUBxWisBogw==", "license": "MIT" }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, "node_modules/@react-three/drei": { "version": "10.7.9", "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.9.tgz", @@ -3745,6 +3656,7 @@ "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3784,12 +3696,6 @@ "@types/react": "*" } }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "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", @@ -4637,6 +4543,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -5126,15 +5033,6 @@ "require-from-string": "^2.0.2" } }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -5261,12 +5159,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5614,15 +5506,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -6531,15 +6414,6 @@ "node": ">= 0.4" } }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.343", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.343.tgz", @@ -7501,29 +7375,6 @@ "pnpm": ">=10" } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -7616,18 +7467,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -7690,34 +7529,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gaxios": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", - "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -7863,32 +7674,6 @@ "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", "license": "MIT" }, - "node_modules/google-auth-library": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz", - "integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.1.4", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -8172,6 +7957,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -8929,15 +8715,6 @@ "node": ">=6" } }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -8988,27 +8765,6 @@ "node": ">=4.0" } }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9364,12 +9120,6 @@ "dev": true, "license": "MIT" }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -10784,26 +10534,6 @@ "node": ">=10" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -10823,24 +10553,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/node-releases": { "version": "2.0.38", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", @@ -11055,19 +10767,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -11393,30 +11092,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/protobufjs": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", - "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -11851,15 +11526,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -13313,6 +12979,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, "license": "MIT" }, "node_modules/unified": { @@ -14255,15 +13922,6 @@ "node": ">=18" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/web-vitals": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.3.tgz", @@ -14477,6 +14135,7 @@ "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/web/package.json b/web/package.json index 6bbaf026b..c1dcf23ad 100644 --- a/web/package.json +++ b/web/package.json @@ -65,7 +65,6 @@ "check:voice": "tsx scripts/voice-lint.ts" }, "dependencies": { - "@google/genai": "^2.9.0", "@mdx-js/loader": "^3.1.1", "@monaco-editor/react": "^4.7.0", "@next/mdx": "^16.2.4", diff --git a/web/public/guide/lab/code.webp b/web/public/guide/lab/code.webp index 2cb12603e..3cde938fe 100644 Binary files a/web/public/guide/lab/code.webp and b/web/public/guide/lab/code.webp differ diff --git a/web/public/guide/lab/gallery.webp b/web/public/guide/lab/gallery.webp deleted file mode 100644 index 9dbda4964..000000000 Binary files a/web/public/guide/lab/gallery.webp and /dev/null differ diff --git a/web/scripts/lab-director-smoke.ts b/web/scripts/lab-director-smoke.ts index 18342cf78..28dd89a58 100644 --- a/web/scripts/lab-director-smoke.ts +++ b/web/scripts/lab-director-smoke.ts @@ -169,7 +169,7 @@ function key(t: number, v: number, mode: "hold" | "curve" = "hold"): DirectorKey knobLabels: ["A", "B", "C", "D"], forkOf: null, editOf: null, - gen: { count: 5, thinking: "LOW" as const, refs: 6, colorMode: "vfield" as const }, + gen: { colorMode: "vfield" as const }, director: show, name: "Smoke Piece", }; diff --git a/web/scripts/lab-edit-smoke.ts b/web/scripts/lab-edit-smoke.ts index a93a0bd2d..7208a19f1 100644 --- a/web/scripts/lab-edit-smoke.ts +++ b/web/scripts/lab-edit-smoke.ts @@ -130,7 +130,7 @@ async function main() { knobLabels: ["A", "B", "C", "D"], forkOf: null, editOf: EDIT, - gen: { count: 5, thinking: "LOW" as const, refs: 6, colorMode: "vfield" as const }, + gen: { colorMode: "vfield" as const }, director: emptyShow(), name: "Edit Smoke", }; diff --git a/web/scripts/lab-flatten-smoke.ts b/web/scripts/lab-flatten-smoke.ts index f98a05a4f..2b9409f47 100644 --- a/web/scripts/lab-flatten-smoke.ts +++ b/web/scripts/lab-flatten-smoke.ts @@ -254,7 +254,7 @@ async function stackSmoke() { knobLabels: ["Hue", "Speed", "Freq", "Mix"], forkOf: null, editOf: null, - gen: { count: 5, thinking: "LOW" as const, refs: 6, colorMode: "vfield" as const }, + gen: { colorMode: "vfield" as const }, director: emptyShow(), name: "Flatten Smoke", }; diff --git a/web/scripts/license-smoke.ts b/web/scripts/license-smoke.ts index a6f8d89b7..6e2c3add0 100644 --- a/web/scripts/license-smoke.ts +++ b/web/scripts/license-smoke.ts @@ -142,15 +142,15 @@ const communityFooter = [ "// please keep this notice and the author credit above when you reuse,", "// remix, or redistribute this pattern. Do not delete it.", ].join("\n"); -const geminiFooter = [ +const labAiFooter = [ "// ---", "// Made with Patternflow Pattern Lab — https://patternflow.work/pattern-lab", "// Licensed CC-BY-SA-4.0. Keep this notice if you share or remix.", ].join("\n"); -const stack = `(function () {\n function draw() {}\n${communityFooter}\n})();\n(function () {\n function draw() {}\n${geminiFooter}\n})();\nexport function draw(display) {}`; +const stack = `(function () {\n function draw() {}\n${communityFooter}\n})();\n(function () {\n function draw() {}\n${labAiFooter}\n})();\nexport function draw(display) {}`; check("embedded community footer leaves the code after it alone", stripShareWrapping(stack), stack); check("trailing community footer still comes off", stripShareWrapping(`${BODY}\n\n${communityFooter}\n`), BODY); -check("trailing gemini footer still comes off", stripShareWrapping(`${BODY}\n\n${geminiFooter}\n`), BODY); +check("trailing Lab AI footer still comes off", stripShareWrapping(`${BODY}\n\n${labAiFooter}\n`), BODY); check( "a @stack line after the body survives a trailing footer", stripShareWrapping(`${BODY}\n\n// @stack v1 d:AAAA\n\n${communityFooter}\n`), diff --git a/web/src/app/api/community/patterns/route.ts b/web/src/app/api/community/patterns/route.ts index 3f954aee1..f66f1e60c 100644 --- a/web/src/app/api/community/patterns/route.ts +++ b/web/src/app/api/community/patterns/route.ts @@ -145,8 +145,8 @@ async function handlePost(request: Request) { if (!code) return Response.json({ error: "Pattern code is missing or over 100KB." }, { status: 400 }); // Any licence block the code arrived with belongs to someone else (a lab - // preset's header, a Gemini stamp, a previously exported file), so drop it - // before we write our own. + // preset's header, the Lab's old AI gallery stamp, a previously exported + // file), so drop it before we write our own. if (stripShareWrapping(code).length === 0) { return Response.json({ error: "Pattern code is empty once the licence header is removed." }, { status: 400 }); } diff --git a/web/src/app/pattern-lab/PatternLab.module.css b/web/src/app/pattern-lab/PatternLab.module.css index eb7499e27..d7c66f102 100644 --- a/web/src/app/pattern-lab/PatternLab.module.css +++ b/web/src/app/pattern-lab/PatternLab.module.css @@ -107,8 +107,7 @@ } .headerMeta, -.stats, -.emptyState { +.stats { color: rgba(23, 21, 18, 0.62); font-family: var(--font-jetbrains), monospace; font-size: 12px; @@ -328,11 +327,6 @@ font-weight: 700; } -.emptyState { - margin: 0; - padding: 18px 14px; -} - .editorHeader button:disabled { opacity: 0.5; cursor: default; @@ -364,62 +358,6 @@ color: #f7f3ea !important; } -.editorHeader .keyButton { - background: transparent !important; - color: rgba(23, 21, 18, 0.62) !important; - border-color: rgba(23, 21, 18, 0.22) !important; - padding: 0 10px; - font-size: var(--pf-fs-mono); -} - -.editorHeader .keyButton:hover { - color: #171512 !important; - border-color: #171512 !important; -} - -.editorHeader .genField { - display: flex; - align-items: center; - gap: 6px; - color: rgba(23, 21, 18, 0.62); - font-family: var(--font-jetbrains), monospace; - font-size: var(--pf-fs-mono); - text-transform: uppercase; -} - -.editorHeader .genField input { - width: 56px; - min-height: 38px; - border: 1px solid rgba(23, 21, 18, 0.3); - background: #ffffff; - color: #171512; - padding: 0 8px; - font-family: var(--font-jetbrains), monospace; - font-size: 12px; -} - -.editorHeader .genThinking { - min-height: 38px; - border: 1px solid rgba(23, 21, 18, 0.3); - background: #ffffff; - color: #171512; - padding: 0 8px; - font-family: var(--font-jetbrains), monospace; - font-size: 12px; - text-transform: uppercase; - cursor: pointer; -} - -@keyframes jobPulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.3; - } -} - .variantActions { display: flex; gap: 8px; diff --git a/web/src/app/pattern-lab/PatternLabClient.tsx b/web/src/app/pattern-lab/PatternLabClient.tsx index 15ea7261b..3a534b4ff 100644 --- a/web/src/app/pattern-lab/PatternLabClient.tsx +++ b/web/src/app/pattern-lab/PatternLabClient.tsx @@ -3,11 +3,11 @@ // Pattern Lab v2 — a layered, dockable pattern editing workspace. // // The old lab was one pattern + one fixed two-column layout. This shell hosts -// nine dockable panels (dockview) over a layer-stack project (zustand): -// Preview, Layers, Code, Pixel, Gallery, Knobs, Color Ramp, Graphic Export, -// Director — the list is panels/registry.tsx. Panels rearrange -// Photoshop-style — drag tabs, split groups, float — and the arrangement -// persists to localStorage alongside the project itself. +// eight dockable panels (dockview) over a layer-stack project (zustand): +// Preview, Layers, Code, Pixel, Knobs, Color Ramp, Graphic Export, Director — +// the list is panels/registry.tsx. Panels rearrange Photoshop-style — drag +// tabs, split groups, float — and the arrangement persists to localStorage +// alongside the project itself. // // Exports (community publish, firmware build) flatten the visible stack into // one standalone JS pattern (see lib/lab/flatten.ts), so every downstream @@ -34,7 +34,7 @@ import { currentPerformanceJson } from "@/lib/lab/director/publish"; import { onEditorReveal } from "@/lib/lab/editorReveal"; import { flattenLayers, needsFlatten } from "@/lib/lab/flatten"; import { readStorage, removeStorage, writeStorage, type LabStorageKey } from "@/lib/lab/persist"; -import { LAYOUT_NARROW_STORAGE, LAYOUT_STORAGE, layoutViewCount } from "@/lib/lab/serialize"; +import { LAYOUT_NARROW_STORAGE, LAYOUT_STORAGE, dropRetiredPanels, layoutViewCount } from "@/lib/lab/serialize"; import { listSessions, type SessionMeta } from "@/lib/lab/sessions"; import { buildStackAnnotation, stripStackAnnotation } from "@/lib/lab/stackShare"; import { flushLabProject, saveLabProjectNow, useLabStore } from "@/lib/lab/store"; @@ -251,7 +251,9 @@ export default function PatternLabClient() { try { const raw = readStorage(layoutKey.current); if (raw) { - const parsed = JSON.parse(raw); + // A panel the registry no longer has (Gallery, 23 October 2026) + // comes out first; fromJSON would throw on it. + const parsed = dropRetiredPanels(JSON.parse(raw), new Set(Object.keys(panelComponents))); // Restoring a layout that places nothing gives you an empty // workspace: every panel gone, and Reset layout the only way out. // dockview will happily do it, so the check has to happen here. diff --git a/web/src/app/pattern-lab/panels/CodePanel.module.css b/web/src/app/pattern-lab/panels/CodePanel.module.css index 3fe1d50b2..ec8deb95d 100644 --- a/web/src/app/pattern-lab/panels/CodePanel.module.css +++ b/web/src/app/pattern-lab/panels/CodePanel.module.css @@ -19,3 +19,11 @@ button.promptButton { background: #eee7d8; font-weight: 600; } + +/* The colour mode is what the copied prompt asks for, so on a narrow bar the + two wrap to the next row together. */ +.promptGroup { + display: inline-flex; + align-items: center; + gap: 6px; +} diff --git a/web/src/app/pattern-lab/panels/CodePanel.test.tsx b/web/src/app/pattern-lab/panels/CodePanel.test.tsx index 4dbfd7d0c..30931f673 100644 --- a/web/src/app/pattern-lab/panels/CodePanel.test.tsx +++ b/web/src/app/pattern-lab/panels/CodePanel.test.tsx @@ -75,3 +75,26 @@ describe("CodePanel copy prompt", () => { } }); }); + +// The colour mode used to be picked in the Gallery tab, which went on +// 23 October 2026. It sits beside Copy prompt now, and the prompt follows it. +describe("CodePanel colour mode", () => { + it("copies a v-field prompt until rgb is picked, then an rgb one", async () => { + const writeText = vi.fn<(text: string) => Promise>(() => Promise.resolve()); + setClipboard({ writeText }); + render(); + + const mode = screen.getByRole("combobox", { name: "Color mode" }) as HTMLSelectElement; + expect(mode.value).toBe("vfield"); + fireEvent.click(screen.getByText("Copy prompt")); + await act(async () => {}); + expect(writeText.mock.calls[0][0]).toContain("display.setValue(x, y, v)"); + + fireEvent.change(mode, { target: { value: "rgb" } }); + expect(useLabStore.getState().gen).toEqual({ colorMode: "rgb" }); + fireEvent.click(screen.getByText("Copied")); + await act(async () => {}); + expect(writeText.mock.calls[1][0]).toContain("display.setPixel(x, y, r, g, b)"); + expect(writeText.mock.calls[1][0]).not.toContain("Do NOT call display.setPixel"); + }); +}); diff --git a/web/src/app/pattern-lab/panels/CodePanel.tsx b/web/src/app/pattern-lab/panels/CodePanel.tsx index 0c7f4d9d6..695a4e314 100644 --- a/web/src/app/pattern-lab/panels/CodePanel.tsx +++ b/web/src/app/pattern-lab/panels/CodePanel.tsx @@ -2,12 +2,13 @@ // Monaco editor bound to the ACTIVE code layer. The toolbar keeps the // single-pattern workflow: preset stepper, paste/clear, the AI variation -// prompt, and the code guide. +// prompt with the colour model it asks for, and the code guide. import Editor from "@monaco-editor/react"; import { useState } from "react"; import { livePresets } from "@/lib/presets"; -import { buildVariantCopyPrompt } from "@/lib/ai/gemini"; +import { COLOR_MODES, type ColorMode } from "@/lib/ai/settings"; +import { buildVariantCopyPrompt } from "@/lib/ai/variantPrompt"; import { useResettingState } from "@/lib/hooks/useResettingState"; import { hasStackAnnotation, importCodeIntoLab } from "@/lib/lab/stackShare"; import { useActiveLayer, useLabStore } from "@/lib/lab/store"; @@ -25,6 +26,7 @@ export default function CodePanel() { const knobs = useLabStore((state) => state.knobs); const ranges = useLabStore((state) => state.ranges); const gen = useLabStore((state) => state.gen); + const setGen = useLabStore((state) => state.setGen); const updateLayerCode = useLabStore((state) => state.updateLayerCode); const applyCodeToActive = useLabStore((state) => state.applyCodeToActive); const addCodeLayer = useLabStore((state) => state.addCodeLayer); @@ -139,14 +141,28 @@ export default function CodePanel() { > {cleared ? "Cleared ✓" : "Clear"} - + + + + diff --git a/web/src/app/pattern-lab/panels/GalleryPanel.module.css b/web/src/app/pattern-lab/panels/GalleryPanel.module.css deleted file mode 100644 index 8ad0f2623..000000000 --- a/web/src/app/pattern-lab/panels/GalleryPanel.module.css +++ /dev/null @@ -1,314 +0,0 @@ -/* ── GalleryPanel: its own classes ───────────────────────────────────────── - Moved out of PatternLab.module.css on 2026-09; only this file uses them. */ - -.galleryPane { - height: 100%; - overflow-y: auto; -} - -.queue { - border-bottom: 1px solid rgba(23, 21, 18, 0.18); -} - -.queueHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 10px 14px; - color: rgba(23, 21, 18, 0.62); - font-family: var(--font-jetbrains), monospace; - font-size: 12px; - text-transform: uppercase; -} - -.queueHeader button { - min-height: 28px; - border: 1px solid rgba(23, 21, 18, 0.22); - background: #ffffff; - color: #171512; - padding: 0 10px; - font: inherit; - font-family: var(--font-jetbrains), monospace; - font-size: var(--pf-fs-mono); - text-transform: uppercase; - cursor: pointer; -} - -.queueHeader button:hover { - border-color: #171512; -} - -.queueList { - margin: 0; - padding: 0; - list-style: none; -} - -.jobRow { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 14px; - border-top: 1px solid rgba(23, 21, 18, 0.1); - font-family: var(--font-jetbrains), monospace; - font-size: 12px; -} - -.jobRow input[type="checkbox"] { - flex: 0 0 auto; - accent-color: #171512; -} - -.jobDot { - flex: 0 0 auto; - width: 8px; - height: 8px; - border-radius: 50%; - background: rgba(23, 21, 18, 0.3); -} - -.jobRow[data-status="running"] .jobDot { - background: #8a5d00; - animation: jobPulse 1s ease-in-out infinite; -} - -.jobRow[data-status="done"] .jobDot { - background: #177246; -} - -.jobRow[data-status="error"] .jobDot { - background: #b42318; -} - -.jobLabel { - display: flex; - align-items: center; - gap: 8px; - white-space: nowrap; -} - -.jobLabel em { - font-style: normal; - color: rgba(23, 21, 18, 0.5); - font-size: 10px; - text-transform: uppercase; -} - -.jobError { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: #b42318; - font-size: var(--pf-fs-mono); -} - -.jobTime { - margin-left: auto; - color: rgba(23, 21, 18, 0.55); - font-variant-numeric: tabular-nums; -} - -.galleryToolbar { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - padding: 8px 14px; - border-bottom: 1px solid rgba(23, 21, 18, 0.14); -} - -.galleryToolbar button { - min-height: 28px; - border: 1px solid rgba(23, 21, 18, 0.22); - background: #ffffff; - color: #171512; - padding: 0 10px; - font: inherit; - font-family: var(--font-jetbrains), monospace; - font-size: var(--pf-fs-mono); - text-transform: uppercase; - cursor: pointer; -} - -.galleryToolbar button:hover { - border-color: #171512; -} - -.galleryToolbar button:disabled { - opacity: 0.4; - cursor: default; -} - -.galleryToolbar .toolbarActive { - background: #171512; - color: #f7f3ea; - border-color: #171512; -} - -.toolbarHint { - margin-left: auto; - color: rgba(23, 21, 18, 0.55); - font-family: var(--font-jetbrains), monospace; - font-size: var(--pf-fs-mono); - text-transform: uppercase; -} - -.deleteButton { - border-color: rgba(180, 35, 24, 0.5) !important; - color: #b42318 !important; -} - -.deleteButton:hover:not(:disabled) { - border-color: #b42318 !important; - background: #fff4f2 !important; -} - -.galleryGrid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 0; - margin: 0; - padding: 0; - list-style: none; -} - -.galleryGrid li { - border-right: 1px solid rgba(23, 21, 18, 0.14); - border-bottom: 1px solid rgba(23, 21, 18, 0.14); -} - -.variantCardSelected { - background: #e8dcc2; -} - -.variantCardSelected:hover { - background: #e1d2b2; -} - -.variantCardSelected .variantFrame { - outline: 2px solid #171512; - outline-offset: 1px; -} - -.selectBadge { - position: absolute; - top: 6px; - right: 6px; - z-index: 2; - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - background: #171512; - color: #f7f3ea; - font-size: var(--pf-fs-mono); - line-height: 1; -} - -.pinBadge { - position: absolute; - top: 6px; - left: 6px; - z-index: 2; - padding: 2px 5px; - background: #8a5d00; - color: #fffdf8; - font-family: var(--font-jetbrains), monospace; - font-size: 9px; - letter-spacing: 0.06em; - line-height: 1; -} - -.variantCard { - display: grid; - gap: 8px; - width: 100%; - border: none; - background: transparent; - padding: 10px; - font: inherit; - color: inherit; - text-align: left; - cursor: pointer; -} - -.variantCard:hover { - background: #f4eede; -} - -.variantCardActive { - background: #e8dcc2; -} - -.variantCardActive:hover { - background: #e1d2b2; -} - -.variantFrame { - position: relative; - aspect-ratio: 1 / 2; - width: 100%; - background: #09090b; - border-radius: 6px; - overflow: hidden; - border: 1px solid rgba(23, 21, 18, 0.2); -} - -.variantFrame canvas { - display: block; - width: 100%; - height: 100%; - image-rendering: pixelated; -} - -.variantError { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - overflow: auto; - padding: 6px; - background: rgba(180, 35, 24, 0.92); - color: #fff; - font-family: var(--font-jetbrains), monospace; - font-size: 10px; - line-height: 1.4; - text-align: center; -} - -.variantMeta { - display: grid; - gap: 2px; -} - -.variantMeta strong { - font-size: 12px; -} - -.variantMeta span { - color: rgba(23, 21, 18, 0.62); - font-family: var(--font-jetbrains), monospace; - font-size: 10px; - line-height: 1.4; -} - -.keyField { - width: 100%; - min-height: 38px; - border: 1px solid rgba(23, 21, 18, 0.3); - background: #ffffff; - color: #171512; - padding: 0 10px; - font-family: var(--font-jetbrains), monospace; - font-size: 12px; -} - -.keyField:focus { - outline: 1px solid #171512; - outline-offset: 1px; -} diff --git a/web/src/app/pattern-lab/panels/GalleryPanel.tsx b/web/src/app/pattern-lab/panels/GalleryPanel.tsx deleted file mode 100644 index ed7437571..000000000 --- a/web/src/app/pattern-lab/panels/GalleryPanel.tsx +++ /dev/null @@ -1,697 +0,0 @@ -"use client"; - -// AI generation gallery. Generations seed from the focused code layer; cards -// render live previews driven by that layer's current knobs and ramp. Clicking -// a card loads it into the active code layer; the ⧉ button stacks it as a new -// layer instead — the layered-lab way to build up a composition. - -import { useEffect, useRef, useState } from "react"; -import { - PatternRuntime, - createIdleInput, - knobTargetToDelta, -} from "@/lib/pattern/harness"; -import { - knobUnitsPerTurn, - LOGICAL_KNOB_WRAP, -} from "@/lib/pattern/controls"; -import { DEFAULT_MATRIX, parseMatrixAnnotation } from "@/lib/pattern/matrix"; -import { - COLOR_MODES, - GEMINI_MODEL, - THINKING_LEVELS, - generatePatternVariants, - loadGeminiKey, - saveGeminiKey, - type ColorMode, - type ThinkingLevelKey, -} from "@/lib/ai/gemini"; -import { captureEvent } from "@/lib/posthogEvents"; -import { livePresets } from "@/lib/presets"; -import { rampStateToHarness } from "@/lib/lab/engine"; -import { useFocusCodeLayer, useLabStore } from "@/lib/lab/store"; -import { isCodeLayer, type GalleryItem, type GenJob, type KnobRange } from "@/lib/lab/types"; -import styles from "../PatternLab.module.css"; -import local from "./GalleryPanel.module.css"; -import dock from "../LabPanels.module.css"; - -const MAX_GALLERY = 48; -const MAX_CONCURRENT_JOBS = 6; -const GEN_COUNT_MIN = 1; -const GEN_COUNT_MAX = 20; -const REF_OPTIONS = [0, 3, 6, 10]; - -function capGallery(items: GalleryItem[]): GalleryItem[] { - if (items.length <= MAX_GALLERY) return items; - const pinnedCount = items.reduce((total, item) => total + (item.pinned ? 1 : 0), 0); - const unpinnedBudget = Math.max(0, MAX_GALLERY - pinnedCount); - let unpinnedKept = 0; - const result: GalleryItem[] = []; - for (const item of items) { - if (item.pinned) { - result.push(item); - } else if (unpinnedKept < unpinnedBudget) { - result.push(item); - unpinnedKept += 1; - } - } - return result; -} - -function sampleExamples(currentCode: string, count: number) { - if (count <= 0) return []; - const pool = livePresets.filter((preset) => preset.code !== currentCode); - for (let i = pool.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [pool[i], pool[j]] = [pool[j], pool[i]]; - } - return pool.slice(0, count).map((preset) => ({ name: preset.name, code: preset.code })); -} - -function formatDuration(ms: number) { - const total = Math.max(0, Math.floor(ms / 1000)); - const minutes = Math.floor(total / 60); - const seconds = total % 60; - return `${minutes}:${String(seconds).padStart(2, "0")}`; -} - -// A live gallery card: runs its own runtime, reading the FOCUS code layer's -// current knobs/ranges/ramp from the store each frame, so turning a knob -// updates every preview at once. -function VariantPreview({ - code, - name, - active, - selected, - selectMode, - pinned, - onSelect, - onAddAsLayer, -}: { - code: string; - name: string; - active: boolean; - selected: boolean; - selectMode: boolean; - pinned: boolean; - onSelect: () => void; - onAddAsLayer: () => void; -}) { - const canvasRef = useRef(null); - const [error, setError] = useState(null); - const cardMatrix = parseMatrixAnnotation(code) ?? DEFAULT_MATRIX; - - useEffect(() => { - const matrix = parseMatrixAnnotation(code) ?? DEFAULT_MATRIX; - const runtime = new PatternRuntime(matrix.width, matrix.height); - const load = runtime.loadCode(code); - let frameId = 0; - if (!load.ok) { - frameId = requestAnimationFrame(() => setError(load.error ?? "Pattern failed to load.")); - return () => cancelAnimationFrame(frameId); - } - - const paint = () => { - const canvas = canvasRef.current; - const context = canvas?.getContext("2d"); - if (!canvas || !context) return; - const imageData = context.createImageData(runtime.width, runtime.height); - const minLength = Math.min(runtime.data.length, imageData.data.length); - imageData.data.set(runtime.data.subarray(0, minLength)); - context.putImageData(imageData, 0, 0); - }; - paint(); - - let previousKnobs: number[] | null = null; - let cachedRampState: unknown = null; - let lastNow = performance.now(); - let simTime = 0; - - const tick = (now: number) => { - const dt = Math.min(Math.max(0, (now - lastNow) / 1000), 0.05); - lastNow = now; - simTime += dt; - - // Follow the SHARED knobs live; ramp/recolor come from the focus layer. - const state = useLabStore.getState(); - const activeLayer = state.layers.find((layer) => layer.id === state.activeLayerId); - const focus = isCodeLayer(activeLayer) ? activeLayer : state.layers.find(isCodeLayer); - - if (previousKnobs === null) previousKnobs = [...state.knobs]; - const knobDeltas = state.knobs.map((value, index) => - knobTargetToDelta( - previousKnobs![index] ?? value, - value, - LOGICAL_KNOB_WRAP[index], - knobUnitsPerTurn(state.ranges[index] ?? [0, 1]), - ), - ); - const knobNormalized = state.knobs.map((value, index) => { - const range: KnobRange = state.ranges[index] ?? [0, 1]; - const span = Math.max(0.0001, range[1] - range[0]); - return (value - range[0]) / span; - }); - previousKnobs = [...state.knobs]; - - if (focus && cachedRampState !== focus.ramp) { - cachedRampState = focus.ramp; - runtime.setRamp(rampStateToHarness(focus.ramp)); - } - runtime.recolor = focus?.recolor ?? false; - const input = createIdleInput(knobDeltas, { - knobValues: state.knobs, - knobNormalized, - knobRanges: state.ranges, - }); - - const result = runtime.renderFrame(dt, simTime, input); - if (!result.ok) { - setError(result.error ?? "Runtime error."); - return; - } - paint(); - frameId = requestAnimationFrame(tick); - }; - - frameId = requestAnimationFrame(tick); - return () => cancelAnimationFrame(frameId); - }, [code]); - - return ( - - ); -} - -export default function GalleryPanel() { - const focus = useFocusCodeLayer(); - const matrix = useLabStore((state) => state.matrix); - const knobs = useLabStore((state) => state.knobs); - const ranges = useLabStore((state) => state.ranges); - const gen = useLabStore((state) => state.gen); - const setGen = useLabStore((state) => state.setGen); - const gallery = useLabStore((state) => state.gallery); - const jobs = useLabStore((state) => state.jobs); - const setGallery = useLabStore((state) => state.setGallery); - const setJobs = useLabStore((state) => state.setJobs); - const applyCodeToActive = useLabStore((state) => state.applyCodeToActive); - const addCodeLayerFromCode = useLabStore((state) => state.addCodeLayerFromCode); - const activeCode = useLabStore((state) => { - const layer = state.layers.find((entry) => entry.id === state.activeLayerId); - return isCodeLayer(layer) ? layer.code : null; - }); - - const [geminiKey, setGeminiKey] = useState(""); - const [keyModalOpen, setKeyModalOpen] = useState(false); - const [keyDraft, setKeyDraft] = useState(""); - const [selected, setSelected] = useState>(() => new Set()); - const [selectMode, setSelectMode] = useState(false); - const [now, setNow] = useState(0); - const removedJobsRef = useRef>(new Set()); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- browser-only localStorage read after mount - setGeminiKey(loadGeminiKey()); - }, []); - - useEffect(() => { - if (!jobs.some((job) => job.status === "running")) return; - const timer = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(timer); - }, [jobs]); - - const runningJobs = jobs.filter((job) => job.status === "running").length; - - const openKeyModal = () => { - setKeyDraft(geminiKey); - setKeyModalOpen(true); - }; - - const saveKey = () => { - const next = keyDraft.trim(); - saveGeminiKey(next); - setGeminiKey(next); - setKeyModalOpen(false); - }; - - const fireGeneration = () => { - if (!focus) return; - if (!geminiKey) { - openKeyModal(); - return; - } - if (runningJobs >= MAX_CONCURRENT_JOBS) return; - - const count = Math.min(GEN_COUNT_MAX, Math.max(GEN_COUNT_MIN, Math.round(gen.count) || 1)); - const thinkingLevel = gen.thinking; - const jobId = - typeof crypto !== "undefined" && crypto.randomUUID - ? crypto.randomUUID() - : `job-${Date.now()}-${Math.random().toString(36).slice(2)}`; - - const job: GenJob = { - id: jobId, - count, - thinkingLevel, - status: "running", - startedAt: Date.now(), - }; - setJobs((current) => [job, ...current]); - - const seedCode = focus.code; - const seedKnobs = [...knobs]; - const seedRanges = ranges.map((range): KnobRange => [...range]); - const examples = sampleExamples(seedCode, gen.refs); - const seedWithCurrent = gen.refs > 0; - - generatePatternVariants({ - apiKey: geminiKey, - code: seedCode, - knobs: seedKnobs, - ranges: seedRanges, - count, - thinkingLevel, - examples, - matrix, - seedWithCurrent, - colorMode: gen.colorMode, - }) - .then((items) => { - if (removedJobsRef.current.has(jobId)) return; - const stamped: GalleryItem[] = items.map((item, index) => ({ - ...item, - id: `${jobId}-${index}`, - })); - setGallery((current) => capGallery([...stamped, ...current])); - setJobs((current) => - current.map((entry) => - entry.id === jobId - ? { ...entry, status: "done", finishedAt: Date.now(), resultCount: items.length } - : entry, - ), - ); - captureEvent("pattern_lab_generate_variants", { - model: GEMINI_MODEL, - requested: count, - count: items.length, - thinking: thinkingLevel, - color_mode: gen.colorMode, - ms: Date.now() - job.startedAt, - }); - }) - .catch((error) => { - if (removedJobsRef.current.has(jobId)) return; - const message = error instanceof Error ? error.message : "Generation failed."; - setJobs((current) => - current.map((entry) => - entry.id === jobId - ? { ...entry, status: "error", finishedAt: Date.now(), error: message } - : entry, - ), - ); - captureEvent("pattern_lab_generate_variants_error", { - model: GEMINI_MODEL, - requested: count, - thinking: thinkingLevel, - message, - }); - }); - }; - - const toggleSelected = (id: string) => { - setSelected((current) => { - const next = new Set(current); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - const exitSelectMode = () => { - setSelectMode(false); - setSelected(new Set()); - }; - - const deleteSelected = () => { - if (selected.size === 0) return; - selected.forEach((id) => removedJobsRef.current.add(id)); - setJobs((current) => current.filter((job) => !selected.has(job.id))); - setGallery((current) => current.filter((item) => !selected.has(item.id))); - setSelected(new Set()); - }; - - const selectedGalleryItems = gallery.filter((item) => selected.has(item.id)); - const allSelectedPinned = - selectedGalleryItems.length > 0 && selectedGalleryItems.every((item) => item.pinned); - const pinnedCount = gallery.reduce((total, item) => total + (item.pinned ? 1 : 0), 0); - - const togglePinSelected = () => { - if (selectedGalleryItems.length === 0) return; - const pinnedTarget = !allSelectedPinned; - setGallery((current) => - current.map((item) => (selected.has(item.id) ? { ...item, pinned: pinnedTarget } : item)), - ); - }; - - return ( -
-
- - - - - - -
- -
-
- {jobs.length > 0 && ( -
-
- Queue - {jobs.some((job) => job.status !== "running") && ( - - )} -
-
    - {jobs.map((job) => { - const elapsed = (job.finishedAt ?? Math.max(now, job.startedAt)) - job.startedAt; - return ( -
  • - {selectMode && ( - toggleSelected(job.id)} - aria-label="Select job" - /> - )} - - - {job.status === "running" - ? `Generating ${job.count}…` - : job.status === "done" - ? `Done · ${job.resultCount ?? job.count} pattern${(job.resultCount ?? job.count) === 1 ? "" : "s"}` - : "Failed"} - {job.thinkingLevel.toLowerCase()} - - {job.status === "error" && job.error && ( - - {job.error} - - )} - {formatDuration(elapsed)} -
  • - ); - })} -
-
- )} - - {(gallery.length > 0 || jobs.length > 0) && ( -
- {selectMode ? ( - <> - - - - - - {selected.size} selected — click items to toggle - - - ) : ( - <> - - {pinnedCount > 0 && pinnedCount < gallery.length && ( - - )} - - )} -
- )} - - {gallery.length === 0 ? ( - jobs.length === 0 && ( -

- No variants yet — set a count and hit Generate. Click a card to load it into the - active code layer, or +⧉ to stack it as a new layer. -

- ) - ) : ( -
    - {[...gallery] - .sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0)) - .map((item) => ( -
  1. - { - if (selectMode) toggleSelected(item.id); - else applyCodeToActive(item.code); - }} - onAddAsLayer={() => addCodeLayerFromCode(item.code, item.name)} - /> -
  2. - ))} -
- )} -
-
- - {keyModalOpen && ( -
setKeyModalOpen(false)} - > -
event.stopPropagation()}> -
- Gemini API key - -
-
-

- Bring your own Google AI Studio key to generate variations in-app. It is stored only - in this browser (localStorage) and sent directly to Google — never to our servers. -

- setKeyDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") saveKey(); - if (event.key === "Escape") setKeyModalOpen(false); - }} - /> -

- Get a free key at aistudio.google.com/apikey. The key never leaves your browser - except to call Google directly. -

-
- - {geminiKey && ( - - )} -
-
-
-
- )} -
- ); -} diff --git a/web/src/app/pattern-lab/panels/registry.tsx b/web/src/app/pattern-lab/panels/registry.tsx index a9d23a68a..dec21b640 100644 --- a/web/src/app/pattern-lab/panels/registry.tsx +++ b/web/src/app/pattern-lab/panels/registry.tsx @@ -15,7 +15,7 @@ // panel to sit next to and the side, and buildDefaultLayout() adds panels in // whatever order satisfies that. // -// The heavy editors — Pixel, Director, Graphic Export, Gallery — are loaded +// The heavy editors — Pixel, Director, Graphic Export — are loaded // on demand. They are a third of the lab's code and most sessions open one // of them at most; before this every panel sat in the route's first chunk. @@ -35,7 +35,6 @@ export type PanelId = | "layers" | "code" | "pixel" - | "gallery" | "knobs" | "ramp" | "capture" @@ -79,7 +78,6 @@ export const PANELS: readonly PanelDef[] = [ { id: "layers", title: "Layers", component: LayersPanel, dock: { ref: "preview", dir: "below" }, size: { height: 0.34 } }, { id: "code", title: "Code", component: CodePanel, dock: { ref: "preview", dir: "right" }, active: true }, { id: "pixel", title: "Pixel", component: lazy(() => import("./PixelPanel")), dock: { ref: "code", dir: "within" } }, - { id: "gallery", title: "Gallery", component: lazy(() => import("./GalleryPanel")), dock: { ref: "code", dir: "within" } }, { id: "knobs", title: "Knobs", component: KnobsPanel, dock: { ref: "code", dir: "right" }, size: { width: 0.24 } }, { id: "ramp", title: "Color Ramp", component: RampPanel, dock: { ref: "knobs", dir: "below" }, size: { height: 0.42 } }, // Output stage (stills/clips at print sizes) — an add-on module, see lib/lab/capture. diff --git a/web/src/components/guide/LabShots.tsx b/web/src/components/guide/LabShots.tsx index 9c30945a6..d37c26ab3 100644 --- a/web/src/components/guide/LabShots.tsx +++ b/web/src/components/guide/LabShots.tsx @@ -26,7 +26,6 @@ type ShotId = | "recent" | "copyPrompt" | "paste" - | "gallery" | "bw" | "ramp" | "rampRandom" @@ -58,17 +57,16 @@ const GEO: Record = { bw: { src: "preview-bw.webp", w: 401, h: 206, ring: null }, color: { src: "preview-color.webp", w: 401, h: 206, ring: null }, convert: { src: "convert.webp", w: 456, h: 256, ring: [2.85, 63.1, 94.3, 16.41] }, - copyPrompt: { src: "code.webp", w: 485, h: 236, ring: [59.65, 15.68, 20.89, 15.25] }, + copyPrompt: { src: "code.webp", w: 539, h: 236, ring: [64.06, 15.68, 18.8, 15.25] }, director: { src: "director.webp", w: 480, h: 292, ring: [25.21, 12.67, 35.35, 12.33] }, exportGo: { src: "export.webp", w: 476, h: 176, ring: [1.05, 76.93, 97.2, 20.45] }, exportSize: { src: "export.webp", w: 476, h: 176, ring: [1.05, 21.02, 65.76, 20.45] }, - gallery: { src: "gallery.webp", w: 485, h: 236, ring: [62.91, 15.68, 25.4, 15.25] }, hwLayers: { src: "hw-layers.webp", w: 456, h: 188, ring: [71.96, 60.77, 22.77, 20.21] }, knobs: { src: "knobs.webp", w: 345, h: 186, ring: [4.06, 76.55, 93.91, 18.28] }, layers: { src: "layers.webp", w: 423, h: 240, ring: [2.84, 0.83, 30.66, 15.0] }, mask: { src: "layers.webp", w: 423, h: 240, ring: [3.31, 87.11, 57.06, 9.97] }, name: { src: "name.webp", w: 400, h: 64, ring: [14.0, 6.25, 72.0, 56.25] }, - paste: { src: "code.webp", w: 485, h: 236, ring: [37.05, 15.68, 11.87, 15.25] }, + paste: { src: "code.webp", w: 539, h: 236, ring: [30.18, 15.68, 10.68, 15.25] }, pasted: { src: "pasted.webp", w: 456, h: 224, ring: [2.85, 62.31, 94.3, 31.96] }, perf: { src: "ports.webp", w: 464, h: 149, ring: [21.79, 76.96, 32.49, 21.93] }, port: { src: "ports.webp", w: 464, h: 149, ring: [24.92, 8.72, 29.55, 21.93] }, @@ -134,15 +132,6 @@ const WORDS: Record = { ko: "Code 탭의 도구 줄과 그 아래 패턴 코드. Paste 버튼이 표시돼 있다.", }, }, - gallery: { - title: "Generate · Key", - en: "Key takes your Gemini key, kept in this browser and sent only to Google. Generate asks for n variations; click a card to load one.", - ko: "Key에 Gemini 키를 넣어요. 이 브라우저에만 저장되고 구글로만 가요. Generate를 누르면 n개를 받고, 카드를 누르면 불러와요.", - alt: { - en: "The Gallery tab before any variations, with its Generate and Key buttons marked.", - ko: "아직 변형이 없는 Gallery 탭. Generate와 Key 버튼이 표시돼 있다.", - }, - }, bw: { title: "Preview", en: "A v-field on the default ramp: each pixel's value, 0 to 1, shown black to white.", @@ -172,8 +161,8 @@ const WORDS: Record = { }, color: { title: "Preview", - en: "The same values through that ramp. Want the AI to pick colours instead? Set the Gallery's v-field to rgb before copying the prompt.", - ko: "같은 값이 그 램프를 거친 모습이에요. 색을 AI에게 맡기려면, 프롬프트를 복사하기 전에 Gallery의 v-field를 rgb로 바꿔요.", + en: "The same values through that ramp. Want the AI to pick colours instead? Set v-field beside Copy prompt to rgb before copying.", + ko: "같은 값이 그 램프를 거친 모습이에요. 색을 AI에게 맡기려면, 복사하기 전에 Copy prompt 옆의 v-field를 rgb로 바꿔요.", alt: { en: "The preview through that ramp: the same rings in purple and blue, with dark bands between them.", ko: "그 램프를 거친 미리보기. 같은 동심원이 보라와 파랑으로 보이고, 그 사이는 어두운 띠다.", @@ -328,7 +317,7 @@ const WORDS: Record = { /** Which screens each step of the chapter shows (copy/lab.ts steps with extra: "labShots"). */ const STEPS: Record = { 1: ["name", "saved", "recent"], - 2: ["copyPrompt", "paste", "gallery"], + 2: ["copyPrompt", "paste"], 3: ["bw", "ramp", "rampRandom", "color"], 4: ["knobs", "push"], 5: ["convert"], diff --git a/web/src/components/guide/desk/ai/answers.test.ts b/web/src/components/guide/desk/ai/answers.test.ts index 17bb66926..4cd61d6f0 100644 --- a/web/src/components/guide/desk/ai/answers.test.ts +++ b/web/src/components/guide/desk/ai/answers.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { buildVariantCopyPrompt } from "@/lib/ai/gemini"; +import { buildVariantCopyPrompt } from "@/lib/ai/variantPrompt"; import { buildCppPrompt } from "@/lib/lab/cppPrompt"; import { buildHExport } from "@/lib/lab/hExport"; import { codeLayerFromSource, initialLabPreset } from "@/lib/lab/store/shared"; diff --git a/web/src/components/guide/desk/ai/answers.ts b/web/src/components/guide/desk/ai/answers.ts index 8f9d9c58d..89d0fb7c9 100644 --- a/web/src/components/guide/desk/ai/answers.ts +++ b/web/src/components/guide/desk/ai/answers.ts @@ -8,7 +8,7 @@ import { HEADER_0710, HEADER_0713 } from "./headers"; // their builders always write: // // the variation prompt Copy prompt, app/pattern-lab/panels/CodePanel.tsx -// → buildVariantCopyPrompt, lib/ai/gemini.ts +// → buildVariantCopyPrompt, lib/ai/variantPrompt.ts // the conversion prompt Copy the conversion prompt, app/pattern-lab/HardwareModal.tsx // → buildCppPrompt, lib/lab/cppPrompt.ts // diff --git a/web/src/lib/ai/gemini.ts b/web/src/lib/ai/gemini.ts deleted file mode 100644 index e4b261e2f..000000000 --- a/web/src/lib/ai/gemini.ts +++ /dev/null @@ -1,491 +0,0 @@ -// Bring-your-own-key Gemini integration for Pattern Lab. -// -// The API key is stored only in the browser (localStorage) and sent directly to -// Google's endpoint from the client — it never touches our servers. Because this -// is a static, open-source site we deliberately avoid bundling any shared key. - -import { - buildMatrixAnnotationLine, - describeMatrixShape, - withMatrixAnnotation, - type MatrixSize, -} from "@/lib/pattern/matrix"; - -// Swap these to change the model / reasoning depth used for in-app generation. -// -// NOTE: we call the `generateContent` endpoint, not the newer Interactions API. -// `/v1beta/interactions` does not send CORS headers, so browsers block it from -// any origin (localhost and production alike). `generateContent` does send them, -// which is what makes client-side BYOK work without a server proxy. -export const GEMINI_MODEL = "gemini-3.6-flash"; - -// The settings vocabulary (thinking levels, colour modes, the variant shape) -// lives in ./settings so the lab can persist a GenSettings block without -// importing this client. Re-exported here for everyone who already does. -import { - COLOR_MODES, - GEMINI_THINKING_LEVEL, - THINKING_LEVELS, - type ColorMode, - type PatternVariant, - type ThinkingLevelKey, -} from "./settings"; -export { COLOR_MODES, GEMINI_THINKING_LEVEL, THINKING_LEVELS }; -export type { ColorMode, PatternVariant, ThinkingLevelKey }; - -// --------------------------------------------------------------------------- -// License / attribution stamped onto every generated pattern. -// Edit these two templates to change the notice. `{{name}}` and `{{year}}` are -// filled in automatically; everything else is yours to change. -// --------------------------------------------------------------------------- -export const LICENSE_HEADER_TEMPLATE = `// Pattern: {{name}} -// Author: your name here -// SPDX-License-Identifier: CC-BY-SA-4.0 -// Date: {{date}} -// Made with Patternflow Pattern Lab — https://patternflow.work/pattern-lab`; - -export const LICENSE_FOOTER_TEMPLATE = `// --- -// Made with Patternflow Pattern Lab — https://patternflow.work/pattern-lab -// Licensed CC-BY-SA-4.0. Keep this notice if you share or remix.`; - -function stampLicense(code: string, name: string): string { - const today = new Date(); - const date = today.toISOString().slice(0, 10); // YYYY-MM-DD, filled automatically - const header = LICENSE_HEADER_TEMPLATE.replaceAll("{{name}}", name) - .replaceAll("{{date}}", date) - .replaceAll("{{year}}", String(today.getFullYear())); - return `${header}\n\n${code.trim()}\n\n${LICENSE_FOOTER_TEMPLATE}\n`; -} - -// Some models put the whole pattern on one line or emit literal "\n" escapes -// inside the JSON string. If there are no real newlines but escaped ones exist, -// restore them so the code shows as normal multi-line text in the editor. -function normalizeCode(code: string): string { - if (code.includes("\n")) return code; - if (code.includes("\\n")) { - return code - .replace(/\\r\\n/g, "\n") - .replace(/\\n/g, "\n") - .replace(/\\t/g, "\t"); - } - return code; -} - -export const GEMINI_KEY_STORAGE = "patternflow_gemini_key"; - -const KNOB_LABELS = ["Knob 1", "Knob 2", "Knob 3", "Knob 4"]; - -export function loadGeminiKey(): string { - if (typeof window === "undefined") return ""; - try { - return window.localStorage.getItem(GEMINI_KEY_STORAGE) ?? ""; - } catch { - return ""; - } -} - -export function saveGeminiKey(key: string) { - if (typeof window === "undefined") return; - try { - if (key) { - window.localStorage.setItem(GEMINI_KEY_STORAGE, key); - } else { - window.localStorage.removeItem(GEMINI_KEY_STORAGE); - } - } catch { - // Ignore private-mode / storage-disabled sessions. - } -} - -function controlLines(knobs: number[], ranges: Array<[number, number]>) { - return ranges - .map( - (range, index) => - `- ${KNOB_LABELS[index]} range: ${range[0]} to ${range[1]}, current value: ${knobs[index]}`, - ) - .join("\n"); -} - -// ── Frame ─────────────────────────────────────────────────────────────────── -// One honest statement of the pixel grid, derived from the numbers alone. This -// is the ONLY place the model is told about shape — no compensating axis -// flips, no second "orientation" knob to disagree with it. -function frameLine(matrix: MatrixSize) { - const { width, height } = matrix; - const shape = describeMatrixShape(matrix); - const shapeNote = - shape === "landscape" - ? `It is WIDER than it is tall (${(width / height).toFixed(2)}:1). Compose for a wide frame.` - : shape === "portrait" - ? `It is TALLER than it is wide (1:${(height / width).toFixed(2)}). Compose for a tall frame — a design meant for a wide frame will read as cramped and over-zoomed here.` - : "It is SQUARE. Compose for a square frame."; - return `Frame — the pattern is drawn into a ${width} × ${height} pixel grid: ${width} wide (x = 0..${width - 1}) and ${height} tall (y = 0..${height - 1}). ${shapeNote} Lay the composition, bands, and motion out for these proportions.`; -} - -function variantIntro(matrix: MatrixSize) { - return `I am writing custom LED patterns in JavaScript for a Patternflow ${matrix.width}x${matrix.height} LED matrix web preview. - -${frameLine(matrix)} - -I will give you one existing Patternflow pattern. Use it as a seed, not as a cage. Create exactly 5 distinct standalone variations that explore different visual directions.`; -} - -// Technical + quality spec shared by every path (API rules, controls, color). -// Split into pieces so the pixel-write rule and the color section can swap -// depending on ColorMode. -const TECH_API_COMMON = `Required API for every pattern: -- export function setup(params) {} -- export function update(dt, input, params) {} -- export function draw(display, params, time) {} -- Use input.knobValues as the primary control API. input.knobValues is an array of 4 absolute knob values after the min/max ranges are applied. -- Declare the ranges your 4 knobs want with ONE comment line near the top of the file, exactly this format: - // @knobs Folds=3..12, Speed=0.1..10, Zoom=2..17, Contrast=0.1..1 - (exactly 4 comma-separated entries, Name=min..max, short names). Pattern Lab parses this line, renames the on-screen knobs, and sets their min/max automatically — the user can then retune any range and your pattern follows, because you read the values back through input.knobValues. -- Read input.knobValues[i] directly as the parameter value (matching your @knobs ranges). Do NOT read input.knobNormalized and remap it with baked constants such as 3 + kn[0] * 9 — that disconnects Pattern Lab's range editor (the user's min/max edits stop doing anything). input.knobNormalized is acceptable only for a truly unitless 0..1 blend where any range would mean the same thing. -- Keep input.knobDeltas only as compatibility fallback if needed. -- Optional: each knob also has a push button. input.btnPressed[i] is true only on the frame it is pressed (edge); input.btnHeld[i] is true while it is held down. Use these for momentary actions like reset, freeze, cycle, or trigger. Do not use long-press or mode-switching; that is a reserved system gesture. -- IMPORTANT: input is passed ONLY to update(dt, input, params). draw's signature is draw(display, params, time) with NO input argument — params.input does not exist. To read knob or button values inside draw, use params.knobValues / params.knobNormalized / params.knobDeltas / params.btnPressed / params.btnHeld (the harness mirrors the latest input onto params every frame), or stash whatever you need on params during update. Never read input.* or params.input.* inside draw. -- Use display.width and display.height in loops. Never hardcode the frame's pixel dimensions inside draw() — the same pattern is run at other resolutions, and a hardcoded size crops or zooms it. -- Declare the frame you composed for with ONE comment line near the top of the file, exactly this format: - {{MATRIX_LINE}} - Use exactly the dimensions given above. Pattern Lab reads this line to set the preview resolution, and the firmware uses it to map the pattern onto the physical panel.`; - -const TECH_PIXEL_RGB = `- Write each pixel with display.setPixel(x, y, r, g, b) — EXACTLY five arguments, where r, g, b are three SEPARATE integer arguments in the range 0–255. Never pass a color array such as display.setPixel(x, y, [r, g, b]); never pass a packed color or a 4th alpha argument. The array form renders pure black. -- Use only plain JavaScript and Math.*. No browser APIs, DOM APIs, imports, async code, external libraries, dynamic evaluation, or per-pixel allocations.`; - -const TECH_PIXEL_VFIELD = `- Write each pixel with display.setValue(x, y, v) — EXACTLY three arguments, where v is a single number from 0.0 to 1.0. Do NOT call display.setPixel anywhere. -- Use only plain JavaScript and Math.*. No browser APIs, DOM APIs, imports, async code, external libraries, dynamic evaluation, or per-pixel allocations.`; - -const TECH_CONTROLS = `Creative control mapping: -- It is okay to keep one knob as animation speed, preferably Knob 2, if that suits the pattern. -- Do not keep all four knobs as the same old hue/speed/mode/frequency template unless it is genuinely the best fit. -- Redesign the controls creatively for each pattern. Examples: cell size, symmetry fold, glitch amount, palette split, trail length, scanline spacing, pulse width, inversion threshold, rotation, warp depth, density, edge thickness, phase offset, bloom-like gain, or motif selection. -- Each pattern should have a slightly different control personality. The controls should reveal the unique idea of that pattern. -- Include a short comment near setup() or update() naming what the 4 knobs do for that specific pattern.`; - -const TECH_COLOR_RGB = `Color direction: -- Make color part of the pattern logic, not just a global hue wash. -- Avoid relying on a single full-frame gradient or a uniform hue shift across the whole image. -- Prefer colors that respond to local pattern values: distance fields, cell seeds, stripe index, phase, brightness, threshold bands, motion direction, edge thickness, density, or mask state. -- Good examples: large values become red while small values become blue; interior/exterior use different palettes; threshold bands step through 3-5 colors; cell IDs pick related colors; moving fronts leave warmer highlights; thin edges are white while filled regions are saturated. -- Both smooth local gradients and stepped posterized color bands are welcome, as long as the color changes are tied to the geometry or signal of the pattern. -- Keep at least some pixels near full LED brightness.`; - -const TECH_COLOR_VFIELD = `Value-field direction (IMPORTANT — there is NO color in this pattern): -- The pattern outputs only a scalar value field via display.setValue(x, y, v). Color is applied OUTSIDE the pattern by a user-controlled color ramp that maps v = 0..1 to colors. -- Do NOT compute any colors, palettes, hues, RGB values, or hsvToRgb helpers anywhere. No color logic at all — pour all effort into geometry, structure, and motion. -- Treat v as the pattern's tonal value and design the field so it reads well under ANY ramp: - - Use the full 0..1 range every frame: some pixels near 0, some near 1. - - Build deliberate tonal structure tied to the geometry: edges, bands, plateaus, gradients driven by distance, phase, cell id, density, or masks. - - Value contrast is your only material — sharp boundaries, layered levels, and clear figure/ground separation matter more than in RGB mode. - - Avoid uniform mid-grey mush (everything hovering near 0.5) and avoid pure binary output unless the idea is intentionally hard-edged. -- Knobs must control geometry/motion/structure, never color (no hue knobs — color belongs to the ramp).`; - -function buildTechGuide(colorMode: ColorMode, matrix: MatrixSize) { - const pixelRule = colorMode === "vfield" ? TECH_PIXEL_VFIELD : TECH_PIXEL_RGB; - const colorSection = colorMode === "vfield" ? TECH_COLOR_VFIELD : TECH_COLOR_RGB; - const api = TECH_API_COMMON.replace("{{MATRIX_LINE}}", buildMatrixAnnotationLine(matrix)); - return `${api}\n${pixelRule}\n\n${TECH_CONTROLS}\n\n${colorSection}`; -} - -// Seed-based direction for the clipboard prompt (remix one existing pattern). -const VARIANT_SEED_DIRECTION = `Variation direction: -- Keep the general intent and the four control roles understandable, but do not copy the original structure too literally. -- At least 3 of the 5 variations must change the main drawing algorithm, not only constants, colors, thresholds, or speed. -- Avoid making all 5 outputs feel like the same pattern with different parameter values. -- Do not reuse the same grid, shape, distance formula, or composition in every variation. -- Give each variation a different dominant idea. Use these five directions: - 1. Structural remix: change the main geometry or repetition system. - 2. Motion remix: change how time moves through the pattern. - 3. Palette/material remix: change color logic, brightness rhythm, or foreground/background relationship. - 4. Domain remix: warp, mirror, fold, scroll, rotate, or otherwise remap coordinates. - 5. Contrast remix: make a clearly different sparse/dense, hard/soft, or organic/mechanical interpretation. -- The variations can be bold. They should still feel related to the seed, but not trapped inside its exact look. -- Keep the patterns bright enough for an LED matrix and reasonably ESP32-friendly. -- Avoid smoothing/lerping knob-controlled values unless the visual idea specifically needs inertia.`; - -// Divergence direction for the in-app call: draw on many references, spread wide. -const VARIANT_DIVERGE_DIRECTION = `Direction — maximize variety: -- These are NEW patterns, not edits of one source. Do not converge on a single look across the set. -- Borrow ideas freely, but never closely reproduce a single familiar template or any reference shown. -- Give each output a different dominant idea. Spread the set across these axes: structure/geometry, how time moves, color logic, coordinate domain (warp/mirror/fold/scroll/rotate), and density/contrast. -- Most outputs must use a different main drawing algorithm — not the same pattern with different constants, colors, thresholds, or speed. -- Do not reuse the same grid, shape, distance formula, or composition across outputs. -- Be bold: it is good if the set spans very different moods — sparse vs dense, hard vs soft, organic vs mechanical, geometric vs noisy. -- Keep the patterns bright enough for an LED matrix and reasonably ESP32-friendly. -- Avoid smoothing/lerping knob-controlled values unless the visual idea specifically needs inertia.`; - -// Prompt for the clipboard fallback: the model returns 5 markdown code blocks. -// Follows the same ColorMode as the in-app call so v-field users get v-field -// output from external chatbots too. -export function buildVariantCopyPrompt( - code: string, - knobs: number[], - ranges: Array<[number, number]>, - colorMode: ColorMode = "rgb", - matrix: MatrixSize = { width: 128, height: 64 }, -) { - return `${variantIntro(matrix)} - -Very important output rules: -- Return exactly 5 separate JavaScript code blocks. -- Each code block must be a complete standalone Patternflow pattern. -- Do not combine the 5 variations into one file. -- Do not add a mode selector, preset array, switch statement, or any code that contains multiple patterns in one output. -- Do not write wrapper text inside the code blocks. -- Put a short variation name before each code block. -- Do not include nested triple backticks inside any code block. - -${buildTechGuide(colorMode, matrix)} - -${VARIANT_SEED_DIRECTION} - -Current Pattern Lab controls: -${controlLines(knobs, ranges)} - -Existing pattern${ - colorMode === "vfield" - ? " (NOTE: if this seed calls display.setPixel and computes colors, it is an older RGB-mode pattern — take only its geometry/motion ideas; every variation you output must use display.setValue(x, y, v) with NO color logic)" - : "" - }: -\`\`\`javascript -${code} -\`\`\``; -} - -// Instruction for the in-app structured-output call: the model returns JSON -// matching the schema below, so there are no markdown fences to parse. `count` -// sets how many patterns; `examples` is a random sample of existing Patternflow -// patterns used to widen the model's range so output is not all one style. -function buildVariantApiInstruction( - code: string, - knobs: number[], - ranges: Array<[number, number]>, - count: number, - examples: Array<{ name: string; code: string }>, - matrix: MatrixSize, - seedWithCurrent: boolean, - colorMode: ColorMode, -) { - const phrase = count === 1 ? "1 standalone pattern" : `${count} distinct standalone patterns`; - - const noReferences = examples.length === 0 && !seedWithCurrent; - const creativityLine = noReferences - ? "No reference patterns are provided, on purpose. Invent the most original, surprising, and varied patterns you can purely from the rules below — do not fall back on common defaults.\n\n" - : ""; - - // Existing presets are RGB-mode; when generating value fields, make sure the - // model doesn't imitate their setPixel/color code. - const vfieldReferenceCaveat = - colorMode === "vfield" - ? " NOTE: the references are older RGB-mode patterns that call display.setPixel and compute colors — your output must instead call display.setValue(x, y, v) with NO color logic. Use the references only for geometry, structure, and motion ideas." - : ""; - - // Every bundled preset was composed for the stock 128×64 panel. Asking for a - // different frame while showing landscape references is exactly how patterns - // came out cropped and over-zoomed, so say so outright. - const frameReferenceCaveat = - matrix.width === 128 && matrix.height === 64 - ? "" - : ` NOTE: the references were composed for a 128×64 frame; you are composing for ${matrix.width}×${matrix.height}. Take their ideas, not their proportions — re-lay every composition out for the ${describeMatrixShape(matrix)} frame described above.`; - - const referenceBlock = examples.length - ? `Reference patterns — these show the RANGE of styles that work on Patternflow. Use them ONLY as inspiration for what is possible. Do NOT copy, port, or closely reproduce any single one.${vfieldReferenceCaveat}${frameReferenceCaveat} - -${examples.map((example) => `===== ${example.name} =====\n${example.code}`).join("\n\n")} - -` - : ""; - - return `I am writing custom LED patterns in JavaScript for a Patternflow ${matrix.width}x${matrix.height} LED matrix web preview. - -${frameLine(matrix)} - -Create exactly ${phrase} that are genuinely different from one another. Aim for wide variety, not small tweaks of a single look. - -${creativityLine}Return the patterns as structured JSON matching the provided schema: an array of exactly ${count} object${count === 1 ? "" : "s"}. Each object has: -- "name": a short pattern name. -- "knobNotes": one line naming what the 4 knobs do for that pattern. -- "code": a complete standalone Patternflow pattern as plain JavaScript text. Do NOT wrap it in markdown fences. Do NOT combine patterns, add a mode selector, preset array, or switch statement that holds multiple patterns. -- Format the code as normal, readable multi-line JavaScript using real line breaks and indentation. Do NOT put the whole pattern on a single line and do NOT minify it. -- Do NOT add any license header, copyright line, author/date comment, or attribution footer — those are added automatically. Start directly with the pattern code. - -${buildTechGuide(colorMode, matrix)} - -${VARIANT_DIVERGE_DIRECTION} - -${referenceBlock}${ - seedWithCurrent - ? `The pattern currently open in the editor (you may relate to it loosely, but prioritize variety over staying close to it): -\`\`\`javascript -${code} -\`\`\` - -` - : "" - }Current Pattern Lab controls: -${controlLines(knobs, ranges)}`; -} - -function errorStatus(error: unknown): number | undefined { - const status = (error as { status?: unknown })?.status; - return typeof status === "number" ? status : undefined; -} - -// Transient server-side failures worth retrying: overload (503), quota bursts -// (429), and the occasional 500. -function isRetryableError(error: unknown): boolean { - const status = errorStatus(error); - if (status === 503 || status === 429 || status === 500) return true; - const message = error instanceof Error ? error.message : String(error); - return /(503|500|429|UNAVAILABLE|overloaded|RESOURCE_EXHAUSTED)/i.test(message); -} - -// Turn the SDK's verbose error blobs into one human-readable line for the UI. -function describeGeminiError(error: unknown): string { - const status = errorStatus(error); - const message = error instanceof Error ? error.message : String(error); - if (status === 503 || /503|UNAVAILABLE|overloaded/i.test(message)) { - return "Gemini is temporarily overloaded (503). Please try again in a moment."; - } - if (status === 429 || /429|RESOURCE_EXHAUSTED/i.test(message)) { - return "Rate limit or quota reached (429). Wait a bit, then retry."; - } - if (status === 403 || /403|PERMISSION_DENIED/i.test(message)) { - return "Your API key can't access this model (403). Check the key, or switch GEMINI_MODEL."; - } - if (status === 400 || /400|API key not valid|INVALID_ARGUMENT/i.test(message)) { - return "Request rejected (400). The API key may be invalid or the request malformed."; - } - return message || "Generation failed."; -} - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -async function withRetry(fn: () => Promise, attempts = 4): Promise { - let lastError: unknown; - for (let attempt = 0; attempt < attempts; attempt++) { - try { - return await fn(); - } catch (error) { - lastError = error; - if (!isRetryableError(error) || attempt === attempts - 1) break; - await sleep(800 * 2 ** attempt); // 0.8s, 1.6s, 3.2s - } - } - throw lastError; -} - -export async function generatePatternVariants({ - apiKey, - code, - knobs, - ranges, - count = 5, - thinkingLevel = GEMINI_THINKING_LEVEL, - examples = [], - matrix = { width: 128, height: 64 }, - seedWithCurrent = true, - colorMode = "rgb", -}: { - apiKey: string; - code: string; - knobs: number[]; - ranges: Array<[number, number]>; - count?: number; - thinkingLevel?: ThinkingLevelKey; - examples?: Array<{ name: string; code: string }>; - matrix?: MatrixSize; - seedWithCurrent?: boolean; - colorMode?: ColorMode; -}): Promise { - // Dynamic import keeps the SDK out of the initial page bundle — it only loads - // when the user actually clicks Generate. - const { GoogleGenAI, Type, ThinkingLevel } = await import("@google/genai"); - const ai = new GoogleGenAI({ apiKey }); - - // Structured output: the schema forces exactly 5 objects so there are no - // markdown code fences to parse out. Retry transient overloads (503/429). - let response; - try { - response = await withRetry(() => - ai.models.generateContent({ - model: GEMINI_MODEL, - contents: buildVariantApiInstruction( - code, - knobs, - ranges, - count, - examples, - matrix, - seedWithCurrent, - colorMode, - ), - config: { - temperature: 1, - topP: 0.95, - maxOutputTokens: 65536, - thinkingConfig: { thinkingLevel: ThinkingLevel[thinkingLevel] }, - responseMimeType: "application/json", - responseSchema: { - type: Type.ARRAY, - minItems: String(count), - maxItems: String(count), - items: { - type: Type.OBJECT, - properties: { - name: { type: Type.STRING }, - knobNotes: { type: Type.STRING }, - code: { type: Type.STRING }, - }, - required: ["name", "code"], - propertyOrdering: ["name", "knobNotes", "code"], - }, - }, - }, - }), - ); - } catch (error) { - throw new Error(describeGeminiError(error)); - } - - const text = response.text; - if (!text) throw new Error("Gemini returned an empty response."); - - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - throw new Error("Gemini returned malformed JSON."); - } - if (!Array.isArray(parsed)) { - throw new Error("Unexpected response shape from Gemini."); - } - - const variants: PatternVariant[] = parsed - .filter( - (item): item is { name?: unknown; knobNotes?: unknown; code?: unknown } => - Boolean(item) && typeof item === "object", - ) - .filter((item) => typeof item.code === "string" && item.code.trim().length > 0) - .map((item) => { - const name = - typeof item.name === "string" && item.name.trim() ? item.name.trim() : "Variation"; - // Models drop the @matrix line often enough that it can't be trusted to - // the prompt alone, and a pattern with no declared frame silently falls - // back to 128×64 later on. The requested size is the authority here — it - // is what the model was told to compose for. - const code = withMatrixAnnotation(normalizeCode(item.code as string), matrix); - return { - name, - knobNotes: typeof item.knobNotes === "string" ? item.knobNotes : undefined, - code: stampLicense(code, name), - }; - }); - - if (variants.length === 0) { - throw new Error("No usable variations were returned."); - } - - return variants; -} diff --git a/web/src/lib/ai/settings.ts b/web/src/lib/ai/settings.ts index 1ceb10031..c62a621a7 100644 --- a/web/src/lib/ai/settings.ts +++ b/web/src/lib/ai/settings.ts @@ -1,17 +1,9 @@ -// ── Generation settings the rest of the app may name ───────────────────────── -// The lab's project stores a GenSettings block (how many variants, how much -// thinking, which colour model) and persists it; the community's atlas quotes -// the prompt vocabulary. None of that needs the Gemini client — a 500-line -// module that talks to Google — so the vocabulary lives here, dependency-free, -// and gemini.ts re-exports it for callers that already import from there. +// ── The colour model a copied prompt asks for ─────────────────────────────── +// The lab's project stores it (serialize.ts) and Copy prompt reads it +// (variantPrompt.ts). It lives apart from the prompt text so the serializer +// can name it without pulling the prompt in. -// More thinking = more varied/correct output but slower. -export type ThinkingLevelKey = "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; -export const THINKING_LEVELS: ThinkingLevelKey[] = ["MINIMAL", "LOW", "MEDIUM", "HIGH"]; -// Default reasoning depth. "LOW" keeps batches responsive. -export const GEMINI_THINKING_LEVEL: ThinkingLevelKey = "LOW"; - -// How generated patterns handle colour: +// How the patterns an AI writes handle colour: // - "rgb": the model colours every pixel itself via setPixel (classic mode). // - "vfield": the model outputs a pure 0..1 value field via setValue; colour is // applied by the user-controlled Color Ramp in Pattern Lab. Keeps the model @@ -28,10 +20,3 @@ export const GEMINI_THINKING_LEVEL: ThinkingLevelKey = "LOW"; // at the display boundary. See lib/pattern/matrix.ts. export type ColorMode = "rgb" | "vfield"; export const COLOR_MODES: ColorMode[] = ["vfield", "rgb"]; - -/** One generated pattern, as the gallery keeps it. */ -export type PatternVariant = { - name: string; - knobNotes?: string; - code: string; -}; diff --git a/web/src/lib/ai/variantPrompt.ts b/web/src/lib/ai/variantPrompt.ts new file mode 100644 index 000000000..f0edaa6f9 --- /dev/null +++ b/web/src/lib/ai/variantPrompt.ts @@ -0,0 +1,156 @@ +// The prompt Pattern Lab's Copy prompt puts on the clipboard (CodePanel.tsx): +// the focused code layer as a seed, the frame, the knobs as they stand and +// the API rules, for whatever AI chat the user pastes it into. Two places copy +// its wording. The guide's practice AI tells it apart by lines it always +// writes (components/guide/desk/ai/answers.ts, checked by answers.test.ts), +// and the community atlas mirrors it (lib/atlas/data.ts). Change them with it. + +import { buildMatrixAnnotationLine, describeMatrixShape, type MatrixSize } from "@/lib/pattern/matrix"; +import type { ColorMode } from "./settings"; + +const KNOB_LABELS = ["Knob 1", "Knob 2", "Knob 3", "Knob 4"]; + +function controlLines(knobs: number[], ranges: Array<[number, number]>) { + return ranges + .map( + (range, index) => + `- ${KNOB_LABELS[index]} range: ${range[0]} to ${range[1]}, current value: ${knobs[index]}`, + ) + .join("\n"); +} + +// ── Frame ─────────────────────────────────────────────────────────────────── +// One honest statement of the pixel grid, derived from the numbers alone. This +// is the ONLY place the model is told about shape — no compensating axis +// flips, no second "orientation" knob to disagree with it. +function frameLine(matrix: MatrixSize) { + const { width, height } = matrix; + const shape = describeMatrixShape(matrix); + const shapeNote = + shape === "landscape" + ? `It is WIDER than it is tall (${(width / height).toFixed(2)}:1). Compose for a wide frame.` + : shape === "portrait" + ? `It is TALLER than it is wide (1:${(height / width).toFixed(2)}). Compose for a tall frame — a design meant for a wide frame will read as cramped and over-zoomed here.` + : "It is SQUARE. Compose for a square frame."; + return `Frame — the pattern is drawn into a ${width} × ${height} pixel grid: ${width} wide (x = 0..${width - 1}) and ${height} tall (y = 0..${height - 1}). ${shapeNote} Lay the composition, bands, and motion out for these proportions.`; +} + +function variantIntro(matrix: MatrixSize) { + return `I am writing custom LED patterns in JavaScript for a Patternflow ${matrix.width}x${matrix.height} LED matrix web preview. + +${frameLine(matrix)} + +I will give you one existing Patternflow pattern. Use it as a seed, not as a cage. Create exactly 5 distinct standalone variations that explore different visual directions.`; +} + +// Technical + quality spec (API rules, controls, color). +// Split into pieces so the pixel-write rule and the color section can swap +// depending on ColorMode. +const TECH_API_COMMON = `Required API for every pattern: +- export function setup(params) {} +- export function update(dt, input, params) {} +- export function draw(display, params, time) {} +- Use input.knobValues as the primary control API. input.knobValues is an array of 4 absolute knob values after the min/max ranges are applied. +- Declare the ranges your 4 knobs want with ONE comment line near the top of the file, exactly this format: + // @knobs Folds=3..12, Speed=0.1..10, Zoom=2..17, Contrast=0.1..1 + (exactly 4 comma-separated entries, Name=min..max, short names). Pattern Lab parses this line, renames the on-screen knobs, and sets their min/max automatically — the user can then retune any range and your pattern follows, because you read the values back through input.knobValues. +- Read input.knobValues[i] directly as the parameter value (matching your @knobs ranges). Do NOT read input.knobNormalized and remap it with baked constants such as 3 + kn[0] * 9 — that disconnects Pattern Lab's range editor (the user's min/max edits stop doing anything). input.knobNormalized is acceptable only for a truly unitless 0..1 blend where any range would mean the same thing. +- Keep input.knobDeltas only as compatibility fallback if needed. +- Optional: each knob also has a push button. input.btnPressed[i] is true only on the frame it is pressed (edge); input.btnHeld[i] is true while it is held down. Use these for momentary actions like reset, freeze, cycle, or trigger. Do not use long-press or mode-switching; that is a reserved system gesture. +- IMPORTANT: input is passed ONLY to update(dt, input, params). draw's signature is draw(display, params, time) with NO input argument — params.input does not exist. To read knob or button values inside draw, use params.knobValues / params.knobNormalized / params.knobDeltas / params.btnPressed / params.btnHeld (the harness mirrors the latest input onto params every frame), or stash whatever you need on params during update. Never read input.* or params.input.* inside draw. +- Use display.width and display.height in loops. Never hardcode the frame's pixel dimensions inside draw() — the same pattern is run at other resolutions, and a hardcoded size crops or zooms it. +- Declare the frame you composed for with ONE comment line near the top of the file, exactly this format: + {{MATRIX_LINE}} + Use exactly the dimensions given above. Pattern Lab reads this line to set the preview resolution, and the firmware uses it to map the pattern onto the physical panel.`; + +const TECH_PIXEL_RGB = `- Write each pixel with display.setPixel(x, y, r, g, b) — EXACTLY five arguments, where r, g, b are three SEPARATE integer arguments in the range 0–255. Never pass a color array such as display.setPixel(x, y, [r, g, b]); never pass a packed color or a 4th alpha argument. The array form renders pure black. +- Use only plain JavaScript and Math.*. No browser APIs, DOM APIs, imports, async code, external libraries, dynamic evaluation, or per-pixel allocations.`; + +const TECH_PIXEL_VFIELD = `- Write each pixel with display.setValue(x, y, v) — EXACTLY three arguments, where v is a single number from 0.0 to 1.0. Do NOT call display.setPixel anywhere. +- Use only plain JavaScript and Math.*. No browser APIs, DOM APIs, imports, async code, external libraries, dynamic evaluation, or per-pixel allocations.`; + +const TECH_CONTROLS = `Creative control mapping: +- It is okay to keep one knob as animation speed, preferably Knob 2, if that suits the pattern. +- Do not keep all four knobs as the same old hue/speed/mode/frequency template unless it is genuinely the best fit. +- Redesign the controls creatively for each pattern. Examples: cell size, symmetry fold, glitch amount, palette split, trail length, scanline spacing, pulse width, inversion threshold, rotation, warp depth, density, edge thickness, phase offset, bloom-like gain, or motif selection. +- Each pattern should have a slightly different control personality. The controls should reveal the unique idea of that pattern. +- Include a short comment near setup() or update() naming what the 4 knobs do for that specific pattern.`; + +const TECH_COLOR_RGB = `Color direction: +- Make color part of the pattern logic, not just a global hue wash. +- Avoid relying on a single full-frame gradient or a uniform hue shift across the whole image. +- Prefer colors that respond to local pattern values: distance fields, cell seeds, stripe index, phase, brightness, threshold bands, motion direction, edge thickness, density, or mask state. +- Good examples: large values become red while small values become blue; interior/exterior use different palettes; threshold bands step through 3-5 colors; cell IDs pick related colors; moving fronts leave warmer highlights; thin edges are white while filled regions are saturated. +- Both smooth local gradients and stepped posterized color bands are welcome, as long as the color changes are tied to the geometry or signal of the pattern. +- Keep at least some pixels near full LED brightness.`; + +const TECH_COLOR_VFIELD = `Value-field direction (IMPORTANT — there is NO color in this pattern): +- The pattern outputs only a scalar value field via display.setValue(x, y, v). Color is applied OUTSIDE the pattern by a user-controlled color ramp that maps v = 0..1 to colors. +- Do NOT compute any colors, palettes, hues, RGB values, or hsvToRgb helpers anywhere. No color logic at all — pour all effort into geometry, structure, and motion. +- Treat v as the pattern's tonal value and design the field so it reads well under ANY ramp: + - Use the full 0..1 range every frame: some pixels near 0, some near 1. + - Build deliberate tonal structure tied to the geometry: edges, bands, plateaus, gradients driven by distance, phase, cell id, density, or masks. + - Value contrast is your only material — sharp boundaries, layered levels, and clear figure/ground separation matter more than in RGB mode. + - Avoid uniform mid-grey mush (everything hovering near 0.5) and avoid pure binary output unless the idea is intentionally hard-edged. +- Knobs must control geometry/motion/structure, never color (no hue knobs — color belongs to the ramp).`; + +function buildTechGuide(colorMode: ColorMode, matrix: MatrixSize) { + const pixelRule = colorMode === "vfield" ? TECH_PIXEL_VFIELD : TECH_PIXEL_RGB; + const colorSection = colorMode === "vfield" ? TECH_COLOR_VFIELD : TECH_COLOR_RGB; + const api = TECH_API_COMMON.replace("{{MATRIX_LINE}}", buildMatrixAnnotationLine(matrix)); + return `${api}\n${pixelRule}\n\n${TECH_CONTROLS}\n\n${colorSection}`; +} + +// Seed-based direction: remix one existing pattern. +const VARIANT_SEED_DIRECTION = `Variation direction: +- Keep the general intent and the four control roles understandable, but do not copy the original structure too literally. +- At least 3 of the 5 variations must change the main drawing algorithm, not only constants, colors, thresholds, or speed. +- Avoid making all 5 outputs feel like the same pattern with different parameter values. +- Do not reuse the same grid, shape, distance formula, or composition in every variation. +- Give each variation a different dominant idea. Use these five directions: + 1. Structural remix: change the main geometry or repetition system. + 2. Motion remix: change how time moves through the pattern. + 3. Palette/material remix: change color logic, brightness rhythm, or foreground/background relationship. + 4. Domain remix: warp, mirror, fold, scroll, rotate, or otherwise remap coordinates. + 5. Contrast remix: make a clearly different sparse/dense, hard/soft, or organic/mechanical interpretation. +- The variations can be bold. They should still feel related to the seed, but not trapped inside its exact look. +- Keep the patterns bright enough for an LED matrix and reasonably ESP32-friendly. +- Avoid smoothing/lerping knob-controlled values unless the visual idea specifically needs inertia.`; + + +// The model returns 5 markdown code blocks, in the ColorMode picked beside the +// button. +export function buildVariantCopyPrompt( + code: string, + knobs: number[], + ranges: Array<[number, number]>, + colorMode: ColorMode = "rgb", + matrix: MatrixSize = { width: 128, height: 64 }, +) { + return `${variantIntro(matrix)} + +Very important output rules: +- Return exactly 5 separate JavaScript code blocks. +- Each code block must be a complete standalone Patternflow pattern. +- Do not combine the 5 variations into one file. +- Do not add a mode selector, preset array, switch statement, or any code that contains multiple patterns in one output. +- Do not write wrapper text inside the code blocks. +- Put a short variation name before each code block. +- Do not include nested triple backticks inside any code block. + +${buildTechGuide(colorMode, matrix)} + +${VARIANT_SEED_DIRECTION} + +Current Pattern Lab controls: +${controlLines(knobs, ranges)} + +Existing pattern${ + colorMode === "vfield" + ? " (NOTE: if this seed calls display.setPixel and computes colors, it is an older RGB-mode pattern — take only its geometry/motion ideas; every variation you output must use display.setValue(x, y, v) with NO color logic)" + : "" + }: +\`\`\`javascript +${code} +\`\`\``; +} diff --git a/web/src/lib/atlas/data.ts b/web/src/lib/atlas/data.ts index 4483331e5..b3e1cf89a 100644 --- a/web/src/lib/atlas/data.ts +++ b/web/src/lib/atlas/data.ts @@ -13,9 +13,10 @@ // prompt is English-only — it is written for AIs, not for readers. // // The generation prompt (BASE) mirrors Pattern Lab's COPY PROMPT assembly -// (lib/ai/gemini.ts: buildVariantCopyPrompt → buildTechGuide) verbatim, with the -// atlas taste/craft directions layered where the lab puts VARIANT_*_DIRECTION. -// If gemini.ts's wording changes, change it here too. +// (lib/ai/variantPrompt.ts: buildVariantCopyPrompt → buildTechGuide) +// verbatim, with the atlas taste/craft directions layered where the lab puts +// VARIANT_SEED_DIRECTION. If variantPrompt.ts's wording changes, change it +// here too. export type AtlasFamilyId = | "ink" | "waterfall" | "optics" | "advect" diff --git a/web/src/lib/community/provenance.test.ts b/web/src/lib/community/provenance.test.ts index 8694b1140..b71db8ccc 100644 --- a/web/src/lib/community/provenance.test.ts +++ b/web/src/lib/community/provenance.test.ts @@ -1,7 +1,7 @@ // Provenance badges against the lines Pattern Lab writes by itself. Every Lab // publish carries `@matrix` and every value-field layer carries `@ramp`, // touched or not. Until 21 October 2026 the badges counted the line alone, so -// a Gemini variant published the way it came out of the gallery showed +// a variant published the way it came out of the Lab's AI gallery showed // "Framed" and "Colour shaped" on its page. import { describe, expect, it } from "vitest"; diff --git a/web/src/lib/community/provenance.ts b/web/src/lib/community/provenance.ts index 7e15bd96a..77b4cd38e 100644 --- a/web/src/lib/community/provenance.ts +++ b/web/src/lib/community/provenance.ts @@ -23,8 +23,8 @@ import { MADE_HOW_BLURBS, MADE_HOW_LABELS, type MadeHow } from "./validate"; // A line is only evidence when it says something other than what Pattern Lab // writes on its own. The Lab puts `@matrix` on everything it publishes, `@ramp` // on every value-field layer, and `@knobs` on every flattened stack with code -// in it. Until 21 October 2026 the mere line counted, so an untouched Gemini -// variant came out "Framed" and "Colour shaped". Each line is now read with its +// in it. Until 21 October 2026 the mere line counted, so an untouched variant +// from the Lab's AI gallery came out "Framed" and "Colour shaped". Each line is now read with its // own parser and set against the Lab's own defaults: a 128×64 frame, the // black-to-white linear ramp a new code layer gets with recolor off, and // Knob 1–4 on their stock ranges. diff --git a/web/src/lib/lab/editorReveal.ts b/web/src/lib/lab/editorReveal.ts index 8a743110c..79fde9b19 100644 --- a/web/src/lib/lab/editorReveal.ts +++ b/web/src/lib/lab/editorReveal.ts @@ -6,7 +6,7 @@ // (which owns the dockview api) fronts the matching editor tab. // // Deliberately NOT store state: deriving it from activeLayerId would front -// tabs on every programmatic selection (gallery loads, imports, deletes), and +// tabs on every programmatic selection (preset steps, imports, deletes), and // re-clicking the already-active layer — the exact "let me in" gesture — would // change nothing and emit nothing. diff --git a/web/src/lib/lab/legacyDraft.ts b/web/src/lib/lab/legacyDraft.ts index 446c29cc2..3a5f48657 100644 --- a/web/src/lib/lab/legacyDraft.ts +++ b/web/src/lib/lab/legacyDraft.ts @@ -1,37 +1,30 @@ -// ── Pattern Lab: the July draft, and the gallery ───────────────────────────── +// ── Pattern Lab: the July draft ────────────────────────────────────────────── // The lab's autosave (e19fe19, 25 July 2026) stored the working session under -// two keys on purpose: -// -// DRAFT_STORAGE small, always worth keeping (code, knobs, ranges, ...) -// GALLERY_STORAGE potentially hundreds of KB of generated patterns -// -// A quota failure while writing the (large) gallery must never take the (small) -// draft down with it, hence the split. +// two keys on purpose: the draft (small, always worth keeping: code, knobs, +// ranges, ...) and the AI gallery (up to hundreds of KB of generated +// patterns). A quota failure while writing the large gallery must never take +// the small draft down with it, hence the split. // // Pattern Lab v2 (9be6e98, 27 July) moved the working session into the layered // project and stopped writing the draft. loadDraft stays for serialize.ts's -// migrateLegacyDraft. The gallery never moved, and loadGallery and saveGallery -// are its live reader and writer. Every read is defensive: a draft written by -// an older build, or hand-edited, must degrade to "no draft" rather than crash -// the lab on boot. +// migrateLegacyDraft. The gallery kept its key until the lab dropped it on +// 23 October 2026, and dropAiGallery clears what it left behind. Every read is +// defensive: a draft written by an older build, or hand-edited, must degrade +// to "no draft" rather than crash the lab on boot. -import { COLOR_MODES, THINKING_LEVELS } from "@/lib/ai/settings"; -import type { ColorMode, PatternVariant, ThinkingLevelKey } from "@/lib/ai/settings"; +import { COLOR_MODES } from "@/lib/ai/settings"; +import type { ColorMode } from "@/lib/ai/settings"; import { isValidMatrix, type MatrixSize } from "@/lib/pattern/matrix"; -import { LAB_STORAGE, readJson, removeStorage as remove, writeJson } from "./persist"; +import { LAB_STORAGE, readJson, removeStorage as remove } from "./persist"; export const DRAFT_STORAGE = LAB_STORAGE.legacyDraft; -export const GALLERY_STORAGE = LAB_STORAGE.gallery; -// Guard rails so one runaway value can't blow the whole quota. +// Guard rail so one runaway value can't blow the whole quota. const MAX_CODE_CHARS = 200_000; -const MAX_GALLERY_ITEMS = 48; +/** The July draft also kept the gallery's count, thinking and refs; only this outlived it. */ export type DraftGenSettings = { - count: number; - thinking: ThinkingLevelKey; - refs: number; colorMode: ColorMode; }; @@ -48,13 +41,10 @@ export type LabDraft = { * covers drafts whose code predates the annotation. */ matrix: MatrixSize; - editorView: "code" | "gallery"; forkOf: { id: string; title: string } | null; gen: DraftGenSettings; }; -export type DraftGalleryItem = PatternVariant & { id: string; pinned?: boolean }; - function numberArray(value: unknown, length: number): number[] | null { if (!Array.isArray(value) || value.length !== length) return null; const result = value.map(Number); @@ -118,8 +108,6 @@ export function loadDraft(knobCount: number): Partial | null { : null; const gen = (raw.gen ?? {}) as Record; - const genCount = Number(gen.count); - const genRefs = Number(gen.refs); // Each field falls back independently: a draft missing (or corrupting) one // value still restores everything else. @@ -131,12 +119,8 @@ export function loadDraft(knobCount: number): Partial | null { knobLabels: stringArray(raw.knobLabels, knobCount) ?? undefined, recolor: typeof raw.recolor === "boolean" ? raw.recolor : undefined, matrix: readMatrix(raw), - editorView: oneOf(raw.editorView, ["code", "gallery"] as const, "code"), forkOf, gen: { - count: Number.isFinite(genCount) ? genCount : 5, - thinking: oneOf(gen.thinking, THINKING_LEVELS, "LOW"), - refs: Number.isFinite(genRefs) ? genRefs : 6, colorMode: oneOf(gen.colorMode, COLOR_MODES, "vfield"), }, }; @@ -144,41 +128,15 @@ export function loadDraft(knobCount: number): Partial | null { export function clearDraft() { remove(DRAFT_STORAGE); - remove(GALLERY_STORAGE); -} - -export function loadGallery(): DraftGalleryItem[] { - const parsed = readJson(GALLERY_STORAGE); - if (!Array.isArray(parsed)) return []; - return parsed - .filter( - (item): item is DraftGalleryItem => - Boolean(item) && - typeof item === "object" && - typeof (item as DraftGalleryItem).id === "string" && - typeof (item as DraftGalleryItem).code === "string" && - typeof (item as DraftGalleryItem).name === "string", - ) - .slice(0, MAX_GALLERY_ITEMS) - .map((item) => ({ - id: item.id, - name: item.name, - code: item.code, - knobNotes: typeof item.knobNotes === "string" ? item.knobNotes : undefined, - pinned: Boolean(item.pinned), - })); } /** - * Best-effort gallery persist. Generated patterns can add up to hundreds of KB, - * so on a quota failure we retry with the pinned items only (the ones the user - * explicitly kept) before giving up — a partial save beats none. + * The AI gallery's patterns and the Gemini key it kept. Nothing writes either + * since 23 October 2026. hydrate calls this on every open of the lab, so a + * browser that held them loses them the first time, and one that never did + * loses nothing. */ -export function saveGallery(items: DraftGalleryItem[]): boolean { - const trimmed = items.slice(0, MAX_GALLERY_ITEMS); - if (writeJson(GALLERY_STORAGE, trimmed)) return true; - const pinned = trimmed.filter((item) => item.pinned); - if (pinned.length > 0 && writeJson(GALLERY_STORAGE, pinned)) return true; - remove(GALLERY_STORAGE); - return false; +export function dropAiGallery() { + remove(LAB_STORAGE.gallery); + remove(LAB_STORAGE.geminiKey); } diff --git a/web/src/lib/lab/persist.ts b/web/src/lib/lab/persist.ts index 8f2243764..8312ff3b6 100644 --- a/web/src/lib/lab/persist.ts +++ b/web/src/lib/lab/persist.ts @@ -8,11 +8,11 @@ // Bumping a version means: add the new key here, keep the old one, and write a // migration that reads the old and writes the new. serialize.ts's // migrateLegacyDraft is the precedent: it lifts _draft_v1 and _ramp_v1 into a -// _project_v2 project. The gallery was never migrated. It still lives under -// its _v1 key. +// _project_v2 project. A key whose feature is gone stays listed here with the +// date, so the lab can still remove it (legacyDraft.ts dropAiGallery). export const LAB_STORAGE = { - /** The whole layered project — layers, knobs, ramps, director, gen settings. serialize.ts */ + /** The whole layered project — layers, knobs, ramps, director, prompt colour mode. serialize.ts */ project: "patternflow_lab_project_v2", /** The dockview layout. PatternLabClient.tsx */ layout: "patternflow_lab_layout_v1", @@ -38,11 +38,17 @@ export const LAB_STORAGE = { */ legacyDraft: "patternflow_lab_draft_v1", /** - * The generated gallery. The lab's autosave has written it since 25 July - * 2026 (e19fe19) and still does, because the gallery never moved into the - * project. legacyDraft.ts + * The AI gallery's generated patterns, written by the lab's autosave from + * 25 July 2026 (e19fe19) until the gallery was taken out on 23 October. + * Nothing writes it now, and the lab removes it on open. legacyDraft.ts */ gallery: "patternflow_lab_gallery_v1", + /** + * The Gemini API key the AI gallery saved for whoever set one. Nothing + * writes it since 23 October 2026, and the lab removes it on open. + * legacyDraft.ts + */ + geminiKey: "patternflow_gemini_key", /** * The colour ramp, written by the lab on every ramp edit from 2 July 2026 * (67b9798) until Pattern Lab v2 on 27 July (9be6e98). Nothing writes it diff --git a/web/src/lib/lab/retiredGallery.test.ts b/web/src/lib/lab/retiredGallery.test.ts new file mode 100644 index 000000000..ae30202f8 --- /dev/null +++ b/web/src/lib/lab/retiredGallery.test.ts @@ -0,0 +1,152 @@ +// The AI gallery came out of the lab on 23 October 2026. What it left behind +// in a browser that used it: a dock layout with a Gallery tab, a project whose +// `gen` block holds batch settings, its generated patterns and the Gemini key. + +import { beforeEach, describe, expect, it } from "vitest"; +import { PANELS } from "@/app/pattern-lab/panels/registry"; +import { LAB_STORAGE } from "./persist"; +import { deserializeProject, dropRetiredPanels, layoutViewCount } from "./serialize"; +import { useLabStore } from "./store"; + +const known = new Set(PANELS.map((panel) => panel.id)); + +function panel(id: string, title: string) { + return { id, contentComponent: id, title }; +} + +// The default desktop layout as dockview's toJSON writes it, with the Gallery +// tab where the registry used to put it: inside the Code group, fronted. +function savedLayout() { + return { + grid: { + root: { + type: "branch", + data: [ + { + type: "branch", + data: [ + { type: "leaf", data: { views: ["preview"], activeView: "preview", id: "1" }, size: 500 }, + { type: "leaf", data: { views: ["layers"], activeView: "layers", id: "2" }, size: 300 }, + ], + size: 420, + }, + { + type: "leaf", + data: { views: ["code", "pixel", "gallery", "capture", "director"], activeView: "gallery", id: "3" }, + size: 680, + }, + { + type: "branch", + data: [ + { type: "leaf", data: { views: ["knobs"], activeView: "knobs", id: "4" }, size: 460 }, + { type: "leaf", data: { views: ["ramp"], activeView: "ramp", id: "5" }, size: 340 }, + ], + size: 340, + }, + ], + size: 800, + }, + width: 1440, + height: 800, + orientation: "HORIZONTAL", + }, + panels: { + preview: panel("preview", "Preview"), + layers: panel("layers", "Layers"), + code: panel("code", "Code"), + pixel: panel("pixel", "Pixel"), + gallery: panel("gallery", "Gallery"), + capture: panel("capture", "Graphic Export"), + director: panel("director", "Director"), + knobs: panel("knobs", "Knobs"), + ramp: panel("ramp", "Color Ramp"), + }, + activeGroup: "3", + }; +} + +beforeEach(() => localStorage.clear()); + +describe("a saved layout from before the gallery went", () => { + it("keeps every other panel where it was and fronts the tab beside the gone one", () => { + const layout = dropRetiredPanels(savedLayout(), known)!; + expect(layout).not.toBeNull(); + expect(Object.keys(layout.panels)).not.toContain("gallery"); + const editors = layout.grid.root.data[1] as { data: { views: string[]; activeView: string } }; + expect(editors.data.views).toEqual(["code", "pixel", "capture", "director"]); + expect(editors.data.activeView).toBe("code"); + expect(layout.grid.width).toBe(1440); + expect(layoutViewCount(layout)).toBe(8); + }); + + it("hands back a layout with nothing retired unchanged", () => { + const layout = dropRetiredPanels(savedLayout(), known)!; + expect(dropRetiredPanels(layout, known)).toBe(layout); + }); + + it("gives up on a layout where the gallery had a group to itself", () => { + const layout = savedLayout(); + layout.grid.root.data[1] = { + type: "leaf", + data: { views: ["gallery"], activeView: "gallery", id: "3" }, + size: 680, + }; + expect(dropRetiredPanels(layout, known)).toBeNull(); + expect(layoutViewCount(dropRetiredPanels(layout, known))).toBe(0); + }); + + it("takes the tab out of a floating group too", () => { + const layout = { + ...savedLayout(), + floatingGroups: [ + { + data: { views: ["knobs", "gallery"], activeView: "knobs", id: "6" }, + position: { left: 10, top: 10, width: 300, height: 200 }, + }, + ], + }; + const result = dropRetiredPanels(layout, known)!; + expect(result.floatingGroups[0].data.views).toEqual(["knobs"]); + }); +}); + +describe("a project saved with the gallery's settings", () => { + it("restores with the colour mode and lets the batch settings go", () => { + const json = JSON.stringify({ + version: 2, + name: "Old piece", + matrix: { width: 128, height: 64 }, + layers: [ + { + id: "a", + type: "code", + name: "Code 1", + visible: true, + opacity: 1, + blend: "normal", + code: "function draw() {}", + }, + ], + activeLayerId: "a", + knobs: [0.5, 0.5, 0.5, 0.5], + ranges: [[0, 1], [0, 1], [0, 1], [0, 1]], + knobLabels: ["Knob 1", "Knob 2", "Knob 3", "Knob 4"], + gen: { count: 12, thinking: "HIGH", refs: 3, colorMode: "rgb" }, + }); + const project = deserializeProject(json); + expect(project?.gen).toEqual({ colorMode: "rgb" }); + }); +}); + +describe("opening the lab", () => { + it("removes the gallery's patterns and the Gemini key, and nothing else", () => { + localStorage.setItem(LAB_STORAGE.gallery, JSON.stringify([{ id: "x", name: "Variation", code: "function draw() {}" }])); + localStorage.setItem(LAB_STORAGE.geminiKey, "AIza-not-a-real-key"); + localStorage.setItem(LAB_STORAGE.capture, "{}"); + useLabStore.getState().hydrate(); + expect(localStorage.getItem(LAB_STORAGE.gallery)).toBeNull(); + expect(localStorage.getItem(LAB_STORAGE.geminiKey)).toBeNull(); + expect(localStorage.getItem(LAB_STORAGE.capture)).toBe("{}"); + expect(useLabStore.getState()).not.toHaveProperty("gallery"); + }); +}); diff --git a/web/src/lib/lab/serialize.test.ts b/web/src/lib/lab/serialize.test.ts index 08b37e0c1..9b2699563 100644 --- a/web/src/lib/lab/serialize.test.ts +++ b/web/src/lib/lab/serialize.test.ts @@ -30,7 +30,7 @@ function projectFixture(): LabProject { knobLabels: ["A", "B", "C", "D"], forkOf: null, editOf: null, - gen: { count: 5, thinking: "LOW", refs: 6, colorMode: "vfield" }, + gen: { colorMode: "vfield" }, director: emptyShow(), }; } diff --git a/web/src/lib/lab/serialize.ts b/web/src/lib/lab/serialize.ts index c27946ed0..645078105 100644 --- a/web/src/lib/lab/serialize.ts +++ b/web/src/lib/lab/serialize.ts @@ -10,8 +10,8 @@ // persist.ts says who wrote each key. import { RAMP_MODES, type RampMode } from "@/lib/pattern/harness"; -import { COLOR_MODES, THINKING_LEVELS } from "@/lib/ai/settings"; -import type { ColorMode, ThinkingLevelKey } from "@/lib/ai/settings"; +import { COLOR_MODES } from "@/lib/ai/settings"; +import type { ColorMode } from "@/lib/ai/settings"; import { isValidMatrix, DEFAULT_MATRIX, type MatrixSize } from "@/lib/pattern/matrix"; import { loadDraft } from "@/lib/lab/legacyDraft"; import { PATTERN_KNOB_COUNT } from "@/lib/pattern/harness"; @@ -78,6 +78,53 @@ export function layoutViewCount(layout: unknown): number { const grid = (layout as { grid?: { root?: unknown } } | null | undefined)?.grid; return walk(grid?.root); } + +/** + * A saved dock layout without the panels this build no longer has, or null + * when taking one out would leave a group with no tabs. + * + * dockview builds every saved panel through the component map, and a name the + * map lacks throws ("Only React.memo(...), React.ForwardRef(...) and + * functional components are accepted"). The restore catches that and lays out + * the default, so when the Gallery panel went on 23 October 2026 every layout + * still holding its tab, the default one included, would have been thrown + * away for that one tab. A group left empty would restore as a blank pane, so + * that case still gets the default. + */ +export function dropRetiredPanels(layout: T, known: ReadonlySet): T | null { + if (!isRecord(layout) || !isRecord(layout.panels)) return layout; + const { panels, ...rest } = layout; + const retired = new Set( + Object.entries(panels) + .filter(([id, panel]) => { + const component = isRecord(panel) ? (panel.contentComponent ?? panel.id ?? id) : id; + return typeof component !== "string" || !known.has(component); + }) + .map(([id]) => id), + ); + if (retired.size === 0) return layout; + + let emptied = false; + const keep = (ids: unknown[]) => ids.filter((id) => !(typeof id === "string" && retired.has(id))); + const strip = (node: unknown): unknown => { + if (Array.isArray(node)) return node.map(strip); + if (!isRecord(node)) return node; + const next: Record = {}; + for (const [key, value] of Object.entries(node)) next[key] = strip(value); + if (Array.isArray(node.views)) { + const views = keep(node.views); + if (views.length === 0 && node.views.length > 0) emptied = true; + next.views = views; + if (typeof node.activeView === "string" && retired.has(node.activeView)) next.activeView = views[0]; + } + if (Array.isArray(node.panelIds)) next.panelIds = keep(node.panelIds); + return next; + }; + const result = strip(rest) as Record; + if (emptied) return null; + result.panels = Object.fromEntries(Object.entries(panels).filter(([id]) => !retired.has(id))); + return result as T; +} /** The colour ramp the lab wrote from 2 to 27 July 2026. Only the migration reads it. */ const LEGACY_RAMP_STORAGE = LAB_STORAGE.legacyRamp; @@ -253,14 +300,7 @@ export function serializeProject(project: LabProject): string | null { function readGen(raw: unknown): GenSettings { const gen = (raw ?? {}) as Record; - const count = Number(gen.count); - const refs = Number(gen.refs); return { - count: Number.isFinite(count) ? Math.max(1, Math.min(20, Math.round(count))) : 5, - thinking: THINKING_LEVELS.includes(gen.thinking as ThinkingLevelKey) - ? (gen.thinking as ThinkingLevelKey) - : "LOW", - refs: Number.isFinite(refs) ? refs : 6, colorMode: COLOR_MODES.includes(gen.colorMode as ColorMode) ? (gen.colorMode as ColorMode) : "vfield", diff --git a/web/src/lib/lab/store.ts b/web/src/lib/lab/store.ts index fd30bd811..7043a4441 100644 --- a/web/src/lib/lab/store.ts +++ b/web/src/lib/lab/store.ts @@ -1,8 +1,8 @@ // ── Lab store ──────────────────────────────────────────────────────────────── -// One zustand store holds the whole layered project plus the AI gallery. The -// render engine reads it imperatively each frame (labEngine + getState), while -// panels subscribe to the slices they draw. All updates are immutable EXCEPT -// pixel buffers, which are mutated in place and versioned via `rev`. +// One zustand store holds the whole layered project. The render engine reads +// it imperatively each frame (labEngine + getState), while panels subscribe to +// the slices they draw. All updates are immutable EXCEPT pixel buffers, which +// are mutated in place and versioned via `rev`. // // The actions live in ./store/.ts, one file per concern, composed here; // ./store/shared.ts holds the helpers they share. The store's shape (LabStore), @@ -10,18 +10,16 @@ import { create } from "zustand"; import type { MatrixSize } from "@/lib/pattern/matrix"; -import { saveGallery } from "@/lib/lab/legacyDraft"; import { saveProject } from "./serialize"; import { createAutosave } from "./autosave"; import type { DirectorShow } from "./director/types"; -import { isCodeLayer, type BlendMode, type CodeLayer, type EditRef, type ForkRef, type LayerRole, type GalleryItem, type GenJob, type GenSettings, type LabProject, type Layer, type RampState, type RampStopState } from "./types"; +import { isCodeLayer, type BlendMode, type CodeLayer, type EditRef, type ForkRef, type LayerRole, type GenSettings, type LabProject, type Layer, type RampState, type RampStopState } from "./types"; import { defaultProject } from "./store/shared"; import { createProjectSlice } from "./store/project"; import { createLayersSlice } from "./store/layers"; import { createKnobsSlice } from "./store/knobs"; import { createRampSlice } from "./store/ramp"; import { createDirectorSlice } from "./store/director"; -import { createGallerySlice } from "./store/gallery"; export { codeLayerFromSource } from "./store/shared"; @@ -31,8 +29,6 @@ export type LabStore = LabProject & { restoredAt: number | null; /** Browser autosave state; never included in the serialized project. */ saveStatus: "idle" | "pending" | "saved" | "error"; - gallery: GalleryItem[]; - jobs: GenJob[]; layerErrors: Record; /** Selected ramp stop per code layer (UI state, not persisted). */ rampSelection: Record; @@ -88,8 +84,6 @@ export type LabStore = LabProject & { bumpPixelLayer: (id: string) => void; replacePixelData: (id: string, data: Uint8ClampedArray) => void; - setGallery: (update: (current: GalleryItem[]) => GalleryItem[]) => void; - setJobs: (update: (current: GenJob[]) => GenJob[]) => void; setLayerErrors: (errors: Record) => void; }; @@ -98,8 +92,6 @@ export const useLabStore = create((set, get) => ({ hydrated: false, restoredAt: null, saveStatus: "idle", - gallery: [], - jobs: [], layerErrors: {}, rampSelection: {}, @@ -108,12 +100,11 @@ export const useLabStore = create((set, get) => ({ ...createKnobsSlice(set), ...createRampSlice(set), ...createDirectorSlice(set), - ...createGallerySlice(set), })); // ── persistence ── -// Debounced project autosave + gallery persist, gated until hydration so the -// pre-restore default state can never overwrite a stored project. +// Debounced project autosave, gated until hydration so the pre-restore +// default state can never overwrite a stored project. const autosave = createAutosave( () => saveProject(useLabStore.getState()), (saved) => useLabStore.setState({ saveStatus: saved ? "saved" : "error" }), @@ -135,10 +126,6 @@ if (typeof window !== "undefined") { useLabStore.subscribe((state, previous) => { if (!state.hydrated) return; - if (state.gallery !== previous.gallery) { - saveGallery(state.gallery); - } - const projectChanged = state.name !== previous.name || state.matrix !== previous.matrix || diff --git a/web/src/lib/lab/store/gallery.ts b/web/src/lib/lab/store/gallery.ts deleted file mode 100644 index fab95e3ee..000000000 --- a/web/src/lib/lab/store/gallery.ts +++ /dev/null @@ -1,15 +0,0 @@ -// ── Lab store slice: the AI gallery and its generation queue ── -// One of the slices store.ts composes. Bodies are unchanged from the single -// 800-line store they came out of; only the file boundary is new. - -import type { LabStore } from "../store"; -import type { SliceSet } from "./kinds"; - -export type GallerySlice = Pick; - -export function createGallerySlice(set: SliceSet): GallerySlice { - return { - setGallery: (update) => set((state) => ({ gallery: update(state.gallery) })), - setJobs: (update) => set((state) => ({ jobs: update(state.jobs) })), - }; -} diff --git a/web/src/lib/lab/store/project.ts b/web/src/lib/lab/store/project.ts index d32f06a71..84621627c 100644 --- a/web/src/lib/lab/store/project.ts +++ b/web/src/lib/lab/store/project.ts @@ -1,9 +1,9 @@ -// ── Lab store slice: the whole project: hydration, the session ring, matrix, generation settings, lineage, name ── +// ── Lab store slice: the whole project: hydration, the session ring, matrix, prompt settings, lineage, name ── // One of the slices store.ts composes. Bodies are unchanged from the single // 800-line store they came out of; only the file boundary is new. import { matrixesEqual } from "@/lib/pattern/matrix"; -import { clearDraft, loadGallery } from "@/lib/lab/legacyDraft"; +import { clearDraft, dropAiGallery } from "@/lib/lab/legacyDraft"; import { clearProject, deserializeProject, loadProject, migrateLegacyDraft, serializeProject } from "../serialize"; import { readSession, stashSession } from "../sessions"; import { cloneRampState, layerId, type Layer } from "../types"; @@ -17,6 +17,7 @@ export function createProjectSlice(set: SliceSet, get: SliceGet): ProjectSlice { return { hydrate: () => { if (get().hydrated) return; + dropAiGallery(); // No project, or one with no layers: lift the July draft if the browser // still holds one. persist.ts names the keys. const restored = loadProject() ?? migrateLegacyDraft(); @@ -26,21 +27,18 @@ export function createProjectSlice(set: SliceSet, get: SliceGet): ProjectSlice { ...project, hydrated: true, restoredAt: savedAt || Date.now(), - gallery: loadGallery(), }); return; } - set({ hydrated: true, gallery: loadGallery() }); + set({ hydrated: true }); }, discardProject: () => { clearProject(); - clearDraft(); // the July draft's key, and the gallery's + clearDraft(); // the July draft's key set({ ...defaultProject(), restoredAt: null, - gallery: [], - jobs: [], layerErrors: {}, }); }, diff --git a/web/src/lib/lab/store/shared.ts b/web/src/lib/lab/store/shared.ts index 52289d0a8..6e3a11c92 100644 --- a/web/src/lib/lab/store/shared.ts +++ b/web/src/lib/lab/store/shared.ts @@ -92,7 +92,7 @@ export function defaultProject(): LabProject { knobLabels: knobState.labels, forkOf: null, editOf: null, - gen: { count: 5, thinking: "LOW", refs: 6, colorMode: "vfield" }, + gen: { colorMode: "vfield" }, director: emptyShow(), }; } diff --git a/web/src/lib/lab/types.ts b/web/src/lib/lab/types.ts index f6a266462..3c712b83f 100644 --- a/web/src/lib/lab/types.ts +++ b/web/src/lib/lab/types.ts @@ -13,7 +13,7 @@ import type { RampMode } from "@/lib/pattern/harness"; import type { MatrixSize } from "@/lib/pattern/matrix"; -import type { ColorMode, PatternVariant, ThinkingLevelKey } from "@/lib/ai/settings"; +import type { ColorMode } from "@/lib/ai/settings"; import type { DirectorShow } from "./director/types"; export type KnobRange = [number, number]; @@ -123,26 +123,15 @@ export type EditRef = { portCount: number; } | null; +/** + * What Copy prompt asks an AI for. Until 23 October 2026 this also held the + * AI gallery's batch size, thinking level and reference count; a project + * saved then still carries them, and readGen (serialize.ts) lets them go. + */ export type GenSettings = { - count: number; - thinking: ThinkingLevelKey; - refs: number; colorMode: ColorMode; }; -export type GalleryItem = PatternVariant & { id: string; pinned?: boolean }; - -export type GenJob = { - id: string; - count: number; - thinkingLevel: ThinkingLevelKey; - status: "running" | "done" | "error"; - startedAt: number; - finishedAt?: number; - resultCount?: number; - error?: string; -}; - export type LabProject = { /** * The piece's name — one identity for every hand-off: the hardware diff --git a/web/src/lib/pattern/share.ts b/web/src/lib/pattern/share.ts index 17fd29420..f57d3fe2c 100644 --- a/web/src/lib/pattern/share.ts +++ b/web/src/lib/pattern/share.ts @@ -166,20 +166,22 @@ export function buildAttributionFooter(meta: ShareMeta): string { // said "render error", the page showed a SyntaxError, and nothing said why. const TRAILING_COMMENTS = String.raw`(?:\n(?:[ \t]*\/\/[^\n]*|[ \t]*))*\s*$`; const COMMUNITY_FOOTER_RE = new RegExp(String.raw`\/\/ ── Made with Patternflow[^\n]*` + TRAILING_COMMENTS); -const GEMINI_FOOTER_RE = new RegExp( +const LAB_AI_FOOTER_RE = new RegExp( String.raw`\/\/ ---[ \t]*\n\/\/ (?:Generated at|Made with) [^\n]*patternflow\.work[^\n]*` + TRAILING_COMMENTS, ); // Remove a header/footer we previously injected, so re-sharing an already // exported file does not stack duplicate blocks. Also strips the header/footer -// the in-app Gemini generation stamps onto patterns (see gemini.ts), so shared -// files carry ONE licence block — the user-titled one from this flow. +// the Lab's AI gallery stamped onto every pattern it generated until it was +// taken out on 23 October 2026. Those patterns still sit in saved layers and +// published code, and a shared file should carry ONE licence block, the +// user-titled one from this flow. export function stripShareWrapping(code: string): string { return code .replace(/\/\/ ===== Patternflow pattern =====[\s\S]*?\/\/ =+\n?/, "") .replace(COMMUNITY_FOOTER_RE, "") .replace(/^\s*\/\/ Pattern:[\s\S]*?\/\/ Made with Patternflow Pattern Lab[^\n]*\n?/, "") - .replace(GEMINI_FOOTER_RE, "") + .replace(LAB_AI_FOOTER_RE, "") .trim(); } diff --git a/web/src/providers/PostHogProvider.tsx b/web/src/providers/PostHogProvider.tsx index 92cae4f52..6f84fbe34 100644 --- a/web/src/providers/PostHogProvider.tsx +++ b/web/src/providers/PostHogProvider.tsx @@ -23,17 +23,6 @@ function loadPostHog() { defaults: '2026-01-30', person_profiles: 'identified_only', capture_pageview: false, - session_recording: { - // Pattern Lab sends the user's BYOK Gemini key to this host. Strip the - // request from session replay so a key can never leak via a URL query - // string or captured payload. - maskNetworkRequestFn: (request) => { - if (request.url?.includes('generativelanguage.googleapis.com')) { - return { ...request, url: 'https://generativelanguage.googleapis.com/(redacted)' } - } - return request - }, - }, }) attachPostHog(posthog) })