mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
shell-redesign V1 attempt-3 Phase 1: Foundation + 9 primitives
Phase 1 of the V1 shell redesign attempt-3, per qitem-20260506173041-56702b70 from orch-lead@openrig-velocity. Drift-disclosed structured-ACK + GO at substrate/shared-docs/openrig-work/missions/recursive-self-improvement-v2/ shell-redesign-v1/attempt-3-driver-3-phase-1-ack.md. Verify-and-reconcile (DRIFT-1): most design tokens already present at baseline24db2b0(preserved through prior attempt reverts via commits64afb7f+e316116). Phase 1 reconciles two specific deltas: - DRIFT-2 fix: .reg-tl is now self-contained matching .reg-tr/bl/br. Each pseudo-element carries its own content + position + bg-color. Parent-qualified .reg-mark > .reg-tl rule preserved as harmless redundancy for the existing RigGraph.tsx:421 consumer. - DRIFT-3 fix: paper-grid radial-gradient bumped from 0.5px → 1px (Option A). Visible at standard browser zoom; runtime-verified via getComputedStyle. Authored 9 vellum primitives at packages/ui/src/components/ui/: - registration-marks.tsx — 4-corner crosshair primitive - vellum-card.tsx — primary card chrome (lifted grammar from RigCard.tsx) - vellum-sheet.tsx — drawer/overlay translucent paper chrome - section-header.tsx — canonical eyebrow - status-pip.tsx — semantic 6-status indicator - empty-state.tsx — unified empty/loading/error; variant=card composes VellumCard - vellum-input.tsx — vellum form input (forwardRef) - vellum-select.tsx — vellum form select (forwardRef) - rig-stamp.tsx — rotated faded watermark 82 unit tests across 9 test files, including: - CSS-source-assertion regression test for DRIFT-2 (jsdom can't render pseudo-elements; test reads globals.css directly; pseudo-element-paint test contract per discipline ritual #7). - Negative-assertion test on StatusPip (does NOT render workspace-kind taxonomy labels; discipline ritual #8). - DRIFT-3 paper-grid radius regression (asserts 1px in globals.css). OUT-OF-SCOPE confirmed not touched: AppShell, Explorer, SharedDetailDrawer, Sidebar (deletion), Dashboard, routes.tsx, all destination subdirs (for-you/, topology/, project/, specs/, dashboard/, drawer/, chat/), drawer viewers/ triggers, mobile responsive, consumer migration, daemon code (SC-29 satisfied vacuously), CLI, existing canonical preserved components, existing shadcn primitives. Verification gates: - tsc --noEmit: clean - vitest: 648/649 (single failure in test/library-review.test.tsx is pre-existing on baseline24db2b0, unrelated to Phase 1) - vite build: succeeded in 1.98s - agent-browser visual walk at 1440x900: paper-grid renders; no chrome regression on existing routes Founder walk packet authored at: substrate/shared-docs/openrig-work/missions/recursive-self-improvement-v2/ shell-redesign-v1/founder-walks/phase-1-walk.md Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
24db2b09e5
commit
9827de6a88
19 files changed
+1171
-3
No files matched your search
@@ -0,0 +1,89 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { VellumCard } from "./vellum-card";
|
||||
|
||||
export type EmptyStateVariant = "minimal" | "card";
|
||||
|
||||
export interface EmptyStateAction {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
description?: React.ReactNode;
|
||||
action?: EmptyStateAction;
|
||||
variant?: EmptyStateVariant;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function ActionButton({ action, testId }: { action: EmptyStateAction; testId?: string }) {
|
||||
const cls =
|
||||
"inline-flex items-center px-3 py-1 border border-stone-900 bg-white font-mono text-[10px] uppercase hover:bg-stone-100";
|
||||
if (action.href) {
|
||||
return (
|
||||
<a href={action.href} className={cls} data-testid={testId ? `${testId}-action` : undefined}>
|
||||
{action.label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className={cls}
|
||||
data-testid={testId ? `${testId}-action` : undefined}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
label,
|
||||
description,
|
||||
action,
|
||||
variant = "minimal",
|
||||
className,
|
||||
testId,
|
||||
}: EmptyStateProps) {
|
||||
const body = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center text-center p-6 gap-2",
|
||||
className,
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{icon ? (
|
||||
<div className="text-on-surface-variant" aria-hidden="true">
|
||||
{typeof icon === "string" ? <span className="text-2xl">{icon}</span> : icon}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="font-headline font-bold uppercase text-stone-900 text-sm tracking-tight">
|
||||
{label}
|
||||
</div>
|
||||
{description ? (
|
||||
<div className="text-xs text-on-surface-variant max-w-prose">{description}</div>
|
||||
) : null}
|
||||
{action ? (
|
||||
<div className="pt-2">
|
||||
<ActionButton action={action} testId={testId} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === "card") {
|
||||
return (
|
||||
<VellumCard variant="ghost" elevation="flat" registrationMarks={false}>
|
||||
{body}
|
||||
</VellumCard>
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface RegistrationMarksProps {
|
||||
testIdPrefix?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RegistrationMarks({ testIdPrefix, className }: RegistrationMarksProps) {
|
||||
const tid = (corner: "tl" | "tr" | "bl" | "br") =>
|
||||
testIdPrefix ? `${testIdPrefix}-reg-${corner}` : undefined;
|
||||
return (
|
||||
<>
|
||||
<span className={cn("reg-tl", className)} data-testid={tid("tl")} aria-hidden="true" />
|
||||
<span className={cn("reg-tr", className)} data-testid={tid("tr")} aria-hidden="true" />
|
||||
<span className={cn("reg-bl", className)} data-testid={tid("bl")} aria-hidden="true" />
|
||||
<span className={cn("reg-br", className)} data-testid={tid("br")} aria-hidden="true" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type RigStampSize = "sm" | "md" | "lg" | "xl";
|
||||
|
||||
export interface RigStampPosition {
|
||||
top?: number | string;
|
||||
left?: number | string;
|
||||
right?: number | string;
|
||||
bottom?: number | string;
|
||||
}
|
||||
|
||||
export interface RigStampProps {
|
||||
text: string;
|
||||
position?: RigStampPosition;
|
||||
size?: RigStampSize;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
const sizeClass: Record<RigStampSize, string> = {
|
||||
sm: "text-[10px]",
|
||||
md: "text-sm",
|
||||
lg: "text-2xl",
|
||||
xl: "text-4xl",
|
||||
};
|
||||
|
||||
function toCss(v: number | string | undefined): string | undefined {
|
||||
if (v === undefined) return undefined;
|
||||
return typeof v === "number" ? `${v}px` : v;
|
||||
}
|
||||
|
||||
export function RigStamp({
|
||||
text,
|
||||
position,
|
||||
size = "md",
|
||||
className,
|
||||
testId,
|
||||
}: RigStampProps) {
|
||||
const style: React.CSSProperties = {
|
||||
top: toCss(position?.top),
|
||||
left: toCss(position?.left),
|
||||
right: toCss(position?.right),
|
||||
bottom: toCss(position?.bottom),
|
||||
};
|
||||
return (
|
||||
<span
|
||||
data-testid={testId}
|
||||
className={cn("stamp-watermark", sizeClass[size], className)}
|
||||
style={style}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SectionHeaderTone = "default" | "muted" | "emphasis";
|
||||
|
||||
type PolymorphicAs = "header" | "div" | "section";
|
||||
|
||||
export interface SectionHeaderProps extends React.HTMLAttributes<HTMLElement> {
|
||||
children?: React.ReactNode;
|
||||
right?: React.ReactNode;
|
||||
tone?: SectionHeaderTone;
|
||||
as?: PolymorphicAs;
|
||||
}
|
||||
|
||||
const toneClass: Record<SectionHeaderTone, string> = {
|
||||
default: "text-stone-900",
|
||||
muted: "text-on-surface-variant",
|
||||
emphasis: "text-tertiary",
|
||||
};
|
||||
|
||||
export function SectionHeader({
|
||||
children,
|
||||
right,
|
||||
tone = "default",
|
||||
as = "header",
|
||||
className,
|
||||
...rest
|
||||
}: SectionHeaderProps) {
|
||||
const cls = cn(
|
||||
"font-mono text-[10px] uppercase tracking-[0.18em] flex items-baseline justify-between gap-2",
|
||||
toneClass[tone],
|
||||
className,
|
||||
);
|
||||
const inner = (
|
||||
<>
|
||||
<span className="flex-1">{children}</span>
|
||||
{right ? <span className="ml-auto flex items-center gap-2">{right}</span> : null}
|
||||
</>
|
||||
);
|
||||
if (as === "div") return <div className={cls} {...rest}>{inner}</div>;
|
||||
if (as === "section") return <section className={cls} {...rest}>{inner}</section>;
|
||||
return <header className={cls} {...rest}>{inner}</header>;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type StatusPipStatus =
|
||||
| "active"
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "info";
|
||||
|
||||
export type StatusPipVariant = "dot" | "pill";
|
||||
|
||||
export interface StatusPipProps {
|
||||
status: StatusPipStatus;
|
||||
label?: string;
|
||||
variant?: StatusPipVariant;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
const toneDot: Record<StatusPipStatus, string> = {
|
||||
active: "bg-success",
|
||||
running: "bg-success",
|
||||
stopped: "bg-stone-400",
|
||||
warning: "bg-warning",
|
||||
error: "bg-tertiary",
|
||||
info: "bg-secondary",
|
||||
};
|
||||
|
||||
const tonePill: Record<StatusPipStatus, string> = {
|
||||
active: "border-success text-success",
|
||||
running: "border-success text-success",
|
||||
stopped: "border-stone-400 text-stone-500",
|
||||
warning: "border-warning text-warning",
|
||||
error: "border-tertiary text-tertiary",
|
||||
info: "border-secondary text-secondary",
|
||||
};
|
||||
|
||||
export function StatusPip({
|
||||
status,
|
||||
label,
|
||||
variant = "dot",
|
||||
className,
|
||||
testId,
|
||||
}: StatusPipProps) {
|
||||
if (variant === "pill") {
|
||||
return (
|
||||
<span
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 border font-mono text-[9px] uppercase tracking-wide",
|
||||
tonePill[status],
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
aria-label={label ?? status}
|
||||
>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", toneDot[status])} aria-hidden="true" />
|
||||
{label ?? status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
data-testid={testId}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
role="status"
|
||||
aria-label={label ?? status}
|
||||
>
|
||||
<span className={cn("w-2 h-2 rounded-full", toneDot[status])} aria-hidden="true" />
|
||||
{label ? (
|
||||
<span className="font-mono text-[9px] uppercase tracking-wide text-on-surface-variant">
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RegistrationMarks } from "./registration-marks";
|
||||
|
||||
export type VellumCardElevation = "elevated" | "flat";
|
||||
export type VellumCardVariant = "primary" | "ghost";
|
||||
|
||||
type PolymorphicAs = "div" | "article" | "section";
|
||||
|
||||
export interface VellumCardProps extends React.HTMLAttributes<HTMLElement> {
|
||||
children?: React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
registrationMarks?: boolean;
|
||||
elevation?: VellumCardElevation;
|
||||
variant?: VellumCardVariant;
|
||||
accentClass?: string;
|
||||
href?: string;
|
||||
as?: PolymorphicAs;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
const variantSurface: Record<VellumCardVariant, string> = {
|
||||
primary: "bg-white border border-stone-900",
|
||||
ghost: "bg-transparent border border-stone-300",
|
||||
};
|
||||
|
||||
const elevationShadow: Record<VellumCardElevation, string> = {
|
||||
elevated: "hard-shadow",
|
||||
flat: "",
|
||||
};
|
||||
|
||||
export function VellumCard({
|
||||
children,
|
||||
header,
|
||||
registrationMarks = true,
|
||||
elevation = "elevated",
|
||||
variant = "primary",
|
||||
accentClass,
|
||||
href,
|
||||
as = "div",
|
||||
className,
|
||||
testId,
|
||||
...rest
|
||||
}: VellumCardProps) {
|
||||
const containerClass = cn(
|
||||
variantSurface[variant],
|
||||
elevationShadow[elevation],
|
||||
"flex flex-col relative",
|
||||
accentClass,
|
||||
className,
|
||||
);
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
{header ? (
|
||||
<div className="bg-stone-900 text-white px-4 py-1.5 font-mono text-[10px]">
|
||||
{header}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col flex-1">{children}</div>
|
||||
{registrationMarks ? <RegistrationMarks testIdPrefix={testId} /> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={containerClass}
|
||||
data-testid={testId}
|
||||
{...(rest as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (as === "article") {
|
||||
return (
|
||||
<article className={containerClass} data-testid={testId} {...rest}>
|
||||
{inner}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
if (as === "section") {
|
||||
return (
|
||||
<section className={containerClass} data-testid={testId} {...rest}>
|
||||
{inner}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={containerClass} data-testid={testId} {...rest}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface VellumInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export const VellumInput = React.forwardRef<HTMLInputElement, VellumInputProps>(
|
||||
({ className, testId, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"block w-full border border-stone-300 bg-white px-2 py-1 font-mono text-xs text-stone-900",
|
||||
"hover:bg-stone-50 focus:border-stone-900 focus:bg-white focus:outline-none",
|
||||
"disabled:bg-stone-100 disabled:text-stone-400 disabled:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
VellumInput.displayName = "VellumInput";
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface VellumSelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
testId?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const VellumSelect = React.forwardRef<HTMLSelectElement, VellumSelectProps>(
|
||||
({ className, testId, children, ...props }, ref) => {
|
||||
return (
|
||||
<select
|
||||
ref={ref}
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"block w-full border border-stone-300 bg-white px-2 py-1 font-mono text-xs text-stone-900",
|
||||
"hover:bg-stone-50 focus:border-stone-900 focus:bg-white focus:outline-none",
|
||||
"disabled:bg-stone-100 disabled:text-stone-400 disabled:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
},
|
||||
);
|
||||
VellumSelect.displayName = "VellumSelect";
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RegistrationMarks } from "./registration-marks";
|
||||
|
||||
export type VellumSheetEdge = "left" | "right";
|
||||
export type VellumSheetWidth = "wide" | "narrow";
|
||||
|
||||
export interface VellumSheetProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode;
|
||||
edge?: VellumSheetEdge;
|
||||
width?: VellumSheetWidth;
|
||||
onClose?: () => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
const widthClass: Record<VellumSheetWidth, string> = {
|
||||
wide: "w-full lg:w-[45rem] lg:max-w-[80vw]",
|
||||
narrow: "w-full lg:w-[22rem] lg:max-w-[60vw]",
|
||||
};
|
||||
|
||||
const edgeClass: Record<VellumSheetEdge, string> = {
|
||||
left: "border-r-2 border-stone-900",
|
||||
right: "border-l-2 border-stone-900",
|
||||
};
|
||||
|
||||
export function VellumSheet({
|
||||
children,
|
||||
edge = "right",
|
||||
width = "wide",
|
||||
onClose,
|
||||
className,
|
||||
testId,
|
||||
...rest
|
||||
}: VellumSheetProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"vellum-heavy relative flex flex-col h-full shadow-[0_0_24px_rgba(0,0,0,0.08)]",
|
||||
widthClass[width],
|
||||
edgeClass[edge],
|
||||
className,
|
||||
)}
|
||||
data-testid={testId}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
{...rest}
|
||||
>
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close sheet"
|
||||
className="absolute top-2 right-2 z-10 px-2 py-0.5 border border-stone-900 bg-white font-mono text-[10px] hover:bg-stone-100"
|
||||
data-testid={testId ? `${testId}-close` : undefined}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
<div className="flex-1 overflow-auto">{children}</div>
|
||||
<RegistrationMarks testIdPrefix={testId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +70,7 @@
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* Blueprint dot grid — the paper texture */
|
||||
background-image: radial-gradient(#d1d1cf 0.5px, transparent 0.5px);
|
||||
background-image: radial-gradient(#d1d1cf 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
@@ -91,8 +91,8 @@
|
||||
.reg-mark > .reg-tl::after { content: ''; position: absolute; background-color: #546073; }
|
||||
|
||||
.reg-tl { position: absolute; top: 0; left: 0; width: 0; height: 0; }
|
||||
.reg-tl::before { top: 0; left: 0; width: 1px; height: 8px; }
|
||||
.reg-tl::after { top: 0; left: 0; width: 8px; height: 1px; }
|
||||
.reg-tl::before { content: ''; position: absolute; top: 0; left: 0; width: 1px; height: 8px; background-color: #546073; }
|
||||
.reg-tl::after { content: ''; position: absolute; top: 0; left: 0; width: 8px; height: 1px; background-color: #546073; }
|
||||
|
||||
.reg-tr { position: absolute; top: 0; right: 0; width: 0; height: 0; }
|
||||
.reg-tr::before { content: ''; position: absolute; top: 0; right: 0; width: 1px; height: 8px; background-color: #546073; }
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// V1 Shell Redesign — Phase 1 — EmptyState primitive.
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { EmptyState } from "../src/components/ui/empty-state";
|
||||
|
||||
describe("EmptyState (Phase 1 primitive)", () => {
|
||||
it("renders label", () => {
|
||||
render(<EmptyState label="NO ITEMS" />);
|
||||
expect(screen.getByText("NO ITEMS")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders description when provided", () => {
|
||||
render(<EmptyState label="X" description="More detail here" />);
|
||||
expect(screen.getByText("More detail here")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("string icon rendered as text", () => {
|
||||
render(<EmptyState label="X" icon="📭" testId="es" />);
|
||||
expect(screen.getByTestId("es").textContent).toContain("📭");
|
||||
});
|
||||
|
||||
it("ReactNode icon rendered as element", () => {
|
||||
render(
|
||||
<EmptyState
|
||||
label="X"
|
||||
icon={<span data-testid="ic">[icon]</span>}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("ic")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("action with onClick renders button and fires", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<EmptyState label="X" action={{ label: "DO IT", onClick }} testId="es-b" />,
|
||||
);
|
||||
const btn = screen.getByTestId("es-b-action");
|
||||
expect(btn.tagName).toBe("BUTTON");
|
||||
fireEvent.click(btn);
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("action with href renders <a>", () => {
|
||||
render(
|
||||
<EmptyState label="X" action={{ label: "GO", href: "/foo" }} testId="es-l" />,
|
||||
);
|
||||
const link = screen.getByTestId("es-l-action") as HTMLAnchorElement;
|
||||
expect(link.tagName).toBe("A");
|
||||
expect(link.getAttribute("href")).toBe("/foo");
|
||||
});
|
||||
|
||||
it("variant=card wraps in a VellumCard ghost+flat composition", () => {
|
||||
const { container } = render(
|
||||
<EmptyState label="X" variant="card" testId="es-c" />,
|
||||
);
|
||||
const body = container.querySelector("[data-testid='es-c']") as HTMLElement;
|
||||
// The VellumCard wrapper renders the body inside an inner flex container,
|
||||
// so VellumCard root = body.parentElement.parentElement.
|
||||
const wrapper = body.parentElement?.parentElement as HTMLElement;
|
||||
expect(wrapper.className).toContain("bg-transparent");
|
||||
expect(wrapper.className).toContain("border-stone-300");
|
||||
// Flat elevation = no hard-shadow.
|
||||
expect(wrapper.className).not.toContain("hard-shadow");
|
||||
});
|
||||
|
||||
it("variant=minimal does NOT wrap in VellumCard", () => {
|
||||
const { container } = render(<EmptyState label="X" testId="es-m" />);
|
||||
const root = container.querySelector("[data-testid='es-m']") as HTMLElement;
|
||||
// No vellum-card wrapper around the testid.
|
||||
const parent = root.parentElement as HTMLElement;
|
||||
expect(parent.className ?? "").not.toContain("bg-transparent");
|
||||
expect(parent.className ?? "").not.toContain("border-stone-900");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// V1 Shell Redesign — Phase 1 — RegistrationMarks primitive.
|
||||
//
|
||||
// API surface tests + CSS-source-assertion regression test for the
|
||||
// pseudo-element-paint contract (discipline ritual #7).
|
||||
//
|
||||
// The CSS-source-assertion test enforces DRIFT-2 fix: each corner
|
||||
// (.reg-tl, .reg-tr, .reg-bl, .reg-br) must have its own ::before and
|
||||
// ::after rules carrying content + position + background-color, so each
|
||||
// corner renders symmetrically without requiring a .reg-mark parent.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { RegistrationMarks } from "../src/components/ui/registration-marks";
|
||||
|
||||
const GLOBALS_CSS = readFileSync(
|
||||
path.resolve(__dirname, "../src/globals.css"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("RegistrationMarks (Phase 1 primitive)", () => {
|
||||
it("renders 4 corner spans (tl/tr/bl/br)", () => {
|
||||
const { container } = render(<RegistrationMarks />);
|
||||
expect(container.querySelector(".reg-tl")).toBeTruthy();
|
||||
expect(container.querySelector(".reg-tr")).toBeTruthy();
|
||||
expect(container.querySelector(".reg-bl")).toBeTruthy();
|
||||
expect(container.querySelector(".reg-br")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("attaches testIds when testIdPrefix is provided", () => {
|
||||
const { getByTestId } = render(<RegistrationMarks testIdPrefix="card-x" />);
|
||||
expect(getByTestId("card-x-reg-tl")).toBeTruthy();
|
||||
expect(getByTestId("card-x-reg-tr")).toBeTruthy();
|
||||
expect(getByTestId("card-x-reg-bl")).toBeTruthy();
|
||||
expect(getByTestId("card-x-reg-br")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("omits testIds when testIdPrefix not provided", () => {
|
||||
const { container } = render(<RegistrationMarks />);
|
||||
const tl = container.querySelector(".reg-tl") as HTMLElement | null;
|
||||
expect(tl?.dataset.testid).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges className onto every corner span", () => {
|
||||
const { container } = render(<RegistrationMarks className="opacity-50" />);
|
||||
const corners = container.querySelectorAll(
|
||||
".reg-tl, .reg-tr, .reg-bl, .reg-br",
|
||||
);
|
||||
expect(corners.length).toBe(4);
|
||||
corners.forEach((c) => {
|
||||
expect((c as HTMLElement).className).toContain("opacity-50");
|
||||
});
|
||||
});
|
||||
|
||||
it("each corner span carries aria-hidden true", () => {
|
||||
const { container } = render(<RegistrationMarks />);
|
||||
const corners = container.querySelectorAll(
|
||||
".reg-tl, .reg-tr, .reg-bl, .reg-br",
|
||||
);
|
||||
corners.forEach((c) => {
|
||||
expect(c.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// CSS-source-assertion: pseudo-element-paint test contract.
|
||||
//
|
||||
// jsdom does not paint pseudo-elements, so we verify the CSS source
|
||||
// directly. Each corner class MUST carry self-contained ::before and
|
||||
// ::after rules with content, position, and background-color — the
|
||||
// DRIFT-2 fix from V1 attempt-3 dispatch.
|
||||
describe("globals.css registration-mark CSS source (DRIFT-2 regression)", () => {
|
||||
const corners = ["reg-tl", "reg-tr", "reg-bl", "reg-br"] as const;
|
||||
|
||||
for (const c of corners) {
|
||||
it(`.${c}::before is self-contained (content + position + bg-color)`, () => {
|
||||
// Match the standalone selector — NOT the .reg-mark > .${c} parent-qualified one.
|
||||
const re = new RegExp(
|
||||
`(^|[^>\\s])\\s*\\.${c}::before\\s*\\{[^}]*content:\\s*'';[^}]*position:\\s*absolute;[^}]*background-color:\\s*#546073;`,
|
||||
"m",
|
||||
);
|
||||
expect(GLOBALS_CSS).toMatch(re);
|
||||
});
|
||||
it(`.${c}::after is self-contained (content + position + bg-color)`, () => {
|
||||
const re = new RegExp(
|
||||
`(^|[^>\\s])\\s*\\.${c}::after\\s*\\{[^}]*content:\\s*'';[^}]*position:\\s*absolute;[^}]*background-color:\\s*#546073;`,
|
||||
"m",
|
||||
);
|
||||
expect(GLOBALS_CSS).toMatch(re);
|
||||
});
|
||||
}
|
||||
|
||||
it("paper-grid radial-gradient uses 1px dot radius (DRIFT-3)", () => {
|
||||
expect(GLOBALS_CSS).toMatch(
|
||||
/radial-gradient\(#d1d1cf\s+1px,\s*transparent\s+1px\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// V1 Shell Redesign — Phase 1 — RigStamp primitive.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { RigStamp } from "../src/components/ui/rig-stamp";
|
||||
|
||||
describe("RigStamp (Phase 1 primitive)", () => {
|
||||
it("renders the text", () => {
|
||||
render(<RigStamp text="VELOCITY" />);
|
||||
expect(screen.getByText("VELOCITY")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies stamp-watermark class", () => {
|
||||
const { container } = render(<RigStamp text="X" testId="rs" />);
|
||||
const el = container.querySelector("[data-testid='rs']") as HTMLElement;
|
||||
expect(el.className).toContain("stamp-watermark");
|
||||
});
|
||||
|
||||
it("size=md is default and applies text-sm", () => {
|
||||
const { container } = render(<RigStamp text="X" testId="rs-md" />);
|
||||
const el = container.querySelector("[data-testid='rs-md']") as HTMLElement;
|
||||
expect(el.className).toContain("text-sm");
|
||||
});
|
||||
|
||||
it("size=xl applies text-4xl", () => {
|
||||
const { container } = render(<RigStamp text="X" size="xl" testId="rs-xl" />);
|
||||
const el = container.querySelector("[data-testid='rs-xl']") as HTMLElement;
|
||||
expect(el.className).toContain("text-4xl");
|
||||
});
|
||||
|
||||
it("size=sm applies the small class", () => {
|
||||
const { container } = render(<RigStamp text="X" size="sm" testId="rs-sm" />);
|
||||
const el = container.querySelector("[data-testid='rs-sm']") as HTMLElement;
|
||||
expect(el.className).toContain("text-[10px]");
|
||||
});
|
||||
|
||||
it("position numeric values become px", () => {
|
||||
const { container } = render(
|
||||
<RigStamp text="X" position={{ top: 12, right: 8 }} testId="rs-p" />,
|
||||
);
|
||||
const el = container.querySelector("[data-testid='rs-p']") as HTMLElement;
|
||||
expect(el.style.top).toBe("12px");
|
||||
expect(el.style.right).toBe("8px");
|
||||
});
|
||||
|
||||
it("position string values pass through", () => {
|
||||
const { container } = render(
|
||||
<RigStamp text="X" position={{ left: "1rem", bottom: "auto" }} testId="rs-ps" />,
|
||||
);
|
||||
const el = container.querySelector("[data-testid='rs-ps']") as HTMLElement;
|
||||
expect(el.style.left).toBe("1rem");
|
||||
expect(el.style.bottom).toBe("auto");
|
||||
});
|
||||
|
||||
it("merges className", () => {
|
||||
const { container } = render(
|
||||
<RigStamp text="X" className="opacity-25" testId="rs-c" />,
|
||||
);
|
||||
const el = container.querySelector("[data-testid='rs-c']") as HTMLElement;
|
||||
expect(el.className).toContain("opacity-25");
|
||||
});
|
||||
|
||||
it("aria-hidden true (decorative)", () => {
|
||||
const { container } = render(<RigStamp text="X" testId="rs-a" />);
|
||||
const el = container.querySelector("[data-testid='rs-a']") as HTMLElement;
|
||||
expect(el.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// V1 Shell Redesign — Phase 1 — SectionHeader primitive.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SectionHeader } from "../src/components/ui/section-header";
|
||||
|
||||
describe("SectionHeader (Phase 1 primitive)", () => {
|
||||
it("renders children", () => {
|
||||
render(<SectionHeader>OVERVIEW</SectionHeader>);
|
||||
expect(screen.getByText("OVERVIEW")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies canonical eyebrow classes", () => {
|
||||
const { container } = render(<SectionHeader>X</SectionHeader>);
|
||||
const el = container.querySelector("header") as HTMLElement;
|
||||
expect(el.className).toContain("font-mono");
|
||||
expect(el.className).toContain("text-[10px]");
|
||||
expect(el.className).toContain("uppercase");
|
||||
expect(el.className).toContain("tracking-[0.18em]");
|
||||
});
|
||||
|
||||
it("renders right slot when provided", () => {
|
||||
render(
|
||||
<SectionHeader right={<span data-testid="rt">EXTRA</span>}>X</SectionHeader>,
|
||||
);
|
||||
expect(screen.getByTestId("rt")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("tone=muted applies on-surface-variant color", () => {
|
||||
const { container } = render(<SectionHeader tone="muted">X</SectionHeader>);
|
||||
expect((container.firstChild as HTMLElement).className).toContain(
|
||||
"text-on-surface-variant",
|
||||
);
|
||||
});
|
||||
|
||||
it("tone=emphasis applies tertiary color", () => {
|
||||
const { container } = render(<SectionHeader tone="emphasis">X</SectionHeader>);
|
||||
expect((container.firstChild as HTMLElement).className).toContain("text-tertiary");
|
||||
});
|
||||
|
||||
it("default as=header element", () => {
|
||||
const { container } = render(<SectionHeader>X</SectionHeader>);
|
||||
expect(container.querySelector("header")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("polymorphic as=div renders <div>", () => {
|
||||
const { container } = render(<SectionHeader as="div">X</SectionHeader>);
|
||||
expect(container.querySelector("header")).toBeNull();
|
||||
expect(container.querySelector("div")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("polymorphic as=section renders <section>", () => {
|
||||
const { container } = render(<SectionHeader as="section">X</SectionHeader>);
|
||||
expect(container.querySelector("section")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// V1 Shell Redesign — Phase 1 — StatusPip primitive.
|
||||
//
|
||||
// API surface + tone mapping + negative-assertion (discipline ritual #8):
|
||||
// status-pip is for SEMANTIC status only, not kind-taxonomy. Tests assert
|
||||
// it does not render workspace-kind labels by accident.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { StatusPip, type StatusPipStatus } from "../src/components/ui/status-pip";
|
||||
|
||||
const ALL_STATUSES: StatusPipStatus[] = [
|
||||
"active",
|
||||
"running",
|
||||
"stopped",
|
||||
"warning",
|
||||
"error",
|
||||
"info",
|
||||
];
|
||||
|
||||
describe("StatusPip (Phase 1 primitive)", () => {
|
||||
it("renders dot variant by default with role=status", () => {
|
||||
render(<StatusPip status="active" testId="sp" />);
|
||||
expect(screen.getByTestId("sp").getAttribute("role")).toBe("status");
|
||||
});
|
||||
|
||||
it.each(ALL_STATUSES)("dot variant tone for %s status maps to expected class", (status) => {
|
||||
const { container } = render(<StatusPip status={status} testId={`sp-${status}`} />);
|
||||
const root = container.querySelector(`[data-testid='sp-${status}']`) as HTMLElement;
|
||||
const dot = root.querySelector("span[aria-hidden]") as HTMLElement;
|
||||
if (status === "active" || status === "running") expect(dot.className).toContain("bg-success");
|
||||
if (status === "stopped") expect(dot.className).toContain("bg-stone-400");
|
||||
if (status === "warning") expect(dot.className).toContain("bg-warning");
|
||||
if (status === "error") expect(dot.className).toContain("bg-tertiary");
|
||||
if (status === "info") expect(dot.className).toContain("bg-secondary");
|
||||
});
|
||||
|
||||
it("pill variant includes label text", () => {
|
||||
render(<StatusPip status="error" label="FAILED" variant="pill" testId="sp-pill" />);
|
||||
const root = screen.getByTestId("sp-pill");
|
||||
expect(root.textContent).toContain("FAILED");
|
||||
});
|
||||
|
||||
it("pill variant tone classes apply to the wrapper", () => {
|
||||
const { container } = render(
|
||||
<StatusPip status="warning" variant="pill" testId="sp-w-pill" />,
|
||||
);
|
||||
const root = container.querySelector("[data-testid='sp-w-pill']") as HTMLElement;
|
||||
expect(root.className).toContain("border-warning");
|
||||
expect(root.className).toContain("text-warning");
|
||||
});
|
||||
|
||||
it("renders label in dot variant when provided", () => {
|
||||
render(<StatusPip status="active" label="LIVE" testId="sp-dlbl" />);
|
||||
expect(screen.getByText("LIVE")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("aria-label falls back to status when no label", () => {
|
||||
render(<StatusPip status="info" testId="sp-aria" />);
|
||||
expect(screen.getByTestId("sp-aria").getAttribute("aria-label")).toBe("info");
|
||||
});
|
||||
|
||||
// Negative-assertion (ritual #8): StatusPip is for semantic status, not kind taxonomy.
|
||||
// Make sure forbidden workspace-kind strings never bleed into the rendering.
|
||||
const FORBIDDEN_KIND_LABELS = ["user", "project", "knowledge", "lab", "delivery"];
|
||||
it("does NOT render workspace-kind taxonomy labels for any status", () => {
|
||||
for (const s of ALL_STATUSES) {
|
||||
const { container, unmount } = render(<StatusPip status={s} />);
|
||||
for (const kind of FORBIDDEN_KIND_LABELS) {
|
||||
expect(container.textContent ?? "").not.toMatch(new RegExp(`\\b${kind}\\b`, "i"));
|
||||
}
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// V1 Shell Redesign — Phase 1 — VellumCard primitive.
|
||||
//
|
||||
// API surface tests: default render, header, registrationMarks toggle,
|
||||
// elevation + variant variants, accentClass, href (Link wrapper),
|
||||
// polymorphic as prop, testId.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { VellumCard } from "../src/components/ui/vellum-card";
|
||||
|
||||
describe("VellumCard (Phase 1 primitive)", () => {
|
||||
it("renders children", () => {
|
||||
render(<VellumCard>HELLO</VellumCard>);
|
||||
expect(screen.getByText("HELLO")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("default container carries vellum classes (bg-white + border-stone-900 + hard-shadow)", () => {
|
||||
const { container } = render(<VellumCard testId="vc">x</VellumCard>);
|
||||
const el = container.querySelector("[data-testid='vc']") as HTMLElement;
|
||||
expect(el.className).toContain("bg-white");
|
||||
expect(el.className).toContain("border-stone-900");
|
||||
expect(el.className).toContain("hard-shadow");
|
||||
});
|
||||
|
||||
it("renders 4 registration mark corners by default", () => {
|
||||
const { container } = render(<VellumCard>x</VellumCard>);
|
||||
expect(container.querySelector(".reg-tl")).toBeTruthy();
|
||||
expect(container.querySelector(".reg-tr")).toBeTruthy();
|
||||
expect(container.querySelector(".reg-bl")).toBeTruthy();
|
||||
expect(container.querySelector(".reg-br")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("registrationMarks={false} omits all 4 corners", () => {
|
||||
const { container } = render(
|
||||
<VellumCard registrationMarks={false}>x</VellumCard>,
|
||||
);
|
||||
expect(container.querySelector(".reg-tl")).toBeNull();
|
||||
expect(container.querySelector(".reg-tr")).toBeNull();
|
||||
expect(container.querySelector(".reg-bl")).toBeNull();
|
||||
expect(container.querySelector(".reg-br")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders dark header stripe when header prop given", () => {
|
||||
render(<VellumCard header="RIG: VELOCITY">x</VellumCard>);
|
||||
expect(screen.getByText("RIG: VELOCITY")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("variant=ghost replaces primary surface classes", () => {
|
||||
const { container } = render(
|
||||
<VellumCard variant="ghost" testId="vc-g">x</VellumCard>,
|
||||
);
|
||||
const el = container.querySelector("[data-testid='vc-g']") as HTMLElement;
|
||||
expect(el.className).toContain("bg-transparent");
|
||||
expect(el.className).not.toContain("bg-white");
|
||||
});
|
||||
|
||||
it("elevation=flat omits hard-shadow", () => {
|
||||
const { container } = render(
|
||||
<VellumCard elevation="flat" testId="vc-f">x</VellumCard>,
|
||||
);
|
||||
const el = container.querySelector("[data-testid='vc-f']") as HTMLElement;
|
||||
expect(el.className).not.toContain("hard-shadow");
|
||||
});
|
||||
|
||||
it("accentClass merges into the container", () => {
|
||||
const { container } = render(
|
||||
<VellumCard accentClass="border-l-4 border-l-tertiary" testId="vc-a">x</VellumCard>,
|
||||
);
|
||||
const el = container.querySelector("[data-testid='vc-a']") as HTMLElement;
|
||||
expect(el.className).toContain("border-l-tertiary");
|
||||
});
|
||||
|
||||
it("href wraps in an <a>", () => {
|
||||
const { container } = render(
|
||||
<VellumCard href="/foo" testId="vc-l">x</VellumCard>,
|
||||
);
|
||||
const a = container.querySelector("a[data-testid='vc-l']");
|
||||
expect(a).toBeTruthy();
|
||||
expect(a?.getAttribute("href")).toBe("/foo");
|
||||
});
|
||||
|
||||
it("as=article renders an <article>", () => {
|
||||
const { container } = render(
|
||||
<VellumCard as="article" testId="vc-art">x</VellumCard>,
|
||||
);
|
||||
expect(container.querySelector("article[data-testid='vc-art']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("as=section renders a <section>", () => {
|
||||
const { container } = render(
|
||||
<VellumCard as="section" testId="vc-sec">x</VellumCard>,
|
||||
);
|
||||
expect(container.querySelector("section[data-testid='vc-sec']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// V1 Shell Redesign — Phase 1 — VellumInput primitive.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, fireEvent } from "@testing-library/react";
|
||||
import { VellumInput } from "../src/components/ui/vellum-input";
|
||||
import * as React from "react";
|
||||
|
||||
describe("VellumInput (Phase 1 primitive)", () => {
|
||||
it("renders an <input>", () => {
|
||||
const { container } = render(<VellumInput testId="vi" />);
|
||||
const el = container.querySelector("input[data-testid='vi']");
|
||||
expect(el).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies vellum form classes", () => {
|
||||
const { container } = render(<VellumInput testId="vi-c" />);
|
||||
const el = container.querySelector("input[data-testid='vi-c']") as HTMLInputElement;
|
||||
expect(el.className).toContain("border-stone-300");
|
||||
expect(el.className).toContain("bg-white");
|
||||
expect(el.className).toContain("font-mono");
|
||||
expect(el.className).toContain("text-xs");
|
||||
});
|
||||
|
||||
it("forwards ref to underlying input", () => {
|
||||
const ref = React.createRef<HTMLInputElement>();
|
||||
render(<VellumInput ref={ref} testId="vi-r" />);
|
||||
expect(ref.current).not.toBeNull();
|
||||
expect(ref.current?.tagName).toBe("INPUT");
|
||||
});
|
||||
|
||||
it("forwards native props (value, onChange)", () => {
|
||||
let captured = "";
|
||||
const { container } = render(
|
||||
<VellumInput
|
||||
defaultValue="abc"
|
||||
onChange={(e) => {
|
||||
captured = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
testId="vi-n"
|
||||
/>,
|
||||
);
|
||||
const el = container.querySelector("input[data-testid='vi-n']") as HTMLInputElement;
|
||||
expect(el.value).toBe("abc");
|
||||
fireEvent.change(el, { target: { value: "xyz" } });
|
||||
expect(captured).toBe("xyz");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
const { container } = render(<VellumInput className="border-red-500" testId="vi-m" />);
|
||||
const el = container.querySelector("input[data-testid='vi-m']") as HTMLInputElement;
|
||||
expect(el.className).toContain("border-red-500");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// V1 Shell Redesign — Phase 1 — VellumSelect primitive.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, fireEvent } from "@testing-library/react";
|
||||
import { VellumSelect } from "../src/components/ui/vellum-select";
|
||||
import * as React from "react";
|
||||
|
||||
describe("VellumSelect (Phase 1 primitive)", () => {
|
||||
it("renders a <select>", () => {
|
||||
const { container } = render(
|
||||
<VellumSelect testId="vs">
|
||||
<option value="a">A</option>
|
||||
</VellumSelect>,
|
||||
);
|
||||
expect(container.querySelector("select[data-testid='vs']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders option children", () => {
|
||||
const { container } = render(
|
||||
<VellumSelect testId="vs-opts">
|
||||
<option value="a">Alpha</option>
|
||||
<option value="b">Beta</option>
|
||||
</VellumSelect>,
|
||||
);
|
||||
const opts = container.querySelectorAll("option");
|
||||
expect(opts.length).toBe(2);
|
||||
expect(opts[0].textContent).toBe("Alpha");
|
||||
});
|
||||
|
||||
it("applies vellum form classes", () => {
|
||||
const { container } = render(
|
||||
<VellumSelect testId="vs-c">
|
||||
<option>x</option>
|
||||
</VellumSelect>,
|
||||
);
|
||||
const el = container.querySelector("select[data-testid='vs-c']") as HTMLSelectElement;
|
||||
expect(el.className).toContain("border-stone-300");
|
||||
expect(el.className).toContain("bg-white");
|
||||
expect(el.className).toContain("font-mono");
|
||||
});
|
||||
|
||||
it("forwards ref", () => {
|
||||
const ref = React.createRef<HTMLSelectElement>();
|
||||
render(
|
||||
<VellumSelect ref={ref} testId="vs-r">
|
||||
<option>x</option>
|
||||
</VellumSelect>,
|
||||
);
|
||||
expect(ref.current).not.toBeNull();
|
||||
expect(ref.current?.tagName).toBe("SELECT");
|
||||
});
|
||||
|
||||
it("fires onChange", () => {
|
||||
let captured = "";
|
||||
const { container } = render(
|
||||
<VellumSelect
|
||||
defaultValue="a"
|
||||
onChange={(e) => {
|
||||
captured = (e.target as HTMLSelectElement).value;
|
||||
}}
|
||||
testId="vs-n"
|
||||
>
|
||||
<option value="a">A</option>
|
||||
<option value="b">B</option>
|
||||
</VellumSelect>,
|
||||
);
|
||||
const el = container.querySelector("select[data-testid='vs-n']") as HTMLSelectElement;
|
||||
fireEvent.change(el, { target: { value: "b" } });
|
||||
expect(captured).toBe("b");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
// V1 Shell Redesign — Phase 1 — VellumSheet primitive.
|
||||
//
|
||||
// API surface tests: edge, width, onClose, testId, registration marks.
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { VellumSheet } from "../src/components/ui/vellum-sheet";
|
||||
|
||||
describe("VellumSheet (Phase 1 primitive)", () => {
|
||||
it("renders children", () => {
|
||||
render(<VellumSheet>BODY</VellumSheet>);
|
||||
expect(screen.getByText("BODY")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("default carries vellum-heavy class", () => {
|
||||
const { container } = render(<VellumSheet testId="vs">x</VellumSheet>);
|
||||
const el = container.querySelector("[data-testid='vs']") as HTMLElement;
|
||||
expect(el.className).toContain("vellum-heavy");
|
||||
});
|
||||
|
||||
it("default width=wide applies the wide width class", () => {
|
||||
const { container } = render(<VellumSheet testId="vs-w">x</VellumSheet>);
|
||||
const el = container.querySelector("[data-testid='vs-w']") as HTMLElement;
|
||||
expect(el.className).toContain("lg:w-[45rem]");
|
||||
expect(el.className).toContain("lg:max-w-[80vw]");
|
||||
});
|
||||
|
||||
it("width=narrow applies the narrow width class", () => {
|
||||
const { container } = render(
|
||||
<VellumSheet width="narrow" testId="vs-n">x</VellumSheet>,
|
||||
);
|
||||
const el = container.querySelector("[data-testid='vs-n']") as HTMLElement;
|
||||
expect(el.className).toContain("lg:w-[22rem]");
|
||||
});
|
||||
|
||||
it("edge=right (default) places left border", () => {
|
||||
const { container } = render(<VellumSheet testId="vs-r">x</VellumSheet>);
|
||||
const el = container.querySelector("[data-testid='vs-r']") as HTMLElement;
|
||||
expect(el.className).toContain("border-l-2");
|
||||
});
|
||||
|
||||
it("edge=left places right border", () => {
|
||||
const { container } = render(
|
||||
<VellumSheet edge="left" testId="vs-l">x</VellumSheet>,
|
||||
);
|
||||
const el = container.querySelector("[data-testid='vs-l']") as HTMLElement;
|
||||
expect(el.className).toContain("border-r-2");
|
||||
});
|
||||
|
||||
it("renders close button when onClose provided; clicking calls handler", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<VellumSheet onClose={onClose} testId="vs-c">x</VellumSheet>,
|
||||
);
|
||||
const closeBtn = screen.getByLabelText("Close sheet");
|
||||
expect(closeBtn).toBeTruthy();
|
||||
fireEvent.click(closeBtn);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("omits close button when onClose not provided", () => {
|
||||
const { container } = render(<VellumSheet>x</VellumSheet>);
|
||||
expect(container.querySelector("button[aria-label='Close sheet']")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders 4 registration marks", () => {
|
||||
const { container } = render(<VellumSheet>x</VellumSheet>);
|
||||
expect(container.querySelectorAll(".reg-tl, .reg-tr, .reg-bl, .reg-br").length).toBe(4);
|
||||
});
|
||||
|
||||
it("dialog role + aria-modal=false (non-modal drawer per content-drawer.md)", () => {
|
||||
const { container } = render(<VellumSheet testId="vs-aria">x</VellumSheet>);
|
||||
const el = container.querySelector("[data-testid='vs-aria']") as HTMLElement;
|
||||
expect(el.getAttribute("role")).toBe("dialog");
|
||||
expect(el.getAttribute("aria-modal")).toBe("false");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user