From 8cb1f284370d0b3b0f9d750cfbcfb15c37859fe0 Mon Sep 17 00:00:00 2001 From: Ravi Teja KNTS Date: Sat, 10 Oct 2026 08:26:39 +0530 Subject: [PATCH 1/3] Redesign content blocks and give prose its own gallery page - Callouts: a neutral box with a monochrome icon per kind (note, tip, warning). Only a warning colors its title. Adds the Tip export. - Formula and example: a header row with the title and the kind, a dashed rule, and formulas in plain mono instead of code chips. - FAQ: always open, with rules between questions and its own heading styles, so it looks right outside prose too. - Code block: the label and a Copy button sit in a header bar inside the block. Unlabeled blocks keep the button beside the code. - Prose: quotes, rules, term lists, bold, small print, nested lists, and muted list markers. - Gallery: one example per look, MDX usage under each content component, a new Prose page, and no ContentMarkdown demos. Co-Authored-By: Claude Opus 5.5 --- .../components-ui/[section]/page.tsx | 2 + .../components-ui/components-ui.module.css | 18 +- .../components-ui/examples/cards.tsx | 58 ++----- .../components-ui/examples/content.tsx | 154 ++++++++++-------- .../components-ui/examples/prose.tsx | 134 +++++++++++++++ .../components-ui/examples/tables.tsx | 28 +++- .../(components-ui)/components-ui/gallery.tsx | 10 +- .../(components-ui)/components-ui/sections.ts | 16 +- app/ui/callout.module.css | 40 +++-- app/ui/callout.tsx | 19 ++- app/ui/content-block.module.css | 51 +++++- app/ui/content-block.tsx | 15 +- app/ui/faq.module.css | 44 ++++- app/ui/faq.tsx | 13 +- app/ui/site/code-block.module.css | 85 +++++++--- app/ui/site/code-block.tsx | 45 ++--- app/ui/site/prose.module.css | 50 ++++++ stack127_docs/design-system.md | 8 +- stack127_docs/shared-ui.md | 8 +- 19 files changed, 587 insertions(+), 211 deletions(-) create mode 100644 app/(site)/(components-ui)/components-ui/examples/prose.tsx diff --git a/app/(site)/(components-ui)/components-ui/[section]/page.tsx b/app/(site)/(components-ui)/components-ui/[section]/page.tsx index 97251e71..03cb278b 100644 --- a/app/(site)/(components-ui)/components-ui/[section]/page.tsx +++ b/app/(site)/(components-ui)/components-ui/[section]/page.tsx @@ -10,6 +10,7 @@ import { MediaAndArtExamples } from "../examples/media-and-art"; import { NavigationExamples } from "../examples/navigation"; import { NumbersAndSlidersExamples } from "../examples/numbers-and-sliders"; import { PageLayoutExamples } from "../examples/page-layout"; +import { ProseExamples } from "../examples/prose"; import { ResultsExamples } from "../examples/results"; import { TablesExamples } from "../examples/tables"; import { TextAndChoicesExamples } from "../examples/text-and-choices"; @@ -27,6 +28,7 @@ const examples: Record = { tables: TablesExamples, cards: CardsExamples, content: ContentExamples, + prose: ProseExamples, "media-and-art": MediaAndArtExamples, "feedback-and-dialogs": FeedbackAndDialogsExamples, navigation: NavigationExamples, diff --git a/app/(site)/(components-ui)/components-ui/components-ui.module.css b/app/(site)/(components-ui)/components-ui/components-ui.module.css index cd04dc59..141f1d21 100644 --- a/app/(site)/(components-ui)/components-ui/components-ui.module.css +++ b/app/(site)/(components-ui)/components-ui/components-ui.module.css @@ -107,6 +107,12 @@ /* Example layouts */ +/* Prose demos stop at the reading width, as they do on pages. */ +.reading { + max-width: var(--measure-prose); + min-width: 0; +} + .stack { display: grid; gap: var(--space-8); @@ -153,18 +159,6 @@ position: relative; } -.cardLogo { - border: 1px solid var(--color-border-strong); - border-radius: var(--radius); - color: var(--color-text); - display: grid; - font-family: var(--font-display); - font-weight: var(--weight-bold); - height: 48px; - place-items: center; - width: 48px; -} - .markerExample { max-width: 320px; } diff --git a/app/(site)/(components-ui)/components-ui/examples/cards.tsx b/app/(site)/(components-ui)/components-ui/examples/cards.tsx index 22164c5a..9328f8d1 100644 --- a/app/(site)/(components-ui)/components-ui/examples/cards.tsx +++ b/app/(site)/(components-ui)/components-ui/examples/cards.tsx @@ -1,30 +1,16 @@ "use client"; import Link from "next/link"; -import { CalendarDays, Code2, HardDrive, MemoryStick, WalletCards } from "lucide-react"; -import { CatalogCard } from "@/app/ui/cards/catalog-card"; +import { HardDrive } from "lucide-react"; import { DirectoryCard } from "@/app/ui/cards/directory-card"; import { PageCard, PageCardCounts } from "@/app/ui/cards/page-card"; import { ToolCard } from "@/app/ui/cards/tool-card"; -import { backup321Art } from "@/app/ui/art/backup-321"; -import { AppFactCardContent, appFactCardClassName } from "@/app/ui/directories/app-fact-card"; import { DirectoryAppCard } from "@/app/ui/directories/directory-app-card"; import { HardwareFactCardContent, hardwareFactCardClassName } from "@/app/ui/directories/hardware-fact-card"; import type { DirectoryAppCardData } from "@/lib/apps-card"; -import type { ToolCatalogSummary } from "@/lib/tool-catalogs"; import { Example } from "../gallery"; import styles from "../components-ui.module.css"; -// CatalogCard reads only the card fields, the page slug, and the tool count. -const exampleCatalog = { - card_name: "Storage tools", - card_description: "Plan pools, parity, backups, and growth before you buy drives.", - card_icon_key: null, - card_art_key: "storage-tools", - page: { slug: "storage-tools" }, - tool_count: 12, -} as unknown as ToolCatalogSummary; - const exampleApp: DirectoryAppCardData = { slug: "example-photo-library", name: "Example photo library", @@ -56,49 +42,31 @@ const exampleApp: DirectoryAppCardData = { export function CardsExamples() { return ( <> - +
- - } - description="Without approved art, the card shows its icon large." - href="/self-host-apps" - icon={HardDrive} - name="Example directory" - /> +
- +
- - + } + description="Without approved art, the card shows its icon large." + href="/storage-tools" + icon={HardDrive} + name="Card without art" + />
- +
- -
diff --git a/app/(site)/(components-ui)/components-ui/examples/content.tsx b/app/(site)/(components-ui)/components-ui/examples/content.tsx index 2be08a3c..afd3cd5d 100644 --- a/app/(site)/(components-ui)/components-ui/examples/content.tsx +++ b/app/(site)/(components-ui)/components-ui/examples/content.tsx @@ -1,94 +1,120 @@ -import { Callout, Note, Warning } from "@/app/ui/callout"; +import { Note, Tip, Warning } from "@/app/ui/callout"; import { ContentBlock } from "@/app/ui/content-block"; -import { ContentMarkdown } from "@/app/ui/content-markdown"; +import { Term, Terms } from "@/app/ui/content-elements"; import { Faq } from "@/app/ui/faq"; import { CodeBlock } from "@/app/ui/site/code-block"; import { proseClassName, proseFlowClassName } from "@/app/ui/site/prose"; import { Example } from "../gallery"; import styles from "../components-ui.module.css"; +// Demos sit in the same prose flow as page content, so they look the way a page shows them. +const flow = `${proseClassName} ${proseFlowClassName} ${styles.reading}`; + export function ContentExamples() { return ( <> - -
+ +Use the provider's current documentation when a setting can change. + + + +Changing a storage layout can erase the disks you select. + + + +Two matching drives are the simplest layout to restore. +` }} + > +

Use the provider's current documentation when a setting can change.

Changing a storage layout can erase the disks you select.

-

Two matching drives are the simplest layout to restore.

+

Two matching drives are the simplest layout to restore.

- -
-

usable space = raw space × efficiency

Keep both values in the same unit.

-

Six 8 TB drives contain 48 TB of raw capacity before parity and overhead.

+ + +\`payback months = CAC ÷ (monthly ARPA × gross margin)\` + +Use gross margin as a decimal, so 80% is 0.8. + + + + + +A customer costs $1,200 to win and pays $100 a month at an 80% gross margin. +Each month returns $80, so the cost comes back in 15 months. + +` }} + > +
+ +

payback months = CAC ÷ (monthly ARPA × gross margin)

+

Use gross margin as a decimal, so 80% is 0.8.

+
+ +

A customer costs $1,200 to win and pays $100 a month at an 80% gross margin. Each month returns $80, so the cost comes back in 15 months.

