loco_motion-rails 0.7.3 → 0.8.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46e6960770e08d768c1d294265dda365243b1e1869de61b67ded52be177614ef
4
- data.tar.gz: 25859b6e5e9d269ed378fd5ee5241efd579094f5305e9875d5b5c06f9b2e2165
3
+ metadata.gz: 7f562576e236a298243e3c154e468e081c58471ba23b86e6dbc1486084c72db2
4
+ data.tar.gz: d3958a2260cee9c7d3529e64c4b7252c4921d4368143c9176e58f8545d7e2392
5
5
  SHA512:
6
- metadata.gz: 32795ba20a4769bd55d2cd917ac78030b28832bfc6d90186eade88311e2043c1d40a76c4fd15b856fc109edf07c3268c81d52763c3cb43087189ee779b0f513d
7
- data.tar.gz: 365d268fed487d7728bc5b1f229403fbadf05ceaae9141efab23f5837b2eebac5dcbcbc83a5d8dcdb2baad3c78a03fee5e7d65125f32d8c65ca4d8dc05c5b0d8
6
+ metadata.gz: 5e07962f154fe394cab75b5da4d9f973e8ccf99de55969ac40ab2d0562cdf7ca7c87367774c4f2e490b196a905ef4b35c6c70956c5f4147de5052174e164ed76
7
+ data.tar.gz: 99ada80a6c6237bdc8c315b26e7c45236753f794383e3bad1d2d0035c555e3486b23bb035fc36647f29b1e55b834e33d85967c53c6bb638bffe4e58ee80af0e0
data/README.md CHANGED
@@ -13,6 +13,7 @@ ViewComponent, TailwindCSS, DaisyUI and more!
13
13
  - [Current Status](#current-status)
14
14
  - [Installation](#installation)
15
15
  - [Using Components](#using-components)
16
+ - [Linting Component Usage](#linting-component-usage)
16
17
  - [Guides](#guides)
17
18
  - [Documentation & Demo](#documentation--demo)
18
19
  - [Developing](#developing)
@@ -70,7 +71,7 @@ Add the gem to your `Gemfile` and run `bundle`:
70
71
 
71
72
  ```ruby
72
73
  # Gemfile
73
- gem "loco_motion-rails", "~> 0.7.3", require: "loco_motion"
74
+ gem "loco_motion-rails", "~> 0.8.0", require: "loco_motion"
74
75
  ```
75
76
 
76
77
  Some components also need a JavaScript package (for their Stimulus controllers)
@@ -104,6 +105,75 @@ For the full set of available components, parts, slots, and live examples, see
104
105
  the [demo site](https://loco-motion.profoundry.us/) and the
105
106
  [API documentation](#documentation--demo).
106
107
 
108
+ ## Linting Component Usage
109
+
110
+ LocoMotion ships a [haml_lint](https://github.com/sds/haml-lint) rule that flags
111
+ views hand-rolling DaisyUI markup instead of calling the helper that owns it —
112
+ `.card` where `daisy_card` belongs. Views compose components; components own
113
+ markup.
114
+
115
+ Wire it up for whichever template language you use — both read the same derived
116
+ map, so HAML and ERB are held to identical rules.
117
+
118
+ **HAML** — add `haml_lint` to your bundle, then `.haml-lint.yml`:
119
+
120
+ ```yaml
121
+ require:
122
+ - loco_motion/lint/haml_lint/component_usage
123
+
124
+ linters:
125
+ LocoMotionComponentUsage:
126
+ enabled: true
127
+ ```
128
+
129
+ ```bash
130
+ bundle exec haml-lint --include-linter LocoMotionComponentUsage app/views
131
+ ```
132
+
133
+ **ERB** — add `erb_lint` to your bundle. It loads custom linters from a
134
+ directory rather than a config key, so create `.erb_linters/loco_motion.rb`:
135
+
136
+ ```ruby
137
+ require "loco_motion/lint/erb_lint/component_usage"
138
+ ```
139
+
140
+ Then `.erb_lint.yml`:
141
+
142
+ ```yaml
143
+ linters:
144
+ LocoMotionComponentUsage:
145
+ enabled: true
146
+ ```
147
+
148
+ ```bash
149
+ bundle exec erb_lint --lint-all --enable-linters LocoMotionComponentUsage
150
+ ```
151
+
152
+ Either way the output names the helper to reach for:
153
+
154
+ ```
155
+ app/views/orders/show.html.haml:12 [W] LocoMotionComponentUsage: `.card` is
156
+ LocoMotion's daisy_card — call the helper instead of hand-rolling the markup
157
+ ```
158
+
159
+ The class list is **derived** from the component registry and each component's
160
+ own `add_css` declaration, so it tracks the library automatically — a component
161
+ added in a release is covered by that release. Tailwind utilities (`flex`,
162
+ `p-4`, `hover:*`) and DaisyUI modifiers (`btn-primary`) are never flagged;
163
+ `css:` remains the sanctioned way to pass those to a helper.
164
+
165
+ Suppress a deliberate exception the usual haml_lint way:
166
+
167
+ ```haml
168
+ -# haml-lint:disable LocoMotionComponentUsage
169
+ .card.custom-thing
170
+ -# haml-lint:enable LocoMotionComponentUsage
171
+ ```
172
+
173
+ Adopting this on an existing codebase will surface a backlog. `haml-lint
174
+ --auto-gen-config` writes a TODO file that excludes today's offenders so new
175
+ views are held to the rule while the backlog drains.
176
+
107
177
  ## Guides
108
178
 
109
179
  These optional guides cover the broader LocoMotion philosophies and a
@@ -139,18 +209,20 @@ for the time being, the latest documentation is available at the links below.
139
209
  To work on LocoMotion, first clone the repository and make sure you have Docker
140
210
  installed and running on your machine.
141
211
 
142
- Next, create a `.env.local` file with the following contents, making sure to
143
- replace the Unsplash keys with real ones (you can create your own account or ask
144
- Topher for his keys).
212
+ Next, create a `docs/demo/.env.local` file with the following contents, making
213
+ sure to replace the Unsplash keys with real ones (you can create your own
214
+ account or ask Topher for his keys). The demo boots fine without them — only the
215
+ example pages that pull sample photos will fail.
145
216
 
146
217
  ```.env
147
- # .env.local
218
+ # docs/demo/.env.local
148
219
  UNSPLASH_ACCESS_KEY="<< INSERT ACCESS KEY >>"
149
220
  UNSPLASH_SECRET_KEY="<< INSERT SECRET KEY >>"
150
221
  ```
151
222
 
152
- You should then be able to run `just rebuild` in the project directory and then
153
- `just all-quick` to start the services.
223
+ You should then be able to run `just build` in the project directory and then
224
+ `just all-fast` to start the services. (`just rebuild` also works, but it passes
225
+ `--no-cache`, which is wasted effort on a fresh clone with an empty cache.)
154
226
 
155
227
  > [!NOTE]
156
228
  >
@@ -165,7 +237,7 @@ You should then be able to run `just rebuild` in the project directory and then
165
237
  > See https://github.com/profoundry-us/loco_motion-buildpack for more info.
166
238
 
167
239
  From here, you can access the demo site at http://localhost:3000 and the YARD
168
- docs at http://localhost:8808/docs/yard
240
+ docs at http://localhost:8808
169
241
 
170
242
  You can type `just demo-shell` to open a shell inside the demo Docker container,
171
243
  or `just loco-shell` to get a shell inside the gem's Docker container.
@@ -28,11 +28,48 @@
28
28
  @custom-variant where (:where(&));
29
29
 
30
30
  /*
31
- * `dark` variant — make `dark:` utilities respond to both an OS-level dark
32
- * preference and a DaisyUI ThemeController set to a dark theme, so manual theme
33
- * switching and system preference stay in sync.
31
+ * `dark` variant — make `dark:` utilities follow the app's actual theme, not
32
+ * just the OS preference. In priority order, `dark:` styles apply when:
33
+ *
34
+ * 1. `data-color-scheme="dark"` is set on an ancestor. LocoMotion's
35
+ * ThemeController stamps this on `<html>` from the active theme's own
36
+ * `color-scheme` declaration, so *any* dark DaisyUI theme (`night`,
37
+ * `synthwave`, a custom theme, …) enables `dark:` utilities — no
38
+ * hardcoded names.
39
+ * 2. `data-theme="dark"` is set on an ancestor — the name-based fallback
40
+ * for apps that manage `data-theme` themselves without stamping a
41
+ * scheme.
42
+ * 3. A checked `.theme-controller` input with `value="dark"` is present —
43
+ * kept for pure-CSS/no-JS DaisyUI theme switching, where the theme
44
+ * changes without `data-theme` ever being set.
45
+ * 4. The OS prefers dark (`prefers-color-scheme`) — the no-choice-saved
46
+ * fallback, suppressed when any theme other than `dark` is explicitly
47
+ * applied via `data-theme`, or when the stamped scheme is `light`
48
+ * (mirrors DaisyUI's `--default` / `--prefersdark` semantics: an
49
+ * explicit choice always wins).
50
+ *
51
+ * Note: without the scheme stamping (1), the fallbacks key on the theme
52
+ * *name* `dark`. Apps not running LocoMotion's ThemeController should either
53
+ * restyle the built-in `dark` theme (`@plugin "daisyui/theme" { name:
54
+ * "dark"; prefersdark: true; ... }`), stamp `data-color-scheme` themselves,
55
+ * or define their own `dark` variant.
34
56
  */
35
- @custom-variant dark (@media (prefers-color-scheme: dark), :root:has(input.theme-controller[value=dark]:checked) &);
57
+ @custom-variant dark {
58
+ &:where([data-color-scheme="dark"], [data-color-scheme="dark"] *) {
59
+ @slot;
60
+ }
61
+ &:where([data-theme="dark"], [data-theme="dark"] *) {
62
+ @slot;
63
+ }
64
+ :root:has(input.theme-controller[value="dark"]:checked) & {
65
+ @slot;
66
+ }
67
+ @media (prefers-color-scheme: dark) {
68
+ &:not(:where([data-theme]:not([data-theme="dark"]), [data-theme]:not([data-theme="dark"]) *, [data-color-scheme="light"], [data-color-scheme="light"] *)) {
69
+ @slot;
70
+ }
71
+ }
72
+ }
36
73
 
37
74
  /*
38
75
  * Reveal tooltips on keyboard focus when the `tooltip` class is applied
@@ -4,15 +4,41 @@
4
4
  * A Stimulus controller that manages theme selection and persistence.
5
5
  * It handles theme switching, localStorage persistence, and synchronization
6
6
  * across multiple theme selectors on the same page.
7
+ *
8
+ * Themes are stored in two preference SLOTS — day and night — so users can
9
+ * keep a preferred theme for each. Either slot may hold ANY theme (some
10
+ * people run a dark theme during the day):
11
+ *
12
+ * - `savedLightTheme` / `savedDarkTheme` — the theme saved in the day /
13
+ * night slot. Classic switchers file a pick under the slot matching the
14
+ * theme's own `color-scheme` declaration; slot-scoped pickers (the
15
+ * `setSchemeTheme` action) file it under THEIR slot regardless.
16
+ * - `savedLightScheme` / `savedDarkScheme` — the actual `color-scheme`
17
+ * of the theme in each slot, cached at save time so the pre-paint
18
+ * script can stamp it before any CSS loads.
19
+ * - `savedThemeMode` — which slot is active: `"light"` or `"dark"` pin
20
+ * that slot, while `"system"` follows the OS preference live, swapping
21
+ * between the two saved themes as the OS switches.
22
+ * - The legacy single `savedTheme` key is migrated on connect.
23
+ *
24
+ * Whenever a theme is applied, the DISPLAYED theme's own scheme is stamped
25
+ * on `<html>` as `data-color-scheme`, which drives the `dark:` Tailwind
26
+ * variant shipped in loco.css — so `dark:` utilities follow the theme
27
+ * actually showing, even a dark theme sitting in the day slot. The active
28
+ * slot is stamped as `data-theme-mode`.
7
29
  */
8
30
  import { Controller } from "@hotwired/stimulus"
9
31
 
10
32
  export default class extends Controller {
11
33
  /**
12
34
  * Called when the controller is connected to the DOM.
13
- * Sets the initial theme input state and sets up event listeners.
35
+ * Migrates legacy storage, stamps the active color scheme, sets the
36
+ * initial theme input state, and sets up event listeners.
14
37
  */
15
38
  connect() {
39
+ this.migrateLegacyStorage()
40
+ this.stampScheme()
41
+ this.stampMode(this.safeStorageGet('savedThemeMode'))
16
42
  this.setInput()
17
43
 
18
44
  // Setup a custom listener to watch for changes on the page in case the
@@ -26,6 +52,11 @@ export default class extends Controller {
26
52
  // enough to save the theme and keep every other selector in sync.
27
53
  this.inputChangeListener = this.handleInputChange.bind(this)
28
54
  this.element.addEventListener('change', this.inputChangeListener)
55
+
56
+ // In "system" mode the active theme follows the OS preference live.
57
+ this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
58
+ this.mediaChangeListener = this.mediaChanged.bind(this)
59
+ this.mediaQuery.addEventListener('change', this.mediaChangeListener)
29
60
  }
30
61
 
31
62
  /**
@@ -35,6 +66,7 @@ export default class extends Controller {
35
66
  disconnect() {
36
67
  window.removeEventListener('localstorage-update', this.storageChangeListener)
37
68
  this.element.removeEventListener('change', this.inputChangeListener)
69
+ this.mediaQuery.removeEventListener('change', this.mediaChangeListener)
38
70
  }
39
71
 
40
72
  /**
@@ -45,17 +77,44 @@ export default class extends Controller {
45
77
  */
46
78
  setInput() {
47
79
  const theme = this.getCurrentTheme()
48
- const inputs = this.element.querySelectorAll('input.theme-controller')
80
+ const inputs = this.element.querySelectorAll('input.theme-controller, input[data-loco-theme-scheme]')
49
81
 
50
82
  inputs.forEach((input) => {
51
- input.checked = input.value === theme
83
+ const scheme = input.dataset.locoThemeScheme
84
+
85
+ if (scheme) {
86
+ // Scheme-scoped picker radios show their SLOT's saved theme
87
+ // (falling back to the built-in theme of the same name), not the
88
+ // theme currently applied to the page.
89
+ const saved = this.safeStorageGet(scheme === 'dark' ? 'savedDarkTheme' : 'savedLightTheme')
90
+
91
+ input.checked = input.value === (saved || scheme)
92
+ } else {
93
+ input.checked = input.value === theme
94
+ }
95
+ })
96
+
97
+ // "Match system appearance" toggles reflect the saved mode, and
98
+ // "Night mode" toggles reflect the scheme actually showing (so they
99
+ // read correctly in system mode too).
100
+ const synced = this.safeStorageGet('savedThemeMode') === 'system'
101
+ const resolved = this.resolveTheme()
102
+ const activeScheme = (resolved && resolved.scheme) || this.computedScheme()
103
+
104
+ this.element.querySelectorAll('input[data-loco-theme-mode-toggle]').forEach((toggle) => {
105
+ toggle.checked = synced
106
+ })
107
+
108
+ this.element.querySelectorAll('input[data-loco-theme-night-toggle]').forEach((toggle) => {
109
+ toggle.checked = activeScheme === 'dark'
52
110
  })
53
111
  }
54
112
 
55
113
  /**
56
- * Clears the user's theme preference from localStorage.
57
- * Removes the saved theme and dispatches an event to notify other controllers.
58
- * Also removes the data-theme attribute from the document element.
114
+ * Clears the user's theme preferences from localStorage.
115
+ * Removes every saved theme key and dispatches an event to notify other
116
+ * controllers. Also removes the `data-theme` and `data-color-scheme`
117
+ * attributes from the document element.
59
118
  *
60
119
  * @param {Event} event - The triggering click event
61
120
  */
@@ -71,15 +130,21 @@ export default class extends Controller {
71
130
  }
72
131
  }
73
132
 
74
- // Remove the savedTheme from local storage
133
+ // Remove every saved theme key from local storage
75
134
  this.safeStorageRemove("savedTheme")
135
+ this.safeStorageRemove("savedThemeMode")
136
+ this.safeStorageRemove("savedLightTheme")
137
+ this.safeStorageRemove("savedDarkTheme")
138
+ this.safeStorageRemove("savedLightScheme")
139
+ this.safeStorageRemove("savedDarkScheme")
76
140
 
77
- // Remove the data-theme attribute from the document element
141
+ // Remove the theme attributes from the document element
78
142
  document.documentElement.removeAttribute('data-theme')
143
+ document.documentElement.removeAttribute('data-color-scheme')
144
+ document.documentElement.removeAttribute('data-theme-mode')
79
145
 
80
146
  // Fire off an update
81
- const updateEvent = new CustomEvent('localstorage-update', { detail: { key: 'savedTheme', newValue: null } })
82
- window.dispatchEvent(updateEvent)
147
+ this.broadcast(null)
83
148
  }
84
149
 
85
150
  /**
@@ -106,6 +171,94 @@ export default class extends Controller {
106
171
  event.preventDefault()
107
172
  }
108
173
 
174
+ /**
175
+ * Changes how the active color scheme is chosen. Expects a Stimulus action
176
+ * param, e.g. `data-loco-theme-mode-param="system"`:
177
+ *
178
+ * - `"light"` / `"dark"` — pin that scheme; its saved theme (or the
179
+ * built-in `light`/`dark` theme when none is saved) applies.
180
+ * - `"system"` — follow the OS preference live, swapping between the
181
+ * saved light and dark themes as the OS switches.
182
+ *
183
+ * @param {Event} event - The triggering event with a `mode` param
184
+ */
185
+ setMode(event) {
186
+ const mode = event && event.params && event.params.mode
187
+
188
+ if (!mode) return
189
+
190
+ this.safeStorageSet('savedThemeMode', mode)
191
+ this.stampMode(mode)
192
+ this.applyResolvedTheme()
193
+
194
+ if (event.preventDefault) event.preventDefault()
195
+ }
196
+
197
+ /**
198
+ * Applies a theme into a SPECIFIC preference slot — the action behind
199
+ * slot-scoped pickers (a "Day theme" / "Night theme" dropdown pair).
200
+ * Expects a Stimulus action param naming the slot, e.g.
201
+ * `data-loco-theme-scheme-param="light"`. Unlike a classic switcher pick
202
+ * (which files the theme under its own color-scheme), the pick lands in
203
+ * the picker's slot even when the theme's scheme differs — a dark theme
204
+ * can be someone's daytime choice. The pick applies immediately and pins
205
+ * its slot.
206
+ *
207
+ * Like {setTheme}, the action can sit on a wrapper element containing an
208
+ * `<input>` or on the input itself.
209
+ *
210
+ * @param {Event} event - The triggering click event with a `scheme` param
211
+ */
212
+ setSchemeTheme(event) {
213
+ const slot = event && event.params && event.params.scheme
214
+ const target = event.currentTarget
215
+ const input = target.matches && target.matches('input')
216
+ ? target
217
+ : target.querySelector('input')
218
+
219
+ if (input && slot) {
220
+ this.applyTheme(input.value, slot)
221
+ }
222
+
223
+ event.preventDefault()
224
+ }
225
+
226
+ /**
227
+ * Backs the "Night mode" toggle (see the component's
228
+ * `build_night_toggle`). Checking it pins the dark scheme — the saved
229
+ * night theme applies immediately, no OS setting required — and
230
+ * unchecking pins light. Either direction is an explicit choice, so it
231
+ * leaves `system` mode.
232
+ *
233
+ * @param {Event} event - The change event from the toggle's checkbox
234
+ */
235
+ toggleNightMode(event) {
236
+ const mode = event.currentTarget.checked ? 'dark' : 'light'
237
+
238
+ this.safeStorageSet('savedThemeMode', mode)
239
+ this.stampMode(mode)
240
+ this.applyResolvedTheme()
241
+ }
242
+
243
+ /**
244
+ * Backs the "Match system appearance" toggle (see the component's
245
+ * `build_system_toggle`). Checking it enters `system` mode; unchecking it
246
+ * pins whichever scheme is currently showing, so the visible theme does
247
+ * not change when sync turns off.
248
+ *
249
+ * @param {Event} event - The change event from the toggle's checkbox
250
+ */
251
+ toggleSystemMode(event) {
252
+ const resolved = this.resolveTheme()
253
+ const mode = event.currentTarget.checked
254
+ ? 'system'
255
+ : ((resolved && resolved.scheme) || this.computedScheme())
256
+
257
+ this.safeStorageSet('savedThemeMode', mode)
258
+ this.stampMode(mode)
259
+ this.applyResolvedTheme()
260
+ }
261
+
109
262
  /**
110
263
  * Handles `change` events bubbling up from any theme-controller input within
111
264
  * this controller. Persists and broadcasts the newly selected theme so every
@@ -126,14 +279,79 @@ export default class extends Controller {
126
279
  * Persists the given theme, applies it to the document, and notifies every
127
280
  * other theme controller on the page so they can sync their inputs.
128
281
  *
282
+ * Without a `slot`, the theme is saved as the preference for the scheme
283
+ * it belongs to (read from its own computed `color-scheme`) — classic
284
+ * switcher behavior. With a `slot` (from a day / night picker), it lands
285
+ * in that slot regardless of its own scheme. Either way the slot's actual
286
+ * scheme is cached for the pre-paint script, the mode pins to the slot,
287
+ * and `data-color-scheme` reflects the DISPLAYED theme's own scheme so
288
+ * `dark:` utilities stay truthful.
289
+ *
129
290
  * @param {string} value - The theme name to apply
291
+ * @param {?string} slot - The preference slot to fill (`"light"` /
292
+ * `"dark"`); defaults to the theme's own scheme
130
293
  */
131
- applyTheme(value) {
132
- this.safeStorageSet("savedTheme", value)
294
+ applyTheme(value, slot = null) {
133
295
  document.documentElement.setAttribute('data-theme', value)
134
296
 
135
- const updateEvent = new CustomEvent('localstorage-update', { detail: { key: 'savedTheme', newValue: value } })
136
- window.dispatchEvent(updateEvent)
297
+ const scheme = this.schemeForTheme(value)
298
+ const targetSlot = slot || scheme
299
+
300
+ this.safeStorageSet(targetSlot === 'dark' ? 'savedDarkTheme' : 'savedLightTheme', value)
301
+ this.safeStorageSet(targetSlot === 'dark' ? 'savedDarkScheme' : 'savedLightScheme', scheme)
302
+ this.safeStorageSet('savedThemeMode', targetSlot)
303
+ this.safeStorageRemove('savedTheme')
304
+
305
+ document.documentElement.setAttribute('data-color-scheme', scheme)
306
+ this.stampMode(targetSlot)
307
+
308
+ this.broadcast(value)
309
+ }
310
+
311
+ /**
312
+ * Resolves the active theme from the saved mode + per-scheme preferences
313
+ * and applies it: sets `data-theme`, stamps `data-color-scheme`, and
314
+ * broadcasts so all selectors re-sync. Used when the mode or the OS
315
+ * preference changes.
316
+ */
317
+ applyResolvedTheme() {
318
+ const resolved = this.resolveTheme()
319
+
320
+ if (!resolved) return
321
+
322
+ document.documentElement.setAttribute('data-theme', resolved.theme)
323
+
324
+ // Stamp the DISPLAYED theme's own scheme, not the slot name — the day
325
+ // slot can legitimately hold a dark theme. Prefer the scheme cached at
326
+ // save time (it works even when the theme's CSS isn't loaded); probe
327
+ // the stylesheet only as a fallback.
328
+ const cached = this.safeStorageGet(resolved.scheme === 'dark' ? 'savedDarkScheme' : 'savedLightScheme')
329
+
330
+ document.documentElement.setAttribute('data-color-scheme', cached || this.schemeForTheme(resolved.theme))
331
+
332
+ this.broadcast(resolved.theme)
333
+ }
334
+
335
+ /**
336
+ * Resolves which theme and scheme should be active from localStorage.
337
+ *
338
+ * @returns {?{theme: string, scheme: string}} The resolved theme and
339
+ * scheme, or null when no mode is saved (no explicit choice yet)
340
+ */
341
+ resolveTheme() {
342
+ const mode = this.safeStorageGet('savedThemeMode')
343
+
344
+ if (!mode) return null
345
+
346
+ const scheme = mode === 'system'
347
+ ? (this.mediaQuery && this.mediaQuery.matches ? 'dark' : 'light')
348
+ : mode
349
+ const saved = this.safeStorageGet(scheme === 'dark' ? 'savedDarkTheme' : 'savedLightTheme')
350
+
351
+ // With no saved preference for the scheme, fall back to the built-in
352
+ // theme of the same name — DaisyUI always defines `light` and `dark`,
353
+ // and downstream apps restyle those names rather than renaming them.
354
+ return { theme: saved || scheme, scheme }
137
355
  }
138
356
 
139
357
  /**
@@ -149,15 +367,122 @@ export default class extends Controller {
149
367
  * @returns {?string} The current theme name, or null if none can be determined
150
368
  */
151
369
  getCurrentTheme() {
152
- const savedTheme = this.safeStorageGet('savedTheme')
370
+ const resolved = this.resolveTheme()
153
371
 
154
- if (savedTheme) {
155
- return savedTheme
372
+ if (resolved) {
373
+ return resolved.theme
156
374
  }
157
375
 
158
376
  return document.documentElement.getAttribute('data-theme')
159
377
  }
160
378
 
379
+ /**
380
+ * Migrates the legacy single `savedTheme` key to the per-scheme model by
381
+ * re-applying it through `applyTheme`, which classifies the theme by its
382
+ * own `color-scheme`, saves it into the matching slot, pins the mode, and
383
+ * removes the legacy key.
384
+ */
385
+ migrateLegacyStorage() {
386
+ const legacy = this.safeStorageGet('savedTheme')
387
+
388
+ if (legacy && !this.safeStorageGet('savedThemeMode')) {
389
+ this.applyTheme(legacy)
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Stamps `data-color-scheme` on the document element from the active
395
+ * theme's computed `color-scheme` when an explicit theme is applied but no
396
+ * scheme is stamped yet (e.g. a server-rendered `data-theme`).
397
+ */
398
+ stampScheme() {
399
+ const root = document.documentElement
400
+
401
+ if (root.getAttribute('data-theme') && !root.getAttribute('data-color-scheme')) {
402
+ root.setAttribute('data-color-scheme', this.computedScheme())
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Stamps the saved theme mode on `<html>` as `data-theme-mode` so UI can
408
+ * reflect the active mode with pure CSS — e.g. the switcher dropdown's
409
+ * "Sync with system" checkmark uses a
410
+ * `[[data-theme-mode=system]_&]:visible` variant. Removes the attribute
411
+ * when no mode is saved.
412
+ *
413
+ * @param {?string} mode - `"light"`, `"dark"`, `"system"`, or null
414
+ */
415
+ stampMode(mode) {
416
+ if (mode) {
417
+ document.documentElement.setAttribute('data-theme-mode', mode)
418
+ } else {
419
+ document.documentElement.removeAttribute('data-theme-mode')
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Reads the active theme's own scheme from the document element's computed
425
+ * `color-scheme` — every DaisyUI theme declares `color-scheme: light` or
426
+ * `color-scheme: dark`.
427
+ *
428
+ * @returns {string} `"dark"` or `"light"`
429
+ */
430
+ computedScheme() {
431
+ const value = getComputedStyle(document.documentElement).colorScheme || ''
432
+
433
+ return value.includes('dark') && !value.includes('light') ? 'dark' : 'light'
434
+ }
435
+
436
+ /**
437
+ * Classifies a specific theme by probing its own `color-scheme`
438
+ * declaration on a scratch element.
439
+ *
440
+ * Reading the ROOT's computed value at apply time is unreliable: DaisyUI
441
+ * theme blocks also match `:root:has(input.theme-controller:checked)`,
442
+ * which outranks `[data-theme]` in the cascade — so until the page's
443
+ * radios re-sync, the PREVIOUS theme's checked radio can misclassify the
444
+ * theme being applied (e.g. synthwave filed as a light preference). The
445
+ * probe carries only `data-theme`, so only the theme's own declaration
446
+ * can match it.
447
+ *
448
+ * @param {string} value - The theme name to classify
449
+ * @returns {string} `"dark"` or `"light"`
450
+ */
451
+ schemeForTheme(value) {
452
+ const probe = document.createElement('div')
453
+
454
+ probe.setAttribute('data-theme', value)
455
+ probe.style.display = 'none'
456
+ document.documentElement.appendChild(probe)
457
+
458
+ const scheme = getComputedStyle(probe).colorScheme || ''
459
+
460
+ probe.remove()
461
+
462
+ return scheme.includes('dark') && !scheme.includes('light') ? 'dark' : 'light'
463
+ }
464
+
465
+ /**
466
+ * Responds to OS color-scheme changes. Only relevant in `"system"` mode,
467
+ * where the active theme swaps between the saved light and dark themes.
468
+ */
469
+ mediaChanged() {
470
+ if (this.safeStorageGet('savedThemeMode') === 'system') {
471
+ this.applyResolvedTheme()
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Notifies every theme controller on the page (including this one) that
477
+ * the theme changed so they can re-sync their inputs.
478
+ *
479
+ * @param {?string} value - The newly applied theme name, or null on clear
480
+ */
481
+ broadcast(value) {
482
+ const updateEvent = new CustomEvent('localstorage-update', { detail: { key: 'savedTheme', newValue: value } })
483
+ window.dispatchEvent(updateEvent)
484
+ }
485
+
161
486
  /**
162
487
  * Safely reads a value from localStorage. Access can throw in some
163
488
  * environments (e.g. Safari private browsing), so failures are swallowed