11import { useEffect , useState } from 'preact/hooks' ;
2- import { Dialog , Button , Label , toast , Collapsible , Tooltip } from 'kinu' ;
2+ import { Dialog , Button , Label , toast , Collapsible } from 'kinu' ;
33
44const CUSTOM_THEME_STORAGE_KEY = 'kinu-custom-theme' ;
55const CUSTOM_THEME_STYLE_ID = 'kinu-custom-style' ;
6+ export const THEME_CUSTOMIZER_DIALOG_ID = 'theme-customizer' ;
7+
8+ /** Read the active theme from <html data-theme="..."> ('' = none/default). */
9+ function activeTheme ( ) : string {
10+ if ( typeof document === 'undefined' ) return '' ;
11+ return document . documentElement . getAttribute ( 'data-theme' ) ?? '' ;
12+ }
13+
14+ /** Build the selectors the customizer's generated CSS should target.
15+ * Two independent axes compose here:
16+ * - active theme (`data-theme="x"`): scope the overlay on top of that
17+ * theme. With kinu's tokens layer, both `:root` (no theme) and
18+ * `[data-theme="x"]` are unlayered and beat kinu's defaults — and the
19+ * late-injected overlay wins source-order against the theme itself.
20+ * - light/dark via `data-color-scheme` (matching variables.css), so the
21+ * customization also reaches explicitly-scoped light/dark subtrees. */
22+ function lightSelectorForTheme ( theme : string ) : string {
23+ // :root + explicit-light subtrees, scoped under the active theme.
24+ return theme
25+ ? `[data-theme="${ theme } "],\n[data-theme="${ theme } "] [data-color-scheme="light"]`
26+ : ':root,\n[data-color-scheme="light"]' ;
27+ }
28+ function darkMediaSelectorForTheme ( theme : string ) : string {
29+ // OS dark mode (inside @media prefers-color-scheme: dark).
30+ return theme ? `[data-theme="${ theme } "]` : ':root' ;
31+ }
32+ function darkSelectorForTheme ( theme : string ) : string {
33+ // Explicit dark via data-color-scheme, page-level or subtree.
34+ return theme
35+ ? `[data-theme="${ theme } "][data-color-scheme="dark"],\n[data-theme="${ theme } "] [data-color-scheme="dark"]`
36+ : '[data-color-scheme="dark"]' ;
37+ }
638
739/* ── hex→HSL helper ───────────────────────────────────────── */
840function hexToHsl ( hex : string ) : [ number , number , number ] {
@@ -98,7 +130,7 @@ interface ThemeSettings {
98130 scaling : string ;
99131}
100132
101- function generateCSS ( settings : ThemeSettings ) : string {
133+ function generateCSS ( settings : ThemeSettings , theme : string = '' ) : string {
102134 const accent = ACCENT_COLORS [ settings . accentColor ] ;
103135 const gray = GRAY_COLORS [ settings . grayColor ] ;
104136 if ( ! accent || ! gray ) return '' ;
@@ -108,12 +140,16 @@ function generateCSS(settings: ThemeSettings): string {
108140 const radius = RADIUS_MAP [ settings . radius ] ?? '0.5rem' ;
109141 const scale = parseInt ( settings . scaling ) / 100 ;
110142
143+ const lightSel = lightSelectorForTheme ( theme ) ;
144+ const darkMediaSel = darkMediaSelectorForTheme ( theme ) ;
145+ const darkSel = darkSelectorForTheme ( theme ) ;
146+
111147 const lines = [
112- '/* Light mode — : root + explicit-light subtrees (e.g. per-component' ,
113- ' `data-color-scheme="light"`). Both need the same tokens so that ' ,
114- ' toggling a subtree back to light keeps the customized palette. */ ' ,
115- ':root, ' ,
116- '[data-color-scheme="light"] {' ,
148+ '/* Light mode — root + explicit-light subtrees (e.g. per-component' ,
149+ ' `data-color-scheme="light"`), scoped to the active theme. Both need ' ,
150+ ' the same tokens so toggling a subtree back to light keeps the' ,
151+ ' customized palette. */ ' ,
152+ ` ${ lightSel } {` ,
117153 ` --k-primary: ${ hsl ( accent . light . step9 ) } ;` ,
118154 ` --k-primary-hover: ${ hsl ( accent . light . step10 ) } ;` ,
119155 ` --k-primary-foreground: ${ fgLight } ;` ,
@@ -145,7 +181,7 @@ function generateCSS(settings: ThemeSettings): string {
145181 // Dark mode (automatic)
146182 lines . push ( '/* Dark mode (automatic) */' ) ;
147183 lines . push ( '@media (prefers-color-scheme: dark) {' ) ;
148- lines . push ( ' :root {' ) ;
184+ lines . push ( ` ${ darkMediaSel } {` ) ;
149185 lines . push ( ' color-scheme: dark;' ) ;
150186 lines . push ( ` --k-primary: ${ hsl ( accent . dark . step9 ) } ;` ) ;
151187 lines . push ( ` --k-primary-hover: ${ hsl ( accent . dark . step10 ) } ;` ) ;
@@ -171,7 +207,7 @@ function generateCSS(settings: ThemeSettings): string {
171207
172208 // Dark mode (explicit attribute — applies to any subtree)
173209 lines . push ( '/* Dark mode (explicit, scopable to any subtree) */' ) ;
174- lines . push ( '[data-color-scheme="dark"] {' ) ;
210+ lines . push ( ` ${ darkSel } {` ) ;
175211 lines . push ( ' color-scheme: dark;' ) ;
176212 lines . push ( ` --k-primary: ${ hsl ( accent . dark . step9 ) } ;` ) ;
177213 lines . push ( ` --k-primary-hover: ${ hsl ( accent . dark . step10 ) } ;` ) ;
@@ -232,10 +268,32 @@ function applyCustomTheme(css: string) {
232268 document . head . appendChild ( style ) ;
233269}
234270
235- try {
236- const saved = localStorage . getItem ( CUSTOM_THEME_STORAGE_KEY ) ;
237- if ( saved ) applyCustomTheme ( saved ) ;
238- } catch { }
271+ /** Read saved settings, regenerate CSS scoped to the currently-active
272+ * theme (falling back to `:root` when none), inject, and write back
273+ * the result so the synchronous bootstrap script in index.html can
274+ * inject it on the next load without re-running the generator. */
275+ function applyFromStorage ( ) {
276+ try {
277+ const raw = localStorage . getItem ( CUSTOM_THEME_STORAGE_KEY + '-settings' ) ;
278+ if ( ! raw ) return ;
279+ const settings = JSON . parse ( raw ) as ThemeSettings ;
280+ const css = generateCSS ( settings , activeTheme ( ) ) ;
281+ localStorage . setItem ( CUSTOM_THEME_STORAGE_KEY , css ) ;
282+ applyCustomTheme ( css ) ;
283+ } catch { }
284+ }
285+
286+ if ( typeof document !== 'undefined' ) {
287+ applyFromStorage ( ) ;
288+
289+ // Re-scope the customizer's overlay whenever the active theme changes
290+ // so the `[data-theme="..."]` selectors track the picker's selection.
291+ const obs = new MutationObserver ( applyFromStorage ) ;
292+ obs . observe ( document . documentElement , {
293+ attributes : true ,
294+ attributeFilter : [ 'data-theme' ] ,
295+ } ) ;
296+ }
239297
240298/* ── Color swatch ─────────────────────────────────────────── */
241299function Swatch ( { color, selected, onClick, label} : {
@@ -287,13 +345,16 @@ export function ThemeCustomizer() {
287345 // };
288346
289347 const handleApply = ( ) => {
290- const css = generateCSS ( settings ) ;
348+ // Always generate against the *current* active theme, so the
349+ // customizer's overlay is correctly scoped to whatever the picker
350+ // has selected at apply-time.
351+ const css = generateCSS ( settings , activeTheme ( ) ) ;
291352 try {
292353 localStorage . setItem ( CUSTOM_THEME_STORAGE_KEY , css ) ;
293354 localStorage . setItem ( CUSTOM_THEME_STORAGE_KEY + '-settings' , JSON . stringify ( settings ) ) ;
294355 } catch { }
295356 applyCustomTheme ( css ) ;
296- toast . show ( 'Theme applied' ) ;
357+ toast . show ( 'Customizations applied' ) ;
297358 } ;
298359
299360 const handleClear = ( ) => {
@@ -303,19 +364,11 @@ export function ThemeCustomizer() {
303364 } catch { }
304365 applyCustomTheme ( '' ) ;
305366 setSettings ( { accentColor : 'blue' , grayColor : 'slate' , radius : 'medium' , scaling : '100%' } ) ;
306- toast . show ( 'Theme reset to defaults ' ) ;
367+ toast . show ( 'Customizations cleared ' ) ;
307368 } ;
308369
309370 return (
310- < Dialog >
311- < Dialog . Trigger >
312- < Tooltip title = "Theme" side = "bottom" >
313- < Button variant = "secondary" size = "icon" >
314- < iconify-icon icon = "lucide:palette" />
315- </ Button >
316- </ Tooltip >
317- </ Dialog . Trigger >
318-
371+ < Dialog id = { THEME_CUSTOMIZER_DIALOG_ID } >
319372 < Dialog . Content >
320373 < div style = "display:flex; flex-direction:column; gap:1.5rem" >
321374 < div style = "display:flex; align-items:center; justify-content:space-between; margin:-.5rem 0;" >
0 commit comments