From 52c1291d75f135237dd7bed0663eb51f377a8842 Mon Sep 17 00:00:00 2001
From: engmung <122682380+engmung@users.noreply.github.com>
Date: Thu, 1 Oct 2026 08:40:26 +0900
Subject: [PATCH 1/7] =?UTF-8?q?web:=20/guide/make=20is=20a=20hands-on=20tu?=
=?UTF-8?q?torial=20=E2=80=94=20the=20real=20Lab,=20a=20practice=20communi?=
=?UTF-8?q?ty,=20a=20practice=20AI?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 3D board meant nothing on the second guide, so the page is a desk of
small windows now: the reader's real Pattern Lab in a browser window, a
practice community (the community's real styles and words, with Origin and
Patterns 1-3 as placeholders, nothing fetched), and a practice AI that
answers the Lab's two prompts with a real preset's JS and then that
preset's real .h. A drawn pointer shows each step and waits for the reader
to do it; it never clicks in the Lab. Narrow screens keep the screenshot
slides.
Played through as a reader in 1440x900 and 1280x800, en and ko, reduced
motion; keyboard-only for the community.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 2 +-
web/src/components/guide/Extras.tsx | 23 +-
web/src/components/guide/Guide.module.css | 115 ++++
web/src/components/guide/GuideExperience.tsx | 34 +-
web/src/components/guide/LabShots.tsx | 4 +-
web/src/components/guide/LabWindow.module.css | 221 +------
web/src/components/guide/LabWindow.tsx | 496 ++------------
web/src/components/guide/copy/community.ts | 57 +-
web/src/components/guide/copy/lab.ts | 104 +--
web/src/components/guide/copy/make.ts | 4 +-
web/src/components/guide/desk/Desk.module.css | 603 +++++++++++++++++
web/src/components/guide/desk/DeskCue.tsx | 39 ++
web/src/components/guide/desk/DeskStage.tsx | 599 +++++++++++++++++
.../guide/desk/ai/PracticeAi.module.css | 271 ++++++++
.../components/guide/desk/ai/PracticeAi.tsx | 351 ++++++++++
.../components/guide/desk/ai/answers.test.ts | 113 ++++
web/src/components/guide/desk/ai/answers.ts | 165 +++++
web/src/components/guide/desk/ai/headers.ts | 493 ++++++++++++++
web/src/components/guide/desk/apps.ts | 15 +
web/src/components/guide/desk/apps/ai.tsx | 14 +
.../components/guide/desk/apps/community.tsx | 19 +
.../guide/desk/community/CommunityApp.tsx | 197 ++++++
.../components/guide/desk/community/Dock.tsx | 318 +++++++++
.../guide/desk/community/Practice.module.css | 256 ++++++++
.../guide/desk/community/SendDialog.tsx | 121 ++++
.../components/guide/desk/community/data.ts | 80 +++
.../components/guide/desk/community/handle.ts | 50 ++
.../components/guide/desk/community/live.ts | 261 ++++++++
.../components/guide/desk/community/pages.tsx | 608 ++++++++++++++++++
.../components/guide/desk/community/parts.tsx | 381 +++++++++++
.../guide/desk/community/state.test.ts | 63 ++
.../components/guide/desk/community/state.ts | 146 +++++
.../components/guide/desk/community/words.ts | 111 ++++
web/src/components/guide/desk/desk.test.ts | 78 +++
web/src/components/guide/desk/deskStore.ts | 114 ++++
web/src/components/guide/desk/pointer.ts | 380 +++++++++++
web/src/components/guide/desk/query.ts | 44 ++
web/src/components/guide/desk/runner.test.ts | 284 ++++++++
web/src/components/guide/desk/runner.ts | 587 +++++++++++++++++
web/src/components/guide/desk/script.ts | 55 ++
web/src/components/guide/desk/target.ts | 120 ++++
web/src/components/guide/desk/types.ts | 189 ++++++
web/src/components/guide/desk/words.ts | 55 ++
web/src/components/guide/pages.test.ts | 17 +-
web/src/components/guide/scenes.ts | 57 +-
web/src/components/guide/scenes/community.ts | 69 +-
web/src/components/guide/scenes/lab.ts | 77 +--
web/src/components/guide/store.ts | 5 +-
.../components/guide/tutorials/community.ts | 289 +++++++++
.../components/guide/tutorials/lab.test.ts | 156 +++++
web/src/components/guide/tutorials/lab.ts | 556 ++++++++++++++++
51 files changed, 8590 insertions(+), 876 deletions(-)
create mode 100644 web/src/components/guide/desk/Desk.module.css
create mode 100644 web/src/components/guide/desk/DeskCue.tsx
create mode 100644 web/src/components/guide/desk/DeskStage.tsx
create mode 100644 web/src/components/guide/desk/ai/PracticeAi.module.css
create mode 100644 web/src/components/guide/desk/ai/PracticeAi.tsx
create mode 100644 web/src/components/guide/desk/ai/answers.test.ts
create mode 100644 web/src/components/guide/desk/ai/answers.ts
create mode 100644 web/src/components/guide/desk/ai/headers.ts
create mode 100644 web/src/components/guide/desk/apps.ts
create mode 100644 web/src/components/guide/desk/apps/ai.tsx
create mode 100644 web/src/components/guide/desk/apps/community.tsx
create mode 100644 web/src/components/guide/desk/community/CommunityApp.tsx
create mode 100644 web/src/components/guide/desk/community/Dock.tsx
create mode 100644 web/src/components/guide/desk/community/Practice.module.css
create mode 100644 web/src/components/guide/desk/community/SendDialog.tsx
create mode 100644 web/src/components/guide/desk/community/data.ts
create mode 100644 web/src/components/guide/desk/community/handle.ts
create mode 100644 web/src/components/guide/desk/community/live.ts
create mode 100644 web/src/components/guide/desk/community/pages.tsx
create mode 100644 web/src/components/guide/desk/community/parts.tsx
create mode 100644 web/src/components/guide/desk/community/state.test.ts
create mode 100644 web/src/components/guide/desk/community/state.ts
create mode 100644 web/src/components/guide/desk/community/words.ts
create mode 100644 web/src/components/guide/desk/desk.test.ts
create mode 100644 web/src/components/guide/desk/deskStore.ts
create mode 100644 web/src/components/guide/desk/pointer.ts
create mode 100644 web/src/components/guide/desk/query.ts
create mode 100644 web/src/components/guide/desk/runner.test.ts
create mode 100644 web/src/components/guide/desk/runner.ts
create mode 100644 web/src/components/guide/desk/script.ts
create mode 100644 web/src/components/guide/desk/target.ts
create mode 100644 web/src/components/guide/desk/types.ts
create mode 100644 web/src/components/guide/desk/words.ts
create mode 100644 web/src/components/guide/tutorials/community.ts
create mode 100644 web/src/components/guide/tutorials/lab.test.ts
create mode 100644 web/src/components/guide/tutorials/lab.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eb34f42c..9742df1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,7 +22,7 @@ All notable changes to Patternflow will be documented in this file, newest first
### Web
-- **A guide you can play along with, at [/guide](https://patternflow.work/guide)** (work in progress, marked WIP). The first page is the first hour, 01 Flash, 02 Knobs, 03 Patterns, 04 Console, on the real v3.9 hardware in 3D, running a port of the firmware's input logic and screens, with the real flasher dialogs and a live copy of the device console. The second, [/guide/make](https://patternflow.work/guide/make), is 05 Community and 06 Pattern Lab: the community's real screens, and a board that plays your own Pattern Lab draft (read, never written) while the real Lab opens in a window beside it. English and Korean. Every step has a "Stuck here?" link to a GitHub issue that already says where (`guide_stuck.yml`).
+- **A guide you can play along with, at [/guide](https://patternflow.work/guide)** (work in progress, marked WIP). The first page is the first hour, 01 Flash, 02 Knobs, 03 Patterns, 04 Console, on the real v3.9 hardware in 3D, running a port of the firmware's input logic and screens, with the real flasher dialogs and a live copy of the device console. The second, [/guide/make](https://patternflow.work/guide/make), is 05 Community and 06 Pattern Lab, done by hand: on a big enough screen your own Pattern Lab, a practice community (placeholder patterns, connected to nothing) and a practice AI with set answers sit beside the steps, and a pointer shows each move and waits for yours; phones get the real screens. English and Korean. Every step has a "Stuck here?" link to a GitHub issue that already says where (`guide_stuck.yml`).
- **The community's Performances note says .pfs**, which is what the Director saves, instead of "Save-JSON".
- **A moderator can take a pattern or deck off the wall without deleting it.** Somebody else's public pattern or deck now offers moderators *Make private*, with an optional reason: the softer removal, for things like the same pattern posted a fifth time. Nothing about the work changes and its author keeps it — they can still open it, edit it, take it into the lab — it just stops being shown to anyone else. On a pattern the reason is posted underneath as the moderator's comment, where the author can answer it; either way the author is told in their alerts. While the take-down stands the author cannot make it public again (a take-down its subject can undo in one click is a request); *Restore to public* puts it back exactly as it was. A moderator still cannot publish what an author made private themselves. A deck slot left empty by a take-down reads "made private by a moderator", not "by its author". Moderators can now comment on private patterns and keep their alerts about them, so the thread under a take-down works both ways. Migration `0025` adds `hidden_at` to patterns and decks; `check:hidemod` drives it all through the real routes.
- **One press of Publish is one post.** A double-click on *Publish to the wall*, or a mouse switch that bounces, could put the same pattern on the wall twice in the same second ([#455](https://github.com/engmung/Patternflow/issues/455)). The button's disabled state was React state, a render too late for the second click, and after a successful publish it came back enabled while the page changed. The publish, thread, reply, deck, header, performance and report forms now refuse a second press in the same tick (`useSubmitLatch`), and a published pattern keeps its button down until the page changes; a thread whose files failed to attach offers *Open the thread* instead of posting it again. Behind them the server answers an identical submission from the same account within a minute with the post it already made, looked up and inserted in one synchronous transaction so two requests arriving together cannot both get through. `check:publish` fires two identical publishes into the route at the same instant; a version that awaits between the lookup and the insert fails it.
diff --git a/web/src/components/guide/Extras.tsx b/web/src/components/guide/Extras.tsx
index e78c92a9..b029277c 100644
--- a/web/src/components/guide/Extras.tsx
+++ b/web/src/components/guide/Extras.tsx
@@ -373,12 +373,27 @@ export default function Extras({ kind, lang, step }: { kind: Extra; lang: GuideL
return ;
case "consoleTour":
return ;
- // The make page: 05 Community and 06 Pattern Lab.
+ // The make page: 05 Community and 06 Pattern Lab. On a screen with the
+ // desk (desk/DeskStage) the real thing is beside the card, so these —
+ // screenshots, and the Lab in a tab — are for screens without it
+ // (Guide.module.css .offDesk).
case "communityShots":
- return ;
+ return (
+
+
+
+ );
case "labShots":
- return ;
+ return (
+
+
+
+ );
case "labWindow":
- return ;
+ return (
+
+
+
+ );
}
}
diff --git a/web/src/components/guide/Guide.module.css b/web/src/components/guide/Guide.module.css
index 737e9086..16a8447d 100644
--- a/web/src/components/guide/Guide.module.css
+++ b/web/src/components/guide/Guide.module.css
@@ -1093,3 +1093,118 @@
line-height: 1.5;
color: var(--g-ink-2);
}
+
+/* ── the make page (/guide/make): a desk of windows, or no stage at all ──
+
+ On a screen big enough for the desk (desk/query.ts; the same query is in
+ desk/Desk.module.css — change all three together) the story is a column on
+ the right and the desk takes the rest: the real Pattern Lab and the
+ practice windows, which show what the cards say. The cards' screenshots
+ are for screens without the desk (.offDesk). Below it there is no stage:
+ one column of cards, screenshots in them. The first page is untouched. */
+
+@media (min-width: 1340px) and (min-height: 640px), (min-width: 1280px) and (min-height: 660px), (min-width: 1180px) and (min-height: 700px) {
+ .page[data-page="make"] {
+ /* The story's column; the desk is everything left of it. */
+ --desk-col: clamp(330px, 27vw, 410px);
+ }
+ .page[data-page="make"] .story {
+ width: calc(var(--desk-col) + 48px);
+ margin-left: auto;
+ }
+ .page[data-page="make"] .opening,
+ .page[data-page="make"] .next {
+ padding: 104px 24px 64px;
+ }
+ .page[data-page="make"] .openingInner,
+ .page[data-page="make"] .nextInner,
+ .page[data-page="make"] .card {
+ width: 100%;
+ }
+ .page[data-page="make"] .openingTitle {
+ font-size: clamp(46px, 4.4vw, 66px);
+ }
+ .page[data-page="make"] .openingLede {
+ font-size: 16px;
+ }
+ .page[data-page="make"] .chapterHead {
+ min-height: 72vh;
+ padding: 104px 24px 32px;
+ }
+ .page[data-page="make"] .chapterNum {
+ font-size: clamp(110px, 11vw, 170px);
+ }
+ .page[data-page="make"] .chapterTitle {
+ font-size: clamp(36px, 3.3vw, 50px);
+ }
+ .page[data-page="make"] .chapterLede {
+ font-size: 16px;
+ }
+ .page[data-page="make"] .step {
+ padding: 10vh 24px 8vh;
+ }
+ .page[data-page="make"] .card {
+ padding: 22px 22px 20px;
+ }
+ .page[data-page="make"] .stepTitle {
+ font-size: 23px;
+ }
+ .page[data-page="make"] .body {
+ font-size: 15px;
+ line-height: 1.6;
+ }
+ .page[data-page="make"] .offDesk {
+ display: none;
+ }
+}
+
+/* The desk query's opposite, spelled out (a query list can't be negated as a whole). */
+@media (max-width: 1179.98px), (max-height: 639.98px), (max-width: 1279.98px) and (max-height: 699.98px), (max-width: 1339.98px) and (max-height: 659.98px) {
+ /* No stage behind: the cards pass under the top bar, which fades them out. */
+ .page[data-page="make"] .top {
+ background: linear-gradient(rgba(10, 9, 8, 0.94) 55%, rgba(10, 9, 8, 0));
+ }
+ .page[data-page="make"] .story {
+ max-width: 640px;
+ margin: 0 auto;
+ }
+ .page[data-page="make"] .opening,
+ .page[data-page="make"] .next {
+ justify-content: center;
+ padding: 112px 24px 72px;
+ }
+ .page[data-page="make"] .openingInner,
+ .page[data-page="make"] .nextInner,
+ .page[data-page="make"] .card {
+ width: 100%;
+ }
+ .page[data-page="make"] .chapterHead {
+ min-height: 60vh;
+ align-items: flex-start;
+ text-align: left;
+ padding: 104px 24px 28px;
+ }
+ .page[data-page="make"] .step {
+ min-height: 90vh;
+ justify-content: center;
+ padding: 8vh 24px;
+ }
+}
+
+@media (max-width: 899px) {
+ .page[data-page="make"] .opening,
+ .page[data-page="make"] .next {
+ align-items: center;
+ padding: 88px 16px 40px;
+ }
+ .page[data-page="make"] .chapterHead {
+ min-height: 56vh;
+ justify-content: flex-end;
+ padding: 88px 16px 24px;
+ }
+ .page[data-page="make"] .step {
+ min-height: 88vh;
+ align-items: center;
+ padding: 6vh 16px;
+ }
+}
diff --git a/web/src/components/guide/GuideExperience.tsx b/web/src/components/guide/GuideExperience.tsx
index e152b8e3..88122e44 100644
--- a/web/src/components/guide/GuideExperience.tsx
+++ b/web/src/components/guide/GuideExperience.tsx
@@ -10,13 +10,22 @@ import { useGuideStore, type GuideLang, type GuidePageId } from "./store";
import Extras from "./Extras";
import { hereFor, reportUrl } from "./report";
import { PAGES, pagePath, scriptMismatches, type PageChapter } from "./pages";
+import { useDeskFits } from "./desk/query";
+import DeskCue from "./desk/DeskCue";
// A page of the guide (pages.ts: /guide is "start", /guide/make is "make").
// A fixed stage behind, the story scrolling over it. The tracker below finds
// the step block nearest the middle of the viewport and hands its scene and
// index to the store; the stage takes it from there, from this page's script.
+//
+// The first page's stage is the 3D device (stage/GuideCanvas). The make
+// page's is a desk of app windows left of the story (desk/DeskStage) — the
+// real Pattern Lab, a practice community, a practice AI, and a pointer that
+// shows the way — on a screen big enough for it (desk/query.ts); below that
+// it has no stage, and its cards show screenshots instead.
const GuideCanvas = dynamic(() => import("./stage/GuideCanvas"), { ssr: false });
+const DeskStage = dynamic(() => import("./desk/DeskStage"), { ssr: false });
function useScrollTracker(root: React.RefObject) {
useEffect(() => {
@@ -139,6 +148,7 @@ function Step({
))}
{copy.warn && {copy.warn}
}
+ {page === "make" && }
{copy.extra && }
{copy.note && (
@@ -195,6 +205,7 @@ export default function GuideExperience({ lang, page = "start" }: { lang: GuideL
const { opening, chapters, next } = PAGES[page].text(lang);
const other: GuideLang = lang === "en" ? "ko" : "en";
const root = useRef(null);
+ const deskFits = useDeskFits();
// The stage plays this page's script, from its top: set before the canvas
// draws a frame, and before the tracker below says where the reader is.
@@ -229,12 +240,21 @@ export default function GuideExperience({ lang, page = "start" }: { lang: GuideL
}, [page]);
return (
-
-
+
+ {page === "make" ? (
+ // The room only: the desk comes after the story, so the keyboard
+ // reaches the cards first (each card's cue line can jump into it).
+
+ ) : (
+
+ )}
@@ -334,6 +354,8 @@ export default function GuideExperience({ lang, page = "start" }: { lang: GuideL
+
+ {page === "make" && deskFits && }
);
}
diff --git a/web/src/components/guide/LabShots.tsx b/web/src/components/guide/LabShots.tsx
index a008ab50..6d279d8f 100644
--- a/web/src/components/guide/LabShots.tsx
+++ b/web/src/components/guide/LabShots.tsx
@@ -224,8 +224,8 @@ const STEPS: Record = {
2: ["copyPrompt", "paste", "gallery"],
3: ["bw", "ramp", "rampRandom", "color"],
4: ["knobs", "push"],
- 5: ["convert", "pasted"],
- 6: ["apply"],
+ 5: ["convert"],
+ 6: ["pasted", "apply"],
7: ["layers", "mask", "previewLayers", "blend", "hwLayers"],
8: ["exportSize", "exportGo"],
9: ["director"],
diff --git a/web/src/components/guide/LabWindow.module.css b/web/src/components/guide/LabWindow.module.css
index a1cdeb37..9a7ae67b 100644
--- a/web/src/components/guide/LabWindow.module.css
+++ b/web/src/components/guide/LabWindow.module.css
@@ -1,9 +1,8 @@
-/* Pattern Lab in a window (LabWindow.tsx): a browser in the guide's colours
- around the real /pattern-lab. Tokens are local because the lifted window is
- portalled to , outside the guide's .page where --g-* live. */
+/* Pattern Lab, as a card shows it where the make page has no desk
+ (LabWindow.tsx): a small browser window in the guide's colours around a
+ real capture of the Lab, and the whole of it a link to the Lab. */
-.root,
-.layer {
+.root {
--lw-ink: #efe9dd;
--lw-ink-2: rgba(239, 233, 221, 0.72);
--lw-ink-3: rgba(239, 233, 221, 0.46);
@@ -22,7 +21,7 @@
margin: 18px 0 4px;
}
-/* ── the card: one button (or, on a phone, one link), the whole window ── */
+/* ── the card: one link, the whole window ── */
.opener {
appearance: none;
@@ -46,10 +45,6 @@
outline: 2px solid var(--lw-led);
outline-offset: 4px;
}
-/* It has left the card while it floats. */
-.opener[data-lifted="1"] {
- opacity: 0;
-}
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
.opener:hover {
transform: translateY(-2px);
@@ -168,209 +163,3 @@
.path {
color: var(--lw-ink-3);
}
-
-/* ── lifted ── */
-
-.layer {
- position: fixed;
- inset: 0;
- z-index: 1000;
-}
-.layer[data-closing="1"] {
- pointer-events: none;
-}
-.backdrop {
- position: absolute;
- inset: 0;
- /* The board, on the left, stays in the light: the window is there to be
- used beside it. */
- background: linear-gradient(90deg, rgba(8, 7, 6, 0) 0%, rgba(8, 7, 6, 0.12) 22%, rgba(8, 7, 6, 0.6) 34%, rgba(8, 7, 6, 0.82) 100%);
- opacity: 0;
- transition: opacity 0.4s ease;
-}
-.layer[data-shown="1"] .backdrop {
- opacity: 1;
-}
-
-.lift {
- position: absolute;
- transform-origin: 0 0;
- transition:
- transform 0.44s cubic-bezier(0.22, 0.8, 0.2, 1),
- opacity 0.26s ease;
-}
-.layer[data-shown="1"] .lift {
- transition:
- transform 0.44s cubic-bezier(0.22, 0.8, 0.2, 1),
- opacity 0.18s ease;
-}
-
-.window {
- display: flex;
- flex-direction: column;
- width: 100%;
- height: 100%;
- overflow: hidden;
- border: 1px solid rgba(239, 233, 221, 0.22);
- border-radius: 12px;
- background: var(--lw-chrome);
- box-shadow:
- 0 40px 120px rgba(0, 0, 0, 0.62),
- 0 4px 16px rgba(0, 0, 0, 0.4);
-}
-
-.bar {
- display: flex;
- flex: none;
- align-items: center;
- gap: 12px;
- padding: 0 10px 0 14px;
- border-bottom: 1px solid rgba(0, 0, 0, 0.5);
- background: var(--lw-chrome-2);
- font-family: var(--pf-sans, system-ui, sans-serif);
-}
-.bar .lights {
- gap: 7px;
- margin-right: 4px;
-}
-.bar .lights i {
- width: 11px;
- height: 11px;
-}
-.tabTitle {
- flex: none;
- max-width: 220px;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- font-size: 12.5px;
- color: var(--lw-ink-2);
-}
-.bar .address {
- height: 26px;
- padding: 0 14px;
- font-size: 12.5px;
-}
-.bar .lock {
- width: 13px;
- height: 13px;
-}
-
-.barLink {
- display: inline-flex;
- flex: none;
- align-items: center;
- gap: 6px;
- height: 26px;
- padding: 0 11px;
- border: 1px solid var(--lw-hair-2);
- border-radius: 999px;
- font-family: var(--pf-mono, ui-monospace, monospace);
- font-size: 10.5px;
- letter-spacing: 0.06em;
- text-transform: uppercase;
- text-decoration: none;
- color: var(--lw-ink-2);
- transition:
- color 0.2s,
- border-color 0.2s,
- background 0.2s;
-}
-.barLink svg {
- width: 11px;
- height: 11px;
-}
-.barLink:hover {
- color: var(--lw-led);
- border-color: var(--lw-led);
- background: rgba(255, 106, 61, 0.1);
-}
-.barLink:focus-visible {
- outline: 2px solid var(--lw-led);
- outline-offset: 2px;
-}
-
-.close {
- appearance: none;
- display: grid;
- flex: none;
- place-items: center;
- width: 28px;
- height: 28px;
- margin: 0;
- padding: 0;
- border: 1px solid var(--lw-hair-2);
- border-radius: 50%;
- background: rgba(239, 233, 221, 0.04);
- color: var(--lw-ink-2);
- cursor: pointer;
- transition:
- color 0.2s,
- border-color 0.2s,
- background 0.2s;
-}
-.close svg {
- width: 14px;
- height: 14px;
-}
-.close:hover {
- color: var(--lw-ink);
- border-color: var(--lw-led);
- background: rgba(255, 106, 61, 0.14);
-}
-.close:focus-visible {
- outline: 2px solid var(--lw-led);
- outline-offset: 2px;
-}
-
-.otherTab {
- flex: none;
- margin: 0;
- padding: 7px 16px;
- border-bottom: 1px solid rgba(0, 0, 0, 0.5);
- background: #2a1a12;
- font-family: var(--pf-sans, system-ui, sans-serif);
- font-size: 12.5px;
- line-height: 1.45;
- color: #ffd8c7;
-}
-
-.viewport {
- position: relative;
- flex: 1;
- min-height: 0;
- overflow: hidden;
- background: var(--lw-lab);
-}
-.posterBig {
- position: absolute;
- left: 0;
- top: 0;
- height: auto;
- pointer-events: none;
-}
-.frame {
- position: absolute;
- left: 0;
- top: 0;
- display: block;
- border: 0;
- background: var(--lw-lab);
- color-scheme: light;
- transform-origin: 0 0;
- opacity: 0;
- transition: opacity 0.3s ease;
-}
-.viewport[data-ready="1"] .frame {
- opacity: 1;
-}
-
-@media (prefers-reduced-motion: reduce) {
- .lift,
- .layer[data-shown="1"] .lift {
- transition: opacity 0.18s ease;
- }
- .frame {
- transition: none;
- }
-}
diff --git a/web/src/components/guide/LabWindow.tsx b/web/src/components/guide/LabWindow.tsx
index 4adbcd7b..33ac98ae 100644
--- a/web/src/components/guide/LabWindow.tsx
+++ b/web/src/components/guide/LabWindow.tsx
@@ -1,97 +1,37 @@
"use client";
-import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode, type RefObject } from "react";
-import { createPortal } from "react-dom";
-import { LAB_STORAGE } from "@/lib/lab/persist";
-import { setStageArea } from "./stage/stageArea";
-import { useGuideStore, type GuideLang } from "./store";
+import { useSyncExternalStore } from "react";
+import type { GuideLang } from "./store";
import styles from "./LabWindow.module.css";
-// 06 Pattern Lab, "Open the Lab." — the real Pattern Lab in a window lifted
-// over the page, the way ConsoleWindow lifts the device's real console, while
-// the device on stage plays the reader's own draft (the step's `mirror`,
-// scenes/lab.ts; lib/guide/labMirror.ts).
-//
-// The window is /pattern-lab itself, same origin, same browser storage: what
-// the reader does in it is their real draft, saved by the lab as it always
-// saves, and the board beside it follows those saves. This file never writes,
-// removes or migrates a patternflow_lab_* key, and does nothing to the frame
-// that would: it reads the frame's address and title, keeps Esc and the
-// page's scroll with the window, sends the lab's links to a new tab rather
-// than away from the lab, and listens for another tab's saves.
-//
-// In the card: a picture of the lab as it opens (a real capture,
-// public/guide/lab-window/lab.webp, scratchpad lab_poster.py) and nothing
-// loaded, so scrolling past never starts the lab. A click lifts the window
-// beside the board: the lab loads at 1024 CSS px wide or more, scaled to fit,
-// and the stage frames the device in what the window leaves (stageArea.ts).
-// Esc, the close button or a click beside it puts it back. A screen too
-// small for both gets a link that opens the lab in a new tab instead.
+// 06 Pattern Lab, "Open the Lab." — where the make page has no desk (a phone,
+// a small window: desk/query.ts), the card shows the Lab as it opens (a real
+// capture, public/guide/lab-window/lab.webp, scratchpad lab_poster.py) in a
+// small browser window, and the whole of it opens the real Pattern Lab in a
+// tab of its own. With the desk, the Lab is on it, beside the card, and this
+// is hidden (Extras.tsx .offDesk).
//
// Used by a step whose copy has extra: "labWindow" (copy/lab.ts step 0).
const LAB_PATH = "/pattern-lab";
const POSTER = "/guide/lab-window/lab.webp";
-/**
- * The lab's layout width: it is laid out at this many CSS px, or more, and
- * scaled to fit. The lab lays out whole at 1024; at 1280 the scale on a
- * 1280–1440 px laptop left its 11–12 px UI at 7–9 px. The poster is captured
- * at this size (lab_poster.py).
- */
+/** The poster's size: the Lab as it lays out at 1024 CSS px. */
const LAB_W = 1024;
const LAB_H = 680;
-const BAR_H = 40;
-const LIFT_MS = 440;
-/**
- * How long the frame stays loaded, hidden, after the window closes. The lab
- * saves 600 ms after the last edit (lib/lab/autosave.ts); an edit made just
- * before closing is saved by the lab's own timer before the frame goes.
- */
-const UNLOAD_MS = 1100;
-/** Wide and tall enough for the window and the board beside it. */
-const LIFT_QUERY = "(min-width: 1180px) and (min-height: 640px)";
type Words = {
open: string;
- close: string;
- label: string;
- newTab: string;
phoneOpen: string;
- otherTab: string;
};
export const LAB_WINDOW_WORDS: Record = {
- en: {
- open: "Open",
- close: "Close",
- label: "Pattern Lab",
- newTab: "Open in a new tab",
- phoneOpen: "Open Pattern Lab",
- otherTab: "Pattern Lab just saved from another tab. Edit in one place: two open Labs overwrite each other's saves.",
- },
- ko: {
- open: "열기",
- close: "닫기",
- label: "패턴 랩",
- newTab: "새 탭에서 열기",
- phoneOpen: "패턴 랩 열기",
- otherTab: "다른 탭의 패턴 랩이 방금 저장했어요. 한 곳에서만 고쳐 주세요. 둘 다 열려 있으면 서로의 저장을 덮어써요.",
- },
+ en: { open: "Open", phoneOpen: "Open Pattern Lab" },
+ ko: { open: "열기", phoneOpen: "패턴 랩 열기" },
};
-// ── what the browser says ───────────────────────────────────────────────────
-
-function subscribeMedia(onChange: () => void) {
- const mq = window.matchMedia(LIFT_QUERY);
- mq.addEventListener("change", onChange);
- return () => mq.removeEventListener("change", onChange);
-}
-const canLift = () => window.matchMedia(LIFT_QUERY).matches;
const noop = () => () => {};
const pageHost = () => window.location.host;
-// ── bits of the window ──────────────────────────────────────────────────────
-
function OpenIcon() {
return (
@@ -100,400 +40,40 @@ function OpenIcon() {
);
}
-function Address({ host }: { host: string }) {
- return (
-
-
-
-
-
-
- {host}
- {LAB_PATH}
-
-
- );
-}
-
-function Lights() {
- return (
-
-
-
-
-
- );
-}
-
-// ── the lifted window ───────────────────────────────────────────────────────
-
-type Box = { x: number; y: number; w: number; h: number };
-
-// The board keeps the left of the screen, about a third of it; the window
-// takes the rest, full height.
-function placement(vw: number, vh: number): Box {
- const m = 20;
- const x = Math.round(Math.min(600, Math.max(340, vw * 0.3)));
- return { x, y: m, w: vw - x - m, h: vh - 2 * m };
-}
-
-function isEditable(t: EventTarget | null): boolean {
- if (!t || typeof (t as Element).closest !== "function") return false;
- const el = t as HTMLElement;
- return Boolean(el.isContentEditable || el.closest("input, textarea, select, [contenteditable], .cm-editor"));
-}
-
-function Lifted({
- lang,
- host,
- from,
- frame,
- closing,
- onClose,
-}: {
- lang: GuideLang;
- host: string;
- from: HTMLElement;
- frame: RefObject;
- closing: boolean;
- onClose: () => void;
-}) {
- const measure = useCallback(
- () => ({
- vw: document.documentElement.clientWidth,
- vh: document.documentElement.clientHeight,
- src: from.getBoundingClientRect(),
- }),
- [from],
- );
- const [geo, setGeo] = useState(measure);
- const [reduced] = useState(() => window.matchMedia("(prefers-reduced-motion: reduce)").matches);
- const [shown, setShown] = useState(false);
- const [ready, setReady] = useState(false);
- const [otherTab, setOtherTab] = useState(false);
- const layer = useRef(null);
- const viewport = useRef(null);
- /** The lab's part of the window, measured (a notice above it takes some). */
- const [inner, setInner] = useState<{ w: number; h: number } | null>(null);
- const closeBtn = useRef(null);
- const tabLink = useRef(null);
- const [title, setTitle] = useState("Pattern Lab / Patternflow");
- const box = placement(geo.vw, geo.vh);
- const words = LAB_WINDOW_WORDS[lang];
-
- // The lab's own size: at least LAB_W × LAB_H, scaled down to the window,
- // never up — a big screen gets more lab, not bigger type.
- const innerW = inner?.w ?? box.w - 2;
- const innerH = inner?.h ?? box.h - BAR_H - 2;
- const scale = Math.min(1, innerW / LAB_W, innerH / LAB_H);
- const labW = Math.ceil(innerW / scale);
- const labH = Math.ceil(innerH / scale);
-
- useEffect(() => {
- const el = viewport.current;
- if (!el) return;
- const ro = new ResizeObserver(() => {
- if (el.clientWidth > 0 && el.clientHeight > 0) setInner({ w: el.clientWidth, h: el.clientHeight });
- });
- ro.observe(el);
- return () => ro.disconnect();
- }, []);
-
- useEffect(() => {
- const onResize = () => setGeo(measure());
- window.addEventListener("resize", onResize);
- // One frame laid out at the card, then the move.
- let r2 = 0;
- const r1 = requestAnimationFrame(() => {
- r2 = requestAnimationFrame(() => setShown(true));
- });
- return () => {
- window.removeEventListener("resize", onResize);
- cancelAnimationFrame(r1);
- cancelAnimationFrame(r2);
- };
- }, [measure]);
-
- // The stage frames the device left of the window while it is open (and
- // starts moving there as the window lifts); closing gives the card back.
- useEffect(() => {
- if (closing) setStageArea(null);
- else setStageArea({ l: 24, r: box.x - 28, t: 64, b: geo.vh - 32 });
- }, [closing, box.x, geo.vh]);
- useEffect(() => () => setStageArea(null), []);
-
- useEffect(() => {
- if (shown && !closing) closeBtn.current?.focus({ preventScroll: true });
- }, [shown, closing]);
-
- // The story must not move under the window — its step, and with it the
- // board, would change. Wheel and touch on the backdrop are held; so are the
- // scrolling keys; focus stays in the window (the close button, the lab).
- useEffect(() => {
- const el = layer.current;
- if (!el || closing) return;
- const hold = (e: Event) => e.preventDefault();
- el.addEventListener("wheel", hold, { passive: false });
- el.addEventListener("touchmove", hold, { passive: false });
- const keys = (e: KeyboardEvent) => {
- if (e.key === "Escape") {
- onClose();
- return;
- }
- const onButton = e.target instanceof HTMLButtonElement || e.target instanceof HTMLAnchorElement;
- if (["PageUp", "PageDown", "Home", "End", "ArrowUp", "ArrowDown"].includes(e.key)) e.preventDefault();
- if (e.key === " " && !onButton) e.preventDefault();
- if (e.key === "Tab" && document.activeElement !== frame.current) {
- // Three stops, round and round: the new-tab link, close, the lab.
- e.preventDefault();
- const stops: HTMLElement[] = [];
- for (const el of [tabLink.current, closeBtn.current, frame.current]) if (el) stops.push(el);
- const at = stops.indexOf(document.activeElement as HTMLElement);
- const next = at < 0 ? 1 : (at + (e.shiftKey ? stops.length - 1 : 1)) % stops.length;
- stops[Math.min(next, stops.length - 1)]?.focus();
- }
- };
- const keep = (e: FocusEvent) => {
- if (e.target instanceof Node && !el.contains(e.target)) closeBtn.current?.focus({ preventScroll: true });
- };
- window.addEventListener("keydown", keys, true);
- document.addEventListener("focusin", keep);
- return () => {
- el.removeEventListener("wheel", hold);
- el.removeEventListener("touchmove", hold);
- window.removeEventListener("keydown", keys, true);
- document.removeEventListener("focusin", keep);
- };
- }, [frame, onClose, closing]);
-
- // The lab in the frame: read-only hooks, nothing that writes storage.
- const detach = useRef<(() => void) | null>(null);
- useEffect(() => () => detach.current?.(), []);
- const onLoad = useCallback(() => {
- detach.current?.();
- detach.current = null;
- const win = frame.current?.contentWindow;
- let doc: Document | null = null;
- try {
- doc = frame.current?.contentDocument ?? null;
- } catch {
- doc = null;
- }
- if (!win || !doc) return;
- if (doc.title) setTitle(doc.title);
- // The frame is there for the lab. If it ever lands anywhere else, that
- // page opens in a tab of its own and the lab comes back here.
- if (win.location.pathname !== LAB_PATH) {
- window.open(win.location.href, "_blank", "noopener");
- win.location.replace(LAB_PATH);
- return;
- }
- // A wheel the lab doesn't use must not scroll the story behind it.
- const style = doc.createElement("style");
- style.textContent = "html, body { overscroll-behavior: none; }";
- doc.head.appendChild(style);
- // Esc closes the window unless the lab is using it (a field, the code
- // editor, a menu that took it).
- const onKey = (e: KeyboardEvent) => {
- if (e.key !== "Escape" || e.defaultPrevented || isEditable(e.target)) return;
- onClose();
- };
- // A link out of the lab opens beside the guide, not in place of the lab.
- const onClick = (e: MouseEvent) => {
- const a = (e.target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null;
- if (!a || e.defaultPrevented || e.button !== 0) return;
- const url = new URL(a.href, win.location.href);
- const stays = url.origin === win.location.origin && url.pathname === LAB_PATH;
- if (stays || a.target === "_blank" || a.hasAttribute("download")) return;
- e.preventDefault();
- e.stopPropagation();
- window.open(url.href, "_blank", "noopener");
- };
- // Another tab's lab saving the same draft: say so (the frame's window
- // hears saves from every document but its own; this page never writes).
- const onStorage = (e: StorageEvent) => {
- if (e.key === LAB_STORAGE.project) setOtherTab(true);
- };
- win.addEventListener("keydown", onKey);
- doc.addEventListener("click", onClick, true);
- win.addEventListener("storage", onStorage);
- detach.current = () => {
- try {
- win.removeEventListener("keydown", onKey);
- doc?.removeEventListener("click", onClick, true);
- win.removeEventListener("storage", onStorage);
- } catch {
- /* the frame is gone */
- }
- };
- // Shown once it has painted, over the picture of it.
- requestAnimationFrame(() => requestAnimationFrame(() => setReady(true)));
- }, [frame, onClose]);
-
- // From the card to its place, and back.
- const at = shown && !closing;
- const k = geo.src.width / box.w;
- const travel: CSSProperties = reduced
- ? { opacity: at ? 1 : 0 }
- : at
- ? { transform: "none", opacity: 1 }
- : {
- transform: `translate3d(${geo.src.left - box.x}px, ${geo.src.top - box.y}px, 0) scale(${k})`,
- opacity: 0,
- };
-
- return (
-
-
-
-
-
- {otherTab && (
-
- {words.otherTab}
-
- )}
-
- {/* eslint-disable-next-line @next/next/no-img-element -- a fixed poster under the frame until it paints */}
-
-
-
-
-
-
- );
-}
-
-// ── the card ────────────────────────────────────────────────────────────────
-
export default function LabWindow({ lang, step }: { lang: GuideLang; step: number }) {
- const lift = useSyncExternalStore(subscribeMedia, canLift, () => false);
const host = useSyncExternalStore(noop, pageHost, () => "patternflow.work");
- const narrow = useGuideStore((s) => s.narrow);
- const opener = useRef(null);
- const frame = useRef(null);
- const [open, setOpen] = useState<{ from: HTMLElement; closing: boolean } | null>(null);
const words = LAB_WINDOW_WORDS[lang];
-
- const close = useCallback(() => {
- setOpen((o) => (o && !o.closing ? { ...o, closing: true } : o));
- }, []);
-
- // Closing: focus goes back to the card once the window has flown back to
- // it; the frame stays loaded (hidden) until the lab's own save has run.
- useEffect(() => {
- if (!open?.closing) return;
- const back = window.setTimeout(() => opener.current?.focus({ preventScroll: true }), LIFT_MS);
- const gone = window.setTimeout(() => setOpen(null), UNLOAD_MS);
- return () => {
- window.clearTimeout(back);
- window.clearTimeout(gone);
- };
- }, [open?.closing]);
-
- // The screen got too small for the window and the board: close it.
- useEffect(() => {
- const mq = window.matchMedia(LIFT_QUERY);
- const onMedia = () => {
- if (!mq.matches) close();
- };
- mq.addEventListener("change", onMedia);
- const offNarrow = useGuideStore.subscribe((s, prev) => {
- if (s.narrow && !prev.narrow) close();
- });
- return () => {
- mq.removeEventListener("change", onMedia);
- offNarrow();
- };
- }, [close]);
-
- const chrome = (end: ReactNode) => (
-
-
-
-
- {end}
-
-
-
- );
- const poster = (
-
- {/* eslint-disable-next-line @next/next/no-img-element -- a real capture of the lab, shown as is */}
-
-
- );
-
- if (!lift || narrow) {
- // A phone, or a screen too small for both: the lab in a tab of its own.
- return (
-
- );
- }
-
return (
);
}
diff --git a/web/src/components/guide/copy/community.ts b/web/src/components/guide/copy/community.ts
index 52842c99..194f4775 100644
--- a/web/src/components/guide/copy/community.ts
+++ b/web/src/components/guide/copy/community.ts
@@ -6,17 +6,24 @@ import type { GuideLang } from "../store";
// must be the same length. The chapter's name on the rail and in the
// opening's list is copy/make.ts's opening.chapters[0].
//
+// Written for doing: each card says what to do and why. Where the desk is up
+// the reader does it in the practice community beside the card (a replica of
+// the real UI, desk/community/), with the pointer showing the way
+// (tutorials/community.ts); elsewhere the card shows the real site's screens
+// (CommunityShots.tsx). So the words hold for both: they are about the real
+// community, which the practice copies.
+//
// Checked against the community's code (web/src/components/community/**,
-// web/src/app/community/**, web/src/lib/community/**) and the live site; the
-// screens each step shows, with the UI's own words, are CommunityShots.tsx.
+// web/src/app/community/**, web/src/lib/community/**) and the live site.
// Button names stay in English in both languages — they are the UI's words.
//
-// 0 the wall hover to play, the wheel turns knobs, search, sorts
-// 1 .h what can go on a board: .h, Flashable now, ports
+// 0 the wall hover to play, the wheel turns a knob (K1–K4 across)
+// 1 .h what can go on a board: .h, Flashable now
// 2 one pattern ↗ Send to my Patternflow → Send over Wi-Fi (no account)
-// 3 your deck the bar at the bottom: +, order, ✕, Send to my board
-// (Send over Wi-Fi drops the order up to firmware v3.10.4;
-// the next firmware keeps it; ↓ .zip always has)
+// 3 your deck the bar at the bottom: +, drop a card, order, ✕,
+// Send to my board → Send over Wi-Fi (up to firmware
+// v3.10.4 that drops the order; the next firmware keeps
+// it; ↓ .zip always has)
// 4 other decks the Decks page: Install to my board, Copy into my deck
// 5 open in the Lab a copy, marked as a fork — which is 06
@@ -29,8 +36,8 @@ const en: SceneCopy = {
kicker: "The wall",
title: "Every pattern, playing.",
body: [
- "community.patternflow.work is a wall of everything people have shared. Hover a card and it plays; roll the wheel over its screen and its knobs turn. Looking needs no account.",
- "On a phone the cards are stills. Tap one to open its page, where it plays.",
+ "community.patternflow.work hangs everything people have shared. Put the mouse on a card and it plays; roll the wheel over its screen and a knob turns. The screen is split K1 to K4, left to right.",
+ "Looking needs no account. On a phone the cards are stills: tap one to open its page, where it plays.",
],
extra: "communityShots",
},
@@ -38,8 +45,8 @@ const en: SceneCopy = {
kicker: ".h",
title: "Only a .h goes on a board.",
body: [
- "Every pattern here is JavaScript — that's what plays in your browser. The board runs C++, so a pattern can go on it only once it has a .h, its firmware header.",
- "Those carry a .h on the screen. Flashable now shows only them.",
+ "Every pattern here is JavaScript: that's what plays in your browser. The board runs C++, so a pattern goes on it only once it has a .h, its firmware header.",
+ "Those carry a .h on the screen. Press Flashable now and only they stay.",
],
note: "A pattern with only JS doesn't have to wait for its author. Anyone can make the .h — in Pattern Lab — and propose it on the pattern's own page. I don't think anyone has yet.",
extra: "communityShots",
@@ -48,7 +55,7 @@ const en: SceneCopy = {
kicker: "One pattern",
title: "Send one to your board.",
body: [
- "Open a pattern with a .h and press Send to my Patternflow, then Send over Wi-Fi. Your board's own Patterns page opens with the pattern linked, fetches it and installs it. No account, no cable.",
+ "Open a pattern with a .h and press ↗ Send to my Patternflow, then Send over Wi-Fi. Your board's own Patterns page opens with the pattern linked, fetches it and installs it. No account, no cable.",
"It's in the list straight away. Hold K4 to find it.",
],
warn: "On Android, type the IP from K2 into Board address in the same window first.",
@@ -58,8 +65,8 @@ const en: SceneCopy = {
kicker: "Your deck",
title: "Make a deck of your own.",
body: [
- "A deck is a running order. Press + on a card with a .h, or drag the card down onto the bar at the bottom: up to 20, kept in this browser. On a phone, the ▦ chip at the top holds it.",
- "Drag a slot to move it, or onto ✕ to take it out. Send to my board builds the deck (you need an account), then Send over Wi-Fi puts it on your board.",
+ "A deck is a running order: up to 20, kept in this browser. Press + on a card with a .h, or drag the card down onto the bar at the bottom. Drag a slot to move it; drop it on ✕ to take it out.",
+ "Send to my board builds the deck (you need an account), then Send over Wi-Fi puts it on your board. On a phone, the ▦ chip at the top holds your deck.",
],
warn: "Up to firmware v3.10.4, Send over Wi-Fi drops this order; the next firmware keeps it. Until your board has it, drop the ↓ .zip on the board's Patterns page instead.",
extra: "communityShots",
@@ -68,7 +75,7 @@ const en: SceneCopy = {
kicker: "Other decks",
title: "Or take someone else's.",
body: [
- "The Decks page is where people put up the sets they stand behind. Basics from 03 is there too, under Ships with Patternflow, with Install to my board right on its card. Open a community deck and press Install to my board: no account, and the order comes with it.",
+ "The Decks page holds the sets people stand behind. Basics from 03 is there, under Ships with Patternflow. Open a community deck and press Install to my board: no account, and the order comes with it.",
"Copy into my deck puts it in your bar instead, to change before you send it.",
],
extra: "communityShots",
@@ -78,7 +85,7 @@ const en: SceneCopy = {
title: "Open it in the Lab.",
body: [
"Every pattern's page has Open in Pattern Lab, .h or not. A copy opens in the Lab; share it from there and it goes up as your fork, linked to the original.",
- "That Lab is the one at community.patternflow.work, with drafts of its own: whatever was open there waits under Recent ▾.",
+ "That Lab is the one at community.patternflow.work, with drafts of its own: whatever was open there waits under Recent ▾. Making one of your own is next.",
],
note: "Open someone's pattern and change it, and it's a fork. I made the Lab mark that by itself, so now and then it marks one that isn't.",
extra: "communityShots",
@@ -95,8 +102,8 @@ const ko: SceneCopy = {
kicker: "벽",
title: "모든 패턴이 돌아가요.",
body: [
- "community.patternflow.work는 사람들이 나눈 패턴이 전부 걸린 벽이에요. 카드에 마우스를 올리면 재생되고, 화면 위에서 휠을 굴리면 노브가 돌아가요. 구경하는 데는 계정이 필요 없어요.",
- "폰에선 카드가 멈춘 그림이에요. 눌러서 들어가면 움직여요.",
+ "community.patternflow.work엔 사람들이 나눈 패턴이 전부 걸려 있어요. 카드에 마우스를 올리면 재생되고, 화면 위에서 휠을 굴리면 노브가 돌아가요. 화면은 왼쪽부터 K1–K4로 나뉘어 있어요.",
+ "구경하는 데는 계정이 필요 없어요. 폰에선 카드가 멈춘 그림이라, 눌러서 들어가야 움직여요.",
],
extra: "communityShots",
},
@@ -104,8 +111,8 @@ const ko: SceneCopy = {
kicker: ".h",
title: ".h가 있어야 보드에 올라가요.",
body: [
- "여기 패턴은 전부 자바스크립트로 돼 있어요. 브라우저에서 돌아가는 게 그거예요. 보드는 C++로 돌아가서, 펌웨어 헤더인 .h가 있는 패턴만 올릴 수 있어요.",
- "그런 패턴은 화면 구석에 .h가 붙어 있어요. Flashable now를 누르면 그것만 모아 보여줘요.",
+ "여기 패턴은 전부 자바스크립트예요. 브라우저에서 돌아가는 게 그거예요. 보드는 C++로 돌아가서, 펌웨어 헤더인 .h가 있어야 올릴 수 있어요.",
+ "그런 패턴은 화면 구석에 .h가 붙어 있어요. Flashable now를 누르면 그것만 남아요.",
],
note: "JS만 있는 패턴이라고 주인이 .h를 붙여 줄 때까지 기다릴 필요는 없다. 누구든 패턴 랩에서 직접 만들어 그 패턴 페이지에 제안하면 된다. 아직 해 본 사람은 없는 것 같지만.",
extra: "communityShots",
@@ -114,7 +121,7 @@ const ko: SceneCopy = {
kicker: "패턴 하나",
title: "하나만 보드로 보내요.",
body: [
- ".h가 있는 패턴을 열고 Send to my Patternflow, 이어서 Send over Wi-Fi를 눌러요. 보드의 Patterns 페이지가 열리면서 그 패턴을 알아서 받아 설치해요. 계정도 케이블도 필요 없어요.",
+ ".h가 있는 패턴을 열고 ↗ Send to my Patternflow, 이어서 Send over Wi-Fi를 눌러요. 보드의 Patterns 페이지가 열리면서 그 패턴을 알아서 받아 설치해요. 계정도 케이블도 필요 없어요.",
"바로 목록에 들어가요. K4를 꾹 눌러 찾아보세요.",
],
warn: "안드로이드라면 같은 창의 Board address에 K2로 본 IP 주소를 먼저 넣어요.",
@@ -124,8 +131,8 @@ const ko: SceneCopy = {
kicker: "내 덱",
title: "내 덱을 만들어요.",
body: [
- "덱은 재생 순서예요. .h가 있는 카드의 +를 누르거나, 카드를 아래 바로 끌어다 놓아요. 20개까지, 이 브라우저에 담겨요. 폰에선 위쪽의 ▦ 칩이 그 바 역할이에요.",
- "칸을 끌면 자리가 바뀌고, ✕ 위에 놓으면 빠져요. Send to my board가 덱을 빌드하고(계정이 필요해요), 이어서 Send over Wi-Fi가 보드로 넘겨요.",
+ "덱은 재생 순서예요. 20개까지, 이 브라우저에 담겨요. .h가 있는 카드의 +를 누르거나, 카드를 아래쪽 덱 바에 끌어다 놓아요. 칸을 끌면 자리가 바뀌고, ✕ 위에 놓으면 빠져요.",
+ "Send to my board가 덱을 빌드하고(계정이 필요해요), 이어서 Send over Wi-Fi가 보드로 넘겨요. 폰에선 위쪽의 ▦ 칩에 덱이 있어요.",
],
warn: "펌웨어 v3.10.4까지는 Send over Wi-Fi로 보내면 이 순서가 빠져요. 다음 펌웨어부터는 따라가요. 그전까진 ↓ .zip을 보드의 Patterns 페이지에 올리면 순서까지 들어가요.",
extra: "communityShots",
@@ -134,7 +141,7 @@ const ko: SceneCopy = {
kicker: "남의 덱",
title: "남의 덱을 통째로 가져와요.",
body: [
- "Decks 페이지엔 사람들이 자신 있게 내건 덱이 있어요. 03에서 올린 Basics도 Ships with Patternflow 아래에 있고, 카드에 Install to my board가 바로 있어요. 커뮤니티 덱은 열어서 Install to my board를 누르면 계정 없이, 순서까지 그대로 들어가요.",
+ "Decks 페이지엔 사람들이 자신 있게 내건 덱이 있어요. 03에서 올린 Basics도 Ships with Patternflow 아래에 있어요. 커뮤니티 덱을 열어 Install to my board를 누르면 계정 없이, 순서까지 그대로 들어가요.",
"Copy into my deck을 누르면 아래쪽 바의 내 덱으로 가져와요. 고친 다음 보내면 돼요.",
],
extra: "communityShots",
@@ -144,7 +151,7 @@ const ko: SceneCopy = {
title: "랩에서 열어요.",
body: [
"패턴 페이지마다 Open in Pattern Lab이 있어요. .h가 없어도요. 복사본이 랩에서 열리고, 거기서 공유하면 원본과 연결된 내 포크로 올라가요.",
- "이때 열리는 건 community.patternflow.work의 랩이에요. 초안도 따로라서, 거기서 하던 건 Recent ▾에 남아요.",
+ "이때 열리는 건 community.patternflow.work의 랩이에요. 초안도 따로라서, 거기서 하던 건 Recent ▾에 남아요. 내 패턴 만들기는 바로 다음이에요.",
],
note: "남의 패턴을 열어 고치면 포크가 된다. 표시는 랩이 알아서 붙이게 해 두었는데, 그래서 가끔 포크가 아닌 데도 붙는다.",
extra: "communityShots",
diff --git a/web/src/components/guide/copy/lab.ts b/web/src/components/guide/copy/lab.ts
index ef97b125..6b695228 100644
--- a/web/src/components/guide/copy/lab.ts
+++ b/web/src/components/guide/copy/lab.ts
@@ -3,22 +3,30 @@ import type { GuideLang } from "../store";
// 06 Pattern Lab — every word of the chapter, in both languages. Layers,
// Graphic Export and Director are steps in here, not chapters. Step N here
-// is shown while step N of scenes/lab.ts is on stage; the two lists must be
-// the same length. The chapter's name on the rail and in the opening's list
-// is copy/make.ts's opening.chapters[1].
+// is shown while step N of scenes/lab.ts is on the desk, and the pointer
+// plays step N of tutorials/lab.ts; the lists must be the same length. The
+// chapter's name on the rail and in the opening's list is copy/make.ts's
+// opening.chapters[1].
//
// Step 0's kicker, title and extra are fixed ("Open the Lab.", labWindow).
//
+// A card says what to do and what to watch for. On a screen big enough for
+// the desk (desk/query.ts — a matter of window size, not of computer or
+// phone) the reader does it in their own, real Lab beside the card (with a
+// practice AI that gives set answers standing in for theirs); below that the
+// same card shows the Lab's screens instead (LabShots.tsx), so a card never
+// says "the window on the left", and says "on a big enough screen", never
+// "on a computer".
+//
// Every claim is the code's: web/src/app/pattern-lab/** (PatternLabClient,
// HardwareModal, panels/*), web/src/lib/lab/**, and for Apply, publishing
// and the pattern page web/src/components/community/** (SendModuleModal,
-// PublishModal, AddHeaderModal) and app/community/p/[id]. UI words are quoted
-// as the UI says them, in English in both languages. The screens, and the
-// detail that belongs beside a screen, are LabShots.tsx: a card here says the
-// step, its screens show where. The "From the maker" notes only restate what
-// the maker wrote in web/content/journal/how-to-use-part-two.mdx.
+// PublishModal) and app/community/p/[id]. UI words are quoted as the UI says
+// them, in English in both languages. The "From the maker" notes only
+// restate what the maker wrote in web/content/journal/how-to-use-part-two.mdx.
//
-// Cards with screens stay short: at 1440×900 the whole card has to fit.
+// Cards stay short: beside the desk the text is a column about 330–410 px
+// wide, and at 1280×800 the whole card has to fit.
const en: SceneCopy = {
num: "06",
@@ -30,7 +38,7 @@ const en: SceneCopy = {
title: "Open the Lab.",
body: [
"Pattern Lab is at patternflow.work/pattern-lab. It runs in the browser: nothing to install, and no account to start making. The preview is on the left, the code in the middle, the knobs and the Color Ramp on the right.",
- "The board on this page now plays your own draft from this browser's Lab, and changes each time the Lab saves. It only reads it; nothing is written back. With nothing saved, it plays the pattern the Lab opens with, Code 1. Drafts in the community site's Lab don't show here.",
+ "On a big enough screen it's open right here, beside these cards. It's your real Lab, with what this browser has saved, and what you make in it stays. The pointer shows the way; the clicks are yours.",
],
extra: "labWindow",
},
@@ -38,8 +46,8 @@ const en: SceneCopy = {
kicker: "The name",
title: "Name it first.",
body: [
- "Type a name where it says name this pattern. It's the name your board will list it under; left blank, the board gets a layer's name, like Code 1.",
- "Your work saves as you go, in this browser only.",
+ "Type a name where it says name this pattern. It's the name your board lists it under; left blank, the board gets a layer's name, like Code 1.",
+ "Your work saves as you go, in this browser only. Under the name it says Saved locally.",
],
extra: "labShots",
},
@@ -47,9 +55,10 @@ const en: SceneCopy = {
kicker: "Ask an AI",
title: "Get a pattern from an AI.",
body: [
- "Press Copy prompt and paste it into ChatGPT, Claude or Gemini. It comes back with five variations: copy the one you like, press Paste.",
- "Or let the Gallery ask Gemini for you, with your own free key from Google AI Studio.",
+ "Press Copy prompt, paste it into an AI and send it. Copy the code it writes, then press Paste in the Lab: the preview changes to the new pattern.",
+ "For real, that's your own AI, ChatGPT, Claude, Gemini or another, and it comes back with five variations to choose from. On a big enough screen, a practice AI with set answers stands in for it here.",
],
+ warn: "Paste reads the clipboard, so the browser may ask first. Allow it, or paste into the box the Lab opens instead and press OK.",
extra: "labShots",
},
{
@@ -57,7 +66,7 @@ const en: SceneCopy = {
title: "Black and white is right.",
body: [
"The prompt asks for a v-field: the code gives each pixel a value from 0 to 1, and the layer's Color Ramp colours it. The ramp starts black to white.",
- "Press Random ramp, or click the bar and pick colours. The ramp goes to the board with the pattern.",
+ "Press Random ramp and the preview takes its colours; press it again for others, or click the bar to add a stop. The ramp goes to the board with the pattern.",
],
extra: "labShots",
},
@@ -65,8 +74,8 @@ const en: SceneCopy = {
kicker: "Knobs",
title: "Give the knobs a range.",
body: [
- "The four sliders are your board's knobs, K1 first, shared by every layer. Their names and ranges come from a line in the code: // @knobs Glitch=0..1, Speed=0.05..5, …",
- "Widen a range and the knob does more — on the board too.",
+ "The four sliders are your board's knobs, K1 at the top, shared by every layer. Drag one and watch the preview.",
+ "The boxes either side are its range: double-click one and type a number. Names and ranges start from a line in the code that begins // @knobs. Widen a range and the knob does more, on the board too.",
],
extra: "labShots",
},
@@ -74,8 +83,8 @@ const en: SceneCopy = {
kicker: "To hardware",
title: "One more prompt, for the board.",
body: [
- "The board runs C++, so the pattern goes to it as a .h header. Press To hardware, then Copy the conversion prompt; paste it into your AI, and its answer back into the Lab.",
- "When it starts with #pragma once, press Next →.",
+ "The board runs C++, so the pattern goes to it as a .h header, and an AI writes that too. Press To hardware, then Copy the conversion prompt, and send it to the AI.",
+ "Copy the .h it writes. It goes into the Lab next.",
],
extra: "labShots",
},
@@ -83,18 +92,18 @@ const en: SceneCopy = {
kicker: "Apply",
title: "Onto your board.",
body: [
- "↗ Apply to my Patternflow builds a module on the community's server; you need to be signed in. Send over Wi-Fi then opens your board's Patterns page, which installs it.",
- "Hold K4: it's in the list, under its name.",
+ "Paste the .h into the box under Copy the conversion prompt. Looks like a header ✓ means it starts with #pragma once; press Next →.",
+ "↗ Apply to my Patternflow builds a module on the community's server, so you need to be signed in. Send over Wi-Fi then opens your board's Patterns page, which installs it. Hold K4: it's in the list, under its name.",
],
- warn: "Your computer has to be on the same Wi-Fi as the board. On Android, put the IP from K2 into Device address first.",
+ warn: "Your computer has to be on the same Wi-Fi as the board. On Android, put the IP from the board's NETWORK screen (hold K2) into Device address first.",
extra: "labShots",
},
{
kicker: "Layers",
title: "Stack them.",
body: [
- "+ Code adds another pattern with its own ramp, + Pixel a layer for pixel art or an image. A layer set to masks layer below cuts out the one beneath it.",
- "For the board, each code layer gets its own prompt.",
+ "+ Code adds another pattern on top, with its own ramp; + Pixel, a layer for pixel art or an image. Tick masks layer below, and the new layer cuts out the one beneath it.",
+ "A layer that paints has Opacity and Blend instead. For the board, each code layer gets its own prompt.",
],
note: "I made layers for pixel art. Code is bad at drawing one particular picture, and I thought a picture with a few things moving on it would be fun enough. With masks, it's a bit like compositing.",
extra: "labShots",
@@ -103,7 +112,7 @@ const en: SceneCopy = {
kicker: "Graphic Export",
title: "Pictures and video.",
body: [
- "Graphic Export saves your pattern as a PNG, or records it as MP4 or WebM, up to 4096 px a side. It all happens in your browser.",
+ "In the Graphic Export tab, pick a size and press Save PNG: your pattern as a picture, up to 4096 px a side. ● Record makes an MP4 or WebM. It all happens in your browser.",
],
note: "The pictures on my business card came out of Graphic Export, and so did a high-res entry to a media-art competition. It didn't get in.",
extra: "labShots",
@@ -112,7 +121,8 @@ const en: SceneCopy = {
kicker: "Director",
title: "A timeline for the knobs.",
body: [
- "The Director keyframes the knobs over time. .pfs plays on a board with the Performance edition, .mid goes to Ableton, Render… makes a video.",
+ "In the Director tab, double-click the lane in two places, a little apart: two keyframes and a curve between them. Press ▶ and the knob follows it.",
+ ".pfs plays on a board with the Performance edition, .mid goes to Ableton, Render… makes a video.",
],
note: "It sets knob moves up ahead of time. With Graphic Export you could bake them to a piece of sound and play the video out beside it, but that isn't live, so it isn't for me. Where it really pays off is the Performance edition.",
extra: "labShots",
@@ -121,8 +131,8 @@ const en: SceneCopy = {
kicker: "The community",
title: "Give it back.",
body: [
- "Upload to the community publishes it with its .h, ready for anyone's board; Share, at the top, without one. You sign in when you publish.",
- "Opened someone's pattern? Yours goes up as its fork. And a JS-only pattern from 05 can get its .h the same way: make it here, propose it on its page.",
+ "Upload to the community, after Next → in To hardware, publishes it with its .h, ready for anyone's board; Share, at the top, without one. You sign in when you publish.",
+ "Opened someone's pattern? Yours goes up as its fork. And a JS-only pattern from 05 can get its .h the same way: make it here, then propose it on its page with Port this pattern (.h).",
],
note: "A Director show can go on someone's pattern the same way, as a performance. I should make one and put it up too; I keep putting it off.",
extra: "labShots",
@@ -140,7 +150,7 @@ const ko: SceneCopy = {
title: "랩을 열어요.",
body: [
"패턴 랩은 patternflow.work/pattern-lab에 있어요. 브라우저에서 돌아가서 설치할 게 없고, 만들기 시작하는 데 계정도 필요 없어요. 왼쪽은 미리보기, 가운데는 코드, 오른쪽엔 노브와 Color Ramp가 있어요.",
- "이제 이 페이지의 보드는 이 브라우저 랩에 있는 내 초안을 틀어요. 랩이 저장할 때마다 따라 바뀌고, 읽기만 할 뿐 아무것도 저장하지 않아요. 저장한 게 없으면 랩이 처음 여는 패턴, Code 1이 나와요. 커뮤니티 사이트 쪽 랩의 초안은 여기 안 보여요.",
+ "화면이 넉넉하면 이 카드 옆에 바로 열려 있어요. 이 브라우저에 저장된 것까지 그대로인 진짜 내 랩이라, 여기서 만든 건 남아요. 포인터가 길을 알려 주고, 누르는 건 직접 해요.",
],
extra: "labWindow",
},
@@ -149,7 +159,7 @@ const ko: SceneCopy = {
title: "이름부터 지어요.",
body: [
"name this pattern 자리에 이름을 적어요. 보드 목록에 뜨는 이름이 이거예요. 비워 두면 Code 1 같은 레이어 이름이 대신 보드로 가요.",
- "작업은 하는 대로 저장되고, 이 브라우저에만 남아요.",
+ "작업은 하는 대로 이 브라우저에만 저장돼요. 이름 아래에 Saved locally라고 떠요.",
],
extra: "labShots",
},
@@ -157,9 +167,10 @@ const ko: SceneCopy = {
kicker: "AI에게",
title: "AI에게 패턴을 받아요.",
body: [
- "Copy prompt를 눌러 ChatGPT, Claude, Gemini 중 하나에 붙여넣으면 변형 다섯 개가 와요. 마음에 드는 걸 복사해서 Paste를 눌러요.",
- "Gallery가 대신 Gemini에게 물어보게 할 수도 있어요. 구글 AI 스튜디오에서 무료로 받는 내 키가 필요해요.",
+ "Copy prompt를 누르고, AI에게 붙여넣어 보내요. AI가 써 준 코드를 복사한 다음 랩에서 Paste를 누르면, 미리보기가 새 패턴으로 바뀌어요.",
+ "실제로는 ChatGPT, Claude, Gemini 같은 내 AI에게 보내고, 고를 수 있게 변형 다섯 개가 돌아와요. 화면이 넉넉하면 여기선 정해진 답만 하는 연습용 AI가 대신해요.",
],
+ warn: "Paste는 클립보드를 읽어서, 브라우저가 먼저 허락을 물을 수 있어요. 허용하거나, 대신 랩이 여는 상자에 붙여넣고 확인을 눌러요.",
extra: "labShots",
},
{
@@ -167,7 +178,7 @@ const ko: SceneCopy = {
title: "흑백이 맞아요.",
body: [
"프롬프트는 v-field를 달라고 해요. 코드는 픽셀마다 0에서 1 사이 값만 내고, 색은 그 레이어의 Color Ramp가 입혀요. 램프는 검정에서 흰색으로 시작해요.",
- "Random ramp를 누르거나, 바를 클릭해 색을 골라요. 램프는 패턴과 함께 보드로 가요.",
+ "Random ramp를 누르면 미리보기에 그 색이 입혀져요. 또 누르면 다른 색이 나오고, 바를 클릭하면 지점이 생겨요. 램프는 패턴과 함께 보드로 가요.",
],
extra: "labShots",
},
@@ -175,8 +186,8 @@ const ko: SceneCopy = {
kicker: "노브",
title: "노브의 범위를 정해요.",
body: [
- "슬라이더 네 개가 보드의 노브예요. 첫 번째가 K1이고, 모든 레이어가 같이 써요. 이름과 범위는 코드의 한 줄에서 와요. // @knobs Glitch=0..1, Speed=0.05..5, …",
- "범위를 넓히면 노브가 더 크게 움직여요. 보드에서도요.",
+ "슬라이더 네 개가 보드의 노브예요. 맨 위가 K1이고, 모든 레이어가 같이 써요. 하나를 끌면서 미리보기를 봐요.",
+ "양옆 칸이 범위예요. 두 번 클릭하고 숫자를 적어요. 이름과 범위는 코드에서 // @knobs로 시작하는 줄에서 와요. 범위를 넓히면 노브가 더 크게 움직여요. 보드에서도요.",
],
extra: "labShots",
},
@@ -184,8 +195,8 @@ const ko: SceneCopy = {
kicker: "To hardware",
title: "보드용 프롬프트가 하나 더 있어요.",
body: [
- "보드는 C++로 돌아가서, 패턴은 .h 헤더가 되어야 보드로 가요. To hardware를 누르고 Copy the conversion prompt로 복사해 AI에게 붙여넣은 다음, AI의 답을 랩에 다시 붙여넣어요.",
- "#pragma once로 시작하면 Next →를 눌러요.",
+ "보드는 C++로 돌아가서, 패턴은 .h 헤더가 되어야 보드로 가요. 이것도 AI가 써요. To hardware를 누르고 Copy the conversion prompt로 복사해서 AI에게 보내요.",
+ "AI가 써 준 .h를 복사해요. 다음 단계에서 랩에 넣어요.",
],
extra: "labShots",
},
@@ -193,18 +204,18 @@ const ko: SceneCopy = {
kicker: "Apply",
title: "내 보드에 올려요.",
body: [
- "↗ Apply to my Patternflow를 누르면 커뮤니티 서버가 모듈을 만들어요. 로그인이 필요해요. 이어서 Send over Wi-Fi를 누르면 보드의 Patterns 페이지가 열리고 알아서 설치해요.",
- "K4를 꾹 누르면 목록에 그 이름으로 있어요.",
+ ".h를 Copy the conversion prompt 아래 상자에 붙여넣어요. Looks like a header ✓가 뜨면 #pragma once로 잘 시작한다는 뜻이에요. Next →를 눌러요.",
+ "↗ Apply to my Patternflow를 누르면 커뮤니티 서버가 모듈을 만들어요. 그래서 로그인이 필요해요. 이어서 Send over Wi-Fi를 누르면 보드의 Patterns 페이지가 열리고 알아서 설치해요. K4를 꾹 누르면 목록에 그 이름으로 있어요.",
],
- warn: "컴퓨터가 보드와 같은 와이파이에 있어야 해요. 안드로이드라면 K2의 IP를 Device address에 먼저 넣어요.",
+ warn: "컴퓨터가 보드와 같은 와이파이에 있어야 해요. 안드로이드라면 보드의 NETWORK 화면(K2를 꾹)에 뜨는 IP를 Device address에 먼저 넣어요.",
extra: "labShots",
},
{
kicker: "레이어",
title: "겹쳐요.",
body: [
- "+ Code는 램프를 따로 가진 패턴을 하나 더, + Pixel은 픽셀아트나 이미지를 올릴 레이어를 더해요. masks layer below를 켠 레이어는 바로 아래 레이어를 오려 내요.",
- "보드로 보낼 땐 코드 레이어마다 프롬프트가 하나씩이에요.",
+ "+ Code는 램프를 따로 가진 패턴을 위에 하나 더 얹고, + Pixel은 픽셀아트나 이미지를 올릴 레이어를 더해요. masks layer below에 체크하면 새 레이어가 바로 아래 레이어를 오려 내요.",
+ "그리는 레이어라면 그 자리에 Opacity와 Blend가 있어요. 보드로 보낼 땐 코드 레이어마다 프롬프트가 하나씩이에요.",
],
note: "레이어는 픽셀아트를 넣고 싶어서 만들었다. 코드만으로 구체적인 그림을 그리기는 어렵고, 그림 위로 몇 가지만 움직여도 꽤 재밌겠다 싶었다. 마스크까지 쓰면 컴포지팅하는 기분도 난다.",
extra: "labShots",
@@ -213,7 +224,7 @@ const ko: SceneCopy = {
kicker: "Graphic Export",
title: "그림과 영상.",
body: [
- "Graphic Export는 내 패턴을 PNG로 저장하거나 MP4·WebM으로 녹화해요. 한 변 4096px까지 되고, 전부 내 브라우저 안에서 해요.",
+ "Graphic Export 탭에서 크기를 고르고 Save PNG를 누르면 내 패턴이 그림이 돼요. 한 변 4096px까지 되고, ● Record는 MP4나 WebM으로 녹화해요. 전부 내 브라우저 안에서 해요.",
],
note: "내 명함에 들어간 그림을 Graphic Export로 만들었다. 고해상도로 뽑아 미디어아트 공모전에도 냈는데, 결과는 탈락.",
extra: "labShots",
@@ -222,7 +233,8 @@ const ko: SceneCopy = {
kicker: "Director",
title: "노브의 타임라인.",
body: [
- "Director는 노브에 시간에 따른 키프레임을 찍어요. .pfs는 퍼포먼스 에디션 보드에서, .mid는 에이블톤에서 쓰고, Render…는 영상으로 뽑아요.",
+ "Director 탭에서 줄의 조금 떨어진 두 곳을 두 번씩 클릭해요. 키프레임 두 개와 그 사이 곡선이 생겨요. ▶를 누르면 노브가 그 곡선을 따라 움직여요.",
+ ".pfs는 퍼포먼스 에디션 보드에서, .mid는 에이블톤에서 쓰고, Render…는 영상으로 뽑아요.",
],
note: "노브 움직임을 미리 짜 두는 기능이다. Graphic Export랑 엮으면 어떤 소리에 맞춰 구운 영상을 같이 틀 수도 있겠지만, 라이브가 아니라서 나랑은 안 맞는다. 제일 빛을 보는 건 퍼포먼스 에디션에서다.",
extra: "labShots",
@@ -231,8 +243,8 @@ const ko: SceneCopy = {
kicker: "커뮤니티",
title: "나눠요.",
body: [
- "Upload to the community는 .h까지 함께 올려서, 누구든 바로 자기 보드에 넣을 수 있어요. 맨 위 Share는 .h 없이 올려요. 로그인은 올릴 때 해요.",
- "남의 패턴에서 열었다면 그 패턴의 포크로 올라가요. 05에서 본 JS만 있는 패턴도 같은 식이에요. 여기서 .h를 만들어 그 패턴 페이지에 제안해요.",
+ "To hardware에서 Next →를 누르면 Upload to the community가 있어요. .h까지 함께 올려서, 누구든 바로 자기 보드에 넣을 수 있어요. 맨 위 Share는 .h 없이 올려요. 로그인은 올릴 때 해요.",
+ "남의 패턴에서 열었다면 그 패턴의 포크로 올라가요. 05에서 본 JS만 있는 패턴도 같은 식이에요. 여기서 .h를 만들어 그 패턴 페이지의 Port this pattern (.h)로 제안해요.",
],
note: "디렉터로 만든 쇼도 같은 식으로 남의 패턴에 퍼포먼스로 붙일 수 있다. 나도 하나 만들어 올려야 하는데, 계속 미루는 중이다.",
extra: "labShots",
diff --git a/web/src/components/guide/copy/make.ts b/web/src/components/guide/copy/make.ts
index 2744f645..641b1354 100644
--- a/web/src/components/guide/copy/make.ts
+++ b/web/src/components/guide/copy/make.ts
@@ -16,7 +16,7 @@ const en: MakeCopy = {
meta: {
title: "Make your own Patternflow patterns",
description:
- "The second part of the Patternflow guide: find patterns and decks in the community, then make your own in Pattern Lab — layers, Graphic Export and Director — shown on a Patternflow you can turn.",
+ "The second part of the Patternflow guide: find patterns and decks in the community, then make your own in Pattern Lab (layers, Graphic Export and Director), hands-on in your own Lab beside the steps, with a practice community and a practice AI.",
},
opening: {
kicker: "The guide, part two",
@@ -43,7 +43,7 @@ const ko: MakeCopy = {
meta: {
title: "패턴플로우 내 패턴 만들기",
description:
- "패턴플로우 가이드 두 번째. 커뮤니티에서 패턴과 덱을 찾고, 패턴 랩에서 직접 만들어요. 레이어, 그래픽 익스포트, 디렉터까지 직접 돌려볼 수 있는 패턴플로우로 보여줍니다.",
+ "패턴플로우 가이드 두 번째. 커뮤니티에서 패턴과 덱을 찾고, 패턴 랩에서 직접 만들어요. 레이어, 그래픽 익스포트, 디렉터까지 내 랩과 연습용 커뮤니티, 연습용 AI로 직접 해 봐요.",
},
opening: {
kicker: "사용법, 두 번째",
diff --git a/web/src/components/guide/desk/Desk.module.css b/web/src/components/guide/desk/Desk.module.css
new file mode 100644
index 00000000..0363e638
--- /dev/null
+++ b/web/src/components/guide/desk/Desk.module.css
@@ -0,0 +1,603 @@
+/* The make page's desk (DeskStage.tsx): app windows in the space left of the
+ story, and the guiding pointer over them. Browser chrome in the guide's
+ colours, as ConsoleWindow draws it; the windows' own content brings its
+ own look (the real Pattern Lab, the practice apps).
+
+ The desk's query — (min-width: 1340px) and (min-height: 640px),
+ (min-width: 1280px) and (min-height: 660px), (min-width: 1180px) and
+ (min-height: 700px) — is written out here, in
+ Guide.module.css (with its opposite) and in query.ts: change them all
+ together. --desk-col (the story's column) is set on the page by
+ Guide.module.css under the same query. */
+
+.desk {
+ --d-ink: #efe9dd;
+ --d-ink-2: rgba(239, 233, 221, 0.72);
+ --d-ink-3: rgba(239, 233, 221, 0.46);
+ --d-hair: rgba(239, 233, 221, 0.1);
+ --d-hair-2: rgba(239, 233, 221, 0.18);
+ --d-led: #ff6a3d;
+ --d-chrome: #1b1a18;
+ --d-chrome-2: #131210;
+ --d-field: #0f0e0d;
+ /* The Lab's own ground, so its window is the Lab's colour before it paints. */
+ --d-lab: #f4efe6;
+ --d-shadow: 0 26px 70px rgba(0, 0, 0, 0.55), 0 2px 10px rgba(0, 0, 0, 0.4);
+ --d-ease: cubic-bezier(0.2, 0.7, 0.2, 1);
+
+ position: fixed;
+ z-index: 1;
+ left: 20px;
+ top: 72px;
+ bottom: 20px;
+ right: calc(var(--desk-col, 380px) + 56px);
+}
+
+/* ── a window ────────────────────────────────────────────────────────────── */
+
+.win {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border: 1px solid var(--d-hair-2);
+ border-radius: 12px;
+ background: var(--d-chrome);
+ box-shadow: var(--d-shadow);
+ transition:
+ transform 0.62s var(--d-ease),
+ opacity 0.45s ease,
+ border-color 0.3s,
+ visibility 0s linear 0s;
+}
+.win[data-role="away"] {
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ transition:
+ transform 0.62s var(--d-ease),
+ opacity 0.4s ease,
+ border-color 0.3s,
+ visibility 0s linear 0.45s;
+}
+.win[data-win="ai"] {
+ inset: auto 18px 18px auto;
+ border-color: rgba(239, 233, 221, 0.24);
+ box-shadow:
+ 0 30px 80px rgba(0, 0, 0, 0.6),
+ 0 2px 10px rgba(0, 0, 0, 0.45);
+}
+.win[data-win="ai"][data-role="away"] {
+ transform: translate3d(0, 18px, 0) scale(0.97);
+}
+
+.bar {
+ display: flex;
+ flex: none;
+ align-items: center;
+ gap: 10px;
+ height: 36px;
+ padding: 0 10px 0 12px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.5);
+ background: var(--d-chrome-2);
+ font-family: var(--pf-sans, system-ui, sans-serif);
+}
+.lights {
+ display: flex;
+ flex: none;
+ gap: 5px;
+}
+.lights i {
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ background: rgba(239, 233, 221, 0.16);
+}
+.tab {
+ display: flex;
+ flex: 0 1 auto;
+ align-items: center;
+ gap: 7px;
+ min-width: 0;
+ max-width: 220px;
+ font-size: 11.5px;
+ color: var(--d-ink-2);
+}
+.favicon {
+ flex: none;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #ff5c2e;
+ box-shadow: 0 0 5px rgba(255, 92, 46, 0.7);
+}
+.tabTitle {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+.address {
+ display: flex;
+ flex: 1;
+ align-items: center;
+ gap: 7px;
+ min-width: 0;
+ height: 23px;
+ padding: 0 11px;
+ border-radius: 999px;
+ background: var(--d-field);
+ font-family: var(--pf-mono, ui-monospace, monospace);
+ font-size: 11px;
+ letter-spacing: 0.01em;
+ color: var(--d-ink);
+}
+.lock {
+ flex: none;
+ width: 12px;
+ height: 12px;
+ color: var(--d-ink-3);
+}
+.url {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+.path {
+ color: var(--d-ink-3);
+}
+.barFill {
+ flex: 1;
+}
+.badge {
+ flex: none;
+ padding: 2px 8px;
+ border: 1px solid rgba(255, 106, 61, 0.5);
+ border-radius: 999px;
+ font-family: var(--pf-mono, ui-monospace, monospace);
+ font-size: 10px;
+ letter-spacing: 0.06em;
+ color: var(--d-led);
+ white-space: nowrap;
+}
+
+.body {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ background: var(--d-chrome);
+}
+
+/* Over a window in the back (or all of them, at rest): dimmed, and a click
+ brings it forward. */
+.cover {
+ position: absolute;
+ inset: 0;
+ z-index: 5;
+ background: rgba(10, 9, 8, 0.58);
+ opacity: 0;
+ pointer-events: none;
+ cursor: pointer;
+ transition:
+ opacity 0.45s ease,
+ background 0.25s;
+}
+.win[data-dim="1"] .cover {
+ opacity: 1;
+ pointer-events: auto;
+}
+.win[data-dim="1"] .cover:hover {
+ background: rgba(10, 9, 8, 0.42);
+}
+.desk[data-rest="1"] .win[data-dim="1"] .cover {
+ background: rgba(10, 9, 8, 0.5);
+}
+
+/* A practice window's content. */
+.appRoot {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+}
+.appEmpty {
+ display: grid;
+ place-items: center;
+ height: 100%;
+ font-family: var(--pf-mono, ui-monospace, monospace);
+ font-size: 12px;
+ letter-spacing: 0.08em;
+ color: var(--d-ink-3);
+}
+
+/* ── the Lab ─────────────────────────────────────────────────────────────── */
+
+.labView {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ background: var(--d-lab);
+}
+.poster {
+ position: absolute;
+ left: 0;
+ top: 0;
+ height: auto;
+ pointer-events: none;
+ user-select: none;
+}
+.labView[data-ready="1"] .poster {
+ visibility: hidden;
+}
+.labFrame {
+ position: absolute;
+ left: 0;
+ top: 0;
+ border: 0;
+ background: var(--d-lab);
+ transform-origin: 0 0;
+ opacity: 0;
+ transition: opacity 0.35s ease;
+}
+.labView[data-ready="1"] .labFrame {
+ opacity: 1;
+}
+.notice {
+ position: absolute;
+ left: 12px;
+ right: 12px;
+ bottom: 12px;
+ z-index: 3;
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ margin: 0;
+ padding: 10px 12px;
+ border: 1px solid rgba(255, 106, 61, 0.55);
+ border-radius: 10px;
+ background: rgba(20, 18, 16, 0.94);
+ font-size: 12.5px;
+ line-height: 1.45;
+ color: var(--d-ink);
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
+}
+.notice span {
+ flex: 1;
+}
+.noticeClose {
+ flex: none;
+ display: grid;
+ place-items: center;
+ width: 22px;
+ height: 22px;
+ padding: 0;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--d-ink-2);
+ cursor: pointer;
+}
+.noticeClose svg {
+ width: 12px;
+ height: 12px;
+}
+.noticeClose:hover,
+.noticeClose:focus-visible {
+ color: var(--d-led);
+}
+
+/* ── the pointer ─────────────────────────────────────────────────────────── */
+
+.pointerLayer {
+ position: absolute;
+ inset: 0;
+ z-index: 20;
+ overflow: hidden;
+ pointer-events: none;
+}
+.cursor,
+.ripple,
+.halo,
+.bubble {
+ position: absolute;
+ left: 0;
+ top: 0;
+ will-change: transform;
+}
+.cursor {
+ width: 0;
+ height: 0;
+ opacity: 0;
+ transition: opacity 0.28s ease;
+}
+.pointerLayer[data-shown="1"] .cursor {
+ opacity: 1;
+}
+.cursor svg {
+ position: absolute;
+ /* The drawn tip (3, 2.2) sits on the point. */
+ left: -3px;
+ top: -2.2px;
+ overflow: visible;
+ transform-origin: 3px 2.2px;
+ filter: drop-shadow(0 3px 5px rgba(0, 0, 0, 0.55));
+ transition: transform 0.14s ease;
+}
+.cursor[data-press="1"] svg {
+ transform: scale(0.82);
+}
+.cursor[data-mode="scroll"] svg {
+ animation: bob 0.5s ease-in-out infinite alternate;
+}
+/* Typing: a caret blinks beside the tip. */
+.cursor[data-mode="type"]::after {
+ content: "";
+ position: absolute;
+ left: 16px;
+ top: 16px;
+ width: 2px;
+ height: 14px;
+ border-radius: 1px;
+ background: var(--d-led);
+ animation: caret 0.9s steps(2, start) infinite;
+}
+/* Waiting for the reader: a ring breathes at the tip. */
+.cursor::before {
+ content: "";
+ position: absolute;
+ left: -14px;
+ top: -14px;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ border: 2px solid var(--d-led);
+ opacity: 0;
+}
+.pointerLayer[data-waiting="1"] .cursor::before {
+ animation: breathe 1.7s ease-in-out infinite;
+}
+.ripple {
+ width: 0;
+ height: 0;
+}
+.ripple::after {
+ content: "";
+ position: absolute;
+ left: -20px;
+ top: -20px;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ border: 2px solid var(--d-led);
+ opacity: 0;
+}
+.pointerLayer[data-shown="1"] .ripple[data-on="1"]::after {
+ animation: ripple 0.55s ease-out;
+}
+.halo {
+ border: 1.5px solid var(--d-led);
+ border-radius: 8px;
+ box-shadow:
+ 0 0 0 3px rgba(255, 106, 61, 0.16),
+ 0 0 22px rgba(255, 106, 61, 0.35);
+ opacity: 0;
+ transition: opacity 0.3s ease;
+}
+.pointerLayer[data-shown="1"] .halo[data-on="1"] {
+ opacity: 1;
+ animation: halo 1.7s ease-in-out infinite;
+}
+.bubble {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ max-width: 260px;
+ padding: 7px 11px 7px 10px;
+ border: 1px solid rgba(255, 106, 61, 0.5);
+ border-radius: 10px;
+ background: rgba(16, 14, 12, 0.95);
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
+ font: 500 12.5px/1.35 var(--pf-sans, system-ui, sans-serif);
+ color: var(--d-ink);
+ opacity: 0;
+ transition: opacity 0.25s ease;
+}
+.pointerLayer[data-shown="1"] .bubble[data-on="1"] {
+ opacity: 1;
+}
+.chip {
+ flex: none;
+ padding: 1px 6px;
+ border: 1px solid rgba(239, 233, 221, 0.35);
+ border-radius: 5px;
+ font-family: var(--pf-mono, ui-monospace, monospace);
+ font-size: 10.5px;
+ letter-spacing: 0.04em;
+ color: var(--d-ink);
+}
+.chip[hidden] {
+ display: none;
+}
+
+@keyframes ripple {
+ from {
+ transform: scale(0.3);
+ opacity: 0.95;
+ }
+ to {
+ transform: scale(1.35);
+ opacity: 0;
+ }
+}
+@keyframes breathe {
+ 0%,
+ 100% {
+ transform: scale(0.7);
+ opacity: 0;
+ }
+ 45% {
+ transform: scale(1);
+ opacity: 0.8;
+ }
+}
+@keyframes halo {
+ 0%,
+ 100% {
+ box-shadow:
+ 0 0 0 3px rgba(255, 106, 61, 0.14),
+ 0 0 16px rgba(255, 106, 61, 0.25);
+ }
+ 50% {
+ box-shadow:
+ 0 0 0 5px rgba(255, 106, 61, 0.22),
+ 0 0 28px rgba(255, 106, 61, 0.45);
+ }
+}
+@keyframes bob {
+ from {
+ transform: translateY(-3px);
+ }
+ to {
+ transform: translateY(3px);
+ }
+}
+@keyframes caret {
+ to {
+ visibility: hidden;
+ }
+}
+
+/* ── the card's cue line (DeskCue) ───────────────────────────────────────── */
+
+.cue {
+ display: none;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 10px;
+ margin: 14px 0 2px;
+ padding: 9px 10px 9px 12px;
+ border: 1px solid var(--g-hair-2, rgba(239, 233, 221, 0.18));
+ border-radius: 12px;
+ background: rgba(255, 106, 61, 0.06);
+}
+@media (min-width: 1340px) and (min-height: 640px), (min-width: 1280px) and (min-height: 660px), (min-width: 1180px) and (min-height: 700px) {
+ .cue {
+ display: flex;
+ }
+}
+.cueDot {
+ flex: none;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--g-led, #ff6a3d);
+ box-shadow: 0 0 10px var(--g-led, #ff6a3d);
+}
+.cue[data-state="done"] .cueDot {
+ background: #7fd1a0;
+ box-shadow: 0 0 8px rgba(127, 209, 160, 0.7);
+}
+.cue[data-state="waiting"] .cueDot {
+ animation: cueBlink 1.7s ease-in-out infinite;
+}
+.cueText {
+ flex: 1 1 140px;
+ min-width: 0;
+ font-size: 13.5px;
+ line-height: 1.4;
+ color: var(--g-ink, #efe9dd);
+}
+.cueAgain {
+ display: inline-flex;
+ flex: none;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 11px;
+ border: 1px solid var(--g-hair-2, rgba(239, 233, 221, 0.18));
+ border-radius: 999px;
+ background: transparent;
+ color: var(--g-ink-2, rgba(239, 233, 221, 0.78));
+ font: 500 12px/1 var(--pf-sans, system-ui, sans-serif);
+ cursor: pointer;
+ transition:
+ color 0.2s,
+ border-color 0.2s,
+ background 0.2s;
+}
+.cueAgain svg {
+ width: 12px;
+ height: 12px;
+}
+.cueAgain:hover,
+.cueAgain:focus-visible {
+ border-color: var(--g-led, #ff6a3d);
+ color: var(--g-ink, #efe9dd);
+ background: rgba(255, 106, 61, 0.1);
+}
+.cueAgain:focus-visible,
+.cueJump:focus-visible {
+ outline: 2px solid var(--g-led, #ff6a3d);
+ outline-offset: 2px;
+}
+/* For the keyboard only: out of sight until it has focus. */
+.cueJump {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ padding: 6px 11px;
+ border: 1px solid var(--g-led, #ff6a3d);
+ border-radius: 999px;
+ background: var(--g-led-wash, rgba(255, 106, 61, 0.1));
+ color: var(--g-ink, #efe9dd);
+ font: 500 12px/1 var(--pf-sans, system-ui, sans-serif);
+ cursor: pointer;
+}
+.cueJump:focus-visible {
+ position: static;
+ width: auto;
+ height: auto;
+ overflow: visible;
+ clip-path: none;
+}
+@keyframes cueBlink {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.35;
+ }
+}
+
+/* ── less motion: windows cut, the pointer jumps, nothing pulses ────────── */
+
+@media (prefers-reduced-motion: reduce) {
+ .win,
+ .win[data-role="away"],
+ .cover,
+ .labFrame,
+ .cursor,
+ .cursor svg,
+ .halo,
+ .bubble {
+ transition: none;
+ }
+ .cursor[data-mode="scroll"] svg,
+ .cursor[data-mode="type"]::after,
+ .pointerLayer[data-waiting="1"] .cursor::before,
+ .pointerLayer[data-shown="1"] .halo[data-on="1"],
+ .cue[data-state="waiting"] .cueDot {
+ animation: none;
+ }
+ .pointerLayer[data-waiting="1"] .cursor::before {
+ opacity: 0.7;
+ transform: none;
+ }
+ /* A press still shows: a ring that stays a moment, not one that flies out. */
+ .pointerLayer[data-shown="1"] .ripple[data-on="1"]::after {
+ animation: none;
+ }
+}
diff --git a/web/src/components/guide/desk/DeskCue.tsx b/web/src/components/guide/desk/DeskCue.tsx
new file mode 100644
index 00000000..cf2fa4fb
--- /dev/null
+++ b/web/src/components/guide/desk/DeskCue.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import type { GuideLang } from "../store";
+import { beatsFor } from "./script";
+import { focusDesk, useDeskStore } from "./deskStore";
+import { DESK_WORDS } from "./words";
+import styles from "./Desk.module.css";
+
+// A line in a make-page step's card, under a step with a tutorial: where the
+// pointer is ("Follow the pointer", its hint while it waits for the reader,
+// "Done"), a "Show me again" button, and — for the keyboard — a way into the
+// window the pointer is working in (deskStore focusDesk). Only on a screen with the desk (Desk.module.css hides it
+// elsewhere, so a card doesn't jump when the desk comes up); a step without
+// a tutorial has none.
+
+export default function DeskCue({ scene, index, lang }: { scene: string; index: number; lang: GuideLang }) {
+ const key = `${scene}.${index}`;
+ const run = useDeskStore((s) => (s.run && s.run.key === key ? s.run : null));
+ if (beatsFor(scene, index).length === 0) return null;
+ const words = DESK_WORDS[lang];
+ const status = !run ? words.watch : run.done ? words.done : run.waiting ? (run.say ?? words.yourTurn) : words.watch;
+ return (
+
+
+
+ {status}
+
+
useDeskStore.getState().askReplay(key)}>
+
+
+
+ {words.again}
+
+
+ {words.toWindow}
+
+
+ );
+}
diff --git a/web/src/components/guide/desk/DeskStage.tsx b/web/src/components/guide/desk/DeskStage.tsx
new file mode 100644
index 00000000..5a29c7af
--- /dev/null
+++ b/web/src/components/guide/desk/DeskStage.tsx
@@ -0,0 +1,599 @@
+"use client";
+
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
+import { LAB_STORAGE } from "@/lib/lab/persist";
+import { useGuideStore, type GuideLang } from "../store";
+import { DESK_APPS } from "./apps";
+import { beatsFor, deskLayout, type Layout } from "./script";
+import { setDeskFocuser, useDeskStore } from "./deskStore";
+import { Pointer } from "./pointer";
+import { DeskTutorial, type DeskEnv } from "./runner";
+import { resolveTarget } from "./target";
+import { usePrefersReducedMotion } from "./query";
+import { DESK_WORDS, type DeskWords } from "./words";
+import type { BigWin, DeskWin } from "./types";
+import styles from "./Desk.module.css";
+
+// The make page's stage (/guide/make, 05–06): a desk of app windows in the
+// space left of the story, instead of the 3D device. A large window holds the
+// reader's real Pattern Lab (/pattern-lab, same origin, its real storage); a
+// practice community and a small practice AI (desk/apps/*) sit with it. Each
+// step of the page says which windows are on the desk and which is in front
+// (scenes/community.ts, scenes/lab.ts `desk`); windows come forward and
+// recede with calm motion, and each is created once and kept alive — the Lab
+// is never reloaded by scrolling.
+//
+// Over the windows, the guiding pointer (pointer.ts) plays the step's
+// tutorial (tutorials/*.ts, runner.ts): it shows the way, then waits for the
+// reader's own hands. In the Lab it never clicks anything.
+//
+// Mounted by GuideExperience only on a screen big enough (query.ts); below
+// that, the make page's cards keep their screenshots instead.
+
+const LAB_PATH = "/pattern-lab";
+/** A real capture of the Lab as it opens (scratchpad lab_poster.py), shown until the frame has painted. */
+const POSTER = "/guide/lab-window/lab.webp";
+/**
+ * The Lab's layout size: laid out at least this big, scaled down to the
+ * window, never up — a big screen gets more Lab, not bigger type. It lays out
+ * whole at 1024; the poster was captured at 1024 × 680.
+ */
+const LAB_W = 1024;
+const LAB_H = 680;
+/** What the keyboard can land on in a practice window. */
+const FOCUSABLE = 'button:not([disabled]), a[href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
+/** Two big windows on the desk: the one behind peeks out this far above and left of the one in front. */
+const CASCADE = 30;
+
+type Role = "solo" | "fore" | "back" | "away";
+
+function safe(fn: () => T, fallback: T): T {
+ try {
+ return fn();
+ } catch {
+ return fallback;
+ }
+}
+
+// ── window chrome ───────────────────────────────────────────────────────────
+
+function Lights() {
+ return (
+
+
+
+
+
+ );
+}
+
+function Address({ host, path }: { host: string; path: string }) {
+ return (
+
+
+
+
+
+
+ {host}
+ {path}
+
+
+ );
+}
+
+function DeskWindow({
+ win,
+ role,
+ dim,
+ style,
+ title,
+ address,
+ badge,
+ words,
+ onRaise,
+ bodyRef,
+ children,
+}: {
+ win: DeskWin;
+ role: Role;
+ dim: boolean;
+ style: CSSProperties;
+ title: string;
+ address?: { host: string; path: string };
+ badge?: string;
+ words: DeskWords;
+ onRaise: () => void;
+ bodyRef: (el: HTMLDivElement | null) => void;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+
+
+ {title}
+
+ {address ?
:
}
+ {badge &&
{badge} }
+
+
+ {children}
+
+ {/* Over a dimmed window: a click brings it forward, and nothing under it is pressed by accident. */}
+
+
+ );
+}
+
+// ── the Lab ─────────────────────────────────────────────────────────────────
+
+function isEditable(t: EventTarget | null): boolean {
+ const el = t as HTMLElement | null;
+ if (!el || typeof el.closest !== "function") return false;
+ return Boolean(el.isContentEditable || el.closest("input, textarea, select, [contenteditable], .cm-editor"));
+}
+
+/**
+ * The real Pattern Lab in the Lab window: laid out at LAB_W × LAB_H or more
+ * and scaled to fit, the poster under it until it has painted. Nothing here
+ * writes the Lab's storage or changes the Lab: it reads the frame's title and
+ * address, sends the Lab's links out of the Lab to a new tab, and says when
+ * the Lab in another tab saved over this one.
+ */
+function LabFrame({
+ mount,
+ frame,
+ words,
+ onReady,
+ onTitle,
+ onPath,
+}: {
+ mount: boolean;
+ frame: RefObject;
+ words: DeskWords;
+ onReady: (ready: boolean) => void;
+ onTitle: (title: string) => void;
+ onPath: (path: string) => void;
+}) {
+ const viewport = useRef(null);
+ const [inner, setInner] = useState<{ w: number; h: number } | null>(null);
+ const [ready, setReady] = useState(false);
+ const [otherTab, setOtherTab] = useState(false);
+ const detach = useRef<(() => void) | null>(null);
+
+ useLayoutEffect(() => {
+ const el = viewport.current;
+ if (!el) return;
+ const measure = () => {
+ if (el.clientWidth > 0 && el.clientHeight > 0) setInner({ w: el.clientWidth, h: el.clientHeight });
+ };
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+ useEffect(() => () => detach.current?.(), []);
+
+ const scale = inner ? Math.min(1, inner.w / LAB_W, inner.h / LAB_H) : 1;
+ const labW = inner ? Math.ceil(inner.w / scale) : LAB_W;
+ const labH = inner ? Math.ceil(inner.h / scale) : LAB_H;
+
+ const onLoad = useCallback(() => {
+ detach.current?.();
+ detach.current = null;
+ const el = frame.current;
+ const win = el?.contentWindow ?? null;
+ const doc = safe(() => el?.contentDocument ?? null, null);
+ if (!win || !doc) return;
+ if (doc.title) onTitle(doc.title);
+ // The frame is there for the Lab. If it ever lands anywhere else, that
+ // page opens in a tab of its own and the Lab comes back here.
+ if (win.location.pathname !== LAB_PATH) {
+ window.open(win.location.href, "_blank", "noopener");
+ win.location.replace(LAB_PATH);
+ return;
+ }
+ onPath(win.location.pathname + win.location.search);
+ // A link out of the Lab opens beside the guide, not in place of the Lab.
+ const onClick = (e: MouseEvent) => {
+ const a = (e.target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null;
+ if (!a || e.defaultPrevented || e.button !== 0) return;
+ const url = new URL(a.href, win.location.href);
+ const stays = url.origin === win.location.origin && url.pathname === LAB_PATH;
+ if (stays || a.target === "_blank" || a.hasAttribute("download")) return;
+ e.preventDefault();
+ e.stopPropagation();
+ window.open(url.href, "_blank", "noopener");
+ };
+ // The Lab in another tab saving the same draft: say so (the frame's
+ // window hears saves from every document but its own).
+ const onStorage = (e: StorageEvent) => {
+ if (e.key === LAB_STORAGE.project) setOtherTab(true);
+ };
+ // Esc in the Lab, when the Lab isn't using it, gives the page the keyboard back.
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key !== "Escape" || e.defaultPrevented || isEditable(e.target)) return;
+ el?.blur();
+ window.focus();
+ };
+ // The title changes as the Lab names its pattern.
+ const titleEl = doc.querySelector("title");
+ const mo = titleEl ? new MutationObserver(() => doc.title && onTitle(doc.title)) : null;
+ if (titleEl) mo?.observe(titleEl, { childList: true, characterData: true, subtree: true });
+ doc.addEventListener("click", onClick, true);
+ win.addEventListener("storage", onStorage);
+ win.addEventListener("keydown", onKey);
+ detach.current = () => {
+ safe(() => {
+ doc.removeEventListener("click", onClick, true);
+ win.removeEventListener("storage", onStorage);
+ win.removeEventListener("keydown", onKey);
+ mo?.disconnect();
+ }, undefined);
+ };
+ // Shown once it has painted, over the picture of it.
+ requestAnimationFrame(() =>
+ requestAnimationFrame(() => {
+ setReady(true);
+ onReady(true);
+ }),
+ );
+ }, [frame, onReady, onTitle, onPath]);
+
+ return (
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed poster under the frame until it paints */}
+
+ {mount && (
+
+ )}
+ {otherTab && (
+
+ {words.otherTab}
+ setOtherTab(false)} aria-label="OK">
+
+
+
+
+
+ )}
+
+ );
+}
+
+// ── the pointer's elements ──────────────────────────────────────────────────
+
+function PointerLayer({ layerRef }: { layerRef: RefObject }) {
+ return (
+
+ );
+}
+
+function pointerEls(layer: HTMLDivElement) {
+ const q = (part: string) => layer.querySelector(`[data-part="${part}"]`)!;
+ return {
+ layer,
+ cursor: q("cursor"),
+ ripple: q("ripple"),
+ halo: q("halo"),
+ bubble: q("bubble"),
+ bubbleText: q("text"),
+ bubbleChip: q("chip"),
+ };
+}
+
+// ── the desk ────────────────────────────────────────────────────────────────
+
+function roles(l: Layout): Record {
+ const big: BigWin[] = (["lab", "community"] as const).filter((w) => l.shown.has(w));
+ const out: Record = { lab: "away", community: "away" };
+ if (big.length === 1) out[big[0]] = "solo";
+ else if (big.length === 2) {
+ out[l.top] = "fore";
+ out[l.top === "lab" ? "community" : "lab"] = "back";
+ }
+ return out;
+}
+
+function bigStyle(role: Role, size: { w: number; h: number } | null, i: number): CSSProperties {
+ const w = size?.w ?? 1000;
+ const h = size?.h ?? 700;
+ const s = Math.min((w - CASCADE) / w, (h - CASCADE) / h);
+ switch (role) {
+ case "solo":
+ return { transform: "none", transformOrigin: "50% 50%", zIndex: 3 };
+ case "fore":
+ return { transform: `scale(${s.toFixed(4)})`, transformOrigin: "100% 100%", zIndex: 3 };
+ case "back":
+ return { transform: `scale(${s.toFixed(4)})`, transformOrigin: "0 0", zIndex: 2 };
+ case "away":
+ return { transform: `translate3d(${i ? 24 : -24}px, 22px, 0) scale(0.97)`, transformOrigin: "50% 50%", zIndex: 1 };
+ }
+}
+
+export default function DeskStage({ lang }: { lang: GuideLang }) {
+ const words = DESK_WORDS[lang];
+ const scene = useGuideStore((s) => s.scene);
+ const step = useGuideStore((s) => s.step);
+ const key = `${scene}.${step}`;
+ const raised = useDeskStore((s) => (s.raised && s.raised.key === key ? s.raised.win : null));
+ const layout = useMemo(() => deskLayout(scene, step, raised), [scene, step, raised]);
+ const reduced = usePrefersReducedMotion();
+
+ const desk = useRef(null);
+ const layer = useRef(null);
+ const frame = useRef(null);
+ const bodies = useRef>({ lab: null, community: null, ai: null });
+ const contents = useRef>({ community: null, ai: null });
+ const [size, setSize] = useState<{ w: number; h: number } | null>(null);
+ const [labTitle, setLabTitle] = useState(words.labTitle);
+ const [labPath, setLabPath] = useState(LAB_PATH);
+ const [paths, setPaths] = useState({
+ community: DESK_APPS.community.chrome.address?.path ?? "",
+ ai: DESK_APPS.ai.chrome.address?.path ?? "",
+ });
+ // The Lab's frame is made the first time the Lab is on the desk for real
+ // (not at rest behind the opening), then kept for the rest of the visit.
+ const [labMounted, setLabMounted] = useState(false);
+ const wantLab = layout.shown.has("lab") && !layout.rest;
+ if (wantLab && !labMounted) setLabMounted(true);
+ const labReady = useRef(false);
+
+ // What the tutorial reads, current without re-making it.
+ const now = useRef({ lang, reduced, layout, key, scene, step });
+ useLayoutEffect(() => {
+ now.current = { lang, reduced, layout, key, scene, step };
+ });
+
+ useLayoutEffect(() => {
+ const el = desk.current;
+ if (!el) return;
+ const measure = () => setSize({ w: el.clientWidth, h: el.clientHeight });
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
+ // The desk is up (the card's cue line shows), and the card can put focus in
+ // it: in the window the pointer is working in now — on the control it
+ // points at, when the keyboard can reach that — else the window in front.
+ useEffect(() => {
+ const st = useDeskStore.getState();
+ st.setOn(true);
+ setDeskFocuser(() => {
+ const { layout: l, key: k, scene: sc, step: sp } = now.current;
+ const run = useDeskStore.getState().run;
+ const beat = run && run.key === k ? beatsFor(sc, sp)[run.index] : undefined;
+ const win: DeskWin = beat && l.shown.has(beat.win) ? beat.win : l.front;
+ if (win === "lab") {
+ frame.current?.focus();
+ return;
+ }
+ const root = contents.current[win];
+ if (!root) return;
+ let el: HTMLElement | null = null;
+ if (beat?.win === win && beat.target) {
+ const t = resolveTarget(root, beat.target);
+ el = (t?.closest(FOCUSABLE) as HTMLElement | null) ?? null;
+ if (el && !root.contains(el)) el = null;
+ }
+ // The practice AI: its message box, when the beat's target isn't something to press (its answer being written).
+ if (!el && win === "ai") el = root.querySelector("[data-ai-input]");
+ el ??= root.querySelector(FOCUSABLE);
+ (el ?? root).focus();
+ });
+ return () => {
+ setDeskFocuser(null);
+ useDeskStore.getState().setOn(false);
+ useDeskStore.getState().setRun(null);
+ };
+ }, []);
+
+ // The tutorial and its pointer: made once, told each step.
+ const tutorial = useRef(null);
+ useEffect(() => {
+ const el = layer.current;
+ if (!el) return;
+ const pointer = new Pointer(pointerEls(el), () => now.current.reduced);
+ const labDoc = () => safe(() => frame.current?.contentDocument ?? null, null);
+ const env: DeskEnv = {
+ lang: () => now.current.lang,
+ reduced: () => now.current.reduced,
+ doc: (win) => (win === "lab" ? labDoc() : document),
+ root: (win) => (win === "lab" ? labDoc() : contents.current[win]),
+ rectOf: (win, target) => {
+ const r = target.getBoundingClientRect();
+ if (win !== "lab") return r;
+ const f = frame.current;
+ if (!f || f.clientWidth < 1) return null;
+ const fr = f.getBoundingClientRect();
+ const k = fr.width / f.clientWidth;
+ return new DOMRect(fr.left + r.left * k, fr.top + r.top * k, r.width * k, r.height * k);
+ },
+ box: (win) => {
+ if (!now.current.layout.shown.has(win)) return null;
+ return bodies.current[win]?.getBoundingClientRect() ?? null;
+ },
+ ready: (win) => (win === "lab" ? labReady.current : true),
+ owns: (win, x, y) => {
+ // The pointer's layer takes no pointer events: this is what's under it.
+ const hit = document.elementFromPoint(x, y);
+ const at = hit?.closest("[data-win]");
+ return !at || at.getAttribute("data-win") === win;
+ },
+ bringForward: (win) => {
+ const { layout: l, key: k } = now.current;
+ if (win === "ai" || !l.shown.has(win) || l.top === win) return;
+ useDeskStore.getState().raise(k, win);
+ },
+ report: (run) => useDeskStore.getState().setRun(run),
+ };
+ const t = new DeskTutorial(env, pointer);
+ tutorial.current = t;
+ const offReplay = useDeskStore.subscribe((s, prev) => {
+ if (s.replay !== prev.replay && s.replay.key === now.current.key) t.replay();
+ });
+ return () => {
+ offReplay();
+ t.stop();
+ pointer.destroy();
+ tutorial.current = null;
+ };
+ }, []);
+
+ useEffect(() => {
+ tutorial.current?.start(key, beatsFor(scene, step));
+ }, [key, scene, step]);
+
+ const onLabReady = useCallback((r: boolean) => {
+ labReady.current = r;
+ }, []);
+ const raise = useCallback((win: DeskWin) => useDeskStore.getState().raise(now.current.key, win), []);
+
+ const r = roles(layout);
+ const host = typeof window !== "undefined" ? window.location.host : "patternflow.work";
+ const aiShown = layout.shown.has("ai");
+ const aiW = Math.round(Math.max(300, Math.min(400, (size?.w ?? 900) * 0.42)));
+ const aiH = Math.round(Math.max(300, Math.min(520, (size?.h ?? 700) * layout.aiHeight)));
+ const aiRole: Role = aiShown ? "solo" : "away";
+
+ const dimBig = (w: BigWin) => r[w] === "back" || (layout.rest && r[w] !== "away") || (layout.front === "ai" && r[w] !== "away");
+ const appProps = (win: "community" | "ai") => ({
+ lang,
+ shown: layout.shown.has(win),
+ front: layout.front === win,
+ scene,
+ step,
+ reduced,
+ setPath: (path: string) => setPaths((p) => (p[win] === path ? p : { ...p, [win]: path })),
+ });
+
+ const Community = DESK_APPS.community.Component;
+ const Ai = DESK_APPS.ai.Component;
+ const cc = DESK_APPS.community.chrome;
+ const ac = DESK_APPS.ai.chrome;
+
+ return (
+
+
raise("community")}
+ bodyRef={(el) => {
+ bodies.current.community = el;
+ }}
+ >
+ {
+ contents.current.community = el;
+ }}
+ >
+
+
+
+
+
raise("lab")}
+ bodyRef={(el) => {
+ bodies.current.lab = el;
+ }}
+ >
+
+
+
+
raise("ai")}
+ bodyRef={(el) => {
+ bodies.current.ai = el;
+ }}
+ >
+ {
+ contents.current.ai = el;
+ }}
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/guide/desk/ai/PracticeAi.module.css b/web/src/components/guide/desk/ai/PracticeAi.module.css
new file mode 100644
index 00000000..98a53079
--- /dev/null
+++ b/web/src/components/guide/desk/ai/PracticeAi.module.css
@@ -0,0 +1,271 @@
+/* The practice AI (PracticeAi.tsx): a plain, generic chat — neutral greys,
+ no accent colour of any product. It sits inside the desk (Desk.module.css),
+ whose --d-* tokens it uses. Sized to its window: the code block's height is
+ a share of the window (cqh), so the newest answer and its Copy button fit
+ on screen together. */
+
+.root {
+ container-type: size;
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ background: #171615;
+ font-family: var(--pf-sans, system-ui, sans-serif);
+ font-size: 12.5px;
+ line-height: 1.5;
+ color: var(--d-ink, #efe9dd);
+}
+
+/* ── the conversation ────────────────────────────────────────────────────── */
+
+.thread {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ /* At its end, the wheel stops here: it doesn't scroll the story (and the step) under the reader. */
+ overscroll-behavior: contain;
+ padding: 14px 14px 8px;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(239, 233, 221, 0.18) transparent;
+}
+
+.you {
+ display: flex;
+ justify-content: flex-end;
+ margin: 0 0 12px 38px;
+}
+.youText,
+.pasted {
+ margin: 0;
+ max-width: 100%;
+ padding: 8px 11px;
+ border-radius: 12px 12px 3px 12px;
+ background: #2a2826;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
+.pasted {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 0;
+}
+.pastedHead {
+ overflow: hidden;
+ max-width: 100%;
+ font-family: var(--pf-mono, ui-monospace, monospace);
+ font-size: 11px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ color: var(--d-ink-2, rgba(239, 233, 221, 0.72));
+}
+.pastedMeta {
+ font-size: 11px;
+ color: var(--d-ink-3, rgba(239, 233, 221, 0.46));
+}
+
+.ai {
+ display: flex;
+ gap: 9px;
+ margin: 0 0 14px;
+}
+.mark {
+ flex: none;
+ width: 20px;
+ height: 20px;
+ margin-top: 1px;
+ border: 1px solid rgba(239, 233, 221, 0.22);
+ border-radius: 50%;
+ background:
+ radial-gradient(circle at 50% 50%, rgba(239, 233, 221, 0.55) 0 2.5px, transparent 3px),
+ #22211f;
+}
+.aiBody {
+ flex: 1;
+ min-width: 0;
+}
+.aiText {
+ margin: 0;
+ color: var(--d-ink, #efe9dd);
+}
+.ai[data-typing] .aiText::after,
+.ai[data-typing] .code code::after {
+ content: "";
+ display: inline-block;
+ width: 6px;
+ height: 1em;
+ margin-left: 1px;
+ vertical-align: -0.15em;
+ background: rgba(239, 233, 221, 0.6);
+}
+/* Only the part being written carries the caret. */
+.ai[data-typing] .aiText:has(+ .code)::after {
+ content: none;
+}
+
+.dots {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ height: 20px;
+ margin: 0;
+}
+.dots i {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: rgba(239, 233, 221, 0.55);
+ animation: dot 1.1s ease-in-out infinite;
+}
+.dots i:nth-child(2) {
+ animation-delay: 0.15s;
+}
+.dots i:nth-child(3) {
+ animation-delay: 0.3s;
+}
+@keyframes dot {
+ 0%,
+ 70%,
+ 100% {
+ opacity: 0.35;
+ transform: none;
+ }
+ 35% {
+ opacity: 1;
+ transform: translateY(-2px);
+ }
+}
+
+/* ── an answer's code ────────────────────────────────────────────────────── */
+
+.code {
+ margin-top: 8px;
+ overflow: hidden;
+ border: 1px solid rgba(239, 233, 221, 0.12);
+ border-radius: 9px;
+ background: #0e0d0c;
+}
+.codeBar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ height: 30px;
+ padding: 0 6px 0 11px;
+ border-bottom: 1px solid rgba(239, 233, 221, 0.08);
+ background: #1d1c1a;
+}
+.codeLang {
+ font-family: var(--pf-mono, ui-monospace, monospace);
+ font-size: 10.5px;
+ letter-spacing: 0.04em;
+ color: var(--d-ink-3, rgba(239, 233, 221, 0.46));
+}
+.copy {
+ height: 22px;
+ padding: 0 10px;
+ border: 1px solid rgba(239, 233, 221, 0.2);
+ border-radius: 6px;
+ background: transparent;
+ font: 500 11.5px/1 var(--pf-sans, system-ui, sans-serif);
+ color: var(--d-ink, #efe9dd);
+ white-space: nowrap;
+ cursor: pointer;
+}
+.copy:hover {
+ background: rgba(239, 233, 221, 0.08);
+}
+.copy:focus-visible {
+ outline: 2px solid rgba(239, 233, 221, 0.7);
+ outline-offset: 1px;
+}
+.copy[data-state="done"] {
+ border-color: rgba(239, 233, 221, 0.4);
+}
+.pre {
+ margin: 0;
+ max-height: min(170px, 34cqh);
+ overflow: auto;
+ overscroll-behavior: contain;
+ padding: 9px 11px;
+ font-family: var(--pf-mono, ui-monospace, monospace);
+ font-size: 11px;
+ line-height: 1.5;
+ color: #d9d4ca;
+ white-space: pre;
+ tab-size: 4;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(239, 233, 221, 0.18) transparent;
+}
+.pre:focus-visible {
+ outline: 2px solid rgba(239, 233, 221, 0.5);
+ outline-offset: -2px;
+}
+
+/* ── the composer ────────────────────────────────────────────────────────── */
+
+.composer {
+ flex: none;
+ display: flex;
+ align-items: flex-end;
+ gap: 8px;
+ padding: 10px 12px 12px;
+ border-top: 1px solid rgba(239, 233, 221, 0.08);
+ background: #171615;
+}
+.input {
+ flex: 1;
+ min-width: 0;
+ height: 48px;
+ resize: none;
+ padding: 7px 10px;
+ border: 1px solid rgba(239, 233, 221, 0.18);
+ border-radius: 10px;
+ background: #0e0d0c;
+ font: 12px/1.45 var(--pf-sans, system-ui, sans-serif);
+ color: var(--d-ink, #efe9dd);
+ scrollbar-width: thin;
+}
+.input::placeholder {
+ color: var(--d-ink-3, rgba(239, 233, 221, 0.46));
+}
+.input:focus-visible {
+ outline: none;
+ border-color: rgba(239, 233, 221, 0.5);
+}
+.send {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ height: 32px;
+ padding: 0 12px;
+ border: 0;
+ border-radius: 9px;
+ background: #e9e4da;
+ font: 600 12px/1 var(--pf-sans, system-ui, sans-serif);
+ color: #151412;
+ cursor: pointer;
+}
+.send svg {
+ width: 13px;
+ height: 13px;
+}
+.send:disabled {
+ background: rgba(239, 233, 221, 0.12);
+ color: var(--d-ink-3, rgba(239, 233, 221, 0.46));
+ cursor: default;
+}
+.send:focus-visible {
+ outline: 2px solid rgba(239, 233, 221, 0.8);
+ outline-offset: 2px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .dots i {
+ animation: none;
+ opacity: 0.7;
+ }
+}
diff --git a/web/src/components/guide/desk/ai/PracticeAi.tsx b/web/src/components/guide/desk/ai/PracticeAi.tsx
new file mode 100644
index 00000000..a08e44e2
--- /dev/null
+++ b/web/src/components/guide/desk/ai/PracticeAi.tsx
@@ -0,0 +1,351 @@
+"use client";
+
+import { useCallback, useEffect, useLayoutEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
+import type { DeskAppProps } from "../types";
+import { deskSignal, registerDeskApp } from "../deskStore";
+import { AI_PRESETS, AI_WORDS, answer, recognize, type PromptKind, type SetAnswer } from "./answers";
+import styles from "./PracticeAi.module.css";
+
+// The practice AI (06 Pattern Lab): a small, generic chat in a window on the
+// make page's desk — no logos, no product names, no brand colours; its title
+// bar says it gives set answers. The reader pastes the Lab's prompt (a real
+// paste, Ctrl/⌘ V) and presses Send or Enter; it answers, typing, with a real
+// preset's JavaScript or firmware header in a code block with a Copy button
+// (answers.ts). Nothing goes over the network.
+//
+// Mounted once for the whole visit (DeskStage keeps practice windows alive),
+// so the conversation carries from 02's prompt to 05's.
+//
+// The tutorial (tutorials/lab.ts) finds its parts by data attributes —
+// data-ai-input, data-ai-send, data-ai-thread, data-ai-last (the newest
+// answer, or the "writing…" line before it), data-ai-copy="js" | "h" — and
+// asks it, through the handle it registers (AiHandle), what the reader has
+// done in it: a beat can tell from that that the reader was quicker than the
+// pointer, or did a step's middle before its start.
+
+/** What a beat can ask the practice AI (ctx.app("ai")). */
+export type AiHandle = {
+ /** Writing an answer (from Send until the last character is on screen). */
+ busy: () => boolean;
+ /** How many answers it has finished. */
+ answered: () => number;
+ /** Which of the Lab's prompts is in the message box now ("empty": nothing). */
+ draftKind: () => PromptKind | "empty";
+ /** When (performance.now()) the reader last sent a prompt of this kind; 0: never. */
+ sentAt: (kind: PromptKind) => number;
+ /** When the reader last copied an answer's code, by what it is; 0: never. */
+ copiedAt: (kind: "js" | "h") => number;
+ /** The text carries the JavaScript this practice AI last gave (it's in the Lab now, say). */
+ carriesAnswer: (text: string) => boolean;
+};
+
+type Said = SetAnswer["kind"] | "hello";
+
+type Msg =
+ | { id: number; from: "you"; text: string; lines: number }
+ | { id: number; from: "ai"; said: Said; code?: string; lang?: "javascript" | "cpp" };
+
+/** Answer time, in ms per character, and its bounds: a real answer streams in, not instantly, nor for ages. */
+const THINK_MS = 700;
+const SAY_MS = 450;
+const CODE_MS = { min: 1600, max: 3200, perChar: 0.34 };
+/** A pasted text longer than this is shown collapsed. */
+const LONG = { chars: 240, lines: 4 };
+
+function isMac() {
+ return typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
+}
+
+function lineCount(s: string) {
+ return s.split(/\r\n?|\n/).length;
+}
+
+export default function PracticeAi({ lang, reduced }: DeskAppProps) {
+ const w = AI_WORDS[lang];
+ const [msgs, setMsgs] = useState([{ id: 0, from: "ai", said: "hello" }]);
+ const [draft, setDraft] = useState("");
+ const [thinking, setThinking] = useState(false);
+ /** The answer being typed out: its id and how many characters are on screen. */
+ const [typing, setTyping] = useState<{ id: number; n: number } | null>(null);
+ const [copied, setCopied] = useState<{ id: number; byHand: boolean } | null>(null);
+
+ const thread = useRef(null);
+ const stick = useRef(true);
+ const nextId = useRef(1);
+ const lastJs = useRef(null);
+ const busy = useRef(false);
+ const answered = useRef(0);
+ const draftNow = useRef("");
+ const sentAt = useRef>>({});
+ const copiedAt = useRef<{ js: number; h: number }>({ js: 0, h: 0 });
+ const timers = useRef(new Set());
+ const raf = useRef(0);
+ const codeEls = useRef(new Map());
+ const reducedRef = useRef(reduced);
+ useLayoutEffect(() => {
+ reducedRef.current = reduced;
+ });
+
+ const later = useCallback((ms: number, fn: () => void) => {
+ const id = window.setTimeout(() => {
+ timers.current.delete(id);
+ fn();
+ }, ms);
+ timers.current.add(id);
+ }, []);
+
+ useEffect(() => {
+ const t = timers.current;
+ return () => {
+ for (const id of t) window.clearTimeout(id);
+ t.clear();
+ cancelAnimationFrame(raf.current);
+ };
+ }, []);
+
+ useLayoutEffect(() => {
+ draftNow.current = draft;
+ });
+
+ // The tutorial's handle.
+ useEffect(() => {
+ const handle: AiHandle = {
+ busy: () => busy.current,
+ answered: () => answered.current,
+ draftKind: () => (draftNow.current.trim() ? recognize(draftNow.current) : "empty"),
+ sentAt: (kind) => sentAt.current[kind] ?? 0,
+ copiedAt: (kind) => copiedAt.current[kind],
+ carriesAnswer: (text) => {
+ const p = AI_PRESETS.find((x) => x.id === lastJs.current);
+ return Boolean(p && p.title.test(text));
+ },
+ };
+ return registerDeskApp("ai", handle);
+ }, []);
+
+ // Keep the newest line in view while it's being written, unless the reader scrolled up to read.
+ useLayoutEffect(() => {
+ const el = thread.current;
+ if (!el || !stick.current) return;
+ el.scrollTop = el.scrollHeight;
+ if (typing) {
+ const code = codeEls.current.get(typing.id);
+ const pre = code?.parentElement;
+ if (pre) pre.scrollTop = pre.scrollHeight;
+ }
+ }, [msgs, thinking, typing]);
+
+ const finish = useCallback(() => {
+ busy.current = false;
+ answered.current += 1;
+ setTyping(null);
+ deskSignal("ai:answered");
+ }, []);
+
+ /** Type an answer out: its words, then its code. */
+ const typeOut = useCallback(
+ (m: Extract, sayLen: number) => {
+ const codeLen = m.code?.length ?? 0;
+ const total = sayLen + codeLen;
+ if (reducedRef.current) {
+ setTyping(null);
+ finish();
+ return;
+ }
+ const codeMs = codeLen ? Math.max(CODE_MS.min, Math.min(CODE_MS.max, codeLen * CODE_MS.perChar)) : 0;
+ const t0 = performance.now();
+ const step = (now: number) => {
+ const t = now - t0;
+ const n = t < SAY_MS ? Math.round((t / SAY_MS) * sayLen) : sayLen + (codeMs ? Math.round(((t - SAY_MS) / codeMs) * codeLen) : 0);
+ if (n >= total) {
+ finish();
+ return;
+ }
+ setTyping({ id: m.id, n });
+ raf.current = requestAnimationFrame(step);
+ };
+ setTyping({ id: m.id, n: 0 });
+ raf.current = requestAnimationFrame(step);
+ },
+ [finish],
+ );
+
+ const send = useCallback(() => {
+ const text = draft.trim();
+ if (!text || busy.current) return;
+ busy.current = true;
+ stick.current = true;
+ const you: Msg = { id: nextId.current++, from: "you", text, lines: lineCount(text) };
+ setMsgs((list) => [...list, you]);
+ setDraft("");
+ setThinking(true);
+ const a = answer(text, lastJs.current);
+ sentAt.current[a.kind] = performance.now();
+ deskSignal("ai:sent");
+ if (a.kind === "variation") lastJs.current = a.preset.id;
+ later(reducedRef.current ? 350 : THINK_MS, () => {
+ const m: Msg =
+ a.kind === "other" || a.kind === "layer"
+ ? { id: nextId.current++, from: "ai", said: a.kind }
+ : { id: nextId.current++, from: "ai", said: a.kind, code: a.code, lang: a.lang };
+ setThinking(false);
+ setMsgs((list) => [...list, m]);
+ typeOut(m, AI_WORDS[lang][m.said].length);
+ });
+ }, [draft, lang, later, typeOut]);
+
+ const onSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ send();
+ };
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
+ e.preventDefault();
+ send();
+ }
+ };
+
+ /** The reader has the code: stamped for the tutorial, and said. */
+ const copiedCode = useCallback((m: Extract) => {
+ copiedAt.current[m.said === "conversion" ? "h" : "js"] = performance.now();
+ deskSignal("ai:copied");
+ }, []);
+
+ const copy = async (m: Extract) => {
+ if (!m.code) return;
+ let byHand = false;
+ try {
+ await navigator.clipboard.writeText(m.code);
+ copiedCode(m);
+ } catch {
+ // The clipboard said no: select the code, for the reader's own Ctrl C.
+ byHand = true;
+ const el = codeEls.current.get(m.id);
+ const sel = window.getSelection();
+ if (el && sel) sel.selectAllChildren(el);
+ }
+ setCopied({ id: m.id, byHand });
+ later(byHand ? 4000 : 1600, () => setCopied((c) => (c && c.id === m.id ? null : c)));
+ };
+
+ const lastAi = [...msgs].reverse().find((m) => m.from === "ai");
+
+ return (
+
+
{
+ const el = e.currentTarget;
+ stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 28;
+ }}
+ // The reader's own Ctrl/⌘ C on a code block counts as copying it (the clipboard refused the button, say).
+ onCopy={() => {
+ const node = window.getSelection()?.anchorNode ?? null;
+ for (const [id, el] of codeEls.current) {
+ if (!node || !el.contains(node)) continue;
+ const m = msgs.find((x) => x.id === id);
+ if (m && m.from === "ai") copiedCode(m);
+ }
+ }}
+ >
+ {msgs.map((m) => {
+ if (m.from === "you") {
+ const long = m.text.length > LONG.chars || m.lines > LONG.lines;
+ return (
+
+ {long ? (
+
+ {m.text.split(/\r\n?|\n/, 1)[0]}
+ {w.pasted(m.lines)}
+
+ ) : (
+
{m.text}
+ )}
+
+ );
+ }
+ const say = w[m.said];
+ const live = typing && typing.id === m.id ? typing.n : null;
+ const sayShown = live === null ? say : say.slice(0, Math.min(live, say.length));
+ const codeShown = m.code === undefined ? undefined : live === null ? m.code : m.code.slice(0, Math.max(0, live - say.length));
+ const isLast = !thinking && m === lastAi;
+ const done = live === null;
+ const kind = m.said === "conversion" ? "h" : "js";
+ return (
+
+
+
+
{sayShown}
+ {codeShown !== undefined && (live === null || live > say.length) && (
+
+
+ {m.lang}
+ {done && (
+ void copy(m)}
+ >
+ {copied?.id === m.id ? (copied.byHand ? w.copyByHand(isMac() ? "⌘ C" : "Ctrl C") : w.copied) : w.copy}
+
+ )}
+
+
+ {
+ if (el) codeEls.current.set(m.id, el);
+ else codeEls.current.delete(m.id);
+ }}
+ >
+ {codeShown}
+
+
+
+ )}
+
+
+ );
+ })}
+ {thinking && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/web/src/components/guide/desk/ai/answers.test.ts b/web/src/components/guide/desk/ai/answers.test.ts
new file mode 100644
index 00000000..17bb6692
--- /dev/null
+++ b/web/src/components/guide/desk/ai/answers.test.ts
@@ -0,0 +1,113 @@
+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 { buildCppPrompt } from "@/lib/lab/cppPrompt";
+import { buildHExport } from "@/lib/lab/hExport";
+import { codeLayerFromSource, initialLabPreset } from "@/lib/lab/store/shared";
+import { defaultKnobState } from "@/lib/lab/annotations";
+import { DEFAULT_RAMP_STATE } from "@/lib/lab/types";
+import { livePresets } from "@/lib/presets";
+import { AI_PRESETS, AI_WORDS, answer, recognize } from "./answers";
+
+// The practice AI (06): it knows the Lab's two prompts by lines their own
+// builders write, and answers with a preset's real JavaScript and that same
+// preset's real firmware header — checked here against the files themselves.
+
+const panel = { width: 128, height: 64 };
+const tall = { width: 64, height: 128 };
+const { knobs, ranges, labels } = defaultKnobState();
+
+const variation = (code: string, colorMode: "vfield" | "rgb" = "vfield", matrix = panel) =>
+ buildVariantCopyPrompt(code, knobs, ranges, colorMode, matrix);
+const conversion = (code: string, extra: Partial[0]> = {}) =>
+ buildCppPrompt({ code, matrix: panel, knobs, ranges, knobLabels: labels, ramp: DEFAULT_RAMP_STATE, name: "My pattern", ...extra });
+
+const presetCode = (id: string) => livePresets.find((p) => p.id === `pattern-${id}`)!.code;
+/** The repository's root: this file is web/src/components/guide/desk/ai/. */
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../../..");
+const firmwareHeader = (id: string) =>
+ readFileSync(path.join(ROOT, "firmware/patternflow/presets", `preset_${id.replace(/-/g, "_")}.h`), "utf8").replace(/\r\n/g, "\n");
+
+describe("practice AI: which prompt is this", () => {
+ it("knows the Lab's Copy prompt, in both colour modes and any frame", () => {
+ for (const mode of ["vfield", "rgb"] as const) {
+ expect(recognize(variation(initialLabPreset.code, mode))).toBe("variation");
+ expect(recognize(variation(initialLabPreset.code, mode, tall))).toBe("variation");
+ }
+ });
+
+ it("knows the conversion prompt, whatever the pattern needs", () => {
+ expect(recognize(conversion(initialLabPreset.code))).toBe("conversion");
+ expect(recognize(conversion(presetCode("0510"), { recolor: true }))).toBe("conversion");
+ expect(recognize(conversion(initialLabPreset.code, { matrix: tall }))).toBe("conversion");
+ expect(recognize(conversion(initialLabPreset.code, { name: "" }))).toBe("conversion");
+ // Pasted on Windows, or with a stray line before it.
+ expect(recognize("\r\n" + conversion(initialLabPreset.code).replace(/\n/g, "\r\n"))).toBe("conversion");
+ });
+
+ it("does not take one for the other, or anything else for either", () => {
+ // The variation prompt carries the pattern's code; the conversion prompt carries JS too.
+ expect(recognize(variation(conversion(initialLabPreset.code)))).toBe("variation");
+ expect(recognize("make me a pattern")).toBe("other");
+ expect(recognize(initialLabPreset.code)).toBe("other");
+ expect(recognize(AI_PRESETS[0].h)).toBe("other");
+ // A stack's per-layer prompt (To hardware with layers) is neither: it gets its own set reply.
+ const one = codeLayerFromSource(initialLabPreset.code, "Code 1").layer;
+ const two = codeLayerFromSource(presetCode("0710"), "Code 2").layer;
+ const stack = buildHExport({ name: "Stack", matrix: panel, layers: [two, one], knobs, ranges, knobLabels: labels });
+ expect(stack.units.length).toBeGreaterThan(1);
+ for (const unit of stack.units) {
+ expect(recognize(unit.prompt)).toBe("layer");
+ expect(answer(unit.prompt)).toEqual({ kind: "layer" });
+ }
+ for (const lang of ["en", "ko"] as const) expect(AI_WORDS[lang].layer).toContain("Copy the conversion prompt");
+ });
+});
+
+describe("practice AI: its answers are the real presets", () => {
+ it("answers with presets that are twins, neither of them the Lab's opening pattern", () => {
+ for (const p of AI_PRESETS) {
+ expect(p.js).toBe(presetCode(p.id));
+ expect(p.h).toBe(firmwareHeader(p.id));
+ // What HardwareModal checks before Next → ("Looks like a header ✓").
+ expect(/^\s*#pragma\s+once\b/m.test(p.h)).toBe(true);
+ expect(p.js).not.toBe(initialLabPreset.code);
+ expect(p.title.test(p.js)).toBe(true);
+ expect(p.title.test(p.h)).toBe(true);
+ }
+ });
+
+ it("gives JavaScript that changes what the Lab has", () => {
+ const [a, b] = AI_PRESETS;
+ expect(answer(variation(initialLabPreset.code))).toMatchObject({ kind: "variation", code: a.js, lang: "javascript" });
+ // The Lab already holds the first: the second, so the preview still changes.
+ expect(answer(variation(a.js))).toMatchObject({ kind: "variation", code: b.js });
+ expect(answer(variation(b.js))).toMatchObject({ kind: "variation", code: a.js });
+ });
+
+ it("gives the header of the pattern in the conversion prompt", () => {
+ const [a, b] = AI_PRESETS;
+ expect(answer(conversion(a.js))).toMatchObject({ kind: "conversion", code: a.h, lang: "cpp" });
+ expect(answer(conversion(b.js))).toMatchObject({ kind: "conversion", code: b.h });
+ // A pattern it doesn't know: the header of the JavaScript it last gave, else the first.
+ expect(answer(conversion(initialLabPreset.code), b.id)).toMatchObject({ code: b.h });
+ expect(answer(conversion(initialLabPreset.code))).toMatchObject({ code: a.h });
+ });
+
+ it("has one set reply for everything else", () => {
+ expect(answer("hello")).toEqual({ kind: "other" });
+ expect(answer("")).toEqual({ kind: "other" });
+ });
+
+ it("says everything in both languages", () => {
+ expect(Object.keys(AI_WORDS.en).sort()).toEqual(Object.keys(AI_WORDS.ko).sort());
+ for (const lang of ["en", "ko"] as const) {
+ for (const [k, v] of Object.entries(AI_WORDS[lang])) {
+ const s = typeof v === "function" ? (v as (arg: unknown) => string)(3) : v;
+ expect(s.trim().length, `${lang}.${k}`).toBeGreaterThan(0);
+ }
+ }
+ });
+});
diff --git a/web/src/components/guide/desk/ai/answers.ts b/web/src/components/guide/desk/ai/answers.ts
new file mode 100644
index 00000000..c2a79359
--- /dev/null
+++ b/web/src/components/guide/desk/ai/answers.ts
@@ -0,0 +1,165 @@
+import { preset as p0710 } from "@/lib/presets/pattern-0710";
+import { preset as p0713 } from "@/lib/presets/pattern-0713";
+import type { GuideLang } from "../../store";
+import { HEADER_0710, HEADER_0713 } from "./headers";
+
+// The practice AI's set answers (06 Pattern Lab, on the make page's desk).
+// It knows two prompts, both the Lab's own, and tells them apart by lines
+// their builders always write:
+//
+// the variation prompt Copy prompt, app/pattern-lab/panels/CodePanel.tsx
+// → buildVariantCopyPrompt, lib/ai/gemini.ts
+// the conversion prompt Copy the conversion prompt, app/pattern-lab/HardwareModal.tsx
+// → buildCppPrompt, lib/lab/cppPrompt.ts
+//
+// and one it doesn't answer but names: a layer stack's per-layer prompt (To
+// hardware's Copy prompt when the Lab has layers, lib/lab/hExport.ts
+// buildLayerPrompt), which gets a set reply saying how to get the one-layer
+// conversion prompt instead.
+//
+// To the first it answers with a real preset's JavaScript, to the second
+// with the same preset's real firmware header — web/src/lib/presets and
+// firmware/patternflow/presets are twins (firmware/toolchain/check_presets.py).
+// Anything else gets one polite set reply. answers.test.ts builds both
+// prompts with the Lab's own builders and checks the answers against the
+// preset files.
+
+export type PromptKind = "variation" | "conversion" | "layer" | "other";
+
+/** Lines buildVariantCopyPrompt always writes (its intro and its output rules). */
+export const VARIATION_MARKERS = [
+ "I will give you one existing Patternflow pattern.",
+ "Return exactly 5 separate JavaScript code blocks.",
+] as const;
+
+/** buildCppPrompt's first line, and the heading its JavaScript goes under. */
+export const CONVERSION_MARKERS = [
+ "Convert the JavaScript LED pattern below into a single complete Arduino-compatible C++ header for the Patternflow ESP32-S3 firmware.",
+ "## JavaScript source",
+] as const;
+
+/** buildLayerPrompt's first line (one layer of a stack). */
+export const LAYER_MARKER = "Translate ONE LAYER of a Patternflow layer-stack composition from JavaScript to C++ for the ESP32-S3 firmware.";
+
+export function recognize(text: string): PromptKind {
+ const t = text.replace(/\r\n?/g, "\n");
+ // The conversion prompt first: it opens with its own line, and the JS it
+ // carries could quote anything.
+ if (t.trimStart().startsWith(CONVERSION_MARKERS[0]) && t.includes(CONVERSION_MARKERS[1])) return "conversion";
+ if (t.trimStart().startsWith(LAYER_MARKER)) return "layer";
+ if (VARIATION_MARKERS.every((m) => t.includes(m))) return "variation";
+ return "other";
+}
+
+/** A preset the practice AI answers with: its JS and its firmware header. */
+export type AiPreset = {
+ id: string;
+ /** web/src/lib/presets/pattern-.ts, its `code`. */
+ js: string;
+ /** firmware/patternflow/presets/preset_.h */
+ h: string;
+ /** Its header's title line: how a prompt carrying this pattern is recognised. */
+ title: RegExp;
+};
+
+/**
+ * The first one unless the reader's Lab already holds it: then the second, so
+ * the preview always visibly changes. Neither is the Lab's opening pattern
+ * (0707, the first lab-only preset).
+ */
+export const AI_PRESETS: readonly AiPreset[] = [
+ { id: "0710", js: p0710.code, h: HEADER_0710, title: /^\/\/ Title:\s+260710\s*$/m },
+ { id: "0713", js: p0713.code, h: HEADER_0713, title: /^\/\/ Title:\s+260713_Firefly\s*$/m },
+];
+
+export type SetAnswer =
+ | { kind: "variation"; preset: AiPreset; code: string; lang: "javascript" }
+ | { kind: "conversion"; preset: AiPreset; code: string; lang: "cpp" }
+ | { kind: "layer" }
+ | { kind: "other" };
+
+/**
+ * The set answer to a pasted text. `lastJs` is the preset whose JS this
+ * practice AI last gave: a conversion prompt for a pattern it doesn't know
+ * gets that one's header (the reader edited the code, or pasted another).
+ */
+export function answer(text: string, lastJs: string | null = null): SetAnswer {
+ const kind = recognize(text);
+ if (kind === "variation") {
+ const preset = AI_PRESETS.find((p) => !p.title.test(text)) ?? AI_PRESETS[0];
+ return { kind, preset, code: preset.js, lang: "javascript" };
+ }
+ if (kind === "conversion") {
+ const preset =
+ AI_PRESETS.find((p) => p.title.test(text)) ?? AI_PRESETS.find((p) => p.id === lastJs) ?? AI_PRESETS[0];
+ return { kind, preset, code: preset.h, lang: "cpp" };
+ }
+ return { kind: kind === "layer" ? "layer" : "other" };
+}
+
+/** What the practice AI says, around its code. */
+export type AiWords = {
+ /** The first message, before anything is pasted. */
+ hello: string;
+ /** Before the JavaScript. */
+ variation: string;
+ /** Before the header. */
+ conversion: string;
+ /** To a layer stack's per-layer prompt. */
+ layer: string;
+ /** To anything else. */
+ other: string;
+ placeholder: string;
+ send: string;
+ copy: string;
+ copied: string;
+ /** When the clipboard refused: the code is selected instead, for the reader's own copy keys. */
+ copyByHand: (keys: string) => string;
+ /** The reader's pasted text, collapsed. */
+ pasted: (lines: number) => string;
+ thinking: string;
+ /** The conversation, for assistive tech. */
+ thread: string;
+ input: string;
+};
+
+export const AI_WORDS: Record = {
+ en: {
+ hello: "I'm a practice AI, and I give set answers. Paste the prompt you copied in the Lab, then press Send.",
+ variation: "A real AI would give you five. Here's one. Copy it, then press Paste in the Lab.",
+ conversion:
+ "Here's the .h. It's this pattern's own header from the firmware, so it keeps its own colours and knob ranges, not the ones you set. A real AI writes it from your prompt. Copy it and paste it into the box in the Lab.",
+ layer:
+ "That's a prompt for one layer of a stack. Hide the other layers, so To hardware offers Copy the conversion prompt, and paste that one.",
+ other:
+ "I only give set answers, and I know just two prompts: the one from Copy prompt in the Lab, and the one from Copy the conversion prompt in To hardware. Paste one of those.",
+ placeholder: "Paste here, then Send",
+ send: "Send",
+ copy: "Copy",
+ copied: "Copied ✓",
+ copyByHand: (keys) => `Selected: press ${keys}`,
+ pasted: (n) => `Pasted text · ${n} ${n === 1 ? "line" : "lines"}`,
+ thinking: "Writing…",
+ thread: "Conversation with the practice AI",
+ input: "Message to the practice AI",
+ },
+ ko: {
+ hello: "저는 정해진 답만 하는 연습용 AI예요. 랩에서 복사한 프롬프트를 붙여넣고 보내기를 눌러요.",
+ variation: "진짜 AI라면 다섯 개를 줘요. 여기선 하나예요. 복사한 다음 랩에서 Paste를 눌러요.",
+ conversion:
+ ".h예요. 이 패턴이 펌웨어에 원래 가진 헤더라서, 내가 정한 색과 노브 범위가 아니라 원래 것 그대로예요. 진짜 AI는 내 프롬프트대로 써 줘요. 복사해서 랩의 상자에 붙여넣어요.",
+ layer:
+ "레이어가 여러 개일 때 레이어 하나에 쓰는 프롬프트예요. 다른 레이어를 숨기면 To hardware에 Copy the conversion prompt가 나와요. 그걸로 복사해서 붙여넣어 주세요.",
+ other:
+ "저는 정해진 답만 하고, 아는 프롬프트도 둘뿐이에요. 랩의 Copy prompt로 복사한 것과 To hardware의 Copy the conversion prompt로 복사한 것이요. 둘 중 하나를 붙여넣어 주세요.",
+ placeholder: "여기에 붙여넣고 보내기",
+ send: "보내기",
+ copy: "복사",
+ copied: "복사됨 ✓",
+ copyByHand: (keys) => `선택했어요. ${keys}를 눌러요`,
+ pasted: (n) => `붙여넣은 글 · ${n}줄`,
+ thinking: "쓰는 중…",
+ thread: "연습용 AI와의 대화",
+ input: "연습용 AI에게 보낼 글",
+ },
+};
diff --git a/web/src/components/guide/desk/ai/headers.ts b/web/src/components/guide/desk/ai/headers.ts
new file mode 100644
index 00000000..d0ce5d7e
--- /dev/null
+++ b/web/src/components/guide/desk/ai/headers.ts
@@ -0,0 +1,493 @@
+// The firmware headers the practice AI answers the Lab's conversion prompt
+// with: firmware/patternflow/presets/preset_.h, copied in verbatim
+// (LF line endings). Generated — don't edit by hand. answers.test.ts checks
+// each one still equals its file; when a header changes, copy it in again.
+
+/** firmware/patternflow/presets/preset_0710.h */
+export const HEADER_0710 = `#pragma once
+
+// ===== Patternflow pattern =====
+// Title: 260710
+// Author: Seunghun LEE
+// Date: 2026-07-10
+// SPDX-License-Identifier: CC-BY-SA-4.0
+// ===============================
+
+#include
+#include "config.h"
+#include "../src/core_display.h"
+#include "../src/core_encoders.h"
+#include "../src/core_canvas.h"
+#include "../src/core_math.h"
+#include "../src/core_params.h"
+
+namespace TileWaves {
+
+const char* NAME = "TileWaves";
+const char* const KNOB_LABELS[4] = {"Quantize", "Speed", "PhaseShift", "Sharpness"};
+constexpr bool ABSOLUTE_READY = true;
+
+static const uint8_t RAMP_LUT[256][3] = {
+ {0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},
+ {0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},
+ {1,0,8},{3,0,17},{4,0,25},{6,0,34},{7,0,42},{9,0,50},{10,0,59},{11,0,67},
+ {13,0,76},{14,0,84},{16,0,93},{17,0,101},{19,0,110},{20,0,118},{21,0,127},{23,0,135},
+ {24,0,144},{26,0,152},{27,0,161},{29,0,169},{30,0,178},{31,0,186},{33,0,195},{34,0,203},
+ {36,0,211},{37,0,220},{39,0,228},{40,0,237},{41,0,245},{43,0,254},{42,2,255},{42,4,255},
+ {41,7,255},{41,9,255},{40,11,255},{39,13,255},{39,16,255},{38,18,255},{37,20,255},{37,22,255},
+ {36,25,255},{36,27,255},{35,29,255},{34,32,255},{34,34,255},{33,36,255},{32,38,255},{32,41,255},
+ {31,43,255},{31,45,255},{30,48,255},{29,50,255},{29,52,255},{28,54,255},{27,57,255},{27,59,255},
+ {26,61,255},{26,64,255},{25,66,255},{24,68,255},{24,70,255},{23,73,255},{22,75,255},{22,77,255},
+ {21,80,255},{21,82,255},{20,84,255},{19,86,255},{19,89,255},{18,91,255},{17,93,255},{17,95,255},
+ {16,98,255},{16,100,255},{15,102,255},{14,105,255},{14,107,255},{13,109,255},{12,111,255},{12,114,255},
+ {11,116,255},{11,118,255},{10,121,255},{9,123,255},{9,125,255},{8,127,255},{7,130,255},{7,132,255},
+ {6,134,255},{6,137,255},{5,139,255},{4,141,255},{4,143,255},{3,146,255},{2,148,255},{2,150,255},
+ {1,152,255},{1,155,255},{0,157,255},{7,153,248},{14,148,241},{21,144,234},{28,140,227},{35,135,220},
+ {42,131,213},{49,127,206},{56,122,199},{63,118,192},{70,114,185},{77,110,178},{84,105,171},{91,101,164},
+ {98,97,157},{105,92,150},{112,88,143},{119,84,136},{126,79,129},{133,75,122},{140,71,115},{147,67,108},
+ {154,62,101},{161,58,94},{168,54,87},{175,49,80},{182,45,73},{189,41,66},{196,36,59},{203,32,52},
+ {210,28,45},{217,23,38},{224,19,31},{231,15,24},{238,11,17},{245,6,10},{252,2,3},{255,0,0},
+ {255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},
+ {255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},
+ {255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},
+ {255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},
+ {255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},
+ {255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},
+ {255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},
+ {255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},
+ {255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},
+ {255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},
+ {255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},
+ {255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},
+ {255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},{255,0,0},
+};
+
+// Knob state initialized to Pattern Lab current values
+static float quantizeState = 6.533f;
+static float speedState = 7.048f;
+static float phaseShiftState = 3.0f;
+static float sharpnessState = 1.0f;
+
+// Calibrated steps per detent
+static const float TILE_QUANTIZE_STEP = 0.05f;
+static const float TILE_SPEED_STEP = 0.1f;
+static const float TILE_PHASESHIFT_STEP = 0.05f;
+static const float TILE_SHARPNESS_STEP = 0.05f;
+
+// Range limits
+static const float TILE_QUANTIZE_MIN = 1.0f;
+static const float TILE_QUANTIZE_MAX = 12.0f;
+static const float TILE_SPEED_MIN = 0.1f;
+static const float TILE_SPEED_MAX = 10.0f;
+static const float TILE_PHASESHIFT_MIN = 0.0f;
+static const float TILE_PHASESHIFT_MAX = 5.0f;
+static const float TILE_SHARPNESS_MIN = 1.0f;
+static const float TILE_SHARPNESS_MAX = 10.0f;
+
+static float timeAcc = 0.0f;
+
+// tileSize is fixed at 8; precompute tile centers and distances for all grid cells.
+// Grid dimensions: (PANEL_RES_W/8 + 1) x (PANEL_RES_H/8 + 1) — center of panel is (w/2, h/2) in pixel coords.
+// We precompute dist for each grid cell once per frame since phaseShift can change,
+// but the gridX/gridY → dist mapping is parameter-dependent only via phaseShift multiplier applied later.
+// Actually dist itself is parameter-independent (pure geometry), so precompute it in setup.
+// gridCols = ceil(PANEL_RES_W / 8.0f), gridRows = ceil(PANEL_RES_H / 8.0f).
+// We store dist for each (gridX, gridY) so we don't recalc sqrtf per pixel.
+
+static const int TILE_SIZE = 8;
+static int gridCols;
+static int gridRows;
+static float* gridDist; // size gridCols * gridRows; dist from tile center to panel center
+
+static float cx, cy;
+
+void setup() {
+ PFMath::buildSinLUT();
+
+ cx = PANEL_RES_W * 0.5f;
+ cy = PANEL_RES_H * 0.5f;
+ gridCols = (PANEL_RES_W + TILE_SIZE - 1) / TILE_SIZE;
+ gridRows = (PANEL_RES_H + TILE_SIZE - 1) / TILE_SIZE;
+ gridDist = new float[gridCols * gridRows];
+
+ float halfTile = TILE_SIZE * 0.5f;
+ for (int gy = 0; gy < gridRows; gy++) {
+ float ty = gy * TILE_SIZE + halfTile;
+ float dy = ty - cy;
+ int rowBase = gy * gridCols;
+ for (int gx = 0; gx < gridCols; gx++) {
+ float tx = gx * TILE_SIZE + halfTile;
+ float dx = tx - cx;
+ gridDist[rowBase + gx] = sqrtf(dx * dx + dy * dy);
+ }
+ }
+}
+
+void update(float dt, const InputFrame& input) {
+ PFParams::apply(input, 0, &quantizeState, TILE_QUANTIZE_MIN, TILE_QUANTIZE_MAX, TILE_QUANTIZE_STEP);
+
+ PFParams::apply(input, 1, &speedState, TILE_SPEED_MIN, TILE_SPEED_MAX, TILE_SPEED_STEP);
+
+ PFParams::apply(input, 2, &phaseShiftState, TILE_PHASESHIFT_MIN, TILE_PHASESHIFT_MAX, TILE_PHASESHIFT_STEP);
+
+ PFParams::apply(input, 3, &sharpnessState, TILE_SHARPNESS_MIN, TILE_SHARPNESS_MAX, TILE_SHARPNESS_STEP);
+
+ timeAcc += dt * speedState;
+ // timeAcc feeds fastSin with integer multiplier (3.0) and floorf for quantization.
+ // Wrap at TWO_PI for the sin use; the quantization floorf operates on the wrapped value
+ // but produces the same floorf(localTime/step) since step is fractional and TWO_PI is the
+ // period of the underlying sine. The phase offset (dist * phaseShift * 0.08) drifts but is
+ // added before floorf. To keep floorf quantization stable we need to preserve the
+ // unbounded drift in the offset, but wrapping timeAcc at 2π is correct because
+ // sin(theta - 2π*k) == sin(theta) and floorf((theta - 2π*k)/step) differs from
+ // floorf(theta/step) by an integer, which sin eliminates. So wrap is safe.
+ if (timeAcc > TWO_PI) {
+ timeAcc -= TWO_PI;
+ } else if (timeAcc < -TWO_PI) {
+ timeAcc += TWO_PI;
+ }
+}
+
+void draw() {
+ float t = timeAcc;
+ float quantize = quantizeState;
+ float phaseShift = phaseShiftState;
+ float sharpness = sharpnessState;
+
+ int tileSize = TILE_SIZE;
+ int cols = gridCols;
+ int rows = gridRows;
+
+ float quantizeStep = 1.0f;
+ float invQuantize = 1.0f;
+ if (quantize > 1.0f) {
+ quantizeStep = 1.0f / quantize;
+ invQuantize = quantize;
+ }
+
+ for (int y = 0; y < PANEL_RES_H; y++) {
+ int gridY = y / tileSize;
+ int rowBase = gridY * cols;
+
+ // tile border check: top edge of tile
+ bool borderY = (y % tileSize == 0);
+
+ for (int x = 0; x < PANEL_RES_W; x++) {
+ int gridX = x / tileSize;
+ float dist = gridDist[rowBase + gridX];
+
+ // Phase-delayed, time-quantized motion
+ float localTime = t - dist * phaseShift * 0.08f;
+ if (quantize > 1.0f) {
+ localTime = floorf(localTime * invQuantize) * quantizeStep;
+ }
+
+ // Concentric tile waves
+ float wave = PFMath::fastSin(dist * 0.25f - localTime * 3.0f);
+ float val = (wave + 1.0f) * 0.5f;
+
+ // Sharpness power scaling
+ if (val > 0.0f) {
+ val = PFMath::fastPow(val, sharpness);
+ }
+
+ // Tile frame borders
+ if (borderY || (x % tileSize == 0)) {
+ val = 0.0f;
+ }
+
+ // Clamp
+ if (val < 0.0f) val = 0.0f;
+ if (val > 1.0f) val = 1.0f;
+
+ int li = (int)(val * 255.0f + 0.5f);
+ PFCanvas::setPixel(x, y, RAMP_LUT[li][0], RAMP_LUT[li][1], RAMP_LUT[li][2]);
+ }
+ }
+
+ PFCanvas::present();
+}
+
+} // namespace TileWaves
+
+// ── Made with Patternflow Pattern Lab · https://patternflow.work/pattern-lab ──
+// Shared under CC-BY-SA-4.0. Attribution is part of this licence —
+// please keep this notice and the author credit above when you reuse,
+// remix, or redistribute this pattern. Do not delete it.
+`;
+
+/** firmware/patternflow/presets/preset_0713.h */
+export const HEADER_0713 = `#pragma once
+
+// ===== Patternflow pattern =====
+// Title: 260713_Firefly
+// Author: Seunghun LEE
+// Date: 2026-07-13
+// SPDX-License-Identifier: CC-BY-SA-4.0
+// ===============================
+
+#include
+#include "config.h"
+#include "../src/core_display.h"
+#include "../src/core_encoders.h"
+#include "../src/core_canvas.h"
+#include "../src/core_math.h"
+#include "../src/core_noise.h"
+#include "../src/core_params.h"
+
+namespace FireflyHollow {
+
+const char* NAME = "Firefly Hollow";
+const char* const KNOB_LABELS[4] = {"Flies", "Speed", "Glow", "Wind"};
+constexpr bool ABSOLUTE_READY = true;
+
+// Ramp LUT – DO NOT EDIT (generated from user ramp, 256 entries)
+static const uint8_t RAMP_LUT[256][3] = {
+ {0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},
+ {0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},
+ {11,10,10},{26,24,24},{41,36,36},{57,47,47},{72,56,56},{87,64,64},{103,70,70},{118,75,75},
+ {133,79,79},{149,81,81},{164,82,82},{179,81,81},{195,79,79},{210,75,75},{225,70,70},{241,64,64},
+ {254,58,56},{254,68,47},{254,80,38},{254,94,29},{255,109,20},{255,126,11},{255,144,2},{255,165,38},
+ {255,185,87},{255,206,138},{255,227,187},{255,248,238},{255,250,227},{255,243,183},{255,236,140},{255,229,96},
+ {255,222,53},{255,215,9},{255,207,0},{255,200,0},{255,193,0},{255,185,0},{255,178,0},{255,171,0},
+ {255,163,0},{255,156,0},{255,149,0},{255,141,0},{255,134,0},{255,127,0},{255,119,0},{255,112,0},
+ {255,105,0},{255,97,0},{255,90,0},{255,83,0},{255,75,0},{255,68,0},{255,61,0},{255,53,0},
+ {255,46,0},{255,39,0},{255,31,0},{255,24,0},{255,17,0},{255,9,0},{255,2,0},{255,48,39},
+ {255,109,93},{255,164,147},{255,213,201},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+ {255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},{255,240,235},
+};
+
+// Knob state (initial values from Pattern Lab)
+static float knobFlies = 34.0f; // 2..34
+static float knobSpeed = 2.275f; // 0.2..5
+static float knobGlow = 1.32f; // 0..2
+static float knobWind = 0.409f; // 0..2
+
+static constexpr int MAX_FLIES = 16;
+static int numFlies = 16; // active count (capped at 16)
+
+// Firefly motion parameters (constant per firefly, set in setup)
+static float fly_s1[MAX_FLIES];
+static float fly_s2[MAX_FLIES];
+static float fly_hb[MAX_FLIES];
+
+// Phase accumulators (each wraps at TWO_PI)
+static float phase_fu[MAX_FLIES]; // main x-osc
+static float phase_fv_main[MAX_FLIES]; // main y-osc
+static float phase_fv_extra[MAX_FLIES];// secondary y-osc (freq 0.9)
+static float phase_b[MAX_FLIES]; // brightness oscillation
+
+// Output buffers used by draw()
+static float fu[MAX_FLIES];
+static float fv[MAX_FLIES];
+static float fb[MAX_FLIES];
+
+// Grass sway phases
+static float grass_phase1 = 0.0f; // freq 1.8
+static float grass_phase2 = 0.0f; // freq 2.6
+
+// Cloud drift accumulator (unwrapped – feeds noise coordinates, not sin)
+static float cloudT = 0.0f;
+
+void setup() {
+ PFMath::buildSinLUT(); // idempotent
+
+ for (int i = 0; i < MAX_FLIES; ++i) {
+ // deterministic per-firefly constants (replace sin‑based hash with cellHash)
+ fly_s1[i] = 0.13f + 0.11f * PFNoise::cellHash(i, 0, 7);
+ fly_s2[i] = 0.09f + 0.13f * PFNoise::cellHash(i, 0, 8);
+ fly_hb[i] = PFNoise::cellHash(i, 0, 9);
+
+ // random initial phases
+ phase_fu[i] = PFNoise::cellHash(i, 0, 10) * TWO_PI;
+ phase_fv_main[i] = PFNoise::cellHash(i, 0, 11) * TWO_PI;
+ phase_fv_extra[i]= PFNoise::cellHash(i, 0, 12) * TWO_PI;
+ phase_b[i] = PFNoise::cellHash(i, 0, 13) * TWO_PI;
+
+ // initial values so first frame is valid
+ fu[i] = 0.5f + 0.44f * PFMath::fastSin(phase_fu[i] + i * 2.39f);
+ fv[i] = 0.62f
+ + 0.30f * PFMath::fastSin(phase_fv_main[i] + i * 5.17f)
+ + 0.05f * PFMath::fastSin(phase_fv_extra[i] + (float)i);
+ float p = PFMath::fastSin(phase_b[i] + i * 1.7f);
+ float pp = fmaxf(0.0f, p);
+ fb[i] = pp * pp * pp; // x^3
+ }
+
+ grass_phase1 = 0.0f;
+ grass_phase2 = 0.0f;
+ cloudT = 0.0f;
+}
+
+void update(float dt, const InputFrame& input) {
+ // ----- knobs -----
+ PFParams::apply(input, 0, &knobFlies, 2.0f, 34.0f, 0.05f);
+
+ PFParams::apply(input, 1, &knobSpeed, 0.2f, 5.0f, 0.1f);
+
+ PFParams::apply(input, 2, &knobGlow, 0.0f, 2.0f, 0.05f);
+
+ PFParams::apply(input, 3, &knobWind, 0.0f, 2.0f, 0.05f);
+
+ int flies = (int)lroundf(knobFlies);
+ if (flies < 2) flies = 2;
+ if (flies > 16) flies = 16;
+ numFlies = flies;
+
+ const float speed = knobSpeed;
+
+ // ----- firefly phases & buffers -----
+ for (int i = 0; i < MAX_FLIES; ++i) {
+ phase_fu[i] += dt * speed * fly_s1[i] * 2.0f;
+ phase_fv_main[i] += dt * speed * fly_s2[i] * 2.0f;
+ phase_fv_extra[i] += dt * speed * 0.9f;
+ float freqB = 0.8f + 0.5f * fly_hb[i];
+ phase_b[i] += dt * speed * freqB;
+
+ // wrap each phase at TWO_PI
+ while (phase_fu[i] > TWO_PI) phase_fu[i] -= TWO_PI;
+ while (phase_fv_main[i] > TWO_PI) phase_fv_main[i] -= TWO_PI;
+ while (phase_fv_extra[i] > TWO_PI) phase_fv_extra[i] -= TWO_PI;
+ while (phase_b[i] > TWO_PI) phase_b[i] -= TWO_PI;
+
+ fu[i] = 0.5f + 0.44f * PFMath::fastSin(phase_fu[i] + i * 2.39f);
+ fv[i] = 0.62f
+ + 0.30f * PFMath::fastSin(phase_fv_main[i] + i * 5.17f)
+ + 0.05f * PFMath::fastSin(phase_fv_extra[i] + (float)i);
+ float p = PFMath::fastSin(phase_b[i] + i * 1.7f);
+ float pp = fmaxf(0.0f, p);
+ fb[i] = pp * pp * pp;
+ }
+
+ // ----- grass phases -----
+ grass_phase1 += dt * speed * 1.8f;
+ while (grass_phase1 > TWO_PI) grass_phase1 -= TWO_PI;
+ grass_phase2 += dt * speed * 2.6f;
+ while (grass_phase2 > TWO_PI) grass_phase2 -= TWO_PI;
+
+ // ----- cloud drift (offset only, no sin → precision loss negligible) -----
+ cloudT += dt * speed * (0.4f + knobWind * 0.8f);
+}
+
+void draw() {
+ const int W = PANEL_RES_W;
+ const int H = PANEL_RES_H;
+
+ const float vh = (float)W; // logical vertical extent
+ const float vw = (float)H; // logical horizontal extent
+ const float vh_half = vh * 0.5f;
+
+ const float glowSq = knobGlow * knobGlow;
+ const float wind = knobWind;
+ const int n = numFlies;
+
+ // ----- pre‑compute per‑row data (depends only on u = y) -----
+ float hillTop[H];
+ float grassGh[H];
+ for (int u = 0; u < H; ++u) {
+ float hu = (float)u;
+ hillTop[u] = vh * 0.58f
+ + PFMath::fastSin(hu * 0.09f + 2.0f) * 5.0f
+ + PFMath::fastSin(hu * 0.023f) * 8.0f;
+
+ int gx = u / 2;
+ float hh = PFNoise::cellHash(gx, 0, 91); // 0..1
+ grassGh[u] = 10.0f + hh * 16.0f;
+ }
+
+ const float cloudDrift = cloudT * (0.4f + wind * 0.8f);
+
+ for (int y = 0; y < H; ++y) {
+ const int u = y; // logical x in JS
+ const float hill = hillTop[u];
+ const float gh = grassGh[u];
+
+ for (int x = 0; x < W; ++x) {
+ const int v = W - 1 - x; // logical y in JS
+ const float g = (float)v / vh; // normalized 0..1
+
+ float val = 0.05f + 0.14f * g;
+
+ // ----- clouds (only upper half of the view) -----
+ if ((float)v < vh_half) {
+ float cn = PFNoise::valueNoise2D(u * 0.045f + cloudDrift * 0.12f,
+ v * 0.09f + cloudDrift * 0.015f) * 0.65f
+ + PFNoise::valueNoise2D(u * 0.11f - cloudDrift * 0.07f,
+ v * 0.22f + 40.0f) * 0.35f;
+ cn *= 1.0f - (g / 0.5f) * 0.7f; // g < 0.5 here
+ float cd = (cn - 0.38f) / 0.18f;
+ if (cd > 0.0f) {
+ float soft = (cd >= 1.0f) ? 1.0f
+ : cd * cd * (3.0f - 2.0f * cd); // smoothstep
+ val += soft * 0.13f * 1.5f; // lit = 1.5
+ }
+ }
+
+ // ----- distant hill silhouette -----
+ if (v >= hill) {
+ val = 0.07f;
+ }
+
+ // ----- foreground grass (swaying) -----
+ float sway = PFMath::fastSin(u * 0.3f + grass_phase1) * wind * 4.0f
+ + PFMath::fastSin(u * 0.9f - grass_phase2) * wind * 2.0f;
+ float grassTop = vh - gh + sway;
+ if (v >= grassTop) {
+ val = 0.015f;
+ if (v - grassTop < 1.2f) val = 0.11f; // moonlit tips
+ }
+
+ // ----- fireflies (additive glow) -----
+ for (int i = 0; i < n; ++i) {
+ float du = (float)u - fu[i] * vw;
+ float dv = (float)v - fv[i] * vh;
+ float dd = du * du + dv * dv;
+ if (dd < glowSq * 9.0f) {
+ val += fb[i] * expf(-dd / glowSq);
+ }
+ }
+
+ // ----- clamp and map through ramp -----
+ if (val < 0.0f) val = 0.0f;
+ if (val > 1.0f) val = 1.0f;
+ int idx = (int)(val * 255.0f + 0.5f);
+ if (idx < 0) idx = 0;
+ if (idx > 255) idx = 255;
+
+ PFCanvas::setPixel(x, y,
+ RAMP_LUT[idx][0],
+ RAMP_LUT[idx][1],
+ RAMP_LUT[idx][2]);
+ }
+ }
+
+ PFCanvas::present();
+}
+
+} // namespace FireflyHollow
+
+// ── Made with Patternflow Pattern Lab · https://patternflow.work/pattern-lab ──
+// Shared under CC-BY-SA-4.0. Attribution is part of this licence —
+// please keep this notice and the author credit above when you reuse,
+// remix, or redistribute this pattern. Do not delete it.
+`;
diff --git a/web/src/components/guide/desk/apps.ts b/web/src/components/guide/desk/apps.ts
new file mode 100644
index 00000000..d90b5aa5
--- /dev/null
+++ b/web/src/components/guide/desk/apps.ts
@@ -0,0 +1,15 @@
+import type { DeskApp, DeskWin } from "./types";
+import { COMMUNITY_APP } from "./apps/community";
+import { AI_APP } from "./apps/ai";
+
+// The practice windows, by window: their chrome and their content. The Lab
+// window is not in here — it is the real /pattern-lab, and the desk frames
+// it itself (DeskStage.tsx LabFrame).
+//
+// Each app is owned by its chapter: apps/community.tsx by 05 Community,
+// apps/ai.tsx by 06 Pattern Lab. This file only registers them.
+
+export const DESK_APPS: Record, DeskApp> = {
+ community: COMMUNITY_APP,
+ ai: AI_APP,
+};
diff --git a/web/src/components/guide/desk/apps/ai.tsx b/web/src/components/guide/desk/apps/ai.tsx
new file mode 100644
index 00000000..92ca53ac
--- /dev/null
+++ b/web/src/components/guide/desk/apps/ai.tsx
@@ -0,0 +1,14 @@
+import type { DeskApp } from "../types";
+import PracticeAi from "../ai/PracticeAi";
+
+// The practice AI (06): a small generic chat that gives set answers — no
+// logos, no product names, no brand colours. Its content is desk/ai/
+// (PracticeAi.tsx, and answers.ts for what it says). OWNED BY THE PATTERN LAB
+// CHAPTER; desk/apps.ts registers it.
+
+export const AI_APP: DeskApp = {
+ chrome: {
+ title: { en: "Practice AI · set answers", ko: "연습용 AI · 정해진 답만 해요" },
+ },
+ Component: PracticeAi,
+};
diff --git a/web/src/components/guide/desk/apps/community.tsx b/web/src/components/guide/desk/apps/community.tsx
new file mode 100644
index 00000000..0f1a53c8
--- /dev/null
+++ b/web/src/components/guide/desk/apps/community.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import type { DeskApp } from "../types";
+import CommunityApp from "../community/CommunityApp";
+
+// The practice community (05): a small replica of the community's real UI,
+// holding placeholder patterns, connected to nothing. Its content is
+// desk/community/ (CommunityApp and its parts); this file only gives the
+// window its chrome, for desk/apps.ts to register. Owned by the community
+// chapter.
+
+export const COMMUNITY_APP: DeskApp = {
+ chrome: {
+ title: { en: "Community / Patternflow", ko: "Community / Patternflow" },
+ address: { host: "community.patternflow.work", path: "/community/patterns" },
+ badge: { en: "Practice", ko: "연습용" },
+ },
+ Component: CommunityApp,
+};
diff --git a/web/src/components/guide/desk/community/CommunityApp.tsx b/web/src/components/guide/desk/community/CommunityApp.tsx
new file mode 100644
index 00000000..046abce2
--- /dev/null
+++ b/web/src/components/guide/desk/community/CommunityApp.tsx
@@ -0,0 +1,197 @@
+"use client";
+
+import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
+import cs from "@/components/community/Community.module.css";
+import { registerDeskApp, useDeskStore } from "../deskStore";
+import { beatsFor } from "../script";
+import { resolveTarget } from "../target";
+import type { DeskAppProps } from "../types";
+import type { CommunityHandle, PracticePage } from "./handle";
+import { patternById } from "./data";
+import { setScreensActive } from "./live";
+import { DeckPage, DecksPage, Header, PatternPage, ViewContext, Wall } from "./pages";
+import { PracticeNote } from "./parts";
+import Dock from "./Dock";
+import SendDialog from "./SendDialog";
+import { usePractice } from "./state";
+import { PRACTICE_WORDS } from "./words";
+import ps from "./Practice.module.css";
+
+// The practice community (05): a small copy of community.patternflow.work
+// inside the desk's community window — the wall, a pattern's page and its
+// send dialog, the deck bar, the Decks page and a deck's page — holding
+// placeholder patterns (data.ts) and connected to nothing. The chapter's
+// tutorial (tutorials/community.ts) points into it and waits for the reader
+// to do each thing here themselves.
+//
+// Rendered once for the whole visit, never unmounted between steps (the desk
+// keeps it), so what the reader built in one step is still there in the next.
+
+/** The deck bar's height: what hides the bottom of the page. */
+const DOCK_H = 68;
+
+function pathOf(page: PracticePage): string {
+ switch (page.kind) {
+ case "wall":
+ return "/community/patterns";
+ case "pattern":
+ return `/community/p/${page.id}`;
+ case "decks":
+ return "/community/decks";
+ case "deck":
+ return `/community/d/${page.id}`;
+ }
+}
+
+export default function CommunityApp({ lang, shown, scene, step, reduced, setPath }: DeskAppProps) {
+ const page = usePractice((s) => s.page);
+ const visit = usePractice((s) => s.visit);
+ const send = usePractice((s) => s.send);
+ const note = usePractice((s) => s.note);
+ const rootRef = useRef(null);
+ const scrollRef = useRef(null);
+ const [view, setView] = useState<{ w: number; h: number } | null>(null);
+ const words = PRACTICE_WORDS[lang];
+
+ // The address bar says where the reader is.
+ useEffect(() => {
+ setPath(pathOf(page));
+ }, [page, setPath]);
+
+ // A new page starts at its top, as a navigation does.
+ useLayoutEffect(() => {
+ scrollRef.current?.scrollTo({ top: 0 });
+ }, [visit]);
+
+ // Screens run only while the window is on the desk.
+ useEffect(() => {
+ setScreensActive(shown);
+ }, [shown]);
+ useEffect(() => () => setScreensActive(false), []);
+
+ // The window's visible size, for what has to fit without scrolling (read before the first paint).
+ useLayoutEffect(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ const measure = () => {
+ if (el.clientWidth < 1 || el.clientHeight < 1) return;
+ setView((v) => (v && v.w === el.clientWidth && v.h === el.clientHeight ? v : { w: el.clientWidth, h: el.clientHeight }));
+ };
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
+ // The tutorial's way in (desk/types.ts, tutorials/community.ts).
+ useEffect(() => {
+ const handle: CommunityHandle = {
+ page: () => usePractice.getState().page,
+ hasCpp: (id) => Boolean(patternById(id)?.hasCpp),
+ flashableOnly: () => usePractice.getState().hw,
+ deck: () => usePractice.getState().deck,
+ sending: () => {
+ const s = usePractice.getState().send;
+ return !s ? "closed" : s.sent ? "sent" : "open";
+ },
+ built: () => usePractice.getState().build,
+ demoPlay: (id) => {
+ const st = usePractice.getState();
+ st.setDemo({ id, turning: false });
+ const timers: number[] = [];
+ // After a moment, the wheel: K1 turns a little, one notch at a time.
+ const start = st.knobsOf(id);
+ for (let i = 1; i <= 10; i++) {
+ timers.push(
+ window.setTimeout(() => {
+ const s = usePractice.getState();
+ const now = s.knobsOf(id).slice();
+ now[0] = Number(((start[0] + i * 0.04) % 1).toFixed(3));
+ s.setKnobs(id, now);
+ }, 1500 + i * 110),
+ );
+ }
+ timers.push(window.setTimeout(() => usePractice.getState().setDemo(null), 4200));
+ return () => {
+ for (const t of timers) window.clearTimeout(t);
+ const s = usePractice.getState();
+ if (s.demo?.id === id) s.setDemo(null);
+ };
+ },
+ demoAdd: (id) => {
+ usePractice.getState().add(id);
+ },
+ };
+ return registerDeskApp("community", handle);
+ }, []);
+
+ // Whatever the pointer is aiming at in this window is scrolled into view
+ // (clear of the deck bar), the way a person would scroll to it.
+ const run = useDeskStore((s) => s.run);
+ const runKey = run?.key;
+ const runIndex = run?.index;
+ useEffect(() => {
+ if (runKey !== `${scene}.${step}` || runIndex === undefined) return;
+ const beat = beatsFor(scene, step)[runIndex];
+ if (!beat || beat.win !== "community" || !beat.target) return;
+ let tries = 0;
+ let id = 0;
+ const reveal = () => {
+ const scroller = scrollRef.current;
+ const el = resolveTarget(rootRef.current, beat.target);
+ if (!scroller || !el) {
+ if (tries++ < 10) id = window.setTimeout(reveal, 120);
+ return;
+ }
+ if (!scroller.contains(el)) return; // the bar, a dialog: always in view
+ const box = scroller.getBoundingClientRect();
+ const r = el.getBoundingClientRect();
+ const top = box.top + 12;
+ const bottom = box.bottom - DOCK_H - 16;
+ let by = 0;
+ if (r.top < top) by = r.top - top;
+ else if (r.bottom > bottom) by = Math.min(r.top - top, r.bottom - bottom);
+ if (Math.abs(by) > 1) scroller.scrollBy({ top: by, behavior: reduced ? "auto" : "smooth" });
+ };
+ id = window.setTimeout(reveal, 60);
+ return () => window.clearTimeout(id);
+ }, [runKey, runIndex, scene, step, reduced]);
+
+ // Esc closes the send dialog.
+ useEffect(() => {
+ if (!send) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && rootRef.current?.contains(document.activeElement)) usePractice.getState().closeSend();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [send]);
+
+ const st = usePractice.getState;
+ const style = { "--pc-view-h": `${Math.max(0, (view?.h ?? 640) - DOCK_H)}px` } as CSSProperties;
+
+ return (
+
+
+
+ {/* Where the real site puts its firmware notice: what this window is. It stays. */}
+
+ {words.tag}
+ {words.banner}
+
+
+
+ {page.kind === "wall" &&
}
+ {page.kind === "pattern" &&
}
+ {page.kind === "decks" &&
}
+ {page.kind === "deck" &&
}
+
+
+
+
+ {send && }
+ {note && st().unsay()} />}
+
+
+ );
+}
diff --git a/web/src/components/guide/desk/community/Dock.tsx b/web/src/components/guide/desk/community/Dock.tsx
new file mode 100644
index 00000000..c9d3e91b
--- /dev/null
+++ b/web/src/components/guide/desk/community/Dock.tsx
@@ -0,0 +1,318 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState, type DragEvent, type PointerEvent as ReactPointerEvent } from "react";
+import cs from "@/components/community/Community.module.css";
+import { deskSignal } from "../deskStore";
+import { COMMUNITY_SIGNAL } from "./handle";
+import { DECK_MAX, MODULE_KB, patternById } from "./data";
+import { PRACTICE_DRAG_TYPE, Screen } from "./parts";
+import { usePractice } from "./state";
+import ps from "./Practice.module.css";
+
+// The deck bar along the bottom (components/community/DeckDock.tsx), on the
+// practice's local deck: drag a slot to reorder it, drag it onto ✕ to throw
+// it out, press ✕ twice to empty the lot, drop a card from the wall onto the
+// bar to add it. Send to my board does not reach a build server here: it
+// says what would (words.ts "built") and shows the bar's own next states.
+
+/** The row never shows fewer places than this (DeckDock MIN_PLACES). */
+const MIN_PLACES = 3;
+const buildExpectedS = (n: number) => Math.ceil(6 + 1.5 * Math.max(1, n));
+
+type Drag = { index: number; startX: number; dx: number; step: number; overTrash: boolean };
+
+export default function Dock() {
+ const deck = usePractice((s) => s.deck);
+ const build = usePractice((s) => s.build);
+ const builtN = usePractice((s) => s.builtN);
+ const knobs = usePractice((s) => s.knobs);
+ const st = usePractice.getState;
+
+ const [confirmEmpty, setConfirmEmpty] = useState(false);
+ const [drag, setDrag] = useState(null);
+ const [settling, setSettling] = useState(false);
+ const [dropping, setDropping] = useState(false);
+ const [dropNote, setDropNote] = useState(null);
+ const slotsRef = useRef(null);
+ const trashRef = useRef(null);
+ const dragRef = useRef(null);
+ const settleRef = useRef(0);
+ const timers = useRef([]);
+ useEffect(
+ () => () => {
+ cancelAnimationFrame(settleRef.current);
+ for (const t of timers.current) window.clearTimeout(t);
+ },
+ [],
+ );
+ const later = (fn: () => void, ms: number) => timers.current.push(window.setTimeout(fn, ms));
+
+ // Send to my board goes disabled while it builds and then gives way to
+ // Send over Wi-Fi: the keyboard is handed on to it rather than dropped.
+ const wifiRef = useRef(null);
+ const handOn = useRef(false);
+ useEffect(() => {
+ if (build === "done" && handOn.current) {
+ handOn.current = false;
+ wifiRef.current?.focus({ preventScroll: true });
+ }
+ }, [build]);
+
+ // ── dragging a slot (DeckDock's own maths) ──────────────────────────────────
+
+ const landing = drag ? Math.max(0, Math.min(deck.length - 1, drag.index + Math.round(drag.dx / drag.step))) : -1;
+
+ const endDrag = useCallback(() => {
+ const cur = dragRef.current;
+ dragRef.current = null;
+ if (!cur) {
+ setDrag(null);
+ return;
+ }
+ setSettling(true);
+ const now = usePractice.getState();
+ if (cur.overTrash) {
+ const doomed = now.deck[cur.index];
+ if (doomed) {
+ now.remove(doomed);
+ deskSignal(COMMUNITY_SIGNAL.removed);
+ }
+ } else {
+ const to = Math.max(0, Math.min(now.deck.length - 1, cur.index + Math.round(cur.dx / cur.step)));
+ if (to !== cur.index) {
+ now.reorder(cur.index, to);
+ deskSignal(COMMUNITY_SIGNAL.reordered);
+ }
+ }
+ setDrag(null);
+ cancelAnimationFrame(settleRef.current);
+ settleRef.current = requestAnimationFrame(() => {
+ settleRef.current = requestAnimationFrame(() => setSettling(false));
+ });
+ }, []);
+
+ useEffect(() => {
+ if (!drag) return;
+ const move = (e: PointerEvent) => {
+ const cur = dragRef.current;
+ if (!cur) return;
+ const bin = trashRef.current?.getBoundingClientRect();
+ const overTrash = bin ? e.clientX >= bin.left && e.clientX <= bin.right && e.clientY >= bin.top && e.clientY <= bin.bottom : false;
+ const next = { ...cur, dx: e.clientX - cur.startX, overTrash };
+ dragRef.current = next;
+ setDrag(next);
+ };
+ window.addEventListener("pointermove", move);
+ window.addEventListener("pointerup", endDrag);
+ window.addEventListener("pointercancel", endDrag);
+ return () => {
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", endDrag);
+ window.removeEventListener("pointercancel", endDrag);
+ };
+ }, [drag, endDrag]);
+
+ const startDrag = (index: number) => (e: ReactPointerEvent) => {
+ if (e.button !== 0) return;
+ const row = slotsRef.current;
+ if (!row) return;
+ e.preventDefault();
+ const boxes = [...row.children].map((c) => c.getBoundingClientRect());
+ const step = boxes.length > 1 ? boxes[1].left - boxes[0].left : (boxes[0]?.width ?? 34);
+ const started = { index, startX: e.clientX, dx: 0, step, overTrash: false };
+ dragRef.current = started;
+ setDrag(started);
+ };
+
+ const shiftFor = (index: number): string => {
+ if (!drag) return "";
+ if (index === drag.index) return `translateX(${drag.dx}px)`;
+ if (drag.overTrash) return "";
+ if (drag.index < landing && index > drag.index && index <= landing) return `translateX(${-drag.step}px)`;
+ if (drag.index > landing && index < drag.index && index >= landing) return `translateX(${drag.step}px)`;
+ return "";
+ };
+
+ // ── a card dropped in from the wall ─────────────────────────────────────────
+
+ const carries = (e: DragEvent) => e.dataTransfer.types.includes(PRACTICE_DRAG_TYPE);
+ const onDragOver = (e: DragEvent) => {
+ if (!carries(e)) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "copy";
+ if (!dropping) setDropping(true);
+ };
+ const onDrop = (e: DragEvent) => {
+ if (!carries(e)) return;
+ e.preventDefault();
+ setDropping(false);
+ const id = e.dataTransfer.getData(PRACTICE_DRAG_TYPE);
+ const now = usePractice.getState();
+ if (now.deck.includes(id)) return;
+ if (now.add(id)) deskSignal(COMMUNITY_SIGNAL.added);
+ else {
+ setDropNote(`A deck holds ${DECK_MAX} patterns — that is what fits in one build. Take one out to make room.`);
+ later(() => setDropNote(null), 2500);
+ }
+ };
+
+ const shown = Math.min(DECK_MAX, Math.max(MIN_PLACES, deck.length + 1));
+ const slots = Array.from({ length: shown }, (_, i) => deck[i] ?? null);
+ const running = build === "running";
+ const done = build === "done";
+
+ return (
+ {
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropping(false);
+ }}
+ onDrop={onDrop}
+ >
+
+ Your deck
+
+ {deck.length} / {DECK_MAX}
+
+
+
+
+ {slots.map((id, i) => {
+ const p = id ? patternById(id) : null;
+ return p ? (
+
{
+ const now = usePractice.getState();
+ if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
+ e.preventDefault();
+ const to = i + (e.key === "ArrowLeft" ? -1 : 1);
+ if (to < 0 || to >= now.deck.length) return;
+ now.reorder(i, to);
+ deskSignal(COMMUNITY_SIGNAL.reordered);
+ const row = slotsRef.current;
+ requestAnimationFrame(() => row?.querySelector(`[data-pc-slot="${to}"]`)?.focus({ preventScroll: true }));
+ } else if (e.key === "Delete" || e.key === "Backspace") {
+ e.preventDefault();
+ now.remove(p.id);
+ deskSignal(COMMUNITY_SIGNAL.removed);
+ }
+ }}
+ onPointerDown={startDrag(i)}
+ >
+
+
+
+ {i + 1}
+
+ ) : i === deck.length ? (
+
+ +
+
+ ) : (
+
+ );
+ })}
+
+
+
+ {
+ if (deck.length === 0) return;
+ if (confirmEmpty) {
+ st().clear();
+ setConfirmEmpty(false);
+ } else {
+ setConfirmEmpty(true);
+ later(() => setConfirmEmpty(false), 4000);
+ }
+ }}
+ >
+ ✕
+
+
+ {dropNote
+ ? dropNote
+ : confirmEmpty
+ ? `press ✕ again to empty all ${deck.length}`
+ : dropping
+ ? "drop it here to add it"
+ : deck.length === 0
+ ? "drag a pattern down here, or press its +"
+ : "drag to rearrange · ✕ removes"}
+
+
+
+
+
+ {done ? (
+ <>
+
+ ✓ {builtN} module{builtN === 1 ? "" : "s"} · {builtN * MODULE_KB} KB
+
+
st().say("zip")}>
+ ↓ .zip
+
+
st().say("sentDeck", builtN)}>
+ Send over Wi-Fi
+
+
st().clearBuild()}>
+ ✕
+
+ >
+ ) : (
+ <>
+ {deck.length > 0 && !running && (
+
st().say("share")}>
+ Share deck
+
+ )}
+ {running && (
+
+
+ Building {builtN} module{builtN === 1 ? "" : "s"}
+ usually under {buildExpectedS(builtN)} s
+
+ )}
+
{
+ handOn.current = document.activeElement === e.currentTarget;
+ st().startBuild();
+ }}
+ >
+ {running ? "Building…" : "Send to my board"}
+
+ >
+ )}
+
+ );
+}
diff --git a/web/src/components/guide/desk/community/Practice.module.css b/web/src/components/guide/desk/community/Practice.module.css
new file mode 100644
index 00000000..45afe280
--- /dev/null
+++ b/web/src/components/guide/desk/community/Practice.module.css
@@ -0,0 +1,256 @@
+/* The practice community (05) inside its desk window. Its look is the real
+ community's: the components here wear components/community/
+ Community.module.css classes, and the --pfc-* tokens are global. This file
+ only fits that page into a window — what the real site pins to the browser
+ (the deck bar, a dialog's backdrop) is pinned to the window instead — and
+ adds the few things the practice has of its own: the canvases, the
+ practice notes.
+
+ Two classes deep wherever a real class is overridden, so the override wins
+ whichever stylesheet loads first. */
+
+.root {
+ position: absolute;
+ inset: 0;
+ /* Its own stacking context: the dock and the dialog stay under the desk's
+ dimming cover when this window is behind another. */
+ isolation: isolate;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background: var(--pfc-ground);
+ color: var(--pfc-ink);
+ font-family: var(--pf-sans, ui-sans-serif, system-ui, sans-serif);
+ font-size: 16px;
+ line-height: 1.45;
+}
+
+/* The community's own rule: a link changes colour on hover, it is not underlined. */
+.root a:hover {
+ text-decoration: none;
+}
+
+.scroll {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ /* At its end, the wheel stops here: it doesn't scroll the story (and the step) under the reader. */
+ overscroll-behavior: contain;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(244, 239, 230, 0.2) transparent;
+}
+
+/* The real page's gutter is 40px; the window is a narrow browser. */
+.root .pageBody {
+ padding: 0 32px;
+}
+.root .header {
+ padding: 14px 32px;
+}
+
+/* ── the banner: where the real site puts its firmware notice ── */
+
+.root .banner {
+ margin: 12px 32px 0;
+}
+
+/* ── the wall ── */
+
+.root .feed {
+ padding: 22px 0 8px;
+}
+.root .endNote {
+ margin-top: 18px;
+}
+
+/* A demo plays a card as the mouse would: lit the same way as :hover. */
+.lit .thumb {
+ outline: 1px solid var(--pfc-edge);
+ outline-offset: -1px;
+}
+.lit .cornerBtn {
+ opacity: 1;
+}
+
+
+/* A pattern on a canvas, filling the rotator or the upright box (the real
+ ones hold an and an