R0: design foundations — reference tokens, motion set, lucide primitives

- globals.css @theme merged with the measured reference system
  (docs/tokens.css): hover/divider/subtle-fg colors, cream + bubble
  tokens, squircle/toggle radii, spec-verbatim shadow set (bubble, card,
  dialog, dock-tile, CTA glow, launcher glow, screen-card ambient, dock
  capsule), marketing light palette, default 0.25s ease-app transition
  tokens. Legacy aliases (--color-accent, --spacing-rail/panel-*)
  retained so existing classes keep working
- motion.css: full app-shell choreography (zoom-in/out from tile origin
  w/ --app-origin-scale, push/pop slides, cross-fade) + messageSlideIn,
  create-claw step/swatch, device-* set — keyframes verbatim from
  docs/motion.md
- lucide-react installed (ISC). New tested primitives: Button (cream/
  coral/ghost/icon/launcher variants per measured states), GradientGlyph
  (per-instance coral linearGradient stroke, the reference glyph
  technique), SegmentedTabs, Card + SectionLabel + GroupedRows,
  SearchPill; Avatar extended (squircle r17, rail/chat/welcome sizes,
  online-dot overlay)

82 unit tests; functional e2e suite green (visual baselines regenerate
in R7).

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 18:07:35 -05:00
co-authored by Claude Fable 5
parent 447f7039d8
commit 3ec1d3b829
14 changed files with 566 additions and 29 deletions
+10
View File
@@ -11,6 +11,7 @@
"@clerk/nextjs": "^7.5.0", "@clerk/nextjs": "^7.5.0",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"geist": "^1.7.2", "geist": "^1.7.2",
"lucide-react": "^1.17.0",
"next": "16.2.9", "next": "16.2.9",
"nuqs": "^2.8.9", "nuqs": "^2.8.9",
"react": "19.2.4", "react": "19.2.4",
@@ -5872,6 +5873,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz",
"integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": { "node_modules/lz-string": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+1
View File
@@ -14,6 +14,7 @@
"@clerk/nextjs": "^7.5.0", "@clerk/nextjs": "^7.5.0",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"geist": "^1.7.2", "geist": "^1.7.2",
"lucide-react": "^1.17.0",
"next": "16.2.9", "next": "16.2.9",
"nuqs": "^2.8.9", "nuqs": "^2.8.9",
"react": "19.2.4", "react": "19.2.4",
+73 -20
View File
@@ -1,54 +1,107 @@
@import "tailwindcss"; @import "tailwindcss";
@import "../styles/motion.css"; @import "../styles/motion.css";
/* Design tokens, spec §2. Dark-first; coral brand accent. */ /* Design tokens — merged from the reverse-engineered reference system
(docs/tokens.css, measured from live DOM) on top of spec §2. Dark-only
app scheme; coral brand accent. Values are spec-verbatim; aliases keep
existing component class names stable. */
@theme { @theme {
/* ---- Color: function-grouped ---- */
--color-background: #0a0a0a; --color-background: #0a0a0a;
--color-foreground: #fafafa; --color-foreground: #fafafa;
--color-card: #0a0a0a; --color-card: #0a0a0a;
--color-popover: #0a0a0a; --color-popover: #0a0a0a;
--color-surface-warm: #141414; /* raised tier 1 */
--color-surface-warm-muted: #1f1f1f; /* raised tier 2 */
--color-subtle: #141414;
--color-hover-bg: #171717; /* generic hover fill */
--color-divider-subtle: #1a1a1a;
--color-border: #262626; --color-border: #262626;
--color-input: #262626; --color-input: #262626;
--color-muted: #262626; --color-muted: #262626;
--color-secondary: #262626; --color-secondary: #262626;
--color-muted-foreground: #a3a3a3; --color-muted-foreground: #a3a3a3;
--color-subtle: #141414; --color-subtle-foreground: #737373;
--color-surface-warm: #141414;
--color-surface-warm-muted: #1f1f1f; /* brand accent (coral) + variants; --color-accent is our legacy alias */
--color-coral: #f96565;
--color-accent: #f96565; --color-accent: #f96565;
--color-coral-light: #fa7575; --color-coral-light: #fa7575;
--color-coral-dark: #e85555; --color-coral-dark: #e85555;
--color-destructive: #7f1d1d; --color-cream: #f5f5f5; /* cream primary button fill */
--color-primary: #fafafa; --color-primary: #fafafa;
--color-primary-foreground: #171717;
--color-ring: #fafafa;
--color-focus-ring: #f5f5f5;
/* chat bubbles (measured) */
--color-bubble-user: #1a1a1a;
--color-bubble-user-text: #d4d4d4;
/* semantic */
--color-destructive: #7f1d1d;
--color-destructive-foreground: #fafafa;
/* gradient stops (coral tile) */
--color-gradient-coral-top: #fa7575;
--color-gradient-coral-bottom: #e85555;
/* ---- Type scale (size / line-height) ---- */
--text-xxs: 10px;
--text-xxs--line-height: 14px;
--text-xxl: 28px;
--text-xxl--line-height: 36px;
--text-xxxl: 36px;
--text-xxxl--line-height: 44px;
/* ---- Radius scale ---- */
--radius: 0.5rem; --radius: 0.5rem;
--radius-button: 9999px; --radius-squircle: 17px; /* 48px claw avatars */
--radius-button: 9999px; /* pill buttons + nav items */
--radius-toggle: 8px;
--font-sans: var(--font-geist), system-ui, sans-serif; --font-sans: var(--font-geist), system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, monospace; --font-mono: var(--font-geist-mono), ui-monospace, monospace;
--text-xxs: 10px; /* Layout (§4, §6, §7): rail 176, sessions 208, computer 448. */
--text-xxxl: 36px;
/* Layout widths (§4, §6, §7): rail 176, sessions 208, computer 448. */
--spacing-rail: 176px; --spacing-rail: 176px;
--spacing-panel-sessions: 208px; --spacing-panel-sessions: 208px;
--spacing-panel-device: 448px; --spacing-panel-device: 448px;
--ease-app: cubic-bezier(0.32, 0.72, 0, 1); /* ---- Motion ---- */
--ease-out-app: cubic-bezier(0.16, 1, 0.3, 1); --ease-app: cubic-bezier(0.32, 0.72, 0, 1); /* DOMINANT curve */
--ease-out-app: cubic-bezier(0.16, 1, 0.3, 1); /* secondary / easeOutExpo-ish */
--ease-bounce: cubic-bezier(0.5, 1.5, 0.6, 1);
--duration-fast: 0.15s; --duration-fast: 0.15s;
--duration-normal: 0.25s; --duration-normal: 0.25s;
--duration-slow: 0.4s; --duration-slow: 0.4s;
--default-transition-duration: 0.25s;
--default-transition-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
/* Shadow set named by §2; exact values locked via visual snapshots. */ /* ---- Elevation / shadows (spec-verbatim, measured) ---- */
--shadow-bubble: 0 1px 2px rgb(0 0 0 / 0.4); --shadow-bubble: 0 1px 2px #0006;
--shadow-button: 0 1px 2px rgb(0 0 0 / 0.5), inset 0 1px 0 rgb(255 255 255 / 0.04); --shadow-bubble-user: 0 2px 5px #00000008, 0 -10.4px 16px #ffffff08;
--shadow-card: 0 2px 8px rgb(0 0 0 / 0.45); --shadow-button: 0 1px 2px #0000004d, 0 2px 4px -1px #0000004d;
--shadow-dialog: 0 12px 40px rgb(0 0 0 / 0.6); --shadow-card: 0 1px 2px #0006, 0 4px 12px -2px #00000080;
--shadow-popover: 0 6px 24px rgb(0 0 0 / 0.55); --shadow-popover: 0 16px 40px -12px #0009;
--shadow-dock-tile: 0 2px 6px rgb(0 0 0 / 0.5), inset 0 1px 0 rgb(255 255 255 / 0.06); --shadow-dialog: 0 32px 80px -24px #000000b3;
--shadow-cta: 0 4px 16px color-mix(in srgb, #f96565 35%, transparent); --shadow-edge: -3px 0 14px -4px #0006; /* panel left edge */
--shadow-dock-tile: 0 0 0 1px #ffffff14, inset 0 1px 0 #ffffff2e;
--shadow-cta: inset 0 1px 0 #ffffff40, 0 3px 10px -3px #f965658c; /* coral CTA */
--shadow-launcher: inset 0 1px 0 #ffffff38, 0 5px 14px -3px #f965657a; /* header Computer launcher */
--shadow-screen:
0 4px 12px #ffffff06, 0 24px 60px -8px #ffffff08,
0 40px 80px -20px #ffffff05; /* panel screen card ambient lift */
--shadow-dock-capsule: inset 0 1px 0 #ffffff0f, 0 10px 30px -6px #00000059;
--nav-logo-shadow: inset 0 0 0 1px #ffffff26;
/* ---- Marketing (light) palette ---- */
--color-marketing-ink: #161d27;
--color-marketing-coral: #e95656;
--color-marketing-slate: #475569;
--color-marketing-slate-dark: #1e293b;
--color-marketing-border: #e2e8f0;
--color-marketing-periwinkle: #e7eafc;
} }
body { body {
@@ -0,0 +1,37 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Avatar } from "./Avatar";
describe("Avatar", () => {
it("renders initials with the accent fill", () => {
const { container } = render(<Avatar name="Scout Larson" />);
expect(container.textContent).toBe("SL");
});
it("squircle shape uses the 17px squircle radius instead of a circle", () => {
const { container } = render(
<Avatar name="Scout" shape="squircle" size="rail" />,
);
const span = container.querySelector("span[aria-hidden]")!;
expect(span.className).toContain("rounded-radius-squircle");
expect(span.className).not.toContain("rounded-full");
expect(span.className).toContain("size-12"); /* 48px rail size */
});
it("welcome size renders the 126px hero avatar", () => {
const { container } = render(
<Avatar name="Scout" shape="squircle" size="welcome" />,
);
expect(
container.querySelector("span[aria-hidden]")!.className,
).toContain("size-[126px]");
});
it("online dot overlays when requested", () => {
const { container } = render(<Avatar name="Scout" online />);
const dot = container.querySelector("[data-online-dot]");
expect(dot).not.toBeNull();
expect(dot!.className).toContain("bg-green-500");
});
});
+32 -4
View File
@@ -2,26 +2,54 @@ interface AvatarProps {
name: string; name: string;
/** Per-agent accent hex; defaults to the brand coral. */ /** Per-agent accent hex; defaults to the brand coral. */
accent?: string; accent?: string;
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg" | "rail" | "chat" | "welcome";
/** Circle (people) or 17px-radius squircle (claws, measured). */
shape?: "circle" | "squircle";
/** Green presence dot overlay (rail + chat headers). */
online?: boolean;
} }
const SIZES = { sm: "size-6 text-xxs", md: "size-8 text-xs", lg: "size-16 text-xl" }; const SIZES = {
sm: "size-6 text-xxs",
md: "size-8 text-xs",
lg: "size-16 text-xl",
rail: "size-12 text-base" /* 48px claw rail tile */,
chat: "size-[50px] text-base" /* assistant message avatar */,
welcome: "size-[126px] text-4xl" /* empty-state hero */,
};
/** Initials avatar used until generated art assets land (excluded by spec). */ /** Initials avatar used until generated art assets land (excluded by spec). */
export function Avatar({ name, accent, size = "md" }: AvatarProps) { export function Avatar({
name,
accent,
size = "md",
shape = "circle",
online = false,
}: AvatarProps) {
const initials = name const initials = name
.split(/\s+/) .split(/\s+/)
.filter(Boolean) .filter(Boolean)
.slice(0, 2) .slice(0, 2)
.map((part) => part[0]!.toUpperCase()) .map((part) => part[0]!.toUpperCase())
.join(""); .join("");
const radius =
shape === "squircle" ? "rounded-radius-squircle" : "rounded-full";
return ( return (
<span className="relative inline-flex">
<span <span
aria-hidden aria-hidden
className={`inline-flex items-center justify-center rounded-full font-medium text-background ${SIZES[size]}`} className={`inline-flex items-center justify-center font-medium text-background ${radius} ${SIZES[size]}`}
style={{ backgroundColor: accent || "var(--color-accent)" }} style={{ backgroundColor: accent || "var(--color-accent)" }}
> >
{initials} {initials}
</span> </span>
{online && (
<span
data-online-dot
aria-hidden
className="absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full border-2 border-background bg-green-500"
/>
)}
</span>
); );
} }
@@ -0,0 +1,52 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./Button";
describe("Button", () => {
it("cream variant: pill with cream fill and dark text (the action primary)", () => {
render(<Button variant="cream">Send</Button>);
const button = screen.getByRole("button", { name: "Send" });
expect(button.className).toContain("rounded-radius-button");
expect(button.className).toContain("bg-cream");
expect(button.className).toContain("text-primary-foreground");
});
it("coral variant: coral fill, white text, CTA glow shadow", () => {
render(<Button variant="coral">Continue</Button>);
const button = screen.getByRole("button", { name: "Continue" });
expect(button.className).toContain("bg-coral");
expect(button.className).toContain("shadow-cta");
expect(button.className).toContain("text-white");
});
it("ghost variant stays transparent until hover", () => {
render(<Button variant="ghost">More</Button>);
const button = screen.getByRole("button", { name: "More" });
expect(button.className).toContain("hover:bg-hover-bg");
expect(button.className).not.toContain("bg-cream");
});
it("icon variant renders a 36px round button with a label for AT", () => {
render(
<Button variant="icon" aria-label="New session">
<svg />
</Button>,
);
const button = screen.getByRole("button", { name: "New session" });
expect(button.className).toContain("size-9");
expect(button.className).toContain("rounded-radius-button");
});
it("disabled drops to half opacity and blocks clicks", async () => {
const onClick = vi.fn();
render(
<Button variant="cream" disabled onClick={onClick}>
Create
</Button>,
);
const button = screen.getByRole("button", { name: "Create" });
expect(button).toBeDisabled();
expect(button.className).toContain("disabled:opacity-50");
});
});
+34
View File
@@ -0,0 +1,34 @@
import type { ButtonHTMLAttributes } from "react";
type Variant = "cream" | "coral" | "ghost" | "icon" | "launcher";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant: Variant;
}
/* The reference system's two primaries (measured): CREAM for actions
(send/create/invite — near-white fill, dark text, soft lift) and CORAL
for wizard/connect CTAs (coral fill, inset highlight + glow). `icon` is
the 36px round header button; `launcher` is the 42px coral-glow
Computer opener. All pills; all on the 0.25s ease-app transition. */
const VARIANTS: Record<Variant, string> = {
cream:
"rounded-radius-button bg-cream px-4 py-2 text-sm font-semibold text-primary-foreground shadow-button transition-colors duration-normal ease-app hover:bg-white active:translate-y-px disabled:opacity-50 disabled:shadow-none",
coral:
"rounded-radius-button bg-coral px-4 py-2 text-sm font-medium text-white shadow-cta transition-colors duration-normal ease-app hover:bg-coral-light active:bg-coral-dark disabled:opacity-50",
ghost:
"rounded-radius-button px-3 py-1.5 text-sm text-muted-foreground transition-colors duration-normal ease-app hover:bg-hover-bg hover:text-foreground disabled:opacity-50",
icon: "inline-flex size-9 items-center justify-center rounded-radius-button text-muted-foreground transition-colors duration-normal ease-app hover:bg-hover-bg hover:text-foreground disabled:opacity-50",
launcher:
"inline-flex size-[42px] items-center justify-center rounded-radius-button text-coral shadow-launcher transition-all duration-normal ease-app hover:bg-coral/10 active:scale-95 disabled:opacity-50",
};
export function Button({ variant, className, ...rest }: ButtonProps) {
return (
<button
type="button"
className={`${VARIANTS[variant]} disabled:opacity-50${className ? ` ${className}` : ""}`}
{...rest}
/>
);
}
+49
View File
@@ -0,0 +1,49 @@
import type { HTMLAttributes } from "react";
/* The reference card pattern (measured): 24px radius, page-black fill,
1px neutral border, 24px padding, ambient card shadow; border lightens
on hover for interactive cards. */
export function Card({
className,
interactive = false,
...rest
}: HTMLAttributes<HTMLDivElement> & { interactive?: boolean }) {
return (
<div
className={`rounded-3xl border border-border bg-card p-6 shadow-card${
interactive
? " transition-colors duration-normal ease-app hover:border-neutral-700"
: ""
}${className ? ` ${className}` : ""}`}
{...rest}
/>
);
}
/* Section label (measured): small caps, tracked, muted — heads grouped
option lists ("WHO CAN USE THESE KEYS?"). */
export function SectionLabel({
className,
...rest
}: HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={`text-[11px] font-medium tracking-wider text-subtle-foreground uppercase${className ? ` ${className}` : ""}`}
{...rest}
/>
);
}
/* Grouped rows card (measured): 12px radius, raised fill, rows divided by
subtle hairlines — Settings/Add-Custom-App option groups. */
export function GroupedRows({
className,
...rest
}: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={`divide-y divide-divider-subtle overflow-hidden rounded-xl bg-surface-warm${className ? ` ${className}` : ""}`}
{...rest}
/>
);
}
@@ -0,0 +1,39 @@
import { render } from "@testing-library/react";
import { Zap } from "lucide-react";
import { describe, expect, it } from "vitest";
import { GradientGlyph } from "./GradientGlyph";
describe("GradientGlyph", () => {
it("wraps a lucide icon with a coral gradient applied as stroke", () => {
const { container } = render(<GradientGlyph icon={Zap} size={24} />);
const svg = container.querySelector("svg");
expect(svg).not.toBeNull();
const gradient = container.querySelector("linearGradient");
expect(gradient).not.toBeNull();
const stops = container.querySelectorAll("stop");
expect(stops.length).toBeGreaterThanOrEqual(2);
// The glyph references its own gradient id (per-instance, collision-free).
const id = gradient!.getAttribute("id")!;
const iconSvg = container.querySelectorAll("svg")[1]!;
expect(iconSvg.getAttribute("stroke")).toBe(`url(#${id})`);
});
it("two instances get distinct gradient ids", () => {
const { container } = render(
<>
<GradientGlyph icon={Zap} />
<GradientGlyph icon={Zap} />
</>,
);
const ids = Array.from(container.querySelectorAll("linearGradient")).map(
(node) => node.getAttribute("id"),
);
expect(new Set(ids).size).toBe(2);
});
it("is decorative by default (aria-hidden)", () => {
const { container } = render(<GradientGlyph icon={Zap} />);
expect(container.querySelector("[aria-hidden]")).not.toBeNull();
});
});
@@ -0,0 +1,40 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { useId } from "react";
interface GradientGlyphProps {
icon: LucideIcon;
size?: number;
/** Gradient stops; defaults to the coral tile pair (measured). */
from?: string;
to?: string;
strokeWidth?: number;
}
/* The reference's `mvp4-glyph-gradient` technique: a lucide glyph whose
stroke references a per-instance linearGradient, giving every in-app
icon the soft coral tint. useId keeps ids collision-free across
instances and SSR. */
export function GradientGlyph({
icon: Icon,
size = 24,
from = "var(--color-gradient-coral-top)",
to = "var(--color-gradient-coral-bottom)",
strokeWidth = 2,
}: GradientGlyphProps) {
const id = `glyph-gradient-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`;
return (
<span aria-hidden className="inline-flex">
<svg width={0} height={0} className="absolute">
<defs>
<linearGradient id={id} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={from} />
<stop offset="100%" stopColor={to} />
</linearGradient>
</defs>
</svg>
<Icon size={size} stroke={`url(#${id})`} strokeWidth={strokeWidth} />
</span>
);
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
import { Search } from "lucide-react";
import type { InputHTMLAttributes } from "react";
/* Rounded search pill (measured): full radius, raised fill, leading
search glyph, muted placeholder. */
export function SearchPill({
className,
...rest
}: InputHTMLAttributes<HTMLInputElement>) {
return (
<span
className={`flex items-center gap-2 rounded-radius-button bg-surface-warm-muted px-3 py-1.5${className ? ` ${className}` : ""}`}
>
<Search aria-hidden size={14} className="shrink-0 text-subtle-foreground" />
<input
type="search"
className="w-full bg-transparent text-sm text-foreground outline-none placeholder:text-subtle-foreground"
{...rest}
/>
</span>
);
}
@@ -0,0 +1,25 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { SegmentedTabs } from "./SegmentedTabs";
const TABS = ["Members", "Claw org chart", "Leaderboard"];
describe("SegmentedTabs", () => {
it("renders a tablist where the active tab is the filled chip", () => {
render(<SegmentedTabs tabs={TABS} active="Members" onChange={() => {}} />);
const active = screen.getByRole("tab", { name: "Members", selected: true });
expect(active.className).toContain("bg-surface-warm-muted");
const inactive = screen.getByRole("tab", { name: "Leaderboard" });
expect(inactive).toHaveAttribute("aria-selected", "false");
expect(inactive.className).toContain("text-muted-foreground");
});
it("clicking a tab reports the change", async () => {
const onChange = vi.fn();
render(<SegmentedTabs tabs={TABS} active="Members" onChange={onChange} />);
await userEvent.click(screen.getByRole("tab", { name: "Leaderboard" }));
expect(onChange).toHaveBeenCalledWith("Leaderboard");
});
});
@@ -0,0 +1,42 @@
"use client";
interface SegmentedTabsProps {
tabs: readonly string[];
active: string;
onChange: (tab: string) => void;
label?: string;
}
/* Segmented pill tabs (measured): active = raised filled chip, inactive =
muted text; the whole group sits in a rounded container. */
export function SegmentedTabs({
tabs,
active,
onChange,
label,
}: SegmentedTabsProps) {
return (
<div
role="tablist"
aria-label={label}
className="inline-flex gap-1 rounded-radius-button bg-subtle p-1"
>
{tabs.map((tab) => (
<button
key={tab}
type="button"
role="tab"
aria-selected={tab === active}
onClick={() => onChange(tab)}
className={`rounded-radius-button px-3 py-1 text-xs font-medium transition-colors duration-normal ease-app ${
tab === active
? "bg-surface-warm-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
</button>
))}
</div>
);
}
+103
View File
@@ -116,6 +116,109 @@
to { opacity: 0; } to { opacity: 0; }
} }
/* ---- Computer-panel app window choreography (measured, docs/motion.md +
computer-window-animations.md). Launch zooms out of the tapped tile
(transform-origin + --app-origin-scale set inline from the tile rect);
drill-ins push/pop; sibling tab switches cross-fade. ~0.28s ease-app. */
@keyframes app-shell-zoom-in {
0% {
opacity: 0;
transform: scale(var(--app-origin-scale, 0.7));
border-radius: 32px;
}
40% { opacity: 1; }
100% {
opacity: 1;
transform: scale(1);
border-radius: var(--app-shell-rest-radius, 0px);
}
}
@keyframes app-shell-zoom-out {
0% {
transform: scale(1);
border-radius: var(--app-shell-rest-radius, 0px);
}
100% {
transform: scale(var(--app-origin-scale, 0.7));
border-radius: 32px;
}
}
@keyframes app-shell-fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes app-shell-fade-out {
0% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes app-shell-slide-in-right {
0% { opacity: 0.5; transform: translate(30%); }
100% { opacity: 1; transform: translate(0); }
}
@keyframes app-shell-slide-in-left {
0% { opacity: 0.5; transform: translate(-30%); }
100% { opacity: 1; transform: translate(0); }
}
@keyframes app-shell-cross-fade-in {
0% { opacity: 0; transform: scale(0.985); }
100% { opacity: 1; transform: scale(1); }
}
.app-shell-anim-launch {
animation: app-shell-zoom-in 0.28s var(--ease-app) both;
}
.app-shell-anim-close {
animation:
app-shell-zoom-out 0.28s var(--ease-app) both,
app-shell-fade-out 0.28s var(--ease-app) both;
}
.app-shell-anim-push-in {
animation: app-shell-slide-in-right 0.28s var(--ease-app) both;
}
.app-shell-anim-pop-in {
animation: app-shell-slide-in-left 0.28s var(--ease-app) both;
}
.app-shell-anim-cross-fade {
animation: app-shell-cross-fade-in 0.28s var(--ease-app) both;
}
/* Message entrance (measured): settle up with a slight rise. */
@keyframes messageSlideIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Create-claw wizard steps + swatches (measured). */
@keyframes create-claw-step-slide-in-right {
from { opacity: 0; transform: translateX(48px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes create-claw-step-slide-in-left {
from { opacity: 0; transform: translateX(-48px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes create-claw-swatch-enter {
from { transform: scale(0.85); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
/* Device provisioning choreography (measured device-* set). */
@keyframes device-stagger-scale-fade {
from { transform: scale(0.55); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes device-slide-up {
from { transform: translateY(24px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes device-app-enter {
from { transform: scale(0.85); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes device-system-progress {
from { transform: translateX(-100%); }
to { transform: translateX(300%); }
}
/* The width-animation pattern shared by every slide-out (§3): the track /* The width-animation pattern shared by every slide-out (§3): the track
animates between 0 and a fixed target width while the inner content keeps animates between 0 and a fixed target width while the inner content keeps
its final width, so children never reflow mid-animation. */ its final width, so children never reflow mid-animation. */