@adea-ai/ui 0.103.0 → 0.104.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.
@@ -1 +1 @@
1
- {"version":3,"file":"theme-provider.js","names":["JSX","createContext","createEffect","createMemo","createSignal","onCleanup","splitProps","useContext","accentPresets","fontOptions","AccentPreset","accentVariables","builtinThemes","defaultDarkThemeId","defaultLightThemeId","isThemeAccentId","themeAccentsFor","themeById","themeCssVariables","ThemeAppearance","ThemeVariant","ThemeSelection","appearance","lightThemeId","darkThemeId","accent","font","density","ThemeDensity","defaultThemeSelection","resolvedDensity","selection","ThemeContextValue","resolvedAppearance","variant","setSelection","patch","Partial","themes","themeAccents","ThemeContext","useTheme","value","Error","preferenceStore","Storage","localStorage","undefined","readSelection","storageKey","raw","getItem","parsed","JSON","parse","some","preset","id","option","ThemeProviderProps","children","Element","initial","ThemeProvider","props","local","setSelectionState","incoming","current","systemAppearance","setSystemAppearance","window","matchMedia","query","matches","onChange","event","MediaQueryListEvent","addEventListener","removeEventListener","preference","document","root","documentElement","active","properties","Object","assign","name","entries","style","setProperty","classList","toggle","dataset","colorScheme","removeAttribute","next","setItem","stringify","_$createComponent","Provider","inlineScriptLiteral","replace","character","charCodeAt","toString","padStart","themeScript","_$createComponent"],"sources":["../../../src/components/theme/theme-provider.tsx"],"sourcesContent":["import type { JSX } from 'solid-js'\nimport {\n createContext,\n createEffect,\n createMemo,\n createSignal,\n onCleanup,\n splitProps,\n useContext,\n} from 'solid-js'\nimport { accentPresets, fontOptions } from '#lib/tokens'\nimport type { AccentPreset } from '@adea-ai/themes'\nimport {\n accentVariables,\n builtinThemes,\n defaultDarkThemeId,\n defaultLightThemeId,\n isThemeAccentId,\n themeAccentsFor,\n themeById,\n themeCssVariables,\n type ThemeAppearance,\n type ThemeVariant,\n} from '#lib/themes'\n\n/**\n * ThemeProvider.\n *\n * Owns the appearance preference and applies it to the document. One provider near\n * the root; everything below reads it.\n *\n * ## What a preference is\n *\n * Four independent choices, and keeping them independent is the point:\n *\n * - **appearance** — light, dark, or follow the system.\n * - **a theme per appearance** — so a user can run Catppuccin Mocha by night and\n * Solarized Light by day without re-choosing when the system flips.\n * - **an accent** — adea's six presets, or one of the accents the selected theme\n * pair carries itself (`ansi-blue`…), which re-colour the interactive roles on\n * top of whichever theme is active.\n * - **a font** — the interface face.\n *\n * Four axes rather than one list of presets, because they compose: a user who wants\n * Nord with a violet accent and the system font should not need someone to author\n * that combination.\n *\n * ## Why the variants are applied as inline properties\n *\n * The catalogue is data, and a theme is applied by writing its roles onto\n * `<html>` as custom properties. That is what makes an imported palette work\n * without a hand-written `[data-theme]` block per theme, and it is why a settings\n * UI can show a preview by reading the same object rather than by loading a\n * stylesheet. `theme.css` keeps the default theme's values so the first paint is\n * correct before any script runs.\n */\nexport type ThemeSelection = {\n /** `system` follows `prefers-color-scheme` and updates when it changes. */\n appearance: ThemeAppearance | 'system'\n /** The variant to use when the resolved appearance is light. */\n lightThemeId: string\n /** The variant to use when the resolved appearance is dark. */\n darkThemeId: string\n /**\n * An `accentPresets` id, a theme accent id from `themeAccents()` (`ansi-blue`…),\n * or `theme` for the variant's own primary. A theme accent the current pair does\n * not offer falls back to the variant's own primary.\n */\n accent: string\n /** A `fontOptions` id, or `space-grotesk` for the default. */\n font: string\n /**\n * `comfortable` is the system's default geometry; `compact` moves the control\n * ladder down one rung.\n *\n * Optional, and defaulted on read, so a preference stored before density existed\n * keeps working and `ThemeSelection` stays assignable without it.\n */\n density?: ThemeDensity\n}\n\nexport const defaultThemeSelection: ThemeSelection = {\n appearance: 'system',\n lightThemeId: defaultLightThemeId,\n darkThemeId: defaultDarkThemeId,\n accent: 'theme',\n font: 'space-grotesk',\n density: 'comfortable',\n}\n\n/**\n * The two density rungs.\n *\n * `comfortable` is the geometry in `theme.css`, so it is the absence of an\n * attribute rather than a value: a document that ships no `data-density` at all\n * renders correctly, which is what keeps this additive.\n */\nexport type ThemeDensity = 'comfortable' | 'compact'\n\n/**\n * A stored selection's density, defaulted.\n *\n * The stored value is read through here rather than trusted, because a preference\n * written before density existed has no `density` key and a hand-edited store may\n * hold anything.\n */\nexport function resolvedDensity(selection: ThemeSelection): ThemeDensity {\n return selection.density === 'compact' ? 'compact' : 'comfortable'\n}\n\nexport type ThemeContextValue = {\n /** The preference as stored. */\n selection: () => ThemeSelection\n /** What the preference resolves to right now, after `system`. */\n resolvedAppearance: () => ThemeAppearance\n /** The variant in use. */\n variant: () => ThemeVariant\n /** Update part of the preference. */\n setSelection: (patch: Partial<ThemeSelection>) => void\n /** The catalogue, so a picker does not import it separately. */\n themes: readonly ThemeVariant[]\n /**\n * The accents the selected light/dark pair carries, offered beside the presets.\n * Follows the selection: a different theme pair may offer different slots.\n */\n themeAccents: () => readonly AccentPreset[]\n}\n\nconst ThemeContext = createContext<ThemeContextValue>()\n\nexport function useTheme(): ThemeContextValue {\n const value = useContext(ThemeContext)\n if (!value) throw new Error('useTheme must be called inside a <ThemeProvider>.')\n return value\n}\n\n/**\n * The preference store, or nothing.\n *\n * Only ever called inside a `try`: where storage is blocked — a sandboxed iframe, a\n * privacy mode, a denied site-data permission — merely *reading* the `localStorage`\n * global throws (\"Access is denied\", `SecurityError`), so even the `typeof` guard\n * has to sit inside the `try` rather than in front of it. Without a store the\n * preference lives only in the provider's signal for the session.\n */\nfunction preferenceStore(): Storage | undefined {\n return typeof localStorage === 'undefined' ? undefined : localStorage\n}\n\n/** The stored preference, validated against the catalogue. */\nfunction readSelection(storageKey: string): ThemeSelection {\n try {\n const raw = preferenceStore()?.getItem(storageKey)\n if (!raw) return defaultThemeSelection\n const parsed = JSON.parse(raw) as Partial<ThemeSelection>\n return {\n appearance:\n parsed.appearance === 'light' ||\n parsed.appearance === 'dark' ||\n parsed.appearance === 'system'\n ? parsed.appearance\n : defaultThemeSelection.appearance,\n // An unknown id would leave the document with no theme at all, so a stale\n // preference from an older catalogue falls back rather than applying nothing.\n lightThemeId:\n themeById(parsed.lightThemeId ?? '')?.appearance === 'light'\n ? (parsed.lightThemeId as string)\n : defaultLightThemeId,\n darkThemeId:\n themeById(parsed.darkThemeId ?? '')?.appearance === 'dark'\n ? (parsed.darkThemeId as string)\n : defaultDarkThemeId,\n // A theme accent is kept whenever it names a slot, even one the stored pair\n // cannot offer: the id is role-shaped so it survives a theme switch, and an\n // unoffered slot already resolves to the theme's own primary when applied.\n accent:\n typeof parsed.accent === 'string' &&\n (accentPresets.some((preset) => preset.id === parsed.accent) ||\n isThemeAccentId(parsed.accent))\n ? parsed.accent\n : defaultThemeSelection.accent,\n font: fontOptions.some((option) => option.id === parsed.font)\n ? (parsed.font as string)\n : defaultThemeSelection.font,\n }\n } catch {\n return defaultThemeSelection\n }\n}\n\nexport type ThemeProviderProps = {\n children?: JSX.Element\n /** Where the preference is persisted. Distinct per application. */\n storageKey?: string\n /** Start from this instead of the stored preference. */\n initial?: Partial<ThemeSelection>\n /**\n * The selection, owned by the caller.\n *\n * For a consumer that keeps the state itself — a settings store, or a workshop\n * whose selection lives in Storybook globals — rather than handing it to this\n * provider's signal. Each change lands here and the document follows; nothing is\n * written to the store, because the caller is the one who persists. A picker\n * below the provider still works, and its change is the caller's to read back.\n *\n * Absent, the provider is the whole owner: it reads `storageKey` on mount,\n * seeds from `initial`, and persists every `setSelection`.\n */\n selection?: Partial<ThemeSelection>\n}\n\nexport function ThemeProvider(props: ThemeProviderProps) {\n const [local] = splitProps(props, ['children', 'storageKey', 'initial', 'selection'])\n const storageKey = () => local.storageKey ?? 'adea-appearance'\n\n const [selection, setSelectionState] = createSignal<ThemeSelection>({\n ...readSelection(storageKey()),\n ...local.initial,\n })\n\n // The controlled half of the contract. A patch from the owner replaces the\n // matching axes; the stored preference is untouched, since the owner is the one\n // that persists. Reading `local.selection` — a prop getter, not a destructured\n // value — is what keeps this subscribed for the provider's lifetime.\n createEffect(() => {\n const incoming = local.selection\n if (incoming) setSelectionState((current) => ({ ...current, ...incoming }))\n })\n\n const [systemAppearance, setSystemAppearance] = createSignal<ThemeAppearance>('light')\n\n if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {\n const query = window.matchMedia('(prefers-color-scheme: dark)')\n setSystemAppearance(query.matches ? 'dark' : 'light')\n const onChange = (event: MediaQueryListEvent) =>\n setSystemAppearance(event.matches ? 'dark' : 'light')\n query.addEventListener('change', onChange)\n onCleanup(() => query.removeEventListener('change', onChange))\n }\n\n const resolvedAppearance = createMemo<ThemeAppearance>(() => {\n const preference = selection().appearance\n if (preference === 'system') return systemAppearance()\n return preference\n })\n\n const variant = createMemo<ThemeVariant>(() => {\n const id = resolvedAppearance() === 'dark' ? selection().darkThemeId : selection().lightThemeId\n return themeById(id) ?? builtinThemes[0]!\n })\n\n createEffect(() => {\n if (typeof document === 'undefined') return\n const root = document.documentElement\n const active = variant()\n\n // The variant's roles, as properties. `--primary` and `--ring` are written here\n // too, and an accent selection is layered over them below.\n //\n // The accent is applied *here* rather than left to the `[data-accent]` blocks in\n // `theme.css`, because this loop writes inline custom properties and an inline\n // declaration outranks every stylesheet selector however specific it is. The\n // blocks still exist and are still correct — they are what styles the window\n // before this runs — but they cannot win from here, so leaving the accent to\n // them meant the attribute was set, the rules were present, and nothing\n // changed on screen.\n const properties = { ...themeCssVariables(active) }\n const accent = accentVariables(selection().accent, resolvedAppearance(), selection())\n if (accent) Object.assign(properties, accent)\n\n for (const [name, value] of Object.entries(properties)) {\n root.style.setProperty(name, value)\n }\n\n root.classList.toggle('dark', resolvedAppearance() === 'dark')\n root.dataset['theme'] = active.id\n root.dataset['appearance'] = resolvedAppearance()\n root.style.colorScheme = resolvedAppearance()\n\n if (!accent) root.removeAttribute('data-accent')\n else root.dataset['accent'] = selection().accent\n\n if (selection().font === 'space-grotesk') root.removeAttribute('data-font')\n else root.dataset['font'] = selection().font\n\n // Density is a pure token shift, so unlike the colour axes there is nothing to\n // compute: the attribute selects a block in `theme.css`. It is applied here for\n // the same reason as the others — this is the one place that knows what the\n // current selection is, and it is the only writer of these attributes.\n if (resolvedDensity(selection()) === 'comfortable') root.removeAttribute('data-density')\n else root.dataset['density'] = resolvedDensity(selection())\n })\n\n const themeAccents = createMemo(() =>\n themeAccentsFor(selection().lightThemeId, selection().darkThemeId)\n )\n\n const value: ThemeContextValue = {\n selection,\n resolvedAppearance,\n variant,\n themes: builtinThemes,\n themeAccents,\n setSelection(patch) {\n const next = { ...selection(), ...patch }\n setSelectionState(next)\n try {\n preferenceStore()?.setItem(storageKey(), JSON.stringify(next))\n } catch {\n // A full, blocked or unavailable store must not break the switch: the\n // preference simply does not survive the reload.\n }\n },\n }\n\n return <ThemeContext.Provider value={value}>{local.children}</ThemeContext.Provider>\n}\n\n/**\n * A string as a JavaScript literal that is also safe inside an inline `<script>`.\n *\n * `JSON.stringify` produces a correct JavaScript literal, and it is **not** safe\n * here: it leaves `<` and `>` alone, so a value containing `</script>` ends the tag\n * early and everything after it is parsed as HTML. That is the classic inline-script\n * injection, and it is a real one even though the value is a developer-supplied prop\n * rather than user input — a prop can come from configuration, and a key that happens\n * to contain a closing tag should not be able to execute.\n *\n * The escape is also correct JavaScript: `\\u003c` is `<` to the parser, so the value\n * is byte-identical at runtime and inert to the HTML tokenizer. `\\u2028` and `\\u2029`\n * are escaped for the neighbouring reason — they are legal inside a JSON string and\n * were, before ES2019, line terminators to a JavaScript parser.\n *\n * CodeQL flags the unescaped form as `js/bad-code-sanitization`, which is how this\n * was found; `tests/theme-script.test.ts` pins it so it cannot come back.\n */\nexport function inlineScriptLiteral(value: string): string {\n return JSON.stringify(value).replace(\n /[<>\\u2028\\u2029]/g,\n (character) => `\\\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`\n )\n}\n\n/**\n * The no-flash script.\n *\n * Rendered inline in `<head>` before any stylesheet that matters. Without it a dark\n * preference paints the light theme first and then swaps, which is the flash every\n * themed application has to solve and the one thing a provider cannot do from\n * inside the body.\n *\n * Deliberately tiny and dependency-free: it reads the stored preference, resolves\n * `system`, and sets the class. It does not apply the variant's roles — the default\n * theme in `theme.css` covers the first paint, and the provider writes the rest on\n * mount.\n *\n * The script reads two values out of `localStorage` and writes them to `dataset`,\n * which is a *property* assignment rather than code: a stored accent or font id can\n * set an attribute and cannot execute. The one value that reaches the source as text\n * is `storageKey`, and it goes through {@link inlineScriptLiteral} for the reason\n * documented there.\n */\nexport function themeScript(storageKey = 'adea-appearance'): string {\n return `(function(){try{var s=localStorage.getItem(${inlineScriptLiteral(storageKey)});var p=s?JSON.parse(s):{};var a=p.appearance||'system';var d=a==='dark'||(a==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);var r=document.documentElement;if(d)r.classList.add('dark');r.style.colorScheme=d?'dark':'light';if(p.accent&&p.accent!=='theme')r.dataset.accent=p.accent;if(p.font&&p.font!=='space-grotesk')r.dataset.font=p.font;if(p.density==='compact')r.dataset.density='compact';}catch(e){}})();`\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"theme-provider.js","names":["JSX","createContext","createEffect","createMemo","createSignal","onCleanup","splitProps","useContext","accentPresets","fontOptions","AccentPreset","accentVariables","builtinThemes","defaultDarkThemeId","defaultLightThemeId","isThemeAccentId","themeAccentsFor","themeById","themeCssVariables","ThemeAppearance","ThemeVariant","ThemeSelection","appearance","lightThemeId","darkThemeId","accent","font","density","ThemeDensity","defaultThemeSelection","resolvedDensity","selection","ThemeContextValue","resolvedAppearance","variant","setSelection","patch","Partial","themes","themeAccents","ThemeContext","useTheme","value","Error","preferenceStore","Storage","localStorage","undefined","readSelection","storageKey","raw","getItem","parsed","JSON","parse","some","preset","id","option","ThemeProviderProps","children","Element","initial","ThemeProvider","props","local","setSelectionState","incoming","current","systemAppearance","setSystemAppearance","window","matchMedia","query","matches","onChange","event","MediaQueryListEvent","addEventListener","removeEventListener","preference","document","root","documentElement","active","properties","Object","assign","name","entries","style","setProperty","classList","toggle","dataset","colorScheme","removeAttribute","next","setItem","stringify","_$createComponent","Provider","inlineScriptLiteral","replace","character","charCodeAt","toString","padStart","themeScript","_$createComponent"],"sources":["../../../src/components/theme/theme-provider.tsx"],"sourcesContent":["import type { JSX } from 'solid-js'\nimport {\n createContext,\n createEffect,\n createMemo,\n createSignal,\n onCleanup,\n splitProps,\n useContext,\n} from 'solid-js'\nimport { accentPresets, fontOptions } from '#lib/tokens'\nimport type { AccentPreset } from '@adea-ai/themes'\nimport {\n accentVariables,\n builtinThemes,\n defaultDarkThemeId,\n defaultLightThemeId,\n isThemeAccentId,\n themeAccentsFor,\n themeById,\n themeCssVariables,\n type ThemeAppearance,\n type ThemeVariant,\n} from '#lib/themes'\n\n/**\n * ThemeProvider.\n *\n * Owns the appearance preference and applies it to the document. One provider near\n * the root; everything below reads it.\n *\n * ## What a preference is\n *\n * Four independent choices, and keeping them independent is the point:\n *\n * - **appearance** — light, dark, or follow the system.\n * - **a theme per appearance** — so a user can run Catppuccin Mocha by night and\n * Solarized Light by day without re-choosing when the system flips.\n * - **an accent** — adea's six presets, or one of the accents the selected theme\n * pair carries itself (`ansi-blue`…), which re-colour the interactive roles on\n * top of whichever theme is active.\n * - **a font** — the interface face.\n *\n * Four axes rather than one list of presets, because they compose: a user who wants\n * Nord with a violet accent and the system font should not need someone to author\n * that combination.\n *\n * ## Why the variants are applied as inline properties\n *\n * The catalogue is data, and a theme is applied by writing its roles onto\n * `<html>` as custom properties. That is what makes an imported palette work\n * without a hand-written `[data-theme]` block per theme, and it is why a settings\n * UI can show a preview by reading the same object rather than by loading a\n * stylesheet. `theme.css` keeps the default theme's values so the first paint is\n * correct before any script runs.\n */\nexport type ThemeSelection = {\n /** `system` follows `prefers-color-scheme` and updates when it changes. */\n appearance: ThemeAppearance | 'system'\n /** The variant to use when the resolved appearance is light. */\n lightThemeId: string\n /** The variant to use when the resolved appearance is dark. */\n darkThemeId: string\n /**\n * An `accentPresets` id, a theme accent id from `themeAccents()` (`ansi-blue`…),\n * or `theme` for the variant's own primary. A theme accent the current pair does\n * not offer falls back to the variant's own primary.\n */\n accent: string\n /** A `fontOptions` id, or `space-grotesk` for the default. */\n font: string\n /**\n * `comfortable` is the system's default geometry; `compact` moves the control\n * ladder down one rung.\n *\n * Optional, and defaulted on read, so a preference stored before density existed\n * keeps working and `ThemeSelection` stays assignable without it.\n */\n density?: ThemeDensity\n}\n\nexport const defaultThemeSelection: ThemeSelection = {\n appearance: 'system',\n lightThemeId: defaultLightThemeId,\n darkThemeId: defaultDarkThemeId,\n accent: 'theme',\n font: 'space-grotesk',\n density: 'comfortable',\n}\n\n/**\n * The two density rungs.\n *\n * `comfortable` is the geometry in `theme.css`, so it is the absence of an\n * attribute rather than a value: a document that ships no `data-density` at all\n * renders correctly, which is what keeps this additive.\n */\nexport type ThemeDensity = 'comfortable' | 'compact'\n\n/**\n * A stored selection's density, defaulted.\n *\n * The stored value is read through here rather than trusted, because a preference\n * written before density existed has no `density` key and a hand-edited store may\n * hold anything.\n */\nexport function resolvedDensity(selection: ThemeSelection): ThemeDensity {\n return selection.density === 'compact' ? 'compact' : 'comfortable'\n}\n\nexport type ThemeContextValue = {\n /** The preference as stored. */\n selection: () => ThemeSelection\n /** What the preference resolves to right now, after `system`. */\n resolvedAppearance: () => ThemeAppearance\n /** The variant in use. */\n variant: () => ThemeVariant\n /** Update part of the preference. */\n setSelection: (patch: Partial<ThemeSelection>) => void\n /** The catalogue, so a picker does not import it separately. */\n themes: readonly ThemeVariant[]\n /**\n * The accents the selected light/dark pair carries, offered beside the presets.\n * Follows the selection: a different theme pair may offer different slots.\n */\n themeAccents: () => readonly AccentPreset[]\n}\n\nconst ThemeContext = createContext<ThemeContextValue>()\n\nexport function useTheme(): ThemeContextValue {\n const value = useContext(ThemeContext)\n if (!value) throw new Error('useTheme must be called inside a <ThemeProvider>.')\n return value\n}\n\n/**\n * The preference store, or nothing.\n *\n * Only ever called inside a `try`: where storage is blocked — a sandboxed iframe, a\n * privacy mode, a denied site-data permission — merely *reading* the `localStorage`\n * global throws (\"Access is denied\", `SecurityError`), so even the `typeof` guard\n * has to sit inside the `try` rather than in front of it. Without a store the\n * preference lives only in the provider's signal for the session.\n */\nfunction preferenceStore(): Storage | undefined {\n return typeof localStorage === 'undefined' ? undefined : localStorage\n}\n\n/** The stored preference, validated against the catalogue. */\nfunction readSelection(storageKey: string): ThemeSelection {\n try {\n const raw = preferenceStore()?.getItem(storageKey)\n if (!raw) return defaultThemeSelection\n const parsed = JSON.parse(raw) as Partial<ThemeSelection>\n return {\n appearance:\n parsed.appearance === 'light' ||\n parsed.appearance === 'dark' ||\n parsed.appearance === 'system'\n ? parsed.appearance\n : defaultThemeSelection.appearance,\n // An unknown id would leave the document with no theme at all, so a stale\n // preference from an older catalogue falls back rather than applying nothing.\n lightThemeId:\n themeById(parsed.lightThemeId ?? '')?.appearance === 'light'\n ? (parsed.lightThemeId as string)\n : defaultLightThemeId,\n darkThemeId:\n themeById(parsed.darkThemeId ?? '')?.appearance === 'dark'\n ? (parsed.darkThemeId as string)\n : defaultDarkThemeId,\n // A theme accent is kept whenever it names a slot, even one the stored pair\n // cannot offer: the id is role-shaped so it survives a theme switch, and an\n // unoffered slot already resolves to the theme's own primary when applied.\n accent:\n typeof parsed.accent === 'string' &&\n (accentPresets.some((preset) => preset.id === parsed.accent) ||\n isThemeAccentId(parsed.accent))\n ? parsed.accent\n : defaultThemeSelection.accent,\n font: fontOptions.some((option) => option.id === parsed.font)\n ? (parsed.font as string)\n : defaultThemeSelection.font,\n // Every axis has to be read back here, or it is lost twice: the effect below\n // removes the `data-density` that `themeScript` set for the first paint, and\n // the next `setSelection` rewrites the store without it.\n density: resolvedDensity(parsed as ThemeSelection),\n }\n } catch {\n return defaultThemeSelection\n }\n}\n\nexport type ThemeProviderProps = {\n children?: JSX.Element\n /** Where the preference is persisted. Distinct per application. */\n storageKey?: string\n /** Start from this instead of the stored preference. */\n initial?: Partial<ThemeSelection>\n /**\n * The selection, owned by the caller.\n *\n * For a consumer that keeps the state itself — a settings store, or a workshop\n * whose selection lives in Storybook globals — rather than handing it to this\n * provider's signal. Each change lands here and the document follows; nothing is\n * written to the store, because the caller is the one who persists. A picker\n * below the provider still works, and its change is the caller's to read back.\n *\n * Absent, the provider is the whole owner: it reads `storageKey` on mount,\n * seeds from `initial`, and persists every `setSelection`.\n */\n selection?: Partial<ThemeSelection>\n}\n\nexport function ThemeProvider(props: ThemeProviderProps) {\n const [local] = splitProps(props, ['children', 'storageKey', 'initial', 'selection'])\n const storageKey = () => local.storageKey ?? 'adea-appearance'\n\n const [selection, setSelectionState] = createSignal<ThemeSelection>({\n ...readSelection(storageKey()),\n ...local.initial,\n })\n\n // The controlled half of the contract. A patch from the owner replaces the\n // matching axes; the stored preference is untouched, since the owner is the one\n // that persists. Reading `local.selection` — a prop getter, not a destructured\n // value — is what keeps this subscribed for the provider's lifetime.\n createEffect(() => {\n const incoming = local.selection\n if (incoming) setSelectionState((current) => ({ ...current, ...incoming }))\n })\n\n const [systemAppearance, setSystemAppearance] = createSignal<ThemeAppearance>('light')\n\n if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {\n const query = window.matchMedia('(prefers-color-scheme: dark)')\n setSystemAppearance(query.matches ? 'dark' : 'light')\n const onChange = (event: MediaQueryListEvent) =>\n setSystemAppearance(event.matches ? 'dark' : 'light')\n query.addEventListener('change', onChange)\n onCleanup(() => query.removeEventListener('change', onChange))\n }\n\n const resolvedAppearance = createMemo<ThemeAppearance>(() => {\n const preference = selection().appearance\n if (preference === 'system') return systemAppearance()\n return preference\n })\n\n const variant = createMemo<ThemeVariant>(() => {\n const id = resolvedAppearance() === 'dark' ? selection().darkThemeId : selection().lightThemeId\n return themeById(id) ?? builtinThemes[0]!\n })\n\n createEffect(() => {\n if (typeof document === 'undefined') return\n const root = document.documentElement\n const active = variant()\n\n // The variant's roles, as properties. `--primary` and `--ring` are written here\n // too, and an accent selection is layered over them below.\n //\n // The accent is applied *here* rather than left to the `[data-accent]` blocks in\n // `theme.css`, because this loop writes inline custom properties and an inline\n // declaration outranks every stylesheet selector however specific it is. The\n // blocks still exist and are still correct — they are what styles the window\n // before this runs — but they cannot win from here, so leaving the accent to\n // them meant the attribute was set, the rules were present, and nothing\n // changed on screen.\n const properties = { ...themeCssVariables(active) }\n const accent = accentVariables(selection().accent, resolvedAppearance(), selection())\n if (accent) Object.assign(properties, accent)\n\n for (const [name, value] of Object.entries(properties)) {\n root.style.setProperty(name, value)\n }\n\n root.classList.toggle('dark', resolvedAppearance() === 'dark')\n root.dataset['theme'] = active.id\n root.dataset['appearance'] = resolvedAppearance()\n root.style.colorScheme = resolvedAppearance()\n\n if (!accent) root.removeAttribute('data-accent')\n else root.dataset['accent'] = selection().accent\n\n if (selection().font === 'space-grotesk') root.removeAttribute('data-font')\n else root.dataset['font'] = selection().font\n\n // Density is a pure token shift, so unlike the colour axes there is nothing to\n // compute: the attribute selects a block in `theme.css`. It is applied here for\n // the same reason as the others — this is the one place that knows what the\n // current selection is, and it is the only writer of these attributes.\n if (resolvedDensity(selection()) === 'comfortable') root.removeAttribute('data-density')\n else root.dataset['density'] = resolvedDensity(selection())\n })\n\n const themeAccents = createMemo(() =>\n themeAccentsFor(selection().lightThemeId, selection().darkThemeId)\n )\n\n const value: ThemeContextValue = {\n selection,\n resolvedAppearance,\n variant,\n themes: builtinThemes,\n themeAccents,\n setSelection(patch) {\n const next = { ...selection(), ...patch }\n setSelectionState(next)\n try {\n preferenceStore()?.setItem(storageKey(), JSON.stringify(next))\n } catch {\n // A full, blocked or unavailable store must not break the switch: the\n // preference simply does not survive the reload.\n }\n },\n }\n\n return <ThemeContext.Provider value={value}>{local.children}</ThemeContext.Provider>\n}\n\n/**\n * A string as a JavaScript literal that is also safe inside an inline `<script>`.\n *\n * `JSON.stringify` produces a correct JavaScript literal, and it is **not** safe\n * here: it leaves `<` and `>` alone, so a value containing `</script>` ends the tag\n * early and everything after it is parsed as HTML. That is the classic inline-script\n * injection, and it is a real one even though the value is a developer-supplied prop\n * rather than user input — a prop can come from configuration, and a key that happens\n * to contain a closing tag should not be able to execute.\n *\n * The escape is also correct JavaScript: `\\u003c` is `<` to the parser, so the value\n * is byte-identical at runtime and inert to the HTML tokenizer. `\\u2028` and `\\u2029`\n * are escaped for the neighbouring reason — they are legal inside a JSON string and\n * were, before ES2019, line terminators to a JavaScript parser.\n *\n * CodeQL flags the unescaped form as `js/bad-code-sanitization`, which is how this\n * was found; `tests/theme-script.test.ts` pins it so it cannot come back.\n */\nexport function inlineScriptLiteral(value: string): string {\n return JSON.stringify(value).replace(\n /[<>\\u2028\\u2029]/g,\n (character) => `\\\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`\n )\n}\n\n/**\n * The no-flash script.\n *\n * Rendered inline in `<head>` before any stylesheet that matters. Without it a dark\n * preference paints the light theme first and then swaps, which is the flash every\n * themed application has to solve and the one thing a provider cannot do from\n * inside the body.\n *\n * Deliberately tiny and dependency-free: it reads the stored preference, resolves\n * `system`, and sets the class. It does not apply the variant's roles — the default\n * theme in `theme.css` covers the first paint, and the provider writes the rest on\n * mount.\n *\n * The script reads two values out of `localStorage` and writes them to `dataset`,\n * which is a *property* assignment rather than code: a stored accent or font id can\n * set an attribute and cannot execute. The one value that reaches the source as text\n * is `storageKey`, and it goes through {@link inlineScriptLiteral} for the reason\n * documented there.\n */\nexport function themeScript(storageKey = 'adea-appearance'): string {\n return `(function(){try{var s=localStorage.getItem(${inlineScriptLiteral(storageKey)});var p=s?JSON.parse(s):{};var a=p.appearance||'system';var d=a==='dark'||(a==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);var r=document.documentElement;if(d)r.classList.add('dark');r.style.colorScheme=d?'dark':'light';if(p.accent&&p.accent!=='theme')r.dataset.accent=p.accent;if(p.font&&p.font!=='space-grotesk')r.dataset.font=p.font;if(p.density==='compact')r.dataset.density='compact';}catch(e){}})();`\n}\n"],"mappings":"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"}
@@ -4,6 +4,7 @@
4
4
  "title": "Catalog Browser",
5
5
  "description": "",
6
6
  "dependencies": [
7
+ "class-variance-authority@^0.7.1",
7
8
  "lucide-solid@^1.48.0"
8
9
  ],
9
10
  "registryDependencies": [
@@ -1956,6 +1956,7 @@
1956
1956
  "title": "Catalog Browser",
1957
1957
  "description": "",
1958
1958
  "dependencies": [
1959
+ "class-variance-authority@^0.7.1",
1959
1960
  "lucide-solid@^1.48.0"
1960
1961
  ],
1961
1962
  "registryDependencies": [
@@ -1,5 +1,27 @@
1
- import { createUniqueId, Show, type JSX } from 'solid-js'
1
+ import { createContext, createUniqueId, Show, useContext, type JSX } from 'solid-js'
2
2
  import { Badge } from '../../ui/badge'
3
+ import { cva } from 'class-variance-authority'
4
+
5
+ type SectionsLayout = 'stacked' | 'columns'
6
+ const SectionsLayoutContext = createContext<() => SectionsLayout>((): SectionsLayout => 'stacked')
7
+ const sectionsVariants = cva('grid min-w-0', {
8
+ variants: {
9
+ layout: {
10
+ stacked: 'gap-2',
11
+ columns:
12
+ 'gap-4 rounded-xl border border-border p-3 md:auto-cols-fr md:grid-flow-col md:gap-0',
13
+ },
14
+ },
15
+ })
16
+ const sectionVariants = cva('grid min-w-0 gap-1.5 wrap-anywhere', {
17
+ variants: {
18
+ layout: {
19
+ stacked: 'rounded-xl border border-border p-3',
20
+ columns:
21
+ 'md:border-s md:border-border md:px-3 md:first:border-s-0 md:first:ps-0 md:last:pe-0',
22
+ },
23
+ },
24
+ })
3
25
 
4
26
  /** Shared item summary and action frame for catalog-owned detail content. */
5
27
  export function CatalogDetail(props: {
@@ -13,6 +35,8 @@ export function CatalogDetail(props: {
13
35
  badges?: JSX.Element
14
36
  action?: JSX.Element
15
37
  status?: JSX.Element
38
+ /** Groups related fields in one padded card; stacks them on narrow screens. */
39
+ sectionsLayout?: SectionsLayout
16
40
  children?: JSX.Element
17
41
  }) {
18
42
  return (
@@ -40,19 +64,25 @@ export function CatalogDetail(props: {
40
64
  </div>
41
65
  </Show>
42
66
  </div>
43
- <div class="grid min-w-0 gap-2">{props.children}</div>
67
+ <SectionsLayoutContext.Provider value={() => props.sectionsLayout ?? 'stacked'}>
68
+ <div
69
+ data-catalog-detail-sections
70
+ data-layout={props.sectionsLayout ?? 'stacked'}
71
+ class={sectionsVariants({ layout: props.sectionsLayout ?? 'stacked' })}
72
+ >
73
+ {props.children}
74
+ </div>
75
+ </SectionsLayoutContext.Provider>
44
76
  </article>
45
77
  )
46
78
  }
47
79
 
48
80
  /** A shared compact row for host-specific fields, permissions, and copy. */
49
81
  export function CatalogDetailSection(props: { title: string; children?: JSX.Element }) {
82
+ const layout = useContext(SectionsLayoutContext)
50
83
  const headingId = `catalog-detail-section-${createUniqueId()}`
51
84
  return (
52
- <section
53
- class="grid min-w-0 gap-1.5 rounded-xl border border-border p-3"
54
- aria-labelledby={headingId}
55
- >
85
+ <section class={sectionVariants({ layout: layout() })} aria-labelledby={headingId}>
56
86
  <h3
57
87
  id={headingId}
58
88
  class="text-2xs font-semibold tracking-wider text-muted-foreground uppercase"
@@ -17,6 +17,8 @@ import { computeLayoutFrames, type LayoutRect } from './geometry'
17
17
  import { paneDropIntent, type PaneDropIntent } from './drop'
18
18
  export type { PaneDropIntent } from './drop'
19
19
  const PANE_DRAG_TYPE = 'application/x-adea-pane-move'
20
+ const DRAG_PREVIEW_MAX_WIDTH = 384
21
+ const DRAG_PREVIEW_MAX_HEIGHT = 256
20
22
  import {
21
23
  listLeaves,
22
24
  MIN_SPLIT_RATIO,
@@ -57,6 +59,114 @@ function rectStyle(rect: LayoutRect): JSX.CSSProperties {
57
59
  height: `${rect.height * 100}%`,
58
60
  }
59
61
  }
62
+ function dragPreviewPlaceholder(source: HTMLElement, message: string) {
63
+ const placeholder = document.createElement('div')
64
+ placeholder.className = cn(
65
+ source.getAttribute('class'),
66
+ 'flex min-h-8 min-w-0 items-center justify-center bg-muted px-2 text-center text-xs text-muted-foreground'
67
+ )
68
+ placeholder.setAttribute('data-split-drag-placeholder', source.localName)
69
+ placeholder.textContent = message
70
+ return placeholder
71
+ }
72
+ function boundedSnapshotSize(width: number, height: number) {
73
+ const sourceWidth = Math.max(1, width)
74
+ const sourceHeight = Math.max(1, height)
75
+ const scale = Math.min(
76
+ 1,
77
+ DRAG_PREVIEW_MAX_WIDTH / sourceWidth,
78
+ DRAG_PREVIEW_MAX_HEIGHT / sourceHeight
79
+ )
80
+ return {
81
+ width: Math.max(1, Math.round(sourceWidth * scale)),
82
+ height: Math.max(1, Math.round(sourceHeight * scale)),
83
+ }
84
+ }
85
+ function cloneDragPreviewNode(source: Node): Node | undefined {
86
+ if (source.nodeType === Node.TEXT_NODE) return source.cloneNode(false)
87
+ if (!(source instanceof HTMLElement || source instanceof SVGElement))
88
+ return source.cloneNode(false)
89
+
90
+ if (['script', 'style', 'link', 'source', 'track', 'meta'].includes(source.localName))
91
+ return undefined
92
+ if (['iframe', 'video', 'audio', 'object', 'embed'].includes(source.localName))
93
+ return dragPreviewPlaceholder(
94
+ source as HTMLElement,
95
+ 'Embedded content unavailable while moving'
96
+ )
97
+ if (source instanceof HTMLImageElement) {
98
+ const snapshot = document.createElement('canvas')
99
+ const size = boundedSnapshotSize(
100
+ source.naturalWidth || source.width,
101
+ source.naturalHeight || source.height
102
+ )
103
+ snapshot.width = size.width
104
+ snapshot.height = size.height
105
+ snapshot.className = source.getAttribute('class') ?? ''
106
+ snapshot.setAttribute('data-split-drag-image', '')
107
+ try {
108
+ if (!source.complete || source.naturalWidth === 0)
109
+ throw new Error('image has no decoded pixels')
110
+ const context = snapshot.getContext('2d')
111
+ if (!context) throw new Error('canvas snapshot context is unavailable')
112
+ context.drawImage(source, 0, 0, snapshot.width, snapshot.height)
113
+ return snapshot
114
+ } catch {
115
+ return dragPreviewPlaceholder(source, 'Image preview unavailable')
116
+ }
117
+ }
118
+
119
+ // Copying a defined custom element can run its constructor; inserting it can
120
+ // also reconnect a stream or terminal session. Flatten its display tree.
121
+ if (source instanceof HTMLElement && source.localName.includes('-')) {
122
+ const children = document.createDocumentFragment()
123
+ for (const child of source.childNodes) {
124
+ const clone = cloneDragPreviewNode(child)
125
+ if (clone) children.append(clone)
126
+ }
127
+ return children.childNodes.length > 0
128
+ ? children
129
+ : dragPreviewPlaceholder(source, 'Live component preview unavailable while moving')
130
+ }
131
+
132
+ const clone = source.cloneNode(false) as HTMLElement | SVGElement
133
+ for (const attribute of [
134
+ 'id',
135
+ 'style',
136
+ 'draggable',
137
+ 'data-pane-drag-handle',
138
+ 'data-pane-id',
139
+ 'src',
140
+ 'srcset',
141
+ 'href',
142
+ 'xlink:href',
143
+ 'data',
144
+ 'poster',
145
+ ])
146
+ clone.removeAttribute(attribute)
147
+ for (const attribute of Array.from(clone.attributes))
148
+ if (/^on/i.test(attribute.name)) clone.removeAttribute(attribute.name)
149
+
150
+ if (source instanceof HTMLCanvasElement && clone instanceof HTMLCanvasElement) {
151
+ const size = boundedSnapshotSize(source.width, source.height)
152
+ clone.width = size.width
153
+ clone.height = size.height
154
+ try {
155
+ const context = clone.getContext('2d')
156
+ if (!context) throw new Error('canvas snapshot context is unavailable')
157
+ context.drawImage(source, 0, 0, clone.width, clone.height)
158
+ } catch {
159
+ return dragPreviewPlaceholder(source, 'Canvas preview unavailable while moving')
160
+ }
161
+ return clone
162
+ }
163
+
164
+ for (const child of source.childNodes) {
165
+ const childClone = cloneDragPreviewNode(child)
166
+ if (childClone) clone.append(childClone)
167
+ }
168
+ return clone
169
+ }
60
170
  /** Muxy frame geometry keeps leaf owners stable; Corvu owns constrained separator interactions. */
61
171
  export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L>) {
62
172
  let root: HTMLDivElement | undefined
@@ -66,11 +176,35 @@ export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L
66
176
  const prefix = createUniqueId()
67
177
  let nextDomId = 0
68
178
  let nextDragId = 0
179
+ let dragPreview: HTMLElement | undefined
69
180
  const [drag, setDrag] = createSignal<{ id: string; token: string }>()
70
181
  const [drop, setDrop] = createSignal<{ id: string; intent: PaneDropIntent }>()
71
182
  const clearDrag = () => {
72
183
  setDrag(undefined)
73
184
  setDrop(undefined)
185
+ dragPreview?.remove()
186
+ dragPreview = undefined
187
+ }
188
+ const createDragPreview = (source: HTMLElement) => {
189
+ dragPreview?.remove()
190
+ const preview = cloneDragPreviewNode(source) as HTMLElement
191
+ preview.className =
192
+ 'pointer-events-none fixed -left-full top-0 z-(--z-dialog) flex h-64 max-h-screen w-96 max-w-screen min-h-0 min-w-0 flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-xl'
193
+ preview.removeAttribute('id')
194
+ preview.removeAttribute('style')
195
+ preview.removeAttribute('role')
196
+ preview.removeAttribute('aria-label')
197
+ preview.removeAttribute('data-pane-id')
198
+ preview.removeAttribute('data-focused')
199
+ preview.removeAttribute('data-drag-source')
200
+ preview.removeAttribute('data-drop-direction')
201
+ preview.removeAttribute('data-drop-placement')
202
+ preview.setAttribute('data-split-drag-preview', '')
203
+ preview.setAttribute('aria-hidden', 'true')
204
+ preview.inert = true
205
+ document.body.append(preview)
206
+ dragPreview = preview
207
+ return preview
74
208
  }
75
209
  const domId = (id: string) => {
76
210
  const existing = domIds.get(id)
@@ -236,7 +370,7 @@ export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L
236
370
  props.onMove?.(current.id, id, next)
237
371
  }}
238
372
  style={rectStyle(frames().get(id)?.rect ?? { x: 0, y: 0, width: 0, height: 0 })}
239
- class="absolute flex min-h-0 min-w-0 flex-col overflow-hidden border border-border transition-opacity data-[drag-source]:opacity-40 data-[focused]:border-primary bg-background text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"
373
+ class="absolute flex min-h-0 min-w-0 flex-col overflow-hidden border border-border transition-opacity motion-reduce:transition-none data-[drag-source]:opacity-40 data-[focused]:border-primary bg-background text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"
240
374
  onFocusIn={() => props.onFocus?.(id)}
241
375
  >
242
376
  <header class="flex min-w-0 shrink-0 items-center gap-2 bg-surface px-2">
@@ -245,6 +379,7 @@ export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L
245
379
  draggable={Boolean(props.onMove)}
246
380
  class={cn('flex min-w-0 flex-1 items-center gap-2 truncate text-sm', {
247
381
  'cursor-grab': Boolean(props.onMove),
382
+ 'cursor-grabbing': drag()?.id === id,
248
383
  })}
249
384
  title={props.onMove ? `Drag ${props.labelForLeaf(leaf())} to move` : undefined}
250
385
  onDragStart={(event) => {
@@ -256,18 +391,23 @@ export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L
256
391
  setDrag({ id, token })
257
392
  event.dataTransfer.setData(PANE_DRAG_TYPE, token)
258
393
  event.dataTransfer.effectAllowed = 'move'
259
- // The ghost is the pane itself, not the handle strip: the
260
- // browser snapshots the region before the drag state dims
261
- // this section, so the dragged pane stays readable while
262
- // its source visibly recedes.
394
+ // The native drag image carries this pane's actual content
395
+ // in a bounded floating-window frame; the source recedes
396
+ // independently as the browser follows the image pointer.
263
397
  const source = refs.get(id)
264
398
  const frame = source?.getBoundingClientRect()
265
- if (source && frame && typeof event.dataTransfer.setDragImage === 'function')
399
+ if (source && frame && typeof event.dataTransfer.setDragImage === 'function') {
400
+ const preview = createDragPreview(source)
401
+ const previewFrame = preview.getBoundingClientRect()
402
+ const scaleX = previewFrame.width / frame.width
403
+ const scaleY = previewFrame.height / frame.height
266
404
  event.dataTransfer.setDragImage(
267
- source,
268
- Math.max(0, Math.round(event.clientX - frame.left)),
269
- Math.max(0, Math.round(event.clientY - frame.top))
405
+ preview,
406
+ Math.max(0, Math.round((event.clientX - frame.left) * scaleX)),
407
+ Math.max(0, Math.round((event.clientY - frame.top) * scaleY))
270
408
  )
409
+ preview.setAttribute('data-native-drag-image', '')
410
+ }
271
411
  }}
272
412
  onDragEnd={clearDrag}
273
413
  >
@@ -182,6 +182,10 @@ function readSelection(storageKey: string): ThemeSelection {
182
182
  font: fontOptions.some((option) => option.id === parsed.font)
183
183
  ? (parsed.font as string)
184
184
  : defaultThemeSelection.font,
185
+ // Every axis has to be read back here, or it is lost twice: the effect below
186
+ // removes the `data-density` that `themeScript` set for the first paint, and
187
+ // the next `setSelection` rewrites the store without it.
188
+ density: resolvedDensity(parsed as ThemeSelection),
185
189
  }
186
190
  } catch {
187
191
  return defaultThemeSelection
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adea-ai/ui",
3
- "version": "0.103.0",
3
+ "version": "0.104.0",
4
4
  "description": "The Adea design system: Kobalte-backed Solid components, semantic OKLCH tokens, and the app-shell layout primitives shared by Adea and Cortana.",
5
5
  "keywords": [
6
6
  "adea",
package/registry.json CHANGED
@@ -1956,6 +1956,7 @@
1956
1956
  "title": "Catalog Browser",
1957
1957
  "description": "",
1958
1958
  "dependencies": [
1959
+ "class-variance-authority@^0.7.1",
1959
1960
  "lucide-solid@^1.48.0"
1960
1961
  ],
1961
1962
  "registryDependencies": [
@@ -1,5 +1,27 @@
1
- import { createUniqueId, Show, type JSX } from 'solid-js'
1
+ import { createContext, createUniqueId, Show, useContext, type JSX } from 'solid-js'
2
2
  import { Badge } from '../../ui/badge'
3
+ import { cva } from 'class-variance-authority'
4
+
5
+ type SectionsLayout = 'stacked' | 'columns'
6
+ const SectionsLayoutContext = createContext<() => SectionsLayout>((): SectionsLayout => 'stacked')
7
+ const sectionsVariants = cva('grid min-w-0', {
8
+ variants: {
9
+ layout: {
10
+ stacked: 'gap-2',
11
+ columns:
12
+ 'gap-4 rounded-xl border border-border p-3 md:auto-cols-fr md:grid-flow-col md:gap-0',
13
+ },
14
+ },
15
+ })
16
+ const sectionVariants = cva('grid min-w-0 gap-1.5 wrap-anywhere', {
17
+ variants: {
18
+ layout: {
19
+ stacked: 'rounded-xl border border-border p-3',
20
+ columns:
21
+ 'md:border-s md:border-border md:px-3 md:first:border-s-0 md:first:ps-0 md:last:pe-0',
22
+ },
23
+ },
24
+ })
3
25
 
4
26
  /** Shared item summary and action frame for catalog-owned detail content. */
5
27
  export function CatalogDetail(props: {
@@ -13,6 +35,8 @@ export function CatalogDetail(props: {
13
35
  badges?: JSX.Element
14
36
  action?: JSX.Element
15
37
  status?: JSX.Element
38
+ /** Groups related fields in one padded card; stacks them on narrow screens. */
39
+ sectionsLayout?: SectionsLayout
16
40
  children?: JSX.Element
17
41
  }) {
18
42
  return (
@@ -40,19 +64,25 @@ export function CatalogDetail(props: {
40
64
  </div>
41
65
  </Show>
42
66
  </div>
43
- <div class="grid min-w-0 gap-2">{props.children}</div>
67
+ <SectionsLayoutContext.Provider value={() => props.sectionsLayout ?? 'stacked'}>
68
+ <div
69
+ data-catalog-detail-sections
70
+ data-layout={props.sectionsLayout ?? 'stacked'}
71
+ class={sectionsVariants({ layout: props.sectionsLayout ?? 'stacked' })}
72
+ >
73
+ {props.children}
74
+ </div>
75
+ </SectionsLayoutContext.Provider>
44
76
  </article>
45
77
  )
46
78
  }
47
79
 
48
80
  /** A shared compact row for host-specific fields, permissions, and copy. */
49
81
  export function CatalogDetailSection(props: { title: string; children?: JSX.Element }) {
82
+ const layout = useContext(SectionsLayoutContext)
50
83
  const headingId = `catalog-detail-section-${createUniqueId()}`
51
84
  return (
52
- <section
53
- class="grid min-w-0 gap-1.5 rounded-xl border border-border p-3"
54
- aria-labelledby={headingId}
55
- >
85
+ <section class={sectionVariants({ layout: layout() })} aria-labelledby={headingId}>
56
86
  <h3
57
87
  id={headingId}
58
88
  class="text-2xs font-semibold tracking-wider text-muted-foreground uppercase"