+
- + +How long a resolver may keep a record before it asks again. +The DNS server that looks up names for your device. +` }} + > +
+ + How long a resolver may keep a record before it asks again. + The DNS server that looks up names for your device. + +
+
+ + Questions stay open, one under another. Course pages use this. In MDX, write each question as a ### heading under a ## heading instead.} + source="app/ui/faq.tsx" + title="Questions" + usage={{ label: "TSX", code: `` }} + > When measured values are easier to compare visually than to read.

}, + { question: "Can a directory use a tool component?", answer:

Yes. Every shared component works on every page type.

}, ]} />
- + {`services: app: image: ghcr.io/example/app:latest ports: - "8080:80"`} - - - -
-

What is a reverse proxy?

-

A reverse proxy sits in front of your apps and sends each request to the right one. It also handles HTTPS certificates in one place.

-
    -
  • One address for many apps
  • -
  • One place to renew certificates
  • -
-
-
- - - [!NOTE] About this result", - "> Values come from a single sample.", - "", - "> [!FORMULA] Percentage used", - "> `used ÷ total × 100`", - "", - "> [!WARNING] Keep a backup", - "> A disk change can remove data.", - "", - "> [!TIP] Start small", - "> Two drives in a mirror are enough to begin.", - "", - "> [!EXAMPLE] Small pool", - "> 5 TB used out of 20 TB is 25% used.", - "", - "| Service | Requests |", - "| --- | ---: |", - "| API | 50 |", - "| Worker | 30 |", - "", - "```bash", - "df -h", - "```", - "", - "```chart-bar", - '{"title":"Requests by service","caption":"Same 15-minute sample.","data":[{"id":"api","label":"API","value":50},{"id":"worker","label":"Worker","value":30}]}', - "```", - "", - "```chart-line", - '{"title":"Storage over time","valueLabel":"Used TB","data":[{"id":"jan","label":"January","xLabel":"Jan","value":5},{"id":"feb","label":"February","xLabel":"Feb","value":7},{"id":"mar","label":"March","xLabel":"Mar","value":8.5},{"id":"apr","label":"April","xLabel":"Apr","value":11}]}', - "```", - "", - "```chart-donut", - '{"title":"Work split","data":[{"id":"api","label":"API","value":50},{"id":"worker","label":"Worker","value":30}]}', - "```", - ].join("\n")} /> + ssh user@192.168.1.50 ); diff --git a/app/(site)/(components-ui)/components-ui/examples/prose.tsx b/app/(site)/(components-ui)/components-ui/examples/prose.tsx new file mode 100644 index 00000000..df059a25 --- /dev/null +++ b/app/(site)/(components-ui)/components-ui/examples/prose.tsx @@ -0,0 +1,134 @@ +import { Note } from "@/app/ui/callout"; +import { ContentBlock } from "@/app/ui/content-block"; +import { ContentTable } from "@/app/ui/content-elements"; +import { CodeBlock } from "@/app/ui/site/code-block"; +import { proseClassName, proseFlowClassName, proseLedeClassName } from "@/app/ui/site/prose"; +import { Example } from "../gallery"; +import styles from "../components-ui.module.css"; + +const flow = `${proseClassName} ${proseFlowClassName} ${styles.reading}`; + +export function ProseExamples() { + return ( + <> + Ctrl + C to stop following logs.` }} + > +
+

How does a reverse proxy work?

+

A reverse proxy sits in front of your apps and sends each request to the right one. It also handles HTTPS certificates in one place.

+

What it changes

+

Requests reach one address, and the proxy picks the app by host name. Run docker compose up -d, then press Ctrl + C to stop following logs.

+

A fourth-level heading

+

Use it rarely. Most pages need only h2 and h3.

+

Small print, for a source line or a date checked. Checked on 9 October 2026.

+
+
+ + +
+
    +
  • One address for many apps
  • +
  • One place to renew certificates +
      +
    • Certificates renew on their own
    • +
    • The proxy picks up each new one
    • +
    +
  • +
+
    +
  1. Point the domain at your server.
  2. +
  3. Start the proxy.
  4. +
  5. Add one rule per app.
  6. +
+
+
+ + Registrars may deny a transfer within 60 days of a change of registrant. + +---` }} + > +
+

Registrars may deny a transfer within 60 days of a change of registrant.

+
+

Text after the rule continues the page.

+
+
+ + +
+

How much does storage cost?

+

Cloud storage is billed per GB each month. The price drops as the tier gets colder.

+ + TierExample price per GB per month + + Hot$0.020 + Cool$0.010 + Archive$0.002 + + +

Multiply the stored amount by the tier price.

+ +

monthly cost = stored GB × price per GB

+
+

Check the provider's price page before you commit.

+

To see what a folder holds before you upload it, run:

+ du -sh ~/photos +

The total is the stored amount to multiply.

+
+
+ + …

+ +
+
…
+
…
+
` }} + > +
+

Find out how long a customer takes to pay back what you spent to win them.

