فهرست منبع

feat(skills): add svg-brand-tint-ops skill (v3.6.0)

Zero-dependency in-browser SVG studio: token-driven tri-tone brand
recolour (feColorMatrix desaturate -> feComponentTransfer grey-ramp
remap -> theme-aware CSS-filter bake) plus a from-scratch raster
vectoriser (PNG -> SVG). The tracer runs a Potrace-paper geometry stage
(tolerance-tube straightness, penalty-DP optimal polygon, sub-pixel
vertex adjustment, alphamax corner analysis - reimplemented from the
freely published Selinger 2003 paper, no GPL code) over soft-field
marching squares with alpha-aware palette handling (matte de-blending,
anti-alias fringe cull, blend-veto) that keeps flat-colour brand logos
crisp. B&W / posterised / colour trace modes, photographic filter stack,
curated Google Fonts on SVG <text>, element inspect, before/after split.

Ships a ~90-line dependency-free static server, a headless trace CLI
sharing one canonical engine with the browser tool (assets/trace-core.mjs,
no drift), a colour-math + trace + theme reference, and a 22-assertion
offline suite. Registered in README/AGENTS/PLAN (99 skills) + CHANGELOG.

Squash-landed from claude/hungry-khorana-de5b6e (branch + iterate/best
tag retain the granular trace-engine iteration history; acceptance 20/24
on the 24-logo benchmark at land time).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 2 هفته پیش
والد
کامیت
726aa143a7

+ 1 - 1
AGENTS.md

@@ -5,7 +5,7 @@
 This is **claude-mods** - a collection of custom extensions for Claude Code:
 - **3 expert agents** for pure context-isolation/worker roles (git-agent, firecrawl-expert, project-organizer) - every domain-knowledge agent became an `-ops` skill (v3.0, skills-first)
 - **2 commands** for session management (/sync, /save)
-- **98 skills** for CLI tools, patterns, workflows, and development tasks (incl. `r-ops` for tidyverse-first modern R / data analysis, `loop-ops` for outer-loop design discipline, `ffmpeg-ops` for probe-first media processing and EDL-driven editing, `supply-chain-defense` for behavioural-first dependency security, `prompt-injection-defense` for instruction-integrity scanning, `pypi-ops` for OIDC Trusted Publishing to PyPI, `net-ops` for network troubleshooting, `windows-ops` / `mac-ops` for workstation diagnostics, `fleet-worker` for cheap parallel worker delegation)
+- **99 skills** for CLI tools, patterns, workflows, and development tasks (incl. `svg-brand-tint-ops` for zero-dep in-browser SVG brand-recolour + Potrace-stage raster vectorising, `r-ops` for tidyverse-first modern R / data analysis, `loop-ops` for outer-loop design discipline, `ffmpeg-ops` for probe-first media processing and EDL-driven editing, `supply-chain-defense` for behavioural-first dependency security, `prompt-injection-defense` for instruction-integrity scanning, `pypi-ops` for OIDC Trusted Publishing to PyPI, `net-ops` for network troubleshooting, `windows-ops` / `mac-ops` for workstation diagnostics, `fleet-worker` for cheap parallel worker delegation)
 - **13 output styles** for response personality (Vesper, Spartan, Mentor, Executive, Pair, Atlas, Coach, Harbour, Meridian, Noir, Roast, Sage, Scout)
 - **13 hooks** for pre-commit linting, post-edit formatting, dangerous command warnings, uv enforcement, dependency-install + manifest-edit supply-chain advisories, hidden-Unicode scanning (session-start + pre-commit), live config-change + worktree guards, mid-session peer-writer guard + touched-files ledger, and pmail notifications - security set auto-wired via plugin hooks.json
 - **Pigeon** inter-session messaging (`pigeon send/read/reply`) - SQLite-backed pmail at `~/.claude/pmail.db`

+ 19 - 0
CHANGELOG.md

