Skip to content

Commit 0953275

Browse files
committed
Use tabs for multi-page Sim docs
1 parent a53677f commit 0953275

5 files changed

Lines changed: 92 additions & 96 deletions

File tree

apps/sim/lib/workspace-files/artifact-stylesheet.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,18 @@ describe('docs fidelity', () => {
165165
expect(SIM_ARTIFACT_STYLESHEET).not.toContain('data-rail="nav"')
166166
})
167167

168+
// Set navigation is the docs' top tab row. A long set must scroll
169+
// sideways — never wrap or truncate — with no scrollbar chrome; and the
170+
// retired prev/next arrows must stay gone.
171+
it('keeps the tab row one scrollable line and carries no arrow chrome', () => {
172+
expect(SIM_ARTIFACT_STYLESHEET).toContain('overflow-x: auto; scrollbar-width: none;')
173+
expect(SIM_ARTIFACT_STYLESHEET).toContain('.page-tabs::-webkit-scrollbar { display: none; }')
174+
expect(SIM_ARTIFACT_STYLESHEET).toContain('.page-tab.is-active')
175+
expect(SIM_ARTIFACT_STYLESHEET).not.toContain('.page-nav')
176+
expect(SIM_ARTIFACT_STYLESHEET).not.toContain('.pa-nav')
177+
expect(SIM_ARTIFACT_SHELL).not.toContain('page-actions')
178+
})
179+
168180
// Pages live inside the app: the PLATFORM stack, not the docs' webfont —
169181
// Inter next to emcn/sim chrome read as foreign.
170182
it('uses the platform font stack', () => {
@@ -178,7 +190,7 @@ describe('docs fidelity', () => {
178190
expect(SIM_ARTIFACT_STYLESHEET).toContain('text-decoration-thickness: 1.5px')
179191
expect(SIM_ARTIFACT_STYLESHEET).toContain('text-underline-offset: 3.5px')
180192
expect(SIM_ARTIFACT_STYLESHEET).toContain('a:hover { opacity: 0.8; }')
181-
expect(SIM_ARTIFACT_STYLESHEET).toContain('.rail a, .page-nav-card { font-weight: 400')
193+
expect(SIM_ARTIFACT_STYLESHEET).toContain('.rail a { font-weight: 400')
182194
})
183195

184196
// The docs' figure.shiki shell and Code.Viewer metrics.

apps/sim/lib/workspace-files/artifact-stylesheet.ts

Lines changed: 26 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -431,18 +431,27 @@ figcaption { font-size: 0.875em; line-height: 1.4285714; color: var(--text-prima
431431
border-bottom: 1px solid var(--border);
432432
}
433433
.art-bar-title { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); margin-right: auto; }
434-
/* Top-of-page controls: Copy page + prev/next chevrons on the title row. */
435-
.page-actions {
436-
display: flex; justify-content: flex-end; align-items: center; gap: 2px;
437-
margin-bottom: -2.4rem; position: relative; z-index: 2;
434+
435+
/* Multi-page set tabs — the docs' top tab row: muted labels, the current
436+
page in primary with a 2px underline riding the row's hairline. The row
437+
scrolls horizontally (no scrollbar chrome) when the set outgrows the
438+
column, so a long set never wraps or truncates. */
439+
.page-tabs {
440+
display: flex; gap: 1.75rem;
441+
margin: 0 0 2rem;
442+
border-bottom: 1px solid var(--border);
443+
overflow-x: auto; scrollbar-width: none;
438444
}
439-
.pa-nav {
440-
display: inline-flex; align-items: center; justify-content: center;
441-
width: 30px; height: 30px; border-radius: 0.5rem;
442-
color: var(--text-icon); transition: background-color 0.15s;
445+
.page-tabs::-webkit-scrollbar { display: none; }
446+
.page-tab {
447+
flex-shrink: 0; white-space: nowrap;
448+
padding: 0 0 0.65rem;
449+
font-size: var(--text-sm); color: var(--text-muted);
450+
text-decoration: none;
451+
border-bottom: 2px solid transparent; margin-bottom: -1px;
443452
}
444-
.pa-nav:hover { background: var(--surface-active); opacity: 1; }
445-
.pa-nav.is-disabled { opacity: 0.35; pointer-events: none; }
453+
a.page-tab:hover { color: var(--text-body); opacity: 1; }
454+
.page-tab.is-active { color: var(--text-primary); font-weight: 500; border-bottom-color: var(--text-primary); }
446455
447456
/* The docs' theme toggle: 30px, rounded-lg, --text-icon, --surface-active hover. */
448457
.art-theme {
@@ -474,8 +483,8 @@ figcaption { font-size: 0.875em; line-height: 1.4285714; color: var(--text-prima
474483
.toc-track, .toc-thumb { display: block; }
475484
.rail ol, .rail ul { list-style: none; margin: 0; padding: 0; }
476485
/* Chrome links are navigation, not prose: no underline weight or hover fade. */
477-
.rail a, .page-nav-card { font-weight: 400; transition: none; }
478-
.rail a:hover, .page-nav-card:hover { opacity: 1; }
486+
.rail a { font-weight: 400; transition: none; }
487+
.rail a:hover { opacity: 1; }
479488
480489
.rail[data-rail="toc"] { min-width: 150px; }
481490
@@ -558,7 +567,11 @@ figcaption { font-size: 0.875em; line-height: 1.4285714; color: var(--text-prima
558567
moment step bodies vary in height; a full-height rail that the opaque
559568
circles paint over is what the docs render. */
560569
.steps { list-style: none; margin: 1.25rem 0; padding: 0; }
561-
.step { --step-marker: 28px; position: relative; display: flex; gap: 1rem; padding-bottom: 1.75rem; }
570+
/* Explicit margin/padding resets: the prose list rules (ol > li's inline-start
571+
indent, li's vertical margins) otherwise leak in — the indent shifts the
572+
circles off the absolutely-positioned rail, and inter-item margins cut gaps
573+
into it. */
574+
.step { --step-marker: 28px; position: relative; display: flex; gap: 1rem; margin: 0; padding: 0 0 1.75rem; }
562575
.step:last-child { padding-bottom: 0.25rem; }
563576
/* The rail spans the whole step, dead-centered under the circle; the last
564577
step draws none, so the line ends AT the final circle. */
@@ -596,23 +609,6 @@ figcaption { font-size: 0.875em; line-height: 1.4285714; color: var(--text-prima
596609
.codetab.is-active { background: var(--surface-active); color: var(--text-primary); }
597610
.codetabs-head .codetabs-copy { position: static; margin-left: auto; }
598611
599-
/* API method chips — sidebar entries only, the docs' reference nav. */
600-
601-
/* Footer pagination — the docs' PageFooter verbatim: name + chevron on a
602-
hover pill (mt-12 flex gap-2 py-3; cards flex-1 gap-1.5 rounded-lg px-3
603-
py-3 text-sm --text-body, hover --surface-active; chevrons 14px
604-
--text-icon; a missing side keeps its half as a spacer). */
605-
.page-nav { display: flex; gap: 0.5rem; margin-top: 3rem; padding: 0.75rem 0; }
606-
.page-nav-card {
607-
flex: 1; display: flex; align-items: center; gap: 0.375rem; min-width: 0;
608-
border-radius: 0.5rem; padding: 0.75rem;
609-
font-size: var(--text-sm); font-weight: 400; color: var(--text-body);
610-
text-decoration: none; transition: background-color 0.2s;
611-
}
612-
.page-nav-card:hover { background: var(--surface-active); opacity: 1; }
613-
.page-nav-card.next { justify-content: flex-end; }
614-
.page-nav-card svg { flex-shrink: 0; color: var(--text-icon); }
615-
.page-nav-spacer { flex: 1; }
616612
`.trim()
617613

618614
/**
@@ -803,29 +799,6 @@ export const SIM_ARTIFACT_SHELL = `<script>
803799
}
804800
cols.append(mid, right)
805801
806-
// Top-of-page controls: prev/next chevrons on the title row, wired to
807-
// the same targets as the footer cards.
808-
const CHEV_L = '<svg ' + ICON_ATTRS + '><path d="M13.25 3L6.25 10.25L13.25 17.5"/></svg>'
809-
const CHEV_R = '<svg ' + ICON_ATTRS + '><path d="M6.25 3L13.25 10.25L6.25 17.5"/></svg>'
810-
const actions = document.createElement('div')
811-
actions.className = 'page-actions'
812-
const prevCard = page.querySelector('.page-nav-card.prev')
813-
const nextCard = page.querySelector('.page-nav-card.next')
814-
if (prevCard || nextCard) {
815-
const mkNav = (card, svg, label) => {
816-
const a = document.createElement('a')
817-
a.className = 'pa-nav'
818-
a.setAttribute('aria-label', label)
819-
const href = card ? card.getAttribute('href') : null
820-
if (href) a.href = href
821-
else a.classList.add('is-disabled')
822-
a.innerHTML = svg
823-
return a
824-
}
825-
actions.append(mkNav(prevCard, CHEV_L, 'Previous page'), mkNav(nextCard, CHEV_R, 'Next page'))
826-
}
827-
if (actions.childElementCount > 0) mid.insertBefore(actions, mid.firstChild)
828-
829802
// Clerk track geometry (fumadocs clerk.js): one path threading every item,
830803
// vertical through each and a cubic easing across depth changes.
831804
const lineOffset = (depth) => (depth === '2' ? 8 : 16)

apps/sim/lib/workspace-files/page-compile.test.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -146,23 +146,33 @@ describe('compileSimPage', () => {
146146
expect(html).toContain('src="/api/files/view/img9"')
147147
})
148148

149-
it('keeps a lone next card right-aligned with a spacer', () => {
150-
const html = compileSimPage('---\ntitle: T\nnext: "[API](sim:file/b)"\n---\nBody.')
151-
expect(html).toContain('<div class="page-nav-spacer"></div><a class="page-nav-card next"')
149+
it('renders the tabs frontmatter as the docs tab row above the title', () => {
150+
const html = compileSimPage(
151+
'---\ntitle: Overview\ntabs:\n - "Overview"\n - "[API Reference](sim:file/b)"\n - "[CLI](sim:file/c)"\n---\nBody.',
152+
{ workspaceId: 'ws1' }
153+
)
154+
expect(html).toContain('<nav class="page-tabs" aria-label="Pages">')
155+
// The bare-label entry IS this page: the active, unlinked tab.
156+
expect(html).toContain('<span class="page-tab is-active">Overview</span>')
157+
// Linked entries resolve through the normal sim: link pass.
158+
expect(html).toContain(
159+
'<a class="page-tab" href="/workspace/ws1/files/b" data-sim-link="">API Reference</a>'
160+
)
161+
// Tabs precede the title, the docs' top-row placement.
162+
expect(html.indexOf('page-tabs')).toBeLessThan(html.indexOf('<h1>'))
163+
})
164+
165+
it('renders no tab row for a single page', () => {
166+
expect(compileSimPage('---\ntitle: T\n---\nBody.')).not.toContain('page-tabs')
152167
})
153168

154-
it('renders frontmatter prev/next as footer pagination cards', () => {
169+
it('tolerates prev/next frontmatter without rendering pagination', () => {
155170
const html = compileSimPage(
156171
'---\ntitle: T\nprev: "[Getting Started](sim:file/a)"\nnext: "[API Reference](sim:file/b)"\n---\nBody.',
157172
{ workspaceId: 'ws1' }
158173
)
159-
expect(html).toContain('<footer class="page-nav">')
160-
expect(html).toContain('class="page-nav-card prev"')
161-
expect(html).toContain('Getting Started')
162-
expect(html).toContain('href="/workspace/ws1/files/b"')
163-
// The docs' PageFooter shape: chevron + name, no Previous/Next labels.
164-
expect(html).toContain('M6.25 3L13.25 10.25L6.25 17.5')
165-
expect(html).not.toContain('Previous')
174+
expect(html).not.toContain('page-nav')
175+
expect(html).toContain('Body.')
166176
})
167177

168178
it('tolerates nav frontmatter without rendering a sidebar', () => {

apps/sim/lib/workspace-files/page-compile.ts

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,17 @@ const frontmatterSchema = z.object({
4040
eyebrow: z.string().optional(),
4141
lede: z.string().optional(),
4242
layout: z.enum(['docs', 'report']).optional(),
43-
/** Docs-style footer pagination: a markdown link — `[Title](sim:file/<id>)`. */
43+
/** Tolerated for old sources; no longer rendered — sets navigate by tabs. */
4444
prev: z.string().optional(),
4545
next: z.string().optional(),
46+
/**
47+
* Multi-page set navigation, the docs' top tab row: every page of a set
48+
* carries the SAME ordered list. Each entry is a markdown link
49+
* (`"[API Reference](sim:file/<id>)"`) to a sibling page; the CURRENT
50+
* page's entry is its bare label (`"Overview"`), rendered as the active
51+
* tab. The row scrolls horizontally when the set outgrows the column.
52+
*/
53+
tabs: z.array(z.string()).min(1).optional(),
4654
/**
4755
* The SET's sidebar, docs-style: groups of pages under muted labels. Each
4856
* page entry is a markdown link; every page of a set carries the same nav.
@@ -66,26 +74,23 @@ const frontmatterSchema = z.object({
6674

6775
const MD_LINK = /^\s*\[([^\]]+)\]\(([^)]+)\)\s*$/
6876

69-
const CHEVRON_ATTRS =
70-
'viewBox="-1 -2 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"'
71-
const CHEVRON_LEFT = `<svg ${CHEVRON_ATTRS}><path d="M13.25 3L6.25 10.25L13.25 17.5"/></svg>`
72-
const CHEVRON_RIGHT = `<svg ${CHEVRON_ATTRS}><path d="M6.25 3L13.25 10.25L6.25 17.5"/></svg>`
73-
7477
/**
75-
* One footer navigation card from a frontmatter `[Title](href)` link — the
76-
* docs' own PageFooter: the destination name with a direction chevron on a
77-
* hover pill, no border and no Previous/Next label (the chevron already says
78-
* which way you are going). A missing side renders a spacer so the other
79-
* card keeps its half.
78+
* The set's tab row above the title — the docs' own top tabs: linked entries
79+
* navigate to sibling pages, the bare-label entry IS this page and renders as
80+
* the active tab. sim: hrefs resolve through the normal link pass.
8081
*/
81-
function paginationCard(value: string | undefined, direction: 'prev' | 'next'): string {
82-
const match = value?.match(MD_LINK)
83-
if (!match) return '<div class="page-nav-spacer"></div>'
84-
const [, title, href] = match
85-
const name = escapeHtml(title)
86-
return direction === 'prev'
87-
? `<a class="page-nav-card prev" href="${escapeHtml(href)}">${CHEVRON_LEFT}${name}</a>`
88-
: `<a class="page-nav-card next" href="${escapeHtml(href)}">${name}${CHEVRON_RIGHT}</a>`
82+
function tabBar(tabs: string[] | undefined): string {
83+
if (!tabs || tabs.length === 0) return ''
84+
const items = tabs
85+
.map((entry) => {
86+
const match = entry.match(MD_LINK)
87+
if (match) {
88+
return `<a class="page-tab" href="${escapeHtml(match[2])}">${escapeHtml(match[1])}</a>`
89+
}
90+
return `<span class="page-tab is-active">${escapeHtml(entry.trim())}</span>`
91+
})
92+
.join('')
93+
return `<nav class="page-tabs" aria-label="Pages">${items}</nav>`
8994
}
9095

9196
/** YAML leaves unquoted scalars typed; reject null/objects rather than stringify them. */
@@ -426,14 +431,10 @@ function compileSimPageDocument(source: string, diagnostics?: string[]): string
426431
'</head>',
427432
'<body>',
428433
`<div class="page" data-layout="${meta.layout ?? 'docs'}">`,
434+
...(meta.tabs ? [tabBar(meta.tabs)] : []),
429435
`<h1>${escapeHtml(meta.title)}</h1>`,
430436
...(meta.lede ? [`<p class="lede">${escapeHtml(meta.lede)}</p>`] : []),
431437
compileBody(rest, diagnostics),
432-
...(meta.prev || meta.next
433-
? [
434-
`<footer class="page-nav">${paginationCard(meta.prev, 'prev')}${paginationCard(meta.next, 'next')}</footer>`,
435-
]
436-
: []),
437438
'</div>',
438439
'</body>',
439440
'</html>',

apps/sim/lib/workspace-files/shell-layout.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@ describe('shell layout decisions', () => {
3030
expect(toc.map((a) => a.textContent)).toEqual(['A', 'B', 'C'])
3131
})
3232

33-
it('keeps prev/next arrows wired from frontmatter', () => {
34-
runShell('---\ntitle: T\nnext: "[Reference](sim:file/b)"\n---\n## Only\n\nx')
35-
const next = document.querySelector('.page-actions .pa-nav[aria-label="Next page"]')
36-
expect(next?.getAttribute('href')).toBe('sim:file/b')
37-
expect(
38-
document.querySelector('.page-actions .pa-nav[aria-label="Previous page"]')?.classList
39-
).toContain('is-disabled')
33+
it('keeps the set tabs in place and adds no arrow chrome', () => {
34+
runShell('---\ntitle: T\ntabs:\n - "T"\n - "[Reference](sim:file/b)"\n---\n## Only\n\nx')
35+
const tabs = [...document.querySelectorAll('.page-tabs .page-tab')]
36+
expect(tabs.map((tab) => tab.textContent)).toEqual(['T', 'Reference'])
37+
expect(document.querySelector('.page-tab.is-active')?.textContent).toBe('T')
38+
expect(document.querySelector('.page-actions')).toBeNull()
39+
expect(document.querySelector('.pa-nav')).toBeNull()
4040
})
4141
})
4242

0 commit comments

Comments
 (0)