Skip to content

Commit 97af41f

Browse files
committed
feat(blocks): add interactive primitive showcase
1 parent f67522f commit 97af41f

7 files changed

Lines changed: 340 additions & 95 deletions

File tree

apps/blocks/src/app/globals.css

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ pre {
163163
align-items: center;
164164
}
165165

166-
@media (min-width: 1080px) {
166+
@media (min-width: 1280px) {
167167
.registry-hero-grid {
168168
grid-template-columns: minmax(0, 1fr) 460px;
169169
gap: 2rem;
@@ -273,6 +273,91 @@ pre {
273273
font-style: normal;
274274
}
275275

276+
/* Source ownership note */
277+
.hero-source-note {
278+
display: flex;
279+
max-width: 30rem;
280+
align-items: center;
281+
gap: 0.875rem;
282+
}
283+
284+
.hero-source-mark {
285+
width: 2.75rem;
286+
height: 2.75rem;
287+
flex: 0 0 auto;
288+
overflow: visible;
289+
color: var(--primary);
290+
transform-origin: center;
291+
transition-property: transform;
292+
transition-duration: 180ms;
293+
transition-timing-function: ease-out;
294+
}
295+
296+
.hero-source-fold {
297+
opacity: 0.5;
298+
transition-property: opacity;
299+
transition-duration: 180ms;
300+
transition-timing-function: ease-out;
301+
}
302+
303+
.hero-source-node {
304+
opacity: 0;
305+
filter: blur(4px);
306+
transform: scale(0.25);
307+
transform-box: fill-box;
308+
transform-origin: center;
309+
transition-property: transform, opacity, filter;
310+
transition-duration: 180ms;
311+
transition-timing-function: ease-out;
312+
}
313+
314+
.hero-source-title,
315+
.hero-source-copy {
316+
display: block;
317+
}
318+
319+
.hero-source-title {
320+
font-size: 0.875rem;
321+
line-height: 1.25rem;
322+
font-weight: 600;
323+
color: var(--foreground);
324+
}
325+
326+
.hero-source-copy {
327+
margin-top: 0.125rem;
328+
font-size: 0.75rem;
329+
line-height: 1.125rem;
330+
color: var(--muted-foreground);
331+
}
332+
333+
@media (hover: hover) and (pointer: fine) {
334+
.hero-source-note:hover .hero-source-mark {
335+
transform: translateY(-3px) rotate(-4deg) scale(1.04);
336+
}
337+
338+
.hero-source-note:hover .hero-source-fold {
339+
opacity: 0.9;
340+
}
341+
342+
.hero-source-note:hover .hero-source-node {
343+
opacity: 1;
344+
filter: blur(0);
345+
transform: scale(1);
346+
}
347+
}
348+
349+
@media (prefers-reduced-motion: reduce) {
350+
.hero-source-mark,
351+
.hero-source-fold,
352+
.hero-source-node {
353+
transition: none;
354+
}
355+
356+
.hero-source-note:hover .hero-source-mark {
357+
transform: none;
358+
}
359+
}
360+
276361
/* floating iso stack */
277362
.hero-visual {
278363
position: relative;
@@ -281,7 +366,7 @@ pre {
281366
perspective-origin: 60% 40%;
282367
}
283368

284-
@media (min-width: 1080px) {
369+
@media (min-width: 1280px) {
285370
.hero-visual {
286371
height: 380px;
287372
}
@@ -298,7 +383,7 @@ pre {
298383
pointer-events: none;
299384
}
300385

301-
@media (min-width: 1080px) {
386+
@media (min-width: 1280px) {
302387
.hero-visual .mark-glow {
303388
width: 230px;
304389
}

apps/blocks/src/app/page.tsx

Lines changed: 2 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,11 @@
1-
import Link from 'next/link';
2-
1+
import { PrimitiveShowcase } from '@/components/landing/primitive-showcase';
32
import { RegistryHero } from '@/components/landing/registry-hero';
4-
import { BASE_PRIMITIVES } from '@/lib/base-primitives';
53

64
export default function HomePage() {
75
return (
86
<>
97
<RegistryHero />
10-
11-
<div className="registry-page">
12-
<section aria-labelledby="component-catalog">
13-
<div className="mb-5">
14-
<p className="registry-eyebrow">Components</p>
15-
<h2 id="component-catalog" className="mt-2 text-[22px] font-semibold tracking-tight">
16-
Base primitives
17-
</h2>
18-
<p className="mt-1.5 max-w-[60ch] text-sm text-muted-foreground">
19-
{BASE_PRIMITIVES.length} package-backed primitives. Open a page for the live preview and install
20-
commands.
21-
</p>
22-
</div>
23-
24-
<div className="registry-block">
25-
<div className="registry-block-bar">
26-
<span>Catalog</span>
27-
<span className="min-w-0 flex-1" />
28-
<span className="shrink-0 font-mono text-xs font-normal text-muted-foreground">@constructive/*</span>
29-
</div>
30-
<div className="registry-block-stage registry-block-stage-col !items-stretch !p-3 sm:!p-4">
31-
<ul className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
32-
{BASE_PRIMITIVES.map((primitive) => (
33-
<li key={primitive.name} className="min-w-0">
34-
<Link
35-
href={`/blocks/ui/${primitive.name}`}
36-
className="flex h-full min-h-14 flex-col rounded-lg border border-border bg-card px-3 py-2.5 outline-none transition-[background-color,border-color] duration-150 ease-out hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring"
37-
>
38-
<span className="text-sm font-medium text-foreground">{primitive.title}</span>
39-
<span className="mt-0.5 line-clamp-2 text-pretty text-xs leading-5 text-muted-foreground">
40-
{primitive.description}
41-
</span>
42-
</Link>
43-
</li>
44-
))}
45-
</ul>
46-
<p className="mt-3 text-sm text-muted-foreground">
47-
Prefer the install guide?{' '}
48-
<Link href="/blocks" className="font-medium text-foreground underline-offset-4 hover:underline">
49-
Open setup
50-
</Link>
51-
.
52-
</p>
53-
</div>
54-
</div>
55-
</section>
56-
</div>
8+
<PrimitiveShowcase />
579
</>
5810
);
5911
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { getUiDemo } from '@/components/docs/showcase-ui';
2+
import { getBasePrimitive, type BasePrimitiveName } from '@/lib/base-primitives';
3+
4+
export const HOME_SHOWCASE_ORDER = [
5+
'button',
6+
'input',
7+
'select',
8+
'checkbox',
9+
'switch',
10+
'radio-group',
11+
'dialog',
12+
'popover',
13+
'dropdown-menu',
14+
'alert-dialog',
15+
'drawer',
16+
'sheet',
17+
'card',
18+
'alert',
19+
'badge',
20+
'tabs',
21+
'collapsible',
22+
'tooltip',
23+
'avatar',
24+
'breadcrumb',
25+
'pagination',
26+
'progress',
27+
'skeleton',
28+
'separator',
29+
'resizable',
30+
'scroll-area',
31+
'table',
32+
'label',
33+
'textarea',
34+
] as const satisfies readonly BasePrimitiveName[];
35+
36+
export const HOME_SHOWCASE_DEMOS = {
37+
alert: getUiDemo('alert', 'BasicAlertDemo'),
38+
'alert-dialog': getUiDemo('alert-dialog', 'BasicAlertDialogDemo'),
39+
avatar: getUiDemo('avatar', 'AvatarSizesDemo'),
40+
badge: getUiDemo('badge', 'BadgeVariantsDemo'),
41+
breadcrumb: getUiDemo('breadcrumb', 'BasicBreadcrumbDemo'),
42+
button: getUiDemo('button', 'BasicButtonDemo'),
43+
card: getUiDemo('card', 'BasicCardDemo'),
44+
checkbox: getUiDemo('checkbox', 'CheckboxGroupDemo'),
45+
collapsible: getUiDemo('collapsible', 'BasicCollapsibleDemo'),
46+
dialog: getUiDemo('dialog', 'BasicDialogDemo'),
47+
drawer: getUiDemo('drawer', 'BasicDrawerDemo'),
48+
'dropdown-menu': getUiDemo('dropdown-menu', 'BasicDropdownMenuDemo'),
49+
input: getUiDemo('input', 'ControlledInputDemo'),
50+
label: getUiDemo('label', 'RequiredLabelDemo'),
51+
pagination: getUiDemo('pagination', 'BasicPaginationDemo'),
52+
popover: getUiDemo('popover', 'BasicPopoverDemo'),
53+
progress: getUiDemo('progress', 'BlockDemo'),
54+
'radio-group': getUiDemo('radio-group', 'BasicRadioGroupDemo'),
55+
resizable: getUiDemo('resizable', 'BasicResizableDemo'),
56+
'scroll-area': getUiDemo('scroll-area', 'BasicScrollAreaDemo'),
57+
select: getUiDemo('select', 'BasicSelectDemo'),
58+
separator: getUiDemo('separator', 'BasicSeparatorDemo'),
59+
sheet: getUiDemo('sheet', 'BasicSheetDemo'),
60+
skeleton: getUiDemo('skeleton', 'BlockDemo'),
61+
switch: getUiDemo('switch', 'SwitchSettingsDemo'),
62+
table: getUiDemo('table', 'WideTableDemo'),
63+
tabs: getUiDemo('tabs', 'BasicTabsDemo'),
64+
textarea: getUiDemo('textarea', 'ControlledTextareaDemo'),
65+
tooltip: getUiDemo('tooltip', 'PositionedTooltipDemo'),
66+
} satisfies Record<BasePrimitiveName, ReturnType<typeof getUiDemo>>;
67+
68+
export const HOME_SHOWCASE_PRIMITIVES = HOME_SHOWCASE_ORDER.map((name) => {
69+
const primitive = getBasePrimitive(name);
70+
if (!primitive) throw new Error(`Missing base primitive metadata for ${name}`);
71+
return primitive;
72+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { cleanup, render, screen, waitFor } from '@testing-library/react';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
4+
vi.mock('@/components/docs/showcase-ui', () => ({
5+
getUiDemo: (name: string) =>
6+
function StaticDemo() {
7+
return <span data-showcase-demo={name} />;
8+
},
9+
}));
10+
11+
import { BASE_PRIMITIVES } from '@/lib/base-primitives';
12+
13+
import {
14+
HOME_SHOWCASE_DEMOS,
15+
HOME_SHOWCASE_ORDER,
16+
} from './primitive-showcase-config';
17+
import { PrimitiveShowcase } from './primitive-showcase';
18+
19+
afterEach(cleanup);
20+
21+
describe('PrimitiveShowcase', () => {
22+
it('covers the base primitive catalog exactly once', () => {
23+
const expected = BASE_PRIMITIVES.map(({ name }) => name).toSorted();
24+
25+
expect(HOME_SHOWCASE_ORDER).toHaveLength(BASE_PRIMITIVES.length);
26+
expect([...HOME_SHOWCASE_ORDER].toSorted()).toEqual(expected);
27+
expect(Object.keys(HOME_SHOWCASE_DEMOS).toSorted()).toEqual(expected);
28+
});
29+
30+
it('links every live specimen to its complete reference', async () => {
31+
const { container } = render(<PrimitiveShowcase />);
32+
33+
await waitFor(() => {
34+
expect(container.querySelectorAll('[data-primitive]')).toHaveLength(BASE_PRIMITIVES.length);
35+
});
36+
37+
for (const primitive of BASE_PRIMITIVES) {
38+
expect(screen.getByRole('link', { name: `${primitive.title} documentation` })).toHaveAttribute(
39+
'href',
40+
`/blocks/ui/${primitive.name}`,
41+
);
42+
}
43+
});
44+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
'use client';
2+
3+
import Link from 'next/link';
4+
import { ArrowUpRight } from 'lucide-react';
5+
6+
import {
7+
HOME_SHOWCASE_DEMOS,
8+
HOME_SHOWCASE_PRIMITIVES,
9+
} from '@/components/landing/primitive-showcase-config';
10+
import { BASE_PRIMITIVES } from '@/lib/base-primitives';
11+
import { cn } from '@/lib/utils';
12+
13+
export function PrimitiveShowcase() {
14+
return (
15+
<section className="registry-page" aria-labelledby="component-showcase">
16+
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
17+
<div>
18+
<p className="registry-eyebrow">Components</p>
19+
<h2 id="component-showcase" className="mt-2 text-balance text-2xl font-semibold">
20+
Base primitives
21+
</h2>
22+
<p className="mt-1.5 max-w-[60ch] text-pretty text-sm text-muted-foreground">
23+
Try every package-backed primitive in place, or open its complete reference.
24+
</p>
25+
</div>
26+
<p className="font-mono text-xs text-muted-foreground tabular-nums">
27+
{BASE_PRIMITIVES.length} live previews
28+
</p>
29+
</div>
30+
31+
<ul className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,20rem),1fr))] gap-px bg-border p-px">
32+
{HOME_SHOWCASE_PRIMITIVES.map((primitive) => {
33+
const Preview = HOME_SHOWCASE_DEMOS[primitive.name];
34+
const labelId = `showcase-${primitive.name}`;
35+
36+
return (
37+
<li
38+
key={primitive.name}
39+
className={cn(
40+
'grid min-h-80 min-w-0 grid-rows-[auto_minmax(0,1fr)] bg-background [contain-intrinsic-size:auto_20rem] [content-visibility:auto]',
41+
primitive.name === 'textarea' && 'sm:max-[860px]:col-span-2 lg:col-span-2',
42+
)}
43+
data-primitive={primitive.name}
44+
aria-labelledby={labelId}
45+
>
46+
<div className="min-w-0 px-3 pt-1.5">
47+
<Link
48+
id={labelId}
49+
href={`/blocks/ui/${primitive.name}`}
50+
className="group inline-flex min-h-11 items-center gap-1.5 rounded-md px-2 text-sm font-medium text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
51+
>
52+
{primitive.title}
53+
<ArrowUpRight
54+
aria-hidden="true"
55+
className="size-3.5 text-muted-foreground group-hover:text-foreground"
56+
/>
57+
<span className="sr-only"> documentation</span>
58+
</Link>
59+
</div>
60+
<div className="flex min-h-60 min-w-0 items-center justify-center p-6">
61+
<Preview />
62+
</div>
63+
</li>
64+
);
65+
})}
66+
</ul>
67+
</section>
68+
);
69+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { cleanup, render, screen } from '@testing-library/react';
2+
import { afterEach, describe, expect, it } from 'vitest';
3+
4+
import { RegistryHero } from './registry-hero';
5+
6+
afterEach(cleanup);
7+
8+
describe('RegistryHero', () => {
9+
it('explains source ownership without a multi-step stack diagram', () => {
10+
const { container } = render(<RegistryHero />);
11+
12+
expect(screen.getByRole('heading', { level: 1, name: 'Build the product on Constructive.' })).toBeVisible();
13+
expect(screen.getByText('Every block becomes your code.')).toBeVisible();
14+
expect(screen.getByText('Copy only what you need, then shape it into your product.')).toBeVisible();
15+
expect(screen.queryByRole('list', { name: 'Constructive application stack' })).not.toBeInTheDocument();
16+
expect(container.querySelectorAll('.hero-source-mark path')).toHaveLength(2);
17+
expect(container.querySelectorAll('.hero-source-mark circle')).toHaveLength(1);
18+
expect(container.querySelector('.hero-source-mark rect, .hero-source-mark image')).not.toBeInTheDocument();
19+
expect(screen.queryByText(/PostgreSQL/i)).not.toBeInTheDocument();
20+
});
21+
22+
it('describes source-installed registry distribution without vanity metrics', () => {
23+
render(<RegistryHero />);
24+
25+
expect(screen.getByText('Source copied')).toBeInTheDocument();
26+
expect(screen.getByLabelText('Theme included')).toBeInTheDocument();
27+
expect(screen.queryByText('color space')).not.toBeInTheDocument();
28+
expect(screen.queryByText('focus rings')).not.toBeInTheDocument();
29+
});
30+
});

0 commit comments

Comments
 (0)