Skip to content

Commit fd5b3c4

Browse files
alicodingclaude
andauthored
feat: docs surface gets a grouped sidebar, breadcrumb, prev/next (goal 0235 S1) (#473)
* feat: docs surface gets a grouped sidebar, breadcrumb, prev/next (goal 0235 S1) The in-app Docs surface was still goal 0125 phase 1: a flat NavList over markdown styled by the Atlas mirror-preview's bounded-box CSS. This is the structural half of a converged docs-site pass (search + TOC are S2): - Sidebar groups pages into collapsible sections by userdocs/ directory (DocsIndexEntry carries no group field, so grouping is derived client-side from each entry's rel prefix, in DocsIndex's own canonical order -- frontend/src/views/docsGroups.ts). Built on Primer NavList's own Item+SubNav accordion shape (confirmed against the installed .d.ts), not NavList.Group, which renders always-expanded. - A breadcrumb header (section, page title) replaces the bare content column; the article's own leading H1 (every userdocs/ page opens with one matching its title) is hidden to avoid repeating it. - A prev/next footer derived from DocsIndex's flat order, hidden at either end. - DocsView gets its own co-located stylesheet (~72ch measure, heading rhythm) composing the shared MarkdownProse base instead of the Atlas mirror preview's bounded-preview-box CSS. Extends the existing docs-view.spec.ts (grouped/collapsed sections, breadcrumb text, prev/next navigation) and adds docsGroups.test.ts for the pure grouping/adjacency logic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq * test: register decision-outcome.spec.ts:132's second live-run sighting Same SUCCESS-status-polling class as the already-registered :192 entry -- the pinned-arm test hit a CI-load timeout once (PR #472's run), passed on retry #1, and ran 5/5 green locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent fbbb2ed commit fd5b3c4

9 files changed

Lines changed: 456 additions & 49 deletions

File tree

frontend/e2e/QUARANTINE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ trace (now auto-captured on retry).
1919
| breakpoints.spec.ts:80 | live-run | 2026-08-16 | 2026-09-16 | paused-at-breakpoint polling |
2020
| seed-completeness.spec.ts:62,84 | live-run | 2026-08-16 | 2026-09-16 | SUCCESS-status polling |
2121
| decision-outcome.spec.ts:192 | live-run | 2026-08-16 | 2026-09-16 | SUCCESS-status polling |
22+
| decision-outcome.spec.ts:132 | live-run | 2026-08-26 | 2026-09-26 | second live-run sighting in this file (:192 already registered): pinned-arm test hit CI-load timeout once on an unrelated PR (#472's run), passed on retry #1, 5/5 green locally -- same SUCCESS-status-polling class |
2223
| mcp-write-staleness.spec.ts:21 | live-run | 2026-08-16 | 2026-09-16 | real MCP round-trip |
2324
| guardrail.spec.ts:238 | live-run | 2026-08-16 | 2026-09-16 | approve/deny resolve polling |
2425
| state-persistence.spec.ts:74 | unclear | 2026-08-16 | 2026-09-16 | reload/IPC timing; await first trace |

frontend/e2e/docs-view.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1+
import type { Page } from '@playwright/test'
12
import { expect, test } from './fixtures/server'
23

34
// Shared worker pool: reads only the embedded docs tree, no app state
45
// touched. The in-app Docs surface (goal 0125 phase 1): reachable on
56
// demand (footer link, palette command), deliberately NOT a sidebar
67
// tab -- a help surface never competes with the work surfaces.
8+
9+
// Section headers (NavList.Item + NavList.SubNav, goal 0235 S1) drop
10+
// unknown props once Primer detects the SubNav child and switches to
11+
// the accordion-header render path, so they carry no data-testid --
12+
// located by their exact visible title within the nav landmark instead.
13+
function expandDocsSection(page: Page, title: string) {
14+
return page.getByTestId('docs-nav').getByText(title, { exact: true }).click()
15+
}
16+
717
test('the Docs surface renders the index and pages, reached via the footer', async ({ page, baseURL }) => {
818
await page.goto(`${baseURL}/`)
919
// Not a standing sidebar destination.
@@ -14,10 +24,62 @@ test('the Docs surface renders the index and pages, reached via the footer', asy
1424
await expect(page.getByTestId('docs-nav-item').first()).toContainText('What is Mill')
1525
await expect(page.getByTestId('docs-content')).toContainText('guardrailed automations')
1626

27+
// "Step reference" lives in the Reference section, collapsed by
28+
// default while "Start here" holds the current page -- expand it
29+
// before its nav item is reachable.
30+
await expandDocsSection(page, 'Reference')
1731
await page.getByTestId('docs-nav-item').filter({ hasText: 'Step reference' }).click()
1832
await expect(page.getByTestId('docs-content')).toContainText('Convert HTML to Markdown')
1933
})
2034

35+
test('the sidebar groups pages by section, current section expanded, others collapsed', async ({ page, baseURL }) => {
36+
await page.goto(`${baseURL}/`)
37+
await page.getByTestId('footer-docs-link').click()
38+
await expect(page.getByTestId('docs-view')).toBeVisible()
39+
40+
// Default page is in Start here -- its items are reachable, a
41+
// collapsed section's items are not.
42+
await expect(page.getByTestId('docs-nav').getByText('Start here', { exact: true })).toBeVisible()
43+
await expect(page.getByTestId('docs-nav-item').filter({ hasText: 'What is Mill' })).toBeVisible()
44+
await expect(page.getByTestId('docs-nav-item').filter({ hasText: 'Step reference' })).toBeHidden()
45+
46+
await expandDocsSection(page, 'Reference')
47+
await expect(page.getByTestId('docs-nav-item').filter({ hasText: 'Step reference' })).toBeVisible()
48+
})
49+
50+
test('the breadcrumb header names the section and the page title', async ({ page, baseURL }) => {
51+
await page.goto(`${baseURL}/`)
52+
await page.getByTestId('footer-docs-link').click()
53+
await expect(page.getByTestId('docs-breadcrumb')).toContainText('Start here')
54+
await expect(page.getByTestId('docs-breadcrumb')).toContainText('What is Mill')
55+
56+
await expandDocsSection(page, 'Reference')
57+
await page.getByTestId('docs-nav-item').filter({ hasText: 'Step reference' }).click()
58+
await expect(page.getByTestId('docs-breadcrumb')).toContainText('Reference')
59+
await expect(page.getByTestId('docs-breadcrumb')).toContainText('Step reference')
60+
})
61+
62+
test('the prev/next footer navigates the flat reading order across section boundaries', async ({ page, baseURL }) => {
63+
await page.goto(`${baseURL}/`)
64+
await page.getByTestId('footer-docs-link').click()
65+
66+
// First page: no prev link.
67+
await expect(page.getByTestId('docs-prev-link')).toHaveCount(0)
68+
await expect(page.getByTestId('docs-next-link')).toContainText('Install')
69+
70+
await page.getByTestId('docs-next-link').click()
71+
await expect(page.getByTestId('docs-breadcrumb')).toContainText('Install')
72+
73+
await page.getByTestId('docs-next-link').click()
74+
await expect(page.getByTestId('docs-breadcrumb')).toContainText('Your first workflow')
75+
await expect(page.getByTestId('docs-prev-link')).toContainText('Install')
76+
77+
// "Your first workflow" is the last Start here page -- next crosses into Concepts.
78+
await page.getByTestId('docs-next-link').click()
79+
await expect(page.getByTestId('docs-breadcrumb')).toContainText('Workflows and steps')
80+
await expect(page.getByTestId('docs-prev-link')).toContainText('Your first workflow')
81+
})
82+
2183
// Regression: an anchor inside a rendered page used to navigate the
2284
// app's own webview (an https link left Mill entirely). Cross-links
2385
// between pages must stay in-app instead.

frontend/src/locales/en/views.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,15 @@
407407
"cancel": "Cancel"
408408
},
409409
"docs": {
410-
"navAriaLabel": "Documentation pages"
410+
"navAriaLabel": "Documentation pages",
411+
"sections": {
412+
"startHere": "Start here",
413+
"concepts": "Concepts",
414+
"reference": "Reference",
415+
"agents": "Agents",
416+
"trust": "Trust"
417+
},
418+
"previous": "Previous",
419+
"next": "Next"
411420
}
412421
}

frontend/src/views/DocsNav.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { useTranslation } from 'react-i18next'
2+
import { NavList } from '@primer/react'
3+
import type { DocsGroup } from './docsGroups'
4+
import styles from './DocsView.module.css'
5+
6+
interface DocsNavProps {
7+
groups: DocsGroup[]
8+
currentPage: string
9+
onSelect: (rel: string) => void
10+
}
11+
12+
// The grouped sidebar (goal 0235 S1): each userdocs/ directory becomes
13+
// a collapsible NavList.Item + NavList.SubNav pair -- Primer's own
14+
// accordion shape (NavList.Item's `defaultOpen`, confirmed against the
15+
// installed .d.ts) rather than NavList.Group, which the installed
16+
// version renders always-expanded with no collapse affordance.
17+
export default function DocsNav({ groups, currentPage, onSelect }: DocsNavProps) {
18+
const { t } = useTranslation('views')
19+
return (
20+
<nav className={styles.nav} aria-label={t('docs.navAriaLabel')} data-testid="docs-nav">
21+
<NavList>
22+
{groups.map((group) => {
23+
const isCurrentSection = group.entries.some((e) => e.rel === currentPage)
24+
return (
25+
// NavList.Item drops unknown props (data-testid included)
26+
// once it detects a NavList.SubNav child and switches to
27+
// the accordion-header render path -- e2e locates this
28+
// element by its visible section title text instead.
29+
<NavList.Item
30+
key={group.dir}
31+
defaultOpen={isCurrentSection}
32+
>
33+
{group.titleKey ? t(group.titleKey) : group.dir}
34+
<NavList.SubNav>
35+
{group.entries.map((entry) => (
36+
<NavList.Item
37+
key={entry.rel}
38+
href="#"
39+
aria-current={entry.rel === currentPage ? 'page' : undefined}
40+
onClick={(ev) => {
41+
ev.preventDefault()
42+
onSelect(entry.rel)
43+
}}
44+
data-testid="docs-nav-item"
45+
>
46+
{entry.title}
47+
</NavList.Item>
48+
))}
49+
</NavList.SubNav>
50+
</NavList.Item>
51+
)
52+
})}
53+
</NavList>
54+
</nav>
55+
)
56+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { useTranslation } from 'react-i18next'
2+
import type { DocsIndexEntry } from './docsGroups'
3+
import styles from './DocsView.module.css'
4+
5+
interface DocsPrevNextProps {
6+
prev?: DocsIndexEntry
7+
next?: DocsIndexEntry
8+
onNavigate: (rel: string) => void
9+
}
10+
11+
// The prev/next footer (goal 0235 S1): derived from DocsIndex's flat
12+
// reading order, hidden at whichever end has no neighbor -- there is
13+
// no "previous" before the first page or "next" after the last.
14+
export default function DocsPrevNext({ prev, next, onNavigate }: DocsPrevNextProps) {
15+
const { t } = useTranslation('views')
16+
if (!prev && !next) return null
17+
return (
18+
<div className={styles.prevNext}>
19+
{prev ? (
20+
<a
21+
href="#"
22+
className={`${styles.prevNextCard} ${styles.prevNextCardPrev}`}
23+
onClick={(ev) => { ev.preventDefault(); onNavigate(prev.rel) }}
24+
data-testid="docs-prev-link"
25+
>
26+
<span className={styles.prevNextLabel}>{t('docs.previous')}</span>
27+
<span className={styles.prevNextTitle}>{prev.title}</span>
28+
</a>
29+
) : <span />}
30+
{next ? (
31+
<a
32+
href="#"
33+
className={`${styles.prevNextCard} ${styles.prevNextCardNext}`}
34+
onClick={(ev) => { ev.preventDefault(); onNavigate(next.rel) }}
35+
data-testid="docs-next-link"
36+
>
37+
<span className={styles.prevNextLabel}>{t('docs.next')}</span>
38+
<span className={styles.prevNextTitle}>{next.title}</span>
39+
</a>
40+
) : <span />}
41+
</div>
42+
)
43+
}

frontend/src/views/DocsView.module.css

Lines changed: 106 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,120 @@
1919
padding: 24px 32px;
2020
}
2121

22-
/* Centered reading measure: the readable-line-length cap without a
23-
boxed-into-the-corner look on wide windows. The shared markdownBody
24-
class is a bounded PREVIEW box (480px cap, border, inner scroll) --
25-
here it's a full reading page, so those constraints are lifted and
26-
the page's own .content scroll is the one scrollbar. */
27-
.content article {
28-
max-width: 80ch;
22+
/* Breadcrumb/page header (goal 0235 S1): section name over the page
23+
title so location is evident before reading a word of the body --
24+
the section uses the muted token to stay visually secondary to the
25+
title beneath it. */
26+
.pageHeader {
27+
max-width: 72ch;
28+
margin-inline: auto;
29+
margin-bottom: var(--base-size-24, 24px);
30+
}
31+
32+
.pageHeaderSection {
33+
display: block;
34+
color: var(--fgColor-muted);
35+
font-size: var(--text-body-size-small, 12px);
36+
font-weight: var(--base-text-weight-semibold, 600);
37+
text-transform: uppercase;
38+
letter-spacing: 0.04em;
39+
margin-bottom: var(--base-size-4, 4px);
40+
}
41+
42+
.pageHeaderTitle {
43+
margin: 0;
44+
font-size: var(--text-title-size-large, 24px);
45+
font-weight: var(--base-text-weight-semibold, 600);
46+
}
47+
48+
/* Reading typography (goal 0235 S1): a co-located class consuming the
49+
shared prose base (MarkdownProse.module.css -- the same element
50+
styling every markdown renderer composes, links/code/tables/mermaid)
51+
plus this surface's own reading-page rhythm. The mirror preview's
52+
bounded-box chrome (480px cap, border, heading underlines) belongs
53+
to that read-only preview alone and is deliberately NOT reused here
54+
-- this is a full page, not a preview box. */
55+
.article {
56+
composes: prose from '../atlas/MarkdownProse.module.css';
57+
max-width: 72ch;
2958
margin-inline: auto;
30-
max-height: none;
31-
overflow-y: visible;
32-
border: none;
33-
padding: 0;
3459
font-size: 15px;
3560
}
3661

62+
/* Every userdocs/ page opens with a single "# Title" heading matching
63+
its DocsIndex title exactly, which the breadcrumb header above
64+
already renders -- hidden here (scoped to .article only) rather than
65+
stripped at render time, so the underlying HTML stays identical for
66+
any other consumer of DocPageHTML. */
67+
.article :global(h1):first-child {
68+
display: none;
69+
}
70+
71+
.article :global(h2) {
72+
margin-top: var(--base-size-32, 32px);
73+
margin-bottom: var(--base-size-8, 8px);
74+
}
75+
76+
.article :global(h3) {
77+
margin-top: var(--base-size-24, 24px);
78+
margin-bottom: var(--base-size-8, 8px);
79+
}
80+
81+
.article :global(p),
82+
.article :global(ul),
83+
.article :global(ol) {
84+
margin-top: 0;
85+
margin-bottom: var(--base-size-16, 16px);
86+
}
87+
3788
.error {
3889
color: var(--fgColor-danger);
3990
}
4091

92+
/* Prev/next footer (goal 0235 S1): derived from DocsIndex's flat
93+
reading order, two symmetric cards -- the next card's text
94+
right-aligns so its label and title read toward the direction the
95+
reader is about to move. */
96+
.prevNext {
97+
max-width: 72ch;
98+
margin-inline: auto;
99+
margin-top: var(--base-size-32, 32px);
100+
padding-top: var(--base-size-16, 16px);
101+
border-top: 1px solid var(--borderColor-default);
102+
display: flex;
103+
gap: var(--base-size-16, 16px);
104+
}
105+
106+
.prevNextCard {
107+
flex: 1;
108+
display: flex;
109+
flex-direction: column;
110+
gap: var(--base-size-4, 4px);
111+
padding: var(--base-size-12, 12px);
112+
border: 1px solid var(--borderColor-default);
113+
border-radius: var(--borderRadius-medium);
114+
text-decoration: none;
115+
color: inherit;
116+
}
117+
118+
.prevNextCard:hover {
119+
background-color: var(--control-transparent-bgColor-hover, #818b981a);
120+
}
121+
122+
.prevNextCardNext {
123+
text-align: right;
124+
align-items: flex-end;
125+
}
126+
127+
.prevNextLabel {
128+
color: var(--fgColor-muted);
129+
font-size: var(--text-body-size-small, 12px);
130+
}
131+
132+
.prevNextTitle {
133+
font-weight: var(--base-text-weight-semibold, 600);
134+
}
135+
41136
@media (max-width: 768px) {
42137
.docs {
43138
flex-direction: column;

0 commit comments

Comments
 (0)