Skip to content

Commit d20deed

Browse files
ouiliameclaude
andauthored
improvement(docs): add Academy learning surface (#5213)
Adds the Academy section to the docs: video-first lessons (self-hosted MP4 on Vercel Blob), organized into Workflows, Agents, Tables, Files, and Knowledge Bases, each linking back to the reference docs. Lessons use a course layout (hero video with chapter seek, "what you'll learn", block diagrams). Docs only — no runtime or auth changes. The content may move to a separate CMS or its own site (academy.sim.ai) later; the docs are a starting point. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 371cc94 commit d20deed

23 files changed

Lines changed: 1387 additions & 9 deletions

apps/docs/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,6 @@ next-env.d.ts
3838
# Fumadocs
3939
/.source/
4040
.plans/
41+
42+
# fumadocs generates .source dirs anywhere a source.config sits
43+
**/.source/

apps/docs/app/[lang]/[[...slug]]/page.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,23 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
7575
}
7676
const isOpenAPI = '_openapi' in data && data._openapi != null
7777
const isApiReference = slug?.some((s) => s === 'api-reference') ?? false
78+
// Academy lessons are video-first: drop the "On this page" TOC and go full
79+
// width so the lesson hero/video gets the room (chapters live in-page instead).
80+
const isAcademy = slug?.[0] === 'academy'
7881

7982
const pageTreeRecord = source.pageTree as Record<string, Root>
8083
const pageTree = pageTreeRecord[lang] ?? pageTreeRecord.en ?? Object.values(pageTreeRecord)[0]
8184
const rawNeighbours = pageTree ? findNeighbour(pageTree, page.url) : null
82-
const neighbours = isApiReference
85+
// Academy and API Reference are self-contained sections; keep prev/next inside
86+
// the section instead of spilling into the main documentation tree. Match both
87+
// the section's pages (`/<slug>/...`) and its index (`/<slug>`).
88+
const sectionSlug = isApiReference ? 'api-reference' : isAcademy ? 'academy' : null
89+
const inSection = (url?: string) =>
90+
url != null && (url.includes(`/${sectionSlug}/`) || url.endsWith(`/${sectionSlug}`))
91+
const neighbours = sectionSlug
8392
? {
84-
previous: rawNeighbours?.previous?.url.includes('/api-reference/')
85-
? rawNeighbours.previous
86-
: undefined,
87-
next: rawNeighbours?.next?.url.includes('/api-reference/') ? rawNeighbours.next : undefined,
93+
previous: inSection(rawNeighbours?.previous?.url) ? rawNeighbours?.previous : undefined,
94+
next: inSection(rawNeighbours?.next?.url) ? rawNeighbours?.next : undefined,
8895
}
8996
: rawNeighbours
9097

