From 4084de3ac8ea6c895fd04b2e61691c3fed2d0b7f Mon Sep 17 00:00:00 2001 From: RaviTejaKNTS Date: Thu, 15 Oct 2026 11:36:14 +0530 Subject: [PATCH 1/2] Add optional section bar to the app shell - AppShell takes an optional sectionNav: a short row under the main navigation for a section's 3 to 6 main pages. The section name links home and the current page gets an accent underline. It scrolls away with the page, scrolls sideways on phones with the current link in view, and is hidden in print. Works with every sidebar position. - Shell heights account for the bar only when it is present, so short pages don't scroll. Pages without it are unchanged. - Dev-only previews at /components-ui/section-bar/[position]/[view]. - Design, shared UI, page family, CSS docs and the page-build skill describe when to use it. Co-Authored-By: Claude Opus 5.5 --- .../components-ui/examples/page-layout.tsx | 8 ++ .../(components-ui)/components-ui/sections.ts | 4 +- .../section-bar/[position]/[view]/layout.tsx | 7 ++ .../section-bar/[position]/[view]/page.tsx | 79 +++++++++++++++++++ app/ui/site/app-shell.module.css | 15 +++- app/ui/site/app-shell.tsx | 10 ++- app/ui/site/section-bar-scroll.tsx | 28 +++++++ app/ui/site/section-bar.module.css | 75 ++++++++++++++++++ app/ui/site/section-bar.tsx | 37 +++++++++ lib/registered-site-pages.json | 1 + skills/stack127/page-build/SKILL.md | 2 +- stack127_docs/css-architecture.md | 2 +- stack127_docs/design-system.md | 10 +++ stack127_docs/page-families.md | 1 + stack127_docs/shared-ui.md | 1 + 15 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 app/(site)/(components-ui-modes)/components-ui/section-bar/[position]/[view]/layout.tsx create mode 100644 app/(site)/(components-ui-modes)/components-ui/section-bar/[position]/[view]/page.tsx create mode 100644 app/ui/site/section-bar-scroll.tsx create mode 100644 app/ui/site/section-bar.module.css create mode 100644 app/ui/site/section-bar.tsx diff --git a/app/(site)/(components-ui)/components-ui/examples/page-layout.tsx b/app/(site)/(components-ui)/components-ui/examples/page-layout.tsx index c584627f..c6499e71 100644 --- a/app/(site)/(components-ui)/components-ui/examples/page-layout.tsx +++ b/app/(site)/(components-ui)/components-ui/examples/page-layout.tsx @@ -96,6 +96,14 @@ export function PageLayoutExamples() { + \n …\n' }}> +
    + {(["left", "right", "none"] as const).map((position) => ( +
  • {position} sidebarSection bar with sidebarPosition="{position}".
  • + ))} +
+
+
    {fullLayouts.map((layout) => ( diff --git a/app/(site)/(components-ui)/components-ui/sections.ts b/app/(site)/(components-ui)/components-ui/sections.ts index 81a09d56..297c27f5 100644 --- a/app/(site)/(components-ui)/components-ui/sections.ts +++ b/app/(site)/(components-ui)/components-ui/sections.ts @@ -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"], }, ]; diff --git a/app/(site)/(components-ui-modes)/components-ui/section-bar/[position]/[view]/layout.tsx b/app/(site)/(components-ui-modes)/components-ui/section-bar/[position]/[view]/layout.tsx new file mode 100644 index 00000000..a14f2178 --- /dev/null +++ b/app/(site)/(components-ui-modes)/components-ui/section-bar/[position]/[view]/layout.tsx @@ -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; diff --git a/app/(site)/(components-ui-modes)/components-ui/section-bar/[position]/[view]/page.tsx b/app/(site)/(components-ui-modes)/components-ui/section-bar/[position]/[view]/page.tsx new file mode 100644 index 00000000..a46bce62 --- /dev/null +++ b/app/(site)/(components-ui-modes)/components-ui/section-bar/[position]/[view]/page.tsx @@ -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 ( + ({ label: item.label, href: previewPath(position, item.slug) })), + }} + sidebar={( +
    +
    Preview options
    + +
    + )} + sidebarLabel="Preview options" + sidebarPosition={position} + > + + +

    Section bar: {currentView.label}

    +

    Try the four section links with a {position} sidebar. These are development previews with no ad network data.

    +
    +
    + +
    +

    One section, four pages

    +

    The section name links to Directory. The active page has an accent underline. On a phone, swipe the links sideways.

    +

    Try each sidebar position

    +
      + {positions.map((item) =>
    • {item} sidebar
    • )} +
    +

    Scroll down

    +

    The section bar leaves the screen. The main navigation stays at the top. A desktop sidebar sits just below it.

    +

    Try the phone drawer

    +

    With a left sidebar, Preview options opens the drawer. The right sidebar follows the content. With none, the content fills the page.

    +

    Check both themes

    +

    Use the theme button in the main navigation. The current link keeps its accent underline and readable text in either theme.

    +

    Keep each navigation useful

    +

    The section bar holds the few main pages. Filters and longer lists belong in a sidebar. Give each a different job.

    +

    Back to page layout

    +
    +
    +
    + ); +} diff --git a/app/ui/site/app-shell.module.css b/app/ui/site/app-shell.module.css index 07fe3753..923665c8 100644 --- a/app/ui/site/app-shell.module.css +++ b/app/ui/site/app-shell.module.css @@ -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); @@ -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); @@ -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; } diff --git a/app/ui/site/app-shell.tsx b/app/ui/site/app-shell.tsx index 162286a0..b71b83a9 100644 --- a/app/ui/site/app-shell.tsx +++ b/app/ui/site/app-shell.tsx @@ -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: @@ -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 }) { @@ -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 ( -
    +
    + {sectionNav ? : null}
    {children}
    @@ -85,7 +90,8 @@ export function AppShell({ } return ( -
    +
    + {sectionNav ? : null}
    (null); + + useLayoutEffect(() => { + let cancelled = false; + const revealCurrent = () => { + const row = ref.current; + const active = row?.querySelector('[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
    {children}
    ; +} diff --git a/app/ui/site/section-bar.module.css b/app/ui/site/section-bar.module.css new file mode 100644 index 00000000..8ba762e2 --- /dev/null +++ b/app/ui/site/section-bar.module.css @@ -0,0 +1,75 @@ +/* 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; + } +} + +@media print { + .bar { + display: none; + } +} diff --git a/app/ui/site/section-bar.tsx b/app/ui/site/section-bar.tsx new file mode 100644 index 00000000..d7a434ea --- /dev/null +++ b/app/ui/site/section-bar.tsx @@ -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 ( + + ); +} diff --git a/lib/registered-site-pages.json b/lib/registered-site-pages.json index 6f6c2895..38b87831 100644 --- a/lib/registered-site-pages.json +++ b/lib/registered-site-pages.json @@ -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", diff --git a/skills/stack127/page-build/SKILL.md b/skills/stack127/page-build/SKILL.md index 225bca66..6b95562b 100644 --- a/skills/stack127/page-build/SKILL.md +++ b/skills/stack127/page-build/SKILL.md @@ -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. diff --git a/stack127_docs/css-architecture.md b/stack127_docs/css-architecture.md index 52ec198b..91c589e8 100644 --- a/stack127_docs/css-architecture.md +++ b/stack127_docs/css-architecture.md @@ -45,7 +45,7 @@ Flat tool pages live under `app/(site)/(tools)//`. 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. diff --git a/stack127_docs/design-system.md b/stack127_docs/design-system.md index 0c3d1848..24d35474 100644 --- a/stack127_docs/design-system.md +++ b/stack127_docs/design-system.md @@ -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 stays visible 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. diff --git a/stack127_docs/page-families.md b/stack127_docs/page-families.md index b8a43194..5615f49e 100644 --- a/stack127_docs/page-families.md +++ b/stack127_docs/page-families.md @@ -41,6 +41,7 @@ The group names organize the repository. They never appear in public URLs. - Every page uses the shared site navigation and `AppShell`. Each page picks its sidebar position: `left` for navigation and options, `right` for related pages and section links, or `none`. Prefer `left`. A sidebar never holds reading content. See Sidebars in [design-system.md](design-system.md). - All page families use `PageCanvas` and the same responsive gutters. +- Any page can opt in to a short section bar through `AppShell`'s `sectionNav`, with any sidebar position. See Section bar in [design-system.md](design-system.md). - Text-led pages use its 760px reading width. Directories, tool workbenches, tables, and card grids stay wide. With a sidebar, both widths start at the same left edge. With no sidebar, reading sections center. - `EntryHero` uses the canvas inside it, so entry headers and body sections line up. Don't nest canvases. - Visible titles and introductions use `PageHeader`. diff --git a/stack127_docs/shared-ui.md b/stack127_docs/shared-ui.md index 92a8619b..3a2c6bc2 100644 --- a/stack127_docs/shared-ui.md +++ b/stack127_docs/shared-ui.md @@ -19,6 +19,7 @@ The Components UI gallery is at `/components-ui` in development. That route retu | 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` | +| A section's 3 to 6 main pages | `SectionBar` in `app/ui/site/section-bar.tsx`, through `AppShell`'s optional `sectionNav`. Pass `label`, home `href`, active `current` href, and `items` with `label` and `href`. | | 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` | | Scrollable table region | `TableFrame` in `app/ui/table-frame.tsx` | From 22af10747ebaea88003f4161d14d519700875bec Mon Sep 17 00:00:00 2001 From: RaviTejaKNTS Date: Thu, 15 Oct 2026 11:42:01 +0530 Subject: [PATCH 2/2] Hide the section name on phones so the links start right away Co-Authored-By: Claude Opus 5.5 --- app/ui/site/section-bar.module.css | 7 +++++++ stack127_docs/design-system.md | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/ui/site/section-bar.module.css b/app/ui/site/section-bar.module.css index 8ba762e2..d11fc2a4 100644 --- a/app/ui/site/section-bar.module.css +++ b/app/ui/site/section-bar.module.css @@ -68,6 +68,13 @@ } } +/* 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; diff --git a/stack127_docs/design-system.md b/stack127_docs/design-system.md index 24d35474..0fc2b90b 100644 --- a/stack127_docs/design-system.md +++ b/stack127_docs/design-system.md @@ -243,7 +243,7 @@ Tool catalog sidebars link between published tool catalogs and mark the current 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 stays visible 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. +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.