entasis 0.13.0 → 0.13.1

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.
@@ -2,7 +2,7 @@ export { default as Theme } from './Theme.svelte';
2
2
  export { ThemeState, useDefaultColor, useTheme } from './theme.state.svelte.js';
3
3
  export type { ThemeProps } from './theme.props.js';
4
4
  export { defaultThemeSpacingScale, typeScalePresets, type ThemeDesignTokenMap, type ThemeDesignTokens, type ThemeRadius, type ThemeSpacing, type ThemeSpacingScale, type ThemeSpacingStep, type TypeScaleOptions, type TypeScalePreset, type TypeScaleRatio } from './theme.designTokens.js';
5
- export { themeTransitions, type ThemeTransition } from './themeTransition.js';
5
+ export { resolveThemeTransition, themeTransitions, type ThemeTransition, type ThemeTransitionInput, type ThemeTransitionOptions } from './themeTransition.js';
6
6
  export { focusRing, selectedSoft, selectedSolid } from './theme.recipes.js';
7
7
  export { themePresetNames, themePresets, type ThemePreset, type ThemePresetName, type ThemePresetTokens } from './theme.presets.js';
8
8
  export { breakpoints, containerBreakpoints, resolveContainerBreakpoint, resolveResponsive, responsiveContainerClasses, responsiveVariables, type ContainerBreakpoint } from './responsive.js';
@@ -1,7 +1,7 @@
1
1
  export { default as Theme } from './Theme.svelte';
2
2
  export { ThemeState, useDefaultColor, useTheme } from './theme.state.svelte.js';
3
3
  export { defaultThemeSpacingScale, typeScalePresets } from './theme.designTokens.js';
4
- export { themeTransitions } from './themeTransition.js';
4
+ export { resolveThemeTransition, themeTransitions } from './themeTransition.js';
5
5
  export { focusRing, selectedSoft, selectedSolid } from './theme.recipes.js';
6
6
  export { themePresetNames, themePresets } from './theme.presets.js';
7
7
  export { breakpoints, containerBreakpoints, resolveContainerBreakpoint, resolveResponsive, responsiveContainerClasses, responsiveVariables } from './responsive.js';
