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.