@@ -5,6 +5,25 @@ All notable changes to claude-mods are documented here. Format follows
 [Semantic Versioning](https://semver.org/). Fuller narrative entries for
 feature releases live in the README "Recent Updates" section.
 
+## [3.6.0] - 2026-07-04
+
+### Added
+- **`svg-brand-tint-ops` skill** - zero-dependency in-browser SVG studio.
+  Recolour any SVG to a brand palette via a token-driven tri-tone
+  (`feColorMatrix` desaturate → `feComponentTransfer` grey-ramp remap →
+  theme-aware CSS-filter bake), plus a from-scratch raster vectoriser
+  (PNG → SVG): a Potrace-paper geometry stage (tolerance-tube straightness,
+  penalty-DP optimal polygon, sub-pixel vertex adjustment, alphamax corners —
+  reimplemented from Selinger 2003, no GPL code) over soft-field marching
+  squares with alpha-aware palette handling (matte de-blending, anti-alias
+  fringe cull, blend-veto). B&W / posterised / colour trace modes, a
+  photographic filter stack, curated Google Fonts on SVG `<text>`, element
+  hover-inspect, before/after split, palette-from-image, and SVG/PNG export.
+  Ships a ~90-line dependency-free static server (`scripts/server.mjs`), a
+  headless trace CLI (`scripts/trace.mjs`) sharing one canonical engine with
+  the browser tool (`assets/trace-core.mjs`), a colour-math + trace + theme
+  reference, and a 22-assertion offline test suite.
+
 ## [3.5.0] - 2026-07-03
 
 ### Added

+ 6 - 2
README.md

@@ -12,16 +12,19 @@
 
 > *A comprehensive extension toolkit that transforms Claude Code into a specialized development powerhouse.*
 
-**claude-mods** is a production-ready plugin that extends Claude Code with 98 specialized skills, 3 expert agents, 13 output styles, 13 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
+**claude-mods** is a production-ready plugin that extends Claude Code with 99 specialized skills, 3 expert agents, 13 output styles, 13 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
 
 Built on the [Agent Skills specification](https://agentskills.io/specification) (an open standard backed by Anthropic, Vercel, Google, Microsoft, and 40+ agent platforms), claude-mods fills critical gaps in Claude Code's capabilities: persistent session state that survives across machines, on-demand expert knowledge for specialized domains, token-efficient modern CLI tools (10-100x faster than traditional alternatives), and proven workflow patterns for TDD, code review, and feature development. The toolkit implements Anthropic's [recommended patterns for long-running agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents), ensuring your development context never vanishes when sessions end.
 
 From Python async patterns to Rust ownership models, from AWS Fargate deployments to Craft CMS development - claude-mods provides the specialized knowledge and tools that transform Claude from a general-purpose assistant into a domain expert who understands your stack, remembers your workflow, and ships production code.
 
-**3 agents. 98 skills. 13 styles. 13 hooks. 8 rules. One install.**
+**3 agents. 99 skills. 13 styles. 13 hooks. 8 rules. One install.**
 
 ## Recent Updates
 
+**v3.6.0** (July 2026)
+- 🎨 **`svg-brand-tint-ops` skill** — a zero-dependency in-browser SVG studio. Recolour any SVG to a brand palette via a token-driven **tri-tone** (`feColorMatrix` desaturate → `feComponentTransfer` grey-ramp remap → theme-aware CSS-filter bake), plus a from-scratch raster **vectoriser** (PNG → SVG) built on a **Potrace-paper geometry stage** — tolerance-tube straightness, penalty-DP optimal polygon, sub-pixel vertex adjustment, and alphamax corner analysis, reimplemented from the freely published Selinger 2003 paper (no GPL code) — over soft-field marching-squares with **alpha-aware palette handling** (matte de-blending, anti-alias fringe cull, blend-veto) that keeps flat-colour brand logos crisp. B&W / posterised / colour trace modes, a photographic filter stack, curated Google Fonts on SVG `<text>`, element hover-inspect, before/after split, and palette-from-image. Ships a ~90-line dependency-free static server (`scripts/server.mjs`), a headless trace CLI (`scripts/trace.mjs`) sharing one canonical engine with the browser tool (`assets/trace-core.mjs`, no drift), a colour-math + trace + theme-bake reference, and a 22-assertion offline test suite.
+
 **v3.5.0** (July 2026)
 - 📐 **`isometric-ops` skill** — isometric illustrative assets end to end: creation, refinement, composition, and export for websites and games. **14 references** anchor the exact projection math (true isometric 30° vs **2:1 dimetric 26.565°** — the mislabel that breaks tilesets — with every constant derived and machine-checked by a §7 staleness verifier), coordinate transforms + y-sort depth doctrine, the tile-spec discipline, SVG/CSS/three.js generation, Aseprite pixel-art workflow, dual Blender ortho rigs (60° dimetric vs 54.736° true iso), engine tilemaps (Godot 4 / Unity / Phaser 3), and the full AI pipeline — Recraft/Midjourney/Flux+LoRA generation under **ControlNet depth/MLSD structure control**, upscale + vectorization ladders, and licence discipline that checks AI-training clauses. Scripts: `iso-math.py` (constants/transforms/grid generator), `tile-validate.py` (AI-tile QA: halo, bleed, anchor, palette), `sheet-pack.py` (spritesheet + atlas). Headlined by **iso-studio** — a zero-dependency browser scene composer with snap-to-grid staging, footprint-aware y-sort, docked control palettes, PNG/SVG/scene-JSON export, and a blockout mode that exports **depth + lineart maps straight into ControlNet conditioning** — built alongside the skill and extracted to [its own repo](https://github.com/0xDarkMatter/iso-studio) (an app with a roadmap and asset library is a product, not a skill resource).
 
@@ -219,6 +222,7 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [threejs-ops](skills/threejs-ops/) | App/game-scale three.js - import maps + ES-module reality, GLTF pipeline (DRACO/KTX2/meshopt, gltf-transform), AnimationMixer crossfades, fixed-timestep loops, rapier/cannon-es physics, R3F + drei, InstancedMesh/LOD/disposal discipline, boids/steering actors; npm staleness verifier |
 | [mapbox-ops](skills/mapbox-ops/) | Advanced Mapbox GL JS (web v3) - custom markers, thematic dataviz, 3D/terrain, cinematic camera, style composition, expressions, performance, gotchas; headless Playwright map verifier |
 | [isometric-ops](skills/isometric-ops/) | Isometric asset creation end-to-end - exact projection math (true iso vs 2:1 dimetric), SVG/CSS/three.js generation, pixel-art + Blender pre-render pipelines, engine tilemaps, AI generation with ControlNet structure control, tile QA + atlas packing scripts; routes to the companion [iso-studio](https://github.com/0xDarkMatter/iso-studio) scene composer (snap-to-grid, y-sort, blockout-to-ControlNet export) |
+| [svg-brand-tint-ops](skills/svg-brand-tint-ops/) | Zero-dep in-browser SVG studio - token-driven tri-tone brand recolour (feColorMatrix/feComponentTransfer + theme-aware CSS-filter bake) and a from-scratch raster vectoriser (PNG->SVG) with a Potrace-paper geometry stage (straightness/optimal-polygon/vertex-adjust/alphamax, no GPL code) over soft-field marching squares with alpha-aware palette handling; curated Google Fonts, filter stack, dependency-free server + headless trace CLI sharing one engine |
 | [unfold-admin](skills/unfold-admin/) | Django Unfold admin theme - ModelAdmin, dashboards, filters, widgets, theming |
 
 #### Python Skills

+ 1 - 1
docs/PLAN.md

@@ -16,7 +16,7 @@
 | Component | Count | Notes |
 |-----------|-------|-------|
 | Agents | 3 | Pure context-isolation/worker roles only: git-agent (background commits/PRs), firecrawl-expert (noisy scrapes), project-organizer (bulk restructure) |
-| Skills | 98 | Operational skills, CLI tools, workflows, diagnostics, security |
+| Skills | 99 | Operational skills, CLI tools, workflows, diagnostics, security |
 | Commands | 2 | Session management (sync, save) |
 | Rules | 8 | cli-tools, commit-style, naming-conventions, prompt-injection, skill-agent-updates, supply-chain, worktree-boundaries, loop-engineering |
 | Output Styles | 13 | Vesper, Spartan, Mentor, Executive, Pair, Atlas, Coach, Harbour, Meridian, Noir, Roast, Sage, Scout |

+ 125 - 0
skills/svg-brand-tint-ops/SKILL.md

@@ -0,0 +1,125 @@
+---
+name: svg-brand-tint-ops
+description: "Recolour, vectorise, and theme ANY SVG to a brand palette with a live zero-dependency studio that emits a copy-paste CSS filter. Triggers on: recolour svg, brand tint an svg, theme an svg, duotone svg, tri-tone svg, feComponentTransfer duotone/tri-tone, recolour a diagram, cloudcraft/draw.io/figma svg to brand, svg css filter, theme-aware svg, tone map svg, brand-tint a diagram export, tint an icon set, png to svg, png to vector, raster to vector, image trace, vectorise a logo, trace a png to svg, threshold vectorization, illustrator image trace."
+when_to_use: "Use when a designer or dev needs to recolour / duotone / tri-tone an SVG to brand colours, turn a raster (PNG/JPG) into vector paths, or bake a theme-aware SVG tint into an app — e.g. 'recolour this CloudCraft/draw.io/Figma export to our petrol palette', 'make a duotone of this logo', 'convert this PNG to SVG', 'trace this image to vector', 'give me the CSS filter to theme these icons'."
+license: MIT
+metadata:
+  author: claude-mods
+  related-skills: "color-ops, genart-ops, mapbox-ops"
+---
+
+# SVG Brand-Tint Studio
+
+Recolour **any** SVG to a brand palette — theme-aware — and get a copy-paste CSS
+filter you can bake into an app. Also **vectorises rasters** (PNG/JPG → SVG) so a
+flat-image logo or a screenshot becomes an editable, recolourable SVG. One
+zero-dependency HTML file served by a ~90-line Node server. Nothing uploads —
+pixels are read in a local `<canvas>`.
+
+Extracted from a real job: taming a CloudCraft AWS-diagram export into a house
+brand for an app's hosting page. It generalises to any third-party SVG export
+(draw.io, Figma, Mermaid, Excalidraw, icon sets) and any raster you need as
+vector.
+
+## When to reach for it
+
+- A **diagram/icon/logo export** is the wrong colours and has hundreds of inline
+  `fill`/`stroke` values you don't want to hand-edit.
+- You want a **duotone / tri-tone** brand treatment and a light + dark variant
+  from one palette swap.
+- You have a **PNG/JPG** and need clean **vector paths** (threshold, posterised,
+  or colour-quantised — an in-browser Image-Trace).
+- You need the **exact CSS `filter:` line** (and a React snippet) to bake a
+  theme-aware tint into a real page.
+
+This is a **skill, not a rule**: it carries the operational knowledge (the
+colour math, the trace pipeline, the bake pattern) plus the working tool. For
+one-line "always do X" guidance there's no rule here — the value is the studio +
+the reference.
+
+## Run the studio
+
+```bash
+node skills/svg-brand-tint-ops/scripts/server.mjs        # → http://localhost:4322
+# PORT=8080 node …/server.mjs      choose a port
+# node …/server.mjs --root ./icons  serve your own folder of SVGs/PNGs
+# node …/server.mjs --help          usage + exit codes
+```
+
+It serves the sibling `assets/` (the studio + a generic `sample.svg`). Open the
+URL, then **drop an SVG or PNG** on the canvas (or use the Source panel's sample
+buttons). The left rail is accordion sections; the right is a scalable,
+pan/zoom viewport with a live readout of the exact `filter:` line and ramp hex.
+
+**What the panels do** — *Source* (load SVG/PNG/JPG, drop, paste, samples) ·
+*Image Trace* (raster → vector: B&W / Steps / Color, detail, smoothing,
+despeckle) · *Tone Map* (the N-stop brand ramp + presets + "palette from image")
+· *Photographic* (saturate/contrast/brightness/hue/sepia/blur/drop-shadow) ·
+*Strokes & Fills* (outline-only, fill-only, stroke width/colour) · *Typography*
+(curated Google Fonts on the SVG's `<text>`, weight, size scale, tracking) ·
+*Geometry* (rotate/flip/scale, strip fixed size) · *Canvas* (checker/solid/
+light/dark, padding, grid) · *Inspect* (hover to highlight any element with its
+tag/id/class/colours) · *Export* (copy CSS / baked SVG / tokens / React snippet;
+download SVG or PNG @1–4×). `Split` (top bar) is a before/after divider.
+
+## How the recolour works (tri-tone)
+
+Three stacked stages — the first two are one SVG `<filter>`, the third is CSS:
+
+1. `feColorMatrix type="saturate" values="0"` → collapse the source to a grey ramp.
+2. `feComponentTransfer` → remap that ramp per channel to the brand stops via
+   `tableValues` (`lines → ink`, `mid → accent`, `faces → canvas`).
+3. `filter: url(#tonemap) saturate(1.55) …` → CSS tune on top.
+
+**Two stops = duotone (often washed); a third accent midstop = tri-tone that
+reads as designed.** Custom stop positions are handled by resampling the ramp to
+a fixed table. Full derivation, N-stop math, and the `sRGB` vs `linearRGB` note:
+[references/tri-tone-and-trace.md](references/tri-tone-and-trace.md).
+
+## How the vectoriser works (PNG → SVG)
+
+A from-scratch, accuracy-tuned engine ([assets/trace-core.mjs](assets/trace-core.mjs),
+shared by the tool and the headless CLI): **2× supersample** → alpha-aware
+segment into layers (threshold / posterise / **median-cut → k-means-refined →
+merged** colour) → **interpolated sub-pixel iso-contours** (`fill-rule="evenodd"`
+cuts holes) → closed-ring **Douglas–Peucker** → **corner-split + Schneider
+least-squares Bézier fit** (fairs out staircase noise into clean curves; straight
+runs and letter corners stay razor-sharp) → despeckle. Benchmarked on 24 real
+logos (RMSE ≈27→11.5, edge-F1 0.66→0.86) it reproduces bold/geometric/coloured
+marks near-perfectly (thin small text stays readable). It's one
+continuous move: **PNG → trace → SVG → brand-tint**. Algorithm + tuning knobs:
+[references/tri-tone-and-trace.md](references/tri-tone-and-trace.md) §2.
+
+Trace from the command line (needs `sharp` to decode; the browser tool needs
+nothing):
+
+```bash
+node skills/svg-brand-tint-ops/scripts/trace.mjs logo.png logo.svg --colors 6
+node skills/svg-brand-tint-ops/scripts/trace.mjs --help    # flags + exit codes
+```
+
+## Baking the result into an app
+
+Render the SVG **inline** (an `<img>` sandboxes fonts + `var()` tokens + document
+filters). Because filter primitives can't read CSS variables, read your theme
+tokens with `getComputedStyle(el).getPropertyValue('--ink')` and write the
+`feFunc*` `tableValues` in JS — rebuild on theme change so light/dark re-tints
+for free. Strip the root `width/height` (keep `viewBox`) so it scales. Apply
+`filter: url(#id) saturate(…)`. The studio's **Export → React snippet** emits
+this pre-filled; the full pattern + gotchas ledger is in
+[references/tri-tone-and-trace.md](references/tri-tone-and-trace.md) §3–4.
+
+## Resources
+
+| Path | What |
+|---|---|
+| [scripts/server.mjs](scripts/server.mjs) | Zero-dep Node static server. `node scripts/server.mjs --help` for flags (`--root`, `--port`, exit codes). Serves `assets/`. |
+| [scripts/trace.mjs](scripts/trace.mjs) | Headless PNG/JPG → SVG tracer over the shared engine. `--help` for options + exit codes. Needs `sharp` to decode (browser tool doesn't). |
+| [assets/trace-core.mjs](assets/trace-core.mjs) | The canonical, dependency-free trace engine (`traceImage`). Same code the tool runs inline; imported by the CLI. `DEFAULTS` at the top are the tuning knobs. |
+| [assets/index.html](assets/index.html) | The studio — the whole tool in one self-contained file (kept classic-script so the `file://` preview works). Editable `PRESETS` and `FONTS` maps near the top of the `<script>`; brand palettes are examples only. |
+| [assets/sample.svg](assets/sample.svg) | Generic (brand-agnostic) diagram that auto-loads for a first-run demo. |
+| [references/tri-tone-and-trace.md](references/tri-tone-and-trace.md) | The colour math, the trace algorithm, the theme-aware bake pattern, and a gotchas ledger. |
+
+**Tenant-agnostic:** every shipped palette (petrol, mono, blueprint, …) is an
+example. Swap the `PRESETS` map for your own tokens — nothing here is bound to a
+brand.

+ 1336 - 0
skills/svg-brand-tint-ops/assets/index.html

@@ -0,0 +1,1336 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>SVG Studio — vectorise · recolour · theme any SVG</title>
+<!-- UI chrome fonts. The SVG-text font is loaded separately at runtime (#svgfont). -->
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
+<link id="svgfont" rel="stylesheet" href="">
+<style>
+/* Evolution 7 design tokens — light is default; petrol is a [data-theme] swap. */
+:root, [data-theme='light']{
+  color-scheme:light;
+  --bg:#e5f3f8; --bg2:#d8ecf3; --panel:#ffffff; --panel2:#f2f9fb; --raise:#e7f2f7;
+  --line:#e2e2e2; --line2:#cfd6d8;
+  --ink:#002d3c; --ink2:#3c5560; --muted:#5a6b72; --faint:#87898c;
+  --accent:#5fb2d3; --accent-ink:#002d3c; --accent-dim:#3f94b8;
+  --hot:#ff6a1f; --good:#2f8f5c; --bad:#b33a3a;
+  --grid-line:rgba(0,45,60,.07); --floor:#e9f4f8; --checker:#dceaef;
+  --glass:rgba(255,255,255,.85); --overlay:rgba(233,244,248,.62);
+  --float-shadow:0 14px 30px rgba(0,45,60,.16);
+  --shadow:0 1px 0 rgba(0,45,60,.04), 0 10px 26px rgba(0,45,60,.10);
+}
+[data-theme='petrol']{
+  color-scheme:dark;
+  --bg:#001e29; --bg2:#012632; --panel:#002d3c; --panel2:#06333f; --raise:#0d3d4c;
+  --line:#1a3d4a; --line2:#2a4d5a;
+  --ink:#e8f0f2; --ink2:#bcd2da; --muted:#9fb6be; --faint:#6d8b95;
+  --accent:#89c6de; --accent-ink:#001e29; --accent-dim:#5fb2d3;
+  --hot:#ff9159; --good:#5bb081; --bad:#e08a8a;
+  --grid-line:rgba(137,198,222,.09); --floor:#001e29; --checker:#0a2731;
+  --glass:rgba(0,30,41,.80); --overlay:rgba(0,12,16,.55);
+  --float-shadow:0 16px 32px rgba(0,0,0,.42);
+  --shadow:0 1px 0 rgba(0,0,0,.2), 0 12px 28px rgba(0,0,0,.34);
+}
+:root{
+  --radius:10px; --radius-sm:6px;
+  --mono:'JetBrains Mono',ui-monospace,'SFMono-Regular',monospace;
+  --sans:'Manrope',system-ui,-apple-system,sans-serif;
+  --display:'Manrope',system-ui,sans-serif;
+}
+*{box-sizing:border-box}
+html,body{height:100%}
+body{
+  margin:0; font-family:var(--sans); color:var(--ink);
+  background:radial-gradient(1100px 520px at 84% -12%, rgba(95,178,211,.16), transparent 62%), var(--bg);
+  -webkit-font-smoothing:antialiased; overflow:hidden;
+}
+button,input,select{font-family:inherit; color:inherit}
+::selection{background:var(--accent); color:var(--accent-ink)}
+
+/* ─── shell ─────────────────────────────────────────────────────────── */
+.app{display:grid; grid-template-rows:auto 1fr; height:100vh}
+.topbar{
+  display:flex; align-items:center; gap:16px; padding:12px 18px;
+  border-bottom:1px solid var(--line); background:linear-gradient(var(--panel),var(--bg2));
+}
+.brand{display:flex; align-items:baseline; gap:10px; user-select:none}
+.brand .mark{
+  font-family:var(--display); font-weight:800; font-size:22px; letter-spacing:-.01em;
+  background:linear-gradient(92deg,var(--ink),var(--accent) 130%); -webkit-background-clip:text; background-clip:text; color:transparent;
+}
+.brand .dot{width:9px; height:9px; border-radius:50%; background:var(--accent); box-shadow:0 0 14px var(--accent); align-self:center}
+.brand .sub{font-size:11px; color:var(--muted); letter-spacing:.14em; text-transform:uppercase}
+.spacer{flex:1}
+.chip{
+  font-family:var(--mono); font-size:11px; color:var(--ink2); background:var(--panel2);
+  border:1px solid var(--line); border-radius:999px; padding:5px 11px; white-space:nowrap;
+}
+.chip b{color:var(--accent); font-weight:700}
+.tbtn{
+  border:1px solid var(--line2); background:var(--panel2); color:var(--ink2);
+  border-radius:10px; padding:7px 12px; font-size:12.5px; cursor:pointer; transition:.14s;
+}
+.tbtn:hover{background:var(--raise); color:var(--ink); border-color:var(--accent-dim)}
+.tbtn.on{background:var(--accent); color:var(--accent-ink); border-color:var(--accent); font-weight:600}
+.tbtn:disabled{opacity:.4; cursor:not-allowed}
+
+.body{display:grid; grid-template-columns:372px 1fr; min-height:0}
+
+/* ─── left rail ─────────────────────────────────────────────────────── */
+.rail{
+  border-right:1px solid var(--line); background:linear-gradient(180deg,var(--bg2),var(--bg));
+  overflow-y:auto; overflow-x:hidden; padding:14px 14px 60px;
+  scrollbar-width:thin; scrollbar-color:var(--raise) transparent;
+}
+.rail::-webkit-scrollbar{width:9px}
+.rail::-webkit-scrollbar-thumb{background:var(--raise); border-radius:9px; border:2px solid var(--bg)}
+
+.acc{border:1px solid var(--line); border-radius:var(--radius); background:var(--panel); margin-bottom:10px; overflow:hidden; transition:border-color .2s}
+.acc.open{border-color:var(--line2)}
+.acc.open.hot{border-color:var(--accent-dim); box-shadow:0 0 0 1px rgba(95,178,211,.22)}
+.acc>.head{
+  display:flex; align-items:center; gap:11px; padding:13px 14px; cursor:pointer; user-select:none;
+  transition:background .15s;
+}
+.acc>.head:hover{background:var(--panel2)}
+.acc>.head .idx{font-family:var(--mono); font-size:10px; color:var(--faint); width:18px}
+.acc>.head .ttl{font-weight:600; font-size:13.5px; letter-spacing:.01em; flex:1}
+.acc>.head .meta{font-family:var(--mono); font-size:10.5px; color:var(--muted)}
+.acc>.head .chev{width:16px; height:16px; color:var(--muted); transition:transform .22s; flex:none}
+.acc.open>.head .chev{transform:rotate(90deg); color:var(--accent)}
+.acc>.wrap{display:grid; grid-template-rows:0fr; transition:grid-template-rows .24s ease}
+.acc.open>.wrap{grid-template-rows:1fr}
+.acc>.wrap>.inner{overflow:hidden; min-height:0}
+.pad{padding:4px 14px 15px}
+
+.lbl{font-size:10.5px; font-weight:700; letter-spacing:.09em; text-transform:uppercase; color:var(--muted); margin:14px 0 8px}
+.lbl:first-child{margin-top:2px}
+.hint{font-size:11px; color:var(--faint); line-height:1.5; margin-top:7px}
+.hint code{font-family:var(--mono); color:var(--muted); font-size:10.5px}
+
+/* controls */
+.sl{margin-bottom:11px}
+.sl .top{display:flex; justify-content:space-between; align-items:baseline; margin-bottom:3px}
+.sl .top .n{font-size:12px; color:var(--ink2)}
+.sl .top .v{font-family:var(--mono); font-size:11.5px; color:var(--accent); font-weight:700}
+input[type=range]{-webkit-appearance:none; appearance:none; width:100%; height:22px; background:transparent; cursor:pointer; margin:0}
+input[type=range]::-webkit-slider-runnable-track{height:4px; border-radius:4px; background:linear-gradient(90deg,var(--accent-dim),var(--raise))}
+input[type=range]::-moz-range-track{height:4px; border-radius:4px; background:var(--raise)}
+input[type=range]::-webkit-slider-thumb{-webkit-appearance:none; width:15px; height:15px; margin-top:-5.5px; border-radius:50%; background:var(--accent); border:2px solid var(--bg); box-shadow:0 2px 6px rgba(0,0,0,.5); transition:transform .1s}
+input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.18)}
+input[type=range]::-moz-range-thumb{width:13px; height:13px; border-radius:50%; background:var(--accent); border:2px solid var(--bg)}
+
+.seg{display:flex; gap:5px; flex-wrap:wrap}
+.seg button{flex:1; min-width:fit-content; border:1px solid var(--line); background:var(--panel2); color:var(--ink2); border-radius:8px; padding:7px 8px; font-size:11.5px; cursor:pointer; transition:.13s; white-space:nowrap}
+.seg button:hover{border-color:var(--accent-dim); color:var(--ink)}
+.seg button.on{background:var(--accent); color:var(--accent-ink); border-color:var(--accent); font-weight:600}
+
+.row{display:flex; gap:8px; align-items:center}
+.row.wrap{flex-wrap:wrap}
+.btn{border:1px solid var(--line2); background:var(--panel2); color:var(--ink); border-radius:9px; padding:9px 12px; font-size:12.5px; cursor:pointer; transition:.14s; text-align:center; flex:1}
+.btn:hover{background:var(--raise); border-color:var(--accent-dim)}
+.btn.primary{background:var(--accent); color:var(--accent-ink); border-color:var(--accent); font-weight:700}
+.btn.primary:hover{filter:brightness(1.06)}
+.btn.ghost{background:transparent}
+.btn:disabled{opacity:.4; cursor:not-allowed}
+.btn.wide{width:100%}
+
+.filebtn{display:block; width:100%; text-align:center; border:1.5px dashed var(--line2); background:var(--panel2);
+  border-radius:11px; padding:14px; font-size:13px; cursor:pointer; color:var(--ink2); transition:.15s}
+.filebtn:hover{border-color:var(--accent); color:var(--ink); background:var(--raise)}
+input[type=file]{display:none}
+
+.toggle{display:flex; align-items:center; gap:9px; cursor:pointer; font-size:12.5px; color:var(--ink2); margin:9px 0}
+.toggle .sw{width:34px; height:19px; border-radius:999px; background:var(--raise); border:1px solid var(--line2); position:relative; transition:.16s; flex:none}
+.toggle .sw::after{content:""; position:absolute; top:1.5px; left:1.5px; width:14px; height:14px; border-radius:50%; background:var(--muted); transition:.16s}
+.toggle.on .sw{background:var(--accent); border-color:var(--accent)}
+.toggle.on .sw::after{left:16px; background:var(--accent-ink)}
+
+.swatchrow{display:flex; gap:7px; align-items:stretch}
+.swatch{flex:1; display:flex; flex-direction:column; gap:5px; align-items:center; font-size:10px; color:var(--muted); position:relative}
+.swatch input[type=color]{width:100%; height:34px; padding:0; border:1px solid var(--line2); border-radius:8px; background:none; cursor:pointer}
+.swatch input[type=color]::-webkit-color-swatch-wrapper{padding:2px}
+.swatch input[type=color]::-webkit-color-swatch{border:none; border-radius:5px}
+.swatch .x{position:absolute; top:-6px; right:-4px; width:16px; height:16px; border-radius:50%; background:var(--bad); color:#fff; border:none; font-size:10px; line-height:16px; cursor:pointer; display:none}
+.swatch:hover .x{display:block}
+.stopmini{font-family:var(--mono); font-size:9px}
+
+select{width:100%; background:var(--panel2); color:var(--ink); border:1px solid var(--line2); border-radius:9px; padding:9px 10px; font-size:12.5px; cursor:pointer}
+optgroup{background:var(--bg2); color:var(--muted); font-style:normal}
+option{background:var(--panel); color:var(--ink)}
+
+.pillcard{background:var(--panel2); border:1px solid var(--line); border-radius:10px; padding:10px 11px; font-family:var(--mono); font-size:11px; line-height:1.75; color:var(--ink2); word-break:break-all}
+.pillcard .k{color:var(--faint); user-select:none}
+.pillcard .cc{display:inline-block; width:10px; height:10px; border-radius:3px; border:1px solid var(--line2); vertical-align:-1px; margin-right:3px}
+
+/* element list */
+.elist{max-height:230px; overflow:auto; border:1px solid var(--line); border-radius:9px; background:var(--bg2)}
+.eitem{display:flex; align-items:center; gap:8px; padding:6px 9px; font-size:11.5px; cursor:pointer; border-bottom:1px solid var(--line)}
+.eitem:last-child{border-bottom:none}
+.eitem:hover,.eitem.hot{background:var(--raise)}
+.eitem .tag{font-family:var(--mono); font-size:10px; color:var(--accent); width:52px; flex:none}
+.eitem .dsc{color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1}
+.eitem .sw2{width:12px; height:12px; border-radius:3px; border:1px solid var(--line2); flex:none}
+
+/* ─── viewport ──────────────────────────────────────────────────────── */
+.stage{position:relative; min-width:0; overflow:hidden; background:var(--bg2)}
+.viewport{position:absolute; inset:0; overflow:hidden; cursor:grab}
+.viewport.grabbing{cursor:grabbing}
+.viewport.inspect{cursor:crosshair}
+.canvasbg{position:absolute; inset:0}
+/* faint isometric floor — the artwork floats above it (grid is fixed; art pans over it) */
+.canvasbg.iso{
+  background-color:var(--floor);
+  background-image:
+    repeating-linear-gradient(30deg,  var(--grid-line) 0 1px, transparent 1px 46px),
+    repeating-linear-gradient(-30deg, var(--grid-line) 0 1px, transparent 1px 46px),
+    radial-gradient(135% 100% at 50% 16%, transparent 58%, rgba(0,45,60,.05));
+}
+/* Fallout-style green CRT: scanlines + phosphor glow + vignette + faint flicker */
+.canvasbg.crt{
+  background-color:#02150b;
+  background-image:
+    repeating-linear-gradient(0deg, rgba(0,25,10,.55) 0 1px, transparent 1px 3px),
+    radial-gradient(120% 90% at 50% 50%, rgba(46,255,120,.08), transparent 68%),
+    radial-gradient(135% 100% at 50% 12%, transparent 50%, rgba(0,0,0,.62));
+  animation:crtflicker 4s steps(48) infinite;
+}
+@keyframes crtflicker{ 0%,100%{opacity:1} 47%{opacity:.98} 50%{opacity:.95} 53%{opacity:.98} }
+.canvasbg.checker{
+  background-color:var(--card);
+  background-image:
+    linear-gradient(45deg,var(--checker) 25%,transparent 25%),linear-gradient(-45deg,var(--checker) 25%,transparent 25%),
+    linear-gradient(45deg,transparent 75%,var(--checker) 75%),linear-gradient(-45deg,transparent 75%,var(--checker) 75%);
+  background-size:22px 22px; background-position:0 0,0 11px,11px -11px,-11px 0;
+}
+.gridoverlay{position:absolute; inset:0; pointer-events:none; opacity:0; transition:opacity .2s;
+  background-image:linear-gradient(var(--line) 1px,transparent 1px),linear-gradient(90deg,var(--line) 1px,transparent 1px);
+  background-size:40px 40px}
+.gridoverlay.on{opacity:1}
+.layer{position:absolute; inset:0; display:grid; place-items:center; filter:drop-shadow(var(--float-shadow))}
+.inner{will-change:transform; transform-origin:center center; display:grid; place-items:center}
+#art,#artBase{display:block; overflow:visible}
+#art svg,#artBase svg{display:block; max-width:none}
+.artwrap{}
+
+.emptyState{position:absolute; inset:0; display:grid; place-items:center; text-align:center; pointer-events:none; padding:40px}
+.emptyState .big{font-family:var(--display); font-size:26px; color:var(--ink2); margin-bottom:8px}
+.emptyState .small{font-size:13px; color:var(--muted); max-width:340px; line-height:1.6}
+.dropping .viewport{outline:2px dashed var(--accent); outline-offset:-14px}
+
+.hoverbox{position:absolute; border:1.5px solid var(--hot); background:rgba(255,106,31,.12); pointer-events:none; display:none; z-index:5; border-radius:2px}
+.tip{position:absolute; z-index:6; background:var(--bg2); border:1px solid var(--line2); border-radius:8px; padding:7px 9px;
+  font-family:var(--mono); font-size:10.5px; color:var(--ink2); pointer-events:none; display:none; box-shadow:var(--shadow); max-width:260px; line-height:1.6}
+.tip b{color:var(--accent)}
+
+.divider{position:absolute; top:0; bottom:0; width:2px; background:var(--accent); z-index:7; display:none; cursor:ew-resize}
+.divider::before{content:""; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:26px; height:26px; border-radius:50%; background:var(--accent); box-shadow:0 3px 10px rgba(0,0,0,.5)}
+.divider::after{content:"◂▸"; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); font-size:11px; color:var(--accent-ink); letter-spacing:-1px}
+.split .divider{display:block}
+.split .baseWrap{display:block}
+.baseWrap{display:none}
+
+.hud{position:absolute; top:14px; right:14px; display:flex; gap:6px; align-items:center; z-index:8;
+  background:var(--glass); backdrop-filter:blur(8px); border:1px solid var(--line2); border-radius:999px; padding:4px; box-shadow:var(--shadow)}
+.hud button{width:30px; height:30px; border:none; background:transparent; color:var(--ink2); font-size:15px; cursor:pointer; border-radius:50%; transition:.12s}
+.hud button:hover{background:var(--raise); color:var(--accent)}
+.hud .lv{width:auto; min-width:52px; font-family:var(--mono); font-size:11px; border-radius:999px}
+
+.readout{position:absolute; left:14px; bottom:14px; right:14px; z-index:8; display:flex; gap:14px; align-items:center; flex-wrap:wrap;
+  background:var(--glass); backdrop-filter:blur(8px); border:1px solid var(--line2); border-radius:11px; padding:9px 13px;
+  font-family:var(--mono); font-size:11px; color:var(--ink2); box-shadow:var(--shadow)}
+.readout .k{color:var(--faint)}
+.readout .g{display:flex; gap:6px; align-items:center}
+.readout .cc{display:inline-block; width:11px; height:11px; border-radius:3px; border:1px solid var(--line2); vertical-align:-1px}
+.readout .cp{cursor:pointer; color:var(--accent)}
+
+.toast{position:fixed; right:22px; bottom:22px; transform:translateY(24px); z-index:10000;
+  background:var(--accent); color:var(--accent-ink); font-weight:600; font-size:13px; padding:11px 18px; border-radius:11px;
+  box-shadow:var(--shadow); opacity:0; transition:.24s var(--ease-out,ease); pointer-events:none}
+.toast.show{opacity:1; transform:translateY(0)}
+.busy{position:absolute; inset:0; z-index:20; display:none; place-items:center; background:var(--overlay); backdrop-filter:blur(2px)}
+.busy.on{display:grid}
+.busy .ring{width:40px; height:40px; border:3px solid var(--raise); border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite}
+.busy .msg{position:absolute; margin-top:66px; font-size:12px; color:var(--ink2); font-family:var(--mono)}
+@keyframes spin{to{transform:rotate(360deg)}}
+
+/* entrance */
+.acc{animation:rise .5s both}
+@keyframes rise{from{opacity:0; transform:translateY(8px)}to{opacity:1; transform:none}}
+</style>
+</head>
+<body>
+<div class="app">
+  <!-- ── top bar ─────────────────────────────────────────────────── -->
+  <header class="topbar">
+    <div class="brand">
+      <span class="dot"></span>
+      <span class="mark">SVG&nbsp;Studio</span>
+      <span class="sub">vectorise · recolour · theme</span>
+    </div>
+    <div class="spacer"></div>
+    <span class="chip" id="srcchip">no source</span>
+    <button class="tbtn" id="undoBtn" title="Undo source change (Ctrl+Z)" disabled>↶ Undo</button>
+    <button class="tbtn" id="splitBtn" title="Before / after split">◑ Split</button>
+    <button class="tbtn" id="inspectBtn" title="Hover to inspect elements">⊹ Inspect</button>
+    <button class="tbtn" id="themeBtn" title="Light / petrol theme">◐ Theme</button>
+    <button class="tbtn" id="resetBtn" title="Reset all controls">↺ Reset</button>
+  </header>
+
+  <div class="body">
+    <!-- ── control rail ──────────────────────────────────────────── -->
+    <aside class="rail" id="rail"></aside>
+
+    <!-- ── stage ─────────────────────────────────────────────────── -->
+    <section class="stage" id="stage">
+      <div class="viewport" id="viewport">
+        <div class="canvasbg checker" id="canvasbg"></div>
+        <div class="gridoverlay" id="grid"></div>
+        <div class="layer baseWrap" id="baseWrap"><div class="inner" id="baseInner"><div id="artBase"></div></div></div>
+        <div class="layer artWrap" id="artWrap"><div class="inner" id="artInner"><div id="art"></div></div></div>
+        <div class="hoverbox" id="hoverbox"></div>
+        <div class="tip" id="tip"></div>
+        <div class="divider" id="divider"></div>
+        <div class="emptyState" id="empty">
+          <div>
+            <div class="big">Drop an SVG or PNG</div>
+            <div class="small">Vectorise a raster with the trace engine, then recolour any SVG to a brand palette. Load a sample from the <b>Source</b> panel to start.</div>
+          </div>
+        </div>
+      </div>
+      <div class="hud" id="hud"></div>
+      <div class="readout" id="readout"></div>
+      <div class="busy" id="busy"><div class="ring"></div><div class="msg" id="busymsg">working…</div></div>
+    </section>
+  </div>
+</div>
+<div class="toast" id="toast"></div>
+
+<!-- live tone-map filter (rebuilt from ramp stops at runtime) -->
+<svg aria-hidden="true" width="0" height="0" style="position:absolute"><defs id="fxdefs"></defs></svg>
+
+<script>
+"use strict";
+/* ═══════════════════════════════════════════════════════════════════════
+   SVG STUDIO — zero-dependency designer tool.
+   Load an SVG (or trace a PNG into one), then recolour / restyle / export.
+   ═══════════════════════════════════════════════════════════════════════ */
+const $ = s => document.querySelector(s);
+const el = (t,a={},...k)=>{const n=document.createElement(t);for(const x in a){if(x==='class')n.className=a[x];else if(x==='html')n.innerHTML=a[x];else n.setAttribute(x,a[x]);}k.flat().forEach(c=>n.append(c));return n;};
+const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
+const hex2rgb=h=>{h=h.replace('#','');if(h.length===3)h=h.split('').map(c=>c+c).join('');const n=parseInt(h,16);return[(n>>16)&255,(n>>8)&255,n&255];};
+const rgb2hex=(r,g,b)=>'#'+[r,g,b].map(v=>clamp(Math.round(v),0,255).toString(16).padStart(2,'0')).join('');
+
+/* ── curated Google Fonts for SVG text ──────────────────────────────── */
+const FONTS = {
+  'Sans':    [['Manrope','400;600;700'],['Work Sans','400;600'],['Sora','400;700'],['Outfit','400;600'],['Figtree','400;600'],['Hanken Grotesk','400;600'],['Schibsted Grotesk','400;700'],['Onest','400;700']],
+  'Serif':   [['Fraunces','9..144,500;9..144,700'],['Playfair Display','500;700'],['Spectral','400;600'],['Zilla Slab','400;600'],['Bitter','400;600'],['Newsreader','400;600']],
+  'Display': [['Unbounded','400;700'],['Bricolage Grotesque','500;700'],['Instrument Serif','400'],['Syne','600;800'],['Archivo','500;700'],['Familjen Grotesk','500;700'],['Anton','400']],
+  'Mono':    [['Space Mono','400;700'],['JetBrains Mono','400;700'],['IBM Plex Mono','400;600'],['Fira Code','400;600'],['Martian Mono','400;600']],
+};
+
+/* ── brand-palette presets (examples — swap for your own) ────────────── */
+const PRESETS = {
+  'Petrol (light)': {stops:['#012836','#4fa7cd','#e7f3f8'], sat:1.55, bg:'#eef4f6'},
+  'Petrol (dark)':  {stops:['#eaf3f6','#7cc0dc','#02222e'], sat:1.4,  bg:'#08171d'},
+  'Mono ink':       {stops:['#111111','#7d7d7d','#f4f4f4'], sat:1.0,  bg:'#e9e9e6'},
+  'Blueprint':      {stops:['#eaf2ff','#4f8ff0','#0a2a63'], sat:1.5,  bg:'#0a1c40'},
+  'Coral pop':      {stops:['#00232f','#ff6a1f','#fff2ea'], sat:1.5,  bg:'#04202a'},
+  'Berry':          {stops:['#2a0f3d','#c0468f','#ffe6f2'], sat:1.7,  bg:'#150720'},
+  'Forest':         {stops:['#07231a','#3f9d6b','#e8f6ec'], sat:1.5,  bg:'#0a1f18'},
+  'Ink & sky':      {stops:['#001018','#5fb2d3','#f2fbff'], sat:1.35, bg:'#00131b'},
+  'Fallout CRT':    {stops:['#001a08','#28e24a','#c9ffcf'], sat:1.9,  bgMode:'crt', glow:'#39ff7a', glowBlur:18},
+};
+
+/* ── state ──────────────────────────────────────────────────────────── */
+const DEFAULT = {
+  // tone map
+  toneOn:true, desat:true, cif:'sRGB',
+  stops:[{c:'#012836',p:0},{c:'#4fa7cd',p:0.5},{c:'#e7f3f8',p:1}],
+  // photographic css filters
+  sat:1.55, con:1, bri:1, hue:0, inv:0, sep:0, gray:0, opa:1, blur:0,
+  shOn:false, shC:'#000000', shBlur:8, shX:0, shY:4,
+  // vector strokes / fills
+  vmode:'normal', strokeW:1.5, strokeOn:false, strokeColor:'#101010', nonScaling:false, roundJoin:false,
+  // typography
+  font:'', weight:400, sizeScale:1, tracking:0, transform:'none',
+  // geometry
+  rot:0, flipH:false, flipV:false, artScale:1, stripSize:true,
+  // canvas
+  bg:'iso', bgColor:'#0f0e14', pad:36, grid:false,
+  // trace
+  traceMode:'color', threshold:128, levels:4, colors:6, detail:0.5, smooth:0.5, despeckle:8, preblur:0, traceInvert:false, traceRes:900,
+  // view
+  zoom:1, panx:0, pany:0, split:false, splitPct:0.5,
+};
+// Safe localStorage — under file:// or a sandboxed preview, localStorage access
+// THROWS. Every access must go through this or an unguarded read aborts boot.
+const LS = {
+  get(k){ try{ return localStorage.getItem(k); }catch{ return null; } },
+  set(k,v){ try{ localStorage.setItem(k,v); }catch{} },
+  del(k){ try{ localStorage.removeItem(k); }catch{} },
+};
+let S = load();
+function load(){ try{const j=JSON.parse(LS.get('svgstudio')); return Object.assign(structuredClone(DEFAULT), j||{});}catch{ return structuredClone(DEFAULT);} }
+function save(){ try{LS.set('svgstudio', JSON.stringify(S));}catch{} }
+
+/* ── source model ───────────────────────────────────────────────────── */
+let SRC = { type:null, svg:null, raster:null, natW:0, natH:0 };  // svg = <svg> element (live), raster = Image
+let baseSize = new Map();  // text node -> base font-size px (for size scale)
+
+/* ═══ SECTIONS (accordion) ══════════════════════════════════════════ */
+let openSet; try{ openSet=new Set(JSON.parse(LS.get('svgstudio-open')||'["source","trace","tone"]')); }catch{ openSet=new Set(['source','trace','tone']); }
+
+function num(key,label,min,max,step,fmt){ return {t:'range',key,label,min,max,step,fmt}; }
+function seg(key,label,opts){ return {t:'seg',key,label,opts}; }
+function tog(key,label){ return {t:'tog',key,label}; }
+
+const SECTIONS = [
+ {id:'source', title:'Source', build:buildSource},
+ {id:'trace',  title:'Image Trace', sub:'PNG → SVG', build:buildTrace},
+ {id:'tone',   title:'Tone Map', sub:'recolour', build:buildTone},
+ {id:'filter', title:'Photographic', build:buildFilter},
+ {id:'vector', title:'Strokes & Fills', build:buildVector},
+ {id:'type',   title:'Typography', build:buildType},
+ {id:'geo',    title:'Geometry', build:buildGeo},
+ {id:'canvas', title:'Canvas', build:buildCanvas},
+ {id:'inspect',title:'Inspect', build:buildInspect},
+ {id:'export', title:'Export', build:buildExport},
+];
+
+function renderRail(){
+  const rail=$('#rail'); rail.innerHTML='';
+  SECTIONS.forEach((sec,i)=>{
+    const open=openSet.has(sec.id);
+    const acc=el('div',{class:'acc'+(open?' open':'')});
+    acc.dataset.id=sec.id;
+    const head=el('div',{class:'head'},
+      el('span',{class:'idx'},String(i+1).padStart(2,'0')),
+      el('span',{class:'ttl'},sec.title),
+      el('span',{class:'meta',id:'meta-'+sec.id},sec.sub||''),
+      el('span',{class:'chev',html:'<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 4l4 4-4 4"/></svg>'}));
+    const inner=el('div',{class:'inner'}); const pad=el('div',{class:'pad'});
+    inner.append(pad); const wrap=el('div',{class:'wrap'}); wrap.append(inner);
+    acc.append(head,wrap);
+    head.onclick=()=>{ acc.classList.toggle('open'); if(acc.classList.contains('open'))openSet.add(sec.id);else openSet.delete(sec.id); LS.set('svgstudio-open',JSON.stringify([...openSet])); };
+    sec.build(pad);
+    rail.append(acc);
+  });
+  refreshMeta();
+}
+
+/* generic control builders --------------------------------------------- */
+function ctlRange(parent,{key,label,min,max,step,fmt}){
+  const wrap=el('div',{class:'sl'});
+  const top=el('div',{class:'top'}, el('span',{class:'n'},label), el('span',{class:'v'}));
+  const inp=el('input',{type:'range',min,max,step,id:'sl-'+key,name:key,'aria-label':label});
+  inp.value=S[key];
+  const out=top.querySelector('.v');
+  const show=()=>out.textContent=(fmt?fmt(S[key]):S[key]);
+  show();
+  inp.oninput=()=>{ S[key]=parseFloat(inp.value); show(); apply(); scheduleSave(); };
+  inp.dataset.key=key;
+  wrap.append(top,inp); parent.append(wrap); return inp;
+}
+function ctlSeg(parent,{key,label,opts}){
+  if(label) parent.append(el('div',{class:'lbl'},label));
+  const seg=el('div',{class:'seg'});
+  opts.forEach(([val,txt])=>{
+    const b=el('button',{},txt); if(S[key]===val)b.classList.add('on');
+    b.onclick=()=>{ S[key]=val; [...seg.children].forEach(c=>c.classList.remove('on')); b.classList.add('on'); apply(); refreshMeta(); scheduleSave(); rebuildDynamic(); };
+    seg.append(b);
+  });
+  parent.append(seg); return seg;
+}
+function ctlTog(parent,{key,label},after){
+  const t=el('div',{class:'toggle'+(S[key]?' on':'')}, el('span',{class:'sw'}), el('span',{},label));
+  t.onclick=()=>{ S[key]=!S[key]; t.classList.toggle('on',S[key]); apply(); if(after)after(); scheduleSave(); };
+  parent.append(t); return t;
+}
+
+/* ── Section: Source ──────────────────────────────────────────────── */
+function buildSource(p){
+  const lbl=el('label',{class:'filebtn'},'⬆  Load SVG · PNG · JPG',el('input',{type:'file',name:'source',accept:'.svg,.png,.jpg,.jpeg,.webp,.gif,image/*'}));
+  lbl.querySelector('input').onchange=e=>{const f=e.target.files[0]; if(f)loadFile(f);};
+  p.append(lbl);
+  p.append(el('div',{class:'hint'},'Or drop a file on the canvas. Rasters unlock the trace engine below.'));
+  p.append(el('div',{class:'lbl'},'Samples'));
+  const r=el('div',{class:'row wrap'});
+  const b1=el('button',{class:'btn ghost'},'◇ Sample SVG'); b1.onclick=loadSampleSVG;
+  const b2=el('button',{class:'btn ghost'},'▦ Sample raster'); b2.onclick=loadSampleRaster;
+  r.append(b1,b2); p.append(r);
+  const r2=el('div',{class:'row wrap'});
+  const b3=el('button',{class:'btn ghost'},'⤓ Paste SVG code'); b3.onclick=pasteSVG;
+  r2.append(b3); p.append(r2);
+}
+
+/* ── Section: Trace ───────────────────────────────────────────────── */
+function buildTrace(p){
+  const note=el('div',{class:'hint',id:'traceNote'},'Load a PNG/JPG to enable. Traces raster → clean vector paths you can then recolour.');
+  p.append(note);
+  ctlSeg(p,seg('traceMode','Mode',[['bw','B&W'],['poster','Steps'],['color','Color']]));
+  const dyn=el('div',{id:'traceDyn'}); p.append(dyn);
+  ctlRange(p,num('detail','Detail  (simplify)',0.2,5,0.1,v=>v.toFixed(1)+'px'));
+  ctlRange(p,num('smooth','Smoothing',0,1,0.05,v=>Math.round(v*100)+'%'));
+  ctlRange(p,num('despeckle','Despeckle  (min area)',0,120,2,v=>v+'px²'));
+  ctlRange(p,num('preblur','Pre-blur  (denoise)',0,4,0.5,v=>v.toFixed(1)));
+  ctlRange(p,num('traceRes','Resolution',300,1400,50,v=>v+'px'));
+  ctlTog(p,tog('traceInvert','Invert luminance'));
+  const btn=el('button',{class:'btn primary wide',id:'traceGo'},'✦  Trace to SVG'); btn.style.marginTop='10px';
+  btn.onclick=runTrace; p.append(btn);
+  p.append(el('div',{class:'hint'},'Marching-squares contours · Douglas–Peucker simplify · optional bézier smoothing. All in-browser, no upload.'));
+  buildTraceDyn();
+}
+function buildTraceDyn(){
+  const dyn=$('#traceDyn'); if(!dyn)return; dyn.innerHTML='';
+  if(S.traceMode==='bw') ctlRange(dyn,num('threshold','Threshold',1,254,1,v=>v));
+  else if(S.traceMode==='poster') ctlRange(dyn,num('levels','Levels',2,8,1,v=>v));
+  else ctlRange(dyn,num('colors','Colors',2,12,1,v=>v));
+}
+
+/* ── Section: Tone Map ────────────────────────────────────────────── */
+function buildTone(p){
+  ctlTog(p,tog('toneOn','Tone map on'));
+  p.append(el('div',{class:'lbl'},'Ramp  (dark → light)'));
+  const holder=el('div',{id:'stopsHolder'}); p.append(holder); renderStops(holder);
+  const r=el('div',{class:'row wrap'});
+  const add=el('button',{class:'btn ghost'},'+ stop'); add.onclick=()=>{ if(S.stops.length>=8)return; const mid=Math.round(S.stops.length/2); S.stops.splice(mid,0,{c:'#888888',p:0.5}); normStops(); renderStops(holder); apply(); scheduleSave(); };
+  const ex=el('button',{class:'btn ghost'},'⤿ From image'); ex.onclick=paletteFromArt;
+  r.append(add,ex); p.append(r);
+  ctlTog(p,tog('desat','Desaturate first'));
+  ctlSeg(p,seg('cif','Interpolation',[['sRGB','sRGB'],['linearRGB','linear']]));
+  p.append(el('div',{class:'lbl'},'Presets'));
+  const ps=el('div',{class:'seg'});
+  Object.keys(PRESETS).forEach(name=>{ const b=el('button',{},name); b.onclick=()=>applyPreset(name); ps.append(b); });
+  p.append(ps);
+}
+function renderStops(holder){
+  holder.innerHTML='';
+  const row=el('div',{class:'swatchrow'});
+  S.stops.forEach((st,i)=>{
+    const sw=el('div',{class:'swatch'});
+    const c=el('input',{type:'color',id:'stop-'+i,name:'stop-'+i,'aria-label':'Ramp stop '+(i+1)+' colour'}); c.value=st.c;
+    c.oninput=()=>{ st.c=c.value; apply(); scheduleSave(); };
+    const lbl=el('span',{class:'stopmini'},st.c.replace('#',''));
+    c.addEventListener('input',()=>lbl.textContent=c.value.replace('#',''));
+    const x=el('button',{class:'x'},'×'); x.onclick=()=>{ if(S.stops.length<=2)return; S.stops.splice(i,1); normStops(); renderStops(holder); apply(); scheduleSave(); };
+    sw.append(c,lbl,x); row.append(sw);
+  });
+  holder.append(row);
+}
+function normStops(){ const n=S.stops.length; S.stops.forEach((s,i)=>s.p=n===1?0:i/(n-1)); }
+function applyPreset(name){ const pr=PRESETS[name];
+  S.stops=pr.stops.map((c,i)=>({c,p:i/(pr.stops.length-1)})); S.sat=pr.sat; S.toneOn=true;
+  if(pr.bgMode){ S.bg=pr.bgMode; } else { S.bg='solid'; S.bgColor=pr.bg; }
+  S.shOn=!!pr.glow; if(pr.glow){ S.shC=pr.glow; S.shBlur=pr.glowBlur||16; S.shX=0; S.shY=0; }   // phosphor bloom
+  renderRail(); apply(); syncCanvasBg(); scheduleSave(); toast('Preset · '+name);
+}
+
+/* ── Section: Photographic ────────────────────────────────────────── */
+function buildFilter(p){
+  ctlRange(p,num('sat','Saturation',0,3,0.05,v=>v.toFixed(2)));
+  ctlRange(p,num('con','Contrast',0.3,2,0.02,v=>v.toFixed(2)));
+  ctlRange(p,num('bri','Brightness',0.3,1.8,0.02,v=>v.toFixed(2)));
+  ctlRange(p,num('hue','Hue rotate',0,360,1,v=>v+'°'));
+  ctlRange(p,num('sep','Sepia',0,1,0.02,v=>Math.round(v*100)+'%'));
+  ctlRange(p,num('gray','Grayscale',0,1,0.02,v=>Math.round(v*100)+'%'));
+  ctlRange(p,num('inv','Invert',0,1,0.02,v=>Math.round(v*100)+'%'));
+  ctlRange(p,num('blur','Blur',0,8,0.1,v=>v.toFixed(1)+'px'));
+  ctlRange(p,num('opa','Opacity',0.1,1,0.02,v=>Math.round(v*100)+'%'));
+  const box=el('div',{id:'shadowBox'});
+  const sh=()=>{ box.innerHTML=''; if(!S.shOn)return;
+    const sr=el('div',{class:'swatchrow'}); const sw=el('div',{class:'swatch'});
+    const c=el('input',{type:'color',id:'shadow-color',name:'shadow-color','aria-label':'Drop shadow colour'}); c.value=S.shC; c.oninput=()=>{S.shC=c.value;apply();scheduleSave();}; sw.append(c,el('span',{class:'stopmini'},'color')); sr.append(sw); box.append(sr);
+    ctlRange(box,num('shBlur','Shadow blur',0,30,1,v=>v+'px'));
+    ctlRange(box,num('shX','Offset X',-30,30,1,v=>v));
+    ctlRange(box,num('shY','Offset Y',-30,30,1,v=>v));
+  };
+  ctlTog(p,tog('shOn','Drop shadow'),sh);   // re-render the sub-panel on toggle
+  p.append(box); sh(); box._render=sh;
+}
+
+/* ── Section: Strokes & Fills ─────────────────────────────────────── */
+function buildVector(p){
+  ctlSeg(p,seg('vmode','Render',[['normal','Normal'],['outline','Outline'],['fill','Fill only']]));
+  ctlTog(p,tog('strokeOn','Override stroke width'));
+  ctlRange(p,num('strokeW','Stroke width',0.2,8,0.1,v=>v.toFixed(1)));
+  const sr=el('div',{class:'swatchrow'}); const sw=el('div',{class:'swatch'});
+  const c=el('input',{type:'color',id:'stroke-color',name:'stroke-color','aria-label':'Outline colour'}); c.value=S.strokeColor; c.oninput=()=>{S.strokeColor=c.value;apply();scheduleSave();};
+  sw.append(c,el('span',{class:'stopmini'},'outline color')); sr.append(sw); p.append(el('div',{class:'lbl'},'Outline color'),sr);
+  ctlTog(p,tog('roundJoin','Round joins & caps'));
+  ctlTog(p,tog('nonScaling','Non-scaling stroke'));
+  p.append(el('div',{class:'hint'},'Outline / Fill-only reinterpret every shape — great for turning a filled logo into a line-art variant.'));
+}
+
+/* ── Section: Typography ──────────────────────────────────────────── */
+function buildType(p){
+  const sel=el('select',{id:'font-select',name:'font','aria-label':'SVG text font family'});
+  sel.append(el('option',{value:''},'— System default —'));
+  for(const grp in FONTS){ const og=el('optgroup',{label:grp}); FONTS[grp].forEach(([name])=>og.append(el('option',{value:name},name))); sel.append(og); }
+  sel.value=S.font;
+  sel.onchange=()=>{ S.font=sel.value; loadSvgFont(S.font); apply(); scheduleSave(); };
+  p.append(el('div',{class:'lbl'},'Font family  (curated Google Fonts)'),sel);
+  ctlRange(p,num('weight','Weight',100,900,100,v=>v));
+  ctlRange(p,num('sizeScale','Size scale',0.4,3,0.05,v=>v.toFixed(2)+'×'));
+  ctlRange(p,num('tracking','Letter spacing',-2,8,0.1,v=>v.toFixed(1)+'px'));
+  ctlSeg(p,seg('transform','Case',[['none','As-is'],['uppercase','UPPER'],['lowercase','lower'],['capitalize','Title']]));
+  p.append(el('div',{class:'hint'},'Applies to every <code>&lt;text&gt;</code> in the SVG. Because the SVG is inline, page-loaded webfonts resolve — an <code>&lt;img&gt;</code> would sandbox them.'));
+}
+
+/* ── Section: Geometry ────────────────────────────────────────────── */
+function buildGeo(p){
+  ctlRange(p,num('rot','Rotate',-180,180,1,v=>v+'°'));
+  ctlRange(p,num('artScale','Scale',0.2,3,0.05,v=>v.toFixed(2)+'×'));
+  const r=el('div',{class:'row wrap'});
+  const fh=el('button',{class:'btn ghost'+(S.flipH?' primary':'')},'⇋ Flip H'); fh.onclick=()=>{S.flipH=!S.flipH;fh.classList.toggle('primary',S.flipH);apply();scheduleSave();};
+  const fv=el('button',{class:'btn ghost'+(S.flipV?' primary':'')},'⇵ Flip V'); fv.onclick=()=>{S.flipV=!S.flipV;fv.classList.toggle('primary',S.flipV);apply();scheduleSave();};
+  r.append(fh,fv); p.append(el('div',{class:'lbl'},'Mirror'),r);
+  ctlTog(p,tog('stripSize','Strip fixed width/height'),()=>{ if(SRC.svg){applySvgSizing(); fitView();} });
+  p.append(el('div',{class:'hint'},'Stripping the root <code>width/height</code> lets the SVG scale fluidly to any container.'));
+}
+
+/* ── Section: Canvas ──────────────────────────────────────────────── */
+function buildCanvas(p){
+  ctlSeg(p,seg('bg','Background',[['iso','Iso grid'],['checker','Checker'],['crt','CRT'],['solid','Solid'],['light','Light'],['dark','Dark']]));
+  const box=el('div',{id:'bgColorBox'}); p.append(box);
+  const bc=()=>{ box.innerHTML=''; if(S.bg!=='solid')return; const sr=el('div',{class:'swatchrow'}); const sw=el('div',{class:'swatch'}); const c=el('input',{type:'color',id:'bg-color',name:'bg-color','aria-label':'Canvas background colour'}); c.value=S.bgColor; c.oninput=()=>{S.bgColor=c.value;syncCanvasBg();scheduleSave();}; sw.append(c,el('span',{class:'stopmini'},'canvas')); sr.append(sw); box.append(sr); };
+  bc(); box._render=bc;
+  ctlRange(p,num('pad','Padding',0,140,4,v=>v+'px'));
+  ctlTog(p,tog('grid','Grid overlay'),()=>syncCanvasBg());
+}
+
+/* ── Section: Inspect ─────────────────────────────────────────────── */
+function buildInspect(p){
+  p.append(el('div',{class:'hint'},'Toggle Inspect (top bar) then hover the artwork — each element highlights with its tag, id, class and colours. Click a row below to flash it.'));
+  const list=el('div',{class:'elist',id:'elist'}); p.append(list);
+  const btn=el('button',{class:'btn ghost wide'},'⟳ Rebuild element list'); btn.style.marginTop='8px'; btn.onclick=buildElementList; p.append(btn);
+  buildElementList();
+}
+
+/* ── Section: Export ──────────────────────────────────────────────── */
+function buildExport(p){
+  p.append(el('div',{class:'lbl'},'Copy'));
+  const r1=el('div',{class:'row wrap'});
+  mk(r1,'❐ CSS filter',()=>copy(cssFilterLine(),'CSS filter copied'));
+  mk(r1,'❐ Tinted SVG',()=>{ const s=exportSVGString(true); if(!s){ toast('Load an SVG (or trace a raster) first'); return; } copy(s,'Baked SVG copied'); });
+  p.append(r1);
+  const r2=el('div',{class:'row wrap'});
+  mk(r2,'❐ Design tokens',()=>copy(tokensString(),'Tokens copied'));
+  mk(r2,'❐ React snippet',()=>copy(reactSnippet(),'React snippet copied'));
+  p.append(r2);
+  p.append(el('div',{class:'lbl'},'Download'));
+  const r3=el('div',{class:'row wrap'});
+  mk(r3,'⤓ SVG',()=>{ const s=exportSVGString(true); if(!s){ toast('Load an SVG (or trace a raster) first'); return; } download(s,'svg-studio.svg','image/svg+xml'); },true);
+  p.append(r3);
+  p.append(el('div',{class:'lbl'},'PNG @'));
+  const r4=el('div',{class:'seg'});
+  [1,2,3,4].forEach(s=>{const b=el('button',{},s+'×'); b.onclick=()=>exportPNG(s); r4.append(b);});
+  p.append(r4);
+  p.append(el('div',{class:'hint'},'Baked SVG embeds the tone-map as a native <code>&lt;filter&gt;</code>. PNG rasterises that plus the photographic chain at the chosen scale.'));
+  function mk(par,txt,fn,wide){const b=el('button',{class:'btn ghost'+(wide?' wide':'')},txt); b.onclick=fn; par.append(b);}
+}
+
+/* rebuild a section's dynamic sub-controls after a mode toggle */
+function rebuildDynamic(){ buildTraceDyn(); const sb=$('#shadowBox'); if(sb&&sb._render)sb._render(); const bb=$('#bgColorBox'); if(bb&&bb._render)bb._render(); refreshMeta(); }
+function refreshMeta(){
+  const set=(id,v)=>{const m=$('#meta-'+id); if(m)m.textContent=v;};
+  set('tone', S.toneOn? S.stops.length+' stops':'off');
+  set('trace', SRC.type==='raster'?S.traceMode:'PNG→SVG');
+  set('type', S.font||'default');
+  set('canvas', S.bg);
+}
+
+/* ═══ APPLY — the render pipeline ════════════════════════════════════ */
+let raf=0;
+function apply(){ cancelAnimationFrame(raf); raf=requestAnimationFrame(applyNow); }
+function applyNow(){
+  buildToneFilter();
+  const art=$('#art'), base=$('#artBase');
+  const svg=art.querySelector('svg');
+  if(svg){
+    svg.style.filter = filterChain();
+    svg.style.transform = artTransform();
+    svg.style.opacity = S.opa;
+    applyScopedCSS();
+    applySizeScale(svg);
+  }
+  const bsvg=base.querySelector('svg');
+  if(bsvg){ bsvg.style.transform=artTransform(); bsvg.style.filter='none'; }
+  updateView();
+  updateReadout();
+}
+function filterChain(){
+  const f=[];
+  if(S.toneOn) f.push('url(#tonemap)');
+  if(S.sat!==1) f.push(`saturate(${S.sat})`);
+  if(S.con!==1) f.push(`contrast(${S.con})`);
+  if(S.bri!==1) f.push(`brightness(${S.bri})`);
+  if(S.hue) f.push(`hue-rotate(${S.hue}deg)`);
+  if(S.sep) f.push(`sepia(${S.sep})`);
+  if(S.gray) f.push(`grayscale(${S.gray})`);
+  if(S.inv) f.push(`invert(${S.inv})`);
+  if(S.blur) f.push(`blur(${S.blur}px)`);
+  if(S.shOn) f.push(`drop-shadow(${S.shX}px ${S.shY}px ${S.shBlur}px ${S.shC})`);
+  return f.length?f.join(' '):'none';
+}
+function cssFilterLine(){ return 'filter: '+filterChain()+';'; }
+function artTransform(){ return `rotate(${S.rot}deg) scale(${S.artScale*(S.flipH?-1:1)}, ${S.artScale*(S.flipV?-1:1)})`; }
+
+/* tone-map <filter> — resample stops to a smooth per-channel table */
+function buildToneFilter(){
+  const defs=$('#fxdefs');
+  const N=33, R=[],G=[],B=[];
+  const stops=[...S.stops].map(s=>({c:s.c,p:s.p})); // equal-spaced positions
+  stops.forEach((s,i)=>s.p=i/(stops.length-1));
+  for(let i=0;i<N;i++){
+    const t=i/(N-1);
+    // find segment
+    let a=0; while(a<stops.length-2 && stops[a+1].p<t) a++;
+    const s0=stops[a], s1=stops[Math.min(a+1,stops.length-1)];
+    const span=(s1.p-s0.p)||1, k=clamp((t-s0.p)/span,0,1);
+    const c0=hex2rgb(s0.c), c1=hex2rgb(s1.c);
+    R.push(((c0[0]+(c1[0]-c0[0])*k)/255).toFixed(4));
+    G.push(((c0[1]+(c1[1]-c0[1])*k)/255).toFixed(4));
+    B.push(((c0[2]+(c1[2]-c0[2])*k)/255).toFixed(4));
+  }
+  defs.innerHTML =
+    `<filter id="tonemap" color-interpolation-filters="${S.cif}" x="-20%" y="-20%" width="140%" height="140%">`+
+    (S.desat?`<feColorMatrix type="saturate" values="0"/>`:``)+
+    `<feComponentTransfer>`+
+    `<feFuncR type="table" tableValues="${R.join(' ')}"/>`+
+    `<feFuncG type="table" tableValues="${G.join(' ')}"/>`+
+    `<feFuncB type="table" tableValues="${B.join(' ')}"/>`+
+    `</feComponentTransfer></filter>`;
+}
+
+/* vector strokes / fills via a scoped <style> injected into the svg */
+/* Vector strokes/fills + typography as ONE document-level stylesheet scoped to
+   `#art svg`. A <style> INSIDE an inline SVG is NOT scoped — it leaks to every
+   SVG in the document (UI chevrons, the hidden #fxdefs). Scoping via #art keeps
+   it on the artwork only; #artBase (the split "before") stays pristine. */
+function scopedCSSRules(A){
+  let css='';
+  if(S.vmode==='outline') css+=`${A} *{fill:none !important;stroke:${S.strokeColor} !important;stroke-width:${S.strokeW}px !important;}`;
+  else if(S.vmode==='fill') css+=`${A} *{stroke:none !important;}`;
+  else if(S.strokeOn) css+=`${A} [stroke],${A} path,${A} line,${A} rect,${A} circle,${A} ellipse,${A} polygon,${A} polyline{stroke-width:${S.strokeW}px !important;}`;
+  if(S.roundJoin) css+=`${A} *{stroke-linejoin:round !important;stroke-linecap:round !important;}`;
+  if(S.nonScaling) css+=`${A} *{vector-effect:non-scaling-stroke;}`;
+  const fam=S.font?`'${S.font}',`:'';
+  css+=`${A} text,${A} tspan{`+
+    (S.font?`font-family:${fam}sans-serif !important;`:'')+
+    `font-weight:${S.weight};letter-spacing:${S.tracking}px;`+
+    (S.transform!=='none'?`text-transform:${S.transform};`:'')+`}`;
+  return css;
+}
+function applyScopedCSS(){
+  let st=document.getElementById('art-scope');
+  if(!st){ st=document.createElement('style'); st.id='art-scope'; document.head.append(st); }
+  st.textContent=scopedCSSRules('#art svg');
+}
+/* size scale — multiply each text node's base size (keeps hierarchy). Skip bare
+   inheriting tspans so they scale via their (already-scaled) parent, not twice. */
+function applySizeScale(svg){
+  svg.querySelectorAll('text,tspan').forEach(t=>{
+    if(t.tagName.toLowerCase()==='tspan' && !t.hasAttribute('font-size') && !t.style.fontSize) return;
+    if(!baseSize.has(t)){ const fs=parseFloat(getComputedStyle(t).fontSize)||16; baseSize.set(t,fs); }
+    t.style.fontSize=(baseSize.get(t)*S.sizeScale)+'px';
+  });
+}
+
+/* ═══ VIEW — zoom / pan / split ══════════════════════════════════════ */
+function updateView(){
+  const t=`translate(${S.panx}px,${S.pany}px) scale(${S.zoom})`;
+  $('#artInner').style.transform=t; $('#baseInner').style.transform=t;
+  $('#stage').classList.toggle('split',S.split);
+  const vp=$('#viewport').getBoundingClientRect();
+  const dx=vp.width*S.splitPct;
+  $('#divider').style.left=dx+'px';
+  $('#artWrap').style.clipPath=S.split?`inset(0 ${vp.width-dx}px 0 0)`:'none';
+  const zp=Math.round(S.zoom*100)+'%';
+  const lv=$('#zlv'); if(lv)lv.textContent=zp;
+  const rz=$('#roZoom'); if(rz)rz.textContent=zp;   // keep the readout zoom in sync (cheap)
+  const pad=S.pad; $('#art').style.padding=$('#artBase').style.padding=pad+'px';
+}
+function fitView(){ S.zoom=1; S.panx=0; S.pany=0; updateView(); }
+function updateReadout(){
+  const ro=$('#readout'); ro.innerHTML='';
+  const g=(k,v)=>el('span',{class:'g'},el('span',{class:'k'},k),v);
+  ro.append(g('css','')); const cssv=el('span',{class:'cp'},filterChain()); cssv.onclick=()=>copy(cssFilterLine(),'CSS filter copied'); ro.lastChild.append(cssv);
+  const ramp=el('span',{class:'g'}); ramp.append(el('span',{class:'k'},'ramp'));
+  S.stops.forEach(s=>ramp.append(el('span',{class:'cc',style:`background:${s.c}`})));
+  ro.append(ramp);
+  if(SRC.type) ro.append(g('src',`${SRC.type} ${SRC.natW||''}×${SRC.natH||''}`));
+  const pc=SRC.svg?SRC.svg.querySelectorAll('path,rect,circle,ellipse,line,polygon,polyline').length:0;
+  if(pc) ro.append(g('nodes',String(pc)));
+  const zspan=el('span',{id:'roZoom'},Math.round(S.zoom*100)+'%'); ro.append(g('zoom',zspan));
+}
+
+/* HUD + interactions */
+function buildHUD(){
+  const hud=$('#hud'); hud.innerHTML='';
+  const mk=(t,fn,cls)=>{const b=el('button',{class:cls||''},t); b.onclick=fn; hud.append(b); return b;};
+  mk('−',()=>zoomTo(S.zoom/1.25));
+  const lv=mk(Math.round(S.zoom*100)+'%',fitView,'lv'); lv.id='zlv';
+  mk('+',()=>zoomTo(S.zoom*1.25));
+  mk('⊡',fitView).title='Fit 100%';
+}
+function zoomTo(z,cx,cy){
+  z=clamp(z,0.1,12);
+  // anchor is measured from the viewport CENTRE — same origin as `pan`
+  // (the .inner is centred and transform-origin is centre). Default 0 = centre.
+  cx=cx??0; cy=cy??0;
+  const k=z/S.zoom;
+  S.panx=cx-(cx-S.panx)*k;
+  S.pany=cy-(cy-S.pany)*k;
+  S.zoom=z; updateView(); scheduleSave();
+}
+function setupViewport(){
+  const vp=$('#viewport');
+  vp.addEventListener('wheel',e=>{ if(!SRC.type)return; e.preventDefault();
+    const r=vp.getBoundingClientRect();
+    const cx=(e.clientX-r.left)-r.width/2, cy=(e.clientY-r.top)-r.height/2;  // centre-origin cursor
+    const factor=e.deltaY<0?1.12:1/1.12; const z=clamp(S.zoom*factor,0.1,12);
+    const k=z/S.zoom; S.panx=cx-(cx-S.panx)*k; S.pany=cy-(cy-S.pany)*k; S.zoom=z; updateView(); scheduleSave();
+  },{passive:false});
+  let drag=null;
+  vp.addEventListener('pointerdown',e=>{
+    if(S.split){ const r=vp.getBoundingClientRect(); if(Math.abs((e.clientX-r.left)-r.width*S.splitPct)<16){ drag={mode:'split'}; vp.setPointerCapture(e.pointerId); return; } }
+    if(inspectOn) return;
+    drag={mode:'pan',x:e.clientX,y:e.clientY,px:S.panx,py:S.pany}; vp.classList.add('grabbing'); vp.setPointerCapture(e.pointerId);
+  });
+  vp.addEventListener('pointermove',e=>{
+    if(!drag)return; const r=vp.getBoundingClientRect();
+    if(drag.mode==='split'){ S.splitPct=clamp((e.clientX-r.left)/r.width,0,1); updateView(); }
+    else{ S.panx=drag.px+(e.clientX-drag.x); S.pany=drag.py+(e.clientY-drag.y); updateView(); }
+  });
+  const end=e=>{ if(drag){drag=null; vp.classList.remove('grabbing'); scheduleSave();} };
+  vp.addEventListener('pointerup',end); vp.addEventListener('pointercancel',end);
+  // drag & drop
+  ['dragover','dragenter'].forEach(ev=>vp.addEventListener(ev,e=>{e.preventDefault();$('#stage').classList.add('dropping');}));
+  ['dragleave','drop'].forEach(ev=>vp.addEventListener(ev,e=>{e.preventDefault();$('#stage').classList.remove('dropping');}));
+  vp.addEventListener('drop',e=>{ const f=e.dataTransfer.files[0]; if(f)loadFile(f); });
+  // inspect
+  vp.addEventListener('pointermove',inspectMove);
+  vp.addEventListener('pointerleave',()=>{$('#hoverbox').style.display='none';$('#tip').style.display='none';});
+  window.addEventListener('resize',()=>updateView());
+  // keyboard
+  window.addEventListener('keydown',e=>{
+    if((e.ctrlKey||e.metaKey)&&(e.key==='z'||e.key==='Z')){ e.preventDefault(); undo(); return; }
+    if(e.target.tagName==='INPUT'||e.target.tagName==='SELECT')return;
+    if(e.key==='f'){fitView();} else if(e.key==='+'||e.key==='='){zoomTo(S.zoom*1.25);} else if(e.key==='-'){zoomTo(S.zoom/1.25);}
+    else if(e.key==='i'){toggleInspect();} else if(e.key==='s'){toggleSplit();}
+  });
+}
+
+/* ═══ INSPECT ════════════════════════════════════════════════════════ */
+let inspectOn=false;
+function toggleInspect(){ inspectOn=!inspectOn; $('#inspectBtn').classList.toggle('on',inspectOn); $('#viewport').classList.toggle('inspect',inspectOn); if(!inspectOn){$('#hoverbox').style.display='none';$('#tip').style.display='none';} }
+function toggleSplit(){ S.split=!S.split; $('#splitBtn').classList.toggle('on',S.split); updateView(); scheduleSave(); }
+function inspectMove(e){
+  if(!inspectOn||!SRC.svg)return;
+  const svg=$('#art').querySelector('svg'); if(!svg)return;
+  const target=document.elementFromPoint(e.clientX,e.clientY);
+  if(!target||!svg.contains(target)||target===svg){ $('#hoverbox').style.display='none';$('#tip').style.display='none'; return; }
+  highlightEl(target);
+}
+function highlightEl(target){
+  const svg=$('#art').querySelector('svg'); if(!svg||!svg.contains(target))return;
+  const vpr=$('#viewport').getBoundingClientRect(); const r=target.getBoundingClientRect();
+  const hb=$('#hoverbox'); hb.style.display='block';
+  hb.style.left=(r.left-vpr.left)+'px'; hb.style.top=(r.top-vpr.top)+'px'; hb.style.width=r.width+'px'; hb.style.height=r.height+'px';
+  const tip=$('#tip'); const fill=target.getAttribute('fill')||getComputedStyle(target).fill||'—'; const stroke=target.getAttribute('stroke')||'—';
+  tip.innerHTML=`<b>&lt;${target.tagName}&gt;</b>`+(target.id?` #${target.id}`:'')+(target.getAttribute('class')?` .${target.getAttribute('class')}`:'')+`<br>fill ${fill} · stroke ${stroke}`;
+  tip.style.display='block';
+  let tx=r.left-vpr.left, ty=r.top-vpr.top-tip.offsetHeight-8; if(ty<4)ty=r.bottom-vpr.top+8;
+  tip.style.left=clamp(tx,4,vpr.width-tip.offsetWidth-4)+'px'; tip.style.top=ty+'px';
+}
+function buildElementList(){
+  const list=$('#elist'); if(!list)return; list.innerHTML='';
+  if(!SRC.svg){ list.append(el('div',{class:'eitem'},el('span',{class:'dsc'},'No SVG loaded'))); return; }
+  const nodes=[...SRC.svg.querySelectorAll('path,rect,circle,ellipse,line,polygon,polyline,text,g[id]')].slice(0,300);
+  nodes.forEach(n=>{
+    const fill=n.getAttribute('fill')||'';
+    const item=el('div',{class:'eitem'},
+      el('span',{class:'tag'},n.tagName),
+      el('span',{class:'sw2',style:`background:${fill||'transparent'}`}),
+      el('span',{class:'dsc'},(n.id?'#'+n.id:'')+(n.getAttribute('class')?' .'+n.getAttribute('class'):'')||n.getAttribute('d')?.slice(0,24)||''));
+    item.onmouseenter=()=>{ if(SRC.svg.contains(n)) highlightEl(n); };
+    item.onmouseleave=()=>{$('#hoverbox').style.display='none';$('#tip').style.display='none';};
+    item.onclick=()=>flashEl(n);
+    list.append(item);
+  });
+}
+function flashEl(n){ const o=n.style.outline; n.style.transition='opacity .1s'; let i=0; const iv=setInterval(()=>{ n.style.opacity=(i%2?1:0.2); if(++i>5){clearInterval(iv);n.style.opacity=1;} },110); highlightEl(n); }
+
+/* ═══ SOURCE LOADING ═════════════════════════════════════════════════ */
+function loadFile(f){
+  const isSvg=/svg/i.test(f.type)||/\.svg$/i.test(f.name);
+  const rd=new FileReader();
+  if(isSvg){ rd.onload=()=>setSVG(String(rd.result)); rd.readAsText(f); }
+  else{ rd.onload=()=>setRaster(String(rd.result)); rd.readAsDataURL(f); }
+}
+/* ── source history (undo) ──────────────────────────────────────────── */
+const history=[]; const HIST_MAX=30;
+function pushHistory(){
+  let snap=null;
+  if(SRC.type==='svg' && SRC.originalText) snap={type:'svg', data:SRC.originalText};
+  else if(SRC.type==='raster' && SRC.rasterURL) snap={type:'raster', data:SRC.rasterURL};
+  if(snap){ history.push(snap); if(history.length>HIST_MAX)history.shift(); }
+  updateUndoBtn();
+}
+function undo(){
+  if(!history.length){ toast('Nothing to undo'); return; }
+  const snap=history.pop();
+  if(snap.type==='svg') setSVG(snap.data,{push:false});
+  else setRaster(snap.data,{push:false});
+  updateUndoBtn(); toast('Undo');
+}
+function updateUndoBtn(){ const b=$('#undoBtn'); if(b) b.disabled=history.length===0; }
+
+function setSVG(text,opts){
+  const doc=new DOMParser().parseFromString(text,'image/svg+xml');
+  if(doc.querySelector('parsererror')){ toast('SVG failed to parse (malformed XML)'); return; }
+  const svg=doc.querySelector('svg');
+  if(!svg){ toast('Not a valid SVG'); return; }
+  document.adoptNode(svg);
+  if(!opts||opts.push!==false) pushHistory();   // capture the outgoing source before replacing
+  SRC={type:'svg', svg, raster:null, natW:0, natH:0, originalText:text};
+  measureSVG(svg);
+  const art=$('#art'); art.innerHTML=''; art.append(svg);
+  if(!SRC.natW||!SRC.natH){ // recover true extent (%/em/no-size SVGs) from the live DOM
+    try{ const bb=svg.getBBox(); SRC.natW=SRC.natW||Math.round(bb.width); SRC.natH=SRC.natH||Math.round(bb.height); }catch{}
+    SRC.natW=SRC.natW||300; SRC.natH=SRC.natH||150;
+  }
+  const clone=svg.cloneNode(true); const base=$('#artBase'); base.innerHTML=''; base.append(clone);
+  baseSize=new Map();
+  applySvgSizing();
+  $('#empty').style.display='none';
+  srcChip(); refreshMeta(); buildElementList();
+  fitView(); apply();
+  if(S.font) loadSvgFont(S.font);
+}
+const pxLen=v=>{ const m=/^\s*([\d.]+)\s*(px)?\s*$/.exec(v||''); return m?parseFloat(m[1]):0; };  // px/unit-less only
+function measureSVG(svg){
+  let w=0,h=0; const vb=svg.getAttribute('viewBox');
+  if(vb){ const a=vb.trim().split(/[ ,]+/).map(Number); w=a[2]||0; h=a[3]||0; }
+  w=w||pxLen(svg.getAttribute('width')); h=h||pxLen(svg.getAttribute('height'));
+  SRC.natW=Math.round(w)||0; SRC.natH=Math.round(h)||0;   // 0 = unknown; setSVG recovers via getBBox
+}
+function applySvgSizing(){
+  const svg=SRC.svg; if(!svg)return;
+  if(!svg.getAttribute('viewBox')){ svg.setAttribute('viewBox',`0 0 ${SRC.natW||300} ${SRC.natH||150}`); }
+  const apply1=s=>{ if(S.stripSize){ s.removeAttribute('width'); s.removeAttribute('height'); s.style.width='min(74vw,'+ (SRC.natW*1.4)+'px)'; s.style.height='auto'; }
+    else{ s.setAttribute('width',SRC.natW); s.setAttribute('height',SRC.natH); s.style.width=''; s.style.height=''; } s.setAttribute('preserveAspectRatio','xMidYMid meet'); };
+  apply1(svg); const b=$('#artBase').querySelector('svg'); if(b)apply1(b);
+}
+function setRaster(dataURL,opts){
+  const img=new Image();
+  img.onload=()=>{
+    if(!opts||opts.push!==false) pushHistory();   // capture the outgoing source before replacing
+    SRC={type:'raster', svg:null, raster:img, natW:img.naturalWidth, natH:img.naturalHeight, rasterURL:dataURL};
+    // show the raster as an <img> wrapped in svg-less preview until traced
+    const art=$('#art'); art.innerHTML=''; const wrap=el('div',{}); const im=el('img',{src:dataURL}); im.style.maxWidth='min(74vw,'+(img.naturalWidth)+'px)'; im.style.display='block'; wrap.append(im); art.append(wrap);
+    $('#artBase').innerHTML='';
+    $('#empty').style.display='none';
+    srcChip(); refreshMeta(); openSection('trace'); fitView();
+    $('#art').querySelector('img').style.filter='none';
+    toast('Raster loaded — open Image Trace to vectorise'); traceNote();
+    updateReadout();
+  };
+  img.src=dataURL;
+}
+function traceNote(){ const n=$('#traceNote'); if(n){ n.textContent = SRC.type==='raster' ? 'Ready. Tune the mode + threshold, then Trace to SVG.' : 'Load a PNG/JPG to enable.'; } const go=$('#traceGo'); if(go)go.disabled=SRC.type!=='raster'; }
+function srcChip(){ const c=$('#srcchip'); c.innerHTML = SRC.type? `<b>${SRC.type.toUpperCase()}</b> ${SRC.natW}×${SRC.natH}` : 'no source'; }
+function openSection(id){ openSet.add(id); const acc=[...document.querySelectorAll('.acc')].find(a=>a.dataset.id===id); if(acc)acc.classList.add('open'); LS.set('svgstudio-open',JSON.stringify([...openSet])); }
+
+function loadSvgFont(name){ if(!name){ $('#svgfont').href=''; return; } const spec=Object.values(FONTS).flat().find(f=>f[0]===name); const w=spec?spec[1]:'400;700'; $('#svgfont').href=`https://fonts.googleapis.com/css2?family=${name.replace(/ /g,'+')}:wght@${w}&display=swap`; }
+
+/* samples */
+function loadSampleSVG(){ fetch('./sample.svg').then(r=>r.ok?r.text():Promise.reject()).then(setSVG).catch(()=>setSVG(FALLBACK_SVG)); }
+function loadSampleRaster(){ setRaster(makeSampleRaster()); }
+function pasteSVG(){ const t=prompt('Paste SVG markup:'); if(t&&t.includes('<svg'))setSVG(t); }
+function makeSampleRaster(){
+  const c=el('canvas'); c.width=520; c.height=360; const x=c.getContext('2d');
+  const g=x.createLinearGradient(0,0,520,360); g.addColorStop(0,'#012836'); g.addColorStop(1,'#5fb2d3'); x.fillStyle=g; x.fillRect(0,0,520,360);
+  x.fillStyle='#eef6f9'; x.beginPath(); x.arc(160,150,90,0,7); x.fill();
+  x.fillStyle='#001e29'; x.beginPath(); x.moveTo(300,60); x.lineTo(470,300); x.lineTo(300,300); x.closePath(); x.fill();
+  x.strokeStyle='#eef6f9'; x.lineWidth=14; x.beginPath(); x.arc(360,140,58,0,7); x.stroke();
+  x.fillStyle='#ff6a1f'; x.font='bold 64px sans-serif'; x.fillText('TRACE',120,320);
+  return c.toDataURL('image/png');
+}
+// Inline generic diagram — used when ./sample.svg can't be fetched (e.g. the
+// file:// preview, where fetch of a sibling file is blocked). Keeps the studio
+// looking right without the server.
+const FALLBACK_SVG=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 400" font-family="sans-serif"><rect width="640" height="400" fill="#f4f4f4"/><g stroke="#9aa0a6" stroke-width="2" fill="none"><path d="M160 120 H240"/><path d="M400 120 H480"/><path d="M120 160 V240"/><path d="M520 160 V240"/><path d="M200 280 H440" stroke-dasharray="6 6"/></g><g stroke="#5f6368" stroke-width="1.5"><rect x="60" y="90" width="100" height="60" rx="10" fill="#d9dce0"/><rect x="240" y="90" width="160" height="60" rx="10" fill="#b7bcc2"/><rect x="480" y="90" width="100" height="60" rx="10" fill="#d9dce0"/><rect x="70" y="240" width="120" height="56" rx="10" fill="#8b9198"/><rect x="450" y="240" width="120" height="56" rx="10" fill="#8b9198"/></g><circle cx="320" cy="270" r="34" fill="#6b7178" stroke="#3c4043" stroke-width="2"/><path d="M320 250 l16 28 h-32 z" fill="#f4f4f4"/><g fill="#2b2f33" font-size="14" text-anchor="middle"><text x="110" y="125">Client</text><text x="320" y="125">Gateway</text><text x="530" y="125">CDN</text><text x="130" y="273">Service A</text><text x="510" y="273">Service B</text><text x="320" y="345" font-size="12" fill="#5f6368">shared queue</text></g></svg>`;
+
+/* ═══ IMAGE TRACE (raster → vector) ══════════════════════════════════ */
+function runTrace(){
+  if(SRC.type!=='raster'){ toast('Load a raster first'); return; }
+  busy(true,'tracing…');
+  setTimeout(()=>{ try{ const svg=traceRaster(SRC.raster); setSVG(svg); toast('Traced → '+(svg.match(/<path/g)||[]).length+' paths'); }catch(err){ console.error(err); toast('Trace failed'); } finally{ busy(false); } }, 30);
+}
+function busy(on,msg){ const b=$('#busy'); b.classList.toggle('on',on); if(msg)$('#busymsg').textContent=msg; }
+
+/* Raster → vector. Mirrors trace-core.mjs (the canonical/headless engine) inline
+   so the tool stays a single self-contained file: SUPERSAMPLE for sub-pixel edges
+   → alpha-aware nearest-palette layers (k-means-refined) → interpolated iso-contours
+   → corner-preserving + faired curve fit. See references/tri-tone-and-trace.md. */
+function traceRaster(img){
+  let w=img.naturalWidth||img.width, h=img.naturalHeight||img.height;
+  const sc=Math.min(2, Math.max(1, (S.traceRes||900)/Math.max(w,h)));   // supersample (2x = quality sweet spot)
+  w=Math.max(2,Math.round(w*sc)); h=Math.max(2,Math.round(h*sc));
+  const cv=el('canvas'); cv.width=w; cv.height=h; const ctx=cv.getContext('2d',{willReadFrequently:true});
+  ctx.imageSmoothingEnabled=true; ctx.imageSmoothingQuality='high';
+  ctx.drawImage(img,0,0,w,h);
+  let idata=ctx.getImageData(0,0,w,h);
+  if(S.preblur>0) idata=boxBlur(idata,w,h,Math.round(S.preblur));
+  const px=idata.data, N=w*h;
+  const lum=new Float32Array(N), opaque=new Uint8Array(N);
+  for(let i=0,j=0;i<N;i++,j+=4){ lum[i]=0.299*px[j]+0.587*px[j+1]+0.114*px[j+2]; opaque[i]=px[j+3]>=128?1:0; }
+  const layers=traceLayers(px,lum,opaque,w,h);
+  const eps=(S.detail||0.7)*sc, minA=(S.despeckle||0)*sc*sc;   // detail/area are native-relative → scale to working px
+  let body='';
+  for(const L of layers){
+    let pxArea=0; for(let i=0;i<N;i++) if(L.field[i]>=0.5) pxArea++;
+    if(pxArea<minA) continue;
+    const contours=isoContours(L.field,w,h,0.5);
+    let d='';
+    for(const poly of contours){
+      if(polyArea(poly)<0.5) continue;
+      const pts=simplify(poly,eps);
+      if(pts.length<3) continue;
+      d+=fitPath(pts,S.smooth,40,1,1.5)+' ';      // corner 40°, 1 fairing pass, Bézier fit err 1.5px
+    }
+    if(d) body+=`<path d="${d.trim()}" fill="${L.fill}" fill-rule="evenodd"/>`;
+  }
+  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">${body}</svg>`;
+}
+function traceLayers(px,lum,opaque,w,h){
+  const N=w*h, layers=[];
+  if(S.traceMode==='bw'){
+    const th=S.threshold, field=new Float32Array(N);
+    for(let i=0;i<N;i++) if(opaque[i]) field[i]=(S.traceInvert?lum[i]>th:lum[i]<th)?1:0;
+    layers.push({field,fill:S.traceInvert?'#ffffff':'#111111'});
+  } else if(S.traceMode==='poster'){
+    const L=Math.max(2,Math.round(S.levels)||4);   // ≥2, never NaN
+    for(let k=1;k<L;k++){ const th=255*k/L, field=new Float32Array(N);
+      for(let i=0;i<N;i++){ if(!opaque[i])continue; let v=lum[i]; if(S.traceInvert)v=255-v; field[i]=v<th?1:0; }
+      const g=Math.round(255*(k-1)/(L-1)); layers.push({field,fill:rgb2hex(g,g,g)}); }
+    layers.reverse();
+  } else { // color — alpha-aware nearest-palette assignment (k-means refined)
+    let pal=medianCut(px,opaque,w,h,S.colors);
+    pal=kmeansRefine(px,opaque,w,h,pal,5);
+    { const preArea=new Array(pal.length).fill(0);   // merge keeps the DOMINANT cluster member
+      for(let i=0,j=0;i<N;i++,j+=4){ if(!opaque[i])continue; let b=0,bd=1e12;
+        for(let p=0;p<pal.length;p++){ const dr=px[j]-pal[p][0],dg=px[j+1]-pal[p][1],dl=px[j+2]-pal[p][2]; const dd=dr*dr+dg*dg+dl*dl; if(dd<bd){bd=dd;b=p;} } preArea[b]++; }
+      pal=mergePalette(pal,48,preArea); }   // collapse near-duplicate colours (mono over-segmentation + AA fringe)
+    const assign=new Int16Array(N), area=new Array(pal.length).fill(0);
+    for(let i=0,j=0;i<N;i++,j+=4){ if(!opaque[i]){assign[i]=-1;continue;} let best=0,bd=1e12;
+      for(let p=0;p<pal.length;p++){ const dr=px[j]-pal[p][0],dg=px[j+1]-pal[p][1],dl=px[j+2]-pal[p][2]; const dd=dr*dr+dg*dg+dl*dl; if(dd<bd){bd=dd;best=p;} }
+      assign[i]=best; area[best]++; }
+    const order=pal.map((_,i)=>i).sort((a,b)=>area[b]-area[a]);
+    for(const p of order){ if(area[p]===0)continue; if(p!==order[0]&&area[p]<(S.despeckle||0))continue;   // never drop the dominant colour
+      const field=new Float32Array(N);
+      for(let i=0;i<N;i++) if(assign[i]===p) field[i]=1; layers.push({field,fill:rgb2hex(...pal[p])}); }
+  }
+  return layers;
+}
+function boxBlur(imgData,w,h,r){
+  if(r<1)return imgData; const src=imgData.data; const out=new Uint8ClampedArray(src.length);
+  const tmp=new Uint8ClampedArray(src.length);
+  const pass=(inp,outp,horiz)=>{ for(let y=0;y<h;y++)for(let x=0;x<w;x++){ let R=0,G=0,B=0,A=0,n=0; for(let k=-r;k<=r;k++){ const xx=horiz?x+k:x, yy=horiz?y:y+k; if(xx<0||yy<0||xx>=w||yy>=h)continue; const idx=(yy*w+xx)*4; R+=inp[idx];G+=inp[idx+1];B+=inp[idx+2];A+=inp[idx+3];n++; } const o=(y*w+x)*4; outp[o]=R/n;outp[o+1]=G/n;outp[o+2]=B/n;outp[o+3]=A/n; } };
+  pass(src,tmp,true); pass(tmp,out,false);
+  return new ImageData(out,w,h);
+}
+/* interpolated iso-contours — marching squares with sub-pixel edge crossings */
+function isoContours(field,w,h,iso){
+  const val=(x,y)=>(x<0||y<0||x>=w||y>=h)?-1e6:field[y*w+x];
+  const lerp=(p,q,a,b)=>{ let t=(iso-a)/((b-a)||1e-9); if(t<0)t=0; else if(t>1)t=1; return [p[0]+(q[0]-p[0])*t, p[1]+(q[1]-p[1])*t]; };
+  const segs=[];
+  for(let y=-1;y<h;y++)for(let x=-1;x<w;x++){
+    const a=val(x,y),b=val(x+1,y),c=val(x+1,y+1),d=val(x,y+1);
+    const ci=(a>iso?8:0)|(b>iso?4:0)|(c>iso?2:0)|(d>iso?1:0);
+    if(ci===0||ci===15)continue;
+    const TL=[x,y],TR=[x+1,y],BR=[x+1,y+1],BL=[x,y+1];
+    const T=()=>lerp(TL,TR,a,b),R=()=>lerp(TR,BR,b,c),B=()=>lerp(BL,BR,d,c),L=()=>lerp(TL,BL,a,d);
+    const add=(p,q)=>{ if(Math.abs(p[0]-q[0])>1e-9||Math.abs(p[1]-q[1])>1e-9) segs.push([p[0],p[1],q[0],q[1]]); };  // skip degenerate corner segs
+    switch(ci){
+      case 1: add(B(),L()); break; case 2: add(R(),B()); break; case 3: add(R(),L()); break;
+      case 4: add(T(),R()); break; case 5: add(T(),R()); add(B(),L()); break; case 6: add(T(),B()); break;
+      case 7: add(T(),L()); break; case 8: add(L(),T()); break; case 9: add(B(),T()); break;
+      case 10: add(L(),T()); add(R(),B()); break; case 11: add(R(),T()); break;
+      case 12: add(L(),R()); break; case 13: add(B(),R()); break; case 14: add(L(),B()); break;
+    }
+  }
+  const key=(x,y)=>Math.round(x*64)+','+Math.round(y*64); const map=new Map();
+  segs.forEach((s,i)=>{ const k=key(s[0],s[1]); if(!map.has(k))map.set(k,[]); map.get(k).push(i); });
+  const used=new Uint8Array(segs.length); const contours=[];
+  for(let seed=0;seed<segs.length;seed++){
+    if(used[seed])continue; const poly=[]; let cur=seed; let guard=0;
+    while(cur!==-1 && !used[cur] && guard++<segs.length+5){
+      used[cur]=1; const s=segs[cur]; poly.push([s[0],s[1]]);
+      const cand=map.get(key(s[2],s[3])); let nxt=-1;
+      if(cand){
+        if(poly.length>=2 && cand.includes(seed)){ poly.push(poly[0].slice()); break; }   // close on segment identity
+        for(const ci of cand){ if(!used[ci]){nxt=ci;break;} }
+      }
+      if(nxt===-1){ poly.push([s[2],s[3]]); break; }
+      cur=nxt;
+    }
+    if(poly.length>=3)contours.push(poly);
+  }
+  return contours;
+}
+/* k-means (Lloyd) refinement — snaps median-cut seeds onto the true flat colours */
+function kmeansRefine(px,opaque,w,h,pal,iters){
+  const N=w*h, step=Math.max(1,Math.floor(N/30000)), K=pal.length, cur=pal.map(c=>c.slice());
+  for(let it=0;it<iters;it++){
+    const sum=Array.from({length:K},()=>[0,0,0,0]);
+    for(let i=0;i<N;i+=step){ if(!opaque[i])continue; const j=i*4,r=px[j],g=px[j+1],b=px[j+2];
+      let best=0,bd=1e12; for(let p=0;p<K;p++){ const dr=r-cur[p][0],dg=g-cur[p][1],db=b-cur[p][2]; const dd=dr*dr+dg*dg+db*db; if(dd<bd){bd=dd;best=p;} }
+      const s=sum[best]; s[0]+=r; s[1]+=g; s[2]+=b; s[3]++; }
+    let moved=0;
+    for(let p=0;p<K;p++){ if(sum[p][3]===0)continue; const nr=sum[p][0]/sum[p][3],ng=sum[p][1]/sum[p][3],nb=sum[p][2]/sum[p][3];
+      moved+=Math.abs(nr-cur[p][0])+Math.abs(ng-cur[p][1])+Math.abs(nb-cur[p][2]); cur[p]=[nr,ng,nb]; }
+    if(moved<1)break;
+  }
+  return cur.map(c=>[Math.round(c[0]),Math.round(c[1]),Math.round(c[2])]);
+}
+function polyArea(p){ let a=0; for(let i=0,n=p.length;i<n;i++){ const [x1,y1]=p[i],[x2,y2]=p[(i+1)%n]; a+=x1*y2-x2*y1; } return Math.abs(a/2); }
+/* Douglas–Peucker for CLOSED rings. isoContours emits closed contours
+   (first point == last), which degenerates open-polyline DP (zero-length chord).
+   Seed two anchors — index 0 and the farthest point from it — then DP each half
+   around the ring. Returns a clean ring (no duplicate closing point). */
+function simplify(pts,eps){
+  if(eps<=0) return pts;
+  let ring=pts;
+  if(ring.length>1 && Math.abs(ring[0][0]-ring[ring.length-1][0])<1e-6 && Math.abs(ring[0][1]-ring[ring.length-1][1])<1e-6)
+    ring=ring.slice(0,-1);
+  const n=ring.length;
+  if(n<4) return ring;
+  const at=i=>ring[i%n];
+  let far=0,dm=-1;
+  for(let i=1;i<n;i++){ const dx=ring[i][0]-ring[0][0], dy=ring[i][1]-ring[0][1]; const d=dx*dx+dy*dy; if(d>dm){dm=d;far=i;} }
+  const keep=new Uint8Array(n); keep[0]=1; keep[far]=1;
+  const dp=(a,b)=>{ const stack=[[a,b]];
+    while(stack.length){ const [s,e]=stack.pop(); if(e<=s+1)continue;
+      const A=at(s),B=at(e); const dx=B[0]-A[0],dy=B[1]-A[1]; const len=Math.hypot(dx,dy)||1;
+      let dmax=0,idx=-1;
+      for(let i=s+1;i<e;i++){ const P=at(i); const d=Math.abs((P[0]-A[0])*dy-(P[1]-A[1])*dx)/len; if(d>dmax){dmax=d;idx=i;} }
+      if(dmax>eps&&idx>-1){ keep[idx%n]=1; stack.push([s,idx],[idx,e]); }
+    }
+  };
+  dp(0,far); dp(far,n);   // n wraps to ring[0] via at()
+  const res=[]; for(let i=0;i<n;i++) if(keep[i]) res.push(ring[i]);
+  return res;
+}
+function polyPath(pts){ let d='M'+pts[0][0].toFixed(1)+' '+pts[0][1].toFixed(1); for(let i=1;i<pts.length;i++)d+='L'+pts[i][0].toFixed(1)+' '+pts[i][1].toFixed(1); return d+'Z'; }
+/* corner-split + least-squares Bézier fit (Schneider): split the ring at sharp
+   corners, fair curve vertices, then fit each smooth span with the fewest cubic
+   Béziers within `fitErr` px — clean curves, crisp corners. Same as trace-core. */
+function fitPath(pts,smooth,cornerDeg,fair,fitErr){
+  let n=pts.length; if(n<3)return polyPath(pts);
+  const cc=Math.cos((cornerDeg||42)*Math.PI/180); const corner=new Array(n);
+  for(let i=0;i<n;i++){ const p0=pts[(i-1+n)%n],p1=pts[i],p2=pts[(i+1)%n];
+    const ax=p1[0]-p0[0],ay=p1[1]-p0[1],bx=p2[0]-p1[0],by=p2[1]-p1[1];
+    const la=Math.hypot(ax,ay)||1,lb=Math.hypot(bx,by)||1; corner[i]=(ax*bx+ay*by)/(la*lb)<cc; }
+  if(fair>0){ let c=pts.map(p=>p.slice());
+    for(let it=0;it<fair;it++){ const nx=c.map(p=>p.slice());
+      for(let i=0;i<n;i++){ if(corner[i])continue; const a=c[(i-1+n)%n],b=c[(i+1)%n];
+        nx[i][0]=c[i][0]+0.5*((a[0]+b[0])/2-c[i][0]); nx[i][1]=c[i][1]+0.5*((a[1]+b[1])/2-c[i][1]); } c=nx; }
+    pts=c; }
+  let breaks=[]; for(let i=0;i<n;i++) if(corner[i]) breaks.push(i);
+  if(breaks.length===0){ let far=0,dm=-1; for(let i=1;i<n;i++){const dx=pts[i][0]-pts[0][0],dy=pts[i][1]-pts[0][1];const d=dx*dx+dy*dy;if(d>dm){dm=d;far=i;}} breaks=[0,far].sort((a,b)=>a-b); }
+  else if(breaks.length===1){ const c0=breaks[0]; let far=c0,dm=-1; for(let k=1;k<n;k++){const i=(c0+k)%n;const dx=pts[i][0]-pts[c0][0],dy=pts[i][1]-pts[c0][1];const d=dx*dx+dy*dy;if(d>dm){dm=d;far=i;}} breaks=[c0,far].sort((a,b)=>a-b); }
+  const errSq=Math.pow((fitErr||1.5)*(0.6+(smooth||0)),2), F=v=>v.toFixed(1);
+  let d='M'+F(pts[breaks[0]][0])+' '+F(pts[breaks[0]][1]);
+  for(let b=0;b<breaks.length;b++){ const a=breaks[b], e=breaks[(b+1)%breaks.length];
+    const span=[]; let i=a; while(true){ span.push(pts[i]); if(i===e)break; i=(i+1)%n; }
+    if(span.length===2){ d+='L'+F(span[1][0])+' '+F(span[1][1]); continue; }
+    for(const bz of fitCurve(span,errSq)) d+=`C${F(bz[1][0])} ${F(bz[1][1])} ${F(bz[2][0])} ${F(bz[2][1])} ${F(bz[3][0])} ${F(bz[3][1])}`; }
+  return d+'Z';
+}
+const _vs=(a,b)=>[a[0]-b[0],a[1]-b[1]],_va=(a,b)=>[a[0]+b[0],a[1]+b[1]],_vk=(a,s)=>[a[0]*s,a[1]*s],_vd=(a,b)=>a[0]*b[0]+a[1]*b[1],_vl=a=>Math.hypot(a[0],a[1]),_vn=a=>{const l=_vl(a)||1;return[a[0]/l,a[1]/l];};
+const _bz=(c,t)=>{let p=c.map(v=>v.slice());for(let r=1;r<p.length;r++)for(let i=0;i<p.length-r;i++)p[i]=[p[i][0]+(p[i+1][0]-p[i][0])*t,p[i][1]+(p[i+1][1]-p[i][1])*t];return p[0];};
+function fitCurve(points,errSq){ const n=points.length; return fitCubic(points,0,n-1,_vn(_vs(points[1],points[0])),_vn(_vs(points[n-2],points[n-1])),errSq,0); }
+function fitCubic(points,first,last,tH1,tH2,errSq,depth){
+  const nPts=last-first+1;
+  if(nPts===2){ const dist=_vl(_vs(points[last],points[first]))/3; return [[points[first],_va(points[first],_vk(tH1,dist)),_va(points[last],_vk(tH2,dist)),points[last]]]; }
+  let u=_chord(points,first,last); let curve=_genBez(points,first,last,u,tH1,tH2);
+  let m=_maxErr(points,first,last,curve,u);
+  if(m.err<errSq) return [curve];
+  if(m.err<errSq*16 && depth<20){ for(let i=0;i<4;i++){ const up=u.map((ui,k)=>_newton(curve,points[first+k],ui)); curve=_genBez(points,first,last,up,tH1,tH2); m=_maxErr(points,first,last,curve,up); if(m.err<errSq)return [curve]; u=up; } }
+  if(m.split<=first||m.split>=last||depth>22) return [curve];
+  const tHC=_vn(_vs(points[m.split-1],points[m.split+1]));
+  return fitCubic(points,first,m.split,tH1,tHC,errSq,depth+1).concat(fitCubic(points,m.split,last,[-tHC[0],-tHC[1]],tH2,errSq,depth+1));
+}
+function _chord(points,first,last){ const u=[0]; for(let i=first+1;i<=last;i++)u.push(u[u.length-1]+_vl(_vs(points[i],points[i-1]))); const tot=u[u.length-1]||1; return u.map(x=>x/tot); }
+function _genBez(points,first,last,uP,tH1,tH2){
+  const nPts=last-first+1,A=[]; for(let i=0;i<nPts;i++){const u=uP[i];A.push([_vk(tH1,3*u*(1-u)*(1-u)),_vk(tH2,3*u*u*(1-u))]);}
+  let C00=0,C01=0,C11=0,X0=0,X1=0; const p0=points[first],p3=points[last];
+  for(let i=0;i<nPts;i++){ const u=uP[i],b0=(1-u)**3,b1=3*u*(1-u)**2,b2=3*u*u*(1-u),b3=u**3;
+    C00+=_vd(A[i][0],A[i][0]);C01+=_vd(A[i][0],A[i][1]);C11+=_vd(A[i][1],A[i][1]);
+    const tmp=_vs(points[first+i],[p0[0]*(b0+b1)+p3[0]*(b2+b3),p0[1]*(b0+b1)+p3[1]*(b2+b3)]); X0+=_vd(A[i][0],tmp);X1+=_vd(A[i][1],tmp); }
+  const dCC=C00*C11-C01*C01,dCX=C00*X1-C01*X0,dXC=X0*C11-X1*C01; let aL=dCC===0?0:dXC/dCC,aR=dCC===0?0:dCX/dCC;
+  const segLen=_vl(_vs(p3,p0)),eps=1e-6*segLen; if(aL<eps||aR<eps){const dist=segLen/3;aL=dist;aR=dist;}
+  return [p0,_va(p0,_vk(tH1,aL)),_va(p3,_vk(tH2,aR)),p3];
+}
+function _maxErr(points,first,last,curve,u){ let err=0,split=Math.floor((first+last)/2); for(let i=first+1;i<last;i++){ const P=_bz(curve,u[i-first]); const dx=P[0]-points[i][0],dy=P[1]-points[i][1],d=dx*dx+dy*dy; if(d>=err){err=d;split=i;} } return {err,split}; }
+function _newton(Q,P,u){ const Q1=[_vk(_vs(Q[1],Q[0]),3),_vk(_vs(Q[2],Q[1]),3),_vk(_vs(Q[3],Q[2]),3)]; const Q2=[_vk(_vs(Q1[1],Q1[0]),2),_vk(_vs(Q1[2],Q1[1]),2)]; const Qu=_bz(Q,u),Q1u=_bz(Q1,u),Q2u=_bz(Q2,u); const num=_vd(_vs(Qu,P),Q1u),den=_vd(Q1u,Q1u)+_vd(_vs(Qu,P),Q2u); if(!den)return u; const nu=u-num/den; return (nu<0||nu>1||Number.isNaN(nu))?u:nu; }
+/* merge palette entries within `thr` (Euclidean RGB) — collapses near-duplicate
+   colours so a near-monochrome logo doesn't split into overlapping tint layers */
+function mergePalette(pal,thr,areas){
+  if(!thr||thr<=0)return pal; const keep=pal.map(()=>true), out=[];
+  for(let i=0;i<pal.length;i++){ if(!keep[i])continue; let rep=i,repA=areas?areas[i]:0;
+    for(let j=i+1;j<pal.length;j++){ if(!keep[j])continue;
+      const dr=pal[i][0]-pal[j][0],dg=pal[i][1]-pal[j][1],db=pal[i][2]-pal[j][2];
+      if(dr*dr+dg*dg+db*db<thr*thr){ keep[j]=false; if(areas&&areas[j]>repA){repA=areas[j];rep=j;} } }
+    out.push(pal[rep]); }   // keep the dominant member of each cluster
+  return out;
+}
+/* median-cut colour quantization (alpha-aware — samples opaque pixels only) */
+function medianCut(px,opaque,w,h,K){
+  const step=Math.max(1,Math.floor((w*h)/20000)); const pts=[];
+  for(let i=0;i<w*h;i+=step){ if(!opaque[i])continue; const j=i*4; pts.push([px[j],px[j+1],px[j+2]]); }
+  if(!pts.length)return [[0,0,0]];
+  let boxes=[pts];
+  while(boxes.length<K){ let bi=-1,br=-1; boxes.forEach((b,i)=>{ if(b.length<2)return; const r=chanRange(b); if(r.range>br){br=r.range;bi=i;} }); if(bi<0)break;
+    const b=boxes[bi]; const r=chanRange(b); b.sort((a,c)=>a[r.ch]-c[r.ch]); const m=b.length>>1; boxes.splice(bi,1,b.slice(0,m),b.slice(m)); }
+  return boxes.map(b=>{ const s=[0,0,0]; b.forEach(c=>{s[0]+=c[0];s[1]+=c[1];s[2]+=c[2];}); const n=b.length||1; return [Math.round(s[0]/n),Math.round(s[1]/n),Math.round(s[2]/n)]; });
+}
+function chanRange(b){ const mn=[255,255,255],mx=[0,0,0]; b.forEach(c=>{for(let k=0;k<3;k++){if(c[k]<mn[k])mn[k]=c[k];if(c[k]>mx[k])mx[k]=c[k];}}); const r=[mx[0]-mn[0],mx[1]-mn[1],mx[2]-mn[2]]; const ch=r.indexOf(Math.max(...r)); return {ch,range:r[ch]}; }
+
+/* palette from current artwork (rasterise stage → quantize) */
+function paletteFromArt(){
+  const svg=SRC.svg; if(!svg){ if(SRC.raster){ seedFromImage(SRC.raster); } else toast('Load artwork first'); return; }
+  const s=new XMLSerializer().serializeToString(svg);
+  const img=new Image(); img.onload=()=>seedFromImage(img);
+  img.onerror=()=>toast('Could not sample'); img.src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(s);
+}
+function seedFromImage(img){
+  const w=120,h=Math.max(1,Math.round(120*(img.naturalHeight||img.height)/(img.naturalWidth||img.width)));
+  const c=el('canvas'); c.width=w;c.height=h; const x=c.getContext('2d',{willReadFrequently:true}); x.drawImage(img,0,0,w,h);
+  const px=x.getImageData(0,0,w,h).data; const pal=medianCut(px,w,h,Math.max(3,S.stops.length));
+  pal.sort((a,b)=>(a[0]+a[1]+a[2])-(b[0]+b[1]+b[2])); // dark→light
+  S.stops=pal.slice(0,Math.max(3,S.stops.length)).map((c,i,arr)=>({c:rgb2hex(...c),p:i/(arr.length-1)}));
+  renderRail(); apply(); scheduleSave(); toast('Palette extracted');
+}
+
+/* ═══ EXPORT ═════════════════════════════════════════════════════════ */
+function exportSVGString(bake){
+  const svg=SRC.svg; if(!svg)return '';
+  const clone=svg.cloneNode(true);
+  // drop the fluid inline sizing we set for the stage; make the file self-sized
+  clone.style.removeProperty('width'); clone.style.removeProperty('height');
+  if(!clone.getAttribute('style')) clone.removeAttribute('style');
+  clone.setAttribute('width',SRC.natW); clone.setAttribute('height',SRC.natH);
+  clone.setAttribute('xmlns','http://www.w3.org/2000/svg');
+  // bake the current stroke/fill + typography look, scoped to a UNIQUE id so it
+  // cannot leak if the exported file is later inlined into a host page.
+  const id = clone.id || (clone.id='svg-studio-'+Math.random().toString(36).slice(2,8));
+  const rules=scopedCSSRules('#'+id);
+  if(rules.trim()){ const st=document.createElementNS('http://www.w3.org/2000/svg','style'); st.textContent=rules; clone.prepend(st); }
+  if(bake&&S.toneOn){
+    buildToneFilter(); const filt=$('#tonemap').cloneNode(true);
+    let defs=clone.querySelector('defs'); if(!defs){ defs=document.createElementNS('http://www.w3.org/2000/svg','defs'); clone.prepend(defs);} defs.append(filt);
+    // wrap children (except defs/style) in a tone-map filtered group
+    const g=document.createElementNS('http://www.w3.org/2000/svg','g'); g.setAttribute('filter','url(#tonemap)');
+    [...clone.childNodes].forEach(n=>{ if(n!==defs && !(n.nodeType===1 && /^(style|defs)$/i.test(n.tagName))) g.append(n); });
+    clone.append(g);
+  }
+  return '<?xml version="1.0" encoding="UTF-8"?>\n'+new XMLSerializer().serializeToString(clone);
+}
+function exportPNG(scale){
+  const svg=SRC.svg; if(!svg){ toast('Load an SVG (or trace a raster) first'); return; }
+  busy(true,'rendering png…');
+  const str=exportSVGString(true);
+  const img=new Image();
+  img.onload=()=>{
+    const W=Math.max(1,Math.round(SRC.natW*scale)), H=Math.max(1,Math.round(SRC.natH*scale));
+    const c=el('canvas'); c.width=W; c.height=H; const x=c.getContext('2d');
+    if(S.bg==='solid'){ x.fillStyle=S.bgColor; x.fillRect(0,0,W,H); }
+    else if(S.bg==='light'){ x.fillStyle='#ffffff'; x.fillRect(0,0,W,H); }
+    else if(S.bg==='dark'){ x.fillStyle='#111214'; x.fillRect(0,0,W,H); }
+    // photographic chain via canvas filter (tone-map already baked into the svg)
+    const css=[]; if(S.sat!==1)css.push(`saturate(${S.sat})`); if(S.con!==1)css.push(`contrast(${S.con})`); if(S.bri!==1)css.push(`brightness(${S.bri})`); if(S.hue)css.push(`hue-rotate(${S.hue}deg)`); if(S.sep)css.push(`sepia(${S.sep})`); if(S.gray)css.push(`grayscale(${S.gray})`); if(S.inv)css.push(`invert(${S.inv})`); if(S.blur)css.push(`blur(${S.blur*scale}px)`); if(S.opa!==1)css.push(`opacity(${S.opa})`);
+    try{ x.filter=css.length?css.join(' '):'none'; }catch{}
+    x.drawImage(img,0,0,W,H);
+    c.toBlob(b=>{ downloadBlob(b,'svg-studio@'+scale+'x.png'); busy(false); toast('PNG @'+scale+'× downloaded'); },'image/png');
+  };
+  img.onerror=()=>{ busy(false); toast('PNG render failed (tainted?)'); };
+  img.src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(str);
+}
+function tokensString(){ const t={ramp:S.stops.map(s=>s.c),filter:filterChain(),saturate:S.sat,contrast:S.con,brightness:S.bri}; return JSON.stringify(t,null,2); }
+function reactSnippet(){
+  buildToneFilter();
+  const rest=filterChain().replace('url(#tonemap) ','').replace('url(#tonemap)','').trim();
+  const cssRest = rest==='none' ? '' : rest;
+  const filterVal=[S.toneOn?'url(#tonemap)':'', cssRest].filter(Boolean).join(' ');
+  const feR=$('#tonemap feFuncR')?.getAttribute('tableValues')||'';
+  const feG=$('#tonemap feFuncG')?.getAttribute('tableValues')||'';
+  const feB=$('#tonemap feFuncB')?.getAttribute('tableValues')||'';
+  return `// Inject this <filter> ONCE so url(#tonemap) resolves (e.g. an inline <svg> defs).
+// Rebuild the tableValues in JS from your theme tokens on theme change — filter
+// primitives can't read CSS vars. Render the target SVG INLINE (an <img> sandboxes it).
+//
+// <svg width="0" height="0" aria-hidden="true"><defs>
+//   <filter id="tonemap" color-interpolation-filters="${S.cif}">
+//     ${S.desat?'<feColorMatrix type="saturate" values="0"/>':''}
+//     <feComponentTransfer>
+//       <feFuncR type="table" tableValues="${feR}"/>
+//       <feFuncG type="table" tableValues="${feG}"/>
+//       <feFuncB type="table" tableValues="${feB}"/>
+//     </feComponentTransfer>
+//   </filter>
+// </defs></svg>
+
+function useBrandTint(ref){
+  React.useEffect(()=>{
+    const el = ref.current; if(!el) return;
+    // To make it theme-aware, read tokens and rewrite the tableValues here:
+    //   const cs = getComputedStyle(el);
+    //   const stops = ['--ink','--accent','--canvas'].map(v => cs.getPropertyValue(v).trim());
+    //   // resample -> per-channel tableValues -> set on #tonemap feFuncR/G/B
+    el.style.filter = ${JSON.stringify(filterVal)};
+  }, []);
+}`;
+}
+
+/* ═══ helpers: clipboard / download / toast ═════════════════════════ */
+function copy(t,msg){ navigator.clipboard?.writeText(t).then(()=>toast(msg||'Copied')).catch(()=>toast('Copy failed')); }
+function download(text,name,type){ downloadBlob(new Blob([text],{type}),name); }
+function downloadBlob(blob,name){ const u=URL.createObjectURL(blob); const a=el('a',{href:u,download:name}); document.body.append(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(u),2000); }
+let toastT=0; function toast(m){ const t=$('#toast'); t.textContent=m; t.classList.add('show'); clearTimeout(toastT); toastT=setTimeout(()=>t.classList.remove('show'),1900); }
+function syncCanvasBg(){ const bg=$('#canvasbg'); bg.className='canvasbg'; bg.style.background='';
+  if(S.bg==='iso')bg.classList.add('iso');
+  else if(S.bg==='checker')bg.classList.add('checker');
+  else if(S.bg==='crt')bg.classList.add('crt');
+  else if(S.bg==='solid')bg.style.background=S.bgColor;
+  else if(S.bg==='light')bg.style.background='#ffffff';
+  else if(S.bg==='dark')bg.style.background='#111214';
+  $('#grid').classList.toggle('on',S.grid);
+}
+let saveT=0; function scheduleSave(){ clearTimeout(saveT); saveT=setTimeout(save,400); }
+
+/* ═══ boot ═══════════════════════════════════════════════════════════ */
+function resetAll(){ if(!confirm('Reset all controls to defaults?'))return; S=structuredClone(DEFAULT); baseSize=new Map(); renderRail(); syncCanvasBg(); if(SRC.svg)applySvgSizing(); apply(); save(); toast('Reset'); }
+$('#resetBtn').onclick=resetAll;
+$('#inspectBtn').onclick=toggleInspect;
+$('#splitBtn').onclick=toggleSplit;
+$('#undoBtn').onclick=undo; updateUndoBtn();
+// chrome theme: E7 light (default) ↔ petrol dark — a token swap on <html data-theme>
+function applyChromeTheme(t){ document.documentElement.dataset.theme=t; try{localStorage.setItem('svgstudio-theme',t);}catch{} }
+$('#themeBtn').onclick=()=>applyChromeTheme(document.documentElement.dataset.theme==='petrol'?'light':'petrol');
+applyChromeTheme(LS.get('svgstudio-theme')||'light');
+
+renderRail(); buildHUD(); setupViewport(); syncCanvasBg(); apply();
+$('#splitBtn').classList.toggle('on',S.split);
+// auto-load sample so the studio is never empty on first run
+loadSampleSVG();
+</script>
+</body>
+</html>

+ 37 - 0
skills/svg-brand-tint-ops/assets/sample.svg

@@ -0,0 +1,37 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 400" font-family="sans-serif">
+  <!-- Generic, brand-agnostic architecture diagram: a grey ramp so a desaturate->
+       tone-map has real midtones to remap. Load any SVG of your own instead. -->
+  <rect x="0" y="0" width="640" height="400" fill="#f4f4f4"/>
+
+  <!-- edges -->
+  <g stroke="#9aa0a6" stroke-width="2" fill="none">
+    <path d="M160 120 H240"/>
+    <path d="M400 120 H480"/>
+    <path d="M120 160 V240"/>
+    <path d="M520 160 V240"/>
+    <path d="M200 280 H440" stroke-dasharray="6 6"/>
+  </g>
+
+  <!-- nodes: a spread of greys -->
+  <g stroke="#5f6368" stroke-width="1.5">
+    <rect x="60"  y="90"  width="100" height="60" rx="10" fill="#d9dce0"/>
+    <rect x="240" y="90"  width="160" height="60" rx="10" fill="#b7bcc2"/>
+    <rect x="480" y="90"  width="100" height="60" rx="10" fill="#d9dce0"/>
+    <rect x="70"  y="240" width="120" height="56" rx="10" fill="#8b9198"/>
+    <rect x="450" y="240" width="120" height="56" rx="10" fill="#8b9198"/>
+  </g>
+
+  <!-- accent shapes -->
+  <circle cx="320" cy="270" r="34" fill="#6b7178" stroke="#3c4043" stroke-width="2"/>
+  <path d="M320 250 l16 28 h-32 z" fill="#f4f4f4"/>
+
+  <!-- labels -->
+  <g fill="#2b2f33" font-size="14" text-anchor="middle">
+    <text x="110" y="125">Client</text>
+    <text x="320" y="125">Gateway</text>
+    <text x="530" y="125">CDN</text>
+    <text x="130" y="273">Service A</text>
+    <text x="510" y="273">Service B</text>
+    <text x="320" y="345" font-size="12" fill="#5f6368">shared queue</text>
+  </g>
+</svg>

+ 1925 - 0
skills/svg-brand-tint-ops/assets/trace-core.mjs

@@ -0,0 +1,1925 @@
+// SVG Studio — raster→vector trace engine (pure, dependency-free ES module).
+//
+// The canonical image-trace core. Runs identically in the browser (fed a
+// <canvas> ImageData) and in Node (fed decoded pixels), so the tool and the
+// headless accuracy harness share ONE implementation — no drift.
+//
+//   import { traceImage } from './trace-core.mjs';
+//   const svg = traceImage({ data, width, height }, { mode:'color', colors:8 });
+//
+// `data` is RGBA bytes (Uint8ClampedArray|Uint8Array|number[]), length w*h*4.
+// Returns an SVG string. No DOM, no canvas, no network.
+
+export const DEFAULTS = {
+  mode: 'color',      // 'color' | 'bw' | 'poster'
+  colors: 6,          // color mode: palette size
+  threshold: 128,     // bw mode: luminance cut
+  levels: 4,          // poster mode: grey bands
+  detail: 1.0,        // Douglas–Peucker epsilon (px) — pre-simplify before Bézier fitting
+  smooth: 0.5,        // widens the Bézier fit tolerance (0..1)
+  cornerDeg: 40,      // turns sharper than this stay sharp (corner-split)
+  fair: 1,            // Laplacian fairing passes on CURVE vertices (de-wiggles thin strokes; corners pinned)
+  fitErr: 1.5,        // Schneider Bézier fit error tolerance (working px) — higher = smoother, fewer curves
+  mergeDist: 48,      // merge palette colours closer than this (collapses mono over-segmentation + anti-alias fringe)
+  smoothPx: 1,        // mask-smoothing blur radius (px); 0 = crisp (blur rounds corners)
+  despeckle: 10,      // drop features under N px²
+  preblur: 0,         // box-blur radius (denoise)
+  invert: false,      // bw/poster: invert luminance
+  alphaCut: 128,      // pixels below this alpha are treated as transparent (excluded)
+  bg: '',             // optional forced background fill for fully-opaque images
+  // ── field/palette machinery (v2 repair rounds — ON by default; set false to disable) ──
+  softField: true,    // CHANGE 1: fractional membership field dq/(dp+dq) instead of hard binary mask
+  alphaField: true,   // CHANGE 1b: multiply membership by alpha/255 (soft outer boundary on transparent-bg art)
+  fringeCull: true,   // CHANGE 2: cull AA-fringe palette entries (on the a—b line, tiny area)
+  fringeDist: 45,     // CHANGE 2: max RGB distance from the a—b segment to count as fringe
+  fringeAreaRatio: 0.25, // CHANGE 2: fringe area must be < this fraction of min(area(a),area(b))
+  matteAnchor: true,  // CHANGE 2b: also cull fringes toward the estimated background matte (transparent-bg halos)
+  matteDilate: 3,     // CHANGE 2b: transparency-dilate passes for the halo border band (callers scale with supersample)
+  straightRun: false, // CHANGE 3 (legacy v2 geometry): greedy straight-run collapse after DP simplify.
+                      // Superseded by `potrace`; only consulted when potrace is disabled.
+  runTol: 0.75,       // CHANGE 3: max deviation (working px) of intermediates from the run segment
+  demat: true,        // CHANGE 0: un-blend the background matte from semi-transparent AA pixels (root-cause halo fix)
+  // ── v3: Potrace-style global geometry stage (Selinger 2003, implemented from the paper) ──
+  potrace: true,      // v3 path: optimal polygon + vertex adjust + corner analysis (replaces DP+straightRun+Schneider)
+  tubeTol: 1.0,       // P2 straightness tube radius (working px; ≈ Potrace's 0.5 native px at 2x supersample)
+  chamferMax: 4.5,    // collapse runs of polygon edges shorter than this across AA-rounded corners (working px)
+  alphaMax: 1.0,      // P6 corner threshold (alpha > alphaMax => corner)
+  cornerBox: 1.0,     // P6 tolerance-square side around a_i (working px)
+  adjustR: 0.75,      // P5 max displacement of adjusted vertex from its ring vertex (working px)
+  axisSnap: true,     // P8 snap near-axis edges to exact 0/90 (and 45) degrees
+  snapDeg: 0.75,      // P8 tolerance for horizontal/vertical snap (degrees)
+  snap45Deg: 0.4,     // P8 tolerance for 45-degree snap (degrees)
+  minSeg: 0.2,        // P1 pre-merge: drop ring vertices closer than this to their predecessor (working px)
+  optiCurve: true,    // P7 curve optimization: join same-convexity Bézier runs (paper §2.4)
+  optTol: 0.2,        // P7 tangency acceptance tolerance (working px)
+  // ── v4 field/palette fidelity fixes (ON by default; set false/0 to disable) ──
+  matteField: true,   // FIX 1: estimated background matte joins the field competition (dq), so the
+                      // outer boundary lands on the 50% ink↔matte blend instead of the AA envelope
+  polarize: true,     // FIX 2a: snap each palette colour to the mean of its FLAT-INTERIOR pixels
+                      // (kmeans means are dragged toward AA blends; interiors are the true inks)
+  interiorGuard: true, // FIX 2b: flat-interior-aware merge + fringe cull — an entry with a real flat
+                      // interior is never culled/merged away; an entry with none, sitting on a blend
+                      // segment (incl. toward the matte), is always culled
+  minFlat: 16,        // FIX 2: minimum flat-interior pixels (working px²; at the 2x-supersample
+                      // reference scale — callers scale by (superF/2)²)
+  fieldSharp: 1.0,    // FIX 3: unsharp gain λ on each layer field (recovers sub-50%-coverage hairlines;
+                      // preserves straight-edge 0.5 crossings exactly). 0 = off
+  sharpR: 2,          // FIX 3: unsharp radius (working px)
+  minLoop: 0,         // per-contour despeckle (working px²); 0 = legacy 0.5
+  blendVeto: true,    // FIX 4: zero membership on pixels explained by another ink-pair blend
+  isoArea: true,      // FIX 5: per-layer area-conserving iso — pick the contour level whose region
+                      // area equals the field integral (∑f). Symmetric ramps ⇒ exactly 0.5 (no-op);
+                      // sub-50%-coverage hairline fields (fine barcodes, serif hairlines) ⇒ the iso
+                      // drops just enough to preserve the stroke's optical weight. Clamped [0.32,0.6]
+  sliverVeto: true,   // FIX 6: phantom-sliver veto — drop/reassign small emitted contours whose
+                      // SOURCE pixels are explained by a blend of the two colours they border
+                      // (AA debris: fringe caps, edge ticks, third-colour slivers) rather than by
+                      // their own fill. Needs two distinct bordering colours, so isolated legit
+                      // marks (dots, ®, faint hairlines on plain background) are never touched.
+  sliverMax: 500,     // FIX 6: max contour area (working px²) eligible for the veto
+};
+
+/* ── main ────────────────────────────────────────────────────────────── */
+export function traceImage(image, opts = {}) {
+  const o = { ...DEFAULTS, ...opts };
+  // coerce numeric knobs to finite defaults so a bad value degrades gracefully
+  // (NaN threshold/levels/detail/colors would otherwise silently blank the SVG)
+  { const num = (v, d) => Number.isFinite(+v) ? +v : d;
+    for (const k of ['colors','threshold','levels','detail','smooth','cornerDeg','fair','fitErr','mergeDist','smoothPx','despeckle','preblur','alphaCut','fringeDist','fringeAreaRatio','runTol']) o[k] = num(o[k], DEFAULTS[k]); }
+  const w = image.width, h = image.height;
+  let px = image.data;
+  if (o.preblur > 0) px = boxBlur(px, w, h, Math.round(o.preblur));
+  const matte = o.matteField ? estimateMatte(px, w * h) : null;  // FIX 1 (from ORIGINAL px, pre-demat)
+  if (o.demat) px = dematte(px, w * h);                 // CHANGE 0: strip matte contamination from AA pixels
+
+  const N = w * h;
+  const lum = new Float32Array(N);
+  const opaque = new Uint8Array(N);
+  for (let i = 0, j = 0; i < N; i++, j += 4) {
+    lum[i] = 0.299 * px[j] + 0.587 * px[j + 1] + 0.114 * px[j + 2];
+    opaque[i] = px[j + 3] >= o.alphaCut ? 1 : 0;
+  }
+
+  const layers = buildLayers(px, lum, opaque, w, h, o, matte);
+
+  // contour + fit each layer → paths
+  const eps = o.detail, minA = o.despeckle;
+  const minLoop = o.minLoop > 0 ? o.minLoop : 0.5;
+
+  // pass 1: per-layer iso level, despeckle survivors
+  const preps = [];
+  const consLevel = (field) => {                         // FIX 5: area-conserving contour level
+    const HB = 512, hist = new Int32Array(HB + 1);
+    let S = 0;
+    for (let i = 0; i < field.length; i++) {
+      const f = field[i];
+      if (f <= 0) continue;
+      S += f;
+      hist[Math.min(HB, Math.floor(f * HB))]++;
+    }
+    let acc = 0, t = 0.5;
+    for (let b = HB; b >= 0; b--) {                      // area(≥ b/HB) grows as b falls; stop at ∑f
+      acc += hist[b];
+      if (acc >= S) { t = b / HB; break; }
+    }
+    return t;
+  };
+  for (const L of layers) {
+    let iso = 0.5, tRaw = 0.5;
+    if (o.isoArea) {
+      if (L.fieldUn) {
+        // FIX 7: two regimes in one layer. The gated field is coverage-true
+        // (iso 0.5 is exact); the ungated field wants its own conserving level
+        // t*. Rescale the ungated field so t* maps to 0.5 and take the max —
+        // equivalent to a per-region iso, in a single marching-squares cut.
+        const t = Math.max(0.32, Math.min(0.6, consLevel(L.fieldUn)));
+        const sc = 0.5 / t;
+        const F = L.field, U = L.fieldUn;
+        for (let i = 0; i < F.length; i++) {
+          const v = U[i] * sc;
+          const u = v > 1 ? 1 : v;
+          if (u > F[i]) F[i] = u;
+        }
+        iso = 0.5; tRaw = t;                            // 0.5 minus a hair: blur+unsharp+fit shave ~0.02 of ramp
+      } else {
+        const t = consLevel(L.field);
+        iso = Math.max(0.32, Math.min(0.6, t));
+        tRaw = t;
+      }
+    }
+    let pxArea = 0;
+    for (let i = 0; i < L.field.length; i++) if (L.field[i] >= iso) pxArea++;
+    if (o._isoHook) o._isoHook({ fill: L.fill, tRaw: +tRaw.toFixed(3), iso, pxArea });
+    if (pxArea < minA) continue;                         // one cull, on true px²
+    preps.push({ L, iso });
+  }
+
+  // FIX 6 prep: region masks + composite index + flattened source (colour mode only)
+  const vetoOn = o.sliverVeto && preps.length > 0 && preps.every(pr => pr.L.rgb);
+  let compTop = null, srcFlat = null, bgRGB = null;
+  if (vetoOn) {
+    bgRGB = matte ? [Math.round(matte[0]), Math.round(matte[1]), Math.round(matte[2])] : [255, 255, 255];
+    compTop = new Int16Array(N).fill(-1);
+    for (let k = 0; k < preps.length; k++) {
+      const { L, iso } = preps[k], m = new Uint8Array(N);
+      for (let i = 0; i < N; i++) if (L.field[i] >= iso) { m[i] = 1; compTop[i] = k; }
+      preps[k].mask = m;
+    }
+    srcFlat = new Uint8ClampedArray(N * 3);
+    for (let i = 0, j = 0; i < N; i++, j += 4) {
+      const a = px[j + 3] / 255;
+      srcFlat[i * 3]     = px[j]     * a + bgRGB[0] * (1 - a);
+      srcFlat[i * 3 + 1] = px[j + 1] * a + bgRGB[1] * (1 - a);
+      srcFlat[i * 3 + 2] = px[j + 2] * a + bgRGB[2] * (1 - a);
+    }
+  }
+
+  // pass 2: contour, veto, fit, emit
+  // FIX 10 (pixel-centre alignment): field[y*w+x] is the coverage of the pixel
+  // CENTRED at (x+0.5, y+0.5), but marching squares emits vertices on the
+  // (x, y) lattice — every contour lands half a working pixel up-left of the
+  // true edge (measured: a uniform (-0.5,-0.5) 2x-px shift of every render vs
+  // its source). Translate rings to pixel-centre space before fitting. Veto /
+  // mask bookkeeping stays in lattice space, consistent with the field.
+  const fitOne = poly => {
+    poly = poly.map(p => [p[0] + 0.5, p[1] + 0.5]);
+    if (o.potrace) {                                     // v3: Potrace global stage on the RAW ring (no DP)
+      if (o._ringHook) o._ringHook(poly);
+      return potracePath(poly, o, o.debugGeo);
+    }
+    let pts = simplify(poly, eps);
+    if (o.straightRun && pts.length >= 4) pts = straightRunCollapse(pts, o.runTol);  // CHANGE 3
+    if (pts.length < 3) return '';
+    return fitPath(pts, o.smooth, o.cornerDeg, o.fair, o.fitErr);
+  };
+  let body = '';
+  const polyPerim = poly => {
+    let s = 0;
+    for (let i = 0, n = poly.length; i < n; i++) {
+      const a = poly[i], b = poly[(i + 1) % n];
+      s += Math.hypot(b[0] - a[0], b[1] - a[1]);
+    }
+    return s;
+  };
+  const polySigned = poly => {
+    let a = 0;
+    for (let i = 0, n = poly.length; i < n; i++) {
+      const [x1, y1] = poly[i], [x2, y2] = poly[(i + 1) % n];
+      a += x1 * y2 - x2 * y1;
+    }
+    return a / 2;
+  };
+  for (let k = 0; k < preps.length; k++) {
+    const { L, iso } = preps[k];
+    const contours = isoContours(L.field, w, h, iso);
+    const keep = [], smalls = [], extras = [];
+    // FIX 11 (sub-px fringe band drop): an OUTER contour whose mean optical
+    // width 2A/P is under a working pixel, with tiny area, is a resampling /
+    // chroma-fringe band hugging another ink's edge (1px accent slivers along
+    // text stems) — not art. Holes (counters, opposite winding) are exempt so
+    // hairline cutouts survive. Reference winding = the layer's largest loop.
+    let refSign = 0, refA = 0;
+    for (const poly of contours) {
+      const sa = polySigned(poly);
+      if (Math.abs(sa) > refA) { refA = Math.abs(sa); refSign = Math.sign(sa); }
+    }
+    for (const poly of contours) {
+      const A = polyArea(poly);
+      if (A < minLoop) continue;                         // discard sliver contours
+      if (A < 300 && Math.sign(polySigned(poly)) === refSign &&
+          2 * A / polyPerim(poly) < 1.1) continue;       // FIX 11
+      if (vetoOn) smalls.push(poly);
+      else keep.push(poly);
+    }
+    if (smalls.length) sliverVetoPass(smalls, keep, extras, k, preps, compTop, srcFlat, bgRGB, w, h, o);
+    let d = '';
+    for (const poly of keep) { const pd = fitOne(poly); if (pd) d += pd + ' '; }
+    if (d) body += `<path d="${d.trim()}" fill="${L.fill}" fill-rule="evenodd"/>`;
+    for (const ex of extras) {
+      let dd = '';
+      for (const poly of ex.polys) { const pd = fitOne(poly); if (pd) dd += pd + ' '; }
+      if (dd) body += `<path d="${dd.trim()}" fill="${ex.fill}" fill-rule="evenodd"/>`;
+    }
+  }
+  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">${body}</svg>`;
+}
+
+/* ── layering per mode ───────────────────────────────────────────────── */
+// Each layer is a hard binary membership, then BLURRED into a smooth 0..1
+// coverage field. isoContours cuts the blurred field at 0.5 → sub-pixel-smooth
+// boundaries (the blur dissolves the pixel staircase) while staying robust for
+// monochrome art (a hard assignment never collapses like a distance ratio does).
+function buildLayers(px, lum, opaque, w, h, o, matte) {
+  const N = w * h, layers = [];
+  const r = Math.max(0, Math.round(o.smoothPx));
+  const smooth = raw => {
+    let f = r > 0 ? blurField(raw, w, h, r) : raw;
+    if (o.fieldSharp > 0) f = unsharpField(f, w, h, Math.max(1, Math.round(o.sharpR)), o.fieldSharp); // FIX 3
+    return f;
+  };
+  if (o.mode === 'bw') {
+    const th = o.threshold, raw = new Float32Array(N);
+    for (let i = 0; i < N; i++) if (opaque[i]) raw[i] = (o.invert ? lum[i] > th : lum[i] < th) ? 1 : 0;
+    layers.push({ field: smooth(raw), fill: o.invert ? '#ffffff' : '#111111' });
+  } else if (o.mode === 'poster') {
+    const L = Math.max(2, Math.round(o.levels) || DEFAULTS.levels);   // ≥2, never NaN (avoids empty output / L-1 div-by-0)
+    for (let k = 1; k < L; k++) {
+      const th = 255 * k / L, raw = new Float32Array(N);
+      for (let i = 0; i < N; i++) { if (!opaque[i]) continue; let v = lum[i]; if (o.invert) v = 255 - v; raw[i] = v < th ? 1 : 0; }
+      const g = Math.round(255 * (k - 1) / (L - 1));
+      layers.push({ field: smooth(raw), fill: rgb2hex(g, g, g) });
+    }
+    layers.reverse();
+  } else { // color — nearest-palette assignment, then blur each membership mask
+    let pal = medianCut(px, opaque, w, h, o.colors);
+    pal = kmeansRefine(px, opaque, w, h, pal, 5);   // snap seeds onto the true flat colours
+    if (o._palHook) o._palHook('kmeans', pal.map(c => c.slice()));
+    if (o.interiorGuard) {                          // FIX 2: interior-aware merge
+      const st = flatStats(px, opaque, w, h, pal);
+      pal = mergePaletteSmart(pal, o.mergeDist, st, o.minFlat);
+    } else {
+      const preArea = new Array(pal.length).fill(0);  // so merge keeps the DOMINANT member of each cluster
+      for (let i = 0, j = 0; i < N; i++, j += 4) { if (!opaque[i]) continue; let b = 0, bd = 1e12;
+        for (let p = 0; p < pal.length; p++) { const dr = px[j]-pal[p][0], dg = px[j+1]-pal[p][1], dl = px[j+2]-pal[p][2]; const dd = dr*dr+dg*dg+dl*dl; if (dd < bd) { bd = dd; b = p; } } preArea[b]++; }
+      pal = mergePalette(pal, o.mergeDist, preArea);    // collapse near-duplicate colours (mono logos)
+    }
+    if (o._palHook) o._palHook('merge', pal.map(c => c.slice()));
+    if (o.polarize) {                               // FIX 2a: pre-cull polarize (cull geometry on true inks)
+      const st = flatStats(px, opaque, w, h, pal);
+      pal = pal.map((c, p) => st.flatCnt[p] >= o.minFlat ? st.flatMean[p].map(Math.round) : c);
+    }
+    if (o.fringeCull) pal = cullFringe(px, opaque, w, h, pal, o, matte);  // CHANGE 2 (+2b) + FIX 2b
+    if (o.polarize) {                               // FIX 2a: post-cull polarize (reassigned AA px absorbed)
+      const st = flatStats(px, opaque, w, h, pal);
+      pal = pal.map((c, p) => st.flatCnt[p] >= o.minFlat ? st.flatMean[p].map(Math.round) : c);
+    }
+    if (o._palHook) o._palHook('cull', pal.map(c => c.slice()));
+    const assign = new Int16Array(N), area = new Array(pal.length).fill(0);
+    for (let i = 0, j = 0; i < N; i++, j += 4) {
+      if (!opaque[i]) { assign[i] = -1; continue; }
+      let best = 0, bd = 1e12;
+      for (let p = 0; p < pal.length; p++) {
+        const dr = px[j] - pal[p][0], dg = px[j + 1] - pal[p][1], dl = px[j + 2] - pal[p][2];
+        const dd = dr * dr + dg * dg + dl * dl;
+        if (dd < bd) { bd = dd; best = p; }
+      }
+      assign[i] = best; area[best]++;
+    }
+    // FIX 7 (collinear-competitor gating): on greyscale-ramp art the real
+    // intermediate inks (barcode greys) are collinear with dark↔matte, so they
+    // "explain" every AA pixel of the dark wordmark and erode its boundary far
+    // inside the true 50%-coverage line. An intermediate ink may only compete
+    // for pixels NEAR ITS OWN flat cores; elsewhere the ramp belongs to the
+    // ink↔matte pair. Pure-hue palettes have no collinear intermediates, so
+    // this is a no-op outside greyscale/monochrome art.
+    let collinAny = false, collinNear = null;
+    if (o.softField && matte && pal.length >= 3) {
+      const dM = c => Math.hypot(c[0] - matte[0], c[1] - matte[1], c[2] - matte[2]);
+      const isCollin = pal.map(() => false);
+      for (let q = 0; q < pal.length; q++) {
+        for (let pp = 0; pp < pal.length; pp++) {
+          if (pp === q) continue;
+          const dQP = Math.hypot(pal[q][0] - pal[pp][0], pal[q][1] - pal[pp][1], pal[q][2] - pal[pp][2]);
+          const dQM = dM(pal[q]);
+          if (dQP > 60 && dQM > 60 && dM(pal[pp]) > dQM + 60 &&
+              ptSegDist3(pal[q], pal[pp], matte) <= 40) { isCollin[q] = true; collinAny = true; break; }
+        }
+      }
+      if (collinAny) {
+        const stC = flatStats(px, opaque, w, h, pal);
+        const R = Math.max(4, Math.round(o.matteDilate * 2));
+        collinNear = pal.map((c, q) => {
+          let cur = new Uint8Array(N);
+          let any = false;
+          for (let i = 0; i < N; i++) if (stC.flatMask[i] && stC.assign[i] === q) { cur[i] = 1; any = true; }
+          if (!any) return cur;
+          for (let pass = 0; pass < R; pass++) {           // chebyshev dilation
+            const nx = new Uint8Array(cur);
+            for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
+              const i = y * w + x;
+              if (cur[i]) continue;
+              let hit = 0;
+              for (let dy = -1; dy <= 1 && !hit; dy++) for (let dx = -1; dx <= 1; dx++) {
+                const xx = x + dx, yy = y + dy;
+                if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue;
+                if (cur[yy * w + xx]) { hit = 1; break; }
+              }
+              if (hit) nx[i] = 1;
+            }
+            cur = nx;
+          }
+          return cur;
+        });
+        collinNear.isCollin = isCollin;
+      }
+    }
+    const order = pal.map((_, i) => i).sort((a, b) => area[b] - area[a]);
+    for (const p of order) {
+      if (area[p] === 0) continue;                              // truly empty (all-transparent entry)
+      if (p !== order[0] && area[p] < o.despeckle) continue;    // never drop the dominant colour → never a blank SVG
+      let raw;
+      if (o.softField && (pal.length >= 2 || o.alphaField || matte)) {
+        raw = softMembership(px, opaque, N, pal, p, o.alphaField, matte, o.blendVeto, collinNear);  // CHANGE 1 (+1b) + FIX 1/4/7
+      } else {
+        raw = new Float32Array(N);
+        for (let i = 0; i < N; i++) if (assign[i] === p) raw[i] = 1;
+      }
+      const L = { field: smooth(raw), fill: rgb2hex(...pal[p]), rgb: pal[p].slice() };
+      if (raw.rawUn) L.fieldUn = smooth(raw.rawUn);
+      if (collinNear && collinNear.isCollin[p]) L.selfNear = collinNear[p];
+      layers.push(L);
+    }
+  }
+  return layers;
+}
+
+/* ── FIX 6: phantom-sliver veto ───────────────────────────────────────────
+   The per-pixel membership machinery occasionally emits small contours whose
+   fill is NOT what the source shows there: fringe caps at stroke tips (an
+   invented overshoot colour), third-colour ticks on wordmark AA, edge slivers
+   of a real ink misplaced onto the blend zone of two OTHER colours. All share
+   one signature: the SOURCE pixels under the contour sit on the RGB blend
+   segment between the two colours the contour borders, and far from the
+   contour's own fill. Test exactly that, per pixel, and on a supermajority
+   drop the contour — or, when one bordering ink explains the source better
+   than the background does, reassign the contour to that ink so strokes keep
+   their full length (a dropped cap must not shorten its stem).
+   Global and principled: no per-image tuning; needs two distinct bordering
+   colours, so isolated legit marks (dots, ®, hairlines on plain background)
+   never qualify. */
+function polyBounds(poly) {
+  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
+  for (const p of poly) {
+    if (p[0] < x0) x0 = p[0]; if (p[0] > x1) x1 = p[0];
+    if (p[1] < y0) y0 = p[1]; if (p[1] > y1) y1 = p[1];
+  }
+  return [x0, y0, x1, y1];
+}
+function pointInPoly(x, y, poly) {
+  let inside = false;
+  for (let i = 0, n = poly.length; i < n; i++) {
+    const a = poly[i], b = poly[(i + 1) % n];
+    if ((a[1] <= y && b[1] > y) || (b[1] <= y && a[1] > y)) {
+      const xc = a[0] + (y - a[1]) / (b[1] - a[1]) * (b[0] - a[0]);
+      if (xc > x) inside = !inside;
+    }
+  }
+  return inside;
+}
+function interiorPixels(poly, w, h) {
+  const [x0, y0, x1, y1] = polyBounds(poly);
+  const out = [];
+  const yA = Math.max(0, Math.ceil(y0)), yB = Math.min(h - 1, Math.floor(y1));
+  for (let y = yA; y <= yB; y++) {
+    const xs = [];
+    for (let i = 0, n = poly.length; i < n; i++) {
+      const a = poly[i], b = poly[(i + 1) % n];
+      if ((a[1] <= y && b[1] > y) || (b[1] <= y && a[1] > y))
+        xs.push(a[0] + (y - a[1]) / (b[1] - a[1]) * (b[0] - a[0]));
+    }
+    xs.sort((p, q) => p - q);
+    for (let m = 0; m + 1 < xs.length; m += 2) {
+      const xa = Math.max(0, Math.ceil(xs[m])), xb = Math.min(w - 1, Math.floor(xs[m + 1]));
+      for (let x = xa; x <= xb; x++) out.push(y * w + x);
+    }
+  }
+  return out;
+}
+function sliverVetoPass(smalls, keep, extras, k, preps, compTop, srcFlat, bg, w, h, o) {
+  const ownRGB = preps[k].L.rgb, mask = preps[k].mask;
+  const d3 = (r, g, b, c) => Math.sqrt((r - c[0]) ** 2 + (g - c[1]) ** 2 + (b - c[2]) ** 2);
+  // context colours along the boundary: adjacent regions (topmost composite
+  // layer, or background) + what lies beneath the contour's own pixels
+  const ctxOf = (poly) => {
+    const cnt = new Map();  // layer index (or -1 = bg) → sample count
+    let total = 0;
+    const step = Math.max(1, Math.floor(poly.length / 96));
+    for (let vi = 0; vi < poly.length; vi += step) {
+      const vx = Math.round(poly[vi][0]), vy = Math.round(poly[vi][1]);
+      for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) {
+        const x = vx + dx, y = vy + dy;
+        if (x < 0 || y < 0 || x >= w || y >= h) continue;
+        const i = y * w + x;
+        let key;
+        if (mask[i]) {                       // own region: the colour beneath
+          key = -1;
+          for (let j = 0; j < k; j++) if (preps[j].mask[i]) key = j;
+        } else key = compTop[i];             // adjacent region (any layer, or bg)
+        if (key === k) continue;
+        cnt.set(key, (cnt.get(key) || 0) + 1); total++;
+      }
+    }
+    const out = [];
+    for (const [key, c] of cnt) {
+      if (c < Math.max(2, 0.15 * total)) continue;
+      out.push({ rgb: key < 0 ? bg : preps[key].L.rgb, isBg: key < 0 });
+    }
+    return out;
+  };
+  const dropped = [], holes = [];
+  for (const poly of smalls) {
+    let P = interiorPixels(poly, w, h);
+    const A = polyArea(poly);
+    if (A > o.sliverMax) {
+      // Large ring. polyArea counts the enclosed DISK, so a thin debris band
+      // ringing another layer's region (AA rim around a bar/stem) escapes the
+      // area cap — and its mostly-not-own interior reads as a "hole". Judge it
+      // by its OWN pixel count: small own-band + mostly-foreign interior =
+      // band-debris candidate, voted on the band pixels only. Genuinely big
+      // own regions stay kept; big foreign-interior rings with no band are
+      // true holes (counters) and follow their host.
+      let inCnt = 0;
+      for (const i of P) if (mask[i]) inCnt++;
+      if (P.length >= 2 && inCnt * 2 < P.length && inCnt > 0 && inCnt <= o.sliverMax) {
+        P = P.filter(i => mask[i]);          // vote on the band's own pixels
+      } else if (P.length >= 2 && inCnt * 2 < P.length) {
+        holes.push(poly); continue;          // true hole ring (counter)
+      } else { keep.push(poly); continue; }  // big legit region
+    } else {
+      // hole rings (counters) are never veto candidates — leave them in place
+      if (P.length >= 2) {
+        let inCnt = 0;
+        for (const i of P) if (mask[i]) inCnt++;
+        if (inCnt * 2 < P.length) { holes.push(poly); continue; }
+      }
+    }
+    if (!P.length) {                         // sub-pixel sliver: sample the ring itself
+      const seen = new Set();
+      for (const v of poly) {
+        const x = Math.min(w - 1, Math.max(0, Math.round(v[0])));
+        const y = Math.min(h - 1, Math.max(0, Math.round(v[1])));
+        seen.add(y * w + x);
+      }
+      P = [...seen];
+    }
+    const ctx = ctxOf(poly);
+    const pairs = [];
+    for (let a = 0; a < ctx.length; a++) for (let b = a + 1; b < ctx.length; b++) {
+      const A = ctx[a].rgb, B = ctx[b].rgb;
+      if ((A[0] - B[0]) ** 2 + (A[1] - B[1]) ** 2 + (A[2] - B[2]) ** 2 >= 50 * 50) pairs.push([A, B]);
+    }
+    if (!pairs.length) { keep.push(poly); continue; }    // no two distinct borders → not a blend zone
+    let votes = 0;
+    const selfNear = preps[k].L.selfNear;
+    const sums = ctx.map(() => 0);
+    for (const i of P) {
+      const r = srcFlat[i * 3], g = srcFlat[i * 3 + 1], b = srcFlat[i * 3 + 2];
+      const dOwn = d3(r, g, b, ownRGB);
+      let dCtx = Infinity, qCtx = null;
+      for (const [A, B] of pairs) {
+        const pr = projSeg3([r, g, b], A, B);
+        if (pr.d < dCtx) { dCtx = pr.d; qCtx = pr.q; }
+      }
+      // Collinear-palette escape: a thin fragment of a real intermediate ink
+      // "is a blend of its borders" by construction (greys are collinear), so
+      // near its own flat cores a blend point that coincides with the own fill
+      // does not count as debris evidence. Away from the cores it does.
+      const protectedPx = selfNear && selfNear[i] && d3(qCtx[0], qCtx[1], qCtx[2], ownRGB) <= 32;
+      if (dCtx + 12 < dOwn && !protectedPx) votes++;
+      for (let ci = 0; ci < ctx.length; ci++) sums[ci] += d3(r, g, b, ctx[ci].rgb);
+    }
+    if (votes < 0.7 * P.length) { keep.push(poly); continue; }
+    // debris — reassign to the context colour that best explains the source
+    let bi = 0;
+    for (let ci = 1; ci < ctx.length; ci++) if (sums[ci] < sums[bi]) bi = ci;
+    const rec = { poly, reFill: null };
+    if (!ctx[bi].isBg) {
+      const fill = rgb2hex(...ctx[bi].rgb);
+      let ex = extras.find(e => e.fill === fill);
+      if (!ex) { ex = { fill, polys: [] }; extras.push(ex); }
+      ex.polys.push(poly);
+      rec.reFill = ex;
+    }
+    dropped.push(rec);
+  }
+  // hole rings inside a vetoed contour follow their host (or vanish with it)
+  for (const hp of holes) {
+    const host = dropped.find(dr => pointInPoly(hp[0][0], hp[0][1], dr.poly));
+    if (!host) keep.push(hp);
+    else if (host.reFill) host.reFill.polys.push(hp);
+  }
+}
+
+/* ── FIX 1: matte estimate (pre-demat pixels) ─────────────────────────────
+   Mean colour of low-alpha pixels (the matte the AA was blended toward);
+   white fallback for binary-alpha cutouts. null when the image is opaque. */
+function estimateMatte(src, N) {
+  let mr = 0, mg = 0, mb = 0, mn = 0, hasTrans = false;
+  for (let i = 0, j = 0; i < N; i++, j += 4) {
+    const a = src[j + 3];
+    if (a < 255) hasTrans = true;
+    if (a > 0 && a <= 96) { mr += src[j]; mg += src[j + 1]; mb += src[j + 2]; mn++; }
+  }
+  if (!hasTrans) return null;
+  return mn ? [mr / mn, mg / mn, mb / mn] : [255, 255, 255];
+}
+
+/* ── FIX 3: unsharp on a layer field ──────────────────────────────────────
+   f' = clamp(f + λ(f − blur(f))). Antisymmetric residue at a straight step
+   edge → the 0.5 crossing is preserved; positive at a ridge (hairline whose
+   AA'd peak fell below 0.5) → the ridge crosses 0.5 again; negative in a thin
+   gap → counters/slots stay open. Counteracts exactly the AA low-pass. */
+function unsharpField(src, w, h, r, lam) {
+  const b = blurField(src, w, h, r), out = new Float32Array(w * h);
+  for (let i = 0; i < w * h; i++) {
+    const v = src[i] + lam * (src[i] - b[i]);
+    out[i] = v < 0 ? 0 : v > 1 ? 1 : v;
+  }
+  return out;
+}
+
+/* ── FIX 2: flat-interior statistics per palette entry ────────────────────
+   A pixel is FLAT when its 4-neighbours are opaque, share its nearest palette
+   entry, and sit within 15 RGB-sum units — i.e. it belongs to a solid interior,
+   not an AA ramp. Real inks have flat interiors; AA fringe clusters have none.
+   A flat pixel only counts toward entry p when its colour is NEAR pal[p]
+   (≤45): a flat mixture region far from every palette colour (dense-hatch
+   optical blends, barcode gap greys) is not evidence of a real ink. */
+export function flatStats(px, opaque, w, h, pal) {
+  const N = w * h, K = pal.length;
+  const assign = new Int16Array(N).fill(-1);
+  const area = new Array(K).fill(0);
+  for (let i = 0, j = 0; i < N; i++, j += 4) {
+    if (!opaque[i]) continue;
+    let best = 0, bd = 1e12;
+    for (let p = 0; p < K; p++) {
+      const dr = px[j] - pal[p][0], dg = px[j + 1] - pal[p][1], db = px[j + 2] - pal[p][2];
+      const dd = dr * dr + dg * dg + db * db;
+      if (dd < bd) { bd = dd; best = p; }
+    }
+    assign[i] = best; area[best]++;
+  }
+  const flatCnt = new Array(K).fill(0);
+  const flatMask = new Uint8Array(N);
+  const fs = Array.from({ length: K }, () => [0, 0, 0]);
+  const NEAR2 = 60 * 60;   // wide enough that a kmeans entry dragged by AA still credits its true cores
+  for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) {
+    const i = y * w + x, a = assign[i];
+    if (a < 0) continue;
+    const j = i * 4;
+    { const dr = px[j] - pal[a][0], dg = px[j + 1] - pal[a][1], db = px[j + 2] - pal[a][2];
+      if (dr * dr + dg * dg + db * db > NEAR2) continue; }
+    let ok = true;
+    for (const d of [-1, 1, -w, w]) {
+      const k = i + d;
+      if (assign[k] !== a) { ok = false; break; }
+      const kj = k * 4;
+      if (Math.abs(px[kj] - px[j]) + Math.abs(px[kj + 1] - px[j + 1]) + Math.abs(px[kj + 2] - px[j + 2]) > 15) { ok = false; break; }
+    }
+    if (ok) { flatCnt[a]++; flatMask[i] = 1; fs[a][0] += px[j]; fs[a][1] += px[j + 1]; fs[a][2] += px[j + 2]; }
+  }
+  const flatMean = fs.map((s, p) => flatCnt[p] ? [s[0] / flatCnt[p], s[1] / flatCnt[p], s[2] / flatCnt[p]] : pal[p].slice());
+  return { assign, area, flatCnt, flatMean, flatMask };
+}
+
+/* ── FIX 2: interior-aware palette merge ──────────────────────────────────
+   Entries closer than TIGHT always merge; entries within `thr` merge only when
+   at least one of the pair lacks a flat interior (an AA cluster). Two REAL inks
+   48 units apart (apc's overlap blues, tear's charcoal/grey) both survive.
+   The representative is the flat-richer (then larger-area) member. */
+function mergePaletteSmart(pal, thr, st, minFlat) {
+  if (!thr || thr <= 0) return pal;
+  const TIGHT = Math.min(20, thr);
+  const keep = pal.map(() => true), out = [];
+  for (let i = 0; i < pal.length; i++) {
+    if (!keep[i]) continue;
+    let rep = i;
+    for (let j = i + 1; j < pal.length; j++) {
+      if (!keep[j]) continue;
+      const dr = pal[i][0] - pal[j][0], dg = pal[i][1] - pal[j][1], db = pal[i][2] - pal[j][2];
+      const d2 = dr * dr + dg * dg + db * db;
+      const mergeable = d2 < TIGHT * TIGHT ||
+        (d2 < thr * thr && (st.flatCnt[i] < minFlat || st.flatCnt[j] < minFlat));
+      if (mergeable) {
+        keep[j] = false;
+        const better = (st.flatCnt[j] >= minFlat) !== (st.flatCnt[rep] >= minFlat)
+          ? (st.flatCnt[j] >= minFlat) : st.area[j] > st.area[rep];
+        if (better) rep = j;
+      }
+    }
+    out.push(pal[rep]);
+  }
+  return out;
+}
+
+// separable box blur on a single-channel float field (clamped edges)
+function blurField(src, w, h, r) {
+  const tmp = new Float32Array(w * h), out = new Float32Array(w * h), inv = 1 / (2 * r + 1);
+  const cx = x => x < 0 ? 0 : x >= w ? w - 1 : x, cy = y => y < 0 ? 0 : y >= h ? h - 1 : y;
+  for (let y = 0; y < h; y++) {
+    const row = y * w; let acc = 0;
+    for (let x = -r; x <= r; x++) acc += src[row + cx(x)];
+    for (let x = 0; x < w; x++) { tmp[row + x] = acc * inv; acc += src[row + cx(x + r + 1)] - src[row + cx(x - r)]; }
+  }
+  for (let x = 0; x < w; x++) {
+    let acc = 0;
+    for (let y = -r; y <= r; y++) acc += tmp[cy(y) * w + x];
+    for (let y = 0; y < h; y++) { out[y * w + x] = acc * inv; acc += tmp[cy(y + r + 1) * w + x] - tmp[cy(y - r) * w + x]; }
+  }
+  return out;
+}
+
+/* ── colour helpers ──────────────────────────────────────────────────── */
+export const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
+export const rgb2hex = (r, g, b) =>
+  '#' + [r, g, b].map(v => clamp(Math.round(v), 0, 255).toString(16).padStart(2, '0')).join('');
+
+// median-cut quantization over OPAQUE pixels
+function medianCut(px, opaque, w, h, K) {
+  const N = w * h, step = Math.max(1, Math.floor(N / 20000)), pts = [];
+  for (let i = 0; i < N; i += step) {
+    if (!opaque[i]) continue;
+    const j = i * 4; pts.push([px[j], px[j + 1], px[j + 2]]);
+  }
+  if (!pts.length) return [[0, 0, 0]];
+  let boxes = [pts];
+  while (boxes.length < K) {
+    let bi = -1, br = -1;
+    boxes.forEach((b, i) => { if (b.length < 2) return; const r = chanRange(b); if (r.range > br) { br = r.range; bi = i; } });
+    if (bi < 0) break;
+    const b = boxes[bi], r = chanRange(b);
+    b.sort((a, c) => a[r.ch] - c[r.ch]);
+    const m = b.length >> 1;
+    boxes.splice(bi, 1, b.slice(0, m), b.slice(m));
+  }
+  return boxes.map(b => {
+    const s = [0, 0, 0];
+    b.forEach(c => { s[0] += c[0]; s[1] += c[1]; s[2] += c[2]; });
+    const n = b.length || 1;
+    return [Math.round(s[0] / n), Math.round(s[1] / n), Math.round(s[2] / n)];
+  });
+}
+// k-means (Lloyd) refinement — pulls median-cut seeds onto the dense colour
+// clusters (a flat logo's true colours), dissolving muddy anti-alias intermediates.
+function kmeansRefine(px, opaque, w, h, pal, iters) {
+  const N = w * h, step = Math.max(1, Math.floor(N / 30000));
+  const K = pal.length, cur = pal.map(c => c.slice());
+  for (let it = 0; it < iters; it++) {
+    const sum = Array.from({ length: K }, () => [0, 0, 0, 0]);
+    for (let i = 0; i < N; i += step) {
+      if (!opaque[i]) continue;
+      const j = i * 4, r = px[j], g = px[j + 1], b = px[j + 2];
+      let best = 0, bd = 1e12;
+      for (let p = 0; p < K; p++) {
+        const dr = r - cur[p][0], dg = g - cur[p][1], db = b - cur[p][2];
+        const dd = dr * dr + dg * dg + db * db;
+        if (dd < bd) { bd = dd; best = p; }
+      }
+      const s = sum[best]; s[0] += r; s[1] += g; s[2] += b; s[3]++;
+    }
+    let moved = 0;
+    for (let p = 0; p < K; p++) {
+      if (sum[p][3] === 0) continue;                 // keep an emptied seed as-is
+      const nr = sum[p][0] / sum[p][3], ng = sum[p][1] / sum[p][3], nb = sum[p][2] / sum[p][3];
+      moved += Math.abs(nr - cur[p][0]) + Math.abs(ng - cur[p][1]) + Math.abs(nb - cur[p][2]);
+      cur[p] = [nr, ng, nb];
+    }
+    if (moved < 1) break;
+  }
+  return cur.map(c => [Math.round(c[0]), Math.round(c[1]), Math.round(c[2])]);
+}
+// merge palette entries closer than `thr` (Euclidean RGB) — keeps the first of
+// each cluster, so a near-monochrome logo collapses to its few true colours.
+function mergePalette(pal, thr, areas) {
+  if (!thr || thr <= 0) return pal;
+  const keep = pal.map(() => true), out = [];
+  for (let i = 0; i < pal.length; i++) {
+    if (!keep[i]) continue;
+    let rep = i, repA = areas ? areas[i] : 0;                 // keep the DOMINANT member of the cluster
+    for (let j = i + 1; j < pal.length; j++) {
+      if (!keep[j]) continue;
+      const dr = pal[i][0] - pal[j][0], dg = pal[i][1] - pal[j][1], db = pal[i][2] - pal[j][2];
+      if (dr * dr + dg * dg + db * db < thr * thr) { keep[j] = false; if (areas && areas[j] > repA) { repA = areas[j]; rep = j; } }
+    }
+    out.push(pal[rep]);
+  }
+  return out;
+}
+/* ── v2 CHANGE 1: soft membership field ──────────────────────────────────
+   For layer colour P and each opaque pixel c: dp=||c-P||, dq=min over OTHER
+   palette colours of ||c-q||; field = dq/(dp+dq). Pure P → 1, decision
+   boundary → 0.5, other → <0.5. AA ramp pixels take intermediate values so
+   interpolated marching squares gets genuine sub-pixel crossings at iso 0.5. */
+function softMembership(px, opaque, N, pal, p, useAlpha, matte, blendVeto, collinNear) {
+  const raw = new Float32Array(N);
+  const P = pal[p], K = pal.length;
+  // FIX 1: the matte competes for every layer whose own ink is far from it, so the
+  // ink↔background boundary lands on the 50% blend instead of the AA envelope.
+  let mC = null;
+  if (matte) {
+    const dr = P[0] - matte[0], dg = P[1] - matte[1], db = P[2] - matte[2];
+    if (dr * dr + dg * dg + db * db > 40 * 40) mC = matte;
+  }
+  // FIX 4 (blend veto): a pixel lying ON the blend segment of two OTHER inks
+  // (or ink↔matte) is their AA, not P-ink — even when P happens to be the
+  // nearest palette point (kills third-colour corner ticks on wordmark edges).
+  // Guarded by e_own: pixels on P's own ink↔matte ramp are never vetoed.
+  let pairs = null;
+  if (blendVeto && (K + (mC ? 1 : 0)) >= 3) {
+    const pts = pal.map((c, q) => ({ c, own: q === p }));
+    if (mC) pts.push({ c: mC, own: false });
+    pairs = [];
+    for (let a = 0; a < pts.length; a++) for (let b = a + 1; b < pts.length; b++) {
+      if (pts[a].own || pts[b].own) continue;
+      const dr = pts[a].c[0] - pts[b].c[0], dg = pts[a].c[1] - pts[b].c[1], db = pts[a].c[2] - pts[b].c[2];
+      if (dr * dr + dg * dg + db * db < 60 * 60) continue;      // degenerate pair
+      pairs.push([pts[a].c, pts[b].c]);
+    }
+    if (!pairs.length) pairs = null;
+  }
+  // FIX 7: gate[q] — q is a collinear intermediate on P's own ink↔matte ramp
+  // (strictly between, with margin); it may only compete near its own flat cores.
+  let gate = null, matteTwin = false;
+  if (collinNear && mC) {
+    const dPM = Math.hypot(P[0] - mC[0], P[1] - mC[1], P[2] - mC[2]);
+    for (let q = 0; q < K; q++) {
+      if (q === p || !collinNear.isCollin[q]) continue;
+      const dQP = Math.hypot(pal[q][0] - P[0], pal[q][1] - P[1], pal[q][2] - P[2]);
+      const dQM = Math.hypot(pal[q][0] - mC[0], pal[q][1] - mC[1], pal[q][2] - mC[2]);
+      if (dQP > 60 && dQM > 60 && dPM > dQM + 60 && ptSegDist3(pal[q], P, mC) <= 40) {
+        if (!gate) gate = new Array(K).fill(false);
+        gate[q] = true;
+      }
+    }
+    // matte pollution guard: transparent-bg exports whose skirt px still carry
+    // ink give a too-dark matte estimate, eroding the gated coverage boundary.
+    // When an ungated palette entry is a near-twin of the matte (a genuine
+    // background-coloured ink layer), let IT carry the background competition.
+    if (gate) {
+      for (let q = 0; q < K; q++) {
+        if (q === p || gate[q]) continue;
+        const dQM = Math.hypot(pal[q][0] - mC[0], pal[q][1] - mC[1], pal[q][2] - mC[2]);
+        if (dQM < 60) { matteTwin = true; break; }
+      }
+    }
+  }
+  const rawUn = gate ? new Float32Array(N) : null;   // FIX 7: parallel ungated field
+  if (gate) raw.rawUn = rawUn;
+  for (let i = 0, j = 0; i < N; i++, j += 4) {
+    const a8 = px[j + 3];
+    if (useAlpha ? a8 === 0 : !opaque[i]) continue;
+    let m, mUn;
+    if (K < 2 && !mC) m = mUn = 1;
+    else {
+      const r = px[j], g = px[j + 1], b = px[j + 2];
+      const dr = r - P[0], dg = g - P[1], db = b - P[2];
+      const dp = Math.sqrt(dr * dr + dg * dg + db * db);
+      let dq2 = Infinity, dq2Un = Infinity;
+      for (let q = 0; q < K; q++) {
+        if (q === p) continue;
+        const qr = r - pal[q][0], qg = g - pal[q][1], qb = b - pal[q][2];
+        const dd = qr * qr + qg * qg + qb * qb;
+        if (dd < dq2Un) dq2Un = dd;
+        if (gate && gate[q] && !collinNear[q][i]) continue;  // FIX 7
+        if (dd < dq2) dq2 = dd;
+      }
+      if (mC) {
+        const mr = r - mC[0], mg = g - mC[1], mb = b - mC[2];
+        const dd = mr * mr + mg * mg + mb * mb;
+        if (dd < dq2 && !matteTwin) dq2 = dd;
+        if (dd < dq2Un) dq2Un = dd;
+      }
+      const dq = Math.sqrt(dq2);
+      m = (dp + dq) > 0 ? dq / (dp + dq) : 0.5;
+      const dqUn = Math.sqrt(dq2Un);
+      mUn = (dp + dqUn) > 0 ? dqUn / (dp + dqUn) : 0.5;
+      if (pairs && dp > 30 && (m >= 0.4 || mUn >= 0.4)) {
+        const pix = [r, g, b];
+        const eOwn = mC ? ptSegDist3(pix, P, mC) : Infinity;
+        const lim = Math.min(0.5 * eOwn, 0.5 * dp, 20);
+        for (const [A, B] of pairs) {
+          if (ptSegDist3(pix, A, B) < lim) { if (m >= 0.4) m = 0; if (mUn >= 0.4) mUn = 0; break; }
+        }
+      }
+    }
+    raw[i] = useAlpha ? m * (a8 / 255) : m;   // CHANGE 1b: coverage-weighted membership
+    if (rawUn) rawUn[i] = useAlpha ? mUn * (a8 / 255) : mUn;
+  }
+  return raw;
+}
+
+/* ── v2 CHANGE 0: de-matte ───────────────────────────────────────────────
+   Transparent-background exports matte AA pixel RGB toward the page colour
+   (usually white): rgb_file = mix(rgb_true, matte, 1-alpha). Un-blend it:
+   rgb_true = matte + (rgb_file - matte) * 255/alpha. Kills fringe colours at
+   the SOURCE — the palette never sees the pale halo blends. Matte estimated
+   from low-alpha pixels; no-op for fully opaque images. */
+function dematte(src, N) {
+  let mr = 0, mg = 0, mb = 0, mn = 0, hasTrans = false;
+  for (let i = 0, j = 0; i < N; i++, j += 4) {
+    const a = src[j + 3];
+    if (a < 255) hasTrans = true;
+    if (a > 0 && a <= 96) { mr += src[j]; mg += src[j + 1]; mb += src[j + 2]; mn++; }
+  }
+  if (!hasTrans) return src;
+  const M = mn ? [mr / mn, mg / mn, mb / mn] : [255, 255, 255];
+  const out = new Uint8ClampedArray(src.length);
+  out.set(src);
+  for (let i = 0, j = 0; i < N; i++, j += 4) {
+    const a = src[j + 3];
+    if (a === 0 || a === 255) continue;
+    const k = 255 / a;
+    out[j]     = M[0] + (src[j]     - M[0]) * k;
+    out[j + 1] = M[1] + (src[j + 1] - M[1]) * k;
+    out[j + 2] = M[2] + (src[j + 2] - M[2]) * k;
+  }
+  return out;
+}
+
+/* ── v2 CHANGE 2: AA-fringe palette cull ─────────────────────────────────
+   Iteratively remove any palette entry f that (a) lies within `maxDist` RGB
+   units of the segment between two other surviving entries a,b (i.e. it is a
+   blend of them — an anti-alias fringe colour), AND (b) claims < areaRatio of
+   min(area(a),area(b)) pixels. Pixels reassign to the nearest survivor on the
+   next assignment pass. Iterate until stable. */
+function projSeg3(p, a, b) {
+  const abx = b[0] - a[0], aby = b[1] - a[1], abz = b[2] - a[2];
+  const apx = p[0] - a[0], apy = p[1] - a[1], apz = p[2] - a[2];
+  const ab2 = abx * abx + aby * aby + abz * abz;
+  let t = ab2 > 0 ? (apx * abx + apy * aby + apz * abz) / ab2 : 0;
+  t = t < 0 ? 0 : t > 1 ? 1 : t;
+  const q = [a[0] + t * abx, a[1] + t * aby, a[2] + t * abz];
+  const dx = p[0] - q[0], dy = p[1] - q[1], dz = p[2] - q[2];
+  return { d: Math.sqrt(dx * dx + dy * dy + dz * dz), q };
+}
+function ptSegDist3(p, a, b) {
+  const abx = b[0] - a[0], aby = b[1] - a[1], abz = b[2] - a[2];
+  const apx = p[0] - a[0], apy = p[1] - a[1], apz = p[2] - a[2];
+  const ab2 = abx * abx + aby * aby + abz * abz;
+  let t = ab2 > 0 ? (apx * abx + apy * aby + apz * abz) / ab2 : 0;
+  t = t < 0 ? 0 : t > 1 ? 1 : t;
+  const dx = apx - t * abx, dy = apy - t * aby, dz = apz - t * abz;
+  return Math.sqrt(dx * dx + dy * dy + dz * dz);
+}
+function cullFringe(px, opaque, w, h, pal, o, matteEst) {
+  const N = w * h, maxDist = o.fringeDist, areaRatio = o.fringeAreaRatio;
+  pal = pal.map(c => c.slice());
+  // CHANGE 2b prep: estimated matte colour + "near transparency" mask (Chebyshev r=3)
+  let matte = null, nearTrans = null;
+  if (o.matteAnchor) {
+    let mr = 0, mg = 0, mb = 0, mn = 0, anyTrans = false;
+    for (let i = 0, j = 0; i < N; i++, j += 4) {
+      const a = px[j + 3];
+      if (a < 255) anyTrans = true;
+      if (a > 0 && a <= 96) { mr += px[j]; mg += px[j + 1]; mb += px[j + 2]; mn++; }
+    }
+    if (anyTrans) {
+      matte = mn ? [mr / mn, mg / mn, mb / mn] : [255, 255, 255];
+      let cur = new Uint8Array(N);
+      for (let i = 0; i < N; i++) cur[i] = opaque[i] ? 0 : 1;
+      const dilate = Math.max(3, Math.round(o.matteDilate || 3));   // scale with supersample (v3: px-space correctness)
+      for (let pass = 0; pass < dilate; pass++) {      // dilate transparency (8-neighbour)
+        const nx = new Uint8Array(cur);
+        for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
+          if (cur[y * w + x]) continue;
+          let hit = 0;
+          for (let dy = -1; dy <= 1 && !hit; dy++) for (let dx = -1; dx <= 1; dx++) {
+            const xx = x + dx, yy = y + dy;
+            if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue;
+            if (cur[yy * w + xx]) { hit = 1; break; }
+          }
+          if (hit) nx[y * w + x] = 1;
+        }
+        cur = nx;
+      }
+      nearTrans = cur;
+    }
+  }
+  const matteRef = matte || matteEst;                 // FIX 2b can use the FIX 1 matte even w/o dilate mask
+  while (pal.length >= 3 || (matteRef && pal.length >= 2)) {
+    const K = pal.length, area = new Array(K).fill(0), border = new Array(K).fill(0);
+    let st = null;
+    if (o.interiorGuard) st = flatStats(px, opaque, w, h, pal);   // FIX 2b: flat interiors per entry
+    const assign = st ? st.assign : null;
+    for (let i = 0, j = 0; i < N; i++, j += 4) {
+      if (!opaque[i]) continue;
+      let best;
+      if (assign) best = assign[i];
+      else {
+        best = 0; let bd = 1e12;
+        for (let p = 0; p < K; p++) {
+          const dr = px[j] - pal[p][0], dg = px[j + 1] - pal[p][1], db = px[j + 2] - pal[p][2];
+          const dd = dr * dr + dg * dg + db * db;
+          if (dd < bd) { bd = dd; best = p; }
+        }
+      }
+      area[best] = (area[best] || 0) + 1;
+      if (nearTrans && nearTrans[i]) border[best]++;
+    }
+    if (st) area.forEach((_, i) => { area[i] = st.area[i]; });
+    // interstitial probe: fraction of f's FLAT pixels with an INKIER entry's pixel
+    // within Chebyshev radius 2 — separates barcode-gap mixtures (sandwiched
+    // between darker ink, high adjacency) from real light inks, whose flat cores
+    // only ever touch their own LIGHTER AA ramp (which must not count).
+    const dM = c => { if (!matteRef) return 0; const dr = c[0] - matteRef[0], dg = c[1] - matteRef[1], db = c[2] - matteRef[2]; return Math.sqrt(dr * dr + dg * dg + db * db); };
+    const flatAdjFrac = (f) => {
+      if (!st || !st.flatCnt[f]) return 0;
+      // a true a↔matte mixture sits near the midpoint (dM(a) ≈ 2·dM(f)); 1.6 keeps
+      // real neighbouring inks (tear charcoal/grey = 1.36) out of the test while
+      // catching genuine interstitials (maddocks black/gap-grey = 1.65), and is
+      // immune to lanczos-overshoot rims (slightly darker than f, ratio ≈ 1)
+      const inkier = pal.map((c, q) => q !== f && dM(c) > 1.6 * dM(pal[f]));
+      if (!inkier.some(Boolean)) return 0;
+      let adj = 0;
+      for (let y = 2; y < h - 2; y++) for (let x = 2; x < w - 2; x++) {
+        const i = y * w + x;
+        if (!st.flatMask[i] || assign[i] !== f) continue;
+        let hit = false;
+        for (let dy = -2; dy <= 2 && !hit; dy++) for (let dx = -2; dx <= 2; dx++) {
+          const k = i + dy * w + dx;
+          if (assign[k] >= 0 && inkier[assign[k]]) { hit = true; break; }
+        }
+        if (hit) adj++;
+      }
+      return adj / st.flatCnt[f];
+    };
+    let victim = -1, victimArea = Infinity;
+    for (let f = 0; f < K; f++) {
+      let isFringe = false;
+      if (st) {
+        const fa = st.flatCnt[f], frac = area[f] ? fa / area[f] : 0;
+        if (fa >= o.minFlat && frac >= 0.25) continue;      // real ink: solid flat interior
+        // is f on ANY blend segment (two surviving inks, or ink↔matte)?
+        let onSeg = false;
+        for (let a = 0; a < K && !onSeg; a++) {
+          if (a === f) continue;
+          for (let b = a + 1; b < K; b++) {
+            if (b === f) continue;
+            if (ptSegDist3(pal[f], pal[a], pal[b]) <= maxDist) { onSeg = true; break; }
+          }
+          if (!onSeg && matteRef && ptSegDist3(pal[f], pal[a], matteRef) <= maxDist) onSeg = true;
+        }
+        if (onSeg) {
+          if (fa < o.minFlat) isFringe = true;              // pure AA cloud — always cull
+          else isFringe = flatAdjFrac(f) >= 0.3;            // ambiguous — cull only if interstitial
+        } else if (matteRef) {
+          // FIX 8: lanczos-undershoot rim ("invented darker underlayer"). A weak
+          // entry whose colour extrapolates a real ink BEYOND its endpoint on the
+          // matte→ink axis (per-channel clamped — supersampling undershoot rings
+          // darker-saturated rims around every ink edge) is a resampling artifact,
+          // not an ink. Confirm spatially: its pixels must hug the parent ink.
+          for (let a = 0; a < K && !isFringe; a++) {
+            if (a === f || st.flatCnt[a] < o.minFlat) continue;
+            const dFA = Math.hypot(pal[f][0] - pal[a][0], pal[f][1] - pal[a][1], pal[f][2] - pal[a][2]);
+            if (dFA > 120) continue;
+            let best = Infinity;
+            for (let t = 1.05; t <= 1.6; t += 0.05) {
+              const er = clamp(matteRef[0] + t * (pal[a][0] - matteRef[0]), 0, 255);
+              const eg = clamp(matteRef[1] + t * (pal[a][1] - matteRef[1]), 0, 255);
+              const eb = clamp(matteRef[2] + t * (pal[a][2] - matteRef[2]), 0, 255);
+              const d = Math.hypot(pal[f][0] - er, pal[f][1] - eg, pal[f][2] - eb);
+              if (d < best) best = d;
+            }
+            if (best > 40) continue;
+            let adj = 0, tot = 0;                           // rim px hug the parent
+            for (let y = 2; y < h - 2; y++) for (let x = 2; x < w - 2; x++) {
+              const i = y * w + x;
+              if (assign[i] !== f) continue;
+              tot++;
+              let hit = false;
+              for (let dy = -2; dy <= 2 && !hit; dy++) for (let dx = -2; dx <= 2; dx++) {
+                if (assign[i + dy * w + dx] === a) { hit = true; break; }
+              }
+              if (hit) adj++;
+            }
+            if (tot > 0 && adj / tot >= 0.6) isFringe = true;
+          }
+        }
+        if (o._cullHook) o._cullHook({ f, pal: pal.map(c => c.slice()), fa, frac: +frac.toFixed(3), onSeg, isFringe, area: area[f] });
+      } else {
+        // legacy (v2 A/B) rules — unchanged
+        for (let a = 0; a < K && !isFringe; a++) {
+          if (a === f) continue;
+          for (let b = a + 1; b < K; b++) {
+            if (b === f) continue;
+            if (ptSegDist3(pal[f], pal[a], pal[b]) <= maxDist &&
+                area[f] < areaRatio * Math.min(area[a], area[b])) { isFringe = true; break; }
+          }
+        }
+        if (!isFringe && matte) {
+          const bf = area[f] ? border[f] / area[f] : 0;
+          if (bf >= 0.6) {
+            for (let a = 0; a < K; a++) {
+              if (a === f) continue;
+              if (ptSegDist3(pal[f], pal[a], matte) <= maxDist && area[f] < area[a]) { isFringe = true; break; }
+            }
+          }
+        }
+      }
+      if (isFringe && area[f] < victimArea) { victim = f; victimArea = area[f]; }
+    }
+    if (victim < 0) break;
+    pal.splice(victim, 1);
+  }
+  return pal;
+}
+
+/* ── v2 CHANGE 3: greedy maximal-straight-run collapse ───────────────────
+   After DP simplify, replace the ring with a maximal-straight-run polygon:
+   from anchor i find the furthest j (cyclic) such that EVERY intermediate
+   vertex lies within `tol` working px of segment(P[i],P[j]); emit j; repeat.
+   Greedy version of Potrace's optimal polygon. Walk starts at the sharpest
+   turn so a true corner anchors the first run. */
+export function straightRunCollapse(ring, tol) {
+  const n = ring.length;
+  if (n < 4) return ring;
+  let start = 0, worst = 2;
+  for (let i = 0; i < n; i++) {
+    const p0 = ring[(i - 1 + n) % n], p1 = ring[i], p2 = ring[(i + 1) % n];
+    const ax = p1[0] - p0[0], ay = p1[1] - p0[1], bx = p2[0] - p1[0], by = p2[1] - p1[1];
+    const la = Math.hypot(ax, ay) || 1, lb = Math.hypot(bx, by) || 1;
+    const cos = (ax * bx + ay * by) / (la * lb);
+    if (cos < worst) { worst = cos; start = i; }
+  }
+  const at = k => ring[(start + k) % n];
+  const tol2 = tol * tol;
+  const dseg2 = (p, a, b) => {
+    const abx = b[0] - a[0], aby = b[1] - a[1];
+    const ab2 = abx * abx + aby * aby;
+    let t = ab2 > 0 ? ((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / ab2 : 0;
+    t = t < 0 ? 0 : t > 1 ? 1 : t;
+    const dx = p[0] - a[0] - t * abx, dy = p[1] - a[1] - t * aby;
+    return dx * dx + dy * dy;
+  };
+  const out = [];
+  let i = 0;
+  while (i < n) {
+    out.push(at(i));
+    let adv = 1;
+    for (let a = 2; i + a <= n; a++) {          // candidate end at(i+a); i+a===n closes the ring
+      let ok = true;
+      const A = at(i), B = at(i + a);
+      for (let k = 1; k < a; k++) if (dseg2(at(i + k), A, B) > tol2) { ok = false; break; }
+      if (!ok) break;
+      adv = a;
+    }
+    i += adv;
+  }
+  return out;
+}
+
+function chanRange(b) {
+  const mn = [255, 255, 255], mx = [0, 0, 0];
+  b.forEach(c => { for (let k = 0; k < 3; k++) { if (c[k] < mn[k]) mn[k] = c[k]; if (c[k] > mx[k]) mx[k] = c[k]; } });
+  const r = [mx[0] - mn[0], mx[1] - mn[1], mx[2] - mn[2]];
+  const ch = r.indexOf(Math.max(...r));
+  return { ch, range: r[ch] };
+}
+
+/* ── denoise ─────────────────────────────────────────────────────────── */
+function boxBlur(src, w, h, r) {
+  if (r < 1) return src;
+  const out = new Uint8ClampedArray(src.length), tmp = new Uint8ClampedArray(src.length);
+  const pass = (inp, outp, horiz) => {
+    for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
+      let R = 0, G = 0, B = 0, A = 0, n = 0;
+      for (let k = -r; k <= r; k++) {
+        const xx = horiz ? x + k : x, yy = horiz ? y : y + k;
+        if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue;
+        const idx = (yy * w + xx) * 4; R += inp[idx]; G += inp[idx + 1]; B += inp[idx + 2]; A += inp[idx + 3]; n++;
+      }
+      const oo = (y * w + x) * 4; outp[oo] = R / n; outp[oo + 1] = G / n; outp[oo + 2] = B / n; outp[oo + 3] = A / n;
+    }
+  };
+  pass(src, tmp, true); pass(tmp, out, false);
+  return out;
+}
+
+/* ── interpolated iso-contours (marching squares with sub-pixel crossings) ─
+   Extracts the iso=level contour of a scalar FIELD, placing each edge crossing
+   by linear interpolation between corner values → smooth, anti-alias-accurate
+   boundaries. Filled-on-right winding so end→start linking traces closed loops. */
+export function isoContours(field, w, h, iso) {
+  const val = (x, y) => (x < 0 || y < 0 || x >= w || y >= h) ? -1e6 : field[y * w + x];
+  const lerp = (p, q, a, b) => { let t = (iso - a) / ((b - a) || 1e-9); if (t < 0) t = 0; else if (t > 1) t = 1; return [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t]; };
+  const segs = [];
+  for (let y = -1; y < h; y++) for (let x = -1; x < w; x++) {
+    const a = val(x, y), b = val(x + 1, y), c = val(x + 1, y + 1), d = val(x, y + 1); // tl tr br bl
+    const ci = (a > iso ? 8 : 0) | (b > iso ? 4 : 0) | (c > iso ? 2 : 0) | (d > iso ? 1 : 0);
+    if (ci === 0 || ci === 15) continue;
+    const TL = [x, y], TR = [x + 1, y], BR = [x + 1, y + 1], BL = [x, y + 1];
+    const T = () => lerp(TL, TR, a, b), R = () => lerp(TR, BR, b, c), B = () => lerp(BL, BR, d, c), L = () => lerp(TL, BL, a, d);
+    const add = (p, q) => { if (Math.abs(p[0] - q[0]) > 1e-9 || Math.abs(p[1] - q[1]) > 1e-9) segs.push([p[0], p[1], q[0], q[1]]); };  // skip degenerate corner segs
+    switch (ci) {
+      case 1: add(B(), L()); break; case 2: add(R(), B()); break; case 3: add(R(), L()); break;
+      case 4: add(T(), R()); break; case 5: add(T(), R()); add(B(), L()); break; case 6: add(T(), B()); break;
+      case 7: add(T(), L()); break; case 8: add(L(), T()); break; case 9: add(B(), T()); break;
+      case 10: add(L(), T()); add(R(), B()); break; case 11: add(R(), T()); break;
+      case 12: add(L(), R()); break; case 13: add(B(), R()); break; case 14: add(L(), B()); break;
+    }
+  }
+  const key = (x, y) => Math.round(x * 64) + ',' + Math.round(y * 64), map = new Map();
+  segs.forEach((s, i) => { const k = key(s[0], s[1]); if (!map.has(k)) map.set(k, []); map.get(k).push(i); });
+  const used = new Uint8Array(segs.length), contours = [];
+  for (let seed = 0; seed < segs.length; seed++) {
+    if (used[seed]) continue;
+    const poly = []; let cur = seed, guard = 0;
+    while (cur !== -1 && !used[cur] && guard++ < segs.length + 5) {
+      used[cur] = 1; const s = segs[cur]; poly.push([s[0], s[1]]);
+      const cand = map.get(key(s[2], s[3])); let nxt = -1;
+      if (cand) {
+        if (poly.length >= 2 && cand.includes(seed)) { poly.push(poly[0].slice()); break; }   // close on segment IDENTITY, not fuzzy key
+        for (const ci of cand) { if (!used[ci]) { nxt = ci; break; } }
+      }
+      if (nxt === -1) { poly.push([s[2], s[3]]); break; }
+      cur = nxt;
+    }
+    if (poly.length >= 3) contours.push(poly);
+  }
+  return contours;
+}
+export function polyArea(p) {
+  let a = 0;
+  for (let i = 0, n = p.length; i < n; i++) { const [x1, y1] = p[i], [x2, y2] = p[(i + 1) % n]; a += x1 * y2 - x2 * y1; }
+  return Math.abs(a / 2);
+}
+
+/* ── simplify (closed-ring Douglas–Peucker) ──────────────────────────── */
+export function simplify(pts, eps) {
+  if (eps <= 0) return pts;
+  let ring = pts;
+  if (ring.length > 1 && Math.abs(ring[0][0] - ring[ring.length - 1][0]) < 1e-6 && Math.abs(ring[0][1] - ring[ring.length - 1][1]) < 1e-6)
+    ring = ring.slice(0, -1);
+  const n = ring.length;
+  if (n < 4) return ring;
+  const at = i => ring[i % n];
+  let far = 0, dm = -1;
+  for (let i = 1; i < n; i++) { const dx = ring[i][0] - ring[0][0], dy = ring[i][1] - ring[0][1]; const d = dx * dx + dy * dy; if (d > dm) { dm = d; far = i; } }
+  const keep = new Uint8Array(n); keep[0] = 1; keep[far] = 1;
+  const dp = (a, b) => {
+    const stack = [[a, b]];
+    while (stack.length) {
+      const [s, e] = stack.pop(); if (e <= s + 1) continue;
+      const A = at(s), B = at(e); const dx = B[0] - A[0], dy = B[1] - A[1]; const len = Math.hypot(dx, dy) || 1;
+      let dmax = 0, idx = -1;
+      for (let i = s + 1; i < e; i++) { const P = at(i); const dd = Math.abs((P[0] - A[0]) * dy - (P[1] - A[1]) * dx) / len; if (dd > dmax) { dmax = dd; idx = i; } }
+      if (dmax > eps && idx > -1) { keep[idx % n] = 1; stack.push([s, idx], [idx, e]); }
+    }
+  };
+  dp(0, far); dp(far, n);
+  const res = [];
+  for (let i = 0; i < n; i++) if (keep[i]) res.push(ring[i]);
+  return res;
+}
+
+/* ── path emit ───────────────────────────────────────────────────────── */
+export function polyPath(pts) {
+  let d = 'M' + f(pts[0][0]) + ' ' + f(pts[0][1]);
+  for (let i = 1; i < pts.length; i++) d += 'L' + f(pts[i][0]) + ' ' + f(pts[i][1]);
+  return d + 'Z';
+}
+export function smoothPath(pts, tension) {
+  const n = pts.length; if (n < 3) return polyPath(pts);
+  const t = clamp(tension, 0, 1) * 0.5;
+  let d = 'M' + f(pts[0][0]) + ' ' + f(pts[0][1]);
+  for (let i = 0; i < n; i++) {
+    const p0 = pts[(i - 1 + n) % n], p1 = pts[i], p2 = pts[(i + 1) % n], p3 = pts[(i + 2) % n];
+    const c1x = p1[0] + (p2[0] - p0[0]) * t, c1y = p1[1] + (p2[1] - p0[1]) * t;
+    const c2x = p2[0] - (p3[0] - p1[0]) * t, c2y = p2[1] - (p3[1] - p1[1]) * t;
+    d += `C${f(c1x)} ${f(c1y)} ${f(c2x)} ${f(c2y)} ${f(p2[0])} ${f(p2[1])}`;
+  }
+  return d + 'Z';
+}
+const f = v => (Math.round(v * 10) / 10);
+
+/* Corner-split + least-squares Bézier fit (Schneider, Graphics Gems).
+   Classify vertices corner-vs-curve, (optionally) Laplacian-fair the curve
+   vertices, split the ring at corners, and fit each smooth span with the FEWEST
+   cubic Béziers that stay within `fitErr` px — averaging staircase noise into
+   clean curves. Straight runs & letter corners stay razor-sharp; scripts and
+   circles become genuinely smooth. `smooth` (0..1) widens the error tolerance. */
+export function fitPath(pts, smooth = 0.5, cornerDeg = 42, fair = 0, fitErr = 1.6) {
+  let n = pts.length;
+  if (n < 3) return polyPath(pts);
+  const cornerCos = Math.cos(cornerDeg * Math.PI / 180);
+  const corner = new Array(n);
+  for (let i = 0; i < n; i++) {
+    const p0 = pts[(i - 1 + n) % n], p1 = pts[i], p2 = pts[(i + 1) % n];
+    const ax = p1[0] - p0[0], ay = p1[1] - p0[1], bx = p2[0] - p1[0], by = p2[1] - p1[1];
+    const la = Math.hypot(ax, ay) || 1, lb = Math.hypot(bx, by) || 1;
+    corner[i] = (ax * bx + ay * by) / (la * lb) < cornerCos;
+  }
+  if (fair > 0) {
+    let cur = pts.map(p => p.slice());
+    for (let it = 0; it < fair; it++) {
+      const nx = cur.map(p => p.slice());
+      for (let i = 0; i < n; i++) {
+        if (corner[i]) continue;
+        const a = cur[(i - 1 + n) % n], b = cur[(i + 1) % n];
+        nx[i][0] = cur[i][0] + 0.5 * ((a[0] + b[0]) / 2 - cur[i][0]);
+        nx[i][1] = cur[i][1] + 0.5 * ((a[1] + b[1]) / 2 - cur[i][1]);
+      }
+      cur = nx;
+    }
+    pts = cur;
+  }
+  // break points: every corner. Ensure ≥2 so a corner-free loop still splits.
+  let breaks = [];
+  for (let i = 0; i < n; i++) if (corner[i]) breaks.push(i);
+  if (breaks.length === 0) {
+    let far = 0, dm = -1;
+    for (let i = 1; i < n; i++) { const dx = pts[i][0] - pts[0][0], dy = pts[i][1] - pts[0][1]; const d = dx * dx + dy * dy; if (d > dm) { dm = d; far = i; } }
+    breaks = [0, far].sort((a, b) => a - b);
+  } else if (breaks.length === 1) {
+    const c0 = breaks[0]; let far = c0, dm = -1;
+    for (let k = 1; k < n; k++) { const i = (c0 + k) % n; const dx = pts[i][0] - pts[c0][0], dy = pts[i][1] - pts[c0][1]; const d = dx * dx + dy * dy; if (d > dm) { dm = d; far = i; } }
+    breaks = [c0, far].sort((a, b) => a - b);
+  }
+  const errSq = Math.pow(fitErr * (0.6 + smooth), 2);
+  let d = 'M' + f(pts[breaks[0]][0]) + ' ' + f(pts[breaks[0]][1]);
+  for (let b = 0; b < breaks.length; b++) {
+    const a = breaks[b], e = breaks[(b + 1) % breaks.length];
+    const span = []; let i = a; while (true) { span.push(pts[i]); if (i === e) break; i = (i + 1) % n; }
+    if (span.length === 2) { d += 'L' + f(span[1][0]) + ' ' + f(span[1][1]); continue; }
+    for (const bez of fitCurve(span, errSq)) d += `C${f(bez[1][0])} ${f(bez[1][1])} ${f(bez[2][0])} ${f(bez[2][1])} ${f(bez[3][0])} ${f(bez[3][1])}`;
+  }
+  return d + 'Z';
+}
+
+/* ── Schneider least-squares cubic fitting ───────────────────────────── */
+const vsub = (a, b) => [a[0] - b[0], a[1] - b[1]], vadd = (a, b) => [a[0] + b[0], a[1] + b[1]];
+const vscale = (a, s) => [a[0] * s, a[1] * s], vdot = (a, b) => a[0] * b[0] + a[1] * b[1];
+const vlen = a => Math.hypot(a[0], a[1]), vnorm = a => { const l = vlen(a) || 1; return [a[0] / l, a[1] / l]; };
+const bez = (c, t) => { let p = c.map(v => v.slice()); for (let r = 1; r < p.length; r++) for (let i = 0; i < p.length - r; i++) p[i] = [p[i][0] + (p[i + 1][0] - p[i][0]) * t, p[i][1] + (p[i + 1][1] - p[i][1]) * t]; return p[0]; };
+function fitCurve(points, errSq) {
+  const n = points.length;
+  const tHat1 = vnorm(vsub(points[1], points[0])), tHat2 = vnorm(vsub(points[n - 2], points[n - 1]));
+  return fitCubic(points, 0, n - 1, tHat1, tHat2, errSq, 0);
+}
+function fitCubic(points, first, last, tHat1, tHat2, errSq, depth) {
+  const nPts = last - first + 1;
+  if (nPts === 2) {
+    const dist = vlen(vsub(points[last], points[first])) / 3;
+    return [[points[first], vadd(points[first], vscale(tHat1, dist)), vadd(points[last], vscale(tHat2, dist)), points[last]]];
+  }
+  let u = chordParam(points, first, last);
+  let curve = genBezier(points, first, last, u, tHat1, tHat2);
+  let { err, split } = maxError(points, first, last, curve, u);
+  if (err < errSq) return [curve];
+  if (err < errSq * 16 && depth < 20) {
+    for (let i = 0; i < 4; i++) {
+      const up = u.map((ui, k) => newton(curve, points[first + k], ui));
+      curve = genBezier(points, first, last, up, tHat1, tHat2);
+      ({ err, split } = maxError(points, first, last, curve, up));
+      if (err < errSq) return [curve];
+      u = up;
+    }
+  }
+  if (split <= first || split >= last || depth > 22) return [curve];   // give up gracefully
+  const tHatC = vnorm(vsub(points[split - 1], points[split + 1]));
+  return fitCubic(points, first, split, tHat1, tHatC, errSq, depth + 1)
+    .concat(fitCubic(points, split, last, [-tHatC[0], -tHatC[1]], tHat2, errSq, depth + 1));
+}
+function chordParam(points, first, last) {
+  const u = [0];
+  for (let i = first + 1; i <= last; i++) u.push(u[u.length - 1] + vlen(vsub(points[i], points[i - 1])));
+  const tot = u[u.length - 1] || 1;
+  return u.map(x => x / tot);
+}
+function genBezier(points, first, last, uP, tHat1, tHat2) {
+  const nPts = last - first + 1, A = [];
+  for (let i = 0; i < nPts; i++) { const u = uP[i]; A.push([vscale(tHat1, 3 * u * (1 - u) * (1 - u)), vscale(tHat2, 3 * u * u * (1 - u))]); }
+  let C00 = 0, C01 = 0, C11 = 0, X0 = 0, X1 = 0;
+  const p0 = points[first], p3 = points[last];
+  for (let i = 0; i < nPts; i++) {
+    const u = uP[i], b0 = (1 - u) ** 3, b1 = 3 * u * (1 - u) ** 2, b2 = 3 * u * u * (1 - u), b3 = u ** 3;
+    C00 += vdot(A[i][0], A[i][0]); C01 += vdot(A[i][0], A[i][1]); C11 += vdot(A[i][1], A[i][1]);
+    const tmp = vsub(points[first + i], [p0[0] * (b0 + b1) + p3[0] * (b2 + b3), p0[1] * (b0 + b1) + p3[1] * (b2 + b3)]);
+    X0 += vdot(A[i][0], tmp); X1 += vdot(A[i][1], tmp);
+  }
+  const detCC = C00 * C11 - C01 * C01, detCX = C00 * X1 - C01 * X0, detXC = X0 * C11 - X1 * C01;
+  let aL = detCC === 0 ? 0 : detXC / detCC, aR = detCC === 0 ? 0 : detCX / detCC;
+  const segLen = vlen(vsub(p3, p0)), eps = 1e-6 * segLen;
+  if (aL < eps || aR < eps) { const dist = segLen / 3; aL = dist; aR = dist; }
+  return [p0, vadd(p0, vscale(tHat1, aL)), vadd(p3, vscale(tHat2, aR)), p3];
+}
+function maxError(points, first, last, curve, u) {
+  let err = 0, split = Math.floor((first + last) / 2);
+  for (let i = first + 1; i < last; i++) {
+    const P = bez(curve, u[i - first]);
+    const dx = P[0] - points[i][0], dy = P[1] - points[i][1], d = dx * dx + dy * dy;
+    if (d >= err) { err = d; split = i; }
+  }
+  return { err, split };
+}
+function newton(Q, P, u) {
+  const Q1 = [vscale(vsub(Q[1], Q[0]), 3), vscale(vsub(Q[2], Q[1]), 3), vscale(vsub(Q[3], Q[2]), 3)];
+  const Q2 = [vscale(vsub(Q1[1], Q1[0]), 2), vscale(vsub(Q1[2], Q1[1]), 2)];
+  const Qu = bez(Q, u), Q1u = bez(Q1, u), Q2u = bez(Q2, u);
+  const num = vdot(vsub(Qu, P), Q1u), den = vdot(Q1u, Q1u) + vdot(vsub(Qu, P), Q2u);
+  if (!den) return u;
+  const nu = u - num / den;
+  return (nu < 0 || nu > 1 || Number.isNaN(nu)) ? u : nu;
+}
+
+/* ═════════════════════ v3: Potrace-style global geometry stage ═════════════════════
+   Implemented from the PAPER: Selinger, "Potrace: a polygon-based tracing algorithm"
+   (2003), https://potrace.sourceforge.net/potrace.pdf — no GPL code consulted.
+   Adapted to dense sub-pixel rings from soft-field marching squares (~1 vertex/px,
+   NOT an integer lattice path):
+     P2  straightness = Euclidean tube around the chord (the paper's max-distance +
+         "not all four directions" clause are lattice-specific and dropped);
+         possible segment i->j iff the subpath clipped-extended by one vertex at
+         both ends, v(i-1)..v(j+1), is straight (paper §2.2.2 — the clipping is
+         what prevents strange behaviour at corners). Computed per-anchor with an
+         incremental constraint-cone: each tested vertex restricts the admissible
+         chord direction to an angular interval; O(1) update + check per advance.
+     P3  penalty P(i,j) = |vj-vi| * RMS distance of subpath points from the chord
+         line, closed form from prefix sums of x, y, x², y², xy (paper §2.2.3).
+     P4  optimal polygon: lexicographic (segment count, total penalty) DP over the
+         chain obtained by cutting the ring at the sharpest windowed turn (§2.2.4).
+     P5  vertex adjustment: least-squares line fit (centroid + dominant covariance
+         eigenvector) per polygon edge; each vertex re-placed at the intersection
+         of its two adjacent lines, clamped into a disc of radius adjustR around
+         the original ring vertex (§2.3.1, unit square -> disc).
+     P6  corner analysis: alpha = 4*gamma/3 from the tolerance-square construction
+         (§2.3.3); alpha > alphaMax => hard corner (two straight lines through a_i),
+         else cubic Bézier b(i-1)->b(i) with control points at fraction
+         clamp(alpha, 0.55, 1) from the midpoints toward a_i.
+     P8  (ours, logo polish): snap edge lines within snapDeg of exact 0/90° (and
+         snap45Deg of 45°) to the exact angle BEFORE vertex intersection, then
+         merge collinear neighbours. Logos are full of axis-aligned strokes. */
+
+function prepRing(poly, minSeg) {
+  let ring = poly;
+  if (ring.length > 1 &&
+      Math.abs(ring[0][0] - ring[ring.length - 1][0]) < 1e-9 &&
+      Math.abs(ring[0][1] - ring[ring.length - 1][1]) < 1e-9)
+    ring = ring.slice(0, -1);
+  if (minSeg > 0 && ring.length > 4) {
+    const out = [ring[0]];
+    for (let i = 1; i < ring.length; i++) {
+      const p = out[out.length - 1], q = ring[i];
+      if (Math.hypot(q[0] - p[0], q[1] - p[1]) >= minSeg) out.push(q);
+    }
+    while (out.length > 3 &&
+           Math.hypot(out[0][0] - out[out.length - 1][0], out[0][1] - out[out.length - 1][1]) < minSeg)
+      out.pop();
+    if (out.length >= 4) ring = out;
+  }
+  return ring;
+}
+
+// P2: maximal pivot advance per anchor. len[i] = largest a such that i -> i+a is a
+// possible segment (early-exit at first failure, per the paper's interval property).
+export function pivotLens(R, tol) {
+  const n = R.length, len = new Int32Array(n);
+  const maxAdv = Math.max(1, n - 3);
+  for (let i = 0; i < n; i++) {
+    const anchor = R[(i - 1 + n) % n];
+    let lo = 0, hi = 0, hasRef = false, empty = false, adv = 1;
+    const addC = (k) => {
+      const p = R[k % n];
+      const dx = p[0] - anchor[0], dy = p[1] - anchor[1];
+      const d = Math.hypot(dx, dy);
+      if (d <= tol) return;                       // inside every line's tube — no constraint
+      let base = Math.atan2(dy, dx);
+      const half = Math.asin(Math.min(1, tol / d));
+      if (!hasRef) { hasRef = true; lo = base - half; hi = base + half; return; }
+      const mid = (lo + hi) / 2;
+      while (base - mid > Math.PI) base -= 2 * Math.PI;
+      while (base - mid < -Math.PI) base += 2 * Math.PI;
+      if (base - half > lo) lo = base - half;
+      if (base + half < hi) hi = base + half;
+      if (lo > hi) empty = true;
+    };
+    const dirOK = (k) => {
+      if (!hasRef) return true;
+      const p = R[k % n];
+      const dx = p[0] - anchor[0], dy = p[1] - anchor[1];
+      if (dx * dx + dy * dy < 1e-18) return false;
+      let a = Math.atan2(dy, dx);
+      const mid = (lo + hi) / 2;
+      while (a - mid > Math.PI) a -= 2 * Math.PI;
+      while (a - mid < -Math.PI) a += 2 * Math.PI;
+      return a >= lo - 1e-9 && a <= hi + 1e-9;
+    };
+    addC(i);
+    for (let a = 1; a <= maxAdv; a++) {
+      addC(i + a);                                 // test set is now i..i+a
+      if (empty) break;
+      if (!dirOK(i + a + 1)) break;                // chord anchor v(i-1) -> v(j+1)
+      adv = a;
+    }
+    len[i] = adv;
+  }
+  return len;
+}
+
+export function potracePath(poly, o, dbg) {
+  const ring0 = prepRing(poly, o.minSeg);
+  const n = ring0.length;
+  if (n < 8) return n >= 3 ? polyPath(ring0) : '';
+
+  // ── cut the ring at the sharpest windowed turn (near-certain polygon vertex) ──
+  const win = Math.max(2, Math.min(8, n >> 3));
+  let rot = 0, worst = 2;
+  for (let i = 0; i < n; i++) {
+    const p0 = ring0[(i - win + n) % n], p1 = ring0[i], p2 = ring0[(i + win) % n];
+    const ax = p1[0] - p0[0], ay = p1[1] - p0[1], bx = p2[0] - p1[0], by = p2[1] - p1[1];
+    const la = Math.hypot(ax, ay) || 1, lb = Math.hypot(bx, by) || 1;
+    const cos = (ax * bx + ay * by) / (la * lb);
+    if (cos < worst) { worst = cos; rot = i; }
+  }
+  const R = ring0.slice(rot).concat(ring0.slice(0, rot));
+
+  // ── P2: pivots ──
+  const len = pivotLens(R, o.tubeTol);
+
+  // ── P3: prefix sums (chain indices 0..n; vertex n ≡ vertex 0) ──
+  const Sx = new Float64Array(n + 2), Sy = new Float64Array(n + 2),
+        Sxx = new Float64Array(n + 2), Syy = new Float64Array(n + 2), Sxy = new Float64Array(n + 2);
+  for (let k = 1; k <= n + 1; k++) {
+    const p = R[(k - 1) % n];
+    Sx[k] = Sx[k - 1] + p[0]; Sy[k] = Sy[k - 1] + p[1];
+    Sxx[k] = Sxx[k - 1] + p[0] * p[0]; Syy[k] = Syy[k - 1] + p[1] * p[1]; Sxy[k] = Sxy[k - 1] + p[0] * p[1];
+  }
+  const pen = (i, j) => {
+    const a = R[i % n], b = R[j % n];
+    const dx = b[0] - a[0], dy = b[1] - a[1];
+    const cnt = j - i + 1;
+    const Ex = (Sx[j + 1] - Sx[i]) / cnt, Ey = (Sy[j + 1] - Sy[i]) / cnt;
+    const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
+    const A = (Sxx[j + 1] - Sxx[i]) / cnt - 2 * mx * Ex + mx * mx;
+    const B = (Sxy[j + 1] - Sxy[i]) / cnt - mx * Ey - my * Ex + mx * my;
+    const C = (Syy[j + 1] - Syy[i]) / cnt - 2 * my * Ey + my * my;
+    return Math.sqrt(Math.max(0, A * dy * dy - 2 * B * dx * dy + C * dx * dx));
+  };
+
+  // ── P4: lexicographic (count, penalty) DP over the cut chain ──
+  const segs = new Int32Array(n + 1).fill(-1);
+  const penA = new Float64Array(n + 1).fill(Infinity);
+  const prev = new Int32Array(n + 1).fill(-1);
+  segs[0] = 0; penA[0] = 0;
+  for (let i = 0; i < n; i++) {
+    if (segs[i] < 0) continue;
+    const jmax = Math.min(i + len[i], n);
+    for (let j = i + 1; j <= jmax; j++) {
+      const s = segs[i] + 1, p = penA[i] + pen(i, j);
+      if (segs[j] < 0 || s < segs[j] || (s === segs[j] && p < penA[j])) {
+        segs[j] = s; penA[j] = p; prev[j] = i;
+      }
+    }
+  }
+  if (segs[n] < 0) return polyPath(ring0);           // unreachable (degenerate) — fallback
+  const chain = [n];
+  for (let c = n; c > 0;) { c = prev[c]; chain.push(c); }
+  chain.reverse();                                    // [0, c1, ..., n]
+  let K = chain.length - 1;                           // polygon vertex/edge count
+  if (K < 3) return polyPath(ring0);
+
+  // ── P5: least-squares line per edge ──
+  const normUnit = (x, y) => { const l = Math.hypot(x, y); return l > 1e-12 ? [x / l, y / l] : [1, 0]; };
+  const fitEdge = (e0, e1) => {
+    const cnt = e1 - e0 + 1;
+    const Ex = (Sx[e1 + 1] - Sx[e0]) / cnt, Ey = (Sy[e1 + 1] - Sy[e0]) / cnt;
+    const a = R[e0 % n], b = R[e1 % n];
+    let dir;
+    if (cnt === 2) dir = normUnit(b[0] - a[0], b[1] - a[1]);
+    else {
+      const A = (Sxx[e1 + 1] - Sxx[e0]) / cnt - Ex * Ex;
+      const B = (Sxy[e1 + 1] - Sxy[e0]) / cnt - Ex * Ey;
+      const C = (Syy[e1 + 1] - Syy[e0]) / cnt - Ey * Ey;
+      const lam = (A + C) / 2 + Math.sqrt(((A - C) / 2) ** 2 + B * B);
+      let vx = B, vy = lam - A;
+      if (Math.abs(vx) < 1e-12 && Math.abs(vy) < 1e-12) { vx = A >= C ? 1 : 0; vy = A >= C ? 0 : 1; }
+      dir = normUnit(vx, vy);
+      if (dir[0] * (b[0] - a[0]) + dir[1] * (b[1] - a[1]) < 0) dir = [-dir[0], -dir[1]];
+    }
+    return { cx: Ex, cy: Ey, dir, e0, e1 };
+  };
+  const lineIntersect = (L1, L2) => {
+    const n1 = [-L1.dir[1], L1.dir[0]], n2 = [-L2.dir[1], L2.dir[0]];
+    const c1 = n1[0] * L1.cx + n1[1] * L1.cy, c2 = n2[0] * L2.cx + n2[1] * L2.cy;
+    const det = n1[0] * n2[1] - n1[1] * n2[0];
+    if (Math.abs(det) < 1e-6) return null;
+    return [(c1 * n2[1] - c2 * n1[1]) / det, (n1[0] * c2 - n2[0] * c1) / det];
+  };
+
+  // vertex/edge lists (edge t runs from vertex t to vertex t+1; support = chain range)
+  let V = [], ES = [];
+  for (let t = 0; t < K; t++) { V.push({ idx: chain[t], clampR: o.adjustR }); ES.push({ e0: chain[t], e1: chain[t + 1] }); }
+
+  // ── chamfer collapse (our adaptation) ─────────────────────────────────────
+  // On an AA-rounded corner the clipped possible-segment relation cannot reach the
+  // apex, so the DP leaves 1-2 tiny "chamfer" edges across each corner arc. Collapse
+  // every run of tiny edges bounded by two long edges into ONE vertex; P5 then puts
+  // it at the intersection of the long edges' fitted lines — the true corner.
+  // Guards keep genuine micro-features alive: real corner turn angle, intersection
+  // close to the arc, bounded run chord.
+  {
+    const CH_MAX = o.chamferMax, RUN_MAX = 1.6 * o.chamferMax;
+    const chordOf = E => { const a = R[E.e0 % n], b = R[E.e1 % n]; return Math.hypot(b[0] - a[0], b[1] - a[1]); };
+    let base = -1;
+    for (let t = 0; t < ES.length; t++) if (chordOf(ES[t]) > CH_MAX) { base = t; break; }
+    if (base >= 0) {
+      if (base > 0) { V = V.slice(base).concat(V.slice(0, base)); ES = ES.slice(base).concat(ES.slice(0, base)); }
+      const NV = [], NE = [];
+      const Kc = ES.length;
+      let skipVert = false, t = 0;
+      while (t < Kc) {
+        if (chordOf(ES[t]) > CH_MAX) {
+          if (!skipVert) NV.push(V[t]); else skipVert = false;
+          NE.push(ES[t]); t++; continue;
+        }
+        let t1 = t;
+        while (t1 + 1 < Kc && chordOf(ES[t1 + 1]) <= CH_MAX) t1++;
+        let collapsed = false;
+        if (t1 + 1 < Kc && NE.length) {                       // run not touching the seam
+          const a0 = R[ES[t].e0 % n], a1 = R[ES[t1].e1 % n];
+          const runChord = Math.hypot(a1[0] - a0[0], a1[1] - a0[1]);
+          const prevE = NE[NE.length - 1], nextE = ES[t1 + 1];
+          if (runChord <= RUN_MAX &&
+              chordOf(prevE) >= Math.max(10, 2.5 * runChord) &&
+              chordOf(nextE) >= Math.max(10, 2.5 * runChord)) {
+            const L1 = fitEdge(prevE.e0, prevE.e1), L2 = fitEdge(nextE.e0, nextE.e1);
+            const crossD = L1.dir[0] * L2.dir[1] - L1.dir[1] * L2.dir[0];
+            const dotD = L1.dir[0] * L2.dir[0] + L1.dir[1] * L2.dir[1];
+            const turn = Math.abs(Math.atan2(crossD, dotD)) * 180 / Math.PI;
+            if (turn > 18 && turn < 160) {
+              const X = lineIntersect(L1, L2);
+              if (X) {
+                const midIdx = Math.round((ES[t].e0 + ES[t1].e1) / 2);
+                const M = R[midIdx % n];
+                const dm = Math.hypot(X[0] - M[0], X[1] - M[1]);
+                const d0 = Math.hypot(X[0] - a0[0], X[1] - a0[1]);
+                const d1 = Math.hypot(X[0] - a1[0], X[1] - a1[1]);
+                if (dm <= 0.85 * runChord + 1.0 && d0 <= runChord + 1.2 && d1 <= runChord + 1.2) {
+                  NV.push({ idx: midIdx, clampR: Math.max(o.adjustR, dm + 0.5) });
+                  skipVert = true;                             // consumed vertex t1+1 too
+                  collapsed = true;
+                }
+              }
+            }
+          }
+        }
+        if (!collapsed) {
+          for (let k = t; k <= t1; k++) {
+            if (k === t) { if (!skipVert) NV.push(V[k]); else skipVert = false; }
+            else NV.push(V[k]);
+            NE.push(ES[k]);
+          }
+        }
+        t = t1 + 1;
+      }
+      if (NV.length === NE.length && NV.length >= 3) { V = NV; ES = NE; }
+    }
+  }
+  K = V.length;
+  if (K < 3) return polyPath(ring0);
+
+  let edges = ES.map(E => fitEdge(E.e0, E.e1));
+
+  // ── P8: axis snap (rotate the fitted line about its centroid) ──
+  if (o.axisSnap) {
+    for (const E of edges) {
+      const th = Math.atan2(E.dir[1], E.dir[0]) * 180 / Math.PI;
+      const mod90 = ((th % 90) + 90) % 90;
+      const d0 = Math.min(mod90, 90 - mod90);
+      const d45 = Math.abs(mod90 - 45);
+      let delta = 0;
+      if (d0 <= o.snapDeg) delta = mod90 < 45 ? -mod90 : (90 - mod90);
+      else if (d45 <= o.snap45Deg) delta = 45 - mod90;
+      if (delta !== 0) {
+        const r = (th + delta) * Math.PI / 180;
+        E.dir = [Math.cos(r), Math.sin(r)];
+      }
+      // FIX 10b: a LONG snapped exact-vertical/horizontal line whose offset is
+      // fractional renders as a sustained half-covered column/row — a blend
+      // colour a crisp steppy source never contains. Land long snapped lines
+      // on the working-px boundary grid (integers in pixel-centre coords);
+      // rounding error <= 0.5 working px, unbiased. Short edges (glyph stems)
+      // keep their sub-px placement — their mass is too small for the blend
+      // mismatch to register, but their position error would compound.
+      {
+        const a = R[E.e0 % n], b = R[E.e1 % n];
+        if (Math.hypot(b[0] - a[0], b[1] - a[1]) >= 10) {
+          if (Math.abs(E.dir[0]) < 1e-9) E.cx = Math.round(E.cx);
+          else if (Math.abs(E.dir[1]) < 1e-9) E.cy = Math.round(E.cy);
+        }
+      }
+    }
+  }
+
+  // ── P5 (cont.): vertex = intersection of adjacent lines, clamped into disc ──
+  const placeVertex = (E1, E2, v0, r) => {
+    const X = lineIntersect(E1, E2);
+    let x, y;
+    if (!X) {                                          // near-parallel: average projections of v0
+      const n1 = [-E1.dir[1], E1.dir[0]], n2 = [-E2.dir[1], E2.dir[0]];
+      const d1 = n1[0] * (v0[0] - E1.cx) + n1[1] * (v0[1] - E1.cy);
+      const d2 = n2[0] * (v0[0] - E2.cx) + n2[1] * (v0[1] - E2.cy);
+      x = v0[0] - (d1 * n1[0] + d2 * n2[0]) / 2;
+      y = v0[1] - (d1 * n1[1] + d2 * n2[1]) / 2;
+    } else { x = X[0]; y = X[1]; }
+    const dx = x - v0[0], dy = y - v0[1], d = Math.hypot(dx, dy);
+    if (d > r) { const s = r / d; x = v0[0] + dx * s; y = v0[1] + dy * s; }
+    return [x, y];
+  };
+  let A = [], polyIdx = [];
+  for (let t = 0; t < K; t++) {
+    A.push(placeVertex(edges[(t - 1 + K) % K], edges[t], R[V[t].idx % n], V[t].clampR));
+    polyIdx.push(V[t].idx);
+  }
+
+  // merge collinear neighbours created by snapping (vertex within 0.15px of its neighbours' segment)
+  for (let t = 0; t < A.length && A.length > 3;) {
+    const Kc = A.length;
+    const p0 = A[(t - 1 + Kc) % Kc], p1 = A[t], p2 = A[(t + 1) % Kc];
+    const ux = p2[0] - p0[0], uy = p2[1] - p0[1], L = Math.hypot(ux, uy) || 1;
+    const dev = Math.abs((p1[0] - p0[0]) * uy - (p1[1] - p0[1]) * ux) / L;
+    if (dev < 0.15) {
+      A.splice(t, 1); polyIdx.splice(t, 1);
+      const Ke = ES.length, tp = (t - 1 + Ke) % Ke;
+      ES[tp] = { e0: ES[tp].e0, e1: ES[t % Ke].e1 };   // merge supports (debug only)
+      ES.splice(t % Ke, 1);
+    }
+    else t++;
+  }
+  K = A.length;
+  if (K < 3) return polyPath(ring0);
+
+  // debug: per-edge max residual of supporting ring points vs final polygon segment
+  if (dbg) {
+    const eds = [];
+    for (let t = 0; t < K; t++) {
+      const e0 = ES[t].e0, e1 = ES[t].e1;
+      const a = A[t], b = A[(t + 1) % K];
+      const ux = b[0] - a[0], uy = b[1] - a[1], L = Math.hypot(ux, uy) || 1;
+      let mx = 0;
+      for (let k = e0; k <= e1; k++) {
+        const p = R[k % n];
+        const d = Math.abs((p[0] - a[0]) * uy - (p[1] - a[1]) * ux) / L;
+        if (d > mx) mx = d;
+      }
+      eds.push({ support: e1 - e0 + 1, len: L, angle: Math.atan2(uy, ux) * 180 / Math.PI, maxResid: mx });
+    }
+    dbg.push({ n, K, edges: eds, A: A.map(p => p.slice()) });
+  }
+
+  // ── P6: corner analysis → pieces (each piece runs b(i-1) -> b(i) via vertex a_i) ──
+  const mids = A.map((p, t) => { const q = A[(t + 1) % K]; return [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2]; });
+  let pieces = [];
+  for (let s = 1; s <= K; s++) {
+    const t = s % K;
+    const aP = A[t], bPrev = mids[s - 1], bCur = mids[t];
+    const ex = bCur[0] - bPrev[0], ey = bCur[1] - bPrev[1];
+    const eL = Math.hypot(ex, ey);
+    let alpha;
+    if (eL < 1e-9) alpha = 2;                          // degenerate: corner
+    else {
+      const nx = -ey / eL, ny = ex / eL;
+      const d = Math.abs((aP[0] - bPrev[0]) * nx + (aP[1] - bPrev[1]) * ny);
+      const delta = o.cornerBox * (Math.abs(nx) + Math.abs(ny)) / 2;
+      const gamma = d > 1e-12 ? (d - delta) / d : 0;
+      alpha = 4 * gamma / 3;
+    }
+    if (alpha > o.alphaMax) pieces.push({ corner: true, a: aP, from: bPrev, to: bCur });
+    else {
+      const al = Math.max(0.55, Math.min(1, alpha));
+      pieces.push({
+        corner: false, a: aP, from: bPrev, to: bCur,
+        c1: [bPrev[0] + al * (aP[0] - bPrev[0]), bPrev[1] + al * (aP[1] - bPrev[1])],
+        c2: [bCur[0] + al * (aP[0] - bCur[0]), bCur[1] + al * (aP[1] - bCur[1])],
+      });
+    }
+  }
+
+  // ── P7: opticurve (paper §2.4) ──
+  if (o.optiCurve && pieces.length > 2) pieces = optiCurve(pieces, o.optTol);
+
+  // ── emit ──
+  const f2 = v => Math.round(v * 100) / 100;
+  const cmds = [];
+  for (const p of pieces) {
+    if (p.corner) { cmds.push(['L', p.a[0], p.a[1]]); cmds.push(['L', p.to[0], p.to[1]]); }
+    else cmds.push(['C', p.c1[0], p.c1[1], p.c2[0], p.c2[1], p.to[0], p.to[1]]);
+  }
+  // cleanup: drop L endpoints collinear with their neighbours (corner-corner midpoints)
+  const start = pieces[0].from;
+  for (let pass = 0; pass < 4; pass++) {
+    let changed = false;
+    for (let i = 0; i + 1 < cmds.length; i++) {
+      if (cmds[i][0] !== 'L' || cmds[i + 1][0] !== 'L') continue;
+      const p0 = i === 0 ? start : cmds[i - 1].slice(-2);
+      const p1 = [cmds[i][1], cmds[i][2]], p2 = [cmds[i + 1][1], cmds[i + 1][2]];
+      const ux = p2[0] - p0[0], uy = p2[1] - p0[1], L = Math.hypot(ux, uy) || 1;
+      const dev = Math.abs((p1[0] - p0[0]) * uy - (p1[1] - p0[1]) * ux) / L;
+      if (dev < 0.05) { cmds.splice(i, 1); changed = true; i--; }
+    }
+    if (!changed) break;
+  }
+  let d = `M${f2(start[0])} ${f2(start[1])}`;
+  for (const c of cmds) {
+    if (c[0] === 'L') d += `L${f2(c[1])} ${f2(c[2])}`;
+    else d += `C${f2(c[1])} ${f2(c[2])} ${f2(c[3])} ${f2(c[4])} ${f2(c[5])} ${f2(c[6])}`;
+  }
+  return d + 'Z';
+}
+
+/* ── P7: curve optimization (paper §2.4) ──────────────────────────────────
+   Join runs of consecutive smooth Bézier pieces that agree in convexity and
+   turn < 179° total into single Béziers: endpoints keep their tangents (so the
+   join is tangent to b(j)a(j+1) and a(k)b(k)); the free parameter alpha is set
+   by matching the enclosed area; the candidate is accepted iff for every
+   interior polygon edge a(i)a(i+1) the point on the candidate with parallel
+   tangent lies within `tol` of that segment. DP minimizes (count, sum d²). */
+const bezPt = (Z, t) => {
+  const u = 1 - t;
+  return [
+    u * u * u * Z[0][0] + 3 * u * u * t * Z[1][0] + 3 * u * t * t * Z[2][0] + t * t * t * Z[3][0],
+    u * u * u * Z[0][1] + 3 * u * u * t * Z[1][1] + 3 * u * t * t * Z[2][1] + t * t * t * Z[3][1],
+  ];
+};
+// signed ∮(x dy − y dx)/2 contribution of a cubic segment (4-pt Gauss–Legendre, exact)
+const GL_T = [0.069431844202973, 0.330009478207572, 0.669990521792428, 0.930568155797027];
+const GL_W = [0.173927422568727, 0.326072577431273, 0.326072577431273, 0.173927422568727];
+function bezAreaTerm(Z) {
+  let s = 0;
+  for (let g = 0; g < 4; g++) {
+    const t = GL_T[g], u = 1 - t;
+    const x = u * u * u * Z[0][0] + 3 * u * u * t * Z[1][0] + 3 * u * t * t * Z[2][0] + t * t * t * Z[3][0];
+    const y = u * u * u * Z[0][1] + 3 * u * u * t * Z[1][1] + 3 * u * t * t * Z[2][1] + t * t * t * Z[3][1];
+    const dx = 3 * ((Z[1][0] - Z[0][0]) * u * u + 2 * (Z[2][0] - Z[1][0]) * u * t + (Z[3][0] - Z[2][0]) * t * t);
+    const dy = 3 * ((Z[1][1] - Z[0][1]) * u * u + 2 * (Z[2][1] - Z[1][1]) * u * t + (Z[3][1] - Z[2][1]) * t * t);
+    s += GL_W[g] * (x * dy - y * dx);
+  }
+  return s / 2;
+}
+function optiCurve(pieces, tol) {
+  const m0 = pieces.length;
+  // rotate so a corner (if any) sits at index 0 — runs then never wrap
+  let rot = -1;
+  for (let i = 0; i < m0; i++) if (pieces[i].corner) { rot = i; break; }
+  const P = rot > 0 ? pieces.slice(rot).concat(pieces.slice(0, rot)) : pieces.slice();
+  const out = [];
+  let i = 0;
+  while (i < m0) {
+    if (P[i].corner) { out.push(P[i]); i++; continue; }
+    let j = i;
+    while (j + 1 < m0 && !P[j + 1].corner) j++;
+    const run = P.slice(i, j + 1);
+    for (const p of optiRun(run, tol)) out.push(p);
+    i = j + 1;
+  }
+  return out;
+}
+function optiRun(run, tol) {
+  const m = run.length;
+  if (m < 2) return run;
+  // exterior turn per piece (signed, at its vertex) + prefix sums
+  const ext = run.map(p => {
+    const u1 = [p.a[0] - p.from[0], p.a[1] - p.from[1]];
+    const u2 = [p.to[0] - p.a[0], p.to[1] - p.a[1]];
+    return Math.atan2(u1[0] * u2[1] - u1[1] * u2[0], u1[0] * u2[0] + u1[1] * u2[1]);
+  });
+  const MAX_TURN = 179 * Math.PI / 180;
+  // per-piece area terms (piece cubic + nothing else; chords handled per candidate)
+  const pieceArea = run.map(p => bezAreaTerm([p.from, p.c1, p.c2, p.to]));
+  const preA = [0], preT = [0], preSgn = [0];
+  for (let k = 0; k < m; k++) {
+    preA.push(preA[k] + pieceArea[k]);
+    preT.push(preT[k] + Math.abs(ext[k]));
+    preSgn.push(preSgn[k] + Math.sign(ext[k]));
+  }
+  const tryJoin = (u, v) => {              // join pieces u..v-1 into one cubic; null if not acceptable
+    const cnt = v - u;
+    if (cnt < 2) return null;
+    if (preT[v] - preT[u] >= MAX_TURN) return null;
+    if (Math.abs(preSgn[v] - preSgn[u]) !== cnt) return null;      // convexity agreement
+    const z0 = run[u].from, z3 = run[v - 1].to;
+    const d1 = [run[u].a[0] - z0[0], run[u].a[1] - z0[1]];
+    const d2 = [z3[0] - run[v - 1].a[0], z3[1] - run[v - 1].a[1]];
+    const det = d1[0] * (-d2[1]) - d1[1] * (-d2[0]);
+    if (Math.abs(det) < 1e-9) return null;
+    // z0 + s·d1 = z3 − r·d2
+    const rx = z3[0] - z0[0], ry = z3[1] - z0[1];
+    const s = (rx * (-d2[1]) - ry * (-d2[0])) / det;
+    const r = (d1[0] * ry - d1[1] * rx) / det;
+    if (s <= 1e-9 || r >= -1e-9) return null;                       // O ahead of z0 (s>0), behind z3 (r<0: O = z3 + r·d2)
+    const O = [z0[0] + s * d1[0], z0[1] + s * d1[1]];
+    // enclosed area: composite curve pieces + closing chord z3->z0 (closed loop, origin-independent)
+    const Achord = (preA[v] - preA[u]) + (z3[0] * z0[1] - z3[1] * z0[0]) / 2;
+    // triangle z0, O, z3 (same loop orientation)
+    const AtriLoop = ((z0[0] * O[1] - z0[1] * O[0]) + (O[0] * z3[1] - O[1] * z3[0]) + (z3[0] * z0[1] - z3[1] * z0[0])) / 2;
+    if (Math.abs(AtriLoop) < 1e-9) return null;
+    const rho = Achord / AtriLoop;
+    if (!(rho > 0.02)) return null;
+    const disc = 4 - 10 * rho / 3;
+    if (disc < 0) return null;
+    const alpha = 2 - Math.sqrt(disc);
+    if (!(alpha > 0.05 && alpha <= 1.0)) return null;
+    const Z = [z0, [z0[0] + alpha * (O[0] - z0[0]), z0[1] + alpha * (O[1] - z0[1])],
+                   [z3[0] + alpha * (O[0] - z3[0]), z3[1] + alpha * (O[1] - z3[1])], z3];
+    // tangency acceptance on interior polygon edges a(k) -> a(k+1)
+    let pen = 0;
+    for (let k = u; k < v - 1; k++) {
+      const a1 = run[k].a, a2 = run[k + 1].a;
+      const dx = a2[0] - a1[0], dy = a2[1] - a1[1];
+      const L = Math.hypot(dx, dy);
+      if (L < 1e-9) return null;
+      // B'(t) ∥ (dx,dy): cross(B'(t), dir) = 0 → quadratic in t
+      const p1 = [Z[1][0] - Z[0][0], Z[1][1] - Z[0][1]];
+      const p2 = [Z[2][0] - Z[1][0], Z[2][1] - Z[1][1]];
+      const p3 = [Z[3][0] - Z[2][0], Z[3][1] - Z[2][1]];
+      const c0 = p1[0] * dy - p1[1] * dx;
+      const c1q = p2[0] * dy - p2[1] * dx;
+      const c2q = p3[0] * dy - p3[1] * dx;
+      const qa = c0 - 2 * c1q + c2q, qb = 2 * (c1q - c0), qc = c0;
+      const roots = [];
+      if (Math.abs(qa) < 1e-12) { if (Math.abs(qb) > 1e-12) roots.push(-qc / qb); }
+      else {
+        const D = qb * qb - 4 * qa * qc;
+        if (D >= 0) { const sq = Math.sqrt(D); roots.push((-qb + sq) / (2 * qa), (-qb - sq) / (2 * qa)); }
+      }
+      let best = null;
+      for (const t of roots) {
+        if (!(t >= -1e-9 && t <= 1 + 1e-9)) continue;
+        const z = bezPt(Z, Math.min(1, Math.max(0, t)));
+        const proj = ((z[0] - a1[0]) * dx + (z[1] - a1[1]) * dy) / (L * L);
+        if (proj < -0.15 || proj > 1.15) continue;
+        const d = Math.abs((z[0] - a1[0]) * dy - (z[1] - a1[1]) * dx) / L;
+        if (best === null || d < best) best = d;
+      }
+      if (best === null || best > tol) return null;
+      pen += best * best;
+    }
+    return { pen, Z };
+  };
+  // DP over boundaries 0..m minimizing (count, penalty)
+  const cnt = new Int32Array(m + 1).fill(1 << 29);
+  const pen = new Float64Array(m + 1).fill(Infinity);
+  const prv = new Int32Array(m + 1).fill(-1);
+  const arc = new Array(m + 1).fill(null);
+  cnt[0] = 0; pen[0] = 0;
+  for (let u = 0; u < m; u++) {
+    if (cnt[u] >= (1 << 29)) continue;
+    // single piece
+    if (cnt[u] + 1 < cnt[u + 1] || (cnt[u] + 1 === cnt[u + 1] && pen[u] < pen[u + 1])) {
+      cnt[u + 1] = cnt[u] + 1; pen[u + 1] = pen[u]; prv[u + 1] = u; arc[u + 1] = null;
+    }
+    for (let v = u + 2; v <= m; v++) {
+      if (preT[v] - preT[u] >= MAX_TURN) break;
+      const J = tryJoin(u, v);
+      if (!J) continue;
+      const c = cnt[u] + 1, p = pen[u] + J.pen;
+      if (c < cnt[v] || (c === cnt[v] && p < pen[v])) { cnt[v] = c; pen[v] = p; prv[v] = u; arc[v] = J.Z; }
+    }
+  }
+  // reconstruct
+  const outR = [];
+  let v = m;
+  const segsR = [];
+  while (v > 0) { segsR.push(v); v = prv[v]; }
+  segsR.reverse();
+  let u = 0;
+  for (const vv of segsR) {
+    if (arc[vv] && prv[vv] === u && vv - u >= 2) {
+      const Z = arc[vv];
+      outR.push({ corner: false, a: null, from: Z[0], to: Z[3], c1: Z[1], c2: Z[2] });
+    } else if (vv - u === 1) outR.push(run[u]);
+    else { for (let k = u; k < vv; k++) outR.push(run[k]); } // shouldn't happen
+    u = vv;
+  }
+  return outR;
+}

+ 202 - 0
skills/svg-brand-tint-ops/references/tri-tone-and-trace.md

@@ -0,0 +1,202 @@
+# The colour math, the trace engine, and the theme-aware bake
+
+Everything the tuner does, explained so you can reproduce any piece of it in a
+production app without the UI. Three parts: the **tone-map** (recolour), the
+**image-trace** (raster → vector), and the **token-driven theme-aware pattern**
+(how to bake a live result into a real page). Ends with a gotchas ledger.
+
+---
+
+## 1. The tone map — desaturate → per-channel ramp → CSS tune
+
+Recolouring an arbitrary SVG to a brand palette is three stacked stages. The
+first two are one SVG `<filter>`; the third is a CSS `filter` on top.
+
+### Stage 1 — kill the source hue
+
+```xml
+<feColorMatrix type="saturate" values="0"/>
+```
+
+Collapses every colour to its luminance grey. Now the image is a single grey
+ramp from black (0) to white (1) — a clean input the next stage can re-map.
+Skip this stage (the tuner's *Desaturate* toggle) only when you want the ramp to
+act on the source's raw R/G/B channels independently, which produces a channel-
+shift effect rather than a true tint.
+
+### Stage 2 — remap the grey ramp to N brand colours
+
+`feComponentTransfer` runs an independent transfer function per channel. With
+`type="table"`, each `<feFunc*>`'s `tableValues` are read as **equally-spaced
+control points across the input [0,1]**, linearly interpolated between. So to
+map *grey → brand ramp* you feed each channel the ramp colours' channel values:
+
+```xml
+<feComponentTransfer>
+  <feFuncR type="table" tableValues="r0 r1 r2 … rN"/>   <!-- reds of each stop  -->
+  <feFuncG type="table" tableValues="g0 g1 g2 … gN"/>   <!-- greens of each stop -->
+  <feFuncB type="table" tableValues="b0 b1 b2 … bN"/>   <!-- blues of each stop  -->
+</feComponentTransfer>
+```
+
+- **2 stops → duotone** (shadows → colour A, highlights → colour B).
+- **3 stops → tri-tone** (`lines → ink`, `midtones → accent`, `faces → canvas`).
+  This is the sweet spot: two-stop duotones interpolate the *whole* midrange as a
+  flat blend of the two endpoints and look washed / muddy. A third mid stop
+  (usually the brand **accent**) gives the midtones somewhere to land and the
+  result reads as intentional. **If your duotone looks washed out, add an accent
+  midstop** — that single change is the difference between "greyscale with a
+  colour cast" and "designed".
+- **4–5 stops** → quad/penta-tone for posterised, screen-print looks.
+
+**Arbitrary stop positions.** `tableValues` positions are *fixed at equal
+spacing*. To honour custom stop positions (a stop at 0.2 vs 0.5), don't try to
+encode position into the table — **resample**. Walk the sorted `(colour,
+position)` stops and emit a fixed number of equally-spaced samples (the tuner
+uses 33) by piecewise-linear interpolation along position. Equal-spaced stops
+resample to themselves, so it's a strict superset. This is `buildToneFilter()`
+in `index.html`.
+
+### Stage 3 — CSS tune on top
+
+```css
+filter: url(#tonemap) saturate(1.55) contrast(1.05) brightness(1);
+```
+
+`saturate()` is the headline dial — the ramp interpolation slightly desaturates
+midtones, and lifting saturation back up (1.4–1.8) makes the tint sing. Add
+`contrast`/`brightness`/`hue-rotate`/`sepia`/`blur`/`drop-shadow` as needed. The
+order matters: `url(#tonemap)` **first** (it defines the colour), CSS functions
+after (they adjust it).
+
+### `color-interpolation-filters`
+
+Set it explicitly on the filter. `sRGB` remaps in gamma space (what you see in
+the pickers — predictable, slightly punchier midtones). `linearRGB` (the SVG
+default!) remaps in linear light — physically "correct" blends but midtones read
+darker and often surprise designers. The tuner defaults to `sRGB` for WYSIWYG.
+
+---
+
+## 2. The image-trace engine — raster → clean vector
+
+The engine lives in **`assets/trace-core.mjs`** (`traceImage(imageData, opts)`), a
+pure, dependency-free module shared by the browser tool (fed a `<canvas>`
+ImageData) and the headless CLI (`scripts/trace.mjs`, fed decoded pixels) — one
+implementation, no drift. Nothing is uploaded; pixels are read locally. Tuned on
+a 24-logo accuracy bench (source PNG → trace → re-rasterise → per-pixel + edge
+fidelity), it moved mean RMSE from **≈27 → ≈11.5** and edge-F1 **0.66 → 0.86** vs
+the naïve first cut. The pipeline and *why each step earns its place*:
+
+1. **Supersample** the source to ~2× (`super`) before reading pixels. The logos
+   are ~320 px; tracing at 2× places every edge crossing with sub-pixel accuracy,
+   then the resolution-independent SVG is crisp at any size. 2× was the measured
+   sweet spot — 3× only adds bytes.
+2. **Alpha-aware luminance + opacity** per pixel. Fully-transparent pixels are
+   excluded from every layer, so a logo on a transparent/white background keeps
+   its background transparent instead of tracing a giant rectangle.
+3. **Segment into layers** by mode:
+   - **B&W** — one field, `luma < threshold`.
+   - **Steps (posterise)** — `levels` cumulative luminance masks, lightest-largest
+     at the bottom to darkest on top.
+   - **Color** — **median-cut** seeds → **k-means (Lloyd) refinement** snaps the
+     seeds onto the true flat colours (dissolving muddy anti-alias intermediates)
+     → **palette merge** collapses entries within `mergeDist` (≈48), which removes
+     the near-duplicate anti-alias fringe shades that otherwise become overlapping
+     tint layers on a near-monochrome logo. Each surviving colour is one binary
+     membership field, painted biggest-area first.
+4. **Interpolated iso-contours** (`isoContours`) — marching squares at iso 0.5 but
+   with **linear interpolation of each edge crossing** between corner values, so
+   the boundary lands at its true sub-pixel position rather than a pixel
+   staircase. Consistent filled-on-right winding links segments into closed loops;
+   `fill-rule="evenodd"` cuts holes. (Saddles 5/10 resolved "separate".)
+5. **Simplify** each loop with closed-ring **Douglas–Peucker** (`detail` epsilon,
+   scaled by the supersample factor).
+6. **Corner-split + least-squares Bézier fit** (`fitPath`, Schneider's algorithm)
+   — the key to sharp *and* smooth logos. Each vertex is classified corner-vs-curve
+   by turn angle (`cornerDeg`); the ring is split at corners, and curve vertices
+   are **Laplacian-faired** (`fair`). Then each smooth span is fitted with the
+   **fewest cubic Béziers that stay within `fitErr` px** (recursive subdivision +
+   Newton-Raphson reparameterisation) — which *averages out* the staircase noise
+   into clean curves instead of interpolating through it. Straight runs and letter
+   corners stay razor-sharp (lines, pinned); scripts and circles become genuinely
+   smooth. This is what keeps **NGV a crisp rectangle** and **Grill'd a smooth
+   script** from the same code. Feed it denser points (lower `detail`) for higher
+   fidelity, raise `fitErr`/`smooth` for fewer, softer curves.
+7. **Despeckle** on true occupied pixel area.
+
+Output is a plain `<svg>` of `<path>`s that flows straight into the tone map:
+**PNG → trace → SVG → brand-tint** is one continuous move.
+
+**Honest scope.** A flat-art tracer, not a centreline/stroke tracer. It excels at
+logos, icons, flat marks, and posterised art — bold/filled/geometric logos come
+out near-perfect; very thin or small text (≈1 px strokes in a low-res source)
+stays readable but shows some edge roughness, the fundamental limit of
+vectorising an anti-aliased raster. Raise `colors`/`super`, lower `detail` for
+more fidelity at the cost of path count. Tuning knobs and their effects are the
+`DEFAULTS` block at the top of `trace-core.mjs`.
+
+---
+
+## 3. Baking a live result into an app (the theme-aware pattern)
+
+The whole point of a *token-driven* tint: define the ramp from your theme tokens
+and it re-themes for free on light/dark switch. The rules that make it work:
+
+### Render the SVG **inline**, never as `<img>`
+
+An inline `<svg>` participates in the document: page `@font-face`s resolve on its
+`<text>`, `var(--token)` reads your CSS custom properties, and a CSS `filter:
+url(#id)` referencing an in-document `<filter>` applies. An `<img src="…svg">`
+**sandboxes** all of that — external font `@import`s are blocked, `var()` can't
+see your tokens, and a document filter id won't resolve. If your baked tint or
+font "just doesn't apply", this is almost always why.
+
+### Filter primitives can't read CSS variables — build the ramp in JS
+
+`tableValues` is a static attribute; it cannot reference `var(--ink)`. So read
+the tokens at runtime and write the numbers:
+
+```js
+function applyToneRamp(el, filter) {
+  const cs = getComputedStyle(el);
+  const stops = ['--ink', '--accent', '--canvas'].map(v => cs.getPropertyValue(v).trim());
+  const ch = stops.map(hexToRgb01);                        // [[r,g,b], …]
+  filter.querySelector('feFuncR').setAttribute('tableValues', ch.map(c => c[0]).join(' '));
+  filter.querySelector('feFuncG').setAttribute('tableValues', ch.map(c => c[1]).join(' '));
+  filter.querySelector('feFuncB').setAttribute('tableValues', ch.map(c => c[2]).join(' '));
+  el.style.filter = `url(#${filter.id}) saturate(1.7)`;
+}
+```
+
+Call it on mount **and whenever the theme changes** (a `MutationObserver` on
+`documentElement`'s `class`/`data-theme`, or your theme hook). On dark mode the
+same `--ink/--accent/--canvas` resolve to different hexes and the artwork
+re-tints with zero per-colour edits.
+
+### Strip the root `width`/`height`
+
+Remove the fixed `width`/`height` from the `<svg>` (keep the `viewBox`) so it
+scales fluidly to its container via CSS. Add `preserveAspectRatio="xMidYMid
+meet"`. The tuner's *Strip fixed width/height* toggle does exactly this.
+
+The tuner's **Export → React snippet** emits this shape pre-filled with your
+current ramp.
+
+---
+
+## 4. Gotchas ledger
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| Duotone looks washed / muddy | Only 2 stops — midrange is a flat blend | Add an **accent midstop** (tri-tone) |
+| Midtones darker than the pickers suggest | `color-interpolation-filters` defaulting to `linearRGB` | Set `sRGB` explicitly on the `<filter>` |
+| Font / `var()` tokens don't apply | SVG loaded as `<img>` — sandboxed | Render **inline** |
+| `tableValues` won't pick up `--ink` | Filter primitives can't read CSS vars | Read tokens in JS, write the numbers; rebuild on theme change |
+| SVG won't scale to its box | Fixed `width`/`height` on root | Strip them, keep `viewBox` + `preserveAspectRatio` |
+| Trace output is inverted (art became holes) | `luma < threshold` selects the *dark* pixels | Toggle **Invert luminance** or move the threshold |
+| Trace is slow / path count explodes | Working resolution too high, `detail` too low | Lower `resolution`, raise `detail`, raise `despeckle` |
+| Traced holes filled solid | Missing even-odd rule | `fill-rule="evenodd"` on each layer's path |
+| PNG export blank / "tainted canvas" | The rasterised SVG referenced a cross-origin resource | Inline/embed resources; trace output and self-contained SVGs are safe |
+| PNG export missing the CSS look | `ctx.filter` didn't get the photographic chain | Bake tone-map into the SVG, apply the CSS functions via `ctx.filter` before `drawImage` |
+| Recolour ignores some shapes | They carry inline `fill`/`stroke` the CSS filter still tints — but a `<style>` override may need `!important` | Scope an injected `<style>` and use `!important` (the tuner's Strokes & Fills does this) |

+ 135 - 0
skills/svg-brand-tint-ops/scripts/server.mjs

@@ -0,0 +1,135 @@
+#!/usr/bin/env node
+// Zero-dependency static server for the SVG brand-tint studio (a designer tool).
+//
+// Usage:   node server.mjs [--root DIR] [--port N] [--open]
+// Input:   flags only (no stdin). ROOT / PORT env vars are honoured as fallbacks.
+// Output:  stdout stays clean (data contract) — nothing but --help text.
+// Stderr:  the "serving on <url>" status line, warnings, and errors.
+// Exit:    0 ok / help, 2 usage (bad flag), 5 precondition (port in use / no web root).
+//
+// The web root defaults to the sibling assets/ directory (where index.html lives in
+// the skill layout); falls back to the script's own directory for a flat checkout.
+// Drop a `diagram.svg` (or `sample.svg`) into the served root to have it auto-load.
+//
+// Examples:
+//   node server.mjs                       # serve ../assets on http://localhost:4322
+//   PORT=8080 node server.mjs             # pick a port via env
+//   node server.mjs --root ./my-icons     # serve a folder of your own SVGs/PNGs
+//   node server.mjs --port 0              # ephemeral port (printed to stderr) — used by tests
+
+import { createServer } from 'node:http';
+import { readFile, access } from 'node:fs/promises';
+import { constants as FS } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join, extname, normalize, resolve, sep } from 'node:path';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+
+const TYPES = {
+  '.html': 'text/html; charset=utf-8',
+  '.svg': 'image/svg+xml',
+  '.js': 'text/javascript; charset=utf-8',
+  '.mjs': 'text/javascript; charset=utf-8',
+  '.css': 'text/css; charset=utf-8',
+  '.json': 'application/json; charset=utf-8',
+  '.png': 'image/png',
+  '.jpg': 'image/jpeg',
+  '.jpeg': 'image/jpeg',
+  '.webp': 'image/webp',
+  '.gif': 'image/gif',
+  '.ico': 'image/x-icon',
+};
+
+const HELP = `svg-brand-tint studio — zero-dependency static server
+
+Usage:   node server.mjs [--root DIR] [--port N] [--open]
+
+Options:
+  --root DIR   Directory to serve (default: sibling assets/, else this script's dir)
+  --port N     Port to listen on (default: $PORT or 4322; 0 = ephemeral)
+  --open       Print the URL only (no server) — for scripting
+  -h, --help   Show this help and exit
+
+Examples:
+  node server.mjs                     # serve the studio on http://localhost:4322
+  PORT=8080 node server.mjs           # choose a port via env var
+  node server.mjs --root ./my-icons   # serve your own folder of SVGs / PNGs
+  node server.mjs --port 0            # ephemeral port (printed to stderr)
+`;
+
+function parseArgs(argv) {
+  const out = { root: process.env.ROOT || '', port: process.env.PORT, open: false };
+  for (let i = 0; i < argv.length; i++) {
+    const a = argv[i];
+    if (a === '-h' || a === '--help') return { help: true };
+    else if (a === '--open') out.open = true;
+    else if (a === '--root') out.root = argv[++i] ?? '';
+    else if (a === '--port') out.port = argv[++i] ?? '';
+    else if (a.startsWith('--root=')) out.root = a.slice(7);
+    else if (a.startsWith('--port=')) out.port = a.slice(7);
+    else return { error: `unknown argument: ${a}` };
+  }
+  return out;
+}
+
+async function hasIndex(dir) {
+  try { await access(join(dir, 'index.html'), FS.R_OK); return true; } catch { return false; }
+}
+
+async function pickRoot(explicit) {
+  if (explicit) return resolve(explicit);
+  const assets = resolve(HERE, '..', 'assets');
+  if (await hasIndex(assets)) return assets;
+  return HERE;
+}
+
+const args = parseArgs(process.argv.slice(2));
+if (args.help) { process.stdout.write(HELP); process.exit(0); }
+if (args.error) { process.stderr.write(`error: ${args.error}\n\n${HELP}`); process.exit(2); }
+
+const root = await pickRoot(args.root);
+if (!(await hasIndex(root))) {
+  process.stderr.write(`error: no index.html found in web root: ${root}\n` +
+    `hint: run from the skill (serves ../assets) or pass --root DIR\n`);
+  process.exit(5);
+}
+
+const port = Number(args.port) || (args.port === '0' ? 0 : 4322);
+
+if (args.open) {
+  process.stderr.write(`web root: ${root}\nopen:     http://localhost:${port || 4322}/\n`);
+  process.exit(0);
+}
+
+const server = createServer(async (req, res) => {
+  try {
+    let p = decodeURIComponent((req.url || '/').split('?')[0]);
+    if (p === '/' || p === '') p = '/index.html';
+    const file = normalize(join(root, p));
+    if (file !== root && !file.startsWith(root + sep)) {   // path-traversal guard
+      res.writeHead(403); return res.end('forbidden');
+    }
+    const data = await readFile(file);
+    res.writeHead(200, {
+      'content-type': TYPES[extname(file).toLowerCase()] || 'application/octet-stream',
+      'cache-control': 'no-store',
+    });
+    res.end(data);
+  } catch {
+    res.writeHead(404); res.end('not found');
+  }
+});
+
+server.on('error', (err) => {
+  if (err && err.code === 'EADDRINUSE') {
+    process.stderr.write(`error: port ${port} is already in use — set PORT or pass --port N\n`);
+    process.exit(5);
+  }
+  process.stderr.write(`error: ${err && err.message ? err.message : err}\n`);
+  process.exit(1);
+});
+
+server.listen(port, () => {
+  const actual = server.address().port;
+  process.stderr.write(`svg-brand-tint studio -> http://localhost:${actual}/  (root: ${root})\n`);
+});

+ 150 - 0
skills/svg-brand-tint-ops/scripts/trace.mjs

@@ -0,0 +1,150 @@
+#!/usr/bin/env node
+// Headless raster→vector tracer — PNG/JPG/WebP → SVG using the shared engine.
+//
+// Wraps assets/trace-core.mjs (the same code the browser tool runs) with a PNG
+// decoder so logos can be vectorised from the command line or an agent. The
+// browser tool is zero-dependency; this CLI needs `sharp` only to decode pixels.
+//
+// Pipeline: decode → 2x lanczos supersample (sharp resize; --super N to change)
+// → v3 engine (soft-field + matte machinery + Potrace-style global geometry,
+// all ON by default in the engine) → SVG at native size. Pixel-space knobs are
+// specified at the 2x-supersample reference scale and scaled with --super so a
+// different factor behaves identically in native px. Fine hairline/barcode art
+// (sub-pixel strokes at 2x) benefits from --super 4.
+//
+// Usage:   node trace.mjs <input> [output.svg] [OPTIONS]
+// Input:   an image file (png/jpg/webp). Output path optional (else stdout).
+// Output:  SVG on stdout when no output path is given (data only).
+// Stderr:  progress + the fidelity-relevant settings.
+// Exit:    0 ok, 2 usage, 3 input-not-found, 5 missing-dep (sharp), 1 error.
+//
+// Options (defaults tuned for flat brand logos):
+//   --mode color|bw|poster   trace mode (default color)
+//   --colors N               palette size for color mode (default 6)
+//   --super N                supersample factor for sub-pixel edges (default 2;
+//                            use 4 for hairline serifs / fine barcode marks)
+//   --detail N               simplify tolerance, native px (default 0.35;
+//                            only used when the potrace stage is disabled)
+//   --smooth N               curve tension 0..1 (default 0.5)
+//   --merge N                merge palette colours within N (default 48)
+//   --despeckle N            drop features under N px², native (default 4)
+//   --threshold N            bw luminance cut (default 128)
+//   --levels N               poster bands (default 4)
+//   --invert                 invert luminance (bw/poster)
+//
+// Examples:
+//   node trace.mjs logo.png logo.svg
+//   node trace.mjs logo.png --colors 8 --super 2 > logo.svg
+//   node trace.mjs wordmark.png wordmark.svg --super 4
+//   node trace.mjs icon.png icon.svg --mode bw --threshold 140
+
+import { readFile, writeFile, access } from 'node:fs/promises';
+import { constants as FS } from 'node:fs';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { dirname, resolve } from 'node:path';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const argv = process.argv.slice(2);
+const has = f => argv.includes(f);
+const flag = (f, d) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : d; };
+
+if (has('-h') || has('--help') || argv.length === 0) {
+  process.stdout.write(HELP()); process.exit(0);
+}
+function HELP() {
+  return `headless raster→vector tracer (PNG/JPG/WebP → SVG)
+
+Usage:   node trace.mjs <input> [output.svg] [OPTIONS]
+
+Options:
+  --mode color|bw|poster  (default color)   --colors N     palette size (default 6)
+  --super N   supersample (default 2; 4 for hairline/barcode art)
+  --detail N  simplify px, non-potrace path (default 0.35)
+  --smooth N  curve tension (default 0.5)     --merge N      merge colours < N (default 48)
+  --despeckle N native px² (default 4)        --threshold N  bw cut (default 128)
+  --levels N  poster bands (default 4)        --invert       invert luminance
+  -h, --help
+
+Examples:
+  node trace.mjs logo.png logo.svg
+  node trace.mjs logo.png --colors 8 > logo.svg
+  node trace.mjs wordmark.png wordmark.svg --super 4
+  node trace.mjs icon.png icon.svg --mode bw --threshold 140
+`;
+}
+
+// positionals (first two non-flag, non-flag-value tokens)
+const flagsWithVal = new Set(['--mode', '--colors', '--super', '--detail', '--smooth', '--merge', '--despeckle', '--threshold', '--levels']);
+const pos = [];
+for (let i = 0; i < argv.length; i++) {
+  const a = argv[i];
+  if (a === '--invert') continue;
+  if (flagsWithVal.has(a)) { i++; continue; }
+  if (a.startsWith('--') || a.startsWith('-')) { process.stderr.write(`error: unknown flag ${a}\n`); process.exit(2); }
+  pos.push(a);
+}
+const input = pos[0], output = pos[1];
+if (!input) { process.stderr.write('error: no input file\n'); process.exit(2); }
+
+try { await access(resolve(input), FS.R_OK); }
+catch { process.stderr.write(`error: input not found: ${input}\n`); process.exit(3); }
+
+// engine + decoder
+let traceImage, sharp;
+try { ({ traceImage } = await import(pathToFileURL(resolve(HERE, '..', 'assets', 'trace-core.mjs')).href)); }
+catch (e) { process.stderr.write(`error: cannot load trace-core.mjs (${e.message})\n`); process.exit(1); }
+try { sharp = (await import('sharp')).default; }
+catch { process.stderr.write('error: `sharp` is required to decode images.\n  install it:  npm i sharp   (or:  uv tool install --from …)\n  the browser tool needs no dependency — this CLI only needs a pixel decoder.\n'); process.exit(5); }
+
+const superF = Math.max(1, Math.min(8, Number(flag('--super', 2)) || 2));
+const opts = {
+  mode: flag('--mode', 'color'),
+  colors: Number(flag('--colors', 6)),
+  smooth: Number(flag('--smooth', 0.5)),
+  mergeDist: Number(flag('--merge', 48)),
+  despeckle: Number(flag('--despeckle', 4)) * superF * superF,
+  cornerDeg: 40, fair: 1, fitErr: 1.5,
+  detail: Number(flag('--detail', 0.35)) * superF,   // only used on the non-potrace path
+  smoothPx: 1,
+  threshold: Number(flag('--threshold', 128)),
+  levels: Number(flag('--levels', 4)),
+  invert: has('--invert'),
+  // field/palette machinery (engine defaults; restated so the CLI config is explicit)
+  softField: true, alphaField: true, fringeCull: true, matteAnchor: true, demat: true,
+  straightRun: false, runTol: 0.75, fringeAreaRatio: 0.25,
+  matteDilate: Math.ceil(3 * superF / 2),            // matte-halo border band scales with supersample
+  matteField: true, polarize: true, interiorGuard: true,
+  minFlat: Math.round(16 * superF * superF / 4),     // 2x-reference px²
+  fieldSharp: 1.0,
+  sharpR: Math.max(1, Math.round(2 * superF / 2)),
+  minLoop: 0,
+  blendVeto: true, isoArea: true,
+  // Potrace-style global geometry stage — px-space knobs are specified at the
+  // 2x-supersample reference scale and scaled with superF so a different
+  // factor behaves identically in native px
+  potrace: true,
+  tubeTol: 1.0 * superF / 2,
+  alphaMax: 1.0,
+  cornerBox: 1.0 * superF / 2,
+  adjustR: 0.75 * superF / 2,
+  chamferMax: 4.5 * superF / 2,
+  axisSnap: true, snapDeg: 0.75, snap45Deg: 0.4,
+  minSeg: 0.2 * superF / 2,
+  optiCurve: true, optTol: 0.2 * superF / 2,
+};
+
+const buf = await readFile(resolve(input));
+const meta = await sharp(buf).metadata();
+const W = Math.max(2, Math.round(meta.width * superF)), H = Math.max(2, Math.round(meta.height * superF));
+// libvips lanczos-family upscale (sharp's default kernel) — supersampling stays
+// in the caller; the engine only ever sees decoded RGBA pixels.
+const { data } = await sharp(buf).resize(W, H, { fit: 'fill' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
+
+const t0 = Date.now();
+let svg = traceImage({ data, width: W, height: H }, opts);
+svg = svg.replace('<svg ', `<svg width="${meta.width}" height="${meta.height}" `);
+const paths = (svg.match(/<path/g) || []).length;
+process.stderr.write(`traced ${input} (${meta.width}x${meta.height}, ${superF}x) → ${paths} paths, ${svg.length} B in ${Date.now() - t0}ms\n`);
+
+if (output) { await writeFile(resolve(output), svg); process.stderr.write(`wrote ${output}\n`); }
+else process.stdout.write(svg + '\n');

+ 90 - 0
skills/svg-brand-tint-ops/tests/run.sh

@@ -0,0 +1,90 @@
+#!/usr/bin/env bash
+# Offline self-test for svg-brand-tint-ops: the server contract + that it serves
+# the studio (tri-tone filter + trace engine) and the sample.
+#
+# Self-contained, no network. Resolves paths relative to itself so it runs both
+# in the repo and once installed to ~/.claude/skills/svg-brand-tint-ops/.
+# Skips gracefully (exit 0) when node or curl is unavailable.
+#
+# Usage:   bash tests/run.sh
+# Exit:    0 all pass (or skipped on unsupported host), 1 one or more failures
+
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SKILL="$(dirname "$HERE")"
+SERVER="$SKILL/scripts/server.mjs"
+INDEX="$SKILL/assets/index.html"
+SAMPLE="$SKILL/assets/sample.svg"
+
+PASS=0; FAIL=0
+ok(){ PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
+no(){ FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
+expect_exit(){ [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; }
+has(){ case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
+
+echo "=== svg-brand-tint-ops self-test ==="
+
+# ── static content sanity (no runtime needed) ──────────────────────────────
+[[ -f "$INDEX" ]] && ok "index.html present" || no "index.html missing"
+idx="$(cat "$INDEX" 2>/dev/null)"
+has "index carries the tri-tone filter" "feComponentTransfer" "$idx"
+has "index references the tone-map filter" "url(#tonemap)" "$idx"
+has "index ships the iso-contour tracer" "isoContours" "$idx"
+has "index has the Image Trace panel" "Image Trace" "$idx"
+[[ -f "$SAMPLE" ]] && ok "sample.svg present" || no "sample.svg missing"
+
+# ── runtime: needs node + curl ─────────────────────────────────────────────
+if ! command -v node >/dev/null 2>&1; then echo "  SKIP  node not found — runtime checks skipped"; echo "=== $PASS passed, $FAIL failed ==="; [[ "$FAIL" -eq 0 ]] || exit 1; exit 0; fi
+if ! command -v curl >/dev/null 2>&1; then echo "  SKIP  curl not found — runtime checks skipped"; echo "=== $PASS passed, $FAIL failed ==="; [[ "$FAIL" -eq 0 ]] || exit 1; exit 0; fi
+
+# server contract (offline, fast)
+node "$SERVER" --help >/dev/null 2>&1;  expect_exit "server --help" 0 $?
+node "$SERVER" --bogus >/dev/null 2>&1; expect_exit "server bad flag -> 2" 2 $?
+EMPTY="$(mktemp -d)"; node "$SERVER" --root "$EMPTY" >/dev/null 2>&1; expect_exit "server no-web-root -> 5" 5 $?; rmdir "$EMPTY" 2>/dev/null
+
+# ── engine (trace-core.mjs) + headless CLI (trace.mjs) ─────────────────────
+CORE="$SKILL/assets/trace-core.mjs"; CLI="$SKILL/scripts/trace.mjs"
+node --check "$CORE" >/dev/null 2>&1; expect_exit "trace-core.mjs compiles" 0 $?
+node --check "$CLI"  >/dev/null 2>&1; expect_exit "trace.mjs compiles" 0 $?
+# engine smoke — trace a synthetic 8x8 two-colour image, expect a valid SVG
+sm="$(cd "$SKILL" && node --input-type=module -e 'import("./assets/trace-core.mjs").then(m=>{const w=8,h=8,d=new Uint8ClampedArray(w*h*4);for(let y=0;y<h;y++)for(let x=0;x<w;x++){const i=(y*w+x)*4,v=x<4?0:255;d[i]=v;d[i+1]=v;d[i+2]=v;d[i+3]=255;}const s=m.traceImage({data:d,width:w,height:h},{mode:"color",colors:2});process.stdout.write((s.includes("<path")&&s.includes("</svg>"))?"OK":"BAD");}).catch(()=>process.stdout.write("ERR"))' 2>&1)"
+case "$sm" in *OK*) ok "trace-core traces a synthetic image";; *) no "trace-core smoke ($sm)";; esac
+# CLI contract
+node "$CLI" --help >/dev/null 2>&1;            expect_exit "trace.mjs --help" 0 $?
+node "$CLI" x.png --bogus >/dev/null 2>&1;     expect_exit "trace.mjs bad flag -> 2" 2 $?
+node "$CLI" /no/such-file.png >/dev/null 2>&1; expect_exit "trace.mjs missing input -> 3" 3 $?
+node "$CLI" "$INDEX" "$(mktemp -u).svg" >/dev/null 2>&1; rc=$?
+case "$rc" in 5) ok "trace.mjs missing-dep -> 5 (needs sharp; browser tool doesn't)";; 1) ok "trace.mjs decoder present (non-image input -> 1)";; *) no "trace.mjs decoder path (got $rc)";; esac
+
+# boot the server on an ephemeral port and probe it
+LOG="$(mktemp)"; PORT_FILE="$(mktemp)"
+node "$SERVER" --port 0 >/dev/null 2>"$LOG" &
+SRV=$!
+trap 'kill "$SRV" >/dev/null 2>&1' EXIT
+PORT=""
+for _ in $(seq 1 40); do
+  PORT="$(sed -n 's#.*http://localhost:\([0-9]\{1,\}\)/.*#\1#p' "$LOG" | head -1)"
+  [[ -n "$PORT" ]] && break
+  sleep 0.1
+done
+
+if [[ -z "$PORT" ]]; then
+  no "server printed a listening URL"; cat "$LOG" >&2
+else
+  ok "server bound ephemeral port $PORT"
+  code="$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/" 2>/dev/null)"
+  expect_exit "GET / -> 200" 200 "$code"
+  body="$(curl -s "http://localhost:$PORT/" 2>/dev/null)"
+  has "served index has tri-tone filter" "feComponentTransfer" "$body"
+  has "served index has the tracer" "isoContours" "$body"
+  scode="$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/sample.svg" 2>/dev/null)"
+  expect_exit "GET /sample.svg -> 200" 200 "$scode"
+  tcode="$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/../server.mjs" 2>/dev/null)"
+  case "$tcode" in 403|404) ok "path traversal blocked (../server.mjs -> $tcode)";; *) no "path traversal not blocked (got $tcode)";; esac
+fi
+kill "$SRV" >/dev/null 2>&1; trap - EXIT
+rm -f "$LOG" "$PORT_FILE" 2>/dev/null
+
+echo "=== $PASS passed, $FAIL failed ==="
+[[ "$FAIL" -eq 0 ]] || exit 1