@krizaka/ui 2.0.0-beta.6 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +13 -10
  2. package/dist/{button-D5aSsmxX.d.ts → button-Cgfkg_3y.d.ts} +5 -1
  3. package/dist/button.d.ts +5 -1
  4. package/dist/button.js +4 -2
  5. package/dist/button.js.map +1 -1
  6. package/dist/card.d.ts +7 -1
  7. package/dist/card.js +2 -2
  8. package/dist/card.js.map +1 -1
  9. package/dist/{chunk-IUGBKMJC.js → chunk-FHYZSO2S.js} +2 -2
  10. package/dist/{chunk-6DJ6SQY3.js → chunk-PESA2R4U.js} +5 -3
  11. package/dist/chunk-PESA2R4U.js.map +1 -0
  12. package/dist/command.d.ts +11 -2
  13. package/dist/command.js +17 -6
  14. package/dist/command.js.map +1 -1
  15. package/dist/confirm-button.d.ts +1 -1
  16. package/dist/confirm-button.js +1 -1
  17. package/dist/countdown.d.ts +7 -6
  18. package/dist/countdown.js +12 -8
  19. package/dist/countdown.js.map +1 -1
  20. package/dist/dialog.js +2 -2
  21. package/dist/dropdown-menu.js +3 -3
  22. package/dist/dropdown-menu.js.map +1 -1
  23. package/dist/field.js +2 -2
  24. package/dist/field.js.map +1 -1
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.js +52 -10
  27. package/dist/index.js.map +1 -1
  28. package/dist/native.d.ts +20 -8
  29. package/dist/native.js +170 -184
  30. package/dist/native.js.map +1 -1
  31. package/dist/theme.d.ts +19 -9
  32. package/dist/theme.js +8 -5
  33. package/dist/theme.js.map +1 -1
  34. package/package.json +3 -3
  35. package/registry/avatar.json +20 -5
  36. package/registry/button.json +36 -15
  37. package/registry/card.json +17 -2
  38. package/registry/command.json +30 -8
  39. package/registry/confirm-button.json +4 -4
  40. package/registry/countdown.json +53 -12
  41. package/registry/dropdown-menu.json +1 -1
  42. package/registry/examples/button/gradient.d.ts +4 -0
  43. package/registry/examples/button/gradient.js +11 -0
  44. package/registry/examples/button/gradient.tsx +5 -0
  45. package/registry/examples/button/native/gradient.d.ts +4 -0
  46. package/registry/examples/button/native/gradient.dark.png +0 -0
  47. package/registry/examples/button/native/gradient.js +12 -0
  48. package/registry/examples/button/native/gradient.light.png +0 -0
  49. package/registry/examples/button/native/gradient.tsx +5 -0
  50. package/registry/examples/command/dialog.js +1 -2
  51. package/registry/examples/command/dialog.tsx +1 -2
  52. package/registry/examples/command/inline.js +1 -2
  53. package/registry/examples/command/inline.tsx +1 -2
  54. package/registry/examples/command/no-results.d.ts +4 -0
  55. package/registry/examples/command/no-results.js +19 -0
  56. package/registry/examples/command/no-results.tsx +20 -0
  57. package/registry/examples/countdown/native/urgent.dark.png +0 -0
  58. package/registry/examples/countdown/native/urgent.light.png +0 -0
  59. package/registry/examples/countdown/native/with-label.d.ts +4 -0
  60. package/registry/examples/countdown/native/with-label.dark.png +0 -0
  61. package/registry/examples/countdown/native/with-label.js +13 -0
  62. package/registry/examples/countdown/native/with-label.light.png +0 -0
  63. package/registry/examples/countdown/native/with-label.tsx +7 -0
  64. package/registry/examples/countdown/with-label.d.ts +4 -0
  65. package/registry/examples/countdown/with-label.js +13 -0
  66. package/registry/examples/countdown/with-label.tsx +10 -0
  67. package/registry/field.json +1 -1
  68. package/registry/index.json +8 -8
  69. package/registry/theme.json +24 -9
  70. package/dist/chunk-6DJ6SQY3.js.map +0 -1
  71. /package/dist/{chunk-IUGBKMJC.js.map → chunk-FHYZSO2S.js.map} +0 -0
package/dist/theme.d.ts CHANGED
@@ -1,16 +1,23 @@
1
1
  import * as React from 'react';
2
- import { a as ButtonVariants } from './button-D5aSsmxX.js';
2
+ import { a as ButtonVariants } from './button-Cgfkg_3y.js';
3
3
  import 'tailwind-variants';
4
4
 
5
5
  type Mode = "dark" | "light" | "system";
6
- /**
7
- * In <head>, before anything renders: applies the persisted mode and named theme, so the page never flashes.
8
- * `nonce` for a strict Content-Security-Policy.
9
- */
10
- declare function ThemeScript({ nonce, }: {
6
+ type ThemeScriptProps = {
11
7
  /** The nonce of a strict Content-Security-Policy (`script-src 'nonce-…'`). */
12
8
  nonce?: string;
13
- }): React.JSX.Element;
9
+ /**
10
+ * The mode before the visitor chooses one — pass the same value as `ThemeProvider`'s `defaultMode`. A persisted
11
+ * choice (`kz-theme`) always wins.
12
+ * @default "system"
13
+ */
14
+ defaultMode?: Mode;
15
+ };
16
+ /**
17
+ * In <head>, before anything renders: applies the persisted mode (else `defaultMode`) and named theme, so the page
18
+ * never flashes. `nonce` for a strict Content-Security-Policy.
19
+ */
20
+ declare function ThemeScript({ nonce, defaultMode }: ThemeScriptProps): React.JSX.Element;
14
21
  type ThemeContextValue = {
15
22
  /** The chosen mode: dark, light or follow the system. */
16
23
  mode: Mode;
@@ -21,7 +28,10 @@ type ThemeContextValue = {
21
28
  };
22
29
  type ThemeProviderProps = {
23
30
  children: React.ReactNode;
24
- /** The mode before anything is persisted. */
31
+ /**
32
+ * The mode before the visitor chooses one (nothing persisted as `kz-theme`) — pass the same value to `ThemeScript`.
33
+ * @default "system"
34
+ */
25
35
  defaultMode?: Mode;
26
36
  };
27
37
  declare function ThemeProvider({ children, defaultMode }: ThemeProviderProps): React.JSX.Element;
@@ -43,4 +53,4 @@ type ThemeToggleProps = Omit<React.ComponentProps<"button">, "onClick" | "childr
43
53
  */
44
54
  declare function ThemeToggle({ label, variant, shape, className, ...props }: ThemeToggleProps): React.JSX.Element;
45
55
 
46
- export { type Mode, type ThemeContextValue, ThemeProvider, type ThemeProviderProps, ThemeScript, ThemeToggle, type ThemeToggleProps, useTheme };
56
+ export { type Mode, type ThemeContextValue, ThemeProvider, type ThemeProviderProps, ThemeScript, type ThemeScriptProps, ThemeToggle, type ThemeToggleProps, useTheme };
package/dist/theme.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import {
3
3
  buttonVariants
4
- } from "./chunk-6DJ6SQY3.js";
4
+ } from "./chunk-PESA2R4U.js";
5
5
 
6
6
  // src/theme/theme.tsx
7
7
  import * as React from "react";
@@ -47,10 +47,9 @@ function applyName(previous, next) {
47
47
  if (previous) classes.remove(`theme-${previous}`);
48
48
  if (next) classes.add(`theme-${next}`);
49
49
  }
