Merge pull request #10 from offendingcommit/codex/page-loading-placeholders

[codex] Show structured page placeholders while loading
This commit is contained in:
Offending Commit
2026-05-12 11:06:22 -05:00
committed by GitHub
13 changed files with 443 additions and 102 deletions

View File

@@ -17,30 +17,9 @@ jobs:
- run: make ci-web
cargo-check:
name: Rust compile check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: packages/desktop/src-tauri -> target
- uses: ./.github/actions/setup
- run: make ci-desktop
release:
name: Release
needs: [check, cargo-check]
needs: [check]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:

16
AGENTS.md Normal file
View File

@@ -0,0 +1,16 @@
# openconcho Agent Notes
## CI policy
- PR CI only runs the web checks.
- Rust/Tauri compile-check is local-only for now because the Linux dependency setup on GitHub Actions is too slow for routine PR validation.
## Required local preflight
- Before pushing any change under `packages/desktop/**` or `packages/desktop/src-tauri/**`, run:
- `pnpm --filter @openconcho/desktop cargo-check`
## Useful commands
- `make ci-web` — matches current PR CI
- `pnpm --filter @openconcho/desktop cargo-check` — local desktop compile check

View File

@@ -17,6 +17,7 @@ Frontend UI for self-hosted Honcho instances — browse memories, peers, session
| `make test` | Vitest (unit + integration), excludes `e2e/` |
| `make test-e2e` | Playwright e2e (uncached) |
| `make check` | lint + typecheck + test |
| `pnpm --filter @openconcho/desktop cargo-check` | Local Rust/Tauri compile check before pushing desktop changes |
| `pnpm --filter @openconcho/web generate:api` | Regen `src/api/schema.d.ts` from `openapi.json` |
## Structure
@@ -58,3 +59,4 @@ Read `docs/architecture.md` for component overview, data flow, and design decisi
- **Conventional commits enforced** — commitlint runs in husky `commit-msg`; body lines must be ≤100 chars
- **Releases via semantic-release** — `.releaserc.json`; commits land on `main`, no manual version bumps
- **GitHub account** — push under `offendingcommit` (`gh auth switch` if needed)
- **Desktop preflight is local** — Rust/Tauri compile-check no longer runs in PR CI; run `pnpm --filter @openconcho/desktop cargo-check` before pushing any `packages/desktop/**` or `packages/desktop/src-tauri/**` change

View File

