Skip to content

Commit 4c3044e

Browse files
os-zhuangclaude
andcommitted
feat(console): docs portal UX pass (landing, mobile nav, index, cards)
Browser-found polish across the portal: - Book landing no longer duplicates the table of contents (sidebar + a second card list). `/docs/:slug` now opens the book to its overview doc (`*_index`, else the first in order) — a redirect into the reader, whose sidebar already is the TOC. A doc-less book shows an empty state. - Narrow screens (< lg) had no book nav at all (the sidebar is lg-only). The reader now collapses the book nav into a `<details>` disclosure above the content, so mobile/tablet readers can still move within the book. - DocsIndex gains a page heading + intro and even-height cards. - Implicit per-package book cards (no authored description) show their package id as a subtitle for context; AppDocsIndex matches. book-nav gains `firstDoc` + a `Book.implicit` flag / `BookCard.subtitle`. Tests: book-nav 19 (+2) + integration updated for the redirect and the twice-rendered responsive sidebar; 49 in pages. tsc 0, eslint 0. Verified in-browser against the showcase backend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 946cbbc commit 4c3044e

7 files changed

Lines changed: 136 additions & 120 deletions

File tree

apps/console/src/pages/AppDocsIndex.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ export default function AppDocsIndex() {
7373
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
7474
{card.description}
7575
</div>
76+
) : card.subtitle ? (
77+
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground/70">
78+
{card.subtitle}
79+
</div>
7680
) : null}
7781
<div className="mt-1 text-xs text-muted-foreground/80">
7882
{card.docCount} {card.docCount === 1 ? 'article' : 'articles'}

apps/console/src/pages/BookPage.tsx

Lines changed: 32 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -7,34 +7,29 @@
77
*/
88

99
import { useMemo } from 'react';
10-
import { Link, useParams } from 'react-router-dom';
11-
import { FileText, FileQuestion, Loader2 } from 'lucide-react';
10+
import { Navigate, useParams } from 'react-router-dom';
11+
import { BookOpen, FileQuestion, Loader2 } from 'lucide-react';
1212
import { DocShell } from './DocShell';
13-
import { BookSidebar } from './BookSidebar';
1413
import { useBookData } from './use-book-data';
15-
import { resolveBookTree, scopeDocsToBook, bookSlug } from './book-nav';
14+
import { resolveBookTree, scopeDocsToBook, bookSlug, firstDoc } from './book-nav';
1615