@@ -1 +1 @@
1
- export declare const themeDescription = "\n# Theme\n\n`Theme` owns global theme selection, runtime design tokens, shared overlay state, and theme\ntransitions. Wrap the application once and use the `ThemeState` received by the children snippet.\nComponents below it read the same state with `useTheme()` from `entasis/theme`, called during\ncomponent initialisation; it throws when no `Theme` is above. For a palette computed at runtime,\nsee `generateColorPalette` in `entasis/color-palette`.\n\n## Runtime design tokens\n\n```svelte\n<script lang=\"ts\">\n\timport { Theme, type ThemeDesignTokenMap } from 'entasis/theme';\n\n\tlet spacing = $state<'small' | 'normal' | 'large'>('normal');\n\tconst designTokens = $derived({\n\t\tlight: {\n\t\t\tspacing,\n\t\t\tspacingScale: { xs: 1, sm: 1.5, md: 2, lg: 3, xl: 4 },\n\t\t\tradius: 'normal',\n\t\t\ttypeScale: 'default',\n\t\t\traisedWithBorder: true,\n\t\t\tdefaultColor: 'neutral'\n\t\t},\n\t\tdark: {\n\t\t\tspacing,\n\t\t\tspacingScale: { xs: 1, sm: 1.5, md: 2, lg: 3, xl: 4 },\n\t\t\tradius: 'small',\n\t\t\ttypeScale: 'compact',\n\t\t\traisedWithBorder: false,\n\t\t\tdefaultColor: 'neutral'\n\t\t}\n\t} satisfies ThemeDesignTokenMap<readonly ['light', 'dark']>);\n</script>\n\n<Theme {designTokens} transition=\"radial-top-right\">\n\t{#snippet children(theme)}\n\t\t<button onclick={() => (spacing = spacing === 'small' ? 'large' : 'small')}>\n\t\t\tChange density\n\t\t</button>\n\t\t<button onclick={() => (theme.theme = theme.resolvedTheme === 'dark' ? 'light' : 'dark')}>\n\t\t\tToggle color scheme\n\t\t</button>\n\t{/snippet}\n</Theme>\n```\n\n`designTokens` is keyed by logical theme name and respects the `attribute` and `value` props.\nEleven presets ship as `themePresets` (`dense`, `compact`, `balanced`, `comfortable`, `spacious`,\n`sharp`, `rounded`, `display`, `editorial`, `glass`, `terminal`): `designTokens={{ light: themePresets.glass.tokens, dark: themePresets.glass.tokens }}`.\nChanging the controlled object updates already-rendered Tailwind utilities without rebuilding CSS.\n\n### ThemeDesignTokens\n\n- `spacing`: `'small' | 'normal' | 'large' | number`. Globally scales density.\n- `spacingScale`: partial overrides for the strictly increasing `xs`, `sm`, `md`, `lg`,\n and `xl` spacing multipliers. Defaults to 1/1.5/2/3/4.\n- `radius`: `'none' | 'subtile' | 'small' | 'normal' | 'large' | 'round' | number`.\n Controls take the full multiplier; surface steps (`lg` and up) stop at `large` (1.5\u00D7).\n- `typeScale`: `'compact' | 'default' | 'comfortable' | 'large' | TypeScaleOptions`.\n- `raisedWithBorder`: toggles the border used by `raised-*` utilities.\n- `defaultColor`: `Colors` role kit chrome inherits when a control omits `color`.\n Defaults to `neutral`. Set `primary` to restore an accent-colored kit. Compiles the\n current-color family (`--color`, `--color-readable`, muted/contrast/light/dark variants)\n and `--default-color` onto the theme selector. Do not set `data-color` on `html`.\n- `focusColor`, `selectedColor`, `hoverColor`, `pressedColor`: the four `Colors` **state\n roles**. They pin, for the whole theme, what a focus ring, a persistent selection and the\n transient hover/pressed layer look like, independently of the role of the control the state\n lands on. They compile `--color-focus`, `--color-selected` (plus its `-contrast`,\n `-readable` and `-muted-readable` companions), `--color-hover` and\n `--color-pressed` onto the theme selector. There is no `--color-selected-muted`: the soft\n fill is a translucent tint of `--color-selected` at `--state-selected-opacity`, so it reads\n on any surface. None is declared at `:root`: every use site\n falls back to the matching current role (`ring-focus` is\n `var(--color-focus, var(--color))`, `bg-selected-muted` tints\n `var(--color-selected, var(--color))`, the state layer is\n `var(--color-hover, currentColor)` and on `:active`\n `var(--color-pressed, var(--color-hover, currentColor))`), so leaving them unset changes\n nothing and `data-color` keeps moving the states with `--color`. Theme-level only: there is\n no per-component override. An unknown role throws.\n\nComponent-level density remains a local variant. It selects utility classes whose values inherit\nthe active global spacing token.\n\nGenerated interfaces should use the public `xs | sm | md | lg | xl` vocabulary through component\nprops and named gap/padding utilities. `micro` and `layout-*` are internal recipe tokens. Prefer\nparent-owned gaps over child margins; do not emit arbitrary spacing or unsupported radius values.\n\n## Theme selection\n\nThe selection props wrap `svelte-themes`: `themes`, `defaultTheme`, `forcedTheme`,\n`systemTheme`, `syncColorScheme`, `transitionOnChange`, `storageKey`, `attribute`,\n`value`, and `colorScheme`. The default themes are light and dark, with system selection\nenabled. `systemTheme`, `syncColorScheme`, and `transitionOnChange` all default to\n`true`; they map onto the library's `enableSystem`, `enableColorScheme`, and\n`disableTransitionOnChange` options.\n\n`ThemeState` exposes `theme`, `resolvedTheme`, `themes`, and `systemTheme`. Assign\n`theme.theme` to switch themes. The optional `transition` prop applies a named view transition;\nunsupported browsers and reduced-motion users switch instantly.\n\n`spinnerVariant` sets the global default spinner animation. The children snippet is required.\n\n## Motion tokens\n\nMotion is a token scale like spacing and radius: five duration steps and four easing roles.\n`motion` retunes them app-wide; an omitted token keeps its default.\n\n| Duration | Default | | Easing role | Default |\n| ---------- | ------- | --- | ------------ | ------------- |\n| `instant` | 0ms | | `standard` | `cubicInOut` |\n| `fast` | 100ms | | `enter` | `cubicOut` |\n| `normal` | 200ms | | `exit` | `cubicIn` |\n| `slow` | 300ms | | `emphasized` | `backOut` |\n| `slower` | 500ms | | | |\n\n```svelte\n<script lang=\"ts\">\n\timport { Theme } from 'entasis/theme';\n\n\tlet { children } = $props();\n</script>\n\n<Theme motion={{ duration: { normal: 150, slow: 260 }, easing: { standard: 'quintOut' } }}>\n\t{@render children()}\n</Theme>\n```\n\nThe Tailwind plugin emits the same scale as CSS variables on `html` (`--duration-normal`,\n`--ease-standard`, ...) plus the matching `duration-*` / `ease-*` utilities, so CSS transitions\nand Svelte transitions read one set of numbers. The `motion` prop rewrites those variables on\n`html` at runtime and `designTokens.motion` rewrites them again per theme, layered over the prop;\n`ThemeState.motion` resolves through the same two rungs, so the utilities and the presets never\ndisagree. `ThemeState.transition` is a deprecated alias for its `normal` duration and `standard`\neasing. Reduced motion resolves every duration to 0 and collapses the `--duration-*` variables via\nthe `data-entasis-reduce-motion` attribute on `html`.\n\nComponents keep their own transition in a reserved `motion` slot on their theme, so the `theme`\nprop covers motion as well as classes:\n\n```svelte\n<script lang=\"ts\">\n\timport { Dialog } from 'entasis/dialog';\n</script>\n\n<Dialog theme={{ motion: { duration: 'fast', easing: 'emphasized' } }} title=\"Quick\">Body</Dialog>\n```\n\n## Component theme registry\n\n`components` sets app-wide component theme defaults without a wrapper component per component:\nit is keyed by theme name (`dialog`, `button`, ...) and each entry takes the same slots as that\ncomponent's `theme` prop, the `motion` slot included. A `set<Component>Theme` call in a subtree\nbeats the registry, and an instance `theme` prop beats both \u2014 per slot: each rung layers on the one\nbelow it, so a subtree that restyles one slot keeps the registry's others.\n\n```svelte\n<script lang=\"ts\">\n\timport { Theme } from 'entasis/theme';\n\n\tlet { children } = $props();\n</script>\n\n<Theme\n\tcomponents={{\n\t\tdialog: { motion: { duration: 'fast' }, content: { base: 'rounded-2xl' } },\n\t\tbutton: { root: { base: 'tracking-wide' } }\n\t}}\n>\n\t{@render children()}\n</Theme>\n```\n\n## Reduced motion\n\n`reduceMotion` forces reduced motion on (`true`) or off (`false`) for every entasis animation,\noverriding the OS `prefers-reduced-motion` setting; omit it to follow the OS. The live result is\nexposed as `ThemeState.preferReducesMotion` (reactive, so it updates when the OS setting changes)\nand mirrored as a `data-entasis-reduce-motion` attribute on `html` for CSS-only animations.\n\n```svelte\n<script>\n\timport { Theme } from 'entasis/theme';\n\n\tlet { children } = $props();\n</script>\n\n<Theme reduceMotion>{@render children()}</Theme>\n```\n\n## Content Security Policy\n\n`nonce` is stamped on every `<script>` and `<style>` the Theme and the components below it\ninject: the theme and viewport boot scripts, the design-token stylesheet, the `transitionOnChange={false}`\nstyle, and the CDN loaders of PhoneInput, Map and Mermaid (the document print frame and Mermaid's own\nsvg `<style>` too). Drag-and-drop base styles are rules in the Tailwind plugin CSS, so nothing is\ninjected for them. Generate one nonce per request in `hooks.server.ts`, send it through `locals` to\n`<Theme nonce>`, and send the Content-Security-Policy header yourself with that same nonce.\nSvelteKit's `kit.csp` cannot be used for this: it generates its own nonce per request, exposes it\nonly as `%sveltekit.nonce%` in `app.html`, and has no option to take yours, so the header would\ncarry one nonce while the Theme's tags carry another. Leave `kit.csp` unset and put the nonce on\nSvelteKit's own inline tags in `transformPageChunk`:\n\n```ts\n// doc-fence: skip (needs nonce declared on the app's App.Locals)\n// src/hooks.server.ts\nimport type { Handle } from '@sveltejs/kit';\n\nexport const handle: Handle = async ({ event, resolve }) => {\n\tconst nonce = crypto.randomUUID();\n\tevent.locals.nonce = nonce;\n\tconst response = await resolve(event, {\n\t\t// Adds the nonce to SvelteKit's inline <script> and <style> tags; tags that already carry one\n\t\t// (the Theme's) are left alone.\n\t\ttransformPageChunk: ({ html }) =>\n\t\t\thtml.replace(/<(script|style)(?=[\\s>])(?![^>]*\\snonce=)/g, `<$1 nonce=\"${nonce}\"`)\n\t});\n\tresponse.headers.set(\n\t\t'content-security-policy',\n\t\t`script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'; style-src-attr 'unsafe-inline'`\n\t);\n\treturn response;\n};\n```\n\n```ts\n// doc-fence: skip (needs nonce declared on the app's App.Locals)\n// src/routes/+layout.server.ts\nimport type { LayoutServerLoad } from './$types';\n\nexport const load: LayoutServerLoad = ({ locals }) => ({ nonce: locals.nonce });\n```\n\n```svelte\n<!-- src/routes/+layout.svelte -->\n<script lang=\"ts\">\n\timport { Theme } from 'entasis/theme';\n\n\tlet { data, children } = $props();\n</script>\n\n<Theme nonce={data.nonce}>\n\t{@render children()}\n</Theme>\n```\n\nServer-rendered `style` attributes (`style:` directives) are not covered by a nonce: allow them with\n`style-src-attr 'unsafe-inline'` (as above). `ThemeState.nonce` exposes the value to your own injected code.\n\n## Shadow DOM and portal containers\n\nDialogs, popovers, menus, tooltips and the floating-window layer mount in `document.body`. Inside a\nshadow root they mount in that root so adopted styles keep applying. `portalContainer` overrides both:\nan element or shadow root, or a function returning one for a container that is not rendered yet.\nA function that reads `$state` (as below) moves the floating-window layer in as soon as the container\nappears; a function over a plain variable is read each time a panel mounts, so the layer, which mounts\nwith the Theme, needs the container to exist by then.\nOutside presses and Escape are heard on the document a panel lives in, so a panel rendered into an\niframe's document dismisses correctly.\n\n```svelte\n<script lang=\"ts\">\n\timport { Theme } from 'entasis/theme';\n\n\tlet { children } = $props();\n\tlet host = $state<HTMLElement>();\n</script>\n\n<div bind:this={host}></div>\n<Theme portalContainer={() => host}>\n\t{@render children()}\n</Theme>\n```\n\nWhen several Themes are mounted, the most recently mounted one that sets `portalContainer` wins.\n\n## Scroll lock and viewport variables\n\nA dialog or popover locks page scroll with a `data-entasis-scroll-lock` attribute on `<html>` and one\nrule in the Tailwind plugin CSS (`overflow: hidden`); nothing is written to inline styles, so a lock a\nthird-party library holds on the same element is neither overwritten nor released early. While\nlocked, the hidden scrollbar's room is kept with `scrollbar-gutter: stable` (the lock measures the\npage and pads the scrollbar's width instead where the gutter does not hold) unless the page already reserves a gutter, so there is no double gutter. Two variables\ndescribe the viewport for your own layout:\n\n- `--entasis-scrollbar-width`: set on `<html>` while locked, the width in px of the scrollbar the\n lock hides (`0px` with overlay scrollbars). Add it to `position: fixed` bars:\n `padding-inline-end: var(--entasis-scrollbar-width, 0px)`.\n- `--keyboard-inset`: the height in px the on-screen keyboard covers at the bottom of the layout\n viewport, measured from the visual viewport (`0px` without a keyboard, while pinch-zoomed, and in\n browsers that resize the layout viewport for the keyboard). Pin a bottom bar above the keyboard with\n `bottom: var(--keyboard-inset)`.\n- `--visual-viewport-top`: how far in px the visible area starts below the top of the layout\n viewport. iOS scrolls it down to bring a focused field above the keyboard, and fixed content then\n starts above the screen; pad it by this to keep it visible (`0px` without a keyboard and while\n pinch-zoomed). The layout viewport is the larger of `innerHeight` and the root's\n `clientHeight`, because iOS 26 shrinks `innerHeight` along with the keyboard.\n\nOn iOS, the same script adds `maximum-scale=1` to the page's `<meta name=\"viewport\">` (when it\nsets no `maximum-scale` of its own). Safari otherwise zooms the page into any text field below\n16px when it takes focus and leaves it zoomed. iOS ignores the limit for the user's own pinch zoom,\nso the page stays zoomable; other platforms would honour it, so they are left alone.\n\n## Build-time boundary\n\nThe Tailwind plugin still generates color palettes and registers utility names, variants,\nkeyframes, and spinner CSS. Spacing, radius, typography scale, raised borders,\n`defaultColor` and the four state roles (`focusColor`, `selectedColor`, `hoverColor`,\n`pressedColor`) belong to `Theme.designTokens`; colors remain CSS variables and can be\noverridden directly. `ThemeState.defaultColor` exposes the active role. Kit chrome should\nresolve omitted `color` props with `useDefaultColor`.\n";
1
+ export declare const themeDescription = "\n# Theme\n\n`Theme` owns global theme selection, runtime design tokens, shared overlay state, and theme\ntransitions. Wrap the application once and use the `ThemeState` received by the children snippet.\nComponents below it read the same state with `useTheme()` from `entasis/theme`, called during\ncomponent initialisation; it throws when no `Theme` is above. For a palette computed at runtime,\nsee `generateColorPalette` in `entasis/color-palette`.\n\n## Runtime design tokens\n\n```svelte\n<script lang=\"ts\">\n\timport { Theme, type ThemeDesignTokenMap } from 'entasis/theme';\n\n\tlet spacing = $state<'small' | 'normal' | 'large'>('normal');\n\tconst designTokens = $derived({\n\t\tlight: {\n\t\t\tspacing,\n\t\t\tspacingScale: { xs: 1, sm: 1.5, md: 2, lg: 3, xl: 4 },\n\t\t\tradius: 'normal',\n\t\t\ttypeScale: 'default',\n\t\t\traisedWithBorder: true,\n\t\t\tdefaultColor: 'neutral'\n\t\t},\n\t\tdark: {\n\t\t\tspacing,\n\t\t\tspacingScale: { xs: 1, sm: 1.5, md: 2, lg: 3, xl: 4 },\n\t\t\tradius: 'small',\n\t\t\ttypeScale: 'compact',\n\t\t\traisedWithBorder: false,\n\t\t\tdefaultColor: 'neutral'\n\t\t}\n\t} satisfies ThemeDesignTokenMap<readonly ['light', 'dark']>);\n</script>\n\n<Theme {designTokens} transition=\"radial-top-right\">\n\t{#snippet children(theme)}\n\t\t<button onclick={() => (spacing = spacing === 'small' ? 'large' : 'small')}>\n\t\t\tChange density\n\t\t</button>\n\t\t<button onclick={() => (theme.theme = theme.resolvedTheme === 'dark' ? 'light' : 'dark')}>\n\t\t\tToggle color scheme\n\t\t</button>\n\t{/snippet}\n</Theme>\n```\n\n`designTokens` is keyed by logical theme name and respects the `attribute` and `value` props.\nEleven presets ship as `themePresets` (`dense`, `compact`, `balanced`, `comfortable`, `spacious`,\n`sharp`, `rounded`, `display`, `editorial`, `glass`, `terminal`): `designTokens={{ light: themePresets.glass.tokens, dark: themePresets.glass.tokens }}`.\nChanging the controlled object updates already-rendered Tailwind utilities without rebuilding CSS.\n\n### ThemeDesignTokens\n\n- `spacing`: `'small' | 'normal' | 'large' | number`. Globally scales density.\n- `spacingScale`: partial overrides for the strictly increasing `xs`, `sm`, `md`, `lg`,\n and `xl` spacing multipliers. Defaults to 1/1.5/2/3/4.\n- `radius`: `'none' | 'subtile' | 'small' | 'normal' | 'large' | 'round' | number`.\n Controls take the full multiplier; surface steps (`lg` and up) stop at `large` (1.5\u00D7).\n- `typeScale`: `'compact' | 'default' | 'comfortable' | 'large' | TypeScaleOptions`.\n- `raisedWithBorder`: toggles the border used by `raised-*` utilities.\n- `defaultColor`: `Colors` role kit chrome inherits when a control omits `color`.\n Defaults to `neutral`. Set `primary` to restore an accent-colored kit. Compiles the\n current-color family (`--color`, `--color-readable`, muted/contrast/light/dark variants)\n and `--default-color` onto the theme selector. Do not set `data-color` on `html`.\n- `focusColor`, `selectedColor`, `hoverColor`, `pressedColor`: the four `Colors` **state\n roles**. They pin, for the whole theme, what a focus ring, a persistent selection and the\n transient hover/pressed layer look like, independently of the role of the control the state\n lands on. They compile `--color-focus`, `--color-selected` (plus its `-contrast`,\n `-readable` and `-muted-readable` companions), `--color-hover` and\n `--color-pressed` onto the theme selector. There is no `--color-selected-muted`: the soft\n fill is a translucent tint of `--color-selected` at `--state-selected-opacity`, so it reads\n on any surface. None is declared at `:root`: every use site\n falls back to the matching current role (`ring-focus` is\n `var(--color-focus, var(--color))`, `bg-selected-muted` tints\n `var(--color-selected, var(--color))`, the state layer is\n `var(--color-hover, currentColor)` and on `:active`\n `var(--color-pressed, var(--color-hover, currentColor))`), so leaving them unset changes\n nothing and `data-color` keeps moving the states with `--color`. Theme-level only: there is\n no per-component override. An unknown role throws.\n\nComponent-level density remains a local variant. It selects utility classes whose values inherit\nthe active global spacing token.\n\nGenerated interfaces should use the public `xs | sm | md | lg | xl` vocabulary through component\nprops and named gap/padding utilities. `micro` and `layout-*` are internal recipe tokens. Prefer\nparent-owned gaps over child margins; do not emit arbitrary spacing or unsupported radius values.\n\n## Theme selection\n\nThe selection props wrap `svelte-themes`: `themes`, `defaultTheme`, `forcedTheme`,\n`systemTheme`, `syncColorScheme`, `transitionOnChange`, `storageKey`, `attribute`,\n`value`, and `colorScheme`. The default themes are light and dark, with system selection\nenabled. `systemTheme`, `syncColorScheme`, and `transitionOnChange` all default to\n`true`; they map onto the library's `enableSystem`, `enableColorScheme`, and\n`disableTransitionOnChange` options.\n\n`ThemeState` exposes `theme`, `resolvedTheme`, `themes`, and `systemTheme`. Assign\n`theme.theme` to switch themes. The optional `transition` prop applies a named view transition;\nunsupported browsers and reduced-motion users switch instantly. Pass\n`{ type, duration, easing }` instead of a name to tune its timing, e.g.\n`transition={{ type: 'radial-top-right', duration: 600 }}` (duration in ms; grid and shutter steps\nscale with it).\n\n`spinnerVariant` sets the global default spinner animation. The children snippet is required.\n\n## Motion tokens\n\nMotion is a token scale like spacing and radius: five duration steps and four easing roles.\n`motion` retunes them app-wide; an omitted token keeps its default.\n\n| Duration | Default | | Easing role | Default |\n| ---------- | ------- | --- | ------------ | ------------- |\n| `instant` | 0ms | | `standard` | `cubicInOut` |\n| `fast` | 100ms | | `enter` | `cubicOut` |\n| `normal` | 200ms | | `exit` | `cubicIn` |\n| `slow` | 300ms | | `emphasized` | `backOut` |\n| `slower` | 500ms | | | |\n\n```svelte\n<script lang=\"ts\">\n\timport { Theme } from 'entasis/theme';\n\n\tlet { children } = $props();\n</script>\n\n<Theme motion={{ duration: { normal: 150, slow: 260 }, easing: { standard: 'quintOut' } }}>\n\t{@render children()}\n</Theme>\n```\n\nThe Tailwind plugin emits the same scale as CSS variables on `html` (`--duration-normal`,\n`--ease-standard`, ...) plus the matching `duration-*` / `ease-*` utilities, so CSS transitions\nand Svelte transitions read one set of numbers. The `motion` prop rewrites those variables on\n`html` at runtime and `designTokens.motion` rewrites them again per theme, layered over the prop;\n`ThemeState.motion` resolves through the same two rungs, so the utilities and the presets never\ndisagree. `ThemeState.transition` is a deprecated alias for its `normal` duration and `standard`\neasing. Reduced motion resolves every duration to 0 and collapses the `--duration-*` variables via\nthe `data-entasis-reduce-motion` attribute on `html`.\n\nComponents keep their own transition in a reserved `motion` slot on their theme, so the `theme`\nprop covers motion as well as classes:\n\n```svelte\n<script lang=\"ts\">\n\timport { Dialog } from 'entasis/dialog';\n</script>\n\n<Dialog theme={{ motion: { duration: 'fast', easing: 'emphasized' } }} title=\"Quick\">Body</Dialog>\n```\n\n## Component theme registry\n\n`components` sets app-wide component theme defaults without a wrapper component per component:\nit is keyed by theme name (`dialog`, `button`, ...) and each entry takes the same slots as that\ncomponent's `theme` prop, the `motion` slot included. A `set<Component>Theme` call in a subtree\nbeats the registry, and an instance `theme` prop beats both \u2014 per slot: each rung layers on the one\nbelow it, so a subtree that restyles one slot keeps the registry's others.\n\n```svelte\n<script lang=\"ts\">\n\timport { Theme } from 'entasis/theme';\n\n\tlet { children } = $props();\n</script>\n\n<Theme\n\tcomponents={{\n\t\tdialog: { motion: { duration: 'fast' }, content: { base: 'rounded-2xl' } },\n\t\tbutton: { root: { base: 'tracking-wide' } }\n\t}}\n>\n\t{@render children()}\n</Theme>\n```\n\n## Reduced motion\n\n`reduceMotion` forces reduced motion on (`true`) or off (`false`) for every entasis animation,\noverriding the OS `prefers-reduced-motion` setting; omit it to follow the OS. The live result is\nexposed as `ThemeState.preferReducesMotion` (reactive, so it updates when the OS setting changes)\nand mirrored as a `data-entasis-reduce-motion` attribute on `html` for CSS-only animations.\n\n```svelte\n<script>\n\timport { Theme } from 'entasis/theme';\n\n\tlet { children } = $props();\n</script>\n\n<Theme reduceMotion>{@render children()}</Theme>\n```\n\n## Content Security Policy\n\n`nonce` is stamped on every `<script>` and `<style>` the Theme and the components below it\ninject: the theme and viewport boot scripts, the design-token stylesheet, the `transitionOnChange={false}`\nstyle, and the CDN loaders of PhoneInput, Map and Mermaid (the document print frame and Mermaid's own\nsvg `<style>` too). Drag-and-drop base styles are rules in the Tailwind plugin CSS, so nothing is\ninjected for them. Generate one nonce per request in `hooks.server.ts`, send it through `locals` to\n`<Theme nonce>`, and send the Content-Security-Policy header yourself with that same nonce.\nSvelteKit's `kit.csp` cannot be used for this: it generates its own nonce per request, exposes it\nonly as `%sveltekit.nonce%` in `app.html`, and has no option to take yours, so the header would\ncarry one nonce while the Theme's tags carry another. Leave `kit.csp` unset and put the nonce on\nSvelteKit's own inline tags in `transformPageChunk`:\n\n```ts\n// doc-fence: skip (needs nonce declared on the app's App.Locals)\n// src/hooks.server.ts\nimport type { Handle } from '@sveltejs/kit';\n\nexport const handle: Handle = async ({ event, resolve }) => {\n\tconst nonce = crypto.randomUUID();\n\tevent.locals.nonce = nonce;\n\tconst response = await resolve(event, {\n\t\t// Adds the nonce to SvelteKit's inline <script> and <style> tags; tags that already carry one\n\t\t// (the Theme's) are left alone.\n\t\ttransformPageChunk: ({ html }) =>\n\t\t\thtml.replace(/<(script|style)(?=[\\s>])(?![^>]*\\snonce=)/g, `<$1 nonce=\"${nonce}\"`)\n\t});\n\tresponse.headers.set(\n\t\t'content-security-policy',\n\t\t`script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'; style-src-attr 'unsafe-inline'`\n\t);\n\treturn response;\n};\n```\n\n```ts\n// doc-fence: skip (needs nonce declared on the app's App.Locals)\n// src/routes/+layout.server.ts\nimport type { LayoutServerLoad } from './$types';\n\nexport const load: LayoutServerLoad = ({ locals }) => ({ nonce: locals.nonce });\n```\n\n```svelte\n<!-- src/routes/+layout.svelte -->\n<script lang=\"ts\">\n\timport { Theme } from 'entasis/theme';\n\n\tlet { data, children } = $props();\n</script>\n\n<Theme nonce={data.nonce}>\n\t{@render children()}\n</Theme>\n```\n\nServer-rendered `style` attributes (`style:` directives) are not covered by a nonce: allow them with\n`style-src-attr 'unsafe-inline'` (as above). `ThemeState.nonce` exposes the value to your own injected code.\n\n## Shadow DOM and portal containers\n\nDialogs, popovers, menus, tooltips and the floating-window layer mount in `document.body`. Inside a\nshadow root they mount in that root so adopted styles keep applying. `portalContainer` overrides both:\nan element or shadow root, or a function returning one for a container that is not rendered yet.\nA function that reads `$state` (as below) moves the floating-window layer in as soon as the container\nappears; a function over a plain variable is read each time a panel mounts, so the layer, which mounts\nwith the Theme, needs the container to exist by then.\nOutside presses and Escape are heard on the document a panel lives in, so a panel rendered into an\niframe's document dismisses correctly.\n\n```svelte\n<script lang=\"ts\">\n\timport { Theme } from 'entasis/theme';\n\n\tlet { children } = $props();\n\tlet host = $state<HTMLElement>();\n</script>\n\n<div bind:this={host}></div>\n<Theme portalContainer={() => host}>\n\t{@render children()}\n</Theme>\n```\n\nWhen several Themes are mounted, the most recently mounted one that sets `portalContainer` wins.\n\n## Scroll lock and viewport variables\n\nA dialog or popover locks page scroll with a `data-entasis-scroll-lock` attribute on `<html>` and one\nrule in the Tailwind plugin CSS (`overflow: hidden`); nothing is written to inline styles, so a lock a\nthird-party library holds on the same element is neither overwritten nor released early. While\nlocked, the hidden scrollbar's room is kept with `scrollbar-gutter: stable` (the lock measures the\npage and pads the scrollbar's width instead where the gutter does not hold) unless the page already reserves a gutter, so there is no double gutter. Two variables\ndescribe the viewport for your own layout:\n\n- `--entasis-scrollbar-width`: set on `<html>` while locked, the width in px of the scrollbar the\n lock hides (`0px` with overlay scrollbars). Add it to `position: fixed` bars:\n `padding-inline-end: var(--entasis-scrollbar-width, 0px)`.\n- `--keyboard-inset`: the height in px the on-screen keyboard covers at the bottom of the layout\n viewport, measured from the visual viewport (`0px` without a keyboard, while pinch-zoomed, and in\n browsers that resize the layout viewport for the keyboard). Pin a bottom bar above the keyboard with\n `bottom: var(--keyboard-inset)`.\n- `--visual-viewport-top`: how far in px the visible area starts below the top of the layout\n viewport. iOS scrolls it down to bring a focused field above the keyboard, and fixed content then\n starts above the screen; pad it by this to keep it visible (`0px` without a keyboard and while\n pinch-zoomed). The layout viewport is the larger of `innerHeight` and the root's\n `clientHeight`, because iOS 26 shrinks `innerHeight` along with the keyboard.\n\nOn iOS, the same script adds `maximum-scale=1` to the page's `<meta name=\"viewport\">` (when it\nsets no `maximum-scale` of its own). Safari otherwise zooms the page into any text field below\n16px when it takes focus and leaves it zoomed. iOS ignores the limit for the user's own pinch zoom,\nso the page stays zoomable; other platforms would honour it, so they are left alone.\n\n## Build-time boundary\n\nThe Tailwind plugin still generates color palettes and registers utility names, variants,\nkeyframes, and spinner CSS. Spacing, radius, typography scale, raised borders,\n`defaultColor` and the four state roles (`focusColor`, `selectedColor`, `hoverColor`,\n`pressedColor`) belong to `Theme.designTokens`; colors remain CSS variables and can be\noverridden directly. `ThemeState.defaultColor` exposes the active role. Kit chrome should\nresolve omitted `color` props with `useDefaultColor`.\n";
@@ -98,7 +98,10 @@ enabled. \`systemTheme\`, \`syncColorScheme\`, and \`transitionOnChange\` all de
98
98
 
99
99
  \`ThemeState\` exposes \`theme\`, \`resolvedTheme\`, \`themes\`, and \`systemTheme\`. Assign
100
100
  \`theme.theme\` to switch themes. The optional \`transition\` prop applies a named view transition;
101
- unsupported browsers and reduced-motion users switch instantly.
101
+ unsupported browsers and reduced-motion users switch instantly. Pass
102
+ \`{ type, duration, easing }\` instead of a name to tune its timing, e.g.
103
+ \`transition={{ type: 'radial-top-right', duration: 600 }}\` (duration in ms; grid and shutter steps
104
+ scale with it).
102
105
 
103
106
  \`spinnerVariant\` sets the global default spinner animation. The children snippet is required.
104
107
 
@@ -2,7 +2,7 @@ import type { Snippet } from 'svelte';
2
2
  import type { SvelteThemeProps } from 'svelte-themes';
3
3
  import type { SpinnerVariant } from '../Spinner/spinner.props.js';
4
4
  import type { ThemeState } from './theme.state.svelte.js';
5
- import type { ThemeTransition } from './themeTransition.js';
5
+ import type { ThemeTransitionInput } from './themeTransition.js';
6
6
  import type { ThemeDesignTokenMap } from './theme.designTokens.js';
7
7
  import type { DeepPartial, MotionTokens } from '../../tailwind/scales.js';
8
8
  import type { ComponentThemeRegistry } from '../../utils/cva/theme.js';
@@ -28,8 +28,11 @@ export type ThemeProps<T extends readonly string[] = readonly string[]> = Omit<S
28
28
  spinnerVariant?: SpinnerVariant;
29
29
  /** Runtime design tokens keyed by logical theme name. */
30
30
  designTokens?: ThemeDesignTokenMap<T>;
31
- /** View transition used when ThemeState.theme changes. Omit for an instant change. */
32
- transition?: ThemeTransition;
31
+ /**
32
+ * View transition used when ThemeState.theme changes: a variation name, or
33
+ * `{ type, duration, easing }` to tune its timing (duration in ms). Omit for an instant change.
34
+ */
35
+ transition?: ThemeTransitionInput;
33
36
  /**
34
37
  * Forces reduced motion on (`true`) or off (`false`) for every entasis animation.
35
38
  * Omit to follow the user's OS reduced-motion setting.
@@ -10,7 +10,7 @@ import { LayerStack } from '../../utils/layers.svelte.js';
10
10
  import { HoverIntentGroup } from '../../utils/hoverIntent.js';
11
11
  import type { TooltipOptions } from '../Tooltip/tooltip.props.js';
12
12
  import type { ConfirmationHost } from '../Confirmation/confirmation.state.svelte.js';
13
- import { type ThemeTransition } from './themeTransition.js';
13
+ import { type ThemeTransitionInput } from './themeTransition.js';
14
14
  import { ThemeFloatingWindows } from './theme.floatingWindows.js';
15
15
  import type { Colors } from '../../types/theme.js';
16
16
  import { type PortalContainerSource } from '../../attachments/portal.js';
@@ -26,7 +26,7 @@ type EventPayload = {
26
26
  type ThemeEventListener = (event: never) => void;
27
27
  interface ThemeOptions {
28
28
  readonly spinnerVariant: SpinnerVariant;
29
- readonly themeTransition?: ThemeTransition;
29
+ readonly themeTransition?: ThemeTransitionInput;
30
30
  readonly defaultColor?: Colors;
31
31
  readonly reduceMotion?: boolean;
32
32
  /** The partial scale passed as `<Theme motion>`; `motion` below is the resolved one. */
@@ -1,3 +1,16 @@
1
1
  export declare const themeTransitions: readonly ["radial-top-left", "radial-top-right", "radial-bottom-left", "radial-bottom-right", "line-top", "line-right", "line-bottom", "line-left", "shutter-top", "shutter-right", "shutter-bottom", "shutter-left", "random-grid", "column-grid-left", "column-grid-right"];
2
2
  export type ThemeTransition = (typeof themeTransitions)[number];
3
- export declare function updateThemeWithTransition(transition: ThemeTransition | undefined, update: () => void | Promise<void>): void;
3
+ /** A theme transition with its timing tuned. Left out, each keeps the variation's own value. */
4
+ export type ThemeTransitionOptions = {
5
+ /** The variation. */
6
+ type: ThemeTransition;
7
+ /** Length of the whole reveal in ms; grid and shutter steps scale with it. */
8
+ duration?: number;
9
+ /** CSS easing of the reveal. Grid variations step from cell to cell whatever the easing. */
10
+ easing?: string;
11
+ };
12
+ /** A variation name, or the variation with its duration and easing. */
13
+ export type ThemeTransitionInput = ThemeTransition | ThemeTransitionOptions;
14
+ /** The options form of a transition input. */
15
+ export declare function resolveThemeTransition(transition: ThemeTransitionInput | undefined): ThemeTransitionOptions | undefined;
16
+ export declare function updateThemeWithTransition(input: ThemeTransitionInput | undefined, update: () => void | Promise<void>): void;
@@ -15,6 +15,12 @@ export const themeTransitions = [
15
15
  'column-grid-left',
16
16
  'column-grid-right'
17
17
  ];
18
+ /** The options form of a transition input. */
19
+ export function resolveThemeTransition(transition) {
20
+ if (!transition)
21
+ return undefined;
22
+ return typeof transition === 'string' ? { type: transition } : transition;
23
+ }
18
24
  const radialOrigins = {
19
25
  'radial-top-left': '0% 0%',
20
26
  'radial-top-right': '100% 0%',
@@ -32,14 +38,23 @@ const SHUTTER_PHASES_PER_BAND = 3;
32
38
  const SHUTTER_START_INTERVAL = 1;
33
39
  const SHUTTER_PHASE_DURATION = 50;
34
40
  let activeTransitionId = 0;
35
- export function updateThemeWithTransition(transition, update) {
41
+ export function updateThemeWithTransition(input, update) {
42
+ const options = resolveThemeTransition(input);
43
+ const transition = options?.type;
36
44
  if (!canAnimateThemeChange(transition)) {
37
45
  void update();
38
46
  return;
39
47
  }
40
48
  const root = document.documentElement;
41
49
  const transitionId = ++activeTransitionId;
42
- const animation = createThemeTransitionAnimation(transition);
50
+ const variation = createThemeTransitionAnimation(transition);
51
+ const animation = {
52
+ ...variation,
53
+ duration: options?.duration !== undefined && Number.isFinite(options.duration) && options.duration >= 0
54
+ ? options.duration
55
+ : variation.duration,
56
+ easing: options?.easing ?? variation.easing
57
+ };
43
58
  root.dataset.entasisThemeTransition = transition;
44
59
  let viewTransition;
45
60
  try {
@@ -1821,7 +1821,7 @@ export declare const componentInventory: readonly [{
1821
1821
  readonly id: "theme";
1822
1822
  readonly subpath: "entasis/theme";
1823
1823
  readonly sourceIndex: "src/lib/components/Theme/index.ts";
1824
- readonly exportedSymbols: readonly ["Breakpoint", "ContainerBreakpoint", "ResponsiveProps", "Theme", "ThemeDesignTokenMap", "ThemeDesignTokens", "ThemePreset", "ThemePresetName", "ThemePresetTokens", "ThemeProps", "ThemeRadius", "ThemeSpacing", "ThemeSpacingScale", "ThemeSpacingStep", "ThemeState", "ThemeTransition", "TypeScaleOptions", "TypeScalePreset", "TypeScaleRatio", "breakpoints", "containerBreakpoints", "defaultThemeSpacingScale", "focusRing", "resolveContainerBreakpoint", "resolveResponsive", "responsiveContainerClasses", "responsiveVariables", "selectedSoft", "selectedSolid", "themePresetNames", "themePresets", "themeTransitions", "typeScalePresets", "useDefaultColor", "useTheme"];
1824
+ readonly exportedSymbols: readonly ["Breakpoint", "ContainerBreakpoint", "ResponsiveProps", "Theme", "ThemeDesignTokenMap", "ThemeDesignTokens", "ThemePreset", "ThemePresetName", "ThemePresetTokens", "ThemeProps", "ThemeRadius", "ThemeSpacing", "ThemeSpacingScale", "ThemeSpacingStep", "ThemeState", "ThemeTransition", "ThemeTransitionInput", "ThemeTransitionOptions", "TypeScaleOptions", "TypeScalePreset", "TypeScaleRatio", "breakpoints", "containerBreakpoints", "defaultThemeSpacingScale", "focusRing", "resolveContainerBreakpoint", "resolveResponsive", "resolveThemeTransition", "responsiveContainerClasses", "responsiveVariables", "selectedSoft", "selectedSolid", "themePresetNames", "themePresets", "themeTransitions", "typeScalePresets", "useDefaultColor", "useTheme"];
1825
1825
  readonly docs: readonly [];
1826
1826
  readonly capabilities: readonly ["svelte", "component", "configuration"];
1827
1827
  readonly relatedComponents: readonly [];
@@ -4429,6 +4429,8 @@ export const componentInventory = [
4429
4429
  'ThemeSpacingStep',
4430
4430
  'ThemeState',
4431
4431
  'ThemeTransition',
4432
+ 'ThemeTransitionInput',
4433
+ 'ThemeTransitionOptions',
4432
4434
  'TypeScaleOptions',
4433
4435
  'TypeScalePreset',
4434
4436
  'TypeScaleRatio',
@@ -4438,6 +4440,7 @@ export const componentInventory = [
4438
4440
  'focusRing',
4439
4441
  'resolveContainerBreakpoint',
4440
4442
  'resolveResponsive',
4443
+ 'resolveThemeTransition',
4441
4444
  'responsiveContainerClasses',
4442
4445
  'responsiveVariables',
4443
4446
  'selectedSoft',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entasis",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "license": "MIT",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -1,130 +0,0 @@
1
- export interface ScrollAreaOptions {
2
- delay: number;
3
- type: 'auto' | 'always' | 'scroll' | 'hover';
4
- scrollOnEdges: boolean;
5
- /**
6
- * Px of content that may stay hidden past an edge before that edge counts as overflowing.
7
- * Defaults to 1.
8
- */
9
- overflowEdgeThreshold?: number;
10
- /**
11
- * Writing direction of the viewport. In `rtl` the horizontal offset runs from 0 down to a
12
- * negative number, so every horizontal read and write is mirrored. Defaults to `ltr`.
13
- */
14
- direction?: () => 'ltr' | 'rtl';
15
- }
16
- declare const ScrollArea_base: new (props: ScrollAreaOptions) => {} & ScrollAreaOptions;
17
- export declare class ScrollArea extends ScrollArea_base {
18
- viewportElement: HTMLElement | undefined;
19
- contentElement: HTMLElement | undefined;
20
- scrollbarXElement: HTMLElement | undefined;
21
- scrollbarYElement: HTMLElement | undefined;
22
- scrollY: number;
23
- scrollX: number;
24
- dragOffset: number;
25
- viewportDimensions: {
26
- width: number;
27
- height: number;
28
- };
29
- contentDimensions: {
30
- width: number;
31
- height: number;
32
- };
33
- measureVersion: number;
34
- maxScrollY: number;
35
- scrollbarYEnabled: boolean;
36
- maxScrollX: number;
37
- scrollbarXEnabled: boolean;
38
- visible: boolean;
39
- visibleX: boolean;
40
- rtl: boolean;
41
- trackYThickness: number;
42
- trackXThickness: number;
43
- private edgeThreshold;
44
- overflowYStart: number;
45
- overflowYEnd: number;
46
- overflowXStart: number;
47
- overflowXEnd: number;
48
- hasOverflowYStart: boolean;
49
- hasOverflowYEnd: boolean;
50
- hasOverflowXStart: boolean;
51
- hasOverflowXEnd: boolean;
52
- canScrollUp: boolean;
53
- canScrollDown: boolean;
54
- thumbRect: {
55
- readonly current: DOMRect;
56
- reference: (ref: HTMLElement) => (() => void) | undefined;
57
- };
58
- trackRect: {
59
- readonly current: DOMRect;
60
- reference: (ref: HTMLElement) => (() => void) | undefined;
61
- };
62
- trackRectX: {
63
- readonly current: DOMRect;
64
- reference: (ref: HTMLElement) => (() => void) | undefined;
65
- };
66
- private trackYLength;
67
- private trackXLength;
68
- /** Thumb length the content ratio asks for, before any overscroll shrinks it. */
69
- thumbYBaseSize: number;
70
- thumbXBaseSize: number;
71
- overscrollY: number;
72
- overscrollX: number;
73
- thumbYSize: number;
74
- thumbXSize: number;
75
- private savedScrollSnapType;
76
- private pauseScrollSnap;
77
- private restoreScrollSnap;
78
- isDraggingY: boolean;
79
- dragY: import("svelte/attachments").Attachment<HTMLElement>;
80
- isDraggingX: boolean;
81
- dragX: import("svelte/attachments").Attachment<HTMLElement>;
82
- overflowing: boolean;
83
- hoover: {
84
- reference: (ref: HTMLElement) => () => void;
85
- destroy: () => void;
86
- cancel: () => void;
87
- readonly isHovered: boolean;
88
- };
89
- thumbYPosition: number;
90
- /** Distance from the track's inline start to the thumb's inline start edge. */
91
- thumbXPosition: number;
92
- /** The horizontal thumb's `translateX` in px: mirrored in RTL, where the track fills from the right. */
93
- thumbXTranslate: number;
94
- isScrolling: boolean;
95
- private scrollingTimer;
96
- private clearScrollingTimer;
97
- /** Horizontal scroll offset as distance from the inline start, whatever the direction. */
98
- private readScrollX;
99
- /** Scrolls horizontally to a distance from the inline start. */
100
- private writeScrollX;
101
- /** Re-resolves the writing direction; true when it flipped. */
102
- private syncDirection;
103
- private readScrollOffsets;
104
- private handleScroll;
105
- handleTrackClick: (event: MouseEvent) => void;
106
- handleTrackClickX: (event: MouseEvent) => void;
107
- private measureFrame;
108
- private lastExtentY;
109
- private lastExtentX;
110
- /**
111
- * Read the live boxes and publish them. Idempotent: state is only written when a number
112
- * actually moved, so a chatty MutationObserver cannot start a re-render loop.
113
- */
114
- measure: () => void;
115
- /** Coalesce bursts of observer callbacks into one post-layout measurement. */
116
- private scheduleMeasure;
117
- private cancelScheduledMeasure;
118
- viewportAttachment: (element: HTMLElement) => () => void;
119
- contentAttachment: (element: HTMLElement) => () => void;
120
- private forwardWheelToViewport;
121
- private keepFocusOnPress;
122
- trackAttachment: (element: HTMLElement) => () => void;
123
- trackAttachmentX: (element: HTMLElement) => () => void;
124
- private scrollAnimationFrame;
125
- private edgeScrollSpeed;
126
- get scrollOnEdgesAttachment(): ((element: HTMLElement) => () => void) | null;
127
- private startEdgeScroll;
128
- private stopEdgeScroll;
129
- }
130
- export {};