diff --git a/README.md b/README.md index 9017752..988a841 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ masters, colours, type, voice, every design token and the UI specs, published fr |---|---| | `brand/` | `space-works-brand-guidelines-v1.pdf` — the source of truth for logo, colour, type, icons and voice | | `logo/` | Ten vector masters exported directly from Figma, with matching 4× PNGs: `symbol`, `symbol-reversed`, `symbol-one-colour-ink`, `lockup-stacked`, `lockup-stacked-reversed`, `lockup-stacked-one-colour-ink`, `lockup-horizontal`, `lockup-horizontal-reversed`, `lockup-horizontal-one-colour-ink`, `app-icon` | -| `mascot/` | **Patch**, the painted wolf mascot, cut from the approved concept sheet in `mascot/concept/`: `patch.png` (hero), `patch-animated.webp` and `patch-animated.svg` (the 7-second loop), the eight frames in `frames/`, and the story and usage rules in `mascot/README.md` | +| `mascot/` | **Patch**, the painted wolf mascot, cut from the approved concept sheets in `mascot/concept/` and traced to vector SVG: `patch.svg` (hero), `patch-animated.webp`/`.svg` (the 7-second sneeze loop) and its eight `frames/`, plus `walk/` (the walking pose, walk cycle and its frames), with high-resolution PNGs. Story and usage rules in `mascot/README.md` | | `icons/` | `favicon.svg`, PNG app icons (16, 32, 180, 192, 512) and the 1200×630 social card | | `fonts/` | Gilroy Regular, Medium, Semibold and Bold (WOFF), plus Unbounded (WOFF2). Legacy faces retained for existing consumers. See `fonts/README.md`. | | `tokens/` | `tokens.css` (`@font-face` + light/dark colour variables as RGB triplets) and `tokens.json` (the same colours as hex, plus the seven brand colours) | diff --git a/brand/README.md b/brand/README.md index 6e52d2b..ce5fc00 100644 --- a/brand/README.md +++ b/brand/README.md @@ -8,7 +8,7 @@ Exported from the connected Figma file on 15 October 2026 using Figma MCP Go+. - `figma-styles.json`: source node IDs, component bounds, seven paint styles and ten text styles. - `../logo/`: ten SVG masters and matching 4× PNGs, including primary, horizontal, symbol and app-tile variants. - `../icons/`: favicon, app icons and social preview. -- `../mascot/`: Patch, the painted wolf mascot, cut from its concept sheet (hero PNG, animated WebP and SVG, eight frames). Added after the Figma export; see `../mascot/README.md`. +- `../mascot/`: Patch, the painted wolf mascot, cut from its two concept sheets and traced to vector SVG (hero, sneeze loop, walk cycle and every frame, with PNGs and animated WebPs). Added after the Figma export; see `../mascot/README.md`. - `../tokens/`: reusable CSS and JSON tokens. `../fonts/`: web fonts and provenance. Use full color on light surfaces, reversed cream on dark surfaces, and the dedicated ink mark for single-color use. Monochrome versions preserve a hollow opening and transparent folds; do not create them by recoloring the full-color logo. Keep a quarter of the symbol height clear on every side. diff --git a/mascot/README.md b/mascot/README.md index 1169665..1287daa 100644 --- a/mascot/README.md +++ b/mascot/README.md @@ -36,34 +36,58 @@ Short, friendly and easy to say in any language. ## The files -Patch is the artwork in the approved concept sheet, cut out as it is. Nothing was redrawn. +Patch is the artwork in the two approved concept sheets in [`concept/`](concept/). Each piece is cut out +and **traced to a colour vector SVG**, so it stays sharp at any size. Nothing was redrawn by hand. Every +PNG and WebP is rendered from those vectors. + +### Pose and sneeze loop (from `concept/patch-concept-sheet.webp`) + +| File | Use it for | Size | +|---|---|---| +| [`patch.svg`](patch.svg) / [`patch.png`](patch.png) | The hero pose: about pages, the website, docs, social posts, stickers, print | SVG any size; PNG 2560 × 2560 | +| [`patch-animated.webp`](patch-animated.webp) | The 7-second sneeze loop in apps, the web and chat | 544 × 544 | +| [`patch-animated.svg`](patch-animated.svg) | The same loop as a vector, sharp at any size. Heavy (about 10 MB), so use it for big hero moments, not lists. | any size | +| [`frames/`](frames/) | The eight frames as SVG and PNG, for app animation code or a Lottie | SVG any size; PNG 1088 × 1088 | + +### Walk cycle (from `concept/patch-walk-sheet.webp`) | File | Use it for | Size | |---|---|---| -| [`patch.png`](patch.png) | The hero pose: about pages, the website, docs, social posts, stickers | 640 × 640, transparent | -| [`patch-animated.webp`](patch-animated.webp) | The 7-second loop, anywhere an image goes: apps, the web, chat | 272 × 272, transparent | -| [`patch-animated.svg`](patch-animated.svg) | The same loop as an SVG: the eight frames embedded and timed with CSS. Respects reduced motion. | 272 × 272, transparent | -| [`frames/`](frames/) | The eight animation frames as separate PNGs, for app animation code or building a Lottie | 272 × 272 each | -| [`concept/patch-concept-sheet.webp`](concept/patch-concept-sheet.webp) | The approved concept sheet: pose, every frame, palette, sizes and backgrounds. **The source of truth.** | 1774 × 887 | +| [`walk/patch-walk.svg`](walk/patch-walk.svg) / [`.png`](walk/patch-walk.png) | Patch trotting with the wrench: loading, "on its way", returns and deliveries | SVG any size; PNG 1920 × 1440 | +| [`walk/patch-walk-animated.webp`](walk/patch-walk-animated.webp) | The walk cycle, looping (8 frames, 0.96 s) | 512 × 352 | +| [`walk/patch-walk-animated.svg`](walk/patch-walk-animated.svg) | The walk cycle as a vector (about 4.5 MB) | any size | +| [`walk/frames/`](walk/frames/) | The eight walk frames as SVG and PNG | SVG any size; PNG 1024 × 704 | -Every frame sits on the same canvas with the badge in the same place, so the frames can be swapped -without Patch jumping. +Frames on the same canvas line up exactly: the sneeze frames on the teal badge, the walk frames on the +ground shadow. Swap them and Patch doesn't jump. The walk's ground shadow is a vector capsule tinted to +match the sheet on Warm Cream; on other surfaces it darkens them gently. ```html Patch, the Space Works mascot +Patch walking ``` -The files are raster, cut from the sheet at its own resolution. Use the hero up to about 320 px wide and -the animation up to about 136 px (half size, so it stays sharp on high-density screens). For larger or -print use, export the original artwork at a higher resolution, replace -`concept/patch-concept-sheet.webp` and re-run the cutter (from `site/`): +The animated SVGs use CSS only, so they play in a plain `` and show one still frame when reduced +motion is on. The WebPs are much lighter; prefer them in product UI. + +### How the files are made + +`site/scripts/cut-mascot.mjs` builds all of it from the sheets: it cuts each piece out (flood-filling the +sheet's cream away from the edges, so cream fur inside Patch stays), upscales it 4×, traces it with +[vtracer](https://github.com/visioncortex/vtracer) at its finest colour settings, and renders the PNGs +and WebPs from the vectors. To rebuild after replacing a sheet (from `site/`; the concept sheet takes a +few minutes): ```bash node scripts/cut-mascot.mjs ``` -If the new sheet has a different size or layout, update the boxes at the top of -`site/scripts/cut-mascot.mjs` first. +Pass `concept` or `walk` to rebuild just one sheet. If a new sheet has a different layout, update its +boxes in `SHEETS` at the top of the script first. A higher-resolution sheet traces into even finer vectors. + +**Known trade-off:** tracing turns soft shading into flat colour layers. Up close, the subtle brown iris +inside each eye merges into the pupil, and the tiny walk frames show slightly stepped outlines at extreme +zoom. At normal sizes Patch looks like the sheet. ## Anatomy @@ -75,6 +99,7 @@ If the new sheet has a different size or layout, update the boxes at the top of | **Eyes** | Big, dark and kind, with bright highlights. Patch is never fierce. | | **Wrench** | Silver, held sideways across the mouth. | | **Badge** | Maker Teal, a Warm Yellow dotted orbit, Warm Yellow ringed planets, Citron, Workshop Orange and Sky Blue sparkles. The ears break out of the top of the badge. | +| **Walking Patch** | Side view, no badge: a thick dark outline, a bushy cream-tipped tail up, the wrench held forward, a soft ground shadow. | The badge and accents use the brand colours. The coat browns and tans and the silver wrench belong to the artwork itself; don't reuse them as UI colours. @@ -85,19 +110,23 @@ artwork itself; don't reuse them as UI colours. | Time | Frame | File | |---|---|---| -| 0.0 s | Idle (breathing) | `frames/patch-01-idle.png` | -| 2.0 s | Blink | `frames/patch-02-blink.png` | -| 3.5 s | Left ear twitch | `frames/patch-03-left-ear.png` | -| 4.0 s | Right ear twitch | `frames/patch-04-right-ear.png` | -| 5.3 s | Sneeze (prep) | `frames/patch-05-sneeze-prep.png` | -| 5.6 s | **Sneeze!** The sneeze vote | `frames/patch-06-sneeze.png` | -| 6.0 s | Recover | `frames/patch-07-recover.png` | -| 6.6–7.0 s | Back to idle | `frames/patch-08-back-to-idle.png` | +| 0.0 s | Idle (breathing) | `frames/patch-01-idle` | +| 2.0 s | Blink | `frames/patch-02-blink` | +| 3.5 s | Left ear twitch | `frames/patch-03-left-ear` | +| 4.0 s | Right ear twitch | `frames/patch-04-right-ear` | +| 5.3 s | Sneeze (prep) | `frames/patch-05-sneeze-prep` | +| 5.6 s | **Sneeze!** The sneeze vote | `frames/patch-06-sneeze` | +| 6.0 s | Recover | `frames/patch-07-recover` | +| 6.6–7.0 s | Back to idle | `frames/patch-08-back-to-idle` | Between those moments Patch holds the idle frame: the blink lasts 0.2 s and each ear twitch 0.3 s, then -the sneeze runs straight through from 5.3 s to 7.0 s. The exact timing lives in `TIMELINE` in +the sneeze runs straight through from 5.3 s to 7.0 s. The exact timing lives in `timeline` in `site/scripts/cut-mascot.mjs`. With reduced motion on, the SVG shows the idle frame only. +The **walk cycle** plays its eight frames at 120 ms each (0.96 s per loop). Frames 6 and 7 have Patch's +eyes closed happily mid-stride, exactly as in the walk sheet. With reduced motion on, the SVG shows the +first frame. + The sneeze is the signature moment. In product, save it for moments when the community acts together: a vote passing, a project filling up, a tool coming back to the shelf. Use `frames/` to play just the sneeze (frames 5 to 7) at those moments. @@ -107,7 +136,8 @@ sneeze (frames 5 to 7) at those moments. Patch is the friendly face of the product. **The box symbol is still the logo.** - **Use Patch for:** empty states, onboarding, loading, success moments, 404 and error pages, the - website hero, docs, social posts, stickers, merch and event signage. + website hero, docs, social posts, stickers, merch and event signage. The walk suits loading and + anything in motion: a loan on its way, a tool being returned. - **Use the logo for:** the app header, the favicon, the app icon, legal pages and anywhere the brand must be identified. Don't put Patch inside or next to the box symbol as a combined mark. - **One bold moment per layout.** Patch counts as that moment. Don't add an Unbounded phrase, an orange @@ -115,11 +145,11 @@ Patch is the friendly face of the product. **The box symbol is still the logo.** ## Keep Patch intact -1. **Don't** recolour, filter or redraw Patch. Use the cut-outs as they are. +1. **Don't** recolour, filter or redraw Patch. Use these files as they are. 2. **Don't** stretch, skew or mirror Patch. The coat is asymmetric on purpose. 3. **Don't** add shadows, glows, gradients or outlines. 4. **Don't** make Patch fierce: no bared teeth, angry brows or hunting poses. -5. **Don't** enlarge the files past the sizes above. Re-export from the original instead. +5. **Don't** scale up the PNGs or WebPs past their size. Use the SVGs when you need bigger. 6. **Do** keep clear space of a quarter of the badge's height around Patch. 7. **Do** check contrast on dark surfaces: Patch's dark ear rims sit close to Deep Ink. Warm Cream, white, Citron, Sky Blue and Workshop Orange surfaces all work. diff --git a/mascot/concept/patch-walk-sheet.webp b/mascot/concept/patch-walk-sheet.webp new file mode 100644 index 0000000..e198222 Binary files /dev/null and b/mascot/concept/patch-walk-sheet.webp differ diff --git a/mascot/frames/patch-01-idle.png b/mascot/frames/patch-01-idle.png index ff341e0..ac0ef90 100644 Binary files a/mascot/frames/patch-01-idle.png and b/mascot/frames/patch-01-idle.png differ diff --git a/mascot/frames/patch-01-idle.svg b/mascot/frames/patch-01-idle.svg new file mode 100644 index 0000000..264b811 --- /dev/null +++ b/mascot/frames/patch-01-idle.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/frames/patch-02-blink.png b/mascot/frames/patch-02-blink.png index 3accef7..3085316 100644 Binary files a/mascot/frames/patch-02-blink.png and b/mascot/frames/patch-02-blink.png differ diff --git a/mascot/frames/patch-02-blink.svg b/mascot/frames/patch-02-blink.svg new file mode 100644 index 0000000..1afc474 --- /dev/null +++ b/mascot/frames/patch-02-blink.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/frames/patch-03-left-ear.png b/mascot/frames/patch-03-left-ear.png index c5003a0..5f7d1cb 100644 Binary files a/mascot/frames/patch-03-left-ear.png and b/mascot/frames/patch-03-left-ear.png differ diff --git a/mascot/frames/patch-03-left-ear.svg b/mascot/frames/patch-03-left-ear.svg new file mode 100644 index 0000000..638095f --- /dev/null +++ b/mascot/frames/patch-03-left-ear.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/frames/patch-04-right-ear.png b/mascot/frames/patch-04-right-ear.png index 2f239c5..e91a660 100644 Binary files a/mascot/frames/patch-04-right-ear.png and b/mascot/frames/patch-04-right-ear.png differ diff --git a/mascot/frames/patch-04-right-ear.svg b/mascot/frames/patch-04-right-ear.svg new file mode 100644 index 0000000..930f0c5 --- /dev/null +++ b/mascot/frames/patch-04-right-ear.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/frames/patch-05-sneeze-prep.png b/mascot/frames/patch-05-sneeze-prep.png index b075d3a..d6efae4 100644 Binary files a/mascot/frames/patch-05-sneeze-prep.png and b/mascot/frames/patch-05-sneeze-prep.png differ diff --git a/mascot/frames/patch-05-sneeze-prep.svg b/mascot/frames/patch-05-sneeze-prep.svg new file mode 100644 index 0000000..fc80c52 --- /dev/null +++ b/mascot/frames/patch-05-sneeze-prep.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/frames/patch-06-sneeze.png b/mascot/frames/patch-06-sneeze.png index 74548c0..2a4a096 100644 Binary files a/mascot/frames/patch-06-sneeze.png and b/mascot/frames/patch-06-sneeze.png differ diff --git a/mascot/frames/patch-06-sneeze.svg b/mascot/frames/patch-06-sneeze.svg new file mode 100644 index 0000000..2d8bd80 --- /dev/null +++ b/mascot/frames/patch-06-sneeze.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/frames/patch-07-recover.png b/mascot/frames/patch-07-recover.png index 1c4c531..1fed081 100644 Binary files a/mascot/frames/patch-07-recover.png and b/mascot/frames/patch-07-recover.png differ diff --git a/mascot/frames/patch-07-recover.svg b/mascot/frames/patch-07-recover.svg new file mode 100644 index 0000000..0511598 --- /dev/null +++ b/mascot/frames/patch-07-recover.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/frames/patch-08-back-to-idle.png b/mascot/frames/patch-08-back-to-idle.png index 8a032b3..47d10c1 100644 Binary files a/mascot/frames/patch-08-back-to-idle.png and b/mascot/frames/patch-08-back-to-idle.png differ diff --git a/mascot/frames/patch-08-back-to-idle.svg b/mascot/frames/patch-08-back-to-idle.svg new file mode 100644 index 0000000..3beb67d --- /dev/null +++ b/mascot/frames/patch-08-back-to-idle.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/patch-animated.svg b/mascot/patch-animated.svg index ba1b8bd..06210d2 100644 --- a/mascot/patch-animated.svg +++ b/mascot/patch-animated.svg @@ -1,31 +1 @@ - - Patch, the Space Works mascot, blinking, twitching its ears and doing a sneeze vote - - - - - - - - - - +Patch, the Space Works mascot, blinking, twitching its ears and doing a sneeze vote diff --git a/mascot/patch-animated.webp b/mascot/patch-animated.webp index 9d9e3a6..43648dc 100644 Binary files a/mascot/patch-animated.webp and b/mascot/patch-animated.webp differ diff --git a/mascot/patch.png b/mascot/patch.png index 36e3316..cf83ec5 100644 Binary files a/mascot/patch.png and b/mascot/patch.png differ diff --git a/mascot/patch.svg b/mascot/patch.svg new file mode 100644 index 0000000..ed17205 --- /dev/null +++ b/mascot/patch.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/walk/frames/patch-walk-01.png b/mascot/walk/frames/patch-walk-01.png new file mode 100644 index 0000000..956ef06 Binary files /dev/null and b/mascot/walk/frames/patch-walk-01.png differ diff --git a/mascot/walk/frames/patch-walk-01.svg b/mascot/walk/frames/patch-walk-01.svg new file mode 100644 index 0000000..2dc0ed0 --- /dev/null +++ b/mascot/walk/frames/patch-walk-01.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/walk/frames/patch-walk-02.png b/mascot/walk/frames/patch-walk-02.png new file mode 100644 index 0000000..15155ce Binary files /dev/null and b/mascot/walk/frames/patch-walk-02.png differ diff --git a/mascot/walk/frames/patch-walk-02.svg b/mascot/walk/frames/patch-walk-02.svg new file mode 100644 index 0000000..fb17438 --- /dev/null +++ b/mascot/walk/frames/patch-walk-02.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/walk/frames/patch-walk-03.png b/mascot/walk/frames/patch-walk-03.png new file mode 100644 index 0000000..2191907 Binary files /dev/null and b/mascot/walk/frames/patch-walk-03.png differ diff --git a/mascot/walk/frames/patch-walk-03.svg b/mascot/walk/frames/patch-walk-03.svg new file mode 100644 index 0000000..1497599 --- /dev/null +++ b/mascot/walk/frames/patch-walk-03.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/walk/frames/patch-walk-04.png b/mascot/walk/frames/patch-walk-04.png new file mode 100644 index 0000000..ad93b38 Binary files /dev/null and b/mascot/walk/frames/patch-walk-04.png differ diff --git a/mascot/walk/frames/patch-walk-04.svg b/mascot/walk/frames/patch-walk-04.svg new file mode 100644 index 0000000..6924e77 --- /dev/null +++ b/mascot/walk/frames/patch-walk-04.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/walk/frames/patch-walk-05.png b/mascot/walk/frames/patch-walk-05.png new file mode 100644 index 0000000..834da85 Binary files /dev/null and b/mascot/walk/frames/patch-walk-05.png differ diff --git a/mascot/walk/frames/patch-walk-05.svg b/mascot/walk/frames/patch-walk-05.svg new file mode 100644 index 0000000..c897d81 --- /dev/null +++ b/mascot/walk/frames/patch-walk-05.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/walk/frames/patch-walk-06.png b/mascot/walk/frames/patch-walk-06.png new file mode 100644 index 0000000..068cbc0 Binary files /dev/null and b/mascot/walk/frames/patch-walk-06.png differ diff --git a/mascot/walk/frames/patch-walk-06.svg b/mascot/walk/frames/patch-walk-06.svg new file mode 100644 index 0000000..627c683 --- /dev/null +++ b/mascot/walk/frames/patch-walk-06.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/walk/frames/patch-walk-07.png b/mascot/walk/frames/patch-walk-07.png new file mode 100644 index 0000000..e65533b Binary files /dev/null and b/mascot/walk/frames/patch-walk-07.png differ diff --git a/mascot/walk/frames/patch-walk-07.svg b/mascot/walk/frames/patch-walk-07.svg new file mode 100644 index 0000000..cfb1ead --- /dev/null +++ b/mascot/walk/frames/patch-walk-07.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/walk/frames/patch-walk-08.png b/mascot/walk/frames/patch-walk-08.png new file mode 100644 index 0000000..7220560 Binary files /dev/null and b/mascot/walk/frames/patch-walk-08.png differ diff --git a/mascot/walk/frames/patch-walk-08.svg b/mascot/walk/frames/patch-walk-08.svg new file mode 100644 index 0000000..2bdb465 --- /dev/null +++ b/mascot/walk/frames/patch-walk-08.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/mascot/walk/patch-walk-animated.svg b/mascot/walk/patch-walk-animated.svg new file mode 100644 index 0000000..cf28dcb --- /dev/null +++ b/mascot/walk/patch-walk-animated.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot, walking with a wrench diff --git a/mascot/walk/patch-walk-animated.webp b/mascot/walk/patch-walk-animated.webp new file mode 100644 index 0000000..493f27f Binary files /dev/null and b/mascot/walk/patch-walk-animated.webp differ diff --git a/mascot/walk/patch-walk.png b/mascot/walk/patch-walk.png new file mode 100644 index 0000000..d19ecab Binary files /dev/null and b/mascot/walk/patch-walk.png differ diff --git a/mascot/walk/patch-walk.svg b/mascot/walk/patch-walk.svg new file mode 100644 index 0000000..8ce609f --- /dev/null +++ b/mascot/walk/patch-walk.svg @@ -0,0 +1 @@ +Patch, the Space Works mascot diff --git a/site/package-lock.json b/site/package-lock.json index c2e9005..2c389d0 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -14,6 +14,9 @@ "astro": "7.3.5", "sharp": "0.34.4", "starlight-links-validator": "0.26.0" + }, + "devDependencies": { + "@neplex/vectorizer": "^0.1.0" } }, "node_modules/@astrojs/compiler-binding": { @@ -1719,6 +1722,299 @@ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, + "node_modules/@neplex/vectorizer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@neplex/vectorizer/-/vectorizer-0.1.0.tgz", + "integrity": "sha512-J2cfkJG0hmOsC+QiPfkNSe8U68vW5SdaM8PjLMVgacmKfOtZKbU5syz3BqlfYoMlPjJlfzYdxwfH4GiAz5vLUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "picocolors": "^1.1.1" + }, + "bin": { + "vectorizer": "cli/index.mjs" + }, + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + }, + "optionalDependencies": { + "@neplex/vectorizer-android-arm-eabi": "0.1.0", + "@neplex/vectorizer-android-arm64": "0.1.0", + "@neplex/vectorizer-darwin-arm64": "0.1.0", + "@neplex/vectorizer-darwin-x64": "0.1.0", + "@neplex/vectorizer-freebsd-x64": "0.1.0", + "@neplex/vectorizer-linux-arm-gnueabihf": "0.1.0", + "@neplex/vectorizer-linux-arm64-gnu": "0.1.0", + "@neplex/vectorizer-linux-arm64-musl": "0.1.0", + "@neplex/vectorizer-linux-x64-gnu": "0.1.0", + "@neplex/vectorizer-linux-x64-musl": "0.1.0", + "@neplex/vectorizer-wasm32-wasi": "0.1.0", + "@neplex/vectorizer-win32-arm64-msvc": "0.1.0", + "@neplex/vectorizer-win32-ia32-msvc": "0.1.0", + "@neplex/vectorizer-win32-x64-msvc": "0.1.0" + } + }, + "node_modules/@neplex/vectorizer-android-arm-eabi": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@neplex/vectorizer-android-arm-eabi/-/vectorizer-android-arm-eabi-0.1.0.tgz", + "integrity": "sha512-QFCkiMEahk0zatLuEtUaTCLPfF6YfoPVSUPjxgkuNQJUc9By4s00Bk9LZK1DcM4O404MQauGxQPZF8r8U7NwWg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@neplex/vectorizer-android-arm64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@neplex/vectorizer-android-arm64/-/vectorizer-android-arm64-0.1.0.tgz", + "integrity": "sha512-+o3+9ahO14z+8lLQ/wZ9bY2DKZsyLtwqabaglfYXjXb8gV+cwNV0UBCEDvd2k21WXb/aYrar55Ayf8cNa+79Sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@neplex/vectorizer-darwin-arm64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@neplex/vectorizer-darwin-arm64/-/vectorizer-darwin-arm64-0.1.0.tgz", + "integrity": "sha512-6nPJgRwm+tZj4b4X8GEcKJUzPgrZaixYo5MLZ1/H40Vp5+UI4Ma9qx0ZsIyh/zhENddYPb7me7EjH0U3O9+JCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@neplex/vectorizer-darwin-x64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@neplex/vectorizer-darwin-x64/-/vectorizer-darwin-x64-0.1.0.tgz", + "integrity": "sha512-WnEtZLR1WG9satz3ZJ791nu7SCCDU7p00egqqGZFhQAmNRM3D0dC4bSfeJmLPvGifvCY9dqQo1XNswgpb0rPzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@neplex/vectorizer-freebsd-x64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@neplex/vectorizer-freebsd-x64/-/vectorizer-freebsd-x64-0.1.0.tgz", + "integrity": "sha512-OSANfJirQvS9iyHbZFMoa8gT9Ife1YIhyWeAvLUqniUmQ1q3lAcONKPP14xClhznDI2a0m51H6NvCjvXMgBDrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@neplex/vectorizer-linux-arm-gnueabihf": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@neplex/vectorizer-linux-arm-gnueabihf/-/vectorizer-linux-arm-gnueabihf-0.1.0.tgz", + "integrity": "sha512-5Wm7rkYY6G6oSdb6Ee3LKtUkzYjtUvIUiIR80FtVNW6S3/d0HHzePKXljtric24fJsKPb/aWJzHZDqWy1T+bVQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@neplex/vectorizer-linux-arm64-gnu": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@neplex/vectorizer-linux-arm64-gnu/-/vectorizer-linux-arm64-gnu-0.1.0.tgz", + "integrity": "sha512-7mvnTzzUW02tFx/Qun7FpO8d81g5xj05a+sd88s38VngE10euzQCiDgGRApxfHSBuiZ2pY7BDmEiZP5FKN8dvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@neplex/vectorizer-linux-arm64-musl": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@neplex/vectorizer-linux-arm64-musl/-/vectorizer-linux-arm64-musl-0.1.0.tgz", + "integrity": "sha512-+WecJ4zyeVQgT1ZIJNH5z6IomkKeTQKWlq8W2eYLfKNKv/UZ0Wj09ANnkFPoZeagk6jH4WdPs/L/Ot6MwO44Dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@neplex/vectorizer-linux-x64-gnu": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@neplex/vectorizer-linux-x64-gnu/-/vectorizer-linux-x64-gnu-0.1.0.tgz", + "integrity": "sha512-ZZOdYvIdygg61c1eSXdBDxPKLrVDAYLaPX7mJtgAM+3C3kSuiDKQHWUGqhcFTlo2oDSWA93Bc0pqaVJBdGW3tA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@neplex/vectorizer-linux-x64-musl": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@neplex/vectorizer-linux-x64-musl/-/vectorizer-linux-x64-musl-0.1.0.tgz", + "integrity": "sha512-T9QgcxddN7mF/8JrA1FNr7F3EwKKwWFFl2v8tndRtDU1N/2wLj+1drSzgJZjxcTjlMG3yOcLhDqFXq7Nsxwwkg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@neplex/vectorizer-wasm32-wasi": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@neplex/vectorizer-wasm32-wasi/-/vectorizer-wasm32-wasi-0.1.0.tgz", + "integrity": "sha512-rEQs14UTfQqh69uxwRo2aJiDzymdOx+j/5pl7sDuEITsx/6nb60dsZkd8gkyuNzBeYSTt8lSSL3sxEmHXQauPw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@neplex/vectorizer-win32-arm64-msvc": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@neplex/vectorizer-win32-arm64-msvc/-/vectorizer-win32-arm64-msvc-0.1.0.tgz", + "integrity": "sha512-QdzriEzZHlzaCFZKw4GgMZ2cUomD+fAgOFs4qYVFQ9K2weyJzTNsCam3gb6xZFhFJ/REPVbNdAB46W6RyAjLBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@neplex/vectorizer-win32-ia32-msvc": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@neplex/vectorizer-win32-ia32-msvc/-/vectorizer-win32-ia32-msvc-0.1.0.tgz", + "integrity": "sha512-z6E5GQcFCQMdJ5HeoHg5KpJJSuq0MPxW2HhPgOjWYnykF4aIwtLAqQdnNTQtKpuunsjoLtK9iqx82+BYX/f64w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@neplex/vectorizer-win32-x64-msvc": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@neplex/vectorizer-win32-x64-msvc/-/vectorizer-win32-x64-msvc-0.1.0.tgz", + "integrity": "sha512-OuFcznF1+CIs09X94PsfiyxwgkPpH8ZQQ81aPRjexFdLLLt5UpIOlsbUK2Ypb5u/bC/lCOpuUfP/bVP205YinQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@neplex/vectorizer/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@oslojs/encoding": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", diff --git a/site/package.json b/site/package.json index 33f754f..192b221 100644 --- a/site/package.json +++ b/site/package.json @@ -18,5 +18,8 @@ "astro": "7.3.5", "sharp": "0.34.4", "starlight-links-validator": "0.26.0" + }, + "devDependencies": { + "@neplex/vectorizer": "^0.1.0" } } diff --git a/site/scripts/cut-mascot.mjs b/site/scripts/cut-mascot.mjs index fd561f3..2fa7cc5 100644 --- a/site/scripts/cut-mascot.mjs +++ b/site/scripts/cut-mascot.mjs @@ -1,57 +1,138 @@ -// Cuts Patch out of the approved concept sheet (mascot/concept/patch-concept-sheet.webp): the hero pose -// and the eight animation frames, on transparent backgrounds, plus the animated WebP and SVG. -// Run from site/ after replacing the sheet: node scripts/cut-mascot.mjs +// Builds every Patch file from the two approved concept sheets in mascot/concept/: // -// The sheet background is removed by flood-filling cream from the edges of each crop, so cream fur -// inside the artwork is kept. Edge pixels get partial alpha and are un-mixed from the cream. +// patch-concept-sheet.webp -> the hero pose and the eight-frame sneeze loop +// patch-walk-sheet.webp -> the walking pose and the eight-frame walk cycle +// +// Each piece is cut out (the sheet's cream is flood-filled away from the edges, so cream fur inside +// the artwork stays), traced to a colour vector SVG at the highest settings, and rendered back to a +// high-resolution PNG from that vector. The loops are built from the traced frames as an animated SVG +// (CSS only, plays in ) and an animated WebP. +// +// Run from site/ after replacing a sheet: node scripts/cut-mascot.mjs [concept|walk] +// (no argument rebuilds both sheets; tracing the concept sheet takes a few minutes) import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; +import { ColorMode, Hierarchical, PathSimplifyMode, optimizeSync, vectorize } from '@neplex/vectorizer'; const mascot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'mascot'); -const sheet = join(mascot, 'concept', 'patch-concept-sheet.webp'); -const frames = join(mascot, 'frames'); - -// Artwork boxes on the 1774 × 887 sheet (x, y, width, height), found by measuring each blob. -const HERO = [31, 31, 619, 615]; -const FRAMES = [ - { name: '01-idle', box: [717, 75, 240, 234] }, - { name: '02-blink', box: [978, 75, 241, 234] }, - { name: '03-left-ear', box: [1240, 74, 241, 235] }, - { name: '04-right-ear', box: [1503, 75, 240, 234] }, - { name: '05-sneeze-prep', box: [716, 387, 242, 233] }, - { name: '06-sneeze', box: [973, 387, 252, 233] }, - { name: '07-recover', box: [1241, 387, 240, 233] }, - { name: '08-back-to-idle', box: [1503, 387, 240, 233] }, -]; -// The loop as frames: [frame index, milliseconds on screen]. Adds up to 7000. -const TIMELINE = [ - [0, 2000], [1, 200], [0, 1300], [2, 300], [0, 200], [3, 300], [0, 1000], - [4, 300], [5, 400], [6, 600], [7, 400], + +// Tracing: upscale 4x, smooth JPEG-style noise, then trace with fine colour layers. +const UPSCALE = 4; +const TRACE = { + colorMode: ColorMode.Color, + hierarchical: Hierarchical.Stacked, + filterSpeckle: 6, + colorPrecision: 8, + layerDifference: 10, + mode: PathSimplifyMode.Spline, + cornerThreshold: 60, + lengthThreshold: 4, + maxIterations: 10, + spliceThreshold: 45, + pathPrecision: 1, +}; +const PAD = 6; // keeps twitch marks and puffs; label text on the sheet starts further away + +const SHEETS = [ + { + sheet: 'patch-concept-sheet.webp', + // Background: cream within NEAR is removed; edge pixels up to FAR get partial alpha. + near: 18, + far: 44, + align: 'badge', // frames line up on the Maker Teal badge + hero: { box: [31, 31, 619, 615], size: [640, 640], out: 'patch' }, + frameSize: [272, 272], + frames: [ + ['frames/patch-01-idle', [717, 75, 240, 234]], + ['frames/patch-02-blink', [978, 75, 241, 234]], + ['frames/patch-03-left-ear', [1240, 74, 241, 235]], + ['frames/patch-04-right-ear', [1503, 75, 240, 234]], + ['frames/patch-05-sneeze-prep', [716, 387, 242, 233]], + ['frames/patch-06-sneeze', [973, 387, 252, 233]], + ['frames/patch-07-recover', [1241, 387, 240, 233]], + ['frames/patch-08-back-to-idle', [1503, 387, 240, 233]], + ], + // [frame index, milliseconds on screen]; adds up to 7000. + timeline: [[0, 2000], [1, 200], [0, 1300], [2, 300], [0, 200], [3, 300], [0, 1000], [4, 300], [5, 400], [6, 600], [7, 400]], + animated: 'patch-animated', + title: 'Patch, the Space Works mascot, blinking, twitching its ears and doing a sneeze vote', + }, + { + sheet: 'patch-walk-sheet.webp', + // The ground shadow sits within FAR of the cream, so it is removed with the background and + // redrawn as a vector capsule that matches it on cream and stays soft on other surfaces. + near: 48, + far: 72, + align: 'shadow', // frames line up on the ground shadow + hero: { box: [647, 193, 467, 344], size: [480, 360], out: 'walk/patch-walk' }, + frameSize: [256, 176], + frames: [ + ['walk/frames/patch-walk-01', [52, 617, 203, 152]], + ['walk/frames/patch-walk-02', [272, 617, 192, 152]], + ['walk/frames/patch-walk-03', [474, 617, 201, 152]], + ['walk/frames/patch-walk-04', [688, 617, 199, 152]], + ['walk/frames/patch-walk-05', [893, 617, 206, 152]], + ['walk/frames/patch-walk-06', [1111, 617, 206, 152]], + ['walk/frames/patch-walk-07', [1330, 615, 191, 154]], + ['walk/frames/patch-walk-08', [1532, 614, 202, 155]], + ], + timeline: [[0, 120], [1, 120], [2, 120], [3, 120], [4, 120], [5, 120], [6, 120], [7, 120]], + animated: 'walk/patch-walk-animated', + title: 'Patch, the Space Works mascot, walking with a wrench', + }, ]; -const PAD = 6; // keeps twitch marks and puffs; label text starts further away -const FRAME_SIZE = 272; // every frame on the same square canvas, badge centred -const NEAR = 18; // max channel distance from the sheet's cream that counts as background -const FAR = 44; // beyond this an edge pixel is fully opaque -const { data: src, info } = await sharp(sheet).removeAlpha().raw().toBuffer({ resolveWithObject: true }); +// The ground shadow colour (sampled) expressed as a tint at 20%, so it matches exactly on the sheet's +// cream and darkens any other surface gently. +const SHADOW_OPACITY = 0.2; + +async function load(sheet) { + const { data, info } = await sharp(join(mascot, 'concept', sheet)).removeAlpha().raw().toBuffer({ resolveWithObject: true }); + return { data, width: info.width, height: info.height }; +} -async function cut([x, y, w, h], pad) { - const X = Math.max(0, x - pad), Y = Math.max(0, y - pad); - const W = Math.min(info.width - X, w + pad * 2), H = Math.min(info.height - Y, h + pad * 2); - const bg = [0, 1, 2].map((c) => src[(Y * info.width + X) * 3 + c]); +// Cuts one box out of a sheet. Returns RGBA pixels and, for walk pieces, the measured ground shadow. +function cut(src, [x, y, w, h], near, far, findShadow) { + const X = Math.max(0, x - PAD), Y = Math.max(0, y - PAD); + const W = Math.min(src.width - X, w + PAD * 2), H = Math.min(src.height - Y, h + PAD * 2); + const at = (i, j, c) => src.data[((Y + j) * src.width + X + i) * 3 + c]; + const bg = [0, 1, 2].map((c) => at(0, 0, c)); const out = Buffer.alloc(W * H * 4); const dist = new Uint8Array(W * H); for (let j = 0; j < H; j++) { for (let i = 0; i < W; i++) { - const s = ((Y + j) * info.width + X + i) * 3, p = j * W + i; - dist[p] = Math.max(...[0, 1, 2].map((c) => Math.abs(src[s + c] - bg[c]))); - for (let c = 0; c < 3; c++) out[p * 4 + c] = src[s + c]; + const p = j * W + i; + dist[p] = Math.max(...[0, 1, 2].map((c) => Math.abs(at(i, j, c) - bg[c]))); + for (let c = 0; c < 3; c++) out[p * 4 + c] = at(i, j, c); out[p * 4 + 3] = 255; } } - // Flood-fill the background from every edge pixel. + // Ground shadow: the solid run of wide shadow-toned rows at the bottom of the piece. Scattered + // shadow-toned pixels higher up are cream fur, not shadow. + let shadow = null; + if (findShadow) { + const tone = (i, j) => dist[j * W + i] > 25 && dist[j * W + i] < 60; + const rows = []; + for (let j = 0; j < H; j++) { + let n = 0; + for (let i = 0; i < W; i++) n += tone(i, j); + rows.push(n); + } + const widest = Math.max(...rows); + let y1 = H - 1; + while (rows[y1] < widest * 0.3) y1--; + let y0 = y1; + while (y0 > 0 && rows[y0 - 1] >= widest * 0.3) y0--; + const peak = rows.indexOf(widest); + let x0 = 0, x1 = W - 1; + while (!tone(x0, peak)) x0++; + while (!tone(x1, peak)) x1--; + const sum = [0, 0, 0]; + for (let i = x0; i <= x1; i++) for (let c = 0; c < 3; c++) sum[c] += at(i, peak, c); + shadow = { x0, x1, y0, y1, rgb: sum.map((v) => v / (x1 - x0 + 1)), bg }; + } const seen = new Uint8Array(W * H), stack = []; for (let i = 0; i < W; i++) stack.push(i, (H - 1) * W + i); for (let j = 0; j < H; j++) stack.push(j * W, j * W + W - 1); @@ -59,88 +140,142 @@ async function cut([x, y, w, h], pad) { const p = stack.pop(); if (seen[p]) continue; seen[p] = 1; - if (dist[p] >= FAR) continue; // artwork: stop here - const a = dist[p] <= NEAR ? 0 : (dist[p] - NEAR) / (FAR - NEAR); + if (dist[p] >= far) continue; // artwork: stop here + const a = dist[p] <= near ? 0 : (dist[p] - near) / (far - near); for (let c = 0; c < 3; c++) { const v = a ? (out[p * 4 + c] - (1 - a) * bg[c]) / a : 0; out[p * 4 + c] = Math.max(0, Math.min(255, Math.round(v))); } out[p * 4 + 3] = Math.round(a * 255); - if (dist[p] > NEAR) continue; // anti-aliased edge: don't spread into the artwork + if (dist[p] > near) continue; // anti-aliased edge: don't spread into the artwork const i = p % W; if (i > 0) stack.push(p - 1); if (i < W - 1) stack.push(p + 1); if (p >= W) stack.push(p - W); if (p < W * (H - 1)) stack.push(p + W); } - return sharp(out, { raw: { width: W, height: H, channels: 4 } }).png(); + return { pixels: out, width: W, height: H, shadow }; } -// Places a cut-out on a square canvas with its Maker Teal badge in the centre, so frames line up -// even when twitch marks or sneeze puffs make one crop wider than another. -async function centred(img, size) { - const { data, info: i } = await img.raw().toBuffer({ resolveWithObject: true }); - let x0 = i.width, y0 = i.height, x1 = 0, y1 = 0; - for (let p = 0; p < i.width * i.height; p++) { - const [r, g, b, a] = data.subarray(p * 4, p * 4 + 4); +// The point a piece is aligned on: the teal badge's centre, or the shadow's centre and bottom. +function anchor(piece, align) { + if (align === 'shadow') { + const s = piece.shadow; + return [(s.x0 + s.x1) / 2, s.y1]; + } + let x0 = piece.width, y0 = piece.height, x1 = 0, y1 = 0; + for (let p = 0; p < piece.width * piece.height; p++) { + const [r, g, b, a] = piece.pixels.subarray(p * 4, p * 4 + 4); if (a > 200 && r < 40 && g > 80 && g < 130 && b > 70 && b < 120) { - const x = p % i.width, y = (p / i.width) | 0; + const x = p % piece.width, y = (p / piece.width) | 0; x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); } } - const left = Math.round(size / 2 - (x0 + x1) / 2), top = Math.round(size / 2 - (y0 + y1) / 2); - const png = await sharp(data, { raw: i }).png().toBuffer(); - return sharp({ create: { width: size, height: size, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }) + return [(x0 + x1) / 2, (y0 + y1) / 2]; +} + +// Places a piece on a transparent canvas so its anchor lands on `target`. +async function place(piece, [w, h], [ax, ay], [tx, ty]) { + const left = Math.round(tx - ax), top = Math.round(ty - ay); + if (left < 0 || top < 0 || left + piece.width > w || top + piece.height > h) { + throw new Error(`piece does not fit the ${w}x${h} canvas at ${left},${top}`); + } + const png = await sharp(piece.pixels, { raw: { width: piece.width, height: piece.height, channels: 4 } }).png().toBuffer(); + const canvas = await sharp({ create: { width: w, height: h, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }) .composite([{ input: png, left, top }]) - .png({ compressionLevel: 9 }); + .png() + .toBuffer(); + return { canvas, left, top }; +} + +// Traces a canvas to SVG markup (paths only, in canvas units x UPSCALE). +// The edge is made hard (alpha in or out) after upscaling: soft edge pixels would otherwise be traced +// as a pale ring around the outline. The renderer anti-aliases the vector edge instead. +async function trace(canvas, [w, h]) { + const { data, info } = await sharp(canvas) + .resize(w * UPSCALE, h * UPSCALE, { kernel: 'lanczos3' }) + .median(3) + .raw() + .toBuffer({ resolveWithObject: true }); + for (let p = 3; p < data.length; p += 4) data[p] = data[p] >= 235 ? 255 : 0; + const up = await sharp(data, { raw: info }).png().toBuffer(); + const svg = optimizeSync(await vectorize(up, TRACE)); + return svg.replace(/^[\s\S]*?]*>/, '').replace(/<\/svg>\s*$/, ''); } -rmSync(frames, { recursive: true, force: true }); -mkdirSync(frames, { recursive: true }); +function shadowShape(s, left, top) { + if (!s) return ''; + const tint = s.bg.map((b, c) => Math.max(0, Math.min(255, Math.round(b - (b - s.rgb[c]) / SHADOW_OPACITY)))); + const x = (left + s.x0) * UPSCALE, y = (top + s.y0) * UPSCALE; + const w = (s.x1 - s.x0 + 1) * UPSCALE, h = (s.y1 - s.y0 + 1) * UPSCALE; + return ``; +} -await (await centred(await cut(HERO, PAD), 640)).toFile(join(mascot, 'patch.png')); +const svgDoc = ([w, h], body, title, extra = '') => + `${title}${extra}${body}\n`; -const framePngs = []; -for (const { name, box } of FRAMES) { - const file = join(frames, `patch-${name}.png`); - await (await centred(await cut(box, PAD), FRAME_SIZE)).toFile(file); - framePngs.push(file); +async function writeVector(out, size, body, title) { + const svg = svgDoc(size, body, title); + writeFileSync(join(mascot, `${out}.svg`), svg); + // High-resolution PNG rendered from the vector (4x the nominal size). + await sharp(Buffer.from(svg), { density: 72 * UPSCALE }).png({ compressionLevel: 9 }).toFile(join(mascot, `${out}.png`)); + return svg; } -// Animated WebP: one image per timeline step, with its own delay. -await sharp(TIMELINE.map(([f]) => framePngs[f]), { join: { animated: true } }) - .webp({ loop: 0, delay: TIMELINE.map(([, ms]) => ms), quality: 92, alphaQuality: 100, effort: 6 }) - .toFile(join(mascot, 'patch-animated.webp')); - -// Animated SVG: the eight frames embedded, each shown on the timeline with CSS (works in ). -const base64 = await Promise.all( - framePngs.map(async (f) => (await sharp(f).webp({ quality: 92, alphaQuality: 100 }).toBuffer()).toString('base64')), -); -// With step-end timing each stop holds until the next one: 1 where the frame starts, 0 where it ends. -const keyframes = FRAMES.map((_, f) => { - const stops = new Map([[0, 0]]); - let t = 0; - for (const [frame, ms] of TIMELINE) { - if (frame === f) { - stops.set(t, 1); - if (!stops.has(t + ms)) stops.set(t + ms, 0); - } - t += ms; +const only = process.argv[2]; +for (const s of SHEETS.filter((s) => !only || s.sheet.includes(only))) { + const frameDir = join(mascot, dirname(s.frames[0][0])); + rmSync(frameDir, { recursive: true, force: true }); + mkdirSync(frameDir, { recursive: true }); + const src = await load(s.sheet); + + // Hero pose, centred on its canvas. + const walk = s.align === 'shadow'; + const hero = cut(src, s.hero.box, s.near, s.far, walk); + const [hw, hh] = s.hero.size; + const hp = await place(hero, s.hero.size, [hero.width / 2, hero.height / 2], [hw / 2, hh / 2]); + await writeVector(s.hero.out, s.hero.size, shadowShape(hero.shadow, hp.left, hp.top) + (await trace(hp.canvas, s.hero.size)), 'Patch, the Space Works mascot'); + + // Frames, aligned on the same anchor so they can be swapped without jumping. + const [fw, fh] = s.frameSize; + const target = walk ? [fw / 2, fh - PAD - 4] : [fw / 2, fh / 2]; + const bodies = []; + for (const [out, box] of s.frames) { + const piece = cut(src, box, s.near, s.far, walk); + const placed = await place(piece, s.frameSize, anchor(piece, s.align), target); + const body = shadowShape(piece.shadow, placed.left, placed.top) + (await trace(placed.canvas, s.frameSize)); + await writeVector(out, s.frameSize, body, 'Patch, the Space Works mascot'); + bodies.push({ out, body }); } - const body = [...stops].map(([ms, o]) => `${+(ms / 70).toFixed(3)}% { opacity: ${o}; }`).join(' '); - return `@keyframes patch-f${f} { ${body} }`; -}); -const svg = ` - Patch, the Space Works mascot, blinking, twitching its ears and doing a sneeze vote - - ${base64.map((b, f) => ``).join('\n ')} - -`; -writeFileSync(join(mascot, 'patch-animated.svg'), svg); - -console.log(`cut-mascot: patch.png, ${FRAMES.length} frames, patch-animated.webp and patch-animated.svg`); + + // Animated SVG: every traced frame, shown on the timeline with CSS visibility steps. + const total = s.timeline.reduce((t, [, ms]) => t + ms, 0); + const keyframes = bodies.map((_, f) => { + const stops = new Map([[0, 'hidden']]); + let t = 0; + for (const [frame, ms] of s.timeline) { + if (frame === f) { + stops.set(t, 'visible'); + if (!stops.has(t + ms)) stops.set(t + ms, 'hidden'); + } + t += ms; + } + const body = [...stops].map(([ms, v]) => `${+((ms / total) * 100).toFixed(3)}%{visibility:${v}}`).join(''); + return `@keyframes f${f}{${body}}`; + }); + const style = ``; + const groups = bodies.map(({ body }, f) => `${body}`).join(''); + writeFileSync(join(mascot, `${s.animated}.svg`), svgDoc(s.frameSize, groups, s.title, style)); + + // Animated WebP at 2x, from PNGs rendered off the traced frames. + const pngs = await Promise.all( + s.timeline.map(([f]) => sharp(join(mascot, `${bodies[f].out}.png`)).resize(fw * 2, fh * 2).png().toBuffer()), + ); + await sharp(pngs, { join: { animated: true } }) + .webp({ loop: 0, delay: s.timeline.map(([, ms]) => ms), quality: 92, alphaQuality: 100, effort: 6 }) + .toFile(join(mascot, `${s.animated}.webp`)); + + console.log(`cut-mascot: ${s.sheet} -> ${s.hero.out}, ${s.frames.length} frames, ${s.animated}`); +} diff --git a/site/src/components/MascotGallery.astro b/site/src/components/MascotGallery.astro index 70e1062..5824fd8 100644 --- a/site/src/components/MascotGallery.astro +++ b/site/src/components/MascotGallery.astro @@ -1,23 +1,35 @@ --- -// Patch from mascot/: the hero, the animated loop and the concept sheet, then the eight animation frames. +// Patch from mascot/: the hero, both loops and the concept sheets, then the frames of each loop. +// Cards preview the light PNG/WebP; the vector SVGs are offered as downloads (some are several MB). import { kitUrl } from './kit-url'; const files = [ - { file: 'patch.png', name: 'Patch', use: 'Hero pose, 640 × 640', surface: 'cream' }, - { file: 'patch-animated.webp', name: 'Patch, animated', use: '7-second loop, also as SVG', surface: 'white', also: 'patch-animated.svg' }, - { file: 'patch-animated.webp', name: 'Patch on a light accent', use: 'Sky Blue, Citron or orange', surface: 'sky' }, - { file: 'concept/patch-concept-sheet.webp', name: 'Concept sheet', use: 'The source of truth', surface: 'cream' }, + { show: 'patch.png', name: 'Patch', use: 'Hero pose', surface: 'cream', downloads: ['patch.svg', 'patch.png'] }, + { show: 'patch-animated.webp', name: 'Patch, sneeze loop', use: '7-second loop', surface: 'white', downloads: ['patch-animated.webp', 'patch-animated.svg'] }, + { show: 'walk/patch-walk.png', name: 'Patch, walking', use: 'On its way', surface: 'cream', downloads: ['walk/patch-walk.svg', 'walk/patch-walk.png'] }, + { show: 'walk/patch-walk-animated.webp', name: 'Patch, walk cycle', use: '8 frames, 0.96 s', surface: 'sky', downloads: ['walk/patch-walk-animated.webp', 'walk/patch-walk-animated.svg'] }, + { show: 'concept/patch-concept-sheet.webp', name: 'Concept sheet', use: 'Pose and sneeze loop', surface: 'cream', downloads: ['concept/patch-concept-sheet.webp'] }, + { show: 'concept/patch-walk-sheet.webp', name: 'Walk sheet', use: 'Walk cycle', surface: 'cream', downloads: ['concept/patch-walk-sheet.webp'] }, ]; -const frames = [ - ['01-idle', '0.0 s', 'Idle'], - ['02-blink', '2.0 s', 'Blink'], - ['03-left-ear', '3.5 s', 'Left ear twitch'], - ['04-right-ear', '4.0 s', 'Right ear twitch'], - ['05-sneeze-prep', '5.3 s', 'Sneeze (prep)'], - ['06-sneeze', '5.6 s', 'Sneeze!'], - ['07-recover', '6.0 s', 'Recover'], - ['08-back-to-idle', '6.6–7.0 s', 'Back to idle'], +const loops = [ + { + label: 'Sneeze loop frames', + frames: [ + ['frames/patch-01-idle', '0.0 s', 'Idle'], + ['frames/patch-02-blink', '2.0 s', 'Blink'], + ['frames/patch-03-left-ear', '3.5 s', 'Left ear twitch'], + ['frames/patch-04-right-ear', '4.0 s', 'Right ear twitch'], + ['frames/patch-05-sneeze-prep', '5.3 s', 'Sneeze (prep)'], + ['frames/patch-06-sneeze', '5.6 s', 'Sneeze!'], + ['frames/patch-07-recover', '6.0 s', 'Recover'], + ['frames/patch-08-back-to-idle', '6.6–7.0 s', 'Back to idle'], + ], + }, + { + label: 'Walk cycle frames', + frames: [1, 2, 3, 4, 5, 6, 7, 8].map((n) => [`walk/frames/patch-walk-0${n}`, `${((n - 1) * 0.12).toFixed(2)} s`, `Step ${n}`]), + }, ]; const ext = (f: string) => f.split('.').pop()!.toUpperCase(); --- @@ -27,7 +39,7 @@ const ext = (f: string) => f.split('.').pop()!.toUpperCase(); files.map((f) => ( @@ -44,16 +55,19 @@ const ext = (f: string) => f.split('.').pop()!.toUpperCase(); } -
- { - frames.map(([file, time, label]) => ( -
- {`Patch, -
- {time} - {label} -
-
- )) - } -
+{ + loops.map((loop) => ( +
+ {loop.frames.map(([file, time, label]) => ( +
+ {`Patch, +
+ {time} + {label} + SVG +
+
+ ))} +
+ )) +} diff --git a/site/src/content/docs/brand/mascot.mdx b/site/src/content/docs/brand/mascot.mdx index e48670a..c8049b0 100644 --- a/site/src/content/docs/brand/mascot.mdx +++ b/site/src/content/docs/brand/mascot.mdx @@ -13,20 +13,23 @@ carries a wrench in its mouth, like a dog bringing back a stick: borrow it, use Every file is in [`mascot/`](https://github.com/SpaceWorks-HQ/SpaceWorks-Design/tree/main/mascot), cut -straight out of the approved -[concept sheet](https://github.com/SpaceWorks-HQ/SpaceWorks-Design/blob/main/mascot/concept/patch-concept-sheet.webp) -on transparent backgrounds. Nothing was redrawn. +from the two approved concept sheets +([pose and sneeze](https://github.com/SpaceWorks-HQ/SpaceWorks-Design/blob/main/mascot/concept/patch-concept-sheet.webp), +[walk](https://github.com/SpaceWorks-HQ/SpaceWorks-Design/blob/main/mascot/concept/patch-walk-sheet.webp)) +and traced to colour vector SVGs, so Patch stays sharp at any size. The PNGs and WebPs are rendered from +those vectors. | File | Use it for | Size | |---|---|---| -| `patch.png` | The hero pose | 640 × 640 | -| `patch-animated.webp` | The 7-second loop, anywhere an image goes | 272 × 272 | -| `patch-animated.svg` | The same loop as an SVG, timed with CSS; respects reduced motion | 272 × 272 | -| `frames/` | The eight frames, for app animation code or a Lottie | 272 × 272 each | +| `patch.svg` / `.png` | The hero pose | SVG any size; PNG 2560 px | +| `patch-animated.webp` / `.svg` | The 7-second sneeze loop | WebP 544 px; SVG any size (about 10 MB) | +| `frames/` | The eight sneeze-loop frames, SVG and PNG | PNG 1088 px | +| `walk/patch-walk.svg` / `.png` | Patch trotting with the wrench | PNG 1920 × 1440 | +| `walk/patch-walk-animated.webp` / `.svg` | The walk cycle, 0.96 s per loop | WebP 512 × 352; SVG any size (about 4.5 MB) | +| `walk/frames/` | The eight walk frames, SVG and PNG | PNG 1024 × 704 | -They're raster at the sheet's own resolution: use the hero up to about 320 px wide and the animation up to -about 136 px. For bigger or print use, export the original artwork larger, replace the sheet and run -`node scripts/cut-mascot.mjs` from `site/`. +Prefer the WebPs in product UI; they're far lighter. Use the SVGs for big moments and print. To rebuild +everything after replacing a sheet, run `node scripts/cut-mascot.mjs` from `site/`. ## Why a painted wolf @@ -59,7 +62,8 @@ wrench belong to the artwork; don't reuse them as UI colours. ## Animation -The concept sheet's 7-second loop, frame by frame. Between moments Patch holds the idle frame. +The concept sheet's 7-second loop, frame by frame. Between moments Patch holds the idle frame. The walk +cycle plays its eight frames at 120 ms each. | Time | Frame | |---|---| @@ -91,11 +95,11 @@ symbol as a combined mark. ## Keep Patch intact -1. **Don't recolour, filter or redraw** Patch. Use the cut-outs as they are. +1. **Don't recolour, filter or redraw** Patch. Use these files as they are. 2. **Don't stretch, skew or mirror** Patch. The coat is asymmetric on purpose. 3. **Don't add effects** such as shadows, glows, gradients or outlines. 4. **Don't make Patch fierce.** No bared teeth, angry brows or hunting poses. -5. **Don't enlarge the files** past the sizes above. Re-export from the original instead. +5. **Don't scale up the PNGs or WebPs.** Use the SVGs when you need bigger. 6. **Keep clear space** of a quarter of the badge height on every side. 7. **Check dark surfaces.** Patch's dark ear rims sit close to Deep Ink; light surfaces and Workshop Orange work best. diff --git a/site/src/content/docs/tokens/using-the-kit.mdx b/site/src/content/docs/tokens/using-the-kit.mdx index 6ea254e..55e4e4f 100644 --- a/site/src/content/docs/tokens/using-the-kit.mdx +++ b/site/src/content/docs/tokens/using-the-kit.mdx @@ -74,7 +74,7 @@ and `dark`. Use it for native apps, Figma plugins or anything that can't read CS |---|---| | `brand/` | The brand guidelines PDF, all 17 pages as SVG and PNG, the logo sheet and the Figma style record | | `logo/` | Ten vector masters with matching 4× PNGs | -| `mascot/` | [Patch](/brand/mascot/): the hero PNG, the animated WebP and SVG, eight frames and the concept sheet | +| `mascot/` | [Patch](/brand/mascot/): vector SVGs and PNGs of the hero, the sneeze loop and the walk cycle, every frame, and the concept sheets | | `icons/` | The favicon, PNG app icons (16, 32, 180, 192, 512) and the 1200 × 630 social card | | `fonts/` | Gilroy and Unbounded web fonts, with licences | | `tokens/` | `tokens.css` and `tokens.json` | diff --git a/site/src/styles/kit.css b/site/src/styles/kit.css index cae714e..5cb11b9 100644 --- a/site/src/styles/kit.css +++ b/site/src/styles/kit.css @@ -104,8 +104,20 @@ text-align: center; } .sw-frame img { width: 100%; max-width: 136px; height: auto; } -.sw-frame figcaption { display: grid; font-size: 0.8125rem; } +.sw-frame figcaption { display: grid; justify-items: center; gap: 0.15rem; font-size: 0.8125rem; } .sw-frame strong { font-weight: 600; } +/* Same pill as .sw-logo__links, fixed to ink because the frame card is always cream. */ +.sw-frame a { + margin-top: 0.35rem; + font-size: 0.75rem; + font-weight: 600; + text-decoration: none; + padding: 0.2rem 0.6rem; + border-radius: 999px; + border: 1px solid rgb(20 44 42 / 0.18); + color: var(--sw-ink); +} +.sw-frame a:hover { border-color: var(--sw-teal); color: var(--sw-teal); } .sw-logo figcaption { display: flex; justify-content: space-between;