1716
/**
18-
* `/docs/:slug` — a book landing page (ADR-0046 §6). Resolves the book's spine
19-
* against the current docs and shows the grouped table of contents: the
20-
* persistent nav sidebar plus an overview that lists every section's docs. Each
21-
* doc links to the in-book reader (`/docs/:slug/:name`), which keeps this
22-
* sidebar in view. `:slug` is the book's `slug` (an implicit per-package book
23-
* uses its packageId).
17+
* `/docs/:slug` — a book landing (ADR-0046 §6). Opening a book means starting
18+
* to read it, so this resolves the book's spine and redirects to its overview
19+
* doc (`*_index`, else the first doc in order); the reader then shows that doc
20+
* with the book sidebar. This avoids duplicating the table of contents (the
21+
* sidebar already is it). A book with no readable docs shows an empty state.
2422
*/
2523
export default function BookPage() {
2624
const { slug, appName } = useParams<{ slug: string; appName?: string }>();
27-
const { books, docs, state, error } = useBookData();
25+
const { books, docs, state } = useBookData();
2826

2927
const found = useMemo(() => books.find((b) => bookSlug(b) === slug), [books, slug]);
30-
const resolved = useMemo(
31-
() => (found ? resolveBookTree(found, scopeDocsToBook(found, docs)) : null),
28+
const opensTo = useMemo(
29+
() => (found ? firstDoc(resolveBookTree(found, scopeDocsToBook(found, docs))) : undefined),
3230
[found, docs],
3331
);
3432

35-
const base = appName ? `/apps/${appName}/docs` : '/docs';
36-
const docHref = (name: string) => `${base}/${slug}/${name}`;
37-
3833
if (state === 'loading') {
3934
return (
4035
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
@@ -43,87 +38,30 @@ export default function BookPage() {
4338
);
4439
}
4540

46-
if (state === 'error' || !found || !resolved) {
47-
return (
48-
<DocShell breadcrumb={slug}>
49-
<div className="mx-auto flex max-w-3xl flex-col items-center gap-3 p-10 text-center">
50-
<FileQuestion className="h-10 w-10 text-muted-foreground" />
51-
<h1 className="text-lg font-semibold">
52-
{state === 'error' ? 'Failed to load documentation' : 'Book not found'}
53-
</h1>
54-
<p className="text-sm text-muted-foreground">
55-
{error ?? (
56-
<>
57-
No book named <code className="rounded bg-muted px-1 py-0.5">{slug}</code> is installed.
58-
</>
59-
)}
60-
</p>
61-
</div>
62-
</DocShell>
63-
);
41+
if (found && opensTo) {
42+
const base = appName ? `/apps/${appName}/docs` : '/docs';
43+
return <Navigate to={`${base}/${slug}/${opensTo}`} replace />;
6444
}
6545

46+
// Unknown book, or a book with no readable docs yet.
6647
return (
67-
<DocShell breadcrumb={resolved.label ?? slug}>
68-
<div className="mx-auto flex max-w-5xl gap-8 p-4 sm:p-6">
69-
<aside className="hidden w-60 shrink-0 lg:block">
70-
<div className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-auto pr-1">
71-
<BookSidebar book={resolved} docHref={docHref} />
72-
</div>
73-
</aside>
74-
75-
<div className="min-w-0 max-w-3xl flex-1">
76-
<h1 className="text-2xl font-bold">{resolved.label ?? slug}</h1>
77-
{resolved.description ? (
78-
<p className="mt-2 text-sm text-muted-foreground">{resolved.description}</p>
79-
) : null}
80-
81-
<div className="mt-6 space-y-8">
82-
{resolved.groups.map((group) => (
83-
<section key={group.key}>
84-
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
85-
{group.label}
86-
</h2>
87-
<ul className="divide-y divide-border rounded-md border border-border">
88-
{group.entries
89-
.filter((e) => !e.separator)
90-
.map((entry, i) =>
91-
entry.href ? (
92-
<li key={`ext-${i}`}>
93-
<a
94-
href={entry.href}
95-
target="_blank"
96-
rel="noreferrer"
97-
className="flex items-start gap-3 px-3 py-3 hover:bg-muted/50"
98-
>
99-
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
100-
<span className="text-sm font-medium">{entry.label ?? entry.href}</span>
101-
</a>
102-
</li>
103-
) : entry.doc ? (
104-
<li key={entry.doc}>
105-
<Link
106-
to={docHref(entry.doc)}
107-
className="flex items-start gap-3 px-3 py-3 hover:bg-muted/50"
108-
>
109-
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
110-
<div className="min-w-0">
111-
<div className="text-sm font-medium">{entry.label ?? entry.doc}</div>
112-
{entry.description ? (
113-
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
114-
{entry.description}
115-
</div>
116-
) : null}
117-
</div>
118-
</Link>
119-
</li>
120-
) : null,
121-
)}
122-
</ul>
123-
</section>
124-
))}
125-
</div>
126-
</div>
48+
<DocShell breadcrumb={found ? (found.label ?? slug) : slug}>
49+
<div className="mx-auto flex max-w-3xl flex-col items-center gap-3 p-10 text-center">
50+
{found ? (
51+
<>
52+
<BookOpen className="h-10 w-10 text-muted-foreground" />
53+
<h1 className="text-lg font-semibold">{found.label ?? slug}</h1>
54+
<p className="text-sm text-muted-foreground">This book has no documents yet.</p>
55+
</>
56+
) : (
57+
<>
58+
<FileQuestion className="h-10 w-10 text-muted-foreground" />
59+
<h1 className="text-lg font-semibold">Book not found</h1>
60+
<p className="text-sm text-muted-foreground">
61+
No book named <code className="rounded bg-muted px-1 py-0.5">{slug}</code> is installed.
62+
</p>
63+
</>
64+
)}
12765
</div>
12866
</DocShell>
12967
);

apps/console/src/pages/DocPage.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import { useCallback, useEffect, useMemo, useState } from 'react';
1010
import { useNavigate, useParams } from 'react-router-dom';
11-
import { AlertCircle, FileQuestion, Loader2 } from 'lucide-react';
11+
import { AlertCircle, ChevronDown, FileQuestion, Loader2, Menu } from 'lucide-react';
1212
import { useAdapter } from '@object-ui/app-shell';
1313
import { MarkdownRenderer, extractToc } from '@object-ui/plugin-markdown';
1414
import { useObjectTranslation } from '@object-ui/i18n';
@@ -156,6 +156,20 @@ export default function DocPage() {
156156

157157
return (
158158
<DocShell breadcrumb={doc?.label ?? name}>
159+
{/* Narrow screens: the persistent left sidebar is hidden, so the book
160+
* nav collapses into a disclosure above the content instead of vanishing. */}
161+
{resolvedBook ? (
162+
<details className="group mx-auto max-w-6xl px-4 pt-4 sm:px-6 lg:hidden">
163+
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm font-medium [&::-webkit-details-marker]:hidden">
164+
<Menu className="h-4 w-4 text-muted-foreground" />
165+
<span className="truncate">{resolvedBook.label ?? 'Contents'}</span>
166+
<ChevronDown className="ml-auto h-4 w-4 text-muted-foreground transition-transform group-open:rotate-180" />
167+
</summary>
168+
<div className="mt-2 rounded-md border p-3">
169+
<BookSidebar book={resolvedBook} activeDoc={name} docHref={docHref} />
170+
</div>
171+
</details>
172+
) : null}
159173
<div className={`mx-auto flex gap-8 p-4 sm:p-6 ${resolvedBook ? 'max-w-6xl' : 'max-w-5xl'}`}>
160174
{resolvedBook ? (
161175
<aside className="hidden w-56 shrink-0 lg:block">

apps/console/src/pages/DocsIndex.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,13 @@ export default function DocsIndex() {
4848

4949
return (
5050
<DocShell>
51-
<div className="mx-auto max-w-3xl p-4 sm:p-6">
51+
<div className="mx-auto max-w-4xl p-4 sm:p-6">
52+
<header className="mb-6">
53+
<h1 className="text-2xl font-bold tracking-tight">Documentation</h1>
54+
<p className="mt-1 text-sm text-muted-foreground">
55+
Books and guides from your installed packages.
56+
</p>
57+
</header>
5258
{cards.length === 0 ? (
5359
<div className="flex flex-col items-center gap-3 py-16 text-center text-muted-foreground">
5460
<BookOpen className="h-10 w-10" />
@@ -59,9 +65,9 @@ export default function DocsIndex() {
5965
</p>
6066
</div>
6167
) : (
62-
<ul className="grid gap-3 sm:grid-cols-2">
68+
<ul className="grid items-stretch gap-3 sm:grid-cols-2">
6369
{cards.map((card) => (
64-
<li key={card.name}>
70+
<li key={card.name} className="h-full">
6571
<Link
6672
to={`/docs/${card.slug}`}
6773
className="flex h-full items-start gap-3 rounded-md border border-border p-3 hover:bg-muted/50"
@@ -73,6 +79,10 @@ export default function DocsIndex() {
7379
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
7480
{card.description}
7581
</div>
82+
) : card.subtitle ? (
83+
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground/70">
84+
{card.subtitle}
85+
</div>
7686
) : null}
7787
<div className="mt-1 text-xs text-muted-foreground/80">
7888
{card.docCount} {card.docCount === 1 ? 'article' : 'articles'}

apps/console/src/pages/book-nav.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
deriveImplicitPackageBook,
1313
buildBookCards,
1414
buildPortalBooks,
15+
firstDoc,
1516
homeBook,
1617
bookSlug,
1718
pkgOf,
@@ -142,7 +143,18 @@ describe('buildPortalBooks (ADR-0046 §6.4 — no flat/book fork)', () => {
142143
expect(portal[0].name).toBe('crm_manual');
143144
expect(portal[portal.length - 1].name).toBe('misc');
144145
// implicit label is humanized, not the raw package id
145-
expect(portal.find((b) => b.name === 'misc')?.label).toBe('Misc');
146+
const misc = portal.find((b) => b.name === 'misc');
147+
expect(misc?.label).toBe('Misc');
148+
expect(misc?.implicit).toBe(true); // synthetic books are flagged
149+
});
150+
151+
it('cards surface the package id as a subtitle for implicit books only', () => {
152+
const authored: Book[] = [
153+
{ name: 'crm_manual', label: 'CRM Manual', description: 'd', groups: [{ key: 'g', label: 'G', include: 'crm_*' }] },
154+
];
155+
const cards = buildBookCards(buildPortalBooks(authored, docs), docs);
156+
expect(cards.find((c) => c.name === 'crm_manual')?.subtitle).toBeUndefined(); // authored: has a description
157+
expect(cards.find((c) => c.name === 'misc')?.subtitle).toBe('misc'); // implicit: package id
146158
});
147159

148160
it('with no authored books, every package becomes its own implicit book', () => {
@@ -213,6 +225,26 @@ describe('portal helpers', () => {
213225
expect(findBookContainingDoc(books, docs, 'misc_note')).toBeNull();
214226
});
215227

228+
it('firstDoc prefers an *_index doc, else the first in reading order', () => {
229+
const withIndex = resolveBookTree(
230+
{ name: 'b', groups: [{ key: 'g', label: 'G', include: 'crm_*' }] },
231+
[
232+
{ name: 'crm_guide_lead', order: 1 },
233+
{ name: 'crm_index', order: 9 }, // later in order, but the index wins
234+
],
235+
);
236+
expect(firstDoc(withIndex)).toBe('crm_index');
237+
238+
const noIndex = resolveBookTree(
239+
{ name: 'b', groups: [{ key: 'g', label: 'G', include: 'crm_*' }] },
240+
[{ name: 'crm_b', order: 2 }, { name: 'crm_a', order: 1 }],
241+
);
242+
expect(firstDoc(noIndex)).toBe('crm_a'); // first by order
243+
244+
// A book with no readable docs → undefined.
245+
expect(firstDoc({ name: 'b', label: 'B', groups: [] })).toBeUndefined();
246+
});
247+
216248
it('countEntries ignores separators', () => {
217249
const groups = [
218250
{ key: 'g', label: 'G', entries: [{ doc: 'a' }, { separator: true }, { href: 'x' }] },

apps/console/src/pages/book-nav.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export interface Book {
5353
groups?: BookGroup[];
5454
/** Owning package id (`_packageId`), stamped by the metadata API. */
5555
packageId?: string;
56+
/** True for a synthetic implicit per-package book (ADR-0046 §6.4). */
57+
implicit?: boolean;
5658
}
5759

5860
/** Minimal doc header the resolver needs. */
@@ -267,6 +269,8 @@ export interface BookCard {
267269
slug: string;
268270
label: string;
269271
description?: string;
272+
/** Context line for cards without a description — the owning package id. */
273+
subtitle?: string;
270274
icon?: string;
271275
packageId?: string;
272276
/** Number of docs the book currently resolves to (excludes separators/links). */
@@ -335,6 +339,9 @@ export function buildBookCards(books: Book[], docs: ResolverDoc[]): BookCard[] {
335339
slug: bookSlug(b),
336340
label: b.label ?? b.name,
337341
description: b.description,
342+
// Implicit per-package books have no authored description; surface the
343+
// package id so the card still says where the docs come from.
344+
subtitle: b.implicit && !b.description ? b.packageId : undefined,
338345
icon: b.icon,
339346
packageId: b.packageId,
340347
docCount: countBookDocs(b, docs),
@@ -356,7 +363,7 @@ export function buildPortalBooks(authored: Book[], docs: ResolverDoc[]): Book[]
356363
// Humanize the package id for the visible label (the slug/name stays the
357364
// full id so it remains unique and reversible); keep the group as the
358365
// generic "Documentation".
359-
.map((p) => ({ ...deriveImplicitPackageBook(p), label: humanizePackageId(p), packageId: p }));
366+
.map((p) => ({ ...deriveImplicitPackageBook(p), label: humanizePackageId(p), packageId: p, implicit: true }));
360367
// Authored books lead (curated, primary) in their own order; the synthetic
361368
// per-package books follow, alphabetically — so an authored book with an
362369
// explicit `order` is never pushed below a fallback.
@@ -404,6 +411,19 @@ export function findBookContainingDoc(
404411
return null;
405412
}
406413

414+
/**
415+
* The doc a book opens to — its overview. Prefers an `*_index` doc, else the
416+
* first doc in reading order. Returns undefined for a book with no readable
417+
* docs (only external links / separators).
418+
*/
419+
export function firstDoc(resolved: ResolvedBook): string | undefined {
420+
const names: string[] = [];
421+
for (const g of resolved.groups) {
422+
for (const e of g.entries) if (e.doc && !e.separator) names.push(e.doc);
423+
}
424+
return names.find((n) => /(^|_)index$/.test(n)) ?? names[0];
425+
}
426+
407427
/** Total docs an entry list covers — handy for "N articles" labels. */
408428
export function countEntries(groups: ResolvedGroup[]): number {
409429
let n = 0;

0 commit comments

Comments
 (0)