@@ -14,8 +14,8 @@ import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import { EmptyState } from "@/components/shared/EmptyState";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { FormModal } from "@/components/shared/FormModal";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Pagination } from "@/components/shared/Pagination";
import { Skeleton } from "@/components/shared/Skeleton";
import { SortControl, type SortDir } from "@/components/shared/SortControl";
import { TimestampChip } from "@/components/shared/TimestampChip";
import { Button } from "@/components/ui/button";
@@ -193,7 +193,7 @@ export function ConclusionBrowser() {
</form>
<ErrorAlert error={error instanceof Error ? error : null} />
{(isLoading || (activeSearch && searchLoading)) && <PageLoader />}
{(isLoading || (activeSearch && searchLoading)) && <ConclusionsSkeleton />}
{!isLoading && !searchLoading && displayedConclusions.length === 0 && (
<EmptyState
@@ -315,6 +315,32 @@ export function ConclusionBrowser() {
);
}
function ConclusionsSkeleton() {
return (
<div className="space-y-3" aria-hidden="true">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="rounded-xl p-5"
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
>
<Skeleton className="h-3 w-full rounded" />
<Skeleton className="mt-2 h-3 w-[94%] rounded" />
<Skeleton className="mt-2 h-3 w-[76%] rounded" />
<div
className="flex items-center gap-3 mt-4 pt-3"
style={{ borderTop: "1px solid var(--border)" }}
>
<Skeleton className="h-3 w-20 rounded" />
<Skeleton className="h-3 w-16 rounded" />
<Skeleton className="ml-auto h-6 w-28 rounded-full" />
</div>
</div>
))}
</div>
);
}
function CreateConclusionModal({
open,
onClose,

View File

@@ -5,7 +5,7 @@ import { useState } from "react";
import { useQueueStatus, useWorkspaces } from "@/api/queries";
import type { components } from "@/api/schema.d.ts";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Skeleton } from "@/components/shared/Skeleton";
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
import { useDemo } from "@/hooks/useDemo";
import { COLOR } from "@/lib/constants";
@@ -182,7 +182,7 @@ export function Dashboard() {
</motion.div>
<ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <PageLoader />}
{isLoading && <DashboardSkeleton />}
{!isLoading && workspaces.length > 0 && (
<div className="space-y-4">
@@ -265,3 +265,64 @@ export function Dashboard() {
</div>
);
}
function DashboardSkeleton() {
return (
<div className="space-y-4" aria-hidden="true">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="rounded-xl p-4 theme-card">
<Skeleton accent={index === 0} className="h-8 w-16 rounded-lg" />
<Skeleton className="mt-3 h-3 w-20 rounded" />
</div>
))}
</div>
<div className="rounded-xl theme-card overflow-hidden">
<div
className="flex items-center gap-2 px-4 py-3"
style={{ borderBottom: "1px solid var(--border)" }}
>
<Skeleton accent className="h-4 w-4 rounded" />
<Skeleton className="h-4 w-28 rounded" />
<Skeleton className="ml-1 h-3 w-32 rounded" />
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr style={{ background: "var(--bg-3)" }}>
{Array.from({ length: 6 }).map((_, index) => (
<th key={index} className="px-4 py-2 text-left">
<Skeleton className="h-3 w-14 rounded" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, rowIndex) => (
<tr key={rowIndex} style={{ borderTop: "1px solid var(--border)" }}>
<td className="px-4 py-3">
<Skeleton accent className="h-3 w-28 rounded" />
</td>
<td className="px-4 py-3">
<div className="flex justify-end">
<Skeleton className="h-3 w-20 rounded" />
</div>
</td>
{Array.from({ length: 4 }).map((__, cellIndex) => (
<td key={cellIndex} className="px-4 py-3">
<div className="flex justify-end">
<Skeleton className="h-3 w-8 rounded" />
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -26,6 +26,7 @@ import { JsonViewer } from "@/components/shared/JsonViewer";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { MarkdownRenderer } from "@/components/shared/MarkdownRenderer";
import { PeerCardViewer } from "@/components/shared/PeerCardViewer";
import { Skeleton } from "@/components/shared/Skeleton";
import { Button } from "@/components/ui/button";
import { Input, Textarea } from "@/components/ui/input";
import {
@@ -143,7 +144,7 @@ export function PeerDetail() {
<div className="mt-6 space-y-4">
<ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <PageLoader />}
{isLoading && <PeerDetailSkeleton />}
{!isLoading && peer && (
<>
@@ -423,3 +424,45 @@ export function PeerDetail() {
</div>
);
}
function PeerDetailSkeleton() {
return (
<div className="space-y-4" aria-hidden="true">
<div className="rounded-xl p-5 theme-card">
<Skeleton className="h-4 w-36 rounded" />
<div className="mt-4 flex gap-2">
<Skeleton className="h-10 flex-1 rounded-lg" />
<Skeleton accent className="h-10 w-24 rounded-lg" />
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{Array.from({ length: 2 }).map((_, index) => (
<div key={index} className="rounded-xl p-5 theme-card">
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-4 w-28 rounded" />
<Skeleton className="h-8 w-16 rounded-lg" />
</div>
<Skeleton className="h-3 w-full rounded" />
<Skeleton className="mt-2 h-3 w-[92%] rounded" />
<Skeleton className="mt-2 h-3 w-[68%] rounded" />
<Skeleton className="mt-4 h-24 w-full rounded-lg" />
</div>
))}
</div>
<div className="rounded-xl p-5 theme-card">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="mt-4 h-3 w-full rounded" />
<Skeleton className="mt-2 h-3 w-[95%] rounded" />
<Skeleton className="mt-2 h-3 w-[76%] rounded" />
</div>
<div className="rounded-xl theme-card overflow-hidden">
<div className="px-5 py-4">
<Skeleton className="h-4 w-20 rounded" />
</div>
</div>
</div>
);
}

View File

@@ -7,8 +7,8 @@ import type { components } from "@/api/schema.d.ts";
import { EmptyState } from "@/components/shared/EmptyState";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { JsonViewer } from "@/components/shared/JsonViewer";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Pagination } from "@/components/shared/Pagination";
import { Skeleton } from "@/components/shared/Skeleton";
import { SortControl, type SortDir } from "@/components/shared/SortControl";
import { MonoCaption, PageTitle } from "@/components/ui/typography";
import { useDemo } from "@/hooks/useDemo";
@@ -178,7 +178,7 @@ export function PeerList() {
)}
<ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <PageLoader />}
{isLoading && <PeerListSkeleton />}
{!isLoading && peers.length === 0 && (
<EmptyState
@@ -329,3 +329,40 @@ export function PeerList() {
</div>
);
}
function PeerListSkeleton() {
return (
<div aria-hidden="true">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="rounded-xl px-5 py-4"
style={{
background: COLOR.cardBaseBg,
border: `1px solid ${COLOR.cardBaseBorder}`,
}}
>
<div className="flex items-center justify-between">
<Skeleton accent className="h-4 w-40 rounded" />
<Skeleton className="h-4 w-4 rounded" />
</div>
<div className="mt-3 flex items-center gap-2 flex-wrap">
<Skeleton className="h-5 w-14 rounded-full" />
<Skeleton className="h-5 w-12 rounded-full" />
</div>
<div className="mt-3 flex items-center gap-2">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 w-28 rounded" />
</div>
</div>
))}
</div>
<div className="mt-4 flex items-center justify-between">
<Skeleton className="h-8 w-20 rounded-lg" />
<Skeleton className="h-4 w-16 rounded" />
<Skeleton className="h-8 w-20 rounded-lg" />
</div>
</div>
);
}

View File

@@ -6,8 +6,8 @@ import { useSessions } from "@/api/queries";
import type { components } from "@/api/schema.d.ts";
import { EmptyState } from "@/components/shared/EmptyState";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Pagination } from "@/components/shared/Pagination";
import { Skeleton } from "@/components/shared/Skeleton";
import { SortControl, type SortDir } from "@/components/shared/SortControl";
import { MonoCaption, PageTitle } from "@/components/ui/typography";
import { useDemo } from "@/hooks/useDemo";
@@ -105,7 +105,7 @@ export function SessionList() {
</motion.div>
<ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <PageLoader />}
{isLoading && <SessionListSkeleton />}
{!isLoading && sessions.length === 0 && (
<EmptyState
@@ -204,3 +204,40 @@ export function SessionList() {
</div>
);
}
function SessionListSkeleton() {
return (
<div aria-hidden="true">
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className="rounded-xl px-5 py-4"
style={{
background: COLOR.cardBaseBg,
border: `1px solid ${COLOR.cardBaseBorder}`,
}}
>
<div className="flex items-center justify-between">
<Skeleton accent className="h-4 w-44 rounded" />
<div className="flex items-center gap-2">
{index % 2 === 0 && <Skeleton className="h-4 w-12 rounded-full" />}
<Skeleton className="h-4 w-4 rounded" />
</div>
</div>
<div className="mt-3 flex items-center gap-2">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 w-28 rounded" />
<Skeleton className="h-5 w-16 rounded-md" />
</div>
</div>
))}
</div>
<div className="mt-4 flex items-center justify-between">
<Skeleton className="h-8 w-20 rounded-lg" />
<Skeleton className="h-4 w-16 rounded" />
<Skeleton className="h-8 w-20 rounded-lg" />
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
accent?: boolean;
}
export function Skeleton({ accent = false, className, ...props }: SkeletonProps) {
return (
<div
aria-hidden="true"
className={cn("theme-skeleton rounded-md", accent && "theme-skeleton--accent", className)}
{...props}
/>
);
}

View File

@@ -7,7 +7,7 @@ import { z } from "zod";
import { useCreateWebhook, useDeleteWebhook, useTestWebhook, useWebhooks } from "@/api/queries";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Skeleton } from "@/components/shared/Skeleton";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
@@ -79,7 +79,6 @@ export function WebhookManager({ workspaceId }: Props) {
<div className="mt-8 space-y-4">
<ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <PageLoader />}
{/* Add webhook form */}
<motion.div
@@ -133,73 +132,73 @@ export function WebhookManager({ workspaceId }: Props) {
</AnimatePresence>
{/* Webhook list */}
{!isLoading && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="rounded-xl theme-card overflow-hidden"
>
{list.length === 0 ? (
<div className="p-8 text-center">
<Webhook
className="w-8 h-8 mx-auto mb-2 opacity-20"
style={{ color: "var(--text-3)" }}
strokeWidth={1.5}
/>
<Muted>No webhook endpoints yet.</Muted>
</div>
) : (
<div className="divide-y" style={{ borderColor: "var(--border)" }}>
{list.map((wh, i) => (
<motion.div
key={(wh as { id: string }).id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: i * 0.04 }}
className="flex items-center justify-between px-5 py-3 gap-4"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span
className="text-xs font-mono truncate"
style={{ color: "var(--accent-text)" }}
>
{mask((wh as { url: string }).url)}
</span>
<button
type="button"
onClick={() => void open((wh as { url: string }).url)}
className="flex-shrink-0"
style={{
color: "var(--text-4)",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
}}
>
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</button>
</div>
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
{mask((wh as { id: string }).id)}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="rounded-xl theme-card overflow-hidden"
>
{isLoading ? (
<WebhookListSkeleton />
) : list.length === 0 ? (
<div className="p-8 text-center">
<Webhook
className="w-8 h-8 mx-auto mb-2 opacity-20"
style={{ color: "var(--text-3)" }}
strokeWidth={1.5}
/>
<Muted>No webhook endpoints yet.</Muted>
</div>
) : (
<div className="divide-y" style={{ borderColor: "var(--border)" }}>
{list.map((wh, i) => (
<motion.div
key={(wh as { id: string }).id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: i * 0.04 }}
className="flex items-center justify-between px-5 py-3 gap-4"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span
className="text-xs font-mono truncate"
style={{ color: "var(--accent-text)" }}
>
{mask((wh as { url: string }).url)}
</span>
<button
type="button"
onClick={() => void open((wh as { url: string }).url)}
className="flex-shrink-0"
style={{
color: "var(--text-4)",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
}}
>
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</button>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteTarget((wh as { id: string }).id)}
aria-label="Delete webhook"
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</motion.div>
))}
</div>
)}
</motion.div>
)}
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
{mask((wh as { id: string }).id)}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteTarget((wh as { id: string }).id)}
aria-label="Delete webhook"
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</motion.div>
))}
</div>
)}
</motion.div>
</div>
<ConfirmDialog
@@ -217,3 +216,19 @@ export function WebhookManager({ workspaceId }: Props) {
</div>
);
}
function WebhookListSkeleton() {
return (
<div className="divide-y" style={{ borderColor: "var(--border)" }} aria-hidden="true">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="flex items-center justify-between px-5 py-3 gap-4">
<div className="min-w-0 flex-1">
<Skeleton accent className="h-3 w-52 rounded" />
<Skeleton className="mt-2 h-3 w-32 rounded" />
</div>
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
))}
</div>
);
}