@@ -197,18 +204,18 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
197204
/>
198205
<DocsPage
199206
toc={data.toc}
200-
full={data.full}
207+
full={data.full || isAcademy}
201208
breadcrumb={{
202209
enabled: false,
203210
}}
204211
tableOfContent={{
205212
style: 'clerk',
206-
enabled: true,
213+
enabled: !isAcademy,
207214
single: false,
208215
}}
209216
tableOfContentPopover={{
210217
style: 'clerk',
211-
enabled: true,
218+
enabled: !isAcademy,
212219
}}
213220
footer={{
214221
enabled: true,

apps/docs/components/navbar/navbar.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ const NAV_TABS = [
1313
{
1414
label: 'Documentation',
1515
href: '/introduction',
16-
match: (p: string) => !p.includes('/api-reference'),
16+
match: (p: string) => !p.includes('/api-reference') && !p.includes('/academy'),
17+
external: false,
18+
},
19+
{
20+
label: 'Academy',
21+
href: '/academy',
22+
match: (p: string) => p.includes('/academy'),
1723
external: false,
1824
},
1925
{
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
'use client'
2+
3+
import { useEffect, useState } from 'react'
4+
import { CirclePlay } from 'lucide-react'
5+
import { cn } from '@/lib/utils'
6+
7+
/** Parse a chapter timestamp ("M:SS" or "H:MM:SS") into seconds. */
8+
function parseTime(time: string): number {
9+
const parts = time.split(':').map(Number)
10+
if (parts.some(Number.isNaN)) return 0
11+
return parts.reduce((acc, n) => acc * 60 + n, 0)
12+
}
13+
14+
interface Chapter {
15+
/** Chapter label. */
16+
title: string
17+
/** Timestamp, e.g. "0:45". */
18+
time?: string
19+
}
20+
21+
interface VideoChaptersProps {
22+
/** Panel heading. Defaults to "Chapters". */
23+
title?: string
24+
chapters: Chapter[]
25+
className?: string
26+
}
27+
28+
/**
29+
* Right-rail panel listing the current video's chapters, styled to match the
30+
* Academy's course panels. Rows are skip-to controls; they activate once the
31+
* lesson's video is recorded.
32+
*/
33+
export function VideoChapters({ title = 'Chapters', chapters, className }: VideoChaptersProps) {
34+
// Chapters only seek when a VideoPlaceholder with a real video is on the page.
35+
// Handshake so the rows stay inert (not falsely clickable) on video-less lessons.
36+
const [hasVideo, setHasVideo] = useState(false)
37+
useEffect(() => {
38+
const onReady = () => setHasVideo(true)
39+
window.addEventListener('academy:video-ready', onReady)
40+
window.dispatchEvent(new Event('academy:video-query'))
41+
return () => window.removeEventListener('academy:video-ready', onReady)
42+
}, [])
43+
44+
return (
45+
<aside
46+
className={cn('not-prose rounded-xl border border-fd-border bg-fd-card/40 p-5', className)}
47+
>
48+
<h2 className='mt-0 mb-3 font-semibold text-fd-foreground text-lg'>{title}</h2>
49+
<ul className='m-0 flex list-none flex-col gap-0.5 p-0'>
50+
{chapters.map((chapter) => (
51+
<li key={chapter.title}>
52+
<button
53+
type='button'
54+
disabled={!hasVideo || chapter.time == null}
55+
onClick={() => {
56+
if (chapter.time == null) return
57+
window.dispatchEvent(
58+
new CustomEvent('academy:seek', { detail: { time: parseTime(chapter.time) } })
59+
)
60+
}}
61+
className='flex w-full cursor-pointer items-start gap-2.5 rounded-lg px-2.5 py-2 text-left text-fd-muted-foreground text-sm transition-colors hover:bg-fd-accent/50 disabled:cursor-default disabled:hover:bg-transparent'
62+
>
63+
<CirclePlay className='mt-0.5 size-4 shrink-0' />
64+
<span className='min-w-0 flex-1 break-words'>{chapter.title}</span>
65+
{chapter.time && (
66+
<span className='mt-0.5 shrink-0 text-fd-muted-foreground text-xs tabular-nums'>
67+
{chapter.time}
68+
</span>
69+
)}
70+
</button>
71+
</li>
72+
))}
73+
</ul>
74+
</aside>
75+
)
76+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
'use client'
2+
3+
import { useEffect, useRef, useState } from 'react'
4+
import { cn, getAssetUrl } from '@/lib/utils'
5+
6+
interface VideoPlaceholderProps {
7+
/** Large title shown on the hero. */
8+
title?: string
9+
/** Small italic eyebrow above the title, e.g. a module name. */
10+
eyebrow?: string
11+
/** Pill in the top-right corner. Defaults to "Coming soon" (shown only until a video is set). */
12+
label?: string
13+
/**
14+
* Self-hosted video source. Accepts an absolute URL, a root-relative path
15+
* (`/static/...`), or a bare asset name resolved through the Blob CDN. When
16+
* set, the play button loads the video; otherwise the card is "coming soon".
17+
*/
18+
src?: string
19+
className?: string
20+
}
21+
22+
/** Resolve a video source: pass absolute/root-relative through, send bare names to the Blob CDN. */
23+
function resolveVideoSrc(src: string): string {
24+
if (/^https?:\/\//.test(src) || src.startsWith('/')) return src
25+
return getAssetUrl(src)
26+
}
27+
28+
/** The sim logotype, drawn with currentColor so the theme can tint it. */
29+
function SimWordmark({ className }: { className?: string }) {
30+
return (
31+
<svg viewBox='0 0 816 392' fill='currentColor' aria-label='Sim' className={className}>
32+
<path d='M 0 297.507 L 54.609 297.507 C 54.609 312.642 60.07 324.71 70.992 333.709 C 81.914 342.299 96.679 346.594 115.287 346.594 C 135.512 346.594 151.086 342.707 162.008 334.936 C 172.93 326.754 178.391 315.915 178.391 302.415 C 178.391 292.598 175.357 284.417 169.289 277.871 C 163.627 271.326 153.109 266.009 137.737 261.918 L 85.555 249.646 C 59.261 243.102 39.642 233.08 26.698 219.581 C 14.158 206.082 7.888 188.287 7.888 166.198 C 7.888 147.79 12.54 131.837 21.844 118.338 C 31.552 104.838 44.699 94.408 61.284 87.045 C 78.274 79.682 97.69 76 119.534 76 C 141.378 76 160.187 79.886 175.964 87.658 C 192.144 95.43 204.684 106.271 213.584 120.179 C 222.888 134.086 227.742 150.654 228.146 169.88 L 173.536 169.88 C 173.132 154.335 168.076 142.267 158.368 133.678 C 148.659 125.087 135.108 120.792 117.714 120.792 C 99.915 120.792 86.162 124.678 76.453 132.451 C 66.745 140.223 61.891 150.858 61.891 164.357 C 61.891 184.402 76.453 198.105 105.579 205.468 L 157.76 218.354 C 182.841 224.08 201.651 233.489 214.191 246.579 C 226.73 259.26 233 276.644 233 298.734 C 233 317.55 227.943 334.118 217.831 348.435 C 207.718 362.343 193.762 373.183 175.964 380.955 C 158.57 388.318 137.939 392 114.073 392 C 79.285 392 51.576 383.409 30.945 366.229 C 10.315 349.048 0 326.141 0 297.507 Z' />
33+
<path d='M 430.759 392 L 374 392 L 374 92 L 424.721 92 L 424.721 143.095 C 430.76 126.357 442.433 112.167 458.535 101.145 C 475.039 89.715 494.966 84 518.314 84 C 544.48 84 566.217 91.144 583.527 105.431 C 600.837 119.719 612.108 138.701 617.342 162.378 L 607.076 162.378 C 611.102 138.701 622.172 119.719 640.287 105.431 C 658.401 91.144 680.743 84 707.311 84 C 741.126 84 767.694 94.001 787.017 114.004 C 806.339 134.006 816 161.357 816 196.056 L 816 392 L 760.448 392 L 760.448 210.139 C 760.448 186.462 754.41 168.297 742.333 155.643 C 730.66 142.579 714.758 136.048 694.631 136.048 C 680.542 136.048 668.062 139.314 657.194 145.845 C 646.728 151.968 638.475 160.949 632.437 172.787 C 626.398 184.625 623.38 198.505 623.38 214.425 L 623.38 392 L 567.223 392 L 567.223 209.527 C 567.223 185.85 561.387 167.888 549.713 155.643 C 538.039 142.988 522.138 136.66 502.01 136.66 C 487.921 136.66 475.442 139.926 464.574 146.457 C 454.108 152.58 445.855 161.562 439.817 173.4 C 433.778 184.83 430.759 198.505 430.759 214.425 L 430.759 392 Z' />
34+
<path d='M 342 38 C 342 58.987 324.987 76 304 76 C 283.013 76 266 58.987 266 38 C 266 17.013 283.013 0 304 0 C 324.987 0 342 17.013 342 38 Z' />
35+
<path d='M 332 392 L 276 392 L 276 92 C 284.5 95.988 293.99 98.218 304 98.218 C 314.01 98.218 323.5 95.988 332 92 L 332 392 Z' />
36+
</svg>
37+
)
38+
}
39+
40+
/**
41+
* A 16:9 lesson hero used across the Academy. Always shows the design-system
42+
* video card (title, blueprint grid, theme-aware dark/light). When a `src` is
43+
* provided the play button loads the self-hosted video inline; otherwise the
44+
* card reads "Coming soon" and the play button is muted.
45+
*/
46+
export function VideoPlaceholder({
47+
title,
48+
eyebrow,
49+
label = 'Coming soon',
50+
src,
51+
className,
52+
}: VideoPlaceholderProps) {
53+
const hasVideo = Boolean(src)
54+
const [playing, setPlaying] = useState(false)
55+
const videoRef = useRef<HTMLVideoElement>(null)
56+
const pendingSeek = useRef<number | null>(null)
57+
58+
// Chapter rows (VideoChapters) dispatch `academy:seek` with a time in seconds.
59+
// Start the video if it isn't playing yet, then jump there. We also announce
60+
// that a video exists (and answer a chapters-side query) so the chapter rows
61+
// only become interactive when there's actually something to seek.
62+
useEffect(() => {
63+
if (!src) return
64+
const onSeek = (e: Event) => {
65+
const time = (e as CustomEvent<{ time: number }>).detail?.time
66+
if (typeof time !== 'number') return
67+
const video = videoRef.current
68+
if (video) {
69+
video.currentTime = time
70+
void video.play()
71+
} else {
72+
pendingSeek.current = time
73+
setPlaying(true)
74+
}
75+
}
76+
const announce = () => window.dispatchEvent(new Event('academy:video-ready'))
77+
window.addEventListener('academy:seek', onSeek)
78+
window.addEventListener('academy:video-query', announce)
79+
announce()
80+
return () => {
81+
window.removeEventListener('academy:seek', onSeek)
82+
window.removeEventListener('academy:video-query', announce)
83+
}
84+
}, [src])
85+
86+
if (playing && src) {
87+
return (
88+
<div
89+
className={cn(
90+
'not-prose my-6 aspect-video w-full overflow-hidden rounded-[20px] bg-black',
91+
className
92+
)}
93+
>
94+
{/* biome-ignore lint/a11y/useMediaCaption: lesson videos have no caption track yet */}
95+
<video
96+
ref={videoRef}
97+
src={resolveVideoSrc(src)}
98+
title={title ?? 'Lesson video'}
99+
controls
100+
autoPlay
101+
playsInline
102+
onLoadedMetadata={() => {
103+
if (pendingSeek.current != null && videoRef.current) {
104+
videoRef.current.currentTime = pendingSeek.current
105+
void videoRef.current.play()
106+
pendingSeek.current = null
107+
}
108+
}}
109+
className='h-full w-full border-0'
110+
/>
111+
</div>
112+
)
113+
}
114+
115+
return (
116+
<div
117+
className={cn(
118+
'not-prose group relative my-6 aspect-video w-full select-none overflow-hidden rounded-[20px] font-season transition-transform duration-200 [container-type:inline-size]',
119+
'shadow-[inset_0_0_0_1px_#E6E6E6] [background:radial-gradient(130%_130%_at_50%_14%,#ffffff_0%,#f6f6f6_55%,#ececec_100%)]',
120+
'dark:shadow-none dark:[background:radial-gradient(130%_130%_at_50%_18%,#1c1c1c_0%,#121212_45%,#0a0a0a_100%)]',
121+
className
122+
)}
123+
>
124+
{/* Blueprint grid — faint, fading to atmosphere at the edges */}
125+
<div
126+
aria-hidden
127+
className='pointer-events-none absolute inset-0 [background-image:linear-gradient(rgba(18,18,18,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(18,18,18,0.05)_1px,transparent_1px)] [background-size:64px_64px] [mask-image:radial-gradient(120%_90%_at_50%_35%,#000_30%,transparent_100%)] dark:[background-image:linear-gradient(rgba(255,255,255,0.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.06)_1px,transparent_1px)]'
128+
/>
129+
130+
{/* Corner plus-marks, 20px inset */}
131+
{['top-5 left-5', 'top-5 right-5', 'bottom-5 left-5', 'right-5 bottom-5'].map((pos) => (
132+
<span
133+
key={pos}
134+
aria-hidden
135+
className={cn(
136+
'absolute font-mono text-[20px] text-[rgba(18,18,18,0.22)] leading-none dark:text-[rgba(255,255,255,0.28)]',
137+
pos
138+
)}
139+
>
140+
+
141+
</span>
142+
))}
143+
144+
{/* Top-right status pill — only until a video is wired up */}
145+
{!hasVideo && (
146+
<span className='absolute top-6 right-6 z-10 inline-flex items-center gap-2 rounded-full border border-[#E6E6E6] bg-white px-4 py-2 font-medium text-[#5F5F5F] text-[12px] uppercase tracking-[0.14em] md:top-8 md:right-8 dark:border-white/12 dark:bg-[#1A1A1A] dark:text-[#E6E6E6]'>
147+
<span className='size-1.5 rounded-full bg-[#1F8A5B]' />
148+
{label}
149+
</span>
150+
)}
151+
152+
{/* Heading: eyebrow + title, bottom-left (design: left:40 bottom:40) */}
153+
<div className='absolute bottom-10 left-10 z-10 max-w-[80%]'>
154+
{eyebrow && (
155+
<span className='mb-[14px] block font-normal text-[#5F5F5F] text-[clamp(15px,2cqi,22px)] italic tracking-[-0.01em] dark:text-[#B4B4B4]'>
156+
{eyebrow}
157+
</span>
158+
)}
159+
{title && (
160+
<span className='block font-semibold text-[#121212] text-[clamp(2.5rem,9.5cqi,5.5rem)] leading-[0.96] tracking-[-0.035em] dark:text-[#F8F8F8]'>
161+
{title}
162+
</span>
163+
)}
164+
</div>
165+
166+
{/* Wordmark, bottom-right (design: right:40 bottom:40, svg height 22) */}
167+
<span className='absolute right-10 bottom-10 z-10 text-[#121212] dark:text-white/90'>
168+
<SimWordmark className='block h-[22px] w-auto' />
169+
</span>
170+
171+
{/* Centered play button — active when a video is wired, muted otherwise */}
172+
<div className='absolute inset-0 z-10 grid place-items-center'>
173+
{hasVideo ? (
174+
<button
175+
type='button'
176+
onClick={() => setPlaying(true)}
177+
aria-label={title ? `Play ${title}` : 'Play video'}
178+
className='grid h-12 w-16 cursor-pointer place-items-center rounded-[14px] bg-[rgba(255,255,255,0.78)] shadow-[0_1px_3px_rgba(18,18,18,0.12),inset_0_0_0_1px_#E6E6E6] backdrop-blur-[4px] transition-transform duration-200 hover:scale-105 active:scale-95 dark:bg-[rgba(10,10,10,0.72)] dark:shadow-none'
179+
>
180+
<svg
181+
width='18'
182+
height='20'
183+
viewBox='0 0 18 20'
184+
aria-hidden
185+
className='translate-x-[1px] text-[#121212] dark:text-white'
186+
>
187+
<path d='M0 0l18 10L0 20z' fill='currentColor' />
188+
</svg>
189+
</button>
190+
) : (
191+
<span className='grid h-12 w-16 place-items-center rounded-[14px] bg-[rgba(255,255,255,0.78)] opacity-60 shadow-[0_1px_3px_rgba(18,18,18,0.12),inset_0_0_0_1px_#E6E6E6] backdrop-blur-[4px] dark:bg-[rgba(10,10,10,0.72)] dark:shadow-none'>
192+
<svg
193+
width='18'
194+
height='20'
195+
viewBox='0 0 18 20'
196+
aria-hidden
197+
className='translate-x-[1px] text-[#121212] dark:text-white'
198+
>
199+
<path d='M0 0l18 10L0 20z' fill='currentColor' />
200+
</svg>
201+
</span>
202+
)}
203+
</div>
204+
</div>
205+
)
206+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { cn } from '@/lib/utils'
2+
3+
interface LearnItem {
4+
title: string
5+
body: string
6+
}
7+
8+
interface WhatYouWillLearnProps {
9+
items: LearnItem[]
10+
className?: string
11+
}
12+
13+
/** A bordered "What you will learn" card listing lesson takeaways. */
14+
export function WhatYouWillLearn({ items, className }: WhatYouWillLearnProps) {
15+
return (
16+
<div
17+
className={cn('not-prose rounded-xl border border-fd-border bg-fd-card/40 p-6', className)}
18+
>
19+
<h2 className='mt-0 mb-5 font-semibold text-fd-foreground text-xl'>What you will learn</h2>
20+
<div className='flex flex-col gap-5'>
21+
{items.map((item) => (
22+
<div key={item.title}>
23+
<p className='mb-1 font-semibold text-fd-foreground text-sm'>{item.title}</p>
24+
<p className='m-0 text-fd-muted-foreground text-sm leading-relaxed'>{item.body}</p>
25+
</div>
26+
))}
27+
</div>
28+
</div>
29+
)
30+
}

0 commit comments

Comments
 (0)