Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/__tests__/componentKit.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { KIT, KIT_BASE, KIT_TOKENS, kitComponent, kitPage, kitStylesheet } from "@/lib/componentKit";

/**
Expand Down Expand Up @@ -168,3 +169,38 @@ describe("everything clickable is big enough to hit", () => {
expect(faq.css).toMatch(/summary \{[^}]*padding:\s*0\.4rem/);
});
});

describe("the page that shows the kit", () => {
const PAGE = readFileSync("src/app/ui/page.tsx", "utf8");
const CLIENT = readFileSync("src/app/ui/UiClient.tsx", "utf8");

it("builds every preview from the same registry the code blocks come from", () => {
// A preview rendered from anything else could show one thing and copy another.
expect(PAGE).toContain("kitPage([c], \"dark\")");
expect(PAGE).toContain("kitPage([c], \"light\")");
expect(PAGE).toContain("components={KIT}");
expect(CLIENT).toContain("srcDoc={light ? pageFor[c.id].light : pageFor[c.id].dark}");
});

it("copies the component's own html and css, not a reformatted copy of them", () => {
expect(CLIENT).toContain("text={active === \"html\" ? c.html : c.css}");
expect(CLIENT).toContain("navigator.clipboard.writeText(text)");
});

it("shows each preview in a frame of its own, so this site's CSS cannot reach into it", () => {
expect(CLIENT).toContain("<iframe");
expect(CLIENT).toMatch(/title=\{`\$\{c\.name\} preview`\}/);
expect(CLIENT, "an inline preview would inherit this page's styles").not.toContain("dangerouslySetInnerHTML");
});

it("offers the width switcher almost nobody else has, and both grounds", () => {
expect(CLIENT).toContain("Phone: 390");
expect(CLIENT).toContain("Tablet: 768");
expect(CLIENT).toContain("lightTokens");
});

it("is reachable: in the nav and in the sitemap", () => {
expect(readFileSync("src/components/Navbar.tsx", "utf8")).toContain('href: "/ui"');
expect(readFileSync("src/app/sitemap.ts", "utf8")).toContain("${siteUrl}/ui");
});
});
1 change: 1 addition & 0 deletions src/__tests__/routeMetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { pageMeta, SHARE_BASE, DEFAULT_SHARE_IMAGE } from "@/lib/pageMeta";
const ROUTES: [string, string][] = [
["src/app/page.tsx", "/"],
["src/app/templates/page.tsx", "/templates"],
["src/app/ui/page.tsx", "/ui"],
["src/app/examples/page.tsx", "/examples"],
["src/app/presets/layout.tsx", "/presets"],
["src/app/changelog/page.tsx", "/changelog"],
Expand Down
1 change: 1 addition & 0 deletions src/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "monthly" as const,
priority: 0.7,
})),
{ url: `${siteUrl}/ui`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${siteUrl}/presets`, lastModified: now, changeFrequency: "weekly", priority: 0.6 },
{ url: `${siteUrl}/about`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: `${siteUrl}/contact`, lastModified: now, changeFrequency: "monthly", priority: 0.4 },
Expand Down
200 changes: 200 additions & 0 deletions src/app/ui/UiClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
"use client";
import { useMemo, useState } from "react";
import { Check, Copy, Monitor, Smartphone, Tablet } from "lucide-react";
import Navbar from "@/components/Navbar";
import SiteFooter from "@/components/SiteFooter";
import { Input } from "@/components/ui/input";
import type { KitComponent } from "@/lib/componentKit";

/**
* The page the kit is actually used from.
*
* Each preview is an iframe of the page that block would make on its own, so what is on
* screen is what the copy button hands over, down to the stylesheet. Rendering it inline
* would let this site's CSS reach into the preview and show somebody a block that behaves
* differently once pasted.
*/

const WIDTHS = {
Phone: 390,
Tablet: 768,
Desktop: 1180,
} as const;
type WidthName = keyof typeof WIDTHS;

const ICONS: Record<WidthName, typeof Monitor> = { Phone: Smartphone, Tablet: Tablet, Desktop: Monitor };

function CopyButton({ text, label }: { text: string; label: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
} catch {
// A browser that refuses the clipboard still lets someone select the code.
return;
}
setCopied(true);
setTimeout(() => setCopied(false), 1600);
}}
className="lc-mono inline-flex h-9 items-center gap-1.5 rounded-md border border-border px-3 text-xs text-foreground hover:border-foreground/40"
>
{copied ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : <Copy className="h-3.5 w-3.5" aria-hidden="true" />}
{copied ? "Copied" : label}
</button>
);
}

