feat: migrate frontend to Astro SSG with design system

- Replace monolithic React SPA (App.tsx) with 7 Astro pages (+ routing)
- Implement full design system from design/ (OKLCH tokens, Tailwind 4 @theme)
- Add 12 Astro components (Button, Badge, Card, Sidebar, Header, states)
- Add 11 React islands (AuthGuard, MessageFeed, VoiceControls, AudioVisualizer, etc.)
- Add 3 Zustand stores (UI, voice, message state)
- Add CSS architecture: dark/light themes, glassmorphism, fluid typography, animations
- Add 404, login, settings, recordings pages
- Remove 40+ legacy React SPA files
- Add Particles background and MascotChat deferred islands
- Wire WebSocket bridge for real-time messages and voice events

Build: 7 pages in ~900ms
Typecheck: clean (0 errors)
Lint: clean (0 errors)
This commit is contained in:
asepharyana
2026-07-02 01:18:45 +07:00
parent 5e40b1ad8c
commit 8ad888da28
110 changed files with 3396 additions and 4980 deletions
@@ -0,0 +1,34 @@
---
// ─── Header.astro — App header bar ──────────────────────────────────────────
// Features: sticky top, backdrop blur, border-bottom, actions slot
// ──────────────────────────────────────────────────────────────────────────────
export interface Props {
title: string;
}
const { title } = Astro.props;
---
<header
class="header sticky top-0 z-[var(--z-header)] flex items-center justify-between border-b border-border px-6 py-3"
>
<!-- Left: title -->
<div class="flex items-center gap-3">
<h1 class="text-lg font-semibold text-foreground">{title}</h1>
</div>
<!-- Right: actions slot -->
<div class="flex items-center gap-2">
<slot name="actions" />
</div>
</header>
<style>
.header {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
background-color: oklch(var(--glass-bg));
min-height: 3.5rem;
}
</style>
@@ -0,0 +1,60 @@
---
// ─── NavItem.astro — Sidebar navigation item ────────────────────────────────
// Handles: active state highlighting, collapsed icon-only mode,
// notification badge / dot indicators.
// ────────────────────────────────────────────────────────────────────────────────
export interface Props {
href: string;
icon: string;
label: string;
active?: boolean;
collapsed?: boolean;
notificationCount?: number;
}
const {
href,
icon,
label,
active = false,
collapsed = false,
notificationCount = 0,
} = Astro.props;
const navClass = active
? "bg-[var(--clr-interactive-selected,oklch(var(--primary-soft)))] text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground";
---
<a
href={href}
class:list={[
"nav-item group relative flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium no-underline transition-all duration-150",
collapsed ? "justify-center px-2.5" : "",
navClass,
]}
aria-current={active ? "page" : undefined}
aria-label={collapsed ? label : undefined}
>
<span class="size-5 shrink-0" aria-hidden="true" set:html={icon} />
{!collapsed && <span class="truncate">{label}</span>}
{!collapsed && notificationCount > 0 && (
<span
class="ml-auto inline-flex min-w-5 items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none text-white"
style="background-color: var(--clr-severity-high, oklch(var(--destructive)))"
>
{notificationCount > 99 ? "99+" : notificationCount}
</span>
)}
{collapsed && notificationCount > 0 && (
<span
class="absolute right-1.5 top-1.5 size-2 rounded-full"
style="background-color: var(--clr-severity-high, oklch(var(--destructive)))"
aria-label={`${notificationCount} notifications`}
/>
)}
</a>
@@ -0,0 +1,93 @@
---
// ─── Sidebar.astro — Main app sidebar navigation ──────────────────────────
// Features: collapsed / expanded (64px / 256px), logo, nav items, version
// ──────────────────────────────────────────────────────────────────────────────
import NavItem from "./NavItem.astro";
export interface Props {
collapsed?: boolean;
version?: string;
currentPath?: string;
}
const { collapsed = false, version = "1.0.0", currentPath = "/live" } =
Astro.props;
const ICONS = {
live: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>`,
messages: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
recordings: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>`,
settings: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
} as const;
---
<aside
class="sidebar fixed left-0 top-0 z-30 flex h-screen flex-col border-r border-border bg-background transition-all duration-300 ease-out"
class:list={[collapsed ? "w-16" : "w-64"]}
aria-label="Main navigation"
>
<!-- ── Header / Logo ──────────────────────────────────────────── -->
<div class="flex items-center border-b border-border px-4 py-4">
<a
href="/"
class="flex items-center gap-3 no-underline"
aria-label="Dashboard home"
>
<span
class="flex size-8 items-center justify-center rounded-lg bg-primary text-sm font-bold text-primary-foreground"
>
B
</span>
{!collapsed && (
<span class="text-lg font-extrabold gradient-text">BETE</span>
)}
</a>
</div>
<!-- ── Navigation ─────────────────────────────────────────────── -->
<nav class="flex flex-1 flex-col gap-1 overflow-y-auto px-3 py-4">
<NavItem
href="/live"
icon={ICONS.live}
label="Live"
active={currentPath.startsWith("/live")}
collapsed={collapsed}
/>
<NavItem
href="/messages"
icon={ICONS.messages}
label="Messages"
active={currentPath.startsWith("/messages")}
collapsed={collapsed}
notificationCount={12}
/>
<NavItem
href="/recordings"
icon={ICONS.recordings}
label="Recordings"
active={currentPath.startsWith("/recordings")}
collapsed={collapsed}
/>
<NavItem
href="/settings"
icon={ICONS.settings}
label="Settings"
active={currentPath.startsWith("/settings")}
collapsed={collapsed}
/>
</nav>
<!-- ── Footer / Version ───────────────────────────────────────── -->
<footer class="border-t border-border px-4 py-3">
{!collapsed && (
<span class="text-xs text-muted-foreground">v{version}</span>
)}
</footer>
</aside>
<style>
.sidebar {
will-change: width;
}
</style>
@@ -0,0 +1,35 @@
---
// ─── EmptyState.astro — Empty / no-data state ───────────────────────────────
// Features: centered layout, icon, title, description, actions slot
// ──────────────────────────────────────────────────────────────────────────────
export interface Props {
icon: string;
title: string;
description?: string;
}
const { icon, title, description } = Astro.props;
---
<div
class="flex min-h-64 flex-col items-center justify-center gap-4 px-6 py-12 text-center"
>
<!-- Icon -->
<div class="flex size-16 items-center justify-center" aria-hidden="true" set:html={icon} />
<!-- Title -->
<h2 class="text-xl font-semibold text-foreground">{title}</h2>
<!-- Description -->
{description && (
<p class="max-w-sm text-sm text-muted-foreground">{description}</p>
)}
<!-- Actions slot (buttons, links, etc.) -->
{Astro.slots.has("actions") && (
<div class="mt-2">
<slot name="actions" />
</div>
)}
</div>
@@ -0,0 +1,51 @@
---
// ─── ErrorState.astro — Error / failure state ───────────────────────────────
// Features: role="alert", severity-critical bg tint, error icon, retry slot
// ──────────────────────────────────────────────────────────────────────────────
export interface Props {
message: string;
}
const { message } = Astro.props;
---
<div
role="alert"
class="flex min-h-48 flex-col items-center justify-center gap-4 rounded-xl px-6 py-10 text-center"
style="background-color: oklch(var(--destructive) / 0.08)"
>
<!-- Error icon circle -->
<div
class="flex size-12 items-center justify-center rounded-full"
style="background-color: oklch(var(--destructive) / 0.15)"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
style="color: oklch(var(--destructive))"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<!-- Error message -->
<p class="max-w-sm text-sm font-medium text-foreground">{message}</p>
<!-- Retry slot -->
{Astro.slots.has("retry") && (
<div class="mt-2">
<slot name="retry" />
</div>
)}
</div>
@@ -0,0 +1,60 @@
---
// ─── LoadingSkeleton.astro — Placeholder loading states ─────────────────────
// Uses the shared Skeleton React component in 3 layout variants.
// ──────────────────────────────────────────────────────────────────────────────
import { Skeleton } from "../../shared/components/skeleton";
export type Variant = "card" | "list" | "detail";
export interface Props {
/** Visual layout variant */
variant?: Variant;
/** Number of skeleton items to render (default 1) */
count?: number;
}
const { variant = "card", count = 1 } = Astro.props;
const items = Array.from({ length: count });
---
<div role="presentation" aria-hidden="true" class="flex flex-col gap-4">
{
items.map((_, i) => (
<div class="skeleton-group" data-variant={variant}>
{variant === "card" && (
<div class="overflow-hidden rounded-xl border border-border">
<Skeleton class="h-40 w-full rounded-none" />
<div class="space-y-3 p-4">
<Skeleton class="h-4 w-3/4" />
<Skeleton class="h-3 w-1/2" />
</div>
</div>
)}
{variant === "list" && (
<div class="flex items-center gap-3 rounded-lg p-3">
<Skeleton class="size-10 shrink-0 rounded-full" />
<div class="flex-1 space-y-2">
<Skeleton class="h-3.5 w-1/3" />
<Skeleton class="h-3 w-2/3" />
</div>
</div>
)}
{variant === "detail" && (
<div class="space-y-4 rounded-xl p-4">
<Skeleton class="h-6 w-1/2" />
<div class="space-y-2">
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-3/4" />
</div>
<Skeleton class="h-10 w-32" />
</div>
)}
</div>
))
}
</div>
@@ -0,0 +1,106 @@
---
export interface Props {
variant?: "default" | "secondary" | "success" | "warning" | "destructive" | "outline";
size?: "sm" | "default";
dot?: boolean;
class?: string;
}
const {
variant = "default",
size = "default",
dot = false,
class: className = "",
} = Astro.props;
---
<span class:list={[`badge badge--${variant} badge--${size}`, className]}>
{dot && <span class="badge__dot" aria-hidden="true" />}
<slot />
</span>
<style is:global>
.badge {
--fs-xs: 0.75rem;
--fs-xxs: 0.625rem;
display: inline-flex;
align-items: center;
gap: 0.375rem;
border-radius: 999px;
border: 1px solid transparent;
font-weight: var(--fw-medium, 500);
transition:
background-color var(--duration-fast, 150ms) var(--ease-out, ease-out),
color var(--duration-fast, 150ms) var(--ease-out, ease-out);
white-space: nowrap;
user-select: none;
}
.badge--default {
padding-inline: 0.625rem;
padding-block: 0.125rem;
font-size: var(--fs-xs);
background-color: oklch(var(--primary));
color: oklch(var(--primary-foreground));
}
.badge--secondary {
padding-inline: 0.625rem;
padding-block: 0.125rem;
font-size: var(--fs-xs);
background-color: oklch(var(--muted));
color: oklch(var(--muted-foreground));
}
.badge--success {
padding-inline: 0.625rem;
padding-block: 0.125rem;
font-size: var(--fs-xs);
background-color: oklch(var(--success) / 0.15);
color: oklch(var(--success));
}
.badge--warning {
padding-inline: 0.625rem;
padding-block: 0.125rem;
font-size: var(--fs-xs);
background-color: oklch(var(--warning) / 0.15);
color: oklch(var(--warning));
}
.badge--destructive {
padding-inline: 0.625rem;
padding-block: 0.125rem;
font-size: var(--fs-xs);
background-color: oklch(var(--destructive) / 0.15);
color: oklch(var(--destructive));
}
.badge--outline {
padding-inline: 0.625rem;
padding-block: 0.125rem;
font-size: var(--fs-xs);
border-color: oklch(var(--border));
background-color: transparent;
color: oklch(var(--foreground));
}
/* ── Size sm ───────────────────────────────────── */
.badge--sm {
padding-inline: 0.375rem;
padding-block: 0.0625rem;
font-size: var(--fs-xxs);
}
/* ── Dot indicator ─────────────────────────────── */
.badge__dot {
display: inline-block;
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
background-color: currentColor;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,146 @@
---
export interface Props {
variant?: "primary" | "secondary" | "destructive" | "outline" | "ghost";
size?: "sm" | "default" | "lg" | "icon";
disabled?: boolean;
href?: string;
class?: string;
}
const {
variant = "primary",
size = "default",
disabled = false,
href,
class: className = "",
} = Astro.props;
const Tag = href ? "a" : "button";
const attrs: Record<string, string | number | boolean> = {
class: `btn btn--${variant} btn--${size} ${className}`,
};
if (Tag === "button") {
attrs.disabled = disabled;
} else if (disabled) {
attrs["aria-disabled"] = true;
attrs.role = "link";
}
---
<Tag {...attrs}>
<slot />
</Tag>
<style is:global>
.btn {
--rd-md: calc(var(--radius, 1rem) - 2px);
--fs-sm: 0.875rem;
--fw-medium: 500;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
white-space: nowrap;
border-radius: var(--rd-md);
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
line-height: 1.25rem;
transition:
background-color var(--duration-fast, 150ms) var(--ease-out, ease-out),
color var(--duration-fast, 150ms) var(--ease-out, ease-out),
border-color var(--duration-fast, 150ms) var(--ease-out, ease-out),
box-shadow var(--duration-fast, 150ms) var(--ease-out, ease-out),
transform var(--duration-fast, 150ms) var(--ease-out, ease-out);
cursor: pointer;
border: 1px solid transparent;
text-decoration: none;
outline: none;
user-select: none;
}
.btn:focus-visible {
outline: 2px solid oklch(var(--ring));
outline-offset: 2px;
}
.btn:active {
transform: scale(0.97);
}
.btn:disabled,
.btn[aria-disabled="true"] {
pointer-events: none;
opacity: 0.5;
}
/* ── Variants ──────────────────────────────────────────── */
.btn--primary {
background-color: oklch(var(--primary));
color: oklch(var(--primary-foreground));
box-shadow: 0 1px 2px oklch(0 0 0 / 0.05);
}
.btn--primary:hover {
background-color: oklch(var(--primary) / 0.9);
}
.btn--secondary {
background-color: oklch(var(--secondary));
color: oklch(var(--secondary-foreground));
}
.btn--secondary:hover {
background-color: oklch(var(--secondary) / 0.8);
}
.btn--destructive {
background-color: oklch(var(--destructive));
color: oklch(var(--destructive-foreground));
box-shadow: 0 1px 2px oklch(0 0 0 / 0.05);
}
.btn--destructive:hover {
background-color: oklch(var(--destructive) / 0.9);
}
.btn--outline {
border-color: oklch(var(--border));
background-color: oklch(var(--background));
color: oklch(var(--foreground));
}
.btn--outline:hover {
background-color: oklch(var(--accent));
color: oklch(var(--accent-foreground));
}
.btn--ghost {
background-color: transparent;
color: oklch(var(--foreground));
}
.btn--ghost:hover {
background-color: oklch(var(--accent));
color: oklch(var(--accent-foreground));
}
/* ── Sizes ─────────────────────────────────────────────── */
.btn--sm {
height: 2.25rem;
padding-inline: 0.75rem;
}
.btn--default {
height: 2.5rem;
padding-inline: 1rem;
}
.btn--lg {
height: 2.75rem;
padding-inline: 2rem;
}
.btn--icon {
height: 2.5rem;
width: 2.5rem;
padding: 0;
}
</style>
@@ -0,0 +1,101 @@
---
export interface Props {
variant?: "default" | "elevated" | "glass" | "interactive";
padding?: "none" | "sm" | "md" | "lg";
class?: string;
}
const {
variant = "default",
padding = "md",
class: className = "",
} = Astro.props;
---
<div class:list={[`card card--${variant} card--pad-${padding}`, className]}>
<slot />
</div>
<style is:global>
.card {
--rd-lg: var(--radius, 1rem);
border-radius: var(--rd-lg);
transition:
background-color var(--duration-normal, 300ms) var(--ease-out, ease-out),
color var(--duration-normal, 300ms) var(--ease-out, ease-out),
border-color var(--duration-normal, 300ms) var(--ease-out, ease-out),
box-shadow var(--duration-normal, 300ms) var(--ease-out, ease-out),
transform var(--duration-normal, 300ms) var(--ease-out, ease-out);
}
/* ── Padding variants ──────────────────────────── */
.card--pad-none {
padding: 0;
}
.card--pad-sm {
padding: 0.75rem;
}
.card--pad-md {
padding: 1.5rem;
}
.card--pad-lg {
padding: 2rem;
}
/* ── Variant: default ──────────────────────────── */
.card--default {
background-color: oklch(var(--card));
color: oklch(var(--card-foreground));
border: 1px solid oklch(var(--border));
box-shadow: 0 1px 2px oklch(0 0 0 / 0.05);
}
/* ── Variant: elevated ─────────────────────────── */
.card--elevated {
background-color: oklch(var(--card));
color: oklch(var(--card-foreground));
border: 1px solid oklch(var(--border));
box-shadow:
0 4px 6px -1px oklch(0 0 0 / 0.1),
0 2px 4px -2px oklch(0 0 0 / 0.1);
}
/* ── Variant: glass ────────────────────────────── */
.card--glass {
background-color: oklch(var(--glass-bg));
border: 1px solid oklch(var(--glass-border));
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 24px oklch(var(--glass-shadow));
}
/* ── Variant: interactive ──────────────────────── */
.card--interactive {
background-color: oklch(var(--card));
color: oklch(var(--card-foreground));
border: 1px solid oklch(var(--border));
box-shadow: 0 1px 2px oklch(0 0 0 / 0.05);
cursor: pointer;
}
.card--interactive:hover {
transform: translateY(-4px);
box-shadow:
0 10px 15px -3px oklch(0 0 0 / 0.1),
0 4px 6px -4px oklch(0 0 0 / 0.1);
border-color: oklch(var(--primary) / 0.3);
}
.card--interactive:focus-visible {
outline: 2px solid oklch(var(--ring));
outline-offset: 2px;
}
</style>
@@ -0,0 +1,97 @@
---
export interface Props {
severity: "safe" | "low" | "medium" | "high" | "critical";
class?: string;
}
const { severity, class: className = "" } = Astro.props;
const ICONS: Record<string, string> = {
safe: "&#x2714;",
low: "&#x2197;",
medium: "&#x26A0;",
high: "&#x26A1;",
critical: "&#x2716;",
};
const icon = ICONS[severity] ?? "";
---
<span
class:list={[`severity-badge severity-badge--${severity}`, className]}
data-severity={severity}
role="status"
>
<span class="severity-badge__icon" aria-hidden="true" set:html={icon} />
<span class="severity-badge__label"><slot /></span>
</span>
<style is:global>
.severity-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding-inline: 0.5rem;
padding-block: 0.2rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
border: 1px solid transparent;
line-height: 1;
user-select: none;
}
.severity-badge__icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
line-height: 1;
flex-shrink: 0;
}
.severity-badge__label {
text-transform: capitalize;
}
/* ── Severity: safe → success ──────────────────── */
.severity-badge--safe {
background-color: oklch(var(--success) / 0.15);
color: oklch(var(--success));
border-color: oklch(var(--success) / 0.25);
}
/* ── Severity: low → info ──────────────────────── */
.severity-badge--low {
background-color: oklch(var(--info) / 0.15);
color: oklch(var(--info));
border-color: oklch(var(--info) / 0.25);
}
/* ── Severity: medium → warning ────────────────── */
.severity-badge--medium {
background-color: oklch(var(--warning) / 0.15);
color: oklch(var(--warning));
border-color: oklch(var(--warning) / 0.25);
}
/* ── Severity: high → destructive ──────────────── */
.severity-badge--high {
background-color: oklch(var(--destructive) / 0.12);
color: oklch(var(--destructive));
border-color: oklch(var(--destructive) / 0.2);
}
/* ── Severity: critical ────────────────────────── */
.severity-badge--critical {
background-color: oklch(var(--destructive) / 0.25);
color: oklch(var(--destructive));
border-color: oklch(var(--destructive) / 0.4);
font-weight: 600;
}
</style>
@@ -0,0 +1,74 @@
---
export interface Props {
variant?: "text" | "card" | "circle" | "rect";
width?: string;
height?: string;
class?: string;
}
const {
variant = "text",
width,
height,
class: className = "",
} = Astro.props;
const style: Record<string, string> = {};
if (width) style.width = width;
if (height) style.height = height;
---
<div
aria-hidden="true"
role="presentation"
class:list={[`skeleton skeleton--${variant}`, className]}
style={Object.keys(style).length > 0 ? style : undefined}
>
<slot />
</div>
<style is:global>
.skeleton {
background: linear-gradient(
90deg,
oklch(var(--muted)) 25%,
oklch(var(--muted-foreground) / 0.15) 50%,
oklch(var(--muted)) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: 0.5rem;
}
/* ── Variant: text (default) ───────────────────── */
.skeleton--text {
width: 100%;
height: 1rem;
border-radius: calc(var(--radius, 1rem) - 4px);
}
/* ── Variant: card ─────────────────────────────── */
.skeleton--card {
width: 100%;
height: 8rem;
border-radius: var(--radius, 1rem);
}
/* ── Variant: circle ───────────────────────────── */
.skeleton--circle {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
}
/* ── Variant: rect ─────────────────────────────── */
.skeleton--rect {
width: 100%;
height: 3rem;
border-radius: calc(var(--radius, 1rem) - 2px);
}
</style>
@@ -0,0 +1,60 @@
---
export interface Props {
size?: "sm" | "default" | "lg";
class?: string;
}
const { size = "default", class: className = "" } = Astro.props;
---
<span
aria-label="Loading"
role="status"
class:list={[`spinner spinner--${size}`, className]}
>
<span class="spinner__ring" aria-hidden="true" />
</span>
<style is:global>
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spinner {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.spinner__ring {
display: block;
border-radius: 50%;
border-style: solid;
border-color: oklch(var(--muted));
border-top-color: oklch(var(--primary));
animation: spin 0.6s linear infinite;
}
/* ── Sizes ─────────────────────────────────────── */
.spinner--sm .spinner__ring {
width: 1rem;
height: 1rem;
border-width: 2px;
}
.spinner--default .spinner__ring {
width: 1.5rem;
height: 1.5rem;
border-width: 2.5px;
}
.spinner--lg .spinner__ring {
width: 2.5rem;
height: 2.5rem;
border-width: 3px;
}
</style>