PageSourceSearch

https://www.georgetowncoc.com/assets/controllers/site_design_controller-f50c86b4.js

js georgetowncoc.com collected 2026-09-25 04:05:57 UTC 5,978 bytes, 178 lines download raw bytes

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.