luckiest-co 1.0.13 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/skills/luckiest-ab-testing/SKILL.md +1 -1
  4. package/skills/luckiest-ad-creative/SKILL.md +1 -1
  5. package/skills/luckiest-ads/SKILL.md +1 -1
  6. package/skills/luckiest-advisors/SKILL.md +1 -1
  7. package/skills/luckiest-aeo-grader/SKILL.md +1 -1
  8. package/skills/luckiest-ai-seo/SKILL.md +1 -1
  9. package/skills/luckiest-analytics/SKILL.md +1 -1
  10. package/skills/luckiest-aso/SKILL.md +1 -1
  11. package/skills/luckiest-churn-prevention/SKILL.md +1 -1
  12. package/skills/luckiest-co-marketing/SKILL.md +1 -1
  13. package/skills/luckiest-coder/SKILL.md +1 -1
  14. package/skills/luckiest-coder-brainstorming/SKILL.md +1 -1
  15. package/skills/luckiest-coder-consistency/SKILL.md +1 -1
  16. package/skills/luckiest-coder-constitution/SKILL.md +1 -1
  17. package/skills/luckiest-coder-debugging/SKILL.md +1 -1
  18. package/skills/luckiest-coder-dispatching-parallel-agents/SKILL.md +1 -1
  19. package/skills/luckiest-coder-executing-plans/SKILL.md +1 -1
  20. package/skills/luckiest-coder-finishing-a-branch/SKILL.md +1 -1
  21. package/skills/luckiest-coder-git-worktrees/SKILL.md +1 -1
  22. package/skills/luckiest-coder-guard/SKILL.md +1 -1
  23. package/skills/luckiest-coder-qa/SKILL.md +1 -1
  24. package/skills/luckiest-coder-receiving-code-review/SKILL.md +1 -1
  25. package/skills/luckiest-coder-requesting-code-review/SKILL.md +1 -1
  26. package/skills/luckiest-coder-shipping/SKILL.md +1 -1
  27. package/skills/luckiest-coder-subagent-driven-development/SKILL.md +1 -1
  28. package/skills/luckiest-coder-tdd/SKILL.md +1 -1
  29. package/skills/luckiest-coder-verification/SKILL.md +1 -1
  30. package/skills/luckiest-coder-writing-plans/SKILL.md +1 -1
  31. package/skills/luckiest-coder-writing-skills/SKILL.md +1 -1
  32. package/skills/luckiest-cold-email/SKILL.md +1 -1
  33. package/skills/luckiest-community-marketing/SKILL.md +1 -1
  34. package/skills/luckiest-competitor-profiling/SKILL.md +1 -1
  35. package/skills/luckiest-competitors/SKILL.md +1 -1
  36. package/skills/luckiest-content-strategy/SKILL.md +1 -1
  37. package/skills/luckiest-copy-editing/SKILL.md +1 -1
  38. package/skills/luckiest-copywriting/SKILL.md +1 -1
  39. package/skills/luckiest-cro/SKILL.md +1 -1
  40. package/skills/luckiest-customer-research/SKILL.md +1 -1
  41. package/skills/luckiest-directory-submissions/SKILL.md +1 -1
  42. package/skills/luckiest-emails/SKILL.md +1 -1
  43. package/skills/luckiest-extract-design-system/SKILL.md +1 -1
  44. package/skills/luckiest-free-tools/SKILL.md +1 -1
  45. package/skills/luckiest-image/SKILL.md +1 -1
  46. package/skills/luckiest-launch/SKILL.md +1 -1
  47. package/skills/luckiest-lead-magnets/SKILL.md +1 -1
  48. package/skills/luckiest-marketing-ideas/SKILL.md +1 -1
  49. package/skills/luckiest-marketing-plan/SKILL.md +1 -1
  50. package/skills/luckiest-marketing-psychology/SKILL.md +1 -1
  51. package/skills/luckiest-model-router/SKILL.md +1 -1
  52. package/skills/luckiest-offers/SKILL.md +1 -1
  53. package/skills/luckiest-onboarding/SKILL.md +1 -1
  54. package/skills/luckiest-paywalls/SKILL.md +1 -1
  55. package/skills/luckiest-popups/SKILL.md +1 -1
  56. package/skills/luckiest-pricing/SKILL.md +1 -1
  57. package/skills/luckiest-product-marketing/SKILL.md +1 -1
  58. package/skills/luckiest-programmatic-seo/SKILL.md +1 -1
  59. package/skills/luckiest-prompt-rewrite/SKILL.md +1 -1
  60. package/skills/luckiest-prospecting/SKILL.md +1 -1
  61. package/skills/luckiest-public-relations/SKILL.md +1 -1
  62. package/skills/luckiest-referrals/SKILL.md +1 -1
  63. package/skills/luckiest-revops/SKILL.md +1 -1
  64. package/skills/luckiest-sales-enablement/SKILL.md +1 -1
  65. package/skills/luckiest-schema/SKILL.md +1 -1
  66. package/skills/luckiest-seo-audit/SKILL.md +1 -1
  67. package/skills/luckiest-session-handoff/SKILL.md +1 -1
  68. package/skills/luckiest-signup/SKILL.md +1 -1
  69. package/skills/luckiest-site-architecture/SKILL.md +1 -1
  70. package/skills/luckiest-sms/SKILL.md +1 -1
  71. package/skills/luckiest-social/SKILL.md +1 -1
  72. package/skills/luckiest-thinker/SKILL.md +1 -1
  73. package/skills/luckiest-trends/SKILL.md +1 -1
  74. package/skills/luckiest-video/SKILL.md +1 -1
  75. package/skills/luckiest-website-cloner/ATTRIBUTION.md +29 -0
  76. package/skills/luckiest-website-cloner/CHANGELOG.md +8 -0
  77. package/skills/luckiest-website-cloner/LICENSE +21 -0
  78. package/skills/luckiest-website-cloner/SKILL.md +289 -0
  79. package/skills/luckiest-website-cloner/references/builtin-browser-recipes.md +74 -0
  80. package/skills/luckiest-website-cloner/references/compositing.md +65 -0
  81. package/skills/luckiest-website-cloner/references/fidelity-fast-path.md +92 -0
  82. package/skills/luckiest-website-cloner/references/playwright-cli-recipes.md +68 -0
  83. package/skills/luckiest-website-cloner/references/teardown.md +142 -0
  84. package/skills/luckiest-website-cloner/references/visual-qa.md +102 -0
  85. package/skills/luckiest-website-cloner/scripts/build-bundle.cjs +12 -0
  86. package/skills/luckiest-website-cloner/scripts/motion-probe.js +423 -0
  87. package/skills/luckiest-website-cloner/scripts/surface-map.js +392 -0
  88. package/skills/luckiest-website-cloner/scripts/tokens-probe.js +178 -0
  89. package/skills/luckiest-website-cloner-dom/ATTRIBUTION.md +4 -0
  90. package/skills/luckiest-website-cloner-dom/CHANGELOG.md +8 -0
  91. package/skills/luckiest-website-cloner-dom/LICENSE +21 -0
  92. package/skills/luckiest-website-cloner-dom/SKILL.md +170 -0
  93. package/skills/luckiest-website-cloner-dom/references/asset-resolution.md +93 -0
  94. package/skills/luckiest-website-cloner-dom/references/interaction-fingerprints.md +85 -0
  95. package/skills/luckiest-website-cloner-remix/ATTRIBUTION.md +4 -0
  96. package/skills/luckiest-website-cloner-remix/CHANGELOG.md +8 -0
  97. package/skills/luckiest-website-cloner-remix/LICENSE +21 -0
  98. package/skills/luckiest-website-cloner-remix/SKILL.md +202 -0
  99. package/skills/luckiest-website-cloner-remix/references/directions.md +82 -0
  100. package/skills/luckiest-website-cloner-remix/references/reskin.md +84 -0
  101. package/skills/luckiest-website-cloner-remix/references/tweak-panel.md +72 -0
  102. package/skills/luckiest-website-cloner-remix/scripts/apply-overrides.cjs +105 -0
  103. package/skills/luckiest-website-cloner-remix/scripts/gallery.cjs +71 -0
  104. package/skills/luckiest-website-cloner-remix/scripts/tokenize-css.cjs +127 -0
  105. package/skills/luckiest-website-cloner-remix/scripts/tweak-panel.js +169 -0
  106. package/skills/luckiest-website-cloner-shaders/ATTRIBUTION.md +11 -0
  107. package/skills/luckiest-website-cloner-shaders/CHANGELOG.md +8 -0
  108. package/skills/luckiest-website-cloner-shaders/LICENSE +21 -0
  109. package/skills/luckiest-website-cloner-shaders/SKILL.md +129 -0
  110. package/skills/luckiest-website-cloner-shaders/references/capture-backends.md +89 -0
  111. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/SKILL.md +123 -0
  112. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/references/capture-backends.md +201 -0
  113. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/references/evidence-policy.md +93 -0
  114. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/references/operating-contract.md +82 -0
  115. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/references/qa-failure-policy.md +100 -0
  116. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/references/recon-kernel.md +206 -0
  117. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/references/replay-policy.md +145 -0
  118. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/references/shaders-com.md +212 -0
  119. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/references/source-analysis.md +112 -0
  120. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/references/surface-discovery.md +113 -0
  121. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/references/target-lock.md +205 -0
  122. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/references/three-shader-reconstruction.md +155 -0
  123. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/references/tool-capability-matrix.md +57 -0
  124. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/references/unicorn-studio.md +387 -0
  125. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/scripts/fetch-rendered-dom.mjs +178 -0
  126. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/scripts/scan-bundle.sh +80 -0
  127. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/templates/extraction-report.md +41 -0
  128. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/templates/known-gaps.md +14 -0
  129. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/templates/qa-report.md +74 -0
  130. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/templates/replay-manifest.json +147 -0
  131. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/templates/run-state.json +75 -0
  132. package/skills/luckiest-website-cloner-shaders/vendor/web-shader-extractor/templates/scout-card.json +106 -0
