design.md

This commit is contained in:
Alvin Unreal 2026-05-23 22:12:05 +02:00
parent 4b13b0163a
commit 7dde0c4a18
4 changed files with 279 additions and 63 deletions

186
DESIGN.md Normal file
View file

@ -0,0 +1,186 @@
# OpenPets Control Center: Design System & Engineering Manual
This document outlines the visual direction, component patterns, and front-end engineering guidelines for the OpenPets Control Center. It serves as a practical implementation guide for migrating existing views (Settings, Plugins, Integrations, Onboarding) and building new pages using a cohesive, high-fidelity tactile design system.
---
## 1. Visual Direction & Heritage
The OpenPets Control Center blends the nostalgic, playful charm of late-90s/early-2000s desktop pet managers with a modern, glassmorphic utility interface.
- **The Core Tension**: Playful nostalgia meets crisp, modern desktop productivity.
- **Atmospheric Depth**: Instead of flat, sterile surfaces, interfaces leverage rich color gradients, subtle mesh backdrops, structured panels, and tactile control elements.
- **Physicality**: Interactive components feel physical. They have distinct borders, inset highlights, drop shadows, and visual feedback on click/press.
---
## 2. Backgrounds & Surface Treatments
Surfaces are layered to establish clear spatial hierarchy and keep the interface readable.
### Page Backdrop
- **Formula**: A radial ambient glow on top of a subtle linear gradient.
- **Implementation**:
```css
background: radial-gradient(circle at 12% 8%, rgba(219, 234, 254, 0.9), transparent 24%),
linear-gradient(180deg, #f8fbff 0%, #eff7ff 54%, #e9f3ff 100%);
```
### Glass Panels (`.glass` / `GlassCard`)
- Panels use semi-transparent white fills coupled with a distinct double-border look (using inset box shadows) to simulate thick, polished acrylic sheets.
- **Specifications**:
- Background: `rgba(255, 255, 255, 0.76)`
- Border: `1px solid rgba(126, 161, 210, 0.48)`
- Inner Highlight: `inset 0 1px 0 rgba(255, 255, 255, 0.96)`
- Shadow: `0 24px 60px rgba(61, 99, 160, 0.15)`
- Blur: `backdrop-blur-xl`
- Border Radius: `28px` (`rounded-[28px]`)
---
## 3. Page Shell, Layout & Grids
The desktop shell is designed to be self-contained, fitting exactly within the viewport height without scrolling the entire window.
- **Page Shell**: Max-width of `1160px` centered, utilizing a full-height flexbox column (`h-screen p-6`).
- **Hero Header**: Consists of a high-fidelity app icon (`rounded-3xl shadow-glass h-16 w-16`) paired with a tight, high-contrast typographic stack.
- **Layout Split**: A clean grid split, typically:
- **Left Gallery/List Panel**: `1.25fr` — for browsing, searching, and filtering.
- **Right Detail/Action Panel**: `0.75fr` — for contextual inspection and heavy operations.
- **Scroll Containers**: Sub-panels (like grids and lists) scroll internally (`overflow-y-auto`) with customized scrollbars so that headers, filters, and primary action bars remain pinned.
---
## 4. Typography & Tone
- **Font Family**: Refined sans-serif (e.g., `Inter`, `system-ui`) for maximum readability in body copy, labels, and metadata.
- **Display Typography**: Strong, characterful monospace or display-sans fonts (e.g., Lucida Console, geometric display fonts) for headers and uppercase labels to evoke retro desktop applications.
- **Typographic Hierarchy**:
- **Hero Title**: `text-5xl font-black tracking-tight`
- **Panel Title**: `text-3xl font-black`
- **Eyebrow / Section Header**: `text-xs font-bold uppercase tracking-[.18em] text-brand`
- **Card Title**: `text-sm font-semibold text-navy`
- **Metadata / Descriptions**: `text-xs text-slate-500`
- **Copy Tone**: Conversational, clean, and helpful. Avoid overly dense technical jargon; explain state transitions clearly (e.g., *"Ready to become your default pet"* instead of *"State: INSTALLED, ACTIVE: FALSE"*).
---
## 5. Colors & Semantic Palette
All colors are mapped to functional roles to ensure consistency across themes.
| Role | Color / Hex | Tailwind Equivalent | Semantic Usage |
| :--- | :--- | :--- | :--- |
| **Brand Primary** | `#176df2` / `#3b96ff` | `bg-brand` / `text-brand` | Active filters, primary buttons, hero accents |
| **Dark Neutral** | `#102149` | `text-navy` | Main body text, card titles, heavy headings |
| **Light Neutral** | `#f8fbff` to `#e9f3ff` | `bg-slate-50` / `bg-blue-50` | Page backdrops, card backgrounds, empty states |
| **Slate Copy** | `#5c6e91` | `text-slatecopy` | Subtitles, helper text, inactive buttons, labels |
| **Success / Ready** | `#059669` / `#34d399` | `emerald` | Active indicators, successfully installed badges |
| **Warning / Import** | `#d97706` / `#fbbf24` | `amber` | Codex source indicators, imports, attention states |
| **Danger / Remove** | `#dc2626` / `#ff6b6b` | `red` | Destructive actions, broken states, error messages |
---
## 6. Tactile Button System
Buttons are the core interactive elements. They reject flat modern trends in favor of a chunky, tangible 3D appearance inspired by physical pet hardware and OS controls.
### Visual Architecture
- **Borders**: Darker semi-transparent bottom/side borders (`rgba(..., 0.32)`).
- **Gradients**: Linear vertical gradients that transition from a lighter top to a richer bottom.
- **Inset Highlights**: A subtle light line (`inset 0 1px 0 rgba(255,255,255,0.38)`) on the top edge to simulate light catching the button's crown.
- **Shadows**: Keep buttons grounded. Use subtle neutral depth plus inset highlights; avoid large colored glow shadows.
### Interactive Rules
- **Hover States**: Under current design guidelines, **avoid dramatic hover glows or float lifts**. Hovering should simply shift the gradient/background color slightly to maintain a grounded, crisp, and predictable interface.
- **Active Click State**: On press, the button scales down slightly to `0.96` to provide a satisfying physical click response.
- **Constraint**: Always use targeted transitions (`transition-[transform,background-color,border-color,box-shadow]`) instead of `transition-all` to prevent layout reflow stutter.
### Button Variants
1. **Primary** (`.btn-primary`):
- Gradient: `#3b96ff` to `#176df2`
- Shadow: `inset 0 1px 0 rgba(255,255,255,0.38), 0 2px 4px rgba(61,99,160,0.10)`
2. **Secondary** (`.btn-secondary`):
- Surface: `rgba(255,255,255,0.76)` with text `#176df2`
- Border: `rgba(37, 99, 235, 0.42)`
3. **Danger** (`.btn-danger`):
- Gradient: `#ff6b6b` to `#dc2626`
- Shadow: same subtle neutral depth as primary; do not add red glow
4. **Warning** (`.btn-warning`):
- Gradient: `#fbbf24` to `#d97706`
5. **Success** (`.btn-success`):
- Gradient: `#34d399` to `#059669`
6. **Compact Button** (`.btn-compact`):
- Designed for pagination (`.pager`) and secondary rows.
- Reduces size to `min-h-[30px]`, padding to `px-2.5 py-1`, and font size to `text-xs` with a tighter border-radius (`rounded-xl`).
---
## 7. Controls & Navigation Elements
### Filters (`.filter`)
- Instead of simple pill badges, filters are structured as miniature tactile buttons.
- Inactive filters use a soft white-to-slate gradient with a light blue-grey border.
- Active filters swap to full brand gradients (e.g., blue for all, orange for original) with only subtle grounded depth.
### Search Inputs (`.search`)
- Rendered as soft, inset text boxes.
- Styled with `bg-white/80` and `shadow-inner` to look recessed into the panel.
- Focus states use a visible but restrained brand ring (`focus:ring-brand/15 focus:border-brand`) for accessibility.
### Pager (`.pager`)
- Placed cleanly at the **bottom** of list/grid containers.
- Merges compact secondary buttons with centered, bold, uppercase status text (`text-xs font-bold tracking-wider`).
---
## 8. Icons & Visual Assets
- **Coherency**: Use clean, outline-style vectors with a consistent stroke weight (`stroke-width="2.5"` or `3` for smaller shapes).
- **Style Alignment**: Align with the **Lucide / Iconify** icon libraries.
- **Implementation**: Embed icons as stateless, inline React SVG components or a localized icon catalog within the component file to avoid heavy runtime dependencies.
- **Sizing**:
- Action/Button Icons: `16px x 16px`
- Compact/Filter Icons: `12px x 12px` or `14px x 14px`
---
## 9. React & Tailwind Implementation Notes
### The Static Purge Constraint
Tailwind CSS extracts classes statically by analyzing source code strings. **Do not construct class names dynamically**.
```tsx
// ❌ BAD: Will be purged by Tailwind in production
const color = "blue";
return <span className={`pill-${color}`}>Label</span>;
// GOOD: Statically discoverable mapping
const statusPillToneClass = {
blue: "pill-blue",
green: "pill-green",
} as const;
return <span className={`pill ${statusPillToneClass[tone]}`}>Label</span>;
```
---
## 10. Migration Playbook for Future Pages
When refactoring other Control Center views, apply the following structural mapping:
### A. Settings Page
- **Old Layout**: Long scrolling list of form controls.
- **New Layout**: Split-pane layout. Left side lists settings categories (General, Hotkeys, Performance) using `.filter` tactile navigation. Right side contains the active form wrapped in a `.glass` card.
- **Inputs**: Swap default inputs to the recessed `.search` input style. Use `.btn-primary` for the final "Save" action.
### B. Plugins Page
- **Old Layout**: Flat list of available integrations.
- **New Layout**: Grid-based layout matching the `.pets-grid` structure.
- **Cards**: Use the `.pet-card` styling with a thumbnail on the left, author/description text on the right, and semantic status pills (e.g., `.pill-green` for "Active", `.pill-slate` for "Disabled").
### C. Onboarding
- **Old Layout**: Full screen wizard.
- **New Layout**: Centered, floating glass card (`.glass` with `max-w-xl shadow-glass`) layered over the atmospheric radial backdrop.
- **Interaction**: Use staggered CSS keyframes for a smooth entrance, and a prominent `.btn-primary` with active scaling for the "Get Started" call to action.

