# @warky/svelix

> Svelte 5 component library with Skeleton UI and Tailwind CSS

One markdown file per component: exports, types, props and every Storybook story as a code example.
`llms-full.txt` contains all of them in a single file.

## Components

- [Alert](./Alert.md) — 7 examples
- [AppShell](./AppShell.md) — 11 examples
- [AspectRatio](./AspectRatio.md) — 4 examples
- [BetterMenu](./BetterMenu.md) — 3 examples
- [Boxer](./Boxer.md) — 8 examples
- [Breadcrumbs](./Breadcrumbs.md) — 6 examples
- [CardGrid](./CardGrid.md) — 30 examples
- [Charts](./Charts.md) — 17 examples
- [ContentEditor](./ContentEditor.md) — 19 examples
- [Dialog](./Dialog.md) — 2 examples
- [ErrorBoundary](./ErrorBoundary.md) — 1 example
- [Former](./Former.md) — 30 examples
- [FormerControllers](./FormerControllers.md) — 140 examples
- [GlobalStateStore](./GlobalStateStore.md)
- [GridExport](./GridExport.md)
- [Gridler](./Gridler.md) — 57 examples
- [Group](./Group.md) — 4 examples
- [IFrame](./IFrame.md) — 2 examples
- [Icons](./Icons.md)
- [LLMChat](./LLMChat.md) — 6 examples
- [NavigationProgress](./NavigationProgress.md) — 3 examples
- [OverlayStack](./OverlayStack.md)
- [Paner](./Paner.md) — 21 examples
- [Portal](./Portal.md)
- [Resolver](./Resolver.md)
- [Schedule](./Schedule.md) — 21 examples
- [Screenshot](./Screenshot.md) — 2 examples
- [ServerEvents](./ServerEvents.md)
- [Stack](./Stack.md) — 3 examples
- [SvelixLayout](./SvelixLayout.md)
- [Tour](./Tour.md) — 19 examples
- [Types](./Types.md)
- [VTree](./VTree.md) — 23 examples
- [Windowz](./Windowz.md) — 7 examples
- [legacy](./legacy.md) — 29 examples
- [Elements](./Elements.md) — 9 examples

## Full

- [llms-full.txt](./llms-full.txt)

---

# Alert

Import: `import { ... } from '@warky/svelix/Alert'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as Alert } from "./Alert.svelte";
export { ALERT_DEFAULTS, alertAppearance } from "./alertStyles.js";
export type * from "./types.js";
```

## Summary

Alert (port of Mantine Alert). Variants map to Skeleton presets for palette colours (`filled` → `preset-filled-*-500`, `light` → `preset-tonal-*`, `outline` → `preset-outlined-*-500`); CSS colours use inline styles. Defaults via `SvelixLayout` key `alert` and `shared.radius`.

## Types (types.ts)

```ts
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import type { SvelixColor, SvelixRadius } from "../../utils/scale.js";

type DivAttributes = Omit<HTMLAttributes<HTMLDivElement>, "children" | "class" | "style" | "title" | "color" | "role">;

/** Mantine variant names; palette colours map to Skeleton presets. */
export type AlertVariant = "filled" | "light" | "outline" | "transparent" | "white" | "default";

/** `SvelixLayout` key `alert`. */
export interface AlertLayoutConfig {
	/** @default "light" */
	variant?: AlertVariant;
	/** Skeleton palette name or CSS colour. @default "primary" */
	color?: SvelixColor;
	/** @default "container" */
	radius?: SvelixRadius;
}

export interface AlertProps extends AlertLayoutConfig, DivAttributes {
	title?: string | Snippet;
	/** Shown before the title / message. */
	icon?: Snippet;
	/** @default false */
	withCloseButton?: boolean;
	onClose?: () => void;
	/** Close button `aria-label`. Default i18n `svelix.alert.close` → "Close". */
	closeButtonLabel?: string;
	/** @default "alert" */
	role?: HTMLAttributes<HTMLDivElement>["role"];
	class?: string;
	style?: string;
	children?: Snippet;
}
```

## Examples (from Storybook stories)

### Components/Alert  (`Alert.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import AlertDemo from './AlertDemo.fixtures.svelte';
```

Helper component `./AlertDemo.fixtures.svelte`:

```svelte
<script lang="ts">
	import IconAlertCircle from "../Icons/IconAlertCircle.svelte";
	import SvelixLayout from "../SvelixLayout/SvelixLayout.svelte";
	import Alert from "./Alert.svelte";
	import type { AlertProps, AlertVariant } from "./types.js";

	export interface Props extends Omit<AlertProps, "children" | "icon" | "title"> {
		demo?: "basic" | "variants" | "layout";
		title?: string;
		message?: string;
		withIcon?: boolean;
	}

	let { demo = "basic", title = "Alert title", message = "Alert message", withIcon = true, ...props }: Props = $props();
	let closed = $state(0);
	const variants: AlertVariant[] = ["filled", "light", "outline", "transparent", "white", "default"];
	const colors = ["primary", "success", "warning", "error", "#7c3aed"];
</script>

{#snippet alertIcon()}<IconAlertCircle size={20} />{/snippet}

{#if demo === "basic"}
	<Alert data-testid="alert" {title} icon={withIcon ? alertIcon : undefined} onClose={() => closed++} {...props}>{message}</Alert>
	<p class="mt-2 text-xs" data-testid="closed">{closed}</p>
{:else if demo === "variants"}
	<div class="grid gap-2">
		{#each colors as color (color)}
			<div class="grid grid-cols-3 gap-2">
				{#each variants as variant (variant)}
					<Alert data-testid="{variant}-{color}" {variant} {color} title="{variant} {color}" icon={alertIcon}>Message</Alert>
				{/each}
			</div>
		{/each}
	</div>
{:else}
	<SvelixLayout shared={{ radius: "xl" }} alert={{ variant: "filled", color: "error" }}>
		<Alert data-testid="alert" title="From layout">Message</Alert>
		<Alert data-testid="alert-prop" class="mt-2" variant="outline" radius="xs" title="Props win">Message</Alert>
	</SvelixLayout>
{/if}
```

#### Default (`Default`)

```ts
export const Default: Story = {
	args: { demo: 'basic' },
};
```

#### With Close Button (`WithCloseButton`)

```ts
export const WithCloseButton: Story = {
	args: { demo: 'basic', withCloseButton: true },
};
```

#### Close Button Label (`CloseButtonLabel`)

```ts
export const CloseButtonLabel: Story = {
	args: { demo: 'basic', withCloseButton: true, closeButtonLabel: 'test-close' },
};
```

#### Message Only (`MessageOnly`)

```ts
export const MessageOnly: Story = {
	args: { demo: 'basic', title: '', withIcon: false },
};
```

#### Custom Role (`CustomRole`)

```ts
export const CustomRole: Story = {
	args: { demo: 'basic', role: 'status', id: 'my-alert' },
};
```

#### Variants (`Variants`)

```ts
export const Variants: Story = {
	args: { demo: 'variants' },
	// Contrast of every variant × colour is a design choice; a11y covered by the other stories.
	parameters: { a11y: { test: 'todo' } },
};
```

#### Layout (`Layout`)

```ts
export const Layout: Story = {
	args: { demo: 'layout' },
	parameters: { a11y: { test: 'todo' } },
};
```

---

# AppShell

Import: `import { ... } from '@warky/svelix/AppShell'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as AppShell } from "./AppShell.svelte";
export { default as AppShellSection } from "./AppShellSection.svelte";
export { APP_SHELL_BREAKPOINTS, APP_SHELL_DEFAULTS, breakpointToCss, buildShellCss, mobileQuery, resolveBorder, toCssSize } from "./appShell.js";
export type * from "./types.js";
```

## Summary

AppShell (port of Mantine AppShell). Parts via `headerContent`/`navbarContent`/`asideContent`/`footerContent` snippets, sizing via `header`/`navbar`/`aside`/`footer` config props. `withBorder` on root with per-part override. Defaults via `SvelixLayout` key `appShell`.

## Types (types.ts)

```ts
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import type { SvelixSpacing } from "../../utils/scale.js";

/** px number or any CSS length. */
export type AppShellSize = number | string;

export type AppShellBreakpointName = "xs" | "sm" | "md" | "lg" | "xl";

/** Mantine-style: a plain value, or per-breakpoint overrides (min-width, mobile first). */
export type AppShellResponsive<T> = T | ({ base?: T } & { [K in AppShellBreakpointName]?: T });

/** Breakpoint name, px number or CSS length. */
export type AppShellBreakpoint = AppShellBreakpointName | AppShellSize;

export type AppShellLayoutMode = "default" | "alt";

export interface AppShellCollapsed {
	/** Below the part's breakpoint. */
	mobile?: boolean;
	/** At or above the part's breakpoint. */
	desktop?: boolean;
}

interface AppShellPartBase {
	/** Overrides the root `withBorder`. */
	withBorder?: boolean;
}

/** Header / footer. */
export interface AppShellBarConfig extends AppShellPartBase {
	height?: AppShellResponsive<AppShellSize>;
	collapsed?: boolean;
	/** `false`: main is not offset by this part. @default true */
	offset?: boolean;
}

/** Navbar / aside. */
export interface AppShellSideConfig extends AppShellPartBase {
	width?: AppShellResponsive<AppShellSize>;
	/** Name, px number or CSS length; below it the part is full width. */
	breakpoint?: AppShellBreakpoint;
	collapsed?: AppShellCollapsed;
}

/** `SvelixLayout` key `appShell`. */
export interface AppShellLayoutConfig {
	/** @default true */
	withBorder?: boolean;
	/** Main padding: size name, Tailwind multiplier or CSS length. @default 0 */
	padding?: AppShellResponsive<SvelixSpacing>;
	/** @default 100 */
	zIndex?: number;
	/** @default "default" */
	layout?: AppShellLayoutMode;
	/** Position inside a relative parent instead of the viewport. @default false */
	contained?: boolean;
	header?: Pick<AppShellBarConfig, "height" | "withBorder">;
	footer?: Pick<AppShellBarConfig, "height" | "withBorder">;
	navbar?: Pick<AppShellSideConfig, "width" | "breakpoint" | "withBorder">;
	aside?: Pick<AppShellSideConfig, "width" | "breakpoint" | "withBorder">;
}

export interface AppShellProps
	extends Omit<AppShellLayoutConfig, "header" | "footer" | "navbar" | "aside">,
		Omit<HTMLAttributes<HTMLDivElement>, "children" | "class" | "style"> {
	header?: AppShellBarConfig;
	footer?: AppShellBarConfig;
	navbar?: AppShellSideConfig;
	aside?: AppShellSideConfig;
	class?: string;
	style?: string;
	/** Snippets: a part renders only when its snippet is given. */
	headerContent?: Snippet;
	footerContent?: Snippet;
	navbarContent?: Snippet;
	asideContent?: Snippet;
	/** Main. */
	children?: Snippet;
}

export interface AppShellSectionProps extends Omit<HTMLAttributes<HTMLDivElement>, "children" | "class"> {
	/** Fill remaining space and scroll. */
	grow?: boolean;
	class?: string;
	children?: Snippet;
}
```

## Examples (from Storybook stories)

### Components/AppShell  (`AppShell.stories.ts`)

Shared setup:

```ts
import { expect, within } from 'storybook/test';

import AppShellDemo from './AppShellDemo.fixtures.svelte';

const bw = (el, side) => parseFloat(getComputedStyle(el)[`border${side}Width`]);
```

Helper component `./AppShellDemo.fixtures.svelte`:

```svelte
<script lang="ts">
	import AppShell from "./AppShell.svelte";
	import AppShellSection from "./AppShellSection.svelte";
	import type { AppShellProps } from "./types.js";

	export interface Props extends Omit<AppShellProps, "children"> {
		/** Render the aside and footer too. */
		full?: boolean;
	}

	let { full = true, ...props }: Props = $props();
</script>

{#snippet header()}
	<div data-testid="header" class="flex h-full items-center px-4">Header</div>
{/snippet}
{#snippet navbar()}
	<div data-testid="navbar" class="flex h-full flex-col p-2">
		<AppShellSection>Navbar</AppShellSection>
		<AppShellSection grow>Grow section</AppShellSection>
		<AppShellSection>Footer section</AppShellSection>
	</div>
{/snippet}
{#snippet aside()}
	<div data-testid="aside" class="p-2">Aside</div>
{/snippet}
{#snippet footer()}
	<div data-testid="footer" class="flex h-full items-center px-4">Footer</div>
{/snippet}

<div style="height: 420px; position: relative">
	<AppShell
		data-testid="root"
		contained
		padding="md"
		header={{ height: 50 }}
		navbar={{ width: 200, breakpoint: 600 }}
		aside={{ width: 160, breakpoint: 600 }}
		footer={{ height: 40 }}
		headerContent={header}
		navbarContent={navbar}
		asideContent={full ? aside : undefined}
		footerContent={full ? footer : undefined}
		{...props}
	>
		<div data-testid="main">Main content</div>
	</AppShell>
</div>
```

#### Default (`Default`)

Renders the helper component shown above (no args).

#### Without Border (`WithoutBorder`)

```ts
export const WithoutBorder: Story = {
	args: { withBorder: false },
};
```

#### Part Border Override (`PartBorderOverride`)

```ts
export const PartBorderOverride: Story = {
	args: { withBorder: false, header: { height: 50, withBorder: true } },
};
```

#### Alt Layout (`AltLayout`)

```ts
export const AltLayout: Story = {
	args: { layout: 'alt' },
};
```

#### Collapsed Navbar (`CollapsedNavbar`)

```ts
export const CollapsedNavbar: Story = {
	args: { navbar: { width: 200, breakpoint: 600, collapsed: { desktop: true } } },
};
```

#### Collapsed Mobile (`CollapsedMobile`)

```ts
export const CollapsedMobile: Story = {
	args: { navbar: { width: 200, breakpoint: 99999, collapsed: { mobile: true } } },
};
```

#### Rtl (`Rtl`)

Renders the helper component shown above (no args).

#### Mobile Breakpoint (`MobileBreakpoint`)

```ts
export const MobileBreakpoint: Story = {
	args: { navbar: { width: 200, breakpoint: 99999 }, aside: { width: 160, breakpoint: 99999 } },
};
```

#### Desktop Breakpoint (`DesktopBreakpoint`)

```ts
export const DesktopBreakpoint: Story = {
	args: { navbar: { width: 200, breakpoint: 1 } },
};
```

#### Responsive Sizes (`ResponsiveSizes`)

```ts
export const ResponsiveSizes: Story = {
	args: { navbar: { width: { base: 120, md: 260 }, breakpoint: 1 }, padding: { base: 0, md: "24px" } },
};
```

#### Header Only (`HeaderOnly`)

```ts
export const HeaderOnly: Story = { args: { full: false } };
```

---

# AspectRatio

Import: `import { ... } from '@warky/svelix/AspectRatio'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as AspectRatio } from "./AspectRatio.svelte";
export { ASPECT_RATIO_DEFAULTS, resolveRatio } from "./aspectRatio.js";
export type * from "./types.js";
```

## Summary

AspectRatio (port of Mantine AspectRatio). Uses CSS `aspect-ratio`; direct children are stretched to fill. Default `ratio` via `SvelixLayout` key `aspectRatio`.

## Types (types.ts)

```ts
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";

/** `SvelixLayout` key `aspectRatio`. */
export interface AspectRatioLayoutConfig {
	/** width / height, e.g. `16 / 9`. @default 1 */
	ratio?: number;
}

export interface AspectRatioProps
	extends AspectRatioLayoutConfig,
		Omit<HTMLAttributes<HTMLDivElement>, "children" | "class" | "style"> {
	class?: string;
	style?: string;
	children?: Snippet;
}
```

## Examples (from Storybook stories)

### Components/AspectRatio  (`AspectRatio.stories.ts`)

Shared setup:

```ts
import { expect, within } from 'storybook/test';

import AspectRatioDemo from './AspectRatioDemo.fixtures.svelte';
```

Helper component `./AspectRatioDemo.fixtures.svelte`:

```svelte
<script lang="ts">
	import AspectRatio from "./AspectRatio.svelte";
	import type { AspectRatioProps } from "./types.js";

	export interface Props extends Omit<AspectRatioProps, "children"> {
		demo?: "image" | "iframe" | "video" | "ratios";
	}

	let { demo = "image", ...props }: Props = $props();
	const ratios = [1, 4 / 3, 16 / 9, 21 / 9];
	const image = "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1600 900'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%236366f1'/%3E%3Cstop offset='1' stop-color='%23ec4899'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='1600' height='900' fill='url(%23g)'/%3E%3Ccircle cx='1200' cy='250' r='110' fill='%23fde68a'/%3E%3Cpath d='M0 900V620l320-220 280 180 360-300 400 260 240-140v500z' fill='%231e1b4b' opacity='.85'/%3E%3C/svg%3E";
	const srcdoc = "<body style='margin:0;display:grid;place-items:center;height:100vh;font-family:sans-serif;background:linear-gradient(135deg,#0ea5e9,#22c55e);color:#fff'><h1>Embedded page</h1></body>";
</script>

<!-- Absolute children give the root no intrinsic width, so the wrapper must set one. -->
<div style="width: 400px; max-width: 100%">
	{#if demo === "image"}
		<AspectRatio data-testid="root" ratio={16 / 9} {...props}>
			<img data-testid="child" alt="Gradient landscape" src={image} />
		</AspectRatio>
	{:else if demo === "iframe"}
		<AspectRatio data-testid="root" ratio={16 / 9} {...props}>
			<iframe data-testid="child" title="Embedded page" {srcdoc} style="border: 0"></iframe>
		</AspectRatio>
	{:else if demo === "video"}
		<AspectRatio data-testid="root" ratio={16 / 9} {...props}>
			<iframe
				data-testid="child"
				title="Video embed"
				src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"
				allow="accelerometer; encrypted-media; picture-in-picture"
				allowfullscreen
				style="border: 0"
			></iframe>
		</AspectRatio>
	{:else}
		<div class="grid gap-2">
			{#each ratios as r (r)}
				<AspectRatio data-testid="ratio-{r}" ratio={r} {...props}>
					<div class="preset-tonal flex items-center justify-center rounded">{r.toFixed(2)}</div>
				</AspectRatio>
			{/each}
		</div>
	{/if}
</div>
```

#### Image (`Image`)

```ts
export const Image: Story = {
	args: { demo: 'image' },
};
```

#### Iframe (`Iframe`)

```ts
export const Iframe: Story = {
	args: { demo: 'iframe' },
};
```

#### Video (`Video`)

```ts
export const Video: Story = { args: { demo: 'video' } };
```

#### Ratios (`Ratios`)

```ts
export const Ratios: Story = { args: { demo: 'ratios' } };
```

---

# BetterMenu

Import: `import { ... } from '@warky/svelix/BetterMenu'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as BetterMenu } from './BetterMenu.svelte';
export { default as MenuRenderer } from './MenuRenderer.svelte';
export { createBetterMenuStore } from './store';
export * from './types';
export { BETTER_MENU_DEFAULTS } from './betterMenuLayout';
```

## Types (types.ts)

```ts
import type { Snippet } from "svelte";

export interface BetterMenuInstance {
  id: string;
  items?: Array<BetterMenuInstanceItem>;
  renderer?: Snippet | ((props: Record<string, unknown>) => unknown);
  /** Optional hook invoked when the menu is closed via backdrop click. */
  onHide?: () => void;
  visible: boolean;
  x: number;
  y: number;
}

export type BetterMenuItemColor = "success" | "warning" | "danger" | "info";

export interface BetterMenuInstanceItem {
  id?: string;
  /** Tints the item's icon and label. */
  color?: BetterMenuItemColor;
  icon?: string | Snippet;
  disabled?: boolean;
  isDivider?: boolean;
  items?: Array<BetterMenuInstanceItem>;
  label?: string;
  leftSection?: Snippet;
  onClick?: (e?: MouseEvent) => void;
  onClickAsync?: (e?: MouseEvent) => Promise<void>;
  renderer?:
    | Snippet
    | ((props: BetterMenuInstanceItem & Record<string, unknown>) => unknown);
}

export interface BetterMenuStoreState {
  menus: Array<BetterMenuInstance>;
  providerID: string;
  width?: number;
}

/** `SvelixLayout` key `betterMenu` (BetterMenu, MenuRenderer). */
export interface BetterMenuLayoutConfig {
  disablePortal?: boolean;
}
```

## Props

### BetterMenu.svelte

```ts
export interface Props {
		children: Snippet;
		disablePortal?: boolean;
		providerID?: string;
		width?: number;
	}
```

### MenuRenderer.svelte

```ts
export interface Props {
		disablePortal?: boolean;
	}
```

## Examples (from Storybook stories)

### Components/BetterMenu  (`BetterMenu.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import BetterMenu from './BetterMenu.svelte';

import BetterMenuPreview from './BetterMenuPreview.svelte';
```

Helper component `./BetterMenuPreview.svelte`:

```svelte
<script lang="ts">
	import BetterMenu from './BetterMenu.svelte';
	import type { BetterMenuInstance } from './types';

	export interface Props {
		providerID?: string;
		width?: number;
	}

	const { providerID = 'storybook-menu', width }: Props = $props();

	type BetterMenuRef = { show: (id: string, options?: Partial<Omit<BetterMenuInstance, 'id'>>) => void; hide: (id: string) => void };
	let betterMenuRef: BetterMenuRef | undefined = $state();
	let buttonRef: HTMLButtonElement | null = $state(null);

	function showMenu() {
		if (betterMenuRef && buttonRef) {
			const rect = buttonRef.getBoundingClientRect();
			betterMenuRef.show('example-menu', {
				x: rect.left,
				y: rect.bottom + 8,
				items: [
					{ label: 'Option 1', onClick: () => { /* Option 1 selected */ } },
					{ label: 'Option 2', onClick: () => { /* Option 2 selected */ } },
					{ isDivider: true },
					{ label: 'Option 3', onClick: () => { /* Option 3 selected */ } },
				]
			});
		}
	}
</script>

<BetterMenu bind:this={betterMenuRef} {providerID} {width}>
	<div class="p-8 space-y-4">
		<h2 class="text-2xl font-bold">BetterMenu Example</h2>
		<p class="text-surface-500">Click the button below to display the menu.</p>
		<div>
			<button bind:this={buttonRef} class="btn btn-filled btn-primary btn-md" onclick={showMenu}>
				Display Menu
			</button>
		</div>
	</div>
</BetterMenu>
```

#### Default (`Default`)

```ts
export const Default: Story = {
	args: {
		providerID: 'storybook-menu',
	},
	render: (args) => ({
		Component: BetterMenuPreview,
		props: args,
	}),
};
```

#### Menu Visible (`MenuVisible`)

```ts
export const MenuVisible: Story = {
	args: {
		providerID: 'storybook-menu-visible',
	},
	render: (args) => ({
		Component: BetterMenuPreview,
		props: args,
	}),
};
```

#### Menu Hide On Backdrop (`MenuHideOnBackdrop`)

```ts
export const MenuHideOnBackdrop: Story = {
	args: {
		providerID: 'storybook-menu-backdrop',
	},
	render: (args) => ({
		Component: BetterMenuPreview,
		props: args,
	}),
};
```

---

# Boxer

Import: `import { ... } from '@warky/svelix/Boxer'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as Boxer } from './Boxer.svelte';
export { default as BoxerLookup } from './BoxerLookup.svelte';
export { createLookupAdapter, lookupAdapterKey } from './boxerLookup';
export type { BoxerLookupSource } from './boxerLookup';
export { default as BoxerTarget } from './BoxerTarget.svelte';
export { BoxerBaseAdapter } from './BoxerBaseAdapter.js';
export { BoxerResolveSpecAdapter } from './BoxerResolveSpecAdapter.js';
export { BoxerRestHeaderSpecAdapter } from './BoxerRestHeaderSpecAdapter.js';
export { createBoxerStore } from './store';
export * from './types';
export { BOXER_DEFAULTS, BOXER_LABELS } from "./boxerLayout";
```

## Types (types.ts)

```ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Snippet } from 'svelte';
import type { FilterOption, SortOption } from '@warkypublic/resolvespec-js';

export type BoxerDataSource =
  | 'local'
  | 'server';

export type BoxerClientType = 'body' | 'header';

export interface BoxerAdapterConfig {
  baseUrl: string;
  token?: string;
  schema: string;
  entity: string;
  /** Field to display as the label. Defaults to `'label'`. */
  labelField?: string;
  /** Field to use as the selected value. Defaults to `'id'`. */
  valueField?: string;
  /** Columns to fetch. When omitted, all columns are fetched. `labelField` and `valueField` are always included. */
  columns?: string[];
  /** Default sort applied to every request. */
  sort?: SortOption[];
  /** Base filters always applied to every request. */
  filters?: FilterOption[];
  /** Columns to search against. Defaults to `[labelField]`. */
  searchColumns?: string[];
  /** Filter operator used for search. Defaults to `'ilike'`. */
  searchOperator?: string;
  /** Custom row-to-BoxerItem mapping. When omitted, `labelField` and `valueField` are mapped automatically. */
  mapItem?: (row: any) => BoxerItem;
}

export interface BoxerFetchParams {
  page: number;
  pageSize: number;
  search?: string;
  signal?: AbortSignal;
}

export interface BoxerServerAdapter {
  fetch(params: BoxerFetchParams): Promise<{ data: BoxerItem[]; total: number }>;
  /** Resolve BoxerItems for a list of selected values. Used to display labels for preloaded values not in the current page. */
  resolveByValue?(values: any[], signal?: AbortSignal): Promise<BoxerItem[]>;
}

export type BoxerItem = {
  [key: string]: any;
  label: string;
  value: any;
  /** Optional icon rendered before the label in the dropdown list. Accepts an SVG string (rendered raw) or plain text/emoji. */
  icon?: string;
};

export interface BoxerProps {
  /** Server adapter (ResolveSpec or HeaderSpec). Automatically sets `dataSource` to `'server'` when provided. */
  adapter?: BoxerServerAdapter;
  clearable?: boolean;
  data?: Array<BoxerItem>;
  dataSource?: BoxerDataSource;
  /** Debounce interval in ms for search-triggered fetches. Defaults to `300`. */
  debounceMs?: number;
  disablePortal?: boolean;
  disabled?: boolean;
  error?: string;
  id?: string;
  label?: string;
  leftSection?: Snippet;
  mah?: number;
  multiSelect?: boolean;
  name?: string;
  onAPICall?: (params: BoxerFetchParams) => Promise<{ data: Array<BoxerItem>; total: number }>;
  onBufferChange?: (buffer: Array<BoxerItem> | BoxerItem | null) => void;
  onChange?: (value: any | Array<any>) => void;
  /** Resolve labels for preloaded values that may not be in the current page (server-mode initial values). */
  onResolveValues?: (values: any[], signal?: AbortSignal) => Promise<Array<BoxerItem>>;
  openOnClear?: boolean;
  pageSize?: number;
  placeholder?: string;
  rightSection?: Snippet;
  /** Columns to search against in `local` mode. Falls back to `['label']`. */
  searchColumns?: string[];
  selectFirst?: boolean;
  showAll?: boolean;
  value?: any | Array<any>;
}

export interface BoxerRef {
  clear: () => void;
  close: () => void;
  focus: () => void;
  getValue: () => any | Array<any>;
  open: () => void;
  refetch: () => void;
  setValue: (value: any | Array<any>) => void;
}

/** `SvelixLayout` key `boxer` (Boxer, BoxerTarget). */
export interface BoxerLayoutConfig {
  clearable?: boolean;
  debounceMs?: number;
  disablePortal?: boolean;
  /** Dropdown max height, px. */
  mah?: number;
  pageSize?: number;
}
```

## Props

### BoxerLookup.svelte

```ts
export interface Props
    extends BoxerLookupSource,
      Omit<BoxerProps, "adapter" | "data" | "dataSource" | "onAPICall" | "onResolveValues" | "searchColumns"> {}
```

### BoxerTarget.svelte

```ts
export interface Props {
    activeDescendant?: string;
    controlsId?: string;
    clearable?: boolean;
    disabled?: boolean;
    error?: string;
    expanded?: boolean;
    inputId?: string;
    isFetching?: boolean;
    label?: string;
    leftSection?: Snippet;
    name?: string;
    onclear?: () => void;
    onclick?: () => void;
    onfocus?: () => void;
    onkeydown?: (e: KeyboardEvent) => void;
    onsearch?: (value: string) => void;
    placeholder?: string;
    search?: string;
  }
```

## Examples (from Storybook stories)

### Components/Boxer  (`Boxer.stories.ts`)

Shared setup:

```ts
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';

import Boxer from './Boxer.svelte';

import BoxerLookup from './BoxerLookup.svelte';

import { BoxerResolveSpecAdapter } from './BoxerResolveSpecAdapter.js';

import { BoxerRestHeaderSpecAdapter } from './BoxerRestHeaderSpecAdapter.js';

import { iconAlertCircleSvg, iconSearchSvg } from '#lib/components/Icons/strings.js';

const sampleData = [
	{ label: 'Apple', value: 'apple' },
	{ label: 'Banana', value: 'banana' },
	{ label: 'Cherry', value: 'cherry' },
	{ label: 'Date', value: 'date' },
	{ label: 'Elderberry', value: 'elderberry' },
	{ label: 'Fig', value: 'fig' },
	{ label: 'Grape', value: 'grape' },
];

const lookupRows = [
	{ id: 1, name: 'Alice' },
	{ id: 2, name: 'Bob' },
	{ id: 3, name: 'Alicia' },
];
```

#### Local (`Local`)

```ts
export const Local: Story = {
	args: {
		data: sampleData,
		dataSource: 'local',
		label: 'Select a fruit',
		placeholder: 'Search...',
		searchable: true,
	},
};
```

#### With Icons (`WithIcons`)

```ts
export const WithIcons: Story = {
	args: {
		data: [
			{ label: 'Search results', value: 'search', icon: iconSearchSvg },
			{ label: 'Needs review', value: 'review', icon: iconAlertCircleSvg },
			{ label: 'No icon', value: 'plain' },
		],
		dataSource: 'local',
		label: 'Select an option',
		placeholder: 'Search...',
		searchable: true,
	},
};
```

#### Multi Select (`MultiSelect`)

```ts
export const MultiSelect: Story = {
	args: {
		data: sampleData,
		dataSource: 'local',
		label: 'Select fruits',
		multiSelect: true,
		placeholder: 'Search...',
		searchable: true,
	},
};
```

#### Clearable (`Clearable`)

```ts
export const Clearable: Story = {
	args: {
		clearable: true,
		data: sampleData,
		dataSource: 'local',
		label: 'Clearable Select',
		placeholder: 'Search...',
	},
};
```

#### With Search (`WithSearch`)

```ts
export const WithSearch: Story = {
	args: {
		data: sampleData,
		dataSource: 'local',
		label: 'Searchable Select',
		placeholder: 'Type to search...',
		searchable: true,
	},
};
```

#### With Resolve Spec (`WithResolveSpec`)

```ts
export const WithResolveSpec: Story = {
	argTypes: {
		apiUrl: { control: 'text', description: 'ResolveSpec API base URL' },
		authToken: { control: 'text', description: 'Bearer token for ResolveSpec authentication' },
		schema: { control: 'text', description: 'Database schema' },
		entity: { control: 'text', description: 'Entity / table name' },
		labelField: { control: 'text', description: 'Field to display as the label' },
		valueField: { control: 'text', description: 'Field to use as the selected value' },
	},
	args: {
		apiUrl: "https://utils.btsys.tech/api/v2",
		authToken: 'A684939F-BA4F-4CC1-9F5F-7050A2637171',
		schema: 'public',
		entity: "process",
		labelField: "process",
		valueField: "id_process",
		label: 'ResolveSpec Select',
		placeholder: 'Search...',
		searchable: true,
	},
	render: ({ apiUrl, authToken, schema, entity, labelField, valueField, ...rest }) => ({
		Component: Boxer,
		props: {
			...rest,
			adapter: new BoxerResolveSpecAdapter({
				baseUrl: apiUrl,
				token: authToken,
				schema,
				entity,
				labelField,
				valueField,
			}),
		},
	}),
};
```

#### With Header Spec (`WithHeaderSpec`)

```ts
export const WithHeaderSpec: Story = {
	argTypes: {
		apiUrl: { control: 'text', description: 'HeaderSpec API base URL' },
		authToken: { control: 'text', description: 'Bearer token for HeaderSpec authentication' },
		schema: { control: 'text', description: 'Database schema' },
		entity: { control: 'text', description: 'Entity / table name' },
		labelField: { control: 'text', description: 'Field to display as the label' },
		valueField: { control: 'text', description: 'Field to use as the selected value' },
	},
	args: {
		apiUrl: 'https://api.example.com',
		authToken: 'your-token-here',
		schema: 'public',
		entity: 'items',
		labelField: 'name',
		valueField: 'id',
		label: 'HeaderSpec Select',
		placeholder: 'Search...',
		searchable: true,
	},
	render: ({ apiUrl, authToken, schema, entity, labelField, valueField, ...rest }) => ({
		Component: Boxer,
		props: {
			...rest,
			adapter: new BoxerRestHeaderSpecAdapter({
				baseUrl: apiUrl,
				token: authToken,
				schema,
				entity,
				labelField,
				valueField,
			}),
		},
	}),
};
```

#### Lookup (`Lookup`)

```ts
/** BoxerLookup against a mocked ResolveSpec endpoint: search, select, and the preloaded value label. */
export const Lookup: Story = {
	args: { onChange: fn() },
	render: (args) => ({
		Component: BoxerLookup,
		props: {
			...args,
			baseUrl: 'https://lookup.example.com',
			schema: 'public',
			entity: 'people',
			idField: 'id',
			labelField: 'name',
			label: 'Person',
			placeholder: 'Search people...',
		},
	}),
	decorators: [
		(Story) => {
			const originalFetch = window.fetch;
			window.fetch = async (input, init) => {
				const url = typeof input === 'string' ? input : (input as Request).url;
				if (!url.includes('lookup.example.com')) return originalFetch(input, init);
				const options = JSON.parse((init?.body as string) ?? '{}').options ?? {};
				const filters = (options.filters ?? []) as { value: unknown }[];
				const term = String(filters[0]?.value ?? '').replace(/%/g, '').toLowerCase();
				const rows = lookupRows.filter((r) => r.name.toLowerCase().includes(term));
				return new Response(
					JSON.stringify({ success: true, data: rows, metadata: { total: rows.length, count: rows.length } }),
					{ status: 200, headers: { 'Content-Type': 'application/json' } },
				);
			};
			setTimeout(() => {
				window.fetch = originalFetch;
			}, 8000);
			return Story();
		},
	],
};
```

---

# Breadcrumbs

Import: `import { ... } from '@warky/svelix/Breadcrumbs'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as Breadcrumbs } from "./Breadcrumbs.svelte";
export { BREADCRUMBS_DEFAULTS } from "./breadcrumbsLayout.js";
export type * from "./types.js";
```

## Summary

Breadcrumb trail (port of Mantine Breadcrumbs, styled like the Skeleton recipe). `nav > ol`, separators `aria-hidden`, last item `aria-current="page"`. Defaults via `SvelixLayout` key `breadcrumbs`.

## Types (types.ts)

```ts
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import type { SvelixSpacing } from "../../utils/scale.js";

type NavAttributes = Omit<HTMLAttributes<HTMLElement>, "children" | "class" | "style">;

export interface BreadcrumbItem {
	label: string;
	/** Rendered as a link when set (except on the last item). */
	href?: string;
	[key: string]: unknown;
}

/** `SvelixLayout` key `breadcrumbs`. */
export interface BreadcrumbsLayoutConfig {
	/** @default "/" */
	separator?: string;
	/** @default "xs" */
	separatorMargin?: SvelixSpacing;
}

export interface BreadcrumbsProps<T extends BreadcrumbItem = BreadcrumbItem> extends NavAttributes {
	items: T[];
	/** String, or snippet receiving the index of the item before it. */
	separator?: string | Snippet<[number]>;
	separatorMargin?: SvelixSpacing;
	/** Custom item content. Default: link when `href` (not last), else text. */
	item?: Snippet<[T, number, boolean]>;
	/** `nav` label. Default i18n `svelix.breadcrumbs.label` → "Breadcrumb". */
	label?: string;
	class?: string;
	style?: string;
	itemClass?: string;
	separatorClass?: string;
	/** Link click; `preventDefault()` to route client-side. */
	onItemClick?: (item: T, index: number, event: MouseEvent) => void;
}
```

## Examples (from Storybook stories)

### Components/Breadcrumbs  (`Breadcrumbs.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, waitFor, within } from 'storybook/test';

import BreadcrumbsDemo from './BreadcrumbsDemo.fixtures.svelte';

/** Tailwind spacing units → px under the active theme's `--spacing`. */
function units(multiplier: number): number {
	const probe = document.createElement('div');
	probe.style.width = `calc(var(--spacing, 0.25rem) * ${multiplier})`;
	document.body.append(probe);
	const px = probe.getBoundingClientRect().width;
	probe.remove();
	return px;
}

const separators = (root: HTMLElement) => [...root.querySelectorAll<HTMLElement>('.svelix-breadcrumbs-separator')];
```

Helper component `./BreadcrumbsDemo.fixtures.svelte`:

```svelte
<script lang="ts">
	import { loadTranslations, clearTranslations } from "../GlobalStateStore/GlobalStateStore.i18n.js";
	import SvelixLayout from "../SvelixLayout/SvelixLayout.svelte";
	import Breadcrumbs from "./Breadcrumbs.svelte";
	import type { BreadcrumbItem, BreadcrumbsProps } from "./types.js";

	export interface Props extends Omit<BreadcrumbsProps, "items" | "item"> {
		demo?: "basic" | "snippets" | "layout" | "i18n";
		items?: BreadcrumbItem[];
	}

	const defaultItems: BreadcrumbItem[] = [
		{ label: "Home", href: "#home" },
		{ label: "Library", href: "#library" },
		{ label: "Data" }
	];

	let { demo = "basic", items = defaultItems, ...props }: Props = $props();
	let clicked = $state("");

	$effect(() => {
		if (demo !== "i18n") return;
		loadTranslations({ "svelix.breadcrumbs.label": { en: "Kruimelpad" } as never });
		return () => clearTranslations();
	});
</script>

{#if demo === "basic"}
	<Breadcrumbs
		{items}
		{...props}
		onItemClick={(entry, _index, event) => {
			event.preventDefault();
			clicked = entry.label;
		}}
	/>
	<p class="mt-2 text-xs" data-testid="clicked">{clicked}</p>
{:else if demo === "snippets"}
	<Breadcrumbs {items} {...props}>
		{#snippet separator()}<span data-testid="sep">›</span>{/snippet}
		{#snippet item(entry, index, last)}
			<button type="button" class="test-class" class:font-bold={last}>{index + 1}. {entry.label}</button>
		{/snippet}
	</Breadcrumbs>
{:else if demo === "layout"}
	<SvelixLayout breadcrumbs={{ separator: "→", separatorMargin: "lg" }}>
		<Breadcrumbs data-testid="layout" {items} />
		<Breadcrumbs data-testid="prop" separator="|" {items} />
	</SvelixLayout>
{:else}
	<Breadcrumbs {items} />
{/if}
```

#### Default (`Default`)

```ts
export const Default: Story = {
	args: { demo: 'basic' },
};
```

#### Custom Separator (`CustomSeparator`)

```ts
export const CustomSeparator: Story = {
	args: { demo: 'basic', separator: 'test-separator', separatorMargin: 'xl' },
};
```

#### Snippets (`Snippets`)

```ts
export const Snippets: Story = {
	args: { demo: 'snippets' },
};
```

#### Single Item (`SingleItem`)

```ts
export const SingleItem: Story = {
	args: { demo: 'basic', items: [{ label: 'Only', href: '#only' }] },
};
```

#### Layout (`Layout`)

```ts
export const Layout: Story = {
	args: { demo: 'layout' },
};
```

#### Translated (`Translated`)

```ts
export const Translated: Story = {
	args: { demo: 'i18n' },
};
```

---

# CardGrid

Import: `import { ... } from '@warky/svelix/CardGrid'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as CardGrid } from './CardGrid.svelte';
export { default as DefaultCard } from './DefaultCard.svelte';
export { default as CardGridFilterPanel } from './CardGridFilterPanel.svelte';
export * from './cardGrid.js';
export { CARD_GRID_DEFAULTS, CARD_GRID_LABELS, cardGridFromShared } from './cardGridLayout.js';
export type { CardGridLayoutConfig } from './cardGridLayout.js';
```

## Props

### CardGrid.svelte

```ts
export interface Props {
		// Column / display
		columns: CardGridColumn[];
		/** Convention-based FK label mapping for columns without an explicit `lookup` (return undefined for none). */
		resolveLookup?: ColumnLookupResolver;
		/** Column defaults by id pattern; explicit column fields win. Falls back to the `SvelixLayout` `columnPresets`. */
		columnPresets?: ColumnPreset<CardGridColumn>[];
		displayColumns?: string[];
		cardMinWidth?: number;
		cardGap?: number;
		/**
		 * Maximum number of columns shown on the front face.
		 * When `resolvedColumns.length` exceeds this, a flip button appears and the
		 * remaining columns are shown on the card's back face.
		 */
		cardMaxFrontColumns?: number;

		// Custom rendering
		/** Front-face snippet: (record, index, selected, focused) */
		card?: Snippet<[Record<string, unknown>, number, boolean, boolean]>;
		/** Back-face snippet for flip mode: (record, index, selected, focused) */
		cardBack?: Snippet<[Record<string, unknown>, number, boolean, boolean]>;
		empty?: Snippet;
		skeleton?: Snippet;
		/**
		 * Extra CSS class(es) for a card's shell, per record, e.g. to colour cards by status.
		 * Card-shell CSS variables (set on an ancestor or via `class`): `--cg-card-radius` (10px),
		 * `--cg-card-border-width` (1.5px), `--cg-card-padding-x` (16px), `--cg-card-padding-y` (14px),
		 * `--cg-card-hover-lift` (0px; translateY on hover, e.g. -4px),
		 * `--cg-flip-duration` (450ms), `--cg-flip-easing`, `--cg-flip-back-bg` (flip cards),
		 * `--cg-card-animation` / `--cg-card-animation-up` (entry animation shorthand, normal / when scrolling up; needs `animate`, keyframes must be global),
		 * `--cg-grid-inset` (4px; grid padding, raise it so large card shadows are not clipped).
		 */
		cardClass?: (record: Record<string, unknown>, index: number, selected: boolean, focused: boolean) => string | undefined;
		/** Inline style for a card's shell, per record, e.g. `border-left-color: red`. */
		cardStyle?: (record: Record<string, unknown>, index: number, selected: boolean, focused: boolean) => string | undefined;

		// Data source
		dataSource?: 'resolvespec' | 'headerspec' | 'local';
		dataSourceOptions?: {
			url?: string;
			authToken?: string;
			schema?: string;
			entity?: string;
			uniqueID?: string;
			hotfields?: string[];
			extraOptions?: Record<string, unknown>;
		};
		data?: Record<string, unknown>[] | (() => Promise<Record<string, unknown>[]>);
		pageSize?: number;
		uniqueID?: string;

		// Search
		searchValue?: string;
		onSearchValueChange?: (v: string) => void;
		serverSideSearch?: boolean;
		searchColumns?: string[];
		searchPlaceholder?: string;

		// Sort
		sortOrder?: GridColumnSortOrder;
		onSortOrderChange?: (order: GridColumnSortOrder) => void;
		sortOptions?: CardSortOption[];
		/**
		 * `false` (default): sorting is a single "Sort" dropdown button.
		 * `true`: one pill button per sort option; they collapse into the dropdown when the toolbar is narrow.
		 */
		showSortButtons?: boolean;

		// Filters
		filters?: GridColumnFilters;
		onFilterChange?: (filters: GridColumnFilters) => void;
		/** Remembers the user's sort and filters under this name (IndexedDB, falling back to localStorage) and restores them on mount. Use a unique name per grid. */
		persistenceName?: string;

		// Selection
		selectedItems?: Record<string, unknown>[];
		onSelectedItemsChange?: (items: Record<string, unknown>[]) => void;
		multiSelect?: boolean;

		// Card events
		onCardClick?: (record: Record<string, unknown>, index: number) => void;
		onCardDblClick?: (record: Record<string, unknown>, index: number) => void;
		onCardContextMenu?: (
			record: Record<string, unknown>,
			index: number,
			x: number,
			y: number,
		) => void;

		// Context menu
		menuItems?: CardGridContextMenuItem[];
		/**
		 * Per-item overrides (icon, label, disabled, etc.) for the built-in context-menu
		 * entries (Refresh, Export, Clear Search, Clear Filters, Clear Sort). Each override is
		 * merged onto that item's default, so any `BetterMenuInstanceItem` field can be overridden.
		 */
		buttonMenuProps?: {
			refresh?: Partial<BetterMenuInstanceItem>;
			export?: Partial<BetterMenuInstanceItem>;
			clearSearch?: Partial<BetterMenuInstanceItem>;
			clearFilters?: Partial<BetterMenuInstanceItem>;
			clearSort?: Partial<BetterMenuInstanceItem>;
		};

		// Export
		/** Adds "Export…" to the context menu: exports all rows matching the current filters/sort/search. */
		exportable?: boolean;
		exportOptions?: GridExportOptions;

		// Status (bindable)
		total?: number;
		loading?: boolean;
		onLoadError?: (error: string) => void;

		// Layout
		class?: string;
		/** Rendered at the very start of the toolbar, before the title. */
		toolbarStart?: Snippet;
		/** Rendered in the toolbar right after the left content (title and inline search), before the right-pinned controls. */
		toolbarCenter?: Snippet;
		/**
		 * Replaces the toolbar's content. Receives the default toolbar (toolbarStart, title, search,
		 * toolbarCenter, controls, toolbarEnd) as a snippet to render, wrap or place among your own content.
		 */
		toolbarContent?: Snippet<[Snippet]>;
		toolbarEnd?: Snippet;
		/** Full-width row rendered below the toolbar, above the cards (e.g. a description or summary). */
		toolbarBelow?: Snippet;
		/** Full-width row rendered below the cards (e.g. totals or pagination info). */
		gridFooter?: Snippet;
		/** Side panel to the left of the cards, below the toolbar. The cards shrink to fit. */
		leftContent?: Snippet;
		/** Side panel to the right of the cards, below the toolbar. The cards shrink to fit. */
		rightContent?: Snippet;
		/**
		 * Grid title at the left of the toolbar, before the search.
		 * Text never hides: it shrinks and trims with an ellipsis. A snippet hides when the toolbar is very narrow.
		 */
		title?: string | Snippet;
		/**
		 * `inline` (default): always-visible search input. `popover`: a search button opens the input
		 * below the toolbar (Ctrl+F also opens it). A too-narrow toolbar switches to `popover` automatically.
		 */
		searchMode?: 'inline' | 'popover';
		/** Label on the popover search button. Defaults to "Search"; set `''` for an icon-only button. */
		searchButtonTitle?: string;
		/** Ctrl/Cmd+F in the grid opens/focuses the search. Defaults to true; `false` leaves it to the browser. */
		searchShortcut?: boolean;
		animate?: boolean;
		density?: 'compact' | 'default' | 'comfortable' | 'spacious';
		expand?: 'none' | 'medium' | 'large';
	}
```

### CardGridFilterPanel.svelte

```ts
interface Props {
		columns: CardGridColumn[];
		/** Server flavour; operators the server cannot evaluate are hidden. Omit for local filtering. */
		mode?: FilterServerMode;
		filters: GridColumnFilters;
		onApply: (filters: GridColumnFilters) => void;
		onCancel: () => void;
	}
```

### DefaultCard.svelte

```ts
interface Props {
		record: Record<string, unknown>;
		columns: CardGridColumn[];
		selected: boolean;
		focused: boolean;
		expanded?: boolean;
	}
```

## Examples (from Storybook stories)

### Components/CardGrid  (`CardGrid.stories.ts`)

Shared setup:

```ts
import { fn } from "storybook/test";

import CardGrid from "./CardGrid.svelte";

import ImageCardStory from "./ImageCardStory.svelte";

import CardGridWithFormerPreview from "./CardGridWithFormerPreview.svelte";

import AllSnippetsStory from "./AllSnippetsStory.svelte";

import ToolbarBelowHost from "./ToolbarBelowHost.svelte";

import CardGridNarrowPreview from "./CardGridNarrowPreview.svelte";

import CardStyleStory from "./CardStyleStory.svelte";

import ModernCardStory from "./ModernCardStory.svelte";

import WackyCardStory from "./WackyCardStory.svelte";

// Give the story root a bounded height so the virtualizer has a scroll container
const heightDecorator = (Story) => {
  const root = document.getElementById('storybook-root');
  if (root) root.style.cssText = 'height:calc(100vh - 40px);display:flex;flex-direction:column;';
  document.body.style.margin = '0';
  return Story();
};

// ── Shared fixtures ──────────────────────────────────────────────────────────

const columns = [
  { id: "id",         title: "ID",         disableSearch: true },
  { id: "name",       title: "Name" },
  { id: "email",      title: "Email" },
  { id: "department", title: "Department" },
  { id: "salary",     title: "Salary",     format: "currency" as const },
  { id: "status",     title: "Status" },
];

const sortOptions = [
  { columnId: "name",       label: "Name" },
  { columnId: "department", label: "Department" },
  { columnId: "salary",     label: "Salary" },
];

const DEPARTMENTS = ["Engineering", "Marketing", "Sales", "HR", "Finance", "Operations"];

const STATUSES    = ["Active", "Pending", "Inactive"];

const FIRST       = ["Alice", "Bob", "Carol", "David", "Eve", "Frank", "Grace", "Henry"];

const LAST        = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia"];

function makeRow(i: number): Record<string, unknown> {
  const first = FIRST[i % FIRST.length];
  const last  = LAST[Math.floor(i / FIRST.length) % LAST.length];
  return {
    id:         i + 1,
    name:       `${first} ${last}`,
    email:      `${first.toLowerCase()}.${last.toLowerCase()}@example.com`,
    department: DEPARTMENTS[i % DEPARTMENTS.length],
    salary:     40000 + (i % 60) * 1000,
    status:     STATUSES[i % STATUSES.length],
  };
}

const TOTAL_ROWS = 500;

const ALL_ROWS   = Array.from({ length: TOTAL_ROWS }, (_, i) => makeRow(i));

// ── Mock fetch factory ────────────────────────────────────────────────────────

/**
 * Mock-server filter: applies ResolveSpec `options.filters` to rows. Groups like the
 * server: an AND filter starts a group, following OR filters join it; groups are AND-ed.
 * Supports eq, neq, gt, gte, lt, lte, ilike (% wildcards), in.
 */
function mockFilter(rows, filters) {
  if (!Array.isArray(filters) || filters.length === 0) return rows;
  const groups = [];
  for (const f of filters) {
    if (f.logic_operator === "OR" && groups.length) groups[groups.length - 1].push(f);
    else groups.push([f]);
  }
  const read = (row, path) => String(path).split(".").reduce((v, k) => (v == null ? v : v[k]), row);
  const cmp = (a, b) => {
    const na = Number(a);
    const nb = Number(b);
    if (a !== "" && b !== "" && a != null && b != null && !isNaN(na) && !isNaN(nb)) return na - nb;
    return String(a ?? "").toLowerCase().localeCompare(String(b ?? "").toLowerCase(), undefined, { numeric: true });
  };
  const test = (row, f) => {
    const v = read(row, f.column);
    switch (f.operator) {
      case "eq":  return cmp(v, f.value) === 0;
      case "neq": return cmp(v, f.value) !== 0;
      case "gt":  return cmp(v, f.value) > 0;
      case "gte": return cmp(v, f.value) >= 0;
      case "lt":  return cmp(v, f.value) < 0;
      case "lte": return cmp(v, f.value) <= 0;
      case "in":  return (Array.isArray(f.value) ? f.value : [f.value]).some((x) => cmp(v, x) === 0);
      case "ilike": {
        const re = String(f.value).split("%").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
        return new RegExp(`^${re}$`, "i").test(String(v ?? ""));
      }
      default: return true;
    }
  };
  return rows.filter((row) => groups.every((g) => g.some((f) => test(row, f))));
}

/** Mock-server sort: applies ResolveSpec `options.sort` ({ column, direction }[]) to rows. */
function mockSort(rows, sort) {
  if (!Array.isArray(sort) || sort.length === 0) return rows;
  return [...rows].sort((a, b) => {
    for (const { column, direction } of sort) {
      const av = a[column];
      const bv = b[column];
      const cmp = typeof av === "number" && typeof bv === "number"
        ? av - bv
        : String(av ?? "").localeCompare(String(bv ?? ""), undefined, { numeric: true });
      if (cmp !== 0) return String(direction).toLowerCase() === "desc" ? -cmp : cmp;
    }
    return 0;
  });
}

// Captured once at module-load time — before any story decorator can replace it.
// All mocks delegate non-matching requests here so they never chain into each other.
const _realFetch = window.fetch;

function mockResolveFetch(
  rows: typeof ALL_ROWS = ALL_ROWS,
  overrideFn?: (opts: Record<string, unknown>, page: typeof ALL_ROWS) => typeof ALL_ROWS,
) {
  return (Story) => {
    window.fetch = async (input, init) => {
      const url = typeof input === "string" ? input : (input as Request).url;
      if (!url.includes("example.com")) return _realFetch(input, init);

      let body: Record<string, unknown> = {};
      try { body = JSON.parse((init?.body as string) ?? "{}"); } catch { /* */ }

      const opts      = (body.options as Record<string, unknown>) ?? {};
      const limit     = Number(opts.limit ?? 20);
      const cursor    = opts.cursor_forward ? Number(opts.cursor_forward) : 0;
      let filtered = mockFilter(rows, opts.filters);
      filtered = mockSort(filtered, opts.sort);
      if (overrideFn) filtered = overrideFn(opts, filtered);

      const startIdx = cursor === 0 ? 0 : filtered.findIndex((r) => r.id === cursor) + 1;
      const page     = startIdx < 0 ? [] : filtered.slice(startIdx, startIdx + limit);

      return new Response(
        JSON.stringify({
          success:  true,
          data:     page,
          metadata: { total: filtered.length, count: page.length, limit, offset: startIdx },
        }),
        { status: 200, headers: { "Content-Type": "application/json" } },
      );
    };
    return Story();
  };
}

const liveArgTypes = {
  apiUrl:    { control: { type: "text" }, description: "ResolveSpec API base URL" },
  authToken: { control: { type: "text" }, description: "Bearer token" },
  schema:    { control: { type: "text" }, description: "Database schema" },
  entity:    { control: { type: "text" }, description: "Entity / table name" },
  uniqueID:  { control: { type: "text" }, description: "Unique row field for cursor paging" },
};

// ── Flip mode ────────────────────────────────────────────────────────────────

const manyColumns = [
  { id: "id",         title: "ID",         disableSearch: true },
  { id: "name",       title: "Name" },
  { id: "email",      title: "Email" },
  { id: "department", title: "Department" },
  { id: "salary",     title: "Salary" },
  { id: "status",     title: "Status" },
  { id: "phone",      title: "Phone" },
  { id: "location",   title: "Location" },
  { id: "manager",    title: "Manager" },
  { id: "hired",      title: "Hired" },
];

const LOCATIONS  = ["Cape Town", "Johannesburg", "Durban", "Pretoria", "Port Elizabeth"];

const PHONES     = ["+27 21 555 0100", "+27 11 555 0200", "+27 31 555 0300"];

function makeBigRow(i: number): Record<string, unknown> {
  const base   = makeRow(i);
  const mgr    = makeRow((i + 3) % 8);
  const hired  = new Date(2018 + (i % 6), i % 12, (i % 28) + 1);
  return {
    ...base,
    phone:    PHONES[i % PHONES.length],
    location: LOCATIONS[i % LOCATIONS.length],
    manager:  mgr.name,
    hired:    hired.toLocaleDateString(),
  };
}

// ── Million rows ─────────────────────────────────────────────────────────────

/**
 * 100 000 rows rendered via the virtualizer — only the visible cards are ever
 * in the DOM at any time. Scroll freely, sort, and search to verify performance.
 * Data is generated lazily when the story first opens (not at module-load time).
 */
let bigRows: ReturnType<typeof makeRow>[] | null = null;

function getBigRows() {
  if (!bigRows) bigRows = Array.from({ length: 100_000 }, (_, i) => makeRow(i));
  return bigRows;
}
```

Helper component `./ImageCardStory.svelte`:

```svelte
<script lang="ts">
	import CardGrid from './CardGrid.svelte';
	import type { CardGridColumn, CardSortOption } from './cardGrid.js';

	const columns: CardGridColumn[] = [
		{ id: 'id',           title: 'ID',           disableSearch: true, disableFilter: true },
		{ id: 'title',        title: 'Title' },
		{ id: 'photographer', title: 'Photographer' },
		{ id: 'category',     title: 'Category' },
		{ id: 'likes',        title: 'Likes',        format: 'number' },
		{ id: 'views',        title: 'Views',        format: 'number' },
	];

	const sortOptions: CardSortOption[] = [
		{ columnId: 'title',        label: 'Title' },
		{ columnId: 'photographer', label: 'Photographer' },
		{ columnId: 'likes',        label: 'Likes' },
	];

	const CATEGORIES    = ['Architecture', 'Nature', 'People', 'Travel', 'Abstract', 'Animals'];
	const PHOTOGRAPHERS = ['Alice Monroe', 'Ben Carter', 'Cara Lee', 'Daniel Park', 'Emma Rios', 'Felix Braun'];
	const TITLES = [
		'Golden Hour', 'Morning Mist', 'Urban Jungle', 'Still Waters', 'First Light',
		'Hidden Path', 'City Pulse', 'Wild Plains', 'Quiet Corner', 'Storm Front',
		'Desert Bloom', 'Arctic Echo', 'Neon Drift', 'Forest Floor', 'Tidal Wave',
	];

	const data = Array.from({ length: 60 }, (_, i) => ({
		id:           i + 1,
		title:        `${TITLES[i % TITLES.length]} ${Math.floor(i / TITLES.length) || ''}`.trim(),
		photographer: PHOTOGRAPHERS[i % PHOTOGRAPHERS.length],
		category:     CATEGORIES[i % CATEGORIES.length],
		likes:        Math.floor(Math.random() * 4800 + 200),
		views:        Math.floor(Math.random() * 48000 + 2000),
		imageUrl:     `https://picsum.photos/seed/${i + 1}/400/260`,
	}));
</script>

<!-- cardMaxFrontColumns=3 splits: front=[id,title,photographer], back=[category,likes,views] -->
<CardGrid
	{columns}
	{sortOptions}
	{data}
	cardMinWidth={260}
	cardMaxFrontColumns={3}
	pageSize={20}
	searchPlaceholder="Search photos…"
>
	{#snippet card(record, _i, selected, _focused)}
		<article class="ic-card" class:ic-selected={selected}>
			<div class="ic-image-wrap">
				<img
					src={record.imageUrl as string}
					alt={record.title as string}
					class="ic-image"
					loading="lazy"
				/>
				<span class="ic-category">{record.category as string}</span>
			</div>
			<div class="ic-body">
				<p class="ic-title">{record.title as string}</p>
				<p class="ic-photographer">by {record.photographer as string}</p>
			</div>
		</article>
	{/snippet}

	{#snippet cardBack(record, _i, selected, _focused)}
		<article class="ic-card ic-back" class:ic-selected={selected}>
			<div class="ic-back-image-wrap">
				<img
					src={record.imageUrl as string}
					alt={record.title as string}
					class="ic-back-image"
					loading="lazy"
				/>
				<div class="ic-back-overlay">
					<p class="ic-back-title">{record.title as string}</p>
					<p class="ic-back-photographer">by {record.photographer as string}</p>
				</div>
			</div>
			<div class="ic-body ic-back-body">
				<div class="ic-back-stat">
					<svg width="14" height="14" viewBox="0 0 20 20" fill="none" aria-hidden="true">
						<path d="M3 7h14M3 10h10M3 13h6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
					</svg>
					<span class="ic-back-label">Category</span>
					<span class="ic-back-value">{record.category as string}</span>
				</div>
				<div class="ic-back-stat">
					<svg width="14" height="14" viewBox="0 0 20 20" fill="none" aria-hidden="true">
						<path d="M10 4C5 4 1 10 1 10s4 6 9 6 9-6 9-6-4-6-9-6Z" stroke="currentColor" stroke-width="1.6"/>
						<circle cx="10" cy="10" r="3" stroke="currentColor" stroke-width="1.6"/>
					</svg>
					<span class="ic-back-label">Views</span>
					<span class="ic-back-value">{(record.views as number).toLocaleString()}</span>
				</div>
				<div class="ic-back-stat">
					<svg width="14" height="14" viewBox="0 0 20 20" fill="none" aria-hidden="true">
						<path d="M10 17s-8-5-8-10a5 5 0 0 1 8-4 5 5 0 0 1 8 4c0 5-8 10-8 10Z" stroke="currentColor" stroke-width="1.6"/>
					</svg>
					<span class="ic-back-label">Likes</span>
					<span class="ic-back-value">{(record.likes as number).toLocaleString()}</span>
				</div>
			</div>
		</article>
	{/snippet}
</CardGrid>

<style>
	/* ── Shared ───────────────────────────────────────────────────────────── */

	.ic-card {
		display: flex;
		flex-direction: column;
		height: 100%;
		overflow: hidden;
		border-radius: 10px;
	}

	.ic-body {
		padding: 12px 14px 14px;
		display: flex;
		flex-direction: column;
		gap: 3px;
		flex: 1;
	}

	/* ── Front face ───────────────────────────────────────────────────────── */

	.ic-image-wrap {
		position: relative;
		overflow: hidden;
		aspect-ratio: 16 / 10;
		background: var(--cg-border-subtle, #f4f6f9);
		flex-shrink: 0;
	}

	.ic-image {
		width: 100%;
		height: 100%;
		object-fit: cover;
		display: block;
		transition: transform 300ms ease;
	}

	.ic-card:hover .ic-image {
		transform: scale(1.04);
	}

	.ic-category {
		position: absolute;
		top: 10px;
		left: 10px;
		background: rgba(0, 0, 0, 0.48);
		color: #fff;
		font-size: 0.7rem;
		font-weight: 600;
		letter-spacing: 0.04em;
		text-transform: uppercase;
		padding: 3px 8px;
		border-radius: 999px;
		backdrop-filter: blur(4px);
	}

	.ic-title {
		font-size: 0.9rem;
		font-weight: 600;
		color: var(--cg-text, #0f172a);
		margin: 0;
		white-space: nowrap;
		overflow: hidden;
		text-overflow: ellipsis;
	}

	.ic-photographer {
		font-size: 0.78rem;
		color: var(--cg-text-muted, #64748b);
		margin: 0;
	}

	.ic-selected .ic-image-wrap::after {
		content: '';
		position: absolute;
		inset: 0;
		box-shadow: inset 0 0 0 3px var(--cg-accent, #6366f1);
	}

	/* ── Back face ────────────────────────────────────────────────────────── */

	.ic-back-image-wrap {
		position: relative;
		overflow: hidden;
		aspect-ratio: 16 / 10;
		flex-shrink: 0;
	}

	.ic-back-image {
		width: 100%;
		height: 100%;
		object-fit: cover;
		display: block;
		filter: brightness(0.45) saturate(0.7);
	}

	.ic-back-overlay {
		position: absolute;
		inset: 0;
		display: flex;
		flex-direction: column;
		justify-content: flex-end;
		padding: 12px 14px;
	}

	.ic-back-title {
		font-size: 0.95rem;
		font-weight: 700;
		color: #fff;
		margin: 0;
		line-height: 1.3;
	}

	.ic-back-photographer {
		font-size: 0.75rem;
		color: rgba(255,255,255,.75);
		margin: 0;
	}

	.ic-back-body {
		gap: 10px;
		justify-content: center;
	}

	.ic-back-stat {
		display: flex;
		align-items: center;
		gap: 8px;
		color: var(--cg-text-muted, #64748b);
	}

	.ic-back-label {
		font-size: 0.78rem;
		flex: 1;
	}

	.ic-back-value {
		font-size: 0.82rem;
		font-weight: 600;
		color: var(--cg-text, #0f172a);
	}

	.ic-selected .ic-back-image-wrap::after {
		content: '';
		position: absolute;
		inset: 0;
		box-shadow: inset 0 0 0 3px var(--cg-accent, #6366f1);
	}
</style>
```

Helper component `./CardGridWithFormerPreview.svelte`:

```svelte
<script lang="ts">
  import CardGrid from "./CardGrid.svelte";
  import type { CardGridColumn, CardGridContextMenuItem } from "./cardGrid.js";
  import FormerDrawer from "#lib/components/Former/FormerDrawer.svelte";
  import TextInputCtrl from "#lib/components/FormerControllers/TextInputCtrl.svelte";
  import NumberInputCtrl from "#lib/components/FormerControllers/NumberInputCtrl.svelte";
  import NativeSelectCtrl from "#lib/components/FormerControllers/NativeSelectCtrl.svelte";
  import type { FormRequestType } from "#lib/components/Former/types.js";
  import {
    iconAddSvg,
    iconEditSvg,
    iconTrashSvg,
  } from "#lib/components/Icons/strings.js";

  interface EmployeeRow {
    id: number;
    name: string;
    department: string;
    salary: number;
    status: string;
  }

  const DEPARTMENTS = [
    "Engineering",
    "Marketing",
    "Sales",
    "HR",
    "Finance",
    "Operations",
  ];
  const STATUSES = ["Active", "Pending", "Inactive"];
  const NAMES = [
    "Alice Smith",
    "Bob Jones",
    "Carol Brown",
    "David Garcia",
    "Eve Johnson",
    "Frank Williams",
  ];

  const columns: CardGridColumn[] = [
    { id: "name", title: "Name" },
    { id: "department", title: "Department" },
    { id: "salary", title: "Salary", format: "currency" },
    { id: "status", title: "Status" },
  ];

  const departmentOptions = DEPARTMENTS.map((d) => ({ label: d, value: d }));
  const statusOptions = STATUSES.map((s) => ({ label: s, value: s }));

  let nextId = 25;
  let rows = $state<Record<string, unknown>[]>(
    Array.from({ length: 24 }, (_, i) => ({
      id: i + 1,
      name: NAMES[i % NAMES.length],
      department: DEPARTMENTS[i % DEPARTMENTS.length],
      salary: 40000 + i * 1500,
      status: STATUSES[i % STATUSES.length],
    })),
  );

  let drawerOpen = $state(false);
  let formMode = $state<FormRequestType>("update");
  let formValues = $state<EmployeeRow | undefined>(undefined);
  let lastSaved = $state<EmployeeRow | undefined>(undefined);

  function openForm(mode: FormRequestType, row?: EmployeeRow) {
    formMode = mode;
    formValues = row
      ? { ...row }
      : {
          id: 0,
          name: "",
          department: "Engineering",
          salary: 50000,
          status: "Active",
        };
    drawerOpen = true;
  }

  const drawerTitle = $derived(
    formMode === "insert"
      ? "Add Employee"
      : formMode === "delete"
        ? "Delete Employee"
        : "Edit Employee",
  );

  const menuItems: CardGridContextMenuItem[] = [
    {
      id: "add",
      label: "Add",
      icon: iconAddSvg,
      onselect: () => openForm("insert"),
    },
    { id: "sep", label: "", kind: "separator", requiresRow: true },
    {
      id: "edit",
      requiresRow: true,
      label: "Edit",
      icon: iconEditSvg,
      onselect: (row) => row && openForm("update", row as unknown as EmployeeRow),
    },
    {
      id: "delete",
      requiresRow: true,
      label: "Delete",
      icon: iconTrashSvg,
      onselect: (row) => row && openForm("delete", row as unknown as EmployeeRow),
    },
  ];
</script>

<div
  style="height:100vh;display:flex;flex-direction:column;gap:8px;padding:16px"
>
  <p class="text-sm">
    Double-click a card to edit · Right-click a card for Add / Edit / Delete ·
    ＋ Add in the toolbar
  </p>

  <div style="flex:1;min-height:0;display:flex;flex-direction:column">
    <CardGrid
      {columns}
      data={rows}
      uniqueID="id"
      title="Employees"
      {menuItems}
      onCardDblClick={(row) =>
        openForm("update", row as unknown as EmployeeRow)}
    >
      {#snippet toolbarEnd()}
        <button class="cg-btn" type="button" onclick={() => openForm("insert")}
          >＋ Add</button
        >
      {/snippet}
    </CardGrid>
  </div>

  <FormerDrawer
    bind:opened={drawerOpen}
    bind:values={formValues}
    request={formMode}
    uniqueKeyField="id"
    layout={{ title: drawerTitle, buttonArea: "bottom" }}
    onAPICall={async (_mode, _req, data) => {
      await new Promise((r) => setTimeout(r, 400));
      return data as EmployeeRow;
    }}
    afterSave={(data) => {
      const saved = data as EmployeeRow;
      lastSaved = saved;
      if (formMode === "insert") {
        saved.id = nextId++;
        rows = [...rows, saved as unknown as Record<string, unknown>];
      } else if (formMode === "delete") {
        rows = rows.filter((r) => r["id"] !== saved.id);
      } else {
        const rec = saved as unknown as Record<string, unknown>;
        rows = rows.map((r) => (r["id"] === saved.id ? { ...rec } : r));
      }
    }}
    onClose={() => {
      drawerOpen = false;
    }}
  >
    {#snippet children(state)}
      {@const isReadonly = state.request === "delete"}
      <div class="p-6 space-y-4">
        <TextInputCtrl
          label="Name"
          name="name"
          value={state.values?.name ?? ""}
          required
          disabled={isReadonly}
          onchange={(v) =>
            state.setState("values", { ...state.values, name: v })}
        />
        <div class="grid grid-cols-2 gap-4">
          <NativeSelectCtrl
            label="Department"
            name="department"
            value={state.values?.department ?? ""}
            options={departmentOptions}
            disabled={isReadonly}
            onchange={(v) =>
              state.setState("values", { ...state.values, department: v })}
          />
          <NativeSelectCtrl
            label="Status"
            name="status"
            value={state.values?.status ?? ""}
            options={statusOptions}
            disabled={isReadonly}
            onchange={(v) =>
              state.setState("values", { ...state.values, status: v })}
          />
        </div>
        <NumberInputCtrl
          label="Salary"
          name="salary"
          value={state.values?.salary}
          min={0}
          disabled={isReadonly}
          onchange={(v) =>
            state.setState("values", { ...state.values, salary: v })}
        />
      </div>
    {/snippet}
  </FormerDrawer>

  {#if lastSaved}
    <pre class="text-xs">Last saved: {JSON.stringify(lastSaved)}</pre>
  {/if}
</div>
```

Helper component `./AllSnippetsStory.svelte`:

```svelte
<script lang="ts">
	import type { Snippet } from 'svelte';
	import CardGrid from './CardGrid.svelte';
	import type { CardGridColumn } from './cardGrid.js';

	const columns: CardGridColumn[] = [
		{ id: 'name', title: 'Name' },
		{ id: 'department', title: 'Department' },
		{ id: 'salary', title: 'Salary', format: 'currency' },
		{ id: 'status', title: 'Status' },
	];

	const NAMES = ['Alice Smith', 'Bob Jones', 'Carol Brown', 'David Garcia', 'Eve Johnson', 'Frank Williams'];
	const rows = Array.from({ length: 24 }, (_, i) => ({
		id: i + 1,
		name: NAMES[i % NAMES.length],
		department: ['Engineering', 'Sales', 'HR'][i % 3],
		salary: 40000 + i * 1500,
		status: ['Active', 'Pending', 'Inactive'][i % 3],
	}));

	let showTitle = $state(true);
	let showToolbarStart = $state(true);
	let showGridFooter = $state(true);
	let showToolbarCenter = $state(true);
	let showToolbarContent = $state(false);
	let showToolbarEnd = $state(true);
	let showToolbarBelow = $state(true);
	let showLeft = $state(true);
	let showRight = $state(true);
	let showCard = $state(true);
	let showCardBack = $state(true);
	let showEmpty = $state(false);
	let noData = $state(false);

	const data = $derived(noData ? [] : rows);
</script>

{#snippet titleSnippet()}
	<strong>🧩 Title snippet</strong>
{/snippet}

{#snippet toolbarStartSnippet()}
	<button class="cg-btn" type="button">toolbarStart</button>
{/snippet}

{#snippet gridFooterSnippet()}
	<span class="text-sm opacity-70">gridFooter: totals, notes or pagination info below the cards.</span>
{/snippet}

{#snippet toolbarCenterSnippet()}
	<button class="cg-btn" type="button">toolbarCenter</button>
{/snippet}

{#snippet toolbarContentSnippet(defaultToolbar: Snippet)}
	<div class="as-wrap">
		<span class="text-sm opacity-70">toolbarContent wrapper →</span>
		{@render defaultToolbar()}
	</div>
{/snippet}

{#snippet toolbarEndSnippet()}
	<button class="cg-btn" type="button">toolbarEnd</button>
{/snippet}

{#snippet toolbarBelowSnippet()}
	<span class="text-sm opacity-70">toolbarBelow: a full-width row for a description, summary or notices.</span>
{/snippet}

{#snippet leftSnippet()}
	<div style="width:120px;padding:8px" class="text-sm opacity-70">leftContent: a side panel.</div>
{/snippet}

{#snippet rightSnippet()}
	<div style="width:120px;padding:8px" class="text-sm opacity-70">rightContent: a side panel.</div>
{/snippet}

{#snippet cardSnippet(record: Record<string, unknown>, _index: number, selected: boolean)}
	<div class="as-card" class:as-selected={selected}>
		<strong>{record.name as string}</strong>
		<span>{record.department as string} · {record.status as string}</span>
	</div>
{/snippet}

{#snippet cardBackSnippet(record: Record<string, unknown>)}
	<div class="as-card">
		<strong>{record.name as string}</strong>
		<span>Salary: {record.salary as number}</span>
	</div>
{/snippet}

{#snippet emptySnippet()}
	<p>🫥 empty snippet: nothing to show</p>
{/snippet}

<div style="height:100vh;display:flex;flex-direction:column;gap:8px;padding:16px">
	<div class="as-toggles">
		<label><input type="checkbox" bind:checked={showTitle} /> title</label>
		<label><input type="checkbox" bind:checked={showToolbarStart} /> toolbarStart</label>
		<label><input type="checkbox" bind:checked={showGridFooter} /> gridFooter</label>
		<label><input type="checkbox" bind:checked={showToolbarCenter} /> toolbarCenter</label>
		<label><input type="checkbox" bind:checked={showToolbarContent} /> toolbarContent (wraps default toolbar)</label>
		<label><input type="checkbox" bind:checked={showToolbarEnd} /> toolbarEnd</label>
		<label><input type="checkbox" bind:checked={showToolbarBelow} /> toolbarBelow</label>
		<label><input type="checkbox" bind:checked={showLeft} /> leftContent</label>
		<label><input type="checkbox" bind:checked={showRight} /> rightContent</label>
		<label><input type="checkbox" bind:checked={showCard} /> card</label>
		<label><input type="checkbox" bind:checked={showCardBack} /> cardBack</label>
		<label><input type="checkbox" bind:checked={showEmpty} /> empty</label>
		<label><input type="checkbox" bind:checked={noData} /> no data</label>
	</div>

	<div style="flex:1;min-height:0;display:flex;flex-direction:column">
		<CardGrid
			{columns}
			{data}
			uniqueID="id"
			title={showTitle ? titleSnippet : 'Title (string)'}
			toolbarStart={showToolbarStart ? toolbarStartSnippet : undefined}
			gridFooter={showGridFooter ? gridFooterSnippet : undefined}
			toolbarCenter={showToolbarCenter ? toolbarCenterSnippet : undefined}
			toolbarContent={showToolbarContent ? toolbarContentSnippet : undefined}
			toolbarEnd={showToolbarEnd ? toolbarEndSnippet : undefined}
			toolbarBelow={showToolbarBelow ? toolbarBelowSnippet : undefined}
			leftContent={showLeft ? leftSnippet : undefined}
			rightContent={showRight ? rightSnippet : undefined}
			card={showCard ? cardSnippet : undefined}
			cardBack={showCardBack ? cardBackSnippet : undefined}
			empty={showEmpty ? emptySnippet : undefined}
			cardMaxFrontColumns={2}
		/>
	</div>
</div>

<style>
	.as-toggles {
		display: flex;
		flex-wrap: wrap;
		gap: 12px;
		font-size: 0.85rem;
	}

	.as-wrap {
		display: flex;
		align-items: center;
		gap: 6px;
		width: 100%;
		border: 1px dashed currentColor;
		padding: 2px 6px;
	}

	.as-card {
		display: flex;
		flex-direction: column;
		gap: 4px;
		padding: 8px;
	}

	.as-selected {
		outline: 2px solid currentColor;
	}
</style>
```

Helper component `./ToolbarBelowHost.svelte`:

```svelte
<script lang="ts">
	import CardGrid from './CardGrid.svelte';
	import type { ComponentProps } from 'svelte';

	const props: ComponentProps<typeof CardGrid> = $props();
</script>

<CardGrid {...props}>
	{#snippet toolbarBelow()}
		<span class="text-sm opacity-70">Below toolbar: a full-width row for a description, summary or notices.</span>
	{/snippet}
</CardGrid>
```

Helper component `./CardGridNarrowPreview.svelte`:

```svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import CardGrid from './CardGrid.svelte';

  type Props = ComponentProps<typeof CardGrid> & { containerWidth?: number };

  const { containerWidth = 280, ...rest }: Props = $props();
</script>

<!-- The toolbar responds to its own width, so the container must be narrow. -->
<div style="width:{containerWidth}px;height:100%;display:flex;flex-direction:column">
  <CardGrid {...rest} />
</div>
```

Helper component `./CardStyleStory.svelte`:

```svelte
<script lang="ts">
	import CardGrid from './CardGrid.svelte';
	import type { CardGridColumn } from './cardGrid.js';

	const columns: CardGridColumn[] = [
		{ id: 'name', title: 'Name' },
		{ id: 'department', title: 'Department' },
		{ id: 'salary', title: 'Salary', format: 'currency' },
		{ id: 'status', title: 'Status' },
	];

	const STATUSES = ['Active', 'Pending', 'Inactive'];
	const NAMES = ['Alice Smith', 'Bob Jones', 'Carol Brown', 'David Garcia', 'Eve Johnson', 'Frank Williams'];
	const data = Array.from({ length: 24 }, (_, i) => ({
		id: i + 1,
		name: NAMES[i % NAMES.length],
		department: ['Engineering', 'Sales', 'HR'][i % 3],
		salary: 40000 + i * 1500,
		status: STATUSES[i % 3],
	}));

	const STATUS_COLOR: Record<string, string> = {
		Active: '#16a34a',
		Pending: '#d97706',
		Inactive: '#dc2626',
	};
</script>

<!--
	cardClass adds a class per record; cardStyle adds inline style per record.
	The card-shell variables are set through the `class` prop (.cs-demo).
-->
<div style="height:100vh;display:flex;flex-direction:column">
	<CardGrid
		{columns}
		{data}
		class="cs-demo"
		title="Styled cards"
		cardClass={(row) => (row.status === 'Inactive' ? 'cs-inactive' : undefined)}
		cardStyle={(row) => `border-left: 6px solid ${STATUS_COLOR[row.status as string]};`}
	/>
</div>

<style>
	:global(.cs-demo) {
		--cg-card-radius: 18px;
		--cg-card-border-width: 1px;
		--cg-card-padding-x: 22px;
		--cg-card-padding-y: 20px;
	}

	:global(.cs-demo .cs-inactive) {
		opacity: 0.6;
	}
</style>
```

Helper component `./ModernCardStory.svelte`:

```svelte
<script lang="ts">
	import CardGrid from './CardGrid.svelte';
	import type { CardGridColumn } from './cardGrid.js';

	const columns: CardGridColumn[] = [
		{ id: 'name', title: 'Name' },
		{ id: 'department', title: 'Department' },
		{ id: 'salary', title: 'Salary', format: 'currency' },
		{ id: 'status', title: 'Status' },
	];

	const STATUSES = ['Active', 'Pending', 'Inactive'];
	const NAMES = ['Alice Smith', 'Bob Jones', 'Carol Brown', 'David Garcia', 'Eve Johnson', 'Frank Williams'];
	const data = Array.from({ length: 24 }, (_, i) => ({
		id: i + 1,
		name: NAMES[i % NAMES.length],
		department: ['Engineering', 'Sales', 'HR'][i % 3],
		salary: 40000 + i * 1500,
		status: STATUSES[i % 3],
	}));
</script>

<!--
	Modern look from variables only: square corners, no border, soft layered shadow, roomy padding, no entry animation, lift on hover, flip side with plain text-link buttons.
	The shadows are the existing --cg-shadow-sm / --cg-shadow-md tokens, overridden on the `class` prop.
-->
<div style="height:100vh;display:flex;flex-direction:column">
	<CardGrid {columns} {data} class="cm-demo" title="Modern cards" animate={false} cardMaxFrontColumns={2}>
		{#snippet cardBack(record)}
			<div class="cm-back">
				<span class="cm-back-name">{record.name as string}</span>
				<dl class="cm-back-rows">
					<div><dt>Salary</dt><dd>ZAR {(record.salary as number).toLocaleString('en-ZA')}</dd></div>
					<div><dt>Status</dt><dd>{record.status as string}</dd></div>
				</dl>
			</div>
		{/snippet}
	</CardGrid>
</div>

<style>
	:global(.cm-demo.cg-root.cg-root) {
		--cg-grid-inset: 24px;
		--cg-card-hover-lift: -4px;
		--cg-flip-duration: 550ms;
		--cg-flip-easing: cubic-bezier(0.22, 1, 0.36, 1);
		--cg-flip-back-bg: #f8fafc;
		--cg-card-radius: 0px;
		--cg-card-border-width: 0px;
		--cg-card-padding-x: 24px;
		--cg-card-padding-y: 22px;
		--cg-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05), 0 8px 24px rgba(15, 23, 42, 0.08);
		--cg-shadow-md: 0 2px 4px rgba(15, 23, 42, 0.06), 0 16px 40px rgba(15, 23, 42, 0.14);
	}

	/* Flip buttons: quiet uppercase text links instead of pills ("+N" is replaced by "Details") */
	:global(.cm-demo.cg-root .cg-flip-trigger),
	:global(.cm-demo.cg-root .cg-flip-back-btn) {
		padding: 0;
		border: 0;
		border-radius: 0;
		background: none;
		color: var(--cg-text-muted);
		font-size: 0.7rem;
		font-weight: 600;
		letter-spacing: 0.1em;
		text-transform: uppercase;
	}

	:global(.cm-demo.cg-root .cg-flip-trigger) {
		bottom: 14px;
		right: 24px;
		font-size: 0;
	}

	:global(.cm-demo.cg-root .cg-flip-trigger::after) {
		content: 'Details  \2192';
		font-size: 0.7rem;
	}

	:global(.cm-demo.cg-root .cg-flip-back-btn) {
		bottom: auto;
		top: 16px;
		left: 24px;
		right: auto;
	}

	:global(.cm-demo.cg-root .cg-flip-trigger:hover),
	:global(.cm-demo.cg-root .cg-flip-back-btn:hover) {
		background: none;
		color: var(--cg-accent);
	}

	.cm-back {
		flex: 1;
		display: flex;
		flex-direction: column;
		gap: 14px;
		padding: 44px 24px 0;
		color: #0f172a;
	}

	.cm-back-name {
		font-size: 1.05rem;
		font-weight: 700;
	}

	.cm-back-rows {
		margin: 0;
		display: flex;
		flex-direction: column;
		gap: 8px;
	}

	.cm-back-rows div {
		display: flex;
		justify-content: space-between;
		gap: 12px;
		padding-top: 8px;
		border-top: 1px solid #e2e8f0;
	}

	.cm-back-rows dt {
		font-size: 0.8rem;
		color: #64748b;
	}

	.cm-back-rows dd {
		margin: 0;
		font-size: 0.9rem;
		font-weight: 600;
	}
</style>
```

Helper component `./WackyCardStory.svelte`:

```svelte
<script lang="ts">
	import CardGrid from './CardGrid.svelte';
	import type { CardGridColumn } from './cardGrid.js';

	const columns: CardGridColumn[] = [
		{ id: 'name', title: 'Name' },
		{ id: 'department', title: 'Department' },
		{ id: 'salary', title: 'Salary', format: 'currency' },
		{ id: 'status', title: 'Status' },
	];

	const STATUSES = ['Active', 'Pending', 'Inactive'];
	const NAMES = ['Alice Smith', 'Bob Jones', 'Carol Brown', 'David Garcia', 'Eve Johnson', 'Frank Williams'];
	const data = Array.from({ length: 24 }, (_, i) => ({
		id: i + 1,
		name: NAMES[i % NAMES.length],
		department: ['Engineering', 'Sales', 'HR'][i % 3],
		salary: 40000 + i * 1500,
		status: STATUSES[i % 3],
	}));
</script>

<!--
	Playful: bouncy spin-in with a per-card stagger, a resting tilt per card, pastel fills, thick outline with a hard
	offset shadow. `--cg-card-animation` swaps the entry animation; cardStyle sets per-card delay, tilt, fill and the
	bouncy transition (selected cards straighten up and grow).
-->
<script lang="ts" module>
	const FILLS = ['#fde68a', '#fbcfe8', '#bae6fd', '#bbf7d0', '#ddd6fe', '#fed7aa'];
</script>

<div style="height:100vh;display:flex;flex-direction:column">
	<CardGrid
		{columns}
		{data}
		class="wk-demo"
		title="Wacky cards"
		cardMaxFrontColumns={2}
		cardStyle={(_row, i, selected) =>
			`animation-delay:${(i % 8) * 70}ms;` +
			`rotate:${selected ? 0 : i % 2 ? 1.5 : -1.5}deg;` +
			`background:${FILLS[i % FILLS.length]};` +
			`transition:transform 350ms cubic-bezier(.34,1.56,.64,1),rotate 350ms cubic-bezier(.34,1.56,.64,1),box-shadow 150ms;`}
	>
		{#snippet cardBack(record)}
			<div class="wk-back">
				<span class="wk-back-label">The fine print</span>
				<dl class="wk-back-rows">
					<div><dt>Salary</dt><dd>ZAR {(record.salary as number).toLocaleString('en-ZA')}</dd></div>
					<div><dt>Status</dt><dd>{record.status as string}</dd></div>
				</dl>
			</div>
		{/snippet}
	</CardGrid>
</div>

<style>
	@keyframes -global-wk-boing {
		0%   { opacity: 0; transform: translateY(-120px) rotate(-200deg) scale(0.2); }
		55%  { opacity: 1; transform: translateY(14px) rotate(12deg) scale(1.12); }
		75%  { transform: translateY(-6px) rotate(-5deg) scale(0.96); }
		100% { transform: translateY(0) rotate(0) scale(1); }
	}

	@keyframes -global-wk-boing-up {
		0%   { opacity: 0; transform: translateY(120px) rotate(200deg) scale(0.2); }
		55%  { opacity: 1; transform: translateY(-14px) rotate(-12deg) scale(1.12); }
		75%  { transform: translateY(6px) rotate(5deg) scale(0.96); }
		100% { transform: translateY(0) rotate(0) scale(1); }
	}

	:global(.wk-demo.cg-root.cg-root) {
		--cg-grid-inset: 20px;
		--cg-card-radius: 22px;
		--cg-card-border-width: 3px;
		--cg-card-padding-x: 20px;
		--cg-card-hover-lift: -8px;
		--cg-card-animation: wk-boing 700ms cubic-bezier(0.34, 1.56, 0.64, 1) backwards;
		--cg-card-animation-up: wk-boing-up 700ms cubic-bezier(0.34, 1.56, 0.64, 1) backwards;
		--cg-flip-duration: 800ms;
		--cg-flip-easing: cubic-bezier(0.34, 1.56, 0.64, 1);
		--cg-flip-back-bg: #fffbeb;
		--cg-border: #111827;
		--cg-shadow-sm: 6px 6px 0 #111827;
		--cg-shadow-md: 10px 10px 0 #111827;
	}

	@keyframes -global-wk-wobble {
		0%, 100% { transform: rotate(-6deg) scale(1); }
		50%      { transform: rotate(6deg) scale(1.12); }
	}

	/* Flip button: a wobbling sticker that reads "Flip me!" (original "+N" text hidden) */
	:global(.wk-demo.cg-root .cg-flip-trigger) {
		bottom: 12px;
		right: 14px;
		padding: 5px 12px;
		border: 3px solid #111827;
		border-radius: 999px;
		background: #f472b6;
		color: #111827;
		font-size: 0;
		box-shadow: 3px 3px 0 #111827;
		animation: wk-wobble 1.4s ease-in-out infinite;
	}

	:global(.wk-demo.cg-root .cg-flip-trigger::after) {
		content: 'Flip me!';
		font-size: 0.8rem;
		font-weight: 800;
		letter-spacing: 0.04em;
	}

	:global(.wk-demo.cg-root .cg-flip-trigger:hover) {
		background: #fde047;
		animation-duration: 0.35s;
	}

	/* Back button on the flip side, top-left */
	:global(.wk-demo.cg-root .cg-flip-back-btn) {
		bottom: auto;
		top: 10px;
		left: 14px;
		right: auto;
		padding: 5px 12px;
		border: 3px solid #111827;
		border-radius: 999px;
		background: #a78bfa;
		color: #111827;
		font-size: 0.8rem;
		font-weight: 800;
		box-shadow: 3px 3px 0 #111827;
		transform: rotate(-3deg);
	}

	:global(.wk-demo.cg-root .cg-flip-back-btn:hover) {
		background: #f472b6;
		transform: rotate(3deg) scale(1.08);
	}

	.wk-back {
		flex: 1;
		display: flex;
		flex-direction: column;
		gap: 8px;
		padding: 14px 20px 0;
		color: #111827;
	}

	.wk-back-label {
		align-self: flex-end;
		padding: 2px 10px;
		border: 2px solid #111827;
		border-radius: 999px;
		background: #fde047;
		font-size: 0.75rem;
		font-weight: 800;
		letter-spacing: 0.08em;
		text-transform: uppercase;
		transform: rotate(-2deg);
	}

	.wk-back-rows {
		margin: 0;
		display: flex;
		flex-direction: column;
		gap: 2px;
	}

	.wk-back-rows div {
		display: flex;
		justify-content: space-between;
		gap: 12px;
		padding-bottom: 2px;
		border-bottom: 2px dashed #111827;
	}

	.wk-back-rows dt {
		font-size: 0.9rem;
		font-weight: 600;
	}

	.wk-back-rows dd {
		margin: 0;
		font-size: 1rem;
		font-weight: 800;
	}
</style>
```

#### Static Data (`StaticData`)

```ts
// ── Stories ──────────────────────────────────────────────────────────────────

/** Local array — no server required. Scroll to the bottom to see all 100 cards. Sort and filters are remembered via `persistenceName`. */
export const StaticData: Story = {
  args: {
    persistenceName: "story-cardgrid-static",
    columns,
    sortOptions,
    data: ALL_ROWS.slice(0, 100),
    pageSize: 20,
    searchPlaceholder: "Search employees…",
    menuItems: [
      { id: "view",   label: "View details", requiresRow: true },
      { id: "edit",   label: "Edit", requiresRow: true },
      { id: "sep",    label: "",    kind: "separator", requiresRow: true },
      { id: "delete", label: "Delete", requiresRow: true },
    ],
  },
};
```

#### Filter Options Select (`FilterOptionsSelect`)

```ts
/** Filter panel: "Department" uses `filterOptions` (async searchable select, `in` = multi-select). Mock options come from the fixture departments. */
export const FilterOptionsSelect: Story = {
  args: {
    columns: columns.map((c) =>
      c.id === "department"
        ? {
            ...c,
            filterOptions: async (search) => {
              await new Promise((r) => setTimeout(r, 200));
              return DEPARTMENTS.filter((d) => d.toLowerCase().includes(search.toLowerCase())).map((d) => ({ value: d, label: d }));
            },
          }
        : c,
    ),
    sortOptions,
    data: ALL_ROWS.slice(0, 100),
    pageSize: 20,
  },
};
```

#### Builtin Menu Item Override (`BuiltinMenuItemOverride`)

```ts
/** `buttonMenuProps` overrides the built-in "Refresh" context-menu item's label and icon (right-click a card). */
export const BuiltinMenuItemOverride: Story = {
  args: {
    columns,
    sortOptions,
    data: ALL_ROWS.slice(0, 100),
    pageSize: 20,
    buttonMenuProps: {
      refresh: { label: "Reload Now", icon: "🔄" },
    },
  },
};
```

#### With Former Edit (`WithFormerEdit`)

```ts
/**
 * Add / Edit / Delete through a `FormerDrawer`: double-click a card to edit, right-click for the
 * Add / Edit / Delete menu, or use the toolbar Add button. Saves are applied in memory.
 */
export const WithFormerEdit: Story = {
  render: () => ({ Component: CardGridWithFormerPreview }),
};
```

#### Toolbar Below (`ToolbarBelow`)

```ts
/** `toolbarBelow` snippet — a full-width row under the toolbar, above the cards (description, summary, notices). */
export const ToolbarBelow: Story = {
  args: {
    columns,
    sortOptions,
    data: ALL_ROWS.slice(0, 100),
    pageSize: 20,
    title: "Employees",
  },
  render: (args) => ({ Component: ToolbarBelowHost, props: args }),
};
```

#### All Snippets (`AllSnippets`)

```ts
/** Toggle every snippet slot on/off: `title`, `toolbarStart`, `toolbarCenter`, `toolbarContent` (wraps the default toolbar), `toolbarEnd`, `toolbarBelow`, `gridFooter`, `leftContent`, `rightContent`, `card`, `cardBack`, `empty` (tick "no data"). */
export const AllSnippets: Story = {
  render: () => ({ Component: AllSnippetsStory }),
};
```

#### Sort Buttons (`SortButtons`)

```ts
/** `showSortButtons` — one pill per sort option instead of the Sort dropdown; pills collapse into the dropdown when the toolbar is narrow. */
export const SortButtons: Story = {
  args: {
    columns,
    sortOptions,
    data: ALL_ROWS.slice(0, 100),
    pageSize: 20,
    showSortButtons: true,
  },
};
```

#### Card Styling (`CardStyling`)

```ts
/**
 * Styling cards from code: `cardClass` / `cardStyle` run per record (here: a status-coloured left border,
 * and a dimmed class for Inactive), and the card-shell CSS variables (`--cg-card-radius`,
 * `--cg-card-border-width`, `--cg-card-padding-x`, `--cg-card-padding-y`) are set through the `class` prop.
 */
export const CardStyling: Story = {
  render: () => ({ Component: CardStyleStory }),
};
```

#### Modern Cards (`ModernCards`)

```ts
/**
 * Modern look from the card-shell variables alone: square corners, no border, soft layered shadows
 * (`--cg-shadow-sm` / `--cg-shadow-md`), roomier padding, no entry animation (`animate={false}`) a hover lift (`--cg-card-hover-lift`), and a flip side (`cardMaxFrontColumns`, `cardBack`) with a smooth ease-out flip and plain text-link buttons.
 */
export const ModernCards: Story = {
  render: () => ({ Component: ModernCardStory }),
};
```

#### Wacky Cards (`WackyCards`)

```ts
/**
 * Playful: bouncy spin-in with a per-card stagger (`--cg-card-animation` + `animation-delay` from `cardStyle`),
 * resting tilt per card, pastel fills and a hard offset shadow. Selected cards straighten and grow; the "+N" button flips a card (`cardMaxFrontColumns`, `cardBack`)
 * with a slow overshooting flip (`--cg-flip-duration`, `--cg-flip-easing`) onto a light, readable back (`--cg-flip-back-bg`).
 */
export const WackyCards: Story = {
  render: () => ({ Component: WackyCardStory }),
};
```

#### With Title (`WithTitle`)

```ts
/** `title` — text (or a snippet) at the left of the toolbar, before the search. Text trims with an ellipsis. */
export const WithTitle: Story = {
  args: {
    columns,
    sortOptions,
    data: ALL_ROWS.slice(0, 100),
    pageSize: 20,
    title: "Employees",
  },
};
```

#### Popover Search (`PopoverSearch`)

```ts
/** `searchMode: "popover"` — a search button opens the search input below the toolbar (Ctrl+F also opens it). */
export const PopoverSearch: Story = {
  args: {
    columns,
    sortOptions,
    data: ALL_ROWS.slice(0, 100),
    pageSize: 20,
    title: "Employees",
    searchMode: "popover",
  },
};
```

#### Narrow Toolbar (`NarrowToolbar`)

```ts
/** Narrow toolbar (280px): item count hides, the search, Sort and Filters buttons collapse to icons, and the text title stays, trimmed with an ellipsis. */
export const NarrowToolbar: Story = {
  render: (args) => ({
    Component: CardGridNarrowPreview,
    props: { ...args, containerWidth: 280 },
  }),
  args: {
    columns,
    sortOptions,
    data: ALL_ROWS.slice(0, 100),
    pageSize: 20,
    title: "Employees",
  },
};
```

#### Async Local Data (`AsyncLocalData`)

```ts
/** Async local data — `data` is a function returning a Promise. */
export const AsyncLocalData: Story = {
  args: {
    columns,
    sortOptions,
    data: () =>
      new Promise<Record<string, unknown>[]>((resolve) =>
        setTimeout(() => resolve(ALL_ROWS.slice(0, 60)), 1200),
      ),
    pageSize: 20,
  },
};
```

#### Server Side Cursor Paging (`ServerSideCursorPaging`)

```ts
/** Infinite scroll with cursor-forward pagination against a mocked ResolveSpec API. */
export const ServerSideCursorPaging: Story = {
  args: {
    columns,
    sortOptions,
    dataSource: "resolvespec",
    dataSourceOptions: {
      url:    "https://api.example.com",
      schema: "public",
      entity: "employees",
      uniqueID: "id",
    },
    pageSize: 20,
    searchPlaceholder: "Search employees…",
  },
  decorators: [mockResolveFetch()],
};
```

#### Server Side Search (`ServerSideSearch`)

```ts
/** Typing in the search box sends `ilike` filters to the mocked server. */
export const ServerSideSearch: Story = {
  args: {
    columns,
    sortOptions,
    dataSource:       "resolvespec",
    dataSourceOptions: {
      url:      "https://api.example.com",
      schema:   "public",
      entity:   "employees",
      uniqueID: "id",
    },
    pageSize:        20,
    serverSideSearch: true,
    searchColumns:   ["name", "email", "department"],
    searchPlaceholder: "Search name, email, department…",
  },
  decorators: [mockResolveFetch()],
};
```

#### Server Error (`ServerError`)

```ts
/** The API returns an error response — verify the `onLoadError` callback fires. */
export const ServerError: Story = {
  parameters: {
    // Error mock returns 500 for ALL example.com requests — excluding from docs
    // prevents it from poisoning the other server-side stories on the same page.
    docs: { disable: true },
  },
  args: {
    columns,
    dataSource: "resolvespec",
    dataSourceOptions: {
      url:    "https://api.example.com",
      schema: "public",
      entity: "employees",
      uniqueID: "id",
    },
    pageSize: 20,
  },
  decorators: [
    (Story) => {
      window.fetch = async (input) => {
        const url = typeof input === "string" ? input : (input as Request).url;
        if (url.includes("example.com")) {
          return new Response(
            JSON.stringify({ success: false, error: { code: "500", message: "Internal server error" } }),
            { status: 500, headers: { "Content-Type": "application/json" } },
          );
        }
        return _realFetch(input);
      };
      const result = Story();
      setTimeout(() => { window.fetch = _realFetch; }, 3000);
      return result;
    },
  ],
};
```

#### Multi Select (`MultiSelect`)

```ts
/** Multi-select: click, Ctrl+click, Shift+click, Space, Ctrl+A. */
export const MultiSelect: Story = {
  args: {
    columns,
    sortOptions,
    data:        ALL_ROWS.slice(0, 40),
    multiSelect: true,
    pageSize:    20,
    onSelectedItemsChange: fn(),
  },
};
```

#### Single Select (`SingleSelect`)

```ts
/** Single-select mode — only one card selected at a time. */
export const SingleSelect: Story = {
  args: {
    columns,
    data:        ALL_ROWS.slice(0, 30),
    multiSelect: false,
    pageSize:    20,
  },
};
```

#### Narrow Cards (`NarrowCards`)

```ts
/** Narrow cards — four or more columns on a wide viewport. */
export const NarrowCards: Story = {
  args: {
    columns,
    data:         ALL_ROWS.slice(0, 60),
    cardMinWidth: 180,
    cardGap:      12,
    pageSize:     20,
  },
};
```

#### Wide Cards (`WideCards`)

```ts
/** Wide cards — one or two columns, more whitespace. */
export const WideCards: Story = {
  args: {
    columns,
    data:         ALL_ROWS.slice(0, 20),
    cardMinWidth: 440,
    cardGap:      20,
    pageSize:     20,
  },
};
```

#### Empty State (`EmptyState`)

```ts
/** Empty data source. */
export const EmptyState: Story = {
  args: {
    columns,
    data:    [],
    pageSize: 20,
  },
};
```

#### Nested Data Keys (`NestedDataKeys`)

```ts
/** Nested dot-notation `dataKey` paths. */
export const NestedDataKeys: Story = {
  args: {
    columns: [
      { id: "rid_id",       title: "ID",         dataKey: "id",                          disableSearch: true },
      { id: "rid_username", title: "Username",   dataKey: "login.username" },
      { id: "rid_email",    title: "Email",      dataKey: "login.contact.email" },
      { id: "rid_team",     title: "Team",       dataKey: "org.department.team.name" },
      { id: "rid_salary",   title: "Salary",     dataKey: "employment.salary",           format: "currency" as const },
    ],
    data: Array.from({ length: 40 }, (_, i) => ({
      id:    i + 1,
      login: {
        username: `${FIRST[i % FIRST.length].toLowerCase()}.${LAST[i % LAST.length].toLowerCase()}`,
        contact:  { email: `${FIRST[i % FIRST.length].toLowerCase()}@example.com` },
      },
      org:   { department: { team: { name: ["Alpha", "Beta", "Gamma"][i % 3] } } },
      employment: { salary: 40000 + (i % 60) * 1000 },
    })),
    pageSize: 20,
  },
};
```

#### Live Resolve Spec (`LiveResolveSpec`)

```ts
/** Connect to a live ResolveSpec endpoint via the Storybook controls panel. */
export const LiveResolveSpec: Story = {
  argTypes: liveArgTypes,
  args: {
    apiUrl:    "https://utils.btsys.tech/api/v2",
    authToken: "",
    schema:    "public",
    entity:    "process",
    uniqueID:  "id_process",
    columns: [
      { id: "id_process",  title: "ID" },
      { id: "process",     title: "Process" },
      { id: "description", title: "Description" },
    ],
    sortOptions: [
      { columnId: "process",     label: "Process" },
      { columnId: "description", label: "Description" },
    ],
    dataSource:       "resolvespec",
    pageSize:         20,
    serverSideSearch: true,
    searchColumns:    ["process", "description"],
  },
  render: ({ apiUrl, authToken, schema, entity, uniqueID, ...args }) => ({
    Component: CardGrid,
    props: {
      ...args,
      dataSourceOptions: { url: apiUrl, authToken, schema, entity, uniqueID },
    },
  }),
};
```

#### Dark Mode (`DarkMode`)

```ts
// ── Dark mode ────────────────────────────────────────────────────────────────

/**
 * Wraps the grid in a `.dark` class container so dark-mode tokens apply.
 * Toggle the decorator class to compare light vs dark side-by-side.
 */
export const DarkMode: Story = {
  args: {
    columns,
    sortOptions,
    data: ALL_ROWS.slice(0, 30),
    pageSize: 20,
    searchPlaceholder: "Search employees…",
    menuItems: [
      { id: "view",   label: "View details", requiresRow: true },
      { id: "edit",   label: "Edit", requiresRow: true },
      { id: "sep",    label: "", kind: "separator", requiresRow: true },
      { id: "delete", label: "Delete", requiresRow: true },
    ],
  },
  decorators: [
    (Story) => {
      const hadDark = document.documentElement.classList.contains('dark');
      const prevBg  = document.body.style.background;
      document.documentElement.classList.add('dark');
      document.body.style.background = '#0f172a';
      const result = Story();
      if (!hadDark) document.documentElement.classList.remove('dark');
      document.body.style.background = prevBg;
      return result;
    },
  ],
};
```

#### Flip Mode (`FlipMode`)

```ts
/**
 * Cards have 10 columns but only 4 are shown on the front.
 * Click the **+6** badge to flip a card and see the remaining fields.
 * Click **← Back** to flip it back.
 */
export const FlipMode: Story = {
  args: {
    columns:             manyColumns,
    cardMaxFrontColumns: 4,
    data:                Array.from({ length: 30 }, (_, i) => makeBigRow(i)),
    pageSize:            20,
    sortOptions: [
      { columnId: "name",     label: "Name" },
      { columnId: "salary",   label: "Salary" },
      { columnId: "location", label: "Location" },
    ],
  },
};
```

#### Single Select Keyboard Navigation (`SingleSelectKeyboardNavigation`)

```ts
/**
 * Single-select mode — arrow keys move the selection, not just the focus ring.
 * Click a card or use arrow keys; the selection follows the keyboard.
 */
export const SingleSelectKeyboardNavigation: Story = {
  args: {
    columns,
    data:        ALL_ROWS.slice(0, 30),
    multiSelect: false,
    pageSize:    20,
    onSelectedItemsChange: fn(),
  },
};
```

#### Hundred Thousand Rows (`HundredThousandRows`)

```ts
export const HundredThousandRows: Story = {
  parameters: {
    // Mounting alongside ~14 other CardGrid instances on the aggregated docs page
    // breaks the virtualizer's viewport bounding (no fixed-height scroll container
    // in that flow layout, unlike the dedicated Canvas view), causing it to render
    // unboundedly and crash the tab. Exclude from docs; still usable as a story.
    docs: { disable: true },
  },
  args: {
    columns,
    sortOptions,
    pageSize: 50,
    searchPlaceholder: "Search 100 000 employees…",
  },
  render: (args) => ({
    Component: CardGrid,
    props: { ...args, data: getBigRows() },
  }),
};
```

#### Image Cards (`ImageCards`)

```ts
// ── Image cards ──────────────────────────────────────────────────────────────

/**
 * Custom `card` snippet rendering photo cards with a cover image, category
 * badge, photographer credit, and view/like counts.
 * Images are loaded from picsum.photos — requires an internet connection.
 */
export const ImageCards: Story = {
  render: () => ({ Component: ImageCardStory }),
};
```

#### Exportable (`Exportable`)

```ts
/**
 * `exportable` adds "Export…" to the card context menu (right-click a card).
 * Exports every row matching the current search/filters/sort; the mocked server
 * answers each page after 150 ms so the progress bar and Cancel are visible.
 */
export const Exportable: Story = {
  args: {
    columns,
    sortOptions,
    dataSource: "resolvespec",
    dataSourceOptions: {
      url:      "https://api.example.com",
      schema:   "public",
      entity:   "employees",
      uniqueID: "id",
    },
    pageSize: 20,
    serverSideSearch: true,
    exportable: true,
    exportOptions: { filename: "employees", onExportComplete: fn() },
  },
  decorators: [
    (Story) =>
      mockResolveFetch()(() => {
        const mocked = window.fetch;
        window.fetch = async (input, init) => {
          await new Promise((r) => setTimeout(r, 150));
          return mocked(input, init);
        };
        return Story();
      }),
  ],
};
```

---

# Charts

Import: `import { ... } from '@warky/svelix/Charts'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as AreaChart } from "./AreaChart.svelte";
export { default as BarChart } from "./BarChart.svelte";
export { default as DonutChart } from "./DonutChart.svelte";
export { default as LineChart } from "./LineChart.svelte";
export { default as PieChart } from "./PieChart.svelte";
export { default as ScatterChart } from "./ScatterChart.svelte";
export { CHARTS_DEFAULTS } from "./config.js";
export { DEFAULT_CHART_PALETTE, paletteColor, resolveChartColor } from "./colors.js";
export { createChartLoader, isChartLibLoaded, loadLayerChart } from "./loader.js";
export type * from "./types.js";
```

## Summary

Mantine-style charts (Area, Bar, Line, Scatter, Pie, Donut) on LayerChart. Engine loads lazily; colors use Skeleton tokens so light/dark follow the theme.

## Types (types.ts)

```ts
import type { Snippet } from "svelte";

export interface ChartSeries {
	/** Data field (LayerChart `key`). */
	name: string;
	label?: string;
	/** Skeleton token (`primary`, `primary.500`), CSS var or any CSS color. Unset → palette. */
	color?: string;
}

export interface ChartReferenceLine {
	x?: number | string | Date;
	y?: number;
	label?: string;
	color?: string;
}

export interface ChartPieDatum {
	name: string;
	value: number;
	color?: string;
}

export interface ScatterSeries {
	name: string;
	color?: string;
	data: Record<string, unknown>[];
}

/** `SvelixLayout` key `charts`. */
export interface ChartsLayoutConfig {
	/** Series colors in order; Skeleton tokens or CSS colors. */
	palette?: string[];
	h?: number;
	curveType?: ChartCurveType;
	withLegend?: boolean;
	withTooltip?: boolean;
	withXAxis?: boolean;
	withYAxis?: boolean;
	gridAxis?: ChartGridAxis;
	tickLine?: ChartGridAxis;
	accessibilityLayer?: boolean;
}

export type ChartCurveType =
	| "bump"
	| "linear"
	| "natural"
	| "monotone"
	| "step"
	| "stepBefore"
	| "stepAfter";
export type ChartGridAxis = "x" | "y" | "xy" | "none";

export interface ChartBaseProps {
	/** Height in px (default 300). */
	h?: number;
	/** Width in px; unset → fills container. */
	w?: number;
	class?: string;
	palette?: string[];
	withLegend?: boolean;
	withTooltip?: boolean;
	accessibilityLayer?: boolean;
	/** Spread onto the LayerChart chart component. */
	chartProps?: Record<string, unknown>;
	/** LayerChart per-part props (`xAxis`, `yAxis`, `grid`, `spline`, `tooltip`, …). */
	props?: Record<string, unknown>;
	/** Rendered while the engine loads; default: Skeleton placeholder. */
	loading?: Snippet;
	/** Rendered when the engine fails to load; default: message + retry. */
	error?: Snippet<[{ error: unknown; retry: () => void }]>;
}

export interface ChartClickDetail {
	/** Source datum (pie/donut: mapped `{ key, label, value, color }`). */
	// eslint-disable-next-line @typescript-eslint/no-explicit-any
	data: any;
}

export interface ChartSeriesClickDetail extends ChartClickDetail {
	series: { key: string; label?: string; color?: string };
}

export interface CartesianChartProps extends ChartBaseProps {
	data: Record<string, unknown>[];
	dataKey: string;
	series: ChartSeries[];
	curveType?: ChartCurveType;
	withXAxis?: boolean;
	withYAxis?: boolean;
	xAxisLabel?: string;
	yAxisLabel?: string;
	/** Best-effort: merged into LayerChart `props.xAxis`. */
	xAxisProps?: Record<string, unknown>;
	/** Best-effort: merged into LayerChart `props.yAxis`. */
	yAxisProps?: Record<string, unknown>;
	tickLine?: ChartGridAxis;
	gridAxis?: ChartGridAxis;
	valueFormatter?: (value: number) => string;
	unit?: string;
	referenceLines?: ChartReferenceLine[];
	withBrush?: boolean;
	withPointLabels?: boolean;
	withDots?: boolean;
	fillOpacity?: number;
	strokeWidth?: number;
	strokeDasharray?: string | number;
	/** Skip null/undefined values and join the line across them (default true; overlap layouts only). */
	connectNulls?: boolean;
}

export interface AreaChartProps extends CartesianChartProps {
	/** Fill fades to transparent. */
	withGradient?: boolean;
	/** `[positive, negative]` colors for `type="split"` (default `["success", "error"]`). */
	splitColors?: [string, string];
	/** Drilldown hook: a point on the chart was clicked. */
	onPointClick?: (event: MouseEvent, detail: ChartClickDetail) => void;
	type?: "default" | "stacked" | "percent" | "split";
}

export interface BarChartProps extends CartesianChartProps {
	/** Drilldown hook: a bar was clicked. */
	/** `series` is only set when tooltips are off; with tooltips on, `data` is the clicked row. */
	onBarClick?: (event: MouseEvent, detail: ChartClickDetail & Partial<ChartSeriesClickDetail>) => void;
	type?: "default" | "stacked" | "percent";
	orientation?: "vertical" | "horizontal";
}

export interface LineChartProps extends CartesianChartProps {
	/** Drilldown hook: a point on the chart was clicked. */
	onPointClick?: (event: MouseEvent, detail: ChartClickDetail) => void;
}

export interface ScatterChartProps extends ChartBaseProps {
	data: ScatterSeries[];
	dataKey: { x: string; y: string };
	xAxisLabel?: string;
	yAxisLabel?: string;
	withXAxis?: boolean;
	withYAxis?: boolean;
	gridAxis?: ChartGridAxis;
	valueFormatter?: (value: number) => string;
	unit?: string;
	/** Drilldown hook: a point was clicked (via tooltip hit). */
	onPointClick?: (event: MouseEvent, detail: ChartClickDetail) => void;
}

export interface PieChartProps extends ChartBaseProps {
	data: ChartPieDatum[];
	/** Drilldown hook: an arc was clicked. */
	onArcClick?: (event: MouseEvent, detail: ChartSeriesClickDetail) => void;
	withLabels?: boolean;
	valueFormatter?: (value: number) => string;
}

export interface DonutChartProps extends PieChartProps {
	/** Ring thickness in px (default 24). */
	thickness?: number;
	/** Text centered in the hole. */
	chartLabel?: string;
}
```

## Examples (from Storybook stories)

### Components/Charts  (`Charts.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, waitFor, within } from 'storybook/test';

import ChartsDemo from './ChartsDemo.fixtures.svelte';

async function rendered(canvasElement: HTMLElement, count: number) {
	await waitFor(() => expect(canvasElement.querySelectorAll('.svelix-chart svg').length).toBeGreaterThanOrEqual(count), { timeout: 10000 });
}
```

Helper component `./ChartsDemo.fixtures.svelte`:

```svelte
<script lang="ts">
	import AreaChart from "./AreaChart.svelte";
	import BarChart from "./BarChart.svelte";
	import DonutChart from "./DonutChart.svelte";
	import LineChart from "./LineChart.svelte";
	import PieChart from "./PieChart.svelte";
	import ScatterChart from "./ScatterChart.svelte";
	import type { ChartCurveType } from "./types.js";

	interface Props {
		chart?: "area" | "bar" | "line" | "scatter" | "pie" | "donut" | "all";
		type?: "default" | "stacked" | "percent" | "split";
		withGradient?: boolean;
		nulls?: boolean;
		curveType?: ChartCurveType;
		withLegend?: boolean;
		dark?: boolean;
	}
	let { chart = "all", type = "default", withGradient = false, nulls = false, curveType = "monotone", withLegend = true, dark = false }: Props = $props();

	const data = [
		{ month: new Date(2026, 0, 1), a: 30, b: 20 },
		{ month: new Date(2026, 1, 1), a: 45, b: 35 },
		{ month: new Date(2026, 2, 1), a: 38, b: 50 },
		{ month: new Date(2026, 3, 1), a: 62, b: 41 },
		{ month: new Date(2026, 4, 1), a: 55, b: 60 }
	];
	const rows = $derived(
		nulls ? data.map((d, i) => ({ ...d, a: i === 2 ? null : d.a })) : type === "split" ? data.map((d) => ({ ...d, a: d.a - 45, b: d.b - 40 })) : data
	);
	const series = [
		{ name: "a", label: "Alpha", color: "primary" },
		{ name: "b", label: "Beta", color: "secondary.600" }
	];
	const pie = [
		{ name: "One", value: 40 },
		{ name: "Two", value: 25 },
		{ name: "Three", value: 35 }
	];
	const scatter = [
		{ name: "Group A", data: [{ x: 1, y: 3 }, { x: 2, y: 5 }, { x: 3, y: 4 }, { x: 4, y: 8 }] },
		{ name: "Group B", data: [{ x: 1, y: 7 }, { x: 2, y: 2 }, { x: 3, y: 6 }, { x: 4, y: 3 }] }
	];
	let clicked = $state("");
	const show = (name: string) => chart === "all" || chart === name;
</script>

<div data-testid="clicked" class="px-6">{clicked}</div>
<div class="grid grid-cols-1 gap-6 p-6 md:grid-cols-2" class:dark data-testid="charts-root">
	{#if show("area")}
		<div class="card preset-outlined-surface-200-800 p-4"><AreaChart data={rows} dataKey="month" {series} {type} {curveType} {withLegend} {withGradient} /></div>
	{/if}
	{#if show("bar")}
		<div class="card preset-outlined-surface-200-800 p-4"><BarChart {data} dataKey="month" {series} type={type === "split" ? "default" : type} {withLegend} onBarClick={(_, d) => (clicked = `bar:${d.data.a}`)} /></div>
	{/if}
	{#if show("line")}
		<div class="card preset-outlined-surface-200-800 p-4"><LineChart data={rows} dataKey="month" {series} {curveType} {withLegend} /></div>
	{/if}
	{#if show("scatter")}
		<div class="card preset-outlined-surface-200-800 p-4"><ScatterChart data={scatter} dataKey={{ x: "x", y: "y" }} {withLegend} /></div>
	{/if}
	{#if show("pie")}
		<div class="card preset-outlined-surface-200-800 p-4"><PieChart data={pie} {withLegend} onArcClick={(_, d) => (clicked = `arc:${d.data.key}`)} /></div>
	{/if}
	{#if show("donut")}
		<div class="card preset-outlined-surface-200-800 p-4"><DonutChart data={pie} chartLabel="100" {withLegend} /></div>
	{/if}
</div>
```

#### All (`All`)

Renders the helper component shown above (no args).

#### Loading (`Loading`)

```ts
export const Loading: Story = {
	args: { chart: 'area' },
};
```

#### Area (`Area`)

```ts
export const Area: Story = { args: { chart: 'area' }, };
```

#### Area Stacked (`AreaStacked`)

```ts
export const AreaStacked: Story = { args: { chart: 'area', type: 'stacked' }, };
```

#### Area Percent (`AreaPercent`)

```ts
export const AreaPercent: Story = { args: { chart: 'area', type: 'percent' }, };
```

#### Bar (`Bar`)

```ts
export const Bar: Story = { args: { chart: 'bar' }, };
```

#### Bar Stacked (`BarStacked`)

```ts
export const BarStacked: Story = { args: { chart: 'bar', type: 'stacked' }, };
```

#### Line (`Line`)

```ts
export const Line: Story = { args: { chart: 'line' }, };
```

#### Scatter (`Scatter`)

```ts
export const Scatter: Story = { args: { chart: 'scatter' }, };
```

#### Pie (`Pie`)

```ts
export const Pie: Story = { args: { chart: 'pie' }, };
```

#### Donut (`Donut`)

```ts
export const Donut: Story = { args: { chart: 'donut' }, };
```

#### Dark (`Dark`)

```ts
export const Dark: Story = {
	args: { dark: true },
};
```

#### Drilldown Pie (`DrilldownPie`)

```ts
export const DrilldownPie: Story = {
	args: { chart: 'pie' },
};
```

#### Drilldown Bar (`DrilldownBar`)

```ts
export const DrilldownBar: Story = {
	args: { chart: 'bar' },
};
```

#### Area Gradient (`AreaGradient`)

```ts
export const AreaGradient: Story = {
	args: { chart: 'area', withGradient: true },
};
```

#### Area Split (`AreaSplit`)

```ts
export const AreaSplit: Story = {
	args: { chart: 'area', type: 'split' },
};
```

#### Line Connect Nulls (`LineConnectNulls`)

```ts
export const LineConnectNulls: Story = {
	args: { chart: 'line', nulls: true },
};
```

---

# ContentEditor

Import: `import { ... } from '@warky/svelix/ContentEditor'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as ContentEditor } from './ContentEditor.svelte';
export { default as AudioPlayer } from './subcomponents/AudioPlayer.svelte';
export { default as CollaboraEditor } from './subcomponents/CollaboraEditor.svelte';
export { default as EmailViewer } from './subcomponents/EmailViewer.svelte';
export { default as ImageViewer } from './subcomponents/ImageViewer.svelte';
export { default as MarkdownViewer } from './subcomponents/MarkdownViewer.svelte';
export { default as MonacoEditor } from './subcomponents/MonacoEditor.svelte';
export { default as Office365Editor } from './subcomponents/Office365Editor.svelte';
export { default as PdfViewer } from './subcomponents/PdfViewer.svelte';
export { default as TextEditor } from './subcomponents/TextEditor.svelte';
export { default as UnknownFile } from './subcomponents/UnknownFile.svelte';
export { default as VideoPlayer } from './subcomponents/VideoPlayer.svelte';
export { default as ZipViewer } from './subcomponents/ZipViewer.svelte';
export * from './types.js';
export * from "./contentEditorLayout.js";
```

## Types (types.ts)

```ts
export type ContentEditorType =
  | "monaco"
  | "image"
  | "audio"
  | "video"
  | "text"
  | "markdown"
  | "pdf"
  | "email"
  | "zip"
  | "collabora"
  | "office365"
  | "unknown";

export interface ContentEditorProps {
  value?: Blob;
  filename?: string;
  contentType?: string;
  mode?: "value" | "filepointer";
  /** Hides TextEditor filename header and surrounding editor chrome. */
  hideHeader?: boolean;
  /** Optional Skeleton theme name (sets `data-theme` on the editor host). */
  theme?: string;
  /** Optional forced color scheme for nested editors; defaults to auto-detect. */
  colorScheme?: "auto" | "light" | "dark";
  readonly?: boolean;
  onChange?: (value: Blob) => void;
  onSave?: (value: Blob) => Promise<void>;
  /** Required to enable the markdown attachment plugin (file/image upload). */
  onUpload?: (file: File) => Promise<string | null>;
  // Collabora / WOPI — only required when editing office documents
  /** Bindable — set by the editor once ready; call to insert plain text at the cursor. */
  insertText?: (text: string) => void;
  /** Bindable — set by the editor once ready; call to insert HTML at the cursor. */
  insertHtml?: (html: string) => void;
  /** Selects the office editor suite when editing office documents. Defaults to "collabora". */
  officeSuite?: "collabora" | "office365";
  wopiUrl?: string;
  discoverUrl?: string;
  adminKey?: string;
  userid?: number;
  username?: string;
  viewtype?: string;
  /** Office Online server base URL. Defaults to Microsoft 365 online. Only used when officeSuite="office365". */
  officeOnlineUrl?: string;
}

export interface MonacoEditorProps {
  value?: Blob;
  filename?: string;
  language?: string;
  readonly?: boolean;
  height?: string;
  onChange?: (value: Blob) => void;
  onSave?: (value: Blob) => Promise<void>;
  insertText?: (text: string) => void;
  insertHtml?: (html: string) => void;
}

export interface CollaboraEditorProps {
  value?: Blob;
  filename?: string;
  readonly?: boolean;
  wopiUrl: string;
  discoverUrl: string;
  adminKey?: string;
  userid?: number;
  username?: string;
  viewtype?: string;
  headers?: Record<string, string>;
  onChange?: (value: Blob) => void;
  onSave?: (value: Blob) => Promise<void>;
  /** Bindable — set by the editor once ready; call to insert plain text at the cursor. */
  insertText?: (text: string) => void;
  /** Bindable — set by the editor once ready; call to insert HTML at the cursor. */
  insertHtml?: (html: string) => void;
}

export interface Office365EditorProps {
  value?: Blob;
  filename?: string;
  readonly?: boolean;
  wopiUrl: string;
  /** Office Online server base URL. Defaults to "https://onenote.officeapps.live.com". */
  officeOnlineUrl?: string;
  /** Admin/API key sent as X-API-Key when fetching tokens from the WOPI server. */
  adminKey?: string;
  userid?: number;
  username?: string;
  headers?: Record<string, string>;
  onChange?: (value: Blob) => void;
  onSave?: (value: Blob) => Promise<void>;
  /** Bindable — set by the editor once ready; call to insert plain text at the cursor. */
  insertText?: (text: string) => void;
  /** Bindable — set by the editor once ready; call to insert HTML at the cursor. */
  insertHtml?: (html: string) => void;
}

export interface ImageViewerProps {
  value?: Blob;
  filename?: string;
}

export interface TextEditorProps {
  value?: Blob;
  filename?: string;
  hideHeader?: boolean;
  readonly?: boolean;
  onChange?: (value: Blob) => void;
  insertText?: (text: string) => void;
  insertHtml?: (html: string) => void;
}

export interface MarkdownViewerProps {
  value?: Blob;
  filename?: string;
  readonly?: boolean;
  onChange?: (value: Blob) => void;
  /** Required to enable the attachment plugin (file/image upload via drag-drop or toolbar). */
  onUpload?: (file: File) => Promise<string | null>;
  insertText?: (text: string) => void;
  insertHtml?: (html: string) => void;
}

export interface AudioPlayerProps {
  value?: Blob;
  filename?: string;
}

export interface VideoPlayerProps {
  value?: Blob;
  filename?: string;
}

export interface UnknownFileProps {
  value?: Blob;
  filename?: string;
  contentType?: string;
  note?: string;
}

/** `SvelixLayout` key `contentEditor`. */
export interface ContentEditorLayoutConfig {
  mode?: "value" | "filepointer";
  hideHeader?: boolean;
  colorScheme?: "auto" | "light" | "dark";
  readonly?: boolean;
  officeSuite?: "collabora" | "office365";
}

/** `SvelixLayout` key `collaboraEditor`. */
export interface CollaboraEditorLayoutConfig {
  readonly?: boolean;
  viewtype?: string;
}

/** `SvelixLayout` key `markdownViewer`. */
export interface MarkdownViewerLayoutConfig {
  readonly?: boolean;
}

/** `SvelixLayout` key `monacoEditor`. */
export interface MonacoEditorLayoutConfig {
  readonly?: boolean;
  height?: string;
}

/** `SvelixLayout` key `office365Editor`. */
export interface Office365EditorLayoutConfig {
  readonly?: boolean;
  officeOnlineUrl?: string;
}

/** `SvelixLayout` key `textEditor`. */
export interface TextEditorLayoutConfig {
  hideHeader?: boolean;
  readonly?: boolean;
}
```

## Examples (from Storybook stories)

### Components/ContentEditor  (`ContentEditor.stories.ts`)

Shared setup:

```ts
import ContentEditor from "./ContentEditor.svelte";

import CollaboraInsertTextDemo from "./CollaboraInsertTextDemo.svelte";

// ── Helpers ──────────────────────────────────────────────────────────────────

/** Generate a simple WAV tone — no network required. */
function generateWav(
  durationSecs = 3,
  frequency = 440,
  sampleRate = 44100,
): Blob {
  const n = sampleRate * durationSecs;
  const buf = new ArrayBuffer(44 + n * 2);
  const v = new DataView(buf);
  const str = (o: number, s: string) => {
    for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i));
  };
  str(0, "RIFF");
  v.setUint32(4, 36 + n * 2, true);
  str(8, "WAVE");
  str(12, "fmt ");
  v.setUint32(16, 16, true);
  v.setUint16(20, 1, true);
  v.setUint16(22, 1, true);
  v.setUint32(24, sampleRate, true);
  v.setUint32(28, sampleRate * 2, true);
  v.setUint16(32, 2, true);
  v.setUint16(34, 16, true);
  str(36, "data");
  v.setUint32(40, n * 2, true);
  for (let i = 0; i < n; i++) {
    const t = i / sampleRate;
    const fade = 1 - t / durationSecs;
    v.setInt16(
      44 + i * 2,
      Math.sin(2 * Math.PI * frequency * t) * fade * 0.5 * 32767,
      true,
    );
  }
  return new Blob([buf], { type: "audio/wav" });
}

/** Generate a tiny valid single-page PDF for the browser's native PDF viewer. */
function generatePdf(): Blob {
  const pdf = `%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 144] /Contents 4 0 R /Resources <<
/Font << /F1 5 0 R >>
>> >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT
/F1 18 Tf
36 84 Td
(Hello from Svelix PDF) Tj
ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f 
0000000009 00000 n 
0000000058 00000 n 
0000000115 00000 n 
0000000241 00000 n 
0000000335 00000 n 
trailer
<< /Size 6 /Root 1 0 R >>
startxref
405
%%EOF`;

  return new Blob([pdf], { type: "application/pdf" });
}

// ── Markdown stories ───────────────────────────────────────────────────────

const SAMPLE_MARKDOWN = `# Hello, Markdown!

This is a **bold** statement and _italic_ emphasis.

## Code Example

\`\`\`typescript
const greet = (name: string) => \`Hello, \${name}!\`;
console.log(greet('World'));
\`\`\`

## Lists

- Item one
- Item two
- Item three

> Blockquote: The quick brown fox jumps over the lazy dog.
`;

// ── HTML stories ───────────────────────────────────────────────────────────

const SAMPLE_HTML = `<h1>HTML Editor</h1>
<p>This content is edited as <strong>HTML</strong> with a rich text surface.</p>
<ul data-type="taskList">
  <li data-type="taskItem" data-checked="true">Existing task</li>
  <li data-type="taskItem" data-checked="false">Editable task</li>
</ul>`;

// ── Collabora stories ──────────────────────────────────────────────────────

/** Minimal valid .docx (OOXML ZIP) — no network required. */
function generateDocx(): Blob {
  const b64 =
    "UEsDBBQAAAAIAMJ+YVzXeYTq8QAAALgBAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbH2QzU7DMBCE730Ky9cqccoBIZSkB36OwKE8wMreJFb9J69b2rdn00KREOVozXwz62nXB+/EHjPZGDq5qhspMOhobBg7+b55ru6koALBgIsBO3lEkut+0W6OCUkwHKiTUynpXinSE3qgOiYMrAwxeyj8zKNKoLcworppmlulYygYSlXmDNkvhGgfcYCdK+LpwMr5loyOpHg4e+e6TkJKzmoorKt9ML+Kqq+SmsmThyabaMkGqa6VzOL1jh/0lSfK1qB4g1xewLNRfcRslIl65xmu/0/649o4DFbjhZ/TUo4aiXh77+qL4sGG71+06jR8/wlQSwMEFAAAAAgAwn5hXCAbhuqyAAAALgEAAAsAAABfcmVscy8ucmVsc43Puw6CMBQG4J2naM4uBQdjDIXFmLAafICmPZRGeklbL7y9HRzEODie23fyN93TzOSOIWpnGdRlBQStcFJbxeAynDZ7IDFxK/nsLDJYMELXFs0ZZ57yTZy0jyQjNjKYUvIHSqOY0PBYOo82T0YXDE+5DIp6Lq5cId1W1Y6GTwPagpAVS3rJIPSyBjIsHv/h3ThqgUcnbgZt+vHlayPLPChMDB4uSCrf7TKzQHNKuorZvgBQSwMEFAAAAAgAwn5hXK+4RN+kAQAAtwMAABEAAAB3b3JkL2RvY3VtZW50LnhtbK1SwW7bMAy99ysI3RelOwyDEbsYuhToqcGcoWdGohNhkmhIjJPc9i37tH3JFMdth23AUKCGAJPQ43viIxc3x+BhoJQdx1pdz+YKKBq2Lm5r9XV99+6jgiwYLXqOVKsTZXXTXC0OlWWzDxQFCkPM1aE3tdqJ9JXW2ewoYJ4FZxJn7mRmOGjuOmdIHzhZ/X5+PR+jPrGhnIvcLcYBs7qC376J+i9i7imWu45TQClp2v5BFnyRmH/QAV1UTeEsD96wPTUjfUn6ZhI6x6vUnH+tnDzBoRrQ12rtxJPSzUJfAC/wESxNO5B3R1geMfSl7PNkxxkvY9VUMxL8U3ciWu9chnIQ8oXqsbQCz/buM1nADK1wOm2Yv4FQFrAoCMUAkB3BLXuPG064tK7AoNjdl3FFmb32OenixebS+JRJc0co+0QZLAWOWRIK2erVvZ4HWuUeTdmkvtBRGkg1AD+//4DHh9U9lAkKG/bgotC2qJS1fFOVZ6fgIXoXCVyXMBBQ2JB9U6UWB9Ke0cLg8NLdl2W7hk+r+//oZDKySnpcW/20t+foaSeaX1BLAQIUAxQAAAAIAMJ+YVzXeYTq8QAAALgBAAATAAAAAAAAAAAAAACAAQAAAABbQ29udGVudF9UeXBlc10ueG1sUEsBAhQDFAAAAAgAwn5hXCAbhuqyAAAALgEAAAsAAAAAAAAAAAAAAIABIgEAAF9yZWxzLy5yZWxzUEsBAhQDFAAAAAgAwn5hXK+4RN+kAQAAtwMAABEAAAAAAAAAAAAAAIAB/QEAAHdvcmQvZG9jdW1lbnQueG1sUEsFBgAAAAADAAMAuQAAANADAAAAAA==";
  const bin = atob(b64);
  const bytes = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  return new Blob([bytes], {
    type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  });
}

/** Minimal valid .xlsx — built in-memory without any library. */
function generateXlsx(): Blob {
  const crcTable = new Uint32Array(256);
  for (let n = 0; n < 256; n++) {
    let c = n;
    for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
    crcTable[n] = c;
  }
  function crc32(buf: Uint8Array): number {
    let c = 0xffffffff;
    for (let i = 0; i < buf.length; i++)
      c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
    return (c ^ 0xffffffff) >>> 0;
  }

  const enc = new TextEncoder();
  const files = [
    {
      name: "[Content_Types].xml",
      data: enc.encode(
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>`,
      ),
    },
    {
      name: "_rels/.rels",
      data: enc.encode(
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`,
      ),
    },
    {
      name: "xl/workbook.xml",
      data: enc.encode(
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>`,
      ),
    },
    {
      name: "xl/_rels/workbook.xml.rels",
      data: enc.encode(
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>`,
      ),
    },
    {
      name: "xl/worksheets/sheet1.xml",
      data: enc.encode(
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Hello</t></is></c></row></sheetData></worksheet>`,
      ),
    },
  ];

  const localParts: Uint8Array[] = [];
  const centralDir: Uint8Array[] = [];
  let offset = 0;

  for (const file of files) {
    const name = enc.encode(file.name);
    const crc = crc32(file.data);
    const size = file.data.length;

    const lfh = new ArrayBuffer(30 + name.length);
    const lv = new DataView(lfh);
    lv.setUint32(0, 0x04034b50, true);
    lv.setUint16(4, 20, true);
    lv.setUint16(6, 0, true);
    lv.setUint16(8, 0, true);
    lv.setUint16(10, 0, true);
    lv.setUint16(12, 0, true);
    lv.setUint32(14, crc, true);
    lv.setUint32(18, size, true);
    lv.setUint32(22, size, true);
    lv.setUint16(26, name.length, true);
    lv.setUint16(28, 0, true);
    new Uint8Array(lfh, 30).set(name);

    const cdh = new ArrayBuffer(46 + name.length);
    const cv = new DataView(cdh);
    cv.setUint32(0, 0x02014b50, true);
    cv.setUint16(4, 20, true);
    cv.setUint16(6, 20, true);
    cv.setUint16(8, 0, true);
    cv.setUint16(10, 0, true);
    cv.setUint16(12, 0, true);
    cv.setUint16(14, 0, true);
    cv.setUint32(16, crc, true);
    cv.setUint32(20, size, true);
    cv.setUint32(24, size, true);
    cv.setUint16(28, name.length, true);
    cv.setUint16(30, 0, true);
    cv.setUint16(32, 0, true);
    cv.setUint16(34, 0, true);
    cv.setUint16(36, 0, true);
    cv.setUint32(38, 0, true);
    cv.setUint32(42, offset, true);
    new Uint8Array(cdh, 46).set(name);

    localParts.push(new Uint8Array(lfh), file.data);
    centralDir.push(new Uint8Array(cdh));
    offset += 30 + name.length + size;
  }

  const cdSize = centralDir.reduce((s, b) => s + b.length, 0);
  const eocd = new ArrayBuffer(22);
  const ev = new DataView(eocd);
  ev.setUint32(0, 0x06054b50, true);
  ev.setUint16(4, 0, true);
  ev.setUint16(6, 0, true);
  ev.setUint16(8, files.length, true);
  ev.setUint16(10, files.length, true);
  ev.setUint32(12, cdSize, true);
  ev.setUint32(16, offset, true);
  ev.setUint16(20, 0, true);

  return new Blob([...localParts, ...centralDir, new Uint8Array(eocd)], {
    type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  });
}
```

Helper component `./CollaboraInsertTextDemo.svelte`:

```svelte
<script lang="ts">
  import type { ContentEditorProps } from "./types.js";
  import ContentEditor from "./ContentEditor.svelte";

  let { value, filename, wopiUrl, discoverUrl, adminKey, userid, username }: ContentEditorProps =
    $props();

  let insertText = $state<((text: string) => void) | undefined>();
  let insertHtml = $state<((html: string) => void) | undefined>();
  let inputText = $state("Hello from Storybook!");
</script>

<div style="display:flex;flex-direction:column;height:100vh;">
  <div
    style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:#f5f5f5;border-bottom:1px solid #ddd;flex-shrink:0;"
  >
    <input
      bind:value={inputText}
      placeholder="Text to insert…"
      style="flex:1;padding:4px 8px;border:1px solid #ccc;border-radius:4px;"
    />
    <button
      onclick={() => insertText?.(inputText)}
      disabled={!insertText}
      style="padding:4px 12px;"
    >
      Insert text
    </button>
    <button
      onclick={() => insertHtml?.(`<b>${inputText}</b>`)}
      disabled={!insertHtml}
      style="padding:4px 12px;"
    >
      Insert bold HTML
    </button>
    {#if !insertText}
      <span style="font-size:12px;color:#888;">Editor loading…</span>
    {/if}
  </div>

  <div style="flex:1;min-height:0;">
    <ContentEditor
      {value}
      {filename}
      {wopiUrl}
      {discoverUrl}
      {adminKey}
      {userid}
      {username}
      bind:insertText
      bind:insertHtml
    />
  </div>
</div>
```

#### Type Script File (`TypeScriptFile`)

```ts
// ── Monaco stories ─────────────────────────────────────────────────────────

export const TypeScriptFile: Story = {
  args: {
    filename: "example.ts",
    value: new Blob(
      ['const greeting: string = "Hello, World!";\nconsole.log(greeting);\n'],
      { type: "text/plain" },
    ),
    readonly: false,
  },
};
```

#### Read Only (`ReadOnly`)

```ts
export const ReadOnly: Story = {
  args: {
    filename: "config.json",
    value: new Blob(['{\n  "name": "svelix",\n  "version": "1.0.0"\n}\n'], {
      type: "application/json",
    }),
    readonly: true,
  },
};
```

#### Xml File (`XmlFile`)

```ts
/** Unformatted XML: use Format Document (Shift+Alt+F); remove a closing tag to see the error marker. */
export const XmlFile: Story = {
  args: {
    filename: "catalog.xml",
    value: new Blob(
      ['<?xml version="1.0"?><catalog><book id="1"><title>Svelte</title><price>10</price></book><book id="2"><title>Skeleton</title></book></catalog>'],
      { type: "application/xml" },
    ),
    readonly: false,
  },
};
```

#### Image File (`ImageFile`)

```ts
// ── Media stories (loaded from online) ────────────────────────────────────

export const ImageFile: Story = {
  args: { filename: "photo.jpg", readonly: true },
  loaders: [
    async () => ({
      blob: await fetch("https://picsum.photos/seed/svelix/1200/800").then(
        (r) => r.blob(),
      ),
    }),
  ],
  render: (args, { loaded }) => ({
    Component: ContentEditor,
    props: { ...args, value: loaded.blob },
  }),
};
```

#### Audio File (`AudioFile`)

```ts
export const AudioFile: Story = {
  args: { filename: "tone.wav", readonly: true },
  loaders: [async () => ({ blob: generateWav() })],
  render: (args, { loaded }) => ({
    Component: ContentEditor,
    props: { ...args, value: loaded.blob },
  }),
};
```

#### Video File (`VideoFile`)

```ts
export const VideoFile: Story = {
  args: { filename: "sample.mp4", readonly: true },
  loaders: [
    async () => ({
      blob: await fetch(
        "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
      ).then((r) => r.blob()),
    }),
  ],
  render: (args, { loaded }) => ({
    Component: ContentEditor,
    props: { ...args, value: loaded.blob },
  }),
};
```

#### Pdf File (`PdfFile`)

```ts
export const PdfFile: Story = {
  args: { filename: "example.pdf", readonly: true, value: generatePdf() },
  render: (args) => ({
    Component: ContentEditor,
    props: args,
  }),
};
```

#### Markdown View (`MarkdownView`)

```ts
export const MarkdownView: Story = {
  args: {
    filename: "readme.md",
    value: new Blob([SAMPLE_MARKDOWN], { type: "text/markdown" }),
    readonly: true,
  },
};
```

#### Markdown Edit (`MarkdownEdit`)

```ts
export const MarkdownEdit: Story = {
  args: {
    filename: "notes.md",
    value: new Blob([SAMPLE_MARKDOWN], { type: "text/markdown" }),
    readonly: false,
  },
};
```

#### Html Edit (`HtmlEdit`)

```ts
export const HtmlEdit: Story = {
  args: {
    filename: "page.html",
    value: new Blob([SAMPLE_HTML], { type: "text/html" }),
    readonly: false,
  },
};
```

#### Html Read Only (`HtmlReadOnly`)

```ts
export const HtmlReadOnly: Story = {
  args: {
    filename: "snippet.html",
    value: new Blob([SAMPLE_HTML], { type: "text/html" }),
    readonly: true,
  },
};
```

#### Html Edit No Header (`HtmlEditNoHeader`)

```ts
export const HtmlEditNoHeader: Story = {
  args: {
    filename: "page.html",
    value: new Blob([SAMPLE_HTML], { type: "text/html" }),
    readonly: false,
    hideHeader: true,
  },
};
```

#### Collabora Docx (`CollaboraDocx`)

```ts
export const CollaboraDocx: Story = {
  args: {
    filename: "document.docx",
    value: generateDocx(),
    wopiUrl: "https://wopi.warky.dev/",
    discoverUrl: "https://office.warky.dev/hosting/discovery",
    adminKey: "borg@1989",
  },
};
```

#### Collabora Xlsx (`CollaboraXlsx`)

```ts
export const CollaboraXlsx: Story = {
  args: {
    filename: "spreadsheet.xlsx",
    value: generateXlsx(),
    wopiUrl: "https://wopi.warky.dev/",
    discoverUrl: "https://office.warky.dev/hosting/discovery",
    adminKey: "borg@1989",
  },
};
```

#### Collabora Insert Text (`CollaboraInsertText`)

```ts
export const CollaboraInsertText: Story = {
  render: (args) => ({ Component: CollaboraInsertTextDemo, props: args }),
  args: {
    filename: "document.docx",
    value: generateDocx(),
    wopiUrl: "https://wopi.warky.dev/",
    discoverUrl: "https://office.warky.dev/hosting/discovery",
    adminKey: "borg@1989",
  },
};
```

#### Collabora Missing Config (`CollaboraMissingConfig`)

```ts
export const CollaboraMissingConfig: Story = {
  args: {
    filename: "spreadsheet.xlsx",
    // wopiUrl and discoverUrl intentionally omitted → shows config warning
  },
};
```

#### Office365 Docx (`Office365Docx`)

```ts
// ── Office 365 stories ────────────────────────────────────────────────────

export const Office365Docx: Story = {
  args: {
    filename: "document.docx",
    value: generateDocx(),
    officeSuite: "office365",
    wopiUrl: "https://wopi.warky.dev/",
    adminKey: "borg@1989",
  },
};
```

#### No Filename (`NoFilename`)

```ts
// ── Fallback stories ───────────────────────────────────────────────────────

export const NoFilename: Story = {
  args: {
    filename: undefined,
    contentType: undefined,
  },
};
```

#### Unknown Binary File (`UnknownBinaryFile`)

```ts
export const UnknownBinaryFile: Story = {
  args: {
    filename: "mystery.binx",
    contentType: "application/octet-stream",
    value: new Blob(
      [
        new Uint8Array([
          0x4d, 0x59, 0x53, 0x54, 0x00, 0x10, 0x7f, 0xa5, 0x42, 0x99, 0x01,
          0x02,
        ]),
      ],
      { type: "application/octet-stream" },
    ),
    readonly: true,
  },
};
```

---

# Dialog

Import: `import { ... } from '@warky/svelix/Dialog'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as Dialog } from './Dialog.svelte';
export { default as Drawer } from './Drawer.svelte';
export { default as ConfirmDialog } from './ConfirmDialog.svelte';
export { confirmDialog } from './confirmDialog.js';
export type { ConfirmOptions, ConfirmVariant } from './types.js';
export type { Props as ConfirmDialogProps } from './ConfirmDialog.svelte';
export type { Props as DialogProps } from './Dialog.svelte';
export type { Props as DrawerProps } from './Drawer.svelte';
export { DIALOG_DEFAULTS, DRAWER_DEFAULTS } from './dialogLayout.js';
export type { DialogLayoutConfig, DrawerLayoutConfig } from './dialogLayout.js';
```

## Types (types.ts)

```ts
export type ConfirmVariant = 'danger' | 'primary' | 'warning';

export interface ConfirmOptions {
	cancelLabel?: string;
	confirmLabel?: string;
	message?: string;
	title?: string;
	variant?: ConfirmVariant;
	width?: string;
}
```

## Props

### ConfirmDialog.svelte

```ts
export interface Props {
		cancelLabel?: string;
		confirmLabel?: string;
		message?: string;
		onCancel?: () => void;
		onConfirm?: () => void;
		open?: boolean;
		title?: string;
		variant?: ConfirmVariant;
		width?: string;
	}
```

### Dialog.svelte

```ts
export interface Props {
		children?: Snippet;
		contentClass?: string;
		closeOnEsc?: boolean;
		onEscapeKeyDown?: (event: KeyboardEvent) => void;
		open?: boolean;
		onClose?: () => void;
		onRequestClose?: () => boolean | Promise<boolean>;
		title?: string;
		positionerClass?: string;
		width?: string;
	}
```

### Drawer.svelte

```ts
export interface Props {
		children?: Snippet;
		contentClass?: string;
		closeOnEsc?: boolean;
		onEscapeKeyDown?: (event: KeyboardEvent) => void;
		onClose?: () => void;
		onRequestClose?: () => boolean | Promise<boolean>;
		open?: boolean;
		title?: string;
		positionerClass?: string;
		width?: string;
	}
```

## Examples (from Storybook stories)

### Components/Dialog/OverlayTracking  (`OverlayTracking.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, waitFor, within } from 'storybook/test';

import OverlayTrackingPreview from './OverlayTrackingPreview.svelte';

const read = (root: HTMLElement, id: string) => within(root).getByTestId(id).textContent;
```

Helper component `./OverlayTrackingPreview.svelte`:

```svelte
<script lang="ts">
	import Dialog from './Dialog.svelte';
	import Drawer from './Drawer.svelte';
	import { useSvelixOverlays } from '../SvelixLayout/overlays.svelte.js';

	interface Props {
		/** The dialog refuses a normal close (e.g. a pending confirmation). */
		veto?: boolean;
	}
	const { veto = false }: Props = $props();

	const overlays = useSvelixOverlays();
	let dialogOpen = $state(true);
	let drawerOpen = $state(true);
	let result = $state('');
</script>

<span data-testid="count">{overlays.count}</span>
<span data-testid="labels">{overlays.overlays.map((o) => `${o.kind}:${o.label}`).join(',')}</span>
<span data-testid="result">{result}</span>
<button type="button" onclick={async () => (result = String(await overlays.closeAll()))}>Close all</button>
<button type="button" onclick={async () => (result = String(await overlays.closeAll({ force: true })))}>Force close all</button>

<Dialog bind:open={dialogOpen} title="First dialog" onRequestClose={() => !veto}>
	<div class="p-6">Dialog body</div>
</Dialog>
<Drawer bind:open={drawerOpen} title="Side drawer">
	<div class="p-6">Drawer body</div>
</Drawer>
```

#### Close All (`CloseAll`)

Renders the helper component shown above (no args).

#### Veto And Force (`VetoAndForce`)

```ts
/** A veto stops closeAll (older dialog stays); force closes regardless. */
export const VetoAndForce: Story = {
	args: { veto: true },
};
```

---

# ErrorBoundary

Import: `import { ... } from '@warky/svelix/ErrorBoundary'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as ErrorBoundary } from './ErrorBoundary.svelte';
export { errorManager } from './ErrorManager';
export * from './types';
```

## Types (types.ts)

```ts
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface CustomAPIConfig {
  endpoint: string;
  headers?: Record<string, string>;
  method?: 'POST' | 'PUT';
  transformPayload?: (report: ErrorReport) => any;
}

export interface ErrorContext {
  componentStack?: string;
  extra?: Record<string, any>;
  namespace?: string;
  tags?: Record<string, string>;
  user?: Record<string, any>;
}

export interface ErrorManagerConfig {
  beforeReport?: (report: ErrorReport) => ErrorReport | null;
  customAPI?: CustomAPIConfig;
  enabled?: boolean;
  onReportFailure?: (error: Error, report: ErrorReport) => void;
  onReportSuccess?: (report: ErrorReport) => void;
  reporters?: ErrorReporter[];
  sentry?: SentryConfig;
}

export interface ErrorReport {
  context?: ErrorContext;
  error: Error;
  errorInfo?: any;
  severity?: ErrorSeverity;
  timestamp?: number;
}

export interface ErrorReporter {
  captureError: (report: ErrorReport) => Promise<void> | void;
  isEnabled: () => boolean;
  name: string;
}

export type ErrorSeverity = 'debug' | 'error' | 'fatal' | 'info' | 'warning';

export interface SentryConfig {
  beforeSend?: (event: any) => any | null;
  dsn: string;
  environment?: string;
  ignoreErrors?: string[];
  release?: string;
  sampleRate?: number;
}
```

## Props

### ErrorBoundary.svelte

```ts
export interface Props {
		children?: Snippet;
		namespace?: string;
		onReportClick?: () => void;
		onResetClick?: () => void;
		onRetryClick?: () => void;
	}
```

## Examples (from Storybook stories)

### Components/ErrorBoundary  (`ErrorBoundary.stories.ts`)

Shared setup:

```ts
import ErrorBoundary from './ErrorBoundary.svelte';

import ErrorBoundaryPreview from './ErrorBoundaryPreview.svelte';
```

Helper component `./ErrorBoundaryPreview.svelte`:

```svelte
<script lang="ts">
  import Button from "../FormerControllers/ButtonCtrl.svelte";
  import ErrorBoundary from "./ErrorBoundary.svelte";

  export interface Props {
    namespace?: string;
  }

  const { namespace = "ExampleComponent" }: Props = $props();

  let errorBoundary: { handleError: (error: Error) => void } | undefined =
    $state();

  async function throwError() {
    if (errorBoundary) {
      errorBoundary.handleError(
        new Error("This is a test error triggered by the button"),
      );
    }
  }
</script>

<ErrorBoundary bind:this={errorBoundary} {namespace}>
  <div class="p-8 space-y-4">
    <h2 class="text-2xl font-bold">Error Boundary Example</h2>
    <p class="text-surface-500">
      Click the button below to trigger an error and see the error boundary in
      action.
    </p>
    <Button variant="filled" onclick={throwError}>
      Set an error if clicked
    </Button>
  </div>
</ErrorBoundary>
```

#### Default (`Default`)

```ts
export const Default: Story = {
	args: {
		namespace: 'ExampleComponent',
	},
	render: (args) => ({
		Component: ErrorBoundaryPreview,
		props: args
	})
};
```

---

# Former

Import: `import { ... } from '@warky/svelix/Former'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as Former } from './Former.svelte';
export { default as FormerButtonArea } from './FormerButtonArea.svelte';
export { default as FormerDrawer } from './FormerDrawer.svelte';
export { default as FormerModal } from './FormerModal.svelte';
export { createFormerState } from './formerState.svelte';
export { createOverlayZIndex, deriveAutoTitle } from './formerOverlayHelpers.svelte';
export { createFieldArray } from './formerFieldArray.svelte';
export { applyFormerResolve, collectFormerResolve, mergeFormerResolve } from './formerResolve';
export type { FieldArrayEntry, FieldArrayHandle, FieldArrayOptions } from './formerFieldArray.svelte';
export { FormerResolveSpecAPI } from './FormerResolveSpecAPI';
export { FormerRestHeadSpecAPI } from './FormerRestHeadSpecAPI';
export * from './types';
export * from './formerLayout';
```

## Types (types.ts)

```ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Snippet } from 'svelte';
import type { Resolver } from '../Resolver/types.js';

export type FormRequestType = 'delete' | 'insert' | 'select' | 'update' | 'view';

export type FormerAPICallType<T = any> = (
  mode: 'mutate' | 'read',
  request: FormRequestType,
  value?: T,
  key?: number | string
) => Promise<T>;

export interface FormerLayout<T = any> {
  buttonArea?: 'bottom' | 'none' | 'top';
  closeButtonTitle?: string;
  renderBottom?: Snippet<[FormerState<T>]>;
  renderTop?: Snippet<[FormerState<T>]>;
  saveButtonTitle?: string;
  showKeepOpenSwitch?: boolean;
  title?: string;
}

/** Prefill step run when the form opens (see `FormerProps.resolve`). */
export interface FormerResolveItem<T = any> {
  /** Resolver input, or a getter receiving the current values. */
  input?: unknown | ((values: T | undefined) => unknown);
  /**
   * Result path -> values path (dot paths). Unlisted result fields are dropped.
   * A function returns the patch to merge. Default: result keys map to the same value keys.
   */
  map?: Record<string, string> | ((result: any, values: T) => Record<string, unknown>);
  /** Replace non-empty values too. Default: only empty fields are filled. */
  overwrite?: boolean;
  /** Request types this item runs for. Default `['insert']`. Non-insert runs after `load()`. */
  requests?: FormRequestType[];
  /** Registry key (needs a `ResolverProvider` or an Intl built-in) or a resolver function. */
  resolver: string | Resolver;
}

export interface FormerProps<T = any> {
  SaveOnEnter?: boolean;
  beforeGet?: (data: T, state: Partial<FormerState<T>>) => Promise<T> | T;
  afterGet?: (data: T, state: Partial<FormerState<T>>) => Promise<T> | void;
  afterSave?: (data: T, state: Partial<FormerState<T>>) => Promise<void> | void;
  beforeSave?: (data: T, state: Partial<FormerState<T>>) => Promise<T> | T;
  children?: Snippet<[FormerState<T>]>;
  closeOnEsc?: boolean;
  confirmCloseMessage?: string;
  dirty?: boolean;
  dirtyComparator?: (values: T, initialValues: T) => boolean;
  dirtyDeps?: () => boolean;
  id?: string;
  keepOpen?: boolean;
  layout?: FormerLayout<T>;
  mounted?: boolean;
  onAPICall?: FormerAPICallType<T>;
  onChange?: (value: T, state: Partial<FormerState<T>>) => void;
  onClose?: (data?: T) => void;
  onConfirmClose?: (values?: T) => boolean | Promise<boolean>;
  onConfirmDelete?: (values?: T) => Promise<boolean>;
  onError?: (error: Error | string, state: Partial<FormerState<T>>) => void;
  onOpen?: (data?: T) => void;
  opened?: boolean;
  primeData?: T;
  request?: FormRequestType;
  /** Prefill values from resolvers on open; does not mark the form dirty. */
  resolve?: FormerResolveItem<T>[];
  uniqueKeyField?: string;
  values?: T;
}

export interface FormerRef<T = any> {
	clearDirty: () => void;
	close: () => Promise<void>;
	confirmClose: () => boolean | Promise<boolean>;
  getValue: () => T | undefined;
  markDirty: () => void;
  reset: () => void;
  save: () => Promise<T | undefined>;
  setValue: (value: T) => void;
  show: () => Promise<void>;
  validate: () => Promise<boolean>;
}

export interface FormerState<T = any> {
  clearDirty: () => void;
  deleteConfirmed?: boolean;
  dirty?: boolean;
  error?: string;
  getAllState: () => FormerStateAndProps<T>;
  getState: <K extends keyof FormerStateAndProps<T>>(key: K) => FormerStateAndProps<T>[K];
  load: (reset?: boolean) => Promise<void>;
  loading?: boolean;
  markDirty: () => void;
  opened?: boolean;
  primeData?: T;
  request?: FormRequestType;
  reset: () => Promise<void>;
  save: () => Promise<T | undefined>;
  setRequest: (request: FormRequestType) => void;
  setState: <K extends keyof FormerStateAndProps<T>>(key: K, value: FormerStateAndProps<T>[K]) => void;
  validate: () => Promise<boolean>;
  values?: T;
}

export type FormerStateAndProps<T = any> = FormerProps<T> & Partial<FormerState<T>>;

/** `SvelixLayout` key `former`. */
export interface FormerLayoutConfig {
  SaveOnEnter?: boolean;
  closeOnEsc?: boolean;
  uniqueKeyField?: string;
}

/** `SvelixLayout` key `formerDrawer`. */
export interface FormerDrawerLayoutConfig extends FormerLayoutConfig {
  width?: string;
}

/** `SvelixLayout` key `formerModal`. */
export interface FormerModalLayoutConfig extends FormerLayoutConfig {
  width?: string;
  surfaceClass?: string;
  borderClass?: string;
}
```

## Props

### Former.svelte

```ts
export interface Props<T = any> {
    SaveOnEnter?: boolean;
    // Called before the GET request is made; can transform the outgoing payload.
    beforeGet?: (data: T, state: Partial<FormerState<T>>) => Promise<T> | T;
    afterGet?: (data: T, state: Partial<FormerState<T>>) => Promise<T> | void;
    afterSave?: (
      data: T,
      state: Partial<FormerState<T>>,
    ) => Promise<void> | void;
    beforeSave?: (data: T, state: Partial<FormerState<T>>) => Promise<T> | T;
    children?: Snippet<[FormerState<T>]>;
    closeOnEsc?: boolean;
    confirmCloseMessage?: string;
    // Custom equality check; return true when values should count as dirty.
    // Replaces the default JSON.stringify comparison entirely when provided.
    dirtyComparator?: (values: T, initialValues: T) => boolean;
    // Called on every dirty re-check; reads inside it are tracked reactively,
    // so it can pull in external relationship state (e.g. a related list)
    // that isn't part of `values`. Returning true forces dirty=true.
    dirtyDeps?: () => boolean;
    id?: string;
    keepOpen?: boolean;
    layout?: FormerLayout<T>;
    mounted?: boolean;
    onAPICall?: FormerAPICallType<T>;
    onChange?: (value: T, state: Partial<FormerState<T>>) => void;
    onClose?: (data?: T) => void;
    onConfirmClose?: (values?: T) => boolean | Promise<boolean>;
    onConfirmDelete?: (values?: T) => Promise<boolean>;
    onError?: (error: Error | string, state: Partial<FormerState<T>>) => void;
    onOpen?: (data?: T) => void;
    opened?: boolean;
    primeData?: T;
    request?: FormRequestType;
    // Prefill values from resolvers when the form opens; never marks the form dirty.
    resolve?: FormerResolveItem<T>[];
    uniqueKeyField?: string;
    values?: T;
  }
```

### FormerButtonArea.svelte

```ts
export interface Props {
    closeButtonTitle?: string;
    deleteConfirmed?: boolean;
    dirty?: boolean;
    keepOpen?: boolean;
    onClose?: () => void;
    onSave?: () => Promise<void>;
    onToggleKeepOpen?: (checked: boolean) => void;
    request?: FormRequestType;
    saveButtonTitle?: string;
    showKeepOpenSwitch?: boolean;
  }
```

### FormerDrawer.svelte

```ts
export interface Props extends FormerProps { title?: string; width?: string; }
```

### FormerModal.svelte

```ts
export interface Props extends FormerProps {
		title?: string;
		width?: string;
		surfaceClass?: string;
		borderClass?: string;
	}
```

## Examples (from Storybook stories)

### Components/Former  (`Former.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, waitFor, within } from 'storybook/test';

import { vi } from 'vitest';

import Former from './Former.svelte';

import FormerPreview from './FormerPreview.svelte';

import FormerModalPreview from './FormerModalPreview.svelte';

import FormerDrawerPreview from './FormerDrawerPreview.svelte';

import FormerRestApiPreview from './FormerRestApiPreview.svelte';

import FormerDirtyPreview from './FormerDirtyPreview.svelte';

import FormerFieldArrayPreview from './FormerFieldArrayPreview.svelte';

import FormerControlsPreview from './FormerControlsPreview.svelte';

import FormerConfirmClosePreview from './FormerConfirmClosePreview.svelte';

// ---- onConfirmClose (Modal + Drawer) ----
const confirmCloseCase = (kind, mode, expectClosed) => ({
  render: () => ({ Component: FormerConfirmClosePreview, props: { kind, mode } }),
  play: async ({ canvasElement }) => {
    const nativeConfirm = vi.spyOn(window, 'confirm');
    const canvas = within(canvasElement);
    const body = within(document.body);
    await userEvent.click(canvas.getByRole('button', { name: /open form/i }));
    // Type only once the open transition has finished (Former resets its dirty baseline on load): dialog open and no running animations.
    await waitFor(() => {
      const dialog = body.getByRole('dialog');
      expect(dialog.getAttribute('data-state')).toBe('open');
      expect(dialog.getAnimations({ subtree: true })).toHaveLength(0);
    });
    const input = await body.findByLabelText(/name/i);
    await userEvent.type(input, 'x');

    // Close via the header X button; a second click while pending must not re-ask.
    const closeBtn = body.getByRole('button', { name: /close (dialog|drawer)/i });
    // Native clicks: zag's modal body lock trips userEvent's pointer-events check in the test browser.
    closeBtn.click();
    closeBtn.click();
    // Wait for the handler to settle instead of sleeping a fixed time.
    await waitFor(() => expect(canvas.getByTestId('settled-calls').textContent).toBe('1'));

    await expect(nativeConfirm).not.toHaveBeenCalled();
    await expect(canvas.getByTestId('confirm-calls').textContent).toBe('1');
    if (expectClosed) {
      await waitFor(() => expect(body.queryByLabelText(/name/i)).toBeNull());
    } else {
      await expect(body.getByLabelText(/name/i)).toBeVisible();
      // Not stuck: Esc can ask again after settling.
      body.getByLabelText(/name/i).focus();
      await userEvent.keyboard('{Escape}');
      await waitFor(() => expect(canvas.getByTestId('confirm-calls').textContent).toBe('2'));
    }
    nativeConfirm.mockRestore();
  },
});
```

Helper component `./FormerPreview.svelte`:

```svelte
<script lang="ts">
  import Former from './Former.svelte';
  import type { FormRequestType } from './types';
  import TextInputCtrl from '#lib/components/FormerControllers/TextInputCtrl.svelte';
  import PasswordInputCtrl from '#lib/components/FormerControllers/PasswordInputCtrl.svelte';
  import NumberInputCtrl from '#lib/components/FormerControllers/NumberInputCtrl.svelte';
  import NativeSelectCtrl from '#lib/components/FormerControllers/NativeSelectCtrl.svelte';
  import TextAreaCtrl from '#lib/components/FormerControllers/TextAreaCtrl.svelte';
  import SwitchCtrl from '#lib/components/FormerControllers/SwitchCtrl.svelte';

  interface UserForm {
    id?: number;
    firstName: string;
    lastName: string;
    email: string;
    password: string;
    age?: number;
    role: string;
    department: string;
    bio: string;
    website: string;
    emailNotifications: boolean;
    active: boolean;
  }

  export interface Props {
    request?: FormRequestType;
    showKeepOpenSwitch?: boolean;
  }

  const { request = 'insert', showKeepOpenSwitch = false }: Props = $props();

  const emptyUser: UserForm = {
    firstName: '',
    lastName: '',
    email: '',
    password: '',
    age: undefined,
    role: '',
    department: '',
    bio: '',
    website: '',
    emailNotifications: true,
    active: true,
  };

  const filledUser: UserForm = {
    id: 1,
    firstName: 'Jane',
    lastName: 'Smith',
    email: 'jane.smith@example.com',
    password: '',
    age: 32,
    role: 'admin',
    department: 'engineering',
    bio: 'Senior software engineer with 10 years of experience building scalable systems.',
    website: 'https://janesmith.dev',
    emailNotifications: true,
    active: true,
  };

  // eslint-disable-next-line svelte/no-unused-svelte-ignore
  // svelte-ignore state_referenced_locally -- initial value is intentional (sets up form state once)
  let values = $state<UserForm>(request === 'insert' ? { ...emptyUser } : { ...filledUser });
  let lastSaved = $state<UserForm | undefined>(undefined);
  let opened = $state(true);

  const roleOptions = [
    { label: 'Admin', value: 'admin' },
    { label: 'Editor', value: 'editor' },
    { label: 'Viewer', value: 'viewer' },
  ];

  const departmentOptions = [
    { label: 'Engineering', value: 'engineering' },
    { label: 'Product', value: 'product' },
    { label: 'Design', value: 'design' },
    { label: 'Marketing', value: 'marketing' },
    { label: 'Operations', value: 'operations' },
  ];
</script>

<div class="p-6 space-y-6">
  <div class="card p-6" style="min-height: 500px;">
    <Former
      bind:values
      bind:opened
      {request}
      layout={{ showKeepOpenSwitch, buttonArea: 'bottom' }}
      onAPICall={async (mode, _req, data) => {
        await new Promise((resolve) => setTimeout(resolve, 800));
        if (mode === 'mutate') {
          return { ...data, id: (data as UserForm)?.id ?? 42 } as UserForm;
        }
        return data as UserForm;
      }}
      afterSave={(data) => {
        lastSaved = data as UserForm;
      }}
      onClose={() => {
        opened = false;
      }}
    >
      {#snippet children(state)}
        {@const isReadonly = state.request === 'view' || state.request === 'delete'}

        <div class="space-y-4">
          <!-- Row 1: First Name / Last Name -->
          <div class="grid grid-cols-2 gap-4">
            <TextInputCtrl
              label="First Name"
              name="firstName"
              value={state.values?.firstName ?? ''}
              required
              disabled={isReadonly}
              onchange={(v) => state.setState('values', { ...state.values, firstName: v })}
            />
            <TextInputCtrl
              label="Last Name"
              name="lastName"
              value={state.values?.lastName ?? ''}
              required
              disabled={isReadonly}
              onchange={(v) => state.setState('values', { ...state.values, lastName: v })}
            />
          </div>

          <!-- Row 2: Email -->
          <div>
            <TextInputCtrl
              label="Email"
              name="email"
              type="email"
              value={state.values?.email ?? ''}
              required
              disabled={isReadonly}
              onchange={(v) => state.setState('values', { ...state.values, email: v })}
            />
          </div>

          <!-- Row 3: Password (insert only) -->
          {#if state.request === 'insert'}
            <div>
              <PasswordInputCtrl
                label="Password"
                name="password"
                value={state.values?.password ?? ''}
                required
                disabled={isReadonly}
                onchange={(v) => state.setState('values', { ...state.values, password: v })}
              />
            </div>
          {/if}

          <!-- Row 4: Age / Website -->
          <div class="grid grid-cols-2 gap-4">
            <NumberInputCtrl
              label="Age"
              name="age"
              value={state.values?.age}
              min={18}
              max={120}
              disabled={isReadonly}
              onchange={(v) => state.setState('values', { ...state.values, age: v })}
            />
            <TextInputCtrl
              label="Website"
              name="website"
              type="url"
              value={state.values?.website ?? ''}
              disabled={isReadonly}
              onchange={(v) => state.setState('values', { ...state.values, website: v })}
            />
          </div>

          <!-- Row 5: Role / Department -->
          <div class="grid grid-cols-2 gap-4">
            <NativeSelectCtrl
              label="Role"
              name="role"
              value={state.values?.role ?? ''}
              required
              placeholder="Select role"
              options={roleOptions}
              disabled={isReadonly}
              onchange={(v) => state.setState('values', { ...state.values, role: v })}
            />
            <NativeSelectCtrl
              label="Department"
              name="department"
              value={state.values?.department ?? ''}
              placeholder="Select department"
              options={departmentOptions}
              disabled={isReadonly}
              onchange={(v) => state.setState('values', { ...state.values, department: v })}
            />
          </div>

          <!-- Row 6: Bio -->
          <div>
            <TextAreaCtrl
              label="Bio"
              name="bio"
              value={state.values?.bio ?? ''}
              rows={3}
              placeholder="Tell us about yourself..."
              disabled={isReadonly}
              onchange={(v) => state.setState('values', { ...state.values, bio: v })}
            />
          </div>

          <!-- Row 7: Switches -->
          <div class="flex gap-8">
            <SwitchCtrl
              label="Email Notifications"
              name="emailNotifications"
              checked={state.values?.emailNotifications ?? true}
              disabled={isReadonly}
              onchange={(c) => state.setState('values', { ...state.values, emailNotifications: c })}
            />
            <SwitchCtrl
              label="Active"
              name="active"
              checked={state.values?.active ?? true}
              disabled={isReadonly}
              onchange={(c) => state.setState('values', { ...state.values, active: c })}
            />
          </div>
        </div>
      {/snippet}
    </Former>
  </div>

  {#if lastSaved}
    <div class="card p-4">
      <p class="font-semibold text-sm mb-2">Saved data:</p>
      <pre class="text-xs overflow-auto">{JSON.stringify(lastSaved, null, 2)}</pre>
    </div>
  {/if}
</div>
```

Helper component `./FormerModalPreview.svelte`:

```svelte
<script lang="ts">
	 
	import FormerModal from './FormerModal.svelte';
	import type { FormRequestType } from './types';
	import TextInputCtrl from '#lib/components/FormerControllers/TextInputCtrl.svelte';
	import PasswordInputCtrl from '#lib/components/FormerControllers/PasswordInputCtrl.svelte';
	import NumberInputCtrl from '#lib/components/FormerControllers/NumberInputCtrl.svelte';
	import NativeSelectCtrl from '#lib/components/FormerControllers/NativeSelectCtrl.svelte';
	import TextAreaCtrl from '#lib/components/FormerControllers/TextAreaCtrl.svelte';
	import SwitchCtrl from '#lib/components/FormerControllers/SwitchCtrl.svelte';
	import Boxer from '#lib/components/Boxer/Boxer.svelte';

	interface UserForm {
		id?: number;
		firstName: string;
		lastName: string;
		email: string;
		password: string;
		age?: number;
		role: string;
		department: string;
		skills: string[];
		bio: string;
		website: string;
		emailNotifications: boolean;
		active: boolean;
	}

	export interface Props {
		request?: FormRequestType;
		width?: string;
		surfaceClass?: string;
		borderClass?: string;
	}

	const { request = 'insert', width, surfaceClass, borderClass }: Props = $props();

	const emptyUser: UserForm = {
		firstName: '',
		lastName: '',
		email: '',
		password: '',
		age: undefined,
		role: '',
		department: '',
		skills: [],
		bio: '',
		website: '',
		emailNotifications: true,
		active: true,
	};

	const filledUser: UserForm = {
		id: 1,
		firstName: 'Jane',
		lastName: 'Smith',
		email: 'jane.smith@example.com',
		password: '',
		age: 32,
		role: 'admin',
		department: 'engineering',
		skills: ['typescript', 'svelte'],
		bio: 'Senior software engineer with 10 years of experience building scalable systems.',
		website: 'https://janesmith.dev',
		emailNotifications: true,
		active: true,
	};

	const roleOptions = [
		{ label: 'Admin', value: 'admin' },
		{ label: 'Editor', value: 'editor' },
		{ label: 'Viewer', value: 'viewer' },
	];

	const departmentOptions = [
		{ label: 'Engineering', value: 'engineering' },
		{ label: 'Product', value: 'product' },
		{ label: 'Design', value: 'design' },
		{ label: 'Marketing', value: 'marketing' },
		{ label: 'Operations', value: 'operations' },
	];

	const skillOptions = [
		{ label: 'Svelte', value: 'svelte' },
		{ label: 'TypeScript', value: 'typescript' },
		{ label: 'Node.js', value: 'nodejs' },
		{ label: 'Testing', value: 'testing' },
		{ label: 'UX', value: 'ux' },
	];

	// eslint-disable-next-line svelte/no-unused-svelte-ignore
	// svelte-ignore state_referenced_locally -- initial value is intentional (sets up form state once)
	let values = $state<UserForm>(request === 'insert' ? { ...emptyUser } : { ...filledUser });
	let lastSaved = $state<UserForm | undefined>(undefined);
	let opened = $state(false);
</script>

<div class="p-8 space-y-6">
	<div class="space-y-2">
		<button class="btn preset-filled-primary-token" onclick={() => (opened = true)}>
			Open Form
		</button>
		{#if lastSaved}
			<div class="card p-4">
				<p class="font-semibold text-sm mb-2">Saved data:</p>
				<pre class="text-xs overflow-auto">{JSON.stringify(lastSaved, null, 2)}</pre>
			</div>
		{/if}
	</div>

	<FormerModal
		bind:values
		bind:opened
		{request}
		{width}
		{surfaceClass}
		{borderClass}
		onAPICall={async (mode, _req, data) => {
			await new Promise((resolve) => setTimeout(resolve, 800));
			if (mode === 'mutate') {
				return { ...data, id: (data as UserForm)?.id ?? 42 } as UserForm;
			}
			return data as UserForm;
		}}
		afterSave={(data) => {
			lastSaved = data as UserForm;
		}}
		onClose={() => {
			opened = false;
		}}
	>
		{#snippet children(state)}
			{@const isReadonly = state.request === 'view' || state.request === 'delete'}

			<div class="p-6 space-y-4">
				<div class="grid grid-cols-2 gap-4">
					<TextInputCtrl
						label="First Name"
						name="firstName"
						value={state.values?.firstName ?? ''}
						required
						disabled={isReadonly}
						onchange={(v) => state.setState('values', { ...state.values, firstName: v })}
					/>
					<TextInputCtrl
						label="Last Name"
						name="lastName"
						value={state.values?.lastName ?? ''}
						required
						disabled={isReadonly}
						onchange={(v) => state.setState('values', { ...state.values, lastName: v })}
					/>
				</div>

				<TextInputCtrl
					label="Email"
					name="email"
					type="email"
					value={state.values?.email ?? ''}
					required
					disabled={isReadonly}
					onchange={(v) => state.setState('values', { ...state.values, email: v })}
				/>

				{#if state.request === 'insert'}
					<PasswordInputCtrl
						label="Password"
						name="password"
						value={state.values?.password ?? ''}
						required
						onchange={(v) => state.setState('values', { ...state.values, password: v })}
					/>
				{/if}

				<div class="grid grid-cols-2 gap-4">
					<NumberInputCtrl
						label="Age"
						name="age"
						value={state.values?.age}
						min={18}
						max={120}
						disabled={isReadonly}
						onchange={(v) => state.setState('values', { ...state.values, age: v })}
					/>
					<TextInputCtrl
						label="Website"
						name="website"
						type="url"
						value={state.values?.website ?? ''}
						disabled={isReadonly}
						onchange={(v) => state.setState('values', { ...state.values, website: v })}
					/>
				</div>

				<div class="grid grid-cols-2 gap-4">
					<NativeSelectCtrl
						label="Role"
						name="role"
						value={state.values?.role ?? ''}
						required
						placeholder="Select role"
						options={roleOptions}
						disabled={isReadonly}
						onchange={(v) => state.setState('values', { ...state.values, role: v })}
					/>
					<NativeSelectCtrl
						label="Department"
						name="department"
						value={state.values?.department ?? ''}
						placeholder="Select department"
						options={departmentOptions}
						disabled={isReadonly}
						onchange={(v) => state.setState('values', { ...state.values, department: v })}
					/>
				</div>

				<Boxer
					label="Skills"
					name="skills"
					data={skillOptions}
					dataSource="local"
					multiSelect
					clearable
					placeholder="Search skills..."
					disabled={isReadonly}
					value={state.values?.skills ?? []}
					onChange={(v) =>
						state.setState('values', {
							...state.values,
							skills: Array.isArray(v) ? v : v ? [v] : [],
						})}
				/>

				<TextAreaCtrl
					label="Bio"
					name="bio"
					value={state.values?.bio ?? ''}
					rows={3}
					placeholder="Tell us about yourself..."
					disabled={isReadonly}
					onchange={(v) => state.setState('values', { ...state.values, bio: v })}
				/>

				<div class="flex gap-8">
					<SwitchCtrl
						label="Email Notifications"
						name="emailNotifications"
						checked={state.values?.emailNotifications ?? true}
						disabled={isReadonly}
						onchange={(c) => state.setState('values', { ...state.values, emailNotifications: c })}
					/>
					<SwitchCtrl
						label="Active"
						name="active"
						checked={state.values?.active ?? true}
						disabled={isReadonly}
						onchange={(c) => state.setState('values', { ...state.values, active: c })}
					/>
				</div>
			</div>
		{/snippet}
	</FormerModal>
</div>
```

Helper component `./FormerDrawerPreview.svelte`:

```svelte
<script lang="ts">
	 
	import FormerDrawer from './FormerDrawer.svelte';
	import type { FormRequestType } from './types';
	import TextInputCtrl from '#lib/components/FormerControllers/TextInputCtrl.svelte';
	import PasswordInputCtrl from '#lib/components/FormerControllers/PasswordInputCtrl.svelte';
	import NumberInputCtrl from '#lib/components/FormerControllers/NumberInputCtrl.svelte';
	import NativeSelectCtrl from '#lib/components/FormerControllers/NativeSelectCtrl.svelte';
	import TextAreaCtrl from '#lib/components/FormerControllers/TextAreaCtrl.svelte';
	import SwitchCtrl from '#lib/components/FormerControllers/SwitchCtrl.svelte';
	import Boxer from '#lib/components/Boxer/Boxer.svelte';

	interface UserForm {
		id?: number;
		firstName: string;
		lastName: string;
		email: string;
		password: string;
		age?: number;
		role: string;
		department: string;
		skills: string[];
		bio: string;
		website: string;
		emailNotifications: boolean;
		active: boolean;
	}

	export interface Props {
		request?: FormRequestType;
	}

	const { request = 'insert' }: Props = $props();

	const emptyUser: UserForm = {
		firstName: '',
		lastName: '',
		email: '',
		password: '',
		age: undefined,
		role: '',
		department: '',
		skills: [],
		bio: '',
		website: '',
		emailNotifications: true,
		active: true,
	};

	const filledUser: UserForm = {
		id: 1,
		firstName: 'Jane',
		lastName: 'Smith',
		email: 'jane.smith@example.com',
		password: '',
		age: 32,
		role: 'admin',
		department: 'engineering',
		skills: ['typescript', 'svelte'],
		bio: 'Senior software engineer with 10 years of experience building scalable systems.',
		website: 'https://janesmith.dev',
		emailNotifications: true,
		active: true,
	};

	const roleOptions = [
		{ label: 'Admin', value: 'admin' },
		{ label: 'Editor', value: 'editor' },
		{ label: 'Viewer', value: 'viewer' },
	];

	const departmentOptions = [
		{ label: 'Engineering', value: 'engineering' },
		{ label: 'Product', value: 'product' },
		{ label: 'Design', value: 'design' },
		{ label: 'Marketing', value: 'marketing' },
		{ label: 'Operations', value: 'operations' },
	];

	const skillOptions = [
		{ label: 'Svelte', value: 'svelte' },
		{ label: 'TypeScript', value: 'typescript' },
		{ label: 'Node.js', value: 'nodejs' },
		{ label: 'Testing', value: 'testing' },
		{ label: 'UX', value: 'ux' },
	];

	// eslint-disable-next-line svelte/no-unused-svelte-ignore
	// svelte-ignore state_referenced_locally -- initial value is intentional (sets up form state once)
	let values = $state<UserForm>(request === 'insert' ? { ...emptyUser } : { ...filledUser });
	let lastSaved = $state<UserForm | undefined>(undefined);
	let opened = $state(false);
</script>

<div class="p-8 space-y-6">
	<div class="space-y-2">
		<button class="btn preset-filled-primary-token" onclick={() => (opened = true)}>
			Open Drawer
		</button>
		{#if lastSaved}
			<div class="card p-4">
				<p class="font-semibold text-sm mb-2">Saved data:</p>
				<pre class="text-xs overflow-auto">{JSON.stringify(lastSaved, null, 2)}</pre>
			</div>
		{/if}
	</div>

	<FormerDrawer
		bind:values
		bind:opened
		{request}
		onAPICall={async (mode, _req, data) => {
			await new Promise((resolve) => setTimeout(resolve, 800));
			if (mode === 'mutate') {
				return { ...data, id: (data as UserForm)?.id ?? 42 } as UserForm;
			}
			return data as UserForm;
		}}
		afterSave={(data) => {
			lastSaved = data as UserForm;
		}}
		onClose={() => {
			opened = false;
		}}
	>
		{#snippet children(state)}
			{@const isReadonly = state.request === 'view' || state.request === 'delete'}

			<div class="p-6 space-y-4">
				<div class="grid grid-cols-2 gap-4">
					<TextInputCtrl
						label="First Name"
						name="firstName"
						value={state.values?.firstName ?? ''}
						required
						disabled={isReadonly}
						onchange={(v) => state.setState('values', { ...state.values, firstName: v })}
					/>
					<TextInputCtrl
						label="Last Name"
						name="lastName"
						value={state.values?.lastName ?? ''}
						required
						disabled={isReadonly}
						onchange={(v) => state.setState('values', { ...state.values, lastName: v })}
					/>
				</div>

				<TextInputCtrl
					label="Email"
					name="email"
					type="email"
					value={state.values?.email ?? ''}
					required
					disabled={isReadonly}
					onchange={(v) => state.setState('values', { ...state.values, email: v })}
				/>

				{#if state.request === 'insert'}
					<PasswordInputCtrl
						label="Password"
						name="password"
						value={state.values?.password ?? ''}
						required
						onchange={(v) => state.setState('values', { ...state.values, password: v })}
					/>
				{/if}

				<div class="grid grid-cols-2 gap-4">
					<NumberInputCtrl
						label="Age"
						name="age"
						value={state.values?.age}
						min={18}
						max={120}
						disabled={isReadonly}
						onchange={(v) => state.setState('values', { ...state.values, age: v })}
					/>
					<TextInputCtrl
						label="Website"
						name="website"
						type="url"
						value={state.values?.website ?? ''}
						disabled={isReadonly}
						onchange={(v) => state.setState('values', { ...state.values, website: v })}
					/>
				</div>

				<div class="grid grid-cols-2 gap-4">
					<NativeSelectCtrl
						label="Role"
						name="role"
						value={state.values?.role ?? ''}
						required
						placeholder="Select role"
						options={roleOptions}
						disabled={isReadonly}
						onchange={(v) => state.setState('values', { ...state.values, role: v })}
					/>
					<NativeSelectCtrl
						label="Department"
						name="department"
						value={state.values?.department ?? ''}
						placeholder="Select department"
						options={departmentOptions}
						disabled={isReadonly}
						onchange={(v) => state.setState('values', { ...state.values, department: v })}
					/>
				</div>

				<Boxer
					label="Skills"
					name="skills"
					data={skillOptions}
					dataSource="local"
					disablePortal
					multiSelect
					clearable
					placeholder="Search skills..."
					disabled={isReadonly}
					value={state.values?.skills ?? []}
					onChange={(v) =>
						state.setState('values', {
							...state.values,
							skills: Array.isArray(v) ? v : v ? [v] : [],
						})}
				/>

				<TextAreaCtrl
					label="Bio"
					name="bio"
					value={state.values?.bio ?? ''}
					rows={3}
					placeholder="Tell us about yourself..."
					disabled={isReadonly}
					onchange={(v) => state.setState('values', { ...state.values, bio: v })}
				/>

				<div class="flex gap-8">
					<SwitchCtrl
						label="Email Notifications"
						name="emailNotifications"
						checked={state.values?.emailNotifications ?? true}
						disabled={isReadonly}
						onchange={(c) => state.setState('values', { ...state.values, emailNotifications: c })}
					/>
					<SwitchCtrl
						label="Active"
						name="active"
						checked={state.values?.active ?? true}
						disabled={isReadonly}
						onchange={(c) => state.setState('values', { ...state.values, active: c })}
					/>
				</div>
			</div>
		{/snippet}
	</FormerDrawer>
</div>
```

Helper component `./FormerRestApiPreview.svelte`:

```svelte
<script lang="ts">
  /* eslint-disable @typescript-eslint/no-explicit-any */
  import FormerModal from "./FormerModal.svelte";
  import { FormerRestHeadSpecAPI } from "./FormerRestHeadSpecAPI";
  import type { FormRequestType } from "./types";
  import TextInputCtrl from "#lib/components/FormerControllers/TextInputCtrl.svelte";
  import TextAreaCtrl from "#lib/components/FormerControllers/TextAreaCtrl.svelte";
  import NativeSelectCtrl from "#lib/components/FormerControllers/NativeSelectCtrl.svelte";

  // ── Config state ────────────────────────────────────────────────────────────
  let apiUrl = $state("https://www.postb.in/b/1771846908265-6770497136749");
  let authToken = $state("");
  let showToken = $state(false);
  let recordId = $state("1");
  let request = $state<FormRequestType>("insert");

  // ── Modal state ─────────────────────────────────────────────────────────────
  let opened = $state(false);
  let values = $state<Record<string, any>>({});
  let lastResponse = $state<Record<string, any> | undefined>(undefined);
  let lastError = $state<string | undefined>(undefined);

  // Re-create the API adapter whenever the URL or token changes.
  // $derived is correct here — FormerRestHeadSpecAPI is a pure factory function.
  const onAPICall = $derived(
    FormerRestHeadSpecAPI({
      authToken,
      url: apiUrl,
    }),
  );

  const requestOptions = [
    { label: "Insert (POST)", value: "insert" },
    { label: "Update (PUT)", value: "update" },
    { label: "Delete (DELETE)", value: "delete" },
    { label: "View (GET)", value: "view" },
  ];

  function openForm() {
    lastError = undefined;
    if (request === "insert") {
      values = { title: "", body: "", userId: 1 };
    } else {
      // For non-insert modes prime the key so Former knows which record to load.
      values = { id: Number(recordId) || 1 };
    }
    opened = true;
  }
</script>

<div class="p-6 space-y-6">
  <!-- Config panel -->
  <div class="card p-6 space-y-4">
    <h2 class="text-lg font-semibold">REST API Configuration</h2>

    <TextInputCtrl
      label="API URL"
      name="apiUrl"
      placeholder="https://api.example.com/resource"
      value={apiUrl}
      onchange={(v) => (apiUrl = v)}
    />

    <!-- Auth token with show/hide toggle -->
    <div class="space-y-1">
      <label class="label text-sm font-medium" for="authToken"
        >Auth Token (Bearer)</label
      >
      <div class="flex gap-2">
        <input
          class="input flex-1"
          id="authToken"
          placeholder="eyJhbGciOi..."
          type={showToken ? "text" : "password"}
          value={authToken}
          oninput={(e) => (authToken = (e.target as HTMLInputElement).value)}
        />
        <button
          class="btn-icon preset-tonal"
          title={showToken ? "Hide token" : "Show token"}
          onclick={() => (showToken = !showToken)}
        >
          {showToken ? "🙈" : "👁"}
        </button>
      </div>
      <p class="text-xs text-surface-500">
        Leave blank if the API does not require authentication.
      </p>
    </div>

    <div class="grid grid-cols-2 gap-4">
      <NativeSelectCtrl
        label="Request Type"
        name="request"
        value={request}
        options={requestOptions}
        onchange={(v) => (request = v as FormRequestType)}
      />

      {#if request !== "insert"}
        <TextInputCtrl
          label="Record ID"
          name="recordId"
          placeholder="1"
          value={recordId}
          onchange={(v) => (recordId = v)}
        />
      {/if}
    </div>

    <div class="flex items-center gap-3 pt-2">
      <button class="btn preset-filled-primary-token" onclick={openForm}>
        Open Form
      </button>
      <span class="text-xs text-surface-500">
        {#if request === "insert"}
          Will POST to <code class="text-primary-500">{apiUrl}</code>
        {:else}
          Will {request === "delete"
            ? "DELETE"
            : request === "view"
              ? "GET"
              : "PUT"} to
          <code class="text-primary-500">{apiUrl}/{recordId}</code>
        {/if}
      </span>
    </div>
  </div>

  <!-- Last response panel -->
  {#if lastError}
    <div class="card p-4 preset-outlined-error-token">
      <p class="font-semibold text-sm mb-1">Error:</p>
      <pre class="text-xs overflow-auto">{lastError}</pre>
    </div>
  {/if}

  {#if lastResponse}
    <div class="card p-4">
      <p class="font-semibold text-sm mb-2">API Response:</p>
      <pre class="text-xs overflow-auto">{JSON.stringify(
          lastResponse,
          null,
          2,
        )}</pre>
    </div>
  {/if}
</div>

<!-- FormerModal wired to the live API adapter -->
<FormerModal
  bind:values
  bind:opened
  bind:request
  {onAPICall}
  uniqueKeyField="id"
  afterSave={(data) => {
    lastResponse = data as Record<string, any>;
    lastError = undefined;
  }}
  onError={(err) => {
    lastError = typeof err === "string" ? err : (err as Error).message;
  }}
  onClose={() => {
    opened = false;
  }}
>
  {#snippet children(state)}
    {@const isReadonly = state.request === "view" || state.request === "delete"}

    <div class="p-6 space-y-4">
      {#if state.request !== "insert" && state.values?.id}
        <div class="text-xs text-surface-500 font-mono">
          id: {state.values.id}
        </div>
      {/if}

      <TextInputCtrl
        label="Title"
        name="title"
        value={state.values?.title ?? ""}
        required
        disabled={isReadonly}
        onchange={(v) =>
          state.setState("values", { ...state.values, title: v })}
      />

      <TextAreaCtrl
        label="Body"
        name="body"
        value={state.values?.body ?? ""}
        rows={4}
        placeholder="Enter content..."
        disabled={isReadonly}
        onchange={(v) => state.setState("values", { ...state.values, body: v })}
      />

      <TextInputCtrl
        label="User ID"
        name="userId"
        value={String(state.values?.userId ?? "")}
        disabled={isReadonly}
        onchange={(v) =>
          state.setState("values", { ...state.values, userId: Number(v) || v })}
      />

      {#if state.loading}
        <p class="text-sm text-surface-500 animate-pulse">
          Communicating with API…
        </p>
      {/if}
    </div>
  {/snippet}
</FormerModal>
```

Helper component `./FormerDirtyPreview.svelte`:

```svelte
<script lang="ts">
  import Former from './Former.svelte';
  import TextInputCtrl from '#lib/components/FormerControllers/TextInputCtrl.svelte';

  interface DirtyForm {
    id?: number;
    name: string;
  }

  export interface Props {
    useCustomComparator?: boolean;
  }

  const { useCustomComparator = false }: Props = $props();

  let values = $state<DirtyForm>({ id: 1, name: 'Jane' });
  let opened = $state(true);
  let relatedChanged = $state(false);
</script>

<div class="p-6 space-y-4">
  <div class="card p-6" style="min-height: 300px;">
    <Former
      bind:values
      bind:opened
      request="update"
      layout={{ buttonArea: 'bottom' }}
      dirtyDeps={() => relatedChanged}
      dirtyComparator={useCustomComparator
        ? (v: DirtyForm, i: DirtyForm) => v.name.trim() !== i.name.trim()
        : undefined}
    >
      {#snippet children(state)}
        <div class="space-y-4">
          <TextInputCtrl
            label="Name"
            name="name"
            value={state.values?.name ?? ''}
            onchange={(v) => state.setState('values', { ...state.values, name: v })}
          />
          <label class="flex items-center gap-2 text-sm">
            <input
              type="checkbox"
              class="checkbox"
              checked={relatedChanged}
              onchange={(e) => (relatedChanged = e.currentTarget.checked)}
            />
            Related record changed
          </label>
          <div class="flex gap-2">
            <button
              type="button"
              class="btn preset-tonal-primary"
              onclick={() => state.markDirty?.()}
            >
              Mark Dirty
            </button>
            <button
              type="button"
              class="btn preset-tonal-secondary"
              onclick={() => state.clearDirty?.()}
            >
              Clear Dirty
            </button>
          </div>
        </div>
      {/snippet}
    </Former>
  </div>
</div>
```

Helper component `./FormerFieldArrayPreview.svelte`:

```svelte
<script lang="ts">
  import Former from './Former.svelte';
  import { createFieldArray } from './formerFieldArray.svelte';
  import TextInputCtrl from '#lib/components/FormerControllers/TextInputCtrl.svelte';

  interface ContactForm {
    id?: number;
    name: string;
    phones: string[];
  }

  let values = $state<ContactForm>({ id: 1, name: 'Jane', phones: ['555-0100', '555-0101'] });
  let opened = $state(true);

  const phoneArray = createFieldArray<string>({
    getArray: () => values.phones,
    setArray: (next) => (values = { ...values, phones: next }),
  });
</script>

<div class="p-6 space-y-4">
  <div class="card p-6" style="min-height: 300px;">
    <Former bind:values bind:opened request="update" layout={{ buttonArea: 'bottom' }}>
      {#snippet children(state)}
        <div class="space-y-4">
          <TextInputCtrl
            label="Name"
            name="name"
            value={state.values?.name ?? ''}
            onchange={(v) => state.setState('values', { ...state.values, name: v })}
          />

          <div class="space-y-2">
            <p class="text-sm font-medium">Phone Numbers</p>
            {#each phoneArray.fields as field, index (field.id)}
              <div class="flex items-center gap-2">
                <TextInputCtrl
                  label={`Phone ${index + 1}`}
                  name={`phones.${index}`}
                  value={field.value}
                  onchange={(v) => phoneArray.update(index, v)}
                />
                <button
                  type="button"
                  class="btn preset-tonal-secondary"
                  disabled={index === 0}
                  onclick={() => phoneArray.move(index, index - 1)}
                >
                  ↑
                </button>
                <button
                  type="button"
                  class="btn preset-tonal-error"
                  onclick={() => phoneArray.remove(index)}
                >
                  Remove
                </button>
              </div>
            {/each}
            <button
              type="button"
              class="btn preset-tonal-primary"
              onclick={() => phoneArray.append('')}
            >
              Add Phone
            </button>
            <button
              type="button"
              class="btn preset-tonal-secondary"
              onclick={() => phoneArray.sort((a, b) => b.localeCompare(a))}
            >
              Sort Descending
            </button>
          </div>
        </div>
      {/snippet}
    </Former>
  </div>
</div>
```

Helper component `./FormerControlsPreview.svelte`:

```svelte
<script lang="ts">
  import Former from './Former.svelte';
  import type { DateRangeValue } from '#lib/components/FormerControllers/DateRangeCtrl.svelte';
  import CheckGroupCtrl from '#lib/components/FormerControllers/CheckGroupCtrl.svelte';
  import ColorPickerCtrl from '#lib/components/FormerControllers/ColorPickerCtrl.svelte';
  import DateRangeCtrl from '#lib/components/FormerControllers/DateRangeCtrl.svelte';
  import NumberInputCtrl from '#lib/components/FormerControllers/NumberInputCtrl.svelte';
  import PinInputCtrl from '#lib/components/FormerControllers/PinInputCtrl.svelte';
  import RadioGroupCtrl from '#lib/components/FormerControllers/RadioGroupCtrl.svelte';
  import RatingGroupCtrl from '#lib/components/FormerControllers/RatingGroupCtrl.svelte';
  import SegmentedControlCtrl from '#lib/components/FormerControllers/SegmentedControlCtrl.svelte';
  import SliderCtrl from '#lib/components/FormerControllers/SliderCtrl.svelte';
  import TagsInputCtrl from '#lib/components/FormerControllers/TagsInputCtrl.svelte';

  interface Values {
    channels: string[];
    color: string;
    discount?: number;
    pin: string;
    plan: string;
    price?: number;
    rating: number | null;
    size: string;
    stay: DateRangeValue;
    tags: string[];
    volume: number;
  }

  let values = $state<Values>({
    channels: ['email'],
    color: '#3366ff',
    discount: 12.5,
    pin: '',
    plan: 'pro',
    price: 1499.99,
    rating: 4,
    size: 'm',
    stay: { start: '2026-01-10', end: '2026-01-15' },
    tags: ['svelte'],
    volume: 40,
  });
  let opened = $state(true);
  let lastSaved = $state<Values | undefined>(undefined);

  const sizes = [
    { label: 'Small', value: 's' },
    { label: 'Medium', value: 'm' },
    { label: 'Large', value: 'l' },
  ];
  const plans = ['free', 'pro', 'team'];
  const channels = [
    { label: 'Email', value: 'email' },
    { label: 'SMS', value: 'sms' },
    { label: 'Push', value: 'push' },
  ];
</script>

<div class="p-6 space-y-6">
  <div class="card p-6">
    <Former
      bind:values
      bind:opened
      request="insert"
      onAPICall={async (_mode, _req, data) => data as Values}
      afterSave={(data) => {
        lastSaved = data as Values;
      }}
    >
      {#snippet children(state)}
        {@const set = (patch: Partial<Values>) => state.setState('values', { ...state.values, ...patch })}
        <div class="space-y-4">
          <div class="grid grid-cols-2 gap-4">
            <NumberInputCtrl label="Price" name="price" min={0} step={0.01} prefix="$" suffix="USD" value={state.values?.price} onchange={(v) => set({ price: v })} />
            <NumberInputCtrl label="Discount" name="discount" min={0} max={100} step={0.1} suffix="%" value={state.values?.discount} onchange={(v) => set({ discount: v })} />
          </div>
          <div class="grid grid-cols-2 gap-4">
            <RadioGroupCtrl label="Size" name="size" options={sizes} value={state.values?.size ?? ''} onchange={(v) => set({ size: v })} />
            <CheckGroupCtrl label="Notify via" name="channels" options={channels} value={state.values?.channels ?? []} onchange={(v) => set({ channels: v })} />
          </div>
          <SegmentedControlCtrl label="Plan" name="plan" options={plans} value={state.values?.plan ?? ''} onchange={(v) => set({ plan: v })} />
          <SliderCtrl label="Volume" name="volume" suffix="%" value={state.values?.volume} onchange={(v) => set({ volume: v as number })} />
          <RatingGroupCtrl label="Rating" name="rating" value={state.values?.rating ?? null} onchange={(v) => set({ rating: v })} />
          <TagsInputCtrl label="Tags" name="tags" placeholder="Add tag" value={state.values?.tags ?? []} onchange={(v) => set({ tags: v })} />
          <div class="grid grid-cols-2 gap-4">
            <ColorPickerCtrl label="Color" name="color" value={state.values?.color ?? ''} onchange={(v) => set({ color: v })} />
            <PinInputCtrl label="PIN" name="pin" length={4} mask value={state.values?.pin ?? ''} onchange={(v) => set({ pin: v })} />
          </div>
          <DateRangeCtrl label="Stay" name="stay" value={state.values?.stay} onchange={(v) => set({ stay: v })} />
        </div>
      {/snippet}
    </Former>
  </div>

  {#if lastSaved}
    <div class="card p-4">
      <h3 class="h5 mb-2">Last saved</h3>
      <pre class="text-xs">{JSON.stringify(lastSaved, null, 2)}</pre>
    </div>
  {/if}
</div>
```

Helper component `./FormerConfirmClosePreview.svelte`:

```svelte
<script lang="ts">
	import FormerModal from './FormerModal.svelte';
	import FormerDrawer from './FormerDrawer.svelte';
	import TextInputCtrl from '#lib/components/FormerControllers/TextInputCtrl.svelte';

	export interface Props {
		kind?: 'modal' | 'drawer';
		/** How onConfirmClose settles; 'none' leaves the prop unset (themed default dialog). */
		mode?: 'true' | 'false' | 'reject' | 'none';
	}

	const { kind = 'modal', mode = 'true' }: Props = $props();

	let opened = $state(false);
	let values = $state({ name: '' });
	let confirmCalls = $state(0);
	let closeCalls = $state(0);
	let settledCalls = $state(0);

	const onConfirmClose = $derived(
		mode === 'none'
			? undefined
			: async () => {
					confirmCalls++;
					try {
						await new Promise((r) => setTimeout(r, 30));
						if (mode === 'reject') throw new Error('boom');
						return mode === 'true';
					} finally {
						settledCalls++;
					}
				}
	);

	const Overlay = $derived(kind === 'drawer' ? FormerDrawer : FormerModal);
</script>

<button type="button" class="btn preset-filled" onclick={() => (opened = true)}>Open form</button>
<span data-testid="confirm-calls">{confirmCalls}</span>
<span data-testid="close-calls">{closeCalls}</span>
<span data-testid="settled-calls">{settledCalls}</span>

<Overlay bind:opened bind:values closeOnEsc request="insert" title="Confirm close" {onConfirmClose} onClose={() => closeCalls++}>
	{#snippet children(state)}
		<div class="p-6">
			<TextInputCtrl
				label="Name"
				name="name"
				value={state.values?.name ?? ''}
				onchange={(v) => state.setState('values', { ...state.values, name: v })}
			/>
		</div>
	{/snippet}
</Overlay>
```

#### Create User (`CreateUser`)

```ts
export const CreateUser: Story = {
  render: () => ({ Component: FormerPreview, props: { request: 'insert' } }),
};
```

#### Edit User (`EditUser`)

```ts
export const EditUser: Story = {
  render: () => ({ Component: FormerPreview, props: { request: 'update' } }),
};
```

#### Delete User (`DeleteUser`)

```ts
export const DeleteUser: Story = {
  render: () => ({ Component: FormerPreview, props: { request: 'delete' } }),
};
```

#### View User (`ViewUser`)

```ts
export const ViewUser: Story = {
  render: () => ({ Component: FormerPreview, props: { request: 'view' } }),
};
```

#### With Keep Open (`WithKeepOpen`)

```ts
export const WithKeepOpen: Story = {
  render: () => ({
    Component: FormerPreview,
    props: { request: 'insert', showKeepOpenSwitch: true },
  }),
};
```

#### With Save And Response (`WithSaveAndResponse`)

```ts
export const WithSaveAndResponse: Story = {
  render: () => ({ Component: FormerPreview, props: { request: 'insert' } }),
};
```

#### Modal Insert (`ModalInsert`)

```ts
// ── Modal layout ─────────────────────────────────────────────────────────────

export const ModalInsert: Story = {
  render: () => ({ Component: FormerModalPreview, props: { request: 'insert' } }),
};
```

#### Modal Edit (`ModalEdit`)

```ts
export const ModalEdit: Story = {
  render: () => ({ Component: FormerModalPreview, props: { request: 'update' } }),
};
```

#### Modal Delete (`ModalDelete`)

```ts
export const ModalDelete: Story = {
  render: () => ({ Component: FormerModalPreview, props: { request: 'delete' } }),
};
```

#### Modal Custom Appearance (`ModalCustomAppearance`)

```ts
export const ModalCustomAppearance: Story = {
  render: () => ({
    Component: FormerModalPreview,
    props: {
      request: 'insert',
      width: '60rem',
      surfaceClass: 'bg-surface-50-950',
      borderClass: 'border-primary-200-800',
    },
  }),
};
```

#### Drawer Insert (`DrawerInsert`)

```ts
// ── Drawer layout ─────────────────────────────────────────────────────────────

export const DrawerInsert: Story = {
  render: () => ({ Component: FormerDrawerPreview, props: { request: 'insert' } }),
};
```

#### Drawer Edit (`DrawerEdit`)

```ts
export const DrawerEdit: Story = {
  render: () => ({ Component: FormerDrawerPreview, props: { request: 'update' } }),
};
```

#### Drawer Delete (`DrawerDelete`)

```ts
export const DrawerDelete: Story = {
  render: () => ({ Component: FormerDrawerPreview, props: { request: 'delete' } }),
};
```

#### Rest Api Insert (`RestApiInsert`)

```ts
// ── REST API live example ─────────────────────────────────────────────────────

export const RestApiInsert: Story = {
  name: 'REST API — Insert (live)',
  render: () => ({ Component: FormerRestApiPreview, props: {} }),
};
```

#### Dirty External Dependency (`DirtyExternalDependency`)

```ts
// ── Dirty tracking extensions ───────────────────────────────────────────────

export const DirtyExternalDependency: Story = {
  render: () => ({ Component: FormerDirtyPreview, props: {} }),
};
```

#### Dirty Marked Manually (`DirtyMarkedManually`)

```ts
export const DirtyMarkedManually: Story = {
  render: () => ({ Component: FormerDirtyPreview, props: {} }),
};
```

#### Dirty Custom Comparator (`DirtyCustomComparator`)

```ts
export const DirtyCustomComparator: Story = {
  render: () => ({ Component: FormerDirtyPreview, props: { useCustomComparator: true } }),
};
```

#### Field Array Update Item (`FieldArrayUpdateItem`)

```ts
// ── Field array ──────────────────────────────────────────────────────────────

export const FieldArrayUpdateItem: Story = {
  render: () => ({ Component: FormerFieldArrayPreview, props: {} }),
};
```

#### Field Array Append And Remove (`FieldArrayAppendAndRemove`)

```ts
export const FieldArrayAppendAndRemove: Story = {
  render: () => ({ Component: FormerFieldArrayPreview, props: {} }),
};
```

#### Field Array Move (`FieldArrayMove`)

```ts
export const FieldArrayMove: Story = {
  render: () => ({ Component: FormerFieldArrayPreview, props: {} }),
};
```

#### Field Array Sort (`FieldArraySort`)

```ts
export const FieldArraySort: Story = {
  render: () => ({ Component: FormerFieldArrayPreview, props: {} }),
};
```

#### Rest Api Update (`RestApiUpdate`)

```ts
export const RestApiUpdate: Story = {
  name: 'REST API — Update (live)',
  render: () => ({ Component: FormerRestApiPreview, props: {} }),
};
```

#### Extra Controls (`ExtraControls`)

```ts
export const ExtraControls: Story = {
  render: () => ({ Component: FormerControlsPreview }),
};
```

#### Modal Confirm Close True (`ModalConfirmCloseTrue`)

```ts
export const ModalConfirmCloseTrue: Story = confirmCloseCase('modal', 'true', true);
```

#### Modal Confirm Close False (`ModalConfirmCloseFalse`)

```ts
export const ModalConfirmCloseFalse: Story = confirmCloseCase('modal', 'false', false);
```

#### Modal Confirm Close Reject (`ModalConfirmCloseReject`)

```ts
export const ModalConfirmCloseReject: Story = confirmCloseCase('modal', 'reject', false);
```

#### Drawer Confirm Close True (`DrawerConfirmCloseTrue`)

```ts
export const DrawerConfirmCloseTrue: Story = confirmCloseCase('drawer', 'true', true);
```

#### Drawer Confirm Close False (`DrawerConfirmCloseFalse`)

```ts
export const DrawerConfirmCloseFalse: Story = confirmCloseCase('drawer', 'false', false);
```

#### Drawer Confirm Close Reject (`DrawerConfirmCloseReject`)

```ts
export const DrawerConfirmCloseReject: Story = confirmCloseCase('drawer', 'reject', false);
```

#### Modal Default Themed Confirm (`ModalDefaultThemedConfirm`)

```ts
export const ModalDefaultThemedConfirm: Story = {
  render: () => ({ Component: FormerConfirmClosePreview, props: { kind: 'modal', mode: 'none' } }),
};
```

---

# FormerControllers

Import: `import { ... } from '@warky/svelix/FormerControllers'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as ButtonCtrl } from './ButtonCtrl.svelte';
export { default as CheckBoxCtrl } from './CheckBoxCtrl.svelte';
export { default as CheckGroupCtrl } from './CheckGroupCtrl.svelte';
export { default as ColorPickerCtrl } from './ColorPickerCtrl.svelte';
export { default as CtrlFieldShell } from './CtrlFieldShell.svelte';
export { default as AddressCtrl } from './AddressCtrl.svelte';
export { default as CalendarCtrl } from './CalendarCtrl.svelte';
export { default as CountryCodeCtrl } from './CountryCodeCtrl.svelte';
export { default as CurrencyCtrl } from './CurrencyCtrl.svelte';
export { default as DateRangeCtrl } from './DateRangeCtrl.svelte';
export type { DateRangeValue } from './DateRangeCtrl.svelte';
export { DateTimeCtrl } from './DateTimeCtrl/index';
export { default as FileInputCtrl } from './FileInputCtrl.svelte';
export { default as GpsCtrl } from './GpsCtrl.svelte';
export { default as IconButtonCtrl } from './IconButtonCtrl.svelte';
export { default as InlineWrapper } from './InlineWrapper.svelte';
export { default as IpCtrl } from './IpCtrl.svelte';
export { JsonCtrl } from './JsonCtrl/index';
export { MonthPickerCtrl } from './MonthPickerCtrl/index';
export { default as NativeSelectCtrl } from './NativeSelectCtrl.svelte';
export { default as NumberInputCtrl } from './NumberInputCtrl.svelte';
export { default as PasswordInputCtrl } from './PasswordInputCtrl.svelte';
export { default as PinInputCtrl } from './PinInputCtrl.svelte';
export { default as PostalCodeCtrl } from './PostalCodeCtrl.svelte';
export { default as RadioGroupCtrl } from './RadioGroupCtrl.svelte';
export { default as RatingGroupCtrl } from './RatingGroupCtrl.svelte';
export { default as LanguageCtrl } from './LanguageCtrl.svelte';
export { default as SegmentedControlCtrl } from './SegmentedControlCtrl.svelte';
export { default as SliderCtrl } from './SliderCtrl.svelte';
export type { SliderCtrlValue } from './SliderCtrl.svelte';
export { default as SwitchCtrl } from './SwitchCtrl.svelte';
export { default as TagsInputCtrl } from './TagsInputCtrl.svelte';
export { default as TextAreaCtrl } from './TextAreaCtrl.svelte';
export { default as TextInputCtrl } from './TextInputCtrl.svelte';
export { default as TimeZoneCtrl } from './TimeZoneCtrl.svelte';
export { default as UnitCtrl } from './UnitCtrl.svelte';
export { makeFieldId } from './utils';
export type { DateTimeConstraints, DateTimeMode, DateTimeParts } from './DateTimeCtrl/DateTimeCtrl.utils';
export * from './types';
export * from './formerControllersLayout';
```

## Types (types.ts)

```ts
import type { Snippet } from 'svelte';

export interface CtrlBaseProps {
  disabled?: boolean;
  error?: string;
  label?: string;
  name?: string;
  placeholder?: string;
  /** Inline text/snippet shown left of the control (e.g. `$`). */
  prefix?: string | Snippet;
  /** Value stays visible and focusable but cannot be edited. */
  readonly?: boolean;
  required?: boolean;
  sid?: string;
  /** Inline text/snippet shown right of the control (e.g. `%`). */
  suffix?: string | Snippet;
  tooltip?: string;
}

/** `SvelixLayout` key `dateTimeCtrl`. Order: props > `timePickerProps` > layout. */
export interface DateTimeCtrlLayoutConfig {
  closeOnPick?: boolean;
  clearable?: boolean;
  disableAutocomplete?: boolean;
  disablePortal?: boolean;
  hoursStep?: number;
  inputFormat?: string;
  minutesStep?: number;
  mode?: 'date' | 'time' | 'datetime';
  outputFormat?: string;
  /** Partial-entry fill: `iso-right` (dd, MM, yy / mm, HH; honours separators) or `format-left`. */
  partialOrder?: 'format-left' | 'iso-right';
  secondsStep?: number;
  timePresets?: Array<string | { label: string; value: string }>;
  use12Hour?: boolean;
  withPicker?: boolean;
  withTimeDropdown?: boolean;
}

/** `SvelixLayout` keys `buttonCtrl` / `iconButtonCtrl`. */
export interface ButtonCtrlLayoutConfig {
  variant?: 'filled' | 'ghost' | 'outlined';
}

export type IconButtonCtrlLayoutConfig = ButtonCtrlLayoutConfig;

/** `SvelixLayout` key `checkBoxCtrl`. */
export interface CheckBoxCtrlLayoutConfig {
  dataFormat?: 'boolean' | 'integer' | 'string';
  type?: 'checkbox' | 'switch';
}

/** `SvelixLayout` key `inlineWrapper`. */
export interface InlineWrapperLayoutConfig {
  labelWidth?: string;
}

/** `SvelixLayout` key `textInputCtrl`. */
export interface TextInputCtrlLayoutConfig {
  type?: string;
}

export type CtrlOption = string | { label: string; value: string };
export type CtrlOrientation = 'horizontal' | 'vertical';

/** `SvelixLayout` key `sliderCtrl`. */
export interface SliderCtrlLayoutConfig {
  showValue?: boolean;
}

/** `SvelixLayout` key `segmentedControlCtrl`. */
export interface SegmentedControlCtrlLayoutConfig {
  orientation?: CtrlOrientation;
}

/** `SvelixLayout` key `ratingGroupCtrl`. */
export interface RatingGroupCtrlLayoutConfig {
  allowHalf?: boolean;
  count?: number;
}

/** `SvelixLayout` key `tagsInputCtrl`. */
export interface TagsInputCtrlLayoutConfig {
  allowDuplicates?: boolean;
  delimiter?: string;
}

/** `SvelixLayout` key `colorPickerCtrl`. */
export interface ColorPickerCtrlLayoutConfig {
  alpha?: boolean;
  format?: 'hex' | 'rgb' | 'hsl';
}

/** `SvelixLayout` key `pinInputCtrl`. */
export interface PinInputCtrlLayoutConfig {
  length?: number;
  mask?: boolean;
  otp?: boolean;
  type?: 'alphanumeric' | 'numeric' | 'alphabetic';
}

/** `SvelixLayout` key `radioGroupCtrl`. */
export interface RadioGroupCtrlLayoutConfig {
  orientation?: CtrlOrientation;
}

/** `SvelixLayout` key `checkGroupCtrl`. */
export interface CheckGroupCtrlLayoutConfig {
  orientation?: CtrlOrientation;
}

/** `SvelixLayout` key `monthPickerCtrl`. */
export interface MonthPickerCtrlLayoutConfig {
  clearable?: boolean;
  closeOnPick?: boolean;
  disableAutocomplete?: boolean;
  disablePortal?: boolean;
  inputFormat?: string;
  locale?: string;
  outputFormat?: string;
  withPicker?: boolean;
}

/** `SvelixLayout` key `dateRangeCtrl`. */
export interface DateRangeCtrlLayoutConfig {
  mode?: 'date' | 'time' | 'datetime';
}
```

## Props

### AddressCtrl.svelte

```ts
export interface Props {
		disabled?: boolean;
		/** Show "Look up coordinates" (geocode the typed address). */
		geocode?: boolean | string | Resolver;
		/** Show "From IP address" (resolver `ip`). */
		fromIp?: boolean | string | Resolver;
		label?: string;
		/** Show "Use my location" (resolver `gps`, then `reverse`). */
		locate?: boolean | string | Resolver;
		onchange?: (value: AddressRecord) => void;
		/** Postal-code lookup on change (resolver `postalCode`). Needs a registered resolver. */
		postalLookup?: boolean | string | Resolver;
		/** Resolver session name (default `address`). */
		session?: string;
		showCoordinates?: boolean;
		value?: AddressRecord;
	}
```

### ButtonCtrl.svelte

```ts
export interface Props {
		children?: Snippet;
		disabled?: boolean;
		loading?: boolean;
		onclick?: () => void | Promise<void>;
		sid?: string;
		title?: string;
		tooltip?: string;
		variant?: 'filled' | 'ghost' | 'outlined';
	}
```

### CalendarCtrl.svelte

```ts
export type Props = Omit<PickerProps, 'defaultFrom' | 'listKey' | 'valueKey'>;
```

### CheckBoxCtrl.svelte

```ts
export interface Props extends Omit<CtrlBaseProps, 'placeholder'> {
		checked?: CheckBoxCtrlValue;
		dataFormat?: 'boolean' | 'integer' | 'string';
		onchange?: (checked: CheckBoxCtrlValue) => void;
		type?: 'checkbox' | 'switch';
	}
```

### CheckGroupCtrl.svelte

```ts
export interface Props extends Omit<CtrlBaseProps, 'placeholder'> {
		onchange?: (value: string[]) => void;
		options?: CtrlOption[];
		orientation?: CtrlOrientation;
		value?: string[];
	}
```

### ColorPickerCtrl.svelte

```ts
export interface Props extends Omit<CtrlBaseProps, 'placeholder'> {
		/** Adds an alpha slider; output gains an alpha channel (`#rrggbbaa`, `rgba()`, `hsla()`). */
		alpha?: boolean;
		/** Output string format. */
		format?: 'hex' | 'rgb' | 'hsl';
		onchange?: (value: string) => void;
		/** Preset colors shown under the picker. */
		swatches?: string[];
		value?: string;
	}
```

### CountryCodeCtrl.svelte

```ts
export interface Props extends Omit<PickerProps, 'defaultFrom' | 'listKey' | 'valueKey'> {
		/**
		 * Stored code. `alpha2` is built in (Intl). `alpha3` / `numeric` need a `countries` resolver
		 * that returns those fields (e.g. the example server); otherwise alpha-2 is stored.
		 */
		format?: 'alpha2' | 'alpha3' | 'numeric';
	}
```

### CtrlFieldShell.svelte

```ts
export interface Props {
		children: Snippet;
		disabled?: boolean;
		error?: string;
		id?: string;
		label?: string;
		/** Inline text/snippet shown left of the control (e.g. `$`). */
		prefix?: string | Snippet;
		required?: boolean;
		/** Inline text/snippet shown right of the control (e.g. `%`). */
		suffix?: string | Snippet;
		tooltip?: string;
	}
```

### CurrencyCtrl.svelte

```ts
export type Props = Omit<PickerProps, 'defaultFrom' | 'listKey' | 'valueKey'>;
```

### DateRangeCtrl.svelte

```ts
/** Date/time options are forwarded to both inputs. */
	export interface Props
		extends
			CtrlBaseProps,
			Pick<
				DateTimeProps,
				| 'clearable'
				| 'closeOnPick'
				| 'disablePortal'
				| 'inputFormat'
				| 'maxDate'
				| 'maxTime'
				| 'minDate'
				| 'minTime'
				| 'mode'
				| 'outputFormat'
				| 'use12Hour'
				| 'withPicker'
			> {
		/** Placeholders for the two inputs. */
		endPlaceholder?: string;
		onchange?: (value: DateRangeValue) => void;
		startPlaceholder?: string;
		value?: DateRangeValue;
	}
```

### FileInputCtrl.svelte

```ts
export interface Props extends Omit<CtrlBaseProps, 'placeholder'> {
		accept?: string;
		onchange?: (value: File | null) => void;
		value?: File | null;
	}
```

### GpsCtrl.svelte

```ts
export interface Props {
		disabled?: boolean;
		error?: string;
		label?: string;
		lat?: number;
		lng?: number;
		onresolve?: (result: unknown) => void;
		/** Also reverse-geocode the position into the session (needs a `reverse` resolver). Key or function. */
		reverse?: boolean | string | Resolver;
		/** GPS resolver key or function. Default `gps`. */
		resolver?: string | Resolver;
		/** Resolver session name (default `address`). Coordinates live at `<session>.lat` / `.lng`. */
		session?: string;
	}
```

### IconButtonCtrl.svelte

```ts
export interface Props {
		children?: Snippet;
		disabled?: boolean;
		loading?: boolean;
		onclick?: () => void | Promise<void>;
		sid?: string;
		tooltip?: string;
		variant?: 'filled' | 'ghost' | 'outlined';
	}
```

### InlineWrapper.svelte

```ts
export interface Props {
		children?: Snippet;
		error?: string;
		label?: string | Snippet;
		/** Id of the wrapped control; renders the label as a `<label for>` so it is announced with the field. */
		labelFor?: string;
		labelWidth?: string;
		leftSection?: Snippet;
		promptArea?: Snippet;
		required?: boolean;
		rightSection?: Snippet;
		tooltip?: string;
		value?: unknown;
	}
```

### IpCtrl.svelte

```ts
export interface Props extends Omit<TextProps, 'resolve'>, ResolveProps {
		/** Resolver session name (default `address`). IP lives at `<session>.ip`. */
		session?: string;
	}
```

### LanguageCtrl.svelte

```ts
export type Props = Omit<PickerProps, 'defaultFrom' | 'listKey' | 'valueKey'>;
```

### NativeSelectCtrl.svelte

```ts
export interface Props extends CtrlBaseProps {
		onchange?: (value: string) => void;
		options?: Array<SelectOption | string>;
		value?: string;
	}
```

### NumberInputCtrl.svelte

```ts
export interface Props extends CtrlBaseProps, ResolveProps {
		max?: number;
		min?: number;
		onchange?: (value: number | undefined) => void;
		step?: number;
		value?: number | undefined;
	}
```

### PasswordInputCtrl.svelte

```ts
export interface Props extends CtrlBaseProps {
		onchange?: (value: string) => void;
		value?: string;
	}
```

### PinInputCtrl.svelte

```ts
export interface Props extends Omit<CtrlBaseProps, 'placeholder'> {
		/** Number of boxes. */
		length?: number;
		/** Hide entered characters. */
		mask?: boolean;
		onchange?: (value: string) => void;
		/** Fired once every box is filled. */
		oncomplete?: (value: string) => void;
		/** Enables one-time-code autofill. */
		otp?: boolean;
		type?: 'alphanumeric' | 'numeric' | 'alphabetic';
		value?: string;
	}
```

### PostalCodeCtrl.svelte

```ts
export interface Props extends Omit<TextProps, 'resolve'>, ResolveProps {
		/** Resolver session name (default `address`). Postal code lives at `<session>.postalCode`. */
		session?: string;
	}
```

### RadioGroupCtrl.svelte

```ts
export interface Props extends Omit<CtrlBaseProps, 'placeholder'> {
		onchange?: (value: string) => void;
		options?: CtrlOption[];
		orientation?: CtrlOrientation;
		value?: string;
	}
```

### RatingGroupCtrl.svelte

```ts
export interface Props extends Omit<CtrlBaseProps, 'placeholder'> {
		allowHalf?: boolean;
		count?: number;
		/** `null`/`undefined` = no rating. */
		onchange?: (value: number | null) => void;
		value?: number | null;
	}
```

### SegmentedControlCtrl.svelte

```ts
export interface Props extends Omit<CtrlBaseProps, 'placeholder'> {
		onchange?: (value: string) => void;
		options?: CtrlOption[];
		orientation?: CtrlOrientation;
		value?: string;
	}
```

### SliderCtrl.svelte

```ts
export interface Props extends Omit<CtrlBaseProps, 'placeholder'> {
		marks?: Array<number | { label?: string; value: number }>;
		max?: number;
		min?: number;
		onchange?: (value: SliderCtrlValue) => void;
		/** Two thumbs; value is `[start, end]`. Implied when `value` is an array. */
		range?: boolean;
		showValue?: boolean;
		step?: number;
		value?: SliderCtrlValue;
	}
```

### SwitchCtrl.svelte

```ts
export interface Props extends Omit<CtrlBaseProps, 'placeholder'> {
		checked?: boolean;
		onchange?: (checked: boolean) => void;
	}
```

### TagsInputCtrl.svelte

```ts
export interface Props extends CtrlBaseProps {
		allowDuplicates?: boolean;
		/** Character that splits pasted/typed text into tags. */
		delimiter?: string;
		max?: number;
		onchange?: (value: string[]) => void;
		/** Return `false` to reject a tag. */
		validate?: (tag: string) => boolean;
		value?: string[];
	}
```

### TextAreaCtrl.svelte

```ts
export interface Props extends CtrlBaseProps {
		onchange?: (value: string) => void;
		rows?: number;
		value?: string;
	}
```

### TextInputCtrl.svelte

```ts
export interface Props extends CtrlBaseProps, ResolveProps {
		onchange?: (value: string) => void;
		type?: string;
		value?: string;
	}
```

### TimeZoneCtrl.svelte

```ts
export type Props = Omit<PickerProps, 'defaultFrom' | 'listKey' | 'valueKey'>;
```

### UnitCtrl.svelte

```ts
export type Props = Omit<PickerProps, 'defaultFrom' | 'listKey' | 'valueKey'>;
```

## Examples (from Storybook stories)

### Components/FormerExtensionControllers/AddressCtrl  (`AddressCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, waitFor, within } from 'storybook/test';

import AddressPreview from './ExtensionPreviews/AddressPreview.svelte';

import AddressCtrl from './AddressCtrl.svelte';
```

Helper component `./ExtensionPreviews/AddressPreview.svelte`:

```svelte
<script lang="ts">
	import ResolverProvider from '#lib/components/Resolver/ResolverProvider.svelte';
	import type { AddressRecord } from '#lib/components/Resolver/types.js';
	import AddressCtrl from '../AddressCtrl.svelte';
	import { mockResolvers } from './mocks';

	let value = $state<AddressRecord>({});
</script>

<ResolverProvider resolvers={mockResolvers()}>
	<div class="max-w-xl p-4">
		<AddressCtrl fromIp geocode locate postalLookup showCoordinates bind:value />
		<pre class="mt-3 text-xs" data-testid="value">{JSON.stringify(value)}</pre>
	</div>
</ResolverProvider>
```

#### Default (`Default`)

```ts
/** Group of address fields over one shared session, with locate / IP / geocode actions (mock resolvers). */
export const Default: Story = {
  render: () => ({ Component: AddressPreview }),
};
```

#### From Ip (`FromIp`)

```ts
export const FromIp: Story = {
  render: () => ({ Component: AddressPreview }),
};
```

#### Geocode (`Geocode`)

```ts
export const Geocode: Story = {
  render: () => ({ Component: AddressPreview }),
};
```

### Components/FormerControllers/ButtonCtrl  (`ButtonCtrl.stories.ts`)

Shared setup:

```ts
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';

import ButtonCtrl from './ButtonCtrl.svelte';
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: {
    title: 'Save',
    tooltip: 'Save record',
    variant: 'filled',
  },
};
```

#### Ghost (`Ghost`)

```ts
export const Ghost: Story = {
  args: {
    title: 'Cancel',
    tooltip: 'Cancel action',
    variant: 'ghost',
  },
};
```

#### Outlined (`Outlined`)

```ts
export const Outlined: Story = {
  args: {
    title: 'Reset',
    tooltip: 'Reset form',
    variant: 'outlined',
  },
};
```

#### Fallback Label (`FallbackLabel`)

```ts
export const FallbackLabel: Story = {
  args: {
    variant: 'filled',
  },
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
  args: {
    disabled: true,
    title: 'Cannot click',
    variant: 'filled',
  },
};
```

#### Loading (`Loading`)

```ts
export const Loading: Story = {
  args: {
    loading: true,
    title: 'Saving...',
    variant: 'filled',
  },
};
```

#### With Async Onclick (`WithAsyncOnclick`)

```ts
export const WithAsyncOnclick: Story = {
  args: {
    title: 'Async action',
    variant: 'filled',
    onclick: fn(
      () =>
        new Promise<void>((resolve) => {
          setTimeout(resolve, 50);
        })
    ),
  },
};
```

### Components/FormerExtensionControllers/CalendarCtrl  (`CalendarCtrl.stories.ts`)

Shared setup:

```ts
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';

import CalendarCtrl from './CalendarCtrl.svelte';
```

#### Default (`Default`)

```ts
/** List comes from the Intl built-ins; the initial value shows its label. */
export const Default: Story = {
  args: { label: 'Calendar', name: 'calendar', value: 'gregory' },
};
```

#### Search And Select (`SearchAndSelect`)

```ts
/** Searchable: typing narrows the list, picking an item reports the value. */
export const SearchAndSelect: Story = {
  args: { label: 'Calendar', name: 'calendar', onchange: fn() },
};
```

#### Auto Detect (`AutoDetect`)

```ts
/** `auto`: with no value and no resolver, the browser locale default is selected. */
export const AutoDetect: Story = {
  args: { auto: 'mount', label: 'Calendar', name: 'calendar' },
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
  args: { disabled: true, label: 'Calendar', value: 'gregory' },
};
```

### Components/FormerControllers/CheckBoxCtrl  (`CheckBoxCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import CheckBoxCtrl from './CheckBoxCtrl.svelte';
```

#### Default (`Default`)

```ts
export const Default: Story = {
	args: {
		label: 'Accept terms',
		name: 'terms',
	},
};
```

#### Switch (`Switch`)

```ts
export const Switch: Story = {
	args: {
		label: 'Enable notifications',
		name: 'notifications',
		type: 'switch',
	},
};
```

#### Integer (`Integer`)

```ts
export const Integer: Story = {
	args: {
		checked: 1,
		dataFormat: 'integer',
		label: 'Numeric status',
		name: 'numeric-status',
	},
};
```

#### String (`String`)

```ts
export const String: Story = {
	args: {
		checked: 'yes',
		dataFormat: 'string',
		label: 'String status',
		name: 'string-status',
		type: 'switch',
	},
};
```

#### With Error (`WithError`)

```ts
export const WithError: Story = {
	args: {
		error: 'You must accept the terms',
		label: 'Accept terms',
		name: 'terms',
		required: true,
	},
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
	args: {
		checked: true,
		disabled: true,
		label: 'Locked setting',
		name: 'locked',
		type: 'switch',
	},
};
```

### Components/FormerControllers/CheckGroupCtrl  (`CheckGroupCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import CheckGroupCtrl from './CheckGroupCtrl.svelte';

const options = ['Email', 'SMS', 'Push'];
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: { label: 'Notify via', name: 'notify', options, value: ['Email'] },
};
```

#### Horizontal (`Horizontal`)

```ts
export const Horizontal: Story = {
  args: { label: 'Notify via', name: 'notify_h', options, orientation: 'horizontal' },
};
```

#### With Error (`WithError`)

```ts
export const WithError: Story = {
  args: { error: 'Pick at least one', label: 'Notify via', name: 'notify_e', options },
};
```

### Components/FormerControllers/ColorPickerCtrl  (`ColorPickerCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import ColorPickerCtrl from './ColorPickerCtrl.svelte';
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: { label: 'Color', name: 'color', value: '#3366ff' },
};
```

#### Text Edit (`TextEdit`)

```ts
export const TextEdit: Story = {
  args: { label: 'Color', name: 'color_t', value: '#000000' },
};
```

#### With Alpha And Swatches (`WithAlphaAndSwatches`)

```ts
export const WithAlphaAndSwatches: Story = {
  args: { alpha: true, label: 'Color', name: 'color_a', swatches: ['#ef4444', '#22c55e', '#3b82f6'], value: '#3b82f6ff' },
};
```

### Components/FormerExtensionControllers/CountryCodeCtrl  (`CountryCodeCtrl.stories.ts`)

Shared setup:

```ts
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';

import CountryCodeCtrl from './CountryCodeCtrl.svelte';
```

#### Default (`Default`)

```ts
/** List comes from the Intl built-ins; the initial value shows its label. */
export const Default: Story = {
  args: { label: 'Country', name: 'countryCode', value: 'ZA' },
};
```

#### Search And Select (`SearchAndSelect`)

```ts
/** Searchable: typing narrows the list, picking an item reports the value. */
export const SearchAndSelect: Story = {
  args: { label: 'Country', name: 'countryCode', onchange: fn() },
};
```

#### Auto Detect (`AutoDetect`)

```ts
/** `auto`: with no value and no resolver, the browser locale default is selected. */
export const AutoDetect: Story = {
  args: { auto: 'mount', label: 'Country', name: 'countryCode' },
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
  args: { disabled: true, label: 'Country', value: 'ZA' },
};
```

### Components/FormerExtensionControllers/CurrencyCtrl  (`CurrencyCtrl.stories.ts`)

Shared setup:

```ts
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';

import CurrencyCtrl from './CurrencyCtrl.svelte';
```

#### Default (`Default`)

```ts
/** List comes from the Intl built-ins; the initial value shows its label. */
export const Default: Story = {
  args: { label: 'Currency', name: 'currency', value: 'ZAR' },
};
```

#### Search And Select (`SearchAndSelect`)

```ts
/** Searchable: typing narrows the list, picking an item reports the value. */
export const SearchAndSelect: Story = {
  args: { label: 'Currency', name: 'currency', onchange: fn() },
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
  args: { disabled: true, label: 'Currency', value: 'ZAR' },
};
```

### Components/FormerControllers/DateRangeCtrl  (`DateRangeCtrl.stories.ts`)

Shared setup:

```ts
import { expect, waitFor, within } from 'storybook/test';

import DateRangeCtrl from './DateRangeCtrl.svelte';
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: { label: 'Stay', name: 'stay', value: { start: '2026-01-10', end: '2026-01-15' } },
};
```

#### Invalid Range (`InvalidRange`)

```ts
export const InvalidRange: Story = {
  args: { label: 'Stay', name: 'stay_i', value: { start: '2026-02-10', end: '2026-02-01' } },
};
```

### Components/FormerControllers/DateTimeCtrl  (`DateTimeCtrl/DateTimeCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import DateTimeCtrl from './DateTimeCtrl.svelte';
```

Helper component `./DateTimeCtrl.svelte`:

```svelte
<script lang="ts">
  import { tick } from 'svelte';
  import Portal from '#lib/components/Portal/Portal.svelte';
  import { registerOverlay } from '#lib/components/OverlayStack/index.js';
  import type { CtrlBaseProps } from '../types';
  import CtrlFieldShell from '../CtrlFieldShell.svelte';
  import { makeErrorId, makeFieldId } from '../utils';
  import DateTimeCtrlPickerPanel from './DateTimeCtrlPickerPanel.svelte';
  import {
    formatDateTimeByFormat,
    getCalendarCells,
    getNowDateTimeParts,
    getYearMonthLabel,
    isDateSelectable,
    normalizePartsForMode,
    parseDateTimeByFormat,
    parsePartialDateTimeByFormat,
    parseNativeDate,
    resolveDateTimeFormat,
    shiftYearMonth,
    toNativeDate,
    validateDateTimeParts,
    type DateTimeMode,
    type DateTimeParts,
    type PartialOrder,
  } from './DateTimeCtrl.utils';
  import { useSvelixLayout } from '../../SvelixLayout/store.svelte.js';
  import { useSvelixLabels } from '../../SvelixLayout/labels.svelte.js';
  import { DATE_TIME_CTRL_DEFAULTS, DATE_TIME_CTRL_LABELS } from '../formerControllersLayout';

  interface TimePickerProps {
    amLabel?: string;
    hoursStep?: number;
    minutesStep?: number;
    pmLabel?: string;
    presets?: Array<string | { label: string; value: string }>;
    secondsStep?: number;
    use12Hour?: boolean;
    withDropdown?: boolean;
  }

  interface PopoverProps {
    closeOnClickOutside?: boolean;
    closeOnEscape?: boolean;
    dialogAriaLabel?: string;
    panelClass?: string;
  }

  export interface Props extends CtrlBaseProps {
    amLabel?: string;
    closeOnPick?: boolean;
    clearable?: boolean;
    disableAutocomplete?: boolean;
    disablePortal?: boolean;
    hoursStep?: number;
    inputFormat?: string;
    maxDate?: string;
    maxTime?: string;
    minDate?: string;
    minTime?: string;
    minutesStep?: number;
    mode?: DateTimeMode;
    onblur?: () => void;
    onchange?: (value: string | null) => void;
    outputFormat?: string;
    partialOrder?: PartialOrder;
    pmLabel?: string;
    popoverProps?: PopoverProps;
    secondsStep?: number;
    timePickerProps?: TimePickerProps;
    timePresets?: Array<string | { label: string; value: string }>;
    use12Hour?: boolean;
    withPicker?: boolean;
    withTimeDropdown?: boolean;
    value?: string | null;
  }

  let {
    amLabel,
    closeOnPick: closeOnPickProp,
    clearable: clearableProp,
    disableAutocomplete: disableAutocompleteProp,
    disablePortal: disablePortalProp,
    disabled,
    error,
    hoursStep,
    inputFormat: inputFormatProp,
    label,
    maxDate,
    maxTime,
    minDate,
    minTime,
    minutesStep,
    mode: modeProp,
    name,
    onblur,
    onchange,
    outputFormat: outputFormatProp,
    partialOrder: partialOrderProp,
    pmLabel,
    popoverProps,
    placeholder,
    prefix,
    readonly,
    required,
    sid,
    suffix,
    tooltip,
    secondsStep,
    timePickerProps,
    timePresets,
    use12Hour,
    value = $bindable(null),
    withPicker: withPickerProp,
    withTimeDropdown,
  }: Props = $props();

  const layout = useSvelixLayout('dateTimeCtrl', DATE_TIME_CTRL_DEFAULTS);
  const labels = useSvelixLabels('dateTimeCtrl', DATE_TIME_CTRL_LABELS);
  const closeOnPick = $derived(closeOnPickProp ?? layout.current.closeOnPick);
  const clearable = $derived(clearableProp ?? layout.current.clearable);
  const disableAutocomplete = $derived(disableAutocompleteProp ?? layout.current.disableAutocomplete);
  // Inside a dialog, a body-level portal sits outside the dialog's focus trap / inert region.
  let insideDialog = $state(false);
  const disablePortal = $derived((disablePortalProp ?? layout.current.disablePortal) || insideDialog);
  const inputFormat = $derived(inputFormatProp ?? layout.current.inputFormat);
  const mode = $derived(modeProp ?? layout.current.mode);
  const outputFormat = $derived(outputFormatProp ?? layout.current.outputFormat);
  const partialOrder = $derived(partialOrderProp ?? layout.current.partialOrder);
  const withPicker = $derived(withPickerProp ?? layout.current.withPicker);

  const uid = $props.id();
  const id = $derived(makeFieldId(name, sid, uid));
  const pickerDialogId = $derived(`${id}_picker_dialog`);
  const resolvedInputFormat = $derived(resolveDateTimeFormat(inputFormat, mode));
  const resolvedOutputFormat = $derived(resolveDateTimeFormat(outputFormat, mode));
  const showDatePicker = $derived(mode === 'date' || mode === 'datetime');
  const showTimePicker = $derived(mode === 'time' || mode === 'datetime');
  const rightSectionCount = $derived((withPicker ? 1 : 0) + (clearable ? 1 : 0));
  const inputRightPaddingClass = $derived(rightSectionCount >= 2 ? 'pr-20' : rightSectionCount === 1 ? 'pr-12' : 'pr-3');
  const resolvedUse12Hour = $derived(use12Hour ?? timePickerProps?.use12Hour ?? layout.current.use12Hour);
  const resolvedWithTimeDropdown = $derived(withTimeDropdown ?? timePickerProps?.withDropdown ?? layout.current.withTimeDropdown);
  const resolvedHoursStep = $derived(hoursStep ?? timePickerProps?.hoursStep ?? layout.current.hoursStep);
  const resolvedMinutesStep = $derived(minutesStep ?? timePickerProps?.minutesStep ?? layout.current.minutesStep);
  const resolvedSecondsStep = $derived(secondsStep ?? timePickerProps?.secondsStep ?? layout.current.secondsStep);
  const resolvedAmLabel = $derived(amLabel ?? timePickerProps?.amLabel ?? labels.current.am);
  const resolvedPmLabel = $derived(pmLabel ?? timePickerProps?.pmLabel ?? labels.current.pm);
  const resolvedTimePresets = $derived(timePresets ?? timePickerProps?.presets ?? layout.current.timePresets);
  const closeOnClickOutside = $derived(popoverProps?.closeOnClickOutside ?? true);
  const closeOnEscape = $derived(popoverProps?.closeOnEscape ?? true);
  const dialogAriaLabel = $derived(popoverProps?.dialogAriaLabel ?? labels.current.pickerLabel);
  const panelClass = $derived(popoverProps?.panelClass ?? '');

  let displayValue = $state('');
  let pickerOpen = $state(false);
  let pickerHour = $state(0);
  let pickerMinute = $state(0);
  let pickerSecond = $state(0);
  let selectedDateIso = $state('');
  let calendarYear = $state(getNowDateTimeParts().year);
  let calendarMonth = $state(getNowDateTimeParts().month);

  let localError = $state<string | undefined>(undefined);
  const effectiveError = $derived(error ?? localError);

  let anchorRef = $state<HTMLDivElement | null>(null);
  let popupTop = $state(0);
  let popupLeft = $state(0);
  let popupWidth = $state(0);
  let pickerZIndex = $state(1100);
  let popupFlipped = $state(false);
  let popupMaxHeight = $state(0);
  let panelEl = $state<HTMLDivElement | null>(null);
  let triggerRef = $state<HTMLButtonElement | null>(null);

  const calendarMonthLabel = $derived(getYearMonthLabel(calendarYear, calendarMonth));
  const calendarCells = $derived(getCalendarCells(calendarYear, calendarMonth));

  function syncPickerFromParts(parts: DateTimeParts): void {
    selectedDateIso = toNativeDate(parts);
    pickerHour = parts.hour;
    pickerMinute = parts.minute;
    pickerSecond = parts.second;
    calendarYear = parts.year;
    calendarMonth = parts.month;
  }

  function getCurrentParts(): DateTimeParts {
    const trimmed = displayValue.trim();
    if (trimmed === '') {
      const now = normalizePartsForMode(getNowDateTimeParts(), mode);
      return now ?? getNowDateTimeParts();
    }

    const parsed = parseDateTimeByFormat(trimmed, resolvedInputFormat, mode);
    if (parsed) {
      return parsed;
    }

    const now = normalizePartsForMode(getNowDateTimeParts(), mode);
    return now ?? getNowDateTimeParts();
  }

  function emitParts(parts: DateTimeParts): void {
    const normalizedInput = formatDateTimeByFormat(parts, resolvedInputFormat);
    const normalizedOutput = formatDateTimeByFormat(parts, resolvedOutputFormat);

    displayValue = normalizedInput;
    value = normalizedOutput;
    syncPickerFromParts(parts);
    onchange?.(normalizedOutput);
  }

  function applyParts(parts: DateTimeParts, closeAfterApply = false): boolean {
    const normalized = normalizePartsForMode(parts, mode);
    if (!normalized) {
      localError = labels.current.invalidValue;
      return false;
    }

    const constraintError = validateDateTimeParts(normalized, mode, {
      minDate,
      maxDate,
      minTime,
      maxTime,
    });

    if (constraintError) {
      localError = constraintError;
      return false;
    }

    localError = undefined;
    emitParts(normalized);

    if (closeAfterApply && closeOnPick) {
      pickerOpen = false;
    }

    return true;
  }

  function syncFromExternalValue(): void {
    const incoming = value;

    if (!incoming || incoming.trim() === '') {
      displayValue = '';
      selectedDateIso = '';
      localError = undefined;
      return;
    }

    const parsed = parseDateTimeByFormat(incoming, resolvedOutputFormat, mode);
    if (!parsed) {
      displayValue = incoming;
      selectedDateIso = '';
      return;
    }

    displayValue = formatDateTimeByFormat(parsed, resolvedInputFormat);
    syncPickerFromParts(parsed);
  }

  function clearValue(): void {
    displayValue = '';
    selectedDateIso = '';
    localError = undefined;
    value = null;
    onchange?.(null);
  }

  function commitFromTextInput(): void {
    const trimmed = displayValue.trim();

    if (trimmed === '') {
      clearValue();
      return;
    }

    const parsed = parseDateTimeByFormat(trimmed, resolvedInputFormat, mode);
    if (parsed) {
      applyParts(parsed);
      return;
    }

    // Fallback: if input is partial, complete missing segments from current date/time
    // and commit without blocking tab/blur flow.
    if (!disableAutocomplete) {
      const partialParsed = parsePartialDateTimeByFormat(trimmed, resolvedInputFormat, mode, partialOrder);
      if (partialParsed) {
        applyParts(partialParsed);
        return;
      }
    }

    // Do not block blur/tab flow, but tell the user the text was not accepted: `value` is unchanged.
    localError = labels.current.invalidValue;
  }

  function handleSelectCalendarDate(isoDate: string): void {
    const parsedDate = parseNativeDate(isoDate);
    if (!parsedDate) {
      return;
    }

    const current = getCurrentParts();
    current.year = parsedDate.year;
    current.month = parsedDate.month;
    current.day = parsedDate.day;

    const closeAfterApply = mode === 'date';
    applyParts(current, closeAfterApply);
  }

  function handleTimeChange(value: { hour: number; minute: number; second: number }): void {
    const current = getCurrentParts();
    current.hour = value.hour;
    current.minute = value.minute;
    current.second = value.second;

    const closeAfterApply = mode === 'time';
    applyParts(current, closeAfterApply);
  }

  function handleNow(): void {
    const now = normalizePartsForMode(getNowDateTimeParts(), mode);
    if (!now) {
      return;
    }

    applyParts(now, true);
  }

  function shiftMonth(delta: number): void {
    const shifted = shiftYearMonth(calendarYear, calendarMonth, delta);
    calendarYear = shifted.year;
    calendarMonth = shifted.month;
  }

  function handleTextKeydown(event: KeyboardEvent): void {
    if (event.key === 'Enter') {
      commitFromTextInput();
      (event.currentTarget as HTMLInputElement).blur();
      return;
    }

    if (event.key === 'Escape' && closeOnEscape && pickerOpen) {
      event.preventDefault();
      pickerOpen = false;
    }
  }

  function closePicker(restoreFocus = false): void {
    pickerOpen = false;
    if (restoreFocus) triggerRef?.focus();
  }

  function handleInputBlur(): void {
    commitFromTextInput();
    onblur?.();
  }

  function updatePortalPosition(): void {
    if (!anchorRef) {
      return;
    }

    const rect = anchorRef.getBoundingClientRect();
    const viewportWidth = typeof window === 'undefined' ? rect.width : window.innerWidth;
    const viewportHeight = typeof window === 'undefined' ? Infinity : window.innerHeight;
    const horizontalPadding = 8;
    const gap = 8;
    const desktopMaxWidth = 560;
    const maxWidth = Math.min(desktopMaxWidth, Math.max(280, viewportWidth - horizontalPadding * 2));
    const nextWidth = Math.min(Math.max(rect.width, 280), maxWidth);
    const maxLeft = viewportWidth - nextWidth - horizontalPadding;

    // Open upward when the panel does not fit below and there is more room above.
    const below = viewportHeight - rect.bottom - gap - horizontalPadding;
    const above = rect.top - gap - horizontalPadding;
    const natural = (panelEl?.scrollHeight ?? 380) + 2;
    popupFlipped = natural > below && above > below;
    popupMaxHeight = Math.max(160, Math.min(popupFlipped ? above : below, viewportHeight * 0.8, 608));

    popupTop = popupFlipped ? viewportHeight - rect.top + gap : rect.bottom + gap;
    popupWidth = nextWidth;
    popupLeft = Math.min(Math.max(rect.left, horizontalPadding), Math.max(horizontalPadding, maxLeft));
  }

  function handlePickerKeydown(event: KeyboardEvent): void {
    if (event.key === 'Escape' && closeOnEscape) {
      event.preventDefault();
      event.stopPropagation();
      closePicker(true);
    }
  }

  async function focusPickerFirstControl(): Promise<void> {
    if (!pickerOpen || !showDatePicker || typeof document === 'undefined') {
      return;
    }

    await tick();

    const dialog = document.getElementById(pickerDialogId);
    if (!dialog) {
      return;
    }

    const selectedDay = dialog.querySelector<HTMLButtonElement>(
      'button[data-calendar-day="true"][aria-selected="true"]:not([disabled])',
    );
    if (selectedDay) {
      selectedDay.focus();
      return;
    }

    const firstEnabledDay = dialog.querySelector<HTMLButtonElement>(
      'button[data-calendar-day="true"]:not([disabled])',
    );
    firstEnabledDay?.focus();
  }

  $effect(() => {
    syncFromExternalValue();
  });

  $effect(() => {
    if (anchorRef) insideDialog = !!anchorRef.closest('dialog, [role="dialog"]');
  });

  $effect(() => {
    if (!pickerOpen || disabled || !closeOnClickOutside || typeof document === 'undefined') {
      return;
    }

    const onDocumentPointerDown = (event: MouseEvent) => {
      const target = event.target as Node | null;
      const dialog = document.getElementById(pickerDialogId);
      const insideAnchor = !!(target && anchorRef?.contains(target));
      const insideDialog = !!(target && dialog?.contains(target));

      if (!insideAnchor && !insideDialog) {
        pickerOpen = false;
      }
    };

    document.addEventListener('mousedown', onDocumentPointerDown);

    return () => {
      document.removeEventListener('mousedown', onDocumentPointerDown);
    };
  });

  $effect(() => {
    if (disablePortal || !pickerOpen || disabled) {
      return;
    }

    updatePortalPosition();
    // Re-run once the panel has rendered so its real height drives the flip decision.
    void tick().then(updatePortalPosition);

    if (typeof window === 'undefined') {
      return;
    }

    const onWindowChange = () => {
      updatePortalPosition();
    };

    window.addEventListener('resize', onWindowChange);
    window.addEventListener('scroll', onWindowChange, true);

    return () => {
      window.removeEventListener('resize', onWindowChange);
      window.removeEventListener('scroll', onWindowChange, true);
    };
  });

  $effect(() => {
    if (!withPicker || !pickerOpen || disabled) {
      return;
    }

    const overlay = registerOverlay({
      kind: 'popover',
      close: () => {
        pickerOpen = false;
      },
      mount: disablePortal ? 'inline' : 'portal',
    });
    pickerZIndex = overlay.layer.content;

    return () => {
      overlay.unregister();
      pickerZIndex = 1100;
    };
  });
</script>

<CtrlFieldShell {disabled} error={effectiveError} {id} {label} {prefix} {required} {suffix} {tooltip}>
  <div class="relative" bind:this={anchorRef}>
    <div class="relative">
      <input
        {id}
        {disabled}
        {name}
        {readonly}
        {required}
        aria-describedby={effectiveError ? makeErrorId(id) : undefined}
        aria-invalid={effectiveError ? true : undefined}
        class={`input ${inputRightPaddingClass}`}
        class:input-error={!!effectiveError}
        value={displayValue}
        placeholder={placeholder ?? resolvedInputFormat}
        type="text"
        onblur={handleInputBlur}
        oninput={(event) => {
          displayValue = (event.currentTarget as HTMLInputElement).value;
        }}
        onkeydown={handleTextKeydown}
      />

      <div class="absolute inset-y-0 right-1 flex items-center gap-1">
        {#if withPicker}
          <button
            type="button"
            class="btn preset-tonal-surface-500 h-8 w-8 p-0"
            bind:this={triggerRef}
            disabled={disabled || readonly}
            aria-label={labels.current.openPicker}
            aria-controls={pickerDialogId}
            aria-expanded={pickerOpen}
            aria-haspopup="dialog"
            title={labels.current.openPicker}
            onclick={() => {
              const current = getCurrentParts();
              syncPickerFromParts(current);
              const nextOpen = !pickerOpen;
              pickerOpen = nextOpen;
              if (nextOpen) {
                void focusPickerFirstControl();
              }
            }}
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              width="16"
              height="16"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="2"
              stroke-linecap="round"
              stroke-linejoin="round"
              aria-hidden="true"
            >
              {#if mode === 'time'}
                <circle cx="12" cy="12" r="10" />
                <path d="M12 6v6l4 2" />
              {:else if mode === 'date'}
                <path d="M8 2v4" />
                <path d="M16 2v4" />
                <rect width="18" height="18" x="3" y="4" rx="2" />
                <path d="M3 10h18" />
              {:else}
                <path d="M8 2v4" />
                <path d="M16 2v4" />
                <rect width="18" height="18" x="3" y="4" rx="2" />
                <path d="M3 10h18" />
                <circle cx="12" cy="16" r="2" />
              {/if}
            </svg>
          </button>
        {/if}

        {#if clearable}
          <button
            type="button"
            class="btn preset-tonal-surface-500 h-8 w-8 p-0"
            disabled={disabled || readonly || !displayValue}
            aria-label={labels.current.clearValue}
            title={labels.current.clearValue}
            onclick={clearValue}
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              width="16"
              height="16"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="2"
              stroke-linecap="round"
              stroke-linejoin="round"
              aria-hidden="true"
            >
              <path d="M18 6L6 18" />
              <path d="M6 6l12 12" />
            </svg>
          </button>
        {/if}
      </div>
    </div>

    {#if withPicker && pickerOpen && !disabled && disablePortal}
      <div
        id={pickerDialogId}
        class={`absolute mt-2 w-full min-w-[17.5rem] max-w-[35rem] rounded border border-surface-300-700 bg-surface-50-950 p-3 shadow-xl ${panelClass}`.trim()}
        role="dialog"
        aria-modal="false"
        aria-label={dialogAriaLabel}
        tabindex="-1"
        onkeydown={handlePickerKeydown}
        style:z-index={pickerZIndex}
      >
        <DateTimeCtrlPickerPanel
          {showDatePicker}
          {showTimePicker}
          {calendarCells}
          {calendarMonthLabel}
          {selectedDateIso}
          hour={pickerHour}
          minute={pickerMinute}
          second={pickerSecond}
          {minDate}
          {maxDate}
          {minTime}
          {maxTime}
          use12Hour={resolvedUse12Hour}
          withTimeDropdown={resolvedWithTimeDropdown}
          hoursStep={resolvedHoursStep}
          minutesStep={resolvedMinutesStep}
          secondsStep={resolvedSecondsStep}
          amLabel={resolvedAmLabel}
          pmLabel={resolvedPmLabel}
          presets={resolvedTimePresets}
          isDateSelectable={(isoDate) => isDateSelectable(isoDate, { minDate, maxDate })}
          onShiftMonth={shiftMonth}
          onSelectCalendarDate={handleSelectCalendarDate}
          onTimeChange={handleTimeChange}
          onNow={handleNow}
          onClose={() => closePicker(true)}
        />
      </div>
    {/if}

    <Portal disabled={disablePortal}>
      {#if withPicker && pickerOpen && !disabled && !disablePortal}
        <div
          bind:this={panelEl}
          id={pickerDialogId}
          class={`fixed overflow-auto rounded border border-surface-300-700 bg-surface-50-950 p-3 shadow-xl ${panelClass}`.trim()}
          role="dialog"
          aria-modal="false"
          aria-label={dialogAriaLabel}
          tabindex="-1"
          onkeydown={handlePickerKeydown}
          style:z-index={pickerZIndex}
          style:top={popupFlipped ? undefined : `${popupTop}px`}
          style:bottom={popupFlipped ? `${popupTop}px` : undefined}
          style:max-height={`${popupMaxHeight}px`}
          style:left={`${popupLeft}px`}
          style:width={`${popupWidth}px`}
        >
          <DateTimeCtrlPickerPanel
            {showDatePicker}
            {showTimePicker}
            {calendarCells}
            {calendarMonthLabel}
            {selectedDateIso}
            hour={pickerHour}
            minute={pickerMinute}
            second={pickerSecond}
            {minDate}
            {maxDate}
            {minTime}
            {maxTime}
            use12Hour={resolvedUse12Hour}
            withTimeDropdown={resolvedWithTimeDropdown}
            hoursStep={resolvedHoursStep}
            minutesStep={resolvedMinutesStep}
            secondsStep={resolvedSecondsStep}
            amLabel={resolvedAmLabel}
            pmLabel={resolvedPmLabel}
            presets={resolvedTimePresets}
            isDateSelectable={(isoDate) => isDateSelectable(isoDate, { minDate, maxDate })}
            onShiftMonth={shiftMonth}
            onSelectCalendarDate={handleSelectCalendarDate}
            onTimeChange={handleTimeChange}
            onNow={handleNow}
            onClose={() => closePicker(true)}
          />
        </div>
      {/if}
    </Portal>
  </div>
</CtrlFieldShell>
```

#### Date Only (`DateOnly`)

```ts
export const DateOnly: Story = {
  args: {
    label: 'Start Date',
    mode: 'date',
    value: '2026-02-22',
  },
};
```

#### Time Only (`TimeOnly`)

```ts
export const TimeOnly: Story = {
  args: {
    label: 'Start Time',
    mode: 'time',
    value: '14:30:00',
  },
};
```

#### Date Time (`DateTime`)

```ts
export const DateTime: Story = {
  args: {
    label: 'Appointment',
    mode: 'datetime',
    value: '2026-02-22T14:30:00',
    inputFormat: "",
    minDate: "",
    maxTime: "",
    maxDate: "\n"
  },
};
```

#### Custom Input And Output Format (`CustomInputAndOutputFormat`)

```ts
export const CustomInputAndOutputFormat: Story = {
  args: {
    label: 'Custom Format',
    mode: 'datetime',
    inputFormat: 'DD/MM/YYYY HH:mm',
    outputFormat: 'YYYYMMDDHHMISS',
    value: '20260222143000',
  },
};
```

#### Quick Entry Date (`QuickEntryDate`)

```ts
/** `iso-right` (default): digits fill day, month, year; separators follow the format order. */
export const QuickEntryDate: Story = {
  args: {
    label: 'Quick date (type 22/10 or 2210)',
    mode: 'date',
    inputFormat: 'DD/MM/YYYY',
    outputFormat: 'ISO',
  },
};
```

#### Quick Entry Time (`QuickEntryTime`)

```ts
/** Time first: `45` = minute, `1445` = HH:mm, `144522` = HH:mm on the 22nd. */
export const QuickEntryTime: Story = {
  args: {
    label: 'Quick time (type 1445)',
    mode: 'time',
    inputFormat: 'HH24:MI',
    outputFormat: 'HH24:MI:SS',
  },
};
```

#### Quick Entry Date Time (`QuickEntryDateTime`)

```ts
export const QuickEntryDateTime: Story = {
  args: {
    label: 'Quick datetime (type 14452210)',
    mode: 'datetime',
    inputFormat: 'DD/MM/YYYY HH24:MI',
    outputFormat: 'ISO',
  },
};
```

#### Quick Entry Format Left (`QuickEntryFormatLeft`)

```ts
/** `format-left`: previous behaviour, digits fill format tokens left to right. */
export const QuickEntryFormatLeft: Story = {
  args: {
    label: 'format-left (type 2026)',
    mode: 'date',
    partialOrder: 'format-left',
  },
};
```

### Components/FormerControllers/FileInputCtrl  (`FileInputCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import FileInputCtrl from './FileInputCtrl.svelte';
```

#### Default (`Default`)

```ts
export const Default: Story = {
	args: {
        label: 'Attachment',
        name: 'attachment',
        required: false,
        tooltip: "Select file\n"
    },
};
```

#### Image (`Image`)

```ts
export const Image: Story = {
	args: {
		label: 'Profile image',
		name: 'profile-image',
		accept: 'image/*',
	},
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
	args: {
		disabled: true,
		label: 'Attachment',
		name: 'attachment',
	},
};
```

### Components/FormerControllers/TextInputCtrl  (`FormerControllers.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import TextInputCtrl from './TextInputCtrl.svelte';
```

#### Default (`Default`)

```ts
export const Default: Story = {
	args: {
		label: 'First Name',
		name: 'firstName',
		placeholder: 'Enter first name',
	},
};
```

#### With Error (`WithError`)

```ts
export const WithError: Story = {
	args: {
		error: 'This field is required',
		label: 'Email',
		name: 'email',
		placeholder: 'Enter email',
		required: true,
	},
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
	args: {
		disabled: true,
		label: 'Read-only Field',
		name: 'readonly',
		value: 'Cannot edit',
	},
};
```

#### Required (`Required`)

```ts
export const Required: Story = {
	args: {
		label: 'Required Field',
		name: 'required',
		required: true,
		placeholder: 'This is required',
	},
};
```

### Components/FormerExtensionControllers/GpsCtrl, IpCtrl, PostalCodeCtrl  (`GpsIpPostalCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, waitFor, within } from 'storybook/test';

import SimpleResolvePreview from './ExtensionPreviews/SimpleResolvePreview.svelte';
```

Helper component `./ExtensionPreviews/SimpleResolvePreview.svelte`:

```svelte
<script lang="ts">
	import ResolverProvider from '#lib/components/Resolver/ResolverProvider.svelte';
	import GpsCtrl from '../GpsCtrl.svelte';
	import IpCtrl from '../IpCtrl.svelte';
	import PostalCodeCtrl from '../PostalCodeCtrl.svelte';
	import TextInputCtrl from '../TextInputCtrl.svelte';
	import { mockResolvers } from './mocks';

	export interface Props {
		kind: 'gps' | 'ip' | 'postal';
	}

	const { kind }: Props = $props();

	let lat = $state<number | undefined>();
	let lng = $state<number | undefined>();
	let ip = $state('');
	let postal = $state('');
	let city = $state('');
	let country = $state('');
</script>

<ResolverProvider resolvers={mockResolvers()}>
	<div class="flex max-w-md flex-col gap-3 p-4">
		{#if kind === 'gps'}
			<GpsCtrl reverse bind:lat bind:lng />
			<TextInputCtrl label="City (from reverse geocode)" name="city" resolve="address.city" bind:value={city} />
		{:else if kind === 'ip'}
			<IpCtrl label="IP address" bind:value={ip} />
			<TextInputCtrl label="Country" name="country" resolve="address.country" bind:value={country} />
			<TextInputCtrl label="City" name="city" resolve="address.city" bind:value={city} />
		{:else}
			<PostalCodeCtrl label="Postal code (2000 or 8001)" bind:value={postal} />
			<TextInputCtrl label="City" name="city" resolve="address.city" bind:value={city} />
		{/if}
	</div>
</ResolverProvider>
```

#### Gps (`Gps`)

```ts
/** `GpsCtrl`: button -> `gps`, then `reverse` fills the rest of the session. */
export const Gps: Story = {
  args: { kind: 'gps' },
};
```

#### Ip (`Ip`)

```ts
/** `IpCtrl`: empty input detects the caller's IP and fills the address. */
export const Ip: Story = {
  args: { kind: 'ip' },
};
```

#### Postal Code (`PostalCode`)

```ts
/** `PostalCodeCtrl`: typing a code looks up city/province (debounced `auto="change"`). */
export const PostalCode: Story = {
  args: { kind: 'postal' },
};
```

#### Postal Code Not Found (`PostalCodeNotFound`)

```ts
export const PostalCodeNotFound: Story = {
  args: { kind: 'postal' },
};
```

### Components/FormerControllers/IconButtonCtrl  (`IconButtonCtrl.stories.ts`)

Shared setup:

```ts
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';

import IconButtonCtrl from './IconButtonCtrl.svelte';
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: {
    tooltip: 'Edit record',
  },
};
```

#### Filled (`Filled`)

```ts
export const Filled: Story = {
  args: {
    tooltip: 'Add item',
    variant: 'filled',
  },
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
  args: {
    disabled: true,
    tooltip: 'Cannot edit',
  },
};
```

#### Loading (`Loading`)

```ts
export const Loading: Story = {
  args: {
    loading: true,
    tooltip: 'Processing',
  },
};
```

#### With Async Onclick (`WithAsyncOnclick`)

```ts
export const WithAsyncOnclick: Story = {
  args: {
    tooltip: 'Delete item',
    variant: 'ghost',
    onclick: fn(
      () =>
        new Promise<void>((resolve) => {
          setTimeout(resolve, 50);
        })
    ),
  },
};
```

### Components/FormerControllers/InlineWrapper  (`InlineWrapper.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import InlineWrapper from './InlineWrapper.svelte';

import InlineWrapperHeightPreview from './InlineWrapperHeightPreview.svelte';

import InlineWrapperPreview from './InlineWrapperPreview.svelte';
```

Helper component `./InlineWrapperHeightPreview.svelte`:

```svelte
<script lang="ts">
	import Boxer from '../Boxer/Boxer.svelte';
	import ResolverProvider from '../Resolver/ResolverProvider.svelte';
	import type { AddressRecord } from '../Resolver/types.js';
	import AddressCtrl from './AddressCtrl.svelte';
	import ButtonCtrl from './ButtonCtrl.svelte';
	import CalendarCtrl from './CalendarCtrl.svelte';
	import CheckBoxCtrl from './CheckBoxCtrl.svelte';
	import CheckGroupCtrl from './CheckGroupCtrl.svelte';
	import ColorPickerCtrl from './ColorPickerCtrl.svelte';
	import CountryCodeCtrl from './CountryCodeCtrl.svelte';
	import CurrencyCtrl from './CurrencyCtrl.svelte';
	import DateRangeCtrl, { type DateRangeValue } from './DateRangeCtrl.svelte';
	import { DateTimeCtrl } from './DateTimeCtrl/index';
	import { mockResolvers } from './ExtensionPreviews/mocks';
	import FileInputCtrl from './FileInputCtrl.svelte';
	import GpsCtrl from './GpsCtrl.svelte';
	import IconButtonCtrl from './IconButtonCtrl.svelte';
	import InlineWrapper from './InlineWrapper.svelte';
	import IpCtrl from './IpCtrl.svelte';
	import { JsonCtrl } from './JsonCtrl/index';
	import LanguageCtrl from './LanguageCtrl.svelte';
	import { MonthPickerCtrl } from './MonthPickerCtrl/index';
	import NativeSelectCtrl from './NativeSelectCtrl.svelte';
	import NumberInputCtrl from './NumberInputCtrl.svelte';
	import PasswordInputCtrl from './PasswordInputCtrl.svelte';
	import PinInputCtrl from './PinInputCtrl.svelte';
	import PostalCodeCtrl from './PostalCodeCtrl.svelte';
	import RadioGroupCtrl from './RadioGroupCtrl.svelte';
	import RatingGroupCtrl from './RatingGroupCtrl.svelte';
	import SegmentedControlCtrl from './SegmentedControlCtrl.svelte';
	import SliderCtrl from './SliderCtrl.svelte';
	import SwitchCtrl from './SwitchCtrl.svelte';
	import TagsInputCtrl from './TagsInputCtrl.svelte';
	import TextAreaCtrl from './TextAreaCtrl.svelte';
	import TextInputCtrl from './TextInputCtrl.svelte';
	import TimeZoneCtrl from './TimeZoneCtrl.svelte';
	import UnitCtrl from './UnitCtrl.svelte';

	const purposes = [
		{ label: 'Sales', value: 'sales' },
		{ label: 'Purchases', value: 'purchases' },
	];

	let owner = $state('Default Organization');
	let description = $state('');
	let purpose = $state<string | undefined>();
	let account = $state<string | undefined>();
	let native = $state('sales');
	let disabled = $state(false);
	let on = $state(false);
	let password = $state('');
	let area = $state('');
	let num = $state<number | undefined>();
	let dateTime = $state<string | null>(null);
	let month = $state<string | null>(null);
	let range = $state<DateRangeValue>({ end: null, start: null });
	let color = $state('');
	let pin = $state('');
	let size = $state('');
	let channels = $state<string[]>([]);
	let plan = $state('');
	let volume = $state<number | undefined>();
	let rating = $state<number | null>(null);
	let tags = $state<string[]>([]);
	let file = $state<File | null>(null);
	let json = $state<unknown>({});
	let country = $state('');
	let currency = $state('');
	let language = $state('');
	let calendar = $state('');
	let timeZone = $state('');
	let unit = $state('');
	let ip = $state('');
	let postal = $state('');
	let address = $state<AddressRecord>({});

	const sizes = [
		{ label: 'S', value: 's' },
		{ label: 'M', value: 'm' },
	];
	const plans = [
		{ label: 'Free', value: 'free' },
		{ label: 'Pro', value: 'pro' },
	];
</script>

<ResolverProvider resolvers={mockResolvers()}>
	<div class="flex w-[34rem] flex-col gap-1" data-testid="inline-heights">
		<InlineWrapper label="TextInput"><TextInputCtrl bind:value={owner} name="owner" /></InlineWrapper>
		<InlineWrapper label="Description"><TextInputCtrl bind:value={description} name="d" placeholder="e.g. text" /></InlineWrapper>
		<InlineWrapper label="Password"><PasswordInputCtrl bind:value={password} name="pw" /></InlineWrapper>
		<InlineWrapper label="NumberInput"><NumberInputCtrl bind:value={num} name="num" /></InlineWrapper>
		<InlineWrapper label="TextArea"><TextAreaCtrl bind:value={area} name="ta" rows={1} /></InlineWrapper>
		<InlineWrapper label="Boxer" required value={purpose}>
			<Boxer data={purposes} dataSource="local" placeholder="Select Purpose..." bind:value={purpose} />
		</InlineWrapper>
		<InlineWrapper label="Lookup" required value={account}>
			<Boxer data={purposes} dataSource="local" placeholder="Search accounts..." bind:value={account} />
		</InlineWrapper>
		<InlineWrapper label="NativeSelect"><NativeSelectCtrl bind:value={native} name="n" options={purposes} /></InlineWrapper>
		<InlineWrapper label="CheckBox"><CheckBoxCtrl bind:checked={disabled} name="c" /></InlineWrapper>
		<InlineWrapper label="Switch"><SwitchCtrl bind:checked={on} name="sw" /></InlineWrapper>
		<InlineWrapper label="DateTime"><DateTimeCtrl bind:value={dateTime} name="dt" /></InlineWrapper>
		<InlineWrapper label="MonthPicker"><MonthPickerCtrl bind:value={month} name="mp" /></InlineWrapper>
		<InlineWrapper label="DateRange"><DateRangeCtrl bind:value={range} name="dr" /></InlineWrapper>
		<InlineWrapper label="ColorPicker"><ColorPickerCtrl bind:value={color} name="col" /></InlineWrapper>
		<InlineWrapper label="PinInput"><PinInputCtrl bind:value={pin} name="pin" length={4} /></InlineWrapper>
		<InlineWrapper label="RadioGroup"><RadioGroupCtrl bind:value={size} name="rg" options={sizes} orientation="horizontal" /></InlineWrapper>
		<InlineWrapper label="CheckGroup"><CheckGroupCtrl bind:value={channels} name="cg" options={sizes} orientation="horizontal" /></InlineWrapper>
		<InlineWrapper label="Segmented"><SegmentedControlCtrl bind:value={plan} name="sc" options={plans} /></InlineWrapper>
		<InlineWrapper label="Slider"><SliderCtrl bind:value={volume} name="sl" /></InlineWrapper>
		<InlineWrapper label="RatingGroup"><RatingGroupCtrl bind:value={rating} name="rt" /></InlineWrapper>
		<InlineWrapper label="TagsInput"><TagsInputCtrl bind:value={tags} name="tg" placeholder="Add tag" /></InlineWrapper>
		<InlineWrapper label="FileInput"><FileInputCtrl bind:value={file} name="f" /></InlineWrapper>
		<InlineWrapper label="Json"><JsonCtrl bind:value={json} name="j" /></InlineWrapper>
		<InlineWrapper label="CountryCode"><CountryCodeCtrl bind:value={country} name="cc" /></InlineWrapper>
		<InlineWrapper label="Currency"><CurrencyCtrl bind:value={currency} name="cur" /></InlineWrapper>
		<InlineWrapper label="Language"><LanguageCtrl bind:value={language} name="lang" /></InlineWrapper>
		<InlineWrapper label="Calendar"><CalendarCtrl bind:value={calendar} name="cal" /></InlineWrapper>
		<InlineWrapper label="TimeZone"><TimeZoneCtrl bind:value={timeZone} name="tz" /></InlineWrapper>
		<InlineWrapper label="Unit"><UnitCtrl bind:value={unit} name="u" /></InlineWrapper>
		<InlineWrapper label="Ip"><IpCtrl bind:value={ip} name="ip" /></InlineWrapper>
		<InlineWrapper label="PostalCode"><PostalCodeCtrl bind:value={postal} name="pc" /></InlineWrapper>
		<InlineWrapper label="Gps"><GpsCtrl /></InlineWrapper>
		<InlineWrapper label="Button"><ButtonCtrl title="Save" onclick={async () => {}} /></InlineWrapper>
		<InlineWrapper label="IconButton"><IconButtonCtrl onclick={async () => {}}>+</IconButtonCtrl></InlineWrapper>
		<InlineWrapper label="Address"><AddressCtrl bind:value={address} /></InlineWrapper>
	</div>
</ResolverProvider>
```

Helper component `./InlineWrapperPreview.svelte`:

```svelte
<script lang="ts">
	import InlineWrapper from './InlineWrapper.svelte';
	import TextInputCtrl from './TextInputCtrl.svelte';
	import ButtonCtrl from './ButtonCtrl.svelte';

	export interface Props {
		error?: string;
		label?: string;
		labelWidth?: string;
		required?: boolean;
		tooltip?: string;
		value?: string;
		withLeftSection?: boolean;
		withRightSection?: boolean;
	}

	let {
		error,
		label = 'Username',
		labelWidth,
		required,
		tooltip,
		value = $bindable(''),
		withLeftSection = false,
		withRightSection = false,
	}: Props = $props();
</script>

{#snippet leftSection()}
	<ButtonCtrl title="🔍" onclick={async () => {}} />
{/snippet}

{#snippet rightSection()}
	<ButtonCtrl title="Go" onclick={async () => {}} />
{/snippet}

<InlineWrapper
	{error}
	{label}
	{labelWidth}
	{required}
	{tooltip}
	{value}
	leftSection={withLeftSection ? leftSection : undefined}
	rightSection={withRightSection ? rightSection : undefined}
>
	<TextInputCtrl bind:value name="field" onchange={(v) => (value = v)} />
</InlineWrapper>
```

#### Default (`Default`)

```ts
export const Default: Story = {
  render: () => ({ Component: InlineWrapperPreview, props: { label: 'Username', value: '' } }),
};
```

#### With Error (`WithError`)

```ts
export const WithError: Story = {
  render: () => ({
    Component: InlineWrapperPreview,
    props: { label: 'Email', error: 'Invalid email', value: '' },
  }),
};
```

#### Required (`Required`)

```ts
export const Required: Story = {
  render: () => ({
    Component: InlineWrapperPreview,
    props: { label: 'Password', required: true, value: '' },
  }),
};
```

#### Required With Value (`RequiredWithValue`)

```ts
export const RequiredWithValue: Story = {
  render: () => ({
    Component: InlineWrapperPreview,
    props: { label: 'Full Name', required: true, value: 'Jane Doe' },
  }),
};
```

#### With Custom Label Width (`WithCustomLabelWidth`)

```ts
export const WithCustomLabelWidth: Story = {
  render: () => ({
    Component: InlineWrapperPreview,
    props: { label: 'Description', labelWidth: '15rem', value: '' },
  }),
};
```

#### With Tooltip (`WithTooltip`)

```ts
export const WithTooltip: Story = {
  render: () => ({
    Component: InlineWrapperPreview,
    props: { label: 'API Key', tooltip: 'Your secret API key — keep it safe', value: '' },
  }),
};
```

#### With Right Section (`WithRightSection`)

```ts
export const WithRightSection: Story = {
  render: () => ({
    Component: InlineWrapperPreview,
    props: { label: 'Search', value: '', withRightSection: true },
  }),
};
```

#### With Left Section (`WithLeftSection`)

```ts
export const WithLeftSection: Story = {
  render: () => ({
    Component: InlineWrapperPreview,
    props: { label: 'Search', value: '', withLeftSection: true },
  }),
};
```

#### With Both Sections (`WithBothSections`)

```ts
export const WithBothSections: Story = {
  render: () => ({
    Component: InlineWrapperPreview,
    props: { label: 'Query', value: '', withLeftSection: true, withRightSection: true },
  }),
};
```

#### Mixed Control Heights (`MixedControlHeights`)

```ts
export const MixedControlHeights: Story = {
  render: () => ({ Component: InlineWrapperHeightPreview }),
};
```

### Components/FormerControllers/JsonCtrl  (`JsonCtrl/JsonCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import JsonCtrl from './JsonCtrl.svelte';
```

Helper component `./JsonCtrl.svelte`:

```svelte
<script lang="ts">
  import type { CtrlBaseProps } from '../types';
  import CtrlFieldShell from '../CtrlFieldShell.svelte';
  import { makeErrorId, makeFieldId } from '../utils';
  import { useSvelixLabels } from '../../SvelixLayout/labels.svelte.js';
  import { JSON_CTRL_LABELS } from '../formerControllersLayout';
  import { iconEyeSvg } from '#lib/components/Icons/strings.js';
  import JsonCtrlEditorDialog from './JsonCtrlEditorDialog.svelte';

  export interface Props extends CtrlBaseProps {
    onchange?: (value: unknown) => void;
    value?: unknown;
  }

  let {
    disabled,
    error,
    label,
    name,
    onchange,
    prefix,
    readonly,
    required,
    sid,
    suffix,
    tooltip,
    value = $bindable<unknown>(undefined),
  }: Props = $props();

  const uid = $props.id();
  const id = $derived(makeFieldId(name, sid, uid));
  const labels = useSvelixLabels('jsonCtrl', JSON_CTRL_LABELS);

  let dialogOpen = $state(false);

  const preview = $derived.by(() => {
    if (value === undefined) return { text: labels.current.empty, empty: true };
    try {
      // JSON.stringify returns undefined (not a string) for functions and symbols.
      const text = JSON.stringify(value);
      if (text === undefined) return { text: labels.current.unserializable, empty: true };
      return { text: text.length > 80 ? `${text.slice(0, 80)}…` : text, empty: false };
    } catch {
      return { text: labels.current.unserializable, empty: true };
    }
  });

  function openDialog() {
    if (disabled) return;
    dialogOpen = true;
  }

  function handleSave(parsed: unknown) {
    value = parsed;
    onchange?.(parsed);
  }
</script>

<CtrlFieldShell {disabled} {error} {id} {label} {prefix} {required} {suffix} {tooltip}>
  <button
    {id}
    {disabled}
    aria-describedby={error ? makeErrorId(id) : undefined}
    aria-haspopup="dialog"
    class="input flex items-center justify-between gap-2 text-left"
    class:input-error={!!error}
    type="button"
    onclick={openDialog}
  >
    <code
      class="truncate text-xs font-mono"
      class:text-surface-500={preview.empty}
    >{preview.text}</code>
    <span class="shrink-0 flex items-center" aria-hidden="true">
      <!-- eslint-disable-next-line svelte/no-at-html-tags -->
      {@html iconEyeSvg}
    </span>
  </button>
</CtrlFieldShell>

<JsonCtrlEditorDialog
  bind:open={dialogOpen}
  {label}
  {readonly}
  {value}
  onsave={handleSave}
/>
```

#### Default (`Default`)

```ts
export const Default: Story = {
	args: {
		label: 'Metadata',
		name: 'metadata',
		value: { role: 'admin', tags: ['a', 'b'] },
	},
};
```

#### Empty (`Empty`)

```ts
export const Empty: Story = {
	args: {
		label: 'Config',
		name: 'config',
	},
};
```

#### With Error (`WithError`)

```ts
export const WithError: Story = {
	args: {
		error: 'Invalid JSON payload',
		label: 'Payload',
		name: 'payload',
		value: { ok: false },
	},
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
	args: {
		disabled: true,
		label: 'Locked',
		name: 'locked',
		value: { locked: true },
	},
};
```

### Components/FormerExtensionControllers/LanguageCtrl  (`LanguageCtrl.stories.ts`)

Shared setup:

```ts
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';

import LanguageCtrl from './LanguageCtrl.svelte';
```

#### Default (`Default`)

```ts
/** List comes from the Intl built-ins; the initial value shows its label. */
export const Default: Story = {
  args: { label: 'Language', name: 'language', value: 'af' },
};
```

#### Search And Select (`SearchAndSelect`)

```ts
/** Searchable: typing narrows the list, picking an item reports the value. */
export const SearchAndSelect: Story = {
  args: { label: 'Language', name: 'language', onchange: fn() },
};
```

#### Auto Detect (`AutoDetect`)

```ts
/** `auto`: with no value and no resolver, the browser locale default is selected. */
export const AutoDetect: Story = {
  args: { auto: 'mount', label: 'Language', name: 'language' },
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
  args: { disabled: true, label: 'Language', value: 'af' },
};
```

### Components/FormerControllers/MonthPickerCtrl  (`MonthPickerCtrl/MonthPickerCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, waitFor, within } from 'storybook/test';

import MonthPickerCtrl from './MonthPickerCtrl.svelte';
```

Helper component `./MonthPickerCtrl.svelte`:

```svelte
<script lang="ts">
  import { tick } from 'svelte';
  import Portal from '#lib/components/Portal/Portal.svelte';
  import { registerOverlay } from '#lib/components/OverlayStack/index.js';
  import type { CtrlBaseProps } from '../types';
  import CtrlFieldShell from '../CtrlFieldShell.svelte';
  import { makeErrorId, makeFieldId } from '../utils';
  import MonthPickerCtrlPanel from './MonthPickerCtrlPanel.svelte';
  import {
    formatYearMonth,
    getCurrentYearMonth,
    getMonthLabels,
    isMonthSelectable,
    parsePartialYearMonth,
    parseYearMonth,
    validateYearMonth,
    type YearMonth,
  } from './MonthPickerCtrl.utils';
  import { useSvelixLayout } from '../../SvelixLayout/store.svelte.js';
  import { useSvelixLabels } from '../../SvelixLayout/labels.svelte.js';
  import { MONTH_PICKER_CTRL_DEFAULTS, MONTH_PICKER_CTRL_LABELS } from '../formerControllersLayout';

  export interface Props extends CtrlBaseProps {
    clearable?: boolean;
    closeOnPick?: boolean;
    disableAutocomplete?: boolean;
    disablePortal?: boolean;
    /** Text input format. Tokens: `YYYY`, `MM`. */
    inputFormat?: string;
    locale?: string;
    /** Latest selectable month, `YYYY-MM`. */
    maxMonth?: string;
    /** Earliest selectable month, `YYYY-MM`. */
    minMonth?: string;
    onblur?: () => void;
    onchange?: (value: string | null) => void;
    /** Emitted `value` format. Tokens: `YYYY`, `MM`. */
    outputFormat?: string;
    value?: string | null;
    withPicker?: boolean;
  }

  let {
    clearable: clearableProp,
    closeOnPick: closeOnPickProp,
    disableAutocomplete: disableAutocompleteProp,
    disablePortal: disablePortalProp,
    disabled,
    error,
    inputFormat: inputFormatProp,
    label,
    locale: localeProp,
    maxMonth,
    minMonth,
    name,
    onblur,
    onchange,
    outputFormat: outputFormatProp,
    placeholder,
    prefix,
    readonly,
    required,
    sid,
    suffix,
    tooltip,
    value = $bindable(null),
    withPicker: withPickerProp,
  }: Props = $props();

  const layout = useSvelixLayout('monthPickerCtrl', MONTH_PICKER_CTRL_DEFAULTS);
  const labels = useSvelixLabels('monthPickerCtrl', MONTH_PICKER_CTRL_LABELS);
  const clearable = $derived(clearableProp ?? layout.current.clearable);
  const closeOnPick = $derived(closeOnPickProp ?? layout.current.closeOnPick);
  const disableAutocomplete = $derived(disableAutocompleteProp ?? layout.current.disableAutocomplete);
  // Inside a dialog, a body-level portal sits outside the dialog's focus trap / inert region.
  let insideDialog = $state(false);
  const disablePortal = $derived((disablePortalProp ?? layout.current.disablePortal) || insideDialog);
  const inputFormat = $derived(inputFormatProp ?? layout.current.inputFormat);
  const locale = $derived(localeProp ?? layout.current.locale);
  const outputFormat = $derived(outputFormatProp ?? layout.current.outputFormat);
  const withPicker = $derived(withPickerProp ?? layout.current.withPicker);

  const uid = $props.id();
  const id = $derived(makeFieldId(name, sid, uid));
  const pickerDialogId = $derived(`${id}_picker_dialog`);
  const rightSectionCount = $derived((withPicker ? 1 : 0) + (clearable ? 1 : 0));
  const inputRightPaddingClass = $derived(rightSectionCount >= 2 ? 'pr-20' : rightSectionCount === 1 ? 'pr-12' : 'pr-3');
  const constraints = $derived({ maxMonth, minMonth });
  const monthLabels = $derived(getMonthLabels(locale));

  let displayValue = $state('');
  let pickerOpen = $state(false);
  let selected = $state<YearMonth | null>(null);
  let pickerYear = $state(getCurrentYearMonth().year);

  let localError = $state<string | undefined>(undefined);
  const effectiveError = $derived(error ?? localError);

  let anchorRef = $state<HTMLDivElement | null>(null);
  let popupTop = $state(0);
  let popupLeft = $state(0);
  let popupWidth = $state(0);
  let pickerZIndex = $state(1100);
  let popupFlipped = $state(false);
  let popupMaxHeight = $state(0);
  let panelEl = $state<HTMLDivElement | null>(null);
  let triggerRef = $state<HTMLButtonElement | null>(null);

  function emit(ym: YearMonth): void {
    const out = formatYearMonth(ym, outputFormat);
    displayValue = formatYearMonth(ym, inputFormat);
    selected = ym;
    pickerYear = ym.year;
    value = out;
    onchange?.(out);
  }

  function apply(ym: YearMonth, closeAfterApply = false): boolean {
    const constraintError = validateYearMonth(ym, constraints);
    if (constraintError) {
      localError = constraintError;
      return false;
    }
    localError = undefined;
    emit(ym);
    if (closeAfterApply && closeOnPick) pickerOpen = false;
    return true;
  }

  function syncFromExternalValue(): void {
    const incoming = value;
    if (!incoming || incoming.trim() === '') {
      displayValue = '';
      selected = null;
      localError = undefined;
      return;
    }
    const parsed = parseYearMonth(incoming, outputFormat);
    if (!parsed) {
      displayValue = incoming;
      selected = null;
      return;
    }
    displayValue = formatYearMonth(parsed, inputFormat);
    selected = parsed;
    pickerYear = parsed.year;
  }

  function clearValue(): void {
    displayValue = '';
    selected = null;
    localError = undefined;
    value = null;
    onchange?.(null);
  }

  function commitFromTextInput(): void {
    const trimmed = displayValue.trim();
    if (trimmed === '') {
      if (value) clearValue();
      return;
    }
    const parsed =
      parseYearMonth(trimmed, inputFormat) ??
      (disableAutocomplete ? null : parsePartialYearMonth(trimmed, inputFormat));
    if (parsed) {
      apply(parsed);
      return;
    }
    // Do not block blur/tab flow, but tell the user the text was not accepted: `value` is unchanged.
    localError = labels.current.invalidValue;
  }

  function togglePicker(): void {
    const nextOpen = !pickerOpen;
    if (nextOpen) pickerYear = selected?.year ?? getCurrentYearMonth().year;
    pickerOpen = nextOpen;
    if (nextOpen) void focusPickerFirstControl();
  }

  function closePicker(restoreFocus = false): void {
    pickerOpen = false;
    if (restoreFocus) triggerRef?.focus();
  }

  function handleTextKeydown(event: KeyboardEvent): void {
    if (event.key === 'Enter') {
      commitFromTextInput();
      (event.currentTarget as HTMLInputElement).blur();
    } else if (event.key === 'Escape' && pickerOpen) {
      event.preventDefault();
      pickerOpen = false;
    }
  }

  function handlePickerKeydown(event: KeyboardEvent): void {
    if (event.key === 'Escape') {
      event.preventDefault();
      event.stopPropagation();
      closePicker(true);
    }
  }

  function handleInputBlur(): void {
    commitFromTextInput();
    onblur?.();
  }

  function updatePortalPosition(): void {
    if (!anchorRef) return;

    const rect = anchorRef.getBoundingClientRect();
    const viewportWidth = typeof window === 'undefined' ? rect.width : window.innerWidth;
    const viewportHeight = typeof window === 'undefined' ? Infinity : window.innerHeight;
    const horizontalPadding = 8;
    const gap = 8;
    const maxWidth = Math.min(320, Math.max(264, viewportWidth - horizontalPadding * 2));
    const nextWidth = Math.min(Math.max(rect.width, 264), maxWidth);
    const maxLeft = viewportWidth - nextWidth - horizontalPadding;

    // Open upward when the panel does not fit below and there is more room above.
    const below = viewportHeight - rect.bottom - gap - horizontalPadding;
    const above = rect.top - gap - horizontalPadding;
    const natural = (panelEl?.scrollHeight ?? 260) + 2;
    popupFlipped = natural > below && above > below;
    popupMaxHeight = Math.max(160, Math.min(popupFlipped ? above : below, viewportHeight * 0.8, 608));

    popupTop = popupFlipped ? viewportHeight - rect.top + gap : rect.bottom + gap;
    popupWidth = nextWidth;
    popupLeft = Math.min(Math.max(rect.left, horizontalPadding), Math.max(horizontalPadding, maxLeft));
  }

  async function focusPickerFirstControl(): Promise<void> {
    if (!pickerOpen || typeof document === 'undefined') return;
    await tick();
    const dialog = document.getElementById(pickerDialogId);
    const target =
      dialog?.querySelector<HTMLButtonElement>('button[data-month-cell="true"][aria-pressed="true"]:not([disabled])') ??
      dialog?.querySelector<HTMLButtonElement>('button[data-month-cell="true"]:not([disabled])');
    target?.focus();
  }

  $effect(() => {
    syncFromExternalValue();
  });

  $effect(() => {
    if (anchorRef) insideDialog = !!anchorRef.closest('dialog, [role="dialog"]');
  });

  $effect(() => {
    if (!pickerOpen || disabled || typeof document === 'undefined') return;

    const onDocumentPointerDown = (event: MouseEvent) => {
      const target = event.target as Node | null;
      const dialog = document.getElementById(pickerDialogId);
      const insideAnchor = !!(target && anchorRef?.contains(target));
      const insidePanel = !!(target && dialog?.contains(target));
      if (!insideAnchor && !insidePanel) pickerOpen = false;
    };

    document.addEventListener('mousedown', onDocumentPointerDown);
    return () => document.removeEventListener('mousedown', onDocumentPointerDown);
  });

  $effect(() => {
    if (disablePortal || !pickerOpen || disabled) return;

    updatePortalPosition();
    // Re-run once the panel has rendered so its real height drives the flip decision.
    void tick().then(updatePortalPosition);

    if (typeof window === 'undefined') return;

    window.addEventListener('resize', updatePortalPosition);
    window.addEventListener('scroll', updatePortalPosition, true);
    return () => {
      window.removeEventListener('resize', updatePortalPosition);
      window.removeEventListener('scroll', updatePortalPosition, true);
    };
  });

  $effect(() => {
    if (!withPicker || !pickerOpen || disabled) return;

    const overlay = registerOverlay({
      kind: 'popover',
      close: () => {
        pickerOpen = false;
      },
      mount: disablePortal ? 'inline' : 'portal',
    });
    pickerZIndex = overlay.layer.content;

    return () => {
      overlay.unregister();
      pickerZIndex = 1100;
    };
  });
</script>

{#snippet panel()}
  <MonthPickerCtrlPanel
    current={getCurrentYearMonth()}
    isSelectable={(ym) => isMonthSelectable(ym, constraints)}
    {monthLabels}
    nextYearLabel={labels.current.nextYear}
    previousYearLabel={labels.current.previousYear}
    {selected}
    thisMonthLabel={labels.current.thisMonth}
    year={pickerYear}
    onSelect={(ym) => apply(ym, true)}
    onShiftYear={(delta) => (pickerYear += delta)}
    onThisMonth={() => apply(getCurrentYearMonth(), true)}
  />
{/snippet}

<CtrlFieldShell {disabled} error={effectiveError} {id} {label} {prefix} {required} {suffix} {tooltip}>
  <div class="relative" bind:this={anchorRef}>
    <div class="relative">
      <input
        {id}
        {disabled}
        {name}
        {readonly}
        {required}
        aria-describedby={effectiveError ? makeErrorId(id) : undefined}
        aria-invalid={effectiveError ? true : undefined}
        class={`input ${inputRightPaddingClass}`}
        class:input-error={!!effectiveError}
        value={displayValue}
        placeholder={placeholder ?? inputFormat}
        type="text"
        onblur={handleInputBlur}
        oninput={(event) => {
          displayValue = (event.currentTarget as HTMLInputElement).value;
        }}
        onkeydown={handleTextKeydown}
      />

      <div class="absolute inset-y-0 right-1 flex items-center gap-1">
        {#if withPicker}
          <button
            type="button"
            class="btn preset-tonal-surface-500 h-8 w-8 p-0"
            bind:this={triggerRef}
            disabled={disabled || readonly}
            aria-label={labels.current.openPicker}
            aria-controls={pickerDialogId}
            aria-expanded={pickerOpen}
            aria-haspopup="dialog"
            title={labels.current.openPicker}
            onclick={togglePicker}
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              width="16"
              height="16"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="2"
              stroke-linecap="round"
              stroke-linejoin="round"
              aria-hidden="true"
            >
              <path d="M8 2v4" />
              <path d="M16 2v4" />
              <rect width="18" height="18" x="3" y="4" rx="2" />
              <path d="M3 10h18" />
              <path d="M8 14h3" />
            </svg>
          </button>
        {/if}

        {#if clearable}
          <button
            type="button"
            class="btn preset-tonal-surface-500 h-8 w-8 p-0"
            disabled={disabled || readonly || !displayValue}
            aria-label={labels.current.clearValue}
            title={labels.current.clearValue}
            onclick={clearValue}
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              width="16"
              height="16"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="2"
              stroke-linecap="round"
              stroke-linejoin="round"
              aria-hidden="true"
            >
              <path d="M18 6L6 18" />
              <path d="M6 6l12 12" />
            </svg>
          </button>
        {/if}
      </div>
    </div>

    {#if withPicker && pickerOpen && !disabled && disablePortal}
      <div
        id={pickerDialogId}
        class="absolute mt-2 w-full min-w-[16.5rem] max-w-[20rem] rounded border border-surface-300-700 bg-surface-50-950 p-3 shadow-xl"
        role="dialog"
        aria-modal="false"
        aria-label={labels.current.pickerLabel}
        tabindex="-1"
        onkeydown={handlePickerKeydown}
        style:z-index={pickerZIndex}
      >
        {@render panel()}
      </div>
    {/if}

    <Portal disabled={disablePortal}>
      {#if withPicker && pickerOpen && !disabled && !disablePortal}
        <div
          bind:this={panelEl}
          id={pickerDialogId}
          class="fixed overflow-auto rounded border border-surface-300-700 bg-surface-50-950 p-3 shadow-xl"
          role="dialog"
          aria-modal="false"
          aria-label={labels.current.pickerLabel}
          tabindex="-1"
          onkeydown={handlePickerKeydown}
          style:z-index={pickerZIndex}
          style:top={popupFlipped ? undefined : `${popupTop}px`}
          style:bottom={popupFlipped ? `${popupTop}px` : undefined}
          style:max-height={`${popupMaxHeight}px`}
          style:left={`${popupLeft}px`}
          style:width={`${popupWidth}px`}
        >
          {@render panel()}
        </div>
      {/if}
    </Portal>
  </div>
</CtrlFieldShell>
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: { label: 'Period', name: 'period', value: '2026-10' },
};
```

#### Pick From Grid (`PickFromGrid`)

```ts
export const PickFromGrid: Story = {
  args: { label: 'Period', name: 'period_pick', value: '2026-10', disablePortal: true },
};
```

#### Typed Partial (`TypedPartial`)

```ts
export const TypedPartial: Story = {
  args: { label: 'Period', name: 'period_partial', value: null },
};
```

#### Min Max (`MinMax`)

```ts
export const MinMax: Story = {
  args: { label: 'Period', name: 'period_mm', value: '2026-06', minMonth: '2026-03', maxMonth: '2026-09', disablePortal: true },
};
```

#### Invalid Text (`InvalidText`)

```ts
export const InvalidText: Story = {
  args: { label: 'Period', name: 'period_inv', value: null },
};
```

#### Clearable (`Clearable`)

```ts
export const Clearable: Story = {
  args: { label: 'Period', name: 'period_clr', value: '2026-10' },
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
  args: { label: 'Period', name: 'period_dis', value: '2026-10', disabled: true },
};
```

#### Custom Format (`CustomFormat`)

```ts
export const CustomFormat: Story = {
  args: { label: 'Period', name: 'period_fmt', value: '2026-10', inputFormat: 'MM/YYYY' },
};
```

### Components/FormerControllers/NativeSelectCtrl  (`NativeSelectCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import NativeSelectCtrl from './NativeSelectCtrl.svelte';
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: {
    label: 'Country',
    name: 'country',
    options: ['USA', 'Canada', 'Mexico'],
    placeholder: 'Select a country',
  },
};
```

#### With Object Options (`WithObjectOptions`)

```ts
export const WithObjectOptions: Story = {
  args: {
    label: 'Role',
    name: 'role',
    options: [
      { label: 'Admin', value: 'admin' },
      { label: 'User', value: 'user' },
      { label: 'Guest', value: 'guest' },
    ],
  },
};
```

#### With Error (`WithError`)

```ts
export const WithError: Story = {
  args: {
    error: 'Please select a role',
    label: 'Role',
    name: 'role',
    options: ['Admin', 'User'],
    required: true,
  },
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
  args: {
    disabled: true,
    label: 'Status',
    name: 'status',
    options: ['Active', 'Inactive'],
    value: 'Active',
  },
};
```

### Components/FormerControllers/NumberInputCtrl  (`NumberInputCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import NumberInputCtrl from './NumberInputCtrl.svelte';
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: {
    label: 'Age',
    name: 'age',
    placeholder: 'Enter age',
  },
};
```

#### With Min Max (`WithMinMax`)

```ts
export const WithMinMax: Story = {
  args: {
    label: 'Score',
    name: 'score',
    min: 0,
    max: 100,
    step: 5,
    value: 50,
  },
};
```

#### With Error (`WithError`)

```ts
export const WithError: Story = {
  args: {
    error: 'Must be positive',
    label: 'Quantity',
    name: 'quantity',
    required: true,
  },
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
  args: {
    disabled: true,
    label: 'Count',
    name: 'count',
    value: 42,
  },
};
```

#### Percentage (`Percentage`)

```ts
export const Percentage: Story = {
  args: {
    label: 'Discount',
    suffix: '%',
    name: 'discount',
    min: 0,
    max: 100,
    step: 0.1,
    value: 12.5,
  },
};
```

#### Currency (`Currency`)

```ts
export const Currency: Story = {
  args: {
    label: 'Price',
    prefix: '$',
    suffix: 'USD',
    name: 'price',
    min: 0,
    step: 0.01,
    value: 1499.99,
  },
};
```

### Components/FormerControllers/PasswordInputCtrl  (`PasswordInputCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import PasswordInputCtrl from './PasswordInputCtrl.svelte';
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: {
    label: 'Password',
    name: 'password',
    placeholder: 'Enter password',
  },
};
```

#### With Error (`WithError`)

```ts
export const WithError: Story = {
  args: {
    error: 'Password too short',
    label: 'Password',
    name: 'password',
    required: true,
  },
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
  args: {
    disabled: true,
    label: 'Password',
    name: 'password',
    value: 'hidden',
  },
};
```

#### Toggle Visibility (`ToggleVisibility`)

```ts
export const ToggleVisibility: Story = {
  args: {
    label: 'Password',
    name: 'pw',
    placeholder: 'Enter password',
  },
};
```

### Components/FormerControllers/PinInputCtrl  (`PinInputCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import PinInputCtrl from './PinInputCtrl.svelte';
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: { label: 'Code', name: 'code', length: 4 },
};
```

#### Masked (`Masked`)

```ts
export const Masked: Story = {
  args: { label: 'PIN', length: 4, mask: true, name: 'pin', value: '1234' },
};
```

#### Alphanumeric (`Alphanumeric`)

```ts
export const Alphanumeric: Story = {
  args: { label: 'Voucher', length: 6, name: 'voucher', type: 'alphanumeric' },
};
```

### Components/FormerControllers/RadioGroupCtrl  (`RadioGroupCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import RadioGroupCtrl from './RadioGroupCtrl.svelte';

const options = [
  { label: 'Small', value: 's' },
  { label: 'Medium', value: 'm' },
  { label: 'Large', value: 'l' },
];
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: { label: 'Size', name: 'size', options, value: 'm' },
};
```

#### Horizontal (`Horizontal`)

```ts
export const Horizontal: Story = {
  args: { label: 'Size', name: 'size_h', options, orientation: 'horizontal' },
};
```

#### Readonly (`Readonly`)

```ts
export const Readonly: Story = {
  args: { label: 'Size', name: 'size_ro', options, readonly: true, value: 's' },
};
```

### Components/FormerControllers/RatingGroupCtrl  (`RatingGroupCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import RatingGroupCtrl from './RatingGroupCtrl.svelte';
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: { label: 'Rating', name: 'rating', value: 3 },
};
```

#### Empty (`Empty`)

```ts
export const Empty: Story = {
  args: { label: 'Rating', name: 'rating_e', value: null },
};
```

#### Half Stars (`HalfStars`)

```ts
export const HalfStars: Story = {
  args: { allowHalf: true, count: 10, label: 'Score', name: 'score', value: 7.5 },
};
```

#### Readonly (`Readonly`)

```ts
export const Readonly: Story = {
  args: { label: 'Rating', name: 'rating_ro', readonly: true, value: 4 },
};
```

### Components/FormerExtensionControllers/Resolver scenarios  (`ResolverScenarios.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, waitFor, within } from 'storybook/test';

import CustomResolverPreview from './ExtensionPreviews/CustomResolverPreview.svelte';

import FormerResolvePreview from './ExtensionPreviews/FormerResolvePreview.svelte';

import ServerResolverPreview from './ExtensionPreviews/ServerResolverPreview.svelte';

import SharedSessionPreview from './ExtensionPreviews/SharedSessionPreview.svelte';

async function pickCountry(canvasElement, canvas, search: string) {
  const root = within(canvasElement.ownerDocument.body);
  const input = canvas.getByPlaceholderText('Country');
  await userEvent.click(input);
  await userEvent.type(input, search);
  const options = await root.findAllByRole('option', {}, { timeout: 4000 });
  await userEvent.click(options[0]);
}
```

Helper component `./ExtensionPreviews/CustomResolverPreview.svelte`:

```svelte
<script lang="ts">
	import ResolverProvider from '#lib/components/Resolver/ResolverProvider.svelte';
	import type { Resolver } from '#lib/components/Resolver/types.js';
	import TextInputCtrl from '../TextInputCtrl.svelte';

	// Registered under a key via the provider...
	const slug: Resolver<string, { slug: string }> = (title) => ({
		slug: String(title).toLowerCase().trim().replace(/\s+/g, '-'),
	});
	// ...or passed inline to a single control (bypasses the registry).
	const upper = (v: string) => ({ upper: String(v).toUpperCase() });

	let title = $state('Hello Custom World');
	let slugValue = $state('');
	let upperValue = $state('');
</script>

<ResolverProvider resolvers={{ slug }}>
	<div class="flex max-w-md flex-col gap-3 p-4">
		<TextInputCtrl label="Title" name="title" bind:value={title} />
		<TextInputCtrl
			label="Slug (registered resolver)"
			name="slug"
			resolve="doc.slug"
			resolveButton
			resolveInput={() => title}
			resolver="slug"
			bind:value={slugValue}
		/>
		<TextInputCtrl
			label="Upper (inline resolver)"
			name="upper"
			resolve="doc.upper"
			resolveButton
			resolveInput={() => title}
			resolver={upper}
			bind:value={upperValue}
		/>
	</div>
</ResolverProvider>
```

Helper component `./ExtensionPreviews/FormerResolvePreview.svelte`:

```svelte
<script lang="ts">
	import Former from '#lib/components/Former/Former.svelte';
	import ResolverProvider from '#lib/components/Resolver/ResolverProvider.svelte';
	import TextInputCtrl from '../TextInputCtrl.svelte';
	import { mockResolvers } from './mocks';

	interface Form {
		city?: string;
		country?: string;
		name?: string;
		timeZone?: string;
	}

	let values = $state<Form>({ name: '' });
	let opened = $state(true);
</script>

<!-- Prefill on open: the Intl `locale` resolver (time zone, region) and a mock server resolver (city). Not dirty. -->
<ResolverProvider resolvers={mockResolvers()}>
	<div class="p-4" style="min-height: 320px;">
		<Former
			bind:opened
			bind:values
			request="insert"
			resolve={[
				{ map: { region: 'country', timeZone: 'timeZone' }, resolver: 'locale' },
				{ map: { city: 'city' }, resolver: 'address' },
			]}
		>
			{#snippet children(state)}
				<div class="flex max-w-md flex-col gap-3">
					<TextInputCtrl
						label="Name"
						name="name"
						value={state.values?.name ?? ''}
						onchange={(v) => state.setState('values', { ...state.values, name: v })}
					/>
					<TextInputCtrl label="City" name="city" value={state.values?.city ?? ''} onchange={(v) => state.setState('values', { ...state.values, city: v })} />
					<TextInputCtrl label="Country" name="country" value={state.values?.country ?? ''} onchange={(v) => state.setState('values', { ...state.values, country: v })} />
					<TextInputCtrl label="Time zone" name="timeZone" value={state.values?.timeZone ?? ''} onchange={(v) => state.setState('values', { ...state.values, timeZone: v })} />
					<p data-testid="dirty">{state.dirty ? 'dirty' : 'clean'}</p>
				</div>
			{/snippet}
		</Former>
	</div>
</ResolverProvider>
```

Helper component `./ExtensionPreviews/ServerResolverPreview.svelte`:

```svelte
<script lang="ts">
	import ResolverProvider from '#lib/components/Resolver/ResolverProvider.svelte';
	import { serverResolvers } from '#lib/components/Resolver/resolvers/server.js';
	import { createHandlers } from '../../../../routes/api/resolve/_handlers/handlers';
	import CountryCodeCtrl from '../CountryCodeCtrl.svelte';
	import CurrencyCtrl from '../CurrencyCtrl.svelte';
	import TextInputCtrl from '../TextInputCtrl.svelte';

	export interface Props {
		/** true: call the example handlers in-process (no network). false: POST to /api/resolve (needs `pnpm dev`). */
		mock?: boolean;
	}

	const { mock = true }: Props = $props();

	const handlers = createHandlers();
	// eslint-disable-next-line svelte/no-unused-svelte-ignore
	// svelte-ignore state_referenced_locally -- chosen once at mount
	const inProcess: typeof fetch = async (url, init) => {
		const key = String(url).split('/').pop() as string;
		const body = JSON.parse(String(init?.body ?? '{}'));
		try {
			return new Response(JSON.stringify({ data: await handlers[key](body.input) }));
		} catch (e) {
			return new Response(JSON.stringify({ error: (e as Error).message }), { status: 400 });
		}
	};

	// eslint-disable-next-line svelte/no-unused-svelte-ignore
	// svelte-ignore state_referenced_locally -- chosen once at mount
	const resolvers = serverResolvers({
		baseUrl: '/api/resolve',
		fetch: mock ? inProcess : undefined,
		keys: ['countryDefaults', 'geocode', 'reverse', 'postalCode'],
	});

	let country = $state('');
	let currency = $state('');
	let dialCode = $state('');
</script>

<ResolverProvider {resolvers}>
	<div class="flex max-w-md flex-col gap-3 p-4">
		<CountryCodeCtrl
			label="Country"
			name="country"
			auto="change"
			resolve="address.countryCode"
			resolver="countryDefaults"
			bind:value={country}
		/>
		<CurrencyCtrl label="Currency (from server)" name="currency" resolve="address.currency" bind:value={currency} />
		<TextInputCtrl label="Dial code (from server)" name="dialCode" resolve="address.dialCode" bind:value={dialCode} />
	</div>
</ResolverProvider>
```

Helper component `./ExtensionPreviews/SharedSessionPreview.svelte`:

```svelte
<script lang="ts">
	import ResolverProvider from '#lib/components/Resolver/ResolverProvider.svelte';
	import CountryCodeCtrl from '../CountryCodeCtrl.svelte';
	import CurrencyCtrl from '../CurrencyCtrl.svelte';
	import TextInputCtrl from '../TextInputCtrl.svelte';
	import TimeZoneCtrl from '../TimeZoneCtrl.svelte';
	import { mockResolvers } from './mocks';

	let country = $state('');
	let currency = $state('');
	let timeZone = $state('');
	let dialCode = $state('');
</script>

<!-- Choosing a country resolves `countryDefaults` once; the other controls pick their field from the shared session. -->
<ResolverProvider resolvers={mockResolvers()}>
	<div class="flex max-w-md flex-col gap-3 p-4">
		<CountryCodeCtrl
			label="Country"
			name="country"
			auto="change"
			resolve="address.countryCode"
			resolver="countryDefaults"
			bind:value={country}
		/>
		<CurrencyCtrl label="Currency" name="currency" resolve="address.currency" bind:value={currency} />
		<TimeZoneCtrl label="Time zone" name="timeZone" resolve="address.timeZone" bind:value={timeZone} />
		<TextInputCtrl label="Dial code" name="dialCode" resolve="address.dialCode" bind:value={dialCode} />
	</div>
</ResolverProvider>
```

#### Shared Session (`SharedSession`)

```ts
/** One `countryDefaults` resolve fills currency, time zone and dial code via the shared session. */
export const SharedSession: Story = {
  render: () => ({ Component: SharedSessionPreview }),
};
```

#### Former Prefill (`FormerPrefill`)

```ts
/** `Former resolve={[...]}` prefills an insert form on open (Intl `locale` + a server-style resolver) without dirtying it. */
export const FormerPrefill: Story = {
  render: () => ({ Component: FormerResolvePreview }),
};
```

#### Custom Resolver (`CustomResolver`)

```ts
/** Your own resolvers: registered by key on the provider, or passed inline to one control. */
export const CustomResolver: Story = {
  render: () => ({ Component: CustomResolverPreview }),
};
```

#### Server Resolver (`ServerResolver`)

```ts
/** `serverResolvers`: the example handlers (src/routes/api/resolve) called in-process; pass `mock: false` to POST to the dev server. */
export const ServerResolver: Story = {
  args: { mock: true },
  render: (args) => ({ Component: ServerResolverPreview, props: args }),
};
```

### Components/FormerControllers/SegmentedControlCtrl  (`SegmentedControlCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import SegmentedControlCtrl from './SegmentedControlCtrl.svelte';

const options = ['Day', 'Week', 'Month'];
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: { label: 'View', name: 'view', options, value: 'Week' },
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
  args: { disabled: true, label: 'View', name: 'view_d', options, value: 'Day' },
};
```

### Components/FormerControllers/SliderCtrl  (`SliderCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import SliderCtrl from './SliderCtrl.svelte';
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: { label: 'Volume', name: 'volume', min: 0, max: 100, step: 5, value: 40 },
};
```

#### Range (`Range`)

```ts
export const Range: Story = {
  args: { label: 'Price', name: 'price', min: 0, max: 1000, step: 10, value: [200, 800] },
};
```

#### With Marks And Suffix (`WithMarksAndSuffix`)

```ts
export const WithMarksAndSuffix: Story = {
  args: { label: 'Opacity', name: 'opacity', suffix: '%', marks: [0, 50, 100], value: 50 },
};
```

### Components/FormerControllers/SwitchCtrl  (`SwitchCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import SwitchCtrl from './SwitchCtrl.svelte';
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: {
    label: 'Enable notifications',
    name: 'notify',
  },
};
```

#### Checked (`Checked`)

```ts
export const Checked: Story = {
  args: {
    checked: true,
    label: 'Feature enabled',
    name: 'feature',
  },
};
```

#### With Error (`WithError`)

```ts
export const WithError: Story = {
  args: {
    error: 'Required',
    label: 'Accept terms',
    name: 'terms',
  },
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
  args: {
    checked: true,
    disabled: true,
    label: 'Locked setting',
    name: 'locked',
  },
};
```

### Components/FormerControllers/TagsInputCtrl  (`TagsInputCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import TagsInputCtrl from './TagsInputCtrl.svelte';
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: { label: 'Tags', name: 'tags', placeholder: 'Add tag', value: ['svelte', 'skeleton'] },
};
```

#### No Duplicates (`NoDuplicates`)

```ts
export const NoDuplicates: Story = {
  args: { label: 'Tags', name: 'tags_nd', placeholder: 'Add tag', value: ['a'] },
};
```

#### With Error (`WithError`)

```ts
export const WithError: Story = {
  args: { error: 'At least one tag', label: 'Tags', name: 'tags_e' },
};
```

### Components/FormerControllers/TextAreaCtrl  (`TextAreaCtrl.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import TextAreaCtrl from './TextAreaCtrl.svelte';
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: {
    label: 'Description',
    name: 'desc',
    placeholder: 'Enter description',
  },
};
```

#### With Rows (`WithRows`)

```ts
export const WithRows: Story = {
  args: {
    label: 'Bio',
    name: 'bio',
    placeholder: 'Tell us about yourself',
    rows: 8,
  },
};
```

#### With Error (`WithError`)

```ts
export const WithError: Story = {
  args: {
    error: 'Description too short',
    label: 'Notes',
    name: 'notes',
    required: true,
  },
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
  args: {
    disabled: true,
    label: 'Readonly notes',
    name: 'notes',
    value: 'Cannot edit this',
  },
};
```

### Components/FormerExtensionControllers/TextInputCtrl (resolve)  (`TextInputResolve.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, waitFor, within } from 'storybook/test';

import ResolveFieldPreview from './ExtensionPreviews/ResolveFieldPreview.svelte';
```

Helper component `./ExtensionPreviews/ResolveFieldPreview.svelte`:

```svelte
<script lang="ts">
	import ResolverProvider from '#lib/components/Resolver/ResolverProvider.svelte';
	import TextInputCtrl from '../TextInputCtrl.svelte';
	import { mockResolvers } from './mocks';

	export interface Props {
		/** button: resolve button; mount: auto on mount; change: auto when postal code changes; dirty: pre-filled value is protected. */
		mode?: 'button' | 'change' | 'dirty' | 'mount';
	}

	const { mode = 'button' }: Props = $props();

	// eslint-disable-next-line svelte/no-unused-svelte-ignore
	// svelte-ignore state_referenced_locally -- initial value only
	let city = $state(mode === 'dirty' ? 'My own city' : '');
	let province = $state('');
	let postalCode = $state('');
</script>

<ResolverProvider resolvers={mockResolvers()}>
	<div class="flex max-w-md flex-col gap-3 p-4">
		{#if mode === 'change'}
			<TextInputCtrl
				label="Postal code (type 2000)"
				name="postalCode"
				auto="change"
				autoDebounceMs={30}
				resolve="address.postalCode"
				resolver="postalCode"
				resolveInput={() => ({ postalCode })}
				bind:value={postalCode}
			/>
		{/if}
		<TextInputCtrl
			label="City"
			name="city"
			auto={mode === 'mount' || mode === 'dirty' ? 'mount' : undefined}
			resolve="address.city"
			resolveButton={mode === 'button' || mode === 'dirty'}
			resolver={mode === 'change' ? undefined : 'address'}
			bind:value={city}
		/>
		<TextInputCtrl label="Province" name="province" resolve="address.province" bind:value={province} />
	</div>
</ResolverProvider>
```

#### Resolve Button (`ResolveButton`)

```ts
/** `resolveButton`: click resolves into the shared session; sibling controls (`Province`) fill too. */
export const ResolveButton: Story = {
  args: { mode: 'button' },
};
```

#### Auto On Mount (`AutoOnMount`)

```ts
/** `auto="mount"`: resolves on mount when the field is empty. */
export const AutoOnMount: Story = {
  args: { mode: 'mount' },
};
```

#### Auto On Change (`AutoOnChange`)

```ts
/** `auto="change"`: typing a postal code resolves (debounced) and fills the city. */
export const AutoOnChange: Story = {
  args: { mode: 'change' },
};
```

#### Dirty Guard (`DirtyGuard`)

```ts
/** A pre-filled value is user-owned: `auto` never overwrites it, the button does. */
export const DirtyGuard: Story = {
  args: { mode: 'dirty' },
};
```

### Components/FormerExtensionControllers/TimeZoneCtrl  (`TimeZoneCtrl.stories.ts`)

Shared setup:

```ts
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';

import TimeZoneCtrl from './TimeZoneCtrl.svelte';
```

#### Default (`Default`)

```ts
/** List comes from the Intl built-ins; the initial value shows its label. */
export const Default: Story = {
  args: { label: 'Time zone', name: 'timeZone', value: 'Africa/Johannesburg' },
};
```

#### Search And Select (`SearchAndSelect`)

```ts
/** Searchable: typing narrows the list, picking an item reports the value. */
export const SearchAndSelect: Story = {
  args: { label: 'Time zone', name: 'timeZone', onchange: fn() },
};
```

#### Auto Detect (`AutoDetect`)

```ts
/** `auto`: with no value and no resolver, the browser locale default is selected. */
export const AutoDetect: Story = {
  args: { auto: 'mount', label: 'Time zone', name: 'timeZone' },
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
  args: { disabled: true, label: 'Time zone', value: 'Africa/Johannesburg' },
};
```

### Components/FormerExtensionControllers/UnitCtrl  (`UnitCtrl.stories.ts`)

Shared setup:

```ts
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';

import UnitCtrl from './UnitCtrl.svelte';
```

#### Default (`Default`)

```ts
/** List comes from the Intl built-ins; the initial value shows its label. */
export const Default: Story = {
  args: { label: 'Unit', name: 'unit', value: 'kilometer' },
};
```

#### Search And Select (`SearchAndSelect`)

```ts
/** Searchable: typing narrows the list, picking an item reports the value. */
export const SearchAndSelect: Story = {
  args: { label: 'Unit', name: 'unit', onchange: fn() },
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
  args: { disabled: true, label: 'Unit', value: 'kilometer' },
};
```

---

# GlobalStateStore

Import: `import { ... } from '@warky/svelix/GlobalStateStore'` (or from `'@warky/svelix'`).

## Exports

```ts
export * from "./GlobalStateStore";
export { _T, appendPageTranslations, appendTranslations, clearPageTranslations, clearTranslations, loadPageTranslations, loadTranslations, t } from "./GlobalStateStore.i18n";
export type { TranslationDictionary, TranslationMap } from "./GlobalStateStore.i18n";

export type * from "./GlobalStateStore.types";
export { default as GlobalStateStoreProvider } from "./GlobalStateStoreProvider.svelte";
export { useGlobalStateStoreContext } from "./GlobalStateStoreContext";
export type { GlobalStateStoreContextValue } from "./GlobalStateStoreContext";
export { GLOBAL_STATE_STORE_PROVIDER_DEFAULTS } from "./globalStateStoreLayout";
export type { GlobalStateStoreProviderLayoutConfig } from "./globalStateStoreLayout";
```

## Props

### GlobalStateStoreProvider.svelte

```ts
export interface Props {
		apiURL?: string;
		autoFetch?: boolean;
		children: Snippet;
		fetchOnMount?: boolean;
		onFetchSession?: (state: Partial<GlobalState>) => Promise<Partial<GlobalState>>;
		onLogin?: (
			state: Partial<GlobalState>
		) => Promise<Partial<Pick<GlobalState, 'owner' | 'program' | 'session' | 'user'>> | void>;
		onLogout?: (
			state: Partial<GlobalState>
		) => Promise<Partial<Pick<GlobalState, 'owner' | 'program' | 'session' | 'user'>> | void>;
		program?: Partial<ProgramState>;
		throttleMs?: number;
	}
```

---

# GridExport

Import: `import { ... } from '@warky/svelix/GridExport'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as GridExportDialog } from './GridExportDialog.svelte';
export * from './exportCore.js';
export * from './formatValue.js';
export type * from './types.js';
export { GRID_EXPORT_DIALOG_DEFAULTS } from './gridExportLayout.js';
```

## Types (types.ts)

```ts
import type { GridColumnFormat, GridExportFormat } from '../Types/generic_grid.js';
import type { ValueLabels } from '../Types/valueLabels.js';

export type { GridExportFormat };

/** Column shape the export reads. GridlerColumn and CardGridColumn are both assignable. */
export interface GridExportColumn {
	id: string;
	title: string;
	/** Field path on the record; defaults to id. Supports dot notation. */
	dataKey?: string;
	format?: GridColumnFormat;
	/** ISO currency code for `format: 'currency'`. Default `ZAR`. */
	currency?: string;
	/** BCP 47 locale for number/currency/date text. Default: browser locale. */
	locale?: string;
	/** Stored value → display text; exported instead of the raw value unless `exportRaw`. */
	valueLabels?: ValueLabels;
	/** Export the raw value instead of the `valueLabels` label. */
	exportRaw?: boolean;
	/** Per-column value override; see `GridColumn.exportValue`. */
	exportValue?: (row: Record<string, unknown>, format: GridExportFormat) => unknown;
}

/** Options for the `exportable` feature of Gridler and CardGrid. */
export interface GridExportOptions {
	/** Formats offered in the export dialog, in order. Default: all. */
	formats?: GridExportFormat[];
	/** File name without extension. Default: entity name, else `export`. */
	filename?: string;
	/** Rows per server request while exporting. Default: the grid's `pageSize`. */
	pageSize?: number;
	/** Called with the finished file. Return `false` to skip the browser download. */
	onExportComplete?: (
		blob: Blob,
		format: GridExportFormat,
		filename: string,
	) => boolean | void | Promise<boolean | void>;
}

/** One batch of rows from a row source; `total` is the server-reported row count, if known. */
export interface GridExportPage {
	rows: Record<string, unknown>[];
	total?: number;
}

/** Yields every row that matches the grid's current query, page by page. */
export type GridExportRowSource = (signal: AbortSignal) => AsyncIterable<GridExportPage>;

export interface GridExportProgress {
	phase: 'fetching' | 'building' | 'done';
	/** Rows collected so far. */
	rows: number;
	/** Expected total rows, when known. */
	total?: number;
	/** Pages fetched so far. */
	pages: number;
}

/** `SvelixLayout` key `gridExportDialog`. */
export interface GridExportDialogLayoutConfig {
	defaultFilename?: string;
}
```

## Props

### GridExportDialog.svelte

```ts
export interface Props {
		/** Bindable. */
		open?: boolean;
		/** Every exportable column, in the grid's current order. */
		columns: GridExportColumn[];
		/** Column ids checked when the dialog opens. Default: all `columns`. */
		selectedColumnIds?: string[];
		options?: GridExportOptions;
		/** Default file name when `options.filename` is unset. */
		defaultFilename?: string;
		/** Called on Start; snapshots the grid's current query into a row source. */
		createSource: () => GridExportRowSource;
	}
```

---

# Gridler

Import: `import { ... } from '@warky/svelix/Gridler'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as Gridler } from './components/Gridler.svelte';
export { default as GridlerFull } from './components/GridlerFull.svelte';
export { GridlerResolveSpecAdapter } from './adapters/GridlerResolveSpecAdapter.js';
export { GridlerRestHeaderSpecAdapter } from './adapters/GridlerRestHeaderSpecAdapter.js';
export * from './types';
export * from './utils/scrolling';
export * from './utils/selection';
export * from './utils/helpers';
export * from './utils/cellCache';
export * from './renderers';
export type {
  GridColumn,
  GridCommonProps,
  GridColumnFormat,
  GridColumnSortOrder,
  GridColumnFilter,
  GridColumnFilters,
  ColumnFilterQuery,
  ColumnSortQuery,
  GridFilterOption,
  GridFilterControlContext,
} from '../Types/generic_grid';
export { filterList, filterValueList, filterValueText, filterDisplayValue } from '../Types/generic_grid';
export { GRIDLER_DEFAULTS, GRIDLER_FULL_DEFAULTS } from './gridlerLayout';
export * from './utils/filterSql';
export * from '../Types/columnLookup';
export * from '../Types/columnPresets';
```

## Types (types.ts)

```ts
import type { Snippet } from 'svelte';
import type { Readable } from 'svelte/store';
import type {
  GridColumn,
  GridCommonProps,
  GridColumnSortOrder,
  GridColumnFilter,
  GridColumnFilters,
  GridContextMenuItem,
} from '../Types/generic_grid';
import type { Options } from '@warkypublic/resolvespec-js';
import type { ServerEventSourceManager } from '../ServerEvents/ServerEventSourceManager';

export type { GridColumnSortOrder, GridColumnFilter, GridColumnFilters };
export { filterList } from '../Types/generic_grid';

export type Item = [col: number, row: number];

/**
 * Configures auto-refresh via ServerEventSource: the adapter subscribes (debounced per row)
 * to the table's insert/update/delete events.
 * - `insert`: not refetched — `row` is always `undefined`. The caller should trigger a full
 *   refresh, since a new row can't be patched into an already-loaded page.
 * - `update`: refetched via the adapter's `readRow`; `row` is the fresh data, or `undefined`
 *   if it no longer matches (race with a delete) — the caller should only patch rows already
 *   loaded and otherwise do nothing (no full refresh).
 * - `delete`: not refetched — `row` is always `undefined`. The caller should remove `id` from
 *   already-loaded rows, if present.
 */
export interface GridlerPushEventOptions {
  /** SSE endpoint URL. Pass a Svelte store for a value resolved/changed after startup. */
  url: Readable<string> | string;
  /**
   * Columns to request via ServerEventSource's combined `&columns=` query param, and to
   * request when refetching a changed row on `update`. Omit to fetch all columns.
   */
  columns?: string[];
  /** Debounce window (ms), per unique row. Default 500. */
  debounceMs?: number;
  /** Called after an insert/update/delete event for this table. See interface doc for `row` per `op`. */
  onRowChanged?: (
    op: 'insert' | 'update' | 'delete' | undefined,
    row: Record<string, unknown> | undefined,
    id: string,
  ) => void;
  /** Called on SSE connection errors or row-refetch failures. */
  onError?: (error: unknown) => void;
  /** Overrides the shared default ServerEventSource manager. Intended for tests. */
  manager?: ServerEventSourceManager;
}

export interface GridlerAdapterConfig {
  url: string;
  token?: string;
  schema: string;
  entity: string;
  /** Row field whose value is passed as cursor_forward on subsequent requests. Defaults to 'id'. */
  uniqueID?: string;
  /** Custom HTTP headers sent with every request. `token` and HeaderSpec query headers win. */
  headers?: Record<string, string>;
  /**
   * Default ResolveSpec options applied to every request. Grid-controlled sort, filters, limit,
   * cursor, and columns always take precedence over values set here.
   *
   * Key fields:
   * - `preload`          — eager-load relations. Each entry specifies `relation`, optional `columns`,
   *                        `filters`, `sort`, `limit`, `recursive`, `sql_joins`, etc.
   * - `omit_columns`     — columns to exclude from the response.
   * - `computedColumns`  — server-side computed expressions: `{ name, expression }`.
   * - `customOperators`  — raw SQL WHERE fragments (AND-combined): `{ name, sql }`.
   * - `parameters`       — named query parameters passed to the server: `{ name, value, sequence? }`.
   * - `filters`          — default server-side filters applied before any grid filters.
   * - `sort`             — default sort order, overridden when the user sorts a column.
   * - `fetch_row_number` — column name whose row number is returned in metadata.
   * - `offset`           — static row offset (cursor pagination is used by the adapter; avoid mixing).
   */
  extraOptions?: Partial<Options>;
  /** When true (and `usePushEventOptions.url` is set), auto-refresh row/table data via ServerEventSource. */
  usePushEvent?: boolean;
  usePushEventOptions?: GridlerPushEventOptions;
}

export interface GridlerPageResult {
  data: Record<string, unknown>[];
  total: number;
  /** Value of uniqueID from the last row — used as cursor_forward on the next request. */
  nextCursor: string | undefined;
}

export type GridlerCellKind =
  | "text"
  | "number"
  | "markdown"
  | "image"
  | "uri"
  | "bubble"
  | "drilldown"
  | "currency"
  | "percentage"
  | "datetime";

export interface GridlerTheme {
  accentColor: string;
  accentFg: string;
  bgCell: string;
  bgHeader: string;
  bgHeaderHasFocus: string;
  bgSearchResult: string;
  borderColor: string;
  /** Stronger border under the header row, separating it from the body. Falls back to `borderColor`. */
  headerBorderColor?: string;
  cellHorizontalPadding: number;
  cellVerticalPadding: number;
  fontFamily: string;
  fontSize: number;
  foreground: string;
  headerFontSize: number;
  lineHeight: number;
}

/**
 * Gridler's column definition. Extends the generic GridColumn interface with
 * Gridler-specific rendering fields (themeOverride, hasMenu) and makes `width`
 * required (GridColumn has it optional).
 */
/** Built-in in-place editors. Inferred from `format` / cell kind unless `GridlerColumn.editor` is set. */
export type GridlerEditorType =
  | 'text'
  | 'number'
  | 'date'
  | 'datetime'
  | 'checkbox'
  | 'select'
  | 'textarea';

/** Value an editor commits: `number` for number editors, `boolean` for checkbox, `null` when cleared. */
export type GridlerEditValue = string | number | boolean | null;

import type { ColumnPreset } from "../Types/columnPresets";

export interface GridlerColumn extends GridColumn<Record<string, unknown>> {
  /** Required in Gridler — GridColumn has width?: number (optional). */
  width: number;
  /** In-place editor for this column. Default: inferred from `format` (number/currency/percentage → number, date, datetime) or the cell kind, else text. */
  editor?: GridlerEditorType;
  /** Choices for the `select` editor; a plain string is both value and label. */
  editorOptions?: Array<string | { label: string; value: string }>;
  /** Minimum editor width in px for this column; the editor grows past the cell (kept inside the grid). Overrides the grid-level `editorMinWidth`. */
  editorMinWidth?: number;
  /** Per-column theme overrides applied during canvas rendering. */
  themeOverride?: Partial<GridlerTheme>;
}

export interface GridlerCellFormatOptions {
  /** Currency code for 'currency' cells (default: 'ZAR'). */
  currency?: string;
  /** Locale for number/date formatting. */
  locale?: string;
  /** For 'percentage' cells: true if data is 0–1 fractional (default), false if 0–100. */
  fractional?: boolean;
  /** Decimal places for 'percentage' cells (default: 2). */
  decimals?: number;
  /** Date style for 'datetime' cells (default: 'short'). */
  dateStyle?: Intl.DateTimeFormatOptions['dateStyle'];
  /** Time style for 'datetime' cells. */
  timeStyle?: Intl.DateTimeFormatOptions['timeStyle'];
}

export interface GridlerCell {
  kind: GridlerCellKind;
  data: unknown;
  displayData: string;
  allowOverlay: boolean;
  allowEditing?: boolean;
  readonly?: boolean;
  span?: [startRow: number, endRow: number];
  /** Per-cell format options for currency, percentage, and datetime renderers. */
  formatOptions?: GridlerCellFormatOptions;
}

export type Selection =
  | { type: "none" }
  | { type: "cell"; item: Item }
  | { type: "range"; start: Item; end: Item }
  | { type: "row"; rows: number[] }
  | { type: "column"; columns: number[] };

export interface VisibleRange {
  firstCol: number;
  lastCol: number;
  firstRow: number;
  lastRow: number;
}

export interface ScrollState {
  scrollX: number;
  scrollY: number;
}

/**
 * Full Gridler props. Extends GridCommonProps (the generic grid interface) and
 * adds Gridler-specific canvas-level props.
 *
 * From GridCommonProps (inherited):
 *   width, height, rowHeight, headerHeight,
 *   onCellEvent, onGridEvent, onMenuClick (with item/column),
 *   onColumnMoved, onSortOrderChange, sortOrder,
 *   onFilterChange, filters, onSearchValueChange, searchValue,
 *   selectedItems, onSelectedItemsChange,
 *   dataSource, dataSourceOptions, data, onDataChange, settings
 */
/** Context handed to the `inplaceEditor` snippet; it replaces the built-in text input. */
export interface GridlerInplaceEditorContext {
  cell: GridlerCell;
  column: GridlerColumn;
  /** `[col, row]` of the cell being edited. */
  item: Item;
  /** Row data of the cell being edited, when the grid has it. */
  rowData: Record<string, unknown> | undefined;
  /** The editor type the built-in default would use for this cell. */
  editorType: GridlerEditorType;
  /** Normalised `column.editorOptions`. */
  options: Array<{ label: string; value: string }>;
  /** Seed text when editing was started by typing; `null` for Enter / double-click. */
  initialValue: string | null;
  width: number;
  height: number;
  /** Commit the edit with the new value (typed: number / boolean / string / null). */
  onComplete: (value: GridlerEditValue) => void;
  /** Abandon the edit. */
  onCancel: () => void;
}

export interface GridlerProps extends Omit<GridCommonProps<Record<string, unknown>>, 'onCellDblClick' | 'onColumnsChange' | 'onRowContextMenu'> {
  /** Override with the more specific GridlerColumn type. */
  columns: GridlerColumn[];

  // ── Core canvas rendering ────────────────────────────────────────────────

  /** Total number of data rows. */
  rows: number;
  /** Returns cell content for a given [col, row] item. */
  getCellContent: (item: Item) => GridlerCell;

  // ── Gridler-specific cell/row callbacks ──────────────────────────────────

  /** Called after a cell value is edited. */
  onCellEdited?: (item: Item, value: GridlerCell) => void;
  /** Called once with every cell changed by a paste. When omitted, `onCellEdited` is called per cell. */
  onCellsEdited?: (edits: { item: Item; value: GridlerCell }[]) => void;
  /** Called when the user finishes resizing a column. When provided, Gridler does not mutate `columns`. */
  onColumnResized?: (col: number, width: number) => void;
  /** Called on cell double-click (low-level; GridlerFull enriches with row data). */
  onCellDblClick?: (item: Item, cell: GridlerCell) => void;
  /** Called when a body row is clicked. */
  onRowClick?: (row: number) => void;
  /** Called when a body row receives a context-menu event. */
  onRowContextMenu?: (row: number, x: number, y: number) => void;
  /** Called when Tab moves past the last column of the last row. */
  onRowAppended?: () => void;
  /** Called when Delete/Backspace is pressed with a selection. */
  onDelete?: (selection: Selection) => void;
  /** Called after Escape clears a non-empty selection. */
  onSelectionCleared?: () => void;

  // ── Canvas-specific display / selection ──────────────────────────────────

  /** Number of columns pinned to the left. */
  fixedColumns?: number;
  /** Row-marker style shown in the leading column. */
  rowMarkers?: "checkbox" | "number" | "none";
  /** When false, cell / range / column selection is disabled; only row-marker toggles select. Default true. */
  cellSelection?: boolean;
  /** Replaces the built-in text input shown while a cell is being edited. Positioned over the cell for you. */
  inplaceEditor?: Snippet<[GridlerInplaceEditorContext]>;
  /** Minimum in-place editor width in px. Narrow columns' editors grow past the cell (kept inside the grid). Default: the column width. */
  editorMinWidth?: number;
  /**
   * Picks an editor snippet per cell from the column, row data and inferred editor type.
   * Return `undefined` to fall through to `inplaceEditor`, then the built-in default editor.
   */
  inplaceEditorFor?: (
    ctx: Pick<GridlerInplaceEditorContext, 'cell' | 'column' | 'item' | 'rowData' | 'editorType'>,
  ) => Snippet<[GridlerInplaceEditorContext]> | undefined;
  /** When false, no cell editing (double-click, Enter, typing, paste); double-click selects the row. Default true. */
  editable?: boolean;
  /** Theme overrides merged onto the default (or dark-mode) theme. */
  theme?: Partial<GridlerTheme>;
  /** Controlled selection state. */
  selection?: Selection;
  /** Called when the internal selection changes. Row data for all selected rows is provided where available. */
  onSelectionChange?: (selection: Selection, rowData?: Record<string, unknown>[]) => void;
  /** Called when the visible row/column range changes (e.g. on scroll). */
  onVisibleRangeChange?: (range: VisibleRange) => void;

  // ── Flat convenience props (also settable via settings from GridCommonProps) ──

  /** When true, all cells are read-only. Also settable via settings.readonly. */
  readonly?: boolean;
  /** Allow users to drag column edges to resize. Also settable via settings.resizableColumns. */
  resizableColumns?: boolean;
  /** Width in px reserved on the right for the vertical scrollbar when it is visible. Columns are laid out within the remaining width. Default 10; 0 disables. */
  scrollbarGutter?: number;
  /** Show the built-in search bar. Also settable via settings.showSearch. */
  showSearch?: boolean;
}

export type GridlerContextMenuItem = GridContextMenuItem<Record<string, unknown>>;

export const DEFAULT_THEME: GridlerTheme = {
  accentColor: "#4f46e5",
  accentFg: "#ffffff",
  bgCell: "#ffffff",
  bgHeader: "#f8fafc",
  bgHeaderHasFocus: "#e0e7ff",
  bgSearchResult: "#fef08a",
  borderColor: "#e2e8f0",
  headerBorderColor: "#cbd5e1",
  cellHorizontalPadding: 8,
  cellVerticalPadding: 4,
  fontFamily: "system-ui, -apple-system, sans-serif",
  fontSize: 13,
  foreground: "#1e293b",
  headerFontSize: 12,
  lineHeight: 24,
};

export const DEFAULT_THEME_DARK: GridlerTheme = {
  accentColor: "#4f46e5",
  accentFg: "#ffffff",
  bgCell: "#1e293b",
  bgHeader: "#334155",
  // Subtle selection on dark backgrounds (used for selected cells/headers).
  bgHeaderHasFocus: "#243145",
  bgSearchResult: "#b45309",
  borderColor: "#475569",
  headerBorderColor: "#64748b",
  cellHorizontalPadding: 8,
  cellVerticalPadding: 4,
  fontFamily: "system-ui, -apple-system, sans-serif",
  fontSize: 13,
  foreground: "#f1f5f9",
  headerFontSize: 12,
  lineHeight: 24,
};

/** `SvelixLayout` key `gridler`. Order: props > `settings` > layout. */
export interface GridlerLayoutConfig {
  width?: number | string;
  height?: number | string;
  rowHeight?: number;
  headerHeight?: number;
  fixedColumns?: number;
  resizableColumns?: boolean;
  scrollbarGutter?: number;
  rowMarkers?: "checkbox" | "number" | "none";
  showSearch?: boolean;
  readonly?: boolean;
  includeSelectionRowData?: boolean;
}

/** `SvelixLayout` key `gridlerFull`. Order: props > `settings` / `dataSourceOptions` > layout. */
export interface GridlerFullLayoutConfig {
  manageColumns?: boolean;
  defaultSearchValue?: string;
  pageSize?: number;
  uniqueID?: string;
  serverSideSearch?: boolean;
  usePushEvent?: boolean;
  fixedColumns?: number;
  rowMarkers?: "checkbox" | "number" | "none";
  showSearch?: boolean;
  height?: number | string;
  exportable?: boolean;
  showToolbar?: boolean;
  /** Menu item icons by item id (SVG strings); extends/overrides the built-in add/change/edit/delete/view icons. */
  menuIcons?: Record<string, string>;
  /** Column defaults by id pattern; explicit column fields win. */
  columnPresets?: ColumnPreset<GridlerColumn>[];
}
```

## Props

### components/Gridler.svelte

```ts
export interface Props extends Partial<GridlerProps> {
    columns: GridlerColumn[];
    rows: number;
    getCellContent: (item: Item) => GridlerCell;
    onSearchToggle?: () => void;
    onSearchClose?: () => void;
    /** Column ids (or dataKeys) whose cells are highlighted by the search. Defaults to all columns. */
    searchColumns?: string[];
    onHeaderContextMenu?: (colIndex: number, x: number, y: number) => void;
    onGridMenuOpen?: (coords: {
      x: number;
      y: number;
      item?: Record<string, unknown>;
      column?: GridlerColumn;
    }) => void;
    onRowClick?: (row: number) => void;
    onRowContextMenu?: (row: number, x: number, y: number) => void;
    /** Resolve raw row data by row index for populating onCellEvent. */
    getRowData?: (row: number) => Record<string, unknown> | undefined;
    /**
     * When false, `onSelectionChange` is not given row data (saves an O(selected rows) pass
     * for consumers, like GridlerFull, that resolve row data themselves). Default true.
     */
    includeSelectionRowData?: boolean;
    children?: Snippet;
  }
```

### components/GridlerFull.svelte

```ts
export interface Props
    extends Omit<
      Partial<GridlerProps>,
      | "columns"
      | "rows"
      | "getCellContent"
      | "searchValue"
      | "onSearchValueChange"
      | "onVisibleRangeChange"
      | "onCellDblClick"
      | "onRowClick"
      | "onRowContextMenu"
      | "onColumnsChange"
    > {
    columns: GridlerColumn[];
    /** Column defaults by id pattern; explicit column fields win. Falls back to the `SvelixLayout` `columnPresets`. */
    columnPresets?: ColumnPreset<GridlerColumn>[];

    /**
     * Optional row data. When provided (and `getCellContent` is omitted), the default
     * cell renderer uses `columns[col].dataKey ?? columns[col].id` as the field name on each row object.
     * Also accepts a function returning a Promise for async data loading.
     */
    data?:
      | Record<string, unknown>[]
      | (() => Promise<Record<string, unknown>[]>);

    /** If provided, takes precedence over `data` rendering. */
    getCellContent?: (item: Item) => GridlerCell;
    /** Total rows. Defaults to `data.length`. */
    rows?: number;

    /** If true, reorders `columns` locally when the user drags headers. */
    manageColumns?: boolean;
    /** Optional notification when local column order changes. */
    onColumnsChange?: (columns: GridlerColumn[]) => void;

    /** Controlled search value (optional). */
    searchValue?: string;
    /** Uncontrolled initial search value (only used when `searchValue` is undefined). */
    defaultSearchValue?: string;
    /** Called whenever the search input changes. */
    onSearchValueChange?: (value: string) => void;

    /** Called when `GridlerFull` mutates its internal `data` in response to edits. */
    onDataChange?: (data: Record<string, unknown>[]) => Promise<void>;

    /** Called on cell double-click with the cell item, cell content, and resolved row data. */
    onCellDblClick?: (
      item: Item,
      cell: GridlerCell,
      rowData: Record<string, unknown> | undefined,
    ) => void;
    /** Called on row double-click with the row index and resolved row data. */
    onRowDblClick?: (
      row: number,
      rowData: Record<string, unknown> | undefined,
    ) => void;
    /** Called on row click with the row index and resolved row data. */
    onRowClick?: (
      row: number,
      rowData: Record<string, unknown> | undefined,
    ) => void;
    /** Called on row context-menu with the row index, resolved row data, and click coordinates. */
    /**
     * Master switch for cell editing (double-click, Enter, typing, paste). Default `false`:
     * double-click then selects the row and fires `onCellDblClick` / `onRowDblClick` as usual.
     */
    enableEdit?: boolean;
    onRowContextMenu?: (
      row: number,
      rowData: Record<string, unknown> | undefined,
      x: number,
      y: number,
    ) => void;

    // ── Server-side fetching ──────────────────────────────────────────────────
    // Use dataSource + dataSourceOptions (from GridCommonProps) for server config.
    // dataSource: 'resolvespec' | 'headerspec' | 'websockspec' | 'local' | 'custom'
    // dataSourceOptions: { url, authToken, schema, entity, uniqueID, headers }

    /** Rows per cursor-forward page. Defaults to 200. */
    pageSize?: number;
    /**
     * Shorthand for dataSourceOptions.uniqueID.
     * Row field whose value is passed as `cursor_forward` on subsequent requests.
     * Defaults to 'id'.
     */
    uniqueID?: string;
    /**
     * When true, the search input value is translated into `ilike` filters sent to
     * the server instead of only being used for client-side highlighting.
     */
    serverSideSearch?: boolean;
    /**
     * Columns searched: `contains` OR'd across them. With `serverSideSearch` the server does it;
     * in local mode the rows are filtered the same way (without it, local search only highlights).
     * Defaults to all column IDs for server search when omitted.
     */
    searchColumns?: string[];
    /** Called when a server fetch fails. */
    onLoadError?: (error: string) => void;

    /**
     * When true (server mode only), subscribes to ServerEventSource for the resolved
     * schema/entity and auto-refreshes: a row-level change refetches just that row in
     * place; a table-level change triggers a full page reload. Requires `usePushEventOptions.url`.
     */
    usePushEvent?: boolean;
    /** ServerEventSource connection/behavior for `usePushEvent`. */
    usePushEventOptions?: GridlerPushEventOptions;

    /**
     * Bindable. Reflects the server-reported total row count.
     * Updated after every successful page fetch; 0 until the first response.
     * Read-only from the parent — do not write this value.
     */
    total?: number;
    /**
     * Bindable. `true` while any server fetch is in flight (initial load or next-page).
     * Read-only from the parent — do not write this value.
     */
    loading?: boolean;

    /**
     * Extra items appended to the built-in context menu (Refresh, Clear Search, Clear Filters, Clear Sort).
     * Pass a separator item `{ id: 'sep', kind: 'separator', label: '' }` to group your items.
     */
    menuItems?: GridlerContextMenuItem[];
    /** Called when any context menu item is selected (built-in or custom). */
    onMenuItemSelect?: (item: GridlerContextMenuItem, rowData?: Record<string, unknown>) => void;
    /**
     * Per-item overrides (icon, label, disabled, etc.) for the built-in context-menu
     * entries (Refresh, Export, Clear Search, Clear Filters, Clear Sort). Each override is
     * merged onto that item's default, so any `BetterMenuInstanceItem` field can be
     * overridden, not just `icon`.
     */
    buttonMenuProps?: {
      refresh?: Partial<BetterMenuInstanceItem>;
      export?: Partial<BetterMenuInstanceItem>;
      clearSearch?: Partial<BetterMenuInstanceItem>;
      clearFilters?: Partial<BetterMenuInstanceItem>;
      clearSort?: Partial<BetterMenuInstanceItem>;
    };

    /**
     * Show the toolbar strip above the grid (inline search, filter/sort chips,
     * row count, menu button). Defaults to true.
     */
    showToolbar?: boolean;

    /**
     * Grid title shown at the left of the toolbar, before the search. Needs the toolbar.
     * Text never hides: it shrinks and trims with an ellipsis. A snippet hides when the toolbar is very narrow.
     */
    title?: string | Snippet;

    /**
     * `false` (default): active sorts show as chips in the toolbar.
     * `true`: sorting is a single "Sort" menu button instead. The chips also collapse into it when the toolbar is narrow.
     */
    collapseSort?: boolean;

    /** Rendered at the very start of the toolbar, before the title. Needs the toolbar. */
    toolbarStart?: Snippet;
    /** Rendered in the middle of the toolbar, between the filter chips and the count/menu buttons. Needs the toolbar. */
    toolbarCenter?: Snippet;
    /** Rendered at the right end of the toolbar, after the grid menu button. Needs the toolbar. */
    toolbarEnd?: Snippet;
    /** Full-width row rendered below the toolbar, above the grid (e.g. a description or summary). Needs the toolbar. */
    toolbarBelow?: Snippet;
    /** Full-width row rendered below the grid (e.g. totals or pagination info). */
    gridFooter?: Snippet;
    /** Side panel to the left of the grid, below the toolbar. The grid shrinks to fit. */
    leftContent?: Snippet;
    /** Side panel to the right of the grid, below the toolbar. The grid shrinks to fit. */
    rightContent?: Snippet;
    /** Replaces the grid body when there are no rows (not shown while loading or on a load error). */
    empty?: Snippet;

    /**
     * Where search lives when the toolbar is shown. `inline` (default): always-visible
     * input in the toolbar. `popover`: a search button beside the menu button opens a
     * search bar over the grid (Ctrl+F also opens it).
     */
    searchMode?: "inline" | "popover";
    /** Label on the popover search button. Defaults to "Search"; set `""` for an icon-only button. */
    searchButtonTitle?: string;
    /** Ctrl/Cmd+F in the grid opens/focuses the search. Defaults to true; `false` leaves it to the browser. */
    searchShortcut?: boolean;

    /**
     * Adds "Export…" to the toolbar and context menus: exports all rows matching the
     * current filters/sort/search (server mode pages through every result).
     */
    exportable?: boolean;
    exportOptions?: GridExportOptions;

    children?: Snippet;
  }
```

## Examples (from Storybook stories)

### Components/Gridler  (`components/Gridler.stories.ts`)

Shared setup:

```ts
import { expect, fn, waitFor } from 'storybook/test';

import Gridler from './Gridler.svelte';

import type { GridlerCell, Item } from '../types';

const defaultColumns = [
	{ id: 'name', title: 'Name', width: 180 },
	{ id: 'email', title: 'Email', width: 220 },
	{ id: 'age', title: 'Age', width: 80 },
	{ id: 'status', title: 'Status', width: 120 },
	{ id: 'department', title: 'Department', width: 150 }
];

const firstNames = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace', 'Henry', 'Ivy', 'Jack'];

const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Wilson', 'Moore'];

const statuses = ['Active', 'Pending', 'Inactive', 'Suspended'];

const departments = ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance', 'Operations'];

function generateCellContent([col, row]: Item): GridlerCell {
	const firstName = firstNames[row % firstNames.length];
	const lastName = lastNames[Math.floor(row / 10) % lastNames.length];
	const email = `${firstName.toLowerCase()}.${lastName.toLowerCase()}@example.com`;
	const age = 25 + (row % 40);
	const status = statuses[row % statuses.length];
	const department = departments[row % departments.length];

	switch (col) {
		case 0:
			return { kind: 'text', displayData: `${firstName} ${lastName}`, data: `${firstName} ${lastName}`, allowOverlay: true };
		case 1:
			return { kind: 'uri', displayData: email, data: email, allowOverlay: true };
		case 2:
			return { kind: 'number', displayData: String(age), data: age, allowOverlay: true };
		case 3:
			return { kind: 'bubble', displayData: status, data: status, allowOverlay: true };
		case 4:
			return { kind: 'text', displayData: department, data: department, allowOverlay: true };
		default:
			return { kind: 'text', displayData: '', data: null, allowOverlay: true };
	}
}

const wideColumns = [
	{ id: 'id', title: 'ID', width: 60 },
	{ id: 'product', title: 'Product Name', width: 200 },
	{ id: 'sku', title: 'SKU', width: 100 },
	{ id: 'price', title: 'Price (ZAR)', width: 110 },
	{ id: 'stock', title: 'Stock', width: 80 },
	{ id: 'category', title: 'Category', width: 120 },
	{ id: 'supplier', title: 'Supplier', width: 150 },
	{ id: 'margin', title: 'Margin', width: 90 },
	{ id: 'created', title: 'Created', width: 100 },
	{ id: 'updated', title: 'Updated', width: 140 }
];

function generateProductCell([col, row]: Item): GridlerCell {
	const products = ['Laptop Pro', 'Wireless Mouse', 'USB-C Hub', 'Monitor 4K', 'Mechanical Keyboard'];
	const categories = ['Electronics', 'Accessories', 'Peripherals', 'Display', 'Input'];
	const baseDate = new Date(2024, 0, 1);

	switch (col) {
		case 0:
			return { kind: 'number', displayData: String(row + 1000), data: row + 1000, allowOverlay: true };
		case 1:
			return { kind: 'text', displayData: products[row % products.length], data: products[row % products.length], allowOverlay: true };
		case 2:
			return { kind: 'text', displayData: `SKU-${row.toString(16).toUpperCase()}`, data: `SKU-${row.toString(16).toUpperCase()}`, allowOverlay: true };
		case 3: {
			const price = 99.99 + row * 10;
			return { kind: 'currency', displayData: String(price), data: price, allowOverlay: true };
		}
		case 4:
			return { kind: 'number', displayData: String(50 + (row % 950)), data: 50 + (row % 950), allowOverlay: true };
		case 5:
			return { kind: 'bubble', displayData: categories[row % categories.length], data: categories[row % categories.length], allowOverlay: true };
		case 6:
			return { kind: 'text', displayData: 'Acme Corp', data: 'Acme Corp', allowOverlay: true };
		case 7: {
			const margin = 0.05 + (row % 20) * 0.02;
			return { kind: 'percentage', displayData: String(margin), data: margin, allowOverlay: true };
		}
		case 8: {
			const created = new Date(baseDate.getTime() + row * 86400_000);
			return { kind: 'datetime', displayData: created.toLocaleDateString(), data: created, allowOverlay: true };
		}
		case 9: {
			const updated = new Date(baseDate.getTime() + (row + 5) * 86400_000);
			return { kind: 'datetime', displayData: updated.toLocaleString(), data: updated, allowOverlay: true };
		}
		default:
			return { kind: 'text', displayData: '', data: null, allowOverlay: true };
	}
}

// ── Interaction-focused stories (used by Storybook test runner) ──────────────

function dispatchCanvasClick(canvas: HTMLCanvasElement, x: number, y: number, opts?: { shiftKey?: boolean }) {
	const rect = canvas.getBoundingClientRect();
	const init: MouseEventInit = {
		bubbles: true,
		clientX: rect.left + x,
		clientY: rect.top + y,
		buttons: 1,
		shiftKey: !!opts?.shiftKey
	};
	canvas.dispatchEvent(new MouseEvent('mousedown', init));
	canvas.dispatchEvent(new MouseEvent('mouseup', init));
	canvas.dispatchEvent(new MouseEvent('click', init));
}
```

#### Basic (`Basic`)

```ts
export const Basic: Story = {
	args: {
		columns: defaultColumns,
		rows: 100,
		getCellContent: generateCellContent,
		height: 400
	}
};
```

#### Large Dataset (`LargeDataset`)

```ts
export const LargeDataset: Story = {
	args: {
		columns: defaultColumns,
		rows: 10000,
		getCellContent: generateCellContent,
		height: 500
	}
};
```

#### With Row Numbers (`WithRowNumbers`)

```ts
export const WithRowNumbers: Story = {
	args: {
		columns: defaultColumns,
		rows: 50,
		getCellContent: generateCellContent,
		rowMarkers: 'number',
		height: 400
	}
};
```

#### With Checkboxes (`WithCheckboxes`)

```ts
export const WithCheckboxes: Story = {
	args: {
		columns: defaultColumns,
		rows: 50,
		getCellContent: generateCellContent,
		rowMarkers: 'checkbox',
		height: 400
	}
};
```

#### Readonly (`Readonly`)

```ts
export const Readonly: Story = {
	args: {
		columns: defaultColumns,
		rows: 50,
		getCellContent: generateCellContent,
		readonly: true,
		height: 400
	}
};
```

#### Custom Theme (`CustomTheme`)

```ts
export const CustomTheme: Story = {
	args: {
		columns: defaultColumns,
		rows: 50,
		getCellContent: generateCellContent,
		theme: {
			accentColor: '#10b981',
			accentFg: '#ffffff',
			bgCell: '#1e293b',
			bgHeader: '#334155',
			bgHeaderHasFocus: '#475569',
			borderColor: '#475569',
			foreground: '#f1f5f9',
			fontFamily: 'ui-monospace, monospace'
		},
		height: 400
	}
};
```

#### Small Rows (`SmallRows`)

```ts
export const SmallRows: Story = {
	args: {
		columns: defaultColumns,
		rows: 10,
		getCellContent: generateCellContent,
		height: 300
	}
};
```

#### Wide Grid (`WideGrid`)

```ts
export const WideGrid: Story = {
	args: {
		columns: wideColumns,
		rows: 100,
		getCellContent: generateProductCell,
		height: 400
	}
};
```

#### With Search Highlight (`WithSearchHighlight`)

```ts
export const WithSearchHighlight: Story = {
	args: {
		columns: defaultColumns,
		rows: 100,
		getCellContent: generateCellContent,
		searchValue: 'Alice',
		height: 400
	}
};
```

#### With Fixed Columns (`WithFixedColumns`)

```ts
export const WithFixedColumns: Story = {
	args: {
		columns: wideColumns,
		rows: 100,
		getCellContent: generateProductCell,
		fixedColumns: 2,
		height: 400
	}
};
```

#### With Fixed Columns And Row Numbers (`WithFixedColumnsAndRowNumbers`)

```ts
export const WithFixedColumnsAndRowNumbers: Story = {
	args: {
		columns: wideColumns,
		rows: 100,
		getCellContent: generateProductCell,
		fixedColumns: 2,
		rowMarkers: 'number',
		height: 400
	}
};
```

#### With Column Reorder (`WithColumnReorder`)

```ts
export const WithColumnReorder: Story = {
	args: {
		columns: [...defaultColumns],
		rows: 50,
		getCellContent: generateCellContent,
		height: 400
	}
};
```

#### Editable With Copy Paste (`EditableWithCopyPaste`)

```ts
export const EditableWithCopyPaste: Story = {
	args: {
		columns: defaultColumns,
		rows: 50,
		getCellContent: generateCellContent,
		rowMarkers: 'checkbox',
		height: 400
	}
};
```

#### All Features (`AllFeatures`)

```ts
export const AllFeatures: Story = {
	args: {
		columns: [...wideColumns],
		rows: 1000,
		getCellContent: generateProductCell,
		fixedColumns: 2,
		rowMarkers: 'number',
		searchValue: 'Laptop',
		height: 500
	},
	render: (args, context) => {
		const isDark = context.globals?.colorScheme === 'dark';
		return {
			Component: Gridler,
			props: {
				...args,
				theme: {
					// Keep accent consistent, but use scheme-appropriate selection/search highlight colors.
					accentColor: '#6366f1',
					bgHeaderHasFocus: isDark ? '#243145' : '#e0e7ff',
					bgSearchResult: isDark ? '#b45309' : '#fef9c3'
				}
			}
		};
	}
};
```

#### Million Rows (`MillionRows`)

```ts
export const MillionRows: Story = {
	args: {
		columns: defaultColumns,
		rows: 1_000_000,
		getCellContent: generateCellContent,
		fixedColumns: 1,
		rowMarkers: 'number',
		height: 500
	}
};
```

#### Play_Select Cell (`Play_SelectCell`)

```ts
export const Play_SelectCell: Story = {
	args: {
		columns: [
			{ id: 'a', title: 'A', width: 150 },
			{ id: 'b', title: 'B', width: 200 },
			{ id: 'c', title: 'C', width: 120 }
		],
		rows: 10,
		rowMarkers: 'checkbox',
		height: 320,
		getCellContent: ([col, row]: Item): GridlerCell => {
			return { kind: 'text', displayData: `R${row}C${col}`, data: `R${row}C${col}`, allowOverlay: true };
		},
		onSelectionChange: fn()
	},
};
```

#### Play_Shift Click Range (`Play_ShiftClickRange`)

```ts
export const Play_ShiftClickRange: Story = {
	args: {
		columns: [
			{ id: 'a', title: 'A', width: 150 },
			{ id: 'b', title: 'B', width: 200 },
			{ id: 'c', title: 'C', width: 120 }
		],
		rows: 10,
		rowMarkers: 'checkbox',
		height: 320,
		getCellContent: ([col, row]: Item): GridlerCell => {
			return { kind: 'text', displayData: `R${row}C${col}`, data: `R${row}C${col}`, allowOverlay: true };
		},
		onSelectionChange: fn()
	},
};
```

#### Play_Edit Enter (`Play_EditEnter`)

```ts
export const Play_EditEnter: Story = {
	args: {
		columns: [
			{ id: 'a', title: 'A', width: 150 },
			{ id: 'b', title: 'B', width: 200 }
		],
		rows: 5,
		rowMarkers: 'checkbox',
		height: 280,
		getCellContent: ([col, row]: Item): GridlerCell => {
			return { kind: 'text', displayData: `R${row}C${col}`, data: `R${row}C${col}`, allowOverlay: true };
		},
		onCellEdited: fn(),
		onSelectionChange: fn()
	},
};
```

### Components/GridlerFull  (`components/GridlerFull.stories.ts`)

Shared setup:

```ts
import { expect, fn, userEvent, waitFor, within } from "storybook/test";

import GridlerFull from "./GridlerFull.svelte";

import GridlerFullSelectionHost from "./GridlerFullSelectionHost.svelte";

import GridlerFullWithFormerPreview from "./GridlerFullWithFormerPreview.svelte";

import GridlerFullNarrowPreview from "./GridlerFullNarrowPreview.svelte";

import GridlerFullToolbarBelowPreview from "./GridlerFullToolbarBelowPreview.svelte";

import GridlerFullAllSnippetsStory from "./GridlerFullAllSnippetsStory.svelte";

import GridlerFullTickboxPreview from "./GridlerFullTickboxPreview.svelte";

import GridlerFullInplaceEditorPreview from "./GridlerFullInplaceEditorPreview.svelte";

import type { GridlerCell, Item } from "../types";

const liveArgTypes = {
  apiUrl: { control: { type: "text" }, description: "ResolveSpec API base URL" },
  authToken: { control: { type: "text" }, description: "Bearer token for ResolveSpec authentication" },
  schema: { control: { type: "text" }, description: "Database schema" },
  entity: { control: { type: "text" }, description: "Entity / table name" },
  uniqueID: { control: { type: "text" }, description: "Unique row field used for cursor paging" },
};

// ── Shared column + data fixtures ──────────────────────────────────────────

const columns = [
  { id: "id", title: "ID", width: 70 },
  { id: "name", title: "Name", width: 180 },
  { id: "email", title: "Email", width: 220 },
  { id: "department", title: "Department", width: 150 },
  { id: "salary", title: "Salary", width: 110 },
  { id: "status", title: "Status", width: 110 },
];

const DEPARTMENTS = [
  "Engineering",
  "Marketing",
  "Sales",
  "HR",
  "Finance",
  "Operations",
];

const STATUSES = ["Active", "Pending", "Inactive"];

const FIRST = [
  "Alice",
  "Bob",
  "Carol",
  "David",
  "Eve",
  "Frank",
  "Grace",
  "Henry",
];

const LAST = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia"];

function makeRow(i: number): Record<string, unknown> {
  const first = FIRST[i % FIRST.length];
  const last = LAST[Math.floor(i / FIRST.length) % LAST.length];
  return {
    id: i + 1,
    name: `${first} ${last}`,
    email: `${first.toLowerCase()}.${last.toLowerCase()}@example.com`,
    department: DEPARTMENTS[i % DEPARTMENTS.length],
    salary: 40000 + (i % 60) * 1000,
    status: STATUSES[i % STATUSES.length],
  };
}

const TOTAL_ROWS = 500;

const ALL_ROWS = Array.from({ length: TOTAL_ROWS }, (_, i) => makeRow(i));

// Editor fixtures: one column per built-in editor type.
const editorColumns = [
  ...columns.map((c) => {
    if (c.id === "department") return { ...c, editor: "select", editorOptions: DEPARTMENTS };
    if (c.id === "status") return { ...c, editor: "select", editorOptions: STATUSES };
    if (c.id === "salary") return { ...c, editor: "number" };
    return c;
  }),
  { id: "joined", title: "Joined", width: 130, editor: "date" },
  { id: "active", title: "Active", width: 80, editor: "checkbox" },
  { id: "notes", title: "Notes", width: 220, editor: "textarea" },
];

const editorRows = ALL_ROWS.slice(0, 50).map((r, i) => ({
  ...r,
  joined: `2024-${String((i % 12) + 1).padStart(2, "0")}-${String((i % 27) + 1).padStart(2, "0")}`,
  active: i % 3 !== 0,
  notes: `Note ${i + 1}`,
}));

// ── Popup menu helper ────────────────────────────────────────────────────────

let _activePopup: HTMLElement | null = null;

function showGridMenu(
  maybeItemOrPosition?: unknown,
  _maybeColumn?: unknown,
  maybePosition?: { x: number; y: number },
) {
  const position =
    maybePosition ??
    (maybeItemOrPosition &&
    typeof maybeItemOrPosition === "object" &&
    "x" in maybeItemOrPosition &&
    "y" in maybeItemOrPosition
      ? (maybeItemOrPosition as { x: number; y: number })
      : undefined);

  if (!position) return;

  const { x, y } = position;
  _activePopup?.remove();

  const menu = document.createElement("div");
  menu.style.cssText = `
		position: fixed; left: ${x}px; top: ${y}px;
		background: #fff; border: 1px solid #e2e8f0;
		border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,.12);
		padding: 4px 0; z-index: 9999; min-width: 170px;
		font-family: system-ui, sans-serif; font-size: 13px; overflow: hidden;
	`;

  const items = [
    { label: "Refresh Data", icon: "↻" },
    { label: "Export CSV", icon: "↓" },
    { label: "Column Settings", icon: "⚙" },
    { separator: true },
    { label: "Clear Filters", icon: "✕", danger: true },
  ];

  for (const item of items) {
    if (item.separator) {
      const hr = document.createElement("hr");
      hr.style.cssText =
        "margin: 4px 0; border: none; border-top: 1px solid #e2e8f0;";
      menu.appendChild(hr);
      continue;
    }
    const btn = document.createElement("button");
    btn.textContent = `${item.icon}  ${item.label}`;
    btn.style.cssText = `
			display: block; width: 100%; padding: 7px 14px; background: none;
			border: none; text-align: left; cursor: pointer;
			color: ${item.danger ? "#dc2626" : "#1e293b"};
			font-size: inherit; font-family: inherit;
		`;
    btn.onmouseenter = () => {
      btn.style.background = "#f1f5f9";
    };
    btn.onmouseleave = () => {
      btn.style.background = "none";
    };
    btn.onclick = () => {
      menu.remove();
      _activePopup = null;
    };
    menu.appendChild(btn);
  }

  document.body.appendChild(menu);
  _activePopup = menu;

  const close = (e: MouseEvent) => {
    if (!menu.contains(e.target as Node)) {
      menu.remove();
      _activePopup = null;
      document.removeEventListener("click", close, true);
    }
  };
  setTimeout(() => document.addEventListener("click", close, true), 0);
}

const selectionStatus = (el: HTMLElement) =>
  el.querySelector("[id$=-selection]")?.textContent?.trim();

// ── All renderers showcase ────────────────────────────────────────────────────

const SVG_ICONS = [
  `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%234f46e5"><circle cx="12" cy="12" r="10"/></svg>`,
  `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%2310b981"><rect x="3" y="3" width="18" height="18" rx="3"/></svg>`,
  `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23f59e0b"><polygon points="12,2 22,22 2,22"/></svg>`,
  `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23ec4899"><path d="M12 2l3 7h7l-6 4 2 7-6-4-6 4 2-7-6-4h7z"/></svg>`,
  `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%230ea5e9"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/></svg>`,
];

const BUBBLE_LABELS = ["Active", "Pending", "Inactive", "Archived", "Draft"];

const MARKDOWN_SAMPLES = [
  "**Bold** text here",
  "_Italic_ with `code`",
  "## Heading level 2",
  "[link](https://example.com)",
  "~~Strikethrough~~ text",
];

const DRILLDOWN_LABELS = ["View details", "Open record", "Expand row", "See more", "Inspect"];

// ── Nested dataKey story ──────────────────────────────────────────────────────

const nestedColumns = [
  { id: "rid_id",       title: "ID",         width: 60,  dataKey: "id" },
  { id: "rid_username", title: "Username",   width: 160, dataKey: "login.username" },
  { id: "rid_email",    title: "Email",      width: 220, dataKey: "login.contact.email" },
  { id: "rid_primary",  title: "Primary Tag",width: 130, dataKey: "tags.[0].name" },
  { id: "rid_team",     title: "Team",       width: 160, dataKey: "org.department.team.name" },
  { id: "rid_salary",   title: "Salary",     width: 110, dataKey: "employment.salary" },
];

const TEAMS = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];

const TAG_NAMES = ["admin", "viewer", "editor", "owner", "guest"];

function makeNestedRow(i: number): Record<string, unknown> {
  const first = FIRST[i % FIRST.length];
  const last = LAST[Math.floor(i / FIRST.length) % LAST.length];
  return {
    id: i + 1,
    login: {
      username: `${first.toLowerCase()}.${last.toLowerCase()}`,
      contact: {
        email: `${first.toLowerCase()}@example.com`,
      },
    },
    tags: [
      { name: TAG_NAMES[i % TAG_NAMES.length] },
      { name: TAG_NAMES[(i + 1) % TAG_NAMES.length] },
    ],
    org: {
      department: {
        team: {
          name: TEAMS[i % TEAMS.length],
        },
      },
    },
    employment: {
      salary: 40000 + (i % 60) * 1000,
    },
  };
}

const NESTED_ROWS = Array.from({ length: 50 }, (_, i) => makeNestedRow(i));

/**
 * Mock-server filter: applies ResolveSpec `options.filters` to rows. Groups like the
 * server: an AND filter starts a group, following OR filters join it; groups are AND-ed.
 * Supports eq, neq, gt, gte, lt, lte, ilike (% wildcards), in.
 */
function mockFilter(rows, filters) {
  if (!Array.isArray(filters) || filters.length === 0) return rows;
  const groups = [];
  for (const f of filters) {
    if (f.logic_operator === "OR" && groups.length) groups[groups.length - 1].push(f);
    else groups.push([f]);
  }
  const read = (row, path) => String(path).split(".").reduce((v, k) => (v == null ? v : v[k]), row);
  const cmp = (a, b) => {
    const na = Number(a);
    const nb = Number(b);
    if (a !== "" && b !== "" && a != null && b != null && !isNaN(na) && !isNaN(nb)) return na - nb;
    return String(a ?? "").toLowerCase().localeCompare(String(b ?? "").toLowerCase(), undefined, { numeric: true });
  };
  const test = (row, f) => {
    const v = read(row, f.column);
    switch (f.operator) {
      case "eq":  return cmp(v, f.value) === 0;
      case "neq": return cmp(v, f.value) !== 0;
      case "gt":  return cmp(v, f.value) > 0;
      case "gte": return cmp(v, f.value) >= 0;
      case "lt":  return cmp(v, f.value) < 0;
      case "lte": return cmp(v, f.value) <= 0;
      case "in":  return (Array.isArray(f.value) ? f.value : [f.value]).some((x) => cmp(v, x) === 0);
      case "ilike": {
        const re = String(f.value).split("%").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
        return new RegExp(`^${re}$`, "i").test(String(v ?? ""));
      }
      default: return true;
    }
  };
  return rows.filter((row) => groups.every((g) => g.some((f) => test(row, f))));
}

/** Mock-server sort: applies ResolveSpec `options.sort` ({ column, direction }[]) to rows. */
function mockSort(rows, sort) {
  if (!Array.isArray(sort) || sort.length === 0) return rows;
  return [...rows].sort((a, b) => {
    for (const { column, direction } of sort) {
      const av = a[column];
      const bv = b[column];
      const cmp = typeof av === "number" && typeof bv === "number"
        ? av - bv
        : String(av ?? "").localeCompare(String(bv ?? ""), undefined, { numeric: true });
      if (cmp !== 0) return String(direction).toLowerCase() === "desc" ? -cmp : cmp;
    }
    return 0;
  });
}

// ── Hidden columns ──────────────────────────────────────────────────────────

const hiddenColumns = columns.map((c) => (c.id === "email" ? { ...c, initiallyHidden: true } : c));

const menuButton = (name: string) => within(document.body).findByRole("button", { name });

// ── Selection: persistence, selectRecord, staying on a changed record ───────

const selectionArgs = (name: string) => ({
  persistenceName: name,
  columns,
  data: ALL_ROWS.slice(0, 100),
  height: 480,
  sortOrder: { name: "asc" },
});

const selectedOutput = (el: HTMLElement) => el.querySelector("[data-testid=selected]")?.textContent;

const click = (el: HTMLElement, name: string) => userEvent.click(within(el).getByRole("button", { name }));
```

Helper component `./GridlerFullSelectionHost.svelte`:

```svelte
<script lang="ts">
	import GridlerFull from './GridlerFull.svelte';
	import type { ComponentProps } from 'svelte';

	let { data, ...props }: ComponentProps<typeof GridlerFull> = $props();

	let grid = $state<ReturnType<typeof GridlerFull>>();
	let mounted = $state(true);
	let selectedIds = $state<string[]>([]);
	// eslint-disable-next-line svelte/no-unused-svelte-ignore
	// svelte-ignore state_referenced_locally -- initial capture; edits below replace it
	let rows = $state(data as Record<string, unknown>[]);

	/** Renames a record so it re-sorts to the end of a name-sorted list. */
	function rename(id: number) {
		rows = rows.map((r) => (r.id === id ? { ...r, name: 'Zed Zulu' } : r));
	}
</script>

<div style="display:flex;gap:8px;padding:8px">
	<button type="button" onclick={() => grid?.selectRecord(12)}>Select 12</button>
	<button type="button" onclick={() => grid?.refresh({ select: 40 })}>Refresh and select 40</button>
	<button type="button" onclick={() => rename(3)}>Rename 3</button>
	<button type="button" onclick={() => (mounted = false)}>Unmount</button>
	<button type="button" onclick={() => (mounted = true)}>Mount</button>
	<output data-testid="selected">{selectedIds.join(',')}</output>
</div>

{#if mounted}
	<GridlerFull
		bind:this={grid}
		{...props}
		data={rows}
		onSelectedItemsChange={(items) => (selectedIds = items.map((r) => String(r.id)))}
	/>
{/if}
```

Helper component `./GridlerFullWithFormerPreview.svelte`:

```svelte
<script lang="ts">
  import GridlerFull from './GridlerFull.svelte';
  import FormerDrawer from '#lib/components/Former/FormerDrawer.svelte';
  import TextInputCtrl from '#lib/components/FormerControllers/TextInputCtrl.svelte';
  import NumberInputCtrl from '#lib/components/FormerControllers/NumberInputCtrl.svelte';
  import NativeSelectCtrl from '#lib/components/FormerControllers/NativeSelectCtrl.svelte';
  import type { FormRequestType } from '#lib/components/Former/types.js';
  import type { GridlerContextMenuItem } from '../types';
  import { iconAddSvg, iconEditSvg, iconTrashSvg } from '#lib/components/Icons/strings.js';

  interface EmployeeRow {
    id: number;
    name: string;
    email: string;
    department: string;
    salary: number;
    status: string;
  }

  const DEPARTMENTS = ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance', 'Operations'];
  const STATUSES = ['Active', 'Pending', 'Inactive'];
  const FIRST = ['Alice', 'Bob', 'Carol', 'David', 'Eve', 'Frank', 'Grace', 'Henry'];
  const LAST = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia'];

  function makeRow(i: number): EmployeeRow {
    const first = FIRST[i % FIRST.length];
    const last = LAST[Math.floor(i / FIRST.length) % LAST.length];
    return {
      id: i + 1,
      name: `${first} ${last}`,
      email: `${first.toLowerCase()}.${last.toLowerCase()}@example.com`,
      department: DEPARTMENTS[i % DEPARTMENTS.length],
      salary: 40000 + (i % 60) * 1000,
      status: STATUSES[i % STATUSES.length],
    };
  }

  const columns = [
    { id: 'id', title: 'ID', width: 70 },
    { id: 'name', title: 'Name', width: 180 },
    { id: 'email', title: 'Email', width: 220 },
    { id: 'department', title: 'Department', width: 150 },
    { id: 'salary', title: 'Salary', width: 110 },
    { id: 'status', title: 'Status', width: 110 },
  ];

  const departmentOptions = DEPARTMENTS.map((d) => ({ label: d, value: d }));
  const statusOptions = STATUSES.map((s) => ({ label: s, value: s }));

  let nextId = 51;
  let rows = $state<Record<string, unknown>[]>(
    Array.from({ length: 50 }, (_, i) => makeRow(i) as unknown as Record<string, unknown>),
  );

  // ── Drawer state ─────────────────────────────────────────────────────────────

  let drawerOpen = $state(false);
  let formMode = $state<FormRequestType>('update');
  let formValues = $state<EmployeeRow | undefined>(undefined);
  let lastSaved = $state<EmployeeRow | undefined>(undefined);

  function openForm(mode: FormRequestType, row?: EmployeeRow) {
    formMode = mode;
    formValues = row ? { ...row } : { id: 0, name: '', email: '', department: '', salary: 50000, status: 'Active' };
    drawerOpen = true;
  }

  const drawerTitle = $derived(
    formMode === 'insert' ? 'Add Employee' : formMode === 'delete' ? 'Delete Employee' : 'Edit Employee',
  );

  // ── Selection tracking ────────────────────────────────────────────────────────

  let selectedItems = $state<Record<string, unknown>[]>([]);

  function firstSelected(): EmployeeRow | undefined {
    const item = selectedItems[0];
    return item ? (item as unknown as EmployeeRow) : undefined;
  }

  // ── Burger menu items ─────────────────────────────────────────────────────────

  const menuItems: GridlerContextMenuItem[] = [
    { id: 'add', label: 'Add', icon: iconAddSvg, color: 'success', kind: 'item', onselect: () => openForm('insert') },
    { id: 'sep1', label: '', kind: 'separator', requiresRow: true },
    {
      id: 'edit',
      requiresRow: true,
      label: 'Edit',
      icon: iconEditSvg,
      color: 'warning',
      kind: 'item',
      onselect: (rowData) => {
        const row = (rowData as EmployeeRow | undefined) ?? firstSelected();
        if (row) openForm('update', row);
      },
    },
    {
      id: 'delete',
      requiresRow: true,
      label: 'Delete',
      icon: iconTrashSvg,
      color: 'danger',
      kind: 'item',
      onselect: (rowData) => {
        const row = (rowData as EmployeeRow | undefined) ?? firstSelected();
        if (row) openForm('delete', row);
      },
    },
  ];
</script>

<div class="p-4 space-y-2">
  <p class="text-sm text-surface-600-300-token">
    Double-click a row to edit · Right-click or ☰ for Add / Edit / Delete
  </p>

  <GridlerFull
    {columns}
    data={rows}
    height={480}
    uniqueID="id"
    rowMarkers="number"
    {menuItems}
    onRowDblClick={(_row, rowData) => rowData && openForm('update', rowData as unknown as EmployeeRow)}
    onSelectedItemsChange={(items) => (selectedItems = items)}
  />

  <FormerDrawer
    bind:opened={drawerOpen}
    bind:values={formValues}
    request={formMode}
    uniqueKeyField="id"
    layout={{ title: drawerTitle, buttonArea: 'bottom' }}
    onAPICall={async (_mode, _req, data) => {
      await new Promise((r) => setTimeout(r, 400));
      return data as EmployeeRow;
    }}
    afterSave={(data) => {
      const saved = data as EmployeeRow;
      lastSaved = saved;
      if (formMode === 'insert') {
        saved.id = nextId++;
        rows = [...rows, saved as unknown as Record<string, unknown>];
      } else if (formMode === 'delete') {
        rows = rows.filter((r) => r['id'] !== saved.id);
      } else {
        const rec = saved as unknown as Record<string, unknown>;
        rows = rows.map((r) => (r['id'] === saved.id ? { ...rec } : r));
      }
    }}
    onClose={() => { drawerOpen = false; }}
  >
    {#snippet children(state)}
      {@const isReadonly = state.request === 'delete'}
      <div class="p-6 space-y-4">
        <TextInputCtrl
          label="Name"
          name="name"
          value={state.values?.name ?? ''}
          required
          disabled={isReadonly}
          onchange={(v) => state.setState('values', { ...state.values, name: v })}
        />
        <TextInputCtrl
          label="Email"
          name="email"
          type="email"
          value={state.values?.email ?? ''}
          required
          disabled={isReadonly}
          onchange={(v) => state.setState('values', { ...state.values, email: v })}
        />
        <div class="grid grid-cols-2 gap-4">
          <NativeSelectCtrl
            label="Department"
            name="department"
            value={state.values?.department ?? ''}
            options={departmentOptions}
            disabled={isReadonly}
            onchange={(v) => state.setState('values', { ...state.values, department: v })}
          />
          <NativeSelectCtrl
            label="Status"
            name="status"
            value={state.values?.status ?? ''}
            options={statusOptions}
            disabled={isReadonly}
            onchange={(v) => state.setState('values', { ...state.values, status: v })}
          />
        </div>
        <NumberInputCtrl
          label="Salary"
          name="salary"
          value={state.values?.salary}
          min={0}
          disabled={isReadonly}
          onchange={(v) => state.setState('values', { ...state.values, salary: v })}
        />
      </div>
    {/snippet}
  </FormerDrawer>

  {#if lastSaved}
    <div class="card p-4">
      <p class="font-semibold text-sm mb-2">Last saved:</p>
      <pre class="text-xs overflow-auto">{JSON.stringify(lastSaved, null, 2)}</pre>
    </div>
  {/if}
</div>
```

Helper component `./GridlerFullNarrowPreview.svelte`:

```svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import GridlerFull from './GridlerFull.svelte';

  type Props = ComponentProps<typeof GridlerFull> & { containerWidth?: number };

  const { containerWidth = 280, ...rest }: Props = $props();
</script>

<!-- The toolbar responds to its own width, so the container (not the grid `width` prop) must be narrow. -->
<div style="width:{containerWidth}px">
  <GridlerFull {...rest} />
</div>
```

Helper component `./GridlerFullToolbarBelowPreview.svelte`:

```svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import GridlerFull from './GridlerFull.svelte';

  const props: ComponentProps<typeof GridlerFull> = $props();
</script>

<GridlerFull {...props}>
  {#snippet toolbarBelow()}
    <span>Below toolbar: a full-width row for a description, summary or notices.</span>
  {/snippet}
</GridlerFull>
```

Helper component `./GridlerFullAllSnippetsStory.svelte`:

```svelte
<script lang="ts">
  import GridlerFull from './GridlerFull.svelte';

  const columns = [
    { id: 'name', title: 'Name', width: 160 },
    { id: 'department', title: 'Department', width: 140 },
    { id: 'salary', title: 'Salary', width: 110 },
    { id: 'status', title: 'Status', width: 110 },
  ];

  const NAMES = ['Alice Smith', 'Bob Jones', 'Carol Brown', 'David Garcia', 'Eve Johnson', 'Frank Williams'];
  const rows = Array.from({ length: 24 }, (_, i) => ({
    id: i + 1,
    name: NAMES[i % NAMES.length],
    department: ['Engineering', 'Sales', 'HR'][i % 3],
    salary: 40000 + i * 1500,
    status: ['Active', 'Pending', 'Inactive'][i % 3],
  }));

  let showTitle = $state(true);
  let showToolbarStart = $state(true);
  let showToolbarCenter = $state(true);
  let showToolbarEnd = $state(true);
  let showToolbarBelow = $state(true);
  let showLeft = $state(true);
  let showRight = $state(true);
  let showGridFooter = $state(true);
  let showEmpty = $state(true);
  let noData = $state(false);

  const data = $derived(noData ? [] : rows);
</script>

{#snippet titleSnippet()}
  <strong>🧩 Title snippet</strong>
{/snippet}

{#snippet toolbarStartSnippet()}
  <button type="button">toolbarStart</button>
{/snippet}

{#snippet toolbarCenterSnippet()}
  <button type="button">toolbarCenter</button>
{/snippet}

{#snippet toolbarEndSnippet()}
  <button type="button">toolbarEnd</button>
{/snippet}

{#snippet toolbarBelowSnippet()}
  <span>toolbarBelow: a full-width row for a description, summary or notices.</span>
{/snippet}

{#snippet gridFooterSnippet()}
  <span>gridFooter: totals, notes or pagination info below the grid.</span>
{/snippet}

{#snippet leftSnippet()}
  <div style="width:120px;padding:8px">leftContent: a side panel.</div>
{/snippet}

{#snippet rightSnippet()}
  <div style="width:120px;padding:8px">rightContent: a side panel.</div>
{/snippet}

{#snippet emptySnippet()}
  <p>🫥 empty snippet: nothing to show</p>
{/snippet}

<div style="display:flex;flex-direction:column;gap:8px;padding:16px">
  <div style="display:flex;flex-wrap:wrap;gap:12px;font-size:0.85rem">
    <label><input type="checkbox" bind:checked={showTitle} /> title</label>
    <label><input type="checkbox" bind:checked={showToolbarStart} /> toolbarStart</label>
    <label><input type="checkbox" bind:checked={showToolbarCenter} /> toolbarCenter</label>
    <label><input type="checkbox" bind:checked={showToolbarEnd} /> toolbarEnd</label>
    <label><input type="checkbox" bind:checked={showToolbarBelow} /> toolbarBelow</label>
    <label><input type="checkbox" bind:checked={showGridFooter} /> gridFooter</label>
    <label><input type="checkbox" bind:checked={showLeft} /> leftContent</label>
    <label><input type="checkbox" bind:checked={showRight} /> rightContent</label>
    <label><input type="checkbox" bind:checked={showEmpty} /> empty</label>
    <label><input type="checkbox" bind:checked={noData} /> no data</label>
  </div>

  <GridlerFull
    {columns}
    {data}
    height={480}
    title={showTitle ? titleSnippet : 'Title (string)'}
    toolbarStart={showToolbarStart ? toolbarStartSnippet : undefined}
    toolbarCenter={showToolbarCenter ? toolbarCenterSnippet : undefined}
    toolbarEnd={showToolbarEnd ? toolbarEndSnippet : undefined}
    toolbarBelow={showToolbarBelow ? toolbarBelowSnippet : undefined}
    gridFooter={showGridFooter ? gridFooterSnippet : undefined}
    leftContent={showLeft ? leftSnippet : undefined}
    rightContent={showRight ? rightSnippet : undefined}
    empty={showEmpty ? emptySnippet : undefined}
  />
</div>
```

Helper component `./GridlerFullTickboxPreview.svelte`:

```svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import GridlerFull from './GridlerFull.svelte';
  import type { Selection } from '../types';
  import { iconCheckCheckSvg, iconChevronsUpDownSvg, iconSquareXSvg } from '#lib/components/Icons/strings.js';

  type Props = ComponentProps<typeof GridlerFull> & { clickToTick?: boolean };

  const { clickToTick = false, ...rest }: Props = $props();

  let grid: ReturnType<typeof GridlerFull> | undefined = $state();
  let selection = $state<Selection>({ type: 'none' });

  const ticked = $derived(selection.type === 'row' ? selection.rows : []);

  function set(rows: number[]) {
    selection = rows.length ? { type: 'row', rows } : { type: 'none' };
  }

  function toggleRow(row: number) {
    set(ticked.includes(row) ? ticked.filter((r) => r !== row) : [...ticked, row]);
  }

  const handleCellEvent: NonNullable<Props['onCellEvent']> = (type, rowData, col, ...more) => {
    rest.onCellEvent?.(type, rowData, col, ...more);
    if (!clickToTick || type !== 'click') return;
    const row = grid?.getLoadedData().indexOf(rowData) ?? -1;
    if (row >= 0) toggleRow(row);
  };

  function tickAll() {
    const count = grid?.getLoadedData().length ?? 0;
    set(Array.from({ length: count }, (_, i) => i));
  }

  function invert() {
    const count = grid?.getLoadedData().length ?? 0;
    const on = new Set(ticked);
    set(Array.from({ length: count }, (_, i) => i).filter((i) => !on.has(i)));
  }

  const menuItems = [
    { id: 'tick-all', label: 'Tick All', icon: iconCheckCheckSvg, onselect: tickAll },
    { id: 'untick-all', label: 'Untick All', icon: iconSquareXSvg, onselect: () => set([]) },
    { id: 'invert', label: 'Invert Ticks', icon: iconChevronsUpDownSvg, onselect: invert },
  ];
</script>

<div data-testid="tickbox-count" style="padding:4px 8px;font-size:13px">
  Ticked: {ticked.length}
</div>
<GridlerFull
  bind:this={grid}
  {...rest}
  rowMarkers="checkbox"
  cellSelection={false}
  {selection}
  {menuItems}
  onCellEvent={handleCellEvent}
  onSelectionChange={(sel, rows) => {
    selection = sel;
    rest.onSelectionChange?.(sel, rows);
  }}
/>
```

Helper component `./GridlerFullInplaceEditorPreview.svelte`:

```svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import GridlerFull from './GridlerFull.svelte';
  import type { GridlerInplaceEditorContext } from '../types';

  type Props = ComponentProps<typeof GridlerFull>;

  const { ...rest }: Props = $props();
</script>

<!-- By column id: Status edits with pill buttons. -->
{#snippet statusEditor(ctx: GridlerInplaceEditorContext)}
  <div style="display:flex;gap:4px;align-items:center;height:100%;padding:0 4px">
    {#each ctx.options as option (option.value)}
      <button
        type="button"
        style="padding:0 6px;border-radius:9999px;border:1px solid currentColor;font-size:11px;line-height:18px;{option.value === ctx.cell.displayData ? 'font-weight:700' : 'opacity:.6'}"
        onclick={() => ctx.onComplete(option.value)}
      >
        {option.label}
      </button>
    {/each}
    <button type="button" style="margin-left:auto" onclick={ctx.onCancel}>✕</button>
  </div>
{/snippet}

<!-- By editor type + row data: Engineering salaries edit with a slider. -->
{#snippet salarySlider(ctx: GridlerInplaceEditorContext)}
  <input
    type="range"
    min="30000"
    max="150000"
    step="1000"
    style="width:100%;height:100%"
    value={Number(ctx.cell.data)}
    onchange={(e) => ctx.onComplete(Number(e.currentTarget.value))}
    onblur={ctx.onCancel}
    onkeydown={(e) => {
      e.stopPropagation();
      if (e.key === 'Escape') ctx.onCancel();
    }}
  />
{/snippet}

<GridlerFull
  {...rest}
  enableEdit
  inplaceEditorFor={({ column, editorType, rowData }) => {
    if (column.id === 'status') return statusEditor;
    if (editorType === 'number' && rowData?.department === 'Engineering') return salarySlider;
    return undefined;
  }}
/>
```

#### With Menu (`WithMenu`)

```ts
// ── Stories ─────────────────────────────────────────────────────────────────

/** Burger menu — click the ☰ icon in the top-left header corner to open the popup. */
export const WithMenu: Story = {
  args: {
    uniqueID: "id",
    columns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
    pageSize: 50,
    onMenuClick: showGridMenu,
  },
};
```

#### Builtin Menu Item Override (`BuiltinMenuItemOverride`)

```ts
/** buttonMenuProps overrides the built-in "Refresh" item's icon and label. */
export const BuiltinMenuItemOverride: Story = {
  args: {
    columns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
    buttonMenuProps: {
      refresh: { label: "Reload Now", icon: "🔄" },
    },
  },
};
```

#### Static Data (`StaticData`)

```ts
/** Static local data — no server required. Sort and filters are remembered via `persistenceName`. */
export const StaticData: Story = {
  args: {
    persistenceName: "story-gridlerfull-static",
    columns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
  },
};
```

#### Search Columns (`SearchColumns`)

```ts
/** `searchColumns` — local search filters rows (contains, OR across those columns) and highlights matches only in them. Try "smith": Name matches, Email is ignored. */
export const SearchColumns: Story = {
  args: {
    columns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
    searchColumns: ["name", "department"],
  },
};
```

#### With Title (`WithTitle`)

```ts
/** `title` — text (or a snippet) at the left of the toolbar, before the search. */
export const WithTitle: Story = {
  args: {
    columns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
    title: "Employees",
  },
};
```

#### Toolbar Below (`ToolbarBelow`)

```ts
/** `toolbarBelow` snippet — a full-width row under the toolbar, above the grid (description, summary, notices). */
export const ToolbarBelow: Story = {
  args: {
    columns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
    title: "Employees",
  },
  render: (args) => ({ Component: GridlerFullToolbarBelowPreview, props: args }),
};
```

#### All Snippets (`AllSnippets`)

```ts
/** Toggle every snippet slot on/off: `title`, `toolbarStart`, `toolbarCenter`, `toolbarEnd`, `toolbarBelow`, `gridFooter`, `leftContent`, `rightContent`, `empty` (tick "no data"). */
export const AllSnippets: Story = {
  render: () => ({ Component: GridlerFullAllSnippetsStory }),
};
```

#### Collapsed Sort (`CollapsedSort`)

```ts
/** `collapseSort` — active sorts are one "Sort" menu button (with a count badge) instead of chips. Without it, the chips collapse into the button when the toolbar is under 480px. */
export const CollapsedSort: Story = {
  args: {
    columns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
    collapseSort: true,
    sortOrder: { name: "asc", salary: "desc" },
  },
};
```

#### Row Markers (`RowMarkers`)

```ts
/** `rowMarkers` — the left row column: `number` (row numbers), `checkbox` (selection), or `none` to hide it. Switch it in Controls. */
export const RowMarkers: Story = {
  args: {
    columns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
    rowMarkers: "number",
  },
};
```

#### Tickboxes (`Tickboxes`)

```ts
/** Checkbox row markers with Tick All / Untick All / Invert Ticks in the grid menu (toolbar menu button or right-click). Ticked count shown above the grid. */
export const Tickboxes: Story = {
  render: (args) => ({
    Component: GridlerFullTickboxPreview,
    props: args,
  }),
  args: {
    columns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
    onSelectionChange: fn(),
  },
};
```

#### Tickboxes Click To Tick (`TickboxesClickToTick`)

```ts
/** Same as `Tickboxes`, but a single click on any cell ticks / unticks its row. */
export const TickboxesClickToTick: Story = {
  render: (args) => ({
    Component: GridlerFullTickboxPreview,
    props: { ...args, clickToTick: true },
  }),
  args: {
    columns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
    onSelectionChange: fn(),
  },
};
```

#### Controlled Selection (`ControlledSelection`)

```ts
/** Controlled `selection` prop: caller-set row ticks are honoured (regression: the internal selection used to override it). */
export const ControlledSelection: Story = {
  args: {
    columns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
    rowMarkers: "checkbox",
    selection: { type: "row", rows: [0, 2, 4] },
  },
};
```

#### Controlled Selection None (`ControlledSelectionNone`)

```ts
/** Controlled `selection` of `none` is honoured too. */
export const ControlledSelectionNone: Story = {
  args: {
    columns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
    rowMarkers: "checkbox",
    selection: { type: "none" },
  },
};
```

#### No Row Markers (`NoRowMarkers`)

```ts
/** `rowMarkers: "none"` — no row number column. */
export const NoRowMarkers: Story = {
  args: {
    columns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
    rowMarkers: "none",
  },
};
```

#### Narrow Toolbar (`NarrowToolbar`)

```ts
/** Narrow toolbar (280px): row count and chips are hidden, the inline search collapses into the search button, and the text title stays, trimmed with an ellipsis. */
export const NarrowToolbar: Story = {
  render: (args) => ({
    Component: GridlerFullNarrowPreview,
    props: { ...args, containerWidth: 280 },
  }),
  args: {
    columns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
    title: "Employees",
  },
};
```

#### Inline Search (`InlineSearch`)

```ts
/** `searchMode: "inline"` (default) — always-visible search input in the toolbar; Ctrl+F focuses it. Switch the `searchMode` control to compare modes. */
export const InlineSearch: Story = {
  args: {
    columns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
    searchMode: "inline",
  },
};
```

#### Popover Search (`PopoverSearch`)

```ts
/** `searchMode: "popover"` — the toolbar shows a search button beside the menu button that opens a search bar over the grid (Ctrl+F also opens it). The grid is editable, and edit-in-place is disabled while the search bar is open. */
export const PopoverSearch: Story = {
  args: {
    columns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
    searchMode: "popover",
    showSearch: false,
    enableEdit: true,
    readonly: false,
    searchColumns: ["name"]
  },
};
```

#### Async Data Function (`AsyncDataFunction`)

```ts
/** `data` as an async function is loaded on mount and reported via the `load` grid event. */
export const AsyncDataFunction: Story = {
  args: {
    columns,
    data: async () => ALL_ROWS.slice(0, 25),
    height: 480,
    onGridEvent: fn(),
  },
};
```

#### Selection Reporting (`SelectionReporting`)

```ts
/** Keyboard selection reports row data, and Escape reports an empty selection. */
export const SelectionReporting: Story = {
  args: {
    columns,
    data: ALL_ROWS.slice(0, 20),
    height: 400,
    onSelectedItemsChange: fn(),
  },
};
```

#### Enable Edit (`EnableEdit`)

```ts
/**
 * `enableEdit` — turns on editing with a built-in editor per column type: text, number (Salary), date / datetime
 * (Joined), select (Department, Status via `editorOptions`), checkbox (Active) and textarea (Notes).
 * Inferred from `format` / cell kind, or set with `column.editor`. Values commit typed (number / boolean / string / null).
 * Untick it in Controls and double-click only selects the row.
 */
export const EnableEdit: Story = {
  args: {
    columns: editorColumns,
    data: editorRows,
    height: 480,
    enableEdit: true,
  },
};
```

#### Editor Min Width (`EditorMinWidth`)

```ts
/**
 * `editorMinWidth` — a minimum editor width (px) so editors in narrow columns can grow past the cell, kept inside the grid.
 * Per column: `column.editorMinWidth`. Here Status and Active are 80px wide, but their editors open at 180px.
 */
export const EditorMinWidth: Story = {
  args: {
    columns: editorColumns.map((c) =>
      c.id === "status" || c.id === "active" ? { ...c, width: 80 } : c,
    ),
    data: editorRows,
    height: 480,
    enableEdit: true,
    editorMinWidth: 180,
  },
};
```

#### Inplace Editor Override (`InplaceEditorOverride`)

```ts
/**
 * `inplaceEditorFor(ctx)` — pick a custom editor snippet per cell from the column, editor type and row data.
 * Return `undefined` for the built-in default. Here Status uses pill buttons (by column id) and Engineering
 * salaries use a slider (by editor type + row data). A plain `inplaceEditor` snippet is the catch-all.
 */
export const InplaceEditorOverride: Story = {
  render: (args) => ({
    Component: GridlerFullInplaceEditorPreview,
    props: args,
  }),
  args: {
    columns: editorColumns,
    data: editorRows,
    height: 480,
  },
};
```

#### Editable Static (`EditableStatic`)

```ts
/** Editable static data — `enableEdit` turns on double-click / Enter / typing to edit; edits are applied in-memory. Without it, double-click only selects the row. */
export const EditableStatic: Story = {
  args: {
    columns,
    data: ALL_ROWS.slice(0, 50),
    height: 480,
    enableEdit: true,
    readonly: false,
  },
};
```

#### Server Side Cursor Paging (`ServerSideCursorPaging`)

```ts
/**
 * Server-side mode with cursor-forward pagination.
 *
 * `window.fetch` is mocked to serve pages from the local fixture array.
 * Each response includes `metadata.total` and the next page is fetched
 * using the last row's `id` as `cursor_forward`.
 */
export const ServerSideCursorPaging: Story = {
  args: {
    columns,
    dataSource: "resolvespec",
    dataSourceOptions: {
      url: "https://api.example.com",
      schema: "public",
      entity: "employees",
      uniqueID: "id",
    },
    pageSize: 50,
    height: 480,
    onGridEvent: fn(),
    onLoadError: fn(),
  },
  decorators: [
    (Story) => {
      // Mock fetch: parse cursor_forward from the request body and return a page.
      const originalFetch = window.fetch;
      window.fetch = async (input, init) => {
        const url = typeof input === "string" ? input : (input as Request).url;
        if (url.includes("example.com")) {
          let body: Record<string, unknown> = {};
          try {
            body = JSON.parse((init?.body as string) ?? "{}");
          } catch {
            /* */
          }

          const opts: Record<string, unknown> =
            (body.options as Record<string, unknown>) ?? {};
          const limit = Number(opts.limit ?? 50);
          const cursor = opts.cursor_forward ? Number(opts.cursor_forward) : 0;

          const startIdx =
            cursor === 0 ? 0 : ALL_ROWS.findIndex((r) => r.id === cursor) + 1;
          const page =
            startIdx < 0 ? [] : ALL_ROWS.slice(startIdx, startIdx + limit);

          return new Response(
            JSON.stringify({
              success: true,
              data: page,
              metadata: {
                total: TOTAL_ROWS,
                count: page.length,
                filtered: TOTAL_ROWS,
                limit,
                offset: startIdx,
              },
            }),
            { status: 200, headers: { "Content-Type": "application/json" } },
          );
        }
        return originalFetch(input, init);
      };
      const result = Story();
      // Restore after a tick so the component mounts with the mock in place.
      setTimeout(() => {
        window.fetch = originalFetch;
      }, 5000);
      return result;
    },
  ],
};
```

#### Server Side Fills Viewport (`ServerSideFillsViewport`)

```ts
/**
 * A first page that does not fill the viewport keeps loading pages until the viewport is
 * full (no scrolling needed), then stops.
 */
export const ServerSideFillsViewport: Story = {
  args: {
    columns,
    dataSource: "resolvespec",
    dataSourceOptions: {
      url: "https://api.example.com",
      schema: "public",
      entity: "employees",
      uniqueID: "id",
    },
    pageSize: 5,
    height: 700,
    onLoadError: fn(),
  },
  decorators: [
    (Story) => {
      const originalFetch = window.fetch;
      window.fetch = async (input, init) => {
        const url = typeof input === "string" ? input : (input as Request).url;
        if (!url.includes("example.com")) return originalFetch(input, init);
        const body = JSON.parse((init?.body as string) ?? "{}");
        const opts = body.options ?? {};
        const limit = Number(opts.limit ?? 50);
        const startIdx = opts.cursor_forward
          ? ALL_ROWS.findIndex((r) => r.id === Number(opts.cursor_forward)) + 1
          : 0;
        const page = ALL_ROWS.slice(startIdx, startIdx + limit);
        return new Response(
          JSON.stringify({
            success: true,
            data: page,
            metadata: { total: TOTAL_ROWS, count: page.length, filtered: TOTAL_ROWS, limit, offset: startIdx },
          }),
          { status: 200, headers: { "Content-Type": "application/json" } },
        );
      };
      setTimeout(() => {
        window.fetch = originalFetch;
      }, 8000);
      return Story();
    },
  ],
};
```

#### Server Side Search (`ServerSideSearch`)

```ts
/**
 * Server-side with server-side search.
 * Typing in the search box sends `ilike` filters to the (mocked) API.
 */
export const ServerSideSearch: Story = {
  args: {
    columns,
    dataSource: "resolvespec",
    dataSourceOptions: {
      url: "https://api.example.com",
      schema: "public",
      entity: "employees",
      uniqueID: "id",
    },
    pageSize: 50,
    height: 480,
    serverSideSearch: true,
    searchColumns: ["name", "email", "department"],
  },
  decorators: [
    (Story) => {
      const originalFetch = window.fetch;
      window.fetch = async (input, init) => {
        const url = typeof input === "string" ? input : (input as Request).url;
        if (url.includes("example.com")) {
          let body: Record<string, unknown> = {};
          try {
            body = JSON.parse((init?.body as string) ?? "{}");
          } catch {
            /* */
          }

          const opts: Record<string, unknown> =
            (body.options as Record<string, unknown>) ?? {};
          const limit = Number(opts.limit ?? 50);
          const cursor = opts.cursor_forward ? Number(opts.cursor_forward) : 0;
          const rawFilters =
            (opts.filters as Array<{
              column: string;
              operator: string;
              value: string;
            }>) ?? [];

          // Apply ilike search filters from the request.
          const term =
            rawFilters
              .filter((f) => f.operator === "ilike")
              .map((f) => f.value.replace(/%/g, "").toLowerCase())
              .find(Boolean) ?? "";

          const filtered = term
            ? ALL_ROWS.filter(
                (r) =>
                  String(r.name).toLowerCase().includes(term) ||
                  String(r.email).toLowerCase().includes(term) ||
                  String(r.department).toLowerCase().includes(term),
              )
            : ALL_ROWS;

          const startIdx =
            cursor === 0 ? 0 : filtered.findIndex((r) => r.id === cursor) + 1;
          const page =
            startIdx < 0 ? [] : filtered.slice(startIdx, startIdx + limit);

          return new Response(
            JSON.stringify({
              success: true,
              data: page,
              metadata: {
                total: filtered.length,
                count: page.length,
                filtered: filtered.length,
                limit,
                offset: startIdx,
              },
            }),
            { status: 200, headers: { "Content-Type": "application/json" } },
          );
        }
        return originalFetch(input, init);
      };
      const result = Story();
      setTimeout(() => {
        window.fetch = originalFetch;
      }, 5000);
      return result;
    },
  ],
};
```

#### Server Error (`ServerError`)

```ts
/** Error state — API returns a failure response. */
export const ServerError: Story = {
  args: {
    columns,
    dataSource: "resolvespec",
    dataSourceOptions: {
      url: "https://api.example.com",
      schema: "public",
      entity: "employees",
      uniqueID: "id",
    },
    height: 480,
  },
  decorators: [
    (Story) => {
      const originalFetch = window.fetch;
      window.fetch = async (input, init) => {
        const url = typeof input === "string" ? input : (input as Request).url;
        if (url.includes("example.com")) {
          return new Response(
            JSON.stringify({
              success: false,
              error: { code: "500", message: "Internal server error" },
            }),
            { status: 500, headers: { "Content-Type": "application/json" } },
          );
        }
        return originalFetch(input, init);
      };
      const result = Story();
      setTimeout(() => {
        window.fetch = originalFetch;
      }, 3000);
      return result;
    },
  ],
};
```

#### All Renderers (`AllRenderers`)

```ts
/** One column per cell renderer kind. */
export const AllRenderers: Story = {
  args: {
    columns: [
      { id: "text",       title: "Text",        width: 130 },
      { id: "number",     title: "Number",      width: 90  },
      { id: "currency",   title: "Currency",    width: 120 },
      { id: "percentage", title: "Percentage",  width: 100 },
      { id: "date",       title: "Date",        width: 95  },
      { id: "datetime",   title: "DateTime",    width: 155 },
      { id: "markdown",   title: "Markdown",    width: 180 },
      { id: "uri",        title: "URI",         width: 180 },
      { id: "bubble",     title: "Bubble",      width: 110 },
      { id: "drilldown",  title: "Drilldown",   width: 140 },
      { id: "image",      title: "Image (SVG)", width: 70  },
    ],
    data: ALL_ROWS,
    height: 480,
    rowHeight: 44,
    rowMarkers: "number",
    getCellContent: ([col, row]: Item): GridlerCell => {
      const price = 1000 + row * 137.5;
      const ratio = 0.01 + (row % 50) * 0.015;
      const date = new Date(2023, row % 12, (row % 28) + 1);
      const ts   = new Date(2025, (row * 3) % 12, (row % 28) + 1, (row * 7) % 24, (row * 11) % 60);
      switch (col) {
        case 0:  return { kind: "text",       data: `Row ${row} value`,                              displayData: `Row ${row} value`,                              allowOverlay: true  };
        case 1:  return { kind: "number",     data: row * 42,                                        displayData: String(row * 42),                                allowOverlay: true  };
        case 2:  return { kind: "currency",   data: price,                                           displayData: String(price),                                   allowOverlay: true  };
        case 3:  return { kind: "percentage", data: ratio,                                           displayData: String(ratio),                                   allowOverlay: true  };
        case 4:  return { kind: "datetime",   data: date,                                            displayData: date.toLocaleDateString(),                       allowOverlay: false };
        case 5:  return { kind: "datetime",   data: ts,                                              displayData: ts.toLocaleString(),                             allowOverlay: false };
        case 6:  return { kind: "markdown",   data: MARKDOWN_SAMPLES[row % MARKDOWN_SAMPLES.length], displayData: MARKDOWN_SAMPLES[row % MARKDOWN_SAMPLES.length], allowOverlay: true  };
        case 7:  return { kind: "uri",        data: `https://example.com/item/${row}`,               displayData: `https://example.com/item/${row}`,               allowOverlay: false };
        case 8:  return { kind: "bubble",     data: BUBBLE_LABELS[row % BUBBLE_LABELS.length],       displayData: BUBBLE_LABELS[row % BUBBLE_LABELS.length],       allowOverlay: false };
        case 9:  return { kind: "drilldown",  data: DRILLDOWN_LABELS[row % DRILLDOWN_LABELS.length], displayData: DRILLDOWN_LABELS[row % DRILLDOWN_LABELS.length], allowOverlay: false };
        case 10: return { kind: "image",      data: SVG_ICONS[row % SVG_ICONS.length],               displayData: "",                                              allowOverlay: false };
        default: return { kind: "text", data: null, displayData: "", allowOverlay: false };
      }
    },
  },
};
```

#### Rest Header Spec Adapter (`RestHeaderSpecAdapter`)

```ts
/** RestHeaderSpec adapter variant (same mock, different client protocol). */
export const RestHeaderSpecAdapter: Story = {
  args: {
    columns,
    dataSource: "headerspec",
    dataSourceOptions: {
      url: "https://api.example.com",
      schema: "public",
      entity: "employees",
      uniqueID: "id",
    },
    pageSize: 50,
    height: 480,
  },
  decorators: [
    (Story) => {
      const originalFetch = window.fetch;
      window.fetch = async (input, init) => {
        const url = typeof input === "string" ? input : (input as Request).url;
        if (url.includes("example.com")) {
          // HeaderSpec sends options as headers, not body.
          const headers = (init?.headers ?? {}) as Record<string, string>;
          const limit = Number(headers["X-Limit"] ?? 50);
          const cursor = headers["X-Cursor-Forward"]
            ? Number(headers["X-Cursor-Forward"])
            : 0;

          const startIdx =
            cursor === 0 ? 0 : ALL_ROWS.findIndex((r) => r.id === cursor) + 1;
          const page =
            startIdx < 0 ? [] : ALL_ROWS.slice(startIdx, startIdx + limit);

          return new Response(
            JSON.stringify({
              success: true,
              data: page,
              metadata: {
                total: TOTAL_ROWS,
                count: page.length,
                filtered: TOTAL_ROWS,
                limit,
                offset: startIdx,
              },
            }),
            { status: 200, headers: { "Content-Type": "application/json" } },
          );
        }
        return originalFetch(input, init);
      };
      const result = Story();
      setTimeout(() => {
        window.fetch = originalFetch;
      }, 5000);
      return result;
    },
  ],
};
```

#### Live Resolve Spec (`LiveResolveSpec`)

```ts
export const LiveResolveSpec: Story = {
  argTypes: liveArgTypes,
  args: {
    apiUrl: "https://utils.btsys.tech/api/v2",
    authToken: "",
    schema: "public",
    entity: "process",
    uniqueID: "id_process",
    columns: [
      { id: "id_process", title: "ID", width: 90 },
      { id: "process", title: "Process", width: 220 },
      { id: "description", title: "Description", width: 300 },
    ],
    dataSource: "resolvespec",
    pageSize: 50,
    height: 480,
    serverSideSearch: true,
    searchColumns: ["process", "description"],
  },
  render: ({ apiUrl, authToken, schema, entity, uniqueID, ...args }) => ({
    Component: GridlerFull,
    props: {
      ...args,
      dataSourceOptions: {
        url: apiUrl,
        authToken,
        schema,
        entity,
        uniqueID,
      },
    },
  }),
};
```

#### With Former Row Edit (`WithFormerRowEdit`)

```ts
/**
 * Double-click any row to open a Former drawer for editing.
 * Saves are applied in-memory and reflected immediately in the grid.
 */
export const WithFormerRowEdit: Story = {
  render: () => ({ Component: GridlerFullWithFormerPreview }),
};
```

#### Nested Data Keys (`NestedDataKeys`)

```ts
/**
 * Demonstrates dot-notation `dataKey` paths resolving values from structured row data.
 * - `login.username` — nested object
 * - `login.contact.email` — multi-level nesting
 * - `tags.[0].name` — array index access
 * - `org.department.team.name` — deep nesting
 * - `employment.salary` — nested scalar
 */
export const NestedDataKeys: Story = {
  args: {
    columns: nestedColumns,
    data: NESTED_ROWS,
    height: 480,
    rowMarkers: "number",
  },
};
```

#### Live Rest Header Spec (`LiveRestHeaderSpec`)

```ts
export const LiveRestHeaderSpec: Story = {
  argTypes: {
    ...liveArgTypes,
    apiUrl: { control: { type: "text" }, description: "RestHeaderSpec API base URL" },
    authToken: { control: { type: "text" }, description: "Bearer token for RestHeaderSpec authentication" },
  },
  args: {
    apiUrl: "https://utils.btsys.tech/api/",
    authToken: "E2B097B9-F4C3-4218-85D0-91240D3B0741",
    schema: "public",
    entity: "process",
    uniqueID: "id_process",
    columns: [{
      "id": "id_taxonomy",
      "title": "ID",
      "width": 90
    }, {
      "id": "process",
      "title": "Name",
      "width": 220
    }, {
      "id": "description",
      "title": "Description",
      "width": 300
    }],
    dataSource: "headerspec",
    pageSize: 50,
    height: 480,
    serverSideSearch: true,
    searchColumns: ["process", "description"],
  },
  render: ({ apiUrl, authToken, schema, entity, uniqueID, ...args }) => ({
    Component: GridlerFull,
    props: {
      ...args,
      dataSourceOptions: {
        url: apiUrl,
        authToken,
        schema,
        entity,
        uniqueID,
      },
    },
  }),
};
```

#### Exportable (`Exportable`)

```ts
/**
 * `exportable` adds "Export…" to the toolbar ⋮ menu and the context menus.
 * Exports every row matching the current filters/sort (server pages of `pageSize`);
 * the mocked server answers each page after 150 ms so progress and Cancel are visible.
 */
export const Exportable: Story = {
  args: {
    columns: [
      ...columns.slice(0, 4),
      { id: "salary", title: "Salary", width: 110, format: "currency" },
      { id: "status", title: "Status", width: 110, exportValue: (row) => String(row.status).toUpperCase() },
    ],
    dataSource: "resolvespec",
    dataSourceOptions: {
      url: "https://api.example.com",
      schema: "public",
      entity: "employees",
      uniqueID: "id",
    },
    pageSize: 50,
    height: 480,
    exportable: true,
    exportOptions: { filename: "employees", onExportComplete: fn() },
  },
  decorators: [
    (Story) => {
      const originalFetch = window.fetch;
      window.fetch = async (input, init) => {
        const url = typeof input === "string" ? input : (input as Request).url;
        if (!url.includes("example.com")) return originalFetch(input, init);
        await new Promise((r) => setTimeout(r, 150));
        let body: Record<string, unknown> = {};
        try {
          body = JSON.parse((init?.body as string) ?? "{}");
        } catch {
          /* */
        }
        const opts = (body.options as Record<string, unknown>) ?? {};
        const limit = Number(opts.limit ?? 50);
        const cursor = opts.cursor_forward ? Number(opts.cursor_forward) : 0;
        const rows = mockSort(mockFilter(ALL_ROWS, opts.filters), opts.sort);
        const startIdx = cursor === 0 ? 0 : rows.findIndex((r) => r.id === cursor) + 1;
        const page = startIdx < 0 ? [] : rows.slice(startIdx, startIdx + limit);
        return new Response(
          JSON.stringify({
            success: true,
            data: page,
            metadata: { total: rows.length, count: page.length, limit, offset: startIdx },
          }),
          { status: 200, headers: { "Content-Type": "application/json" } },
        );
      };
      return Story();
    },
  ],
};
```

#### Initially Hidden Columns (`InitiallyHiddenColumns`)

```ts
/** `initiallyHidden` — Email starts hidden; the Columns list shows it again. Hidden columns stay available (unticked) in Export. */
export const InitiallyHiddenColumns: Story = {
  args: {
    persistenceName: "story-gridlerfull-hidden-columns",
    columns: hiddenColumns,
    data: ALL_ROWS.slice(0, 100),
    height: 480,
    exportable: true,
  },
};
```

#### Hidden Columns In Export (`HiddenColumnsInExport`)

```ts
export const HiddenColumnsInExport: Story = {
  args: { columns: hiddenColumns, data: ALL_ROWS.slice(0, 100), height: 480, exportable: true },
};
```

#### Select Record (`SelectRecord`)

```ts
/** `selectRecord(id)` and `refresh({ select: id })` select a record by uniqueID and scroll to it. */
export const SelectRecord: Story = {
  render: (args) => ({ Component: GridlerFullSelectionHost, props: args }),
  args: selectionArgs(`story-select-record-${Date.now()}`),
};
```

#### Stays On Changed Record (`StaysOnChangedRecord`)

```ts
/** Another record is renamed and re-sorts: the selection stays on its record. */
export const StaysOnChangedRecord: Story = {
  render: (args) => ({ Component: GridlerFullSelectionHost, props: args }),
  args: selectionArgs(`story-stay-${Date.now()}`),
};
```

#### Selection Persisted (`SelectionPersisted`)

```ts
/** The selected record is restored after a remount (`persistenceName`). */
export const SelectionPersisted: Story = {
  render: (args) => ({ Component: GridlerFullSelectionHost, props: args }),
  args: selectionArgs(`story-persisted-${Date.now()}`),
};
```

---

# Group

Import: `import { ... } from '@warky/svelix/Group'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as Group } from "./Group.svelte";
export { GROUP_DEFAULTS, groupChildWidth } from "./groupLayout.js";
export type * from "./types.js";
```

## Summary

Horizontal flex container (port of Mantine Group). Defaults configurable via `SvelixLayout` key `group` and `shared.gap`.

## Types (types.ts)

```ts
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import type { SvelixSpacing } from "../../utils/scale.js";

type DivAttributes = Omit<HTMLAttributes<HTMLDivElement>, "children" | "class" | "style">;

/** `SvelixLayout` key `group`. */
export interface GroupLayoutConfig {
	/** @default "flex-start" */
	justify?: string;
	/** @default "center" */
	align?: string;
	/** @default "wrap" */
	wrap?: string;
	/** @default "md" */
	gap?: SvelixSpacing;
	/** Children get `flex-grow: 1`. @default false */
	grow?: boolean;
	/** With `grow`, children are capped to an equal share. @default true */
	preventGrowOverflow?: boolean;
}

export interface GroupProps extends GroupLayoutConfig, DivAttributes {
	class?: string;
	style?: string;
	children?: Snippet;
}
```

## Examples (from Storybook stories)

### Components/Group  (`Group.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, waitFor, within } from 'storybook/test';

import GroupDemo from './GroupDemo.fixtures.svelte';

/** Tailwind spacing units → px under the active theme's `--spacing`. */
function units(multiplier: number): number {
	const probe = document.createElement('div');
	probe.style.width = `calc(var(--spacing, 0.25rem) * ${multiplier})`;
	document.body.append(probe);
	const px = probe.getBoundingClientRect().width;
	probe.remove();
	return px;
}

async function expectPx(value: string, multiplier: number) {
	await expect(parseFloat(value)).toBeCloseTo(units(multiplier), 1);
}
```

Helper component `./GroupDemo.fixtures.svelte`:

```svelte
<script lang="ts">
	import SvelixLayout from "../SvelixLayout/SvelixLayout.svelte";
	import Group from "./Group.svelte";
	import type { GroupProps } from "./types.js";

	export interface Props extends Omit<GroupProps, "children"> {
		demo?: "basic" | "layout";
		count?: number;
	}

	let { demo = "basic", count = 3, ...props }: Props = $props();
	let items = $state(0);
	const labels = $derived(Array.from({ length: count + items }, (_, i) => `Item ${i + 1}`));
</script>

{#snippet boxes()}
	{#each labels as label (label)}
		<div class="rounded bg-primary-500/20 p-2 text-sm">{label}</div>
	{/each}
{/snippet}

{#if demo === "basic"}
	<button class="btn preset-tonal mb-2" type="button" data-testid="add" onclick={() => items++}>Add item</button>
	<Group data-testid="group" class="rounded border border-surface-300 p-2" {...props}>{@render boxes()}</Group>
{:else}
	<SvelixLayout shared={{ gap: "xl" }} group={{ justify: "center" }}>
		<Group data-testid="group" class="rounded border border-surface-300 p-2">{@render boxes()}</Group>
		<Group data-testid="group-prop" gap={2} class="mt-2 rounded border border-surface-300 p-2">{@render boxes()}</Group>
	</SvelixLayout>
{/if}
```

#### Default (`Default`)

```ts
export const Default: Story = {
	args: { demo: 'basic' },
};
```

#### Grow (`Grow`)

```ts
export const Grow: Story = {
	args: { demo: 'basic', grow: true },
};
```

#### Grow Overflow (`GrowOverflow`)

```ts
export const GrowOverflow: Story = {
	args: { demo: 'basic', grow: true, preventGrowOverflow: false },
};
```

#### Layout (`Layout`)

```ts
export const Layout: Story = {
	args: { demo: 'layout' },
};
```

---

# IFrame

Import: `import { ... } from '@warky/svelix/IFrame'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as IFrame } from "./IFrame.svelte";
export { DEFAULT_READY_MESSAGE_TYPE, IFrameMessenger, sendMessageToWindow, sendToWindow } from "./IFrameMessenger";
export * from "./types";
export { IFRAME_DEFAULTS } from "./iframeLayout";
export type { IFrameLayoutConfig } from "./iframeLayout";
```

## Summary

Wraps a native `<iframe>` for embedding external sites or internal routes, with a postMessage bridge (raw passthrough + a typed `{ type, payload }` envelope with an optional ready handshake) and an imperative ref API (`reload`, `navigate`, `send`, `sendMessage`, `on`/`off`, `focus`).

## Types (types.ts)

```ts
import type { Snippet } from "svelte";

export type IFrameLoading = "eager" | "lazy";

export type IFrameSandboxToken =
	| "allow-downloads"
	| "allow-forms"
	| "allow-modals"
	| "allow-orientation-lock"
	| "allow-pointer-lock"
	| "allow-popups"
	| "allow-popups-to-escape-sandbox"
	| "allow-presentation"
	| "allow-same-origin"
	| "allow-scripts"
	| "allow-storage-access-by-user-activation"
	| "allow-top-navigation"
	| "allow-top-navigation-by-user-activation"
	| "allow-top-navigation-to-custom-protocols";

/** Structured message envelope used by the typed `on()`/`sendMessage()` layer. */
export interface IFrameMessage<TPayload = unknown> {
	type: string;
	payload?: TPayload;
}

export type IFrameRawMessageListener = (event: MessageEvent) => void;

export type IFrameTypedMessageListener<TPayload = unknown> = (
	payload: TPayload,
	event: MessageEvent
) => void;

export interface IFrameProps {
	/** Feature-policy string passed to the native `allow` attribute, e.g. `"camera; microphone"`. */
	allow?: string;
	allowfullscreen?: boolean;
	/**
	 * Origin(s) a received message must come from to be forwarded to `onMessage`/`on()`.
	 * Defaults to `'*'` (accept from any origin).
	 */
	allowedOrigins?: string | string[];
	class?: string;
	/** Fallback content rendered inside the `<iframe>` for browsers that don't support it. */
	children?: Snippet;
	height?: number | string;
	id?: string;
	loading?: IFrameLoading;
	name?: string;
	onError?: (event: Event) => void;
	onLoad?: (event: Event) => void;
	/** Fires for every message event that passes the `allowedOrigins`/source check, regardless of shape. */
	onMessage?: IFrameRawMessageListener;
	/** Fires once when the embedded document posts the `readyMessageType` handshake message. */
	onReady?: () => void;
	referrerpolicy?: ReferrerPolicy;
	/** Message `type` the embedded document can post once it has finished initializing to trigger `onReady`. Defaults to `'svelix:iframe-ready'`. */
	readyMessageType?: string;
	/** Restricts the embedded document. Pass an array of tokens or a raw attribute string. Omitted entirely disables sandboxing (native default). */
	sandbox?: IFrameSandboxToken[] | string;
	/** URL to load - an external site or an internal (same-origin) route. Bindable, and changed programmatically via the `navigate()` ref method. */
	src?: string;
	/** Inline HTML document, rendered instead of `src`. */
	srcdoc?: string;
	style?: string;
	/**
	 * Target origin used by `send()`/`sendMessage()` when posting to the iframe.
	 * Defaults to `'*'`.
	 */
	targetOrigin?: string;
	/** Required for accessibility - describes the embedded document to assistive tech. */
	title: string;
	width?: number | string;
}

export interface IFrameRef {
	/** Escape hatch returning the underlying `<iframe>` element. */
	getElement: () => HTMLIFrameElement | undefined;
	/** Escape hatch returning the iframe's `contentWindow`. */
	getWindow: () => Window | null;
	focus: () => void;
	/** Changes the iframe's `src` (works for both external sites and internal routes). */
	navigate: (url: string) => void;
	off: (type: string, handler: IFrameTypedMessageListener) => void;
	/** Subscribes to envelope messages of a given `type`. Returns an unsubscribe function. */
	on: <TPayload = unknown>(type: string, handler: IFrameTypedMessageListener<TPayload>) => () => void;
	/** Force-reloads the current `src`. */
	reload: () => void;
	/** Posts a raw value to the iframe (no envelope). */
	send: (data: unknown, targetOrigin?: string) => void;
	/** Posts a `{ type, payload }` envelope to the iframe. */
	sendMessage: (type: string, payload?: unknown, targetOrigin?: string) => void;
}
```

## Examples (from Storybook stories)

### Components/IFrame  (`IFrame.stories.ts`)

Shared setup:

```ts
import { expect, waitFor, within } from 'storybook/test';

import IFrame from './IFrame.svelte';

import IFramePreview from './IFramePreview.svelte';
```

Helper component `./IFramePreview.svelte`:

```svelte
<script lang="ts">
	import IFrame from "./IFrame.svelte";
	import type { IFrameRef } from "./types";

	// Self-contained embedded document (via srcdoc) so the story has no external network
	// dependency. On load it sends the ready handshake, then echoes any "ping" it receives
	// back as a "pong" - demonstrating the bidirectional messaging bridge end to end.
	// The script tags are built via concatenation so this file never contains a literal
	// "<script" substring, which would otherwise be parsed as this file's own script block.
	const scriptOpenTag = "<" + "script>";
	const scriptCloseTag = "<" + "/script>";
	const embeddedDocument = `<!doctype html>
<html>
	<body style="font-family: sans-serif; padding: 1rem;">
		<p>I'm the embedded document.</p>
		<p id="log"></p>
		${scriptOpenTag}
			const log = document.getElementById("log");
			window.parent.postMessage({ type: "svelix:iframe-ready" }, "*");
			window.addEventListener("message", (event) => {
				if (event.data?.type === "ping") {
					log.textContent = "received ping: " + JSON.stringify(event.data.payload);
					window.parent.postMessage({ type: "pong", payload: event.data.payload }, "*");
				}
			});
		${scriptCloseTag}
	</body>
</html>`;

	let iframeRef: IFrameRef | undefined = $state();
	let status = $state("waiting for iframe to load...");
	let lastPong = $state<string | undefined>(undefined);

	function handleReady() {
		status = "iframe ready - sending ping";
		iframeRef?.sendMessage("ping", { at: Date.now() });
	}

	$effect(() => {
		return iframeRef?.on<{ at: number }>("pong", (payload) => {
			lastPong = JSON.stringify(payload);
			status = "received pong from iframe";
		});
	});
</script>

<div class="space-y-4 p-4">
	<p class="text-sm text-surface-500" data-testid="iframe-status">{status}</p>
	{#if lastPong}
		<p class="text-sm" data-testid="iframe-pong">pong payload: {lastPong}</p>
	{/if}

	<IFrame
		bind:this={iframeRef}
		srcdoc={embeddedDocument}
		title="IFrame messaging demo"
		width="100%"
		height={160}
		sandbox={["allow-scripts"]}
		onReady={handleReady}
	/>
</div>
```

#### Basic (`Basic`)

```ts
export const Basic: Story = {
	args: {
		src: 'about:blank',
		title: 'Basic iframe',
		width: '100%',
		height: 200
	}
};
```

#### Messaging (`Messaging`)

```ts
export const Messaging: Story = {
	render: () => ({
		Component: IFramePreview
	}),
};
```

---

# Icons

Import: `import { ... } from '@warky/svelix/Icons'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as IconAdd } from './IconAdd.svelte';
export { default as IconAlertCircle } from './IconAlertCircle.svelte';
export { default as IconAlertTriangle } from './IconAlertTriangle.svelte';
export { default as IconCamera } from './IconCamera.svelte';
export { default as IconClose } from './IconClose.svelte';
export { default as IconEdit } from './IconEdit.svelte';
export { default as IconEye } from './IconEye.svelte';
export { default as IconFilter } from './IconFilter.svelte';
export { default as IconSearch } from './IconSearch.svelte';
export { default as IconSort } from './IconSort.svelte';
export { default as IconTrash } from './IconTrash.svelte';
export { default as IconLog } from './IconLog.svelte';
export { default as IconSessions } from './IconSessions.svelte';
export { default as IconSend } from './IconSend.svelte';
export { default as IconPaperclip } from './IconPaperclip.svelte';
export { default as IconStop } from './IconStop.svelte';
export { default as IconSettings } from './IconSettings.svelte';
export { default as IconTool } from './IconTool.svelte';
export { default as IconModel } from './IconModel.svelte';
export { default as IconDownload } from './IconDownload.svelte';
export { default as IconFile } from './IconFile.svelte';
export { default as IconMenu } from './IconMenu.svelte';
export * from './strings';
export * from './menuIcons';
```

## Props

### IconAdd.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconAlertCircle.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconAlertTriangle.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconCamera.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconClose.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconDownload.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconEdit.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconEye.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconFile.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconFilter.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconLog.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconMenu.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconModel.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconPaperclip.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconSearch.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconSend.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconSessions.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconSettings.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconSort.svelte

```ts
interface Props { direction: 'asc' | 'desc'; size?: number; class?: string; }
```

### IconStop.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconTool.svelte

```ts
interface Props { size?: number; class?: string; }
```

### IconTrash.svelte

```ts
interface Props { size?: number; class?: string; }
```

---

# LLMChat

Import: `import { ... } from '@warky/svelix/LLMChat'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as LLMChat } from './components/LLMChat.svelte';
export { default as LLMChatBubble } from './components/ChatBubble.svelte';
export { default as McpSettings } from './components/McpSettings.svelte';
export { default as ModelSettings } from './components/ModelSettings.svelte';

export * from './auth/index';
export * from './clients/index';
export * from './storage/index';
export * from './tools/index';
export { canDownloadAttachment, downloadAttachment, fileToAttachment, isMarkdownAttachment } from './utils/attachments';
export { assistantMessage, rowsToMessages, userContent } from './utils/messages';

export type * from './types';
export { LLM_CHAT_DEFAULTS, MODEL_SETTINGS_DEFAULTS } from './llmChatLayout';
export type { LLMChatLayoutConfig, ModelSettingsLayoutConfig } from './llmChatLayout';
```

## Types (auth/types.ts)

```ts
/** How requests to an MCP server are authenticated. Custom `headers` on the server config are always sent too. */
export type McpAuthConfig =
  | { type: 'none' }
  | { type: 'bearer'; token: string }
  | { type: 'basic'; username: string; password: string }
  /** API key style: any header name / value, e.g. `X-API-Key` */
  | { type: 'header'; name: string; value: string }
  | McpOAuthConfig;

export interface McpOAuthConfig {
  type: 'oauth';
  /** Pre-registered client. Without it a client is registered dynamically (RFC 7591). */
  clientId?: string;
  clientSecret?: string;
  scope?: string;
  /** Overrides for servers that do not publish OAuth metadata */
  authorizationEndpoint?: string;
  tokenEndpoint?: string;
  registrationEndpoint?: string;
}

export interface McpOAuthOptions {
  /** Where tokens and registered clients are kept (default: localStorage, falling back to memory) */
  store?: McpAuthStore;
  /** Must be a page of your app that calls `completeMcpOAuthCallback()` (default: `<origin>/oauth/callback`) */
  redirectUri?: string;
  /** window.open features for the login popup */
  popupFeatures?: string;
  /** How long to wait for the user to finish logging in (default 5 minutes) */
  timeoutMs?: number;
  fetch?: typeof fetch;
}

/** Key / value storage for OAuth state. Values are JSON strings. */
export interface McpAuthStore {
  get(key: string): string | null | Promise<string | null>;
  set(key: string, value: string): void | Promise<void>;
  delete(key: string): void | Promise<void>;
}

export interface OAuthTokens {
  accessToken: string;
  refreshToken?: string;
  /** epoch ms */
  expiresAt?: number;
  tokenType: string;
  scope?: string;
}

export interface OAuthClientInfo {
  clientId: string;
  clientSecret?: string;
}

export interface OAuthServerMetadata {
  authorization_endpoint: string;
  token_endpoint: string;
  registration_endpoint?: string;
  token_endpoint_auth_methods_supported?: string[];
}

export type McpAuthErrorCode =
  | 'popup_blocked'
  | 'popup_closed'
  | 'timeout'
  | 'denied'
  | 'state_mismatch'
  | 'discovery'
  | 'registration'
  | 'token';

export class McpAuthError extends Error {
  constructor(
    public readonly code: McpAuthErrorCode,
    message: string
  ) {
    super(message);
    this.name = 'McpAuthError';
  }
}
```

## Types (storage/types.ts)

```ts
import type { ChatRow, McpServerConfig } from '../types';

export interface ChatSessionMeta {
  id: string;
  title: string;
  createdAt: number;
  updatedAt: number;
  messageCount: number;
}

export interface ChatSession extends ChatSessionMeta {
  rows: ChatRow[];
}

export type ChatSessionStoreKind = 'indexeddb' | 'localstorage' | 'memory' | 'keyvalue' | 'callback';

/** Persistence for chat sessions. `list` returns metadata only, newest first. */
export interface ChatSessionStore {
  readonly kind: ChatSessionStoreKind;
  list(): Promise<ChatSessionMeta[]>;
  get(id: string): Promise<ChatSession | undefined>;
  save(session: ChatSession): Promise<void>;
  delete(id: string): Promise<void>;
  clear(): Promise<void>;
}

export interface ChatSessionStoreOptions {
  /** Names the database / key prefix, so several chats in one app stay separate (default `svelix-llmchat`) */
  namespace?: string;
}

/** Plain JSON values by key. Used by the built-in local settings / server stores. */
export interface KeyValueStorage {
  /** `undefined` when the key does not exist */
  load(key: string): Promise<unknown | undefined>;
  save(key: string, value: unknown): Promise<void>;
  remove(key: string): Promise<void>;
}

/** Model options chosen in the settings dialog. */
export interface ChatSettings {
  model?: string;
  systemPrompt?: string;
  temperature?: number;
  maxTokens?: number;
  effort?: 'low' | 'medium' | 'high';
}

export interface ChatSettingsStore {
  readonly kind: ChatSessionStoreKind;
  /** `undefined` when nothing is saved yet */
  load(): Promise<ChatSettings | undefined>;
  save(settings: ChatSettings): Promise<void>;
  clear(): Promise<void>;
}

/** Persistence for the configured MCP servers (headers included, as given). */
export interface McpServerStore {
  readonly kind: ChatSessionStoreKind;
  /** `undefined` when nothing is saved yet */
  load(): Promise<McpServerConfig[] | undefined>;
  save(servers: McpServerConfig[]): Promise<void>;
  clear(): Promise<void>;
}
```

## Types (tools/types.ts)

```ts
import type { LLMClient, LLMMessage, LLMToolCall, LLMUsage } from '../clients/llmClient';
import type { ToolRegistry } from './registry';

export interface ToolDefinition {
  name: string;
  description?: string;
  /** JSON schema of the arguments */
  inputSchema: Record<string, unknown>;
}

/** Who is calling a tool; passed to providers and local handlers. */
export interface ToolCallContext {
  /** Id of the model's tool call */
  toolCallId?: string;
  /** Tool loop round (0-based) */
  round?: number;
}

export interface ToolResult {
  text: string;
  isError: boolean;
}

/** A source of tools the model can call: an MCP server, local functions, anything. */
export interface ToolProvider {
  id: string;
  listTools(): Promise<ToolDefinition[]>;
  callTool(
    name: string,
    args: Record<string, unknown>,
    signal?: AbortSignal,
    ctx?: ToolCallContext
  ): Promise<ToolResult>;
  close?(): void;
}

export interface LocalToolParameter {
  name: string;
  type: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object';
  /** What the parameter is, for the model to understand */
  description?: string;
  /** Default false */
  required?: boolean;
  /** Allowed values */
  enum?: (string | number)[];
  /** Element type when type is 'array' (default 'string') */
  items?: 'string' | 'number' | 'integer' | 'boolean' | 'object';
}

export interface LocalTool extends Omit<ToolDefinition, 'inputSchema'> {
  /** Unique name, letters, numbers, `_` and `-` only */
  name: string;
  /** Full JSON schema of the arguments; takes precedence over `parameters` */
  inputSchema?: Record<string, unknown>;
  /** Shorthand for a flat object schema; used when `inputSchema` is not set */
  parameters?: LocalToolParameter[];
  /** Abort the call (the model gets an error result) after this many ms. Overrides the provider default. */
  timeoutMs?: number;
  /**
   * Return a string, or any JSON-serialisable value. Throw to report an error to the model.
   * `signal` aborts on user stop or timeout; the call result is dropped as soon as it aborts, even if the handler ignores it.
   */
  handler: (args: Record<string, any>, signal?: AbortSignal, ctx?: ToolCallContext) => unknown | Promise<unknown>; // eslint-disable-line @typescript-eslint/no-explicit-any
}

export interface ExecutedToolCall extends LLMToolCall {
  result: string;
  error: boolean;
  durationMs: number;
}

export interface ToolLoopOptions {
  client: LLMClient;
  /** Request fields; `messages`/`tools` are managed by the loop */
  request: { model: string; tool_choice?: unknown; [key: string]: unknown };
  messages: LLMMessage[];
  registry?: ToolRegistry;
  /** Max tool round trips (default 8) */
  maxRounds?: number;
  /** Stream replies (default true) */
  stream?: boolean;
  signal?: AbortSignal;
  onRoundStart?: (round: number) => void;
  onDelta?: (accumulated: string, round: number) => void;
  /** Streamed model reasoning, when the model sends any */
  onReasoningDelta?: (accumulated: string, round: number) => void;
  /** The model finished a reply; `toolCalls` are about to run (empty when it is the final answer) */
  onAssistant?: (
    content: string,
    toolCalls: LLMToolCall[],
    round: number,
    info: { finishReason?: string; usage?: LLMUsage; reasoning?: string }
  ) => void;
  /** A tool is about to run */
  onToolCall?: (call: LLMToolCall, round: number) => void;
  onToolResult?: (call: ExecutedToolCall, round: number) => void;
}

export interface ToolLoopResult {
  /** Input messages plus every assistant / tool message produced */
  messages: LLMMessage[];
  /** Final assistant text */
  content: string;
  rounds: number;
  /** True when maxRounds was hit while the model still wanted tools */
  truncated: boolean;
  usage?: LLMUsage;
}
```

## Types (types.ts)

```ts
import type { McpAuthConfig, McpOAuthOptions } from './auth/types';
import type { ChatSessionStore, ChatSettingsStore, McpServerStore } from './storage/types';
import type { LocalTool, ToolProvider } from './tools/types';

export type McpTransport = 'streamable-http' | 'sse' | 'http';

export interface McpServerConfig {
  id: string;
  name: string;
  url: string;
  /** streamable-http: MCP streamable HTTP (JSON or SSE replies). sse: legacy HTTP+SSE. http: plain JSON-RPC POST. */
  transport: McpTransport;
  /** Custom headers sent with every request */
  headers?: Record<string, string>;
  /** Authentication (bearer, basic, API key header, OAuth). Applied after `headers`. */
  auth?: McpAuthConfig;
  enabled?: boolean;
}

export interface McpTool {
  serverId: string;
  name: string;
  description?: string;
  inputSchema: Record<string, unknown>;
}

export interface McpResource {
  uri: string;
  name: string;
  description?: string;
  mimeType?: string;
}

export interface McpResourceTemplate {
  uriTemplate: string;
  name: string;
  description?: string;
  mimeType?: string;
}

export interface McpResourceContent {
  uri: string;
  mimeType?: string;
  text?: string;
  /** base64 */
  blob?: string;
}

export interface McpPrompt {
  name: string;
  description?: string;
  arguments?: { name: string; description?: string; required?: boolean }[];
}

export interface McpPromptMessage {
  role: 'user' | 'assistant';
  content: { type: string; text?: string; [key: string]: unknown };
}

export interface ChatAttachment {
  id: string;
  name: string;
  mime: string;
  size: number;
  /** data: URL (images / binary documents) */
  dataUrl?: string;
  /** extracted text (text-like documents) */
  text?: string;
}

export interface ToolCall {
  id: string;
  name: string;
  arguments: string;
  result?: string;
  error?: boolean;
}

export type ChatRow = {
  id: string;
  role: 'user' | 'assistant';
  content: string;
  /** Model reasoning shown above the answer (assistant rows, when `thinking` is enabled) */
  reasoning?: string;
  attachments?: ChatAttachment[];
  toolCalls?: ToolCall[];
  pending?: boolean;
  error?: string;
};

export interface LLMChatProps {
  /** Base URL of an OpenAI compatible API, e.g. https://api.openai.com/v1 */
  url: string;
  apiKey?: string;
  /** Selected model, bindable; changed by the model picker */
  model?: string;
  /** System message sent with every request */
  systemPrompt?: string;
  /** Greeting shown as the first assistant message and sent to the model as context; survives "clear chat" */
  startPrompt?: string;
  /** Extra headers sent to the OpenAI endpoint */
  headers?: Record<string, string>;
  mcpServers?: McpServerConfig[];
  onMcpServersChange?: (servers: McpServerConfig[]) => void;
  /**
   * Show the model's reasoning live in a collapsible block above the answer.
   * `true` only displays what the model sends (`reasoning_content` / `reasoning` fields and <think> tags).
   * An object also asks the model to think: `effort` is sent as `reasoning_effort`,
   * `budgetTokens` as `thinking: { type: 'enabled', budget_tokens }` (Anthropic via LiteLLM).
   */
  thinking?: boolean | ThinkingOptions;
  /** Save conversations (IndexedDB, falling back to localStorage, then memory). Default true. */
  persist?: boolean;
  /** Custom session store, e.g. from `createChatSessionStore()` or your own backend */
  storage?: ChatSessionStore;
  /** Saves model, system prompt, temperature, max tokens and effort. Not saved when unset. */
  settingsStorage?: ChatSettingsStore;
  /** Saves the MCP server list (headers included). Not saved when unset. */
  serversStorage?: McpServerStore;
  /** Separates the saved chats of several LLMChats in one app (default `svelix-llmchat`) */
  storageNamespace?: string;
  /** Current chat session. Opened on start if set (else the most recent), updated as you switch, bindable */
  sessionId?: string;
  /** Show the chat list on the left, bindable */
  showSessions?: boolean;
  /** Hide the chat list toggle button */
  hideSessionsToggle?: boolean;
  /** Hide the model picker (filled from GET /models) */
  hideModelPicker?: boolean;
  /** Show the activity log (requests, tool calls, results, errors), bindable */
  showLog?: boolean;
  /** Hide the log toggle button */
  hideLogToggle?: boolean;
  /** Max log entries kept (default 500) */
  maxLogEntries?: number;
  /** Called for every log entry */
  onLog?: (entry: LogEntry) => void;
  /** Hide the MCP settings button */
  hideSettings?: boolean;
  height?: number | string;
  /** Max tool call round trips per user message */
  maxToolRounds?: number;
  /** Max attachment size in bytes (default 20MB) */
  maxFileSize?: number;
  /**
   * fetch used for MCP calls. Pass a CORS-free fetch (e.g. Tauri's http plugin) when the MCP
   * server does not expose Mcp-Session-Id via Access-Control-Expose-Headers.
   */
  mcpFetch?: typeof fetch;
  /** OAuth settings shared by all MCP servers: token store, redirect URI, popup features */
  mcpOAuth?: McpOAuthOptions;
  /** Extra tool sources (e.g. LocalToolProvider) offered to the model alongside the MCP servers */
  toolProviders?: ToolProvider[];
  /** Functions run in the browser when the model calls them, independent of MCP */
  localTools?: LocalTool[];
  /** Default timeout (ms) for local tool calls; a tool's own `timeoutMs` wins. No timeout when unset. */
  localToolTimeout?: number;
}

export type LogKind = 'request' | 'response' | 'tool-call' | 'tool-result' | 'error' | 'info';

export interface LogEntry {
  id: string;
  time: number;
  kind: LogKind;
  title: string;
  /** Arguments, result, error text, ... shown when the entry is expanded */
  detail?: string;
  error?: boolean;
  durationMs?: number;
}

export interface ThinkingOptions {
  effort?: 'low' | 'medium' | 'high';
  budgetTokens?: number;
}
```

## Props

### components/ChatBubble.svelte

```ts
interface Props {
    row: ChatRow;
  }
```

### components/McpSettings.svelte

```ts
interface Props {
    open?: boolean;
    servers: McpServerConfig[];
    onChange: (servers: McpServerConfig[]) => void;
    /** Login state per OAuth server id */
    oauthStatus?: Record<string, boolean>;
    onOAuthConnect?: (serverId: string) => void;
    onOAuthDisconnect?: (serverId: string) => void;
  }
```

### components/ModelSettings.svelte

```ts
interface Props {
    open?: boolean;
    model: string;
    /** Models from GET /models; empty means the model is typed in */
    modelIds?: string[];
    systemPrompt?: string;
    temperature?: number;
    maxTokens?: number;
    effort?: 'low' | 'medium' | 'high';
    disabled?: boolean;
  }
```

## Examples (from Storybook stories)

### Components/LLMChat  (`components/LLMChat.stories.ts`)

Shared setup:

```ts
import LLMChat from "./LLMChat.svelte";
```

#### Default (`Default`)

```ts
export const Default: Story = {
  args: {
    url: "http://localhost:11434/v1",
    model: "ornith-1.5:9b",
    height: "100vh",
    systemPrompt: "Your name is Oneil from stargate. Great the user.",
    startPrompt: "",
    persist: true,
    storageNamespace: "test"
  },
};
```

### Components/LLMChat/Storage callbacks  (`components/LLMChatStorage.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, waitFor, within } from 'storybook/test';

import { createCallbackStorage } from '../storage';

import type { ChatSession, ChatSettings } from '../storage';

import type { McpServerConfig } from '../types';

import LLMChat from './LLMChat.svelte';

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const body = () => within(document.body);

const savedSession = (): ChatSession => ({
  id: 'saved-1',
  title: 'Saved chat',
  createdAt: 1,
  updatedAt: 2,
  messageCount: 1,
  rows: [{ id: 'r1', role: 'user', content: 'restored message' }],
});

const savedServer = (): McpServerConfig => ({
  id: 'srv-1',
  name: 'Stored server',
  url: 'https://example.test/mcp',
  transport: 'http',
  enabled: false,
});

/** A fake server behind async callbacks (with a delay), recording every call. */
function makeBackend({ failLoads = false } = {}) {
  const sessions = new Map<string, ChatSession>([['saved-1', savedSession()]]);
  const saves = { settings: [] as ChatSettings[], servers: [] as McpServerConfig[][], sessions: [] as ChatSession[] };
  const fail = async (what: string) => {
    await wait(10);
    throw new Error(`${what} offline`);
  };
  const storage = createCallbackStorage({
    sessions: {
      list: async () => (await wait(20), [...sessions.values()].map(({ rows: _rows, ...meta }) => meta)),
      get: async (id) => (await wait(20), sessions.get(id)),
      save: async (session) => {
        await wait(20);
        saves.sessions.push(structuredClone(session));
        sessions.set(session.id, structuredClone(session));
      },
      delete: async (id) => void (await wait(20), sessions.delete(id)),
    },
    settings: {
      load: async () => (failLoads ? fail('settings') : (await wait(30), { model: 'stored-model', temperature: 0.3 })),
      save: async (settings) => void (await wait(10), saves.settings.push(settings)),
    },
    servers: {
      load: async () => (failLoads ? fail('servers') : (await wait(30), [savedServer()])),
      save: async (servers) => void (await wait(10), saves.servers.push(servers)),
    },
  });
  return { storage, saves, sessions };
}

/** Stubs the OpenAI-compatible endpoint: GET /models and a streamed /chat/completions reply. */
function stubLlm() {
  const realFetch = globalThis.fetch;
  const chunk = (delta: object, finish: string | null = null) =>
    `data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`;
  globalThis.fetch = async (input, init) => {
    const url = String(input instanceof Request ? input.url : input);
    if (url.endsWith('/models')) {
      return new Response(JSON.stringify({ data: [{ id: 'stored-model' }] }), { headers: { 'Content-Type': 'application/json' } });
    }
    if (url.endsWith('/chat/completions')) {
      return new Response(chunk({ content: 'po' }) + chunk({ content: 'ng' }) + chunk({}, 'stop') + 'data: [DONE]\n\n', {
        headers: { 'Content-Type': 'text/event-stream' },
      });
    }
    return realFetch(input, init);
  };
  return () => void (globalThis.fetch = realFetch);
}

const common = { url: 'http://llm.test/v1', model: 'default-model', height: '100vh', hideLogToggle: false };

const restoring = makeBackend();

const settingsSaving = makeBackend();

const serversSaving = makeBackend();

const sending = makeBackend();

const failing = makeBackend({ failLoads: true });
```

#### Restores Everything From Async Callbacks (`RestoresEverythingFromAsyncCallbacks`)

```ts
export const RestoresEverythingFromAsyncCallbacks: Story = {
  args: { ...common, showSessions: true, storage: restoring.storage.sessions, settingsStorage: restoring.storage.settings, serversStorage: restoring.storage.servers },
  beforeEach: stubLlm,
};
```

#### Saves Settings After Change (`SavesSettingsAfterChange`)

```ts
export const SavesSettingsAfterChange: Story = {
  args: { ...common, persist: false, settingsStorage: settingsSaving.storage.settings },
  beforeEach: stubLlm,
};
```

#### Saves Servers When Changed (`SavesServersWhenChanged`)

```ts
export const SavesServersWhenChanged: Story = {
  args: { ...common, persist: false, serversStorage: serversSaving.storage.servers },
  beforeEach: stubLlm,
};
```

#### Saves The Session After AReply (`SavesTheSessionAfterAReply`)

```ts
export const SavesTheSessionAfterAReply: Story = {
  args: { ...common, storage: sending.storage.sessions, sessionId: undefined },
  beforeEach: stubLlm,
};
```

#### Storage Failure Is Logged And Chat Keeps Working (`StorageFailureIsLoggedAndChatKeepsWorking`)

```ts
export const StorageFailureIsLoggedAndChatKeepsWorking: Story = {
  args: { ...common, showLog: true, persist: false, settingsStorage: failing.storage.settings, serversStorage: failing.storage.servers },
  beforeEach: stubLlm,
};
```

---

# NavigationProgress

Import: `import { ... } from '@warky/svelix/NavigationProgress'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as NavigationProgress } from "./NavigationProgress.svelte";
export { NAVIGATION_PROGRESS_DEFAULTS } from "./navigationProgressStyles.js";
export { clampProgress, getStep } from "./nprogressSteps.js";
export { createNProgress, NProgressStore, nprogress } from "./nprogressStore.svelte.js";
export { useNavigationProgress } from "./useNavigationProgress.js";
export type { NavigationHooks } from "./useNavigationProgress.js";
export type * from "./types.js";
```

## Summary

NavigationProgress (port of Mantine nprogress). Render once near the root and drive it with the `nprogress` store (`start/stop/set/increment/decrement/reset/complete`) or `createNProgress()` + `store` prop. `useNavigationProgress` wires SvelteKit navigation. Defaults via `SvelixLayout` key `navigationProgress`.

## Types (types.ts)

```ts
import type { HTMLAttributes } from "svelte/elements";
import type { SvelixColor } from "../../utils/scale.js";
import type { NProgressStore } from "./nprogressStore.svelte.js";

/** `SvelixLayout` key `navigationProgress`. */
export interface NavigationProgressLayoutConfig {
	/** Auto-increment interval in ms. @default 500 */
	stepInterval?: number;
	/** Progress (0-100) the bar starts from. @default 0 */
	initialProgress?: number;
	/** Skeleton palette name or CSS colour. @default "primary" */
	color?: SvelixColor;
	/** Bar height in px. @default 3 */
	size?: number;
	/** @default 9999 */
	zIndex?: number;
	/** Render through `Portal`. @default true */
	withinPortal?: boolean;
}

export interface NavigationProgressProps
	extends NavigationProgressLayoutConfig,
		Omit<HTMLAttributes<HTMLDivElement>, "children" | "class" | "style" | "color" | "role"> {
	/** `aria-label` of the progressbar. @default "Navigation progress" */
	progressLabel?: string;
	/** Store to render. @default the global `nprogress` */
	store?: NProgressStore;
	class?: string;
	style?: string;
}
```

## Examples (from Storybook stories)

### Components/NavigationProgress  (`NavigationProgress.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import { nprogress } from './nprogressStore.svelte.js';

import NavigationProgressDemo from './NavigationProgressDemo.fixtures.svelte';
```

Helper component `./NavigationProgressDemo.fixtures.svelte`:

```svelte
<script lang="ts">
	import NavigationProgress from "./NavigationProgress.svelte";
	import { createNProgress, nprogress } from "./nprogressStore.svelte.js";
	import type { NavigationProgressProps } from "./types.js";

	export interface Props extends Omit<NavigationProgressProps, "store"> {
		demo?: "basic" | "isolated";
	}

	let { demo = "basic", ...props }: Props = $props();
	const local = createNProgress();
	const store = $derived(demo === "isolated" ? local : nprogress);
	const actions = ["start", "stop", "increment", "decrement", "reset", "complete"] as const;
</script>

<NavigationProgress {store} withinPortal={false} style="position: absolute" {...props} />

<div class="relative flex flex-wrap gap-2 pt-4">
	{#each actions as action (action)}
		<button type="button" class="btn preset-tonal" data-testid={action} onclick={() => store[action]()}>{action}</button>
	{/each}
	<button type="button" class="btn preset-tonal" data-testid="set50" onclick={() => store.set(50)}>set(50)</button>
</div>
<p class="mt-2 text-xs" data-testid="state">{Math.round(store.progress)}|{store.mounted}|{store.isStarted}</p>
```

#### Default (`Default`)

```ts
export const Default: Story = {
	args: { demo: 'basic' },
};
```

#### Isolated (`Isolated`)

```ts
export const Isolated: Story = { args: { demo: 'isolated', color: 'success', size: 6 } };
```

#### Auto Increment (`AutoIncrement`)

```ts
export const AutoIncrement: Story = {
	args: { demo: 'basic', stepInterval: 100 },
};
```

---

# OverlayStack

Import: `import { ... } from '@warky/svelix/OverlayStack'` (or from `'@warky/svelix'`).

## Exports

```ts
export * from './overlayStack';
```

---

# Paner

Import: `import { ... } from '@warky/svelix/Paner'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as Paner } from "./Paner.svelte";
export { default as PanerPane } from "./PanerPane.svelte";
export { PANER_DEFAULTS, panerFromShared } from "./config.js";
export { computeRequiredWidth, resolveLayout, toLayoutInfo } from "./layoutResolver.js";
export { PanerController } from "./panerEngine.js";
export type { PanerControllerHost, PanerControllerOptions, PanerHandleAria, PanerKeyInput } from "./panerEngine.js";
export { defaultPanerStorage, loadPanerState, panerStorageKey, savePanerState } from "./persistence.js";
export type * from "./types.js";
```

## Summary

Resizable split panes (port of Mantine Splitter) with intelligent layout: auto-stacking by container fit / viewport breakpoint / coarse pointer, auto side-by-side promotion, separate size sets per orientation, nested cascade, persistence, collapse buttons and animation.

## Types (types.ts)

```ts
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";

type DivAttributes = Omit<HTMLAttributes<HTMLDivElement>, "children" | "class" | "style" | "dir">;

/** Pane size. A bare `number` or `%` string is flexible (shares leftover space), `px`/`rem` is fixed. */
export type PanerSize = number | `${number}%` | `${number}px` | `${number}rem`;

/** Keyboard step. A bare `number` or `%` string is a percentage of the container, `px`/`rem` is pixels. */
export type PanerStep = PanerSize;

/**
 * Length used by the per-axis layout rules (`minWidth`, `maxWidth`, `minHeight`, `maxHeight`).
 * Unlike {@link PanerSize}, a bare `number` here means pixels.
 */
export type PanerLength = number | `${number}%` | `${number}px` | `${number}rem`;

export type PanerOrientation = "horizontal" | "vertical";
export type PanerDirection = "ltr" | "rtl";

/** `resizable`: draggable splitter. `flow`: auto-stacked fallback, natural height + scroll, no handles. */
export type PanerLayoutMode = "resizable" | "flow";

/**
 * How a Paner affects nested Paners.
 * - `independent`: no effect, descendants decide from their own container.
 * - `inherit`: descendants stack while this Paner is stacked.
 * - `force-stack`: descendants are always stacked.
 */
export type PanerCascade = "independent" | "inherit" | "force-stack";

/** Why the effective layout was chosen. */
export type PanerLayoutReason = "declared" | "fit" | "breakpoint" | "pointer" | "cascade";

export interface PanerLayout {
	orientation: PanerOrientation;
	mode: PanerLayoutMode;
	reason: PanerLayoutReason;
}

export interface PanerLayoutInfo extends PanerLayout {
	/** Declared orientation was overridden into a top/bottom arrangement. */
	stacked: boolean;
	/** Nested Paners are currently forced to stack by this one (cascade). */
	forceDescendants: boolean;
}

/** Per-orientation overrides for a pane's size rules. */
export interface PanerAxisConfig {
	defaultSize?: PanerSize;
	min?: PanerSize;
	max?: PanerSize;
	collapseThreshold?: PanerSize;
}

/** Panel configuration consumed by the engine (one orientation). */
export interface PanerPanelConfig {
	defaultSize: PanerSize;
	min?: PanerSize;
	max?: PanerSize;
	collapsible?: boolean;
	collapseThreshold?: PanerSize;
}

/** Panel configuration resolved to numeric units (percent or pixels). */
export interface PanerResolvedPanel {
	defaultSize: number;
	min?: number;
	max?: number;
	collapsible?: boolean;
	collapseThreshold?: number;
}

export interface PanerRedistributeInput {
	/** Sizes before applying delta, in resolved units (percent or pixels). */
	sizes: number[];
	panels: PanerResolvedPanel[];
	handleIndex: number;
	/** Requested change in resolved units (positive = grow before-panel). */
	delta: number;
}

export type PanerRedistributeFn = (input: PanerRedistributeInput) => number[];
export type PanerRedistribute = "nearest" | "equal" | PanerRedistributeFn;

/** Conditions that switch a side-by-side layout to stacked. */
export interface PanerStackTriggers {
	/** Stack when the panes' `minWidth`s plus handles do not fit the container. */
	fit?: boolean;
	/** Stack below this viewport width in px. `false` disables. */
	breakpoint?: number | false;
	/** Stack on coarse-pointer (touch) devices. */
	coarsePointer?: boolean;
}

export interface PanerStorage {
	getItem(key: string): string | null;
	setItem(key: string, value: string): void;
}

/** Persisted shape for `autoSaveId`. */
export interface PanerPersistedState {
	horizontal?: PanerSize[];
	vertical?: PanerSize[];
}

/** Values configurable through `SvelixLayout` (key `paner`) and per Paner (props). */
export interface PanerDefaults {
	/** Fit-check width of a pane without its own `minWidth`. */
	minWidth: PanerLength;
	/** Flow-mode min-height of a pane without its own `minHeight`. */
	minHeight: PanerLength;
	/** Dead band in px around fit / breakpoint thresholds to avoid flicker. */
	hysteresis: number;
	stackTriggers: Required<PanerStackTriggers>;
	/** Declared horizontal Paners stack when a trigger fires. */
	autoStack: boolean;
	/** Declared vertical Paners go side-by-side when nothing triggers stacking. */
	autoSideBySide: boolean;
	cascade: PanerCascade;
	lineSize: number | string;
	withHandle: boolean;
	resetOnDoubleClick: boolean;
	step: PanerStep;
	shiftStep: PanerStep;
	withCollapseButton: boolean;
	animate: boolean;
	/** ms */
	animationDuration: number;
	storage?: PanerStorage;
}

export type PanerConfigInput = Partial<Omit<PanerDefaults, "stackTriggers">> & {
	stackTriggers?: PanerStackTriggers;
};

/** `SvelixLayout` key `paner`. */
export type PanerLayoutConfig = PanerConfigInput & {
	/** Unset → document `dir`. */
	dir?: PanerDirection;
};

export interface PanerHandleIconProps {
	orientation: PanerOrientation;
	active: boolean;
}

/** Imperative API, available via `bind:this`. */
export interface PanerApi {
	getSizes(): PanerSize[];
	getCollapsed(): boolean[];
	getActiveHandle(): number;
	getLayout(): PanerLayoutInfo;
	setSizes(sizes: PanerSize[]): void;
	collapse(index: number): void;
	expand(index: number): void;
	toggleCollapse(index: number): void;
	reset(handleIndex: number): void;
}

export interface PanerProps extends PanerConfigInput, DivAttributes {
	/** Declared orientation. @default "horizontal" */
	orientation?: PanerOrientation;
	/** Controlled sizes of the declared orientation (bindable). */
	sizes?: PanerSize[];
	/** Controlled sizes of the flipped orientation (bindable). */
	flippedSizes?: PanerSize[];
	onSizeChange?: (sizes: PanerSize[], orientation: PanerOrientation) => void;
	onResizeStart?: (handleIndex: number) => void;
	onResizeEnd?: (handleIndex: number, sizes: PanerSize[]) => void;
	onCollapseChange?: (paneIndex: number, collapsed: boolean) => void;
	onLayoutChange?: (layout: PanerLayoutInfo) => void;
	redistribute?: PanerRedistribute;
	dir?: PanerDirection;
	/** Disables drag and keyboard resizing. @default true */
	enabled?: boolean;
	/** CSS color for the separator line. */
	handleColor?: string;
	handleIcon?: Snippet<[PanerHandleIconProps]>;
	/** External cascade override (used by the web element). */
	forceStack?: boolean;
	/** Persist sizes per orientation under this key. Persistence is off without it. */
	autoSaveId?: string;
	class?: string;
	paneClass?: string;
	handleClass?: string;
	thumbClass?: string;
	collapseButtonClass?: string;
	style?: string;
	children?: Snippet;
}

export interface PanerPaneProps extends DivAttributes {
	/** Initial size for both orientations (override per axis with `horizontal` / `vertical`). */
	defaultSize: PanerSize;
	/** @default 0 */
	min?: PanerSize;
	max?: PanerSize;
	/** @default false */
	collapsible?: boolean;
	/** Defaults to `min`. */
	collapseThreshold?: PanerSize;
	horizontal?: PanerAxisConfig;
	vertical?: PanerAxisConfig;
	/** Side-by-side: fit-check width and drag constraint. Number = px. */
	minWidth?: PanerLength;
	maxWidth?: PanerLength;
	/** Stacked: flow min-height, and drag constraint when vertical resizable. Number = px. */
	minHeight?: PanerLength;
	maxHeight?: PanerLength;
	class?: string;
	style?: string;
	children?: Snippet;
}
```

## Examples (from Storybook stories)

### Components/Paner  (`Paner.stories.ts`)

Shared setup:

```ts
import { expect, fireEvent, userEvent, waitFor, within } from 'storybook/test';

import PanerDemo from './PanerDemo.fixtures.svelte';

import PanerWorkbench from './PanerWorkbench.fixtures.svelte';

async function setWidth(canvas, value: number) {
	await fireEvent.input(canvas.getByTestId('width'), { target: { value: String(value) } });
}

/** Handles that belong to this Paner root (not to nested Paners). */
function ownHandles(root: HTMLElement): HTMLElement[] {
	return [...root.querySelectorAll<HTMLElement>(':scope > [role="separator"]')];
}

async function drag(handle: HTMLElement, dx: number, dy = 0) {
	const rect = handle.getBoundingClientRect();
	const x = rect.x + rect.width / 2;
	const y = rect.y + rect.height / 2;
	await fireEvent.pointerDown(handle, { button: 0, clientX: x, clientY: y });
	await fireEvent.pointerUp(document, { clientX: x + dx, clientY: y + dy });
}

const now = (handle: HTMLElement) => Number(handle.getAttribute('aria-valuenow'));
```

Helper component `./PanerDemo.fixtures.svelte`:

```svelte
<script lang="ts">
	import Paner from "./Paner.svelte";
	import SvelixLayout from "../SvelixLayout/SvelixLayout.svelte";
	import PanerPane from "./PanerPane.svelte";
	import type { PanerCascade, PanerLayoutInfo, PanerSize } from "./types.js";

	export type PanerDemoName =
		| "basic"
		| "vertical"
		| "multiple"
		| "units"
		| "collapsible"
		| "controlled"
		| "redistribute"
		| "styling"
		| "autoStack"
		| "autoSideBySide"
		| "nested"
		| "persistence"
		| "animated"
		| "config"
		| "rtl"
		| "disabled"
		| "dynamic"
		| "breakpoint"
		| "forceStack";

	export interface Props {
		demo: PanerDemoName;
		cascade?: PanerCascade;
	}

	let { demo, cascade = "inherit" }: Props = $props();

	let width = $state(900);
	let controlled = $state<PanerSize[]>([30, 70]);
	let layoutInfo = $state<PanerLayoutInfo>();
	let log = $state<string[]>([]);
	let api: Paner | undefined = $state();

	function push(message: string): void {
		log = [message, ...log].slice(0, 5);
	}

	let paneIds = $state([1, 2]);
	let nextPaneId = 3;
	let breakpoint = $state<number | false>(false);
	let forced = $state(false);

	const redistributeModes = ["nearest", "equal"] as const;
	const box = "flex h-full items-center justify-center p-3 text-sm";
</script>

{#snippet content(label: string, tone = "bg-surface-100 dark:bg-surface-800")}
	<div class="{box} {tone}">{label}</div>
{/snippet}

{#snippet widthControl()}
	<label class="flex items-center gap-2 text-sm">
		Container width
		<input type="range" min="240" max="1200" bind:value={width} data-testid="width" />
		<span data-testid="width-value">{width}px</span>
	</label>
{/snippet}

{#snippet layoutStatus()}
	<p class="text-xs text-surface-500" data-testid="layout">
		{layoutInfo ? `${layoutInfo.orientation} / ${layoutInfo.mode} / ${layoutInfo.reason}` : ""}
	</p>
{/snippet}

<div class="space-y-3 p-4">
	{#if demo === "basic"}
		<Paner class="h-64 rounded border border-surface-300">
			<PanerPane defaultSize={50}>{@render content("Pane 1")}</PanerPane>
			<PanerPane defaultSize={50}>{@render content("Pane 2")}</PanerPane>
		</Paner>
	{:else if demo === "vertical"}
		<Paner orientation="vertical" class="h-80 rounded border border-surface-300">
			<PanerPane defaultSize={40}>{@render content("Top")}</PanerPane>
			<PanerPane defaultSize={60}>{@render content("Bottom")}</PanerPane>
		</Paner>
	{:else if demo === "multiple"}
		<Paner class="h-64 rounded border border-surface-300">
			<PanerPane defaultSize={25} min={10} max={40}>{@render content("25% (10–40)")}</PanerPane>
			<PanerPane defaultSize={50} min={20}>{@render content("50% (min 20)")}</PanerPane>
			<PanerPane defaultSize={25} min={10}>{@render content("25% (min 10)")}</PanerPane>
		</Paner>
	{:else if demo === "units"}
		<Paner class="h-64 rounded border border-surface-300" onSizeChange={(s) => push(JSON.stringify(s))}>
			<PanerPane defaultSize="240px" min="160px" max="400px">{@render content("240px fixed")}</PanerPane>
			<PanerPane defaultSize={1}>{@render content("flexible")}</PanerPane>
			<PanerPane defaultSize="12rem" min="8rem">{@render content("12rem fixed")}</PanerPane>
		</Paner>
		<pre class="text-xs" data-testid="log">{log.join("\n")}</pre>
	{:else if demo === "collapsible"}
		<div class="flex flex-wrap gap-2">
			<button class="btn preset-tonal" type="button" onclick={() => api?.toggleCollapse(0)}>Toggle left</button>
			<button class="btn preset-tonal" type="button" onclick={() => api?.toggleCollapse(2)}>Toggle right</button>
			<button class="btn preset-tonal" type="button" onclick={() => api?.reset(0)}>Reset handle 1</button>
		</div>
		<Paner
			bind:this={api}
			withCollapseButton
			class="h-64 rounded border border-surface-300"
			onCollapseChange={(i, c) => push(`pane ${i} ${c ? "collapsed" : "expanded"}`)}
		>
			<PanerPane defaultSize={25} min={15} collapsible>{@render content("Collapsible (drag below 15%)")}</PanerPane>
			<PanerPane defaultSize={50}>{@render content("Main")}</PanerPane>
			<PanerPane defaultSize={25} min={15} collapsible collapseThreshold={8}>{@render content("Collapsible (threshold 8%)")}</PanerPane>
		</Paner>
		<pre class="text-xs" data-testid="log">{log.join("\n")}</pre>
	{:else if demo === "controlled"}
		<Paner bind:sizes={controlled} class="h-64 rounded border border-surface-300">
			<PanerPane defaultSize={30}>{@render content(`${Math.round(Number(controlled[0]))}%`)}</PanerPane>
			<PanerPane defaultSize={70}>{@render content(`${Math.round(Number(controlled[1]))}%`)}</PanerPane>
		</Paner>
		<div class="flex gap-2">
			<button class="btn preset-tonal" type="button" onclick={() => (controlled = [20, 80])}>20 / 80</button>
			<button class="btn preset-tonal" type="button" onclick={() => (controlled = [50, 50])}>50 / 50</button>
		</div>
		<p class="text-xs" data-testid="sizes">{JSON.stringify(controlled.map((s) => Math.round(Number(s))))}</p>
	{:else if demo === "redistribute"}
		{#each redistributeModes as mode (mode)}
			<p class="text-sm font-semibold">redistribute="{mode}"</p>
			<Paner redistribute={mode} class="h-40 rounded border border-surface-300">
				<PanerPane defaultSize={25} min={5}>{@render content("A")}</PanerPane>
				<PanerPane defaultSize={25} min={20}>{@render content("B (min 20)")}</PanerPane>
				<PanerPane defaultSize={25} min={5}>{@render content("C")}</PanerPane>
				<PanerPane defaultSize={25} min={5}>{@render content("D")}</PanerPane>
			</Paner>
		{/each}
	{:else if demo === "styling"}
		<Paner lineSize={6} handleColor="var(--color-primary-200)" class="h-40 rounded border border-surface-300">
			<PanerPane defaultSize={50}>{@render content("lineSize 6, handleColor")}</PanerPane>
			<PanerPane defaultSize={50}>{@render content("Pane")}</PanerPane>
		</Paner>
		<Paner withHandle={false} class="h-40 rounded border border-surface-300">
			<PanerPane defaultSize={50}>{@render content("withHandle=false")}</PanerPane>
			<PanerPane defaultSize={50}>{@render content("Pane")}</PanerPane>
		</Paner>
		<Paner class="h-40 rounded border border-surface-300" thumbClass="!w-5 !h-5">
			{#snippet handleIcon({ active })}
				<span class="text-xs leading-none">{active ? "●" : "↔"}</span>
			{/snippet}
			<PanerPane defaultSize={50}>{@render content("custom handleIcon")}</PanerPane>
			<PanerPane defaultSize={50}>{@render content("Pane")}</PanerPane>
		</Paner>
	{:else if demo === "autoStack"}
		{@render widthControl()}
		{@render layoutStatus()}
		<div style="width: {width}px" class="max-w-full">
			<Paner autoStack class="h-72 rounded border border-surface-300" onLayoutChange={(l) => (layoutInfo = l)}>
				<PanerPane defaultSize={30}>{@render content("Sidebar (min 320px)")}</PanerPane>
				<PanerPane defaultSize={70} minWidth={360}>{@render content("Content (minWidth 360)")}</PanerPane>
			</Paner>
		</div>
	{:else if demo === "autoSideBySide"}
		{@render widthControl()}
		{@render layoutStatus()}
		<div style="width: {width}px" class="max-w-full">
			<Paner orientation="vertical" autoSideBySide class="h-72 rounded border border-surface-300" onLayoutChange={(l) => (layoutInfo = l)}>
				<PanerPane defaultSize={50} horizontal={{ defaultSize: 40 }}>{@render content("Top / left")}</PanerPane>
				<PanerPane defaultSize={50} horizontal={{ defaultSize: 60 }}>{@render content("Bottom / right")}</PanerPane>
			</Paner>
		</div>
	{:else if demo === "nested"}
		{@render widthControl()}
		{@render layoutStatus()}
		<div style="width: {width}px" class="max-w-full">
			<Paner autoStack {cascade} minWidth={240} class="h-96 rounded border border-surface-300" onLayoutChange={(l) => (layoutInfo = l)}>
				<PanerPane defaultSize={25}>{@render content("Nav")}</PanerPane>
				<PanerPane defaultSize={75} minWidth={480}>
					<Paner orientation="vertical" class="h-full">
						<PanerPane defaultSize={60}>
							<Paner autoStack minWidth={200} class="h-full">
								<PanerPane defaultSize={50}>{@render content("Editor A", "bg-primary-100 dark:bg-primary-900")}</PanerPane>
								<PanerPane defaultSize={50}>{@render content("Editor B", "bg-primary-100 dark:bg-primary-900")}</PanerPane>
							</Paner>
						</PanerPane>
						<PanerPane defaultSize={40}>{@render content("Console")}</PanerPane>
					</Paner>
				</PanerPane>
			</Paner>
		</div>
	{:else if demo === "persistence"}
		<p class="text-xs text-surface-500">Resize, then reload the story. Sizes persist under localStorage key "svelix-paner:story-demo".</p>
		<Paner autoSaveId="story-demo" class="h-64 rounded border border-surface-300">
			<PanerPane defaultSize={30}>{@render content("Persisted")}</PanerPane>
			<PanerPane defaultSize={70}>{@render content("Persisted")}</PanerPane>
		</Paner>
	{:else if demo === "animated"}
		{@render widthControl()}
		<div class="flex gap-2">
			<button class="btn preset-tonal" type="button" onclick={() => api?.toggleCollapse(0)}>Toggle left</button>
		</div>
		<div style="width: {width}px" class="max-w-full">
			<Paner bind:this={api} animate autoStack withCollapseButton class="h-64 rounded border border-surface-300">
				<PanerPane defaultSize={30} min={15} collapsible>{@render content("Animated collapse")}</PanerPane>
				<PanerPane defaultSize={70}>{@render content("Animated flips")}</PanerPane>
			</Paner>
		</div>
	{:else if demo === "config"}
		{@render widthControl()}
		<p class="text-xs text-surface-500">SvelixLayout paner: autoStack, minWidth 200, breakpoint off, collapse buttons. The inner Paner overrides minWidth via props.</p>
		<div style="width: {width}px" class="max-w-full space-y-3">
			<SvelixLayout paner={{ autoStack: true, minWidth: 200, withCollapseButton: true, stackTriggers: { breakpoint: false } }}>
				<Paner class="h-40 rounded border border-surface-300">
					<PanerPane defaultSize={50} collapsible min={10}>{@render content("Inherits config")}</PanerPane>
					<PanerPane defaultSize={50}>{@render content("Pane")}</PanerPane>
				</Paner>
				<Paner minWidth={400} class="h-40 rounded border border-surface-300">
					<PanerPane defaultSize={50}>{@render content("minWidth 400 via prop")}</PanerPane>
					<PanerPane defaultSize={50}>{@render content("Pane")}</PanerPane>
				</Paner>
			</SvelixLayout>
		</div>
	{:else if demo === "rtl"}
		<div dir="rtl">
			<Paner dir="rtl" class="h-40 rounded border border-surface-300">
				<PanerPane defaultSize={50}>{@render content("Start (right)")}</PanerPane>
				<PanerPane defaultSize={50}>{@render content("End (left)")}</PanerPane>
			</Paner>
		</div>
	{:else if demo === "disabled"}
		<Paner enabled={false} class="h-40 rounded border border-surface-300" onSizeChange={(s) => push(JSON.stringify(s))}>
			<PanerPane defaultSize={50}>{@render content("enabled=false")}</PanerPane>
			<PanerPane defaultSize={50}>{@render content("Pane")}</PanerPane>
		</Paner>
		<pre class="text-xs" data-testid="log">{log.join("\n")}</pre>
	{:else if demo === "dynamic"}
		<div class="flex gap-2">
			<button class="btn preset-tonal" type="button" onclick={() => (paneIds = [...paneIds, nextPaneId++])}>Add pane</button>
			<button class="btn preset-tonal" type="button" onclick={() => (paneIds = paneIds.slice(0, -1))}>Remove last</button>
			<button class="btn preset-tonal" type="button" onclick={() => (paneIds = paneIds.slice(1))}>Remove first</button>
		</div>
		<Paner class="h-40 rounded border border-surface-300">
			{#each paneIds as id (id)}
				<PanerPane defaultSize={100 / paneIds.length}>{@render content(`Pane ${id}`)}</PanerPane>
			{/each}
		</Paner>
	{:else if demo === "breakpoint"}
		{@render layoutStatus()}
		<div class="flex gap-2">
			<button class="btn preset-tonal" type="button" onclick={() => (breakpoint = 100000)}>Breakpoint 100000px</button>
			<button class="btn preset-tonal" type="button" onclick={() => (breakpoint = false)}>Breakpoint off</button>
		</div>
		<Paner
			autoStack
			stackTriggers={{ fit: false, breakpoint }}
			class="h-64 rounded border border-surface-300"
			onLayoutChange={(l) => (layoutInfo = l)}
		>
			<PanerPane defaultSize={50}>{@render content("Pane 1")}</PanerPane>
			<PanerPane defaultSize={50}>{@render content("Pane 2")}</PanerPane>
		</Paner>
	{:else if demo === "forceStack"}
		{@render layoutStatus()}
		<button class="btn preset-tonal" type="button" onclick={() => (forced = !forced)}>Toggle forceStack</button>
		<Paner forceStack={forced} cascade="inherit" class="h-80 rounded border border-surface-300" onLayoutChange={(l) => (layoutInfo = l)}>
			<PanerPane defaultSize={40}>{@render content("Pane 1")}</PanerPane>
			<PanerPane defaultSize={60}>
				<Paner orientation="vertical" class="h-full">
					<PanerPane defaultSize={50}>{@render content("Nested top")}</PanerPane>
					<PanerPane defaultSize={50}>{@render content("Nested bottom")}</PanerPane>
				</Paner>
			</PanerPane>
		</Paner>
	{/if}
</div>
```

Helper component `./PanerWorkbench.fixtures.svelte`:

```svelte
<script lang="ts">
	import Paner from "./Paner.svelte";
	import PanerPane from "./PanerPane.svelte";
	import type { PanerCascade, PanerLayoutInfo, PanerSize, PanerStorage } from "./types.js";

	interface Props {
		cascade?: PanerCascade;
	}

	let { cascade = "inherit" }: Props = $props();

	// In-memory storage so the story persists across remounts without touching localStorage.
	const memory: Record<string, string> = {};
	const storage: PanerStorage = {
		getItem: (key) => memory[key] ?? null,
		setItem: (key, value) => {
			memory[key] = value;
		}
	};

	let width = $state(1000);
	let mountKey = $state(0);
	let outer: Paner | undefined = $state();
	let column: Paner | undefined = $state();
	let outerLayout = $state<PanerLayoutInfo>();
	let editorsLayout = $state<PanerLayoutInfo>();
	let outerSizes = $state<PanerSize[]>([]);
	let log = $state<string[]>([]);

	function push(message: string): void {
		log = [message, ...log].slice(0, 8);
	}

	function describe(info: PanerLayoutInfo | undefined): string {
		return info ? `${info.orientation} / ${info.mode} / ${info.reason}` : "";
	}

	function round(sizes: PanerSize[]): string {
		return JSON.stringify(sizes.map((size) => (typeof size === "number" ? Math.round(size) : size)));
	}

	const paneNames = ["explorer", "main"];
	const columnNames = ["editors", "terminal"];
	const box = "flex h-full min-h-0 flex-col";
</script>

<div class="space-y-3 p-4">
	<div class="flex flex-wrap items-center gap-2 text-sm">
		<label class="flex items-center gap-2">
			Width
			<input type="range" min="320" max="1400" bind:value={width} data-testid="width" />
			<span data-testid="width-value">{width}px</span>
		</label>
		<button class="btn btn-sm preset-tonal" type="button" onclick={() => outer?.toggleCollapse(0)}>Toggle explorer</button>
		<button class="btn btn-sm preset-tonal" type="button" onclick={() => column?.toggleCollapse(1)}>Toggle terminal</button>
		<button class="btn btn-sm preset-tonal" type="button" onclick={() => outer?.reset(0)}>Reset explorer</button>
		<button class="btn btn-sm preset-tonal" type="button" onclick={() => (mountKey += 1)}>Remount</button>
	</div>

	<dl class="grid grid-cols-[auto_1fr] gap-x-3 text-xs text-surface-600 dark:text-surface-400">
		<dt>Outer</dt>
		<dd data-testid="outer-layout">{describe(outerLayout)}</dd>
		<dt>Editors</dt>
		<dd data-testid="editors-layout">{describe(editorsLayout)}</dd>
		<dt>Outer sizes</dt>
		<dd data-testid="outer-sizes">{round(outerSizes)}</dd>
	</dl>

	{#key mountKey}
		<div style="width: {width}px" class="max-w-full">
			<Paner
				bind:this={outer}
				data-testid="outer"
				autoStack
				{cascade}
				withCollapseButton
				autoSaveId="workbench"
				{storage}
				class="h-[28rem] rounded border border-surface-300 dark:border-surface-700"
				onLayoutChange={(info) => {
					outerLayout = info;
					push(`layout ${describe(info)}`);
				}}
				onSizeChange={(sizes) => (outerSizes = sizes)}
				onResizeStart={(handle) => push(`resize start ${handle}`)}
				onResizeEnd={(handle) => push(`resize end ${handle}`)}
				onCollapseChange={(pane, collapsed) => push(`${paneNames[pane]} ${collapsed ? "collapsed" : "expanded"}`)}
			>
				<PanerPane defaultSize="260px" minWidth={200} maxWidth={420} collapsible>
					<nav class="h-full space-y-1 bg-surface-100 p-3 text-sm dark:bg-surface-800" aria-label="Explorer">
						<p class="font-semibold">Explorer</p>
						{#each ["src/", "lib/", "Paner.svelte", "PanerPane.svelte", "types.ts"] as file (file)}
							<p class="truncate">{file}</p>
						{/each}
					</nav>
				</PanerPane>
				<PanerPane defaultSize={1} minWidth={480}>
					<Paner
						bind:this={column}
						data-testid="column"
						orientation="vertical"
						withCollapseButton
						class="h-full"
						onCollapseChange={(pane, collapsed) => push(`${columnNames[pane]} ${collapsed ? "collapsed" : "expanded"}`)}
					>
						<PanerPane defaultSize={70} minHeight={160}>
							<Paner
								data-testid="editors"
								autoStack
								minWidth={240}
								class="h-full"
								onLayoutChange={(info) => (editorsLayout = info)}
							>
								<PanerPane defaultSize={50}>
									<section class="{box} bg-primary-50 p-3 text-sm dark:bg-primary-950" aria-label="Editor">
										<p class="font-semibold">Editor</p>
										<p class="text-xs text-surface-500">Paner.svelte</p>
									</section>
								</PanerPane>
								<PanerPane defaultSize={50}>
									<section class="{box} bg-secondary-50 p-3 text-sm dark:bg-secondary-950" aria-label="Preview">
										<p class="font-semibold">Preview</p>
										<p class="text-xs text-surface-500">Rendered output</p>
									</section>
								</PanerPane>
							</Paner>
						</PanerPane>
						<PanerPane defaultSize={30} collapsible minHeight={120}>
							<section class="{box} bg-surface-900 p-3 font-mono text-xs text-surface-50" aria-label="Terminal">
								<p>$ pnpm test</p>
								<p>✓ Paner</p>
							</section>
						</PanerPane>
					</Paner>
				</PanerPane>
			</Paner>
		</div>
	{/key}

	<pre class="text-xs" data-testid="log">{log.join("\n")}</pre>
</div>
```

#### Basic (`Basic`)

```ts
export const Basic: Story = {
	args: { demo: 'basic' },
};
```

#### Vertical (`Vertical`)

```ts
export const Vertical: Story = { args: { demo: 'vertical' } };
```

#### Multiple Panes (`MultiplePanes`)

```ts
export const MultiplePanes: Story = { args: { demo: 'multiple' } };
```

#### Css Units (`CssUnits`)

```ts
export const CssUnits: Story = { args: { demo: 'units' } };
```

#### Collapsible (`Collapsible`)

```ts
export const Collapsible: Story = {
	args: { demo: 'collapsible' },
};
```

#### Controlled (`Controlled`)

```ts
export const Controlled: Story = {
	args: { demo: 'controlled' },
};
```

#### Redistribute (`Redistribute`)

```ts
export const Redistribute: Story = { args: { demo: 'redistribute' } };
```

#### Styling (`Styling`)

```ts
export const Styling: Story = { args: { demo: 'styling' } };
```

#### Auto Stack (`AutoStack`)

```ts
export const AutoStack: Story = {
	args: { demo: 'autoStack' },
};
```

#### Auto Side By Side (`AutoSideBySide`)

```ts
export const AutoSideBySide: Story = {
	args: { demo: 'autoSideBySide' },
};
```

#### Nested Cascade (`NestedCascade`)

```ts
export const NestedCascade: Story = {
	args: { demo: 'nested', cascade: 'inherit' },
};
```

#### Nested Independent (`NestedIndependent`)

```ts
export const NestedIndependent: Story = { args: { demo: 'nested', cascade: 'independent' } };
```

#### Persistence (`Persistence`)

```ts
export const Persistence: Story = { args: { demo: 'persistence' } };
```

#### Animated (`Animated`)

```ts
export const Animated: Story = { args: { demo: 'animated' } };
```

#### Global Config (`GlobalConfig`)

```ts
export const GlobalConfig: Story = {
	args: { demo: 'config' },
};
```

#### Right To Left (`RightToLeft`)

```ts
export const RightToLeft: Story = {
	args: { demo: 'rtl' },
};
```

#### Disabled (`Disabled`)

```ts
export const Disabled: Story = {
	args: { demo: 'disabled' },
};
```

#### Dynamic Panes (`DynamicPanes`)

```ts
export const DynamicPanes: Story = {
	args: { demo: 'dynamic' },
};
```

#### Breakpoint (`Breakpoint`)

```ts
export const Breakpoint: Story = {
	args: { demo: 'breakpoint' },
};
```

#### Force Stack (`ForceStack`)

```ts
export const ForceStack: Story = {
	args: { demo: 'forceStack' },
};
```

#### Workbench (`Workbench`)

```ts
export const Workbench: Story = {
	render: () => ({ Component: PanerWorkbench, props: {} }),
	parameters: {
		docs: {
			description: {
				story:
					'IDE layout: collapsible px-sized explorer, vertical editor/terminal column, nested auto-stacking editor/preview. Cascade "inherit" stacks everything below the fit width; collapsing the explorer frees its width. Sizes persist (in-memory storage) across Remount.'
			}
		}
	},
};
```

---

# Portal

Import: `import { ... } from '@warky/svelix/Portal'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as Portal } from "./Portal.svelte";
export { PORTAL_DEFAULTS } from "./portalLayout";
export type { PortalLayoutConfig } from "./portalLayout";
```

## Props

### Portal.svelte

```ts
export interface PortalRootProps extends Required<PropsWithChildren> {
    /**
     * If true, the portal functionality is disabled and children are rendered in place.
     *
     * @default false
     */
    disabled?: boolean;
    /**
     * The HTML element to which the portal content will be appended.
     *
     * @default document.body
     */
    target?: HTMLElement;
  }
```

---

# Resolver

Import: `import { ... } from '@warky/svelix/Resolver'` (or from `'@warky/svelix'`).

## Exports

```ts
export { createResolverContext, getResolverContext, setResolverContext, useResolverContext, useResolverRegistry, useResolverSession } from './context.js';
export type { ResolverContextOptions, ResolverContextValue } from './context.js';
export { createResolverRegistry } from './registry.js';
export type { RegistryOptions } from './registry.js';
export { default as ResolveButton } from './ResolveButton.svelte';
export { createResolveBinding } from './resolveBinding.svelte.js';
export type { ResolveBinding, ResolveBindingOptions } from './resolveBinding.svelte.js';
export { default as ResolverProvider } from './ResolverProvider.svelte';
export * from './resolvers/index.js';
export { createResolverSession } from './session.svelte.js';
export type { ResolveCallOptions, ResolverSession, SessionOptions } from './session.svelte.js';
export type * from './types.js';
```

## Types (types.ts)

```ts
/* eslint-disable @typescript-eslint/no-explicit-any */

export interface ResolverContext {
  signal: AbortSignal;
}

/** Any sync/async function can be a resolver. */
export type Resolver<In = any, Out = any> = (input: In, ctx: ResolverContext) => Out | Promise<Out>;

/**
 * Declaration-merge to type custom keys:
 * `declare module '@warky/svelix' { interface ResolverMap { myKey: Resolver<string, Foo> } }`
 */
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ResolverMap {}

export type ResolverKey = Extract<keyof ResolverMap, string> | (string & {});

export type ResolverInput<K extends string> = K extends keyof ResolverMap
  ? ResolverMap[K] extends Resolver<infer I, any>
    ? I
    : unknown
  : unknown;

export type ResolverOutput<K extends string> = K extends keyof ResolverMap
  ? ResolverMap[K] extends Resolver<any, infer O>
    ? Awaited<O>
    : unknown
  : unknown;

export interface AddressRecord {
  city?: string;
  country?: string;
  countryCode?: string;
  currency?: string;
  dialCode?: string;
  ip?: string;
  lat?: number;
  lng?: number;
  postalCode?: string;
  province?: string;
  suburb?: string;
  timeZone?: string;
}

/** Item shape returned by list resolvers (timezones, currencies, countries ...). */
export interface ResolverListItem {
  [key: string]: unknown;
  label: string;
  value: string;
}

export interface LocaleRecord {
  calendar?: string;
  hourCycle?: string;
  language?: string;
  locale?: string;
  numberingSystem?: string;
  region?: string;
  script?: string;
  textDirection?: string;
  timeZone?: string;
  timeZones?: string[];
  weekInfo?: unknown;
}

export interface RegisterOptions {
  /** Built-in resolvers are subject to the provider `enabled` gate; user-supplied ones are not. */
  builtin?: boolean;
}

export interface ResolverRegistry {
  get(key: string): Resolver | undefined;
  has(key: string): boolean;
  keys(): string[];
  register(key: string, resolver: Resolver, options?: RegisterOptions): () => void;
  registerAll(resolvers: Record<string, Resolver>, options?: RegisterOptions): () => void;
  resolve<T = any>(key: string, input?: unknown, options?: { signal?: AbortSignal }): Promise<T>;
  unregister(key: string): void;
}

/** Props shared by every control that can resolve its value. */
export interface ResolveProps {
  /**
   * `true` / `'mount'+'change'`: resolve on mount when empty and on input change.
   * `'mount'`: only on mount when empty. `'change'`: only when the input changes (debounced).
   * User-edited fields are never overwritten by auto resolves.
   */
  auto?: boolean | 'change' | 'mount';
  /** Debounce for `auto` change resolves, ms. Default 400. */
  autoDebounceMs?: number;
  /** Called with the raw resolver result. */
  onresolve?: (result: any) => void;
  /** `"session.path"`: read this control's value from the shared session record (and write user edits back). */
  resolve?: string;
  /** Show a resolve button. A snippet replaces the default icon. */
  resolveButton?: boolean | import('svelte').Snippet;
  /** Resolver key or function. Falls back to the session's default resolver. */
  resolver?: string | Resolver;
  /** Resolver input: a value or a getter (reactive reads are tracked). Default: this control's value. */
  resolveInput?: unknown | (() => unknown);
  /** Tooltip / aria-label of the resolve button. */
  resolveTitle?: string;
}
```

## Props

### ResolveButton.svelte

```ts
export interface Props {
		children?: Snippet;
		disabled?: boolean;
		loading?: boolean;
		onclick: () => void;
		title?: string;
	}
```

### ResolverProvider.svelte

```ts
export interface Props {
		children?: Snippet;
		/** Restrict built-in resolvers to these keys (opt-in). User-supplied `resolvers` are never gated. */
		enabled?: string[];
		/** Register the Intl built-ins (locale, timezones, currencies, countries, languages, calendars, units, regionDefaults). Default true. */
		intl?: boolean;
		/** Your resolvers by key. Same key as a built-in overrides it. */
		resolvers?: Record<string, Resolver>;
		/** Per-session defaults (default resolver / input). */
		sessions?: Record<string, SessionOptions>;
	}
```

---

# Schedule

Import: `import { ... } from '@warky/svelix/Schedule'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as AgendaView } from "./AgendaView.svelte";
export { default as DayView } from "./DayView.svelte";
export { default as MobileMonthView } from "./MobileMonthView.svelte";
export { default as MonthView } from "./MonthView.svelte";
export { default as ResourcesDayView } from "./ResourcesDayView.svelte";
export { default as ResourcesMonthView } from "./ResourcesMonthView.svelte";
export { default as ResourcesSchedule } from "./ResourcesSchedule.svelte";
export { default as ResourcesWeekView } from "./ResourcesWeekView.svelte";
export { default as Schedule } from "./Schedule.svelte";
export { ScheduleHeader } from "./ScheduleHeader.js";
export { default as WeekView } from "./WeekView.svelte";
export { default as YearView } from "./YearView.svelte";
export { getStartOfWeek, isEventsOverlap, SCHEDULE_DEFAULT_LABELS } from "./config.js";
export { expandRecurringEvents } from "./recurrence.js";
export { defaultScheduleStorage, loadScheduleState, saveScheduleState, scheduleStorageKey } from "./persistence.js";
export type * from "./types.js";
```

## Summary

Port of @mantine/schedule: Schedule, Day/Week/Month/Year/MobileMonth/Agenda views and Resources Day/Week/Month/Schedule. Recurring events (RRULE) expanded lazily for the visible range; time grids, agenda and resource lanes are windowed so thousands of events / hundreds of resources stay responsive. Event dates are wall-clock `YYYY-MM-DD HH:mm:ss` strings or Dates.

## Types (types.ts)

```ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Snippet } from "svelte";

/** `YYYY-MM-DD` */
export type DateStringValue = string;
/** `YYYY-MM-DD HH:mm:ss` */
export type DateTimeStringValue = string;
export type ScheduleViewLevel = "day" | "week" | "month" | "year";
export type ResourcesScheduleViewLevel = "day" | "week" | "month";
export type ScheduleEventOverlapMode = "columns" | "cascade";
export type ScheduleEventPayload = Record<PropertyKey, any>;
export type ScheduleId = string | number;

export interface ScheduleRecurrence {
	/** RFC 5545 rule, e.g. `FREQ=WEEKLY;BYDAY=MO,WE` */
	rrule: string;
	exdate?: DateTimeStringValue[];
	/** Defaults to the event `start` */
	dtstart?: DateTimeStringValue;
}

export interface ScheduleEventData<P = ScheduleEventPayload> {
	id: ScheduleId;
	title: string;
	start: Date | DateTimeStringValue;
	end: Date | DateTimeStringValue;
	/** Skeleton palette key (`primary`, `success`, ...), Tailwind color name (`blue`) or any CSS color. Default `primary` */
	color?: string;
	/** Default `light` */
	variant?: "filled" | "light";
	/** `background` = non-interactive block behind regular events. Default `default` */
	display?: "default" | "background";
	payload?: P;
	/** Resources views: lane this event belongs to */
	resourceId?: ScheduleId;
	/** Recurring series */
	recurrence?: ScheduleRecurrence;
	/** Recurring override: id of the parent series */
	recurringEventId?: ScheduleId;
	/** Recurring override: original occurrence start */
	recurrenceId?: DateTimeStringValue;
	/** Set on generated occurrences */
	recurringInstance?: ScheduleRecurringInstance;
}

export interface ScheduleRecurringInstance {
	isRecurringInstance: boolean;
	recurringEventId: ScheduleId;
	recurrenceId: DateTimeStringValue;
	originalStart: DateTimeStringValue;
	originalEnd: DateTimeStringValue;
}

export type ScheduleSingleEventData = Omit<ScheduleEventData, "recurrence" | "recurringEventId" | "recurrenceId">;
export type ScheduleRecurringSeriesEventData = ScheduleEventData & { recurrence: ScheduleRecurrence };
export type ScheduleRecurringOverrideEventData = ScheduleEventData & { recurringEventId: ScheduleId; recurrenceId: DateTimeStringValue };

export interface ScheduleResourceData {
	id: ScheduleId;
	label: string;
}

/** Internal event: wall-clock ms (`Date.UTC` of the local wall-clock fields) */
export interface NormEvent {
	/** Unique per occurrence */
	key: string;
	id: ScheduleId;
	title: string;
	start: number;
	end: number;
	color: string;
	variant: "filled" | "light";
	display: "default" | "background";
	resourceId?: ScheduleId;
	/** Spans >= 24h: shown in all-day sections */
	allDay: boolean;
	/** Public shape handed to callbacks / snippets */
	data: ScheduleEventData;
}

export interface ScheduleLabels {
	day: string;
	week: string;
	month: string;
	year: string;
	today: string;
	previous: string;
	next: string;
	more: string;
	moreLabel?: (hiddenEventsCount: number) => string;
	allDay: string;
	noEvents: string;
	agenda: string;
	resources: string;
}

/** Intl options (wall-clock, UTC) or a function receiving a `YYYY-MM-DD HH:mm:ss` string */
export type ScheduleFormat = Intl.DateTimeFormatOptions | ((value: DateTimeStringValue) => string);

export interface SchedulePlacementRejected {
	action: "drop" | "resize" | "external-drop";
	reason: "overlap" | "rejected";
	event?: ScheduleEventData;
	conflicts: ScheduleEventData[];
	resourceId?: ScheduleId;
	newStart?: DateTimeStringValue;
	newEnd?: DateTimeStringValue;
	dropDateTime?: DateTimeStringValue;
}

export interface ScheduleInteractionProps {
	/** `static` disables every interaction */
	mode?: "default" | "static";
	withEventsDragAndDrop?: boolean;
	onEventDrop?: (a: { eventId: ScheduleId; newStart: DateTimeStringValue; newEnd: DateTimeStringValue; resourceId?: ScheduleId; event: ScheduleEventData }) => void;
	/** Snap in minutes, defaults to the view's `intervalMinutes` */
	eventDragInterval?: number;
	canDragEvent?: (event: ScheduleEventData) => boolean;
	onExternalEventDrop?: (dataTransfer: DataTransfer, dropDateTime: DateTimeStringValue, resourceId?: ScheduleId) => void;
	canDropExternalEvent?: (a: { dataTransfer: DataTransfer; dropDateTime: DateTimeStringValue; resourceId?: ScheduleId }) => boolean;
	/** `true` = no overlap with other events; function = pair check (true => conflict) */
	preventEventOverlap?: boolean | ((moved: ScheduleEventData, other: ScheduleEventData) => boolean);
	canDropEvent?: (a: { event: ScheduleEventData; newStart: DateTimeStringValue; newEnd: DateTimeStringValue; resourceId?: ScheduleId }) => boolean;
	withEventResize?: boolean;
	onEventResize?: (a: { eventId: ScheduleId; newStart: DateTimeStringValue; newEnd: DateTimeStringValue; event: ScheduleEventData }) => void;
	eventResizeInterval?: number;
	canResizeEvent?: (event: ScheduleEventData) => boolean;
	canResizeEventTo?: (a: { event: ScheduleEventData; newStart: DateTimeStringValue; newEnd: DateTimeStringValue; resourceId?: ScheduleId }) => boolean;
	onEventPlacementRejected?: (a: SchedulePlacementRejected) => void;
	withDragSlotSelect?: boolean;
	onSlotDragEnd?: (rangeStart: DateTimeStringValue, rangeEnd: DateTimeStringValue, resourceId?: ScheduleId) => void;
}

export interface ScheduleClickProps {
	onEventClick?: (event: ScheduleEventData) => void;
	onTimeSlotClick?: (a: { slotStart: DateTimeStringValue; slotEnd: DateTimeStringValue; nativeEvent: MouseEvent; resourceId?: ScheduleId }) => void;
	onAllDaySlotClick?: (slotDate: DateStringValue) => void;
	onDayClick?: (date: DateStringValue, nativeEvent?: MouseEvent, resourceId?: ScheduleId) => void;
}

export interface ScheduleEventRenderArgs {
	event: ScheduleEventData;
	/** Spread on the root element: class, style, data-*, drag + click handlers */
	props: Record<string, any>;
	/** Default body (title/time + resize handles) */
	children: Snippet;
}

export interface ScheduleHeaderContext {
	label: string;
	view?: string;
	views?: string[];
	labels: ScheduleLabels;
	date: DateStringValue;
	previous: () => void;
	next: () => void;
	today: () => void;
	setView?: (view: string) => void;
	setDate?: (date: DateStringValue) => void;
	locale: string;
}

export interface ScheduleSharedProps extends ScheduleInteractionProps, ScheduleClickProps {
	events?: ScheduleEventData[];
	date?: DateStringValue | Date;
	onDateChange?: (date: DateStringValue) => void;
	locale?: string;
	labels?: Partial<ScheduleLabels>;
	radius?: number | string;
	withHeader?: boolean;
	headerFormat?: ScheduleFormat;
	/** Custom header, use `ScheduleHeader.*` parts inside */
	header?: Snippet<[ScheduleHeaderContext]>;
	renderEvent?: Snippet<[ScheduleEventRenderArgs]>;
	renderEventBody?: Snippet<[ScheduleEventData]>;
	recurrenceExpansionLimit?: number;
	/** Fixed or max height of the scroll area, e.g. `500` or `"60vh"` */
	height?: number | string;
	class?: string;
}

export interface TimeGridProps {
	startTime?: string;
	endTime?: string;
	startScrollTime?: string;
	intervalMinutes?: number;
	slotLabelInterval?: number;
	slotHeight?: number;
	allDaySlotHeight?: number;
	slotLabelFormat?: ScheduleFormat;
	withSubHourGridLines?: boolean;
	getTimeSlotProps?: (a: { start: DateTimeStringValue; end: DateTimeStringValue }) => Record<string, any> | undefined;
	withCurrentTimeIndicator?: boolean;
	withCurrentTimeBubble?: boolean;
	getCurrentTime?: () => DateTimeStringValue;
	highlightBusinessHours?: boolean;
	/** `[start, end]` as `HH:mm[:ss]` */
	businessHours?: [string, string];
	eventOverlapMode?: ScheduleEventOverlapMode;
	eventOverlapRaiseDelay?: number;
	withAllDaySlots?: boolean;
}

export interface DayViewProps extends ScheduleSharedProps, TimeGridProps {
	withAgenda?: boolean;
	onViewChange?: (view: ScheduleViewLevel) => void;
}

export interface WeekViewProps extends ScheduleSharedProps, TimeGridProps {
	firstDayOfWeek?: number;
	weekdayFormat?: ScheduleFormat;
	withWeekendDays?: boolean;
	weekendDays?: number[];
	highlightToday?: boolean;
	withWeekNumber?: boolean;
	forceCurrentTimeIndicator?: boolean;
	withAgenda?: boolean;
	onViewChange?: (view: ScheduleViewLevel) => void;
}

export interface MonthViewProps extends ScheduleSharedProps {
	withWeekNumbers?: boolean;
	withWeekDays?: boolean;
	withWeekendDays?: boolean;
	weekendDays?: number[];
	firstDayOfWeek?: number;
	weekdayFormat?: ScheduleFormat;
	consistentWeeks?: boolean;
	highlightToday?: boolean;
	withOutsideDays?: boolean;
	/** 1-10, default 3 */
	maxEventsPerDay?: number;
	withAgenda?: boolean;
	withInteractiveBackgroundEvents?: boolean;
	onViewChange?: (view: ScheduleViewLevel) => void;
}

export interface YearViewProps extends ScheduleSharedProps {
	withWeekNumbers?: boolean;
	withWeekDays?: boolean;
	withWeekendDays?: boolean;
	weekendDays?: number[];
	withOutsideDays?: boolean;
	firstDayOfWeek?: number;
	renderDay?: Snippet<[DateStringValue, ScheduleEventData[]]>;
	onMonthClick?: (date: DateStringValue) => void;
	onViewChange?: (view: ScheduleViewLevel) => void;
}

export interface MobileMonthViewProps extends Omit<ScheduleSharedProps, "renderEvent"> {
	selectedDate?: DateStringValue | null;
	onSelectedDateChange?: (date: DateStringValue | null) => void;
	withWeekNumbers?: boolean;
	withOutsideDays?: boolean;
	withWeekendDays?: boolean;
	weekendDays?: number[];
	highlightToday?: boolean;
	firstDayOfWeek?: number;
	consistentWeeks?: boolean;
	weekdayFormat?: ScheduleFormat;
	withWeekDays?: boolean;
	renderEvent?: Snippet<[ScheduleEventRenderArgs]>;
	/** Receives the default header */
	renderHeader?: Snippet<[Snippet]>;
	onViewChange?: (view: ScheduleViewLevel) => void;
}

export interface AgendaViewProps extends Omit<ScheduleSharedProps, "date" | "onDateChange" | "withHeader" | "height"> {
	rangeStart: DateStringValue;
	rangeEnd: DateStringValue;
	dateHeaderFormat?: ScheduleFormat;
	headerFormat?: ScheduleFormat;
	height?: number | string;
	withHeader?: boolean;
}

export interface ScheduleProps extends ScheduleSharedProps {
	view?: ScheduleViewLevel;
	defaultView?: ScheduleViewLevel;
	onViewChange?: (view: ScheduleViewLevel) => void;
	/** `responsive`: Week -> Day and Month -> MobileMonthView below `responsiveBreakpoint` */
	layout?: "default" | "responsive";
	responsiveBreakpoint?: number;
	withAgenda?: boolean;
	dayViewProps?: Partial<DayViewProps>;
	weekViewProps?: Partial<WeekViewProps>;
	monthViewProps?: Partial<MonthViewProps>;
	yearViewProps?: Partial<YearViewProps>;
	mobileMonthViewProps?: Partial<MobileMonthViewProps>;
	/** Persist view + date in storage under this id */
	autoSaveId?: string;
	storage?: SchedulePersistStorage;
}

export interface SchedulePersistStorage {
	getItem(key: string): string | null;
	setItem(key: string, value: string): void;
}

export interface SchedulePersistedState {
	view?: string;
	date?: DateStringValue;
}

/* Resources */

export interface ResourcesCommonProps extends ScheduleSharedProps {
	resources: ScheduleResourceData[];
	/** px height of a lane, default 56 */
	rowHeight?: number;
	/** px width of the resource label column, default 160 */
	resourceColumnWidth?: number;
	startScrollDate?: DateStringValue;
	slotWidth?: number;
	startTime?: string;
	endTime?: string;
	intervalMinutes?: number;
	startScrollDateTime?: DateTimeStringValue;
	startScrollTime?: string;
	maxEventsPerTimeSlot?: number;
	eventOverlapMode?: ScheduleEventOverlapMode;
	withCurrentTimeIndicator?: boolean;
	getCurrentTime?: () => DateTimeStringValue;
	firstDayOfWeek?: number;
	renderResource?: Snippet<[ScheduleResourceData]>;
	onViewChange?: (view: ResourcesScheduleViewLevel) => void;
}

export type ResourcesDayViewProps = ResourcesCommonProps;
export type ResourcesWeekViewProps = ResourcesCommonProps;
export type ResourcesMonthViewProps = ResourcesCommonProps;

export interface ResourcesScheduleProps extends ResourcesCommonProps {
	view?: ResourcesScheduleViewLevel;
	defaultView?: ResourcesScheduleViewLevel;
	dayViewProps?: Partial<ResourcesDayViewProps>;
	weekViewProps?: Partial<ResourcesWeekViewProps>;
	monthViewProps?: Partial<ResourcesMonthViewProps>;
	autoSaveId?: string;
	storage?: SchedulePersistStorage;
}
```

## Examples (from Storybook stories)

### Components/Schedule  (`Schedule.stories.ts`)

Shared setup:

```ts
import { expect, fireEvent, waitFor, within } from 'storybook/test';

import ScheduleDemo from './ScheduleDemo.fixtures.svelte';
```

Helper component `./ScheduleDemo.fixtures.svelte`:

```svelte
<script lang="ts">
	/* eslint-disable @typescript-eslint/no-explicit-any */
	import {
		DayView, MobileMonthView, MonthView, ResourcesDayView, ResourcesMonthView, ResourcesSchedule, ResourcesWeekView, Schedule, ScheduleHeader, WeekView, YearView, AgendaView
	} from "./index.js";
	import { makeEvents, makeRecurringSet, makeResources } from "./testData.js";
	import type { ScheduleEventData } from "./types.js";

	interface Props {
		demo?: string;
		count?: number;
		resourceCount?: number;
		withDnd?: boolean;
		withResize?: boolean;
		withSelect?: boolean;
		preventOverlap?: boolean;
		overlapMode?: "columns" | "cascade";
		locale?: string;
		height?: number;
	}

	let { demo = "schedule", count = 120, resourceCount = 12, withDnd = true, withResize = true, withSelect = true, preventOverlap = false, overlapMode = "columns", locale = "en", height = 640 }: Props = $props();

	const ANCHOR = "2026-10-19";
	let date = $state(ANCHOR);
	let log = $state<string[]>([]);
	let selectedDate = $state<string | null>(ANCHOR);
	let events = $state<ScheduleEventData[]>([]);
	let resEvents = $state<ScheduleEventData[]>([]);
	const resources = $derived(makeResources(resourceCount));

	$effect.pre(() => {
		events = [...makeRecurringSet(ANCHOR), ...makeEvents({ anchor: ANCHOR, count, spreadDays: demo === "stress" ? 60 : 10 })];
		resEvents = makeEvents({ anchor: ANCHOR, count, resources: resourceCount, spreadDays: 7, allDayShare: 0 });
	});

	const note = (m: string) => (log = [m, ...log].slice(0, 6));
	const fmt = (v: unknown) => String(v);

	function move(list: ScheduleEventData[], id: string | number, patch: Partial<ScheduleEventData>) {
		return list.map((e) => (e.id === id ? { ...e, ...patch } : e));
	}

	const interaction = $derived({
		withEventsDragAndDrop: withDnd,
		withEventResize: withResize,
		withDragSlotSelect: withSelect,
		preventEventOverlap: preventOverlap,
		onEventDrop: ({ eventId, newStart, newEnd, resourceId }: any) => {
			note(`drop ${fmt(eventId)} -> ${newStart}${resourceId ? " @" + resourceId : ""}`);
			if (demo.startsWith("resources")) resEvents = move(resEvents, eventId, { start: newStart, end: newEnd, ...(resourceId ? { resourceId } : {}) });
			else events = move(events, eventId, { start: newStart, end: newEnd });
		},
		onEventResize: ({ eventId, newStart, newEnd }: any) => {
			note(`resize ${fmt(eventId)} -> ${newStart} / ${newEnd}`);
			if (demo.startsWith("resources")) resEvents = move(resEvents, eventId, { start: newStart, end: newEnd });
			else events = move(events, eventId, { start: newStart, end: newEnd });
		},
		onSlotDragEnd: (s: string, e: string, r?: unknown) => note(`select ${s} - ${e}${r ? " @" + fmt(r) : ""}`),
		onEventPlacementRejected: (a: any) => note(`rejected ${a.action}: ${a.reason} (${a.conflicts.length})`),
		onExternalEventDrop: (_dt: DataTransfer, at: string, r?: unknown) => note(`external drop at ${at}${r ? " @" + fmt(r) : ""}`),
		onEventClick: (e: ScheduleEventData) => note(`click ${fmt(e.id)}`),
		onTimeSlotClick: ({ slotStart }: any) => note(`slot ${slotStart}`),
		onDayClick: (d: string) => note(`day ${d}`),
		onAllDaySlotClick: (d: string) => note(`all-day ${d}`)
	});
</script>

<div class="flex flex-col gap-2 p-2" data-testid="schedule-demo">
	{#if demo === "external"}
		<div
			class="w-fit cursor-grab rounded border border-dashed p-2 text-sm"
			draggable="true"
			role="button"
			tabindex="0"
			data-testid="external-source"
			ondragstart={(e) => e.dataTransfer?.setData("text/plain", "Ticket 42")}
		>
			Drag me onto the schedule
		</div>
	{/if}

	{#if demo === "schedule" || demo === "stress" || demo === "external" || demo === "responsive" || demo === "persisted"}
		<Schedule
			bind:date
			{events}
			{locale}
			{height}
			withAgenda
			layout={demo === "responsive" ? "responsive" : "default"}
			autoSaveId={demo === "persisted" ? "storybook-schedule" : undefined}
			dayViewProps={{ startScrollTime: "07:00:00", eventOverlapMode: overlapMode }}
			weekViewProps={{ startScrollTime: "07:00:00", highlightBusinessHours: true, withWeekNumber: true, eventOverlapMode: overlapMode }}
			{...interaction}
		/>
	{:else if demo === "day"}
		<DayView bind:date {events} {locale} {height} startScrollTime="07:00:00" highlightBusinessHours eventOverlapMode={overlapMode} withAgenda {...interaction} />
	{:else if demo === "week"}
		<WeekView bind:date {events} {locale} {height} startScrollTime="07:00:00" withWeekNumber withAgenda eventOverlapMode={overlapMode} {...interaction} />
	{:else if demo === "week-weekdays"}
		<WeekView bind:date {events} {locale} {height} withWeekendDays={false} startTime="07:00:00" endTime="19:00:00" intervalMinutes={30} {...interaction} />
	{:else if demo === "month"}
		<MonthView bind:date {events} {locale} {height} withWeekNumbers maxEventsPerDay={3} withAgenda {...interaction} />
	{:else if demo === "year"}
		<YearView bind:date {events} {locale} {height} withWeekNumbers onDayClick={(d) => note(`day ${d}`)} />
	{:else if demo === "mobile"}
		<MobileMonthView bind:date bind:selectedDate {events} {locale} height={680} onEventClick={interaction.onEventClick} />
	{:else if demo === "agenda"}
		<AgendaView {events} {locale} rangeStart="2026-10-12" rangeEnd="2026-11-15" height={560} onEventClick={interaction.onEventClick} />
	{:else if demo === "custom-header"}
		<WeekView bind:date {events} {height}>
			{#snippet header(ctx)}
				<div class="flex items-center gap-2 p-2" data-testid="custom-header">
					<ScheduleHeader.Previous />
					<ScheduleHeader.Control interactive={false}>{ctx.label}</ScheduleHeader.Control>
					<ScheduleHeader.Next />
					<ScheduleHeader.Today />
				</div>
			{/snippet}
		</WeekView>
	{:else if demo === "static"}
		<Schedule bind:date {events} {height} mode="static" defaultView="month" />
	{:else if demo === "resources-day"}
		<ResourcesDayView bind:date {resources} events={resEvents} {locale} {height} startTime="07:00:00" endTime="20:00:00" eventOverlapMode={overlapMode} {...interaction} />
	{:else if demo === "resources-week"}
		<ResourcesWeekView bind:date {resources} events={resEvents} {locale} {height} startTime="08:00:00" endTime="18:00:00" startScrollDateTime="2026-10-20 08:00:00" {...interaction} />
	{:else if demo === "resources-month"}
		<ResourcesMonthView bind:date {resources} events={resEvents} {locale} {height} {...interaction} />
	{:else if demo === "resources" || demo === "resources-stress"}
		<ResourcesSchedule bind:date {resources} events={resEvents} {locale} {height} dayViewProps={{ startTime: "07:00:00", endTime: "20:00:00" }} {...interaction} />
	{/if}

	<ul class="min-h-24 rounded border border-dashed p-2 text-xs" data-testid="event-log">
		{#each log as l, i (i + l)}<li>{l}</li>{/each}
	</ul>
</div>
```

#### Default (`Default`)

```ts
export const Default: Story = {
	args: { demo: 'schedule' },
};
```

#### Day View (`DayView`)

```ts
export const DayView: Story = { args: { demo: 'day' } };
```

#### Day View Cascade (`DayViewCascade`)

```ts
export const DayViewCascade: Story = { args: { demo: 'day', overlapMode: 'cascade', count: 400 } };
```

#### Week View (`WeekView`)

```ts
export const WeekView: Story = {
	args: { demo: 'week' },
};
```

#### Week Weekdays Only (`WeekWeekdaysOnly`)

```ts
export const WeekWeekdaysOnly: Story = {
	args: { demo: 'week-weekdays' },
};
```

#### Month View (`MonthView`)

```ts
export const MonthView: Story = {
	args: { demo: 'month' },
};
```

#### Year View (`YearView`)

```ts
export const YearView: Story = { args: { demo: 'year' } };
```

#### Mobile Month View (`MobileMonthView`)

```ts
export const MobileMonthView: Story = {
	args: { demo: 'mobile' },
};
```

#### Agenda View (`AgendaView`)

```ts
export const AgendaView: Story = { args: { demo: 'agenda' } };
```

#### Custom Header (`CustomHeader`)

```ts
export const CustomHeader: Story = { args: { demo: 'custom-header' } };
```

#### Static Mode (`StaticMode`)

```ts
export const StaticMode: Story = { args: { demo: 'static' } };
```

#### External Drop (`ExternalDrop`)

```ts
export const ExternalDrop: Story = { args: { demo: 'external' } };
```

#### Responsive (`Responsive`)

```ts
export const Responsive: Story = { args: { demo: 'responsive' } };
```

#### Persisted View And Date (`PersistedViewAndDate`)

```ts
export const PersistedViewAndDate: Story = { args: { demo: 'persisted' } };
```

#### Prevent Overlap (`PreventOverlap`)

```ts
export const PreventOverlap: Story = { args: { demo: 'week', preventOverlap: true } };
```

#### Resources Schedule (`ResourcesSchedule`)

```ts
export const ResourcesSchedule: Story = { args: { demo: 'resources' } };
```

#### Resources Day (`ResourcesDay`)

```ts
export const ResourcesDay: Story = { args: { demo: 'resources-day' } };
```

#### Resources Week (`ResourcesWeek`)

```ts
export const ResourcesWeek: Story = { args: { demo: 'resources-week' } };
```

#### Resources Month (`ResourcesMonth`)

```ts
export const ResourcesMonth: Story = { args: { demo: 'resources-month' } };
```

#### Stress10k Events (`Stress10kEvents`)

```ts
/** 10k events over +-60 days, recurring series included. Only visible rows/events are in the DOM. */
export const Stress10kEvents: Story = {
	args: { demo: 'stress', count: 10000 },
};
```

#### Stress500 Resources (`Stress500Resources`)

```ts
/** 500 resources x 20k events: lanes + time axis windowed in both directions. */
export const Stress500Resources: Story = {
	args: { demo: 'resources-stress', resourceCount: 500, count: 20000 },
};
```

---

# Screenshot

Import: `import { ... } from '@warky/svelix/Screenshot'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as CaptureScreenshotComponent } from "./Screenshot.svelte";
export { CaptureScreen } from "./Screenshot.util";
export { SCREENSHOT_DEFAULTS } from "./screenshotLayout";
export type { ScreenshotLayoutConfig } from "./screenshotLayout";
```

## Summary

Click "Capture Screenshot" to trigger browser screen capture permission and preview the captured image as base64.

## Props

### Screenshot.svelte

```ts
export interface Props {
    onCapture?: (dataUrl: string) => void;
    showPreview?: boolean;
  }
```

## Examples (from Storybook stories)

### Components/Screenshot  (`Screenshot.stories.ts`)

Shared setup:

```ts
import Screenshot from './Screenshot.svelte';

import ScreenshotDownloadStory from './ScreenshotDownloadStory.svelte';
```

Helper component `./ScreenshotDownloadStory.svelte`:

```svelte
<script lang="ts">
  import Screenshot from "./Screenshot.svelte";

  let dataUrl = $state("");

  const download = () => {
    const a = document.createElement("a");
    a.href = dataUrl;
    a.download = `screenshot-${new Date().toISOString().replace(/[:.]/g, "-")}.png`;
    a.click();
  };
</script>

<Screenshot onCapture={(url) => (dataUrl = url)} />

{#if dataUrl}
  <div class="px-4">
    <button
      onclick={download}
      class="rounded bg-green-600 px-4 py-2 text-white transition-colors hover:bg-green-700"
    >
      Download Screenshot
    </button>
  </div>
{/if}
```

#### Default (`Default`)

Renders the helper component shown above (no args).

#### With Download (`WithDownload`)

```ts
export const WithDownload: Story = {
	render: () => ({ Component: ScreenshotDownloadStory })
};
```

---

# ServerEvents

Import: `import { ... } from '@warky/svelix/ServerEvents'` (or from `'@warky/svelix'`).

## Exports

```ts
export { createServerSSEChanges } from "./ServerEventSource";
export type * from "./ServerEventSource.types";
export {
	createServerEventSourceManager,
	defaultServerEventSourceManager,
	ServerEventSourceManager
} from "./ServerEventSourceManager";
```

---

# Stack

Import: `import { ... } from '@warky/svelix/Stack'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as Stack } from "./Stack.svelte";
export { STACK_DEFAULTS } from "./stackLayout.js";
export type * from "./types.js";
```

## Summary

Vertical flex container (port of Mantine Stack). Defaults configurable via `SvelixLayout` key `stack` and `shared.gap`.

## Types (types.ts)

```ts
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import type { SvelixSpacing } from "../../utils/scale.js";

type DivAttributes = Omit<HTMLAttributes<HTMLDivElement>, "children" | "class" | "style">;

/** `SvelixLayout` key `stack`. */
export interface StackLayoutConfig {
	/** @default "md" */
	gap?: SvelixSpacing;
	/** @default "stretch" */
	align?: string;
	/** @default "flex-start" */
	justify?: string;
}

export interface StackProps extends StackLayoutConfig, DivAttributes {
	class?: string;
	style?: string;
	children?: Snippet;
}
```

## Examples (from Storybook stories)

### Components/Stack  (`Stack.stories.ts`)

Shared setup:

```ts
import { expect, within } from 'storybook/test';

import StackDemo from './StackDemo.fixtures.svelte';

/** Tailwind spacing units → px under the active theme's `--spacing`. */
function units(multiplier: number): number {
	const probe = document.createElement('div');
	probe.style.width = `calc(var(--spacing, 0.25rem) * ${multiplier})`;
	document.body.append(probe);
	const px = probe.getBoundingClientRect().width;
	probe.remove();
	return px;
}

async function expectPx(value: string, multiplier: number) {
	await expect(parseFloat(value)).toBeCloseTo(units(multiplier), 1);
}
```

Helper component `./StackDemo.fixtures.svelte`:

```svelte
<script lang="ts">
	import SvelixLayout from "../SvelixLayout/SvelixLayout.svelte";
	import Stack from "./Stack.svelte";
	import type { StackProps } from "./types.js";

	export interface Props extends Omit<StackProps, "children"> {
		demo?: "basic" | "layout";
	}

	let { demo = "basic", ...props }: Props = $props();
</script>

{#snippet boxes()}
	{#each ["One", "Two", "Three"] as label (label)}
		<div class="rounded bg-primary-500/20 p-2 text-sm">{label}</div>
	{/each}
{/snippet}

{#if demo === "basic"}
	<Stack data-testid="stack" class="h-64 rounded border border-surface-300 p-2" {...props}>{@render boxes()}</Stack>
{:else}
	<SvelixLayout shared={{ gap: "xs" }} stack={{ align: "center" }}>
		<Stack data-testid="stack" class="rounded border border-surface-300 p-2">{@render boxes()}</Stack>
		<SvelixLayout stack={{ gap: 6 }}>
			<Stack data-testid="stack-nested" class="mt-2 rounded border border-surface-300 p-2">{@render boxes()}</Stack>
		</SvelixLayout>
	</SvelixLayout>
{/if}
```

#### Default (`Default`)

```ts
export const Default: Story = {
	args: { demo: 'basic' },
};
```

#### Props (`Props`)

```ts
export const Props: Story = {
	args: { demo: 'basic', gap: 'xl', align: 'flex-end', justify: 'space-between' },
};
```

#### Layout (`Layout`)

```ts
export const Layout: Story = {
	args: { demo: 'layout' },
};
```

---

# SvelixLayout

Import: `import { ... } from '@warky/svelix/SvelixLayout'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as SvelixLayout } from "./SvelixLayout.svelte";
export {
	documentDir,
	getSvelixLayout,
	resetSvelixLayout,
	resolveSvelixLayout,
	setSvelixLayout,
	useSvelixLayout
} from "./store.svelte.js";
export { formatLabel, svelixLabel, svelixLabelKey, useSvelixLabels } from "./labels.svelte.js";
export { mergeLayout, mergeLayoutConfig } from "./merge.js";
export type * from "./types.js";
export { useSvelixOverlays } from "./overlays.svelte.js";
```

## Types (types.ts)

```ts
import type { Snippet } from "svelte";
import type { AlertLayoutConfig } from "../Alert/types.js";
import type { BetterMenuLayoutConfig } from "../BetterMenu/types.js";
import type { BoxerLayoutConfig } from "../Boxer/types.js";
import type { BreadcrumbsLayoutConfig } from "../Breadcrumbs/types.js";
import type { ChartsLayoutConfig } from "../Charts/types.js";
import type { CardGridLayoutConfig } from "../CardGrid/cardGridLayout.js";
import type {
	CollaboraEditorLayoutConfig,
	ContentEditorLayoutConfig,
	MarkdownViewerLayoutConfig,
	MonacoEditorLayoutConfig,
	Office365EditorLayoutConfig,
	TextEditorLayoutConfig
} from "../ContentEditor/types.js";
import type { DialogLayoutConfig, DrawerLayoutConfig } from "../Dialog/dialogLayout.js";
import type { FormerDrawerLayoutConfig, FormerLayoutConfig, FormerModalLayoutConfig } from "../Former/types.js";
import type {
	ButtonCtrlLayoutConfig,
	CheckBoxCtrlLayoutConfig,
	CheckGroupCtrlLayoutConfig,
	ColorPickerCtrlLayoutConfig,
	DateRangeCtrlLayoutConfig,
	DateTimeCtrlLayoutConfig,
	IconButtonCtrlLayoutConfig,
	InlineWrapperLayoutConfig,
	MonthPickerCtrlLayoutConfig,
	PinInputCtrlLayoutConfig,
	RadioGroupCtrlLayoutConfig,
	RatingGroupCtrlLayoutConfig,
	SegmentedControlCtrlLayoutConfig,
	SliderCtrlLayoutConfig,
	TagsInputCtrlLayoutConfig,
	TextInputCtrlLayoutConfig
} from "../FormerControllers/types.js";
import type { GlobalStateStoreProviderLayoutConfig } from "../GlobalStateStore/globalStateStoreLayout.js";
import type { GridExportDialogLayoutConfig } from "../GridExport/types.js";
import type { GridlerFullLayoutConfig, GridlerLayoutConfig } from "../Gridler/types.js";
import type { GroupLayoutConfig } from "../Group/types.js";
import type { IFrameLayoutConfig } from "../IFrame/iframeLayout.js";
import type { LLMChatLayoutConfig, ModelSettingsLayoutConfig } from "../LLMChat/llmChatLayout.js";
import type { NavigationProgressLayoutConfig } from "../NavigationProgress/types.js";
import type { AspectRatioLayoutConfig } from "../AspectRatio/types.js";
import type { AppShellLayoutConfig } from "../AppShell/types.js";
import type { WindowzBarLayoutConfig, WindowzLayoutConfig } from "../Windowz/types.js";
import type { PanerLayoutConfig } from "../Paner/types.js";
import type { PortalLayoutConfig } from "../Portal/portalLayout.js";
import type { ScreenshotLayoutConfig } from "../Screenshot/screenshotLayout.js";
import type { StackLayoutConfig } from "../Stack/types.js";
import type { SvarkLayoutConfig } from "../legacy/Svark/svarkLayout.js";
import type { SvarkGridLayoutConfig } from "../legacy/SvarkGrid/svarkGridLayout.js";
import type { TourLayoutConfig } from "../Tour/types.js";
import type { VTreeLayoutConfig } from "../VTree/vtreeLayout.js";
import type { SvelixRadius, SvelixSpacing } from "../../utils/scale.js";

export type SvelixDir = "ltr" | "rtl";

/** Tokens shared by every component that understands them. */
export interface SvelixSharedLayout {
	gap?: SvelixSpacing;
	radius?: SvelixRadius;
	/** Viewport width in px below which responsive components switch layout. */
	breakpoint?: number;
	/** Text direction; unset → document `dir`. */
	dir?: SvelixDir;
	motion?: {
		animate?: boolean;
		/** ms */
		duration?: number;
	};
}

/**
 * Component key → configurable values. Components add their own key here.
 * Values are partial at every level; `undefined` never overrides.
 */
export interface SvelixLayoutRegistry {
	paner: PanerLayoutConfig;
	group: GroupLayoutConfig;
	stack: StackLayoutConfig;
	breadcrumbs: BreadcrumbsLayoutConfig;
	alert: AlertLayoutConfig;
	tour: TourLayoutConfig;
	windowz: WindowzLayoutConfig;
	windowzBar: WindowzBarLayoutConfig;
	aspectRatio: AspectRatioLayoutConfig;
	appShell: AppShellLayoutConfig;
	navigationProgress: NavigationProgressLayoutConfig;
	betterMenu: BetterMenuLayoutConfig;
	boxer: BoxerLayoutConfig;
	cardGrid: CardGridLayoutConfig;
	charts: ChartsLayoutConfig;
	contentEditor: ContentEditorLayoutConfig;
	collaboraEditor: CollaboraEditorLayoutConfig;
	markdownViewer: MarkdownViewerLayoutConfig;
	monacoEditor: MonacoEditorLayoutConfig;
	office365Editor: Office365EditorLayoutConfig;
	textEditor: TextEditorLayoutConfig;
	dialog: DialogLayoutConfig;
	drawer: DrawerLayoutConfig;
	former: FormerLayoutConfig;
	formerDrawer: FormerDrawerLayoutConfig;
	formerModal: FormerModalLayoutConfig;
	dateTimeCtrl: DateTimeCtrlLayoutConfig;
	monthPickerCtrl: MonthPickerCtrlLayoutConfig;
	buttonCtrl: ButtonCtrlLayoutConfig;
	iconButtonCtrl: IconButtonCtrlLayoutConfig;
	checkBoxCtrl: CheckBoxCtrlLayoutConfig;
	inlineWrapper: InlineWrapperLayoutConfig;
	textInputCtrl: TextInputCtrlLayoutConfig;
	sliderCtrl: SliderCtrlLayoutConfig;
	segmentedControlCtrl: SegmentedControlCtrlLayoutConfig;
	ratingGroupCtrl: RatingGroupCtrlLayoutConfig;
	tagsInputCtrl: TagsInputCtrlLayoutConfig;
	colorPickerCtrl: ColorPickerCtrlLayoutConfig;
	pinInputCtrl: PinInputCtrlLayoutConfig;
	radioGroupCtrl: RadioGroupCtrlLayoutConfig;
	checkGroupCtrl: CheckGroupCtrlLayoutConfig;
	dateRangeCtrl: DateRangeCtrlLayoutConfig;
	globalStateStoreProvider: GlobalStateStoreProviderLayoutConfig;
	gridExportDialog: GridExportDialogLayoutConfig;
	gridler: GridlerLayoutConfig;
	gridlerFull: GridlerFullLayoutConfig;
	iframe: IFrameLayoutConfig;
	llmChat: LLMChatLayoutConfig;
	modelSettings: ModelSettingsLayoutConfig;
	portal: PortalLayoutConfig;
	screenshot: ScreenshotLayoutConfig;
	svark: SvarkLayoutConfig;
	svarkGrid: SvarkGridLayoutConfig;
	vtree: VTreeLayoutConfig;
}

export type SvelixLayoutKey = keyof SvelixLayoutRegistry;

export type SvelixLayoutConfig = { shared?: SvelixSharedLayout } & {
	[K in SvelixLayoutKey]?: SvelixLayoutRegistry[K];
};

/** Partial at every plain-object level. */
export type SvelixDeepPartial<T> = {
	[K in keyof T]?: T[K] extends (...args: never[]) => unknown
		? T[K]
		: T[K] extends readonly unknown[]
			? T[K]
			: T[K] extends object
				? SvelixDeepPartial<T[K]>
				: T[K];
};

/** Maps shared tokens onto one component's keys. */
export type SvelixSharedMapper<T> = (shared: SvelixSharedLayout) => SvelixDeepPartial<T>;

export interface SvelixLayoutProps extends SvelixLayoutConfig {
	children?: Snippet;
}

export interface SvelixLayoutState<T> {
	/** Resolved config: built-in < module < providers (outer → inner, each `shared` then key). */
	readonly current: T;
}
```

---

# Tour

Import: `import { ... } from '@warky/svelix/Tour'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as Tour } from "./Tour.svelte";
export { default as TourRoot } from "./TourRoot.svelte";
export { default as TourStep } from "./TourStep.svelte";
export { default as TourOverlay } from "./TourOverlay.svelte";
export { default as TourTooltip } from "./TourTooltip.svelte";
export { default as TourTitle } from "./TourTitle.svelte";
export { default as TourBody } from "./TourBody.svelte";
export { default as TourNavigation } from "./TourNavigation.svelte";
export { default as TourCloseButton } from "./TourCloseButton.svelte";
export { default as TourBeacon } from "./TourBeacon.svelte";
export { TOUR_DEFAULTS, tourFromShared } from "./tourConfig.js";
export type { TourResolvedConfig } from "./tourConfig.js";
export { getOverlayHitPath, isElementInView, TOUR_DEFAULT_LABELS } from "./tourGeometry.js";
export { TourState } from "./tourState.svelte.js";
export type { TourStateHost, TourStateInput } from "./tourState.svelte.js";
export type * from "./types.js";
```

## Summary

Guided product tour (port of Mantine Tour): SVG spotlight overlay, `@zag-js/popper` tooltip, keyboard (←/→, Esc, RTL aware), focus trap (`@zag-js/focus-trap`), beacon mode, and compound parts (`TourRoot`, `TourOverlay`, `TourTooltip`, `TourTitle`, `TourBody`, `TourNavigation`, `TourCloseButton`, `TourBeacon`). `active` and `step` are bindable. Defaults via `SvelixLayout` key `tour`; labels via i18n `svelix.tour.*`.

## Types (types.ts)

```ts
import type { Snippet } from "svelte";
import type { HTMLAttributes, HTMLButtonAttributes } from "svelte/elements";
import type { SvelixColor, SvelixRadius } from "../../utils/scale.js";
import type { SvelixDir } from "../SvelixLayout/types.js";

type DivAttributes = Omit<HTMLAttributes<HTMLDivElement>, "children" | "class" | "style" | "dir" | "color">;

export type TourMode = "guided" | "beacon";

/** CSS selector (re-resolved when it mounts later), element, or getter. */
export type TourTarget = string | HTMLElement | (() => HTMLElement | null) | null | undefined;

export type TourPosition =
	| "top"
	| "top-start"
	| "top-end"
	| "bottom"
	| "bottom-start"
	| "bottom-end"
	| "left"
	| "left-start"
	| "left-end"
	| "right"
	| "right-start"
	| "right-end";

export interface TourRect {
	top: number;
	left: number;
	width: number;
	height: number;
}

export interface TourLabels {
	/** @default "Skip" */
	skip: string;
	/** @default "Back" */
	back: string;
	/** @default "Next" */
	next: string;
	/** Close / finish button. @default "Close" */
	close: string;
	/** @default (current, total) => `${current} of ${total}` */
	stepCounter: (current: number, total: number) => string;
	/** Beacon button; a string step title is appended. @default "Start tour" */
	beacon: string;
}

/** `SvelixLayout` key `tour`. */
export interface TourLayoutConfig {
	/** @default true */
	withOverlay?: boolean;
	/** Clicks pass through the spotlight cutout. @default false */
	withOverlayInteraction?: boolean;
	/** @default true */
	withCloseButton?: boolean;
	/** ←/→ in guided mode. @default true */
	withKeyboardNavigation?: boolean;
	/** @default true */
	withScrollIntoView?: boolean;
	/** px. @default 8 */
	spotlightPadding?: number;
	/** px. @default 4 */
	spotlightRadius?: number;
	/** @default true */
	closeOnEscape?: boolean;
	/** @default false */
	closeOnOverlayClick?: boolean;
	/** @default true */
	withinPortal?: boolean;
	/** @default 10000 */
	zIndex?: number | string;
	/** Tooltip move between targets, ms. @default 300 */
	stepTransitionDuration?: number;
	/** Tooltip / overlay enter-leave, ms. @default 200 */
	transitionDuration?: number;
	/** Tooltip width when the viewport allows, px. @default 360 */
	maxWidth?: number;
	/** @default "rgba(0, 0, 0, 0.5)" */
	overlayColor?: string;
	/** Beacon + next button. @default "primary" */
	color?: SvelixColor;
	/** Tailwind shadow name or CSS box-shadow. @default "md" */
	tooltipShadow?: string;
	/** @default "container" */
	radius?: SvelixRadius;
	/** Unset → document `dir`. */
	dir?: SvelixDir;
}

export interface TourStepProps {
	target?: TourTarget;
	title?: string | Snippet;
	children?: Snippet;
	onStepOpen?: () => void;
	onStepClose?: () => void;
	/** @default "bottom" */
	position?: TourPosition;
	withOverlay?: boolean;
	spotlightPadding?: number;
	spotlightRadius?: number;
	withBeacon?: boolean;
}

interface TourBaseProps extends TourLayoutConfig, DivAttributes {
	/** Bindable; closing a guided tour sets it `false`. @default false */
	active?: boolean;
	onClose?: () => void;
	/** Bindable current step. */
	step?: number;
	/** Uncontrolled start step. @default 0 */
	defaultStep?: number;
	onStepChange?: (step: number) => void;
	onStepOpen?: (step: number) => void;
	onStepClose?: (step: number) => void;
	labels?: Partial<TourLabels>;
	/** Portal target when `withinPortal`. @default document.body */
	portalTarget?: HTMLElement;
	class?: string;
	style?: string;
	children?: Snippet;
}

export interface TourProps extends TourBaseProps {
	/** @default "guided" */
	mode?: TourMode;
	/** Replaces the built-in scroll (always called, even when the target is visible). */
	scrollToHandler?: (element: HTMLElement) => void;
}

export interface TourRootProps extends TourBaseProps {
	stepsCount: number;
}

export interface TourOverlayProps {
	targetRect?: TourRect | null;
	color?: string;
	withInteraction?: boolean;
	onclick?: (event: MouseEvent) => void;
	/** Animates the spotlight (Tour, between steps). */
	moving?: boolean;
}

export interface TourTooltipProps extends Omit<DivAttributes, "role"> {
	targetElement?: HTMLElement | null;
	/** @default "bottom" */
	position?: TourPosition;
	mounted?: boolean;
	/** px; fixed width (Tour passes its constrained width). */
	maxWidth?: number;
	/** Animates top/left between steps. */
	moving?: boolean;
	role?: string;
	class?: string;
	style?: string;
	children?: Snippet;
	onoutroend?: () => void;
}

export interface TourTitleProps extends Omit<HTMLAttributes<HTMLHeadingElement>, "children"> {
	children?: Snippet;
}

export interface TourBodyProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
	children?: Snippet;
}

export type TourNavigationProps = Omit<HTMLAttributes<HTMLDivElement>, "children">;

export type TourCloseButtonProps = Omit<HTMLButtonAttributes, "children">;

export interface TourBeaconProps extends Omit<HTMLButtonAttributes, "children" | "role"> {
	target?: TourTarget;
}
```

## Props

### TourTooltip.svelte

```ts
interface Props extends TourTooltipProps {
		/** Overrides the trap rule (Tour: guided mode only). */
		trap?: boolean;
		/** ms for `moving`. */
		moveDuration?: number;
	}
```

## Examples (from Storybook stories)

### Components/Tour  (`Tour.stories.ts`)

Shared setup:

```ts
import { expect, fireEvent, userEvent, waitFor, within } from 'storybook/test';

import TourDemo from './TourDemo.fixtures.svelte';

const body = () => within(document.body);

const dialog = () => body().getByRole('dialog');

const log = (canvas) => canvas.getByTestId('log').textContent;

const tooltipCount = () => document.querySelectorAll('.svelix-tour-tooltip').length;
```

Helper component `./TourDemo.fixtures.svelte`:

```svelte
<script lang="ts">
	import { untrack } from "svelte";
	import { clearTranslations, loadTranslations } from "../GlobalStateStore/GlobalStateStore.i18n.js";
	import SvelixLayout from "../SvelixLayout/SvelixLayout.svelte";
	import Tour from "./Tour.svelte";
	import TourBody from "./TourBody.svelte";
	import TourNavigation from "./TourNavigation.svelte";
	import TourOverlay from "./TourOverlay.svelte";
	import TourRoot from "./TourRoot.svelte";
	import TourStep from "./TourStep.svelte";
	import TourTitle from "./TourTitle.svelte";
	import TourTooltip from "./TourTooltip.svelte";
	import TourCloseButton from "./TourCloseButton.svelte";
	import type { TourProps, TourRect } from "./types.js";

	export interface Props extends Omit<TourProps, "children" | "active" | "step"> {
		demo?: "guided" | "centered" | "late" | "root" | "layout" | "i18n" | "scroll";
		startActive?: boolean;
		controlled?: boolean;
		noBeaconOnStep2?: boolean;
	}

	let { demo = "guided", startActive = true, controlled = false, noBeaconOnStep2 = false, ...props }: Props = $props();

	let active = $state(untrack(() => startActive));
	let step = $state(untrack(() => (controlled ? 0 : undefined)));
	let log = $state<string[]>([]);
	let lateMounted = $state(false);
	let rootActive = $state(true);
	let rootStep = $state(0);
	let rootRect = $state<TourRect | null>(null);
	let scrolled = $state<string[]>([]);

	const record = (entry: string) => (log = [...log, entry]);

	$effect(() => {
		if (demo !== "i18n") return;
		loadTranslations({
			"svelix.tour.next": { en: "Weiter" } as never,
			"svelix.tour.stepCounter": { en: "Schritt {current}/{total}" } as never
		});
		return () => clearTranslations();
	});

	$effect(() => {
		if (demo !== "root") return;
		const el = document.getElementById(`target-${rootStep + 1}`);
		const r = el?.getBoundingClientRect();
		rootRect = r ? { top: r.top, left: r.left, width: r.width, height: r.height } : null;
	});
</script>

{#snippet targets()}
	<div class="flex flex-wrap gap-6 p-6">
		<button class="btn preset-tonal" type="button" id="opener" onclick={() => (active = true)}>Start tour</button>
		<button class="btn preset-tonal" type="button" id="target-1">Target 1</button>
		<button class="btn preset-tonal" type="button" id="target-2">Target 2</button>
		<button class="btn preset-tonal" type="button" id="target-3">Target 3</button>
		<input class="input w-40" id="combo" aria-label="Combo" aria-expanded="false" />
		{#if lateMounted}<button class="btn preset-tonal" type="button" id="late-target">Late target</button>{/if}
		<button class="btn preset-tonal" type="button" data-testid="mount-late" onclick={() => (lateMounted = true)}>Mount late target</button>
	</div>
	<p class="px-6 text-xs" data-testid="state">active={active} step={step ?? "-"}</p>
	<pre class="px-6 text-xs" data-testid="log">{log.join("|")}</pre>
{/snippet}

{#snippet steps()}
	<TourStep target="#target-1" title="Step 1 Title" onStepOpen={() => record("step1-open")} onStepClose={() => record("step1-close")}>Step 1 Content</TourStep>
	<TourStep target="#target-2" title="Step 2 Title" withBeacon={noBeaconOnStep2 ? false : undefined}>Step 2 Content</TourStep>
	<TourStep target="#target-3" title="Step 3 Title" position="top">Step 3 Content</TourStep>
{/snippet}

{@render targets()}

{#if demo === "guided" || demo === "i18n"}
	{#if controlled}
		<Tour
			bind:active
			bind:step
			{...props}
			onStepChange={(s) => record(`change:${s}`)}
			onStepOpen={(s) => record(`open:${s}`)}
			onStepClose={(s) => record(`close:${s}`)}
			onClose={() => record("onClose")}
		>
			{@render steps()}
		</Tour>
	{:else}
		<Tour
			bind:active
			{...props}
			onStepChange={(s) => record(`change:${s}`)}
			onStepOpen={(s) => record(`open:${s}`)}
			onStepClose={(s) => record(`close:${s}`)}
			onClose={() => record("onClose")}
		>
			{@render steps()}
		</Tour>
	{/if}
{:else if demo === "centered"}
	<Tour bind:active {...props}>
		<TourStep target="#target-1" title="Targeted">Next one has no target</TourStep>
		<TourStep title="Centered">Welcome</TourStep>
	</Tour>
{:else if demo === "late"}
	<Tour bind:active {...props}>
		<TourStep target="#late-target" title="Late">Mounted later</TourStep>
	</Tour>
{:else if demo === "scroll"}
	<Tour bind:active {...props} scrollToHandler={(el) => (scrolled = [...scrolled, el.id])}>
		{@render steps()}
	</Tour>
	<p class="px-6 text-xs" data-testid="scrolled">{scrolled.join(",")}</p>
{:else if demo === "layout"}
	<SvelixLayout shared={{ radius: "xs", motion: { animate: false } }} tour={{ withCloseButton: false, zIndex: 4321 }}>
		<Tour bind:active {...props}>
			{@render steps()}
		</Tour>
	</SvelixLayout>
{:else if demo === "root"}
	<TourRoot bind:active={rootActive} bind:step={rootStep} stepsCount={2} color="#0d9488" onClose={() => record("onClose")}>
		<TourOverlay targetRect={rootRect} />
		<TourTooltip mounted targetElement={document.getElementById(`target-${rootStep + 1}`)} data-testid="root-tooltip">
			<TourCloseButton />
			<TourTitle>Root step {rootStep + 1}</TourTitle>
			<TourBody>Composed from parts</TourBody>
			<TourNavigation />
		</TourTooltip>
	</TourRoot>
	<p class="px-6 text-xs" data-testid="root-state">active={rootActive} step={rootStep}</p>
{/if}
```

#### Guided (`Guided`)

```ts
export const Guided: Story = {
	args: { demo: 'guided' },
};
```

#### Keyboard (`Keyboard`)

```ts
export const Keyboard: Story = {
	args: { demo: 'guided', withOverlay: false },
};
```

#### Last Step Arrow (`LastStepArrow`)

```ts
export const LastStepArrow: Story = {
	args: { demo: 'guided', withOverlay: false, defaultStep: 2 },
};
```

#### Right To Left (`RightToLeft`)

```ts
export const RightToLeft: Story = {
	args: { demo: 'guided', withOverlay: false, dir: 'rtl' },
};
```

#### No Keyboard (`NoKeyboard`)

```ts
export const NoKeyboard: Story = {
	args: { demo: 'guided', withOverlay: false, withKeyboardNavigation: false, closeOnEscape: false },
};
```

#### Controlled (`Controlled`)

```ts
export const Controlled: Story = {
	args: { demo: 'guided', withOverlay: false, controlled: true },
};
```

#### Labels (`Labels`)

```ts
export const Labels: Story = {
	args: {
		demo: 'guided',
		withOverlay: false,
		defaultStep: 1,
		labels: { skip: 'Saltar', back: 'Atrás', next: 'Siguiente', close: 'Cerrar', stepCounter: (c, t) => `${c} de ${t}` }
	},
};
```

#### Translated (`Translated`)

```ts
export const Translated: Story = {
	args: { demo: 'i18n', withOverlay: false },
};
```

#### Styling (`Styling`)

```ts
export const Styling: Story = {
	args: { demo: 'guided', zIndex: 321, overlayColor: 'rgb(1, 2, 3)', tooltipShadow: 'lg', color: 'teal', spotlightRadius: 20 },
};
```

#### Overlay Interaction (`OverlayInteraction`)

```ts
export const OverlayInteraction: Story = {
	args: { demo: 'guided', withOverlayInteraction: true, closeOnOverlayClick: true },
};
```

#### Overlay Click (`OverlayClick`)

```ts
export const OverlayClick: Story = {
	args: { demo: 'guided' },
};
```

#### Centered (`Centered`)

```ts
export const Centered: Story = {
	args: { demo: 'centered', maxWidth: 300 },
};
```

#### Late Target (`LateTarget`)

```ts
export const LateTarget: Story = {
	args: { demo: 'late', withOverlay: false },
};
```

#### Placement (`Placement`)

```ts
export const Placement: Story = {
	args: { demo: 'guided', withOverlay: false, defaultStep: 2 },
};
```

#### Scroll Handler (`ScrollHandler`)

```ts
export const ScrollHandler: Story = {
	args: { demo: 'scroll', withOverlay: false },
};
```

#### No Scroll (`NoScroll`)

```ts
export const NoScroll: Story = {
	args: { demo: 'scroll', withOverlay: false, withScrollIntoView: false },
};
```

#### Beacon (`Beacon`)

```ts
export const Beacon: Story = {
	args: { demo: 'guided', mode: 'beacon', noBeaconOnStep2: true },
};
```

#### Root (`Root`)

```ts
export const Root: Story = {
	args: { demo: 'root' },
};
```

#### Layout (`Layout`)

```ts
export const Layout: Story = {
	args: { demo: 'layout' },
};
```

---

# Types

Import: `import { ... } from '@warky/svelix/Types'` (or from `'@warky/svelix'`).

## Exports

```ts
export type * from "./generic_grid";
```

## Props

### FilterValueControl.svelte

```ts
interface Props {
		column: ControlColumn;
		op: NonNullable<GridColumnFilter['op']>;
		value: string | string[];
		label?: string;
		labels?: string[];
		onChange: (value: string | string[], label?: string, labels?: string[]) => void;
		onApply: () => void;
	}
```

---

# VTree

Import: `import { ... } from '@warky/svelix/VTree'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as VTree } from './VTree.svelte';
export * from './types.js';
export * from './VTreeResolveSpecAdapter.js';

export { VTREE_DEFAULTS, VTREE_LABELS } from './vtreeLayout.js';
export type { VTreeLayoutConfig } from './vtreeLayout.js';
```

## Types (types.ts)

```ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Snippet } from 'svelte';
import type { FilterOption, Options, SortOption } from '@warkypublic/resolvespec-js';

export type VTreeClientType = 'body' | 'header';
export type VTreeNodeKey = string | number;
export type VTreeRecord = Record<string, any>;

export interface VTreeContextMenuItem<T extends VTreeRecord = VTreeRecord> {
  id: string;
  label: string;
  disabled?: boolean;
  /** SVG markup string (see `Icons/strings`), rendered before the label. */
  icon?: string;
  /** Tints the item's icon and label. */
  color?: 'success' | 'warning' | 'danger' | 'info';
  /** Use 'separator' to render a divider line. */
  kind?: 'item' | 'separator';
  /** Renders the item as a submenu parent (one level). `onselect` is ignored on parents. */
  children?: VTreeContextMenuItem<T>[];
  onselect?: (node: T | null, treeData: T[]) => void;
}

export type VTreeBuiltinMenuKey =
  | 'expandCollapse'
  | 'expand'
  | 'collapse'
  | 'expandChildren'
  | 'collapseSiblings'
  | 'expandAll'
  | 'collapseAll'
  | 'checkUncheck'
  | 'check'
  | 'uncheck'
  | 'checkChildren'
  | 'uncheckChildren'
  | 'checkAll'
  | 'uncheckAll';

export type VTreeBuiltinMenuProps = Partial<Record<VTreeBuiltinMenuKey, Partial<VTreeContextMenuItem>>>;

export interface VTreeServerAdapter<T extends VTreeRecord = VTreeRecord> {
  readRoot(options?: Partial<Options>): Promise<T[]>;
  readChildren(node: T, options?: Partial<Options>): Promise<T[]>;
  search(query: string, options?: Partial<Options>): Promise<T[]>;
}

export interface VTreeResolveSpecAdapterConfig<T extends VTreeRecord = VTreeRecord> {
  baseUrl: string;
  token?: string;
  schema: string;
  entity: string;
  clientType?: VTreeClientType;
  idField?: keyof T & string;
  parentIdField?: keyof T & string;
  labelField?: keyof T & string;
  descriptionField?: keyof T & string;
  hasChildrenField?: keyof T & string;
  rootValue?: VTreeNodeKey | null;
  columns?: string[];
  defaultLimit?: number;
  sort?: SortOption[];
  filters?: FilterOption[];
  rootFilters?: FilterOption[] | ((options?: Partial<Options>) => FilterOption[]);
  childFilters?: FilterOption[] | ((node: T, options?: Partial<Options>) => FilterOption[]);
  searchColumns?: string[];
  searchFilters?: FilterOption[] | ((query: string, options?: Partial<Options>) => FilterOption[]);
  mapNode?: (row: any) => T;
}

export interface VTreeProps<T extends VTreeRecord = VTreeRecord> {
  data?: T[];
  adapter?: VTreeServerAdapter<T>;
  idField?: keyof T & string;
  labelField?: keyof T & string;
  descriptionField?: keyof T & string;
  childrenField?: keyof T & string;
  hasChildrenField?: keyof T & string;
  /** Field rendered as a badge/code in a secondary column at the right of the row, before `rightSection`. */
  secondaryField?: keyof T & string;
  /** Where the description renders: beside the label (`inline`) or under it (`below`). Use `autoMeasure` or a taller `rowHeight` with `below`. */
  descriptionPlacement?: 'inline' | 'below';
  /** Draws a vertical guide line per indent level. */
  showGuides?: boolean;
  /** Expand every node that has children on first render. Static `data` only (ignored with `adapter`). */
  defaultExpanded?: boolean;
  /** Expand nodes up to this level on first render (1 = roots). Static `data` only (ignored with `adapter`). Wins over `defaultExpanded`. */
  expandLevel?: number;
  height?: number | string;
  /**
   * Tree title at the left of the toolbar, before the search.
   * Text never hides: it shrinks and trims with an ellipsis. A snippet hides when the toolbar is very narrow.
   */
  title?: string | Snippet;
  /** Rendered at the very start of the toolbar, before the title. */
  toolbarStart?: Snippet;
  /** Rendered in the middle of the toolbar, between the search and the menu button. */
  toolbarCenter?: Snippet;
  /** Rendered at the right end of the toolbar, after the menu button. */
  toolbarEnd?: Snippet;
  /** Full-width row rendered below the toolbar, above the tree (e.g. a description or summary). */
  toolbarBelow?: Snippet;
  /** Full-width row rendered below the tree (e.g. totals or notes). */
  gridFooter?: Snippet;
  /** Side panel to the left of the tree, below the toolbar. The tree shrinks to fit. */
  leftContent?: Snippet;
  /** Side panel to the right of the tree, below the toolbar. The tree shrinks to fit. */
  rightContent?: Snippet;
  /** Replaces `emptyMessage` when there are no rows (not shown while loading or on a load error). */
  empty?: Snippet;
  rowHeight?: number | ((node: T, level: number, index: number) => number);
  autoMeasure?: boolean;
  indent?: number;
  overscan?: number;
  searchable?: boolean;
  searchPlaceholder?: string;
  searchValue?: string;
  serverSideSearch?: boolean;
  autoExpandSearchResults?: boolean;
  loadOnMount?: boolean;
  emptyMessage?: string;
  loadingMessage?: string;
  leftSection?: Snippet<[{
    node: T;
    level: number;
    rowIndex: number;
    treeData: T[];
  }]>;
  rightSection?: Snippet<[{
    node: T;
    level: number;
    rowIndex: number;
    treeData: T[];
  }]>;
  contextMenuItems?:
    | VTreeContextMenuItem<T>[]
    | ((node: T | null, treeData: T[]) => VTreeContextMenuItem<T>[]);
  /**
   * Per-item overrides for the built-in menu entries. Each override is merged onto the
   * item's default, so any `VTreeContextMenuItem` field (label, icon, color, disabled,
   * onselect...) can be overridden. `expandCollapse` is the submenu parent; `checkUncheck` (submenu parent), `check`, `uncheck`, `checkChildren`, `uncheckChildren`, `checkAll` and `uncheckAll` only exist in checkbox selection mode.
   */
  menuItemProps?: VTreeBuiltinMenuProps;
  onselect?: (node: T) => void;
  onclick?: (node: T, treeData: T[], event: MouseEvent) => void;
  ondoubleclick?: (node: T, treeData: T[], event: MouseEvent) => void;
  oncontext?: (node: T | null, treeData: T[], event: MouseEvent) => void;
  ontoggle?: (node: T, expanded: boolean) => void;
  onsearch?: (query: string) => void;
  onloaderror?: (error: unknown, node?: T) => void;

  iconField?: keyof T & string;
  icon?: Snippet<[{ node: T; level: number; rowIndex: number; treeData: T[] }]>;
  /** Optional icon at the right end of each row. Render anything; render nothing to omit it for a row. Takes precedence over `getRowRightSection`. */
  rowRightSection?: Snippet<[{ node: T; level: number; rowIndex: number; treeData: T[] }]>;
  /**
   * Callback alternative to `rowRightSection`: return text/emoji or an image URL/data URI, or
   * nothing to omit the icon for that row. (A snippet and a function can't be told apart at runtime, hence a separate prop.)
   */
  getRowRightSection?: (args: { node: T; level: number; rowIndex: number; treeData: T[] }) => string | number | null | undefined;
  classes?: VTreeClassOverrides;
  showRowSeparator?: boolean;
  hoverEffect?: boolean;

  /** Controlled single-selection. Undefined means uncontrolled; null means explicitly empty. */
  selected?: T | null;
  /** Enables selectedItems/onSelectedItemsChange multi-selection. */
  multiple?: boolean;
  selectionMode?: 'modifier' | 'checkbox';
  // Deliberately camelCase to match Gridler controlled-prop convention; legacy events stay lowercase.
  onSelectedChange?: (node: T | null) => void;
  selectedItems?: T[];
  onSelectedItemsChange?: (items: T[]) => void;

  onsingleclick?: (node: T, treeData: T[], event: MouseEvent) => void;
  singleClickDelay?: number;
  onlongpress?: (node: T, treeData: T[], event: PointerEvent) => void;
  longPressDelay?: number;
  longPressMoveTolerance?: number;
  onrowhover?: (node: T, treeData: T[], rowIndex: number, event: PointerEvent) => void;
  onrowhoverend?: (node: T, treeData: T[], rowIndex: number, event: PointerEvent) => void;
}

export interface VTreeClassOverrides {
  row?: string;
  rowSeparator?: string;
  rowFocused?: string;
  rowSelected?: string;
  rowHover?: string;
  expandIcon?: string;
  indentGuide?: string;
  label?: string;
  /** Added to the label of rows that have children. */
  parentLabel?: string;
  /** Added to the label of rows without children. */
  leafLabel?: string;
  /** Vertical guide line segment (see `showGuides`). */
  guide?: string;
  /** Secondary column badge (see `secondaryField`). */
  secondary?: string;
  description?: string;
  iconWrapper?: string;
  rowRightSectionWrapper?: string;
}
```

## Props

### VTree.svelte

```ts
export type Props<T extends VTreeRecord = VTreeRecord> = VTreeProps<T>;
```

## Examples (from Storybook stories)

### Components/VTree  (`VTree.stories.ts`)

Shared setup:

```ts
import VTree from './VTree.svelte';

import VTreeEventsDemo from './VTreeEventsDemo.svelte';

import VTreeCustomizationDemo from './VTreeCustomizationDemo.svelte';

import VTreeInteractionDemo from './VTreeInteractionDemo.svelte';

import VTreeControlledSelectionDemo from './VTreeControlledSelectionDemo.svelte';

import VTreeMultiSelectDemo from './VTreeMultiSelectDemo.svelte';

import VTreeRowRightSectionDemo from './VTreeRowRightSectionDemo.svelte';

import VTreeTitleDemo from './VTreeTitleDemo.svelte';

import VTreeCheckboxDemo from './VTreeCheckboxDemo.svelte';

import VTreeAllSnippetsDemo from './VTreeAllSnippetsDemo.svelte';

import type { VTreeRecord, VTreeServerAdapter } from './types';

function makeLocalTree(): DemoNode[] {
  return Array.from({ length: 24 }, (_, i) => {
    const parent = i + 1;
    return {
      id: `group-${parent}`,
      label: `Group ${parent}`,
      description: 'Local branch',
      hasChildren: true,
      children: Array.from({ length: 20 }, (_, j) => ({
        id: `group-${parent}-item-${j + 1}`,
        label: `Item ${parent}.${j + 1}`,
        description: j % 4 === 0 ? 'Searchable term: alpha' : 'Leaf row'
      }))
    };
  });
}

const localTreeData = makeLocalTree();

class DemoLazyAdapter implements VTreeServerAdapter<DemoNode> {
  private readonly roots: DemoNode[];
  private readonly childrenMap: Record<string, DemoNode[]>;

  constructor() {
    this.roots = Array.from({ length: 12 }, (_, i) => ({
      id: `srv-root-${i + 1}`,
      label: `Server Root ${i + 1}`,
      description: 'Loaded from adapter',
      hasChildren: true
    }));

    this.childrenMap = {};
    for (const root of this.roots) {
      this.childrenMap[root.id] = Array.from({ length: 30 }, (_, j) => ({
        id: `${root.id}-child-${j + 1}`,
        label: `Node ${root.id.split('-').pop()}.${j + 1}`,
        description: j % 6 === 0 ? 'contains keyword beta' : 'Child node',
        hasChildren: j % 7 === 0
      }));
    }

    for (const [parentId, nodes] of Object.entries(this.childrenMap)) {
      for (const node of nodes) {
        if (!node.hasChildren) continue;
        this.childrenMap[node.id] = Array.from({ length: 10 }, (_, k) => ({
          id: `${node.id}-leaf-${k + 1}`,
          label: `Leaf ${parentId}.${k + 1}`,
          description: 'Nested lazy child'
        }));
      }
    }
  }

  async readRoot(): Promise<DemoNode[]> {
    await new Promise((resolve) => setTimeout(resolve, 120));
    return this.roots;
  }

  async readChildren(node: DemoNode): Promise<DemoNode[]> {
    await new Promise((resolve) => setTimeout(resolve, 180));
    return this.childrenMap[node.id] ?? [];
  }

  async search(query: string): Promise<DemoNode[]> {
    await new Promise((resolve) => setTimeout(resolve, 120));
    const q = query.toLowerCase();
    const allNodes = [...this.roots, ...Object.values(this.childrenMap).flat()];
    return allNodes.filter((node) =>
      `${node.label} ${node.description ?? ''}`.toLowerCase().includes(q)
    );
  }
}

const lazyAdapter = new DemoLazyAdapter();

const codedTreeData = localTreeData.slice(0, 4).map((group, i) => ({
  ...group,
  code: `G-${i + 1}`,
  children: group.children.slice(0, 5).map((item, j) => ({ ...item, code: `I-${i + 1}.${j + 1}` }))
}));
```

Helper component `./VTreeEventsDemo.svelte`:

```svelte
<script lang="ts">
  // @ts-nocheck
  import VTree from './VTree.svelte';
  import type { VTreeContextMenuItem, VTreeRecord } from './types.js';

  interface DemoNode extends VTreeRecord {
    id: string;
    label: string;
    description?: string;
    children?: DemoNode[];
    hasChildren?: boolean;
  }

  const data: DemoNode[] = [
    {
      id: 'alpha',
      label: 'Alpha Team',
      description: 'Top-level group',
      hasChildren: true,
      children: [
        { id: 'alpha-1', label: 'Alpha User 1', description: 'Owner' },
        { id: 'alpha-2', label: 'Alpha User 2', description: 'Contributor' }
      ]
    },
    {
      id: 'beta',
      label: 'Beta Team',
      description: 'Top-level group',
      hasChildren: true,
      children: [
        { id: 'beta-1', label: 'Beta User 1', description: 'Owner' },
        { id: 'beta-2', label: 'Beta User 2', description: 'Contributor' }
      ]
    }
  ];

  let eventLog = $state('No row event yet.');

  function printEvent(kind: string, row: DemoNode | null, treeData: DemoNode[]) {
    eventLog = `${kind}: ${row?.label ?? 'null'} | tree roots: ${treeData.length}`;
  }

  const menuItems: VTreeContextMenuItem<DemoNode>[] = [
    {
      id: 'open',
      label: 'Open',
      onselect: (row) => {
        eventLog = `menu: open -> ${row?.label ?? 'null'}`;
      }
    },
    {
      id: 'inspect',
      label: 'Inspect Node',
      onselect: (row, treeData) => {
        eventLog = `menu: inspect -> ${row.id} (roots: ${treeData.length})`;
      }
    }
  ];
</script>

{#snippet leftContent(args)}
  <span class="inline-flex h-4 w-4 items-center justify-center rounded bg-surface-200-700 text-[10px]">
    {args.level}
  </span>
{/snippet}

{#snippet rightContent(args)}
  <span class="text-[10px] text-surface-500-400-token">#{args.rowIndex + 1}</span>
{/snippet}

<div class="space-y-2">
  <VTree
    data={data}
    height={300}
    rowHeight={34}
    searchable
    leftSection={leftContent}
    rightSection={rightContent}
    contextMenuItems={menuItems}
    onclick={(row, treeData) => printEvent('click', row, treeData)}
    ondoubleclick={(row, treeData) => printEvent('double-click', row, treeData)}
    oncontext={(row, treeData) => printEvent('context', row, treeData)}
  />

  <div class="card border border-surface-300-700 bg-surface-50-950 p-2 text-xs text-surface-700-200-token">
    {eventLog}
  </div>
</div>
```

Helper component `./VTreeCustomizationDemo.svelte`:

```svelte
<script lang="ts">
  // @ts-nocheck
  import VTree from './VTree.svelte';

  const data = [
    { id: 'folder', label: 'Folder', icon: '📁', children: [{ id: 'doc', label: 'Document', icon: '📄' }] },
    { id: 'settings', label: 'Settings', icon: '⚙️' }
  ];
</script>

{#snippet customIcon({ node })}
  <span aria-hidden="true">{node.id === 'folder' ? '🗂️' : '✨'}</span>
{/snippet}

<VTree {data} iconField="icon" icon={customIcon} searchable={false} />
```

Helper component `./VTreeInteractionDemo.svelte`:

```svelte
<script lang="ts">
  import VTree from './VTree.svelte';

  const data = [{ id: 'a', label: 'Press/hover me', children: [{ id: 'b', label: 'Child row' }] }];
  let events: string[] = [];
  const log = (name: string) => (node: Record<string, unknown>) => {
    events = [`${name}: ${String(node.label)}`, ...events].slice(0, 8);
  };
</script>

<div class="space-y-2">
  <VTree
    {data}
    searchable={false}
    onclick={log('click')}
    onsingleclick={log('single')}
    ondoubleclick={log('double')}
    onlongpress={log('long')}
    onrowhover={(node) => { events = [`hover: ${String(node.label)}`, ...events].slice(0, 8); }}
  />
  <ul class="text-xs">
    {#each events as event, i (i)}<li>{event}</li>{/each}
  </ul>
</div>
```

Helper component `./VTreeControlledSelectionDemo.svelte`:

```svelte
<script lang="ts">
  import VTree from './VTree.svelte';

  const data = [{ id: 'root', label: 'Root', children: [{ id: 'deep', label: 'Deep child', children: [{ id: 'leaf', label: 'Leaf' }] }] }];
  let selected: Record<string, unknown> | null = null;
</script>

<div class="space-y-2">
  <button class="btn preset-filled-primary-500" type="button" onclick={() => selected = data[0].children[0].children[0]}>Select leaf</button>
  <VTree {data} {selected} onSelectedChange={(node) => selected = node} searchable={false} />
</div>
```

Helper component `./VTreeMultiSelectDemo.svelte`:

```svelte
<script lang="ts">
  import VTree from './VTree.svelte';

  const data = [{ id: 'a', label: 'Alpha' }, { id: 'b', label: 'Beta' }, { id: 'c', label: 'Gamma' }];
  let selectedItems: Record<string, unknown>[] = [];
  let selectionMode: 'modifier' | 'checkbox' = 'checkbox';
</script>

<div class="space-y-2">
  <select bind:value={selectionMode}>
    <option value="checkbox">checkbox</option>
    <option value="modifier">modifier</option>
  </select>
  <VTree {data} multiple {selectionMode} {selectedItems} onSelectedItemsChange={(items) => selectedItems = items} searchable={false} />
  <p class="text-xs">Selected: {selectedItems.map((item) => item.label).join(', ') || 'none'}</p>
</div>
```

Helper component `./VTreeRowRightSectionDemo.svelte`:

```svelte
<script lang="ts">
  import VTree from './VTree.svelte';

  const data = Array.from({ length: 8 }, (_, i) => ({
    id: `n${i}`,
    label: `Item ${i + 1}`,
    status: i % 3 === 0 ? 'locked' : i % 3 === 1 ? 'starred' : undefined,
    children: [{ id: `n${i}-c`, label: `Child ${i + 1}`, status: i % 2 === 0 ? 'starred' : undefined }]
  }));
</script>

{#snippet rowRightSection({ node }: { node: Record<string, unknown> })}
  {#if node.status === 'locked'}
    <span title="Locked">🔒</span>
  {:else if node.status === 'starred'}
    <span class="badge preset-filled-warning-500 px-1 text-xs">★</span>
  {/if}
{/snippet}

<VTree {data} {rowRightSection} height={420} searchable={false} />
```

Helper component `./VTreeTitleDemo.svelte`:

```svelte
<script lang="ts">
  import VTree from './VTree.svelte';

  const data = [
    { id: 'a', label: 'Alpha', children: [{ id: 'a1', label: 'Alpha 1' }, { id: 'a2', label: 'Alpha 2' }] },
    { id: 'b', label: 'Beta', children: [{ id: 'b1', label: 'Beta 1' }] },
    { id: 'c', label: 'Gamma' }
  ];
  const longTitle = 'Organisation structure and reporting lines';
</script>

{#snippet badgeTitle()}
  <span class="badge preset-filled">Org</span>
{/snippet}

<div class="space-y-4">
  <p class="text-xs">Text title, 480px: shown in full.</p>
  <div style="width: 480px"><VTree {data} height={200} title={longTitle} /></div>

  <p class="text-xs">Text title, 280px: stays, trimmed with an ellipsis.</p>
  <div style="width: 280px"><VTree {data} height={200} title={longTitle} /></div>

  <p class="text-xs">Snippet title, 480px: shown.</p>
  <div style="width: 480px"><VTree {data} height={200} title={badgeTitle} /></div>

  <p class="text-xs">Snippet title, 220px: hidden (toolbar under 240px).</p>
  <div style="width: 220px"><VTree {data} height={200} title={badgeTitle} /></div>
</div>
```

Helper component `./VTreeCheckboxDemo.svelte`:

```svelte
<script lang="ts">
  import VTree from './VTree.svelte';
  import type { VTreeRecord } from './types.js';

  const data = [
    {
      id: 'eng',
      label: 'Engineering',
      children: [
        { id: 'eng-fe', label: 'Frontend', children: [{ id: 'eng-fe-1', label: 'Design system' }, { id: 'eng-fe-2', label: 'Web app' }] },
        { id: 'eng-be', label: 'Backend', children: [{ id: 'eng-be-1', label: 'API' }, { id: 'eng-be-2', label: 'Jobs' }] }
      ]
    },
    { id: 'ops', label: 'Operations', children: [{ id: 'ops-1', label: 'Support' }, { id: 'ops-2', label: 'Logistics' }] },
    { id: 'fin', label: 'Finance' }
  ];

  let selectedItems = $state<VTreeRecord[]>([]);
</script>

<div class="space-y-2">
  <VTree
    {data}
    height={360}
    title="Teams"
    multiple
    selectionMode="checkbox"
    {selectedItems}
    onSelectedItemsChange={(items) => (selectedItems = items)}
  />
  <p class="text-xs">Checked ({selectedItems.length}): {selectedItems.map((item) => item.label).join(', ') || 'none'}</p>
</div>
```

Helper component `./VTreeAllSnippetsDemo.svelte`:

```svelte
<script lang="ts">
  import VTree from './VTree.svelte';

  const rows = [
    { id: 'a', label: 'Alpha', children: [{ id: 'a1', label: 'Alpha 1' }, { id: 'a2', label: 'Alpha 2' }] },
    { id: 'b', label: 'Beta', children: [{ id: 'b1', label: 'Beta 1' }] },
    { id: 'c', label: 'Gamma' }
  ];

  let showTitle = $state(true);
  let showToolbarStart = $state(true);
  let showToolbarCenter = $state(true);
  let showToolbarEnd = $state(true);
  let showToolbarBelow = $state(true);
  let showGridFooter = $state(true);
  let showLeft = $state(true);
  let showRight = $state(true);
  let showEmpty = $state(true);
  let noData = $state(false);

  const data = $derived(noData ? [] : rows);
</script>

{#snippet titleSnippet()}
  <strong>🧩 Title snippet</strong>
{/snippet}

{#snippet toolbarStartSnippet()}
  <button class="btn btn-sm preset-tonal" type="button">toolbarStart</button>
{/snippet}

{#snippet toolbarCenterSnippet()}
  <button class="btn btn-sm preset-tonal" type="button">toolbarCenter</button>
{/snippet}

{#snippet toolbarEndSnippet()}
  <button class="btn btn-sm preset-tonal" type="button">toolbarEnd</button>
{/snippet}

{#snippet toolbarBelowSnippet()}
  <span class="opacity-70">toolbarBelow: a full-width row for a description, summary or notices.</span>
{/snippet}

{#snippet gridFooterSnippet()}
  <span class="opacity-70">gridFooter: totals or notes below the tree.</span>
{/snippet}

{#snippet leftSnippet()}
  <div class="w-30 p-2 text-sm opacity-70">leftContent: a side panel.</div>
{/snippet}

{#snippet rightSnippet()}
  <div class="w-30 p-2 text-sm opacity-70">rightContent: a side panel.</div>
{/snippet}

{#snippet emptySnippet()}
  <p class="grid min-h-24 place-items-center text-sm">🫥 empty snippet: nothing to show</p>
{/snippet}

<div class="flex flex-col gap-2 p-4">
  <div class="flex flex-wrap gap-3 text-sm">
    <label><input type="checkbox" bind:checked={showTitle} /> title</label>
    <label><input type="checkbox" bind:checked={showToolbarStart} /> toolbarStart</label>
    <label><input type="checkbox" bind:checked={showToolbarCenter} /> toolbarCenter</label>
    <label><input type="checkbox" bind:checked={showToolbarEnd} /> toolbarEnd</label>
    <label><input type="checkbox" bind:checked={showToolbarBelow} /> toolbarBelow</label>
    <label><input type="checkbox" bind:checked={showGridFooter} /> gridFooter</label>
    <label><input type="checkbox" bind:checked={showLeft} /> leftContent</label>
    <label><input type="checkbox" bind:checked={showRight} /> rightContent</label>
    <label><input type="checkbox" bind:checked={showEmpty} /> empty</label>
    <label><input type="checkbox" bind:checked={noData} /> no data</label>
  </div>

  <VTree
    {data}
    height={480}
    title={showTitle ? titleSnippet : 'Title (string)'}
    toolbarStart={showToolbarStart ? toolbarStartSnippet : undefined}
    toolbarCenter={showToolbarCenter ? toolbarCenterSnippet : undefined}
    toolbarEnd={showToolbarEnd ? toolbarEndSnippet : undefined}
    toolbarBelow={showToolbarBelow ? toolbarBelowSnippet : undefined}
    gridFooter={showGridFooter ? gridFooterSnippet : undefined}
    leftContent={showLeft ? leftSnippet : undefined}
    rightContent={showRight ? rightSnippet : undefined}
    empty={showEmpty ? emptySnippet : undefined}
  />
</div>
```

#### Local Data (`LocalData`)

```ts
export const LocalData: Story = {
  args: {
    data: localTreeData,
    height: "100%",
    rowHeight: 36,
    searchable: true
  }
};
```

#### Local Large Virtualized (`LocalLargeVirtualized`)

```ts
export const LocalLargeVirtualized: Story = {
  args: {
    data: localTreeData,
    height: 540,
    rowHeight: 34,
    overscan: 12,
    searchable: true
  }
};
```

#### Server Lazy And Search (`ServerLazyAndSearch`)

```ts
export const ServerLazyAndSearch: Story = {
  args: {
    adapter: lazyAdapter,
    serverSideSearch: true,
    searchable: true,
    height: "100%",
    rowHeight: 36,
    loadOnMount: true,
    overscan: 100,
    hoverEffect: true,
    showRowSeparator: false
  }
};
```

#### Events And Sections (`EventsAndSections`)

```ts
export const EventsAndSections: Story = {
  render: () => ({ Component: VTreeEventsDemo })
};
```

#### Visual Overrides (`VisualOverrides`)

```ts
export const VisualOverrides: Story = {
  args: { data: localTreeData, searchable: false, showRowSeparator: false, hoverEffect: false }
};
```

#### Class Overrides (`ClassOverrides`)

```ts
export const ClassOverrides: Story = {
  args: {
    data: localTreeData,
    searchable: false,
    classes: { row: 'rounded-md', rowFocused: '!bg-warning-100', rowSelected: '!bg-success-100', indentGuide: 'border-l border-dashed border-surface-300' }
  }
};
```

#### Icons Field (`IconsField`)

```ts
export const IconsField: Story = {
  args: { data: localTreeData.map((node) => ({ ...node, icon: '📁' })), iconField: 'icon', searchable: false }
};
```

#### Row Right Section (`RowRightSection`)

```ts
/** `rowRightSection` snippet — renders anything at the right end of each row (here only some rows have one). */
export const RowRightSection: Story = { render: () => ({ Component: VTreeRowRightSectionDemo }) };
```

#### Row Right Section Callback (`RowRightSectionCallback`)

```ts
/** `getRowRightSection` callback — return text/emoji or an image URL per row (undefined = no icon). */
export const RowRightSectionCallback: Story = {
  args: {
    data: localTreeData,
    height: 480,
    searchable: false,
    getRowRightSection: ({ node }) => (node.hasChildren ? '📁' : node.description === 'Leaf row' ? undefined : '🔎')
  }
};
```

#### Icons Snippet (`IconsSnippet`)

```ts
export const IconsSnippet: Story = { render: () => ({ Component: VTreeCustomizationDemo }) };
```

#### Variable Height Function (`VariableHeightFunction`)

```ts
export const VariableHeightFunction: Story = {
  args: { data: localTreeData, rowHeight: (node: DemoNode) => node.description ? 56 : 32, searchable: false }
};
```

#### Variable Height Auto Measure (`VariableHeightAutoMeasure`)

```ts
export const VariableHeightAutoMeasure: Story = {
  args: { data: localTreeData, rowHeight: 32, autoMeasure: true, searchable: false }
};
```

#### Interaction Events (`InteractionEvents`)

```ts
export const InteractionEvents: Story = { render: () => ({ Component: VTreeInteractionDemo }) };
```

#### Controlled Selection (`ControlledSelection`)

```ts
export const ControlledSelection: Story = { render: () => ({ Component: VTreeControlledSelectionDemo }) };
```

#### Multi Select Modes (`MultiSelectModes`)

```ts
export const MultiSelectModes: Story = { render: () => ({ Component: VTreeMultiSelectDemo }) };
```

#### With Title (`WithTitle`)

```ts
/** `title` — text at the left of the toolbar, before the search. */
export const WithTitle: Story = {
  args: {
    data: localTreeData,
    height: 480,
    title: 'Groups'
  }
};
```

#### Title Sizing (`TitleSizing`)

```ts
/** Text titles never hide (trimmed with an ellipsis); snippet titles hide when the toolbar is under 240px. */
export const TitleSizing: Story = { render: () => ({ Component: VTreeTitleDemo }) };
```

#### Checkbox Selection (`CheckboxSelection`)

```ts
/** Checkbox selection (uncontrolled). The burger / context menu gains Check All and Uncheck All. */
export const CheckboxSelection: Story = {
  args: {
    data: localTreeData,
    height: 480,
    multiple: true,
    selectionMode: 'checkbox'
  }
};
```

#### Checkbox Controlled (`CheckboxControlled`)

```ts
/** Controlled checkbox selection on a nested tree, with a live list of the checked rows. */
export const CheckboxControlled: Story = { render: () => ({ Component: VTreeCheckboxDemo }) };
```

#### All Snippets (`AllSnippets`)

```ts
/** Toggle every snippet slot on/off: `title`, `toolbarStart`, `toolbarCenter`, `toolbarEnd`, `toolbarBelow`, `gridFooter`, `leftContent`, `rightContent`, `empty` (tick "no data"). */
export const AllSnippets: Story = { render: () => ({ Component: VTreeAllSnippetsDemo }) };
```

#### Expand Level (`ExpandLevel`)

```ts
export const ExpandLevel: Story = {
  args: { data: codedTreeData, expandLevel: 1, searchable: false }
};
```

#### Default Expanded (`DefaultExpanded`)

```ts
export const DefaultExpanded: Story = {
  args: { data: codedTreeData, defaultExpanded: true, searchable: false }
};
```

#### Guides And Secondary (`GuidesAndSecondary`)

```ts
export const GuidesAndSecondary: Story = {
  args: {
    data: codedTreeData,
    defaultExpanded: true,
    showGuides: true,
    secondaryField: 'code',
    descriptionPlacement: 'below',
    autoMeasure: true,
    rowHeight: 44,
    searchable: false
  }
};
```

---

# Windowz

Import: `import { ... } from '@warky/svelix/Windowz'` (or from `'@warky/svelix'`).

## Exports

```ts
export { default as Windowz } from "./Windowz.svelte";
export { default as WindowzBar } from "./WindowzBar.svelte";
export { default as WindowzProvider } from "./WindowzProvider.svelte";
export { WINDOWZ_BAR_DEFAULTS, WINDOWZ_DEFAULTS } from "./windowzConfig.js";
export { clampPosition, clampRect, maximizedRect, resizeRect } from "./windowzGeometry.js";
export type { WindowzRect, WindowzResizeDir } from "./windowzGeometry.js";
export { createWindowzManager, getWindowzManager, setWindowzManager, WindowzManager, windowz } from "./windowzManager.svelte.js";
export type { WindowzRegistration } from "./windowzManager.svelte.js";
export type * from "./types.js";
```

## Summary

Windowz: draggable, resizable floating windows (Mantine FloatingWindow port plus minimize/maximize) behind a window manager that owns visibility and z-order. `WindowzBar` renders a show/hide bar anywhere. Defaults via `SvelixLayout` keys `windowz` and `windowzBar`.

## Types (types.ts)

```ts
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import type { SvelixColor, SvelixRadius, SvelixSizeName } from "../../utils/scale.js";
import type { WindowzManager } from "./windowzManager.svelte.js";

export interface WindowzPoint {
	x: number;
	y: number;
}

export interface WindowzSize {
	width: number;
	height: number;
}

export interface WindowzStorage {
	getItem(key: string): string | null;
	setItem(key: string, value: string): void;
}

export type WindowzShadow = "none" | "sm" | "md" | "lg" | "xl";

/** Window state the manager (and bar) tracks. */
export interface WindowzEntry {
	id: string;
	title: string;
	icon?: Snippet;
	visible: boolean;
	minimized: boolean;
	maximized: boolean;
}

/** `SvelixLayout` key `windowz`. */
export interface WindowzLayoutConfig {
	/** Render through `Portal`. @default true */
	withinPortal?: boolean;
	/** @default true */
	withBorder?: boolean;
	/** @default "container" */
	radius?: SvelixRadius;
	/** @default "lg" */
	shadow?: WindowzShadow;
	/** Keep the window inside the viewport. @default true */
	constrainToViewport?: boolean;
	/** Viewport inset in px. @default 0 */
	constrainOffset?: number;
	/** @default true */
	draggable?: boolean;
	/** @default true */
	resizable?: boolean;
	/** @default 200 */
	minWidth?: number;
	/** @default 120 */
	minHeight?: number;
	/** @default true */
	closable?: boolean;
	/** @default true */
	minimizable?: boolean;
	/** @default true */
	maximizable?: boolean;
	/** Escape hides the focused window. @default false */
	closeOnEscape?: boolean;
}

export interface WindowzProps
	extends WindowzLayoutConfig,
		Omit<HTMLAttributes<HTMLDivElement>, "children" | "class" | "style" | "title" | "role" | "draggable"> {
	/** Unique within a manager. Default generated. */
	id?: string;
	/** Header text and the bar label. */
	title?: string;
	/** Header / bar icon. */
	icon?: Snippet;
	/** Initial visibility. @default true */
	defaultOpen?: boolean;
	/** Viewport coordinates of the top-left corner. @default { x: 80, y: 80 } */
	position?: WindowzPoint;
	/** @default { width: 360, height: 240 } */
	size?: WindowzSize;
	/** Elements inside the window that start a drag. @default the header */
	dragHandleSelector?: string;
	/** Descendants of the handle that never start a drag. @default buttons, inputs, links */
	excludeDragHandleSelector?: string;
	manager?: WindowzManager;
	/** Persist position, size and visibility under this key. Off without it; must be unique per window. */
	autoSaveId?: string;
	/** @default localStorage */
	storage?: WindowzStorage;
	onPositionChange?: (position: WindowzPoint) => void;
	onSizeChange?: (size: WindowzSize) => void;
	onDragStart?: (position: WindowzPoint) => void;
	onDragEnd?: (position: WindowzPoint) => void;
	onFocus?: () => void;
	onClose?: () => void;
	/** Close / minimize / maximize button `aria-label`s. Default i18n `svelix.windowz.*`. */
	labels?: Partial<WindowzLabels>;
	class?: string;
	style?: string;
	contentClass?: string;
	/** Extra header controls, before the window buttons. */
	headerActions?: Snippet;
	children?: Snippet;
}

export interface WindowzLabels {
	close: string;
	minimize: string;
	maximize: string;
	restore: string;
}

export type WindowzBarOrientation = "horizontal" | "vertical";
export type WindowzBarPlacement = "static" | "top" | "bottom" | "left" | "right";
export type WindowzBarAlign = "start" | "center" | "end";

/** `SvelixLayout` key `windowzBar`. */
export interface WindowzBarLayoutConfig {
	/** @default "horizontal" */
	orientation?: WindowzBarOrientation;
	/** `static` renders in place; edges pin the bar to the viewport. @default "static" */
	placement?: WindowzBarPlacement;
	/** Position along the pinned edge. @default "center" */
	align?: WindowzBarAlign;
	/** Look of the active window's item. @default "light" */
	variant?: "filled" | "light" | "outline" | "transparent" | "white" | "default";
	/** @default "primary" */
	color?: SvelixColor;
	/** @default "sm" */
	size?: SvelixSizeName;
	/** @default "container" */
	radius?: SvelixRadius;
	/** Gap in px. @default 4 */
	gap?: number;
	/** Show window titles next to icons. @default true */
	withLabels?: boolean;
	/** Bring forward / send backward buttons per item. @default false */
	withOrderControls?: boolean;
	/** Backdrop container look (`bar` wrapper). @default true */
	withBackground?: boolean;
	/** @default 9000 */
	zIndex?: number;
}

export interface WindowzBarItemState {
	active: boolean;
	hidden: boolean;
	minimized: boolean;
	index: number;
}

export interface WindowzBarProps
	extends WindowzBarLayoutConfig,
		Omit<HTMLAttributes<HTMLDivElement>, "children" | "class" | "style" | "role" | "color"> {
	manager?: WindowzManager;
	/** Which windows to list. @default all */
	filter?: (entry: WindowzEntry) => boolean;
	/** Replace an item. Call `toggle()` to run the default click behaviour. */
	item?: Snippet<[entry: WindowzEntry, state: WindowzBarItemState, toggle: () => void]>;
	empty?: Snippet;
	class?: string;
	style?: string;
}

export interface WindowzProviderProps {
	manager?: WindowzManager;
	baseZIndex?: number;
	children?: Snippet;
}
```

## Examples (from Storybook stories)

### Components/Windowz  (`Windowz.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import WindowzDemo from './WindowzDemo.fixtures.svelte';

const win = (name: string) => document.querySelector(`#${name}`) as HTMLElement;

const PERSIST_KEY = 'story-windowz';
```

Helper component `./WindowzDemo.fixtures.svelte`:

```svelte
<script lang="ts">
	import Windowz from "./Windowz.svelte";
	import WindowzBar from "./WindowzBar.svelte";
	import WindowzProvider from "./WindowzProvider.svelte";
	import { createWindowzManager } from "./windowzManager.svelte.js";
	import type { WindowzBarProps, WindowzProps } from "./types.js";

	export interface Props {
		demo?: "basic" | "bar" | "custom";
		bar?: Omit<WindowzBarProps, "manager" | "item" | "empty">;
		window?: Omit<WindowzProps, "manager" | "children" | "id" | "title">;
		/** Persist each window as `<autoSaveId>-<id>`. */
		autoSaveId?: string;
	}

	let { demo = "basic", bar = {}, window: win = {}, autoSaveId }: Props = $props();
	const manager = createWindowzManager();
	const apps = [
		{ id: "notes", title: "Notes", x: 60, y: 60 },
		{ id: "files", title: "Files", x: 200, y: 120 },
		{ id: "logs", title: "Logs", x: 340, y: 180 }
	];
</script>

<WindowzProvider {manager}>
	<div class="flex flex-wrap items-center gap-2 pb-3">
		<WindowzBar data-testid="bar" {...bar} />
		{#if demo !== "basic"}
			<button type="button" class="btn btn-sm preset-tonal" data-testid="hide-all" onclick={manager.hideAll}>Hide all</button>
			<button type="button" class="btn btn-sm preset-tonal" data-testid="show-all" onclick={manager.showAll}>Show all</button>
		{/if}
	</div>

	{#each apps as app (app.id)}
		<Windowz id={app.id} title={app.title} autoSaveId={autoSaveId ? `${autoSaveId}-${app.id}` : undefined} position={{ x: app.x, y: app.y }} {...win}>
			<p class="text-sm">{app.title} window. Drag the header, resize from the edges, double-click the header to maximize.</p>
			<div class="mt-2 flex flex-wrap gap-1">
				<button type="button" class="btn btn-sm preset-tonal" onclick={() => manager.bringForward(app.id)}>Forward</button>
				<button type="button" class="btn btn-sm preset-tonal" onclick={() => manager.sendBackward(app.id)}>Backward</button>
				<button type="button" class="btn btn-sm preset-tonal" onclick={() => manager.bringToFront(app.id)}>To front</button>
				<button type="button" class="btn btn-sm preset-tonal" onclick={() => manager.sendToBack(app.id)}>To back</button>
			</div>
		</Windowz>
	{/each}
</WindowzProvider>
```

#### Default (`Default`)

```ts
export const Default: Story = {
	args: { demo: 'basic' },
};
```

#### Bar Vertical (`BarVertical`)

```ts
export const BarVertical: Story = {
	args: { demo: 'bar', bar: { orientation: 'vertical', placement: 'left', align: 'center', withOrderControls: true, variant: 'filled' } }
};
```

#### Bar Bottom Compact (`BarBottomCompact`)

```ts
export const BarBottomCompact: Story = {
	args: { demo: 'bar', bar: { placement: 'bottom', size: 'xs', color: 'secondary', withLabels: true } }
};
```

#### No Border No Resize (`NoBorderNoResize`)

```ts
export const NoBorderNoResize: Story = {
	args: { demo: 'bar', window: { withBorder: false, resizable: false, shadow: 'xl', radius: 'xs' } }
};
```

#### Constrain Offset (`ConstrainOffset`)

```ts
export const ConstrainOffset: Story = {
	args: { demo: 'bar', window: { constrainOffset: 24, closeOnEscape: true } }
};
```

#### Keyboard Move (`KeyboardMove`)

```ts
export const KeyboardMove: Story = {
	args: { demo: 'basic' },
};
```

#### Persisted (`Persisted`)

```ts
export const Persisted: Story = {
	args: { demo: 'bar', autoSaveId: PERSIST_KEY },
};
```

---

# legacy

Import: `import { ... } from '@warky/svelix/legacy'` (or from `'@warky/svelix'`).

## Types (Svark/types.ts)

```ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Snippet } from 'svelte';
import type { IColumnConfig as GridColumnConfig, IConfig } from '@svar-ui/svelte-grid';
import type { FilterOption, Options, SortOption } from '@warkypublic/resolvespec-js';

export type IColumnConfig = GridColumnConfig & {
	/**
	 * When `serverSide` search is used (Ctrl/Cmd+F), only columns with `searchable: true`
	 * are sent to the API as OR `ilike` filters.
	 *
	 * For client-side highlighting, columns are searchable by default unless `searchable: false`.
	 */
	searchable?: boolean;
	/**
	 * Optional discrete values used to render a menu-style header filter.
	 * Used by Svark's filter popup to render a select-style value picker.
	 */
	filterOptions?: Array<string | number | { id: string | number; label: string | number }>;
	/**
	 * Optional list of supported operators exposed in Svark's filter popup.
	 * Defaults are chosen based on whether `filterOptions` are provided.
	 */
	filterOperators?: string[];
};

export type { IConfig } from '@svar-ui/svelte-grid';
export type { FilterOption, Options, SortOption };

// Historical naming: Svark previously used 'body' | 'header' to select protocol.
// New naming: 'ResolveSpec' (body JSON) | 'RestHeaderSpec' (options via HTTP headers).
export type SvarkClientType = 'body' | 'header';
export type SvarkSpecType = 'ResolveSpec' | 'RestHeaderSpec';
export type SvarkAdapterType = SvarkClientType | SvarkSpecType;

export interface SvarkAdapterConfig {
	url: string;
	token?: string;
	schema: string;
	entity: string;
}

export interface SvarkSortState {
	key: string;
	order: 'asc' | 'desc';
}

export interface SvarkFilterState {
	[filterId: string]: {
		column?: string;
		value: any;
		operator: string;
		logic_operator?: 'AND' | 'OR';
	};
}

export interface SvarkPopupFilterDraft {
	id: number;
	column: string;
	operator: string;
	value: string;
	logic: 'AND' | 'OR';
}

export interface SvarkFilterableColumn {
	id: string;
	label: string;
	column: IColumnConfig;
}

export interface SvarkFilterOptionItem {
	value: string;
	label: string;
}

export interface SvarkChangeEvent {
	action: 'add' | 'update' | 'delete';
	id?: string | number;
	row?: any;
	column?: string | number;
	value?: any;
}

export interface SvarkRowInteractionEvent {
	id: string | number;
	row?: any;
	event: MouseEvent;
}

export interface SvarkContextMenuContext {
	api?: any;
	/**
	 * The currently selected row (or null if nothing is selected / data not loaded).
	 */
	row: any | null;
	rowId: string | number | null;
	selectedIds: (string | number)[];
	/**
	 * Current in-memory rows rendered/held by Svark (may be a window under virtual scrolling).
	 */
	data: any[];
	event: MouseEvent;
}

export interface SvarkContextMenuItem {
	id: string;
	label: string;
	disabled?: boolean;
	/**
	 * Render a separator line. If set, other fields are ignored.
	 */
	kind?: 'item' | 'separator';
	onselect?: (ctx: SvarkContextMenuContext) => void;
}

export interface SvarkContextMenuProps {
	/**
	 * Items can be static or generated per-open based on the currently selected row.
	 */
	items:
		| SvarkContextMenuItem[]
		| ((ctx: SvarkContextMenuContext) => SvarkContextMenuItem[]);
	/**
	 * Called after an item is selected (in addition to item.onselect).
	 */
	onselectitem?: (item: SvarkContextMenuItem, ctx: SvarkContextMenuContext) => void;
}

export interface SvarkInitialQuery {
	sort?: SortOption[];
	filters?: FilterOption[];
	columns?: string[];
	limit?: number;
	offset?: number;
}

export interface SvarkProps {
	// Prefer `url`. `baseUrl` is kept for backwards compatibility.
	url?: string;
	baseUrl?: string;
	token?: string;
	schema: string;
	entity: string;
	adapterType?: SvarkAdapterType;
	// Backwards compatibility alias for `adapterType`.
	clientType?: SvarkAdapterType;
	columns: IColumnConfig[];
	config?: Omit<IConfig, 'data'>;
	query?: SvarkInitialQuery;
	pageSize?: number;
	serverSide?: boolean;
	height?: string | number;
	select?: boolean;
	multiselect?: boolean;
	virtualScroll?: boolean;
	infiniteScroll?: boolean;
	topBarLeft?: Snippet;
	topBarRight?: Snippet;
	onchange?: (event: SvarkChangeEvent) => void;
	onerror?: (message: string) => void;
	onselect?: (ids: (string | number)[]) => void;
	onrowclick?: (event: SvarkRowInteractionEvent) => void;
	onrowdblclick?: (event: SvarkRowInteractionEvent) => void;
	onrowcontextmenu?: (event: SvarkRowInteractionEvent) => void;
	contextMenuProps?: SvarkContextMenuProps;
}
```

## Types (SvarkGrid/types.ts)

```ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Snippet } from 'svelte';
import type { IColumnConfig as GridColumnConfig, IConfig } from '@svar-ui/svelte-grid';
import type { FilterOption, Options, SortOption } from '@warkypublic/resolvespec-js';

export type IColumnConfig = GridColumnConfig & {
	/**
	 * When `serverSide` search is used (Ctrl/Cmd+F), only columns with `searchable: true`
	 * are sent to the API as OR `ilike` filters.
	 *
	 * For client-side highlighting, columns are searchable by default unless `searchable: false`.
	 */
	searchable?: boolean;
	/**
	 * Optional discrete values used to render a menu-style header filter.
	 * Used by Svark's filter popup to render a select-style value picker.
	 */
	filterOptions?: Array<string | number | { id: string | number; label: string | number }>;
	/**
	 * Optional list of supported operators exposed in Svark's filter popup.
	 * Defaults are chosen based on whether `filterOptions` are provided.
	 */
	filterOperators?: string[];
};

export type { IConfig } from '@svar-ui/svelte-grid';
export type { FilterOption, Options, SortOption };

// Historical naming: Svark previously used 'body' | 'header' to select protocol.
// New naming: 'ResolveSpec' (body JSON) | 'RestHeaderSpec' (options via HTTP headers).
export type SvarkClientType = 'body' | 'header';
export type SvarkSpecType = 'ResolveSpec' | 'RestHeaderSpec';
export type SvarkAdapterType = SvarkClientType | SvarkSpecType;

export interface SvarkAdapterConfig {
	url: string;
	token?: string;
	schema: string;
	entity: string;
}

export interface SvarkSortState {
	key: string;
	order: 'asc' | 'desc';
}

export interface SvarkFilterState {
	[filterId: string]: {
		column?: string;
		value: any;
		operator: string;
		logic_operator?: 'AND' | 'OR';
	};
}

export interface SvarkPopupFilterDraft {
	id: number;
	column: string;
	operator: string;
	value: string;
	logic: 'AND' | 'OR';
}

export interface SvarkFilterableColumn {
	id: string;
	label: string;
	column: IColumnConfig;
}

export interface SvarkFilterOptionItem {
	value: string;
	label: string;
}

export interface SvarkChangeEvent {
	action: 'add' | 'update' | 'delete';
	id?: string | number;
	row?: any;
	column?: string | number;
	value?: any;
}

export interface SvarkRowInteractionEvent {
	id: string | number;
	row?: any;
	event: MouseEvent;
}

export interface SvarkContextMenuContext {
	api?: any;
	/**
	 * The currently selected row (or null if nothing is selected / data not loaded).
	 */
	row: any | null;
	rowId: string | number | null;
	selectedIds: (string | number)[];
	/**
	 * Current in-memory rows rendered/held by Svark (may be a window under virtual scrolling).
	 */
	data: any[];
	event: MouseEvent;
}

export interface SvarkContextMenuItem {
	id: string;
	label: string;
	disabled?: boolean;
	/**
	 * Render a separator line. If set, other fields are ignored.
	 */
	kind?: 'item' | 'separator';
	onselect?: (ctx: SvarkContextMenuContext) => void;
}

export interface SvarkContextMenuProps {
	/**
	 * Items can be static or generated per-open based on the currently selected row.
	 */
	items:
		| SvarkContextMenuItem[]
		| ((ctx: SvarkContextMenuContext) => SvarkContextMenuItem[]);
	/**
	 * Called after an item is selected (in addition to item.onselect).
	 */
	onselectitem?: (item: SvarkContextMenuItem, ctx: SvarkContextMenuContext) => void;
}

export interface SvarkInitialQuery {
	sort?: SortOption[];
	filters?: FilterOption[];
	columns?: string[];
	limit?: number;
	offset?: number;
}

export interface SvarkProps {
	// Prefer `url`. `baseUrl` is kept for backwards compatibility.
	url?: string;
	baseUrl?: string;
	token?: string;
	schema: string;
	entity: string;
	adapterType?: SvarkAdapterType;
	// Backwards compatibility alias for `adapterType`.
	clientType?: SvarkAdapterType;
	columns: IColumnConfig[];
	config?: Omit<IConfig, 'data'>;
	query?: SvarkInitialQuery;
	pageSize?: number;
	serverSide?: boolean;
	height?: string | number;
	select?: boolean;
	multiselect?: boolean;
	virtualScroll?: boolean;
	infiniteScroll?: boolean;
	topBarLeft?: Snippet;
	topBarRight?: Snippet;
	onchange?: (event: SvarkChangeEvent) => void;
	onerror?: (message: string) => void;
	onselect?: (ids: (string | number)[]) => void;
	onrowclick?: (event: SvarkRowInteractionEvent) => void;
	onrowdblclick?: (event: SvarkRowInteractionEvent) => void;
	onrowcontextmenu?: (event: SvarkRowInteractionEvent) => void;
	contextMenuProps?: SvarkContextMenuProps;
}
```

## Props

### Svark/Svark.svelte

```ts
export interface Props {
		// Prefer `url`. `baseUrl` is kept for backwards compatibility.
		url?: string;
		baseUrl?: string;
		token?: string;
		schema: string;
		entity: string;
		adapterType?: SvarkAdapterType;
		// Backwards compatibility alias for `adapterType`.
		clientType?: SvarkAdapterType;
		columns: IColumnConfig[];
		config?: Omit<IConfig, 'data'>;
		query?: SvarkInitialQuery;
		pageSize?: number;
		serverSide?: boolean;
		height?: string | number;
		select?: boolean;
		multiselect?: boolean;
		virtualScroll?: boolean;
		infiniteScroll?: boolean;
		topBarLeft?: Snippet;
		topBarRight?: Snippet;
		onchange?: (event: SvarkChangeEvent) => void;
		onerror?: (message: string) => void;
		onselect?: (ids: (string | number)[]) => void;
		onrowclick?: (event: SvarkRowInteractionEvent) => void;
		onrowdblclick?: (event: SvarkRowInteractionEvent) => void;
		onrowcontextmenu?: (event: SvarkRowInteractionEvent) => void;
		contextMenuProps?: SvarkContextMenuProps;
	}
```

### Svark/SvarkContextMenu.svelte

```ts
export interface Props {
		open: boolean;
		x: number;
		y: number;
		items: SvarkContextMenuItem[];
		onselectitem?: (item: SvarkContextMenuItem | { id: '__close__' }) => void;
	}
```

### Svark/SvarkInfiniteLoader.svelte

```ts
export interface Props {
		dark?: boolean;
	}
```

### Svark/SvarkPager.svelte

```ts
export interface Props {
		dark?: boolean;
		page: number;
		total: number;
		totalPages: number;
		pageStart: number;
		pageEnd: number;
		loading: boolean;
		onFirst: () => void;
		onPrev: () => void;
		onNext: () => void;
		onLast: () => void;
	}
```

### Svark/SvarkTopBar.svelte

```ts
export interface Props {
		dark?: boolean;
		topBarLeft?: Snippet;
		topBarRight?: Snippet;
		searchOpen: boolean;
		canFilter: boolean;
		filterMenuOpen: boolean;
		searchValue: string;
		popupAppliedFilters: SvarkPopupFilterDraft[];
		popupDraftFilters: SvarkPopupFilterDraft[];
		filterableColumns: SvarkFilterableColumn[];
		filterOperatorLabels: Record<string, string>;
		normalizeOperatorToken: (value: string | undefined) => string;
		getSupportedOperators: (columnId: string) => string[];
		getColumnOptions: (columnId: string) => SvarkFilterOptionItem[];
		onToggleSearch: () => void;
		onSearchChange: (value: string) => void;
		onToggleFilterMenu: () => void;
		onClearPopupFilters: () => void;
		onRemoveAppliedPopupFilter: (id: number) => void;
		onUpdatePopupDraft: (
			id: number,
			updates: Partial<SvarkPopupFilterDraft>,
			resetValue?: boolean
		) => void;
		onRemovePopupDraft: (id: number) => void;
		onAddPopupDraft: () => void;
		onCloseFilterMenu: () => void;
		onApplyPopupDrafts: () => void;
	}
```

### SvarkGrid/SvarkGrid.svelte

```ts
export type SvarProps = Partial<
		Omit<
			SvarkProps,
			// Generic-first equivalents / resolved from GridCommonProps:
			| 'columns'
			| 'height'
			// Server config is generic-first but allowed as fallback:
			| 'url'
			| 'baseUrl'
			| 'token'
			| 'schema'
			| 'entity'
		>
	> & {
		columns?: IColumnConfig[];
		url?: string;
		baseUrl?: string;
		token?: string;
		schema?: string;
		entity?: string;
		height?: string | number;
		config?: Omit<IConfig, 'data'>;
	};

export interface Props extends GridCommonProps<Record<string, unknown>, unknown> {
		columns: Array<GridColumn<Record<string, unknown>, unknown>>;
		/** Svark/SVAR-grid escape hatch props. Generic props take precedence when overlapping. */
		SvarProps?: SvarProps;
		/** Used only when `searchValue` is undefined (uncontrolled). */
		defaultSearchValue?: string;
		/** Rows per fetch/page. If omitted, falls back to `SvarProps.pageSize` then 100. */
		pageSize?: number;
		/** Enables SVAR virtual scrolling. If omitted, falls back to `SvarProps.virtualScroll` then false. */
		virtualScroll?: boolean;
		/** Enables SVAR infinite scrolling. If omitted, falls back to `SvarProps.infiniteScroll` then false. */
		infiniteScroll?: boolean;
		/** Enables SVAR row selection. If omitted, falls back to `SvarProps.select` then true. */
		select?: boolean;
		/** Enables SVAR multi-row selection. If omitted, falls back to `SvarProps.multiselect` then false. */
		multiselect?: boolean;
	}
```

### SvarkGrid/components/SvarkColumnFilterForm.svelte

```ts
export interface Props {
		columnId: string;
		columnLabel: string;
		current?: GridColumnFilter;
		currentSort?: 'none' | 'asc' | 'desc';
		onSet: (next: GridColumnFilter) => void;
		onClear: () => void;
		onSetSort: (next: 'none' | 'asc' | 'desc') => void;
		onClose: () => void;
	}
```

### SvarkGrid/components/SvarkContextMenu.svelte

```ts
export interface Props {
		open: boolean;
		x: number;
		y: number;
		items: SvarkContextMenuItem[];
		onselectitem?: (item: SvarkContextMenuItem | { id: '__close__' }) => void;
	}
```

### SvarkGrid/components/SvarkHeaderFilterCell.svelte

```ts
export interface Props {
		api: IApi;
		row: IRow;
		column: IColumn;
		onaction: (ev: { action?: any; data?: { [key: string]: any } }) => void;
		cell: Omit<IHeaderCell, 'cell'> & HeaderCellExtras;
	}
```

### SvarkGrid/components/SvarkInfiniteLoader.svelte

```ts
export interface Props {
		dark?: boolean;
	}
```

### SvarkGrid/components/SvarkPager.svelte

```ts
export interface Props {
		dark?: boolean;
		page: number;
		total: number;
		totalPages: number;
		pageStart: number;
		pageEnd: number;
		loading: boolean;
		onFirst: () => void;
		onPrev: () => void;
		onNext: () => void;
		onLast: () => void;
	}
```

### SvarkGrid/components/SvarkSearchPopover.svelte

```ts
export interface Props {
		value: string;
		onChange: (value: string) => void;
		onClose: () => void;
	}
```

### SvarkGrid/components/SvarkTopBar.svelte

```ts
export interface Props {
		dark?: boolean;
		topBarLeft?: Snippet;
		topBarRight?: Snippet;
		searchOpen: boolean;
		onToggleSearch: () => void;
	}
```

### SvarkGrid/internal/SvarkGridView.svelte

```ts
export interface Props {
		// sizing
		heightStyle: string;
		widthStyle: string;
		menuProviderID: string;

		// grid render
		hideHeader: boolean;
		columns: IColumnConfig[];
		data: Array<Record<string, unknown>>;
		loading: boolean;
		error: string | null;
		select: boolean;
		multiselect: boolean;
		gridDynamic?: any;
		gridConfig: Record<string, unknown>;
		onInit: (api: IApi) => void;

		// top bar
		topBarLeft?: Snippet;
		topBarRight?: Snippet;
		searchOpen: boolean;
		searchValue: string;
		onToggleSearch: () => void;
		onSearchChange: (value: string) => void;

		// infinite scroll
		infiniteScroll: boolean;
		infiniteLoading: boolean;
		total: number;
		onLoadNextPage: () => void;

		// pager
		showPager: boolean;
		page: number;
		totalPages: number;
		pageStart: number;
		pageEnd: number;
		onPageFirst: () => void;
		onPagePrev: () => void;
		onPageNext: () => void;
		onPageLast: () => void;

		// row + context menu
		gridApi: IApi | null;
		uniqueID: string;
		genericColumnsById: Record<string, GridColumn<Record<string, unknown>, unknown>>;
		selectedIds: Array<string | number>;
		onSelectedIdsChange: (ids: Array<string | number>) => void;
		onRowClick?: (ctx: { id: string | number; row?: any; event: MouseEvent }) => void;
		onRowDblClick?: (ctx: { id: string | number; row?: any; event: MouseEvent }) => void;
		onRowContextMenu?: (ctx: { id: string | number; row?: any; event: MouseEvent }) => void;
		contextMenuProps?: SvarkContextMenuProps;
		onGridEvent?: GridCommonProps<Record<string, unknown>, unknown>['onGridEvent'];
		onCellEvent?: GridCommonProps<Record<string, unknown>, unknown>['onCellEvent'];
		onMenuClick?: GridCommonProps<Record<string, unknown>, unknown>['onMenuClick'];
	}
```

## Examples (from Storybook stories)

### Legacy/Svark  (`Svark/Svark.stories.ts`)

Shared setup:

```ts
import { expect, within, userEvent, fn } from 'storybook/test';

import Svark from './Svark.svelte';

import SvarkSelectionDemo from './SvarkSelectionDemo.svelte';

// ── Shared mock data ──────────────────────────────────────────────────────────

const mockUsers = Array.from({ length: 50 }, (_, i) => ({
	id: i + 1,
	name: ['Alice Smith', 'Bob Jones', 'Carol White', 'Dave Brown', 'Eve Wilson'][i % 5],
	email: `user${i + 1}@example.com`,
	role: ['Admin', 'Editor', 'Viewer'][i % 3],
	status: ['Active', 'Inactive', 'Pending'][i % 3],
	department: ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'][i % 5]
}));

// Large dataset for virtual / infinite scroll demos (5 000 rows)
const mockLargeUsers = Array.from({ length: 5000 }, (_, i) => ({
	id: i + 1,
	name: ['Alice Smith', 'Bob Jones', 'Carol White', 'Dave Brown', 'Eve Wilson'][i % 5],
	email: `user${i + 1}@example.com`,
	role: ['Admin', 'Editor', 'Viewer'][i % 3],
	status: ['Active', 'Inactive', 'Pending'][i % 3],
	department: ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'][i % 5]
}));

// Paginated mock: slices mockLargeUsers by limit/offset from the request body
function mockFetchPaginated(dataset: unknown[]) {
	return async (_url: string, init?: RequestInit) => {
		const body = init?.body ? JSON.parse(init.body as string) : {};
		const offset = body.options?.offset ?? 0;
		const limit = body.options?.limit ?? 50;
		const page = dataset.slice(offset, offset + limit);
		return new Response(
			JSON.stringify({
				success: true,
				data: page,
				metadata: {
					total: dataset.length,
					count: page.length,
					filtered: dataset.length,
					limit,
					offset
				}
			}),
			{ status: 200, headers: { 'Content-Type': 'application/json' } }
		);
	};
}

function mockApiResponse(data: unknown[], total?: number) {
	return new Response(
		JSON.stringify({
			success: true,
			data,
			metadata: { total: total ?? data.length, count: data.length, filtered: data.length, limit: 100, offset: 0 }
		}),
		{ status: 200, headers: { 'Content-Type': 'application/json' } }
	);
}

function mockFetch(data: unknown[], delayMs = 0) {
	return async () => {
		if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
		return mockApiResponse(data);
	};
}

function valueMatchesFilter(cellValue: unknown, filter: { operator?: string; value?: unknown }) {
	const operator = String(filter.operator ?? 'eq').toLowerCase();
	const rawValue = filter.value;
	const actual = cellValue ?? '';

	if (operator === 'eq') return String(actual) === String(rawValue ?? '');
	if (operator === 'neq') return String(actual) !== String(rawValue ?? '');

	if (operator === 'gt' || operator === 'gte' || operator === 'lt' || operator === 'lte') {
		const left = Number(actual);
		const right = Number(rawValue);
		if (Number.isNaN(left) || Number.isNaN(right)) return false;
		if (operator === 'gt') return left > right;
		if (operator === 'gte') return left >= right;
		if (operator === 'lt') return left < right;
		return left <= right;
	}

	const haystack = String(actual).toLowerCase();
	const needle = String(rawValue ?? '').toLowerCase();

	if (operator === 'ilike' || operator === 'contains') {
		const startsWith = needle.startsWith('%');
		const endsWith = needle.endsWith('%');
		const core = needle.replace(/^%|%$/g, '');
		if (startsWith && endsWith) return haystack.includes(core);
		if (startsWith) return haystack.endsWith(core);
		if (endsWith) return haystack.startsWith(core);
		return haystack.includes(core);
	}

	return haystack.includes(needle);
}

function applyMockFilters(
	rows: Record<string, unknown>[],
	filters: Array<{ column: string; operator?: string; value?: unknown; logic_operator?: string }>
) {
	if (!filters.length) return rows;

	let result = rows;
	let pendingOr: typeof filters = [];

	const flushOrGroup = () => {
		if (!pendingOr.length) return;
		result = result.filter((row) => pendingOr.some((filter) => valueMatchesFilter(row[filter.column], filter)));
		pendingOr = [];
	};

	for (const filter of filters) {
		const logic = String(filter.logic_operator ?? 'AND').toUpperCase();
		if (logic === 'OR') {
			pendingOr.push(filter);
			continue;
		}

		flushOrGroup();
		result = result.filter((row) => valueMatchesFilter(row[filter.column], filter));
	}

	flushOrGroup();
	return result;
}

function mockFetchError(message = 'Internal Server Error', status = 500) {
	return async () =>
		new Response(JSON.stringify({ success: false, error: { code: 'ERR', message } }), {
			status,
			headers: { 'Content-Type': 'application/json' }
		});
}

// ── Shared columns ────────────────────────────────────────────────────────────

const userColumns = [
	{ id: 'id', header: 'ID', width: 60 },
	{ id: 'name', header: 'Name', width: 180, sort: true },
	{ id: 'email', header: 'Email', width: 220 },
	{ id: 'role', header: 'Role', width: 100 },
	{ id: 'status', header: 'Status', width: 100 },
	{ id: 'department', header: 'Department', width: 140 }
];

const userColumnsWithFilter = [
	{ id: 'id', header: 'ID', width: 60 },
	{ id: 'name', header: 'Name', width: 180, sort: true },
	{ id: 'email', header: 'Email', width: 220 },
	{
		id: 'role',
		header: 'Role',
		width: 100,
		filterOptions: ['Admin', 'Editor', 'Viewer'],
		filterOperators: ['eq', 'neq']
	},
	{
		id: 'status',
		header: 'Status',
		width: 100,
		filterOptions: ['Active', 'Inactive', 'Pending'],
		filterOperators: ['eq', 'neq']
	},
	{
		id: 'department',
		header: 'Department',
		width: 140,
		filterOptions: ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'],
		filterOperators: ['eq', 'neq', 'startswith']
	}
];

// ── HeaderSpec (header client) stories ───────────────────────────────────────
//
// HeaderSpecClient sends GET requests with options encoded in HTTP headers:
//   X-Limit / X-Offset   — pagination
//   X-Sort               — sort: "+col" asc, "-col" desc
//   X-SearchOp-ilike-col — case-insensitive filter value for a column
//
// The server must return the raw data array as JSON body and signal the total
// via the "content-range: {offset}-{end}/{total}" response header.

function mockFetchHeader(dataset: unknown[], delayMs = 0) {
	return async (_url: string, init?: RequestInit) => {
		if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));

		const hdrs = (init?.headers ?? {}) as Record<string, string>;
		const limit = Number(hdrs['X-Limit'] ?? 50);
		const offset = Number(hdrs['X-Offset'] ?? 0);
		let data = [...dataset] as Record<string, unknown>[];

		// Apply ilike filters: X-SearchOp-ilike-{column}: value
		for (const [key, val] of Object.entries(hdrs)) {
			const m = key.match(/^X-SearchOp-ilike-(.+)$/i);
			if (m) {
				const col = m[1];
				const search = String(val).toLowerCase();
				data = data.filter((row) => String(row[col] ?? '').toLowerCase().includes(search));
			}
		}

		// Apply sort: X-Sort: +col,-col2
		const sortHeader = hdrs['X-Sort'];
		if (sortHeader) {
			for (const part of sortHeader.split(',').reverse()) {
				const desc = part.startsWith('-');
				const col = part.slice(1);
				data.sort((a, b) => {
					const av = String(a[col] ?? '');
					const bv = String(b[col] ?? '');
					return desc ? bv.localeCompare(av) : av.localeCompare(bv);
				});
			}
		}

		const total = data.length;
		const page = data.slice(offset, offset + limit);
		const end = offset + page.length - 1;

		return new Response(JSON.stringify(page), {
			status: 200,
			headers: {
				'Content-Type': 'application/json',
				'content-range': `${offset}-${Math.max(end, 0)}/${total}`,
				'x-limit': String(limit)
			}
		});
	};
}

const userColumnsWithSort = [
	{ id: 'id', header: 'ID', width: 60 },
	{ id: 'name', header: 'Name', width: 180, sort: true },
	{ id: 'email', header: 'Email', width: 220, sort: true },
	{ id: 'role', header: 'Role', width: 100, sort: true },
	{ id: 'status', header: 'Status', width: 100, sort: true },
	{ id: 'department', header: 'Department', width: 140, sort: true }
];
```

Helper component `./Svark.svelte`:

```svelte
<script lang="ts">
	/* eslint-disable @typescript-eslint/no-explicit-any */
	import type { Snippet } from 'svelte';
	import { loadSvar, type SvarGridModule } from './svarLoader.js';
	import type { IApi } from '@svar-ui/svelte-grid';
	import SvarkInfiniteLoader from './SvarkInfiniteLoader.svelte';
	import SvarkPager from './SvarkPager.svelte';
	import { SvarkResolveSpecAdapter } from './SvarkResolveSpecAdapter.js';
	import { SvarkRestHeaderSpecAdapter } from './SvarkRestHeaderSpecAdapter.js';
	import SvarkTopBar from './SvarkTopBar.svelte';
	import { reduceServerFilterState } from './filtering.js';
	import SvarkContextMenu from './SvarkContextMenu.svelte';
	import type {
		SvarkFilterState,
		SvarkFilterOptionItem,
		SvarkSortState,
		SvarkChangeEvent,
		SvarkRowInteractionEvent,
		SvarkAdapterType,
		SvarkInitialQuery,
		SvarkPopupFilterDraft,
		IColumnConfig,
		IConfig,
		SvarkContextMenuContext,
		SvarkContextMenuItem,
		SvarkContextMenuProps
	} from './types.js';
	import { useSvelixLayout } from '../../SvelixLayout/store.svelte.js';
	import { SVARK_DEFAULTS } from './svarkLayout.js';

	export interface Props {
		// Prefer `url`. `baseUrl` is kept for backwards compatibility.
		url?: string;
		baseUrl?: string;
		token?: string;
		schema: string;
		entity: string;
		adapterType?: SvarkAdapterType;
		// Backwards compatibility alias for `adapterType`.
		clientType?: SvarkAdapterType;
		columns: IColumnConfig[];
		config?: Omit<IConfig, 'data'>;
		query?: SvarkInitialQuery;
		pageSize?: number;
		serverSide?: boolean;
		height?: string | number;
		select?: boolean;
		multiselect?: boolean;
		virtualScroll?: boolean;
		infiniteScroll?: boolean;
		topBarLeft?: Snippet;
		topBarRight?: Snippet;
		onchange?: (event: SvarkChangeEvent) => void;
		onerror?: (message: string) => void;
		onselect?: (ids: (string | number)[]) => void;
		onrowclick?: (event: SvarkRowInteractionEvent) => void;
		onrowdblclick?: (event: SvarkRowInteractionEvent) => void;
		onrowcontextmenu?: (event: SvarkRowInteractionEvent) => void;
		contextMenuProps?: SvarkContextMenuProps;
	}

	let {
		url,
		baseUrl,
		token,
		schema,
		entity,
		adapterType,
		clientType,
		columns,
		config = {},
		query = {},
		pageSize: pageSizeProp,
		serverSide: serverSideProp,
		height: heightProp,
		select: selectProp,
		multiselect: multiselectProp,
		virtualScroll: virtualScrollProp,
		infiniteScroll: infiniteScrollProp,
		topBarLeft,
		topBarRight,
		onchange,
		onerror,
		onselect,
		onrowclick,
		onrowdblclick,
		onrowcontextmenu,
		contextMenuProps
	}: Props = $props();

	const layout = useSvelixLayout('svark', SVARK_DEFAULTS);
	const pageSize = $derived(pageSizeProp ?? layout.current.pageSize);
	const serverSide = $derived(serverSideProp ?? layout.current.serverSide);
	const height = $derived(heightProp ?? layout.current.height);
	const select = $derived(selectProp ?? layout.current.select);
	const multiselect = $derived(multiselectProp ?? layout.current.multiselect);
	const virtualScroll = $derived(virtualScrollProp ?? layout.current.virtualScroll);
	const infiniteScroll = $derived(infiniteScrollProp ?? layout.current.infiniteScroll);

	function normalizeAdapterType(t: SvarkAdapterType): 'ResolveSpec' | 'RestHeaderSpec' {
		return t === 'header' || t === 'RestHeaderSpec' ? 'RestHeaderSpec' : 'ResolveSpec';
	}

	const FILTER_OPERATOR_LABELS: Record<string, string> = {
		contains: 'Contains',
		eq: 'Equals',
		neq: 'Not equal',
		startswith: 'Starts with',
		endswith: 'Ends with',
		gt: 'Greater than',
		gte: 'Greater or equal',
		lt: 'Less than',
		lte: 'Less or equal'
	};

	function normalizeOperatorToken(value: string | undefined): string {
		return String(value ?? 'contains')
			.trim()
			.toLowerCase()
			.replace(/[\s_-]+/g, '');
	}

	function mapPopupOperatorValue(operator: string, value: string): { operator: string; value: string } {
		const normalized = normalizeOperatorToken(operator);
		if (normalized === 'contains') return { operator: 'ilike', value };
		if (normalized === 'startswith') return { operator: 'ilike', value: `${value}%` };
		if (normalized === 'endswith') return { operator: 'ilike', value: `%${value}` };
		return { operator: normalized, value };
	}

	// Enable column sorting by default.
	// Opt-out: set `sort: false` on a column.
	const gridColumns = $derived(
		(columns ?? []).map((col) => {
			return {
				...col,
				sort: (col as any).sort ?? true
			} as IColumnConfig;
		})
	);

	const filterableColumns = $derived(
		gridColumns
			.filter((col) => (col as any).id != null && !(col as any).hidden)
			.map((col) => ({
				id: String((col as any).id),
				label:
					typeof (col as any).header === 'string'
						? String((col as any).header)
						: String((col as any).header?.text ?? (col as any).id),
				column: col
			}))
	);
	const canFilter = $derived(serverSide && filterableColumns.length > 0);

	function getColumnDefinition(columnId: string): IColumnConfig | undefined {
		return filterableColumns.find((entry) => entry.id === columnId)?.column;
	}

	function getColumnOptions(columnId: string): SvarkFilterOptionItem[] {
		const column = getColumnDefinition(columnId);
		const source = column?.filterOptions;
		if (!source?.length) return [];
		return source.map((option) =>
			typeof option === 'object' && option != null && 'id' in option && 'label' in option
				? { value: String(option.id), label: String(option.label) }
				: { value: String(option), label: String(option) }
		);
	}

	function getSupportedOperators(columnId: string) {
		const column = getColumnDefinition(columnId);
		const configured = column?.filterOperators?.map((item) => normalizeOperatorToken(item)).filter(Boolean);
		if (configured?.length) return configured;
		if (column?.filterOptions?.length) return ['eq', 'neq'];
		return ['contains', 'eq', 'neq', 'startswith', 'endswith'];
	}

	function getDefaultOperator(columnId: string) {
		return getSupportedOperators(columnId)[0] ?? 'contains';
	}

	let popupFilterId = $state(0);

	function nextPopupFilterId() {
		popupFilterId += 1;
		return popupFilterId;
	}

	function createPopupDraft(columnId?: string): SvarkPopupFilterDraft {
		const selectedColumn = columnId ?? filterableColumns[0]?.id ?? '';
		return {
			id: nextPopupFilterId(),
			column: selectedColumn,
			operator: getDefaultOperator(selectedColumn),
			value: '',
			logic: 'AND'
		};
	}

	function clonePopupDrafts(filters: SvarkPopupFilterDraft[]) {
		return filters.map((filter) => ({ ...filter }));
	}

	function stripPopupFilters(state: SvarkFilterState): SvarkFilterState {
		return Object.fromEntries(
			Object.entries(state).filter(([key]) => !key.startsWith('popup:'))
		);
	}

	function buildPopupFilterState(filters: SvarkPopupFilterDraft[]): SvarkFilterState {
		const next: SvarkFilterState = {};
		for (const filter of filters) {
			const value = filter.value.trim();
			if (!filter.column || value === '') continue;
			const mapped = mapPopupOperatorValue(filter.operator, value);
			next[`popup:${filter.id}`] = {
				column: filter.column,
				operator: mapped.operator,
				value: mapped.value,
				logic_operator: filter.logic
			};
		}
		return next;
	}

	function syncPopupFilters(filters: SvarkPopupFilterDraft[]) {
		popupAppliedFilters = clonePopupDrafts(filters).filter((filter) => filter.column && filter.value.trim() !== '');
		filterState = {
			...stripPopupFilters(filterState),
			...buildPopupFilterState(popupAppliedFilters)
		};
	}

	const resolvedUrl = $derived(url ?? baseUrl ?? '');
	const resolvedAdapterType = $derived(normalizeAdapterType(adapterType ?? clientType ?? layout.current.adapterType));

	const heightStyle = $derived(typeof height === 'number' ? `${height}px` : height);

	let data = $state<any[]>([]);
	let loading = $state(false);
	let infiniteLoading = $state(false);
	let error = $state<string | null>(null);
	let total = $state(0);
	let sortState = $state<SvarkSortState[]>([]);
	let filterState = $state<SvarkFilterState>({});
	let searchFilterState = $state<SvarkFilterState>({});
	let page = $state(0);
	let reload = $state(0);

	let gridApi = $state<IApi | null>(null);
	let searchOpen = $state(false);
	let searchValue = $state('');
	let filterMenuOpen = $state(false);
	let popupAppliedFilters = $state<SvarkPopupFilterDraft[]>([]);
	let popupDraftFilters = $state<SvarkPopupFilterDraft[]>([]);

	// Paging is active when serverSide with no scroll variant
	const showPager = $derived(serverSide && !infiniteScroll && !virtualScroll);
	const totalPages = $derived(pageSize > 0 ? Math.ceil(total / pageSize) : 1);
	const pageStart = $derived(total === 0 ? 0 : page * pageSize + 1);
	const pageEnd = $derived(Math.min((page + 1) * pageSize, total));

	// Debounced mirrors of sortState/filterState used by the main fetch effect.
	// When serverSide is true, changes are held for 300 ms before committing so
	// rapid keystrokes / click-sort chains don't fire a request per event.
	// Page resets to 0 atomically with the debounced update so only one fetch fires.
	let debouncedSort = $state<SvarkSortState[]>([]);
	let debouncedFilter = $state<SvarkFilterState>({});
	let _debounceTimer: ReturnType<typeof setTimeout> | null = null;

	$effect(() => {
		const s = sortState;
		const f = { ...filterState, ...searchFilterState };

		if (!serverSide) {
			debouncedSort = s;
			debouncedFilter = f;
			return;
		}

		if (_debounceTimer !== null) clearTimeout(_debounceTimer);
		_debounceTimer = setTimeout(() => {
			_debounceTimer = null;
			page = 0; // reset page atomically — prevents a double-fetch
			debouncedSort = s;
			debouncedFilter = f;
		}, 300);

		return () => {
			if (_debounceTimer !== null) {
				clearTimeout(_debounceTimer);
				_debounceTimer = null;
			}
		};
	});

	// Dark mode detection — priority: inline color-scheme style > .dark class > OS media query
	let isDark = $state(false);

	$effect(() => {
		const mq = window.matchMedia('(prefers-color-scheme: dark)');
		const html = document.documentElement;

		function update() {
			const explicit = html.style.colorScheme;
			if (explicit === 'dark' || explicit === 'light') {
				isDark = explicit === 'dark';
			} else {
				isDark = html.classList.contains('dark') || mq.matches;
			}
		}

		update();
		mq.addEventListener('change', update);

		const observer = new MutationObserver(update);
		observer.observe(html, { attributeFilter: ['class', 'style'] });

		return () => {
			mq.removeEventListener('change', update);
			observer.disconnect();
		};
	});

	// Bind to outer container so we can find .wx-scroll for infinite scroll
	let containerRef = $state<HTMLElement | undefined>();
	let scrollContainerRef = $state<HTMLElement | null>(null);

	function getLocalSearchColumns() {
		const columnsMap: Record<string, boolean> = {};
		for (const col of gridColumns) {
			const id = (col as any).id;
			if (id == null) continue;
			if ((col as any).hidden) continue;
			if ((col as any).searchable === false) continue;
			columnsMap[String(id)] = true;
		}
		return Object.keys(columnsMap).length > 0 ? columnsMap : undefined;
	}

	function applyServerSearchFilters(value: string) {
		if (!serverSide) return;

		if (!value) {
			searchFilterState = {};
			return;
		}

		const searchableIds: string[] = [];
		for (const col of gridColumns) {
			const id = (col as any).id;
			if (id == null) continue;
			if ((col as any).hidden) continue;
			if ((col as any).searchable === true) searchableIds.push(String(id));
		}

		if (searchableIds.length === 0) {
			searchFilterState = {};
			return;
		}

		const next: SvarkFilterState = {};
		for (const id of searchableIds) {
			// Avoid clobbering an existing column filter the user set elsewhere.
			const hasExistingFilter = Object.entries(filterState).some(([key, filter]) => {
				const target = filter.column ?? key;
				return target === id && filter.value != null && filter.value !== '';
			});
			if (hasExistingFilter) continue;
			next[id] = { value, operator: 'ilike', logic_operator: 'OR' };
		}
		searchFilterState = next;
	}

	function setSearch(next: string) {
		searchValue = next;
		gridApi?.exec('search-rows', { search: next, columns: getLocalSearchColumns() });
		applyServerSearchFilters(next.trim());
	}

	function openSearch() {
		searchOpen = true;
	}

	function closeSearch() {
		searchOpen = false;
	}

	function openFilterMenu() {
		popupDraftFilters =
			popupAppliedFilters.length > 0 ? clonePopupDrafts(popupAppliedFilters) : [createPopupDraft()];
		filterMenuOpen = true;
	}

	function closeFilterMenu() {
		filterMenuOpen = false;
	}

	function toggleFilterMenu() {
		if (filterMenuOpen) {
			closeFilterMenu();
			return;
		}
		openFilterMenu();
	}

	function addPopupDraft() {
		popupDraftFilters = [...popupDraftFilters, createPopupDraft()];
	}

	function removePopupDraft(id: number) {
		const next = popupDraftFilters.filter((filter) => filter.id !== id);
		popupDraftFilters = next.length > 0 ? next : [createPopupDraft()];
	}

	function updatePopupDraft(
		id: number,
		updates: Partial<SvarkPopupFilterDraft>,
		resetValue = false
	) {
		popupDraftFilters = popupDraftFilters.map((filter) => {
			if (filter.id !== id) return filter;
			const next = { ...filter, ...updates };
			if (updates.column) {
				const supported = getSupportedOperators(updates.column);
				if (!supported.includes(normalizeOperatorToken(next.operator))) {
					next.operator = getDefaultOperator(updates.column);
				}
				if (resetValue) next.value = '';
			}
			return next;
		});
	}

	function applyPopupDrafts() {
		syncPopupFilters(popupDraftFilters);
		filterMenuOpen = false;
	}

	function clearPopupFilters() {
		popupAppliedFilters = [];
		popupDraftFilters = [createPopupDraft()];
		filterState = stripPopupFilters(filterState);
	}

	function removeAppliedPopupFilter(id: number) {
		syncPopupFilters(popupAppliedFilters.filter((filter) => filter.id !== id));
	}

	$effect(() => {
		if (!containerRef) return;
		const node = containerRef;

		function onKeyDown(ev: KeyboardEvent) {
			const key = ev.key?.toLowerCase?.() ?? '';
			const isFind = (ev.ctrlKey || ev.metaKey) && !ev.altKey && !ev.shiftKey && key === 'f';

			if (isFind) {
				ev.preventDefault();
				ev.stopPropagation();
				openSearch();
				return;
			}

			if (searchOpen && ev.key === 'Escape') {
				ev.preventDefault();
				ev.stopPropagation();
				closeSearch();
			}

			if (contextMenuOpen && ev.key === 'Escape') {
				ev.preventDefault();
				ev.stopPropagation();
				closeContextMenu();
			}
		}

		node.addEventListener('keydown', onKeyDown, true);
		return () => node.removeEventListener('keydown', onKeyDown, true);
	});

	let selectedIdsInternal = $state<(string | number)[]>([]);
	const selectedRow = $derived.by(() => {
		const id = selectedIdsInternal[0];
		if (id == null) return null;
		return data.find((item) => String(item?.id) === String(id)) ?? null;
	});

	function resolveRowFromTarget(target: EventTarget | null) {
		if (!containerRef || !(target instanceof Element)) return undefined;
		const rowEl = target.closest('[data-id]') as HTMLElement | null;
		if (!rowEl || !containerRef.contains(rowEl)) return undefined;

		const attrId = rowEl.getAttribute('data-id');
		if (attrId == null) return undefined;

		const row = data.find((item) => String(item?.id) === attrId);
		return {
			id: (row?.id ?? attrId) as string | number,
			row
		};
	}

	let contextMenuOpen = $state(false);
	let contextMenuX = $state(0);
	let contextMenuY = $state(0);
	let contextMenuRowId = $state<string | number | null>(null);
	let contextMenuRow = $state<any | null>(null);
	let contextMenuEvent = $state<MouseEvent | null>(null);

	function buildContextMenuCtx(): SvarkContextMenuContext | null {
		if (!contextMenuEvent) return null;
		return {
			api: gridApi ?? undefined,
			row: contextMenuRow,
			rowId: contextMenuRowId,
			selectedIds: selectedIdsInternal,
			data,
			event: contextMenuEvent
		};
	}

	const contextMenuItems = $derived.by(() => {
		if (!contextMenuOpen) return [] as SvarkContextMenuItem[];
		if (!contextMenuProps) return [] as SvarkContextMenuItem[];
		const ctx = buildContextMenuCtx();
		if (!ctx) return [] as SvarkContextMenuItem[];
		const resolved =
			typeof contextMenuProps.items === 'function'
				? contextMenuProps.items(ctx)
				: contextMenuProps.items ?? [];
		return (resolved ?? []).map((it): SvarkContextMenuItem => ({
			...it,
			kind: it.kind ?? 'item'
		}));
	});

	function closeContextMenu() {
		contextMenuOpen = false;
		contextMenuEvent = null;
		contextMenuRowId = null;
		contextMenuRow = null;
	}

	function handleContextMenuItem(item: SvarkContextMenuItem | { id: '__close__' }) {
		if (item.id === '__close__') {
			closeContextMenu();
			return;
		}
		if (!('label' in item)) {
			closeContextMenu();
			return;
		}
		const ctx = buildContextMenuCtx();
		closeContextMenu();
		if (!ctx) return;
		item.onselect?.(ctx);
		contextMenuProps?.onselectitem?.(item, ctx);
	}

	$effect(() => {
		if (!containerRef) return;
		const node = containerRef;

		function handleClick(ev: MouseEvent) {
			const resolved = resolveRowFromTarget(ev.target);
			if (!resolved) return;
			onrowclick?.({ ...resolved, event: ev });
		}

		function handleContextMenu(ev: MouseEvent) {
			// Only handle right-clicks on the grid surface itself, not on Svark's toolbar/pager.
			if (
				!containerRef ||
				!(ev.target instanceof Element) ||
				!ev.target.closest('.wx-grid') ||
				!containerRef.contains(ev.target.closest('.wx-grid'))
			) {
				return;
			}

			const resolved = resolveRowFromTarget(ev.target);
			if (resolved) {
				onrowcontextmenu?.({ ...resolved, event: ev });
			}

			if (!contextMenuProps) return;
			// Always prevent the browser context menu when a custom menu is configured.
			ev.preventDefault();
			ev.stopPropagation();

			// Right-clicking a row should behave like "select + open menu".
			if (resolved?.id != null && gridApi && select !== false) {
				gridApi.exec('select-row', { id: resolved.id, toggle: false, range: false });
				selectedIdsInternal = [resolved.id];
			}

			contextMenuEvent = ev;
			contextMenuX = ev.clientX;
			contextMenuY = ev.clientY;

			// Prefer the right-clicked row; otherwise fall back to current selection.
			const ctxRowId = (resolved?.id ?? selectedIdsInternal[0] ?? null) as string | number | null;
			const ctxRow =
				resolved?.row ??
				(ctxRowId != null
					? data.find((item) => String(item?.id) === String(ctxRowId)) ?? null
					: selectedRow) ??
				null;

			contextMenuRowId = ctxRowId;
			contextMenuRow = ctxRow;
			contextMenuOpen = true;
		}

		function handleDoubleClick(ev: MouseEvent) {
			const resolved = resolveRowFromTarget(ev.target);
			if (!resolved) return;
			onrowdblclick?.({ ...resolved, event: ev });
		}

		node.addEventListener('click', handleClick);
		node.addEventListener('dblclick', handleDoubleClick);
		node.addEventListener('contextmenu', handleContextMenu);

		return () => {
			node.removeEventListener('click', handleClick);
			node.removeEventListener('dblclick', handleDoubleClick);
			node.removeEventListener('contextmenu', handleContextMenu);
		};
	});

	const adapter = $derived(
		resolvedAdapterType === 'RestHeaderSpec'
			? new SvarkRestHeaderSpecAdapter({ url: resolvedUrl, token, schema, entity })
			: new SvarkResolveSpecAdapter({ url: resolvedUrl, token, schema, entity })
	);

	// For virtual scroll: set dynamic rowCount once total is known
	const gridDynamic = $derived(
		virtualScroll && total > 0 ? { rowCount: total } : undefined
	);

	// ── Main data effect (initial load + sort/filter/page/reload triggers) ──────

	$effect(() => {
		const _adapter = adapter;
		const _limit = virtualScroll ? 1 : pageSize;
		const _sort = serverSide ? debouncedSort : [];
		const _filters = serverSide ? debouncedFilter : {};
		// Paging offset: only applied when server-side paging is active
		const _offset = showPager ? page * pageSize : 0;
		void reload;
		let cancelled = false;

		loading = true;
		error = null;

		_adapter
			.read(_sort, _filters, _limit, _offset, query)
			.then((response) => {
				if (cancelled) return;
				if (response.success) {
					const incoming = response.data ?? [];
					total = (response as any).metadata?.total ?? incoming.length;

					if (virtualScroll) {
						data = [];
					} else {
						data = incoming;
					}
				} else {
					const msg = (response as any).error?.message ?? 'Failed to load data';
					error = msg;
					onerror?.(msg);
				}
			})
			.catch((e) => {
				if (cancelled) return;
				const msg = e instanceof Error ? e.message : 'Unknown error';
				error = msg;
				onerror?.(msg);
			})
			.finally(() => {
				if (!cancelled) loading = false;
			});

		return () => {
			cancelled = true;
		};
	});

	// ── Infinite scroll: append pages when the user nears the bottom ───────────

	function maybeLoadNextPage() {
		if (!infiniteScroll || !containerRef || loading || infiniteLoading || data.length >= total) return;

		const scrollEl = scrollContainerRef;
		if (scrollEl && scrollEl.scrollHeight > scrollEl.clientHeight + 1) {
			const { scrollTop, scrollHeight, clientHeight } = scrollEl;
			if (scrollHeight - scrollTop - clientHeight < 150) {
				loadNextPage();
			}
			return;
		}

		const { bottom } = containerRef.getBoundingClientRect();
		if (bottom - window.innerHeight < 150) {
			loadNextPage();
		}
	}

	$effect(() => {
		if (!infiniteScroll || loading || infiniteLoading) return;
		void data.length;
		void total;

		queueMicrotask(() => {
			maybeLoadNextPage();
		});
	});

	$effect(() => {
		if (!infiniteScroll || !containerRef) return;

		let scrollEl: Element | null = null;

		function attach(el: Element) {
			scrollEl = el;
			scrollContainerRef = el as HTMLElement;
			el.addEventListener('scroll', onScroll);
			queueMicrotask(onViewportChange);
		}

		function onScroll() {
			maybeLoadNextPage();
		}

		function onViewportChange() {
			maybeLoadNextPage();
		}

		window.addEventListener('scroll', onViewportChange, true);
		window.addEventListener('resize', onViewportChange);

		// SVAR renders .wx-scroll asynchronously; watch for it with MutationObserver
		const candidate = containerRef.querySelector('.wx-scroll');
		if (candidate) {
			attach(candidate);
		} else {
			const obs = new MutationObserver(() => {
				const el = containerRef?.querySelector('.wx-scroll');
				if (el) {
					attach(el);
					obs.disconnect();
				}
			});
			obs.observe(containerRef, { childList: true, subtree: true });
			return () => {
				obs.disconnect();
				scrollEl?.removeEventListener('scroll', onScroll);
				window.removeEventListener('scroll', onViewportChange, true);
				window.removeEventListener('resize', onViewportChange);
				scrollContainerRef = null;
			};
		}

		return () => {
			scrollEl?.removeEventListener('scroll', onScroll);
			window.removeEventListener('scroll', onViewportChange, true);
			window.removeEventListener('resize', onViewportChange);
			scrollContainerRef = null;
		};
	});

	async function loadNextPage() {
		// Block if main fetch is running (sort/filter change in progress)
		if (loading || infiniteLoading || data.length >= total) return;

		// Snapshot debounced sort+filter so we can discard the response if they
		// change while the request is in-flight (race condition guard).
		const snapshotSort = serverSide ? JSON.stringify(debouncedSort) : '';
		const snapshotFilter = serverSide ? JSON.stringify(debouncedFilter) : '';
		const snapshotOffset = data.length;

		infiniteLoading = true;
		try {
			const response = await adapter.read(
				serverSide ? debouncedSort : [],
				serverSide ? debouncedFilter : {},
				pageSize,
				snapshotOffset,
				query
			);
			// Discard if sort/filter changed while we were fetching
			if (serverSide) {
				if (
					JSON.stringify(debouncedSort) !== snapshotSort ||
					JSON.stringify(debouncedFilter) !== snapshotFilter
				) {
					return;
				}
			}
			if (response.success) {
				data = [...data, ...(response.data ?? [])];
			}
		} catch {
			// Silent — don't replace existing rows with an error on pagination
		} finally {
			infiniteLoading = false;
		}
	}

	// ── SVAR Grid init ─────────────────────────────────────────────────────────

	function handleInit(api: IApi) {
		gridApi = api;

		// Row selection callback
		selectedIdsInternal = ((api.getState() as any).selectedRows ?? []) as (string | number)[];
		api.on('select-row', () => {
			const ids = ((api.getState() as any).selectedRows ?? []) as (string | number)[];
			selectedIdsInternal = ids;
			onselect?.(ids);
		});

		// Virtual scroll: SVAR fires request-data when the visible row window changes.
		// We fetch only that window from the API and replace data.
		if (virtualScroll) {
			api.on('request-data', async (ev: any) => {
				const { start, end } = ev.row as { start: number; end: number };
				const limit = end - start;
				if (limit <= 0) return;

				loading = true;
				try {
					const response = await adapter.read(
						serverSide ? debouncedSort : [],
						serverSide ? debouncedFilter : {},
						limit,
						start,
						query
					);
					if (response.success) {
						data = response.data ?? [];
					}
				} catch (e) {
					const msg = e instanceof Error ? e.message : 'Failed to load data';
					error = msg;
					onerror?.(msg);
				} finally {
					loading = false;
				}
			});
		}

		if (serverSide) {
			api.intercept('sort-rows', (ev: any) => {
				if (ev.key) {
					if (ev.add) {
						const idx = sortState.findIndex((s) => s.key === ev.key);
						if (idx >= 0) {
							sortState[idx] = { key: ev.key, order: ev.order ?? 'asc' };
						} else {
							sortState = [...sortState, { key: ev.key, order: ev.order ?? 'asc' }];
						}
					} else {
						sortState = [{ key: ev.key, order: ev.order ?? 'asc' }];
					}
				} else {
					sortState = [];
				}
				return false;
			});

			api.intercept('filter-rows', (ev: any) => {
				filterState = reduceServerFilterState(filterState, ev ?? {});
				return false;
			});
		}

		api.on('add-row', async (ev: any) => {
			try {
				await adapter.create(ev.row);
				onchange?.({ action: 'add', row: ev.row });
				reload++;
			} catch (e) {
				onerror?.(e instanceof Error ? e.message : 'Create failed');
			}
		});

		api.on('delete-row', async (ev: any) => {
			try {
				await adapter.delete(ev.id);
				onchange?.({ action: 'delete', id: ev.id });
				reload++;
			} catch (e) {
				onerror?.(e instanceof Error ? e.message : 'Delete failed');
			}
		});

		api.on('update-cell', async (ev: any) => {
			try {
				await adapter.update(ev.id, { [String(ev.column)]: ev.value });
				onchange?.({ action: 'update', id: ev.id, column: ev.column, value: ev.value });
			} catch (e) {
				onerror?.(e instanceof Error ? e.message : 'Update failed');
			}
		});
	}

	let svar = $state.raw<SvarGridModule | undefined>(undefined);
	$effect(() => {
		loadSvar().then((m) => (svar = m));
	});
</script>

<div class="svark-container" class:svark-dark={isDark} style:height={heightStyle} bind:this={containerRef}>
	<div class="svark-grid-area">
		<SvarkTopBar
			dark={isDark}
			{topBarLeft}
			{topBarRight}
			{searchOpen}
			{canFilter}
			{filterMenuOpen}
			{searchValue}
			{popupAppliedFilters}
			{popupDraftFilters}
			{filterableColumns}
			filterOperatorLabels={FILTER_OPERATOR_LABELS}
			{normalizeOperatorToken}
			{getSupportedOperators}
			{getColumnOptions}
			onToggleSearch={searchOpen ? closeSearch : openSearch}
			onSearchChange={setSearch}
			onToggleFilterMenu={toggleFilterMenu}
			onClearPopupFilters={clearPopupFilters}
			onRemoveAppliedPopupFilter={removeAppliedPopupFilter}
			onUpdatePopupDraft={updatePopupDraft}
			onRemovePopupDraft={removePopupDraft}
			onAddPopupDraft={addPopupDraft}
			onCloseFilterMenu={closeFilterMenu}
			onApplyPopupDrafts={applyPopupDrafts}
		/>

		{#if !svar}
			<!-- svar-ui grid is loading -->
		{:else if isDark}
			<svar.WillowDark fonts={false}>
				<div class="svark-wrapper" class:svark-loading={loading} aria-busy={loading}>
					{#if error}
						<div class="svark-error" role="alert">{error}</div>
					{/if}
					<svar.Grid
						columns={gridColumns}
						{data}
						init={handleInit}
						{select}
						{multiselect}
						dynamic={gridDynamic}
						{...config}
					/>
					<SvarkContextMenu
						open={contextMenuOpen}
						x={contextMenuX}
						y={contextMenuY}
						items={contextMenuItems}
						onselectitem={handleContextMenuItem}
					/>
					{#if infiniteLoading}
						<SvarkInfiniteLoader dark={true} />
					{/if}
				</div>
			</svar.WillowDark>
		{:else}
			<svar.Willow fonts={false}>
				<div class="svark-wrapper" class:svark-loading={loading} aria-busy={loading}>
					{#if error}
						<div class="svark-error" role="alert">{error}</div>
					{/if}
					<svar.Grid
						columns={gridColumns}
						{data}
						init={handleInit}
						{select}
						{multiselect}
						dynamic={gridDynamic}
						{...config}
					/>
					<SvarkContextMenu
						open={contextMenuOpen}
						x={contextMenuX}
						y={contextMenuY}
						items={contextMenuItems}
						onselectitem={handleContextMenuItem}
					/>
					{#if infiniteLoading}
						<SvarkInfiniteLoader />
					{/if}
				</div>
			</svar.Willow>
		{/if}
	</div>

	{#if showPager}
		<SvarkPager
			dark={isDark}
			{page}
			{total}
			{totalPages}
			{pageStart}
			{pageEnd}
			{loading}
			onFirst={() => (page = 0)}
			onPrev={() => page--}
			onNext={() => page++}
			onLast={() => (page = totalPages - 1)}
		/>
	{/if}
</div>

<style>
	.svark-container {
		position: relative;
		width: 100%;
		display: flex;
		flex-direction: column;
	}

	.svark-grid-area {
		flex: 1;
		min-height: 0;
		overflow: hidden;
		display: flex;
		flex-direction: column;
	}

	.svark-wrapper {
		position: relative;
		width: 100%;
		height: 100%;
		flex: 1;
		min-height: 0;
	}

	.svark-wrapper :global(.wx-grid) {
		width: 100%;
	}

	.svark-error {
		color: var(--color-error-500, #ef4444);
		padding: 0.5rem;
		font-size: 0.875rem;
		margin-bottom: 0.25rem;
	}
</style>
```

Helper component `./SvarkSelectionDemo.svelte`:

```svelte
<script lang="ts">
	import Svark from './Svark.svelte';
	import type { SvarkProps } from './types.js';

	// Accept all Svark props but own the onselect handler
	let { onselect: _ignored, ...rest }: SvarkProps = $props();

	let selectedIds = $state<(string | number)[]>([]);
</script>

<Svark
	{...rest}
	onselect={(ids) => {
		selectedIds = ids;
	}}
/>

<div class="selection-panel">
	<span class="label">Selected rows:</span>
	{#if selectedIds.length === 0}
		<em class="empty">none — click a row to select</em>
	{:else}
		<span class="ids">{selectedIds.join(', ')}</span>
		<span class="count">({selectedIds.length} row{selectedIds.length !== 1 ? 's' : ''})</span>
	{/if}
</div>

<style>
	.selection-panel {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		margin-top: 0.5rem;
		padding: 0.4rem 0.75rem;
		background: #f1f5f9;
		border: 1px solid #e2e8f0;
		border-radius: 4px;
		font-size: 13px;
		font-family: system-ui, -apple-system, sans-serif;
	}

	.label {
		font-weight: 600;
		color: #475569;
	}

	.ids {
		font-family: ui-monospace, monospace;
		color: #4f46e5;
	}

	.count {
		color: #94a3b8;
	}

	.empty {
		color: #94a3b8;
	}
</style>
```

#### Basic (`Basic`)

```ts
// ── Stories ───────────────────────────────────────────────────────────────────

export const Basic: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = mockFetch(mockUsers);
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumns,
		clientType: 'body',
		pageSize: 50
	}
};
```

#### Header Client (`HeaderClient`)

```ts
export const HeaderClient: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = mockFetchHeader(mockUsers);
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumns,
		clientType: 'header',
		pageSize: 50
	}
};
```

#### With Token (`WithToken`)

```ts
export const WithToken: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = async (url, init) => {
				const hasAuth = (init?.headers as Record<string, string>)?.['Authorization'];
				return mockApiResponse(hasAuth ? mockUsers : []);
			};
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		token: 'demo-bearer-token',
		columns: userColumns,
		clientType: 'body'
	}
};
```

#### Server Side Sort (`ServerSideSort`)

```ts
export const ServerSideSort: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = async (url, init) => {
				const body = init?.body ? JSON.parse(init.body as string) : {};
				const sort = body.options?.sort ?? [];
				const limit = body.options?.limit ?? 50;
				const offset = body.options?.offset ?? 0;
				const data = [...mockUsers];
				if (sort.length > 0) {
					const { column, direction } = sort[0];
					data.sort((a, b) => {
						const av = String(a[column] ?? '');
						const bv = String(b[column] ?? '');
						return direction === 'desc' ? bv.localeCompare(av) : av.localeCompare(bv);
					});
				}
				return mockApiResponse(data.slice(offset, offset + limit), data.length);
			};
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumns,
		clientType: 'body',
		serverSide: true,
		pageSize: 10
	}
};
```

#### Server Side Filter (`ServerSideFilter`)

```ts
export const ServerSideFilter: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = async (url, init) => {
				const body = init?.body ? JSON.parse(init.body as string) : {};
				const filters = body.options?.filters ?? [];
				const limit = body.options?.limit ?? 50;
				const offset = body.options?.offset ?? 0;
				const data = applyMockFilters([...mockUsers], filters);
				return mockApiResponse(data.slice(offset, offset + limit), data.length);
			};
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumnsWithFilter,
		clientType: 'body',
		serverSide: true,
		pageSize: 10
	}
};
```

#### Server Side Advanced Filters (`ServerSideAdvancedFilters`)

```ts
export const ServerSideAdvancedFilters: Story = {
	parameters: {
		docs: {
			description: {
				story:
					'Preloads a compound server-side filter set that mirrors the richer menu payloads Svark now normalizes: an OR group for role, a starts-with match on department, and a numeric range.'
			}
		}
	},
	decorators: [
		(Story) => {
			globalThis.fetch = async (_url, init) => {
				const body = init?.body ? JSON.parse(init.body as string) : {};
				const filters = body.options?.filters ?? [];
				const limit = body.options?.limit ?? 50;
				const offset = body.options?.offset ?? 0;
				const rankedUsers = mockUsers.map((row, index) => ({ ...row, score: 10 + index }));
				const data = applyMockFilters(rankedUsers, filters);
				return mockApiResponse(data.slice(offset, offset + limit), data.length);
			};
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: [
			{ id: 'id', header: 'ID', width: 60 },
			{ id: 'name', header: 'Name', width: 180, sort: true },
			{
				id: 'role',
				header: 'Role',
				width: 100,
				filterOptions: ['Admin', 'Editor', 'Viewer'],
				filterOperators: ['eq', 'neq']
			},
			{
				id: 'department',
				header: 'Department',
				width: 140,
				filterOptions: ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'],
				filterOperators: ['eq', 'neq', 'startswith']
			},
			{ id: 'score', header: 'Score', width: 100, sort: true, filterOperators: ['gte', 'lte', 'eq'] }
		],
		clientType: 'body',
		serverSide: true,
		pageSize: 16,
		query: {
			filters: [
				{ column: 'role', operator: 'eq', value: 'Admin', logic_operator: 'OR' },
				{ column: 'role', operator: 'eq', value: 'Editor', logic_operator: 'OR' },
				{ column: 'department', operator: 'ilike', value: 'Eng%' },
				{ column: 'score', operator: 'gte', value: 10 },
				{ column: 'score', operator: 'lte', value: 30 }
			]
		}
	}
};
```

#### Initial Query (`InitialQuery`)

```ts
export const InitialQuery: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = mockFetch(mockUsers.filter((u) => u.role === 'Admin'));
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumns,
		clientType: 'body',
		query: {
			filters: [{ column: 'role', operator: 'eq', value: 'Admin' }],
			sort: [{ column: 'name', direction: 'asc' }],
			limit: 20
		}
	}
};
```

#### Loading State (`LoadingState`)

```ts
export const LoadingState: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = mockFetch(mockUsers, 2000);
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumns,
		clientType: 'body'
	}
};
```

#### Error State (`ErrorState`)

```ts
export const ErrorState: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = mockFetchError('Connection refused');
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumns,
		clientType: 'body',
		onerror: (msg: string) => console.error('Svark error:', msg)
	},
};
```

#### Empty Data (`EmptyData`)

```ts
export const EmptyData: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = mockFetch([]);
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumns,
		clientType: 'body',
		config: { autoConfig: true }
	}
};
```

#### Context Menu (`ContextMenu`)

```ts
export const ContextMenu: Story = {
	parameters: {
		docs: {
			description: {
				story:
					'Right-click anywhere on the grid surface to open a context menu. Right-clicking a row selects it first, and menu items are generated from the selected row.'
			}
		}
	},
	decorators: [
		(Story) => {
			globalThis.fetch = mockFetch(mockUsers);
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumns,
		clientType: 'body',
		height: '420px',
		multiselect: true,
		contextMenuProps: {
			items: ({ row }) => {
				const items = [
					{ id: 'add', label: 'Add user' },
					{ id: 'sep1', kind: 'separator' },
					{ id: 'edit', label: 'Edit user', disabled: !row }
				];
				if (row?.role === 'Admin') {
					items.push({ id: 'admin', label: 'Admin action' });
				}
				return items;
			}
		}
	},
};
```

#### Context Menu Empty Grid (`ContextMenuEmptyGrid`)

```ts
export const ContextMenuEmptyGrid: Story = {
	parameters: {
		docs: {
			description: {
				story:
					'Even with zero rows, the context menu can open by right-clicking the grid surface.'
			}
		}
	},
	decorators: [
		(Story) => {
			globalThis.fetch = mockFetch([]);
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumns,
		clientType: 'body',
		height: '420px',
		contextMenuProps: {
			items: () => [{ id: 'add', label: 'Add user' }]
		}
	},
};
```

#### With Grid Config (`WithGridConfig`)

```ts
export const WithGridConfig: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = mockFetch(mockUsers);
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumns,
		clientType: 'body',
		config: {
			select: true,
			reorder: true,
			sizes: { rowHeight: 40, headerHeight: 42 }
		}
	}
};
```

#### Single Row Selection (`SingleRowSelection`)

```ts
// ── Row selection stories ──────────────────────────────────────────────────────

export const SingleRowSelection: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = mockFetch(mockUsers);
			return Story();
		}
	],
	render: (args) => ({ Component: SvarkSelectionDemo, props: args }),
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumns,
		clientType: 'body',
		select: true,
		multiselect: false
	},
};
```

#### Multi Row Selection (`MultiRowSelection`)

```ts
export const MultiRowSelection: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = mockFetch(mockUsers);
			return Story();
		}
	],
	render: (args) => ({ Component: SvarkSelectionDemo, props: args }),
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumns,
		clientType: 'body',
		select: true,
		multiselect: true
	},
};
```

#### Pre Selected Rows (`PreSelectedRows`)

```ts
export const PreSelectedRows: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = mockFetch(mockUsers);
			return Story();
		}
	],
	render: (args) => ({ Component: SvarkSelectionDemo, props: args }),
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumns,
		clientType: 'body',
		select: true,
		multiselect: true,
		// Pass initial selection via Grid's selectedRows prop through config spread
		config: { selectedRows: [1, 3, 5] }
	},
};
```

#### Virtual Scrolling (`VirtualScrolling`)

```ts
// ── Virtual & Infinite scroll stories ────────────────────────────────────────

export const VirtualScrolling: Story = {
	decorators: [
		(Story) => {
			// Paginated fetch: Svark sends limit=1 first (metadata), then
			// request-data drives subsequent window fetches.
			globalThis.fetch = mockFetchPaginated(mockLargeUsers);
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumns,
		clientType: 'body',
		pageSize: 50,
		virtualScroll: true,
		height: '500px'
	}
};
```

#### Infinite Scrolling (`InfiniteScrolling`)

```ts
export const InfiniteScrolling: Story = {
	decorators: [
		(Story) => {
			// Paginated fetch: Svark appends pages as the user scrolls.
			globalThis.fetch = mockFetchPaginated(mockLargeUsers);
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumns,
		clientType: 'body',
		pageSize: 50,
		infiniteScroll: true,
		height: '500px'
	}
};
```

#### Header Infinite Scroll (`HeaderInfiniteScroll`)

```ts
export const HeaderInfiniteScroll: Story = {
	decorators: [
		(Story) => {
			// 300ms delay so the loading indicator is visible on each page append
			globalThis.fetch = mockFetchHeader(mockLargeUsers, 300);
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumns,
		clientType: 'header',
		pageSize: 50,
		infiniteScroll: true,
		height: '500px'
	}
};
```

#### Header Sorting (`HeaderSorting`)

```ts
export const HeaderSorting: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = mockFetchHeader(mockLargeUsers);
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumnsWithSort,
		clientType: 'header',
		pageSize: 100,
		serverSide: true,
		height: '500px'
	}
};
```

#### Header Filtering (`HeaderFiltering`)

```ts
export const HeaderFiltering: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = mockFetchHeader(mockLargeUsers);
			return Story();
		}
	],
	args: {
		baseUrl: 'http://localhost:3000',
		schema: 'public',
		entity: 'users',
		columns: userColumnsWithFilter,
		clientType: 'header',
		pageSize: 100,
		serverSide: true,
		height: '500px'
	}
};
```

#### Live Header Infinite Scroll (`LiveHeaderInfiniteScroll`)

```ts
// ── Live HeaderSpec stories (real backend, no mock) ───────────────────────────
// Set baseUrl, schema, entity and optionally token in the Controls panel.

export const LiveHeaderInfiniteScroll: Story = {
	args: {
		baseUrl: "https://utils.btsys.tech/api",
		token: "773EB99C-F625-4E99-9DB9-CDDA7CA17639",
		schema: 'public',
		entity: "synclog",
		columns: [{
            "id": "id",
            "header": "ID",
            "width": 120
        }, {
            "id": "logmessage",

            "header": {
                "text": "Name",
                "filter": "text"
            },

            "width": 280,
            "sort": true
        }, {
            "id": "logtype",

            "header": {
                "text": "Test",
                "filter": "text"
            },

            "width": 140
        }],
		clientType: 'header',
		serverSide: true,
		pageSize: 50,
		infiniteScroll: true,
		height: "400px"
	}
};
```

#### Live Header Sorting (`LiveHeaderSorting`)

```ts
export const LiveHeaderSorting: Story = {
	args: {
		baseUrl: "https://utils.btsys.tech/api/",
		token: "773EB99C-F625-4E99-9DB9-CDDA7CA17639",
		schema: 'public',
		entity: 'users',
		columns: userColumnsWithSort,
		clientType: 'header',
		pageSize: 10,
		serverSide: true,
		height: '500px'
	}
};
```

#### Live Header Filtering (`LiveHeaderFiltering`)

```ts
export const LiveHeaderFiltering: Story = {
	args: {
		baseUrl: "https://utils.btsys.tech/api",
		token: "773EB99C-F625-4E99-9DB9-CDDA7CA17639",
		schema: 'public',
		entity: "synclog",
		columns: [{
            "id": "id",
            "header": "ID",
            "width": 120
        }, {
            "id": "logmessage",

            "header": {
                "text": "Name",
                "filter": "text"
            },

            "width": 280,
            "sort": true
        }, {
            "id": "logtype",

            "header": {
                "text": "Test",
                "filter": "text"
            },

            "width": 140
        }],
		clientType: 'header',
		pageSize: 100,
		serverSide: true,
		height: '500px'
	}
};
```

### Legacy/SvarkGrid  (`SvarkGrid/SvarkGrid.stories.ts`)

Shared setup:

```ts
import { fn } from 'storybook/test';

import SvarkGrid from './SvarkGrid.svelte';

const liveArgTypes = {
	apiUrl: { control: 'text', description: 'ResolveSpec API base URL' },
	authToken: { control: 'text', description: 'Bearer token for ResolveSpec authentication' },
	schema: { control: 'text', description: 'Database schema' },
	entity: { control: 'text', description: 'Entity / table name' },
	uniqueID: { control: 'text', description: 'Unique row field used for identity and paging' }
};

// ── Mock data ────────────────────────────────────────────────────────────────

const mockUsers = Array.from({ length: 50 }, (_, i) => ({
	id: i + 1,
	name: ['Alice Smith', 'Bob Jones', 'Carol White', 'Dave Brown', 'Eve Wilson'][i % 5],
	email: `user${i + 1}@example.com`,
	role: ['Admin', 'Editor', 'Viewer'][i % 3],
	status: ['Active', 'Inactive', 'Pending'][i % 3],
	department: ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'][i % 5]
}));

const genericColumns = [
	{ id: 'id', title: 'ID', width: 60 },
	{ id: 'name', title: 'Name', width: 180 },
	{ id: 'email', title: 'Email', width: 220 },
	{ id: 'role', title: 'Role', width: 100 },
	{ id: 'status', title: 'Status', width: 100 },
	{ id: 'department', title: 'Department', width: 140 }
];

function valueMatchesFilter(cellValue: unknown, filter: { operator?: string; value?: unknown }) {
	const operator = String(filter.operator ?? 'eq').toLowerCase();
	const rawValue = filter.value;
	const actual = cellValue ?? '';

	if (operator === 'eq') return String(actual) === String(rawValue ?? '');
	if (operator === 'neq') return String(actual) !== String(rawValue ?? '');

	if (operator === 'gt' || operator === 'gte' || operator === 'lt' || operator === 'lte') {
		const left = Number(actual);
		const right = Number(rawValue);
		if (Number.isNaN(left) || Number.isNaN(right)) return false;
		if (operator === 'gt') return left > right;
		if (operator === 'gte') return left >= right;
		if (operator === 'lt') return left < right;
		return left <= right;
	}

	const haystack = String(actual).toLowerCase();
	const needle = String(rawValue ?? '').toLowerCase();

	if (operator === 'ilike' || operator === 'contains') {
		const startsWith = needle.startsWith('%');
		const endsWith = needle.endsWith('%');
		const core = needle.replace(/^%|%$/g, '');
		if (startsWith && endsWith) return haystack.includes(core);
		if (startsWith) return haystack.endsWith(core);
		if (endsWith) return haystack.startsWith(core);
		return haystack.includes(core);
	}

	return haystack.includes(needle);
}

function applyMockFilters(
	rows: Record<string, unknown>[],
	filters: Array<{ column: string; operator?: string; value?: unknown; logic_operator?: string }>
) {
	if (!filters.length) return rows;

	let result = rows;
	let pendingOr: typeof filters = [];

	const flushOrGroup = () => {
		if (!pendingOr.length) return;
		result = result.filter((row) => pendingOr.some((filter) => valueMatchesFilter(row[filter.column], filter)));
		pendingOr = [];
	};

	for (const filter of filters) {
		const logic = String(filter.logic_operator ?? 'AND').toUpperCase();
		if (logic === 'OR') {
			pendingOr.push(filter);
			continue;
		}

		flushOrGroup();
		result = result.filter((row) => valueMatchesFilter(row[filter.column], filter));
	}

	flushOrGroup();
	return result;
}

function applyMockSort(
	rows: Record<string, unknown>[],
	sort: Array<{ column: string; direction: 'asc' | 'desc' }>
) {
	if (!sort.length) return rows;
	const next = rows.slice();
	next.sort((a, b) => {
		for (const s of sort) {
			const av = a[s.column];
			const bv = b[s.column];
			const dir = s.direction === 'desc' ? -1 : 1;
			if (av == null && bv == null) continue;
			if (av == null) return -1 * dir;
			if (bv == null) return 1 * dir;
			if (av === bv) continue;
			return String(av).localeCompare(String(bv)) * dir;
		}
		return 0;
	});
	return next;
}

function mockResolveSpecFetch(dataset: Record<string, unknown>[]) {
	return async (_url: string, init?: RequestInit) => {
		const body = init?.body ? JSON.parse(init.body as string) : {};
		const offset = body.options?.offset ?? 0;
		const limit = body.options?.limit ?? 50;
		const filters = body.options?.filters ?? [];
		const sort = body.options?.sort ?? [];

		const filtered = applyMockFilters(dataset, filters);
		const sorted = applyMockSort(filtered, sort);
		const page = sorted.slice(offset, offset + limit);

		return new Response(
			JSON.stringify({
				success: true,
				data: page,
				metadata: {
					total: filtered.length,
					count: page.length,
					filtered: filtered.length,
					limit,
					offset
				}
			}),
			{ status: 200, headers: { 'Content-Type': 'application/json' } }
		);
	};
}

function getHeader(headers: HeadersInit | undefined, key: string): string | null {
	if (!headers) return null;
	if (headers instanceof Headers) return headers.get(key);
	if (Array.isArray(headers)) {
		const found = headers.find(([k]) => String(k).toLowerCase() === key.toLowerCase());
		return found ? String(found[1]) : null;
	}
	const obj = headers as Record<string, string>;
	for (const [k, v] of Object.entries(obj)) {
		if (k.toLowerCase() === key.toLowerCase()) return String(v);
	}
	return null;
}

function parseHeaderFilters(headers: HeadersInit | undefined) {
	const out: Array<{ column: string; operator?: string; value?: unknown; logic_operator?: string }> = [];
	if (!headers) return out;

	const entries: Array<[string, string]> =
		headers instanceof Headers
			? Array.from(headers.entries())
			: Array.isArray(headers)
				? headers.map(([k, v]) => [String(k), String(v)])
				: Object.entries(headers as Record<string, string>).map(([k, v]) => [String(k), String(v)]);

	const opWordToToken: Record<string, string> = {
		equals: 'eq',
		notequals: 'neq',
		greaterthan: 'gt',
		greaterthanorequal: 'gte',
		lessthan: 'lt',
		lessthanorequal: 'lte',
		contains: 'ilike',
		beginswith: 'ilike',
		endswith: 'ilike'
	};

	for (const [rawKey, rawValue] of entries) {
		const key = rawKey.toLowerCase();
		if (key.startsWith('x-fieldfilter-')) {
			const column = rawKey.slice('X-FieldFilter-'.length);
			out.push({ column, operator: 'eq', value: rawValue, logic_operator: 'AND' });
			continue;
		}
		if (key.startsWith('x-searchor-') || key.startsWith('x-searchop-')) {
			const isOr = key.startsWith('x-searchor-');
			const parts = rawKey.split('-');
			// X-SearchOr-<op>-<column>  / X-SearchOp-<op>-<column>
			const opWord = String(parts[2] ?? '').toLowerCase();
			const column = parts.slice(3).join('-');
			const operator = opWordToToken[opWord] ?? opWord;
			let value: unknown = rawValue;
			if (opWord === 'beginswith') value = `${rawValue}%`;
			if (opWord === 'endswith') value = `%${rawValue}`;
			out.push({ column, operator, value, logic_operator: isOr ? 'OR' : 'AND' });
		}
	}

	return out;
}

function parseHeaderSort(headers: HeadersInit | undefined) {
	const raw = getHeader(headers, 'X-Sort');
	if (!raw) return [];
	return String(raw)
		.split(',')
		.map((token) => token.trim())
		.filter(Boolean)
		.map((token) => {
			const direction = token.startsWith('-') ? 'desc' : 'asc';
			const column = token.replace(/^[-+]/, '');
			return { column, direction };
		});
}

function mockHeaderSpecFetch(dataset: Record<string, unknown>[]) {
	return async (_url: string, init?: RequestInit) => {
		const headers = init?.headers;
		const offset = Number(getHeader(headers, 'X-Offset') ?? '0') || 0;
		const limit = Number(getHeader(headers, 'X-Limit') ?? '50') || 50;
		const filters = parseHeaderFilters(headers);
		const sort = parseHeaderSort(headers);

		const filtered = applyMockFilters(dataset, filters);
		const sorted = applyMockSort(filtered, sort);
		const page = sorted.slice(offset, offset + limit);

		const end = page.length > 0 ? offset + page.length - 1 : offset;

		return new Response(JSON.stringify(page), {
			status: 200,
			headers: {
				'Content-Type': 'application/json',
				'content-range': `${offset}-${end}/${filtered.length}`,
				'x-limit': String(limit)
			}
		});
	};
}
```

Helper component `./SvarkGrid.svelte`:

```svelte
<script lang="ts">
	/* eslint-disable @typescript-eslint/no-explicit-any */
	import type { IApi } from '@svar-ui/svelte-grid';
	import SvarkGridView from './internal/SvarkGridView.svelte';
	import { SvarkResolveSpecAdapter } from './adapters/SvarkResolveSpecAdapter.js';
	import { SvarkRestHeaderSpecAdapter } from './adapters/SvarkRestHeaderSpecAdapter.js';
	import type {
		GridColumn,
		GridColumnFilter,
		GridColumnFilters,
		GridColumnSortOrder,
		GridCommonProps,
		GridEventType
	} from '../../Types/generic_grid';
	import { filterList } from '../../Types/generic_grid';
	import type {
		SvarkFilterState,
		SvarkSortState,
		SvarkAdapterType,
		SvarkInitialQuery,
		SvarkContextMenuItem,
		SvarkContextMenuProps,
		SvarkProps,
		IColumnConfig,
		IConfig
	} from './types.js';
	import {
		adapterKindFromDataSource,
		gridColumnFiltersToSvarkFilterState,
		idsToSelectedItems,
		mapGenericColumnsToSvarColumns,
		selectedItemsToIds,
		sortOrderToSvarkSortState,
		svarkSortStateToSortMarks,
		svarkSortStateToSortOrder,
		toCssSize
	} from './utils/svarGridMapping.js';
	import SvarkHeaderFilterCell from './components/SvarkHeaderFilterCell.svelte';
	import { SVARKGRID_DEFAULT_ITEMS_MARKER_ID } from './utils/contextMenu.js';
	import { useSvelixLayout } from '../../SvelixLayout/store.svelte.js';
	import { SVARK_GRID_DEFAULTS } from './svarkGridLayout.js';

	export type SvarProps = Partial<
		Omit<
			SvarkProps,
			// Generic-first equivalents / resolved from GridCommonProps:
			| 'columns'
			| 'height'
			// Server config is generic-first but allowed as fallback:
			| 'url'
			| 'baseUrl'
			| 'token'
			| 'schema'
			| 'entity'
		>
	> & {
		columns?: IColumnConfig[];
		url?: string;
		baseUrl?: string;
		token?: string;
		schema?: string;
		entity?: string;
		height?: string | number;
		config?: Omit<IConfig, 'data'>;
	};

	export interface Props extends GridCommonProps<Record<string, unknown>, unknown> {
		columns: Array<GridColumn<Record<string, unknown>, unknown>>;
		/** Svark/SVAR-grid escape hatch props. Generic props take precedence when overlapping. */
		SvarProps?: SvarProps;
		/** Used only when `searchValue` is undefined (uncontrolled). */
		defaultSearchValue?: string;
		/** Rows per fetch/page. If omitted, falls back to `SvarProps.pageSize` then 100. */
		pageSize?: number;
		/** Enables SVAR virtual scrolling. If omitted, falls back to `SvarProps.virtualScroll` then false. */
		virtualScroll?: boolean;
		/** Enables SVAR infinite scrolling. If omitted, falls back to `SvarProps.infiniteScroll` then false. */
		infiniteScroll?: boolean;
		/** Enables SVAR row selection. If omitted, falls back to `SvarProps.select` then true. */
		select?: boolean;
		/** Enables SVAR multi-row selection. If omitted, falls back to `SvarProps.multiselect` then false. */
		multiselect?: boolean;
	}

	let {
		columns,
		data: dataProp,
		width,
		height,
		rowHeight,
		headerHeight,
			onColumnMoved: _onColumnMoved,
		sortOrder,
		onSortOrderChange,
		filters,
		onFilterChange,
		searchValue,
		onSearchValueChange,
		selectedItems,
		onSelectedItemsChange,
		dataSource,
		dataSourceOptions,
		onDataChange,
		settings,
		onCellEvent,
		onGridEvent,
		onMenuClick,
		SvarProps: svarProps = {},
		defaultSearchValue: defaultSearchValueProp,
		pageSize: pageSizeProp,
		virtualScroll: virtualScrollProp,
		infiniteScroll: infiniteScrollProp,
		select: selectProp,
		multiselect: multiselectProp
	}: Props = $props();

	const layout = useSvelixLayout('svarkGrid', SVARK_GRID_DEFAULTS);
	const defaultSearchValue = $derived(defaultSearchValueProp ?? layout.current.defaultSearchValue);

	function normalizeAdapterType(t: SvarkAdapterType): 'ResolveSpec' | 'RestHeaderSpec' {
		return t === 'header' || t === 'RestHeaderSpec' ? 'RestHeaderSpec' : 'ResolveSpec';
	}

	const resolvedUrl = $derived(dataSourceOptions?.url ?? svarProps.url ?? svarProps.baseUrl ?? '');
	const resolvedToken = $derived(dataSourceOptions?.authToken ?? svarProps.token);
	const resolvedSchema = $derived(dataSourceOptions?.schema ?? svarProps.schema ?? '');
	const resolvedEntity = $derived(dataSourceOptions?.entity ?? svarProps.entity ?? '');
	const resolvedUniqueID = $derived(dataSourceOptions?.uniqueID ?? 'id');

	const resolvedAdapterType = $derived(
		normalizeAdapterType(
			svarProps.adapterType ??
				svarProps.clientType ??
				adapterKindFromDataSource(dataSource)
		)
	);

	const resolvedServerSide = $derived(
		svarProps.serverSide ??
			(!!dataSource && dataSource !== 'local' && dataSource !== 'custom')
	);

	const resolvedResizableColumns = $derived(settings?.resizableColumns ?? true);
	const resolvedHideHeader = $derived(settings?.hideHeader ?? false);
	const resolvedReadonly = $derived(settings?.readonly ?? false);
	const enableColumnFilters = $derived(resolvedServerSide || dataSource === 'local');

	const resolvedHeight = $derived(height ?? svarProps.height ?? layout.current.height);
	const resolvedWidth = $derived(width ?? layout.current.width);

	const baseColumns = $derived.by((): IColumnConfig[] => {
		if (svarProps.columns?.length) return svarProps.columns;
		return mapGenericColumnsToSvarColumns(columns, {
			serverSide: resolvedServerSide,
			resizableColumns: resolvedResizableColumns,
			hideHeader: resolvedHideHeader
		});
	});

	const heightStyle = $derived(toCssSize(resolvedHeight, '400px'));
	const widthStyle = $derived(toCssSize(resolvedWidth, '100%'));

	const pageSize = $derived(pageSizeProp ?? svarProps.pageSize ?? layout.current.pageSize);
	const virtualScroll = $derived(virtualScrollProp ?? svarProps.virtualScroll ?? layout.current.virtualScroll);
	const infiniteScroll = $derived(infiniteScrollProp ?? svarProps.infiniteScroll ?? layout.current.infiniteScroll);
	const select = $derived(selectProp ?? svarProps.select ?? layout.current.select);
	const multiselect = $derived(multiselectProp ?? svarProps.multiselect ?? layout.current.multiselect);
	const query = $derived(svarProps.query ?? ({} as SvarkInitialQuery));
	const config = $derived(svarProps.config ?? ({} as Omit<IConfig, 'data'>));

	const topBarLeft = $derived(svarProps.topBarLeft);
	const topBarRight = $derived(svarProps.topBarRight);
	const onchange = $derived(svarProps.onchange);
	const onerror = $derived(svarProps.onerror);
	const onselect = $derived(svarProps.onselect);
	const onrowclick = $derived(svarProps.onrowclick);
	const onrowdblclick = $derived(svarProps.onrowdblclick);
	const onrowcontextmenu = $derived(svarProps.onrowcontextmenu);
	const userContextMenuProps = $derived(svarProps.contextMenuProps);

	let data = $state<Record<string, unknown>[]>([]);
	let localRawData = $state<Record<string, unknown>[]>([]);
	let loading = $state(false);
	let infiniteLoading = $state(false);
	let error = $state<string | null>(null);
	let total = $state(0);
	let sortState = $state<SvarkSortState[]>([]);
	let gridFilterState = $state<SvarkFilterState>({});
	let searchFilterState = $state<SvarkFilterState>({});
	let page = $state(0);
	let reload = $state(0);
	let lastServerRequestKey = $state<string>('');
	let activeServerRequestId = 0;
	let activeServerRequestKey = '';

	let gridApi = $state<IApi | null>(null);
	let searchOpen = $state(false);
	// eslint-disable-next-line svelte/no-unused-svelte-ignore
	// svelte-ignore state_referenced_locally -- intentional initial capture; controlled changes handled by $effect
	let searchValueState = $state(defaultSearchValue);

	const effectiveFilterState = $derived({ ...gridFilterState, ...searchFilterState });

	// Paging is active when serverSide with no scroll variant
	const showPager = $derived(resolvedServerSide && !infiniteScroll && !virtualScroll);
	const totalPages = $derived(pageSize > 0 ? Math.ceil(total / pageSize) : 1);
	const pageStart = $derived(total === 0 ? 0 : page * pageSize + 1);
	const pageEnd = $derived(Math.min((page + 1) * pageSize, total));

	// ── Generic controlled/uncontrolled bridges ───────────────────────────────

	let internalSortOrder = $state<GridColumnSortOrder>({});
	$effect(() => {
		if (sortOrder === undefined) return;
		internalSortOrder = sortOrder;
	});
	const resolvedSortOrder = $derived(sortOrder ?? internalSortOrder);

	let internalFilters = $state<GridColumnFilters>({});
	$effect(() => {
		if (filters === undefined) return;
		internalFilters = filters;
	});
	const resolvedFilters = $derived(filters ?? internalFilters);

	const instanceId = $state(
		`svarkgrid-${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`
	);

	let internalSelectedItems = $state<Record<string, unknown>[]>([]);
	$effect(() => {
		if (selectedItems === undefined) return;
		internalSelectedItems = selectedItems;
	});
	const resolvedSelectedItems = $derived(selectedItems ?? internalSelectedItems);
	const resolvedSelectedIds = $derived(selectedItemsToIds(resolvedSelectedItems, resolvedUniqueID));

	function emitSortOrder(next: GridColumnSortOrder) {
		if (sortOrder === undefined) internalSortOrder = next;
		onSortOrderChange?.(next);
	}

	function emitFilters(next: GridColumnFilters) {
		if (filters === undefined) internalFilters = next;
		onFilterChange?.(next);
	}

	function emitGridEvent(
		type: GridEventType,
		item?: Record<string, unknown>,
		column?: GridColumn<Record<string, unknown>, unknown>,
		coords?: { x: number; y: number; code?: string },
		detail?: Record<string, unknown>
	) {
		onGridEvent?.(type, item, column, coords, detail);
	}

	const genericColumnsById = $derived.by(() => {
		const map: Record<string, GridColumn<Record<string, unknown>, unknown>> = {};
		for (const col of columns ?? []) map[String(col.id)] = col;
		return map;
	});

	function setColumnFilter(columnId: string, next: GridColumnFilter) {
		const col = genericColumnsById[columnId];
		const dataKey = col?.dataKey;
		const merged: GridColumnFilter = {
			...(filterList(resolvedFilters[columnId])[0] ?? {}),
			...next,
			...(dataKey ? { dataKey } : {})
		};
		emitFilters({ ...resolvedFilters, [columnId]: merged });
	}

	function clearColumnFilter(columnId: string) {
		const { [columnId]: _ignored, ...rest } = resolvedFilters;
		emitFilters(rest);
	}

	function setColumnSort(columnId: string, next: 'none' | 'asc' | 'desc') {
		// Prefer using the grid API so local mode actually sorts rows and
		// server-side mode triggers the existing intercept logic.
		if (gridApi) {
			if (next === 'none') {
				gridApi.exec('sort-rows', {} as any);
			} else {
				gridApi.exec('sort-rows', { key: columnId, add: false, order: next });
			}
			return;
		}

		const merged: GridColumnSortOrder = { ...(resolvedSortOrder ?? {}) };
		merged[columnId] = next;
		emitSortOrder(merged);
	}

	function isGenericColumnFilterDisabled(columnId: string): boolean {
		const col = genericColumnsById[columnId];
		return col?.disableFilter === true;
	}

	function enhanceColumnHeader(col: IColumnConfig): IColumnConfig {
		const id = (col as any)?.id;
		const columnId = id == null ? '' : String(id);
		const queryText = searchValueState.trim();

		// Enable column sorting by default (SVAR Grid expects `sort` opt-out).
		const sort = (col as any).sort ?? true;

		if (resolvedHideHeader) return { ...col, sort, header: undefined };

		const rawHeader = (col as any).header;
		// Avoid messing with complex multi-row headers.
		if (Array.isArray(rawHeader)) return { ...col, sort };

		const headerCfg: any =
			typeof rawHeader === 'string'
				? { text: rawHeader }
				: rawHeader && typeof rawHeader === 'object'
					? { ...rawHeader }
					: { text: columnId };

		const isSearchTarget =
			!!queryText &&
			!!columnId &&
			!(col as any).hidden &&
			(resolvedServerSide ? (col as any).searchable === true : (col as any).searchable !== false);

		if (!headerCfg.cell) {
			const filterable =
				enableColumnFilters &&
				!!columnId &&
				!(col as any).hidden &&
				!isGenericColumnFilterDisabled(columnId);

			headerCfg.cell = SvarkHeaderFilterCell;
			headerCfg.__svarkMenuId = `${instanceId}:filter:${columnId}`;
			headerCfg.__svarkFilterable = filterable;
			headerCfg.__svarkColumnId = columnId;
			headerCfg.__svarkColumnLabel = String(headerCfg.text ?? columnId);
			headerCfg.__svarkSearchHighlight = isSearchTarget;
			headerCfg.__svarkCurrentSort = (resolvedSortOrder?.[columnId] ?? 'none') as any;
			headerCfg.__svarkSetSort = (next: 'none' | 'asc' | 'desc') => setColumnSort(columnId, next);
			headerCfg.__svarkCurrentFilter = filterList(resolvedFilters[columnId])[0];
			headerCfg.__svarkSetFilter = (next: GridColumnFilter) => setColumnFilter(columnId, next);
			headerCfg.__svarkClearFilter = () => clearColumnFilter(columnId);
		}

		return { ...col, sort, header: headerCfg };
	}

	const gridColumns = $derived.by(() => (baseColumns ?? []).map((col) => enhanceColumnHeader(col)));

	function emitSearchValue(next: string) {
		if (searchValue === undefined) searchValueState = next;
		onSearchValueChange?.(next);
	}

	function emitSelectedItems(next: Record<string, unknown>[]) {
		if (selectedItems === undefined) internalSelectedItems = next;
		onSelectedItemsChange?.(next);
	}

	$effect(() => {
		sortState = sortOrderToSvarkSortState(resolvedSortOrder);
	});

	$effect(() => {
		gridFilterState = gridColumnFiltersToSvarkFilterState(resolvedFilters, { prefix: '' });
	});

	$effect(() => {
		if (searchValue === undefined) return;
		searchValueState = searchValue;
	});

	// ── Local data mode (dataSource: 'local' | 'custom') ──────────────────────

	$effect(() => {
		if (resolvedServerSide) return;

		void reload; // allow Refresh to re-run local loaders

		error = null;
		loading = false;

		if (!dataProp) {
			localRawData = [];
			data = [];
			total = 0;
			return;
		}

		let cancelled = false;

		if (typeof dataProp === 'function') {
			loading = true;
			dataProp()
				.then((result) => {
					if (cancelled) return;
					localRawData = (result ?? []) as any;
				})
				.catch((e) => {
					if (cancelled) return;
					const msg = e instanceof Error ? e.message : 'Failed to load data';
					error = msg;
					onerror?.(msg);
				})
				.finally(() => {
					if (!cancelled) loading = false;
				});
		} else {
			localRawData = (dataProp ?? []) as any;
		}

		return () => {
			cancelled = true;
		};
	});

	function valueMatchesLocal(cellValue: unknown, filter: { operator?: string; value?: unknown }) {
		const operator = String(filter.operator ?? 'eq').toLowerCase();
		const rawValue = filter.value;
		const actual = cellValue ?? '';

		if (operator === 'eq') return String(actual) === String(rawValue ?? '');
		if (operator === 'neq') return String(actual) !== String(rawValue ?? '');

		if (operator === 'gt' || operator === 'gte' || operator === 'lt' || operator === 'lte') {
			const left = Number(actual);
			const right = Number(rawValue);
			if (Number.isNaN(left) || Number.isNaN(right)) return false;
			if (operator === 'gt') return left > right;
			if (operator === 'gte') return left >= right;
			if (operator === 'lt') return left < right;
			return left <= right;
		}

		const haystack = String(actual).toLowerCase();
		let needle = String(rawValue ?? '').toLowerCase();

		if (operator === 'like' || operator === 'ilike' || operator === 'contains') {
			const startsWith = needle.startsWith('%');
			const endsWith = needle.endsWith('%');
			const core = needle.replace(/^%|%$/g, '');
			// If the UI provided a bare string, treat it like "%value%".
			if (!startsWith && !endsWith) return haystack.includes(needle);
			if (startsWith && endsWith) return haystack.includes(core);
			if (startsWith) return haystack.endsWith(core);
			if (endsWith) return haystack.startsWith(core);
			return haystack.includes(core);
		}

		if (operator === 'startswith') return haystack.startsWith(needle);
		if (operator === 'endswith') return haystack.endsWith(needle);

		return haystack.includes(needle);
	}

	function applyLocalFiltersAndSearch(rows: Record<string, unknown>[]) {
		let result = rows.slice();

		const entries = Object.values(effectiveFilterState);
		const andFilters = entries.filter((f) => String(f.logic_operator ?? 'AND').toUpperCase() !== 'OR');
		const orFilters = entries.filter((f) => String(f.logic_operator ?? 'AND').toUpperCase() === 'OR');

		for (const f of andFilters) {
			const col = String(f.column ?? '');
			if (!col) continue;
			result = result.filter((row) => valueMatchesLocal((row as any)[col], f));
		}

		if (orFilters.length > 0) {
			result = result.filter((row) =>
				orFilters.some((f) => {
					const col = String(f.column ?? '');
					if (!col) return false;
					return valueMatchesLocal((row as any)[col], f);
				})
			);
		}

		const q = searchValueState.trim().toLowerCase();
		if (q) {
			const searchableCols = gridColumns
				.filter((c: any) => c?.id != null && !c.hidden && c.searchable !== false)
				.map((c: any) => String(c.id));

			if (searchableCols.length > 0) {
				result = result.filter((row) =>
					searchableCols.some((id) => String((row as any)[id] ?? '').toLowerCase().includes(q))
				);
			}
		}

		return result;
	}

	$effect(() => {
		if (resolvedServerSide) return;
		const next = applyLocalFiltersAndSearch(localRawData);
		data = next;
		total = next.length;
	});

	const sortMarks = $derived.by(() => svarkSortStateToSortMarks(sortState));

	const gridConfig = $derived.by(() => {
		const base = config ?? {};
		const sizes = {
			...(base as any).sizes,
			...(rowHeight != null ? { rowHeight } : {}),
			...(headerHeight != null ? { headerHeight } : {})
		};

		const next: Omit<IConfig, 'data'> & {
			selectedRows?: Array<string | number>;
		} = {
			...base,
			sizes,
			// Keep SVAR's sort indicators in sync with generic sortOrder.
			...(resolvedServerSide ? { sortMarks } : {}),
			...(selectedItems !== undefined ? { selectedRows: resolvedSelectedIds } : {})
		};

		return next;
	});

	// Debounced mirrors of sortState/filterState used by the main fetch effect.
	// When serverSide is true, changes are held for 300 ms before committing so
	// rapid keystrokes / click-sort chains don't fire a request per event.
	// Page resets to 0 atomically with the debounced update so only one fetch fires.
	let debouncedSort = $state<SvarkSortState[]>([]);
	let debouncedFilter = $state<SvarkFilterState>({});
	let _debounceTimer: ReturnType<typeof setTimeout> | null = null;

	function isSameSortState(a: SvarkSortState[], b: SvarkSortState[]): boolean {
		if (a.length !== b.length) return false;
		return a.every((item, index) => item.key === b[index]?.key && item.order === b[index]?.order);
	}

	function isSameFilterState(a: SvarkFilterState, b: SvarkFilterState): boolean {
		const aKeys = Object.keys(a);
		const bKeys = Object.keys(b);
		if (aKeys.length !== bKeys.length) return false;

		for (const key of aKeys) {
			const left = a[key];
			const right = b[key];
			if (
				!right ||
				left?.column !== right.column ||
				left?.operator !== right.operator ||
				left?.logic_operator !== right.logic_operator ||
				left?.value !== right.value
			) {
				return false;
			}
		}

		return true;
	}

	$effect(() => {
		const s = sortState;
		const f = effectiveFilterState;

		if (!resolvedServerSide) {
			debouncedSort = s;
			debouncedFilter = f;
			return;
		}

		if (_debounceTimer !== null) clearTimeout(_debounceTimer);
		_debounceTimer = setTimeout(() => {
			_debounceTimer = null;
			if (page === 0 && isSameSortState(debouncedSort, s) && isSameFilterState(debouncedFilter, f)) {
				return;
			}
			page = 0; // reset page atomically — prevents a double-fetch
			debouncedSort = s;
			debouncedFilter = f;
		}, 300);

		return () => {
			if (_debounceTimer !== null) {
				clearTimeout(_debounceTimer);
				_debounceTimer = null;
			}
		};
	});

	function getLocalSearchColumns() {
		const columnsMap: Record<string, boolean> = {};
		for (const col of gridColumns) {
			const id = (col as any).id;
			if (id == null) continue;
			if ((col as any).hidden) continue;
			if ((col as any).searchable === false) continue;
			columnsMap[String(id)] = true;
		}
		return Object.keys(columnsMap).length > 0 ? columnsMap : undefined;
	}

	function applyServerSearchFilters(value: string) {
		if (!resolvedServerSide) return;

		if (!value) {
			// Avoid triggering a refetch loop by reassigning `{}` repeatedly.
			if (Object.keys(searchFilterState).length === 0) return;
			searchFilterState = {};
			return;
		}

		const searchableIds: string[] = [];
		for (const col of gridColumns) {
			const id = (col as any).id;
			if (id == null) continue;
			if ((col as any).hidden) continue;
			if ((col as any).searchable === true) searchableIds.push(String(id));
		}

		if (searchableIds.length === 0) {
			if (Object.keys(searchFilterState).length === 0) return;
			searchFilterState = {};
			return;
		}

		const next: SvarkFilterState = {};
		for (const id of searchableIds) {
			// Avoid clobbering an existing column filter the user set elsewhere.
			const hasExistingFilter = Object.entries({ ...gridFilterState }).some(([key, filter]) => {
				const target = filter.column ?? key;
				return target === id && filter.value != null && filter.value !== '';
			});
			if (hasExistingFilter) continue;
			next[`search:${id}`] = { column: id, value, operator: 'ilike', logic_operator: 'OR' };
		}

		// Idempotency guard: avoid reassigning equivalent filter state.
		const prev = searchFilterState;
		const prevKeys = Object.keys(prev);
		const nextKeys = Object.keys(next);
		if (prevKeys.length === nextKeys.length) {
			let same = true;
			for (const k of nextKeys) {
				const a = (prev as any)[k];
				const b = (next as any)[k];
				if (!a || !b) {
					same = false;
					break;
				}
				if (
					String(a.column ?? '') !== String(b.column ?? '') ||
					String(a.operator ?? '') !== String(b.operator ?? '') ||
					String(a.logic_operator ?? '') !== String(b.logic_operator ?? '') ||
					String(a.value ?? '') !== String(b.value ?? '')
				) {
					same = false;
					break;
				}
			}
			if (same) return;
		}

		searchFilterState = next;
	}

	function setSearch(next: string) {
		emitSearchValue(next);
		gridApi?.exec('search-rows', { search: next, columns: getLocalSearchColumns() });
		applyServerSearchFilters(next.trim());
	}

	function refreshGrid() {
		reload++;
	}

	function clearAllSort() {
		// Clear in the UI/store first (local mode), then clear generic sort state.
		gridApi?.exec('sort-rows', {} as any);
		emitSortOrder({});
	}

	function clearAllFilters() {
		emitFilters({});
	}

	async function applyLocalDataChange(next: Record<string, unknown>[]) {
		localRawData = next;
		await onDataChange?.(next);
	}

	function resolveGenericColumn(columnId: string | undefined) {
		if (!columnId) return undefined;
		return genericColumnsById[columnId];
	}

	function extractRowsFromResponse(response: unknown): Record<string, unknown>[] {
		if (Array.isArray(response)) {
			return response as Record<string, unknown>[];
		}

		if (
			response &&
			typeof response === 'object' &&
			Array.isArray((response as { data?: unknown }).data)
		) {
			return (response as { data: Record<string, unknown>[] }).data;
		}

		return [];
	}

	function normalizeGridRows(rows: Record<string, unknown>[], uniqueID: string): Record<string, unknown>[] {
		return rows.map((row) => {
			const rowId = row?.id;
			const uniqueValue = row?.[uniqueID];
			if (rowId != null || uniqueValue == null) return row;
			return { ...row, id: uniqueValue };
		});
	}

	function extractTotalFromResponse(response: unknown, rows: Record<string, unknown>[]): number {
		if (
			response &&
			typeof response === 'object' &&
			typeof (response as { metadata?: { total?: unknown } }).metadata?.total === 'number'
		) {
			return (response as { metadata: { total: number } }).metadata.total;
		}

		return rows.length;
	}

	$effect(() => {
		// Keep SVAR's local highlight + server-side search filters in sync with controlled searchValue.
		const next = searchValueState;
		gridApi?.exec('search-rows', { search: next, columns: getLocalSearchColumns() });
		applyServerSearchFilters(next.trim());
	});

	function openSearch() {
		searchOpen = true;
	}

	function closeSearch() {
		searchOpen = false;
	}

	let selectedIdsInternal = $state<(string | number)[]>([]);
	$effect(() => {
		if (selectedItems === undefined) return;
		selectedIdsInternal = resolvedSelectedIds;
	});
	function handleSelectedIdsChange(ids: Array<string | number>) {
		selectedIdsInternal = ids;
		emitSelectedItems(idsToSelectedItems(ids, data, resolvedUniqueID));
	}

	const contextMenuProps = $derived.by((): SvarkContextMenuProps => {
		const hasSort = Object.values(resolvedSortOrder ?? {}).some((dir) => dir === 'asc' || dir === 'desc');
		const hasFilters = Object.keys(resolvedFilters ?? {}).length > 0;
		const hasSearch = searchValueState.trim().length > 0;

		return {
			items: (ctx) => {
				const defaults: SvarkContextMenuItem[] = [
					{ id: 'refresh', label: 'Refresh', onselect: () => refreshGrid() },
					{ id: 'clear-sort', label: 'Clear Sort', disabled: !hasSort, onselect: () => clearAllSort() },
					{ id: 'clear-filters', label: 'Clear Filters', disabled: !hasFilters, onselect: () => clearAllFilters() },
					{ id: 'clear-search', label: 'Clear Search', disabled: !hasSearch, onselect: () => setSearch('') }
				];

				const userItemsRaw =
					typeof userContextMenuProps?.items === 'function'
						? (userContextMenuProps.items as any)(ctx)
						: (userContextMenuProps?.items as any);

				const userItems = Array.isArray(userItemsRaw) ? (userItemsRaw as SvarkContextMenuItem[]) : [];
				if (userItems.length === 0) return defaults;

				const hasMarker = userItems.some((it) => it?.id === SVARKGRID_DEFAULT_ITEMS_MARKER_ID);
				if (hasMarker) {
					const merged: SvarkContextMenuItem[] = [];
					for (const it of userItems) {
						if (it?.id === SVARKGRID_DEFAULT_ITEMS_MARKER_ID) merged.push(...defaults);
						else merged.push(it);
					}
					return merged;
				}

				return [...defaults, { id: '__sep__', kind: 'separator', label: '' } as any, ...userItems];
			},
			onselectitem: userContextMenuProps?.onselectitem
		};
	});

	const isServerConfigured = $derived(!!(resolvedUrl && resolvedSchema && resolvedEntity));

	type Adapter = SvarkResolveSpecAdapter | SvarkRestHeaderSpecAdapter;

	const adapter = $derived.by((): Adapter | null => {
		if (!isServerConfigured) return null;
		return resolvedAdapterType === 'RestHeaderSpec'
			? new SvarkRestHeaderSpecAdapter({
					url: resolvedUrl,
					token: resolvedToken,
					schema: resolvedSchema,
					entity: resolvedEntity
				})
			: new SvarkResolveSpecAdapter({
					url: resolvedUrl,
					token: resolvedToken,
					schema: resolvedSchema,
					entity: resolvedEntity
				});
	});

	// For virtual scroll: set dynamic rowCount once total is known
	const gridDynamic = $derived(
		virtualScroll && total > 0 ? { rowCount: total } : undefined
	);

	// ── Main data effect (initial load + sort/filter/page/reload triggers) ──────

	$effect(() => {
		const _adapter = adapter;
		const _limit = virtualScroll ? 1 : pageSize;
		const _sort = resolvedServerSide ? debouncedSort : [];
		const _filters = resolvedServerSide ? debouncedFilter : {};
		// Paging offset: only applied when server-side paging is active
		const _offset = showPager ? page * pageSize : 0;
		void reload;

		if (!_adapter) {
			activeServerRequestId += 1;
			activeServerRequestKey = '';
			loading = false;
			error = resolvedServerSide
				? 'Missing server configuration (dataSourceOptions.url/schema/entity)'
				: null;
			if (error) onerror?.(error);
			return;
		}

		// Guard: if something upstream keeps re-triggering the effect without changing the
		// actual server query parameters, avoid a tight refetch loop.
		let requestId = 0;
		let reqKey = '';
		if (resolvedServerSide) {
			const sortKey = JSON.stringify(_sort ?? []);
			const filterKey = JSON.stringify(
				Object.keys(_filters ?? {})
					.sort()
					.map((k) => [k, (_filters as any)[k]?.column, (_filters as any)[k]?.operator, (_filters as any)[k]?.value])
			);
			const queryKey = JSON.stringify(query ?? {});
			reqKey = JSON.stringify({
				url: resolvedUrl,
				schema: resolvedSchema,
				entity: resolvedEntity,
				adapter: resolvedAdapterType,
				limit: _limit,
				offset: _offset,
				sortKey,
				filterKey,
				queryKey,
				reload
			});

			if (reqKey === lastServerRequestKey) {
				return;
			}

			lastServerRequestKey = reqKey;
			activeServerRequestKey = reqKey;
			activeServerRequestId += 1;
			requestId = activeServerRequestId;
		}

		loading = true;
		error = null;

		_adapter
			.read(_sort, _filters, _limit, _offset, query)
			.then((response) => {
				if (
					resolvedServerSide &&
					(requestId !== activeServerRequestId || reqKey !== activeServerRequestKey)
				) {
					return;
				}
				if ((response as any)?.success !== false) {
					const incoming = normalizeGridRows(
						extractRowsFromResponse(response),
						resolvedUniqueID
					);
					total = extractTotalFromResponse(response, incoming);

					if (virtualScroll) {
						data = [];
					} else {
						data = incoming;
					}
					emitGridEvent('load', incoming[0], undefined, { x: 0, y: 0 }, { total });

					// If selection is currently id-based, refresh selectedItems mapping.
					if (selectedIdsInternal.length > 0 && onSelectedItemsChange) {
						const items = idsToSelectedItems(selectedIdsInternal, incoming as any, resolvedUniqueID);
						emitSelectedItems(items);
					}
				} else {
					const msg = (response as any).error?.message ?? 'Failed to load data';
					error = msg;
					onerror?.(msg);
				}
			})
			.catch((e) => {
				if (
					resolvedServerSide &&
					(requestId !== activeServerRequestId || reqKey !== activeServerRequestKey)
				) {
					return;
				}
				const msg = e instanceof Error ? e.message : 'Unknown error';
				error = msg;
				onerror?.(msg);
			})
			.finally(() => {
				if (
					!resolvedServerSide ||
					(requestId === activeServerRequestId && reqKey === activeServerRequestKey)
				) {
					loading = false;
				}
			});
	});

	async function loadNextPage() {
		const _adapter = adapter;
		if (!_adapter) return;

		// Block if main fetch is running (sort/filter change in progress)
		if (loading || infiniteLoading || data.length >= total) return;

		// Snapshot debounced sort+filter so we can discard the response if they
		// change while the request is in-flight (race condition guard).
		const snapshotSort = resolvedServerSide ? JSON.stringify(debouncedSort) : '';
		const snapshotFilter = resolvedServerSide ? JSON.stringify(debouncedFilter) : '';
		const snapshotOffset = data.length;

		infiniteLoading = true;
		try {
			const response = await _adapter.read(
				resolvedServerSide ? debouncedSort : [],
				resolvedServerSide ? debouncedFilter : {},
				pageSize,
				snapshotOffset,
				query
			);
			// Discard if sort/filter changed while we were fetching
			if (resolvedServerSide) {
				if (
					JSON.stringify(debouncedSort) !== snapshotSort ||
					JSON.stringify(debouncedFilter) !== snapshotFilter
				) {
					return;
				}
			}
			if ((response as any)?.success !== false) {
				const incoming = normalizeGridRows(
					extractRowsFromResponse(response),
					resolvedUniqueID
				);
				data = [...data, ...incoming];
			}
		} catch {
			// Silent — don't replace existing rows with an error on pagination
		} finally {
			infiniteLoading = false;
		}
	}

	// ── SVAR Grid init ─────────────────────────────────────────────────────────

	function handleInit(api: IApi) {
		gridApi = api;

		// Row selection callback
		selectedIdsInternal = ((api.getState() as any).selectedRows ?? []) as (string | number)[];
		api.on('select-row', () => {
			const ids = ((api.getState() as any).selectedRows ?? []) as (string | number)[];
			selectedIdsInternal = ids;
			onselect?.(ids);
			emitSelectedItems(idsToSelectedItems(ids, data, resolvedUniqueID));
		});

		// Virtual scroll: SVAR fires request-data when the visible row window changes.
		// We fetch only that window from the API and replace data.
		if (virtualScroll) {
			api.on('request-data', async (ev: any) => {
				const _adapter = adapter;
				if (!_adapter) return;

				const { start, end } = ev.row as { start: number; end: number };
				const limit = end - start;
				if (limit <= 0) return;

				loading = true;
				try {
					const response = await _adapter.read(
						resolvedServerSide ? debouncedSort : [],
						resolvedServerSide ? debouncedFilter : {},
						limit,
						start,
						query
					);
					if ((response as any)?.success !== false) {
						data = normalizeGridRows(extractRowsFromResponse(response), resolvedUniqueID);
					}
				} catch (e) {
					const msg = e instanceof Error ? e.message : 'Failed to load data';
					error = msg;
					onerror?.(msg);
				} finally {
					loading = false;
				}
			});
		}

		// Keep generic sortOrder in sync with SVAR, in both local and server-side modes.
		// In server-side mode we block SVAR's default sorting (the server returns sorted rows).
		api.intercept('sort-rows', (ev: any) => {
			if (ev.key) {
				if (ev.add) {
					const idx = sortState.findIndex((s) => s.key === ev.key);
					if (idx >= 0) {
						sortState[idx] = { key: ev.key, order: ev.order ?? 'asc' };
					} else {
						sortState = [...sortState, { key: ev.key, order: ev.order ?? 'asc' }];
					}
				} else {
					sortState = [{ key: ev.key, order: ev.order ?? 'asc' }];
				}
			} else {
				sortState = [];
			}
			emitSortOrder(svarkSortStateToSortOrder(sortState));
			return resolvedServerSide ? false : true;
		});

		api.intercept('resize-column', (ev: any) => {
			emitGridEvent(
				'column_resized',
				undefined,
				resolveGenericColumn(ev?.id != null ? String(ev.id) : undefined),
				{ x: 0, y: 0 },
				{ width: ev?.width, inProgress: ev?.inProgress }
			);
			return true;
		});

		api.on('add-row', async (ev: any) => {
			if (resolvedReadonly) return;
			const _adapter = adapter;
			try {
				if (_adapter) {
					await _adapter.create(ev.row);
				} else {
					await applyLocalDataChange([...localRawData, ev.row]);
				}
				onchange?.({ action: 'add', row: ev.row });
				reload++;
			} catch (e) {
				onerror?.(e instanceof Error ? e.message : 'Create failed');
			}
		});

		api.on('delete-row', async (ev: any) => {
			if (resolvedReadonly) return;
			const _adapter = adapter;
			try {
				if (_adapter) {
					await _adapter.delete(ev.id);
				} else {
					await applyLocalDataChange(
						localRawData.filter((row) => String(row?.[resolvedUniqueID]) !== String(ev.id))
					);
				}
				onchange?.({ action: 'delete', id: ev.id });
				reload++;
			} catch (e) {
				onerror?.(e instanceof Error ? e.message : 'Delete failed');
			}
		});

		api.on('update-cell', async (ev: any) => {
			if (resolvedReadonly) return;
			const _adapter = adapter;
			try {
				if (_adapter) {
					await _adapter.update(ev.id, { [String(ev.column)]: ev.value });
				} else {
					await applyLocalDataChange(
						localRawData.map((row) =>
							String(row?.[resolvedUniqueID]) === String(ev.id)
								? { ...row, [String(ev.column)]: ev.value }
								: row
						)
					);
				}
				onchange?.({ action: 'update', id: ev.id, column: ev.column, value: ev.value });
			} catch (e) {
				onerror?.(e instanceof Error ? e.message : 'Update failed');
			}
		});
	}

	let lastSortSignature = $state('');
	$effect(() => {
		const signature = JSON.stringify(resolvedSortOrder ?? {});
		if (!signature || signature === lastSortSignature) return;
		lastSortSignature = signature;
		emitGridEvent('sort_changed', undefined, undefined, { x: 0, y: 0 }, { sortOrder: resolvedSortOrder });
	});

	let lastFilterSignature = $state('');
	$effect(() => {
		const signature = JSON.stringify(resolvedFilters ?? {});
		if (signature === lastFilterSignature) return;
		lastFilterSignature = signature;
		emitGridEvent('filter_changed', undefined, undefined, { x: 0, y: 0 }, { filters: resolvedFilters });
	});

	// eslint-disable-next-line svelte/no-unused-svelte-ignore
	// svelte-ignore state_referenced_locally -- intentional initial capture for change-detection
	let lastSearchValue = $state(searchValueState);
	$effect(() => {
		const next = searchValueState;
		if (next === lastSearchValue) return;
		lastSearchValue = next;
		emitGridEvent('search_changed', undefined, undefined, { x: 0, y: 0 }, { value: next });
	});

	let lastSelectionSignature = $state('');
	$effect(() => {
		const signature = JSON.stringify(selectedIdsInternal);
		if (signature === lastSelectionSignature) return;
		lastSelectionSignature = signature;
		emitGridEvent('selection_changed', resolvedSelectedItems[0], undefined, { x: 0, y: 0 }, {
			selectedIds: selectedIdsInternal,
			selectedItems: resolvedSelectedItems
		});
	});

	let lastSettingsSignature = $state('');
	$effect(() => {
		const signature = JSON.stringify(settings ?? {});
		if (signature === lastSettingsSignature) return;
		lastSettingsSignature = signature;
		emitGridEvent('settings_changed', undefined, undefined, { x: 0, y: 0 }, { settings });
	});

	let lastPageSignature = $state('');
	$effect(() => {
		if (!showPager) return;
		const signature = `${page}:${totalPages}:${pageStart}:${pageEnd}`;
		if (signature === lastPageSignature) return;
		lastPageSignature = signature;
		emitGridEvent('page_loaded', data[0], undefined, { x: 0, y: 0 }, {
			page,
			totalPages,
			pageStart,
			pageEnd
		});
	});

	let lastDataSignature = $state('');
	$effect(() => {
		const signature = `${data.length}:${total}:${loading ? 1 : 0}`;
		if (signature === lastDataSignature || loading) return;
		lastDataSignature = signature;
		emitGridEvent('data_changed', data[0], undefined, { x: 0, y: 0 }, { data, total });
	});
</script>

<SvarkGridView
	{heightStyle}
	{widthStyle}
	menuProviderID={instanceId}
	hideHeader={resolvedHideHeader}
	columns={gridColumns}
	{data}
	{loading}
	{error}
	{select}
	{multiselect}
	gridDynamic={gridDynamic}
	gridConfig={gridConfig as any}
	onInit={handleInit}
	{topBarLeft}
	{topBarRight}
	{searchOpen}
	searchValue={searchValueState}
	onToggleSearch={searchOpen ? closeSearch : openSearch}
	onSearchChange={setSearch}
	{infiniteScroll}
	{infiniteLoading}
	{total}
	onLoadNextPage={loadNextPage}
	{showPager}
	{page}
	{totalPages}
	{pageStart}
	{pageEnd}
	onPageFirst={() => (page = 0)}
	onPagePrev={() => page--}
	onPageNext={() => page++}
	onPageLast={() => (page = totalPages - 1)}
	gridApi={gridApi}
	uniqueID={resolvedUniqueID}
	genericColumnsById={genericColumnsById}
	selectedIds={selectedIdsInternal}
	onSelectedIdsChange={handleSelectedIdsChange}
	onRowClick={onrowclick}
	onRowDblClick={onrowdblclick}
	onRowContextMenu={onrowcontextmenu}
	contextMenuProps={contextMenuProps}
	{onGridEvent}
	{onCellEvent}
	{onMenuClick}
/>
```

#### Resolve Spec (`ResolveSpec`)

```ts
// ── Stories ─────────────────────────────────────────────────────────────────

export const ResolveSpec: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = mockResolveSpecFetch(mockUsers);
			return Story();
		}
	],
	args: {
		columns: genericColumns,
		height: 520,
		dataSource: 'resolvespec',
		dataSourceOptions: {
			url: 'http://localhost:3000',
			schema: 'public',
			entity: 'users'
		},
		settings: { showSearch: true },
		searchValue: 'Admin',
		filters: {
			department: { value: 'Eng', op: 'startsWith' }
		}
	}
};
```

#### Rest Header Spec (`RestHeaderSpec`)

```ts
export const RestHeaderSpec: Story = {
	decorators: [
		(Story) => {
			globalThis.fetch = mockHeaderSpecFetch(mockUsers);
			return Story();
		}
	],
	args: {
		columns: genericColumns,
		height: 520,
		dataSource: 'headerspec',
		dataSourceOptions: {
			url: 'http://localhost:3000',
			schema: 'public',
			entity: 'users'
		},
		settings: { showSearch: true },
		searchValue: 'Alice',
		filters: {
			role: { value: 'Admin', op: 'equals' }
		}
	}
};
```

#### Local Filtering And Search (`LocalFilteringAndSearch`)

```ts
export const LocalFilteringAndSearch: Story = {
	args: {
		columns: genericColumns,
		height: 520,
		dataSource: 'local',
		data: mockUsers,
		settings: { showSearch: true }
	}
};
```

#### Live Resolve Spec (`LiveResolveSpec`)

```ts
export const LiveResolveSpec: Story = {
	argTypes: liveArgTypes,
	args: {
		apiUrl: "https://utils.btsys.tech/api",
		authToken: "A684939F-BA4F-4CC1-9F5F-7050A2637171",
		schema: "public",
		entity: "todolist",
		uniqueID: "id_todolist",
		columns: [{
            "id": "id_todolist",
            "title": "ID",
            "width": 90
        }, {
            "id": "name",
            "title": "Process",
            "width": 220
        }, {
            "id": "description",
            "title": "Description",
            "width": 280
        }],
		height: 520,
		dataSource: "headerspec",
		settings: { showSearch: true }
	},
	render: ({ apiUrl, authToken, schema, entity, uniqueID, ...args }) => ({
		Component: SvarkGrid,
		props: {
			...args,
			dataSourceOptions: {
				url: apiUrl,
				authToken,
				schema,
				entity,
				uniqueID
			}
		}
	})
};
```

#### Live Rest Header Spec (`LiveRestHeaderSpec`)

```ts
export const LiveRestHeaderSpec: Story = {
	argTypes: {
		...liveArgTypes,
		apiUrl: { control: 'text', description: 'RestHeaderSpec API base URL' },
		authToken: { control: 'text', description: 'Bearer token for RestHeaderSpec authentication' }
	},
	args: {
		apiUrl: 'https://utils.btsys.tech/api/v2',
		authToken: '',
		schema: 'public',
		entity: 'process',
		uniqueID: 'id_process',
		columns: [
			{ id: 'id_process', title: 'ID', width: 90 },
			{ id: 'process', title: 'Process', width: 220 },
			{ id: 'description', title: 'Description', width: 280 }
		],
		height: 520,
		dataSource: 'headerspec',
		settings: { showSearch: true }
	},
	render: ({ apiUrl, authToken, schema, entity, uniqueID, ...args }) => ({
		Component: SvarkGrid,
		props: {
			...args,
			dataSourceOptions: {
				url: apiUrl,
				authToken,
				schema,
				entity,
				uniqueID
			}
		}
	})
};
```

---

# Elements

Custom elements (`<svelix-*>`) for non-Svelte hosts. Register with `import '@warky/svelix/elements/register'`; per-element entries are `@warky/svelix/elements/svelix-<name>`. Attributes map to props; light-DOM children are the content.

## Summary

- **Elements/Alert**: The compiled `<svelix-alert>` custom element. Attributes `variant`, `color`, `radius`, `title`, `with-close-button`, `close-button-label`; light-DOM children are the message. The close button dispatches `svelix-close` (the host stays; remove it in the handler). Requires `pnpm build:elements`.
- **Elements/Breadcrumbs**: The compiled `<svelix-breadcrumbs>` custom element. `items` is a JSON attribute or an array property; `separator`, `separator-margin`, `label`. Link clicks dispatch cancelable `svelix-itemclick` (`detail: { item, index }`); `preventDefault()` stops navigation. Bad JSON → `svelix-error` + `data-svelix-error`. Requires `pnpm build:elements`.
- **Elements/Dialog**: The compiled `<svelix-dialog>` custom element. `open` reflects as a boolean attribute; closing dispatches `svelix-close`, and the close request itself fires a cancelable `svelix-requestclose` first (check the demo checkbox to see it blocked). This story renders the real `dist/elements/` build output, not the Svelte source component, so it requires `pnpm build:elements` to have been run at least once.
- **Elements/Drawer**: The compiled `<svelix-drawer>` custom element. `open` reflects as a boolean attribute; closing dispatches `svelix-close`, and the close request itself fires a cancelable `svelix-requestclose` first (check the demo checkbox to see it blocked). This story renders the real `dist/elements/` build output, not the Svelte source component, so it requires `pnpm build:elements` to have been run at least once.
- **Elements/ErrorBoundary**: The compiled `<svelix-error-boundary>` custom element. Light-DOM children are captured on connect and re-projected via `createRawSnippet` (native `<slot>` does not work in light DOM). Click "Trigger error" to call its imperative `handleError()` method. This story renders the real `dist/elements/` build output, not the Svelte source component, so it requires `pnpm build:elements` to have been run at least once.
- **Elements/Grid**: The compiled `<svelix-grid>` custom element, wrapping `GridlerFull`. Structured props (`columns`, `data`, `data-source-options`, `filters`, `sort-order`, `settings`, `menu-items`) are written as JSON attributes, or assigned as real values to the element property. Server mode is configured through `data-source="resolvespec"` plus `data-source-options`, whose `extraOptions` carries the ResolveSpec query options (`preload`, `omit_columns`, `computedColumns`, `parameters`, ...). `total` and `loading` reflect as attributes. This story renders the real `dist/elements/` build output, not the Svelte source component, so it requires `pnpm build:elements` to have been run at least once.
- **Elements/IFrame**: The compiled `<svelix-iframe>` custom element from the `./elements/register` + `./elements/elements.css` package exports - usable from any framework or plain HTML with no build step. This story renders the real `dist/elements/` build output, not the Svelte source component, so it requires `pnpm build:elements` to have been run at least once.
- **Elements/Paner**: The compiled `<svelix-paner>` custom element with `<svelix-paner-pane>` children. Attributes mirror the Svelte props in kebab-case; events are `svelix-sizechange`, `svelix-resizestart`, `svelix-resizeend`, `svelix-collapsechange` and `svelix-layoutchange`. Nested `<svelix-paner>`s honour `cascade` through the `data-paner-force-descendants` attribute. Requires `pnpm build:elements`.
- **Elements/Screenshot**: The compiled `<svelix-screenshot>` custom element, no attributes. This story renders the real `dist/elements/` build output, not the Svelte source component, so it requires `pnpm build:elements` to have been run at least once.

## Props

### Alert.element.svelte

```ts
interface Props {
		variant?: AlertVariant;
		color?: string;
		radius?: string;
		heading?: string;
		withCloseButton?: boolean | string;
		closeButtonLabel?: string;
	}
```

### Breadcrumbs.element.svelte

```ts
interface Props {
		items?: BreadcrumbItem[] | string;
		separator?: string;
		separatorMargin?: string;
		label?: string;
	}
```

### Dialog.element.svelte

```ts
interface Props {
		closeOnEsc?: boolean | string;
		contentClass?: string;
		heading?: string;
		open?: boolean;
		positionerClass?: string;
		width?: string;
	}
```

### Drawer.element.svelte

```ts
interface Props {
		closeOnEsc?: boolean | string;
		contentClass?: string;
		heading?: string;
		open?: boolean;
		positionerClass?: string;
		width?: string;
	}
```

### ErrorBoundary.element.svelte

```ts
interface Props {
		namespace?: string;
	}
```

### Grid.element.svelte

```ts
/**
	 * Every structured prop accepts either JSON text (an attribute) or the real value (a property
	 * assignment), which is what the `| string` in each type means.
	 */
	interface Props {
		columns?: GridlerColumn[] | string;
		data?: Row[] | (() => Promise<Row[]>) | string;
		dataSource?: GridCommonProps<Row>["dataSource"];
		dataSourceOptions?: DataSourceOptions | string;
		defaultSearchValue?: string;
		filters?: GridColumnFilters | string;
		fixedColumns?: number;
		height?: number | string;
		loading?: boolean;
		manageColumns?: boolean | string;
		menuItems?: GridlerContextMenuItem[] | string;
		pageSize?: number;
		rowMarkers?: "checkbox" | "number" | "none";
		searchColumns?: string[] | string;
		searchValue?: string;
		selectedItems?: Row[] | string;
		serverSideSearch?: boolean;
		settings?: Settings | string;
		enableEdit?: boolean;
		showSearch?: boolean;
		showToolbar?: boolean | string;
		sortOrder?: GridColumnSortOrder | string;
		total?: number;
		uniqueID?: string;
		usePushEvent?: boolean;
		usePushEventOptions?: GridlerPushEventOptions | string;
		width?: number | string;
	}
```

### IFrame.element.svelte

```ts
interface Props {
		allow?: string;
		allowfullscreen?: boolean;
		allowedOrigins?: string;
		frameId?: string;
		frameStyle?: string;
		frameTitle: string;
		height?: number | string;
		loading?: "eager" | "lazy";
		name?: string;
		readyMessageType?: string;
		referrerpolicy?: ReferrerPolicy;
		sandbox?: string;
		src?: string;
		srcdoc?: string;
		targetOrigin?: string;
		width?: number | string;
	}
```

### Paner.element.svelte

```ts
interface Props {
		orientation?: PanerOrientation;
		sizes?: PanerSize[] | string;
		flippedSizes?: PanerSize[] | string;
		redistribute?: "nearest" | "equal";
		step?: string | number;
		shiftStep?: string | number;
		direction?: PanerDirection;
		lineSize?: string | number;
		handleColor?: string;
		withHandle?: Flag;
		resetOnDoubleClick?: Flag;
		enabled?: Flag;
		autoStack?: Flag;
		autoSideBySide?: Flag;
		stackFit?: Flag;
		coarsePointer?: Flag;
		withCollapseButton?: Flag;
		animated?: Flag;
		breakpoint?: string | number;
		hysteresis?: number;
		animationDuration?: number;
		minWidth?: string | number;
		minHeight?: string | number;
		cascade?: PanerCascade;
		autoSaveId?: string;
		paneClass?: string;
		handleClass?: string;
		thumbClass?: string;
		collapseButtonClass?: string;
	}
```

### Screenshot.element.svelte

```ts
interface Props {
		showPreview?: boolean | string;
	}
```

## Examples (from Storybook stories)

### Elements/Alert  (`Alert.element.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, waitFor, within } from 'storybook/test';

import AlertElementPreview from './AlertElementPreview.svelte';
```

Helper component `./AlertElementPreview.svelte`:

```svelte
<script lang="ts">
	import { onMount } from "svelte";
	import { ensureElementsRegistered } from "./storybookRegister";

	let ready = $state(false);
	let status = $state("loading");
	let host: HTMLElement | undefined = $state();
	let open = $state(true);

	onMount(() => {
		ensureElementsRegistered()
			.then(() => {
				ready = true;
				status = "ready";
			})
			.catch(() => {
				status = "failed to load dist/elements/register.js - run `pnpm build:elements` first";
			});
	});

	$effect(() => {
		if (!ready || !host) return;
		const onClose = () => {
			status = "svelix-close";
			open = false;
		};
		host.addEventListener("svelix-close", onClose);
		return () => host?.removeEventListener("svelix-close", onClose);
	});
</script>

<p class="mb-2 text-xs" data-testid="alert-status">{status}</p>
{#if ready}
	{#if open}
		<svelix-alert bind:this={host} variant="filled" color="warning" title="Heads up" with-close-button>
			Light-DOM <strong>message</strong>
		</svelix-alert>
	{/if}
	<svelix-alert data-testid="plain" with-close-button="false">Plain alert</svelix-alert>
{/if}
```

#### Default (`Default`)

Renders the helper component shown above (no args).

### Elements/Breadcrumbs  (`Breadcrumbs.element.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, waitFor, within } from 'storybook/test';

import BreadcrumbsElementPreview from './BreadcrumbsElementPreview.svelte';
```

Helper component `./BreadcrumbsElementPreview.svelte`:

```svelte
<script lang="ts">
	import { onMount } from "svelte";
	import { ensureElementsRegistered } from "./storybookRegister";

	let ready = $state(false);
	let status = $state("loading");
	let host: HTMLElement | undefined = $state();

	onMount(() => {
		ensureElementsRegistered()
			.then(() => {
				ready = true;
				status = "ready";
			})
			.catch(() => {
				status = "failed to load dist/elements/register.js - run `pnpm build:elements` first";
			});
	});

	$effect(() => {
		if (!ready || !host) return;
		const onClick = (event: Event) => {
			const { item, index } = (event as CustomEvent<{ item: { label: string }; index: number }>).detail;
			event.preventDefault();
			status = `svelix-itemclick ${index} ${item.label}`;
		};
		host.addEventListener("svelix-itemclick", onClick);
		return () => host?.removeEventListener("svelix-itemclick", onClick);
	});
</script>

<p class="mb-2 text-xs" data-testid="breadcrumbs-status">{status}</p>
{#if ready}
	<svelix-breadcrumbs
		bind:this={host}
		items={JSON.stringify([{ label: "Home", href: "#home" }, { label: "Docs", href: "#docs" }, { label: "Breadcrumbs" }])}
		separator="›"
		separator-margin="md"
	></svelix-breadcrumbs>
	<svelix-breadcrumbs data-testid="broken" items="[not json"></svelix-breadcrumbs>
{/if}
```

#### Default (`Default`)

Renders the helper component shown above (no args).

### Elements/Dialog  (`Dialog.element.stories.ts`)

Shared setup:

```ts
import { expect, screen, userEvent, within } from 'storybook/test';

import DialogElementPreview from './DialogElementPreview.svelte';
```

Helper component `./DialogElementPreview.svelte`:

```svelte
<script lang="ts">
	import { onMount } from "svelte";
	import { ensureElementsRegistered } from "./storybookRegister";

	type SvelixDialogElement = HTMLElement;

	let ready = $state(false);
	let host: SvelixDialogElement | undefined = $state();
	let status = $state("closed");
	let preventClose = $state(false);

	const badgeClass = $derived(
		status === "open"
			? "preset-filled-success-500"
			: status.startsWith("close request blocked")
				? "preset-filled-warning-500"
				: "preset-tonal"
	);

	onMount(() => {
		ensureElementsRegistered()
			.then(() => {
				ready = true;
			})
			.catch(() => {
				status = "failed to load dist/elements/register.js - run `pnpm build:elements` first";
			});
	});

	$effect(() => {
		if (!ready || !host) return;

		const onClose = () => (status = "closed (svelix-close received)");
		const onRequestClose = (event: Event) => {
			if (preventClose) {
				event.preventDefault();
				status = "close request blocked by demo checkbox";
			}
		};

		host.addEventListener("svelix-close", onClose);
		host.addEventListener("svelix-requestclose", onRequestClose);
		return () => {
			host?.removeEventListener("svelix-close", onClose);
			host?.removeEventListener("svelix-requestclose", onRequestClose);
		};
	});

	function open(): void {
		host?.setAttribute("open", "");
		status = "open";
	}
</script>

<div class="mx-auto max-w-xl space-y-5 p-6">
	<header class="space-y-1">
		<h2 class="h4 font-semibold">&lt;svelix-dialog&gt;</h2>
		<p class="text-sm text-surface-700-300">
			Open it by setting the <code class="code">open</code> attribute; closing removes it again and fires <code class="code">svelix-close</code>.
		</p>
	</header>

	{#if ready}
		<div class="card space-y-4 border border-surface-200-800 bg-surface-50-950 p-4">
			<div class="flex items-center justify-between gap-3">
				<span class="text-sm text-surface-700-300">State</span>
				<span class="badge {badgeClass}" data-testid="dialog-status">{status}</span>
			</div>

			<label class="flex items-start gap-2 text-sm">
				<input class="checkbox mt-0.5" type="checkbox" bind:checked={preventClose} />
				<span>
					Block close requests
					<span class="block text-xs text-surface-700-300">Cancels the <code class="code">svelix-requestclose</code> event.</span>
				</span>
			</label>

			<button class="btn preset-filled-primary-500" type="button" onclick={open}>Open dialog</button>
		</div>

		<svelix-dialog bind:this={host} title="Storybook dialog demo">
			<div class="space-y-3 text-sm">
				<p>Dialog body content, projected as light-DOM children.</p>
				<p class="text-surface-700-300">
					Press Esc or the close button to dismiss it. The page behind is dimmed and inert while it is open.
				</p>
			</div>
		</svelix-dialog>
	{:else}
		<p class="text-sm text-surface-700-300">
			loading &lt;svelix-dialog&gt; (run <code class="code">pnpm build:elements</code> first if this never resolves)...
		</p>
	{/if}
</div>
```

#### Default (`Default`)

Renders the helper component shown above (no args).

### Elements/Drawer  (`Drawer.element.stories.ts`)

Shared setup:

```ts
import { expect, screen, userEvent, within } from 'storybook/test';

import DrawerElementPreview from './DrawerElementPreview.svelte';
```

Helper component `./DrawerElementPreview.svelte`:

```svelte
<script lang="ts">
	import { onMount } from "svelte";
	import { ensureElementsRegistered } from "./storybookRegister";

	type SvelixDrawerElement = HTMLElement;

	let ready = $state(false);
	let host: SvelixDrawerElement | undefined = $state();
	let status = $state("closed");
	let preventClose = $state(false);

	const badgeClass = $derived(
		status === "open"
			? "preset-filled-success-500"
			: status.startsWith("close request blocked")
				? "preset-filled-warning-500"
				: "preset-tonal"
	);

	onMount(() => {
		ensureElementsRegistered()
			.then(() => {
				ready = true;
			})
			.catch(() => {
				status = "failed to load dist/elements/register.js - run `pnpm build:elements` first";
			});
	});

	$effect(() => {
		if (!ready || !host) return;

		const onClose = () => (status = "closed (svelix-close received)");
		const onRequestClose = (event: Event) => {
			if (preventClose) {
				event.preventDefault();
				status = "close request blocked by demo checkbox";
			}
		};

		host.addEventListener("svelix-close", onClose);
		host.addEventListener("svelix-requestclose", onRequestClose);
		return () => {
			host?.removeEventListener("svelix-close", onClose);
			host?.removeEventListener("svelix-requestclose", onRequestClose);
		};
	});

	function open(): void {
		host?.setAttribute("open", "");
		status = "open";
	}
</script>

<div class="mx-auto max-w-xl space-y-5 p-6">
	<header class="space-y-1">
		<h2 class="h4 font-semibold">&lt;svelix-drawer&gt;</h2>
		<p class="text-sm text-surface-700-300">
			Open it by setting the <code class="code">open</code> attribute; closing removes it again and fires <code class="code">svelix-close</code>.
		</p>
	</header>

	{#if ready}
		<div class="card space-y-4 border border-surface-200-800 bg-surface-50-950 p-4">
			<div class="flex items-center justify-between gap-3">
				<span class="text-sm text-surface-700-300">State</span>
				<span class="badge {badgeClass}" data-testid="drawer-status">{status}</span>
			</div>

			<label class="flex items-start gap-2 text-sm">
				<input class="checkbox mt-0.5" type="checkbox" bind:checked={preventClose} />
				<span>
					Block close requests
					<span class="block text-xs text-surface-700-300">Cancels the <code class="code">svelix-requestclose</code> event.</span>
				</span>
			</label>

			<button class="btn preset-filled-primary-500" type="button" onclick={open}>Open drawer</button>
		</div>

		<svelix-drawer bind:this={host} title="Storybook drawer demo">
			<div class="space-y-3 text-sm">
				<p>Drawer body content, projected as light-DOM children.</p>
				<p class="text-surface-700-300">
					Press Esc or the close button to dismiss it. The page behind is dimmed and inert while it is open.
				</p>
			</div>
		</svelix-drawer>
	{:else}
		<p class="text-sm text-surface-700-300">
			loading &lt;svelix-drawer&gt; (run <code class="code">pnpm build:elements</code> first if this never resolves)...
		</p>
	{/if}
</div>
```

#### Default (`Default`)

Renders the helper component shown above (no args).

### Elements/ErrorBoundary  (`ErrorBoundary.element.stories.ts`)

Shared setup:

```ts
import { expect, userEvent, within } from 'storybook/test';

import ErrorBoundaryElementPreview from './ErrorBoundaryElementPreview.svelte';
```

Helper component `./ErrorBoundaryElementPreview.svelte`:

```svelte
<script lang="ts">
	import { onMount } from "svelte";
	import { ensureElementsRegistered } from "./storybookRegister";

	interface SvelixErrorBoundaryElement extends HTMLElement {
		handleError(error: Error): void;
	}

	let ready = $state(false);
	let host: SvelixErrorBoundaryElement | undefined = $state();
	let status = $state("no error triggered yet");

	onMount(() => {
		ensureElementsRegistered()
			.then(() => {
				ready = true;
			})
			.catch(() => {
				status = "failed to load dist/elements/register.js - run `pnpm build:elements` first";
			});
	});

	$effect(() => {
		if (!ready || !host) return;

		const onReset = () => (status = "svelix-reset received");
		const onRetry = () => (status = "svelix-retry received");
		const onReport = () => (status = "svelix-report received");

		host.addEventListener("svelix-reset", onReset);
		host.addEventListener("svelix-retry", onRetry);
		host.addEventListener("svelix-report", onReport);
		return () => {
			host?.removeEventListener("svelix-reset", onReset);
			host?.removeEventListener("svelix-retry", onRetry);
			host?.removeEventListener("svelix-report", onReport);
		};
	});
</script>

<div class="space-y-4 p-4">
	<p class="text-sm text-surface-500" data-testid="error-boundary-status">{status}</p>

	{#if ready}
		<svelix-error-boundary bind:this={host}>
			<button class="btn preset-filled" type="button" onclick={() => host?.handleError(new Error("Demo error"))}>
				Trigger error
			</button>
		</svelix-error-boundary>
	{:else}
		<p class="text-sm text-surface-500">
			loading &lt;svelix-error-boundary&gt; (run <code>pnpm build:elements</code> first if this never resolves)...
		</p>
	{/if}
</div>
```

#### Default (`Default`)

Renders the helper component shown above (no args).

### Elements/Grid  (`Grid.element.stories.ts`)

Shared setup:

```ts
import { expect, within } from 'storybook/test';

import GridElementPreview from './GridElementPreview.svelte';
```

Helper component `./GridElementPreview.svelte`:

```svelte
<script lang="ts">
	import { onMount } from "svelte";
	import { ensureElementsRegistered } from "./storybookRegister";

	let ready = $state(false);
	let error = $state(false);
	let host = $state<HTMLElement | undefined>();
	let status = $state("no selection");

	const columns = JSON.stringify([
		{ id: "name", title: "Name", width: 200 },
		{ id: "role", title: "Role", width: 180 },
		{ id: "city", title: "City", width: 160 }
	]);

	const data = JSON.stringify([
		{ id: 1, name: "Ada Lovelace", role: "Analyst", city: "London" },
		{ id: 2, name: "Grace Hopper", role: "Rear Admiral", city: "Arlington" },
		{ id: 3, name: "Katherine Johnson", role: "Mathematician", city: "Hampton" },
		{ id: 4, name: "Margaret Hamilton", role: "Software Engineer", city: "Cambridge" },
		{ id: 5, name: "Radia Perlman", role: "Network Engineer", city: "Boston" }
	]);

	onMount(() => {
		ensureElementsRegistered()
			.then(() => {
				ready = true;
			})
			.catch(() => {
				error = true;
			});
	});

	$effect(() => {
		if (!host) return;
		const onRowClick = (event: Event) => {
			const detail = (event as CustomEvent<{ row: number; rowData?: Record<string, unknown> }>).detail;
			status = `row ${detail.row}: ${detail.rowData?.name ?? "(unknown)"}`;
		};
		host.addEventListener("svelix-rowclick", onRowClick);
		return () => host?.removeEventListener("svelix-rowclick", onRowClick);
	});
</script>

<div class="space-y-3 p-4">
	{#if ready}
		<p class="text-sm text-surface-500" data-testid="grid-status">{status}</p>
		<!-- Configured the way an Astro template or htmx fragment would: JSON in attributes. -->
		<svelix-grid bind:this={host} {columns} {data} height="260" row-markers="number"></svelix-grid>
	{:else if error}
		<p class="text-sm text-surface-500">
			failed to load dist/elements/register.js - run <code>pnpm build:elements</code> first
		</p>
	{:else}
		<p class="text-sm text-surface-500">loading &lt;svelix-grid&gt;...</p>
	{/if}
</div>
```

#### Default (`Default`)

Renders the helper component shown above (no args).

### Elements/IFrame  (`IFrame.element.stories.ts`)

Shared setup:

```ts
import { expect, waitFor, within } from 'storybook/test';

import IFrameElementPreview from './IFrameElementPreview.svelte';
```

Helper component `./IFrameElementPreview.svelte`:

```svelte
<script lang="ts">
	import { onMount } from "svelte";
	import { ensureElementsRegistered } from "./storybookRegister";

	interface SvelixIFrameElement extends HTMLElement {
		sendMessage(type: string, payload?: unknown): void;
		reload(): void;
		navigate(url: string): void;
	}

	// Same self-contained embedded document trick as IFramePreview.svelte: built via string
	// concatenation so this file never contains a literal "<script" substring.
	const scriptOpenTag = "<" + "script>";
	const scriptCloseTag = "<" + "/script>";
	const embeddedDocument = `<!doctype html>
<html>
	<body style="font-family: sans-serif; padding: 1rem;">
		<p>I'm the embedded document.</p>
		${scriptOpenTag}
			window.parent.postMessage({ type: "svelix:iframe-ready" }, "*");
			window.addEventListener("message", (event) => {
				if (event.data?.type === "ping") {
					window.parent.postMessage({ type: "pong", payload: event.data.payload }, "*");
				}
			});
		${scriptCloseTag}
	</body>
</html>`;

	let ready = $state(false);
	let host: SvelixIFrameElement | undefined = $state();
	let status = $state("loading <svelix-iframe> (run `pnpm build:elements` first if this never resolves)...");

	onMount(() => {
		ensureElementsRegistered()
			.then(() => {
				ready = true;
			})
			.catch(() => {
				status = "failed to load dist/elements/register.js - run `pnpm build:elements` first";
			});
	});

	$effect(() => {
		if (!ready || !host) return;

		const onReady = () => {
			status = "svelix-ready received - sending ping";
			host?.sendMessage("ping", { at: Date.now() });
		};
		const onMessage = (event: Event) => {
			// svelix-message's detail is the raw MessageEvent (see IFrameMessenger's onMessage
			// callback) - the { type, payload } envelope lives at detail.data.
			const messageEvent = (event as CustomEvent).detail as MessageEvent;
			const data = messageEvent?.data as { type?: string; payload?: unknown } | undefined;
			if (data?.type === "pong") {
				status = `received pong: ${JSON.stringify(data.payload)}`;
			}
		};

		host.addEventListener("svelix-ready", onReady);
		host.addEventListener("svelix-message", onMessage);
		return () => {
			host?.removeEventListener("svelix-ready", onReady);
			host?.removeEventListener("svelix-message", onMessage);
		};
	});
</script>

<div class="space-y-4 p-4">
	<p class="text-sm text-surface-500" data-testid="iframe-status">{status}</p>

	{#if ready}
		<svelix-iframe
			bind:this={host}
			srcdoc={embeddedDocument}
			frame-title="IFrame web component messaging demo"
			width="100%"
			height="160"
			sandbox="allow-scripts"
		></svelix-iframe>
		<div class="flex gap-2">
			<button class="btn preset-filled" type="button" onclick={() => host?.reload()}>Reload</button>
			<button class="btn preset-filled" type="button" onclick={() => host?.navigate("about:blank")}
				>Navigate to about:blank</button
			>
		</div>
	{/if}
</div>
```

#### Default (`Default`)

Renders the helper component shown above (no args).

### Elements/Paner  (`Paner.element.stories.ts`)

Shared setup:

```ts
import { expect, fireEvent, userEvent, waitFor, within } from 'storybook/test';

import PanerElementPreview from './PanerElementPreview.svelte';
```

Helper component `./PanerElementPreview.svelte`:

```svelte
<script lang="ts">
	import { onMount } from "svelte";
	import { ensureElementsRegistered } from "./storybookRegister";

	let ready = $state(false);
	let host: HTMLElement | undefined = $state();
	let status = $state("loading");
	let width = $state(900);

	onMount(() => {
		ensureElementsRegistered()
			.then(() => {
				ready = true;
				status = "ready";
			})
			.catch(() => {
				status = "failed to load dist/elements/register.js - run `pnpm build:elements` first";
			});
	});

	$effect(() => {
		if (!ready || !host) return;
		const onSize = (event: Event) => {
			if (event.target !== host) return;
			const detail = (event as CustomEvent<{ sizes: unknown[] }>).detail;
			status = `svelix-sizechange ${JSON.stringify(detail.sizes.map((s) => (typeof s === "number" ? Math.round(s) : s)))}`;
		};
		const onLayout = (event: Event) => {
			if (event.target !== host) return;
			const { layout } = (event as CustomEvent<{ layout: { orientation: string; mode: string } }>).detail;
			status = `svelix-layoutchange ${layout.orientation} / ${layout.mode}`;
		};
		host.addEventListener("svelix-sizechange", onSize);
		host.addEventListener("svelix-layoutchange", onLayout);
		return () => {
			host?.removeEventListener("svelix-sizechange", onSize);
			host?.removeEventListener("svelix-layoutchange", onLayout);
		};
	});
</script>

<div class="space-y-3 p-4">
	<p class="text-sm text-surface-500" data-testid="paner-status">{status}</p>
	<label class="flex items-center gap-2 text-sm">
		Container width
		<input type="range" min="240" max="1200" bind:value={width} data-testid="width" />
	</label>

	{#if ready}
		<div style="width: {width}px; height: 360px" class="max-w-full rounded border border-surface-300">
			<svelix-paner bind:this={host} auto-stack cascade="inherit" with-collapse-button min-width="240">
				<svelix-paner-pane default-size="25" min="10" collapsible>
					<div class="h-full bg-surface-100 p-3 text-sm">Nav (light-DOM content)</div>
				</svelix-paner-pane>
				<svelix-paner-pane default-size="75" min-width="480">
					<svelix-paner orientation="vertical">
						<svelix-paner-pane default-size="60">
							<div class="h-full bg-primary-100 p-3 text-sm">Editor</div>
						</svelix-paner-pane>
						<svelix-paner-pane default-size="40">
							<div class="h-full bg-surface-100 p-3 text-sm">Console</div>
						</svelix-paner-pane>
					</svelix-paner>
				</svelix-paner-pane>
			</svelix-paner>
		</div>
	{/if}
</div>
```

#### Default (`Default`)

Renders the helper component shown above (no args).

### Elements/Screenshot  (`Screenshot.element.stories.ts`)

Shared setup:

```ts
import ScreenshotElementPreview from './ScreenshotElementPreview.svelte';
```

Helper component `./ScreenshotElementPreview.svelte`:

```svelte
<script lang="ts">
	import { onMount } from "svelte";
	import { ensureElementsRegistered } from "./storybookRegister";

	let ready = $state(false);
	let error = $state(false);

	onMount(() => {
		ensureElementsRegistered()
			.then(() => {
				ready = true;
			})
			.catch(() => {
				error = true;
			});
	});
</script>

<div class="p-4">
	{#if ready}
		<svelix-screenshot></svelix-screenshot>
	{:else if error}
		<p class="text-sm text-surface-500">failed to load dist/elements/register.js - run <code>pnpm build:elements</code> first</p>
	{:else}
		<p class="text-sm text-surface-500">loading &lt;svelix-screenshot&gt;...</p>
	{/if}
</div>
```

#### Default (`Default`)

Renders the helper component shown above (no args).
