1import { Controller } from "@hotwired/stimulus" 2 3// Live style preview. 4// 5// Every value here is a CSS custom property that the public layout writes onto 6// <body>, so previewing a change is just setting the same property on the 7// preview document - no round trip, and it stays honest because the preview is 8// the real site rather than a mock-up of it. 9export default class extends Controller { 10 static targets = ["frame", "swatch", "alpha", "font", "radius", "value", "readout", "contrast", "row"] 11 static values = { previewUrl: String } 12 13 connect() { 14 this.defaults = new Map(this.valueTargets.map((input) => [input.dataset.siteDesignVar, input.value])) 15 } 16 17 // A solid colour is its swatch; a translucent one is its swatch plus opacity. 18 pickColor(event) { 19 const name = event.currentTarget.dataset.siteDesignVar 20 const hex = this.targetFor(this.swatchTargets, name)?.value || "#000000" 21 const alphaInput = this.targetFor(this.alphaTargets, name) 22 23 if (alphaInput) { 24 const alpha = Number(alphaInput.value) / 100 25 const [r, g, b] = this.channels(hex) 26 this.write(name, `rgba(${r}, ${g}, ${b}, ${alpha})`, `${Math.round(alpha * 100)}%`) 27 } else { 28 this.write(name, hex, hex) 29 } 30 } 31 32 pickFont(event) { 33 const name = event.currentTarget.dataset.siteDesignVar 34 this.write(name, event.currentTarget.value, null) 35 this.syncPreviewFonts() 36 } 37 38 pickRadius(event) { 39 const name = event.currentTarget.dataset.siteDesignVar 40 const px = Number(event.currentTarget.value) 41 this.write(name, `${px}px`, `${px}px`) 42 } 43 44 resetAll(event) { 45 event.preventDefault() 46 this.defaults.forEach((value, name) => { 47 this.write(name, value, null) 48 this.syncControls(name, value) 49 }) 50 } 51 52 applyAll() { 53 this.valueTargets.forEach((input) => this.preview(input.dataset.siteDesignVar, input.value)) 54 this.syncPreviewFonts() 55 } 56 57 // One place writes the stored value, the preview, and the readout, so they 58 // cannot drift apart. 59 write(name, value, readout) { 60 const input = this.targetFor(this.valueTargets, name) 61 if (!input) return 62 63 input.value = value 64 if (readout !== null) { 65 const node = this.targetFor(this.readoutTargets, name) 66 if (node) node.textContent = readout 67 } 68 69 this.preview(name, value) 70 this.updateContrast(name, value) 71 } 72 73 syncControls(name, value) { 74 const swatch = this.targetFor(this.swatchTargets, name) 75 const alpha = this.targetFor(this.alphaTargets, name) 76 const font = this.targetFor(this.fontTargets, name) 77 const radius = this.targetFor(this.radiusTargets, name) 78 79 const rgba = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([0-9.]+)\s*)?\)$/i.exec(value) 80 if (swatch && rgba) swatch.value = this.toHex(Number(rgba[1]), Number(rgba[2]), Number(rgba[3])) 81 else if (swatch && /^#[0-9a-f]{3,6}$/i.test(value)) swatch.value = value 82 if (alpha && rgba) alpha.value = Math.round(Number(rgba[4] ?? 1) * 100) 83 if (font) font.value = value 84 if (radius) radius.value = parseFloat(value) || 0 85 } 86 87 // Setting font-family only names a face; the preview still has to fetch it, 88 // or every choice that shares a fallback looks identical. 89 syncPreviewFonts() { 90 const doc = this.previewDocument() 91 if (!doc) return 92 93 const wanted = this.fontTargets 94 .map((select) => select.selectedOptions[0]?.dataset.fontHref) 95 .filter(Boolean) 96 97 const existing = Array.from(doc.querySelectorAll("link[data-site-design-font]")) 98 existing.forEach((link) => { 99 if (!wanted.includes(link.getAttribute("href"))) link.remove() 100 }) 101 102 wanted.forEach((href) => { 103 if (existing.some((link) => link.getAttribute("href") === href)) return 104 105 const link = doc.createElement("link") 106 link.rel = "stylesheet" 107 link.setAttribute("data-site-design-font", "true") 108 link.setAttribute("href", href) 109 doc.head.appendChild(link) 110 }) 111 } 112 113 preview(name, value) { 114 const body = this.previewBody() 115 if (body && value) body.style.setProperty(name, value) 116 } 117 118 updateContrast(name, value) { 119 const node = this.targetFor(this.contrastTargets, name) 120 if (!node || node.dataset.siteDesignTextLike !== "true") return 121 122 const ratio = this.contrastWithWhite(value) 123 if (ratio === null) { 124 node.textContent = "" 125 return 126 } 127 128 const readable = ratio >= 4.5 129 node.textContent = readable 130 ? `Reads clearly on the page background (${ratio}:1)` 131 : `Hard to read as text on the page background (${ratio}:1)` 132 node.classList.toggle("text-amber-700", !readable) 133 node.classList.toggle("text-zinc-500", readable) 134 } 135 136 // WCAG relative luminance. Anything that is not plain hex opts out rather than 137 // guessing, because a wrong reassurance is worse than none. 138 contrastWithWhite(value) { 139 const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value.trim()) 140 if (!match) return null 141 142 const channels = this.channels(value).map((raw) => { 143 const channel = raw / 255 144 return channel <= 0.03928 ? channel / 12.92 : Math.pow((ch
144annel + 0.055) / 1.055, 2.4) 145 }) 146 147 const luminance = 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2] 148 return Math.round((1.05 / (luminance + 0.05)) * 10) / 10 149 } 150 151 channels(hex) { 152 let digits = hex.trim().replace("#", "") 153 if (digits.length === 3) digits = digits.split("").map((char) => char + char).join("") 154 return [0, 2, 4].map((offset) => parseInt(digits.slice(offset, offset + 2), 16) || 0) 155 } 156 157 toHex(r, g, b) { 158 return `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}` 159 } 160 161 previewDocument() { 162 if (!this.hasFrameTarget) return null 163 164 try { 165 return this.frameTarget.contentDocument || null 166 } catch (_error) { 167 return null 168 } 169 } 170 171 previewBody() { 172 return this.previewDocument()?.body || null 173 } 174 175 targetFor(targets, name) { 176 return targets.find((node) => node.dataset.siteDesignVar === name) 177 } 178}
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.