50
- function ThemeScript({
51
- nonce
52
- }) {
53
- const js = `(function(){try{var m=localStorage.getItem("${MODE_KEY}")||"system";var n=localStorage.getItem("${NAME_KEY}");var d=matchMedia("${DARK_QUERY}").matches;var c=document.documentElement.classList;c.toggle("light",m==="light"||(m==="system"&&!d));if(n)c.add("theme-"+n)}catch(e){}})()`;
50
+ function ThemeScript({ nonce, defaultMode = "system" }) {
51
+ const fallback = isMode(defaultMode) ? defaultMode : "system";
52
+ const js = `(function(){try{var m=localStorage.getItem("${MODE_KEY}");if(m!=="dark"&&m!=="light"&&m!=="system")m="${fallback}";var n=localStorage.getItem("${NAME_KEY}");var d=matchMedia("${DARK_QUERY}").matches;var c=document.documentElement.classList;c.toggle("light",m==="light"||(m==="system"&&!d));if(n)c.add("theme-"+n)}catch(e){}})()`;
54
53
  return /* @__PURE__ */ jsx("script", { nonce, dangerouslySetInnerHTML: { __html: js } });
55
54
  }
56
55
  var ThemeContext = React.createContext(null);
@@ -58,6 +57,10 @@ function ThemeProvider({ children, defaultMode = "system" }) {
58
57
  const stored = React.useSyncExternalStore(subscribe, () => read(MODE_KEY), () => null);
59
58
  const mode = isMode(stored) ? stored : defaultMode;
60
59
  const theme = React.useSyncExternalStore(subscribe, () => read(NAME_KEY), () => null);
60
+ React.useEffect(() => {
61
+ const persisted = read(MODE_KEY);
62
+ applyMode(isMode(persisted) ? persisted : defaultMode);
63
+ }, [defaultMode]);
61
64
  React.useEffect(() => {
62
65
  if (mode !== "system") return;
63
66
  const query = window.matchMedia(DARK_QUERY);
package/dist/theme.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/theme/theme.tsx"],"sourcesContent":["// Client. ONE mechanism for the whole organisation: dark by default, `html.light` for light, `html.theme-<name>` for a\n// named theme, persisted in localStorage (`kz-theme` for the mode, `kz-theme-name` for the named theme).\nimport * as React from \"react\";\n\nimport { type ButtonVariants, buttonVariants } from \"../button/button\";\n\nexport type Mode = \"dark\" | \"light\" | \"system\";\n\nconst MODE_KEY = \"kz-theme\";\nconst NAME_KEY = \"kz-theme-name\";\nconst MODES: readonly Mode[] = [\"dark\", \"light\", \"system\"];\nconst DARK_QUERY = \"(prefers-color-scheme: dark)\";\n\nconst isMode = (value: unknown): value is Mode => MODES.includes(value as Mode);\n\n// Storage blocked (private mode, sandbox): the choice lives in memory for the page.\nconst memory = new Map<string, string>();\n\nfunction read(key: string): string | null {\n try {\n return localStorage.getItem(key);\n } catch {\n return memory.get(key) ?? null;\n }\n}\n\nfunction write(key: string, value: string | null) {\n if (value === null) memory.delete(key);\n else memory.set(key, value);\n try {\n if (value === null) localStorage.removeItem(key);\n else localStorage.setItem(key, value);\n } catch {\n // storage blocked: memory holds it\n }\n}\n\n// The persisted choice is an external store: every provider and tab reads the same value, the server reads none.\nconst listeners = new Set<() => void>();\nconst notify = () => listeners.forEach((listener) => listener());\nfunction subscribe(listener: () => void) {\n listeners.add(listener);\n window.addEventListener(\"storage\", listener);\n return () => {\n listeners.delete(listener);\n window.removeEventListener(\"storage\", listener);\n };\n}\n\nfunction applyMode(mode: Mode) {\n const dark = window.matchMedia(DARK_QUERY).matches;\n document.documentElement.classList.toggle(\"light\", mode === \"light\" || (mode === \"system\" && !dark));\n}\n\nfunction applyName(previous: string | null, next: string | null) {\n const classes = document.documentElement.classList;\n if (previous) classes.remove(`theme-${previous}`);\n if (next) classes.add(`theme-${next}`);\n}\n\n/**\n * In <head>, before anything renders: applies the persisted mode and named theme, so the page never flashes.\n * `nonce` for a strict Content-Security-Policy.\n */\nexport function ThemeScript({\n nonce,\n}: {\n /** The nonce of a strict Content-Security-Policy (`script-src 'nonce-…'`). */\n nonce?: string;\n}) {\n const js =\n `(function(){try{var m=localStorage.getItem(\"${MODE_KEY}\")||\"system\";var n=localStorage.getItem(\"${NAME_KEY}\");` +\n `var d=matchMedia(\"${DARK_QUERY}\").matches;var c=document.documentElement.classList;` +\n `c.toggle(\"light\",m===\"light\"||(m===\"system\"&&!d));if(n)c.add(\"theme-\"+n)}catch(e){}})()`;\n return <script nonce={nonce} dangerouslySetInnerHTML={{ __html: js }} />;\n}\n\nexport type ThemeContextValue = {\n /** The chosen mode: dark, light or follow the system. */\n mode: Mode;\n setMode: (mode: Mode) => void;\n /** The named theme (`html.theme-<name>`), or null. */\n theme: string | null;\n setTheme: (theme: string | null) => void;\n};\n\nconst ThemeContext = React.createContext<ThemeContextValue | null>(null);\n\nexport type ThemeProviderProps = {\n children: React.ReactNode;\n /** The mode before anything is persisted. */\n defaultMode?: Mode;\n};\n\nexport function ThemeProvider({ children, defaultMode = \"system\" }: ThemeProviderProps) {\n // Before hydration the server knows nothing persisted (ThemeScript has already applied it to <html>).\n const stored = React.useSyncExternalStore(subscribe, () => read(MODE_KEY), () => null);\n const mode: Mode = isMode(stored) ? stored : defaultMode;\n const theme = React.useSyncExternalStore(subscribe, () => read(NAME_KEY), () => null);\n\n // \"system\" follows the operating system while the page is open.\n React.useEffect(() => {\n if (mode !== \"system\") return;\n const query = window.matchMedia(DARK_QUERY);\n const onChange = () => applyMode(\"system\");\n query.addEventListener?.(\"change\", onChange);\n return () => query.removeEventListener?.(\"change\", onChange);\n }, [mode]);\n\n const setMode = React.useCallback((next: Mode) => {\n write(MODE_KEY, next);\n applyMode(next);\n notify();\n }, []);\n\n const setTheme = React.useCallback((next: string | null) => {\n applyName(read(NAME_KEY), next);\n write(NAME_KEY, next);\n notify();\n }, []);\n\n const value = React.useMemo(() => ({ mode, setMode, theme, setTheme }), [mode, setMode, theme, setTheme]);\n return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;\n}\n\nexport function useTheme(): ThemeContextValue {\n const context = React.useContext(ThemeContext);\n if (!context) throw new Error(\"useTheme: wrap the app in <ThemeProvider>\");\n return context;\n}\n\nconst ICONS: Record<Mode, React.ReactNode> = {\n dark: <path d=\"M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z\" />,\n light: (\n <>\n <circle cx=\"12\" cy=\"12\" r=\"4\" />\n <path d=\"M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4\" />\n </>\n ),\n system: (\n <>\n <rect x=\"3\" y=\"4\" width=\"18\" height=\"12\" rx=\"2\" />\n <path d=\"M8 20h8M12 16v4\" />\n </>\n ),\n};\n\nexport type ThemeToggleProps = Omit<React.ComponentProps<\"button\">, \"onClick\" | \"children\"> &\n Pick<ButtonVariants, \"variant\" | \"shape\"> & {\n /** Any `Button` variant. */\n variant?: ButtonVariants[\"variant\"];\n /**\n * rounded · pill.\n * @default \"rounded\"\n */\n shape?: ButtonVariants[\"shape\"];\n /** The accessible name — a string, or one per current mode (e.g. `(mode) => t(\"theme.\" + mode)`). */\n label: string | ((mode: Mode) => string);\n };\n\n/**\n * An icon button (styled as `IconButton`, without its Slot) that cycles dark → light → system. Needs a\n * <ThemeProvider> above it. State: `data-mode`.\n */\nexport function ThemeToggle({ label, variant = \"ghost\", shape, className, ...props }: ThemeToggleProps) {\n const { mode, setMode } = useTheme();\n const next = MODES[(MODES.indexOf(mode) + 1) % MODES.length] as Mode;\n const name = typeof label === \"function\" ? label(mode) : label;\n return (\n <button\n type=\"button\"\n aria-label={name}\n title={name}\n data-mode={mode}\n onClick={() => setMode(next)}\n className={buttonVariants({ variant, shape, size: \"icon\", className })}\n {...props}\n >\n <svg viewBox=\"0 0 24 24\" className=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n {ICONS[mode]}\n </svg>\n </button>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/theme/theme.tsx"],"sourcesContent":["// Client. ONE mechanism for the whole organisation: dark by default, `html.light` for light, `html.theme-<name>` for a\n// named theme, persisted in localStorage (`kz-theme` for the mode, `kz-theme-name` for the named theme).\nimport * as React from \"react\";\n\nimport { type ButtonVariants, buttonVariants } from \"../button/button\";\n\nexport type Mode = \"dark\" | \"light\" | \"system\";\n\nconst MODE_KEY = \"kz-theme\";\nconst NAME_KEY = \"kz-theme-name\";\nconst MODES: readonly Mode[] = [\"dark\", \"light\", \"system\"];\nconst DARK_QUERY = \"(prefers-color-scheme: dark)\";\n\nconst isMode = (value: unknown): value is Mode => MODES.includes(value as Mode);\n\n// Storage blocked (private mode, sandbox): the choice lives in memory for the page.\nconst memory = new Map<string, string>();\n\nfunction read(key: string): string | null {\n try {\n return localStorage.getItem(key);\n } catch {\n return memory.get(key) ?? null;\n }\n}\n\nfunction write(key: string, value: string | null) {\n if (value === null) memory.delete(key);\n else memory.set(key, value);\n try {\n if (value === null) localStorage.removeItem(key);\n else localStorage.setItem(key, value);\n } catch {\n // storage blocked: memory holds it\n }\n}\n\n// The persisted choice is an external store: every provider and tab reads the same value, the server reads none.\nconst listeners = new Set<() => void>();\nconst notify = () => listeners.forEach((listener) => listener());\nfunction subscribe(listener: () => void) {\n listeners.add(listener);\n window.addEventListener(\"storage\", listener);\n return () => {\n listeners.delete(listener);\n window.removeEventListener(\"storage\", listener);\n };\n}\n\nfunction applyMode(mode: Mode) {\n const dark = window.matchMedia(DARK_QUERY).matches;\n document.documentElement.classList.toggle(\"light\", mode === \"light\" || (mode === \"system\" && !dark));\n}\n\nfunction applyName(previous: string | null, next: string | null) {\n const classes = document.documentElement.classList;\n if (previous) classes.remove(`theme-${previous}`);\n if (next) classes.add(`theme-${next}`);\n}\n\nexport type ThemeScriptProps = {\n /** The nonce of a strict Content-Security-Policy (`script-src 'nonce-…'`). */\n nonce?: string;\n /**\n * The mode before the visitor chooses one — pass the same value as `ThemeProvider`'s `defaultMode`. A persisted\n * choice (`kz-theme`) always wins.\n * @default \"system\"\n */\n defaultMode?: Mode;\n};\n\n/**\n * In <head>, before anything renders: applies the persisted mode (else `defaultMode`) and named theme, so the page\n * never flashes. `nonce` for a strict Content-Security-Policy.\n */\nexport function ThemeScript({ nonce, defaultMode = \"system\" }: ThemeScriptProps) {\n const fallback = isMode(defaultMode) ? defaultMode : \"system\";\n const js =\n `(function(){try{var m=localStorage.getItem(\"${MODE_KEY}\");if(m!==\"dark\"&&m!==\"light\"&&m!==\"system\")m=\"${fallback}\";` +\n `var n=localStorage.getItem(\"${NAME_KEY}\");var d=matchMedia(\"${DARK_QUERY}\").matches;var c=document.documentElement.classList;` +\n `c.toggle(\"light\",m===\"light\"||(m===\"system\"&&!d));if(n)c.add(\"theme-\"+n)}catch(e){}})()`;\n return <script nonce={nonce} dangerouslySetInnerHTML={{ __html: js }} />;\n}\n\nexport type ThemeContextValue = {\n /** The chosen mode: dark, light or follow the system. */\n mode: Mode;\n setMode: (mode: Mode) => void;\n /** The named theme (`html.theme-<name>`), or null. */\n theme: string | null;\n setTheme: (theme: string | null) => void;\n};\n\nconst ThemeContext = React.createContext<ThemeContextValue | null>(null);\n\nexport type ThemeProviderProps = {\n children: React.ReactNode;\n /**\n * The mode before the visitor chooses one (nothing persisted as `kz-theme`) — pass the same value to `ThemeScript`.\n * @default \"system\"\n */\n defaultMode?: Mode;\n};\n\nexport function ThemeProvider({ children, defaultMode = \"system\" }: ThemeProviderProps) {\n // Before hydration the server knows nothing persisted (ThemeScript has already applied it to <html>).\n const stored = React.useSyncExternalStore(subscribe, () => read(MODE_KEY), () => null);\n const mode: Mode = isMode(stored) ? stored : defaultMode;\n const theme = React.useSyncExternalStore(subscribe, () => read(NAME_KEY), () => null);\n\n // The same resolution as ThemeScript (a no-op after it), for an app that renders the provider without the script.\n React.useEffect(() => {\n const persisted = read(MODE_KEY);\n applyMode(isMode(persisted) ? persisted : defaultMode);\n }, [defaultMode]);\n\n // \"system\" follows the operating system while the page is open.\n React.useEffect(() => {\n if (mode !== \"system\") return;\n const query = window.matchMedia(DARK_QUERY);\n const onChange = () => applyMode(\"system\");\n query.addEventListener?.(\"change\", onChange);\n return () => query.removeEventListener?.(\"change\", onChange);\n }, [mode]);\n\n const setMode = React.useCallback((next: Mode) => {\n write(MODE_KEY, next);\n applyMode(next);\n notify();\n }, []);\n\n const setTheme = React.useCallback((next: string | null) => {\n applyName(read(NAME_KEY), next);\n write(NAME_KEY, next);\n notify();\n }, []);\n\n const value = React.useMemo(() => ({ mode, setMode, theme, setTheme }), [mode, setMode, theme, setTheme]);\n return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;\n}\n\nexport function useTheme(): ThemeContextValue {\n const context = React.useContext(ThemeContext);\n if (!context) throw new Error(\"useTheme: wrap the app in <ThemeProvider>\");\n return context;\n}\n\nconst ICONS: Record<Mode, React.ReactNode> = {\n dark: <path d=\"M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z\" />,\n light: (\n <>\n <circle cx=\"12\" cy=\"12\" r=\"4\" />\n <path d=\"M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4\" />\n </>\n ),\n system: (\n <>\n <rect x=\"3\" y=\"4\" width=\"18\" height=\"12\" rx=\"2\" />\n <path d=\"M8 20h8M12 16v4\" />\n </>\n ),\n};\n\nexport type ThemeToggleProps = Omit<React.ComponentProps<\"button\">, \"onClick\" | \"children\"> &\n Pick<ButtonVariants, \"variant\" | \"shape\"> & {\n /** Any `Button` variant. */\n variant?: ButtonVariants[\"variant\"];\n /**\n * rounded · pill.\n * @default \"rounded\"\n */\n shape?: ButtonVariants[\"shape\"];\n /** The accessible name — a string, or one per current mode (e.g. `(mode) => t(\"theme.\" + mode)`). */\n label: string | ((mode: Mode) => string);\n };\n\n/**\n * An icon button (styled as `IconButton`, without its Slot) that cycles dark → light → system. Needs a\n * <ThemeProvider> above it. State: `data-mode`.\n */\nexport function ThemeToggle({ label, variant = \"ghost\", shape, className, ...props }: ThemeToggleProps) {\n const { mode, setMode } = useTheme();\n const next = MODES[(MODES.indexOf(mode) + 1) % MODES.length] as Mode;\n const name = typeof label === \"function\" ? label(mode) : label;\n return (\n <button\n type=\"button\"\n aria-label={name}\n title={name}\n data-mode={mode}\n onClick={() => setMode(next)}\n className={buttonVariants({ variant, shape, size: \"icon\", className })}\n {...props}\n >\n <svg viewBox=\"0 0 24 24\" className=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n {ICONS[mode]}\n </svg>\n </button>\n );\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krizaka/ui",
3
- "version": "2.0.0-beta.6",
3
+ "version": "2.1.0",
4
4
  "description": "The Krizaka front-end platform's components: the primitives (button, card, dialog, toast, menu, field, tabs, chip, switch, slider, command palette, theme, countdown…) on the semantic --kz-* tokens, the animated marks (Krizaka, Orazaka, Orochia) and the motion signature — web and /native.",
5
5
  "keywords": [
6
6
  "krizaka",
@@ -194,7 +194,7 @@
194
194
  "*.css"
195
195
  ],
196
196
  "peerDependencies": {
197
- "@krizaka/tailwind": ">=2.0.0-beta.6",
197
+ "@krizaka/tailwind": ">=2.1.0",
198
198
  "react": ">=18",
199
199
  "react-dom": ">=18",
200
200
  "react-native": ">=0.76",
@@ -238,7 +238,7 @@
238
238
  "typescript": "^5.9.0",
239
239
  "vitest": "^4.1.11",
240
240
  "@krizaka/config": "0.1.0",
241
- "@krizaka/tokens": "2.0.0-beta.6"
241
+ "@krizaka/tokens": "2.1.0"
242
242
  },
243
243
  "engines": {
244
244
  "node": ">=20"
@@ -171,12 +171,13 @@
171
171
  "differences": [
172
172
  "The fallback is the initial of `alt` when `fallback` is not given.",
173
173
  "`size` also takes a number of points.",
174
- "`src` is a URI string (an `Image` source), not a URL object."
174
+ "`src` is a URI string (an `Image` source), not a URL object.",
175
+ "An SVG `src` (`….svg`, `data:image/svg+xml,…`, or any URI with `svg`) is drawn by react-native-svg — `Image` cannot."
175
176
  ],
176
177
  "props": [
177
178
  {
178
179
  "component": "AvatarRoot",
179
- "description": "A round picture; the initial (or `fallback`) when there is none or it fails to load.",
180
+ "description": "",
180
181
  "props": [
181
182
  {
182
183
  "name": "alt",
@@ -203,7 +204,14 @@
203
204
  "name": "src",
204
205
  "type": "string | null",
205
206
  "default": null,
206
- "description": "The image URI; absent, `null` or failing: the fallback.",
207
+ "description": "The image URI; absent, `null` or failing: the fallback. An SVG (`….svg`, `data:image/svg+xml,…` — generated\navatars) is drawn by react-native-svg, which React Native's `Image` cannot do; `svg` forces it for a URI without\nthe extension.",
208
+ "required": false
209
+ },
210
+ {
211
+ "name": "svg",
212
+ "type": "boolean",
213
+ "default": null,
214
+ "description": "Draw `src` as an SVG even when its URI does not say so (an avatar service that answers `image/svg+xml`).",
207
215
  "required": false
208
216
  }
209
217
  ]
@@ -257,7 +265,14 @@
257
265
  "name": "src",
258
266
  "type": "string | null",
259
267
  "default": null,
260
- "description": "The image URI; absent, `null` or failing: the fallback.",
268
+ "description": "The image URI; absent, `null` or failing: the fallback. An SVG (`….svg`, `data:image/svg+xml,…` — generated\navatars) is drawn by react-native-svg, which React Native's `Image` cannot do; `svg` forces it for a URI without\nthe extension.",
269
+ "required": false
270
+ },
271
+ {
272
+ "name": "svg",
273
+ "type": "boolean",
274
+ "default": null,
275
+ "description": "Draw `src` as an SVG even when its URI does not say so (an avatar service that answers `image/svg+xml`).",
261
276
  "required": false
262
277
  }
263
278
  ]
@@ -301,7 +316,7 @@
301
316
  },
302
317
  {
303
318
  "path": "avatar/meta.ts",
304
- "content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Avatar\",\n summary: \"A person or an account: their picture, or initials while it loads, when it fails or when there is none.\",\n status: \"stable\",\n category: \"data-display\",\n platforms: \"both\",\n whenToUse: [\n \"Next to a name: comments, creators, members, the signed-in account in a header.\",\n \"`Avatar.Group` to show who takes part (followers, collaborators) in little space.\",\n ],\n whenNotToUse: [\n { when: \"For a product, a video or a place: use an image in a card.\", use: \"card\" },\n { when: \"As the only way to say who someone is: show their name too, or give the avatar an `alt`.\" },\n ],\n bestPractices: [\n \"Always pass a `fallback` (initials) on the web: images fail, and the layout should not jump.\",\n \"Use one size per list: `sm` in dense lists, `md` by default, `lg`/`xl` on a profile.\",\n \"In a group, set `max` so a long list ends with `+n` instead of overflowing.\",\n ],\n accessibility: {\n keyboard: [],\n notes: [\n \"With `alt`, the picture is an image named by it; without `alt`, it is decorative — put the name in the text next to it.\",\n \"The fallback initials are shown, not announced, when an `alt` names the person.\",\n ],\n },\n related: [\"card\", \"badge\", \"skeleton\"],\n web: {\n imports: [\"Avatar\"],\n examples: [\n { name: \"with-image\", title: \"With an image\", description: \"A picture named by `alt`, with initials as `fallback`.\" },\n { name: \"fallback\", title: \"Fallback\", description: \"No image: the initials.\" },\n { name: \"sizes\", title: \"Sizes\", description: \"`xs`, `sm`, `md` (default), `lg`, `xl`.\" },\n { name: \"group\", title: \"Group\", description: \"`Avatar.Group` with `max`: the others become `+n`.\" },\n ],\n },\n native: {\n imports: [\"Avatar\"],\n differences: [\n \"The fallback is the initial of `alt` when `fallback` is not given.\",\n \"`size` also takes a number of points.\",\n \"`src` is a URI string (an `Image` source), not a URL object.\",\n ],\n examples: [\n { name: \"sizes\", title: \"Sizes\", description: \"Without an image: the initial of `alt`, from `xs` to `xl`.\" },\n { name: \"group\", title: \"Group\", description: \"`Avatar.Group max={3}`: the rest becomes `+n`.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
319
+ "content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Avatar\",\n summary: \"A person or an account: their picture, or initials while it loads, when it fails or when there is none.\",\n status: \"stable\",\n category: \"data-display\",\n platforms: \"both\",\n whenToUse: [\n \"Next to a name: comments, creators, members, the signed-in account in a header.\",\n \"`Avatar.Group` to show who takes part (followers, collaborators) in little space.\",\n ],\n whenNotToUse: [\n { when: \"For a product, a video or a place: use an image in a card.\", use: \"card\" },\n { when: \"As the only way to say who someone is: show their name too, or give the avatar an `alt`.\" },\n ],\n bestPractices: [\n \"Always pass a `fallback` (initials) on the web: images fail, and the layout should not jump.\",\n \"Use one size per list: `sm` in dense lists, `md` by default, `lg`/`xl` on a profile.\",\n \"In a group, set `max` so a long list ends with `+n` instead of overflowing.\",\n ],\n accessibility: {\n keyboard: [],\n notes: [\n \"With `alt`, the picture is an image named by it; without `alt`, it is decorative — put the name in the text next to it.\",\n \"The fallback initials are shown, not announced, when an `alt` names the person.\",\n ],\n },\n related: [\"card\", \"badge\", \"skeleton\"],\n web: {\n imports: [\"Avatar\"],\n examples: [\n { name: \"with-image\", title: \"With an image\", description: \"A picture named by `alt`, with initials as `fallback`.\" },\n { name: \"fallback\", title: \"Fallback\", description: \"No image: the initials.\" },\n { name: \"sizes\", title: \"Sizes\", description: \"`xs`, `sm`, `md` (default), `lg`, `xl`.\" },\n { name: \"group\", title: \"Group\", description: \"`Avatar.Group` with `max`: the others become `+n`.\" },\n ],\n },\n native: {\n imports: [\"Avatar\"],\n differences: [\n \"The fallback is the initial of `alt` when `fallback` is not given.\",\n \"`size` also takes a number of points.\",\n \"`src` is a URI string (an `Image` source), not a URL object.\",\n \"An SVG `src` (`….svg`, `data:image/svg+xml,…`, or any URI with `svg`) is drawn by react-native-svg — `Image` cannot.\",\n ],\n examples: [\n { name: \"sizes\", title: \"Sizes\", description: \"Without an image: the initial of `alt`, from `xs` to `xl`.\" },\n { name: \"group\", title: \"Group\", description: \"`Avatar.Group max={3}`: the rest becomes `+n`.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
305
320
  },
306
321
  {
307
322
  "path": "meta.ts",
@@ -2,8 +2,8 @@
2
2
  "name": "button",
3
3
  "type": "primitive",
4
4
  "title": "Button",
5
- "summary": "The action: a button in five looks (primary, secondary, outline, ghost, danger), three sizes and two shapes.",
6
- "description": "The action: a button in five looks (primary, secondary, outline, ghost, danger), three sizes and two shapes.",
5
+ "summary": "The action: a button in six looks (primary, gradient, secondary, outline, ghost, danger), three sizes and two shapes.",
6
+ "description": "The action: a button in six looks (primary, gradient, secondary, outline, ghost, danger), three sizes and two shapes.",
7
7
  "status": "stable",
8
8
  "category": "actions",
9
9
  "platforms": "both",
@@ -96,9 +96,9 @@
96
96
  },
97
97
  {
98
98
  "name": "variant",
99
- "type": "\"danger\" | \"primary\" | \"secondary\" | \"outline\" | \"ghost\"",
99
+ "type": "\"danger\" | \"primary\" | \"gradient\" | \"secondary\" | \"outline\" | \"ghost\"",
100
100
  "default": "secondary",
101
- "description": "The look: `primary` (the accent), `secondary` (a raised surface), `outline`, `ghost`, `danger`.",
101
+ "description": "The look: `primary` (the accent), `gradient` (accent → accent-2), `secondary` (a raised surface), `outline`,\n`ghost`, `danger`.",
102
102
  "required": false
103
103
  }
104
104
  ]
@@ -144,9 +144,9 @@
144
144
  },
145
145
  {
146
146
  "name": "variant",
147
- "type": "\"danger\" | \"primary\" | \"secondary\" | \"outline\" | \"ghost\"",
147
+ "type": "\"danger\" | \"primary\" | \"gradient\" | \"secondary\" | \"outline\" | \"ghost\"",
148
148
  "default": "secondary",
149
- "description": "The look: `primary` (the accent), `secondary` (a raised surface), `outline`, `ghost`, `danger`.",
149
+ "description": "The look: `primary` (the accent), `gradient` (accent → accent-2), `secondary` (a raised surface), `outline`,\n`ghost`, `danger`.",
150
150
  "required": false
151
151
  }
152
152
  ]
@@ -161,6 +161,14 @@
161
161
  "module": "@krizaka/ui/registry/examples/button/primary",
162
162
  "code": "import { Button } from \"@krizaka/ui/button\";\n\nexport default function ButtonPrimary() {\n return <Button variant=\"primary\">Continue</Button>;\n}\n"
163
163
  },