View File

@@ -17,7 +17,7 @@ import { useDeleteWorkspace, useQueueStatus, useScheduleDream, useWorkspace } fr
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { JsonViewer } from "@/components/shared/JsonViewer";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Skeleton } from "@/components/shared/Skeleton";
import { Button } from "@/components/ui/button";
import { Body, Caption, PageTitle, SectionHeading } from "@/components/ui/typography";
import { ScheduleDreamModal } from "@/components/workspaces/ScheduleDreamModal";
@@ -107,7 +107,7 @@ export function WorkspaceDetail() {
<div className="mt-8">
<ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <PageLoader />}
{isLoading && <WorkspaceDetailSkeleton />}
{!isLoading && workspace && (
<div className="space-y-4">
@@ -342,3 +342,42 @@ export function WorkspaceDetail() {
</div>
);
}
function WorkspaceDetailSkeleton() {
return (
<div className="space-y-4" aria-hidden="true">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="rounded-xl p-5 theme-card">
<Skeleton accent className="h-5 w-5 rounded" />
<Skeleton className="mt-4 h-4 w-24 rounded" />
<Skeleton className="mt-3 h-3 w-32 rounded" />
<Skeleton className="mt-2 h-3 w-24 rounded" />
</div>
))}
</div>
<div className="rounded-xl p-5 theme-card">
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-4 w-28 rounded" />
<Skeleton className="h-4 w-20 rounded" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index}>
<Skeleton accent className="h-8 w-12 rounded" />
<Skeleton className="mt-2 h-3 w-16 rounded" />
</div>
))}
</div>
</div>
<div className="rounded-xl p-5 theme-card">
<Skeleton className="h-4 w-20 rounded" />
<Skeleton className="mt-4 h-3 w-full rounded" />
<Skeleton className="mt-2 h-3 w-[92%] rounded" />
<Skeleton className="mt-2 h-3 w-[64%] rounded" />
</div>
</div>
);
}

