@glasshome/ui 1.6.0 → 2.0.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 (84) hide show
  1. package/README.md +3 -3
  2. package/SPEC.md +30 -0
  3. package/dist/index.d.ts +13 -5
  4. package/dist/index.js +2 -13
  5. package/dist/lib/alert-tones.d.ts +2 -1
  6. package/dist/lib/badge-tone.d.ts +3 -1
  7. package/dist/lib/button-variants.d.ts +2 -1
  8. package/dist/lib/card-classes.d.ts +8 -7
  9. package/dist/lib/glass-tone.d.ts +2 -0
  10. package/dist/lib/input-classes.d.ts +10 -1
  11. package/dist/lib/layers.d.ts +8 -0
  12. package/dist/lib/menu-classes.d.ts +5 -0
  13. package/dist/lib/motion-classes.d.ts +8 -0
  14. package/dist/lib/overlay-classes.d.ts +11 -4
  15. package/dist/lib/picker-classes.d.ts +3 -0
  16. package/dist/lib/pill-classes.d.ts +3 -0
  17. package/dist/lib/position-bar-classes.d.ts +2 -0
  18. package/dist/lib/section-tokens.d.ts +3 -2
  19. package/dist/lib/segment-classes.d.ts +1 -0
  20. package/dist/lib/thumb-classes.d.ts +5 -0
  21. package/dist/lib/use-is-mobile.d.ts +2 -1
  22. package/dist/solid/alert-dialog.d.ts +16 -17
  23. package/dist/solid/area-picker.d.ts +3 -0
  24. package/dist/solid/bottom-sheet/bottom-sheet.d.ts +9 -7
  25. package/dist/solid/bottom-sheet/constants.d.ts +0 -1
  26. package/dist/solid/bottom-sheet/index.d.ts +1 -1
  27. package/dist/solid/charts.d.ts +10 -5
  28. package/dist/solid/checkbox.d.ts +16 -1
  29. package/dist/solid/context-menu.d.ts +3 -1
  30. package/dist/solid/count-pill.d.ts +5 -3
  31. package/dist/solid/data-table.d.ts +4 -3
  32. package/dist/solid/dialog-parts.d.ts +55 -0
  33. package/dist/solid/dialog.d.ts +17 -14
  34. package/dist/solid/dropdown-menu.d.ts +19 -25
  35. package/dist/solid/empty.d.ts +5 -28
  36. package/dist/solid/entity-selector.d.ts +2 -0
  37. package/dist/solid/form.d.ts +27 -7
  38. package/dist/solid/hero-action.d.ts +0 -24
  39. package/dist/solid/icon-picker.d.ts +2 -0
  40. package/dist/solid/index.d.ts +15 -11
  41. package/dist/solid/index.js +7488 -6798
  42. package/dist/solid/input-group.d.ts +14 -2
  43. package/dist/solid/item.d.ts +6 -30
  44. package/dist/solid/menu-parts.d.ts +47 -0
  45. package/dist/solid/option-card.d.ts +20 -0
  46. package/dist/solid/ornament.d.ts +3 -0
  47. package/dist/solid/page-header.d.ts +11 -1
  48. package/dist/solid/picker-row.d.ts +14 -0
  49. package/dist/solid/picker-search.d.ts +15 -0
  50. package/dist/solid/popover.d.ts +9 -18
  51. package/dist/solid/progress.d.ts +1 -0
  52. package/dist/solid/responsive-dialog.d.ts +16 -6
  53. package/dist/solid/schema-form.d.ts +4 -0
  54. package/dist/solid/scroll-area.d.ts +1 -0
  55. package/dist/solid/section-card.d.ts +5 -6
  56. package/dist/solid/sheet.d.ts +22 -16
  57. package/dist/solid/step-indicator.d.ts +2 -2
  58. package/dist/solid/table.d.ts +3 -0
  59. package/dist/solid/tabs.d.ts +7 -5
  60. package/dist/solid/tooltip.d.ts +1 -1
  61. package/dist/solid/widget-identity.d.ts +4 -1
  62. package/dist/tokens/index.js +1 -1
  63. package/dist/{utils-8e8QCynI.js → utils-CiJUMt2j.js} +560 -523
  64. package/package.json +5 -3
  65. package/src/lib/alert-tones.ts +8 -3
  66. package/src/lib/badge-tone.ts +9 -2
  67. package/src/lib/button-variants.ts +8 -4
  68. package/src/lib/card-classes.ts +12 -7
  69. package/src/lib/carousel-classes.ts +2 -3
  70. package/src/lib/glass-tone.ts +8 -0
  71. package/src/lib/input-classes.ts +23 -1
  72. package/src/lib/layers.ts +12 -0
  73. package/src/lib/menu-classes.ts +21 -0
  74. package/src/lib/motion-classes.ts +40 -0
  75. package/src/lib/overlay-classes.ts +22 -5
  76. package/src/lib/picker-classes.ts +17 -0
  77. package/src/lib/pill-classes.ts +13 -0
  78. package/src/lib/position-bar-classes.ts +10 -0
  79. package/src/lib/section-tokens.ts +9 -4
  80. package/src/lib/segment-classes.ts +7 -0
  81. package/src/lib/thumb-classes.ts +21 -0
  82. package/src/lib/use-is-mobile.ts +10 -6
  83. package/src/styles/globals.css +90 -19
  84. package/src/styles/theme.css +67 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/ui",
