diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d61ab52b..54734a7d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,56 @@ All notable changes to Patternflow will be documented in this file, newest first ### Web +- **Pattern Lab's preview shows add layers the way the device does.** The preview clamped each layer to 255 before blending the next, so two (200, 0, 0) add layers under a (128, 128, 128) multiply showed R 128 in the Lab and 201 in the exported pattern and on the panel. +- **The Live Editor says why pasted code does not run.** The error, and the line it failed on when the stack gives one, shows under the editor. It used to reach the browser console only, once a frame for a `draw()` that throws. +- **Community pages open when the browser blocks site data.** Reading the deck from `localStorage` threw a SecurityError that took every community page down to "This page couldn't load", and the atlas did the same reading its language. Both reads are guarded now, and a deck or language the browser refuses to store holds for the rest of the visit. +- **The home page waits for the Pattern tab before fetching the code editor and the flasher.** The Pattern panel is mounted on `/`, `/build` and `/inside` too, folded away, so every visit asked jsdelivr for Monaco's loader and unpkg for esp-web-tools. Both now load the first time the tab opens, and stay. +- **The guide's tests give the same answer on a busy machine and a fast one.** The hub hand test read Make's redraw off the wall clock and, on a fast machine, passed without the hand turning a knob; it now runs on its own clock. That turned up a simulator bug: nine clicks of K1 out and nine back left Origin's hue at 0.9999999999999993, read as "hue 1.00" with a full bar, and `wrap` now calls that the start. The NETWORK timeout test covers its 15 seconds in 150 frames instead of 900. +- **Pattern Lab's "Copied" labels clear their timer when the panel closes.** The seven labels that show after a copy, paste or clear no longer leave a `setTimeout` behind, which could fail the Lab tests after every test had passed, and a second click keeps its label for the full 1.2 s. The image import modal's wheel listener now attaches in the same commit that enables Apply. +- **posthog-js loads after the page does.** The analytics library was the largest script on every page (318 KB, 103 KB gzipped); it is now fetched with `import()` after the load event, and events captured before then are sent with their original timestamps. +- **Closing Send or Publish from its backdrop keeps To hardware open.** In Pattern Lab's To hardware dialog, a press outside the nested Send or Publish modal closed To hardware as well, and the C++ header pasted into it was lost. +- **Korean pages fetch 318 KB of Pretendard Hangul instead of 1.2 MB.** The Hangul part of each weight is now the 2,350 syllables of KS X 1001 and the jamo; the other 8,822 syllables are a second file that a page fetches only when its text has one of them. +- **Editing a deck no longer runs out after four changes.** Sharing a deck and editing it counted against the same five requests a minute, so the fifth edit within a minute of sharing (each visibility change is one) came back 429. Edits now have their own twenty a minute, the same as a pattern's or a post's. +- **The hero video has a stop button.** The YouTube embed on the home page played for 89 seconds by itself and took no clicks, so nothing could stop it (WCAG 2.2.2). The button in its corner takes the video away and leaves the photo under it. Pressed again, or after the video ends, it plays from the start. +- **Pattern Lab's dialogs close on Esc, and its header menus close on a tap outside.** To hardware, Import image and Code guide take the keyboard focus when they open and give it back to their button when they close; an Esc over Send or Publish closes only that window, so the pasted header stays. Recent and Panels close on a press outside them, on Esc and on Tab past the last item, and the Panels list tells a screen reader which panels are open. +- **The breadboard build guide fits the window from 1280px up, and loads its photos as you reach them.** At 1440px the page scrolled 146px sideways and the photos on the left were cut off. The step photos now sit in the space beside the text, up to 360px wide. On a phone the eight photos (1.9 MB) no longer download when the page opens, and the page ships 59 KB less JavaScript. +- **The deck panel and the Send window stop waiting when the build server goes quiet.** With the build worker down, both asked the server every second until the window was closed, with a spinner beside "usually a second or two". They now give up after the wait the deck bar already used (6 s, plus 1.5 s a pattern, plus 5 s), say the server has not answered, and leave a button to start the build again. The bar, the panel and the window share that rule in one hook, `lib/community/useModuleBuild.ts`. +- **The Live Editor fits a phone screen.** On a 390px phone the preset bar squeezed the pattern name to nothing and showed only "01"; the name now takes its own row above the buttons. The editor shrinks to the height left under the pinned 3D preview (330px on a 390×844 phone, 480px on desktop as before), so some of the page always stays in view to scroll by. +- **`npm run build:enqueue` queues a module build.** Without `--pfm` it wrote a whole-image row, which the worker built as modules anyway, so `build:status` reported an image it never made. +- **Pattern Lab's Pixel zoom follows the wheel's travel and holds the cell under the cursor.** Each wheel event was one zoom step, so a trackpad swipe of thirty small events ran from 6× to 32×, and the picture grew from the viewport's top-left corner. Steps now add up from the distance turned, 100 px a step, and the viewport scrolls the cursor's cell back under it. The image-import preview scales 8% per 100 px around the cursor the same way. +- **The Live Editor's creation prompt gives the step one detent really moves each knob.** It said 0.05 a detent (0.10 for Knob 2). With 24 detents a turn and two turns to a range it is 0.021, 0.206, 0.102 and 0.021, worked out from the same ranges the preview's knobs turn through. The note under the Pattern Lab card no longer says the build server compiles firmware in the browser, and links PlatformIO instead of the Arduino IDE. +- **Community dialogs close with a thumb, and the atlas works from the keyboard.** The × in the header of the community's 13 dialogs was a 10.7×23.2px target and is now 44×44px, with the header the same height. A point on the atlas shows a ring when Tab reaches it, and Space no longer scrolls the page; pin tiles can be reached with Tab and opened with Enter; the live ring stops pulsing under reduced motion. +- **Pattern Lab's Pixel panel opens with the whole picture in view.** It always opened at 6×, so a 390 px phone showed 56 of 128 columns and 19 of 64 rows. It now opens once at the zoom the Fit button would pick, when 6× does not fit. +- **The community rate limiter keeps an hour of builds through its cleanup.** Once it held more than 10,000 keys, the cleanup judged every key by the one-minute window of the call that set it off, so somebody who had used their 30 builds for the hour got a 31st. The limiter and the origin check every community route runs first now have tests. +- **Pattern Lab writes an unnamed knob and a fine knob range into `@knobs` so they read back.** An empty name went out as `-=0..1` and came back as a knob called "-", and 0.0001..0.0004 was rounded to `0..0` and dropped. The `@knobs` and `@matrix` lines and the knob wrap rule now have tests. +- **The home tabs' link rows draw a focus ring, and each tab's title is its page's h1.** A focused row under Start here or in the case card took only a cream fill, 1.23:1 against white, and now draws the LED ring the case tabs use; the join band on Inside does the same. With a tab open the hero's h1 is inert, so /build had no h1 at all. +- **A desk run of `check:drift` reports a skipped `links-absolute` as skipped.** In a clone without the release tags the check could not read 24 links and said so, but exited 0, so the runner's summary listed it as passed. It now exits with the drift checks' skip code; under `PF_DRIFT_STRICT=1` it still fails. +- **Links drawn as buttons and cards keep their look on hover.** The global `a:hover` underline outranked each CSS module's `text-decoration: none`, so 453 links on 16 pages grew an underline when pointed at, and the guide's "Stuck here? Tell us" drew a second line under its own. +- **Two unimported files and six uncalled exports are gone.** `Deck.tsx`, `waveTest.ts`, `renderPatternStill`, `cloneShow`, `deleteSession`, `useActiveCodeLayer`, `timelinePatternNames` and `licensedHeaderText` had no callers in `web/` or `tools/virtual-panel/`, and `web/ARCHITECTURE.md` no longer lists `Deck.tsx` on the landing page. +- **The 3D device and the Inside globe hold still for reduced motion.** With the system's reduced-motion setting on, the preview device stands facing the reader instead of swaying 26° each way every 42 seconds, and the globe no longer turns by itself or runs dots along its links. The LED pattern on the panel still plays. +- **`web/ARCHITECTURE.md` says what Vitest runs and when a licence header is added.** `check:panels` runs every `src/**/*.test.ts(x)`, not only the Pattern Lab panels, and the community bakes the licence header into the stored code on publish and edit. `web/README.md` lists `check:voice`. +- **The atlas says why it did not save a pin.** Every pin write ignored the server's answer, so after a 401 a dragged tile stayed at its new spot as if saved, and a dropped connection ended in an unhandled rejection. The panel now shows the server's reason, and a refused drop goes back to where the pin is saved. +- **The guide's panel simulator fails a test when a firmware number it copies changes.** `firmwareConstants.test.ts` reads the 21 timings and limits from `firmware/patternflow/` (brightness idle, SELECT settle, the bus grace, the lane release and others) and the brightness percent rounding, and checks every `FIRMWARE` key against them. +- **A pattern page stops counting the lines Pattern Lab writes on its own as the author's decisions.** The Lab stamps `@matrix 128x64` on every publish and the stock black-to-white `@ramp` on every value-field layer, so a Gemini variant published untouched showed "Framed" and "Colour shaped". Each line is now read with its parser and set against the Lab's defaults, and "Framed" names the frame. +- **The atlas fits a phone.** At 390px the page scrolled 99px sideways, "Add my pattern" opened its picker 201px past the left edge where no tile could be tapped, and the placing banner ran off both sides of the map. The toolbar buttons now wrap as one block, the picker grid no longer widens with long titles (it scrolled sideways at 1440px too), and the banner sits under the filter chips instead of on them. +- **The site's typecheck catches unused locals and parameters, switch fallthrough and a missing `override`.** It found twelve; one was a ramp table the pattern runtime rebuilt on every `setRamp` and never read. The eight `eslint-disable-next-line` comments without a reason now give one. +- **Community fields a screen reader could not name now have names.** The device address under a deck or Send build was read out as an edit box with no name, and so were the atlas's made-from select and pin knob sliders; the atlas search and the report's detail box read out their placeholder instead. The atlas names them in Korean too. +- **The home page's tabs open in a browser without WebGL.** Where WebGL 2 was turned off, `/`, Build, Pattern and Inside threw from the 3D view's renderer and showed the site's error screen. The view now asks for a context before it makes a canvas, and a scene that throws anyway is taken off, with one line standing in its place. +- **The home page poster comes at the width it is drawn.** It was the 4032-wide camera original, 2.1 MB, on every tab of the home view. On a phone at 9 Mbps the first screen's largest paint took 9.6 s, and it now takes 0.5 s. +- **The Build and Pattern tabs, and the home page on a phone, no longer download the build globe's photos.** The 3D view opened on the globe for a moment on /build and /pattern, and a phone's hidden hero globe warmed every pin's photos as well: 65 images, 8.3 MB on a 3× phone. The view now starts on the page's own tab, and the globe warms its photos only where it is on screen. +- **Community write routes answer a `null` body with a 400.** `null` parses as JSON, so it got past every route's `catch` and threw on the first field read, and 22 handlers answered an empty 500 with no CORS headers. They now read the body through one helper that answers `{"error":"Expected a JSON object."}`. +- **Pattern Lab and the Live Editor say when pasted code was cut off.** Code that stopped inside a call reported "Unexpected token 'return'", and code missing its last `}` reported "Unexpected token ')'", tokens the author never typed. The message now counts the brackets that never close and quotes the last line. +- **Pattern Lab's Recent and Panels menus open inside a phone screen.** Below 800px each menu hung from its button's right edge and opened off the left of the screen. It now takes its left edge from the header's button row, at most 360px wide, with 44px rows. The × in the Lab's dialogs grows from 30px to 44px on the same screens. +- **Send over Wi-Fi reaches the board when the address field is empty or holds a pasted `http://` address.** An emptied field made `http:///patterns`, which a browser reads as a host called `patterns`, and `http://192.168.0.44/` made a host called `http`. Every handoff and install link now cleans the address the same way, in `lib/community/deviceHost.ts`. +- **Copy buttons in Pattern Lab and the Live Editor say "Copy failed" when the browser refuses the clipboard.** The Lab's Copy prompt and the To hardware copy buttons kept their label, and the Live Editor said the creation prompt was copied before the write had finished. +- **Sharing from Pattern Lab measures the code the way the community does.** "Open in Pattern Lab" hands back the stored code with its licence header and footer on, and the publish modal counted them, so an unchanged fork or a reopened post just under the 100 KB cap was refused before it was sent, though the server takes it. +- **The community API no longer lets an edit move a pattern to MIT or CC0.** Both are retired and stay on the patterns already published under them, but a direct PATCH still moved a CC BY-SA original to CC0 and rewrote the SPDX line in its source. +- **Five community windows close on Esc and hand the focus back.** Sign-in, report, Edit details, Add firmware header and Publish a performance take the focus when they open and return it to the button that opened them; before, Esc left them open and four kept the focus on the page behind. +- **The community atlas stands its filter chips above the map on a phone.** At 390px the two rows of chips hid 21 of the map's 83 labels, the map box left 152px blank under the drawing, and touch screens showed the "wheel to zoom · drag to pan" hint, which names gestures they do not have. +- **Six pages stop prefetching the home page's 3D code.** The Patternflow link on /terms, /contact, /features, /roadmap, the 404 and /community prefetched / as soon as it was in view, and the home page's three.js and post-processing chunks came with it: /terms fetched 767,286 bytes of script at 390px, and 148,456 without them. The home page now loads when the link is clicked. +- **Pages no longer preload fonts their text doesn't use.** The root layout drops DM Sans, which set no text, and loads Newsreader and Silkscreen without a preload, so only the journal, /terms and the guide fetch them; `/` and Pattern Lab take 166 KB of fonts less. +- **A wrapping knob on a range other than 0..1 hands the pattern every detent.** Knobs 1 and 4 wrapped at ±0.5 whatever their range, so in the Lab, on community cards and on the guide's desk a pattern declaring `@knobs Hue=0..360` read a full turn as 20.8 clicks instead of 24; the wrap now sits at half the knob's own range. +- **A mask over a 0% layer in Pattern Lab gates nothing in the exports either.** The exported JS and the C++ header dropped the 0% layer before grouping and handed its mask to the layer below, so a layer the Lab showed whole came out half black; the preview did the same for a code layer that had sat at 0% since it was added. - **The build map has a fourth kind of pin, a showing.** A unit set out somewhere for people to play is a triangle under In use, with a line back to the unit it is and no part in the web of builds. *Anbu*, a DJ night in Hongdae, the Hongik course exhibition and the Seoul Design Week party go in as showings, beside new pins for two gifts, SimonePDA's laser-cut case, Besoiobiy's printed case, the MQTT test between Ajman and Seoul, and jonwtr's norns screen mirror in Ghent. A pin's link into the site, like Build this case, changes the tab in place instead of opening a new one. - **The Build guide keeps up with a finger on a phone.** A scroll waits for the 3D stage's script only when the finger lands on a knob. The stage's models start downloading with three.js and weigh 266 KB less, and a stage that has stopped moving no longer redraws its shadow map. - **Pattern Lab's pixel undo survives a matrix resize.** Undo after shrinking the matrix threw a RangeError, and after growing it wrote the old rows into the new buffer. Each undo frame now keeps its size and goes back through the same centred crop or pad the resize gave the layer. @@ -69,6 +119,8 @@ All notable changes to Patternflow will be documented in this file, newest first ### Docs +- **The build service docs describe the worker that runs today.** `docs/SERVICES.md` and `web/.env.example` still said the worker compiles firmware with arduino-cli and offered `ARDUINO_CLI_PATH` and `BUILD_FQBN`, which nothing has read since whole-image builds ended on 12 August. `.env.example` now lists `PF_XTENSA_BIN`, `PF_TARGET_ABI` and `GITHUB_TOKEN`, and `src/lib/envExample.test.ts` fails when it, the code and the table in `web/ARCHITECTURE.md` disagree. +- **The docs index, the repository map and the contributor guide describe the tree as it is.** `docs/README.md` lists the cleanup RFC and its log, the rename list in `docs/REPOSITORY.md` names all 30 files the site's absolute links reach (it had ten and the BOM), `CONTRIBUTING.md` names the four build kinds and `originId`, and `docs/EDITIONS.md` says `build.sh all` builds five compositions. - **The cleanup's progress log records steps 1 to 5 and the gate.** `docs/rfc-cleanup-progress.md` has an entry for each step and checks the six items of §2.12 at `9dff573`, and all six hold. `docs/rfc-cleanup.md` is marked done. - **The cleanup has a baseline.** `docs/investigations/2026-10-cleanup-baseline.md` records the commit the cleanup starts from and what the five commands in `docs/rfc-cleanup.md` §2.1 said there. All five pass. `AGENTS.md` gains hard rule 8: during a cleanup window no PR changes behaviour, and moves and renames carry a redirect or an updated link map. - **Cleanup step 1 is planned.** [`docs/investigations/2026-10-cleanup-step1-plan.md`](docs/investigations/2026-10-cleanup-step1-plan.md) cuts the step into three lanes that share no file (root moves, the changelog split, the ko/en parity list), lists what the RFC did not know about the tree (`docs/releases/` already exists; the ko/en pairs it names do not), and asks the maintainer five more questions, each with a default. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 291c10739..8e675ccba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,7 +78,7 @@ A pin on the map is one entry in [`web/src/components/sections/InsideGlobe/build Inside includes builds, projects derived from Patternflow, and places where it is used. Add one entry to `web/src/components/sections/InsideGlobe/builds.ts`: a short `title`, `maker`, public `location`, `date`, `description`, and any external `links`. Photos are optional. Keep existing `id` and `slug` values stable so shared links keep working. -Choose one primary `category`: `builds` for assembled devices and enclosure changes, `projects` for ports and new tools, or `in-use` for installations, performances and other uses. This is separate from `kind`, which preserves the existing build/collaboration relationship and marker style. A maker can have several entries: a new tool and a later exhibition are different records. Link to the maker's own documentation for ongoing updates; a short introduction here is enough. Do not add unannounced work or assume an event happened from a sale or a planned appearance. +Choose one primary `category`: `builds` for assembled devices and enclosure changes, `projects` for ports and new tools, or `in-use` for installations, performances and other uses. This is separate from `kind`, which says what the pin is and picks its marker on the globe: `build`, `collaboration`, `sold` or `showing`, each explained in the comment above `BuildKind`. A collaboration, a sale or a showing names the pin it grew out of in `originId`, and the globe draws a line back to it. Left out, the line goes to the first build in the file. A maker can have several entries: a new tool and a later exhibition are different records. Link to the maker's own documentation for ongoing updates; a short introduction here is enough. Do not add unannounced work or assume an event happened from a sale or a planned appearance. Classify the subject of the entry, not its surroundings. A completed device photographed in a studio or DJ booth is still a `builds` entry. Use `in-use` when the entry documents actual use in an exhibition, performance or installation; a sale or delivery alone is not enough. Leave a category empty until a qualifying case is available. diff --git a/docs/EDITIONS.md b/docs/EDITIONS.md index 78774c23d..7e7b69d08 100644 --- a/docs/EDITIONS.md +++ b/docs/EDITIONS.md @@ -218,7 +218,7 @@ deliberately re-pinned in the same commit. feature code that recompiles against a checkout, in-tree or out. Widening is tail-append only (the rule and its reason are written on the struct); the compiler surfaces every break at build, and `build.sh all` makes sure -"builds" means all three compositions. The two composition filenames and +"builds" means all five compositions. The two composition filenames and their macros are the out-of-tree seam - renamed spellings are shimmed, and shims get deleted only after their consumers migrate. diff --git a/docs/README.md b/docs/README.md index 0ce9a0888..007b2f060 100644 --- a/docs/README.md +++ b/docs/README.md @@ -58,6 +58,7 @@ The interactive guide at [patternflow.work/guide](https://patternflow.work/guide ## Records - [`investigations/`](investigations/) — dated post-mortems, and the cleanup's plans. `npm run doc-map` in `web/` writes the two lists here: `2026-10-doc-map.csv`, every link in the tree and whether it resolves, and `2026-10-doc-parity.csv`, every Korean/English pair and which side is missing or short +- [`rfc-cleanup.md`](rfc-cleanup.md) and its [progress log](rfc-cleanup-progress.md) — the October 2026 tidy-up before the any-resolution work, done on 16 October. It changed where things live and what CI checks, and nothing the firmware or the site does. §2.9 lists the writing it locked - [`releases/`](releases/) — every release's changelog section as written, one file each (`vX.Y.Z.md`), back to v1.0.0; `CHANGELOG.md` at the root keeps the three newest in full and links the rest. `v3.0.0-notes.md` and `v3.1.0-notes.md` are long-form notes for two July 2026 releases, beside the v3.0.0 launch checklist; notes since 3.2 are on [GitHub Releases](https://github.com/engmung/Patternflow/releases) - [`manifesto.md`](manifesto.md) — why Patternflow exists, and (section 6) how we treat each other - [`images/`](images/), [`media/`](media/) — assets the guides and the site reference diff --git a/docs/REPOSITORY.md b/docs/REPOSITORY.md index d05a54e38..adbf7f49b 100644 --- a/docs/REPOSITORY.md +++ b/docs/REPOSITORY.md @@ -44,9 +44,13 @@ Filenames are lowercase with underscores. Tags are `vX.Y.Z`; the firmware source The site, the firmware and the release notes hold absolute `blob/main/…` links into this tree, and a pull request that breaks one fails `check:links-absolute`. Before renaming or moving any of these, grep `web/src`, `web/content`, `firmware/patternflow/console` and `firmware/patternflow/features/*/*_index.h`: -- `README.md`, `BUILD_GUIDE.md` (the site links its section anchors — §1, §2, §4, §8, §8.2 and §10 when this was written — so a renamed heading breaks them), `FEATURE_GUIDE.md`, `PATTERN_GUIDE.md` +- `README.md`, `BUILD_GUIDE.md` (the site links its section anchors — §1, §2, §4, §8, §8.2 and §10 when this was written — so a renamed heading breaks them), `FEATURE_GUIDE.md`, `PATTERN_GUIDE.md`, `CHANGELOG.md`, `CONTRIBUTING.md` and `.github/SUPPORT.md` (the issue forms link the last two) - `docs/AUDIO_GUIDE.md`, `docs/assembly/README.md`, `docs/midi-ableton.md`, `docs/midi-spec.md` (the last two are baked into the Audio edition's console pages) -- `docs/EDITIONS.md`, `docs/rest-api.md`, `firmware/README.md`, `hardware/bom/bom_v3.9.csv` +- `docs/EDITIONS.md`, `docs/rest-api.md`, `docs/osc-spec.md`, `docs/mqtt-spec.md`, `docs/audio-ws-spec.md`, `docs/pfst-v2-spec.md`, `docs/director-midi.md` (the features page links each of these) +- `docs/panel-compatibility.md`, `docs/legacy/BUILD_GUIDE_v2.md`, `docs/LICENSE-SUMMARY.md`, `docs/releases/v2.0.0.md`, `firmware/README.md`, `firmware/CUSTOM_PATTERNS.md`, `hardware/README.md`, `hardware/bom/bom_v3.9.csv`, `tools/patternflow-audio-android/README.md` +- `hardware/case/README.md`, and each folder in `hardware/case/remixes/` with the files in it. The Build panel's case cards link them by name: a README, a `print_layout.3mf`, the mbchars case's `assembly.md` and `bom.md` - `web/src/app/compliance/` — the `/compliance` URL is printed on the box. Never move, redirect or noindex it. +Folders are linked too. The features page links each feature's folder in `firmware/patternflow/features/`, and the site's Build guide links files in `hardware/case/bed_256mm/`, `hardware/case/bed_330mm/` and `hardware/pcb/`. Every row, with the file the link sits in, is in [`investigations/2026-10-doc-map.csv`](investigations/2026-10-doc-map.csv) under the kind `absolute-blob`. `npm run doc-map` in `web/` writes that list again from the tree as it is. + `.github/scripts/check_links.py` checks the *relative* links in every tracked markdown file and runs on every pull request that touches one. `check:links-absolute` (`web/scripts/doc-map.ts --check absolute`, in `drift.yml`) checks the absolute ones on every pull request, against the tree or the tag a link names. diff --git a/docs/SERVICES.md b/docs/SERVICES.md index ee49989ab..fca4ac937 100644 --- a/docs/SERVICES.md +++ b/docs/SERVICES.md @@ -1,6 +1,6 @@ # Patternflow Server Services & Worker Management -Patternflow 커뮤니티 웹 서버 및 C++ 펌웨어 웹플래시 컴파일 빌드 워커 서비스 구성과 운영 방법 가이드입니다. +Patternflow 커뮤니티 웹 서버와, 패턴 C++ 헤더를 `.pfm` 모듈로 굽는 빌드 워커 서비스 구성과 운영 방법 가이드입니다. --- @@ -32,10 +32,10 @@ RestartSec=3 OOM 킬은 `dmesg -T | grep -i "killed process"` 로 봅니다. ### ② `patternflow-worker.service` -- **역할**: C++ 펌웨어 빌드 워커 (`scripts/build-worker.ts`) -- **설명**: 웹플래시 요청 대기열(`queued`)을 수신하여 `arduino-cli`를 이용해 ESP32-S3 바이너리를 컴파일합니다. +- **역할**: 패턴 모듈 빌드 워커 (`scripts/build-worker.ts`) +- **설명**: 빌드 대기열(`queued`)에서 잡을 하나씩 가져가 헤더마다 `firmware/toolchain/build_module.py`와 Xtensa 컴파일러로 ESP32-S3용 `.pfm` 모듈을 굽습니다. 펌웨어 이미지 통째 빌드는 2026-08-12(`4c13474`)에 끝났고, 그 뒤로 워커는 `arduino-cli`를 부르지 않습니다. - **위치**: `/etc/systemd/system/patternflow-worker.service` -- **환경변수 중요 사항**: `Environment=PATH`에 `arduino-cli` 설치 경로(`~/.local/bin` 또는 `/usr/local/bin`) 포함 필수 +- **환경변수 중요 사항**: 워커는 `python`(다른 이름이면 `PYTHON_PATH`)과 `PF_XTENSA_BIN`이 가리키는 컴파일러(아래 2단계)를 띄웁니다. `Environment=PATH`에는 Python이 있어야 하고, 예전에 넣은 `arduino-cli` 경로는 이제 쓰이지 않습니다. #### 워커는 격리된 채로 돈다 — `hardening.conf` (2026-09-04) @@ -93,9 +93,10 @@ strace로 확인한 전부다: 데이터 디렉터리(DB·WAL·산출물), 워 것으로 재현했다. 두 단계 중 첫째는 끝났다: 1. **시크릿은 유닛 밖 (완료, 2026-09-04).** 워커가 필요로 하는 변수 - (`COMMUNITY_ENABLED`, `BUILD_ENABLED`, `BUILD_FQBN`, `COMMUNITY_DB_PATH`)는 - root 전용 `/etc/patternflow/worker.env`(0600)에서 `EnvironmentFile=`로 - 받는다. `.env.local`은 `InaccessiblePaths`로 막으면 워커가 못 뜬다 — + (`COMMUNITY_ENABLED`, `BUILD_ENABLED`, `COMMUNITY_DB_PATH`, 2단계의 + `PF_XTENSA_BIN`)는 root 전용 `/etc/patternflow/worker.env`(0600)에서 + `EnvironmentFile=`로 받는다. 그날 같이 넣은 `BUILD_FQBN`은 통째 이미지 + 빌드의 보드 옵션이라 8월 12일 뒤로 읽는 코드가 없다. `.env.local`은 `InaccessiblePaths`로 막으면 워커가 못 뜬다 — `scripts/loadEnv.ts`의 `existsSync`는 접근 불가 파일에도 `true`를 돌려주고, 그다음 `process.loadEnvFile`이 EACCES에 예외를 던진다. 그래서 `BindReadOnlyPaths=/dev/null:…/.env.local`로 **빈 파일로 보이게** 한다: @@ -314,8 +315,11 @@ npm run build:enqueue ## 3. 자주 발생하는 문제 & 조치법 (Troubleshooting) -- **웹플래시 요청이 계속 `queued`에서 안 넘어감**: +- **빌드 요청이 계속 `queued`에서 안 넘어감**: - `patternflow-worker.service`가 켜져 있는지 확인: `sudo systemctl status patternflow-worker.service` - 꺼져 있다면 재시작: `sudo systemctl restart patternflow-worker.service` -- **`spawn arduino-cli ENOENT` 에러 발생**: - - `patternflow-worker.service` 파일 내 `PATH` 환경변수에 `arduino-cli` 설치 경로가 올바르게 지정되었는지 확인. +- **빌드 오류가 `The build service's compiler is not available right now. No Xtensa compiler found`로 시작함**: + - 워커가 `xtensa-esp32s3-elf-g++`를 찾지 못했습니다. `/etc/patternflow/worker.env`의 `PF_XTENSA_BIN`이 래퍼 디렉터리(`/etc/patternflow/xtensa-sandbox`)를 가리키는지, 래퍼가 부르는 `~/.arduino15/packages/esp32/tools/esp-x32/2601`이 그대로 있는지 확인. + - 오류 전문은 `npm run build:status -- `의 `--- error ---` 아래에 나옵니다. +- **빌드 오류에 `[cannot run python: spawn python ENOENT]`가 나옴**: + - 유닛의 `PATH`에 `python`이 없습니다. `worker.env`에 `PYTHON_PATH=python3`처럼 실행 파일 이름이나 경로를 넣고 워커를 재시작. diff --git a/web/.env.example b/web/.env.example index a8952b8c4..0b50b0ea6 100644 --- a/web/.env.example +++ b/web/.env.example @@ -2,6 +2,12 @@ NEXT_PUBLIC_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com +# ── Roadmap ───────────────────────────────────────────────────────────────── +# /api/roadmap reads the repository's open issues from the GitHub API on the +# server. A token raises GitHub's rate limit; the repository is public, so the +# route works without one. +#GITHUB_TOKEN= + # ── Community (self-hosted deployment only) ────────────────────────────────── # The community runs where this flag is set (the Raspberry Pi / VPS). Leave it # unset on Vercel — community pages then show a pointer to the community host. @@ -36,27 +42,36 @@ NEXT_PUBLIC_COMMUNITY_URL=https://community.patternflow.work # here or cross-origin sign-in and publishing are rejected. COMMUNITY_ALLOWED_ORIGINS=https://patternflow.work -# ── Firmware build service (community host with a worker) ──────────────────── -# Server side: enables POST /api/community/builds. Needs arduino-cli installed -# and `npm run worker` running. Compiling submitted C++ is arbitrary code -# execution at compile time — keep this off unless the worker's unit carries -# the sandbox in docs/SERVICES.md (no network, read-only system, limits). +# ── Pattern module builds (community host with a worker) ──────────────────── +# Server side: enables POST /api/community/builds and the module cache. Needs +# `npm run worker` running, which compiles each pattern header into a .pfm +# module with firmware/toolchain/build_module.py and the Xtensa compiler. It +# has not built whole firmware images since 12 August. Compiling submitted +# C++ is arbitrary code execution at compile time — keep this off unless the +# worker's unit carries the sandbox in docs/SERVICES.md (no network, +# read-only system, limits). #BUILD_ENABLED=1 -# Client side: shows the "Build firmware" buttons. Set both, or neither. +# Client side: shows the deck, "Send to my Patternflow" and Pattern Lab's +# "To hardware". Set both, or neither. #NEXT_PUBLIC_BUILD_ENABLED=1 # Overrides for the worker (defaults are usually right in a repo checkout): #FIRMWARE_SRC_DIR=/home/pi/Patternflow/firmware/patternflow #BUILD_WORK_DIR=/home/pi/patternflow-data/build-worker #BUILD_ARTIFACT_DIR=/home/pi/patternflow-data/builds -#ARDUINO_CLI_PATH=arduino-cli # -# BUILD_FQBN — normally leave this unset. The built-in default already pins the -# board options firmware/README.md requires (PSRAM, flash size, partition -# scheme, CDC on boot), and a build is refused if an override drops any of -# them: leaving one out compiles against the board defaults and produces -# firmware that boots but is not the firmware we release. -# To inspect the available options: arduino-cli board details --fqbn esp32:esp32:esp32s3 -#BUILD_FQBN=esp32:esp32:esp32s3:PSRAM=opi,FlashSize=16M,PartitionScheme=app3M_fat9M_16MB,CDCOnBoot=cdc,USBMode=hwcdc +# The directory holding xtensa-esp32s3-elf-g++ and the nm beside it. Unset, the +# worker looks where the ESP32 Arduino core installs its compiler +# (ARDUINO_DIRECTORIES_DATA, then ~/.arduino15 and the macOS and Windows +# equivalents), then in PlatformIO (PLATFORMIO_CORE_DIR, else ~/.platformio). +# The Pi points it at the bwrap wrappers in docs/SERVICES.md. +#PF_XTENSA_BIN=/etc/patternflow/xtensa-sandbox +# +# PF_TARGET_ABI — normally leave this unset, which builds descriptor 2 for a +# pattern that reads the absolute knob tier. 1 makes a module that also +# installs on firmware older than v3.5.1, and compiles that tier out: a +# Director show then sets values the pattern cannot read. Changing it changes +# the toolchain revision, so the worker rebakes every public header. +#PF_TARGET_ABI=1 # Moderators — comma-separated USERNAMES (not ids) who may remove other # people's patterns, posts and comments. Unset means nobody can, which is the diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index 90e187949..8abee783e 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -42,7 +42,7 @@ HomeView (src/components/HomeView.tsx) │ ├── LedMatrixTexture.ts ← renders JS patterns into a texture for the 3D LED mesh │ └── patterns/ ← GLSL pattern ports (common.ts = shared vertex shader + registry) └── RightPanel (components/sections/) ← tabbed content panel - ├── Hero.tsx / Deck.tsx ← hero copy + panel open/close orchestration + ├── Hero.tsx ← hero copy and the intro video ├── BuildPanel.tsx ← build paths, the case switch, flasher entry ├── PatternPanel.tsx ← preset browser + code editor; every CTA leads to /pattern-lab ├── InsidePanel.tsx + InsideGlobe/ ← concept content + community builds globe @@ -59,7 +59,7 @@ Mirrors the firmware; the same JS runs in the browser, in the community's sandbo - **`src/lib/pattern/harness.ts`** — runs pattern JS on a 128×64 virtual matrix with 4 virtual encoders (24 detents/turn), matching device semantics (`knobDeltas`, `btnPressed`/`btnHeld`); also the OKLab/OKLCH colour ramp math and the LUT builders. `public/pattern-sandbox.html` is **built from it** — `scripts/build-sandbox.ts` bundles `src/sandbox/sandbox.ts` (the canvas, the live loop, the postMessage protocol) with the runtime it imports, and `check:sandbox-sync` fails when the committed page is not that build. It was a hand-kept ES5 port until 3.9.4, and it drifted (`next.config.ts` records one such bug; another was 20 detents a turn against the lab's 24). The 24 now comes from `src/lib/shared/constants.ts`, and so do the panel size and the knob count. `npm run gen:shared` writes that file from `shared/constants.json` at the repository root, the same source as the firmware's `core_shared.h`. - **Annotations** — a pattern's frame, ramp, knob ranges and layer stack travel *inside its code* as one-line comments so they survive every copy: `@matrix` (`src/lib/pattern/matrix.ts`), `@ramp` (`src/lib/pattern/ramp.ts`), `@knobs` (`src/lib/pattern/knobs.ts` — the lab's knob state in `src/lib/lab/annotations.ts` and the community's card reader in `src/lib/community/knobs.ts` both build on it), `@stack` (`src/lib/lab/stackShare.ts`). - **`src/lib/pattern/controls.ts`** — knob-scale constants shared with the firmware. -- **`src/lib/pattern/share.ts`** — licence header + attribution footer, injected at export time only. +- **`src/lib/pattern/share.ts`** — licence header + attribution footer. The community bakes them into the stored code when a pattern is published or its details are edited (`src/lib/community/license.ts`), so the pattern page shows them, and a download wraps the code it hands out (`src/lib/community/download.ts`). The Live Editor and the Lab never add them while you write. - **`src/lib/pattern/packs.ts`** — the Basics pack that ships with the firmware (`public/packs/`, built by `firmware/toolchain/make_pack.py`). - **`src/lib/ai/gemini.ts`** — bring-your-own-key Gemini generation. The key lives in `localStorage` and calls go straight from the browser to Google; no server proxy, no bundled key. @@ -97,11 +97,11 @@ A published header is compiled **once** and kept beside the code in `module_cach ## Analytics -PostHog (`src/providers/PostHogProvider.tsx`, event helpers in `src/lib/posthogEvents.ts`) plus Vercel Analytics / Speed Insights. All no-ops when the env vars are absent. +PostHog (`src/providers/PostHogProvider.tsx`, event helpers in `src/lib/posthogEvents.ts`) plus Vercel Analytics / Speed Insights. All no-ops when the env vars are absent. `posthog-js` is fetched with `import()` after the page's load event, and an event captured before then waits in `posthogEvents.ts` with its own time. A static import of it puts 318 KB back into every page's first scripts; `posthogEvents.test.ts` fails on one. ## Environment variables -`web/.env.example` is the complete, commented list. The site builds and runs with none set; each group switches on one deployment concern: +`web/.env.example` is the complete, commented list. `src/lib/envExample.test.ts` fails when the code reads a variable the file leaves out, when the file offers one nothing reads, or when this table and the file disagree. The site builds and runs with none set; each group switches on one deployment concern: | Group | Vars | | :--- | :--- | @@ -109,7 +109,7 @@ PostHog (`src/providers/PostHogProvider.tsx`, event helpers in `src/lib/posthogE | Roadmap | `GITHUB_TOKEN` | | Community host | `COMMUNITY_ENABLED`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `COMMUNITY_DB_PATH`, `COMMUNITY_ALLOWED_ORIGINS`, `COMMUNITY_ADMIN_USERNAMES` | | Every deployment with a community | `NEXT_PUBLIC_COMMUNITY_URL` | -| Build service | `BUILD_ENABLED`, `NEXT_PUBLIC_BUILD_ENABLED`, `FIRMWARE_SRC_DIR`, `BUILD_WORK_DIR`, `BUILD_ARTIFACT_DIR`, `ARDUINO_CLI_PATH`, `BUILD_FQBN`, `PYTHON_PATH`, `WORKER_ID`, `BUILD_POLL_MS` | +| Build service | `BUILD_ENABLED`, `NEXT_PUBLIC_BUILD_ENABLED`, `FIRMWARE_SRC_DIR`, `BUILD_WORK_DIR`, `BUILD_ARTIFACT_DIR`, `PF_XTENSA_BIN`, `PF_TARGET_ABI`, `PYTHON_PATH`, `WORKER_ID`, `BUILD_POLL_MS` | | Edition images | `VARIANT_BIN_DIR` | | Local dev | `NEXT_DEV_ORIGINS` | @@ -118,6 +118,6 @@ PostHog (`src/providers/PostHogProvider.tsx`, event helpers in `src/lib/posthogE - Styling: Tailwind v4 (`@import "tailwindcss"` in `globals.css`) + global custom CSS; larger components use CSS Modules (`*.module.css`). - **Layering:** `lib/` is the bottom. `app/` and `components/` import from it; it never imports from them (ESLint enforces it). A type a component and a serializer share belongs in `lib/` — `lib/community/cardTypes.ts` is the precedent. - Adding a preset: add the JS file under `src/lib/presets/`, register it in `index.ts`, then generate the firmware `.h` with the Pattern Lab's **To hardware** → *Copy the conversion prompt* flow. -- Tests are the `check:*` scripts in `package.json` — bespoke smoke suites under `scripts/*-smoke.ts`, one per subsystem — plus `check:panels`, a Vitest + jsdom + Testing Library harness (`vitest.config.ts`, `test/setup.ts`) for the Pattern Lab panels, where pointer gestures, keyboard shortcuts, undo and the Director's keyframes need a DOM. Those tests live beside the panels (`panels/*.test.tsx`) and read the store's buffers rather than the canvas, which jsdom does not have. `npm run check:ci` runs every one that needs nothing a CI runner lacks; `check:module` wants the Xtensa toolchain and stays local. The drift checks are `check:*` scripts too, one file each in `scripts/drift/`. Each compares two things the tree keeps by hand, the BOM and the build guide's parts table for one, and `check:drift`, the last step of `check:ci`, runs them all. Five hold a contract against a running panel instead. `check:rest-vectors`, `check:osc-vectors`, `check:midi-vectors`, `check:mqtt-vectors` and `check:audio-vectors` replay the vectors beside each spec in `docs/` against the virtual panel in `tools/virtual-panel/`, which is `lib/guide/`'s `DeviceSim` behind the firmware's ports. A vector also fails when the spec sentence it quotes is gone. `check:virtual-panel` is not a vector suite: it runs every preset on that panel. A file there whose name starts with `_` is a library the suites share. `_broker.ts` among them is a test broker, because a Patternflow is a broker's client and the MQTT suite has to bring one. +- Tests are the `check:*` scripts in `package.json` — bespoke smoke suites under `scripts/*-smoke.ts`, one per subsystem — plus `check:panels`, a Vitest + jsdom + Testing Library harness (`vitest.config.ts`, `test/setup.ts`) that runs every `src/**/*.test.ts` and `*.test.tsx`, each beside the file it tests. The name is from its first tests, the Pattern Lab panels, where pointer gestures, keyboard shortcuts, undo and the Director's keyframes need a DOM. Those read the store's buffers rather than the canvas, which jsdom does not have. The guide, the landing sections, the community and `lib/` have tests there too now. Some, like `lib/pattern/knobs.test.ts`, call plain functions and never touch the DOM. `npm run check:ci` runs every one that needs nothing a CI runner lacks; `check:module` wants the Xtensa toolchain and stays local. The drift checks are `check:*` scripts too, one file each in `scripts/drift/`. Each compares two things the tree keeps by hand, the BOM and the build guide's parts table for one, and `check:drift`, the last step of `check:ci`, runs them all. Five hold a contract against a running panel instead. `check:rest-vectors`, `check:osc-vectors`, `check:midi-vectors`, `check:mqtt-vectors` and `check:audio-vectors` replay the vectors beside each spec in `docs/` against the virtual panel in `tools/virtual-panel/`, which is `lib/guide/`'s `DeviceSim` behind the firmware's ports. A vector also fails when the spec sentence it quotes is gone. `check:virtual-panel` is not a vector suite: it runs every preset on that panel. A file there whose name starts with `_` is a library the suites share. `_broker.ts` among them is a test broker, because a Patternflow is a broker's client and the MQTT suite has to bring one. - CI (`.github/workflows/web-ci.yml`) lints, typechecks (`npm run typecheck`, which reads `tools/virtual-panel/` too), builds and runs `check:ci` on every PR touching `web/`. `.github/workflows/drift.yml` runs `check:drift` on every PR, whatever it touches, and `check:human-lock` against the PR's base. It sets `PF_DRIFT_STRICT=1`: at a desk a check skips a side it cannot read (a tag the clone lacks, no `pdftotext`), and in CI that skip fails. - **Deployment weight:** Vercel meters storage per *retained deployment* — every one counts at its full output size, static files and function bundles both, with nothing shared between deployments — so size × push count is what the Hobby allowance (10 GB each) is spent on. Only `main` deploys: `vercel.json` turns deployments off for `dev`, which was three quarters of them and whose previews nobody opened. `public/` is ~90 % of a deployment's static weight (journal and build-map photos). Server functions must carry none of it: `outputFileTracingExcludes` in `next.config.ts` keeps `public/` out of every function, and a build that prints "Dynamic filesystem access causes tracing of the whole project" has found a new way in — the comment there has the history. diff --git a/web/README.md b/web/README.md index 97a545bd4..d41db901d 100644 --- a/web/README.md +++ b/web/README.md @@ -23,6 +23,7 @@ npm run check:rest-vectors # a drift check: ../docs/rest-api-vectors against a npm run check:virtual-panel # a drift check too, but no vectors: every preset runs two seconds on a virtual panel without throwing npm run virtual-panel # a Patternflow with no board, until Ctrl-C; on Linux add -- --port http=8080 (../tools/virtual-panel/README.md) npm run check:human-lock # fails if a word of the maintainer's own writing changed since the merge-base with origin/dev +npm run check:voice # file:line for each banned word or pattern in the lines added since that merge-base; run it before a PR that adds prose (CI does not) npm start # serve the production build ``` diff --git a/web/public/fonts/Pretendard-Bold.hangul-rare.woff2 b/web/public/fonts/Pretendard-Bold.hangul-rare.woff2 new file mode 100644 index 000000000..e23dc3ef3 Binary files /dev/null and b/web/public/fonts/Pretendard-Bold.hangul-rare.woff2 differ diff --git a/web/public/fonts/Pretendard-Bold.hangul.woff2 b/web/public/fonts/Pretendard-Bold.hangul.woff2 index 84626af40..36943af3a 100644 Binary files a/web/public/fonts/Pretendard-Bold.hangul.woff2 and b/web/public/fonts/Pretendard-Bold.hangul.woff2 differ diff --git a/web/public/fonts/Pretendard-Regular.hangul-rare.woff2 b/web/public/fonts/Pretendard-Regular.hangul-rare.woff2 new file mode 100644 index 000000000..09df8db43 Binary files /dev/null and b/web/public/fonts/Pretendard-Regular.hangul-rare.woff2 differ diff --git a/web/public/fonts/Pretendard-Regular.hangul.woff2 b/web/public/fonts/Pretendard-Regular.hangul.woff2 index cb7442015..72deecd51 100644 Binary files a/web/public/fonts/Pretendard-Regular.hangul.woff2 and b/web/public/fonts/Pretendard-Regular.hangul.woff2 differ diff --git a/web/public/pattern-sandbox.html b/web/public/pattern-sandbox.html index 28d5cdcf8..4bc8162b1 100644 --- a/web/public/pattern-sandbox.html +++ b/web/public/pattern-sandbox.html @@ -19,7 +19,12 @@ diff --git a/web/scripts/doc-map.ts b/web/scripts/doc-map.ts index ff718c7dc..a785d1417 100644 --- a/web/scripts/doc-map.ts +++ b/web/scripts/doc-map.ts @@ -34,8 +34,9 @@ * reads the same rows and writes nothing. It fails on an absolute row that does * not resolve, and on a tracked file outside the sources above that carries a * repository URL, since the map never reads that file. A row on a ref the clone - * lacks is skipped, and the check says which refs. With PF_DRIFT_STRICT=1 a - * skip fails as well. + * lacks is skipped, and the check says which refs and exits with SKIPPED from + * scripts/drift/_lib.ts, so scripts/drift.ts prints it as skipped and not as + * passed. With PF_DRIFT_STRICT=1 a skip fails as well. * * The parity list (docs/rfc-cleanup.md §2.3) is every text the repository * keeps in both Korean and English, one row per pair, and whether a side is @@ -62,6 +63,8 @@ import fs from "node:fs"; import path from "node:path"; import ts from "typescript"; +import { SKIPPED } from "./drift/_lib"; + const ROOT = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim(); const OUT = "docs/investigations/2026-10-doc-map.csv"; const PARITY = "docs/investigations/2026-10-doc-parity.csv"; @@ -257,7 +260,7 @@ if (CHECK) { } const failed = broken.length > 0 || strays.length > 0 || (unread.length > 0 && strict); console.log(`links-absolute: ${failed ? "failed" : unread.length ? "skipped" : "passed"}`); - process.exit(failed ? 1 : 0); + process.exit(failed ? 1 : unread.length ? SKIPPED : 0); } const csv = ["from,to,kind,anchor,resolves", ...sorted.map((r) => [r.from, r.to, r.kind, r.anchor, r.resolves].map(cell).join(","))]; diff --git a/web/scripts/drift/_lib.ts b/web/scripts/drift/_lib.ts index 455975753..f4379364a 100644 --- a/web/scripts/drift/_lib.ts +++ b/web/scripts/drift/_lib.ts @@ -6,8 +6,9 @@ * compare() prints both sides and what is on one side only, so a red run in * CI says what drifted without anyone checking the branch out. A side a check * cannot read where it runs (a tag the clone lacks, no gh login, no pdftotext) - * goes to skip(), which says why and passes. Under PF_DRIFT_STRICT=1, which - * the CI workflow sets, a skip fails. + * goes to skip(), which says why. The check then exits SKIPPED, which fails + * nothing and which scripts/drift.ts prints as skipped. Under + * PF_DRIFT_STRICT=1, which the CI workflow sets, a skip fails. */ import { execFileSync } from "node:child_process"; import fs from "node:fs"; @@ -59,7 +60,7 @@ export function compare(name: string, left: Side, right: Side): boolean { return ok; } -/** A side this check cannot read here. Says why; passes, or fails under PF_DRIFT_STRICT=1. */ +/** A side this check cannot read here. Says why; done() then exits SKIPPED, or 1 under PF_DRIFT_STRICT=1. */ export function skip(name: string, why: string): void { console.log(`${name}: skipped, ${why}`); skipped++; diff --git a/web/scripts/enqueue-test-build.ts b/web/scripts/enqueue-test-build.ts index ccdd01ac2..7e78fc8ce 100644 --- a/web/scripts/enqueue-test-build.ts +++ b/web/scripts/enqueue-test-build.ts @@ -1,9 +1,13 @@ /** * Put one build on the queue, for testing the worker without the web UI. * - * npm run build:enqueue # whole image from the template - * npm run build:enqueue -- path/to.h # or a header of your choosing - * npm run build:enqueue -- --pfm path/to.h # loadable modules instead + * npm run build:enqueue # the pattern template + * npm run build:enqueue -- path/to.h # or a header of your choosing + * + * The worker compiles the header into a .pfm module and zips it, as it does + * for a pattern sent from the site. Whole-image builds ended on 12 August + * (4c13474). --pfm, which used to pick modules over an image, is still + * accepted and changes nothing. * * Prints the build id, then poll it with: npm run build:status -- */ @@ -18,9 +22,7 @@ async function main() { const { getDb } = await import("../src/lib/community/server/db"); const { user } = await import("../src/lib/community/server/schema"); - const args = process.argv.slice(2); - const pfm = args.includes("--pfm"); - const headerArg = args.find((a) => a !== "--pfm"); + const headerArg = process.argv.slice(2).find((a) => a !== "--pfm"); const headerPath = headerArg ? path.resolve(process.cwd(), headerArg) : path.resolve(process.cwd(), "../firmware/patternflow/_TEMPLATE.h"); @@ -38,13 +40,9 @@ async function main() { } const code = fs.readFileSync(headerPath, "utf8"); - const id = await enqueueBuild( - accounts[0].id, - [{ label: path.basename(headerPath), code }], - pfm ? "pfm" : "bin", - ); + const id = await enqueueBuild(accounts[0].id, [{ label: path.basename(headerPath), code }], "pfm"); - console.log(`queued ${pfm ? "module" : "image"} build ${id}`); + console.log(`queued module build ${id}`); console.log(` header : ${headerPath} (${(code.length / 1024).toFixed(1)} KB)`); console.log(` as : ${accounts[0].name}`); console.log(`\nnpm run build:status -- ${id}`); diff --git a/web/src/app/api/community/atlas/route.ts b/web/src/app/api/community/atlas/route.ts index 2cd779fa1..f13c690a3 100644 --- a/web/src/app/api/community/atlas/route.ts +++ b/web/src/app/api/community/atlas/route.ts @@ -3,6 +3,7 @@ import { ENTRY_BY_ID } from "@/lib/atlas/data"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { listAtlasPins } from "@/lib/community/server/queries"; import { rateLimit } from "@/lib/community/ratelimit"; @@ -84,12 +85,8 @@ async function handleWrite(request: Request, mode: "place" | "remove") { return Response.json({ error: "Too many moves — let the pin settle a moment." }, { status: 429 }); } - let payload: Record; - try { - payload = (await request.json()) as Record; - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const payload = await readJsonObject(request); + if (payload instanceof Response) return payload; const patternId = typeof payload.patternId === "string" ? payload.patternId : ""; if (!patternId) { diff --git a/web/src/app/api/community/builds/route.ts b/web/src/app/api/community/builds/route.ts index d12411762..e8aa6f459 100644 --- a/web/src/app/api/community/builds/route.ts +++ b/web/src/app/api/community/builds/route.ts @@ -8,6 +8,7 @@ import { type BuildPatternInput, } from "@/lib/community/server/builds"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled } from "@/lib/community/server/db"; import { rateLimit } from "@/lib/community/ratelimit"; import { CPP_MAX } from "@/lib/community/validate"; @@ -70,12 +71,8 @@ async function handlePost(request: Request) { ); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const body = await readJsonObject(request); + if (body instanceof Response) return body; // "bin" asked for a whole firmware image. The community built those from 25 // July 2026 (c5e0907) until 4c13474 removed them on 12 August, and since @@ -84,7 +81,7 @@ async function handlePost(request: Request) { // for an image, sent no format, and a missing format meant "bin" until 12 // August. The 410 is for a caller outside this repository that names the // format, as this route's header told it to from 27 July (d7cd193). - const rawFormat = (body as Record).format ?? "pfm"; + const rawFormat = body.format ?? "pfm"; if (rawFormat === "bin") { return Response.json( { @@ -100,7 +97,7 @@ async function handlePost(request: Request) { } const format: BuildFormat = rawFormat; - const raw = (body as Record).patterns; + const raw = body.patterns; if (!Array.isArray(raw) || raw.length === 0) { return Response.json({ error: "Send at least one pattern to build." }, { status: 400 }); } diff --git a/web/src/app/api/community/comments/[id]/route.ts b/web/src/app/api/community/comments/[id]/route.ts index 8a9307705..cf3b030bb 100644 --- a/web/src/app/api/community/comments/[id]/route.ts +++ b/web/src/app/api/community/comments/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { clearNotificationsFor } from "@/lib/community/server/notify"; import { getCommentStub } from "@/lib/community/server/queries"; @@ -64,14 +65,10 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "You can only edit your own comments." }, { status: 403 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const body = await readJsonObject(request); + if (body instanceof Response) return body; - const next = cleanComment((body as Record).body); + const next = cleanComment(body.body); if (!next) { return Response.json( { error: `A comment cannot be empty, and must be under ${COMMENT_MAX} characters.` }, diff --git a/web/src/app/api/community/decks/[id]/route.test.ts b/web/src/app/api/community/decks/[id]/route.test.ts new file mode 100644 index 000000000..08662a42d --- /dev/null +++ b/web/src/app/api/community/decks/[id]/route.test.ts @@ -0,0 +1,133 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +// Sharing a deck and editing it used to count against the same five a minute. +// The visibility select sends a PATCH on every change, so somebody who shared +// a deck and then flipped it public and back a couple of times was refused on +// the fifth edit. Driven through the real routes with real sessions, on a +// database of its own. + +const ENV_KEYS = ["COMMUNITY_DB_PATH", "COMMUNITY_ENABLED", "BETTER_AUTH_SECRET"] as const; +const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +let tmp = ""; + +/** Signs somebody up with one public pattern and returns their Cookie header. */ +let enrol: (username: string) => Promise; +/** Shares a private deck holding that person's pattern. */ +let share: (cookie: string, username: string) => Promise<{ status: number; id?: string }>; +let edit: (cookie: string, deckId: string, body: Record) => Promise; + +beforeAll(async () => { + // The database is a lazy singleton keyed off these variables, so they are + // set before anything imports it. + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-deck-rate-")); + process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); + process.env.COMMUNITY_ENABLED = "1"; + process.env.BETTER_AUTH_SECRET = "deck-rate-test-secret-not-a-real-one"; + + const { eq } = await import("drizzle-orm"); + const { getAuth } = await import("@/lib/community/server/auth"); + const { getDb } = await import("@/lib/community/server/db"); + const schema = await import("@/lib/community/server/schema"); + const createRoute = await import("../route"); + const deckRoute = await import("./route"); + + const db = getDb(); + const auth = getAuth(); + + enrol = async (username) => { + const response = await auth.api.signUpEmail({ + body: { + email: `${username}@patternflow.local`, + password: "deck-rate-test-password", + name: username, + username, + }, + asResponse: true, + }); + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) throw new Error(`no session cookie for ${username}`); + const [author] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, username)) + .limit(1); + const now = new Date(); + await db.insert(schema.patterns).values({ + id: `p-${username}`, + userId: author!.id, + title: `${username}'s pattern`, + code: "// js", + license: "CC-BY-SA-4.0", + visibility: "public", + createdAt: now, + updatedAt: now, + }); + return setCookie.split(";")[0]; + }; + + // No Origin header below, which is the same-origin case originBlocked() + // lets through. The session is the gate under test. + share = async (cookie, username) => { + const response = await createRoute.POST( + new Request("http://localhost:3000/api/community/decks", { + method: "POST", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify({ + title: `${username}'s running order`, + visibility: "private", + patternIds: [`p-${username}`], + }), + }), + ); + const payload = (await response.json()) as { id?: string }; + return { status: response.status, id: payload.id }; + }; + + edit = async (cookie, deckId, body) => { + const response = await deckRoute.PATCH( + new Request(`http://localhost:3000/api/community/decks/${deckId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id: deckId }) }, + ); + return response.status; + }; +}, 30_000); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); +}); + +it("lets the owner edit a deck twenty times in the minute they shared it", async () => { + const ada = await enrol("ada"); + const shared = await share(ada, "ada"); + expect(shared.status).toBe(201); + const deckId = shared.id!; + + const statuses: number[] = []; + for (let n = 0; n < 20; n += 1) { + statuses.push(await edit(ada, deckId, { visibility: n % 2 === 0 ? "public" : "private" })); + } + expect(statuses).toEqual(Array(20).fill(200)); + + // Edits still have a ceiling, the same twenty a minute a pattern's have. + expect(await edit(ada, deckId, { title: "One too many" })).toBe(429); + // And they never ate into sharing: a new deck still goes through. + expect((await share(ada, "ada")).status).toBe(201); +}, 30_000); + +it("keeps sharing new decks at five a minute", async () => { + const bea = await enrol("bea"); + const statuses: number[] = []; + for (let n = 0; n < 6; n += 1) statuses.push((await share(bea, "bea")).status); + expect(statuses).toEqual([201, 201, 201, 201, 201, 429]); +}, 30_000); diff --git a/web/src/app/api/community/decks/[id]/route.ts b/web/src/app/api/community/decks/[id]/route.ts index 9cad0bdcb..992ef7ddc 100644 --- a/web/src/app/api/community/decks/[id]/route.ts +++ b/web/src/app/api/community/decks/[id]/route.ts @@ -7,6 +7,7 @@ import { } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { DECK_MAX, PUBLIC_DECKS_MAX } from "@/lib/community/deck"; import { checkDeckPattern, cleanPatternIds } from "@/lib/community/deckShare"; @@ -84,7 +85,11 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "Sign in to edit your deck." }, { status: 401 }); } - if (!rateLimit(`deck:${session.user.id}`, 5, 60_000)) { + // Edits count on a key of their own. The visibility select sends a PATCH on + // every change, and while edits shared `deck:` with sharing a new deck, the + // fifth edit within a minute of the share was refused. Twenty, the same as + // a pattern's or a post's edits. + if (!rateLimit(`deckpatch:${session.user.id}`, 20, 60_000)) { return Response.json({ error: "Too many deck updates — wait a minute and try again." }, { status: 429 }); } @@ -97,13 +102,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "You can only edit your own decks." }, { status: 403 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; if (moderating) { if (!moderatorVisibilityPatchOnly(raw)) { diff --git a/web/src/app/api/community/decks/route.ts b/web/src/app/api/community/decks/route.ts index 3aa0ab1fb..99b6f6c40 100644 --- a/web/src/app/api/community/decks/route.ts +++ b/web/src/app/api/community/decks/route.ts @@ -1,6 +1,7 @@ import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { DECK_MAX, PUBLIC_DECKS_MAX } from "@/lib/community/deck"; import { checkDeckPattern, cleanPatternIds } from "@/lib/community/deckShare"; @@ -42,13 +43,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many deck updates — wait a minute and try again." }, { status: 429 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const title = cleanTitle(raw.title); if (!title) return Response.json({ error: "Title is required (max 80 chars)." }, { status: 400 }); diff --git a/web/src/app/api/community/featured/route.ts b/web/src/app/api/community/featured/route.ts index e80d3a825..4ef3bc9c4 100644 --- a/web/src/app/api/community/featured/route.ts +++ b/web/src/app/api/community/featured/route.ts @@ -2,6 +2,7 @@ import { inArray } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { featuredPatterns, patterns } from "@/lib/community/server/schema"; @@ -35,14 +36,10 @@ async function handlePut(request: Request) { return Response.json({ error: "Not found." }, { status: 404 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const payload = await readJsonObject(request); + if (payload instanceof Response) return payload; - const raw = (payload as { patternIds?: unknown }).patternIds; + const raw = payload.patternIds; if (!Array.isArray(raw) || raw.some((id) => typeof id !== "string")) { return Response.json({ error: "Expected patternIds: string[]." }, { status: 400 }); } diff --git a/web/src/app/api/community/nullBody.test.ts b/web/src/app/api/community/nullBody.test.ts new file mode 100644 index 000000000..0c4a4c807 --- /dev/null +++ b/web/src/app/api/community/nullBody.test.ts @@ -0,0 +1,133 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +// `null` is valid JSON, so it got past the `catch` around every write route's +// `request.json()` and threw a TypeError on the first field read. Next.js +// answered that with a bare 500: no JSON saying why, and no CORS headers, so a +// Pattern Lab on another origin could not read even the status. Every route +// below is driven with a signed-in moderator and the Lab's Origin, on a +// database of its own, with something for each id in the path to find. +// +// territories/[code]/pin is not here: its body is optional, and a `null` one +// already pins with no note. + +const LAB_ORIGIN = "https://lab.patternflow.test"; +const ENV_KEYS = [ + "COMMUNITY_DB_PATH", + "COMMUNITY_ENABLED", + "BETTER_AUTH_SECRET", + "COMMUNITY_ADMIN_USERNAMES", + "COMMUNITY_ALLOWED_ORIGINS", + "BUILD_ENABLED", +] as const; +const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +let tmp = ""; +let cookie = ""; + +type Handler = (request: Request, context: { params: Promise> }) => Promise; +type RouteModule = Record; + +// [method, path, module loader, path params] +const ROUTES: [string, string, () => Promise, Record][] = [ + ["POST", "atlas", () => import("./atlas/route"), {}], + ["DELETE", "atlas", () => import("./atlas/route"), {}], + ["POST", "builds", () => import("./builds/route"), {}], + ["PATCH", "comments/c1?on=pattern", () => import("./comments/[id]/route"), { id: "c1" }], + ["POST", "decks", () => import("./decks/route"), {}], + ["PATCH", "decks/d1", () => import("./decks/[id]/route"), { id: "d1" }], + ["PUT", "featured", () => import("./featured/route"), {}], + ["POST", "patterns", () => import("./patterns/route"), {}], + ["PATCH", "patterns/p1", () => import("./patterns/[id]/route"), { id: "p1" }], + ["POST", "patterns/p1/comments", () => import("./patterns/[id]/comments/route"), { id: "p1" }], + ["POST", "patterns/p1/header", () => import("./patterns/[id]/header/route"), { id: "p1" }], + ["POST", "patterns/p1/performance", () => import("./patterns/[id]/performance/route"), { id: "p1" }], + ["PATCH", "ports/h1", () => import("./ports/[id]/route"), { id: "h1" }], + ["POST", "posts", () => import("./posts/route"), {}], + ["PATCH", "posts/s1", () => import("./posts/[id]/route"), { id: "s1" }], + ["POST", "posts/s1/comments", () => import("./posts/[id]/comments/route"), { id: "s1" }], + ["POST", "posts/s1/pin", () => import("./posts/[id]/pin/route"), { id: "s1" }], + ["POST", "presence", () => import("./presence/route"), {}], + ["POST", "reports", () => import("./reports/route"), {}], + ["PATCH", "reports/r1", () => import("./reports/[id]/route"), { id: "r1" }], + ["POST", "territories", () => import("./territories/route"), {}], + ["PATCH", "territories/A1", () => import("./territories/[code]/route"), { code: "A1" }], +]; + +beforeAll(async () => { + // The database is a lazy singleton keyed off these variables, so they are + // set before anything imports it. + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-null-body-")); + process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); + process.env.COMMUNITY_ENABLED = "1"; + process.env.BETTER_AUTH_SECRET = "null-body-test-secret-not-a-real-one"; + process.env.COMMUNITY_ADMIN_USERNAMES = "ada"; + process.env.COMMUNITY_ALLOWED_ORIGINS = LAB_ORIGIN; + process.env.BUILD_ENABLED = "1"; + + const { eq } = await import("drizzle-orm"); + const { getAuth } = await import("@/lib/community/server/auth"); + const { getDb } = await import("@/lib/community/server/db"); + const schema = await import("@/lib/community/server/schema"); + + const response = await getAuth().api.signUpEmail({ + body: { email: "ada@patternflow.local", password: "null-body-test-password", name: "ada", username: "ada" }, + asResponse: true, + }); + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) throw new Error("no session cookie for ada"); + cookie = setCookie.split(";")[0]; + + const db = getDb(); + const [ada] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, "ada")) + .limit(1); + const userId = ada!.id; + const now = new Date(); + await db.insert(schema.patterns).values({ + id: "p1", userId, title: "ada's pattern", code: "// js", visibility: "public", createdAt: now, updatedAt: now, + }); + await db.insert(schema.decks).values({ id: "d1", userId, title: "ada's deck", createdAt: now, updatedAt: now }); + await db.insert(schema.comments).values({ id: "c1", patternId: "p1", userId, body: "first", createdAt: now }); + await db.insert(schema.patternHeaders).values({ + id: "h1", patternId: "p1", userId, codeCpp: "#pragma once\n", createdAt: now, + }); + await db.insert(schema.territories).values({ id: "t1", code: "A1", title: "Workshop", createdAt: now }); + await db.insert(schema.posts).values({ + id: "s1", territoryId: "t1", userId, title: "ada's post", body: "hello", createdAt: now, updatedAt: now, + }); + await db.insert(schema.reports).values({ + id: "r1", targetType: "pattern", targetId: "p1", reporterId: userId, reason: "other", createdAt: now, + }); +}, 30_000); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); +}); + +it.each(ROUTES)("%s /api/community/%s answers a null body with a 400 the Lab can read", async ( + method, + route, + load, + params, +) => { + const handler = (await load())[method] as Handler; + const response = await handler( + new Request(`http://localhost:3000/api/community/${route}`, { + method, + headers: { "Content-Type": "application/json", Origin: LAB_ORIGIN, cookie }, + body: "null", + }), + { params: Promise.resolve(params) }, + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Expected a JSON object." }); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe(LAB_ORIGIN); +}); diff --git a/web/src/app/api/community/patterns/[id]/comments/route.ts b/web/src/app/api/community/patterns/[id]/comments/route.ts index cf794febe..16bf38756 100644 --- a/web/src/app/api/community/patterns/[id]/comments/route.ts +++ b/web/src/app/api/community/patterns/[id]/comments/route.ts @@ -1,6 +1,7 @@ import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { notifyCommentAdded } from "@/lib/community/server/notify"; import { getPatternStub, newId } from "@/lib/community/server/queries"; @@ -46,14 +47,10 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str return Response.json({ error: "Pattern not found." }, { status: 404 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const body = await readJsonObject(request); + if (body instanceof Response) return body; - const text = cleanComment((body as Record).body); + const text = cleanComment(body.body); if (!text) { return Response.json({ error: "Comment is empty or over 2000 chars." }, { status: 400 }); } diff --git a/web/src/app/api/community/patterns/[id]/header/route.ts b/web/src/app/api/community/patterns/[id]/header/route.ts index 026df279d..ffc6b3a47 100644 --- a/web/src/app/api/community/patterns/[id]/header/route.ts +++ b/web/src/app/api/community/patterns/[id]/header/route.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { bakePatternHeader } from "@/lib/community/server/moduleCache"; import { notifyPortAdded } from "@/lib/community/server/notify"; @@ -106,13 +107,8 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str return Response.json({ error: "No such pattern." }, { status: 404 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const codeCpp = cleanCpp(raw.codeCpp); if (!codeCpp) { diff --git a/web/src/app/api/community/patterns/[id]/performance/route.ts b/web/src/app/api/community/patterns/[id]/performance/route.ts index d903fa64d..c14c712bf 100644 --- a/web/src/app/api/community/patterns/[id]/performance/route.ts +++ b/web/src/app/api/community/patterns/[id]/performance/route.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { notifyPerformanceAdded } from "@/lib/community/server/notify"; import { @@ -129,13 +130,8 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str return Response.json({ error: "No such pattern." }, { status: 404 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; if (typeof raw.performanceJson !== "string" || raw.performanceJson.length === 0) { return Response.json({ error: "performanceJson (a Director show, as its JSON string) is required." }, { status: 400 }); diff --git a/web/src/app/api/community/patterns/[id]/route.test.ts b/web/src/app/api/community/patterns/[id]/route.test.ts new file mode 100644 index 000000000..70413c546 --- /dev/null +++ b/web/src/app/api/community/patterns/[id]/route.test.ts @@ -0,0 +1,179 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +// MIT and CC0 left the licence picker, and a pattern published under one keeps +// it. The edit route said it would refuse a change TO one of them, then looked +// the new value up in the list that still holds both, so a direct PATCH moved +// a CC BY-SA original to CC0 and rewrote the SPDX line in its source. Driven +// through the real route with real sessions, on a database of its own. + +const ENV_KEYS = ["COMMUNITY_DB_PATH", "COMMUNITY_ENABLED", "BETTER_AUTH_SECRET"] as const; +const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +let tmp = ""; + +/** Signs somebody up and returns their user id and Cookie header. */ +let enrol: (username: string) => Promise<{ id: string; cookie: string }>; +/** Puts a pattern straight into the table, as a past publish left it. */ +let seed: (row: { id: string; userId: string; license: string; parentId?: string }) => Promise; +let edit: ( + cookie: string, + patternId: string, + body: Record, +) => Promise<{ status: number; error?: string }>; +/** The licence column and the SPDX line baked into the stored source. */ +let stored: (patternId: string) => Promise<{ license: string; spdx: string | undefined }>; + +beforeAll(async () => { + // The database is a lazy singleton keyed off these variables, so they are + // set before anything imports it. + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-pattern-licence-")); + process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); + process.env.COMMUNITY_ENABLED = "1"; + process.env.BETTER_AUTH_SECRET = "pattern-licence-test-secret-not-a-real-one"; + + const { eq } = await import("drizzle-orm"); + const { getAuth } = await import("@/lib/community/server/auth"); + const { getDb } = await import("@/lib/community/server/db"); + const schema = await import("@/lib/community/server/schema"); + const patternRoute = await import("./route"); + + const db = getDb(); + const auth = getAuth(); + + enrol = async (username) => { + const response = await auth.api.signUpEmail({ + body: { + email: `${username}@patternflow.local`, + password: "pattern-licence-test-password", + name: username, + username, + }, + asResponse: true, + }); + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) throw new Error(`no session cookie for ${username}`); + const [author] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, username)) + .limit(1); + return { id: author!.id, cookie: setCookie.split(";")[0] }; + }; + + seed = async ({ id, userId, license, parentId }) => { + const now = new Date(); + await db.insert(schema.patterns).values({ + id, + userId, + title: id, + code: "function draw() {}", + license, + parentId: parentId ?? null, + visibility: "public", + createdAt: now, + updatedAt: now, + }); + }; + + // No Origin header below, which is the same-origin case originBlocked() + // lets through. + edit = async (cookie, patternId, body) => { + const response = await patternRoute.PATCH( + new Request(`http://localhost:3000/api/community/patterns/${patternId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id: patternId }) }, + ); + const payload = (await response.json()) as { error?: string }; + return { status: response.status, error: payload.error }; + }; + + stored = async (patternId) => { + const [row] = await db + .select({ license: schema.patterns.license, code: schema.patterns.code }) + .from(schema.patterns) + .where(eq(schema.patterns.id, patternId)) + .limit(1); + return { + license: row!.license, + spdx: /SPDX-License-Identifier: (\S+)/.exec(row!.code)?.[1], + }; + }; +}, 30_000); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); +}); + +it("refuses to move an original pattern to MIT or CC0", async () => { + const ada = await enrol("ada"); + await seed({ id: "p-ada", userId: ada.id, license: "CC-BY-SA-4.0" }); + // One edit first, so the source carries a baked header to compare against. + expect((await edit(ada.cookie, "p-ada", { title: "Tide" })).status).toBe(200); + + for (const license of ["CC0-1.0", "MIT"]) { + const refused = await edit(ada.cookie, "p-ada", { license }); + expect(refused.status).toBe(400); + expect(refused.error).toBe( + `${license} is retired. A pattern published under it keeps it, but no pattern can change to it.`, + ); + } + expect(await stored("p-ada")).toEqual({ license: "CC-BY-SA-4.0", spdx: "CC-BY-SA-4.0" }); +}, 30_000); + +it("still moves an original between the two licences the picker offers", async () => { + const bea = await enrol("bea"); + await seed({ id: "p-bea", userId: bea.id, license: "CC-BY-SA-4.0" }); + + expect((await edit(bea.cookie, "p-bea", { license: "CC-BY-4.0" })).status).toBe(200); + expect(await stored("p-bea")).toEqual({ license: "CC-BY-4.0", spdx: "CC-BY-4.0" }); + expect((await edit(bea.cookie, "p-bea", { license: "CC-BY-SA-4.0" })).status).toBe(200); + expect(await stored("p-bea")).toEqual({ license: "CC-BY-SA-4.0", spdx: "CC-BY-SA-4.0" }); +}, 30_000); + +it("lets a pattern published under a retired licence keep it, or leave it for an offered one", async () => { + const cal = await enrol("cal"); + await seed({ id: "p-cal", userId: cal.id, license: "MIT" }); + + // The edit modal sends the current licence back with every save. + expect((await edit(cal.cookie, "p-cal", { title: "Typo fixed", license: "MIT" })).status).toBe(200); + expect(await stored("p-cal")).toEqual({ license: "MIT", spdx: "MIT" }); + + // Retired to retired is still a change to a retired licence. + expect((await edit(cal.cookie, "p-cal", { license: "CC0-1.0" })).status).toBe(400); + expect(await stored("p-cal")).toEqual({ license: "MIT", spdx: "MIT" }); + + expect((await edit(cal.cookie, "p-cal", { license: "CC-BY-SA-4.0" })).status).toBe(200); + expect(await stored("p-cal")).toEqual({ license: "CC-BY-SA-4.0", spdx: "CC-BY-SA-4.0" }); +}, 30_000); + +it("refuses CC0 on a fork whose parent puts no limit on it", async () => { + // An MIT parent lets a fork take any licence (forkLicenseAllowed), so the + // fork check alone let this one through. + const dee = await enrol("dee"); + const eli = await enrol("eli"); + await seed({ id: "p-dee", userId: dee.id, license: "MIT" }); + await seed({ id: "p-eli", userId: eli.id, license: "CC-BY-4.0", parentId: "p-dee" }); + + expect((await edit(eli.cookie, "p-eli", { license: "CC0-1.0" })).status).toBe(400); + expect((await edit(eli.cookie, "p-eli", { license: "CC-BY-SA-4.0" })).status).toBe(200); + expect((await stored("p-eli")).license).toBe("CC-BY-SA-4.0"); +}, 30_000); + +it("refuses a licence it has never offered", async () => { + const fay = await enrol("fay"); + await seed({ id: "p-fay", userId: fay.id, license: "CC-BY-SA-4.0" }); + + expect(await edit(fay.cookie, "p-fay", { license: "GPL-3.0-only" })).toEqual({ + status: 400, + error: "Unknown licence.", + }); +}, 30_000); diff --git a/web/src/app/api/community/patterns/[id]/route.ts b/web/src/app/api/community/patterns/[id]/route.ts index 482dff73c..afa177c08 100644 --- a/web/src/app/api/community/patterns/[id]/route.ts +++ b/web/src/app/api/community/patterns/[id]/route.ts @@ -8,6 +8,7 @@ import { } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { bakePatternHeader } from "@/lib/community/server/moduleCache"; import { @@ -39,7 +40,13 @@ import { cleanTitle, } from "@/lib/community/validate"; import { cleanVisibility } from "@/lib/community/visibility"; -import { KNOWN_LICENSES, forkLicenseAllowed, stripShareWrapping } from "@/lib/pattern/share"; +import { + KNOWN_LICENSES, + LICENSE_OPTIONS, + RETIRED_LICENSE_OPTIONS, + forkLicenseAllowed, + stripShareWrapping, +} from "@/lib/pattern/share"; // PATCH /api/community/patterns/[id] — the author edits their own pattern. // @@ -128,13 +135,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "You can only edit your own patterns." }, { status: 403 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; // A moderator's reach into someone else's pattern is its visibility or its // .h, never both at once. Every other field below simply never runs for @@ -182,11 +184,25 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st let license = pattern.license; if (raw.license !== undefined) { - // KNOWN_LICENSES, not LICENSE_OPTIONS: a pattern published under a licence - // that has since been retired must stay editable by its author. Keeping the - // stored value is always allowed; only *changing* it is restricted. - const match = KNOWN_LICENSES.find((option) => option.spdx === raw.license); - if (!match) return Response.json({ error: "Unknown licence." }, { status: 400 }); + // Keeping the stored value is always allowed, so it is looked up in + // KNOWN_LICENSES: a pattern published under a licence that has since been + // retired must stay editable by its author. Changing it picks from + // LICENSE_OPTIONS, the two the publish route and both pickers offer. A + // retired licence stays on the work that already has it, and no pattern + // moves to one. + const choosable = raw.license === pattern.license ? KNOWN_LICENSES : LICENSE_OPTIONS; + const match = choosable.find((option) => option.spdx === raw.license); + if (!match) { + const retired = RETIRED_LICENSE_OPTIONS.find((option) => option.spdx === raw.license); + return Response.json( + { + error: retired + ? `${retired.spdx} is retired. A pattern published under it keeps it, but no pattern can change to it.` + : "Unknown licence.", + }, + { status: 400 }, + ); + } if (match.spdx !== pattern.license && parent && !forkLicenseAllowed(parent.license, match.spdx)) { return Response.json( { diff --git a/web/src/app/api/community/patterns/route.ts b/web/src/app/api/community/patterns/route.ts index 4ab900e46..3f954aee1 100644 --- a/web/src/app/api/community/patterns/route.ts +++ b/web/src/app/api/community/patterns/route.ts @@ -1,6 +1,7 @@ import { and, eq, gte } from "drizzle-orm"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { MAX_FEED_PAGE_SIZE } from "@/lib/community/feedView"; import { notifyForkPublished } from "@/lib/community/server/notify"; @@ -124,13 +125,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many uploads — wait a minute and try again." }, { status: 429 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const title = cleanTitle(raw.title); if (!title) return Response.json({ error: "Title is required (max 80 chars)." }, { status: 400 }); diff --git a/web/src/app/api/community/ports/[id]/route.ts b/web/src/app/api/community/ports/[id]/route.ts index 63d15bc4c..a20b1727b 100644 --- a/web/src/app/api/community/ports/[id]/route.ts +++ b/web/src/app/api/community/ports/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { bakePatternHeader } from "@/lib/community/server/moduleCache"; import { clearNotificationsFor, notifyHeaderModerated } from "@/lib/community/server/notify"; @@ -96,13 +97,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st const port = await getPortStub(id); if (!port) return Response.json({ error: "Port not found." }, { status: 404 }); - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const codeCpp = cleanCpp(raw.codeCpp); if (codeCpp === null) { diff --git a/web/src/app/api/community/posts/[id]/comments/route.ts b/web/src/app/api/community/posts/[id]/comments/route.ts index 3c2e45ac7..e38d30af3 100644 --- a/web/src/app/api/community/posts/[id]/comments/route.ts +++ b/web/src/app/api/community/posts/[id]/comments/route.ts @@ -1,5 +1,6 @@ import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { notifyCommentAdded } from "@/lib/community/server/notify"; import { getPostStub, newId } from "@/lib/community/server/queries"; @@ -40,14 +41,10 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str return Response.json({ error: "Post not found." }, { status: 404 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const payload = await readJsonObject(request); + if (payload instanceof Response) return payload; - const text = cleanComment((payload as Record).body); + const text = cleanComment(payload.body); if (!text) { return Response.json({ error: "Comment is empty or over 2000 chars." }, { status: 400 }); } diff --git a/web/src/app/api/community/posts/[id]/pin/route.ts b/web/src/app/api/community/posts/[id]/pin/route.ts index b39f0ee18..10c216b56 100644 --- a/web/src/app/api/community/posts/[id]/pin/route.ts +++ b/web/src/app/api/community/posts/[id]/pin/route.ts @@ -2,6 +2,7 @@ import { eq, isNotNull } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { getPostStub } from "@/lib/community/server/queries"; import { posts } from "@/lib/community/server/schema"; @@ -36,13 +37,9 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str const post = await getPostStub(id); if (!post) return Response.json({ error: "Post not found." }, { status: 404 }); - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const pinned = (body as Record).pinned; + const body = await readJsonObject(request); + if (body instanceof Response) return body; + const pinned = body.pinned; if (typeof pinned !== "boolean") { return Response.json({ error: "Expected { pinned: true | false }." }, { status: 400 }); } diff --git a/web/src/app/api/community/posts/[id]/route.ts b/web/src/app/api/community/posts/[id]/route.ts index 5a607e70c..36ee7b0eb 100644 --- a/web/src/app/api/community/posts/[id]/route.ts +++ b/web/src/app/api/community/posts/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { clearNotificationsFor } from "@/lib/community/server/notify"; import { getPostStub } from "@/lib/community/server/queries"; @@ -79,13 +80,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "Too many edits — wait a minute and try again." }, { status: 429 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = payload as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; // Partial update: only the fields present in the body change. const patch: { title?: string; body?: string; updatedAt: Date } = { updatedAt: new Date() }; diff --git a/web/src/app/api/community/posts/route.ts b/web/src/app/api/community/posts/route.ts index 9ce0d9f9e..45f3d970b 100644 --- a/web/src/app/api/community/posts/route.ts +++ b/web/src/app/api/community/posts/route.ts @@ -1,5 +1,6 @@ import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { cleanPinNote, cleanTerritoryCode } from "@/lib/community/workshop"; import { notifyNewThread } from "@/lib/community/server/notify"; @@ -43,14 +44,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many posts — wait a minute and try again." }, { status: 429 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - - const raw = payload as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const title = cleanTitle(raw.title); if (!title) { return Response.json({ error: "Title is empty or too long." }, { status: 400 }); diff --git a/web/src/app/api/community/presence/route.ts b/web/src/app/api/community/presence/route.ts index f5c26ae0c..0385ef7df 100644 --- a/web/src/app/api/community/presence/route.ts +++ b/web/src/app/api/community/presence/route.ts @@ -1,5 +1,6 @@ import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { STAGE_HEIGHT, @@ -69,12 +70,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many moves — stand still a moment." }, { status: 429 }); } - let payload: Record; - try { - payload = (await request.json()) as Record; - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const payload = await readJsonObject(request); + if (payload instanceof Response) return payload; const hasPosition = payload.x !== undefined || payload.y !== undefined; const hasStatus = payload.status !== undefined; diff --git a/web/src/app/api/community/reports/[id]/route.ts b/web/src/app/api/community/reports/[id]/route.ts index b747578a4..d1d9940af 100644 --- a/web/src/app/api/community/reports/[id]/route.ts +++ b/web/src/app/api/community/reports/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { reports } from "@/lib/community/server/schema"; @@ -34,13 +35,9 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "Not found." }, { status: 404 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const status = (body as Record).status; + const body = await readJsonObject(request); + if (body instanceof Response) return body; + const status = body.status; if (typeof status !== "string" || !STATUSES.includes(status as never)) { return Response.json({ error: "Unknown status." }, { status: 400 }); } diff --git a/web/src/app/api/community/reports/route.ts b/web/src/app/api/community/reports/route.ts index dd2c24d0b..7f80c5666 100644 --- a/web/src/app/api/community/reports/route.ts +++ b/web/src/app/api/community/reports/route.ts @@ -1,5 +1,6 @@ import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { newId, reportTarget } from "@/lib/community/server/queries"; import { rateLimit } from "@/lib/community/ratelimit"; @@ -35,13 +36,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many reports — wait a minute and try again." }, { status: 429 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; if (!isReportTargetType(raw.targetType)) { return Response.json({ error: "Unknown report target." }, { status: 400 }); diff --git a/web/src/app/api/community/territories/[code]/route.ts b/web/src/app/api/community/territories/[code]/route.ts index cec3a0083..7e554b835 100644 --- a/web/src/app/api/community/territories/[code]/route.ts +++ b/web/src/app/api/community/territories/[code]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { QUESTION_MAX, @@ -78,13 +79,8 @@ async function handlePatch(request: Request, context: Context) { if (resolved.error) return resolved.error; const { territory } = resolved; - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = payload as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; // Every field is optional — the editor sends what changed, and archiving // arrives as its own one-key body. diff --git a/web/src/app/api/community/territories/route.ts b/web/src/app/api/community/territories/route.ts index 231bd7b46..eb5e3b08a 100644 --- a/web/src/app/api/community/territories/route.ts +++ b/web/src/app/api/community/territories/route.ts @@ -1,6 +1,7 @@ import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { cleanQuestions, @@ -39,13 +40,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Not found." }, { status: 404 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = payload as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const code = cleanTerritoryCode(raw.code); if (!code) { diff --git a/web/src/app/build/breadboard/breadboard.module.css b/web/src/app/build/breadboard/breadboard.module.css new file mode 100644 index 000000000..e46b71b93 --- /dev/null +++ b/web/src/app/build/breadboard/breadboard.module.css @@ -0,0 +1,81 @@ +/* A step photo sits under its diagram, and on a wide screen beside the step, + in the space between the 772px column and the window's edge. */ +.photo { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + margin-top: 16px; +} + +.photo img { + width: 100%; + height: auto; + display: block; + border-radius: 8px; + border: 1px solid var(--pf-rule); +} + +/* The photo is centred in that space and at most 360px wide. 50vw - 394px is + the space with room left for an 8px half of a desktop scrollbar. The photo + used to sit 120px out at a fixed 360px, which needs a 1,732px window: at + 1440px the page scrolled 146px sideways and the left-hand photos were cut + off where no scroll reaches. */ +@media (min-width: 1280px) { + .photoRight, + .photoLeft { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: calc(50vw - 394px); + margin-top: 0; + padding: 0 32px; + align-items: center; + } + + .photoRight { + left: 100%; + } + + .photoLeft { + right: 100%; + } + + .photoRight > *, + .photoLeft > * { + width: 100%; + max-width: 360px; + } +} + +@media print { + .page { + background: #ffffff !important; + color: #000000 !important; + } + + .page main { + padding: 0 !important; + max-width: none !important; + } + + .page section { + break-inside: avoid; + page-break-inside: avoid; + } + + .page header { + display: none !important; + } + + .photoRight, + .photoLeft { + position: static; + margin-top: 16px; + transform: none; + width: 100%; + max-width: 440px; + padding: 0; + align-items: stretch; + } +} diff --git a/web/src/app/build/breadboard/layout.tsx b/web/src/app/build/breadboard/layout.tsx index 80abfca9b..d1e867d88 100644 --- a/web/src/app/build/breadboard/layout.tsx +++ b/web/src/app/build/breadboard/layout.tsx @@ -1,9 +1,9 @@ import type { Metadata } from "next"; import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; -// page.tsx is a client component, and a client component cannot export -// metadata. Without this the guide's browser tab and its link preview were -// the home page's. +// The metadata lives here from when page.tsx was a client component, which +// cannot export it. Without it the guide's browser tab and its link preview +// were the home page's. export const metadata: Metadata = { title: "Breadboard Build Guide / Patternflow", description: diff --git a/web/src/app/build/breadboard/page.tsx b/web/src/app/build/breadboard/page.tsx index f5c2fc433..9435eb456 100644 --- a/web/src/app/build/breadboard/page.tsx +++ b/web/src/app/build/breadboard/page.tsx @@ -1,12 +1,10 @@ -'use client'; - -import React from 'react'; import Link from 'next/link'; import BreadboardDiagram from '@/components/docs/BreadboardDiagram'; +import styles from './breadboard.module.css'; export default function BreadboardBuildPage() { return ( -
+
{/* Navigation Header */}
↑ Ground (GND) pins on both sides of all four rotary encoders land on the Ground (−) rail of the breadboard.