164
+ {
165
+ "name": "gradient",
166
+ "title": "Gradient",
167
+ "description": "The brand's signature, accent → accent-2: a hero or a closing call to action, once per page.",
168
+ "path": "examples/button/gradient.tsx",
169
+ "module": "@krizaka/ui/registry/examples/button/gradient",
170
+ "code": "import { Button } from \"@krizaka/ui/button\";\n\nexport default function ButtonGradient() {\n return <Button variant=\"gradient\">Start free</Button>;\n}\n"
171
+ },
164
172
  {
165
173
  "name": "secondary",
166
174
  "title": "Secondary",
@@ -250,7 +258,8 @@
250
258
  "The text is the `label` prop (a string), not children: it is also the accessible name.",
251
259
  "`onPress` instead of `onClick` (a `Pressable`); haptics, if any, belong to your `onPress`.",
252
260
  "`icon` places an icon before the label; there is no `asChild`.",
253
- "`loading` shows the platform's `ActivityIndicator` in place of the icon."
261
+ "`loading` shows the platform's `ActivityIndicator` in place of the icon.",
262
+ "`variant=\"gradient\"` draws its fill with react-native-svg (React Native has no CSS gradient)."
254
263
  ],
255
264
  "props": [
256
265
  {
@@ -303,7 +312,7 @@
303
312
  "name": "variant",
304
313
  "type": "ButtonVariant",
305
314
  "default": null,
306
- "description": "The look: `primary`, `secondary` (default), `outline`, `ghost`, `danger` — as on the web.",
315
+ "description": "The look: `primary`, `gradient` (accent → accent-2), `secondary` (default), `outline`, `ghost`, `danger` — as on the web.",
307
316
  "required": false
308
317
  }
309
318
  ]
