@glasshome/ui 1.0.0 → 1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/ui",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",
@@ -51,6 +51,7 @@
51
51
  "files": [
52
52
  "dist",
53
53
  "src/astro",
54
+ "src/lib",
54
55
  "src/styles",
55
56
  "scripts"
56
57
  ],
@@ -77,6 +78,8 @@
77
78
  "clsx": "^2.1.1",
78
79
  "cva": "^1.0.0-beta.4",
79
80
  "embla-carousel": "^8.5.1",
81
+ "embla-carousel-autoplay": "^8.5.1",
82
+ "embla-carousel-fade": "^8.5.1",
80
83
  "solid-sonner": "^0.3.1",
81
84
  "tailwind-merge": "^3.4.0"
82
85
  },
@@ -0,0 +1,79 @@
1
+ ---
2
+ /**
3
+ * Server-rendered carousel. Slides live in the HTML, so they are indexable and
4
+ * the first one is the LCP image; the client script only adds behaviour.
5
+ * Use this on content pages; use the Solid <Carousel> inside an app.
6
+ *
7
+ * Slides go in the default slot as <CarouselItem>-shaped elements, or pass
8
+ * `count` to have dots rendered for you.
9
+ */
10
+ import {
11
+ CAROUSEL_DOTS,
12
+ CAROUSEL_VIEWPORT,
13
+ type CarouselTransition,
14
+ carouselDot,
15
+ carouselTrack,
16
+ } from "../lib/carousel-classes";
17
+ import { cn } from "../lib/utils";
18
+
19
+ interface Props {
20
+ transition?: CarouselTransition;
21
+ /** Advance every N ms. Skipped under prefers-reduced-motion. */
22
+ autoplay?: number;
23
+ loop?: boolean;
24
+ /** Slide count; when set, dots are rendered. */
25
+ count?: number;
26
+ class?: string;
27
+ }
28
+
29
+ const {
30
+ transition = "slide",
31
+ autoplay,
32
+ loop = false,
33
+ count,
34
+ class: className,
35
+ ...rest
36
+ } = Astro.props;
37
+ ---
38
+
39
+ <div
40
+ data-carousel
41
+ data-carousel-transition={transition}
42
+ data-carousel-autoplay={autoplay}
43
+ data-carousel-loop={loop ? "" : undefined}
44
+ class={cn("relative", className)}
45
+ role="region"
46
+ aria-roledescription="carousel"
47
+ {...rest}
48
+ >
49
+ <div data-carousel-viewport class={CAROUSEL_VIEWPORT}>
50
+ <div class={carouselTrack(transition)}>
51
+ <slot />
52
+ </div>
53
+ </div>
54
+
55
+ <slot name="controls" />
56
+
57
+ {
58
+ count && (
59
+ <div class={CAROUSEL_DOTS} data-carousel-dots>
60
+ {Array.from({ length: count }, (_, i) => (
61
+ <button
62
+ type="button"
63
+ data-carousel-dot
64
+ aria-label={`Go to slide ${i + 1}`}
65
+ aria-current={i === 0}
66
+ class={carouselDot(i === 0)}
67
+ />
68
+ ))}
69
+ </div>
70
+ )
71
+ }
72
+ </div>
73
+
74
+ <script>
75
+ import { initCarousels } from "../lib/carousel-init";
76
+
77
+ initCarousels();
78
+ document.addEventListener("astro:page-load", () => initCarousels());
79
+ </script>
@@ -0,0 +1,65 @@
1
+ export type AlertTone = "info" | "warning" | "success" | "destructive";
2
+
3
+ export type AlertToneStyle = {
4
+ color: string;
5
+ icon: string;
6
+ text: string;
7
+ };
8
+
9
+ /* Text mixes in oklab: no hue channel, a hued tone desaturates without swinging yellow. */
10
+ export const ALERT_TONES: Record<AlertTone, AlertToneStyle> = {
11
+ info: {
12
+ color: "var(--primary)",
13
+ icon: "lucide:info",
14
+ text: "var(--primary-tint-foreground)",
15
+ },
16
+ warning: {
17
+ color: "var(--warning)",
18
+ icon: "lucide:alert-triangle",
19
+ text: "color-mix(in oklab, var(--warning) 55%, var(--foreground))",
20
+ },
21
+ success: {
22
+ color: "var(--success)",
23
+ icon: "lucide:check-circle-2",
24
+ text: "color-mix(in oklab, var(--success) 60%, var(--foreground))",
25
+ },
26
+ destructive: {
27
+ color: "var(--destructive)",
28
+ icon: "lucide:alert-octagon",
29
+ text: "color-mix(in oklab, var(--destructive) 65%, var(--foreground))",
30
+ },
31
+ };
32
+
33
+ /* Lucide glyph paths (viewBox 0 0 24 24), rendered by both the Solid and Astro <Alert>. */
34
+ export const ALERT_ICON_PATHS: Record<AlertTone, string> = {
35
+ info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
36
+ warning:
37
+ '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
38
+ success: '<path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/>',
39
+ destructive:
40
+ '<path d="M12 16h.01"/><path d="M12 8v4"/><path d="M15.312 2a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688A2 2 0 0 1 15.312 22H8.688a2 2 0 0 1-1.414-.586l-4.688-4.688A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2z"/>',
41
+ };
42
+
43
+ /* srgb, not oklch: oklch drags a tone mixed toward transparent down to mud. */
44
+ export const alertBorder = (c: string) => `color-mix(in srgb, ${c} 28%, transparent)`;
45
+ export const alertFill = (c: string) => `color-mix(in srgb, ${c} 9%, transparent)`;
46
+ export const alertIconFill = (c: string) => `color-mix(in srgb, ${c} 16%, transparent)`;
47
+
48
+ export const ALERT_CLASS =
49
+ "relative overflow-hidden flex items-start gap-3 rounded-lg border p-3 backdrop-blur-sm";
50
+ export const ALERT_ICON_CLASS =
51
+ "flex size-8 shrink-0 items-center justify-center rounded-md [&>svg]:size-[18px]";
52
+ export const ALERT_ICON_BG_CLASS =
53
+ "pointer-events-none absolute -right-3 -bottom-4 [&>svg]:size-28 [&>svg]:[stroke-width:2.5px]";
54
+ export function alertIconBgStyle(color: string): Record<string, string> {
55
+ return {
56
+ color,
57
+ opacity: "0.32",
58
+ "-webkit-mask-image": "radial-gradient(140% 140% at 100% 100%, #000 45%, transparent 95%)",
59
+ "mask-image": "radial-gradient(140% 140% at 100% 100%, #000 45%, transparent 95%)",
60
+ };
61
+ }
62
+ export const ALERT_CONTENT_CLASS = "relative z-10 min-w-0 flex-1";
63
+ export const ALERT_TITLE_CLASS = "font-semibold text-base leading-snug";
64
+ export const ALERT_DESCRIPTION_CLASS =
65
+ "text-foreground/80 text-sm leading-snug [&:not(:first-child)]:mt-0.5";
@@ -0,0 +1,4 @@
1
+ export const BADGE_DEFAULT_TONE = "var(--primary)";
2
+
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";
@@ -0,0 +1,44 @@
1
+ import { cva } from "cva";
2
+
3
+ /* Buttons are glass pills on an OPAQUE --card base: a translucent fill goes
4
+ * muddy over dark heroes/sections. Every class is literal for Tailwind's
5
+ * scanner. `size: none` is sizeless for callers that own height/padding. */
6
+ export const buttonVariants = cva({
7
+ 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",
8
+ variants: {
9
+ variant: {
10
+ default:
11
+ "glass glass-tint [--glass-text:70%] [--glass-tone:var(--primary)] [--glass-wash:30%] hover:[--glass-edge:color-mix(in_srgb,var(--primary)_60%,transparent)] hover:[--glass-wash:40%]",
12
+ destructive:
13
+ "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%]",
14
+ secondary:
15
+ "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%]",
16
+ outline:
17
+ "glass [--glass-edge:var(--border)] hover:[--glass-base:var(--muted)] dark:[--glass-base:var(--input)] dark:hover:[--glass-base:var(--muted)]",
18
+ ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50",
19
+ link: "text-primary underline-offset-4 hover:underline",
20
+ },
21
+ size: {
22
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
23
+ sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
24
+ lg: "h-10 px-6 has-[>svg]:px-4",
25
+ icon: "size-9 p-2",
26
+ none: "",
27
+ },
28
+ },
29
+ defaultVariants: {
30
+ variant: "default",
31
+ size: "default",
32
+ },
33
+ });
34
+
35
+ // Evaluated once for static/SSR call sites (marketing CTAs, .astro frontmatter)
36
+ // that want a class string without hydrating <Button>. Sizeless: callers add
37
+ // their own height and padding.
38
+ export const BUTTON_DEFAULT_CLASS = buttonVariants({ variant: "default", size: "none" });
39
+ export const BUTTON_OUTLINE_CLASS = buttonVariants({ variant: "outline", size: "none" });
40
+
41
+ /* Round ghost icon button (copy buttons, small icon actions). Size and text
42
+ * color stay at the call site. */
43
+ export const ICON_BUTTON_CLASS =
44
+ "inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full transition-all duration-150 hover:bg-primary/10 active:scale-90";
@@ -0,0 +1,37 @@
1
+ const CARD_KNOBS =
2
+ "glass [--glass-wash:20%] [--glass-rim:0.3] [--glass-lift:0.45] [--glass-shade:0.05] dark:[--glass-shade:0]";
3
+
4
+ export const CARD_SURFACE_BASE = `${CARD_KNOBS} [--glass-base:color-mix(in_srgb,var(--card)_60%,transparent)]`;
5
+
6
+ /* Split from the surface so dash's performant-blur engine can gate it off and
7
+ * paint a precomputed frost instead. */
8
+ export const CARD_BLUR = "backdrop-blur-[var(--glass-blur,24px)] backdrop-saturate-[1.8]";
9
+
10
+ export const CARD_SURFACE = `${CARD_SURFACE_BASE} ${CARD_BLUR}`;
11
+
12
+ /* Opaque fill, no blur: for busy/dark backgrounds and transform-animated
13
+ * contexts where backdrop-blur can't run. */
14
+ export const CARD_SURFACE_OPAQUE = CARD_KNOBS;
15
+
16
+ /* Recessed track frame (tabs, segmented controls). */
17
+ export const TRACK_SURFACE = "border border-border/50 bg-card/40 backdrop-blur-sm";
18
+
19
+ 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",
23
+ none: "",
24
+ } as const;
25
+
26
+ export type CardPadding = keyof typeof CARD_PADDING;
27
+
28
+ export const CARD_INTERACTIVE =
29
+ "cursor-pointer transition-colors duration-200 hover:border-border hover:bg-foreground/[0.03] focus-within:border-border";
30
+
31
+ 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";
33
+ export const CARD_TITLE_CLASS = "font-semibold leading-none";
34
+ export const CARD_DESCRIPTION_CLASS = "text-muted-foreground text-sm";
35
+ 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";
@@ -0,0 +1,35 @@
1
+ /* Shared by the Solid <Carousel> and astro/Carousel.astro so the two render the
2
+ * same markup. Astro renders slides server-side (SEO, LCP) and enhances them on
3
+ * the client with the same embla engine. */
4
+
5
+ export type CarouselTransition = "slide" | "fade" | "wipe";
6
+
7
+ export const CAROUSEL_VIEWPORT = "h-full overflow-hidden";
8
+
9
+ /** Stacked modes put every slide in one grid cell; slide lays out a flex track. */
10
+ export function carouselTrack(
11
+ transition: CarouselTransition,
12
+ orientation: "horizontal" | "vertical" = "horizontal",
13
+ ) {
14
+ return transition === "slide"
15
+ ? `flex ${orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col"}`
16
+ : "carousel-stack";
17
+ }
18
+
19
+ export function carouselItem(
20
+ transition: CarouselTransition,
21
+ orientation: "horizontal" | "vertical" = "horizontal",
22
+ ) {
23
+ // Stacked slides fill their grid cell; only a flex track needs basis/gutter.
24
+ if (transition === "wipe") return "min-w-0 carousel-wipe";
25
+ if (transition === "fade") return "min-w-0";
26
+ return `min-w-0 shrink-0 grow-0 basis-full ${orientation === "horizontal" ? "pl-4" : "pt-4"}`;
27
+ }
28
+
29
+ export const CAROUSEL_DOTS = "flex items-center justify-center gap-2";
30
+
31
+ export function carouselDot(active: boolean) {
32
+ return `h-1.5 rounded-full transition-all hover:bg-foreground/70 ${
33
+ active ? "w-6 bg-foreground/80" : "w-1.5 bg-foreground/40"
34
+ }`;
35
+ }
@@ -0,0 +1,87 @@
1
+ import EmblaCarousel from "embla-carousel";
2
+ import Autoplay from "embla-carousel-autoplay";
3
+ import Fade from "embla-carousel-fade";
4
+ import { type CarouselTransition, carouselDot } from "./carousel-classes.js";
5
+
6
+ /**
7
+ * Client enhancement for astro/Carousel.astro. The markup is already in the
8
+ * server HTML; this only adds behaviour, so the slides stay visible (and
9
+ * indexable) if the script never runs.
10
+ *
11
+ * Emits `carousel:select` with `{ index }` so a host page can sync its own
12
+ * chrome (labels, lightboxes) without owning the carousel.
13
+ */
14
+ export function initCarousels(root: ParentNode = document) {
15
+ const roots = Array.from(root.querySelectorAll("[data-carousel]")) as HTMLElement[];
16
+ for (const el of roots) {
17
+ if (el.dataset.carouselReady) continue;
18
+ el.dataset.carouselReady = "1";
19
+
20
+ const viewport = el.querySelector("[data-carousel-viewport]") as HTMLElement | null;
21
+ if (!viewport) continue;
22
+
23
+ const transition = (el.dataset.carouselTransition ?? "slide") as CarouselTransition;
24
+ const autoplayMs = Number(el.dataset.carouselAutoplay ?? 0);
25
+ const loop = el.dataset.carouselLoop === "";
26
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
27
+
28
+ const plugins = [];
29
+ if (transition !== "slide") plugins.push(Fade());
30
+ if (autoplayMs > 0 && !reduced) {
31
+ // Manual nav should not kill autoplay for good; hovering pauses it and
32
+ // leaving resumes, which is what a hero carousel wants.
33
+ plugins.push(
34
+ Autoplay({
35
+ delay: autoplayMs,
36
+ stopOnInteraction: false,
37
+ stopOnMouseEnter: true,
38
+ stopOnFocusIn: true,
39
+ }),
40
+ );
41
+ }
42
+
43
+ const embla = EmblaCarousel(viewport, { loop }, plugins);
44
+ const dots = Array.from(el.querySelectorAll("[data-carousel-dot]")) as HTMLButtonElement[];
45
+
46
+ let previous = -1;
47
+ const onSelect = () => {
48
+ const index = embla.selectedScrollSnap();
49
+ embla.slideNodes().forEach((node, i) => {
50
+ node.toggleAttribute("data-selected", i === index);
51
+ // The outgoing slide stays visible one layer down until the sweep lands.
52
+ node.toggleAttribute("data-prev", i === previous && i !== index);
53
+ });
54
+ previous = index;
55
+ dots.forEach((dot, i) => {
56
+ dot.className = carouselDot(i === index);
57
+ dot.setAttribute("aria-current", String(i === index));
58
+ });
59
+ el.dispatchEvent(new CustomEvent("carousel:select", { detail: { index }, bubbles: true }));
60
+ };
61
+
62
+ el.addEventListener("keydown", (e) => {
63
+ const key = (e as KeyboardEvent).key;
64
+ if (key !== "ArrowLeft" && key !== "ArrowRight") return;
65
+ e.preventDefault();
66
+ if (key === "ArrowLeft") embla.scrollPrev();
67
+ else embla.scrollNext();
68
+ });
69
+
70
+ // A host that covers the carousel (a lightbox) pauses it, so the slide
71
+ // does not change behind the overlay.
72
+ const autoplay = (embla.plugins() as { autoplay?: { play: () => void; stop: () => void } })
73
+ .autoplay;
74
+ el.addEventListener("carousel:pause", () => autoplay?.stop());
75
+ el.addEventListener("carousel:resume", () => autoplay?.play());
76
+
77
+ dots.forEach((dot, i) => {
78
+ dot.addEventListener("click", () => embla.scrollTo(i));
79
+ });
80
+ el.querySelector("[data-carousel-prev]")?.addEventListener("click", () => embla.scrollPrev());
81
+ el.querySelector("[data-carousel-next]")?.addEventListener("click", () => embla.scrollNext());
82
+
83
+ onSelect();
84
+ embla.on("select", onSelect);
85
+ embla.on("reInit", onSelect);
86
+ }
87
+ }
@@ -0,0 +1,23 @@
1
+ /* Per-tone label contrast: light tones (amber, lime) mix further toward
2
+ * --foreground so they read as strongly as dark ones. */
3
+ const TONE_L: Record<string, number> = {
4
+ "var(--primary)": 0.48,
5
+ "var(--accent)": 0.6,
6
+ "var(--success)": 0.68,
7
+ "var(--warning)": 0.76,
8
+ "var(--destructive)": 0.66,
9
+ "var(--muted-foreground)": 0.55,
10
+ };
11
+
12
+ export function toneTextMix(color: string): number {
13
+ const known = TONE_L[color];
14
+ const raw = color.match(/oklch\(\s*([0-9.]+)/)?.[1];
15
+ const l = known ?? (raw ? Number.parseFloat(raw) : 0.6);
16
+ return Math.min(70, Math.max(46, Math.round(71 - (l - 0.5) * 60)));
17
+ }
18
+
19
+ /* oklab, not oklch: no hue channel, so a hued tone desaturates toward neutral
20
+ * instead of swinging toward yellow. */
21
+ export function glassToneText(color: string): string {
22
+ return `color-mix(in oklab, ${color} ${toneTextMix(color)}%, var(--foreground))`;
23
+ }
@@ -0,0 +1,5 @@
1
+ /* Recessed (concave) glass field. Opaque --input fill: a translucent field
2
+ * shows whatever sits behind it (worst inside the overlapping Select listbox). */
3
+ export const INPUT_SURFACE = "glass glass-sink [--glass-base:var(--input)] [--glass-light:0.04]";
4
+
5
+ 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`;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The GlassHome lockup spec, pure — shared by the Solid <Logo>, the Astro
3
+ * <Logo>, and any `.astro` consumer that needs the class strings without
4
+ * hydrating a component.
5
+ *
6
+ * One ratio everywhere (audit L2): the sub-brand line is the dominant element,
7
+ * the wordmark sits above it as a smaller eyebrow. Sub-line spec (audit L1):
8
+ * text-primary, uppercase, bold, tracking-[0.18em] — never restyled per
9
+ * surface, never recolored, and the mark is never stretched or used as an
10
+ * avatar.
11
+ */
12
+
13
+ export type LogoSize = "sm" | "md" | "lg";
14
+
15
+ export const LOGO_DEFAULT_SRC = "/assets/glasshome_logo.png";
16
+ export const LOGO_DEFAULT_SUB = "Dash";
17
+ export const LOGO_DEFAULT_SIZE: LogoSize = "md";
18
+
19
+ /** Rendered pixel size of the mark per size, for width/height attrs (CLS). */
20
+ export const LOGO_MARK_PX: Record<LogoSize, number> = {
21
+ sm: 32,
22
+ md: 48,
23
+ lg: 64,
24
+ };
25
+
26
+ export const LOGO_SIZES: Record<
27
+ LogoSize,
28
+ { mark: string; name: string; sub: string; gap: string }
29
+ > = {
30
+ sm: { mark: "h-8 w-8", name: "text-xs", sub: "text-base", gap: "gap-1.5" },
31
+ md: { mark: "h-12 w-12", name: "text-base", sub: "text-2xl", gap: "gap-2" },
32
+ lg: { mark: "h-16 w-16", name: "text-lg", sub: "text-3xl", gap: "gap-2" },
33
+ };
34
+
35
+ /**
36
+ * The stack sits on the mark's baseline (items-end), so the sub-brand line
37
+ * bottoms out level with the mark rather than floating centred against it.
38
+ */
39
+ export const LOGO_ROOT_CLASS = "inline-flex items-end";
40
+ /** The two lines are pinned by an explicit gap, never by line-height slack. */
41
+ export const LOGO_STACK_CLASS = "flex flex-col gap-0.5";
42
+ /**
43
+ * Compose as cn(SIZE, BASE), not cn(BASE, SIZE): tailwind-merge treats a
44
+ * font-size class as carrying a line-height, so a trailing `text-2xl` would
45
+ * strip the `leading-none` these rely on.
46
+ */
47
+ export const LOGO_MARK_CLASS = "shrink-0";
48
+ export const LOGO_NAME_CLASS = "whitespace-nowrap font-bold leading-none tracking-tight";
49
+ export const LOGO_SUB_CLASS = "font-bold text-primary uppercase leading-none tracking-[0.18em]";
@@ -0,0 +1,7 @@
1
+ /* Floating-panel glass: opaque popover fill so menus stay legible over anything. */
2
+ export const OVERLAY_SURFACE =
3
+ "glass [--glass-base:var(--popover)] [--glass-rim:0.3] [--glass-lift:0.55]";
4
+
5
+ /* Modal scrim behind dialogs/sheets. BottomSheet keeps its own unblurred scrim
6
+ * (backdrop-blur is too slow on mobile). */
7
+ export const SCRIM_CLASS = "bg-background/70 backdrop-blur-sm";
@@ -0,0 +1,8 @@
1
+ /* Concentric radius: inner = outer - padding, clamped for tiny --radius themes. */
2
+ export const SECTION_OUTER_RADIUS = "rounded-[var(--radius)]";
3
+ export const SECTION_INNER_RADIUS = "rounded-[max(0px,calc(var(--radius)-0.75rem))]";
4
+ export const SECTION_PADDING = "p-3";
5
+
6
+ const SECTION_ROW_SURFACE = `${SECTION_INNER_RADIUS} border border-border/60 bg-card/60`;
7
+
8
+ export const SECTION_ROW_CLASS = `${SECTION_ROW_SURFACE} ${SECTION_PADDING}`;
@@ -0,0 +1,31 @@
1
+ /* Metallic tier chip: 135deg brushed sweep, bright ends, dip in the middle. */
2
+
3
+ export const TIER_BADGE_CLASS =
4
+ "inline-flex cursor-default select-none items-center rounded-full px-2 py-0.5 font-black text-[10px] uppercase tracking-wider";
5
+
6
+ /** Optional sweep shaping. Default is the symmetric brushed look (both ends
7
+ * `hi`, dipping to `lo` at the midpoint). The collectible/license golds are a
8
+ * lit bevel instead: they dip early and land on a darker `end`. */
9
+ export interface TierSweep {
10
+ /** Far end of the sweep. Default: `hi` (symmetric). */
11
+ end?: string;
12
+ /** Where `lo` sits, in percent. Default: 50. */
13
+ stop?: number;
14
+ }
15
+
16
+ /** Inline style for the metallic pill body. Keys are CSS property names (works
17
+ * as a Solid `style` object and, joined, as an Astro `style` string). */
18
+ export function tierBadgeStyle(
19
+ hi: string,
20
+ lo: string,
21
+ text: string,
22
+ sweep: TierSweep = {},
23
+ ): Record<string, string> {
24
+ const end = sweep.end ?? hi;
25
+ const stop = sweep.stop ?? 50;
26
+ return {
27
+ background: `linear-gradient(135deg, ${hi}, ${lo} ${stop}%, ${end})`,
28
+ color: text,
29
+ "box-shadow": "inset 0 1px 0 oklch(1 0 0 / 0.45), 0 1px 2px oklch(0 0 0 / 0.25)",
30
+ };
31
+ }
@@ -0,0 +1,17 @@
1
+ import { createSignal, onCleanup, onMount } from "solid-js";
2
+
3
+ const MOBILE_BREAKPOINT = 768;
4
+
5
+ export function createIsMobile() {
6
+ const [isMobile, setIsMobile] = createSignal<boolean | undefined>(undefined);
7
+
8
+ onMount(() => {
9
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
10
+ const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
11
+ mql.addEventListener("change", onChange);
12
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
13
+ onCleanup(() => mql.removeEventListener("change", onChange));
14
+ });
15
+
16
+ return isMobile;
17
+ }
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -101,6 +101,14 @@
101
101
  * mixed toward transparent-black down to mud on light surfaces. Deliberately
102
102
  * unlayered so the material owns border/background/box-shadow; tune via knobs. */
103
103
  :where(.glass) {
104
+ /* Descendant-readable mirror of this surface's tone. The --glass-* knobs are
105
+ * @property inherits:false so a nested glass element never picks up an
106
+ * ancestor's tint — correct for surfaces, but it also means a plain child
107
+ * (an icon, a label) reading var(--glass-tone) silently gets `transparent`
108
+ * and paints nothing. --surface-tone is an ordinary inheriting property, so
109
+ * children can read it; a nested .glass re-declares it from its own knob,
110
+ * which resets the chain and keeps the no-leak guarantee. */
111
+ --surface-tone: var(--glass-tone);
104
112
  border: 1px solid var(--glass-edge);
105
113
  /* Base is a gradient layer (not background-color) so the frost slice can sit
106
114
  * BELOW it — background-color always paints under every image layer, which
@@ -162,3 +170,54 @@
162
170
  background: var(--muted-foreground);
163
171
  }
164
172
  }
173
+
174
+ /* Stacked carousel modes (fade, wipe) put every slide in one grid cell. This is
175
+ * structural, so it ships as real CSS: a Tailwind arbitrary variant written in
176
+ * package source never reaches a consumer, because Tailwind skips node_modules
177
+ * when scanning for class names. */
178
+ .carousel-stack {
179
+ display: grid;
180
+ }
181
+ .carousel-stack > * {
182
+ grid-column: 1;
183
+ grid-row: 1;
184
+ }
185
+
186
+ /* Carousel `wipe`: the incoming slide sweeps in diagonally over the previous
187
+ * one, which stays fully visible beneath until the sweep lands. Only the
188
+ * incoming slide animates: giving the outgoing one a matching transition makes
189
+ * it collapse at the same time, which reads as two wipes from opposite corners.
190
+ * Slides are opaque here; embla's fade plugin only stops the track translating,
191
+ * and its opacity would wash out the sweep. */
192
+ .carousel-wipe {
193
+ /* Content choreography, not a state change: the motion scale's rungs are for
194
+ * micro-interactions and are far too quick to read as a sweep. Override per
195
+ * instance by setting --carousel-wipe on the carousel. */
196
+ --carousel-wipe: 1600ms;
197
+ clip-path: polygon(0 0, 300% 0, 0 300%, 0 0);
198
+ /* The fade plugin fades AND parks inactive slides off-screen
199
+ * (transform: translate(containerWidth + 2)), on embla's own short scroll
200
+ * duration. Both have to be neutralised or the sweep reveals empty space
201
+ * once the outgoing slide leaves: in wipe mode clip-path is the only reveal,
202
+ * and z-index alone decides what shows through. Inline styles, hence
203
+ * !important. */
204
+ opacity: 1 !important;
205
+ transform: none !important;
206
+ }
207
+ .carousel-wipe[data-prev] {
208
+ z-index: 1;
209
+ }
210
+ .carousel-wipe[data-selected] {
211
+ z-index: 2;
212
+ animation: carousel-wipe-in var(--carousel-wipe) ease-in-out;
213
+ }
214
+ @keyframes carousel-wipe-in {
215
+ from {
216
+ clip-path: polygon(0 0, 0 0, 0 0, 0 0);
217
+ }
218
+ }
219
+ @media (prefers-reduced-motion: reduce) {
220
+ .carousel-wipe[data-selected] {
221
+ animation: none;
222
+ }
223
+ }
@@ -160,6 +160,14 @@
160
160
  --shadow-2xl: 0px 2px 3px 0px hsl(0 0% 0% / 0.4);
161
161
  --tracking-normal: 0em;
162
162
  --spacing: 0.27rem;
163
+
164
+ /* Motion. Durations name the gesture, not the number: micro is a hover or
165
+ * border change, state a tone or chip swap, expand an open/close. Theme
166
+ * independent, so declared once; zeroed under prefers-reduced-motion. */
167
+ --ease-emphasis: cubic-bezier(0.22, 1, 0.36, 1);
168
+ --duration-micro: 120ms;
169
+ --duration-state: 160ms;
170
+ --duration-expand: 200ms;
163
171
  }
164
172
 
165
173
  .dark {
@@ -418,6 +426,13 @@
418
426
  ::view-transition-new(area-title) {
419
427
  animation: none !important;
420
428
  }
429
+
430
+ /* Anything timed off the motion scale stops moving, no per-component opt-in. */
431
+ :root {
432
+ --duration-micro: 0ms;
433
+ --duration-state: 0ms;
434
+ --duration-expand: 0ms;
435
+ }
421
436
  }
422
437
 
423
438
  /* Performance: disable all backdrop-filter when reduce-blur is active */