-
+
{/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */} Encoders mounted with GND wires connected to the breadboard rail

↑ Encoders fixed, GND wires on the − rail. @@ -432,12 +433,15 @@ export default function BreadboardBuildPage() {

↑ U1 (ESP32-S3) on the left, and J1 (HUB75E) on the right. Wire the pins that share the same color-coded labels (e.g. R1, B1, A, B...).

-
+
{/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */} ESP32-S3 connected to the HUB75E LED matrix via ribbon cable

↑ ESP32-S3 with HUB75E jumpers connected. @@ -474,12 +478,15 @@ export default function BreadboardBuildPage() {

↑ Four rotary encoders on the left, and ESP32 on the right. Wire the signal channels (A, B, SW) to their matching colored tags (e.g. 1A, 2A...).

-
+
{/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */} All encoder signal wires routed routed into the case alongside the LED panel

↑ Encoder A, B, SW wires all connected. @@ -513,12 +520,15 @@ export default function BreadboardBuildPage() {

-
+
{/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */} Stripped USB and LED panel power wires being twisted onto jumpers

↑ Power leads stripped and twisted onto jumpers. @@ -561,7 +571,11 @@ export default function BreadboardBuildPage() { {img.label}

{img.label} @@ -617,57 +631,6 @@ export default function BreadboardBuildPage() {

- - {/* Scoped style to support printing and avoiding split sections */} -
); } diff --git a/web/src/app/community/layout.tsx b/web/src/app/community/layout.tsx index 487b88ee5..978a12ce1 100644 --- a/web/src/app/community/layout.tsx +++ b/web/src/app/community/layout.tsx @@ -80,7 +80,7 @@ export default async function CommunityLayout({ children }: { children: React.Re
- + Patternflow diff --git a/web/src/app/features/TryInstall.tsx b/web/src/app/features/TryInstall.tsx index 60f1281c6..787c3f2cb 100644 --- a/web/src/app/features/TryInstall.tsx +++ b/web/src/app/features/TryInstall.tsx @@ -20,7 +20,7 @@ import type { TryOut } from "./features-data"; const SITE = "https://patternflow.work"; export default function TryInstall({ t }: { t: TryOut }) { - const { deviceHost, changeDeviceHost } = useDeviceHost(); + const { deviceHost, host, changeDeviceHost } = useDeviceHost(); // The panel's page fetches the image, so the URL has to be absolute. The // production origin is the server-side answer, so the first paint matches; // a preview or a dev server reads its own in on the client. A store that @@ -32,7 +32,6 @@ export default function TryInstall({ t }: { t: TryOut }) { () => SITE, ); - const host = deviceHost.trim() || "patternflow.local"; const src = new URL(t.url, origin).toString(); const href = `http://${host}/update?src=${encodeURIComponent(src)}`; diff --git a/web/src/app/features/page.tsx b/web/src/app/features/page.tsx index c86c2f66d..ca1e00e07 100644 --- a/web/src/app/features/page.tsx +++ b/web/src/app/features/page.tsx @@ -104,7 +104,7 @@ export default function FeaturesPage() {
- + Patternflow diff --git a/web/src/app/fonts.test.ts b/web/src/app/fonts.test.ts new file mode 100644 index 000000000..ad9f50b08 --- /dev/null +++ b/web/src/app/fonts.test.ts @@ -0,0 +1,223 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { TextDecoder } from 'node:util'; +import { brotliDecompressSync } from 'node:zlib'; +import { describe, expect, it, vi } from 'vitest'; + +// Pretendard's parts against the @font-face rules in globals.css: each +// weight's ranges still cover all of Unicode, the Hangul is the 2,350 +// syllables of KS X 1001 in one file and the other 8,822 in another, and the +// common file is the one the browser tries first. Reads each WOFF2's cmap. +// Then the Google fonts the root layout loads, and which of them every page +// preloads. + +const FONTS = path.resolve(__dirname, '../../public/fonts'); +const CSS = fs.readFileSync(path.resolve(__dirname, 'globals.css'), 'utf8'); + +/** The one Hangul file a Korean page fetched before the split: Regular 593,848 bytes, Bold 617,400. */ +const COMMON_BUDGET = 170_000; + +type Range = [number, number]; + +interface Face { + weight: number; + file: string; + ranges: Range[]; +} + +function parseRanges(value: string): Range[] { + return value.split(',').map((part) => { + const [from, to] = part.trim().replace(/^U\+/i, '').split('-'); + return [parseInt(from, 16), parseInt(to ?? from, 16)]; + }); +} + +const faces: Face[] = [...CSS.matchAll(/@font-face\s*{([^}]*)}/g)] + .map((m) => m[1]) + .filter((body) => /font-family:\s*'Pretendard'/.test(body)) + .map((body) => ({ + weight: Number(/font-weight:\s*(\d+)/.exec(body)![1]), + file: /url\('\/fonts\/([^']+)'\)/.exec(body)![1], + ranges: parseRanges(/unicode-range:\s*([^;]+);/.exec(body)![1]), + })); + +const inRanges = (ranges: Range[], cp: number) => ranges.some(([a, b]) => cp >= a && cp <= b); + +/** UIntBase128 from the WOFF2 table directory. */ +function base128(buf: Buffer, at: { pos: number }): number { + let value = 0; + for (let i = 0; i < 5; i++) { + const byte = buf[at.pos++]; + value = value * 128 + (byte & 0x7f); + if (!(byte & 0x80)) return value; + } + throw new Error('UIntBase128 longer than five bytes'); +} + +/** The code points a WOFF2 maps to a glyph other than .notdef. */ +function woff2Cmap(file: string): Set { + const buf = fs.readFileSync(path.join(FONTS, file)); + expect(buf.toString('latin1', 0, 4), `${file} is not WOFF2`).toBe('wOF2'); + const numTables = buf.readUInt16BE(12); + const compressedSize = buf.readUInt32BE(20); + const at = { pos: 48 }; + let offset = 0; + let cmap: { offset: number; length: number } | null = null; + for (let i = 0; i < numTables; i++) { + const flags = buf[at.pos++]; + const known = flags & 0x3f; + const tag = known === 0x3f ? buf.toString('latin1', at.pos, (at.pos += 4)) : known; + const origLength = base128(buf, at); + const version = flags >> 6; + // glyf (10) and loca (11) are transformed at version 0; every other table at any other version. + const transformed = tag === 10 || tag === 11 ? version === 0 : version !== 0; + const length = transformed ? base128(buf, at) : origLength; + if (tag === 0) cmap = { offset, length }; + offset += length; + } + expect(cmap, `${file} has no cmap`).not.toBeNull(); + const data = brotliDecompressSync(buf.subarray(at.pos, at.pos + compressedSize)); + const table = data.subarray(cmap!.offset, cmap!.offset + cmap!.length); + + const subtables = new Map(); + for (let i = 0; i < table.readUInt16BE(2); i++) { + const rec = 4 + i * 8; + subtables.set(`${table.readUInt16BE(rec)},${table.readUInt16BE(rec + 2)}`, table.readUInt32BE(rec + 4)); + } + const cps = new Set(); + const full = subtables.get('3,10') ?? subtables.get('0,4'); + if (full !== undefined) { + expect(table.readUInt16BE(full), `${file}: full-repertoire cmap is not format 12`).toBe(12); + const groups = table.readUInt32BE(full + 12); + for (let g = 0; g < groups; g++) { + const rec = full + 16 + g * 12; + const start = table.readUInt32BE(rec); + const end = table.readUInt32BE(rec + 4); + const glyph = table.readUInt32BE(rec + 8); + for (let cp = start; cp <= end; cp++) if (glyph + cp - start !== 0) cps.add(cp); + } + return cps; + } + const bmp = subtables.get('3,1') ?? subtables.get('0,3'); + expect(bmp, `${file} has no Unicode cmap`).toBeDefined(); + expect(table.readUInt16BE(bmp!), `${file}: BMP cmap is not format 4`).toBe(4); + const segs = table.readUInt16BE(bmp! + 6) / 2; + const ends = bmp! + 14; + const starts = ends + segs * 2 + 2; + const deltas = starts + segs * 2; + const rangeOffsets = deltas + segs * 2; + for (let s = 0; s < segs; s++) { + const end = table.readUInt16BE(ends + s * 2); + const start = table.readUInt16BE(starts + s * 2); + const delta = table.readInt16BE(deltas + s * 2); + const rangeOffset = table.readUInt16BE(rangeOffsets + s * 2); + for (let cp = start; cp <= end; cp++) { + let glyph: number; + if (rangeOffset === 0) glyph = (cp + delta) & 0xffff; + else { + glyph = table.readUInt16BE(rangeOffsets + s * 2 + rangeOffset + (cp - start) * 2); + if (glyph !== 0) glyph = (glyph + delta) & 0xffff; + } + if (glyph !== 0) cps.add(cp); + } + } + return cps; +} + +/** The 2,350 Hangul syllables of KS X 1001: EUC-KR lead bytes B0–C8, trail bytes A1–FE. */ +function ksx1001Syllables(): Set { + const decoder = new TextDecoder('euc-kr'); + const out = new Set(); + for (let lead = 0xb0; lead <= 0xc8; lead++) { + for (let trail = 0xa1; trail <= 0xfe; trail++) { + const cp = decoder.decode(Uint8Array.of(lead, trail)).codePointAt(0)!; + if (cp >= 0xac00 && cp <= 0xd7a3) out.add(cp); + } + } + return out; +} + +const isSyllable = (cp: number) => cp >= 0xac00 && cp <= 0xd7a3; + +describe.each([ + [400, 'Regular'], + [700, 'Bold'], +] as const)('Pretendard %i', (weight, name) => { + const parts = faces.filter((face) => face.weight === weight); + const common = `Pretendard-${name}.hangul.woff2`; + const rare = `Pretendard-${name}.hangul-rare.woff2`; + + it('covers all of Unicode between its parts, as the whole file did', () => { + const ranges = parts.flatMap((face) => face.ranges).sort((x, y) => x[0] - y[0]); + let next = 0; + for (const [a, b] of ranges) { + expect(a, `nothing covers U+${next.toString(16).toUpperCase()}`).toBeLessThanOrEqual(next); + next = Math.max(next, b + 1); + } + expect(next).toBe(0x110000); + }); + + it('writes hangul-rare before hangul, so the browser tries hangul first', () => { + const files = parts.map((face) => face.file); + expect(files).toContain(common); + expect(files).toContain(rare); + expect(files.indexOf(rare)).toBeLessThan(files.indexOf(common)); + for (const file of [common, rare]) { + const face = parts.find((p) => p.file === file)!; + expect(inRanges(face.ranges, 0xac00) && inRanges(face.ranges, 0xd7a3), `${file} claims the syllable block`).toBe(true); + } + }); + + it('keeps each glyph of each part inside that part\'s unicode-range', () => { + for (const face of parts) { + const outside = [...woff2Cmap(face.file)].filter((cp) => !inRanges(face.ranges, cp)); + expect(outside.map((cp) => `U+${cp.toString(16).toUpperCase()}`), face.file).toEqual([]); + } + }); + + it('puts the jamo and the 2,350 syllables of KS X 1001 in hangul, and the other 8,822 in hangul-rare', () => { + const ks = ksx1001Syllables(); + expect(ks.size).toBe(2350); + const commonCps = woff2Cmap(common); + const rareCps = woff2Cmap(rare); + expect([...commonCps].filter(isSyllable).sort()).toEqual([...ks].sort()); + // Hangul Compatibility Jamo, ㄱ to ㅣ, as typed alone. + for (let cp = 0x3131; cp <= 0x3163; cp++) expect(commonCps.has(cp)).toBe(true); + expect([...rareCps].every(isSyllable)).toBe(true); + expect(rareCps.size).toBe(11172 - 2350); + expect([...rareCps].filter((cp) => ks.has(cp))).toEqual([]); + }); + + it('keeps a Korean page\'s Hangul under a quarter of the file it replaced', () => { + expect(fs.statSync(path.join(FONTS, common)).size).toBeLessThan(COMMON_BUDGET); + }); +}); + +// Each loader call the root layout makes, with its options. A font added there +// without a line here fails the import: the mock has no export for it. +const googleFonts = vi.hoisted(() => [] as { name: string; preload?: boolean }[]); +vi.mock('next/font/google', () => { + const loader = (name: string) => (options: { preload?: boolean }) => { + googleFonts.push({ name, preload: options.preload }); + return { variable: '', className: '' }; + }; + return { + Inter: loader('Inter'), + JetBrains_Mono: loader('JetBrains_Mono'), + Silkscreen: loader('Silkscreen'), + Newsreader: loader('Newsreader'), + }; +}); + +describe('the root layout\'s Google fonts', () => { + // Next puts a preload link for each font the root layout loads in the head + // of every page. On 22 October DM Sans, which no text used, and Newsreader + // and Silkscreen, which only the journal, /terms and the guide use, were + // 166,368 bytes on / and on /pattern-lab that no letter there was set in. + it('preloads only Inter and JetBrains Mono, the two every page sets text in', async () => { + await import('@/app/layout'); + const preloaded = googleFonts.filter((font) => font.preload !== false).map((font) => font.name); + expect(preloaded.sort()).toEqual(['Inter', 'JetBrains_Mono']); + expect(googleFonts.map((font) => font.name).sort()).toEqual(['Inter', 'JetBrains_Mono', 'Newsreader', 'Silkscreen']); + }); +}); diff --git a/web/src/app/globals.css b/web/src/app/globals.css index a1360c8c2..9c2339379 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1,13 +1,32 @@ @import "tailwindcss"; - /* Pretendard, each weight in four parts. The whole file is 770–790 KB, most + /* Pretendard, each weight in five parts. The whole file is 770–790 KB, most of it Hangul, and a page fetched all of it for one glyph Inter lacks — an English page for the arrow in a link. The parts are the same glyphs, outlines and metrics, cut by script (pyftsubset on the files beside them); their ranges cover all of Unicode between them, so whatever the whole file drew, one part still draws. A page now fetches only the parts it has characters for: an arrow costs 38 KB, and the Hangul comes with - the first Korean word. */ + the first Korean word. + + The Hangul is two parts. hangul holds the jamo and the 2,350 syllables + of KS X 1001 in 158–161 KB; on 21 October 2026 those were every + syllable in web/src and web/content. hangul-rare holds the other 8,822 + in 463–484 KB. Both claim the syllable block, and hangul-rare is + written first on purpose: where two faces' ranges overlap, the browser + tries the one written last and moves to the earlier one only for a + syllable it has no glyph for. A Korean page fetches hangul alone, and + hangul-rare waits for a name like 똠 or 뷁. Listing the 2,350 syllables + in unicode-range instead takes 1,613 runs, 29 KB of CSS on every page, + English ones included. + + Cut with fonttools 4.67 from the files beside them: + pyftsubset Pretendard-Regular.woff2 --unicodes-file= + --layout-features='*' --name-IDs='*' --flavor=woff2 + where the hangul list is U+1100-11FF, U+3130-318F, U+A960-A97F, + U+D7B0-D7FF and every syllable Python's euc_kr codec decodes from + lead bytes B0-C8, trail bytes A1-FE; the hangul-rare list is the rest + of U+AC00-D7A3. src/app/fonts.test.ts holds the files to that split. */ @font-face { font-family: 'Pretendard'; font-weight: 400; @@ -16,6 +35,14 @@ src: url('/fonts/Pretendard-Regular.latin.woff2') format('woff2'); unicode-range: U+0000-052F, U+1E00-1EFF; } + @font-face { + font-family: 'Pretendard'; + font-weight: 400; + font-style: normal; + font-display: swap; + src: url('/fonts/Pretendard-Regular.hangul-rare.woff2') format('woff2'); + unicode-range: U+AC00-D7A3; + } @font-face { font-family: 'Pretendard'; font-weight: 400; @@ -48,6 +75,14 @@ src: url('/fonts/Pretendard-Bold.latin.woff2') format('woff2'); unicode-range: U+0000-052F, U+1E00-1EFF; } + @font-face { + font-family: 'Pretendard'; + font-weight: 700; + font-style: normal; + font-display: swap; + src: url('/fonts/Pretendard-Bold.hangul-rare.woff2') format('woff2'); + unicode-range: U+AC00-D7A3; + } @font-face { font-family: 'Pretendard'; font-weight: 700; @@ -203,6 +238,16 @@ .viewer-hint { top: 40px; } + /* Where the 3D view would be, in a browser that cannot draw it (ViewerPanel). */ + .viewer-off { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + text-align: center; + } @media (max-width: 900px) { .app-layout { flex-direction: column; } @@ -369,6 +414,31 @@ border-color: var(--ink); text-decoration: none; } + /* Stop and Play for the hero video, in its bottom-right corner. Dark with a + light rule, so it shows on the poster and on the video. 40px square, for a + finger on a phone. The focus ring stays inside the frame, which clips + anything past its edge. */ + .hero-video-toggle { + position: absolute; + right: 10px; + bottom: 10px; + width: 40px; + height: 40px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(244, 239, 230, 0.55); + background: rgba(20, 20, 20, 0.62); + color: var(--cream); + cursor: pointer; + transition: background-color 0.15s ease; + } + .hero-video-toggle:hover { background: rgba(20, 20, 20, 0.85); } + .hero-video-toggle:focus-visible { + outline: 2px solid var(--pf-led); + outline-offset: 2px; + } /* At laptop height the column — wordmark, video, the two facts, the buttons — can be taller than the window with the full 80px above it, and the buttons' note falls below the fold. Take the difference from the top. The nav is out @@ -580,7 +650,7 @@ .panel-header { margin-bottom: 40px; } - .panel-header h2 { + .panel-header h1 { font-weight: 500; font-size: 64px; line-height: 1; @@ -793,7 +863,7 @@ @media (max-width: 900px) { .tab-bar { padding: 20px 24px 16px; gap: 20px; } .deck-content { padding: 0 24px 60px; } - .panel-header h2 { font-size: 42px; } + .panel-header h1 { font-size: 42px; } .panel-header .sub { font-size: 16px; } .panel-body p { font-size: 17px; } .panel-content { padding: 40px 0; min-height: auto; } diff --git a/web/src/app/homeLinkPrefetch.test.ts b/web/src/app/homeLinkPrefetch.test.ts new file mode 100644 index 000000000..754175524 --- /dev/null +++ b/web/src/app/homeLinkPrefetch.test.ts @@ -0,0 +1,69 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// A in view prefetches the home page, and the home page's +// client chunks come with it: three.js (237,537 bytes gzip) and the +// post-processing chunk (212,365). At 390px in Chromium on 22 October, +// /terms, /contact and a 404 fetched 767,286 bytes of script with the +// prefetch and 148,456 without, and /community 987,765 and 408,515. With +// prefetch={false} the home page loads when the link is followed. Pattern +// Lab (579,250 bytes) and the guide (107,109) still prefetch it, as do the +// breadboard guide, journal, editions and compliance. They are left out +// below until they are done the same way. + +const SRC = path.resolve(__dirname, '..'); +const LEFT_OUT = [ + 'components/guide', + 'app/build/breadboard', + 'app/pattern-lab', + 'components/journal', + 'app/editions', + 'app/compliance', +]; + +function sources(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return sources(full); + return entry.name.endsWith('.tsx') && !entry.name.includes('.test.') ? [full] : []; + }); +} + +/** Each `` opening tag, read to the `>` outside any braces. */ +function linkTags(source: string): string[] { + const tags: string[] = []; + for (const m of source.matchAll(/' && depth === 0) break; + } + tags.push(source.slice(m.index, end + 1)); + } + return tags; +} + +const homeLinks = sources(SRC).flatMap((file) => { + const rel = path.relative(SRC, file).split(path.sep).join('/'); + return linkTags(fs.readFileSync(file, 'utf8')) + .filter((tag) => /\bhref="\/"/.test(tag)) + .map((tag) => ({ rel, tag })); +}); + +describe('links to the home page', () => { + it('are still found', () => { + expect(homeLinks.map((l) => l.rel)).toContain('app/not-found.tsx'); + }); + + it('skip the prefetch outside the pages left out', () => { + const prefetching = homeLinks + .filter(({ rel }) => !LEFT_OUT.some((dir) => rel.startsWith(`${dir}/`))) + .filter(({ tag }) => !/\bprefetch=\{false\}/.test(tag)) + .map(({ rel }) => rel); + expect(prefetching).toEqual([]); + }); +}); diff --git a/web/src/app/hoverUnderline.test.ts b/web/src/app/hoverUnderline.test.ts new file mode 100644 index 000000000..d227d6a25 --- /dev/null +++ b/web/src/app/hoverUnderline.test.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// globals.css underlines every link on hover with `a:hover`, which is (0,1,1). +// A CSS module class that says `text-decoration: none` is (0,1,0) and loses +// to it, so a link drawn as a button or a card grew an underline when pointed +// at: 453 links on 16 pages in Chromium on 21 October, and the guide's +// "Stuck? Tell us" drew one under the line it already draws. Each such class +// says it again in a `:hover` rule. Community (31 classes), editions and +// compliance still lose. They are left out below until they are done the +// same way. + +const SRC = path.resolve(__dirname, '..'); +const LEFT_OUT = ['components/community', 'app/community', 'app/editions', 'app/compliance', 'app/journal']; + +function modules(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return modules(full); + return entry.name.endsWith('.module.css') ? [full] : []; + }); +} + +interface Rule { + selectors: string[]; + body: string; +} + +/** Every rule with no braces inside it; an @media block's rules come out on their own. */ +function rules(css: string): Rule[] { + const bare = css.replace(/\/\*[\s\S]*?\*\//g, ''); + return [...bare.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({ + selectors: m[1].split(',').map((s) => s.trim()), + body: m[2], + })); +} + +const NO_LINE = /text-decoration(-line)?:\s*none/; + +/** Classes that turn the line off on their own, with no :hover rule saying anything about it. */ +function losing(css: string): string[] { + const all = rules(css); + const off = new Set( + all.flatMap((r) => (NO_LINE.test(r.body) ? r.selectors.filter((s) => /^\.[\w-]+$/.test(s)) : [])), + ); + return [...off].filter((cls) => { + const own = all.filter((r) => r.selectors.includes(cls)); + // A class that takes no pointer is never hovered (update's .goDisabled). + if (own.some((r) => /pointer-events:\s*none/.test(r.body))) return false; + return !all.some((r) => r.selectors.includes(`${cls}:hover`) && /text-decoration/.test(r.body)); + }); +} + +describe('hover underline', () => { + it('is still the global rule the classes have to beat', () => { + const globals = fs.readFileSync(path.join(SRC, 'app/globals.css'), 'utf8'); + expect(globals).toMatch(/\n\s*a:hover \{ text-decoration: underline;/); + }); + + it('stays off on hover for every class that turns it off', () => { + const files = modules(SRC).filter((f) => !LEFT_OUT.some((dir) => f.includes(`${path.sep}${dir}${path.sep}`))); + const found = files.flatMap((f) => + losing(fs.readFileSync(f, 'utf8')).map((cls) => `${path.relative(SRC, f)} ${cls}`), + ); + expect(found).toEqual([]); + }); + + it('finds the class the rule was written for', () => { + expect(losing('.card { text-decoration: none; }\n.card:hover { color: red; }')).toEqual(['.card']); + expect(losing('.card { text-decoration: none; }\n.card:hover { text-decoration: none; }')).toEqual([]); + expect(losing('@media (x) {\n .card { text-decoration: none; }\n}')).toEqual(['.card']); + expect(losing('.row a { text-decoration: none; }')).toEqual([]); + }); +}); diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 3b845cb1f..f29b9dc53 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { Inter, JetBrains_Mono, DM_Sans, Silkscreen, Newsreader } from "next/font/google"; +import { Inter, JetBrains_Mono, Silkscreen, Newsreader } from "next/font/google"; import "./globals.css"; import { PostHogProvider } from "@/providers/PostHogProvider"; import { Analytics } from "@vercel/analytics/next"; @@ -18,15 +18,15 @@ const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], }); -const dmSans = DM_Sans({ - variable: "--font-dm-sans", - subsets: ["latin"], -}); - +// Next puts a preload link for each font loaded here in the head of every +// page. Silkscreen (the guide's kickers) and Newsreader (the journal, /terms, +// the guide's notes) are fetched only by the pages whose text uses them +// (app/fonts.test.ts). const silkscreen = Silkscreen({ weight: ['400', '700'], variable: "--font-silkscreen", subsets: ["latin"], + preload: false, }); const newsreader = Newsreader({ @@ -34,6 +34,7 @@ const newsreader = Newsreader({ style: ["normal", "italic"], variable: "--font-newsreader", subsets: ["latin"], + preload: false, }); export const metadata: Metadata = { @@ -171,7 +172,7 @@ export default function RootLayout({ /> {children} diff --git a/web/src/app/metadata.test.ts b/web/src/app/metadata.test.ts index e35e89b19..6fcba2c2c 100644 --- a/web/src/app/metadata.test.ts +++ b/web/src/app/metadata.test.ts @@ -15,7 +15,7 @@ import type { GuideScreen } from "@/components/guide/store"; vi.mock("@/components/HomeView", () => ({ default: () => null })); vi.mock("next/font/google", () => { const font = () => ({ variable: "", className: "" }); - return { Inter: font, JetBrains_Mono: font, DM_Sans: font, Silkscreen: font, Newsreader: font }; + return { Inter: font, JetBrains_Mono: font, Silkscreen: font, Newsreader: font }; }); type OpenGraph = NonNullable; diff --git a/web/src/app/not-found.tsx b/web/src/app/not-found.tsx index 58245a15d..98b580300 100644 --- a/web/src/app/not-found.tsx +++ b/web/src/app/not-found.tsx @@ -4,7 +4,7 @@ export default function NotFound() { return (

404

- Return to main + Return to main
); } diff --git a/web/src/app/pattern-lab/HardwareModal.test.tsx b/web/src/app/pattern-lab/HardwareModal.test.tsx new file mode 100644 index 000000000..eb4db6989 --- /dev/null +++ b/web/src/app/pattern-lab/HardwareModal.test.tsx @@ -0,0 +1,169 @@ +// The To hardware modal opens Send and Publish inside its own backdrop. A +// press on their backdrop closes them, and before the fix the same click went +// on up to this backdrop and closed To hardware as well. The header the model +// translated lives only in this modal's state, so it went with it. +// +// Esc goes the same way. Send and Publish listen for no keys, so this modal +// hears the Esc meant for them, and it closes the one on top. +// +// The modal is mounted the way the lab mounts it, behind an open flag that +// onClose clears, so a stray close really unmounts it and the pasted header +// is really gone. + +import { useState } from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import HardwareModal from "./HardwareModal"; +import { useLabStore } from "@/lib/lab/store"; + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }) })); +vi.mock("@/lib/community/auth-client", () => ({ + authClient: { + useSession: () => ({ data: { user: { id: "u1", username: "ada" } }, isPending: false }), + }, +})); + +const HEADER = "#pragma once\nnamespace Lab { void draw() {} }\n"; +const CODE = "export function draw(display) { display.setPixel(0, 0, 255, 0, 0); }"; + +function Lab() { + const [open, setOpen] = useState(true); + return open ? ( + setOpen(false)} exportCode={async () => CODE} /> + ) : ( +

closed

+ ); +} + +function hardware() { + return screen.getByRole("dialog", { name: "Hardware" }); +} + +/** Paste a header, press Next, and land on the "On to hardware" stage. */ +function pasteHeaderAndContinue() { + fireEvent.change(screen.getByPlaceholderText(/Paste the \.h the model gives you/), { + target: { value: HEADER }, + }); + fireEvent.click(screen.getByText("Next →")); + expect(screen.getByText("On to hardware")).toBeTruthy(); +} + +/** The header is still in the modal: back on the first stage, in the box. */ +function expectHeaderKept() { + expect(hardware()).toBeTruthy(); + fireEvent.click(screen.getByText("← Back to the header")); + expect(screen.getByPlaceholderText(/Paste the \.h the model gives you/)).toHaveValue(HEADER); +} + +beforeEach(() => { + useLabStore.getState().discardProject(); + vi.stubEnv("NEXT_PUBLIC_BUILD_ENABLED", "1"); + vi.stubEnv("NEXT_PUBLIC_COMMUNITY_URL", "https://patternflow.work"); + // Nothing here should reach the network; a request that does stays unanswered. + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe("HardwareModal", () => { + it("a press on the Send modal's backdrop closes Send and keeps the header", () => { + render(); + pasteHeaderAndContinue(); + fireEvent.click(screen.getByText("↗ Apply to my Patternflow")); + const send = screen.getByText("Send to my Patternflow").closest('[role="dialog"]')!; + + fireEvent.click(send); + + expect(screen.queryByText("Send to my Patternflow")).toBeNull(); + expectHeaderKept(); + }); + + it("a press on the Publish modal's backdrop closes Publish and keeps the header", async () => { + render(); + pasteHeaderAndContinue(); + fireEvent.click(screen.getByText("Upload to the community")); + const title = await screen.findByText("Share to Community"); + const publish = title.closest('[role="dialog"]')!; + + fireEvent.click(publish); + + await waitFor(() => expect(screen.queryByText("Share to Community")).toBeNull()); + expectHeaderKept(); + }); + + it("a press inside the card leaves the modal open", () => { + render(); + fireEvent.click(screen.getByText("Convert to a firmware header")); + expect(hardware()).toBeTruthy(); + }); + + it("a press on its own backdrop still closes it", () => { + render(); + fireEvent.click(hardware()); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.getByText("closed")).toBeTruthy(); + }); + + it("opens with the focus inside it", () => { + render(); + expect(hardware().contains(document.activeElement)).toBe(true); + }); + + it("Esc closes it", () => { + render(); + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.getByText("closed")).toBeTruthy(); + }); + + it("Esc over the Send modal closes Send alone and keeps the header", () => { + render(); + pasteHeaderAndContinue(); + fireEvent.click(screen.getByText("↗ Apply to my Patternflow")); + expect(screen.getByText("Send to my Patternflow")).toBeTruthy(); + + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + + expect(screen.queryByText("Send to my Patternflow")).toBeNull(); + expect(hardware().contains(document.activeElement)).toBe(true); + expectHeaderKept(); + }); + + // The prompt goes to a model by paste. When the browser refuses the write, + // the label used to stay as it was, and the paste carried whatever the + // clipboard held before. + it("says Copy failed on the button whose copy the browser refused", async () => { + const writeText = vi.fn<(text: string) => Promise>(() => + Promise.reject(new DOMException("Write permission denied.", "NotAllowedError")), + ); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + try { + render(); + fireEvent.click(screen.getByText("Copy the conversion prompt")); + expect(await screen.findByText("Copy failed")).toBeTruthy(); + expect(screen.getByText("Copy .h")).toBeTruthy(); + + writeText.mockImplementation(() => Promise.resolve()); + fireEvent.click(screen.getByText("Copy failed")); + expect(await screen.findByText("Copied ✓")).toBeTruthy(); + expect(writeText).toHaveBeenCalledTimes(2); + } finally { + delete (navigator as { clipboard?: unknown }).clipboard; + } + }); + + it("Esc over the Publish modal closes Publish alone and keeps the header", async () => { + render(); + pasteHeaderAndContinue(); + fireEvent.click(screen.getByText("Upload to the community")); + await screen.findByText("Share to Community"); + + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + + await waitFor(() => expect(screen.queryByText("Share to Community")).toBeNull()); + expectHeaderKept(); + }); +}); diff --git a/web/src/app/pattern-lab/HardwareModal.tsx b/web/src/app/pattern-lab/HardwareModal.tsx index a2c35af07..dda5ada6a 100644 --- a/web/src/app/pattern-lab/HardwareModal.tsx +++ b/web/src/app/pattern-lab/HardwareModal.tsx @@ -1,7 +1,9 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type MouseEvent } from "react"; import { PublishModal, SendModuleModal, buildsConfigured, communityConfigured } from "./community"; +import { useDialog } from "@/lib/hooks/useDialog"; +import { useResettingState } from "@/lib/hooks/useResettingState"; import { assembleH, buildHExport, cleanPastedUnit, enforcePatternName } from "@/lib/lab/hExport"; import { buildCppPrompt } from "@/lib/lab/cppPrompt"; import { currentPerformanceJson } from "@/lib/lab/director/publish"; @@ -47,7 +49,7 @@ export default function HardwareModal({ const [name, setName] = useState(() => labPatternName(useLabStore.getState())); const [pastes, setPastes] = useState>({}); const [single, setSingle] = useState(""); - const [copiedKey, setCopiedKey] = useState(null); + const [copied, flashCopied] = useResettingState<{ key: string; ok: boolean } | null>(null, 1200); const [stage, setStage] = useState("convert"); const [sendOpen, setSendOpen] = useState(false); @@ -105,19 +107,20 @@ export default function HardwareModal({ return cleanPastedUnit(pasted, index) !== null; }; - const flashCopied = (key: string) => { - setCopiedKey(key); - window.setTimeout(() => setCopiedKey((current) => (current === key ? null : current)), 1200); - }; const copyText = async (key: string, text: string) => { try { await navigator.clipboard.writeText(text); - flashCopied(key); + flashCopied({ key, ok: true }); } catch { - /* clipboard may be blocked */ + // The browser refused the write. Saying nothing left the label as it + // was, and whatever the clipboard held before went to the model. + flashCopied({ key, ok: false }); } }; + const copyLabel = (key: string, idle: string) => + copied?.key === key ? (copied.ok ? "Copied ✓" : "Copy failed") : idle; + const downloadH = () => { const blob = new Blob([assembled], { type: "text/plain" }); const url = URL.createObjectURL(blob); @@ -132,15 +135,44 @@ export default function HardwareModal({ const label = stacked ? name : codeLayers[0]?.name ?? "Pattern"; + // Only a press on this backdrop itself closes. Send and Publish open inside + // it, so a press on their backdrop bubbles up here too, and the header + // pasted above lives in this component's state and would go with it. + const closeOnBackdrop = (event: MouseEvent) => { + if (event.target === event.currentTarget) onClose(); + }; + + // Esc closes the window on top. Send and Publish open over this one and + // hear no keys of their own, so the first Esc is theirs and the header + // stays. + const cardRef = useDialog(() => { + if (sendOpen) setSendOpen(false); + else if (publishCode !== null) setPublishCode(null); + else onClose(); + }); + + // Their buttons go with them, and the focus would fall to the page behind. + const childOpen = sendOpen || publishCode !== null; + useEffect(() => { + if (!childOpen && !cardRef.current?.contains(document.activeElement)) { + cardRef.current?.focus({ preventScroll: true }); + } + }, [childOpen, cardRef]); + return (
-
event.stopPropagation()}> +
event.stopPropagation()} + >
{stage === "convert" ? "Convert to a firmware header" : "On to hardware"}