@@ -358,7 +367,7 @@
358
367
  "name": "variant",
359
368
  "type": "ButtonVariant",
360
369
  "default": null,
361
- "description": "The look: `primary`, `secondary` (default), `outline`, `ghost`, `danger` — as on the web.",
370
+ "description": "The look: `primary`, `gradient` (accent → accent-2), `secondary` (default), `outline`, `ghost`, `danger` — as on the web.",
362
371
  "required": false
363
372
  }
364
373
  ]
@@ -377,6 +386,18 @@
377
386
  "light": "examples/button/native/primary.light.png"
378
387
  }
379
388
  },
389
+ {
390
+ "name": "gradient",
391
+ "title": "Gradient",
392
+ "description": "accent → accent-2, drawn with react-native-svg: the hero action of a screen.",
393
+ "path": "examples/button/native/gradient.tsx",
394
+ "module": "@krizaka/ui/registry/examples/button/native/gradient",
395
+ "code": "import { Button } from \"@krizaka/ui/native\";\n\nexport default function NativeButtonGradient() {\n return <Button variant=\"gradient\" label=\"Start free\" onPress={() => {}} />;\n}\n",
396
+ "screenshots": {
397
+ "dark": "examples/button/native/gradient.dark.png",
398
+ "light": "examples/button/native/gradient.light.png"
399
+ }
400
+ },
380
401
  {
381
402
  "name": "secondary",
382
403
  "title": "Secondary",
@@ -490,7 +511,7 @@
490
511
  "files": [
491
512
  {
492
513
  "path": "button/button.tsx",
493
- "content": "// Server-safe: no hook, no context. `buttonVariants` styles a <Link> or a Server Component without the component.\nimport { Slot } from \"radix-ui\";\nimport type * as React from \"react\";\nimport { tv, type VariantProps } from \"tailwind-variants\";\n\nexport const buttonVariants = tv({\n base:\n \"inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 \" +\n \"disabled:pointer-events-none disabled:opacity-40 data-loading:cursor-progress active:scale-[0.98]\",\n variants: {\n variant: {\n primary: \"bg-accent text-on-accent shadow-sm hover:bg-accent-hover\",\n secondary: \"border border-border-default bg-surface-2 text-fg hover:border-border-strong hover:bg-surface-3\",\n outline: \"border border-border-default bg-transparent text-fg hover:border-accent hover:text-fg-accent\",\n ghost: \"text-fg-secondary hover:bg-surface-2 hover:text-fg\",\n // The danger token is invariant and too light for small text on a light surface (3.6:1): the label stays a\n // text role, the border and the tint carry the danger.\n danger: \"border border-danger/50 bg-danger/10 text-fg hover:border-danger hover:bg-danger/20\",\n },\n size: { sm: \"h-8 px-3 text-xs\", md: \"h-10 px-4 text-sm\", lg: \"h-12 px-6 text-sm\", icon: \"h-10 w-10\" },\n shape: { rounded: \"rounded-lg\", pill: \"rounded-full\" },\n },\n defaultVariants: { variant: \"secondary\", size: \"md\", shape: \"rounded\" },\n});\n\nexport type ButtonVariants = VariantProps<typeof buttonVariants>;\n\nexport type ButtonProps = React.ComponentProps<\"button\"> &\n ButtonVariants & {\n /**\n * The look: `primary` (the accent), `secondary` (a raised surface), `outline`, `ghost`, `danger`.\n * @default \"secondary\"\n */\n variant?: ButtonVariants[\"variant\"];\n /**\n * sm · md · lg, or `icon` (a square).\n * @default \"md\"\n */\n size?: ButtonVariants[\"size\"];\n /**\n * rounded · pill.\n * @default \"rounded\"\n */\n shape?: ButtonVariants[\"shape\"];\n /** Renders the child (e.g. a <Link>) instead of the <button>, with props, classes and ref merged. */\n asChild?: boolean;\n /** Disables and signals the wait (aria-busy, data-loading) — the product adds a spinner if it wants one. */\n loading?: boolean;\n };\n\nexport function Button({ asChild, loading, variant, size, shape, className, disabled, ...props }: ButtonProps) {\n const Comp = asChild ? Slot.Root : \"button\";\n return (\n <Comp\n type={asChild ? undefined : \"button\"}\n aria-busy={loading || undefined}\n data-loading={loading ? \"\" : undefined}\n data-variant={variant ?? \"secondary\"}\n disabled={asChild ? undefined : disabled || loading}\n aria-disabled={asChild && (disabled || loading) ? true : undefined}\n className={buttonVariants({ variant, size, shape, className })}\n {...props}\n />\n );\n}\n\nexport type IconButtonProps = Omit<ButtonProps, \"size\"> & {\n /** The accessible name (and tooltip) of the button — required, passed translated. */\n label: string;\n /**\n * sm · md · lg, or `icon` (a square).\n * @default \"icon\"\n */\n size?: ButtonProps[\"size\"];\n};\n\n/** An icon-only button: `label` is required, it is its accessible name. */\nexport function IconButton({ label, size = \"icon\", ...props }: IconButtonProps) {\n return <Button aria-label={label} title={label} size={size} {...props} />;\n}\n"
514
+ "content": "// Server-safe: no hook, no context. `buttonVariants` styles a <Link> or a Server Component without the component.\nimport { Slot } from \"radix-ui\";\nimport type * as React from \"react\";\nimport { tv, type VariantProps } from \"tailwind-variants\";\n\nexport const buttonVariants = tv({\n base:\n \"inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 \" +\n \"disabled:pointer-events-none disabled:opacity-40 data-loading:cursor-progress active:scale-[0.98]\",\n variants: {\n variant: {\n primary: \"bg-accent text-on-accent shadow-sm hover:bg-accent-hover\",\n // The brand's signature: accent → accent-2 (on-accent reads on both stops, tested in @krizaka/tokens).\n gradient: \"bg-linear-to-r from-accent to-accent-2 text-on-accent shadow-sm hover:brightness-110\",\n secondary: \"border border-border-default bg-surface-2 text-fg hover:border-border-strong hover:bg-surface-3\",\n outline: \"border border-border-default bg-transparent text-fg hover:border-accent hover:text-fg-accent\",\n ghost: \"text-fg-secondary hover:bg-surface-2 hover:text-fg\",\n // The border and the tint carry the danger; the label stays the primary text role, legible on the tint at rest\n // and hovered over every surface (`text-fg-danger` is for text set directly on a surface).\n danger: \"border border-danger/50 bg-danger/10 text-fg hover:border-danger hover:bg-danger/20\",\n },\n size: { sm: \"h-8 px-3 text-xs\", md: \"h-10 px-4 text-sm\", lg: \"h-12 px-6 text-sm\", icon: \"h-10 w-10\" },\n shape: { rounded: \"rounded-lg\", pill: \"rounded-full\" },\n },\n defaultVariants: { variant: \"secondary\", size: \"md\", shape: \"rounded\" },\n});\n\nexport type ButtonVariants = VariantProps<typeof buttonVariants>;\n\nexport type ButtonProps = React.ComponentProps<\"button\"> &\n ButtonVariants & {\n /**\n * The look: `primary` (the accent), `gradient` (accent → accent-2), `secondary` (a raised surface), `outline`,\n * `ghost`, `danger`.\n * @default \"secondary\"\n */\n variant?: ButtonVariants[\"variant\"];\n /**\n * sm · md · lg, or `icon` (a square).\n * @default \"md\"\n */\n size?: ButtonVariants[\"size\"];\n /**\n * rounded · pill.\n * @default \"rounded\"\n */\n shape?: ButtonVariants[\"shape\"];\n /** Renders the child (e.g. a <Link>) instead of the <button>, with props, classes and ref merged. */\n asChild?: boolean;\n /** Disables and signals the wait (aria-busy, data-loading) — the product adds a spinner if it wants one. */\n loading?: boolean;\n };\n\nexport function Button({ asChild, loading, variant, size, shape, className, disabled, ...props }: ButtonProps) {\n const Comp = asChild ? Slot.Root : \"button\";\n return (\n <Comp\n type={asChild ? undefined : \"button\"}\n aria-busy={loading || undefined}\n data-loading={loading ? \"\" : undefined}\n data-variant={variant ?? \"secondary\"}\n disabled={asChild ? undefined : disabled || loading}\n aria-disabled={asChild && (disabled || loading) ? true : undefined}\n className={buttonVariants({ variant, size, shape, className })}\n {...props}\n />\n );\n}\n\nexport type IconButtonProps = Omit<ButtonProps, \"size\"> & {\n /** The accessible name (and tooltip) of the button — required, passed translated. */\n label: string;\n /**\n * sm · md · lg, or `icon` (a square).\n * @default \"icon\"\n */\n size?: ButtonProps[\"size\"];\n};\n\n/** An icon-only button: `label` is required, it is its accessible name. */\nexport function IconButton({ label, size = \"icon\", ...props }: IconButtonProps) {\n return <Button aria-label={label} title={label} size={size} {...props} />;\n}\n"
494
515
  },
495
516
  {
496
517
  "path": "button/index.ts",
@@ -498,7 +519,7 @@
498
519
  },
499
520
  {
500
521
  "path": "button/meta.ts",
501
- "content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Button\",\n summary: \"The action: a button in five looks (primary, secondary, outline, ghost, danger), three sizes and two shapes.\",\n status: \"stable\",\n category: \"actions\",\n platforms: \"both\",\n whenToUse: [\n \"To run an action: submit a form, open a dialog, start an upload, save a draft.\",\n \"One `primary` button for the main action of a view; `secondary`, `outline` or `ghost` for the others.\",\n \"`IconButton` for a compact action shown by its icon alone (close, add, more), with a `label` for its name.\",\n ],\n whenNotToUse: [\n { when: \"To go to another page: use a link (`asChild` with your router's `<Link>` keeps the button look).\" },\n { when: \"To turn a setting on or off, with an immediate effect.\", use: \"switch\" },\n { when: \"For a destructive action that needs a second press to confirm, in place.\", use: \"confirm-button\" },\n { when: \"To choose one option among several, or filter a list.\", use: \"chip\" },\n ],\n bestPractices: [\n \"Name the action with a verb: “Save”, “Send the invitation” — not “OK” or “Yes”.\",\n \"One primary per view; the danger variant only for what destroys or cannot be undone.\",\n \"`loading` while the action runs: the button is disabled and says it is busy; keep its width by keeping its words short.\",\n \"Style a link without the component with `buttonVariants({ variant, size })`.\",\n ],\n accessibility: {\n keyboard: [\n { keys: \"Enter / Space\", action: \"Runs the action.\" },\n { keys: \"Tab\", action: \"Moves the focus to the next control; the focus ring uses `--kz-ring`.\" },\n ],\n notes: [\n \"A native `<button type=\\\"button\\\">`: the role, the focus and the keyboard come from the platform.\",\n \"`IconButton` requires `label`: it becomes the `aria-label` of a button that shows no text.\",\n \"`loading` sets `aria-busy` and disables the button; `disabled` keeps it out of the tab order.\",\n ],\n },\n related: [\"confirm-button\", \"dialog\", \"dropdown-menu\", \"tooltip\"],\n web: {\n imports: [\"Button\", \"IconButton\"],\n examples: [\n { name: \"primary\", title: \"Primary\", description: \"The main action of a view: the accent.\" },\n { name: \"secondary\", title: \"Secondary\", description: \"The default: a raised surface, for the other actions.\" },\n { name: \"outline\", title: \"Outline\", description: \"A lighter action next to a primary one.\" },\n { name: \"ghost\", title: \"Ghost\", description: \"In toolbars and dense lists: no frame until hovered.\" },\n { name: \"danger\", title: \"Danger\", description: \"A destructive action: the border and the tint carry the danger, the label stays legible in both themes.\" },\n { name: \"sizes\", title: \"Sizes\", description: \"`sm`, `md` (default) and `lg`.\" },\n { name: \"pill\", title: \"Pill\", description: \"`shape=\\\"pill\\\"`: fully rounded.\" },\n { name: \"disabled\", title: \"Disabled\", description: \"Not available yet: out of the tab order.\" },\n { name: \"loading\", title: \"Loading\", description: \"While the action runs: disabled, `aria-busy`, `data-loading`.\" },\n { name: \"as-child-link\", title: \"As a link\", description: \"`asChild` renders your `<a>` (or router link) with the button's look.\" },\n { name: \"icon\", title: \"Icon button\", description: \"`IconButton`: a square button holding an icon; `label` is its accessible name.\" },\n ],\n },\n native: {\n imports: [\"Button\", \"IconButton\"],\n differences: [\n \"The text is the `label` prop (a string), not children: it is also the accessible name.\",\n \"`onPress` instead of `onClick` (a `Pressable`); haptics, if any, belong to your `onPress`.\",\n \"`icon` places an icon before the label; there is no `asChild`.\",\n \"`loading` shows the platform's `ActivityIndicator` in place of the icon.\",\n ],\n examples: [\n { name: \"primary\", title: \"Primary\", description: \"The main action of a screen.\" },\n { name: \"secondary\", title: \"Secondary\", description: \"The default look.\" },\n { name: \"outline\", title: \"Outline\", description: \"A lighter action.\" },\n { name: \"ghost\", title: \"Ghost\", description: \"No frame: toolbars, headers.\" },\n { name: \"danger\", title: \"Danger\", description: \"A destructive action.\" },\n { name: \"sizes\", title: \"Sizes\", description: \"`sm`, `md`, `lg`, and `shape=\\\"pill\\\"`.\" },\n { name: \"icon\", title: \"With an icon\", description: \"`icon` before the label.\" },\n { name: \"disabled\", title: \"Disabled\", description: \"Not available yet.\" },\n { name: \"loading\", title: \"Loading\", description: \"Disabled and busy, a spinner in place of the icon.\" },\n { name: \"icon-only\", title: \"Icon button\", description: \"`IconButton`: `label` is required, it is the accessible name.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
522
+ "content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Button\",\n summary: \"The action: a button in six looks (primary, gradient, secondary, outline, ghost, danger), three sizes and two shapes.\",\n status: \"stable\",\n category: \"actions\",\n platforms: \"both\",\n whenToUse: [\n \"To run an action: submit a form, open a dialog, start an upload, save a draft.\",\n \"One `primary` button for the main action of a view; `secondary`, `outline` or `ghost` for the others.\",\n \"`IconButton` for a compact action shown by its icon alone (close, add, more), with a `label` for its name.\",\n ],\n whenNotToUse: [\n { when: \"To go to another page: use a link (`asChild` with your router's `<Link>` keeps the button look).\" },\n { when: \"To turn a setting on or off, with an immediate effect.\", use: \"switch\" },\n { when: \"For a destructive action that needs a second press to confirm, in place.\", use: \"confirm-button\" },\n { when: \"To choose one option among several, or filter a list.\", use: \"chip\" },\n ],\n bestPractices: [\n \"Name the action with a verb: “Save”, “Send the invitation” — not “OK” or “Yes”.\",\n \"One primary per view; the danger variant only for what destroys or cannot be undone.\",\n \"`loading` while the action runs: the button is disabled and says it is busy; keep its width by keeping its words short.\",\n \"Style a link without the component with `buttonVariants({ variant, size })`.\",\n ],\n accessibility: {\n keyboard: [\n { keys: \"Enter / Space\", action: \"Runs the action.\" },\n { keys: \"Tab\", action: \"Moves the focus to the next control; the focus ring uses `--kz-ring`.\" },\n ],\n notes: [\n \"A native `<button type=\\\"button\\\">`: the role, the focus and the keyboard come from the platform.\",\n \"`IconButton` requires `label`: it becomes the `aria-label` of a button that shows no text.\",\n \"`loading` sets `aria-busy` and disables the button; `disabled` keeps it out of the tab order.\",\n ],\n },\n related: [\"confirm-button\", \"dialog\", \"dropdown-menu\", \"tooltip\"],\n web: {\n imports: [\"Button\", \"IconButton\"],\n examples: [\n { name: \"primary\", title: \"Primary\", description: \"The main action of a view: the accent.\" },\n { name: \"gradient\", title: \"Gradient\", description: \"The brand's signature, accent → accent-2: a hero or a closing call to action, once per page.\" },\n { name: \"secondary\", title: \"Secondary\", description: \"The default: a raised surface, for the other actions.\" },\n { name: \"outline\", title: \"Outline\", description: \"A lighter action next to a primary one.\" },\n { name: \"ghost\", title: \"Ghost\", description: \"In toolbars and dense lists: no frame until hovered.\" },\n { name: \"danger\", title: \"Danger\", description: \"A destructive action: the border and the tint carry the danger, the label stays legible in both themes.\" },\n { name: \"sizes\", title: \"Sizes\", description: \"`sm`, `md` (default) and `lg`.\" },\n { name: \"pill\", title: \"Pill\", description: \"`shape=\\\"pill\\\"`: fully rounded.\" },\n { name: \"disabled\", title: \"Disabled\", description: \"Not available yet: out of the tab order.\" },\n { name: \"loading\", title: \"Loading\", description: \"While the action runs: disabled, `aria-busy`, `data-loading`.\" },\n { name: \"as-child-link\", title: \"As a link\", description: \"`asChild` renders your `<a>` (or router link) with the button's look.\" },\n { name: \"icon\", title: \"Icon button\", description: \"`IconButton`: a square button holding an icon; `label` is its accessible name.\" },\n ],\n },\n native: {\n imports: [\"Button\", \"IconButton\"],\n differences: [\n \"The text is the `label` prop (a string), not children: it is also the accessible name.\",\n \"`onPress` instead of `onClick` (a `Pressable`); haptics, if any, belong to your `onPress`.\",\n \"`icon` places an icon before the label; there is no `asChild`.\",\n \"`loading` shows the platform's `ActivityIndicator` in place of the icon.\",\n \"`variant=\\\"gradient\\\"` draws its fill with react-native-svg (React Native has no CSS gradient).\",\n ],\n examples: [\n { name: \"primary\", title: \"Primary\", description: \"The main action of a screen.\" },\n { name: \"gradient\", title: \"Gradient\", description: \"accent → accent-2, drawn with react-native-svg: the hero action of a screen.\" },\n { name: \"secondary\", title: \"Secondary\", description: \"The default look.\" },\n { name: \"outline\", title: \"Outline\", description: \"A lighter action.\" },\n { name: \"ghost\", title: \"Ghost\", description: \"No frame: toolbars, headers.\" },\n { name: \"danger\", title: \"Danger\", description: \"A destructive action.\" },\n { name: \"sizes\", title: \"Sizes\", description: \"`sm`, `md`, `lg`, and `shape=\\\"pill\\\"`.\" },\n { name: \"icon\", title: \"With an icon\", description: \"`icon` before the label.\" },\n { name: \"disabled\", title: \"Disabled\", description: \"Not available yet.\" },\n { name: \"loading\", title: \"Loading\", description: \"Disabled and busy, a spinner in place of the icon.\" },\n { name: \"icon-only\", title: \"Icon button\", description: \"`IconButton`: `label` is required, it is the accessible name.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
502
523
  },
503
524
  {
504
525
  "path": "meta.ts",
@@ -549,9 +570,9 @@
549
570
  },
550
571
  {
551
572
  "name": "variant",
552
- "type": "\"danger\" | \"primary\" | \"secondary\" | \"outline\" | \"ghost\"",
573
+ "type": "\"danger\" | \"primary\" | \"gradient\" | \"secondary\" | \"outline\" | \"ghost\"",
553
574
  "default": "secondary",
554
- "description": "The look: `primary` (the accent), `secondary` (a raised surface), `outline`, `ghost`, `danger`.",
575
+ "description": "The look: `primary` (the accent), `gradient` (accent → accent-2), `secondary` (a raised surface), `outline`,\n`ghost`, `danger`.",
555
576
  "required": false
556
577
  }
557
578
  ]
@@ -597,9 +618,9 @@
597
618
  },