export default function UiClient({
components,
tokens,
lightTokens,
base,
pageFor,
}: {
components: KitComponent[];
tokens: string;
lightTokens: string;
base: string;
/** The exact document each preview shows, built on the server from the same registry. */
pageFor: Record<string, { dark: string; light: string }>;
}) {
const [query, setQuery] = useState("");
const [width, setWidth] = useState<WidthName>("Desktop");
const [light, setLight] = useState(false);
const [tab, setTab] = useState<Record<string, "html" | "css">>({});

const shown = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return components;
return components.filter(
(c) => c.name.toLowerCase().includes(needle) || c.description.toLowerCase().includes(needle) || c.id.includes(needle)
);
}, [components, query]);

return (
<main className="min-h-screen bg-background text-foreground">
<Navbar />

<section className="px-6 pb-10 pt-20 text-center">
<p className="lc-mono mb-6 text-sm text-primary-ink">{components.length} sections, nothing to install</p>
<h1 className="lc-display text-5xl md:text-6xl lg:text-7xl">
<span className="block">Copy a section,</span>
<span className="block text-primary-ink">paste it anywhere</span>
</h1>
<p className="mx-auto mt-6 max-w-xl text-lg text-muted-foreground">
Plain HTML and CSS. No React, no Tailwind, no build step. These work in a static folder,
a WordPress block or a PHP template, and they read your page&apos;s own colours where you have them.
</p>
</section>

<div className="mx-auto max-w-5xl space-y-4 px-6 pb-10">
<div className="relative mx-auto max-w-md">
<label htmlFor="kit-search" className="sr-only">Search sections</label>
<Input
id="kit-search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search sections"
className="lc-mono h-11 bg-card"
/>
</div>

<div className="flex flex-wrap items-center justify-center gap-3">
<div className="flex items-center gap-1 rounded-md border border-border p-0.5" role="group" aria-label="Preview width">
{(Object.keys(WIDTHS) as WidthName[]).map((name) => {
const Icon = ICONS[name];
return (
<button
key={name}
type="button"
onClick={() => setWidth(name)}
aria-pressed={width === name}
className={`lc-mono inline-flex h-8 items-center gap-1.5 rounded px-3 text-xs transition-colors ${
width === name ? "bg-primary-ink/15 text-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{name}
</button>
);
})}
</div>
<button
type="button"
onClick={() => setLight((v) => !v)}
aria-pressed={light}
className="lc-mono h-9 rounded-md border border-border px-3 text-xs text-foreground hover:border-foreground/40"
>
{light ? "On a light page" : "On a dark page"}
</button>
<CopyButton text={light ? lightTokens : tokens} label="Copy the tokens" />
<CopyButton text={base} label="Copy the shared CSS" />
</div>

<p className="lc-mono text-center text-xs text-muted-foreground" aria-live="polite">
{shown.length} of {components.length} sections
</p>
</div>

<section className="mx-auto max-w-5xl space-y-12 px-6 pb-24">
{shown.map((c) => {
const active = tab[c.id] ?? "html";
const code = active === "html" ? c.html : c.css;
return (
<article key={c.id} id={c.id} className="rounded-lg border border-border bg-card">
<header className="flex flex-wrap items-start justify-between gap-3 border-b border-border p-5">
<div>
<h2 className="text-xl font-medium">{c.name}</h2>
<p className="mt-1 max-w-xl text-sm text-muted-foreground">{c.description}</p>
</div>
<CopyButton text={active === "html" ? c.html : c.css} label={`Copy ${active.toUpperCase()}`} />
</header>

<div className="flex justify-center bg-background/60 p-4">
<iframe
title={`${c.name} preview`}
srcDoc={light ? pageFor[c.id].light : pageFor[c.id].dark}
loading="lazy"
style={{ width: WIDTHS[width], maxWidth: "100%", height: 420, border: 0, borderRadius: 8, background: "#fff" }}
/>
</div>

<div className="border-t border-border">
<div className="flex gap-1 border-b border-border px-3 pt-3" role="tablist" aria-label={`${c.name} code`}>
{(["html", "css"] as const).map((name) => (
<button
key={name}
type="button"
role="tab"
aria-selected={active === name}
onClick={() => setTab((t) => ({ ...t, [c.id]: name }))}
className={`lc-mono rounded-t-md px-3 py-2 text-xs transition-colors ${
active === name ? "bg-background text-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{name.toUpperCase()}
</button>
))}
</div>
<pre className="max-h-80 overflow-auto bg-background p-4 text-xs leading-relaxed text-foreground">
<code>{code}</code>
</pre>
</div>
</article>
);
})}

{shown.length === 0 && (
<p className="rounded-lg border border-border bg-card p-10 text-center text-muted-foreground">
No section matches that. Clear the search to see all {components.length}.
</p>
)}
</section>

<SiteFooter />
</main>
);
}
32 changes: 32 additions & 0 deletions src/app/ui/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { pageMeta } from "@/lib/pageMeta";
import { KIT, KIT_BASE, KIT_TOKENS, KIT_TOKENS_LIGHT, kitPage } from "@/lib/componentKit";
import UiClient from "./UiClient";

export const metadata: Metadata = pageMeta({
title: "Components",
description: `${KIT.length} sections in plain HTML and CSS. Copy one, paste it into any page: no React, no Tailwind, no build step.`,
path: "/ui",
});

/**
* Server shell for the component kit.
*
* Each preview document is built here from the same registry the code blocks come from,
* so the page cannot show one thing and copy another.
*/
export default function UiPage() {
const pageFor = Object.fromEntries(
KIT.map((c) => [c.id, { dark: kitPage([c], "dark"), light: kitPage([c], "light") }])
);

return (
<UiClient
components={KIT}
tokens={KIT_TOKENS}
lightTokens={KIT_TOKENS_LIGHT}
base={KIT_BASE}
pageFor={pageFor}
/>
);
}
1 change: 1 addition & 0 deletions src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { GITHUB_REPO_URL, GITHUB_SPONSORS_URL } from "@/lib/links";
const NAV_LINKS = [
{ href: "/examples", label: "Examples" },
{ href: "/templates", label: "Templates" },
{ href: "/ui", label: "Components" },
{ href: "/presets", label: "Presets" },
{ href: "/changelog", label: "Changelog" },
];
Expand Down
23 changes: 19 additions & 4 deletions src/lib/componentKit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ export const KIT_TOKENS = `:root {
--sck-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}`;

/** The same tokens for a light page, so a block can be checked on either ground. */
export const KIT_TOKENS_LIGHT = `:root {
--sck-ground: #ffffff;
--sck-surface: #f2f5fb;
--sck-ink: #0f1420;
--sck-muted: #333c4d;
--sck-accent: #2340b8;
--sck-accent-ink: #ffffff;
--sck-line: rgba(15, 20, 32, 0.16);
--sck-field: rgba(15, 20, 32, 0.04);
--sck-radius: 10px;
--sck-measure: 62ch;
--sck-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}`;

/** Shared by every section: the base type, focus ring and spacing rhythm. */
export const KIT_BASE = `.sck {
font-family: var(--sck-font);
Expand Down Expand Up @@ -225,7 +240,7 @@ export const KIT: KitComponent[] = [
.sck-footer-nav { display: flex; flex-wrap: wrap; gap: 1.25rem; margin-bottom: 1.25rem; }
.sck-footer-nav a { color: var(--sck-ink); text-decoration: none; border-bottom: 1px solid var(--sck-line); display: inline-flex; align-items: center; min-height: 32px; padding: 0 0 2px; }
.sck-footer-nav a:hover { border-bottom-color: currentColor; }
.sck-footer-legal { font-size: 0.875rem; margin: 0; }`,
.sck-footer-legal { font-size: 0.9375rem; margin: 0; }`,
},
];

Expand All @@ -238,16 +253,16 @@ export function kitStylesheet(component: KitComponent): string {
return `${KIT_TOKENS}\n\n${KIT_BASE}\n\n${component.css}`;
}

/** A whole page of the kit, for seeing the set together. */
export function kitPage(components: KitComponent[] = KIT): string {
/** A whole page of the kit, for seeing the set together or one block on its own. */
export function kitPage(components: KitComponent[] = KIT, theme: "dark" | "light" = "dark"): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ScrollCraft components</title>
<style>
${KIT_TOKENS}
${theme === "light" ? KIT_TOKENS_LIGHT : KIT_TOKENS}

${KIT_BASE}

Expand Down
Loading