PageSourceSearch

https://railsblocks.com/assets/controllers/theme_builder_controlle…22ab918417c3e476d02f1700afa4277569b7faed.js

js railsblocks.com collected 2026-09-25 20:24:26 UTC 26,348 bytes, 678 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2
3const SHADES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
4
5const PAIRINGS = {
6  base: "neutral",
7  red: "neutral",
8  orange: "taupe",
9  amber: "stone",
10  yellow: "stone",
11  lime: "olive",
12  green: "olive",
13  emerald: "neutral",
14  teal: "mist",
15  cyan: "mist",
16  sky: "mist",
17  blue: "slate",
18  indigo: "slate",
19  violet: "mauve",
20  purple: "mauve",
21  fuchsia: "mauve",
22  pink: "mauve",
23  rose: "taupe",
24};
25
26// Rails Blocks-owned neutral palettes. The values live here so preview,
27// swatches, and generated CSS always use the same source of truth.
28const CUSTOM_BASES = {
29  mauve: { hue: 320 },
30  olive: { hue: 110 },
31  mist: { hue: 220 },
32  taupe: { hue: 55 },
33};
34
35const BASE_LIGHTNESS = {
36  50: "98.5% 0.004",
37  100: "96.7% 0.006",
38  200: "92% 0.011",
39  300: "87.1% 0.016",
40  400: "70.5% 0.022",
41  500: "55.2% 0.026",
42  600: "44.2% 0.023",
43  700: "37% 0.020",
44  800: "26.9% 0.015",
45  900: "21% 0.012",
46  950: "14.1% 0.008",
47};
48
49// Recipe: [light primary, hover, content, fg, dark primary, hover, content, fg]
50// Hover is always one shade lighter than primary in both modes.
51const PRIMARY_RECIPES = {
52  red: [600, 500, 700, "white", 600, 500, 400, "white"],
53  orange: [500, 400, 700, "neutral-950", 400, 300, 400, "neutral-950"],
54  amber: [500, 400, 700, "neutral-950", 400, 300, 400, "neutral-950"],
55  yellow: [500, 400, 700, "neutral-950", 400, 300, 400, "neutral-950"],
56  lime: [500, 400, 700, "neutral-950", 400, 300, 400, "neutral-950"],
57  green: [500, 400, 700, "white", 500, 400, 400, "neutral-950"],
58  emerald: [500, 400, 700, "white", 500, 400, 400, "neutral-950"],
59  teal: [500, 400, 700, "white", 500, 400, 400, "neutral-950"],
60  cyan: [500, 400, 700, "white", 500, 400, 400, "neutral-950"],
61  sky: [500, 400, 700, "white", 500, 400, 400, "neutral-950"],
62  blue: [600, 500, 700, "white", 600, 500, 400, "white"],
63  indigo: [500, 400, 700, "white", 500, 400, 400, "white"],
64  violet: [600, 500, 700, "white", 600, 500, 400, "white"],
65  purple: [600, 500, 700, "white", 600, 500, 400, "white"],
66  fuchsia: [600, 500, 700, "white", 600, 500, 400, "white"],
67  pink: [600, 500, 700, "white", 600, 500, 400, "white"],
68  rose: [600, 500, 700, "white", 600, 500, 400, "white"],
69};
70
71const PRIMARY_NAMES = Object.keys(PAIRINGS);
72const BASE_NAMES = ["slate", "gray", "zinc", "neutral", "stone", "mauve", "olive", "mist", "taupe"];
73const MODES = ["light", "dark"];
74
75export default class extends Controller {
76  static targets = [
77    "primaryOption",
78    "baseOption",
79    "primarySelect",
80    "baseSelect",
81    "modeOption",
82    "frame",
83    "linkButton",
84    "status",
85    "summary",
86    "code",
87    "copyButton",
88    "copyPromptButton",
89  ];
90
91  static values = {
92    primary: { type: String, default: "base" },
93    base: { type: String, default: "neutral" },
94    linked: { type: Boolean, default: true },
95    mode: { type: String, default: "light" },
96  };
97
98  connect() {
99    this.restoreFromUrl();
100    this.paintSwatches();
101    this.update();
102  }
103
104  disconnect() {
105    this.previewResizeObserver?.disconnect();
106  }
107
108  selectPrimary(event) {
109    this.choosePrimary(event.currentTarget.dataset.themeValue);
110  }
111
112  primarySelectChanged(event) {
113    const value = event.target.value;
114    if (!value || value === this.primaryValue) return;
115
116    this.choosePrimary(value);
117  }
118
119  selectBase(event) {
120    this.chooseBase(event.currentTarget.dataset.themeValue);
121  }
122
123  baseSelectChanged(event) {
124    const value = event.target.value;
125    if (!value || value === this.baseValue) return;
126
127    this.chooseBase(value);
128  }
129
130  selectMode(event) {
131    this.modeValue = event.currentTarget.dataset.themeValue;
132    this.update(`${this.title(this.modeValue)} preview selected.`);
133  }
134
135  toggleLink() {
136    this.linkedValue = !this.linkedValue;
137
138    if (this.linkedValue) {
139      this.baseValue = PAIRINGS[this.primaryValue];
140      this.update(`Base changed to ${this.title(this.baseValue)} to match ${this.title(this.primaryValue)}.`);
141    } else {
142      this.update("Automatic primary and base pairing is off.");
143    }
144  }
145
146  randomize() {
147    const primaryChoices = PRIMARY_NAMES.filter((name) => name !== this.primaryValue);
148    this.primaryValue = primaryChoices[Math.floor(Math.random() * primaryChoices.length)];
149
150    if (this.linkedValue) {
151      this.baseValue = PAIRINGS[this.primaryValue];
152      this.update(`${this.title(this.primaryValue)} and ${this.title(this.baseValue)} make a fine pair.`);
153    } else {
154      const baseChoices = BASE_NAMES.filter((name) => name !== this.baseValue);
155      this.baseValue = baseChoices[Math.floor(Math.random() * baseChoices.length)];
156      this.update(`Randomized to ${this.title(this.primaryValue)} primary and ${this.title(this.baseValue)} base.`);
157    }
158  }
159
160  reset() {
161    this.primaryValue = "base";
162    this.baseValue = "neutral";
163    this.linkedValue = true;
164    this.modeValue = "light";
165    this.update("Theme reset to the Rails Blocks defaults.");
166  }
167
168  navigate(event) {
169    const options = this.optionsFor(event.currentTarget.dataset.themeGroup);
170    const currentIndex = options.indexOf(event.currentTarget);
171    if (currentIndex < 0) return;
172
173    let nextIndex;
174    switch (event.key) {
175      case "ArrowLeft":
176      case "ArrowUp":
177        nextIndex = (currentIndex - 1 + options.length) % options.length;
178        break;
179      case "ArrowRight":
180      case "ArrowDown":
181        nextIndex = (currentIndex + 1) % options.length;
182        break;
183      case "Home":
184        nextIndex = 0;
185        break;
186      case "End":
187        nextIndex = options.length - 1;
188        break;
189      default:
190        return;
191    }
192
193    event.preventDefault();
194    options[nextIndex].focus();
195    options[nextIndex].click();
196  }
197
198  frameLoaded() {
199    this.applyPreview();
200    this.observePreviewHeight();
201  }
202
203  prepareSnippet() {
204    const snippet = this.generatedCss();
205    const prompt = this.generatedPrompt(snippet);
206
207    if (this.hasCodeTarget) {
208      const code = this.codeTarget;
209      code.classList.remove("prism-highlighted");
210      code.textContent = snippet;
211
212      if (typeof Prism !== "undefined") {
213        try {
214          Prism.highlightElement(code);
215          code.classList.add("prism-highlighted");
216        } catch (e) {
217          console.warn("Prism highlighting failed:", e);
218        }
219      }
220
221      const pre = code.closest("pre");
222      if (pre) {
223        pre.classList.add("prism-processed");
224        pre.style.visibility = "visible";
225      }
226    }
227
228    if (this.hasCopyButtonTarget) this.copyButtonTarget.dataset.clipboardText = snippet;
229    if (this.hasCopyPromptButtonTarget) this.copyPromptButtonTarget.dataset.clipboardText = prompt;
230  }
231
232  generatedPrompt(snippet = this.generatedCss()) {
233    const primary = this.title(this.primaryValue);
234    const base = this.title(this.baseValue);
235    const baseNote = this.baseValue === "neutral"
236      ? "Keep Tailwind's default neutral palette (no --color-neutral-* overrides)."
237      : `Remap Tailwind's neutral scale to the ${base} palette via --rb-base-* variables.`;
238
239    return [
240      "Apply this Rails Blocks theme to our app.",
241      "",
242      `Selected recipe: ${primary} primary + ${base} base (light and dark values included).`,
243      "",
244      "## Goal",
245      "Wire the theme into our Tailwind v4 CSS and migrate interactive UI to semantic primary tokens so brand color can change from one place.",
246      "",
247      "## 1. Verify Tailwind / Rails Blocks CSS is set up correctly (critical)",
248      "Inspect our main Tailwind entry (usually `app/assets/tailwind/application.css` or `app/assets/stylesheets/application.css`) and confirm it matches this contract before migrating components. If anything is missing or outdated, update it first (or refresh Rails Blocks Custom CSS from `/docs/installation#3-custom-css`).",
249      "",
250      "Expected pieces:",
251      "",
252      "1. Dark variant follows a `.dark` class on the document root (not `prefers-color-scheme` alone):",
253      "```css",
254      "@custom-variant dark (&:where(.dark, .dark *));",
255      "```",
256      "",
257      "2. Semantic primary tokens are mapped with `--rb-*` fallbacks. Do not invent numbered `primary-*` colors (no `primary-600`):",
258      "```css",
259      "@theme inline {",
260      "  --color-primary: var(--rb-primary, var(--color-neutral-800));",
261      "  --color-primary-hover: var(--rb-primary-hover, var(--color-neutral-700));",
262      "  --color-primary-content: var(--rb-primary-content, var(--color-neutral-800));",
263      "  --color-primary-foreground: var(--rb-primary-foreground, var(--color-white));",
264      "  --color-accent: var(--rb-primary, var(--color-neutral-800));",
265      "}",
266      "```",
267      "",
268      "3. Dark-mode defaults only update semantic `--color-*` values — never assign the customizable `--rb-*` variables here — so a generated theme can be pasted without being overwritten:",
269      "```css",
270      ":root {",
271      "  color-scheme: light;",
272      "  accent-color: var(--color-accent);",
273      "}",
274      "",
275      ":root.dark {",
276      "  color-scheme: dark;",
277      "  --color-primary: var(--rb-primary, var(--color-neutral-100));",
278      "  --color-primary-hover: var(--rb-primary-hover, var(--color-white));",
279      "  --color-primary-content: var(--rb-primary-content, var(--color-white));",
280      "  --color-primary-foreground: var(--rb-primary-foreground, var(--color-neutral-900));",
281      "  --color-accent: var(--rb-primary, var(--color-neutral-100));",
282      "}",
283      "```",
284      "",
285      "4. Form controls and accents should already use semantic utilities such as `accent-primary`, `checked:bg-primary`, and `outline-primary-content` / `ring-primary-content`.",
286      "5. Leave status colors alone (`red`, `amber`, `green`, `blue`) — they are for errors/warnings/success/info, not brand.",
287      "",
288      "## 2. Paste the theme CSS",
289      "1. Keep `@import \"tailwindcss\";` first.",
290      "2. Paste the CSS block below immediately after that import (it can replace an older generated theme block).",
291      "3. Keep using only the unnumbered semantic tokens below.",
292      "",
293      "```css",
294      snippet,
295      "```",
296      "",
297      "## 3. How the theme works",
298      "- Appearance (`light` / `dark` / `system`) is separate from brand theme.",
299      "- Brand theme is controlled by CSS variables: `--rb-primary`, `--rb
299-primary-hover`, `--rb-primary-content`, `--rb-primary-foreground`.",
300      "- `@theme inline` maps those to Tailwind utilities: `bg-primary`, `hover:bg-primary-hover`, `text-primary-content`, `text-primary-foreground`, `accent-primary`.",
301      `- ${baseNote}`,
302      "- Dark mode values live under `.dark` and only change when the document root has `.dark`.",
303      "",
304      "## 4. Code changes to make it functional",
305      "Update our components to use semantic tokens instead of hardcoded brand colors:",
306      "",
307      "| Use case | Prefer | Avoid for brand |",
308      "| --- | --- | --- |",
309      "| Primary button fill | `bg-primary hover:bg-primary-hover` | `bg-red-600 hover:bg-red-500` |",
310      "| Text on primary | `text-primary-foreground` | `text-white` when it must follow brand contrast |",
311      "| Links / emphasis | `text-primary-content` | Hardcoded brand hex or `text-red-700` |",
312      "| Focus rings | `focus-visible:outline-primary-content` / `ring-primary-content` | Fixed brand focus colors |",
313      "| Checkboxes / radios | `accent-primary`, `checked:bg-primary` | Per-control brand colors |",
314      "| Neutrals / surfaces | `bg-neutral-*`, `text-neutral-*`, `border-neutral-*` | Mixing slate/zinc/gray if we remapped neutrals |",
315      "",
316      "Migration checklist:",
317      "1. Find hardcoded brand utilities (e.g. `bg-red-600`, `text-indigo-600`, brand hex values) on buttons, links, tabs, badges that should follow brand.",
318      "2. Replace fills with `bg-primary` / `hover:bg-primary-hover`.",
319      "3. Replace on-primary text with `text-primary-foreground`.",
320      "4. Replace brand text/focus colors with `text-primary-content` / outline/ring primary-content.",
321      "5. Prefer `neutral-*` for surfaces/borders/body text so the selected base palette applies.",
322      "6. Keep true status UI on red/amber/green/blue.",
323      "7. Confirm light and dark both look correct with our existing dark-mode setup (`.dark` on `<html>`).",
324      "",
325      "## 5. Optional brand customization",
326      "If our brand colors differ from this recipe, keep the same variable names and only change the values in `:root` / `.dark` for:",
327      "`--rb-primary`, `--rb-primary-hover`, `--rb-primary-content`, `--rb-primary-foreground`",
328      "(and `--rb-base-*` if we customized neutrals).",
329      "Do not rename the tokens — Rails Blocks components read these names.",
330      "",
331      "Please inspect our codebase, verify the CSS setup against the examples above, apply the theme CSS, and migrate the highest-impact interactive components first (buttons, links, form controls, nav active states).",
332    ].join("\n");
333  }
334
335  choosePrimary(primary) {
336    this.primaryValue = primary;
337
338    if (this.linkedValue) {
339      this.baseValue = PAIRINGS[primary];
340      this.update(`Primary changed to ${this.title(primary)}. Base changed to ${this.title(this.baseValue)}.`);
341    } else {
342      this.update(`Primary changed to ${this.title(primary)}.`);
343    }
344  }
345
346  chooseBase(base) {
347    this.baseValue = base;
348    this.linkedValue = false;
349    this.update(`Base changed to ${this.title(this.baseValue)}. Automatic pairing is off.`);
350  }
351
352  update(message) {
353    this.renderControls();
354    this.applyPreview();
355    this.renderSummary();
356    this.prepareSnippet();
357    this.syncUrl();
358    if (message && this.hasStatusTarget) this.statusTarget.textContent = message;
359  }
360
361  renderControls() {
362    this.renderOptionGroup(this.primaryOptionTargets, this.primaryValue);
363    this.renderOptionGroup(this.baseOptionTargets, this.baseValue);
364    this.renderOptionGroup(this.modeOptionTargets, this.modeValue);
365    this.syncSelect(this.hasPrimarySelectTarget ? this.primarySelectTarget : null, this.primaryValue);
366    this.syncSelect(this.hasBaseSelectTarget ? this.baseSelectTarget : null, this.baseValue);
367
368    const linkLabel = this.linkedValue ? "Unpair colors" : "Pair colors";
369    this.linkButtonTarget.setAttribute("aria-pressed", this.linkedValue.toString());
370    this.linkButtonTarget.setAttribute("aria-label", linkLabel);
371    this.linkButtonTarget.setAttribute("data-tooltip-content", linkLabel);
372
373    const tooltip = this.application.getControllerForElementAndIdentifier(this.linkButtonTarget, "tooltip");
374    if (tooltip) {
375      tooltip.tooltipContent = linkLabel;
376      if (typeof tooltip._renderTooltipContent === "function") tooltip._renderTooltipContent();
377    }
378  }
379
380  renderOptionGroup(options, selectedValue) {
381    options.forEach((option) => {
382      const selected = option.dataset.themeValue === selectedValue;
383      option.setAttribute("aria-checked", selected.toString());
384      option.tabIndex = selected ? 0 : -1;
385    });
386  }
387
388  syncSelect(select, value) {
389    if (!select) return;
390
391    if (select.tomselect) {
392      if (select.tomselect.getValue() !== value) {
393        select.tomselect.setValue(value, true);
394      }
395      return;
396    }
397
398    if (select.value !== value) select.value = value;
399  }
400
401  renderSummary() {
402    this.summaryTarget.textContent = `${this.title(this.primaryValue)} primary · ${this.title(this.baseValue)} base · ${this.title(this.modeValue)} preview`;
403  }
404
405  restoreFromUrl() {
406    const params = new URLSearchParams(window.location.search);
407    const primary = params.get("primary");
408    const base = params.get("base");
409    const mode = params.get("mode");
410    const linked = params.get("linked");
411
412    if (PRIMARY_NAMES.includes(primary)) this.primaryValue = primary;
413    if (BASE_NAMES.includes(base)) this.baseValue = base;
414    if (MODES.includes(mode)) {
415      this.modeValue = mode;
416    } else if (!params.has("mode")) {
417      // Match the site's current effective palette (explicit dark or system→dark).
418      this.modeValue = this.sitePalette();
419    }
420    if (linked === "false" || linked === "0") {
421      this.linkedValue = false;
422    } else if (this.linkedValue) {
423      // A paired URL must always reflect the recommended pairing, even when a
424      // stale or hand-edited base query parameter says otherwise.
425      this.baseValue = PAIRINGS[this.primaryValue];
426    }
427  }
428
429  sitePalette() {
430    const root = document.documentElement;
431    if (root.dataset.theme === "dark" || root.dataset.theme === "light") {
432      return root.dataset.theme;
433    }
434
435    return root.classList.contains("dark") ? "dark" : "light";
436  }
437
438  syncUrl() {
439    const url = new URL(window.location.href);
440    url.searchParams.set("primary", this.primaryValue);
441    url.searchParams.set("base", this.baseValue);
442    url.searchParams.set("mode", this.modeValue);
443
444    if (this.linkedValue) {
445      url.searchParams.delete("linked");
446    } else {
447      url.searchParams.set("linked", "0");
448    }
449
450    window.history.replaceState(window.history.state, "", url);
451  }
452
453  paintSwatches() {
454    this.primaryOptionTargets.forEach((option) => {
455      const value = option.dataset.themeValue;
456      const swatch = option.querySelector("[data-theme-swatch]");
457      // Base primary is dark in light mode and light in dark mode (see recipeFor).
458      // Leave coloring to Tailwind classes so it tracks the site palette.
459      if (value === "base") return;
460
461      swatch.style.backgroundColor = `var(--color-${value}-500)`;
462    });
463
464    this.baseOptionTargets.forEach((option) => {
465      const value = option.dataset.themeValue;
466      const swatch = option.querySelector("[data-theme-swatch]");
467      swatch.style.setProperty("--theme-swatch-light", this.baseColor(value, 600));
468      swatch.style.setProperty("--theme-swatch-dark", this.baseColor(value, 400));
469    });
470  }
471
472  applyPreview() {
473    const root = this.previewRoot;
474    if (!root) return;
475
476    this.applyBase(root);
477    this.applyPrimary(root);
478    root.classList.toggle("dark", this.modeValue === "dark");
479    root.dataset.previewMode = this.modeValue;
480    root.style.colorScheme = this.modeValue;
481    // Match the iframe chrome to the preview mode. Height rounding can expose a
482    // 1–2px strip of the frame background under the document.
483    this.frameTarget.style.backgroundColor =
484      this.modeValue === "dark"
485        ? "var(--color-neutral-950)"
486        : "color-mix(in oklab, var(--color-neutral-50) 45%, white)";
487  }
488
489  applyBase(root) {
490    SHADES.forEach((shade) => {
491      const property = `--color-neutral-${shade}`;
492      if (this.baseValue === "neutral") {
493        root.style.removeProperty(property);
494      } else {
495        root.style.setProperty(property, this.baseColor(this.baseValue, shade));
496      }
497    });
498  }
499
500  applyPrimary(root) {
501    const recipe = this.recipeFor(this.primaryValue, this.modeValue);
502    root.style.setProperty("--rb-primary", recipe.primary);
503    root.style.setProperty("--rb-primary-hover", recipe.hover);
504    root.style.setProperty("--rb-primary-content", recipe.content);
505    root.style.setProperty("--rb-primary-foreground", recipe.foreground);
506    root.dataset.primary = this.primaryValue;
507    // Drive checkbox/radio mark contrast from the same foreground token buttons use.
508    root.dataset.primaryMark = recipe.foreground.includes("white") ? "light" : "dark";
509  }
510
511  observePreviewHeight() {
512    this.previewResizeObserver?.disconnect();
513    const documentElement = this.frameTarget.contentDocument?.documentElement;
514    const body = this.frameTarget.contentDocument?.body;
515    if (!documentElement || !body) return;
516
517    const resize = () => {
518      // Prefer content height over the documentElement. Measuring the iframe's
519      // own viewport height feeds back into itself and grows forever.
520      const height = Math.ceil(
521        Math.max(body.scrollHeight, body.offsetHeight, documentElement.scrollHeight),
522      );
523      if (this.frameTarget.style.height === `${height}px`) return;
524      this.frameTarget.style.height = `${height}px`;
525    };
526
527    resize();
528    this.previewResizeObserver = new ResizeObserver(resize);
529    this.previewResizeObserver.observe(body);
530  }
531
532  generatedCss() {
533    const light = this.recipeFor(this.primaryValue, "light");
534    const dark = this.recipeFor(this.primaryValue, "dark");
535    const baseThemeLines = this.baseValue === "neutral"
536      ? []
537      : SHADES.map((shade) => `  --color-neutral-${shade}: var(--rb-base-${shade});`);
538    const baseRootLines = this.baseValue === "neutral"
539      ? ["    /* Uses Tailwind's default neutral palette. */"]
540      : SHADES.map((shade) => `    --rb-base-${shade}: ${this.baseColor(this.baseValue, shade)};`);
541    const darkCheck = "url(\"data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23171717' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e\")";
542    const darkIndeterminate = "url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3e%3cg fill='none' stroke='%23171717' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' %3e%3cline x1='10.75' y1='6' x2='1.25' y2='6'%3e%3c/line%3e%3c/g%3e%3c/svg%3e\")";
543    const darkRadio = "url(\"data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23171717' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e\")";
544    const lightCheck = "url(\"data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e\")";
545    const lightIndeterminate = "url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3e%3cg fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' %3e%3cline x1='10.75' y1='6' x2='1.25' y2='6'%3e%3c/line%3e%3c/g%3e%3c/svg%3e\")";
546    const lightRadio = "url(\"data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e\")";
547    const lightNeedsDarkMark = !light.foreground.includes("white");
548    const darkNeedsDarkMark = !dark.foreground.includes("white");
549    const markLines = [];
550
551    if (lightNeedsDarkMark) {
552      markLines.push(
553        "",
554        "  :root [type=\"checkbox\"]:checked {",
555        `    background-image: ${darkCheck};`,
556        "  }",
557        "",
558        "  :root [type=\"checkbox\"]:indeterminate {",
559        `    background-image: ${darkIndeterminate};`,
560        "  }",
561        "",
562        "  :root [type=\"radio\"]:checked {",
563        `    background-image: ${darkRadio};`,
564        "  }",
565      );
566    }
567
568    // Always emit dark-mode mark color. Base CSS defaults to dark marks in .dark
569    // (white primary); colored primaries with white foreground must opt back in
570    // to light marks so checkmarks stay visible on saturated fills.
571    markLines.push(
572      "",
573      "  .dark [type=\"checkbox\"]:checked {",
574      `    background-image: ${darkNeedsDarkMark ? darkCheck : lightCheck};`,
575      "  }",
576      "",
577      "  .dark [type=\"checkbox\"]:indeterminate {",
578      `    background-image: ${darkNeedsDarkMark ? darkIndeterminate : lightIndeterminate};`,
579      "  }",
580      "",
581      "  .dark [type=\"radio\"]:checked {",
582      `    background-image: ${darkNeedsDarkMark ? darkRadio : lightRadio};`,
583      "  }",
584    );
585
586    return [
587      "/* Add after @import \"tailwindcss\"; */",
588      "@theme inline {",
589      ...baseThemeLines,
590      "  --color-primary: var(--rb-primary);",
591      "  --color-primary-hover: var(--rb-primary-hover);",
592      "  --color-primary-content: var(--rb-primary-content);",
593      "  --color-primary-foreground: var(--rb-primary-foreground);",
594      "  --color-accent: var(--rb-primary);",
595      "}",
596      "",
597      "@layer theme {",
598      "  :root {",
599      ...baseRootLines,
600      `    --rb-primary: ${light.primary};`,
601      `    --rb-primary-hover: ${light.hover};`,
602      `    --rb-primary-content: ${light.content};`,
603      `    --rb-primary-foreground: ${light.foreground};`,
604      "    accent-color: var(--rb-primary);",
605      "  }",
606      "",
607      "  .dark {",
608      `    --rb-primary: ${dark.primary};`,
609      `    --rb-primary-hover: ${dark.hover};`,
610      `    --rb-primary-content: ${dark.content};`,
611      `    --rb-primary-foreground: ${dark.foreground};`,
612      "  }",
613      ...markLines,
614      "}",
615    ].join("\n");
616  }
617
618  recipeFor(primary, mode) {
619    if (primary === "base") {
620      return mode === "dark"
621        ? {
622            primary: "var(--color-neutral-100)",
623            hover: "var(--color-white)",
624            content: "var(--color-white)",
625            foreground: "var(--color-neutral-900)",
626          }
627        : {
628            primary: "var(--color-neutral-800)",
629            hover: "var(--color-neutral-700)",
630            content: "var(--color-neutral-800)",
631            foreground: "var(--color-white)",
632          };
633    }
634
635    const recipe = PRIMARY_RECIPES[primary];
636    const offset = mode === "dark" ? 4 : 0;
637    return {
638      primary: this.colorReference(primary, recipe[offset]),
639      hover: this.colorReference(primary, recipe[offset + 1]),
640      content: this.colorReference(primary, recipe[offset + 2]),
641      foreground: this.foregroundReference(recipe[offset + 3]),
642    };
643  }
644
645  foregroundReference(value) {
646    if (value === "white") return "var(--color-white)";
647    const [name, shade] = value.split("-");
648    return this.colorReference(name, shade);
649  }
650
651  colorReference(name, shade) {
652    return `var(--color-${name}-${shade})`;
653  }
654
655  baseColor(base, shade) {
656    const custom = CUSTOM_BASES[base];
657    if (!custom) return `var(--color-${base}-${shade})`;
658
659    return `oklch(${BASE_LIGHTNESS[shade]} ${custom.hue})`;
660  }
661
662  optionsFor(group) {
663    switch (group) {
664      case "primary": return this.primaryOptionTargets;
665      case "base": return this.baseOptionTargets;
666      case "mode": return this.modeOptionTargets;
667      default: return [];
668    }
669  }
670
671  get previewRoot() {
672    return this.frameTarget.contentDocument?.documentElement;
673  }
674
675  title(value) {
676    return value.charAt(0).toUpperCase() + value.slice(1);
677  }
678};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.