@@ -0,0 +1,82 @@
1
+ # Directions — three forks, not five shades
2
+
3
+ A "direction" is an overrides file (tokens + motion + shaders + assets). Three
4
+ is the number: two is a coin flip, five converges into blends. Each direction
5
+ must differ from the others on **at least three** of the forks below, and the
6
+ set should span the axis the user cares about (ask one question if unclear:
7
+ "louder or quieter than the original?").
8
+
9
+ ## The forks
10
+
11
+ | Fork | What changes | Knobs it touches |
12
+ |---|---|---|
13
+ | **Palette mood** | dark / light / tinted-neutral / saturated | `--rx-color-bg`, `--rx-color-text`, accent, surface tints |
14
+ | **Type pairing** | grotesk-only / serif display + grotesk text / mono-led / expressive display | `--rx-font-1..n`, display size + tracking |
15
+ | **Hero treatment** | photo / illustration / generated art / pure type / GPU-led | hero asset key, shader props, overlay gradient |
16
+ | **Density** | airy (×1.25 spacing) / original / compact (×0.8) | `--rx-space-*`, max-widths, radii |
17
+ | **Motion personality** | still (reveals only) / original / springy (shorter, overshoot ease) / cinematic (longer, bigger travel) | `motion.reveal.{y,duration,ease}`, stagger, shader speed |
18
+ | **Surface** | flat / bordered / elevated (shadows) / glass | radius, shadow, border color, backdrop |
19
+
20
+ ## Deriving three from the original
21
+
22
+ Read the original's position on each fork from `TEARDOWN.md`. Then:
23
+
24
+ 1. **Nearest-legit** — keeps the original's strongest fork (the thing that
25
+ made the user pick this site) and flips three others. Safe pick.
26
+ 2. **Opposite** — flips the original's defining fork (dark→light, serif→mono,
27
+ photo→type). Shows the skeleton survives a different skin.
28
+ 3. **Wildcard** — pulled from the user's taste library (`/taste-vault`,
29
+ reference images, a named site) rather than from the original. The one
30
+ that might be *better* than the source.
31
+
32
+ Name each with a two-word slug (`editorial-quiet`, `night-signal`,
33
+ `paper-brutal`) and a one-line thesis. The thesis is the test: if you can't
34
+ say why a viewer would *feel* different, it's a shade, not a direction.
35
+
36
+ ## Rendering the contact sheet
37
+
38
+ Screenshot each direction's hero + one content section at 1440, compose
39
+ three columns with slug + thesis captions → `remix/directions.png`. Present
40
+ only that plus three lines. Don't pre-argue for one; the user's reaction to
41
+ the sheet is the data.
42
+
43
+ ## Overrides file checklist
44
+
45
+ - tokens: only keys that change (the base supplies the rest)
46
+ - motion: full objects for what changes (`reveal`, `stagger`, `nav`) so the
47
+ behaviors read one shape
48
+ - shaders: per-mount props; keep the library, change the props
49
+ - assets: per key; generated files under `remix/generated/<slug>-*.ext`
50
+ - `thesis` filled in
51
+
52
+ ## Amplitude — ask before deriving
53
+
54
+ Ask the user first: **subtle / medium / big**, and **any aesthetics in mind?**
55
+
56
+ | Amplitude | What moves | Files |
57
+ |---|---|---|
58
+ | subtle | palette, accent, motion timing, shader colors | `<slug>.json` |
59
+ | medium | + radius/border/shadow, spacing scale, type scale + weights, grain | + `<slug>.patch.css` |
60
+ | big | + hero treatment, layout (grid ↔ editorial ↔ bento), section order, nav style, own hero asset | + `<slug>.patch.js` + generated assets |
61
+
62
+ Subtle side by side reads as "the same site in three coats" — say so when
63
+ recommending; default to **medium** unless the user is exploring brand identity
64
+ (then big). A direction file may declare `"amplitude": "medium"` explicitly.
65
+
66
+ **Patch rules.** `patch.css` is scoped under `:root[data-rx="<slug>"]` and only
67
+ *adds* rules. `patch.js` default-exports `function (rx) {}` and may reorder
68
+ sections, swap a hero variant (e.g. hide the image layer, enlarge the headline),
69
+ or add layout classes — never delete content, never touch the base files.
70
+ Everything must be reversible by loading `?rx=base`.
71
+
72
+ **Big-direction hero recipes for a photo-hero skeleton:**
73
+ - *type-only* — hide the image + shader layers, headline to 2× size, one rule
74
+ - *GPU-led* — hide the image, full-bleed GrainGradient/Dithering behind the type
75
+ - *engraving* — regenerate the art as B/W line engraving, hard 2-column split
76
+ - *product* — the cutout asset becomes the focal object, painting removed
77
+
78
+ **Patch gotcha (Tailwind / utility sites):** font sizes usually live on the
79
+ inner `<span>`s (`text-[64px]`), not the `h1`. A `h1 span { font-size: 108% }`
80
+ patch *replaces* that with 108 % of the h1's 16 px base and collapses the
81
+ headline. Scale display type via the `h1`'s own size or `letter-spacing` /
82
+ `line-height`, never via child spans.
@@ -0,0 +1,84 @@
1
+ # Re-skin — making the template yours
2
+
3
+ The order matters: **copy → brand → assets → fonts → scrub**. Copy first because
4
+ headline length drives layout; everything after is fitting into that.
5
+
6
+ ## Copy: keep the shape, change the words
7
+
8
+ Walk `TEARDOWN.md`'s section list top to bottom. For each section record its
9
+ *shape* before touching it: eyebrow? headline lines? sub length? CTA count?
10
+ card count and per-card fields? That shape is the template; the words are not.
11
+
12
+ - Headlines: match the original's character count ±20 % or the hero reflows.
13
+ If the user's message genuinely needs more, reduce the display size knob
14
+ (`--rx-size-1`) rather than letting it wrap to a third line.
15
+ - Cards/lists: same count as the original unless the grid obviously tolerates
16
+ fewer (3→2 breaks most grids; 6→4 usually doesn't). Say so in the report.
17
+ - Microcopy (buttons, labels, footer) — replace every string; a leftover
18
+ "Book a demo" or their product name is the #1 tell of a lazy remix.
19
+ - Sources, in order of preference: the user's doc/URL → the user's bullets
20
+ expanded in the original's register → honest placeholders in the structure
21
+ (`[Headline about X]`) — never their copy paraphrased.
22
+
23
+ ## Brand
24
+
25
+ - Logo → header + footer slots + favicon + OG image. If none exists, generate a
26
+ **simple wordmark** in the site's display font as a placeholder and label it
27
+ PLACEHOLDER — never generate a logo and present it as final brand work.
28
+ - Colors: primary → whatever role the original's accent played (`--rx-color-n`
29
+ with the highest `uses` among non-neutrals); neutrals usually stay. If the
30
+ user gives only a logo, sample its two dominant colors and propose.
31
+
32
+ ## Assets: flip the ladder
33
+
34
+ In `/luckiest-website-cloner` the REAL original asset was the goal. Here it's the thing to
35
+ remove. Per asset in the teardown's table:
36
+
37
+ | Original role | Action |
38
+ |---|---|
39
+ | Decorative (hero background, ambient texture, abstract art, section bg) | GENERATE — brief below |
40
+ | Product/UI screenshots | Replace with the user's, or a generated *abstract* stand-in clearly not a product |
41
+ | Photos of people, testimonials, avatars | Replace with the user's or remove the block |
42
+ | Their logo / brand marks / partner logos | Remove or replace with the user's partners |
43
+ | Icons (generic UI) | Keep |
44
+ | Textures/noise SVGs generated in code | Keep |
45
+
46
+ **Generation brief template** (Higgsfield; reuse the original's *composition*,
47
+ not its content):
48
+
49
+ ```
50
+ Aspect <W:H from the original file>. Subject placement: <where the original
51
+ put its focal mass — e.g. "figure right third, empty sky left two-thirds">.
52
+ Mood: <from the direction, or the original's if pre-direction>. Style: <one
53
+ phrase — "painterly landscape", "studio product on seamless", "abstract
54
+ gradient mesh">. Must leave <region> clean for overlaid text. No text, no
55
+ logos, no recognizable brands.
56
+ ```
57
+ Generate at ≥ the original's served resolution. Keep the original's filename
58
+ slot (`hero-bg.jpg` → the mount / `<img data-rx-asset="hero-bg">` stays the
59
+ same) so directions can swap it by key.
60
+
61
+ ## Fonts
62
+
63
+ Keep open-license fonts. Swap commercially licensed ones for metric-compatible
64
+ open pairs — match x-height and width so the measured sizes still fit:
65
+
66
+ | Licensed | Open stand-in |
67
+ |---|---|
68
+ | Neue Haas Grotesk / Helvetica Neue | Inter (tracking −0.01em) or Instrument Sans |
69
+ | PP Cirka / editorial serif display | Instrument Serif, Fraunces, Newsreader |
70
+ | GT America / Söhne / ABC Diatype | Inter, Geist, Manrope |
71
+ | Founders Grotesk | Archivo, Work Sans |
72
+ | Monument Extended / PP Neue Machina | Unbounded, Syncopate |
73
+ | Tiempos / Canela | Fraunces, Source Serif 4, Cormorant |
74
+ | SF Mono / Berkeley Mono | Geist Mono, JetBrains Mono, IBM Plex Mono |
75
+
76
+ After a swap, re-measure the hero headline height and adjust `--rx-size-1`
77
+ until it matches the original's within a line.
78
+
79
+ ## Scrub
80
+
81
+ Remove: analytics (GA/PostHog/Plausible), their API endpoints and forms'
82
+ `action`s, their social links, their legal pages, `<meta>` descriptions/OG,
83
+ sitemap/robots, any `data-*` that carries their ids. Grep the built output for
84
+ their domain and brand name — zero hits before Phase 1 is done.
@@ -0,0 +1,72 @@
1
+ # Tweak panel — the knobs you'll actually turn
2
+
3
+ Built on the picked direction only. 10–15 knobs. The test for including one:
4
+ *would the user change this twice in the next hour?* Accent yes; the 7th
5
+ spacing step no.
6
+
7
+ ## The --p-* pattern (how a patch becomes tweakable)
8
+
9
+ A knob can only drive what is already a variable. So when you write a
10
+ direction's `patch.css`, declare every decision as a `--p-*` custom property
11
+ with its default in the `:root[data-rx="<slug>"]` block, and reference it in
12
+ the rules (`filter: grayscale(var(--p-hero-gray)) contrast(var(--p-hero-contrast))`).
13
+ The panel then sets `--p-*` on `:root` live. Validated pattern: hero processing
14
+ (gray/contrast/brightness/invert/blur), overlay gradient (alpha/angle/reach),
15
+ scanlines, figure transform (scale/x/y/opacity), grain opacity, grid
16
+ size/alpha, hairline weight/style, radius, shadow, display tracking/leading/
17
+ weight, body leading, section rhythm multiplier, content max-width, CTA colors,
18
+ footer colors. Use `zoom` (not `transform: scale`) for display scale so the
19
+ layout reflows.
20
+
21
+ "Aggressive" is fine — 60–80 knobs is workable **if** they are grouped
22
+ (Color / Type / Space / Hero / Texture / Motion / GPU / Footer), searchable,
23
+ and every group can collapse. The panel ships with a filter box, presets,
24
+ collapse-all, shuffle-within-ranges, right-click-to-reset per knob,
25
+ double-click a value to type it, and a resize grip.
26
+
27
+ ## panel.json schema
28
+
29
+ ```json
30
+ { "title": "Night Signal", "slug": "night-signal",
31
+ "presets": { "as picked": {}, "inverted": { "--rx-color-bg": "#0a0a0a", "--p-hero-invert": 1 } },
32
+ "knobs": [
33
+ { "id": "--rx-color-1", "label": "Accent", "group": "Color", "type": "color", "default": "#1f3bff" },
34
+ { "id": "--rx-color-bg", "label": "Page", "group": "Color", "type": "color", "default": "#ffffff" },
35
+ { "id": "--rx-font-1", "label": "Display font", "group": "Type", "type": "select", "options": ["Inter","Instrument Serif","Geist Mono"], "default": "Inter" },
36
+ { "id": "--rx-size-1", "label": "Display size", "group": "Type", "type": "length", "default": "80px", "min": 40, "max": 140, "step": 1, "unit": "px" },
37
+ { "id": "--rx-space-2", "label": "Section gap", "group": "Space", "type": "length", "default": "128px", "min": 48, "max": 240, "step": 4, "unit": "px" },
38
+ { "id": "--rx-radius-1", "label": "Radius", "group": "Space", "type": "length", "default": "4px", "min": 0, "max": 32, "step": 1, "unit": "px" },
39
+ { "id": "motion.reveal.y", "label": "Reveal travel", "group": "Motion", "type": "number", "default": 22, "min": 0, "max": 80, "step": 1 },
40
+ { "id": "motion.reveal.duration", "label": "Reveal duration", "group": "Motion", "type": "number", "default": 0.7, "min": 0.1, "max": 2, "step": 0.05 },
41
+ { "id": "motion.stagger", "label": "Stagger", "group": "Motion", "type": "number", "default": 0.08, "min": 0, "max": 0.3, "step": 0.01 },
42
+ { "id": "shaders.hero.colorFront", "label": "Paper tint", "group": "GPU", "type": "color", "default": "#9FADBC" },
43
+ { "id": "shaders.hero.intensity", "label": "Texture", "group": "GPU", "type": "number", "default": 0.5, "min": 0, "max": 1, "step": 0.02 }
44
+ ] }
45
+ ```
46
+
47
+ - `--*` ids set CSS custom properties live on `:root`.
48
+ - Dotted ids patch `window.__remix` via `__remix.apply({...})`, which dispatches
49
+ `rx:change`. Behaviors must read their params at call time (not cache them
50
+ at import), and shader mounts must listen for `rx:change` and re-render
51
+ with the new props. Hoist both during Phase 0 if the clone didn't.
52
+ - Groups render as collapsible sections in the given order.
53
+ - `slug` (optional): the panel only mounts when that direction is active.
54
+ - `presets`: named partial states layered on the direction; keys are knob ids.
55
+ - `labels` on a select: display names parallel to `options` (use for font stacks).
56
+
57
+ ## Wiring
58
+
59
+ ```ts
60
+ import rx from './remix/remix-loader'; // first — applies the direction
61
+ import { mountTweakPanel } from './remix/tweak-panel.js';
62
+ import panel from '../remix/panel.json';
63
+ if (import.meta.env.DEV) mountTweakPanel(panel); // dev-only
64
+ ```
65
+
66
+ ## Verify before reporting
67
+
68
+ 1. Every knob visibly changes the page (screenshot two extremes for a couple).
69
+ 2. Permalink round-trips: copy link → open in a fresh tab → same state.
70
+ 3. Reset returns exactly to the direction's defaults (0 % diff vs `?rx=<slug>`).
71
+ 4. Copy JSON → `apply-overrides.cjs` accepts it as a new direction.
72
+ 5. Production build contains no panel (`grep rx-panel dist/` → nothing).
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * apply-overrides.cjs — materialize a direction (or panel export) into the site.
4
+ *
5
+ * node apply-overrides.cjs <site-dir> <overrides.json> [--activate]
6
+ *
7
+ * overrides.json:
8
+ * { "name": "editorial-quiet", "thesis": "...",
9
+ * "tokens": { "--rx-color-1": "#…", "--rx-font-1": "…" },
10
+ * "motion": { "reveal": { "y": 22, "duration": .7, "ease": [.16,1,.3,1] }, "stagger": .08 },
11
+ * "shaders": { "hero": { "colorFront": "#…" } },
12
+ * "assets": { "hero-bg": "remix/generated/editorial-hero.jpg" } }
13
+ *
14
+ * Writes into <site>/src/remix/:
15
+ * tokens.<slug>.css :root overrides (only the keys given)
16
+ * motion.<slug>.js `window.__remix.motion` / `.shaders` patch
17
+ * directions.json registry of all slugs (for ?rx=<slug> switching)
18
+ * and, for "assets", copies files into <site>/public/assets/remix/<slug>/ and
19
+ * records the mapping so the loader can swap <img src> / shader image props.
20
+ *
21
+ * The site loads the active direction with the tiny runtime in remix-loader.js
22
+ * (written here too, once): reads ?rx=<slug> or #rx=<state>, injects the CSS,
23
+ * applies motion/shader patches, swaps assets. Default = base.
24
+ */
25
+ const fs = require('fs'), path = require('path');
26
+ const [,, siteDir, ovPath, ...flags] = process.argv;
27
+ if (!siteDir || !ovPath) { console.error('usage: apply-overrides.cjs <site-dir> <overrides.json> [--activate]'); process.exit(1); }
28
+ const ov = JSON.parse(fs.readFileSync(ovPath, 'utf8'));
29
+ const slug = (ov.name || path.basename(ovPath, '.json')).toLowerCase().replace(/[^a-z0-9]+/g, '-');
30
+ const out = path.join(siteDir, 'src', 'remix'); fs.mkdirSync(out, { recursive: true });
31
+
32
+ // tokens
33
+ const tokens = ov.tokens || {};
34
+ fs.writeFileSync(path.join(out, `tokens.${slug}.css`), `/* direction: ${slug} — ${ov.thesis || ''} */\n:root[data-rx="${slug}"] {\n${Object.entries(tokens).map(([k, v]) => ` ${k}: ${v};`).join('\n')}\n}\n`);
35
+
36
+ // assets
37
+ const assetMap = {};
38
+ for (const [key, src] of Object.entries(ov.assets || {})) {
39
+ const dest = path.join(siteDir, 'public', 'assets', 'remix', slug, path.basename(src));
40
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
41
+ if (fs.existsSync(src)) fs.copyFileSync(src, dest); else console.warn('asset missing:', src);
42
+ assetMap[key] = `/assets/remix/${slug}/${path.basename(src)}`;
43
+ }
44
+
45
+ // optional patch layers (medium: css, big: js) — picked up if they sit next to the JSON
46
+ const patchCss = ovPath.replace(/\.json$/, '.patch.css'), patchJs = ovPath.replace(/\.json$/, '.patch.js');
47
+ const hasCss = fs.existsSync(patchCss), hasJs = fs.existsSync(patchJs);
48
+ if (hasCss) fs.writeFileSync(path.join(out, `patch.${slug}.css`), fs.readFileSync(patchCss, 'utf8'));
49
+ if (hasJs) fs.writeFileSync(path.join(out, `patch.${slug}.js`), fs.readFileSync(patchJs, 'utf8'));
50
+
51
+ // motion / shaders patch
52
+ fs.writeFileSync(path.join(out, `motion.${slug}.js`), `// direction: ${slug}\nexport default ${JSON.stringify({ motion: ov.motion || {}, shaders: ov.shaders || {}, assets: assetMap }, null, 1)};\n`);
53
+
54
+ // registry
55
+ const regPath = path.join(out, 'directions.json');
56
+ const reg = fs.existsSync(regPath) ? JSON.parse(fs.readFileSync(regPath, 'utf8')) : { active: 'base', directions: {} };
57
+ reg.directions[slug] = { name: ov.name || slug, thesis: ov.thesis || '', amplitude: ov.amplitude || (hasJs ? 'big' : hasCss ? 'medium' : 'subtle'), tokens: `./tokens.${slug}.css`, motion: `./motion.${slug}.js`, patchCss: hasCss ? `./patch.${slug}.css` : null, patchJs: hasJs ? `./patch.${slug}.js` : null, assets: assetMap };
58
+ if (flags.includes('--activate')) reg.active = slug;
59
+ fs.writeFileSync(regPath, JSON.stringify(reg, null, 1));
60
+
61
+ // loader (idempotent)
62
+ const loader = path.join(out, 'remix-loader.ts');
63
+ if (!fs.existsSync(loader)) fs.writeFileSync(loader, `/**
64
+ * remix-loader — picks the active direction and applies it before the page paints.
65
+ * Order: ?rx=<slug> → localStorage rx → directions.json active → 'base'.
66
+ * Exposes window.__remix = { slug, motion, shaders, assets, apply(patch) }.
67
+ * Import this FIRST in main.ts. Behaviors read window.__remix.motion; shader
68
+ * mounts read window.__remix.shaders; <img data-rx-asset="hero-bg"> gets swapped.
69
+ */
70
+ import registry from './directions.json';
71
+ const tokenSheets = import.meta.glob('./tokens.*.css', { query: '?inline', import: 'default', eager: true }) as Record<string, string>;
72
+ const motionMods = import.meta.glob('./motion.*.js', { import: 'default', eager: true }) as Record<string, any>;
73
+ const patchSheets = import.meta.glob('./patch.*.css', { query: '?inline', import: 'default', eager: true }) as Record<string, string>;
74
+ const patchMods = import.meta.glob('./patch.*.js', { import: 'default', eager: true }) as Record<string, any>;
75
+
76
+ const params = new URLSearchParams(location.search);
77
+ const slug = params.get('rx') || localStorage.getItem('rx') || (registry as any).active || 'base';
78
+ const rx: any = { slug, motion: {}, shaders: {}, assets: {}, patches: [] as any[] };
79
+ (window as any).__remix = rx;
80
+ document.documentElement.dataset.rx = slug;
81
+
82
+ if (slug !== 'base') {
83
+ const css = tokenSheets[\`./tokens.\${slug}.css\`];
84
+ if (css) { const st = document.createElement('style'); st.id = 'rx-tokens'; st.textContent = css; document.head.appendChild(st); }
85
+ const m = motionMods[\`./motion.\${slug}.js\`];
86
+ if (m) { rx.motion = m.motion || {}; rx.shaders = m.shaders || {}; rx.assets = m.assets || {}; }
87
+ for (const [key, url] of Object.entries(rx.assets)) document.querySelectorAll<HTMLImageElement>(\`[data-rx-asset="\${key}"]\`).forEach((img) => { img.src = url as string; img.removeAttribute('srcset'); });
88
+ // medium: scoped CSS patch (surface / density / type scale)
89
+ const pc = patchSheets[\`./patch.\${slug}.css\`];
90
+ if (pc) { const st = document.createElement('style'); st.id = 'rx-patch'; st.textContent = pc; document.head.appendChild(st); }
91
+ // big: structural DOM patch — export default function (rx) { ... }
92
+ const pj = patchMods[\`./patch.\${slug}.js\`];
93
+ if (typeof pj === 'function') { const run = () => pj(rx); document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', run) : run(); }
94
+ }
95
+ // live patch API used by the tweak panel: { tokens?, motion?, shaders? }
96
+ rx.apply = (patch: any) => {
97
+ for (const [k, v] of Object.entries(patch.tokens || {})) document.documentElement.style.setProperty(k, v as string);
98
+ Object.assign(rx.motion, patch.motion || {}); Object.assign(rx.shaders, patch.shaders || {});
99
+ rx.patches.push(patch); window.dispatchEvent(new CustomEvent('rx:change', { detail: patch }));
100
+ };
101
+ export default rx;
102
+ `);
103
+
104
+ console.log(`direction "${slug}" → ${out}/tokens.${slug}.css, motion.${slug}.js; registry active=${reg.active}; assets: ${Object.keys(assetMap).length}`);
105
+ console.log(`view: ?rx=${slug}`);
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * gallery.js — write <site>/directions.html: the three directions LIVE, side
4
+ * by side, in iframes of the dev server (real shaders, real motion), with
5
+ * synced scrolling, a fullscreen click-through, and a "pick" button that
6
+ * records the choice. Run after apply-overrides.cjs; served by Vite as a page.
7
+ *
8
+ * node gallery.js <site-dir> [--title "Acme directions"]
9
+ */
10
+ const fs = require('fs'), path = require('path');
11
+ const [,, siteDir, ...rest] = process.argv;
12
+ if (!siteDir) { console.error('usage: gallery.js <site-dir>'); process.exit(1); }
13
+ const title = (rest.indexOf('--title') >= 0 ? rest[rest.indexOf('--title') + 1] : null) || 'Directions';
14
+ const reg = JSON.parse(fs.readFileSync(path.join(siteDir, 'src', 'remix', 'directions.json'), 'utf8'));
15
+ const dirs = Object.entries(reg.directions);
16
+
17
+ const html = `<!doctype html>
18
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
19
+ <title>${title}</title>
20
+ <style>
21
+ :root{color-scheme:dark}
22
+ *{box-sizing:border-box} html,body{margin:0;height:100%;background:#0c0c0e;color:#eee;font:13px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}
23
+ header{display:flex;align-items:center;gap:16px;padding:10px 16px;border-bottom:1px solid #222;position:sticky;top:0;background:#0c0c0e;z-index:5}
24
+ header b{font-size:14px} header .sp{flex:1} header label{display:flex;gap:6px;align-items:center;opacity:.8;cursor:pointer}
25
+ main{display:grid;grid-template-columns:repeat(${dirs.length},1fr);gap:10px;padding:10px;height:calc(100% - 45px)}
26
+ .col{display:flex;flex-direction:column;min-width:0;border:1px solid #222;border-radius:8px;overflow:hidden;background:#000}
27
+ .bar{display:flex;align-items:center;gap:10px;padding:8px 10px;background:#141416;border-bottom:1px solid #222}
28
+ .bar .name{font-weight:600} .bar .thesis{opacity:.6;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px}
29
+ .bar button,header button{background:#222;color:#eee;border:1px solid #333;border-radius:5px;padding:4px 9px;font:inherit;cursor:pointer} .bar button:hover{background:#2c2c30}
30
+ .bar button.pick{background:#2f6df6;border-color:#2f6df6} .bar button.pick.on{background:#1db472;border-color:#1db472}
31
+ .frame{flex:1;position:relative;background:#000} iframe{position:absolute;inset:0;width:100%;height:100%;border:0;background:#fff}
32
+ body.full main{grid-template-columns:1fr} body.full .col:not(.active){display:none}
33
+ .hint{opacity:.5}
34
+ </style></head><body>
35
+ <header><b>${title}</b><span class="hint">${dirs.length} directions · click a name to go fullscreen · Esc back</span><span class="sp"></span>
36
+ <label><input type="checkbox" id="sync" checked> sync scroll</label>
37
+ <label><input type="checkbox" id="shaders" checked> shaders</label>
38
+ <button id="reload">reload</button></header>
39
+ <main>
40
+ ${dirs.map(([slug, d]) => ` <section class="col" data-slug="${slug}">
41
+ <div class="bar"><button class="name" data-full="${slug}">${d.name || slug}</button><span class="thesis" title="${(d.thesis || '').replace(/"/g, '&quot;')}">${d.thesis || ''}</span><button class="pick" data-pick="${slug}">pick</button></div>
42
+ <div class="frame"><iframe data-slug="${slug}" src="/?rx=${slug}&shaders=1" loading="eager"></iframe></div>
43
+ </section>`).join('\n')}
44
+ </main>
45
+ <script>
46
+ const frames = [...document.querySelectorAll('iframe')];
47
+ const shaders = document.getElementById('shaders'), sync = document.getElementById('sync');
48
+ const src = (s) => '/?rx=' + s + '&shaders=' + (shaders.checked ? 1 : 0);
49
+ document.getElementById('reload').onclick = () => frames.forEach((f) => (f.src = src(f.dataset.slug)));
50
+ shaders.onchange = () => document.getElementById('reload').onclick();
51
+ // synced scroll (same-origin): mirror scrollTop ratio across frames
52
+ let lock = false;
53
+ frames.forEach((f) => f.addEventListener('load', () => {
54
+ const w = f.contentWindow; if (!w) return;
55
+ w.addEventListener('scroll', () => {
56
+ if (!sync.checked || lock) return; lock = true;
57
+ const d = w.document.documentElement, r = w.scrollY / Math.max(1, d.scrollHeight - w.innerHeight);
58
+ for (const o of frames) if (o !== f && o.contentWindow) { const od = o.contentWindow.document.documentElement; o.contentWindow.scrollTo(0, r * (od.scrollHeight - o.contentWindow.innerHeight)); }
59
+ requestAnimationFrame(() => (lock = false));
60
+ }, { passive: true });
61
+ }));
62
+ // fullscreen click-through
63
+ const full = (slug) => { document.body.classList.toggle('full', !!slug); document.querySelectorAll('.col').forEach((c) => c.classList.toggle('active', c.dataset.slug === slug)); };
64
+ document.querySelectorAll('[data-full]').forEach((b) => (b.onclick = () => full(document.body.classList.contains('full') ? null : b.dataset.full)));
65
+ window.addEventListener('keydown', (e) => { if (e.key === 'Escape') full(null); });
66
+ // pick → localStorage + POST-less marker the agent can read back
67
+ const current = localStorage.getItem('rx-pick');
68
+ document.querySelectorAll('[data-pick]').forEach((b) => { b.classList.toggle('on', b.dataset.pick === current); b.onclick = () => { localStorage.setItem('rx-pick', b.dataset.pick); document.querySelectorAll('[data-pick]').forEach((x) => x.classList.toggle('on', x === b)); b.textContent = 'picked'; setTimeout(() => (b.textContent = 'pick'), 900); }; });
69
+ </script></body></html>`;
70
+ fs.writeFileSync(path.join(siteDir, 'directions.html'), html);
71
+ console.log(`${path.join(siteDir, 'directions.html')} — open /directions.html on the dev server (${dirs.length} directions)`);
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * tokenize-css.cjs — make a cloned stylesheet variable-driven.
4
+ *
5
+ * node tokenize-css.cjs <input.css> <tokens.json> <out-dir>
6
+ *
7
+ * Reads the measured tokens (from luckiest-website-cloner's tokens-probe) and rewrites the
8
+ * CSS so each token's literal value goes through `var(--rx-<role>, <literal>)`.
9
+ * Writes:
10
+ * <out-dir>/<input-basename>.tokenized.css the rewritten stylesheet
11
+ * <out-dir>/remix-tokens.css :root defaults (the direction "base")
12
+ * <out-dir>/../remix/knobs.json the knob list for directions + panel
13
+ *
14
+ * Role naming: colors become --rx-color-<n> ranked by usage, with the body
15
+ * text / page bg / accent detected and named; font families --rx-font-<n>;
16
+ * the most common font sizes --rx-size-<n>; spacing values --rx-space-<n>;
17
+ * radii --rx-radius-<n>; shadows --rx-shadow-<n>. If the site already defines
18
+ * custom properties (tokens.customProps), those are NOT rewritten — they're
19
+ * listed as knobs under their own names so the site's vocabulary is kept.
20
+ *
21
+ * Conservative on purpose: only exact literal matches are replaced, color
22
+ * matches are case-insensitive and cover hex/rgb()/rgba() spellings of the
23
+ * same value, and values inside url(...) or @font-face are left alone.
24
+ */
25
+ const fs = require('fs'), path = require('path');
26
+ const [,, cssPath, tokensPath, outDir] = process.argv;
27
+ if (!cssPath || !tokensPath || !outDir) { console.error('usage: tokenize-css.cjs <input.css> <tokens.json> <out-dir>'); process.exit(1); }
28
+
29
+ const css = fs.readFileSync(cssPath, 'utf8');
30
+ const t = JSON.parse(fs.readFileSync(tokensPath, 'utf8'));
31
+ fs.mkdirSync(outDir, { recursive: true });
32
+ const remixDir = path.join(outDir, '..', 'remix'); fs.mkdirSync(remixDir, { recursive: true });
33
+
34
+ /* ---------- color normalization ---------- */
35
+ function parseColor(s) {
36
+ s = s.trim().toLowerCase();
37
+ let m;
38
+ if ((m = s.match(/^#([0-9a-f]{3})$/))) return [...m[1]].map((c) => parseInt(c + c, 16)).concat([1]);
39
+ if ((m = s.match(/^#([0-9a-f]{6})([0-9a-f]{2})?$/))) { const h = m[1]; return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)).concat([m[2] ? parseInt(m[2], 16) / 255 : 1]); }
40
+ if ((m = s.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+))?\s*\)$/))) return [+m[1], +m[2], +m[3], m[4] == null ? 1 : +m[4]];
41
+ if ((m = s.match(/^rgba?\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.%]+))?\s*\)$/))) return [+m[1], +m[2], +m[3], m[4] == null ? 1 : m[4].endsWith('%') ? parseFloat(m[4]) / 100 : +m[4]];
42
+ return null;
43
+ }
44
+ const hex = ([r, g, b]) => '#' + [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
45
+ const spellings = (rgba) => {
46
+ const [r, g, b, a] = rgba.map((v, i) => (i < 3 ? Math.round(v) : v));
47
+ const out = [`rgb(${r}, ${g}, ${b})`, `rgb(${r},${g},${b})`, hex(rgba)];
48
+ const h = hex(rgba); if (/^#(.)\1(.)\2(.)\3$/.test(h)) out.push('#' + h[1] + h[3] + h[5]);
49
+ if (a !== 1) return [`rgba(${r}, ${g}, ${b}, ${a})`, `rgba(${r},${g},${b},${a})`];
50
+ return out.concat([`rgba(${r}, ${g}, ${b}, 1)`]);
51
+ };
52
+
53
+ /* ---------- pick roles ---------- */
54
+ const knobs = [];
55
+ const vars = {}; // name -> default literal
56
+ const rewrite = []; // [regex, replacement]
57
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
58
+
59
+ // colors
60
+ const colors = (t.colors || []).map((c) => ({ ...c, rgba: parseColor(c.value) })).filter((c) => c.rgba);
61
+ const bodyBg = parseColor(t.body?.background || '') , bodyFg = parseColor(t.body?.color || '');
62
+ const same = (a, b) => a && b && a.slice(0, 3).every((v, i) => Math.round(v) === Math.round(b[i]));
63
+ let ci = 0;
64
+ for (const c of colors.slice(0, 14)) {
65
+ let role = `color-${++ci}`;
66
+ if (same(c.rgba, bodyFg)) role = 'color-text';
67
+ else if (same(c.rgba, bodyBg)) role = 'color-bg';
68
+ const name = `--rx-${role}`;
69
+ vars[name] = hex(c.rgba) + (c.rgba[3] !== 1 ? '' : '');
70
+ const lit = c.rgba[3] === 1 ? hex(c.rgba) : spellings(c.rgba)[0];
71
+ vars[name] = lit;
72
+ knobs.push({ id: name, group: 'Color', type: 'color', default: lit, uses: c.count, where: c.where });
73
+ for (const sp of spellings(c.rgba)) rewrite.push([new RegExp(`(?<![\\w-])${esc(sp)}(?![\\w-])`, 'gi'), `var(${name}, ${lit})`]);
74
+ }
75
+ // fonts (family stacks)
76
+ const fams = new Map();
77
+ for (const f of t.fonts || []) { const fam = f.value.split(' / ')[0]; fams.set(fam, (fams.get(fam) || 0) + f.count); }
78
+ let fi = 0;
79
+ for (const [fam, count] of [...fams.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4)) {
80
+ const name = `--rx-font-${++fi}`; vars[name] = fam;
81
+ knobs.push({ id: name, group: 'Type', type: 'font', default: fam, uses: count });
82
+ rewrite.push([new RegExp(`font-family:\\s*${esc(fam)}`, 'g'), `font-family: var(${name}, ${fam})`]);
83
+ }
84
+ // sizes
85
+ let si = 0;
86
+ for (const s of (t.typeScale || []).slice(0, 8)) {
87
+ const px = s.value.split(' / ')[0]; if (!/px$/.test(px)) continue;
88
+ const name = `--rx-size-${++si}`; vars[name] = px;
89
+ knobs.push({ id: name, group: 'Type', type: 'length', default: px, min: 8, max: 160, step: 0.5, uses: s.count, where: s.where });
90
+ rewrite.push([new RegExp(`font-size:\\s*${esc(px)}(?![\\d.])`, 'g'), `font-size: var(${name}, ${px})`]);
91
+ }
92
+ // spacing
93
+ let pi = 0;
94
+ for (const s of (t.spacing || []).slice(0, 8)) {
95
+ const name = `--rx-space-${++pi}`; vars[name] = s.value;
96
+ knobs.push({ id: name, group: 'Space', type: 'length', default: s.value, min: 0, max: 256, step: 1, uses: s.count });
97
+ rewrite.push([new RegExp(`(padding|margin|gap|row-gap|column-gap)(-[a-z]+)?:\\s*${esc(s.value)}(?![\\d.])`, 'g'), `$1$2: var(${name}, ${s.value})`]);
98
+ }
99
+ // radii + shadows
100
+ let ri = 0; for (const r of (t.radii || []).slice(0, 3)) { const name = `--rx-radius-${++ri}`; vars[name] = r.value; knobs.push({ id: name, group: 'Space', type: 'length', default: r.value, min: 0, max: 64, step: 1 }); rewrite.push([new RegExp(`border-radius:\\s*${esc(r.value)}(?![\\d.%])`, 'g'), `border-radius: var(${name}, ${r.value})`]); }
101
+ let shi = 0; for (const s of (t.shadows || []).slice(0, 2)) { const name = `--rx-shadow-${++shi}`; vars[name] = s.value; knobs.push({ id: name, group: 'Color', type: 'text', default: s.value }); rewrite.push([new RegExp(`box-shadow:\\s*${esc(s.value)}`, 'g'), `box-shadow: var(${name}, ${s.value})`]); }
102
+ // site's own custom props → knobs under their own names (not rewritten)
103
+ for (const [k, v] of Object.entries(t.customProps || {})) {
104
+ const isColor = !!parseColor(v), isLen = /^-?[\d.]+(px|rem|em|vw|vh)$/.test(v);
105
+ knobs.push({ id: k, group: isColor ? 'Color' : isLen ? 'Space' : 'Site', type: isColor ? 'color' : isLen ? 'length' : 'text', default: v, native: true });
106
+ }
107
+
108
+ /* ---------- rewrite, protecting url() and @font-face ---------- */
109
+ const protectedBlocks = [];
110
+ let work = css.replace(/@font-face\s*\{[^}]*\}|url\([^)]*\)/g, (m) => { protectedBlocks.push(m); return `__RXP__${protectedBlocks.length - 1}__RXP__`; });
111
+ let replaced = 0;
112
+ // Only rewrite inside declaration blocks — never in selectors. Tailwind's
113
+ // arbitrary-value classes (.text-\[\#e0a24a\]) carry the same literal in the
114
+ // selector, and rewriting there silently breaks the rule.
115
+ work = work.replace(/\{[^{}]*\}/g, (block) => {
116
+ let b = block;
117
+ for (const [re, rep] of rewrite) b = b.replace(re, (m) => { replaced++; return m.replace(re, rep); });
118
+ return b;
119
+ });
120
+ work = work.replace(/__RXP__(\d+)__RXP__/g, (_, i) => protectedBlocks[+i]);
121
+
122
+ const base = path.basename(cssPath, '.css');
123
+ fs.writeFileSync(path.join(outDir, `${base}.tokenized.css`), work);
124
+ fs.writeFileSync(path.join(outDir, 'remix-tokens.css'), `/* remix base — generated by tokenize-css.cjs; directions override these */\n:root {\n${Object.entries(vars).map(([k, v]) => ` ${k}: ${v};`).join('\n')}\n}\n`);
125
+ fs.writeFileSync(path.join(remixDir, 'knobs.json'), JSON.stringify({ generatedFrom: path.basename(tokensPath), knobs }, null, 1));
126
+ console.log(`${replaced} replacements, ${Object.keys(vars).length} vars, ${knobs.length} knobs → ${outDir}/${base}.tokenized.css, remix-tokens.css, ${remixDir}/knobs.json`);
127
+ console.log('next: import remix-tokens.css BEFORE the tokenized stylesheet; verify 0% diff vs the untokenized build.');