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:
co-authored by
Claude Fable 5
parent
447f7039d8
commit
3ec1d3b829
Generated
+10
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user