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
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ export function PageLayoutExamples() {
</ul>
</Example>

<Example components={["SectionBar"]} note="Pass sectionNav to AppShell for a short row of related pages. It scrolls away with the page. On phones, the links scroll sideways and the current page stays visible on load. These previews use four working links with each sidebar position." source="app/ui/site/section-bar.tsx" title="Section bar" usage={{ label: "TSX", code: '<AppShell\n sidebarPosition="none"\n sectionNav={{\n label: "ad networks",\n href: "/ad-networks",\n current: "/ad-networks/checker",\n items: [\n { label: "Directory", href: "/ad-networks" },\n { label: "Niches", href: "/ad-networks/niches" },\n { label: "Stats", href: "/ad-networks/stats" },\n { label: "Checker", href: "/ad-networks/checker" },\n ],\n }}\n>\n <PageCanvas>…</PageCanvas>\n</AppShell>' }}>
<ul className={styles.layoutLinks}>
{(["left", "right", "none"] as const).map((position) => (
<li key={position}><Link href={`/components-ui/section-bar/${position}/checker`}>{position} sidebar</Link><span>Section bar with sidebarPosition="{position}".</span></li>
))}
</ul>
</Example>

<Example components={["AppShell", "PageCanvas"]} note="PageCanvas wraps each content section. This gallery uses both. See full layouts on their live pages." source="app/ui/site/app-shell.tsx, app/ui/site/page-canvas.tsx" title="Full page layouts">
<ul className={styles.layoutLinks}>
{fullLayouts.map((layout) => (
Expand Down
4 changes: 2 additions & 2 deletions app/(site)/(components-ui)/components-ui/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,9 @@ export const gallerySections: GallerySection[] = [
{
slug: "page-layout",
label: "Page layout",
description: "Page headers, title icons, the entry hero, page metadata, brand pieces, and the tool workbench.",
description: "Page headers, sidebar positions, the section bar, the entry hero, page metadata, brand pieces, and the tool workbench.",
icon: LayoutTemplate,
components: ["PageHeader", "PageTitleIcon", "CategoryIcon", "EntryHero", "PageMeta", "BrandWordmark", "ThemeToggle", "ToolWorkbench", "ToolFieldRow", "ToolResult"],
components: ["PageHeader", "PageTitleIcon", "CategoryIcon", "EntryHero", "PageMeta", "BrandWordmark", "ThemeToggle", "ToolWorkbench", "ToolFieldRow", "ToolResult", "SectionBar"],
},
];

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { registryLayout } from "@/lib/registry-layout";

const registry = registryLayout("/components-ui/section-bar/[position]/[view]");
export const generateMetadata = registry.generateMetadata;
export function generateStaticParams() { return []; }
export const revalidate = false;
export default registry.Layout;
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { AppShell, SidebarLabel, type SidebarPosition } from "@/app/ui/site/app-shell";
import { PageCanvas } from "@/app/ui/site/page-canvas";
import { PageHeader } from "@/app/ui/site/page-header";
import { proseClassName, proseFlowClassName } from "@/app/ui/site/prose";
import { SidebarList } from "@/app/ui/site/sidebar-list";
import { sidebarPanelClassName, sidebarPanelHeadingClassName } from "@/app/ui/site/sidebar-panel";

const views = [
{ slug: "directory", label: "Directory" },
{ slug: "niches", label: "Niches" },
{ slug: "stats", label: "Stats" },
{ slug: "checker", label: "Checker" },
];
const positions: SidebarPosition[] = ["left", "right", "none"];
const previewPath = (position: SidebarPosition, view: string) => `/components-ui/section-bar/${position}/${view}`;

// Outside the gallery shell so each preview renders exactly one AppShell. Never public.
export default async function SectionBarPreview({ params }: { params: Promise<{ position: string; view: string }> }) {
if (process.env.NODE_ENV !== "development") notFound();
const { position: requestedPosition, view } = await params;
const position = positions.find((item) => item === requestedPosition);
const currentView = views.find((item) => item.slug === view);
if (!position || !currentView) notFound();

return (
<AppShell
mainLabel="Section bar preview"
menuLabel="Preview options"
sectionNav={{
label: "ad networks",
href: previewPath(position, "directory"),
current: previewPath(position, view),
items: views.map((item) => ({ label: item.label, href: previewPath(position, item.slug) })),
}}
sidebar={(
<div className={sidebarPanelClassName}>
<div className={sidebarPanelHeadingClassName}><SidebarLabel>Preview options</SidebarLabel></div>
<nav aria-label="Preview sidebar position">
<SidebarList items={positions.map((item) => ({
label: `${item} sidebar`,
href: previewPath(item, view),
active: item === position,
}))} />
</nav>
</div>
)}
sidebarLabel="Preview options"
sidebarPosition={position}
>
<PageCanvas>
<PageHeader bordered>
<h1>Section bar: <mark>{currentView.label}</mark></h1>
<p>Try the four section links with a {position} sidebar. These are development previews with no ad network data.</p>
</PageHeader>
</PageCanvas>
<PageCanvas reading>
<div className={`${proseClassName} ${proseFlowClassName}`}>
<h2>One section, four pages</h2>
<p>The section name links to Directory. The active page has an accent underline. On a phone, swipe the links sideways.</p>
<h2>Try each sidebar position</h2>
<ul>
{positions.map((item) => <li key={item}><Link href={previewPath(item, view)}>{item} sidebar</Link></li>)}
</ul>
<h2>Scroll down</h2>
<p>The section bar leaves the screen. The main navigation stays at the top. A desktop sidebar sits just below it.</p>
<h2>Try the phone drawer</h2>
<p>With a left sidebar, Preview options opens the drawer. The right sidebar follows the content. With none, the content fills the page.</p>
<h2>Check both themes</h2>
<p>Use the theme button in the main navigation. The current link keeps its accent underline and readable text in either theme.</p>
<h2>Keep each navigation useful</h2>
<p>The section bar holds the few main pages. Filters and longer lists belong in a sidebar. Give each a different job.</p>
<p><Link href="/components-ui/page-layout">Back to page layout</Link></p>
</div>
</PageCanvas>
</AppShell>
);
}
15 changes: 11 additions & 4 deletions app/ui/site/app-shell.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,26 @@
width: 100%;
}

.shellWithSectionNav {
/* Link line, vertical padding, and its 2px underline plus the bar's bottom border. */
--section-bar-height: calc(var(--text-body) * var(--line-ui) + var(--space-5) * 2 + 3px);
/* The main navigation's bottom border sits outside --top-bar-height. */
--shell-viewport-height: calc(100vh - var(--top-bar-height) - 1px - var(--section-bar-height));
}

.page {
align-items: start;
display: grid;
gap: 0;
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
min-height: calc(100vh - var(--top-bar-height));
min-height: var(--shell-viewport-height, calc(100vh - var(--top-bar-height)));
padding: 0;
}

.sidebar {
background: var(--color-bg);
border-right: 1px solid var(--color-border);
height: calc(100vh - var(--top-bar-height));
height: var(--shell-viewport-height, calc(100vh - var(--top-bar-height)));
overflow-y: auto;
position: sticky;
top: var(--top-bar-height);
Expand Down Expand Up @@ -50,7 +57,7 @@
background: var(--color-bg);
border-left: 1px solid var(--color-border);
grid-area: aside;
height: calc(100vh - var(--top-bar-height));
height: var(--shell-viewport-height, calc(100vh - var(--top-bar-height)));
overflow-y: auto;
position: sticky;
top: var(--top-bar-height);
Expand Down Expand Up @@ -97,7 +104,7 @@
.main {
display: flex;
flex-direction: column;
min-height: calc(100vh - var(--top-bar-height));
min-height: var(--shell-viewport-height, calc(100vh - var(--top-bar-height)));
min-width: 0;
}

Expand Down
10 changes: 8 additions & 2 deletions app/ui/site/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { BrandWordmark } from "./brand-wordmark";
import { intentPrefetch } from "./intent-prefetch";
import styles from "./app-shell.module.css";
import { sidebarPanelTitleClassName } from "./sidebar-panel";
import { SectionBar, type SectionNav } from "./section-bar";

/**
* Where the page puts its sidebar. Pick it per page, for what the sidebar does:
Expand All @@ -24,6 +25,8 @@ type AppShellProps = {
/** Label shown next to the mobile hamburger, e.g. "Lessons" or "Filters". Left sidebars only. */
menuLabel?: string;
sidebarPosition?: SidebarPosition;
/** Optional short navigation between a section's main pages. Scrolls away with the page. */
sectionNav?: SectionNav;
};

export function SidebarLabel({ children }: { children: ReactNode }) {
Expand Down Expand Up @@ -65,12 +68,14 @@ export function AppShell({
sidebarLabel = "Page sidebar",
menuLabel = "Menu",
sidebarPosition = "left",
sectionNav,
}: AppShellProps) {
// A right sidebar is secondary, so it follows the content in reading order. On small
// screens it stacks below the content, above the footer, instead of hiding in a drawer.
if (sidebarPosition === "right") {
return (
<main className={styles.shell}>
<main className={`${styles.shell}${sectionNav ? ` ${styles.shellWithSectionNav}` : ""}`}>
{sectionNav ? <SectionBar {...sectionNav} /> : null}
<section className={`${styles.page} ${styles.pageRight}`} data-sidebar-position="right">
<section className={styles.main} aria-label={mainLabel} data-app-main>
<div className={styles.content}>{children}</div>
Expand All @@ -85,7 +90,8 @@ export function AppShell({
}

return (
<main className={styles.shell}>
<main className={`${styles.shell}${sectionNav ? ` ${styles.shellWithSectionNav}` : ""}`}>
{sectionNav ? <SectionBar {...sectionNav} /> : null}
<section
className={`${styles.page}${sidebarPosition === "none" ? ` ${styles.pageNone}` : ""}`}
data-sidebar-position={sidebarPosition}
Expand Down
28 changes: 28 additions & 0 deletions app/ui/site/section-bar-scroll.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client";

import { useLayoutEffect, useRef, type ReactNode } from "react";
import styles from "./section-bar.module.css";

/** Only the row scrolls. scrollIntoView could also move the page under the sticky header. */
export function SectionBarScroll({ children, current }: { children: ReactNode; current: string }) {
const ref = useRef<HTMLDivElement>(null);

useLayoutEffect(() => {
let cancelled = false;
const revealCurrent = () => {
const row = ref.current;
const active = row?.querySelector<HTMLElement>('[aria-current="page"]');
if (cancelled || !row || !active) return;
const bounds = row.getBoundingClientRect();
const link = active.getBoundingClientRect();
if (link.right > bounds.right) row.scrollLeft += link.right - bounds.right;
else if (link.left < bounds.left) row.scrollLeft += link.left - bounds.left;
};

revealCurrent();
void document.fonts.ready.then(revealCurrent);
return () => { cancelled = true; };
}, [current]);

return <div className={styles.scroll} ref={ref}>{children}</div>;
}
82 changes: 82 additions & 0 deletions app/ui/site/section-bar.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/* Match the main navigation and sidebar rail gutters. This bar scrolls with the page. */
.bar {
align-items: center;
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
display: flex;
gap: var(--space-12);
min-width: 0;
padding-inline: clamp(20px, 2vw, 28px);
}

.bar .identity {
color: var(--color-text-muted);
flex: 0 0 auto;
font-family: var(--font-mono);
font-size: var(--text-small);
text-transform: lowercase;
white-space: nowrap;
}

.scroll {
min-width: 0;
overflow-x: auto;
overscroll-behavior-x: contain;
}

.links {
display: flex;
gap: var(--space-10);
list-style: none;
margin: 0;
padding: 0;
width: max-content;
}

.bar .link {
border-bottom: 2px solid transparent;
color: var(--color-text-muted);
display: block;
font-size: var(--text-body);
line-height: var(--line-ui);
padding-block: var(--space-5);
white-space: nowrap;
}

.bar a[href]:hover {
color: var(--color-text);
text-decoration: none;
}

.bar .link[aria-current="page"],
.bar .link[aria-current="page"]:hover {
border-bottom-color: var(--color-accent-text);
color: var(--color-accent-text);
}

.bar a[href]:focus-visible {
border-radius: var(--radius);
outline: var(--focus-outline);
outline-offset: -2px;
text-decoration: none;
}

@media (max-width: 960px) {
.bar {
gap: var(--space-9);
padding-inline: 22px;
}
}

/* On phones the links start at the edge. The nav's label still names the section. */
@media (max-width: 680px) {
.bar .identity {
display: none;
}
}

@media print {
.bar {
display: none;
}
}
37 changes: 37 additions & 0 deletions app/ui/site/section-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Link from "next/link";
import { intentPrefetch } from "./intent-prefetch";
import { SectionBarScroll } from "./section-bar-scroll";
import styles from "./section-bar.module.css";

/** A page opts in to a short set of related pages, independently of its sidebar. */
export type SectionNav = {
label: string;
href: string;
/** The active item's exact href. Passed by the page, without a pathname lookup. */
current: string;
items: readonly { label: string; href: string }[];
};

export function SectionBar({ label, href, current, items }: SectionNav) {
return (
<nav aria-label={`${label} pages`} className={styles.bar}>
<Link {...intentPrefetch} className={styles.identity} href={href}>{label}</Link>
<SectionBarScroll current={current}>
<ul className={styles.links}>
{items.map((item) => (
<li key={item.href}>
<Link
{...intentPrefetch}
aria-current={item.href === current ? "page" : undefined}
className={styles.link}
href={item.href}
>
{item.label}
</Link>
</li>
))}
</ul>
</SectionBarScroll>
</nav>
);
}
1 change: 1 addition & 0 deletions lib/registered-site-pages.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"/components-ui",
"/components-ui/[section]",
"/components-ui/charts",
"/components-ui/section-bar/[position]/[view]",
"/components-ui/sidebar-modes/[position]",
"/customer-acquisition-cost-calculator",
"/customer-churn-calculator",
Expand Down
2 changes: 1 addition & 1 deletion skills/stack127/page-build/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Read `AGENTS.md`, `stack127_docs/writing-style.md`, `stack127_docs/page-content.
## Work

1. Confirm the route family, flat public URL, publication state, indexing setting, and data source. Use `registry.is_indexable: false` for thin child pages, filtered views with their own paths, or public pages still being improved. Keep them published when people should be able to use them.
2. Check the shared components and `/components-ui` gallery in development before creating local UI. Pick the sidebar position for this page: `left` for navigation and options, `right` for related pages or section links, or `none` when the page only needs its content. Prefer `left`. See Sidebars in `stack127_docs/design-system.md`. Implement the page with the shared shell, `PageCanvas`, tokens, components, metadata system, and server-rendered content. Write the page's content as MDX in `content/` beside the route and import it, as `stack127_docs/page-content.md` describes. Tools, directories, and catalogs have no database-content fallback.
2. Check the shared components and `/components-ui` gallery in development before creating local UI. Pick the sidebar position for this page: `left` for navigation and options, `right` for related pages or section links, or `none` when the page only needs its content. Prefer `left`. Optionally pass `sectionNav` to `AppShell` for a section's 3 to 6 main pages, with the active href passed explicitly. Don't duplicate the sidebar's job. See Sidebars and Section bar in `stack127_docs/design-system.md`. Implement the page with the shared shell, `PageCanvas`, tokens, components, metadata system, and server-rendered content. Write the page's content as MDX in `content/` beside the route and import it, as `stack127_docs/page-content.md` describes. Tools, directories, and catalogs have no database-content fallback.
3. Keep the page static and on-demand revalidated, as `stack127_docs/caching.md` describes. No `force-dynamic`, no timed `revalidate`, and no server `searchParams`, `headers()`, or `cookies()`. URL-dependent parts are client components. A new data source gets a cache tag and pipeline coverage.
4. Keep page styles beside the page or component. Do not add page selectors to `app/globals.css`.
5. Add a focused test only for calculations, parsers, data transforms, or a failure that could return a wrong answer. Do not test prose, documentation, skill wording, hashes, file inventories, or incidental markup.
Expand Down
2 changes: 1 addition & 1 deletion stack127_docs/css-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Flat tool pages live under `app/(site)/(tools)/<slug>/`. The `(tools)` route gro

## Building a page

1. Use `AppShell` with `PageCanvas` for each outer content section. Set `sidebarPosition` to `left`, `right`, or `none`, as the Sidebars section of [design-system.md](design-system.md) describes. `EntryHero` already includes a canvas. Use the shared navigation, sidebar-panel, prose, media, and code-block components where they fit.
1. Use `AppShell` with `PageCanvas` for each outer content section. Set `sidebarPosition` to `left`, `right`, or `none`, as the Sidebars section of [design-system.md](design-system.md) describes. Add `sectionNav` only when the page needs short navigation between related pages. Its styles live in `app/ui/site/section-bar.module.css`. `EntryHero` already includes a canvas. Use the shared navigation, sidebar-panel, prose, media, and code-block components where they fit.
2. Put the page's other styles in its route folder or beside its component.
3. Import family CSS from the layout or pages that use it, not from a layout that also serves unrelated pages. Import a CSS Module directly from the component that owns it.
4. Use semantic tokens from `globals.css`. Don't create another global token layer in a page file. Every bounded UI shape uses the shared 2px `--radius` token.
Expand Down
10 changes: 10 additions & 0 deletions stack127_docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,16 @@ Don't hand-roll another sidebar link list.

Tool catalog sidebars link between published tool catalogs and mark the current one. Individual tools belong in the catalog's main cards, not in a duplicate sidebar list. Individual tool pages list the other tools in their catalog. Tool pages and catalogs use `left`.

### Section bar

Pages can opt in to `sectionNav` on `AppShell` for a section's 3 to 6 main pages. The owner chooses page by page. The lowercase mono section name links home, followed by plain page links. The current page uses accent text and a 2px accent underline.

The bar spans the page below the main navigation and shares its horizontal gutter. It isn't sticky. On phones the section name is hidden and the links scroll sideways in one row. The current link is revealed on load without moving the page or focus. The bar is hidden in print.

Use it with any sidebar position. Keep filters and long lists in the sidebar. Don't give the section bar and sidebar the same job.

Development previews: `/components-ui/section-bar/left/checker`, `/components-ui/section-bar/right/checker`, and `/components-ui/section-bar/none/checker`. Each has Directory, Niches, Stats, and Checker links.

### Homepage

The homepage lists published tool catalogs, selected directories, the self-hosting course, and Labs. Hardware directories stay published but are off the homepage cards. The homepage uses `none` and doesn't list individual tools or directory child pages. Card names and descriptions come from each family's page table.
Expand Down
Loading
Loading