View file

@ -25,10 +25,10 @@ const InstallIcon = () => (
const ImportIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<polyline points="9 15 12 12 15 15" />
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<path d="M12 18v-6" />
<path d="m9 15 3 3 3-3" />
</svg>
);
@ -40,69 +40,83 @@ const SetDefaultIcon = () => (
const RemoveIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
);
const RefreshIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67" />
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M3 21v-5h5" />
</svg>
);
const PrevIcon = () => (
<svg className="btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
<path d="m15 18-6-6 6-6" />
</svg>
);
const NextIcon = () => (
<svg className="btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
<path d="m9 18 6-6-6-6" />
</svg>
);
const FilterAllIcon = () => (
<svg className="filter-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>
);
const FilterInstalledIcon = () => (
<svg className="filter-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>
);
const FilterOriginalIcon = () => (
<svg className="filter-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v8M8 12h8" />
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275Z" />
</svg>
);
const FilterWesternIcon = () => (
<svg className="filter-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
);
const FilterAsianIcon = () => (
<svg className="filter-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2a10 10 0 0 1 10 10c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2z" />
<path d="M12 6v12M6 12h12" />
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
);
const FilterCodexIcon = () => (
<svg className="filter-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
);
@ -134,6 +148,7 @@ const statusPillToneClass = {
function Button({
children,
variant = "primary",
size = "normal",
onClick,
disabled,
icon,
@ -142,6 +157,7 @@ function Button({
}: {
children: React.ReactNode;
variant?: "primary" | "secondary" | "danger" | "success" | "warning";
size?: "normal" | "compact";
onClick?: () => void;
disabled?: boolean;
icon?: React.ReactNode;
@ -150,13 +166,13 @@ function Button({
}) {
return (
<button
className={`btn ${buttonVariantClass[variant]} ${fullWidth ? "w-full" : ""} ${icon ? "has-icon" : ""}`}
className={`btn ${buttonVariantClass[variant]} ${size === "compact" ? "btn-compact" : ""} ${fullWidth ? "w-full" : ""} ${icon ? "has-icon" : ""}`}
onClick={onClick}
disabled={disabled}
>
{icon && iconPosition === "left" && <span className="btn-icon-wrapper mr-2 inline-flex items-center justify-center">{icon}</span>}
{icon && iconPosition === "left" && <span className="btn-icon-wrapper mr-1.5 inline-flex items-center justify-center">{icon}</span>}
<span className="btn-text">{children}</span>
{icon && iconPosition === "right" && <span className="btn-icon-wrapper ml-2 inline-flex items-center justify-center">{icon}</span>}
{icon && iconPosition === "right" && <span className="btn-icon-wrapper ml-1.5 inline-flex items-center justify-center">{icon}</span>}
</button>
);
}
@ -425,7 +441,7 @@ function App() {
{error && <div className="error">{error}</div>}
<div className="layout">
<GlassCard className="gallery">
<div className="toolbar"><SearchInput value={query} onChange={(e) => setQuery(e.target.value)} /><StatusPill tone="slate">{pets.length} pets</StatusPill></div>
<div className="toolbar"><SearchInput value={query} onChange={(e) => setQuery(e.target.value)} /></div>
<div className="filters">
{(["all", "installed", "original", "western", "asian", "codex"] as Filter[]).map((f) => (
<button
@ -438,28 +454,6 @@ function App() {
</button>
))}
</div>
{!!catalog?.pageCount && catalog.pageCount > 1 && (
<div className="pager">
<Button
variant="secondary"
icon={<PrevIcon />}
disabled={!!busy || catalogPage <= 0}
onClick={() => void loadCatalogPage(catalogPage - 1)}
>
Prev
</Button>
<span className="pager-text">Page {catalogPage + 1} of {catalog.pageCount}</span>
<Button
variant="secondary"
icon={<NextIcon />}
iconPosition="right"
disabled={!!busy || catalogPage >= catalog.pageCount - 1}
onClick={() => void loadCatalogPage(catalogPage + 1)}
>
Next
</Button>
</div>
)}
<div className="pets-grid">{pets.map((pet) => {
const isBuiltIn = pet.builtIn;
const hasDistinctPreview = pet.preview && pet.preview !== pet.spritesheet;
@ -483,6 +477,32 @@ function App() {
</button>
);
})}</div>
<div className="pager">
{!!catalog?.pageCount && catalog.pageCount > 1 ? (
<Button
variant="secondary"
size="compact"
icon={<PrevIcon />}
disabled={!!busy || catalogPage <= 0}
onClick={() => void loadCatalogPage(catalogPage - 1)}
>
Prev
</Button>
) : <span />}
<span className="pager-text">{pets.length} pets{!!catalog?.pageCount && catalog.pageCount > 1 ? ` · Page ${catalogPage + 1} of ${catalog.pageCount}` : ""}</span>
{!!catalog?.pageCount && catalog.pageCount > 1 ? (
<Button
variant="secondary"
size="compact"
icon={<NextIcon />}
iconPosition="right"
disabled={!!busy || catalogPage >= catalog.pageCount - 1}
onClick={() => void loadCatalogPage(catalogPage + 1)}
>
Next
</Button>
) : <span />}
</div>
</GlassCard>
<GlassCard className="detail">
{selected ? <><p className="eyebrow">Pet detail</p><h2>{selected.displayName}</h2><p className="desc">{selected.description || selected.id}</p>
@ -545,7 +565,7 @@ function App() {
)}
{selected.installed && selected.id !== defaultId && !selected.broken && (
<Button
variant="success"
variant="primary"
fullWidth
icon={<SetDefaultIcon />}
disabled={!!busy}

View file

@ -3,7 +3,7 @@
@tailwind utilities;
@layer base {
body { margin:0; min-height:100vh; overflow:hidden; color:#102149; background: radial-gradient(circle at 20% 0%, rgba(59,150,255,.2), transparent 32rem), linear-gradient(135deg,#f8fbff,#eff7ff 45%,#e9f3ff); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; }
body { margin:0; min-height:100vh; overflow:hidden; color:#102149; background: radial-gradient(circle at 12% 8%, rgba(219, 234, 254, 0.9), transparent 24%), linear-gradient(180deg, #f8fbff 0%, #eff7ff 54%, #e9f3ff 100%); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(96, 165, 250, 0.25); border-radius: 100px; }
@ -14,18 +14,22 @@
.hero { @apply mb-6 flex items-center gap-5; } .hero img { @apply h-16 w-16 rounded-3xl shadow-glass; } .hero h1 { @apply m-0 font-monoDisplay text-5xl font-black tracking-tight; } .hero p { @apply m-0 text-slatecopy; }
.eyebrow { @apply mb-2 font-monoDisplay text-xs font-black uppercase tracking-[.18em] text-brand; }
.layout { @apply grid min-h-0 flex-1 grid-cols-[1.25fr_.75fr] gap-5 max-[900px]:grid-cols-1; }
.glass { @apply rounded-[28px] border border-[rgba(126,161,210,.44)] bg-white/65 p-5 shadow-glass backdrop-blur-xl; box-shadow: inset 0 1px 0 rgba(255,255,255,.85), 0 24px 70px rgba(50,104,180,.18); }
.glass { @apply rounded-[28px] border border-[rgba(126,161,210,.48)] p-5 shadow-glass backdrop-blur-xl; background: rgba(255, 255, 255, 0.76); box-shadow: inset 0 1px 0 rgba(255,255,255,.96), 0 24px 60px rgba(61,99,160,.15); }
.gallery { @apply flex min-h-0 flex-col overflow-hidden; }
.toolbar { @apply flex items-center gap-3; } .search { @apply min-w-0 flex-1 rounded-2xl border border-blue-300/80 bg-white/80 px-4 py-3 text-navy outline-none shadow-inner transition-shadow duration-150 focus:border-brand focus:bg-white focus:ring-4 focus:ring-brand/15; }
.filters { @apply my-4 flex flex-wrap gap-2; }
.filter { @apply rounded-full border border-slate-200 bg-slate-50/40 px-4 py-2 font-monoDisplay text-xs font-bold uppercase tracking-wide text-slate-600 active:scale-[0.96] transition-[transform,background-color,border-color,box-shadow] duration-150 flex items-center gap-1.5 cursor-pointer select-none h-10 shadow-sm; }
.filter:hover:not(.active) { @apply bg-slate-100/80 border-slate-300 text-slate-800; }
.filter.active { @apply border-brand bg-brand text-white shadow-md; }
.filter.original.active { @apply border-orange-500 bg-orange-500 text-white shadow-md; }
.filters { @apply my-3 flex flex-wrap gap-1.5; }
.filter { @apply rounded-xl border px-3 py-1.5 font-monoDisplay text-xs font-black uppercase tracking-wide text-slate-600 active:scale-[0.96] transition-[transform,background-color,border-color,box-shadow,translate] duration-150 flex items-center gap-1.5 cursor-pointer select-none h-8 shadow-sm; border-color: rgba(126, 161, 210, 0.4); background: linear-gradient(180deg, rgba(255, 255, 255, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%); box-shadow: inset 0 1px 0 rgba(255,255,255,1), 0 2px 4px rgba(61,99,160,0.06); }
.filter:hover:not(.active) { @apply text-brand; border-color: rgba(37, 99, 235, 0.35); background: linear-gradient(180deg, rgba(255, 255, 255, 1) 0%, rgba(239, 246, 255, 0.9) 100%); box-shadow: inset 0 1px 0 rgba(255,255,255,1), 0 2px 4px rgba(61,99,160,0.06); }
.filter:active { translate: 0 0; }
.filter.active { @apply text-white; border-color: rgba(29, 78, 216, 0.5); background: linear-gradient(180deg, #3b96ff 0%, #176df2 100%); box-shadow: inset 0 1.5px 0 rgba(255,255,255,0.35), 0 2px 4px rgba(61,99,160,0.10); }
.filter.active:hover { background: linear-gradient(180deg, #55a6ff 0%, #176df2 100%); }
.filter.active:active { translate: 0 0; }
.filter.original.active { border-color: rgba(194, 65, 12, 0.5); background: linear-gradient(180deg, #fb923c 0%, #ea580c 100%); box-shadow: inset 0 1.5px 0 rgba(255,255,255,0.35), 0 2px 4px rgba(61,99,160,0.10); }
.filter.original.active:hover { background: linear-gradient(180deg, #ffaa47 0%, #ea580c 100%); }
.filter-icon-wrapper { @apply flex items-center justify-center shrink-0 opacity-70; }
.filter.active .filter-icon-wrapper { @apply opacity-100; }
.filter-icon { @apply h-3.5 w-3.5 stroke-[2.5]; }
.pager { @apply mb-4 flex items-center justify-between gap-3 rounded-2xl border border-blue-200/60 bg-blue-50/30 p-2 text-sm font-semibold text-slatecopy; }
.pager { @apply mt-3 flex items-center justify-between gap-2.5 rounded-xl border border-blue-200/40 bg-blue-50/20 p-1.5 text-xs font-bold text-slatecopy; }
.pets-grid { @apply grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-y-auto pr-2 max-[680px]:grid-cols-1; overscroll-behavior: contain; scrollbar-color: rgba(96, 165, 250, 0.42) transparent; }
.pet-card { @apply flex flex-row items-center gap-3.5 rounded-2xl border border-slate-200 bg-white/80 p-3 text-left shadow-sm transition-[transform,border-color,background-color,box-shadow] hover:-translate-y-0.5 hover:border-brand hover:bg-white active:scale-[0.96] min-h-[92px] w-full cursor-pointer; }
.pet-card.selected { @apply border-brand bg-blue-50/90 ring-4 ring-blue-200/50 shadow-md; }
@ -49,12 +53,18 @@
@media (prefers-reduced-motion: reduce) { .sprite-frame { animation: none !important; } .sprite-frame animate { display: none; } }
@media (max-width: 900px) { body { overflow:auto; } .app-shell { @apply h-auto min-h-screen; } .layout, .gallery, .pets-grid, .detail { @apply overflow-visible; } }
.meta { @apply flex flex-wrap gap-2; }
.btn { @apply rounded-2xl px-4 py-3 font-monoDisplay text-sm font-black uppercase tracking-wide disabled:cursor-not-allowed disabled:opacity-60 active:scale-[0.96] transition-[transform,background-color,border-color,box-shadow] duration-150 flex items-center justify-center min-h-[44px] cursor-pointer select-none; }
.btn-primary { @apply bg-gradient-to-r from-brand to-[#0d56c6] text-white shadow-md hover:from-brand-light hover:to-brand focus:ring-2 focus:ring-brand/40 border-b-2 border-blue-700; }
.btn-secondary { @apply border border-slate-200 bg-white text-slate-700 shadow-sm hover:bg-slate-50 hover:border-slate-300 focus:ring-2 focus:ring-slate-300/50; }
.btn-danger { @apply border border-red-200 bg-red-50/20 text-red-600 shadow-sm hover:bg-red-50 hover:border-red-400 focus:ring-2 focus:ring-red-500/30; }
.btn-success { @apply bg-gradient-to-r from-emerald-600 to-emerald-700 text-white shadow-md hover:from-emerald-500 hover:to-emerald-600 focus:ring-2 focus:ring-emerald-500/40 border-b-2 border-emerald-800; }
.btn-warning { @apply bg-gradient-to-r from-amber-500 to-amber-600 text-white shadow-md hover:from-amber-400 hover:to-amber-500 focus:ring-2 focus:ring-amber-500/40 border-b-2 border-amber-700; }
.btn { @apply rounded-2xl px-4 py-3 font-monoDisplay text-sm font-black uppercase tracking-wide disabled:cursor-not-allowed disabled:opacity-60 active:scale-[0.96] transition-[transform,translate,background-color,border-color,box-shadow] duration-150 flex items-center justify-center min-h-[44px] cursor-pointer select-none; }
.btn-compact { @apply rounded-xl px-2.5 py-1 text-xs min-h-[30px]; }
.btn-primary { border: 1px solid rgba(37, 99, 235, 0.32); background: linear-gradient(180deg, #3b96ff, #176df2); color: #fff; box-shadow: inset 0 1px 0 rgba(255,255,255,0.38), 0 2px 4px rgba(61,99,160,0.10); }
.btn-primary:hover:not(:disabled) { background: linear-gradient(180deg, #55a6ff, #176df2); }
.btn-secondary { background: rgba(255,255,255,0.76); color: #176df2; border: 1px solid rgba(37, 99, 235, 0.42); box-shadow: inset 0 1px 0 rgba(255,255,255,0.9), 0 2px 4px rgba(61,99,160,0.08); }
.btn-secondary:hover:not(:disabled) { background: rgba(255,255,255,0.95); border-color: rgba(37, 99, 235, 0.55); box-shadow: inset 0 1px 0 rgba(255,255,255,1), 0 2px 4px rgba(61,99,160,0.10); }
.btn-danger { border: 1px solid rgba(220, 38, 38, 0.32); background: linear-gradient(180deg, #ff6b6b, #dc2626); color: #fff; box-shadow: inset 0 1px 0 rgba(255,255,255,0.38), 0 2px 4px rgba(61,99,160,0.10); }
.btn-danger:hover:not(:disabled) { background: linear-gradient(180deg, #ff8787, #dc2626); }
.btn-success { border: 1px solid rgba(5, 150, 105, 0.32); background: linear-gradient(180deg, #34d399, #059669); color: #fff; box-shadow: inset 0 1px 0 rgba(255,255,255,0.38), 0 2px 4px rgba(61,99,160,0.10); }
.btn-success:hover:not(:disabled) { background: linear-gradient(180deg, #6ee7b7, #059669); }
.btn-warning { border: 1px solid rgba(217, 119, 6, 0.32); background: linear-gradient(180deg, #fbbf24, #d97706); color: #fff; box-shadow: inset 0 1px 0 rgba(255,255,255,0.38), 0 2px 4px rgba(61,99,160,0.10); }
.btn-warning:hover:not(:disabled) { background: linear-gradient(180deg, #fcd34d, #d97706); }
.btn-icon-wrapper { @apply flex items-center justify-center shrink-0; }
.btn-icon { @apply h-4 w-4 stroke-[2.5]; }

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB