diff --git a/web/routes/blocks/+layout@.svelte b/web/routes/blocks/+layout@.svelte
index 21c344d30..8024ef7e8 100644
--- a/web/routes/blocks/+layout@.svelte
+++ b/web/routes/blocks/+layout@.svelte
@@ -1,30 +1,68 @@
diff --git a/web/routes/blocks/_lib/catalog.ts b/web/routes/blocks/_lib/catalog.ts
new file mode 100644
index 000000000..84540d668
--- /dev/null
+++ b/web/routes/blocks/_lib/catalog.ts
@@ -0,0 +1,61 @@
+/**
+ * The blocks catalog — one source for the section rail and the gallery index,
+ * so a block that lands does not have to be remembered in two places.
+ * Mirrors `docs/process/PLAN-blocks.md` (F2–F4).
+ */
+export interface BlockEntry {
+ slug: string;
+ label: string;
+ /** A live demo exists at `/blocks/{slug}`. */
+ shipped: boolean;
+}
+
+export interface BlockGroup {
+ title: string;
+ items: BlockEntry[];
+}
+
+export const BLOCK_CATALOG: BlockGroup[] = [
+ {
+ title: 'Site',
+ items: [
+ { slug: 'site-header', label: 'Site header', shipped: true },
+ { slug: 'hero', label: 'Hero', shipped: false },
+ { slug: 'feature-grid', label: 'Feature grid', shipped: false },
+ { slug: 'pricing', label: 'Pricing', shipped: false },
+ { slug: 'testimonials', label: 'Testimonials', shipped: false },
+ { slug: 'faq', label: 'FAQ', shipped: false },
+ { slug: 'stats-band', label: 'Stats band', shipped: false },
+ { slug: 'cta', label: 'CTA', shipped: false },
+ { slug: 'newsletter', label: 'Newsletter', shipped: false },
+ { slug: 'site-footer', label: 'Site footer', shipped: false },
+ { slug: 'banner', label: 'Banner', shipped: false },
+ { slug: 'team', label: 'Team', shipped: false },
+ { slug: 'contact', label: 'Contact', shipped: false },
+ { slug: 'content-section', label: 'Content section', shipped: false }
+ ]
+ },
+ {
+ title: 'App',
+ items: [
+ { slug: 'app-shell', label: 'App shell', shipped: false },
+ { slug: 'auth', label: 'Auth', shipped: false },
+ { slug: 'data-table', label: 'Data table', shipped: false },
+ { slug: 'dashboard', label: 'Dashboard', shipped: false },
+ { slug: 'settings', label: 'Settings', shipped: false },
+ { slug: 'user-menu', label: 'User menu', shipped: false },
+ { slug: 'notifications', label: 'Notifications', shipped: false },
+ { slug: 'wizard', label: 'Wizard', shipped: false },
+ { slug: 'error-page', label: 'Error page', shipped: false },
+ { slug: 'kanban', label: 'Kanban', shipped: false }
+ ]
+ },
+ {
+ title: 'Docs',
+ items: [
+ { slug: 'docs-shell', label: 'Docs shell', shipped: false },
+ { slug: 'code-showcase', label: 'Code showcase', shipped: false },
+ { slug: 'props-table', label: 'Props table', shipped: false }
+ ]
+ }
+];
diff --git a/web/routes/blocks/blocks.css b/web/routes/blocks/blocks.css
new file mode 100644
index 000000000..37343f33d
--- /dev/null
+++ b/web/routes/blocks/blocks.css
@@ -0,0 +1,63 @@
+/*
+ * Chrome of the /blocks section — the shell only: topbar, rail and content
+ * column. The CONTROLS inside it are canon components (ToggleGroup, Button,
+ * Link…), like the rest of the site; this file just places the three regions
+ * and paints their edges with foundation tokens.
+ *
+ * (The B contract's «no CSS» rule is about `src/uix/blocks/`, the tier itself.
+ * This is app-land chrome for the demo site, the same role `uix.css` plays for
+ * the component docs.)
+ */
+
+[data-blocks-shell] {
+ min-block-size: 100dvh;
+ background: var(--color-surface-default);
+ color: var(--color-content-primary);
+}
+
+[data-blocks-topbar] {
+ position: sticky;
+ inset-block-start: 0;
+ z-index: var(--z-index-raised);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+ padding-inline: var(--space-4);
+ padding-block: var(--space-2);
+ background: color-mix(in srgb, var(--color-surface-default) 88%, transparent);
+ backdrop-filter: blur(8px);
+ border-block-end: var(--border-width) solid var(--color-border-subtle);
+}
+
+[data-blocks-body] {
+ display: flex;
+ align-items: stretch;
+ min-block-size: 0;
+}
+
+[data-blocks-rail] {
+ flex: none;
+ inline-size: 15rem;
+ padding: var(--space-4);
+ border-inline-end: var(--border-width) solid var(--color-border-subtle);
+ position: sticky;
+ inset-block-start: var(--space-12);
+ align-self: start;
+ max-block-size: calc(100dvh - var(--space-12));
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+
+/* Below `lg` the rail folds away: the topbar keeps the axes, and the gallery
+ is one tap away. A demo section does not need a drawer of its own. */
+@media (width < 64rem) {
+ [data-blocks-rail] {
+ display: none;
+ }
+}
+
+[data-blocks-content] {
+ flex: 1 1 auto;
+ min-inline-size: 0;
+}