598
619
  {
599
620
  "name": "variant",
600
- "type": "\"danger\" | \"primary\" | \"secondary\" | \"outline\" | \"ghost\"",
621
+ "type": "\"danger\" | \"primary\" | \"gradient\" | \"secondary\" | \"outline\" | \"ghost\"",
601
622
  "default": "secondary",
602
- "description": "The look: `primary` (the accent), `secondary` (a raised surface), `outline`, `ghost`, `danger`.",
623
+ "description": "The look: `primary` (the accent), `gradient` (accent → accent-2), `secondary` (a raised surface), `outline`,\n`ghost`, `danger`.",
603
624
  "required": false
604
625
  }
605
626
  ]
@@ -29,6 +29,7 @@
29
29
  "Build product cards from these parts and override any of them with `className` — never fork the card.",
30
30
  "Keep one primary target per card: either the whole card is a link, or it holds buttons — not both.",
31
31
  "Give `Card.Media` a fixed `aspect` so a grid stays aligned while images load.",
32
+ "`Card.Image` loads lazily; pass `loading=\"eager\"` (and `fetchPriority=\"high\"`) to the first cards of a page, above the fold.",
32
33
  "`reveal={index}` with one `MotionObserver` per page for the entrance; it is shown at once under reduced motion."
33
34
  ],