View File

@@ -6,8 +6,8 @@ import { useWorkspaces } from "@/api/queries";
import type { components } from "@/api/schema.d.ts";
import { EmptyState } from "@/components/shared/EmptyState";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Pagination } from "@/components/shared/Pagination";
import { Skeleton } from "@/components/shared/Skeleton";
import { SortControl, type SortDir } from "@/components/shared/SortControl";
import { MonoCaption, Muted, PageTitle } from "@/components/ui/typography";
import { useDemo } from "@/hooks/useDemo";
@@ -94,7 +94,7 @@ export function WorkspaceList() {
</motion.div>
<ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <PageLoader />}
{isLoading && <WorkspaceListSkeleton />}
{!isLoading && workspaces.length === 0 && (
<EmptyState
@@ -156,3 +156,36 @@ export function WorkspaceList() {
</div>
);
}
function WorkspaceListSkeleton() {
return (
<div aria-hidden="true">
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className="rounded-xl px-5 py-4"
style={{
background: COLOR.cardBaseBg,
border: `1px solid ${COLOR.cardBaseBorder}`,
}}
>
<div className="flex items-center justify-between">
<Skeleton accent className="h-4 w-40 rounded" />
<Skeleton className="h-4 w-4 rounded" />
</div>
<div className="mt-3 flex items-center gap-2">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 w-32 rounded" />
</div>
</div>
))}
</div>
<div className="mt-4 flex items-center justify-between">
<Skeleton className="h-8 w-20 rounded-lg" />
<Skeleton className="h-4 w-16 rounded" />
<Skeleton className="h-8 w-20 rounded-lg" />
</div>
</div>
);
}

View File

@@ -196,6 +196,44 @@ body::after {
outline: none;
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.theme-skeleton {
background-image: linear-gradient(
90deg,
var(--skeleton-base) 0%,
var(--skeleton-highlight) 50%,
var(--skeleton-base) 100%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.6s ease-in-out infinite;
}
.theme-skeleton--accent {
background-image: linear-gradient(
90deg,
var(--skeleton-accent-base) 0%,
var(--skeleton-accent-highlight) 50%,
var(--skeleton-accent-base) 100%
);
}
@media (prefers-reduced-motion: reduce) {
.theme-skeleton,
.theme-skeleton--accent {
animation: none;
background-position: 50% 0;
}
}
/* ─── Responsive container ─── */
/* Base: padding + centering only. Width is a CSS variable so modifiers cascade. */
.page-container {