3
- "version": "1.6.0",
3
+ "version": "2.0.0",
4
4
  "description": "SolidJS component library for GlassHome, built on Kobalte",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/glasshome/ui#readme",
@@ -68,7 +68,8 @@
68
68
  "lint": "biome check .",
69
69
  "test": "vitest run",
70
70
  "test:watch": "vitest",
71
- "dev:gallery": "vite --config vite.dev.config.ts"
71
+ "dev:gallery": "vite --config vite.dev.config.ts",
72
+ "gallery:shots": "node dev/shoot.mjs"
72
73
  },
73
74
  "peerDependencies": {
74
75
  "astro": ">=4",
@@ -78,7 +79,7 @@
78
79
  },
79
80
  "dependencies": {
80
81
  "@iconify-icon/solid": "^3.0.3",
81
- "@kobalte/core": "^0.13.11",
82
+ "@kobalte/core": "^0.13.13",
82
83
  "clsx": "^2.1.1",
83
84
  "cva": "^1.0.0-beta.4",
84
85
  "embla-carousel": "^8.5.1",
@@ -96,6 +97,7 @@
96
97
  "@types/node": "^25.2.2",
97
98
  "fallow": "3.17.0",
98
99
  "happy-dom": "^20.11.0",
100
+ "playwright": "^1.60.0",
99
101
  "publint": "^0.3.21",
100
102
  "tailwindcss": "^4.1.13",
101
103
  "tw-animate-css": "^1.4.0",
@@ -46,21 +46,26 @@ export const alertFill = (c: string) => `color-mix(in srgb, ${c} 9%, transparent
46
46
  export const alertIconFill = (c: string) => `color-mix(in srgb, ${c} 16%, transparent)`;
47
47
 
48
48
  export const ALERT_CLASS =
49
- "relative overflow-hidden flex items-start gap-3 rounded-lg border p-3 backdrop-blur-sm";
49
+ "relative overflow-hidden flex items-start gap-3 rounded-lg p-3 backdrop-blur-sm";
50
50
  /* gh-alert-* svg chrome lives in globals.css as real CSS: an arbitrary variant
51
51
  * here only works if the consumer's Tailwind scans package source. */
52
52
  export const ALERT_ICON_CLASS =
53
53
  "gh-alert-icon flex size-8 shrink-0 items-center justify-center rounded-md";
54
54
  export const ALERT_ICON_BG_CLASS =
55
55
  "gh-alert-icon-bg pointer-events-none absolute -right-3 -bottom-4";
56
- export function alertIconBgStyle(color: string): Record<string, string> {
56
+ export function iconBgMaskStyle(color: string): Record<string, string> {
57
57
  return {
58
58
  color,
59
- opacity: "0.32",
60
59
  "-webkit-mask-image": "radial-gradient(140% 140% at 100% 100%, #000 45%, transparent 95%)",
61
60
  "mask-image": "radial-gradient(140% 140% at 100% 100%, #000 45%, transparent 95%)",
62
61
  };
63
62
  }
63
+ export function alertIconBgStyle(color: string): Record<string, string> {
64
+ return {
65
+ ...iconBgMaskStyle(color),
66
+ opacity: "0.32",
67
+ };
68
+ }
64
69
  export const ALERT_CONTENT_CLASS = "relative z-10 min-w-0 flex-1";
65
70
  export const ALERT_TITLE_CLASS = "font-semibold text-base leading-snug";
66
71
  export const ALERT_DESCRIPTION_CLASS = "gh-alert-desc text-foreground/80 text-sm leading-snug";
@@ -1,4 +1,11 @@
1
+ import { NEUTRAL_KNOBS } from "./glass-tone.js";
2
+ import { CHIP } from "./pill-classes.js";
3
+
1
4
  export const BADGE_DEFAULT_TONE = "var(--primary)";
2
5
 
3
- export const BADGE_TONE_CLASS =
4
- "inline-flex w-fit shrink-0 cursor-default select-none items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 py-0.5 font-medium text-xs";
6
+ /** @deprecated CHIP from lib/pill-classes.ts */
7
+ export const BADGE_TONE_CLASS = CHIP;
8
+
9
+ /* The neutral chip also drops the tint sheen: at chip size it reads as a
10
+ * milky cap over the text. */
11
+ export const BADGE_NEUTRAL_KNOBS = `${NEUTRAL_KNOBS} [--glass-light:0.08]`;
@@ -1,5 +1,10 @@
1
1
  import { cva } from "cva";
2
2
 
3
+ /* One outline material, worn by the pill and by Toggle: `glass` owns the
4
+ * element's background and border, so a real border-input box is a no-op. */
5
+ export const OUTLINE_SURFACE =
6
+ "glass [--glass-edge:var(--border)] hover:[--glass-base:var(--muted)] dark:[--glass-base:var(--input)] dark:hover:[--glass-base:var(--muted)]";
7
+
3
8
  /* Buttons are glass pills on an OPAQUE --card base: a translucent fill goes
4
9
  * muddy over dark heroes/sections. Every class is literal for Tailwind's
5
10
  * scanner. `size: none` is sizeless for callers that own height/padding. */
@@ -7,7 +12,7 @@ export const buttonVariants = cva({
7
12
  // structural-ok: the [&_svg]: utilities stay in Tailwind's utility layer so a
8
13
  // caller's own [&_svg]:size-* override can still win; unlayered real CSS in
9
14
  // globals.css would beat every consumer utility regardless of specificity.
10
- base: "inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-full font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
15
+ base: "inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-full font-medium text-sm outline-none transition-glass focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
11
16
  variants: {
12
17
  variant: {
13
18
  default:
@@ -16,8 +21,7 @@ export const buttonVariants = cva({
16
21
  "glass glass-tint [--glass-text:61%] [--glass-tone:var(--destructive)] [--glass-wash:30%] focus-visible:ring-destructive/30 hover:[--glass-edge:color-mix(in_srgb,var(--destructive)_60%,transparent)] hover:[--glass-wash:40%]",
17
22
  secondary:
18
23
  "glass glass-tint [--glass-tone:var(--accent)] [--glass-wash:30%] hover:[--glass-edge:color-mix(in_srgb,var(--accent)_60%,transparent)] hover:[--glass-wash:40%]",
19
- outline:
20
- "glass [--glass-edge:var(--border)] hover:[--glass-base:var(--muted)] dark:[--glass-base:var(--input)] dark:hover:[--glass-base:var(--muted)]",
24
+ outline: OUTLINE_SURFACE,
21
25
  ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50",
22
26
  link: "text-primary underline-offset-4 hover:underline",
23
27
  },
@@ -44,4 +48,4 @@ export const BUTTON_OUTLINE_CLASS = buttonVariants({ variant: "outline", size: "
44
48
  /* Round ghost icon button (copy buttons, small icon actions). Size and text
45
49
  * color stay at the call site. */
46
50
  export const ICON_BUTTON_CLASS =
47
- "inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full transition-all duration-150 hover:bg-primary/10 active:scale-90";
51
+ "inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full transition-glass duration-150 hover:bg-primary/10 active:scale-[0.97]";
@@ -13,25 +13,30 @@ export const CARD_SURFACE = `${CARD_SURFACE_BASE} ${CARD_BLUR}`;
13
13
  * contexts where backdrop-blur can't run. */
14
14
  export const CARD_SURFACE_OPAQUE = CARD_KNOBS;
15
15
 
16
+ /* Rows inside a section: the card material again, one step in, so a row never
17
+ * reads as a flat plate on a glass panel. */
18
+ export const SECTION_ROW_SURFACE =
19
+ "glass [--glass-rim:0.3] [--glass-base:color-mix(in_srgb,var(--card)_60%,transparent)]";
20
+
16
21
  /* Recessed track frame (tabs, segmented controls). */
17
22
  export const TRACK_SURFACE = "border border-border/50 bg-card/40 backdrop-blur-sm";
18
23
 
19
24
  export const CARD_PADDING = {
20
- slots: "flex flex-col gap-3 py-3 md:gap-4 md:py-4",
21
- md: "p-4",
22
- sm: "p-3",
25
+ slots: "flex flex-col gap-3 p-3 md:gap-4 md:p-4",
26
+ md: "flex flex-col gap-3 p-4",
27
+ sm: "flex flex-col gap-2 p-3",
23
28
  none: "",
24
29
  } as const;
25
30
 
26
31
  export type CardPadding = keyof typeof CARD_PADDING;
27
32
 
28
33
  export const CARD_INTERACTIVE =
29
- "cursor-pointer transition-colors duration-200 hover:border-border hover:bg-foreground/[0.03] focus-within:border-border";
34
+ "cursor-pointer transition-glass hover:[--glass-light:0.09] focus-within:[--glass-edge:var(--border)]";
30
35
 
31
36
  export const CARD_HEADER_CLASS =
32
- "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] md:px-4";
37
+ "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 has-data-[slot=card-action]:grid-cols-[1fr_auto]";
33
38
  export const CARD_TITLE_CLASS = "font-semibold leading-none";
34
39
  export const CARD_DESCRIPTION_CLASS = "text-muted-foreground text-sm";
35
40
  export const CARD_ACTION_CLASS = "col-start-2 row-span-2 row-start-1 self-start justify-self-end";
36
- export const CARD_CONTENT_CLASS = "px-3 md:px-4";
37
- export const CARD_FOOTER_CLASS = "flex items-center px-3 md:px-4 [.border-t]:pt-6";
41
+ export const CARD_CONTENT_CLASS = "min-w-0";
42
+ export const CARD_FOOTER_CLASS = "flex items-center";
@@ -1,6 +1,7 @@
1
1
  /* Shared by the Solid <Carousel> and astro/Carousel.astro so the two render the
2
2
  * same markup. Astro renders slides server-side (SEO, LCP) and enhances them on
3
3
  * the client with the same embla engine. */
4
+ import { POSITION_BAR, POSITION_BAR_LIT } from "./position-bar-classes.js";
4
5
 
5
6
  export type CarouselTransition = "slide" | "fade" | "wipe";
6
7
 
@@ -40,7 +41,5 @@ export function carouselItem(
40
41
  export const CAROUSEL_DOTS = "flex items-center justify-center gap-2";
41
42
 
42
43
  export function carouselDot(active: boolean) {
43
- return `h-1.5 rounded-full transition-all hover:bg-foreground/70 ${
44
- active ? "w-6 bg-foreground/80" : "w-1.5 bg-foreground/40"
45
- }`;
44
+ return `${POSITION_BAR} ${active ? `w-6 ${POSITION_BAR_LIT}` : "w-2.5 hover:[--glass-light:0.12]"}`;
46
45
  }
@@ -21,3 +21,11 @@ export function toneTextMix(color: string): number {
21
21
  export function glassToneText(color: string): string {
22
22
  return `color-mix(in oklab, ${color} ${toneTextMix(color)}%, var(--foreground))`;
23
23
  }
24
+
25
+ /* The neutral tone every "no verdict" surface passes (neutral badges,
26
+ * HeroOption's default accent). Through the tint formula a light grey tone
27
+ * lifts the fill to the same grey as its text, so a neutral surface sinks
28
+ * its wash to keep the text on a deeper ground. */
29
+ const NEUTRAL_TONE = "var(--muted-foreground)";
30
+ export const NEUTRAL_KNOBS = "[--glass-wash:12%]";
31
+ export const isNeutralTone = (tone: string | undefined) => tone === NEUTRAL_TONE;
@@ -17,4 +17,26 @@ export const INPUT_SURFACE =
17
17
  * in the light theme. */
18
18
  export const FIELD_CHROME = "glass glass-sink [--glass-base:var(--input)] [--glass-light:0.04]";
19
19
 
20
- export const INPUT_CLASS = `flex h-9 w-full min-w-0 rounded-md ${INPUT_SURFACE} px-3 py-1 text-base outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40`;
20
+ /* Focus on a glass element: border utilities are no-ops there, so the edge
21
+ * moves through the knob and the ring paints outside. */
22
+ export const FOCUS_RING =
23
+ "outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:[--glass-edge:var(--ring)]";
24
+
25
+ export const INVALID_RING =
26
+ "aria-invalid:[--glass-edge:var(--destructive)] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40";
27
+
28
+ /* The three control heights every field, trigger and button shares. */
29
+ export const CONTROL_H = {
30
+ sm: "h-8",
31
+ default: "h-9",
32
+ lg: "h-10",
33
+ } as const;
34
+
35
+ /* Field type: 16px on touch so iOS does not zoom, 14px from md up. */
36
+ export const FIELD_TEXT = "text-base md:text-sm";
37
+
38
+ /* Trigger-shaped fields (Select, pickers) compose from these; the text input
39
+ * adds file:* and selection styling on top. */
40
+ export const FIELD_CONTROL = `flex w-full min-w-0 rounded-md ${INPUT_SURFACE} px-3 ${FIELD_TEXT} transition-[color,box-shadow] placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 ${FOCUS_RING} ${INVALID_RING}`;
41
+
42
+ export const INPUT_CLASS = `${FIELD_CONTROL} ${CONTROL_H.default} py-1 selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm`;
@@ -0,0 +1,12 @@
1
+ /* Stacking order for floating surfaces. Sheets sit under popovers so a
2
+ * dropdown opened inside a sheet stacks naturally. Class strings stay literal
3
+ * for Tailwind's scanner; numbers are for inline styles. */
4
+ export const Z = {
5
+ sheet: 40,
6
+ overlay: 50,
7
+ } as const;
8
+
9
+ export const Z_CLASS = {
10
+ sheet: "z-40",
11
+ overlay: "z-50",
12
+ } as const satisfies Record<keyof typeof Z, string>;
@@ -0,0 +1,21 @@
1
+ /* One menu row, label and separator for every listbox and menu (Select,
2
+ * DropdownMenu, ContextMenu, pickers). Highlight comes from SlidingIndicator,
3
+ * so the row itself paints no background. A toned row (MenuItemPart sets
4
+ * data-tone) guards off the muted svg color so its icon inherits the row's
5
+ * own currentColor instead.
6
+ *
7
+ * The horizontal padding is a separate term: an indented row (checkbox, radio)
8
+ * needs pl-8 pr-2, and tailwind-merge does not cancel px-2 for a more specific
9
+ * pl-8/pr-2 pair, so both would stay in the class list and the visible result
10
+ * would come down to CSS emit order. Such a row composes MENU_ITEM_BASE. */
11
+ export const MENU_ITEM_BASE =
12
+ // structural-ok: guards an svg color rule on a sibling data attribute this package always renders, not a class the consumer must also scan
13
+ "relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 text-sm outline-hidden transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&:not([data-tone])_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&>iconify-icon]:shrink-0 [&>iconify-icon]:text-[16px]";
14
+
15
+ export const MENU_ITEM_X = "px-2";
16
+
17
+ export const MENU_ITEM = `${MENU_ITEM_BASE} ${MENU_ITEM_X}`;
18
+
19
+ export const MENU_LABEL = "px-2 py-1.5 font-medium text-muted-foreground text-xs";
20
+
21
+ export const MENU_SEPARATOR = "-mx-1 my-1 h-px bg-border";
@@ -0,0 +1,40 @@
1
+ /* The motion vocabulary. A thing arrives one of three ways and leaves the
2
+ * same way in reverse, faster. Durations and curves are theme tokens, so
3
+ * prefers-reduced-motion zeroes all of it in one place. */
4
+
5
+ const ARRIVE =
6
+ "data-[expanded]:animate-in data-[expanded]:duration-(--duration-expand) data-[expanded]:ease-(--ease-expand)";
7
+ const LEAVE =
8
+ "data-[closed]:animate-out data-[closed]:duration-(--duration-micro) data-[closed]:ease-(--ease-contract)";
9
+
10
+ /* Anchored panels (menu, popover, tooltip, hover card): unfold from the
11
+ * trigger edge. Kobalte writes the origin per placement. */
12
+ export const OVERLAY_MOTION = `origin-(--kb-popper-content-transform-origin) ${ARRIVE} data-[expanded]:fade-in-0 data-[expanded]:zoom-in-90 ${LEAVE} data-[closed]:fade-out-0 data-[closed]:zoom-out-95`;
13
+
14
+ /* Field pickers: paired with gutter={0} + anchorToTriggerTop, the clip-path
15
+ * reveal is what makes the panel read as the field expanding, not a box
16
+ * dropping in. */
17
+ export const FIELD_MOTION = "data-[closed]:animate-select-out data-[expanded]:animate-select-in";
18
+
19
+ /* Anchored panels that own their trigger (dropdown menu): the panel covers
20
+ * the trigger (gutter 0 + anchorToTriggerTop) and unrolls out of the
21
+ * trigger's box; the content writes --morph-h/--morph-radius from the
22
+ * trigger it replaces, --morph-w is kobalte's anchor width. */
23
+ export const MORPH_MOTION =
24
+ "[--morph-w:var(--kb-popper-anchor-width)] min-w-[var(--kb-popper-anchor-width)] data-[expanded]:animate-morph-in data-[closed]:animate-morph-out";
25
+
26
+ /* Children of a panel arrive staggered through the `gh-stagger` class
27
+ * (globals.css): one door, no per-row index, mount-only. */
28
+ export const STAGGER = "gh-stagger";
29
+
30
+ /* Modals: rise into place. Sheets keep their slide. */
31
+ export const MODAL_MOTION = `${ARRIVE} data-[expanded]:fade-in-0 data-[expanded]:zoom-in-[0.94] data-[expanded]:slide-in-from-bottom-2 ${LEAVE} data-[closed]:fade-out-0 data-[closed]:zoom-out-[0.96]`;
32
+
33
+ export const SCRIM_MOTION = `${ARRIVE} data-[expanded]:fade-in-0 ${LEAVE} data-[closed]:fade-out-0`;
34
+
35
+ /* Content that swaps in place (tab panel, a row added to a list). */
36
+ export const SETTLE_MOTION =
37
+ "animate-in fade-in-0 slide-in-from-bottom-1 duration-(--duration-state) ease-(--ease-expand)";
38
+
39
+ /* Every pressable dips the same amount. */
40
+ export const PRESS_DIP = "active:scale-[0.97]";
@@ -1,13 +1,16 @@
1
- /* Rim/sheen raised above card level (0.3/0.05) so the bevel and highlight read
2
- * on large panels instead of smearing into a flat matte gray. */
1
+ import { Z_CLASS } from "./layers.js";
2
+
3
+ /* Flat frost. No corner sheen: on a panel this size the 120% radial is a
4
+ * 13-step grey ramp that bands and washes half the surface, so the glass read
5
+ * comes from the rim line, the blur and the grain instead. */
3
6
  const OVERLAY_KNOBS =
4
- "glass [--glass-rim:1] [--glass-lift:0.6] [--glass-light:0.05] [--glass-edge:color-mix(in_srgb,var(--border)_90%,transparent)]";
7
+ "glass glass-frost [--glass-rim:0.5] [--glass-lift:0.6] [--glass-light:0] [--glass-edge:color-mix(in_srgb,var(--border)_90%,transparent)]";
5
8
 
6
9
  /* Split from the surface the way CARD_BLUR is, so a host can gate it off and
7
10
  * paint a precomputed frost instead. */
8
- export const OVERLAY_BLUR = "backdrop-blur-[var(--glass-blur,20px)] backdrop-saturate-[1.6]";
11
+ export const OVERLAY_BLUR = "backdrop-blur-[var(--glass-blur,24px)] backdrop-saturate-[1.2]";
9
12
 
10
- export const OVERLAY_SURFACE_BASE = `${OVERLAY_KNOBS} [--glass-base:color-mix(in_srgb,var(--popover)_75%,transparent)]`;
13
+ export const OVERLAY_SURFACE_BASE = `${OVERLAY_KNOBS} [--glass-base:color-mix(in_srgb,var(--popover)_92%,transparent)]`;
11
14
 
12
15
  /* Floating-panel glass: translucent popover fill over a blur, so a menu reads
13
16
  * as glass and not as a flat plate. */
@@ -21,3 +24,17 @@ export const OVERLAY_SURFACE_OPAQUE = `${OVERLAY_KNOBS} [--glass-base:var(--popo
21
24
  /* Modal scrim behind dialogs/sheets. BottomSheet keeps its own unblurred scrim
22
25
  * (backdrop-blur is too slow on mobile). */
23
26
  export const SCRIM_CLASS = "bg-background/70 backdrop-blur-md";
27
+
28
+ /* The floating panel every anchored surface wears. Padding is the caller's:
29
+ * menus add p-1, popovers p-4, pickers none. */
30
+ export const FLOATING_PANEL = `${OVERLAY_SURFACE} relative ${Z_CLASS.overlay} rounded-md text-popover-foreground outline-hidden`;
31
+
32
+ /* Zero-height rect on the trigger's top edge. With gutter 0 and the panel
33
+ * width bound to --kb-popper-anchor-width, the panel covers the trigger and
34
+ * grows downward from it. */
35
+ export const anchorToTriggerTop = (anchor?: HTMLElement) => {
36
+ const r = anchor?.getBoundingClientRect();
37
+ return r
38
+ ? { x: r.left, y: r.top, width: r.width, height: 0 }
39
+ : { x: 0, y: 0, width: 0, height: 0 };
40
+ };
@@ -0,0 +1,17 @@
1
+ import { CONTROL_H, FIELD_CONTROL } from "./input-classes.js";
2
+
3
+ /* The one trigger every field-shaped picker wears (Select, AreaPicker,
4
+ * EntitySelector, IconPicker, ImagePicker). While the panel is open it covers
5
+ * the trigger at the same width and radius, so the trigger drops its own edge
6
+ * and focus ring: leaving them on paints a double border at the corners and a
7
+ * ring halo down the sides of the panel. The compound data+focus variants are
8
+ * needed because the trigger keeps focus while the panel is open. */
9
+ export const PICKER_TRIGGER = `${FIELD_CONTROL} ${CONTROL_H.default} items-center justify-between gap-2 text-left hover:[--glass-light:0.09] disabled:hover:[--glass-light:0.04] data-[expanded]:ring-0 data-[expanded]:[--glass-edge:transparent] data-[expanded]:focus-visible:ring-0 data-[expanded]:focus-visible:[--glass-edge:transparent]`;
10
+
11
+ /* Every picker list scrolls at the same size, on the list itself, never on the
12
+ * panel: a max-height on the panel clips the search row and the footer too. */
13
+ export const PICKER_LIST = "max-h-[min(70vh,400px)] overflow-y-auto gh-scroll";
14
+
15
+ /* 44 px: the minimum comfortable touch target, for the search row when a picker
16
+ * opens as a sheet. CONTROL_H has no touch step yet, so it is named here. */
17
+ export const CONTROL_H_TOUCH = "h-11";
@@ -0,0 +1,13 @@
1
+ /* Small tinted things share the Badge material. A chip is a rounded-full glass
2
+ * pill (Badge, CountPill, scope chips); an icon pill is the square-ish glass
3
+ * well behind a glyph (SectionIcon, ItemMedia, EmptyMedia). Tone comes from the
4
+ * caller's --glass-tone (via Badge's style or a [--glass-tone:…] utility);
5
+ * neutral wells set none and read as foreground glass. */
6
+ export const CHIP =
7
+ "glass glass-tint inline-flex w-fit shrink-0 select-none items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-0.5 font-medium text-xs";
8
+
9
+ export const ICON_PILL =
10
+ "glass flex shrink-0 items-center justify-center [--glass-rim:0.4] [--glass-light:0.08] [--glass-base:color-mix(in_srgb,var(--foreground)_10%,transparent)]";
11
+
12
+ export const ICON_PILL_TINT =
13
+ "glass glass-tint flex shrink-0 items-center justify-center [--glass-rim:0.4] [--glass-wash:18%]";
@@ -0,0 +1,10 @@
1
+ import { FIELD_CHROME } from "./input-classes.js";
2
+
3
+ /* One short bar per position among siblings (Carousel pagination,
4
+ * StepIndicator): the slider's rail, lit with the slider's fill. Width is the
5
+ * caller's (a stepper keeps every bar equal, a carousel stretches the current
6
+ * one). */
7
+ export const POSITION_BAR = `${FIELD_CHROME} h-1.5 rounded-full transition-glass duration-(--duration-morph) ease-(--ease-morph)`;
8
+
9
+ export const POSITION_BAR_LIT =
10
+ "glass glass-tint [--glass-tone:var(--primary)] [--glass-wash:70%] [--glass-drop:0%]";
@@ -1,8 +1,13 @@
1
- /* Concentric radius: inner = outer - padding, clamped for tiny --radius themes. */
1
+ /* Concentric radius: inner = outer - padding, clamped for tiny --radius themes.
2
+ * The padding term is 3 spacing units, the same number SECTION_PADDING's p-3
3
+ * resolves to, so the two cannot drift apart. */
2
4
  export const SECTION_OUTER_RADIUS = "rounded-[var(--radius)]";
3
- export const SECTION_INNER_RADIUS = "rounded-[max(0px,calc(var(--radius)-0.75rem))]";
5
+ export const SECTION_INNER_RADIUS = "rounded-[max(0px,calc(var(--radius)-var(--spacing)*3))]";
4
6
  export const SECTION_PADDING = "p-3";
5
7
 
6
- const SECTION_ROW_SURFACE = `${SECTION_INNER_RADIUS} border border-border/60 bg-card/60`;
8
+ /* Not card-classes' SECTION_ROW_SURFACE: this is the flat pre-glass fill, kept
9
+ * only for the deprecated class below. */
10
+ const LEGACY_SECTION_ROW_SURFACE = `${SECTION_INNER_RADIUS} border border-border/60 bg-card/60`;
7
11
 
8
- export const SECTION_ROW_CLASS = `${SECTION_ROW_SURFACE} ${SECTION_PADDING}`;
12
+ /** @deprecated Render `SectionRow`, or compose `SECTION_ROW_SURFACE` from card-classes. */
13
+ export const SECTION_ROW_CLASS = `${LEGACY_SECTION_ROW_SURFACE} ${SECTION_PADDING}`;
@@ -0,0 +1,7 @@
1
+ import { CONTROL_H, FOCUS_RING } from "./input-classes.js";
2
+
3
+ /* One segmented-item look shared by TabsTrigger and ToggleGroupItem (via
4
+ * toggleVariants' base): both sit inside a TRACK_SURFACE track and pick one
5
+ * of a few siblings, so they share height, type and focus ring. Callers still
6
+ * layer their own variant/size classes on top through cn()'s tailwind-merge. */
7
+ export const SEGMENT_ITEM = `inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md ${CONTROL_H.sm} px-3 font-medium text-sm outline-none transition-glass active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 ${FOCUS_RING}`;
@@ -0,0 +1,21 @@
1
+ /* The size-7 below in px (7 x --spacing, 0.27rem at a 16px root), rounded up:
2
+ * the colour wheel takes its ring thickness as a number, and a ring thinner
3
+ * than the thumb riding it lets the thumb overhang. */
4
+ export const THUMB_SIZE = 31;
5
+
6
+ /* The one draggable knob: switch, slider, colour slider, colour wheel. Colour
7
+ * is the caller's (state, per-thumb tint, live channel value), everything else
8
+ * is this recipe, so the four controls read as the same physical object. The
9
+ * focus ring is not part of it: on a slider the thumb takes focus, on a switch
10
+ * the track does. */
11
+ export const THUMB_CLASS =
12
+ "block size-7 shrink-0 rounded-xl shadow-[0_2px_5px_oklch(0_0_0/0.35),inset_0_1px_0_oklch(1_0_0/0.35),inset_0_-2px_3px_oklch(0_0_0/0.2)]";
13
+
14
+ /* A rail pads by half a thumb so the knob stays inside the track at both ends;
15
+ * the fill cancels the same inset to reach the rounded edges. */
16
+ /* A thumb painted the live channel colour sits on a track of that same colour,
17
+ * so it needs its own edge to be found. */
18
+ export const THUMB_COLOR_RING = "border-2 border-white/90";
19
+
20
+ export const THUMB_RAIL_PAD = "px-3.5";
21
+ export const THUMB_RAIL_BLEED = "-mx-3.5";
@@ -1,15 +1,19 @@
1
1
  import { createSignal, onCleanup, onMount } from "solid-js";
2
2
 
3
- const MOBILE_BREAKPOINT = 768;
3
+ /* Tailwind's `sm` edge, so the JS branch (dialog → bottom sheet, picker →
4
+ * sheet, toast position) flips where the `sm:` utilities flip. */
5
+ export const MOBILE_BREAKPOINT = 640;
4
6
 
5
- export function createIsMobile() {
6
- const [isMobile, setIsMobile] = createSignal<boolean | undefined>(undefined);
7
+ export function createIsMobile(breakpoint: number = MOBILE_BREAKPOINT) {
8
+ const [isMobile, setIsMobile] = createSignal<boolean | undefined>(
9
+ typeof window !== "undefined" ? window.innerWidth < breakpoint : undefined,
10
+ );
7
11
 
8
12
  onMount(() => {
9
- const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
10
- const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
13
+ const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
14
+ const onChange = () => setIsMobile(window.innerWidth < breakpoint);
11
15
  mql.addEventListener("change", onChange);
12
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
16
+ setIsMobile(window.innerWidth < breakpoint);
13
17
  onCleanup(() => mql.removeEventListener("change", onChange));
14
18
  });
15
19
 
@@ -76,12 +76,42 @@
76
76
  inherits: false;
77
77
  }
78
78
 
79
+ /* Top-most layer: a tiled noise slice over the fill. Untyped for the same
80
+ * reason as --glass-frost. */
81
+ @property --glass-grain {
82
+ syntax: "*";
83
+ inherits: false;
84
+ }
85
+
79
86
  /* --glass-base and --glass-edge need var() defaults, which @property forbids,
80
87
  * so they reset here instead. MUST stay in @layer base so [--glass-*] utilities
81
88
  * and inline style knobs still win. Rim shadows are declared on .glass (not
82
89
  * :root) so calc(var(--glass-rim)) resolves per element, not frozen at :root. */
90
+ :root {
91
+ /* biome-ignore-start lint/correctness/noMissingVarFunction: transition-property takes property names, and these are the registered knobs */
92
+ --glass-transition:
93
+ --glass-tone, --glass-wash, --glass-light, --glass-shade, --glass-rim, --glass-lift,
94
+ --glass-glow, --glass-drop, --glass-text, color, background-color, border-color, box-shadow,
95
+ opacity, transform, translate, scale, width, height;
96
+ /* biome-ignore-end lint/correctness/noMissingVarFunction: see above */
97
+ }
98
+
99
+ @layer utilities {
100
+ .transition-glass {
101
+ transition-property: var(--glass-transition);
102
+ }
103
+ }
104
+
83
105
  @layer base {
84
106
  :where(.glass) {
107
+ /* State never cuts: the registered knobs interpolate, so a tone, wash or
108
+ * sheen swap (checked, selected, hover) morphs on the state duration.
109
+ * Layered, so a surface that names its own transition keeps it; such a
110
+ * surface says transition-glass, never transition-all: Chromium's `all`
111
+ * does not cover registered custom properties. */
112
+ transition-property: var(--glass-transition);
113
+ transition-duration: var(--duration-state);
114
+ transition-timing-function: var(--ease-emphasis);
85
115
  --glass-base: var(--card);
86
116
  --glass-edge: color-mix(in srgb, var(--border) 60%, transparent);
87
117
  --glass-rim-raise:
@@ -102,6 +132,12 @@
102
132
  :where(.glass-sink) {
103
133
  --glass-rim-shine: var(--glass-rim-sink);
104
134
  }
135
+ /* Frosted: large translucent panels (dialogs, sheets, menus). A fine grey
136
+ * grain on top breaks the 8-bit banding a wide low-alpha gradient shows,
137
+ * and reads as frost instead of a flat plate. */
138
+ :where(.glass-frost) {
139
+ --glass-grain: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.7 0 0 0 0 0.7 0 0 0 0 0.7 0 0 0 0.05 0'/></filter><rect width='160' height='160' filter='url(%23n)'/></svg>");
140
+ }
105
141
  }
106
142
 
107
143
  /* The ONE glass formula. Neutral by default (--glass-tone transparent makes
@@ -128,9 +164,10 @@
128
164
  border: 1px solid var(--glass-edge);
129
165
  /* Base is a gradient layer (not background-color) so the frost slice can sit
130
166
  * BELOW it — background-color always paints under every image layer, which
131
- * would put the frost above the fill. Layer order top→bottom: light, shade,
132
- * tone wash, base fill, frost. */
167
+ * would put the frost above the fill. Layer order top→bottom: grain, light,
168
+ * shade, tone wash, base fill, frost. */
133
169
  background:
170
+ var(--glass-grain, none),
134
171
  radial-gradient(
135
172
  var(--glass-sheen, 120% 120%) at 22% 8%,
136
173
  oklch(1 0 0 / var(--glass-light)),
@@ -176,21 +213,6 @@
176
213
  pointer-events: none;
177
214
  }
178
215
 
179
- /* PageHeader banner surface: bespoke three-stop card gradient (its material
180
- * predates the one-glass formula) over the same frost slot .glass uses, so
181
- * hosts wire the performant-blur engine identically on both. Frost defaults
182
- * to none → plain gradient banner. */
183
- :where(.glass-banner) {
184
- background:
185
- linear-gradient(
186
- to bottom right,
187
- color-mix(in srgb, var(--card) 55%, transparent),
188
- color-mix(in srgb, var(--card) 25%, transparent),
189
- color-mix(in srgb, var(--card) 10%, transparent)
190
- ),
191
- var(--glass-frost, none);
192
- }
193
-
194
216
  /**
195
217
  * Consistent, theme-aware overlay scrollbar across browsers. Firefox uses
196
218
  * scrollbar-width/color; Chromium/WebKit use the ::-webkit-scrollbar pseudos
@@ -198,11 +220,17 @@
198
220
  * scroll surface that isn't a bare page — <ScrollArea>, dialogs, sheets.
199
221
  */
200
222
  .gh-scroll {
223
+ /* The width the bar reserves, published so a scroll container can subtract it
224
+ from its own inset and keep its content column aligned with the panel's
225
+ other parts. Blink and Gecko decide that width from scrollbar-width: thin
226
+ and ignore the pseudo below; 10px is what they reserve, and the pseudo uses
227
+ the same number so a WebKit build without scrollbar-width matches. */
228
+ --scrollbar-w: 10px;
201
229
  scrollbar-width: thin;
202
230
  scrollbar-color: color-mix(in srgb, var(--foreground) 22%, transparent) transparent;
203
231
  &::-webkit-scrollbar {
204
- width: 8px;
205
- height: 8px;
232
+ width: var(--scrollbar-w);
233
+ height: var(--scrollbar-w);
206
234
  }
207
235
  &::-webkit-scrollbar-track {
208
236
  background: transparent;
@@ -284,3 +312,46 @@
284
312
  animation: none;
285
313
  }
286
314
  }
315
+
316
+ /* Children arrive one after another. Mount-only (a CSS animation runs when
317
+ * the child is inserted), so a live re-render never re-staggers; a child
318
+ * added later takes its own slot, capped so a long list never waits. The
319
+ * travel is `translate`, never `transform`, so a positioned child (the
320
+ * sliding indicator) keeps its own transform. A group is transparent: its
321
+ * rows take slots, it does not. Slots come from --i: nth-child below for a
322
+ * flat list, or written inline by a part that knows its true order. */
323
+ @keyframes stagger-in {
324
+ from {
325
+ opacity: 0;
326
+ /* Upward: a child of a scroll container must not extend its scrollable
327
+ * height mid-animation, and rows trail a downward-growing edge. */
328
+ translate: 0 -8px;
329
+ }
330
+ to {
331
+ opacity: 1;
332
+ translate: 0 0;
333
+ }
334
+ }
335
+ .gh-stagger > :not([role="group"]),
336
+ .gh-stagger > [role="group"] > * {
337
+ animation: stagger-in calc(var(--duration-morph) * 0.5) var(--ease-emphasis) both;
338
+ animation-delay: calc(var(--stagger-base, 80ms) + var(--i, 0) * var(--stagger-step, 60ms));
339
+ }
340
+ .gh-stagger > :nth-child(2) {
341
+ --i: 1;
342
+ }
343
+ .gh-stagger > :nth-child(3) {
344
+ --i: 2;
345
+ }
346
+ .gh-stagger > :nth-child(4) {
347
+ --i: 3;
348
+ }
349
+ .gh-stagger > :nth-child(5) {
350
+ --i: 4;
351
+ }
352
+ .gh-stagger > :nth-child(6) {
353
+ --i: 5;
354
+ }
355
+ .gh-stagger > :nth-child(n + 7) {
356
+ --i: 6;
357
+ }