+
+
+ + ); +} diff --git a/app/(site)/(components-ui)/components-ui/examples/tables.tsx b/app/(site)/(components-ui)/components-ui/examples/tables.tsx index 2adaf53c..51b6fecc 100644 --- a/app/(site)/(components-ui)/components-ui/examples/tables.tsx +++ b/app/(site)/(components-ui)/components-ui/examples/tables.tsx @@ -1,4 +1,4 @@ -import { ContentMarkdown } from "@/app/ui/content-markdown"; +import { ContentTable } from "@/app/ui/content-elements"; import { UiDataTable } from "@/app/ui/display"; import { Example } from "../gallery"; @@ -24,14 +24,24 @@ export function TablesExamples() { />
- - + + + PlanPriceStorageUsersBackupsSupport + + Starter$550 GB1DailyEmail + Team$20500 GB10HourlyEmail and chat + Business$602 TB50HourlyPriority + + ); diff --git a/app/(site)/(components-ui)/components-ui/gallery.tsx b/app/(site)/(components-ui)/components-ui/gallery.tsx index 36728f57..82602e9e 100644 --- a/app/(site)/(components-ui)/components-ui/gallery.tsx +++ b/app/(site)/(components-ui)/components-ui/gallery.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { CodeBlock } from "@/app/ui/site/code-block"; import { PageCanvas } from "@/app/ui/site/page-canvas"; import { PageHeader } from "@/app/ui/site/page-header"; import { PageTitleIcon } from "@/app/ui/site/page-title-icon"; @@ -17,19 +18,25 @@ export function GalleryPage({ children, section }: { children: ReactNode; sectio ); } -/** One example: a plain name, the components it shows, where they live, and the working demo. */ +/** + * One example: a plain name, the components it shows, where they live, the working demo, + * and optionally what to write to get it. Show each look once; list every component that renders it. + */ export function Example({ children, components, note, source, title, + usage, }: { children: ReactNode; components: string[]; note?: ReactNode; source: string; title: string; + /** What to write, for example the MDX tags. The label names the language. */ + usage?: { label: "MDX" | "TSX"; code: string }; }) { return (
@@ -42,6 +49,7 @@ export function Example({ {note ?

{note}

: null}
{children} + {usage ? {usage.code} : null} ); } diff --git a/app/(site)/(components-ui)/components-ui/sections.ts b/app/(site)/(components-ui)/components-ui/sections.ts index 636ce345..78557ac5 100644 --- a/app/(site)/(components-ui)/components-ui/sections.ts +++ b/app/(site)/(components-ui)/components-ui/sections.ts @@ -7,6 +7,7 @@ import { ListFilter, MessageSquare, MousePointerClick, + Pilcrow, Signpost, SlidersHorizontal, SquareStack, @@ -76,9 +77,9 @@ export const gallerySections: GallerySection[] = [ { slug: "tables", label: "Tables", - description: "Data tables with row headers, and a scroll frame for wide tables.", + description: "Data tables with row headers, content tables written in MDX, and a scroll frame for wide tables.", icon: Table2, - components: ["UiDataTable", "TableFrame"], + components: ["UiDataTable", "ContentTable", "TableFrame"], }, { slug: "charts", @@ -97,9 +98,16 @@ export const gallerySections: GallerySection[] = [ { slug: "content", label: "Content", - description: "Notes, warnings, formulas, examples, questions, code, and Markdown.", + description: "Notes, warnings, tips, formulas, examples, terms, questions, and code.", icon: FileText, - components: ["Callout", "Note", "Warning", "ContentBlock", "Faq", "CodeBlock", "ContentMarkdown"], + components: ["Callout", "Note", "Warning", "Tip", "ContentBlock", "Terms", "Term", "Faq", "CodeBlock"], + }, + { + slug: "prose", + label: "Prose", + description: "Reading text: headings, paragraphs, links, lists, quotes, inline code, and how blocks sit between them.", + icon: Pilcrow, + components: ["proseClassName", "proseFlowClassName", "proseSectionFlowClassName", "proseLedeClassName"], }, { slug: "media-and-art", diff --git a/app/ui/callout.module.css b/app/ui/callout.module.css index 6ab2edfc..ddb923a9 100644 --- a/app/ui/callout.module.css +++ b/app/ui/callout.module.css @@ -1,35 +1,51 @@ +/* Neutral box. The icon names the kind; only a warning colors its words. */ .callout { align-self: start; - background: var(--color-surface-2); + background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius); + column-gap: var(--space-5); display: grid; - gap: var(--space-2); + grid-template-columns: auto minmax(0, 1fr); max-width: var(--measure-prose); min-width: 0; - padding: var(--space-4) var(--space-6); + padding: var(--space-7) var(--space-9) var(--space-8) var(--space-8); width: 100%; } -.callout .title { - color: var(--color-text); - font-size: var(--text-small); - font-weight: var(--weight-semibold); - line-height: var(--line-ui); - margin: 0; +.icon { + color: var(--color-text-muted); + margin-top: 1px; } -.callout[data-tone="recommend"] .title { - color: var(--color-success-text); +.callout[data-tone="recommend"] .icon { + color: var(--color-accent-text); } +.callout[data-tone="warning"] .icon, .callout[data-tone="warning"] .title { color: var(--color-warning-text); } +.content { + display: grid; + gap: var(--space-3); + min-width: 0; +} + +.callout .title { + color: var(--color-text); + font-family: var(--font-display); + font-size: var(--text-body); + font-weight: var(--weight-bold); + letter-spacing: var(--tracking-display); + line-height: var(--line-heading); + margin: 0; +} + .body { display: grid; - gap: var(--space-2); + gap: var(--space-5); min-width: 0; } diff --git a/app/ui/callout.tsx b/app/ui/callout.tsx index 6c4d05ce..14d16c64 100644 --- a/app/ui/callout.tsx +++ b/app/ui/callout.tsx @@ -1,17 +1,28 @@ import type { ReactNode } from "react"; +import { Info, Lightbulb, TriangleAlert, type LucideIcon } from "lucide-react"; import styles from "./callout.module.css"; export type CalloutTone = "note" | "warning" | "recommend"; +const toneIcons: Record = { + note: Info, + recommend: Lightbulb, + warning: TriangleAlert, +}; + export function Callout({ children, title, tone = "note" }: { children: ReactNode; title: string; tone?: CalloutTone; }) { + const Icon = toneIcons[tone]; return ( ); } @@ -23,3 +34,7 @@ export function Note({ children, title = "Note" }: { children: ReactNode; title? export function Warning({ children, title = "Warning" }: { children: ReactNode; title?: string }) { return {children}; } + +export function Tip({ children, title = "Tip" }: { children: ReactNode; title?: string }) { + return {children}; +} diff --git a/app/ui/content-block.module.css b/app/ui/content-block.module.css index bc9f9ce0..a70d9c8b 100644 --- a/app/ui/content-block.module.css +++ b/app/ui/content-block.module.css @@ -1,30 +1,67 @@ .block { align-self: start; - background: var(--color-surface-2); + background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius); display: grid; - gap: var(--space-2); max-width: var(--measure-prose); min-width: 0; - padding: var(--space-4) var(--space-6); width: 100%; } -.block .title { - color: var(--color-text); +/* Title on the left, the kind on the right. Without a title, the kind leads. */ +.header { + align-items: baseline; + border-bottom: 1px dashed var(--color-border); + display: flex; + flex-wrap: wrap; + gap: var(--space-2) var(--space-7); + justify-content: space-between; + min-width: 0; + padding: var(--space-6) var(--space-9) var(--space-6) var(--space-8); +} + +.header .kind { + align-items: center; + align-self: center; + color: var(--color-text-muted); + display: inline-flex; + font-family: var(--font-mono); font-size: var(--text-small); - font-weight: var(--weight-semibold); + gap: var(--space-2); line-height: var(--line-ui); margin: 0; } +.header .title { + color: var(--color-text); + font-family: var(--font-display); + font-size: var(--text-body); + font-weight: var(--weight-bold); + letter-spacing: var(--tracking-display); + line-height: var(--line-heading); + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + .body { display: grid; - gap: var(--space-2); + gap: var(--space-5); min-width: 0; + padding: var(--space-7) var(--space-9) var(--space-8) var(--space-8); } .body :where(p, ul, ol) { margin-block: 0; } + +/* A formula reads as math, not as a row of code chips. */ +.block[data-kind="formula"] .body code { + background: none; + border: 0; + color: var(--color-text); + font-family: var(--font-mono); + font-size: 0.95em; + padding: 0; +} diff --git a/app/ui/content-block.tsx b/app/ui/content-block.tsx index 5a8d3017..7eae3909 100644 --- a/app/ui/content-block.tsx +++ b/app/ui/content-block.tsx @@ -1,14 +1,25 @@ import type { ReactNode } from "react"; +import { FlaskConical, Sigma } from "lucide-react"; import styles from "./content-block.module.css"; +const kinds = { + example: { icon: FlaskConical, label: "Example" }, + formula: { icon: Sigma, label: "Formula" }, +} as const; + +/** A formula or worked example: a kind label, an optional title, then the body. */ export function ContentBlock({ children, kind, title }: { children: ReactNode; kind: "formula" | "example"; title?: string; }) { + const { icon: Icon, label } = kinds[kind]; return ( -
-

{title || (kind === "formula" ? "Formula" : "Worked example")}

+
+
+ {title ?

{title}

: null} +

+
{children}
); diff --git a/app/ui/faq.module.css b/app/ui/faq.module.css index c8fe0e9b..23d384b7 100644 --- a/app/ui/faq.module.css +++ b/app/ui/faq.module.css @@ -1,14 +1,52 @@ .faq { display: grid; - gap: var(--space-6); + gap: var(--space-8); + max-width: var(--measure-prose); + min-width: 0; +} + +/* Inside a prose flow, the questions start like any later h2 section. */ +.faq:not(:first-child) { + margin-top: var(--space-prose-heading-2); +} + +.heading, +.question { + color: var(--color-text); + font-family: var(--font-display); + font-weight: var(--weight-bold); + letter-spacing: var(--tracking-display); + line-height: var(--line-heading); + margin: 0; +} + +.heading { + font-size: var(--text-h2); +} + +.question { + font-size: var(--text-h3); +} + +.list { + border-top: 1px solid var(--color-border); + display: grid; } .item { + border-bottom: 1px solid var(--color-border); display: grid; - gap: var(--space-2); + gap: var(--space-4); + padding: var(--space-9) 0; } -.item > div { +.answer { + display: grid; font-size: var(--text-body); + gap: var(--space-5); line-height: var(--line-body); } + +.answer :where(p, ul, ol) { + margin: 0; +} diff --git a/app/ui/faq.tsx b/app/ui/faq.tsx index 616d12e1..e3c3a63a 100644 --- a/app/ui/faq.tsx +++ b/app/ui/faq.tsx @@ -1,17 +1,20 @@ import type { ReactNode } from "react"; import styles from "./faq.module.css"; +/** Questions and answers, always open, separated by rules. */ export function Faq({ items, title = "Common questions" }: { items: Array<{ question: string; answer: ReactNode }>; title?: string; }) { return (
-

{title}

- {items.map((item) =>
-

{item.question}

-
{item.answer}
-
)} +

{title}

+
+ {items.map((item) =>
+

{item.question}

+
{item.answer}
+
)} +
); } diff --git a/app/ui/site/code-block.module.css b/app/ui/site/code-block.module.css index 783226a0..987c9395 100644 --- a/app/ui/site/code-block.module.css +++ b/app/ui/site/code-block.module.css @@ -1,57 +1,82 @@ +/* Code surfaces stay dark in both themes: one familiar terminal everywhere. + The locally scoped custom properties keep that independent of the page theme. */ .block { + --code-bg: #141413; + --code-header: #1b1b1a; + --code-rule: rgba(232, 232, 228, 0.1); + --code-text: #e8e8e4; + --code-muted: #9d9c95; + --code-accent: #bafc3c; + background: var(--code-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius); display: grid; - gap: var(--space-2); margin: 0; min-width: 0; + overflow: hidden; +} + +.header { + align-items: center; + background: var(--code-header); + border-bottom: 1px solid var(--code-rule); + display: flex; + gap: var(--space-5); + justify-content: space-between; + min-width: 0; + padding: var(--space-2) var(--space-3) var(--space-2) var(--space-8); } .label { - color: var(--color-text-muted); + color: var(--code-muted); font-family: var(--font-mono); font-size: var(--text-small); line-height: var(--line-ui); min-width: 0; overflow: hidden; - padding: 0 var(--space-1); text-overflow: ellipsis; white-space: nowrap; } -/* Code surfaces stay dark in both themes: one familiar terminal everywhere. - The locally scoped custom properties keep that independent of the page theme. */ -.frame { - --code-bg: #141413; - --code-text: #e8e8e4; - --code-muted: #9d9c95; +.body { align-items: flex-start; - background: var(--code-bg); - border: 1px solid var(--color-border); - border-radius: var(--radius); display: flex; - gap: var(--space-3); + gap: var(--space-5); min-width: 0; - padding: var(--space-6) var(--space-4) var(--space-6) var(--space-7); + padding: var(--space-8) var(--space-8); +} + +.block:not([data-labeled]) .body { + align-items: center; + padding: var(--space-4) var(--space-3) var(--space-4) var(--space-8); } .copy { align-items: center; appearance: none; background: transparent; - border: 0; + border: 1px solid transparent; border-radius: var(--radius); color: var(--code-muted); cursor: pointer; display: inline-flex; flex: 0 0 auto; - height: 22px; - justify-content: center; + font-family: var(--font-mono); + font-size: var(--text-small); + gap: var(--space-2); + height: 30px; + line-height: 1; margin: 0; - padding: 0; - width: 22px; + padding: 0 var(--space-4); } .copy:hover { - color: var(--color-accent); + border-color: var(--code-rule); + color: var(--code-text); +} + +.copy[data-copied] { + color: var(--code-accent); } .copy:focus-visible { @@ -67,10 +92,28 @@ padding: 0; } +.block:not([data-labeled]) .pre { + padding-block: var(--space-4); +} + .pre code { color: var(--code-text); font-family: var(--font-mono); font-size: var(--text-small); - line-height: 1.6; + line-height: 1.7; white-space: pre; } + +@media (max-width: 640px) { + .header { + padding-left: var(--space-6); + } + + .body { + padding: var(--space-7) var(--space-6); + } + + .block:not([data-labeled]) .body { + padding-left: var(--space-6); + } +} diff --git a/app/ui/site/code-block.tsx b/app/ui/site/code-block.tsx index d55f8c5f..d5a2bb0f 100644 --- a/app/ui/site/code-block.tsx +++ b/app/ui/site/code-block.tsx @@ -10,7 +10,7 @@ type CodeBlockProps = { * Do not wrap children in yourself. */ children: string; - /** Optional short label, e.g. a filename shown above the block. */ + /** Optional short label, e.g. a filename or language, shown in the block's header bar. */ label?: string; className?: string; }; @@ -48,30 +48,37 @@ export function CodeBlock({ children, label, className }: CodeBlockProps) { window.setTimeout(() => setCopied(false), 1600); } + const button = ( + + ); + return ( -
+
{label ? ( - - {label} - +
+ {label} + {button} +
) : null} -
+
           {code}
         
- + {label ? null : button}
); diff --git a/app/ui/site/prose.module.css b/app/ui/site/prose.module.css index f780d8e5..75c79009 100644 --- a/app/ui/site/prose.module.css +++ b/app/ui/site/prose.module.css @@ -55,6 +55,56 @@ padding-left: var(--space-8); } +.prose li > :where(ul, ol) { + margin-top: var(--space-3); +} + +.prose li::marker { + color: var(--color-text-muted); +} + +.prose strong { + font-weight: var(--weight-semibold); +} + +.prose small { + color: var(--color-text-muted); + font-size: var(--text-small); +} + +/* A quote is someone else's words: indented under a neutral rule, never a callout. */ +.prose blockquote { + border-left: 2px solid var(--color-border-strong); + display: grid; + gap: var(--space-5); + margin: 0; + padding-left: var(--space-8); +} + +.prose hr { + border: 0; + border-top: 1px solid var(--color-border); + margin: var(--space-3) 0; + width: 100%; +} + +.prose dl { + display: grid; + margin: 0; +} + +.prose dt { + font-weight: var(--weight-semibold); +} + +.prose dt:not(:first-child) { + margin-top: var(--space-7); +} + +.prose dd { + margin: var(--space-1) 0 0; +} + .flow, .sectionFlow > section { display: grid; diff --git a/stack127_docs/design-system.md b/stack127_docs/design-system.md index e8bb61f8..7eabb706 100644 --- a/stack127_docs/design-system.md +++ b/stack127_docs/design-system.md @@ -252,7 +252,11 @@ The h1 icon is unboxed, em-sized, in `--color-accent-text`, and optically aligne ### Callouts -Callouts stand apart through a neutral `--color-border` full border and spacing alone. For a warning, the key phrase inside uses `--color-warning-text`. The box itself stays neutral. Never use a colored left edge. +Callouts stand apart through a neutral `--color-border` full border on `--color-surface`, and spacing. A monochrome icon names the kind: info for a note, a light bulb for a tip, a triangle for a warning. The title uses `--font-display`. Only a warning colors its icon and title, with `--color-warning-text`. A tip's icon uses `--color-accent-text`. The box itself stays neutral. Never use a colored left edge or a tinted panel. + +Formulas and worked examples use the same neutral box with a header row: the title on the left, and the kind in muted mono with its icon on the right. A dashed `--color-border` rule separates the header from the body. Code inside a formula reads as plain mono math, without inline code chips. + +FAQs stay open. Each question and answer sits between neutral `--color-border` rules. ### Small controls @@ -272,7 +276,7 @@ Media figures use `MediaFigure` from `app/ui/site/media-figure.tsx`. Screenshots Data charts use `UiChart`, or `UiLiveChart` for data computed in the browser. Chart colors come from the data tokens through the placeholder mapping in `app/ui/charts/`, so every chart follows both themes. See the Charts section of [shared-ui.md](shared-ui.md). Check the rendered chart or figure where it actually sits on the page. -Code snippets use `CodeBlock` from `app/ui/site/code-block.tsx`. Code surfaces stay dark in both themes. The accent highlights only the lines the prose is talking about. Pass the exact text as string children. Don't hand-roll a bare `
` on new pages.
+Code snippets use `CodeBlock` from `app/ui/site/code-block.tsx`. Code surfaces stay dark in both themes. A labeled block puts its file name or language and a Copy button in a header bar. An unlabeled block puts the Copy button beside the code. The accent highlights only the lines the prose is talking about. Pass the exact text as string children. Don't hand-roll a bare `
` on new pages.
 
 ### Marker art
 
diff --git a/stack127_docs/shared-ui.md b/stack127_docs/shared-ui.md
index cc71805e..f8d6fc79 100644
--- a/stack127_docs/shared-ui.md
+++ b/stack127_docs/shared-ui.md
@@ -14,7 +14,7 @@ The Components UI gallery is at `/components-ui` in development. That route retu
 | Interactive charts on pages | `UiChart` in `app/ui/charts/chart.tsx` |
 | Charts nobody needs to interact with, such as in articles | `UiStaticChart` in `app/ui/charts/chart-static.tsx` |
 | Charts of data computed in the browser, such as tool results | `UiLiveChart` in `app/ui/charts/chart-client.tsx` |
-| Notes, warnings, and recommendations | `Callout`, `Note`, `Warning` in `app/ui/callout.tsx` |
+| Notes, warnings, and recommendations | `Callout`, `Note`, `Warning`, `Tip` in `app/ui/callout.tsx` |
 | Formulas and worked examples | `ContentBlock` in `app/ui/content-block.tsx` |
 | Empty, loading, and error panels | `StatePanel` in `app/ui/state-panel.tsx` |
 | URL-based page navigation | `Pagination` in `app/ui/pagination.tsx` |
@@ -41,10 +41,12 @@ import { UiDataTable } from "@/app/ui/display";
 import { Callout } from "@/app/ui/callout";
 ```
 
-The gallery is the component library. It groups components by what they do, not by the page family that first used them, because any page can use any component. Its sidebar links one page per section: text and choices, numbers and sliders, actions and forms, file uploads, filters and sorting, results, tables, charts, cards, content, media and art, feedback and dialogs, navigation, and page layout. `/components-ui` lists every section and its components. Page layout links to real pages for full layouts. The TypeScript props beside each component are the full API. The table above helps you pick the right one.
+The gallery is the component library. It groups components by what they do, not by the page family that first used them, because any page can use any component. Its sidebar links one page per section: text and choices, numbers and sliders, actions and forms, file uploads, filters and sorting, results, tables, charts, cards, content, prose, media and art, feedback and dialogs, navigation, and page layout. `/components-ui` lists every section and its components. Page layout links to real pages for full layouts. The TypeScript props beside each component are the full API. The table above helps you pick the right one.
 
 To add a component to the gallery, add an `Example` to the matching file in `app/(site)/(components-ui)/components-ui/examples/` and add its name to that section in `sections.ts`. Add a new section only when no existing type fits.
 
+Show each look once. When a thin wrapper renders the same thing as another component, such as `ToolCard` over `PageCard`, list both names on one example instead of drawing the card twice. A component content can use gets a `usage` snippet with the MDX to write.
+
 ### When to add a component
 
 - Use the existing component when its behavior fits.
@@ -148,7 +150,7 @@ Page content is MDX beside each route. [page-content.md](page-content.md) lists
 - Draw a chart with ``, using the spec from [Charts](#charts). Use `UiChart` only when the reader needs hover, toggles, or ranges.
 - Embed approved marker art with ``. The figure must be registered in `app/marker-art-registry.json`.
 
-`ContentMarkdown` in `app/ui/content-markdown.tsx` renders pages whose content is still in the database. It uses the same elements, so moved and unmoved pages look the same.
+`ContentMarkdown` in `app/ui/content-markdown.tsx` renders pages whose content is still in the database: course pages, the ad network pages, and pages not yet moved to MDX. It uses the same elements, so moved and unmoved pages look the same. It isn't in the gallery, because new content is MDX. Remove it once nothing renders database content.
 
 ## Check a change
 

From 76810fad94392b89cf570ca7f01f879f6397dc91 Mon Sep 17 00:00:00 2001
From: Ravi Teja KNTS 
Date: Sat, 10 Oct 2026 08:44:26 +0530
Subject: [PATCH 2/3] Lay out prose terms like tool articles and keep raw HTML
 out of the MDX sample

Co-Authored-By: Claude Opus 5.5 
---
 .../components-ui/examples/prose.tsx            |  4 ++--
 app/ui/site/prose.module.css                    | 17 +++++++++++++----
 2 files changed, 15 insertions(+), 6 deletions(-)

diff --git a/app/(site)/(components-ui)/components-ui/examples/prose.tsx b/app/(site)/(components-ui)/components-ui/examples/prose.tsx
index df059a25..7ccf197f 100644
--- a/app/(site)/(components-ui)/components-ui/examples/prose.tsx
+++ b/app/(site)/(components-ui)/components-ui/examples/prose.tsx
@@ -24,13 +24,13 @@ It also handles [HTTPS certificates](/nginx-proxy-pass-calculator) in one place.
 ### What it changes
 
 Requests reach **one address**, and the proxy picks the app by *host name*.
-Run \`docker compose up -d\`, then press Ctrl + C to stop following logs.` }}
+Run \`docker compose up -d\` to start it.` }}
       >
         

How does a reverse proxy work?

A reverse proxy sits in front of your apps and sends each request to the right one. It also handles HTTPS certificates in one place.

What it changes

-

Requests reach one address, and the proxy picks the app by host name. Run docker compose up -d, then press Ctrl + C to stop following logs.

+

Requests reach one address, and the proxy picks the app by host name. Run docker compose up -d to start it.

A fourth-level heading

Use it rarely. Most pages need only h2 and h3.

Small print, for a source line or a date checked. Checked on 9 October 2026.

diff --git a/app/ui/site/prose.module.css b/app/ui/site/prose.module.css index 75c79009..f0344238 100644 --- a/app/ui/site/prose.module.css +++ b/app/ui/site/prose.module.css @@ -88,8 +88,11 @@ width: 100%; } +/* Terms sit beside their meanings, the same layout tool articles use, and stack on phones. */ .prose dl { display: grid; + gap: var(--space-2) var(--space-5); + grid-template-columns: minmax(8rem, max-content) minmax(0, 1fr); margin: 0; } @@ -97,12 +100,18 @@ font-weight: var(--weight-semibold); } -.prose dt:not(:first-child) { - margin-top: var(--space-7); +.prose dd { + margin: 0; } -.prose dd { - margin: var(--space-1) 0 0; +@media (max-width: 640px) { + .prose dl { + grid-template-columns: 1fr; + } + + .prose dd + dt { + margin-top: var(--space-3); + } } .flow, From 36b4033dd01cc4ea98cbe67800f55c876248bbd9 Mon Sep 17 00:00:00 2001 From: Ravi Teja KNTS Date: Sat, 10 Oct 2026 10:14:31 +0530 Subject: [PATCH 3/3] Show breadcrumbs above every page title except home - lib/breadcrumbs.ts builds the trail from the page registry: stack127, each published parent, then the page. A tool sits under its primary catalog. A page can pass a shorter name for itself, such as an app. - Breadcrumbs renders it as small muted mono steps with slashes. Sections show lowercase like the main menu; the page keeps its name. - PageHeader takes the trail, so tools, catalogs, directories, child pages, the course, and site pages all show it, inside EntryHero too. - The platform fees back link is replaced by the trail. - Gallery example in Navigation, and the design and shared UI docs. Co-Authored-By: Claude Opus 5.5 --- app/(site)/(catalogs)/[slug]/page.tsx | 3 +- .../components-ui/examples/navigation.tsx | 17 +++++ .../(components-ui)/components-ui/sections.ts | 4 +- .../(directories)/ad-networks/[slug]/page.tsx | 3 +- app/(site)/(directories)/ad-networks/page.tsx | 3 +- app/(site)/(directories)/cli-apps/page.tsx | 3 +- .../(directories)/epp-status-codes/page.tsx | 3 +- .../keyboard-shortcuts-app-page.tsx | 6 +- .../keyboard-shortcuts-directory.tsx | 3 +- .../keyboard-shortcuts-reference.tsx | 5 +- .../(directories)/launch-directories/page.tsx | 3 +- .../platform-fees/components.tsx | 8 +-- .../platform-fees/page.module.css | 7 -- .../(directories)/platform-fees/page.tsx | 2 +- .../(directories)/self-host-apps/page.tsx | 3 +- .../page.tsx | 3 +- .../(directories)/tld-directory/page.tsx | 3 +- .../(directories)/zigbee-devices/page.tsx | 3 +- app/ui/courses/course-shell.tsx | 2 +- .../directories/hardware-directory-page.tsx | 3 +- app/ui/site/breadcrumbs.module.css | 65 +++++++++++++++++++ app/ui/site/breadcrumbs.tsx | 21 ++++++ app/ui/site/info-page.tsx | 3 +- app/ui/site/page-header.tsx | 7 +- app/ui/site/tool-page-content.tsx | 6 +- lib/breadcrumbs.ts | 41 ++++++++++++ stack127_docs/design-system.md | 2 +- stack127_docs/shared-ui.md | 1 + 28 files changed, 197 insertions(+), 36 deletions(-) create mode 100644 app/ui/site/breadcrumbs.module.css create mode 100644 app/ui/site/breadcrumbs.tsx create mode 100644 lib/breadcrumbs.ts diff --git a/app/(site)/(catalogs)/[slug]/page.tsx b/app/(site)/(catalogs)/[slug]/page.tsx index 24e71586..29cea4d3 100644 --- a/app/(site)/(catalogs)/[slug]/page.tsx +++ b/app/(site)/(catalogs)/[slug]/page.tsx @@ -12,6 +12,7 @@ import { ToolCatalogSidebar } from "@/app/ui/catalogs/tool-catalog-sidebar"; import { cardArt } from "@/lib/card-art"; import { cardIcon } from "@/lib/card-icons"; import { getPublishedToolCatalog } from "@/lib/tool-catalogs"; +import { getBreadcrumbs } from "@/lib/breadcrumbs"; import { catalogContent } from "./content"; export default async function ToolCatalogPage({ params }: { params: Promise<{ slug: string }> }) { @@ -31,7 +32,7 @@ export default async function ToolCatalogPage({ params }: { params: Promise<{ sl sidebarLabel="Tool catalogs sidebar" > - +

{" "} diff --git a/app/(site)/(components-ui)/components-ui/examples/navigation.tsx b/app/(site)/(components-ui)/components-ui/examples/navigation.tsx index 19130b76..eba40776 100644 --- a/app/(site)/(components-ui)/components-ui/examples/navigation.tsx +++ b/app/(site)/(components-ui)/components-ui/examples/navigation.tsx @@ -1,5 +1,6 @@ import { Globe, HardDrive, Keyboard } from "lucide-react"; import { Pagination } from "@/app/ui/pagination"; +import { Breadcrumbs } from "@/app/ui/site/breadcrumbs"; import { SidebarList } from "@/app/ui/site/sidebar-list"; import { Example } from "../gallery"; import styles from "../components-ui.module.css"; @@ -9,6 +10,22 @@ const here = "/components-ui/navigation"; export function NavigationExamples() { return ( <> + +

…

+` }} + > +
+ + + +
+
+ page === 1 ? here : `${here}?page=${page}`} totalPages={12} /> diff --git a/app/(site)/(components-ui)/components-ui/sections.ts b/app/(site)/(components-ui)/components-ui/sections.ts index 78557ac5..867d1c18 100644 --- a/app/(site)/(components-ui)/components-ui/sections.ts +++ b/app/(site)/(components-ui)/components-ui/sections.ts @@ -126,9 +126,9 @@ export const gallerySections: GallerySection[] = [ { slug: "navigation", label: "Navigation", - description: "Page links for long lists and sidebar link lists.", + description: "Breadcrumbs above page titles, page links for long lists, and sidebar link lists.", icon: Signpost, - components: ["Pagination", "SidebarList"], + components: ["Breadcrumbs", "Pagination", "SidebarList"], }, { slug: "page-layout", diff --git a/app/(site)/(directories)/ad-networks/[slug]/page.tsx b/app/(site)/(directories)/ad-networks/[slug]/page.tsx index c2ab2c3e..843357d1 100644 --- a/app/(site)/(directories)/ad-networks/[slug]/page.tsx +++ b/app/(site)/(directories)/ad-networks/[slug]/page.tsx @@ -1,4 +1,5 @@ import { DirectoryPageTitle, DirectoryPageContent } from "@/app/ui/directories/directory-page-content"; +import { getBreadcrumbs } from "@/lib/breadcrumbs"; import { PageMeta } from "@/app/ui/site/page-meta"; import { ContentMarkdown } from "@/app/ui/content-markdown"; import { notFound } from "next/navigation"; @@ -34,7 +35,7 @@ export default async function AdNetworkPage({ params }: { params: Promise<{ slug return } sidebarLabel="Ad networks"> - +

} />{" "}

diff --git a/app/(site)/(directories)/ad-networks/page.tsx b/app/(site)/(directories)/ad-networks/page.tsx index 4e9cd933..10f664b3 100644 --- a/app/(site)/(directories)/ad-networks/page.tsx +++ b/app/(site)/(directories)/ad-networks/page.tsx @@ -1,4 +1,5 @@ import { getRequiredDirectoryPage } from "@/lib/directory-pages"; +import { getBreadcrumbs } from "@/lib/breadcrumbs"; import { DirectoryPageTitle, DirectoryPageContent } from "@/app/ui/directories/directory-page-content"; import { PageMeta } from "@/app/ui/site/page-meta"; import { ContentMarkdown } from "@/app/ui/content-markdown"; @@ -25,7 +26,7 @@ export default async function AdNetworksPage() { } sidebarLabel="Ad networks"> - +

} />{" "}

diff --git a/app/(site)/(directories)/cli-apps/page.tsx b/app/(site)/(directories)/cli-apps/page.tsx index 73e290c8..402a1eac 100644 --- a/app/(site)/(directories)/cli-apps/page.tsx +++ b/app/(site)/(directories)/cli-apps/page.tsx @@ -1,4 +1,5 @@ import { getRequiredDirectoryPage } from "@/lib/directory-pages"; +import { getBreadcrumbs } from "@/lib/breadcrumbs"; import { DirectoryPageTitle } from "@/app/ui/directories/directory-page-content"; import { PageMeta } from "@/app/ui/site/page-meta"; import { PageCanvas } from "@/app/ui/site/page-canvas"; @@ -139,7 +140,7 @@ export default async function CliAppsPage() { sidebarLabel="Terminal app filters" > - +

{" "}

diff --git a/app/(site)/(directories)/epp-status-codes/page.tsx b/app/(site)/(directories)/epp-status-codes/page.tsx index f5955f62..ece3d558 100644 --- a/app/(site)/(directories)/epp-status-codes/page.tsx +++ b/app/(site)/(directories)/epp-status-codes/page.tsx @@ -1,4 +1,5 @@ import { isValidElement, type ComponentPropsWithoutRef } from "react"; +import { getBreadcrumbs } from "@/lib/breadcrumbs"; import { getRequiredDirectoryPage } from "@/lib/directory-pages"; import { DirectoryPageTitle } from "@/app/ui/directories/directory-page-content"; import { PageMeta } from "@/app/ui/site/page-meta"; @@ -61,7 +62,7 @@ export default async function EppStatusCodesPage() { sidebarLabel="Domain tools sidebar" > - +

{" "}

diff --git a/app/(site)/(directories)/keyboard-shortcuts/keyboard-shortcuts-app-page.tsx b/app/(site)/(directories)/keyboard-shortcuts/keyboard-shortcuts-app-page.tsx index 39818ecc..a8dfd847 100644 --- a/app/(site)/(directories)/keyboard-shortcuts/keyboard-shortcuts-app-page.tsx +++ b/app/(site)/(directories)/keyboard-shortcuts/keyboard-shortcuts-app-page.tsx @@ -1,3 +1,4 @@ +import { getBreadcrumbs } from "@/lib/breadcrumbs"; import { getPublishedDirectoryPage, getRequiredDirectoryPage } from "@/lib/directory-pages"; import { AppShell } from "@/app/ui/site/app-shell"; import type { ReactNode } from "react"; @@ -6,9 +7,10 @@ import { KeyboardShortcutsSidebar } from "@/app/ui/directories/keyboard-shortcut import { KeyboardShortcutsReference } from "./keyboard-shortcuts-reference"; export async function KeyboardShortcutsAppPage({ app, intro }: { app: AppShortcuts; intro: ReactNode }) { - const [directoryPage, parentPage] = await Promise.all([ + const [directoryPage, parentPage, breadcrumbs] = await Promise.all([ getRequiredDirectoryPage(`keyboard-shortcuts/${app.slug}`), getPublishedDirectoryPage("keyboard-shortcuts"), + getBreadcrumbs(`keyboard-shortcuts/${app.slug}`, app.name), ]); const iconKey = directoryPage.card_icon_key ?? parentPage?.card_icon_key ?? null; return ( @@ -18,7 +20,7 @@ export async function KeyboardShortcutsAppPage({ app, intro }: { app: AppShortcu sidebar={} sidebarLabel="Shortcut pages" > - +
); } diff --git a/app/(site)/(directories)/keyboard-shortcuts/keyboard-shortcuts-directory.tsx b/app/(site)/(directories)/keyboard-shortcuts/keyboard-shortcuts-directory.tsx index 7f6a038d..f12a37f5 100644 --- a/app/(site)/(directories)/keyboard-shortcuts/keyboard-shortcuts-directory.tsx +++ b/app/(site)/(directories)/keyboard-shortcuts/keyboard-shortcuts-directory.tsx @@ -1,4 +1,5 @@ import { getRequiredDirectoryPage } from "@/lib/directory-pages"; +import { getBreadcrumbs } from "@/lib/breadcrumbs"; import { DirectoryPageTitle } from "@/app/ui/directories/directory-page-content"; import { PageMeta } from "@/app/ui/site/page-meta"; import { PageCanvas } from "@/app/ui/site/page-canvas"; @@ -26,7 +27,7 @@ export async function KeyboardShortcutsDirectory() { return ( <> - +

{" "}

diff --git a/app/(site)/(directories)/keyboard-shortcuts/keyboard-shortcuts-reference.tsx b/app/(site)/(directories)/keyboard-shortcuts/keyboard-shortcuts-reference.tsx index 9db828ab..f49807d0 100644 --- a/app/(site)/(directories)/keyboard-shortcuts/keyboard-shortcuts-reference.tsx +++ b/app/(site)/(directories)/keyboard-shortcuts/keyboard-shortcuts-reference.tsx @@ -1,3 +1,4 @@ +import type { Breadcrumb } from "@/lib/breadcrumbs"; import type { Stack127DirectoryPage } from "@/lib/directory-pages"; import { DirectoryPageTitle } from "@/app/ui/directories/directory-page-content"; import { PageMeta } from "@/app/ui/site/page-meta"; @@ -58,10 +59,10 @@ function ShortcutRow({ shortcut, platforms }: { shortcut: Shortcut; platforms: A * `intro` is the app page's own `content/intro.mdx`. `iconKey` is the page's own card icon key, * or the keyboard shortcuts directory's when it has none. */ -export function KeyboardShortcutsReference({ app, directoryPage, iconKey, intro }: { app: AppShortcuts; directoryPage: Stack127DirectoryPage; iconKey: string | null; intro: ReactNode }) { +export function KeyboardShortcutsReference({ app, breadcrumbs, directoryPage, iconKey, intro }: { app: AppShortcuts; breadcrumbs: Breadcrumb[]; directoryPage: Stack127DirectoryPage; iconKey: string | null; intro: ReactNode }) { return ( - +

{" "} diff --git a/app/(site)/(directories)/launch-directories/page.tsx b/app/(site)/(directories)/launch-directories/page.tsx index 1dc8e8d5..df81257d 100644 --- a/app/(site)/(directories)/launch-directories/page.tsx +++ b/app/(site)/(directories)/launch-directories/page.tsx @@ -1,4 +1,5 @@ import { getRequiredDirectoryPage } from "@/lib/directory-pages"; +import { getBreadcrumbs } from "@/lib/breadcrumbs"; import { DirectoryPageTitle } from "@/app/ui/directories/directory-page-content"; import { PageMeta } from "@/app/ui/site/page-meta"; import { DirectoryQueryProvider } from "@/app/ui/directories/directory-query"; @@ -36,7 +37,7 @@ export default async function LaunchDirectoriesPage() { return - +

{" "}

diff --git a/app/(site)/(directories)/platform-fees/components.tsx b/app/(site)/(directories)/platform-fees/components.tsx index b6780c69..1bc0418c 100644 --- a/app/(site)/(directories)/platform-fees/components.tsx +++ b/app/(site)/(directories)/platform-fees/components.tsx @@ -20,6 +20,7 @@ import { DirectoryTitleIcon } from '@/app/ui/directories/directory-title-icon'; import { SidebarList } from '@/app/ui/site/sidebar-list'; import { sidebarPanelClassName, sidebarPanelHeadingClassName } from '@/app/ui/site/sidebar-panel'; import { feeGroups, groupPath, type FeeGroup } from '@/lib/platform-fees/groups'; +import { getBreadcrumbs } from '@/lib/breadcrumbs'; import type { FeeEntry, FeeSection } from '@/lib/platform-fees/model'; import { FeeSearch, FeeSidebarAnchors, type FeeAnchor } from './filters'; import styles from './page.module.css'; @@ -40,12 +41,11 @@ export function SectionIcon({ icon, size = 22 }: { icon: FeeSection['icon']; siz } /** `intro` and `article` are the page's own MDX content. */ -export function FeeShell({ directoryPage, intro, article, children, overview = false, group, anchors = [] }: { +export async function FeeShell({ directoryPage, intro, article, children, group, anchors = [] }: { directoryPage: Stack127DirectoryPage; intro: ReactNode; article?: ReactNode; children: ReactNode; - overview?: boolean; group?: FeeGroup; anchors?: FeeAnchor[]; }) { @@ -62,11 +62,11 @@ export function FeeShell({ directoryPage, intro, article, children, overview = f {anchors.length > 0 && }

; + const breadcrumbs = await getBreadcrumbs(group ? groupPath(group.name).slice(1) : 'platform-fees'); return part.text).join('')} sidebar={sidebar} sidebarLabel="Fee categories" menuLabel="Categories"> - - {!overview && Platform fees} +

{group ? : }{' '}

{intro} diff --git a/app/(site)/(directories)/platform-fees/page.module.css b/app/(site)/(directories)/platform-fees/page.module.css index 6767ed5b..fd2b4ef0 100644 --- a/app/(site)/(directories)/platform-fees/page.module.css +++ b/app/(site)/(directories)/platform-fees/page.module.css @@ -189,13 +189,6 @@ margin-top: 4px; } -.back { - display: inline-block; - margin-bottom: var(--space-4); - color: var(--color-text-muted); - font-size: var(--text-small); -} - .jump { display: flex; flex-wrap: wrap; diff --git a/app/(site)/(directories)/platform-fees/page.tsx b/app/(site)/(directories)/platform-fees/page.tsx index dc8330cb..d3c9cbe4 100644 --- a/app/(site)/(directories)/platform-fees/page.tsx +++ b/app/(site)/(directories)/platform-fees/page.tsx @@ -26,7 +26,7 @@ export default async function PlatformFeesPage() { return <> - } article={
} overview> + } article={
}>

{feeGroups.length} categories · {directory.sections.length} sections · {directory.entries.length} listings

diff --git a/app/(site)/(directories)/self-host-apps/page.tsx b/app/(site)/(directories)/self-host-apps/page.tsx index 672d3b32..cf78856c 100644 --- a/app/(site)/(directories)/self-host-apps/page.tsx +++ b/app/(site)/(directories)/self-host-apps/page.tsx @@ -1,4 +1,5 @@ import { getRequiredDirectoryPage } from "@/lib/directory-pages"; +import { getBreadcrumbs } from "@/lib/breadcrumbs"; import { DirectoryPageTitle } from "@/app/ui/directories/directory-page-content"; import { PageMeta } from "@/app/ui/site/page-meta"; import { PageCanvas } from "@/app/ui/site/page-canvas"; @@ -65,7 +66,7 @@ export default async function SelfHostAppsPage() { sidebarLabel="Self-hosted app filters" > - +

{" "}

diff --git a/app/(site)/(directories)/software-licenses-for-self-hosting/page.tsx b/app/(site)/(directories)/software-licenses-for-self-hosting/page.tsx index 39e6338a..2a2db832 100644 --- a/app/(site)/(directories)/software-licenses-for-self-hosting/page.tsx +++ b/app/(site)/(directories)/software-licenses-for-self-hosting/page.tsx @@ -1,4 +1,5 @@ import { getRequiredDirectoryPage } from "@/lib/directory-pages"; +import { getBreadcrumbs } from "@/lib/breadcrumbs"; import { DirectoryPageTitle } from "@/app/ui/directories/directory-page-content"; import { PageMeta } from "@/app/ui/site/page-meta"; import type { MDXComponents } from "mdx/types"; @@ -203,7 +204,7 @@ export default async function SoftwareLicensesPage() { sidebarLabel="Software licenses sidebar" > - +

{" "}

{children}

}} /> diff --git a/app/(site)/(directories)/tld-directory/page.tsx b/app/(site)/(directories)/tld-directory/page.tsx index bf0b69f9..f8b40ce5 100644 --- a/app/(site)/(directories)/tld-directory/page.tsx +++ b/app/(site)/(directories)/tld-directory/page.tsx @@ -1,4 +1,5 @@ import { getRequiredDirectoryPage } from "@/lib/directory-pages"; +import { getBreadcrumbs } from "@/lib/breadcrumbs"; import { DirectoryPageTitle } from "@/app/ui/directories/directory-page-content"; import { PageMeta } from "@/app/ui/site/page-meta"; import { PageCanvas } from "@/app/ui/site/page-canvas"; @@ -39,7 +40,7 @@ export default async function TldDirectoryPage() { sidebarLabel="Domain tools sidebar" > - +

{" "}

diff --git a/app/(site)/(directories)/zigbee-devices/page.tsx b/app/(site)/(directories)/zigbee-devices/page.tsx index 429c33af..76ee14b5 100644 --- a/app/(site)/(directories)/zigbee-devices/page.tsx +++ b/app/(site)/(directories)/zigbee-devices/page.tsx @@ -1,4 +1,5 @@ import { getRequiredDirectoryPage } from "@/lib/directory-pages"; +import { getBreadcrumbs } from "@/lib/breadcrumbs"; import { DirectoryPageTitle } from "@/app/ui/directories/directory-page-content"; import { PageMeta } from "@/app/ui/site/page-meta"; import { PageCanvas } from "@/app/ui/site/page-canvas"; @@ -73,7 +74,7 @@ export default async function ZigbeeDevicesPage() { - +

{" "}

diff --git a/app/ui/courses/course-shell.tsx b/app/ui/courses/course-shell.tsx index fafc818e..8489eb30 100644 --- a/app/ui/courses/course-shell.tsx +++ b/app/ui/courses/course-shell.tsx @@ -104,7 +104,7 @@ export async function CourseLessonPage({ children, course, currentSlug, entryHer } const headingBlock = ( - + ({ name: crumb.name, href: crumb.path, section: index < 2 }))}>

diff --git a/app/ui/directories/hardware-directory-page.tsx b/app/ui/directories/hardware-directory-page.tsx index 2ca348e3..2b9d39ce 100644 --- a/app/ui/directories/hardware-directory-page.tsx +++ b/app/ui/directories/hardware-directory-page.tsx @@ -1,4 +1,5 @@ import { getRequiredDirectoryPage } from "@/lib/directory-pages"; +import { getBreadcrumbs } from "@/lib/breadcrumbs"; import { DirectoryPageTitle } from "./directory-page-content"; import { PageMeta } from "@/app/ui/site/page-meta"; import { PageCanvas } from "@/app/ui/site/page-canvas"; @@ -63,7 +64,7 @@ export async function HardwareDirectoryPage({ article, config, intro, snapshot } - +

{" "}

{intro} diff --git a/app/ui/site/breadcrumbs.module.css b/app/ui/site/breadcrumbs.module.css new file mode 100644 index 00000000..80a69976 --- /dev/null +++ b/app/ui/site/breadcrumbs.module.css @@ -0,0 +1,65 @@ +/* Muted mono steps divided by slashes. Sections show lowercase, like the main menu. The current page is semibold in the text color. */ +.breadcrumbs { + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: var(--text-small); + line-height: var(--line-ui); + min-width: 0; +} + +nav.breadcrumbs ol { + display: flex; + flex-wrap: wrap; + gap: var(--space-1) 0; + list-style: none; + margin: 0; + padding: 0; +} + +/* Inside a prose header, list items would otherwise take the reading size. */ +nav.breadcrumbs li { + align-items: baseline; + color: inherit; + display: inline-flex; + font-size: inherit; + line-height: inherit; + min-width: 0; +} + +/* The slash trails each step, so a wrapped line starts with a name, not a slash. */ +nav.breadcrumbs li:not(:last-child)::after { + color: var(--color-text-muted); + content: "/"; + content: "/" / ""; + opacity: 0.6; + padding-inline: var(--space-3); +} + +nav.breadcrumbs li[data-section] { + text-transform: lowercase; +} + +nav.breadcrumbs a { + color: var(--color-text-muted); + text-decoration: none; +} + +nav.breadcrumbs a:hover, +nav.breadcrumbs a:focus-visible { + color: var(--color-text); + text-decoration: underline; + text-decoration-color: currentColor; + text-underline-offset: 3px; +} + +nav.breadcrumbs a:focus-visible { + border-radius: var(--radius); + box-shadow: var(--focus-ring); + outline: none; +} + +nav.breadcrumbs [aria-current="page"] { + color: var(--color-text); + font-weight: var(--weight-semibold); + overflow-wrap: anywhere; +} diff --git a/app/ui/site/breadcrumbs.tsx b/app/ui/site/breadcrumbs.tsx new file mode 100644 index 00000000..ba9102bd --- /dev/null +++ b/app/ui/site/breadcrumbs.tsx @@ -0,0 +1,21 @@ +import Link from "next/link"; +import type { Breadcrumb } from "@/lib/breadcrumbs"; +import styles from "./breadcrumbs.module.css"; + +/** The trail above a page title. The last step is the page itself and isn't a link. */ +export function Breadcrumbs({ items }: { items: Breadcrumb[] }) { + if (items.length < 2) return null; + return ( + + ); +} diff --git a/app/ui/site/info-page.tsx b/app/ui/site/info-page.tsx index dbeb3d36..d405b9d4 100644 --- a/app/ui/site/info-page.tsx +++ b/app/ui/site/info-page.tsx @@ -1,6 +1,7 @@ import { PageCanvas } from "@/app/ui/site/page-canvas"; import Link from "next/link"; import type { ReactNode } from "react"; +import { getBreadcrumbs } from "@/lib/breadcrumbs"; import { getRequiredPublishedPage } from "@/lib/page-registry"; import { AppShell, SidebarLabel } from "./app-shell"; import { proseClassName, proseSectionFlowClassName } from "./prose"; @@ -66,7 +67,7 @@ export async function InfoPage({ children, slug, title, heading, intro, heroArt, sidebarLabel={`${title} sidebar`} > - +

{heading ?? title}

{intro}
diff --git a/app/ui/site/page-header.tsx b/app/ui/site/page-header.tsx index d3fb6ef3..db74a3de 100644 --- a/app/ui/site/page-header.tsx +++ b/app/ui/site/page-header.tsx @@ -1,18 +1,23 @@ import type { HTMLAttributes, ReactNode } from "react"; +import type { Breadcrumb } from "@/lib/breadcrumbs"; +import { Breadcrumbs } from "./breadcrumbs"; import styles from "./page-header.module.css"; type PageHeaderProps = HTMLAttributes & { bordered?: boolean; + /** The trail above the title, from `getBreadcrumbs`. Every page but home has one. */ + breadcrumbs?: Breadcrumb[]; children: ReactNode; }; -export function PageHeader({ bordered = false, children, className, ...props }: PageHeaderProps) { +export function PageHeader({ bordered = false, breadcrumbs, children, className, ...props }: PageHeaderProps) { const classes = [styles.header, bordered ? styles.bordered : "", className ?? ""] .filter(Boolean) .join(" "); return (
+ {breadcrumbs ? : null} {children}
); diff --git a/app/ui/site/tool-page-content.tsx b/app/ui/site/tool-page-content.tsx index dccefad2..143f3d5e 100644 --- a/app/ui/site/tool-page-content.tsx +++ b/app/ui/site/tool-page-content.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { getToolBreadcrumbs } from "@/lib/breadcrumbs"; import type { Stack127ToolPage } from "@/lib/tool-pages"; import { PageHeader } from "./page-header"; import { PageMeta } from "./page-meta"; @@ -8,7 +9,7 @@ import { proseClassName, proseFlowClassName } from "./prose"; import styles from "./tool-page-content.module.css"; /** `intro` is the page's content/intro.mdx. Pages not yet moved to MDX render intro_markdown. */ -export function ToolPageHeader({ +export async function ToolPageHeader({ icon, intro, toolPage, @@ -17,8 +18,9 @@ export function ToolPageHeader({ intro?: ReactNode; toolPage: Stack127ToolPage; }) { + const breadcrumbs = await getToolBreadcrumbs(toolPage.page_id, toolPage.slug, toolPage.card_name); return ( - +

{icon}{" "} {toolPage.title} diff --git a/lib/breadcrumbs.ts b/lib/breadcrumbs.ts new file mode 100644 index 00000000..c7d3a056 --- /dev/null +++ b/lib/breadcrumbs.ts @@ -0,0 +1,41 @@ +import { cache } from "react"; +import { getExactPublishedPage, pathFromSlug } from "./page-registry"; +import { getPrimaryToolCatalog } from "./tool-catalogs"; + +/** One step in a page's trail. Sections are the site's top-level areas and show lowercase, like the main menu. */ +export type Breadcrumb = { name: string; href: string; section: boolean }; + +const home: Breadcrumb = { name: "stack127", href: "/", section: true }; + +/** + * stack127, each published parent path, then the page. The registry label names each step, + * unless the page passes a shorter name for itself, such as an app name. Home has no trail. + */ +export const getBreadcrumbs = cache(async (slug: string, current?: string): Promise => { + if (!slug) return []; + const parts = slug.split("/"); + const trail: Breadcrumb[] = [home]; + for (let depth = 1; depth < parts.length; depth += 1) { + const parentSlug = parts.slice(0, depth).join("/"); + const parent = await getExactPublishedPage(parentSlug); + if (parent) trail.push({ name: parent.label, href: pathFromSlug(parentSlug), section: depth === 1 && parent.page_family !== "site" }); + } + const page = await getExactPublishedPage(slug); + const topLevel = parts.length === 1; + trail.push({ + name: current ?? page?.label ?? parts.at(-1)!, + href: pathFromSlug(slug), + section: !current && topLevel && page?.page_family !== "tool" && page?.page_family !== "site", + }); + return trail; +}); + +/** A tool sits under its primary catalog: stack127 / domain tools / Domain age checker. */ +export const getToolBreadcrumbs = cache(async (toolPageId: number | string, slug: string, name: string): Promise => { + const catalog = await getPrimaryToolCatalog(toolPageId); + return [ + home, + ...(catalog ? [{ name: catalog.page.label, href: pathFromSlug(catalog.page.slug), section: true }] : []), + { name, href: pathFromSlug(slug), section: false }, + ]; +}); diff --git a/stack127_docs/design-system.md b/stack127_docs/design-system.md index 7eabb706..c4d5bde9 100644 --- a/stack127_docs/design-system.md +++ b/stack127_docs/design-system.md @@ -264,7 +264,7 @@ FAQs stay open. Each question and answer sits between neutral `--color-border` r | --- | --- | | Pagination | Mono numerals in bordered squares. The current page is the one accent fill. | | Tabs | The active tab gets a 2px accent bottom underline. | -| Breadcrumbs | Muted text, with the current page in `--weight-semibold`. | +| Breadcrumbs | Above every page title except home, through `PageHeader`'s `breadcrumbs`. Small muted mono steps split by slashes. Sections show lowercase, like the main menu; the page keeps its own name in `--color-text` at `--weight-semibold`. A tool sits under its primary catalog. | | Drawers | Rows and dividers. They sit above the top bar and go full-screen on mobile. | | Filters | Share input height, radius, font size, and focus styles. Selected filter tags use an `--color-accent-text` border and text, never a fill. | diff --git a/stack127_docs/shared-ui.md b/stack127_docs/shared-ui.md index f8d6fc79..87dbfbef 100644 --- a/stack127_docs/shared-ui.md +++ b/stack127_docs/shared-ui.md @@ -17,6 +17,7 @@ The Components UI gallery is at `/components-ui` in development. That route retu | Notes, warnings, and recommendations | `Callout`, `Note`, `Warning`, `Tip` in `app/ui/callout.tsx` | | Formulas and worked examples | `ContentBlock` in `app/ui/content-block.tsx` | | Empty, loading, and error panels | `StatePanel` in `app/ui/state-panel.tsx` | +| Breadcrumbs above a page title | `getBreadcrumbs` or `getToolBreadcrumbs` in `lib/breadcrumbs.ts`, passed to `PageHeader`'s `breadcrumbs` | | URL-based page navigation | `Pagination` in `app/ui/pagination.tsx` | | Detail drawer with focus and close behavior | `DetailDialog` and `DetailDialogClose` in `app/ui/detail-dialog.tsx` | | FAQ with repeated questions | `Faq` in `app/ui/faq.tsx` |