34
35
  "accessibility": {
@@ -119,6 +120,13 @@
119
120
  "description": "Shown when there is no image: an icon, an illustration (decorative).",
120
121
  "required": false
121
122
  },
123
+ {
124
+ "name": "loading",
125
+ "type": "\"eager\" | \"lazy\"",
126
+ "default": "lazy",
127
+ "description": "When the browser loads it: `lazy` (default, a card in a list or below the fold) or `eager` for the first cards\nof a page — the image that is the largest contentful paint must not wait (add `fetchPriority=\"high\"`).",
128
+ "required": false
129
+ },
122
130
  {
123
131
  "name": "src",
124
132
  "type": "string | null",
@@ -405,7 +413,7 @@
405
413
  "files": [
406
414
  {
407
415
  "path": "card/card.tsx",
408
- "content": "// Server-safe: no hook, no context. One `tv` definition with slots: each part takes its slot, and a product overrides\n// any part through `className` (merged last by tailwind-merge).\nimport { Slot } from \"radix-ui\";\nimport type * as React from \"react\";\nimport { tv, type VariantProps } from \"tailwind-variants\";\n\nimport { cn } from \"../cn\";\n\nexport const card = tv({\n slots: {\n root:\n \"group relative flex flex-col overflow-hidden border border-border-default bg-surface-1 text-fg transition-colors \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\",\n media: \"relative overflow-hidden bg-media\",\n image: \"h-full w-full object-cover transition-transform duration-500 motion-safe:group-hover:scale-105\",\n fallback: \"flex h-full w-full items-center justify-center bg-linear-to-br from-accent/15 via-media to-accent-2/15 text-accent/60\",\n overlay: \"absolute z-10 flex items-center gap-1.5\",\n body: \"relative flex flex-1 flex-col gap-3\",\n title: \"line-clamp-1 text-sm font-semibold text-fg transition-colors group-hover:text-fg-accent\",\n description: \"line-clamp-2 text-xs text-fg-secondary\",\n stat: \"min-w-0\",\n statLabel: \"text-[10px] font-semibold uppercase tracking-wider text-fg-secondary\",\n statValue: \"font-display text-xl font-black tabular-nums text-fg\",\n footer: \"relative mt-auto flex items-center gap-2 border-t border-border-subtle pt-3 text-xs text-fg-secondary\",\n },\n variants: {\n radius: { md: { root: \"rounded-md\" }, lg: { root: \"rounded-lg\" }, xl: { root: \"rounded-xl\" } },\n tone: { default: {}, elevated: { root: \"bg-surface-2 shadow-md\" }, glass: { root: \"bg-surface-1/70 backdrop-blur-md\" } },\n interactive: { true: { root: \"kz-spotlight kz-lift hover:border-accent/50\" } },\n padding: { none: { body: \"p-0\" }, sm: { body: \"p-3\" }, md: { body: \"p-4\" }, lg: { body: \"p-6\" } },\n },\n defaultVariants: { radius: \"xl\", tone: \"default\", padding: \"md\" },\n});\n\ntype CardVariants = VariantProps<typeof card>;\n/** The parts without a variant read the default slots: no context, so server-safe. */\nconst slots = card();\n\nexport type CardRootProps = React.ComponentProps<\"div\"> &\n Pick<CardVariants, \"radius\" | \"tone\" | \"interactive\"> & {\n /**\n * md · lg · xl.\n * @default \"xl\"\n */\n radius?: CardVariants[\"radius\"];\n /**\n * default (the base surface) · elevated (raised, a shadow) · glass (translucent, blurred).\n * @default \"default\"\n */\n tone?: CardVariants[\"tone\"];\n /** The spotlight that follows the pointer and the lift on hover: for a card that is a link. */\n interactive?: CardVariants[\"interactive\"];\n /** Renders the child (a link, an article) instead of the <div>, with props, classes and ref merged. */\n asChild?: boolean;\n /** Enters on scroll (MotionObserver): the index in the grid becomes the delay (50 ms a step, 8 steps at most). */\n reveal?: number;\n };\n\nexport function CardRoot({ asChild, reveal, radius, tone, interactive, className, style, ...props }: CardRootProps) {\n const Comp = asChild ? Slot.Root : \"div\";\n const revealStyle =\n reveal === undefined ? style : ({ ...style, \"--kz-delay\": `${Math.min(Math.max(reveal, 0), 8) * 50}ms` } as React.CSSProperties);\n return (\n <Comp\n data-reveal={reveal === undefined ? undefined : \"\"}\n data-interactive={interactive ? \"\" : undefined}\n style={revealStyle}\n className={card({ radius, tone, interactive }).root({ className })}\n {...props}\n />\n );\n}\n\nconst ASPECT = { video: \"aspect-video\", square: \"aspect-square\", portrait: \"aspect-[3/4]\", auto: \"\" } as const;\nexport type CardMediaProps = React.ComponentProps<\"div\"> & {\n /** The ratio of the media: video (16:9) · square · portrait (3:4) · auto (the content's). */\n aspect?: keyof typeof ASPECT;\n};\n\nexport function CardMedia({ aspect = \"video\", className, ...props }: CardMediaProps) {\n return <div data-aspect={aspect} className={slots.media({ className: cn(ASPECT[aspect], className) })} {...props} />;\n}\n\nexport type CardImageProps = Omit<React.ComponentProps<\"img\">, \"src\"> & {\n /** The image's URL; `null` or nothing shows the `fallback`. */\n src?: string | null;\n /** Shown when there is no image: an icon, an illustration (decorative). */\n fallback?: React.ReactNode;\n};\n\n/** The image of a media, or its `fallback` when there is none. `alt` is empty by default: the title names the card. */\nexport function CardImage({ src, alt = \"\", fallback, className, ...props }: CardImageProps) {\n if (!src) {\n return (\n <div data-fallback=\"\" aria-hidden className={slots.fallback({ className })}>\n {fallback}\n </div>\n );\n }\n return <img src={src} alt={alt} loading=\"lazy\" decoding=\"async\" className={slots.image({ className })} {...props} />;\n}\n\nconst CORNER = {\n \"top-left\": \"left-2.5 top-2.5\",\n \"top-right\": \"right-2.5 top-2.5\",\n \"bottom-left\": \"bottom-2.5 left-2.5\",\n \"bottom-right\": \"bottom-2.5 right-2.5\",\n} as const;\nexport type CardOverlayProps = React.ComponentProps<\"div\"> & {\n /** The corner of the media it sits in. */\n corner?: keyof typeof CORNER;\n};\n\n/** What sits on the media (badges, a caption). `scrim` and `fg-on-media` are invariant: nothing to do for light. */\nexport function CardOverlay({ corner = \"top-left\", className, ...props }: CardOverlayProps) {\n return <div data-corner={corner} className={slots.overlay({ className: cn(CORNER[corner], className) })} {...props} />;\n}\n\nexport type CardBodyProps = React.ComponentProps<\"div\"> & {\n /**\n * none · sm · md · lg.\n * @default \"md\"\n */\n padding?: CardVariants[\"padding\"];\n};\n\nexport function CardBody({ padding, className, ...props }: CardBodyProps) {\n return <div className={card({ padding }).body({ className })} {...props} />;\n}\n\nexport type CardTitleProps = React.ComponentProps<\"h3\"> & {\n /** The heading level, to fit the page's outline. */\n as?: \"h2\" | \"h3\" | \"h4\";\n};\n\nexport function CardTitle({ as: Tag = \"h3\", className, ...props }: CardTitleProps) {\n return <Tag className={slots.title({ className })} {...props} />;\n}\n\nexport function CardDescription({ className, ...props }: React.ComponentProps<\"p\">) {\n return <p className={slots.description({ className })} {...props} />;\n}\n\nexport type CardStatProps = React.ComponentProps<\"div\"> & {\n /** What the figure is, in small capitals above it (the figure is the children). */\n label: React.ReactNode;\n};\n\n/** A figure put forward: the label in capitals, the value in tabular digits. */\nexport function CardStat({ label, className, children, ...props }: CardStatProps) {\n return (\n <div className={slots.stat({ className })} {...props}>\n <p className={slots.statLabel()}>{label}</p>\n <p className={slots.statValue()}>{children}</p>\n </div>\n );\n}\n\nexport function CardFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n return <div className={slots.footer({ className })} {...props} />;\n}\n\n/** `Card.Root/Media/Image/Overlay/Body/Title/Description/Stat/Footer`. */\nexport const Card = {\n Root: CardRoot,\n Media: CardMedia,\n Image: CardImage,\n Overlay: CardOverlay,\n Body: CardBody,\n Title: CardTitle,\n Description: CardDescription,\n Stat: CardStat,\n Footer: CardFooter,\n};\n"
416
+ "content": "// Server-safe: no hook, no context. One `tv` definition with slots: each part takes its slot, and a product overrides\n// any part through `className` (merged last by tailwind-merge).\nimport { Slot } from \"radix-ui\";\nimport type * as React from \"react\";\nimport { tv, type VariantProps } from \"tailwind-variants\";\n\nimport { cn } from \"../cn\";\n\nexport const card = tv({\n slots: {\n root:\n \"group relative flex flex-col overflow-hidden border border-border-default bg-surface-1 text-fg transition-colors \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\",\n media: \"relative overflow-hidden bg-media\",\n image: \"h-full w-full object-cover transition-transform duration-500 motion-safe:group-hover:scale-105\",\n fallback: \"flex h-full w-full items-center justify-center bg-linear-to-br from-accent/15 via-media to-accent-2/15 text-accent/60\",\n overlay: \"absolute z-10 flex items-center gap-1.5\",\n body: \"relative flex flex-1 flex-col gap-3\",\n title: \"line-clamp-1 text-sm font-semibold text-fg transition-colors group-hover:text-fg-accent\",\n description: \"line-clamp-2 text-xs text-fg-secondary\",\n stat: \"min-w-0\",\n statLabel: \"text-[10px] font-semibold uppercase tracking-wider text-fg-secondary\",\n statValue: \"font-display text-xl font-black tabular-nums text-fg\",\n footer: \"relative mt-auto flex items-center gap-2 border-t border-border-subtle pt-3 text-xs text-fg-secondary\",\n },\n variants: {\n radius: { md: { root: \"rounded-md\" }, lg: { root: \"rounded-lg\" }, xl: { root: \"rounded-xl\" } },\n tone: { default: {}, elevated: { root: \"bg-surface-2 shadow-md\" }, glass: { root: \"bg-surface-1/70 backdrop-blur-md\" } },\n interactive: { true: { root: \"kz-spotlight kz-lift hover:border-accent/50\" } },\n padding: { none: { body: \"p-0\" }, sm: { body: \"p-3\" }, md: { body: \"p-4\" }, lg: { body: \"p-6\" } },\n },\n defaultVariants: { radius: \"xl\", tone: \"default\", padding: \"md\" },\n});\n\ntype CardVariants = VariantProps<typeof card>;\n/** The parts without a variant read the default slots: no context, so server-safe. */\nconst slots = card();\n\nexport type CardRootProps = React.ComponentProps<\"div\"> &\n Pick<CardVariants, \"radius\" | \"tone\" | \"interactive\"> & {\n /**\n * md · lg · xl.\n * @default \"xl\"\n */\n radius?: CardVariants[\"radius\"];\n /**\n * default (the base surface) · elevated (raised, a shadow) · glass (translucent, blurred).\n * @default \"default\"\n */\n tone?: CardVariants[\"tone\"];\n /** The spotlight that follows the pointer and the lift on hover: for a card that is a link. */\n interactive?: CardVariants[\"interactive\"];\n /** Renders the child (a link, an article) instead of the <div>, with props, classes and ref merged. */\n asChild?: boolean;\n /** Enters on scroll (MotionObserver): the index in the grid becomes the delay (50 ms a step, 8 steps at most). */\n reveal?: number;\n };\n\nexport function CardRoot({ asChild, reveal, radius, tone, interactive, className, style, ...props }: CardRootProps) {\n const Comp = asChild ? Slot.Root : \"div\";\n const revealStyle =\n reveal === undefined ? style : ({ ...style, \"--kz-delay\": `${Math.min(Math.max(reveal, 0), 8) * 50}ms` } as React.CSSProperties);\n return (\n <Comp\n data-reveal={reveal === undefined ? undefined : \"\"}\n data-interactive={interactive ? \"\" : undefined}\n style={revealStyle}\n className={card({ radius, tone, interactive }).root({ className })}\n {...props}\n />\n );\n}\n\nconst ASPECT = { video: \"aspect-video\", square: \"aspect-square\", portrait: \"aspect-[3/4]\", auto: \"\" } as const;\nexport type CardMediaProps = React.ComponentProps<\"div\"> & {\n /** The ratio of the media: video (16:9) · square · portrait (3:4) · auto (the content's). */\n aspect?: keyof typeof ASPECT;\n};\n\nexport function CardMedia({ aspect = \"video\", className, ...props }: CardMediaProps) {\n return <div data-aspect={aspect} className={slots.media({ className: cn(ASPECT[aspect], className) })} {...props} />;\n}\n\nexport type CardImageProps = Omit<React.ComponentProps<\"img\">, \"src\"> & {\n /** The image's URL; `null` or nothing shows the `fallback`. */\n src?: string | null;\n /** Shown when there is no image: an icon, an illustration (decorative). */\n fallback?: React.ReactNode;\n /**\n * When the browser loads it: `lazy` (default, a card in a list or below the fold) or `eager` for the first cards\n * of a page — the image that is the largest contentful paint must not wait (add `fetchPriority=\"high\"`).\n * @default \"lazy\"\n */\n loading?: \"lazy\" | \"eager\";\n};\n\n/** The image of a media, or its `fallback` when there is none. `alt` is empty by default: the title names the card. */\nexport function CardImage({ src, alt = \"\", fallback, loading = \"lazy\", className, ...props }: CardImageProps) {\n if (!src) {\n return (\n <div data-fallback=\"\" aria-hidden className={slots.fallback({ className })}>\n {fallback}\n </div>\n );\n }\n return <img src={src} alt={alt} loading={loading} decoding=\"async\" className={slots.image({ className })} {...props} />;\n}\n\nconst CORNER = {\n \"top-left\": \"left-2.5 top-2.5\",\n \"top-right\": \"right-2.5 top-2.5\",\n \"bottom-left\": \"bottom-2.5 left-2.5\",\n \"bottom-right\": \"bottom-2.5 right-2.5\",\n} as const;\nexport type CardOverlayProps = React.ComponentProps<\"div\"> & {\n /** The corner of the media it sits in. */\n corner?: keyof typeof CORNER;\n};\n\n/** What sits on the media (badges, a caption). `scrim` and `fg-on-media` are invariant: nothing to do for light. */\nexport function CardOverlay({ corner = \"top-left\", className, ...props }: CardOverlayProps) {\n return <div data-corner={corner} className={slots.overlay({ className: cn(CORNER[corner], className) })} {...props} />;\n}\n\nexport type CardBodyProps = React.ComponentProps<\"div\"> & {\n /**\n * none · sm · md · lg.\n * @default \"md\"\n */\n padding?: CardVariants[\"padding\"];\n};\n\nexport function CardBody({ padding, className, ...props }: CardBodyProps) {\n return <div className={card({ padding }).body({ className })} {...props} />;\n}\n\nexport type CardTitleProps = React.ComponentProps<\"h3\"> & {\n /** The heading level, to fit the page's outline. */\n as?: \"h2\" | \"h3\" | \"h4\";\n};\n\nexport function CardTitle({ as: Tag = \"h3\", className, ...props }: CardTitleProps) {\n return <Tag className={slots.title({ className })} {...props} />;\n}\n\nexport function CardDescription({ className, ...props }: React.ComponentProps<\"p\">) {\n return <p className={slots.description({ className })} {...props} />;\n}\n\nexport type CardStatProps = React.ComponentProps<\"div\"> & {\n /** What the figure is, in small capitals above it (the figure is the children). */\n label: React.ReactNode;\n};\n\n/** A figure put forward: the label in capitals, the value in tabular digits. */\nexport function CardStat({ label, className, children, ...props }: CardStatProps) {\n return (\n <div className={slots.stat({ className })} {...props}>\n <p className={slots.statLabel()}>{label}</p>\n <p className={slots.statValue()}>{children}</p>\n </div>\n );\n}\n\nexport function CardFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n return <div className={slots.footer({ className })} {...props} />;\n}\n\n/** `Card.Root/Media/Image/Overlay/Body/Title/Description/Stat/Footer`. */\nexport const Card = {\n Root: CardRoot,\n Media: CardMedia,\n Image: CardImage,\n Overlay: CardOverlay,\n Body: CardBody,\n Title: CardTitle,\n Description: CardDescription,\n Stat: CardStat,\n Footer: CardFooter,\n};\n"
409
417
  },
410
418
  {
411
419
  "path": "card/index.ts",
@@ -413,7 +421,7 @@
413
421
  },
414
422
  {
415
423
  "path": "card/meta.ts",
416
- "content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Card\",\n summary: \"The composed chassis of every card: a media with overlays, a body with a title, a description, stats and a footer.\",\n status: \"stable\",\n category: \"layout\",\n platforms: \"both\",\n whenToUse: [\n \"To present one item of a collection: a video, a creator, a guide, a plan.\",\n \"As a link to the item (`asChild` + `interactive`): the whole card is one target.\",\n \"To group a few figures under a title (`Card.Stat`).\",\n ],\n whenNotToUse: [\n { when: \"To frame a whole page section: use the page layout, not a card around everything.\" },\n { when: \"For a single key figure without a frame.\", use: \"stat\" },\n { when: \"For a message about the page.\", use: \"alert\" },\n ],\n bestPractices: [\n \"Build product cards from these parts and override any of them with `className` — never fork the card.\",\n \"Keep one primary target per card: either the whole card is a link, or it holds buttons — not both.\",\n \"Give `Card.Media` a fixed `aspect` so a grid stays aligned while images load.\",\n \"`reveal={index}` with one `MotionObserver` per page for the entrance; it is shown at once under reduced motion.\",\n ],\n accessibility: {\n keyboard: [{ keys: \"Tab / Enter\", action: \"An `asChild` link card is one focusable link that Enter follows.\" }],\n notes: [\n \"`Card.Title` is a heading (`h3` by default, `as` to change the level) so cards are reachable by heading.\",\n \"A card that is a link is named by its content: keep the title first.\",\n \"The image fallback is decorative and hidden from assistive technology.\",\n ],\n },\n related: [\"badge\", \"stat\", \"avatar\", \"skeleton\"],\n web: {\n imports: [\"Card\"],\n examples: [\n { name: \"media-card\", title: \"Media card\", description: \"An image, badges on the media, a title, a description and a footer.\" },\n { name: \"media-fallback\", title: \"Media fallback\", description: \"No image: the `fallback` on the accent gradient.\" },\n { name: \"interactive-link\", title: \"Interactive link\", description: \"`asChild` + `<a>`: the whole card is a link, with the spotlight and the lift.\" },\n { name: \"stat-card\", title: \"Stat card\", description: \"Figures put forward with `Card.Stat`, `tone=\\\"elevated\\\"`.\" },\n { name: \"reveal-grid\", title: \"Reveal grid\", description: \"Cards entering in cascade with `reveal` and `MotionObserver`.\" },\n ],\n },\n native: {\n imports: [\"Card\"],\n differences: [\n \"`onPress` on `Card.Root` makes the whole card a button (no `asChild`, no `interactive`).\",\n \"Built on `View`, `Image` and `Text`: style with `style`, not `className`.\",\n \"No `Card.Stat` and no `reveal`.\",\n ],\n examples: [\n { name: \"default\", title: \"Default\", description: \"A media, a body and a footer.\" },\n { name: \"elevated\", title: \"Elevated\", description: \"`tone=\\\"elevated\\\"`: one step higher, with a shadow.\" },\n { name: \"pressable\", title: \"Pressable\", description: \"`onPress`: the whole card is a button.\" },\n { name: \"body\", title: \"Text and an action\", description: \"A body only, with a button.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
424
+ "content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Card\",\n summary: \"The composed chassis of every card: a media with overlays, a body with a title, a description, stats and a footer.\",\n status: \"stable\",\n category: \"layout\",\n platforms: \"both\",\n whenToUse: [\n \"To present one item of a collection: a video, a creator, a guide, a plan.\",\n \"As a link to the item (`asChild` + `interactive`): the whole card is one target.\",\n \"To group a few figures under a title (`Card.Stat`).\",\n ],\n whenNotToUse: [\n { when: \"To frame a whole page section: use the page layout, not a card around everything.\" },\n { when: \"For a single key figure without a frame.\", use: \"stat\" },\n { when: \"For a message about the page.\", use: \"alert\" },\n ],\n bestPractices: [\n \"Build product cards from these parts and override any of them with `className` — never fork the card.\",\n \"Keep one primary target per card: either the whole card is a link, or it holds buttons — not both.\",\n \"Give `Card.Media` a fixed `aspect` so a grid stays aligned while images load.\",\n \"`Card.Image` loads lazily; pass `loading=\\\"eager\\\"` (and `fetchPriority=\\\"high\\\"`) to the first cards of a page, above the fold.\",\n \"`reveal={index}` with one `MotionObserver` per page for the entrance; it is shown at once under reduced motion.\",\n ],\n accessibility: {\n keyboard: [{ keys: \"Tab / Enter\", action: \"An `asChild` link card is one focusable link that Enter follows.\" }],\n notes: [\n \"`Card.Title` is a heading (`h3` by default, `as` to change the level) so cards are reachable by heading.\",\n \"A card that is a link is named by its content: keep the title first.\",\n \"The image fallback is decorative and hidden from assistive technology.\",\n ],\n },\n related: [\"badge\", \"stat\", \"avatar\", \"skeleton\"],\n web: {\n imports: [\"Card\"],\n examples: [\n { name: \"media-card\", title: \"Media card\", description: \"An image, badges on the media, a title, a description and a footer.\" },\n { name: \"media-fallback\", title: \"Media fallback\", description: \"No image: the `fallback` on the accent gradient.\" },\n { name: \"interactive-link\", title: \"Interactive link\", description: \"`asChild` + `<a>`: the whole card is a link, with the spotlight and the lift.\" },\n { name: \"stat-card\", title: \"Stat card\", description: \"Figures put forward with `Card.Stat`, `tone=\\\"elevated\\\"`.\" },\n { name: \"reveal-grid\", title: \"Reveal grid\", description: \"Cards entering in cascade with `reveal` and `MotionObserver`.\" },\n ],\n },\n native: {\n imports: [\"Card\"],\n differences: [\n \"`onPress` on `Card.Root` makes the whole card a button (no `asChild`, no `interactive`).\",\n \"Built on `View`, `Image` and `Text`: style with `style`, not `className`.\",\n \"No `Card.Stat` and no `reveal`.\",\n ],\n examples: [\n { name: \"default\", title: \"Default\", description: \"A media, a body and a footer.\" },\n { name: \"elevated\", title: \"Elevated\", description: \"`tone=\\\"elevated\\\"`: one step higher, with a shadow.\" },\n { name: \"pressable\", title: \"Pressable\", description: \"`onPress`: the whole card is a button.\" },\n { name: \"body\", title: \"Text and an action\", description: \"A body only, with a button.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
417
425
  },
418
426
  {
419
427
  "path": "meta.ts",
@@ -497,6 +505,13 @@
497
505
  "description": "Shown when there is no image: an icon, an illustration (decorative).",
498
506
  "required": false
499
507
  },
508
+ {
509
+ "name": "loading",
510
+ "type": "\"eager\" | \"lazy\"",
511
+ "default": "lazy",
512
+ "description": "When the browser loads it: `lazy` (default, a card in a list or below the fold) or `eager` for the first cards\nof a page — the image that is the largest contentful paint must not wait (add `fetchPriority=\"high\"`).",
513
+ "required": false
514
+ },
500
515
  {
501
516
  "name": "src",
502
517
  "type": "string | null",