fimo 2.2.0-staging.10 → 2.2.0-staging.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/assets/skills/fimo-cli/SKILL.md +1 -1
  2. package/assets/skills/fimo-cli/references/assets.md +38 -2
  3. package/assets/skills/fimo-cli/references/content.md +1 -1
  4. package/assets/skills/fimo-code/references/content/schema.md +1 -0
  5. package/assets/skills/fimo-migration/SKILL.md +44 -13
  6. package/assets/skills/fimo-migration/references/frameworks/nextjs.md +8 -0
  7. package/assets/skills/fimo-migration/references/qa.md +30 -4
  8. package/assets/skills/fimo-migration/references/questions-and-improvements.md +188 -0
  9. package/assets/skills/fimo-migration/references/workers/content-planner.md +8 -3
  10. package/assets/skills/fimo-migration/references/workers/validator.md +20 -6
  11. package/assets/skills/fimo-migration/references/workers.md +17 -6
  12. package/assets/skills/fimo-migration/references/workflow.md +33 -6
  13. package/assets/skills/fimo-studio/SKILL.md +3 -1
  14. package/assets/skills/fimo-studio/references/agents.md +25 -0
  15. package/dist/cli/bundle.json +1 -1
  16. package/dist/cli/index.js +12 -11
  17. package/dist/runtime/templates.d.ts +1 -0
  18. package/dist/runtime/templates.js +3 -0
  19. package/dist/scripts/validate-schemas.js +10 -15
  20. package/package.json +8 -7
  21. package/templates/astro/package.json +2 -2
  22. package/templates/nextjs/package.json +3 -3
  23. package/templates/react-router/package.json +4 -4
  24. package/templates/sveltekit/package.json +2 -2
  25. package/templates/vite/package.json +4 -4
  26. package/templates/welcome/manifest.json +404 -0
  27. package/templates/welcome/media/analytics-overview.avif +0 -0
  28. package/templates/welcome/media/autonomous-loop.avif +0 -0
  29. package/templates/welcome/media/editable-preview.avif +0 -0
  30. package/templates/welcome/media/media-library.avif +0 -0
  31. package/templates/welcome/media/slack-teams.avif +0 -0
  32. package/templates/welcome/template/.fimo/agents/welcome-guide/GOAL.md +7 -0
  33. package/templates/welcome/template/.fimo/agents/welcome-guide/config.yaml +8 -0
  34. package/templates/welcome/template/.fimo/config.json +53 -0
  35. package/templates/welcome/template/src/font.css +60 -0
  36. package/templates/welcome/template/src/pages/ArticleDetail.tsx +68 -0
  37. package/templates/welcome/template/src/pages/Index.tsx +32 -0
  38. package/templates/welcome/template/src/pages/SiteContent.tsx +413 -0
  39. package/templates/welcome/template/src/pages/fallback-content.ts +133 -0
  40. package/templates/welcome/template/src/routes.ts +9 -0
  41. package/templates/welcome/template/src/schemas/Article.json +12 -0
  42. package/templates/welcome/template/src/schemas/Article.ts +59 -0
  43. package/templates/welcome/template/src/schemas/Article.types.ts +73 -0
  44. package/templates/welcome/template/src/schemas/HomePage.json +39 -0
  45. package/templates/welcome/template/src/schemas/HomePage.types.ts +114 -0
@@ -0,0 +1,60 @@
1
+ @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Geist+Mono:wght@400;500&display=swap');
2
+
3
+ .fimo-starter-font {
4
+ font-family:
5
+ 'Manrope',
6
+ ui-sans-serif,
7
+ system-ui,
8
+ -apple-system,
9
+ BlinkMacSystemFont,
10
+ 'Segoe UI',
11
+ sans-serif;
12
+ }
13
+
14
+ .fimo-mono {
15
+ font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
16
+ letter-spacing: 0.08em;
17
+ }
18
+
19
+ @keyframes fimo-icon-loop {
20
+ 0%,
21
+ 100% {
22
+ transform: translate(0, 0) rotate(0deg);
23
+ }
24
+ 25% {
25
+ transform: translate(2px, -2px) rotate(6deg);
26
+ }
27
+ 50% {
28
+ transform: translate(0, 1px) rotate(0deg);
29
+ }
30
+ 75% {
31
+ transform: translate(-2px, -1px) rotate(-6deg);
32
+ }
33
+ }
34
+
35
+ .step-cell:hover .step-icon {
36
+ animation: fimo-icon-loop 1.1s ease-in-out 2;
37
+ }
38
+
39
+ @keyframes fimo-dot-blink {
40
+ 0%,
41
+ 80%,
42
+ 100% {
43
+ opacity: 0.25;
44
+ }
45
+ 40% {
46
+ opacity: 1;
47
+ }
48
+ }
49
+
50
+ .fimo-dots span {
51
+ animation: fimo-dot-blink 1.2s infinite;
52
+ }
53
+
54
+ .fimo-dots span:nth-child(2) {
55
+ animation-delay: 0.2s;
56
+ }
57
+
58
+ .fimo-dots span:nth-child(3) {
59
+ animation-delay: 0.4s;
60
+ }
@@ -0,0 +1,68 @@
1
+ import { Image, RichText, Text } from 'fimo-react';
2
+ import { ArrowLeft } from 'lucide-react';
3
+ import { Link, useParams } from 'react-router';
4
+
5
+ import ArticleContent from '@/schemas/Article';
6
+
7
+ import '../font.css';
8
+
9
+ const ArticleDetail = () => {
10
+ const { slug = '' } = useParams();
11
+ const { data: article, isLoading } = ArticleContent.useGetBySlug(slug);
12
+
13
+ if (!article) {
14
+ return (
15
+ <main className="fimo-starter-font min-h-screen bg-zinc-100 px-3 py-3 text-zinc-950 sm:px-6 sm:py-8">
16
+ <div className="mx-auto max-w-4xl px-6 py-24 text-center sm:px-12">
17
+ <p className="text-zinc-600">{isLoading ? 'Loading article…' : 'This article does not exist.'}</p>
18
+ {!isLoading && (
19
+ <Link
20
+ to="/"
21
+ className="mt-6 inline-flex cursor-pointer items-center gap-2 rounded-[8px] font-semibold text-orange-700 hover:text-orange-800"
22
+ >
23
+ <ArrowLeft className="size-4" aria-hidden />
24
+ Back to the tutorial
25
+ </Link>
26
+ )}
27
+ </div>
28
+ </main>
29
+ );
30
+ }
31
+
32
+ return (
33
+ <main className="fimo-starter-font min-h-screen bg-zinc-100 px-3 py-3 text-zinc-950 sm:px-6 sm:py-8">
34
+ <article className="mx-auto max-w-4xl">
35
+ <header className="border-b border-zinc-300 px-6 py-6 sm:px-12">
36
+ <Link
37
+ to="/"
38
+ className="inline-flex cursor-pointer items-center gap-2 rounded-[8px] text-sm font-semibold text-orange-700 hover:text-orange-800"
39
+ >
40
+ <ArrowLeft className="size-4" aria-hidden />
41
+ Back to the tutorial
42
+ </Link>
43
+ </header>
44
+ <div className="px-6 py-12 sm:px-12 sm:py-16">
45
+ <p className="text-xs font-semibold tracking-[0.18em] text-orange-700 uppercase">Fimo tutorial</p>
46
+ <Text
47
+ value={article.title}
48
+ as="h1"
49
+ className="mt-5 max-w-3xl text-4xl font-bold tracking-[-0.04em] sm:text-6xl"
50
+ />
51
+ <Text value={article.summary} as="p" className="mt-6 max-w-2xl text-lg leading-8 text-zinc-600" />
52
+ <Image
53
+ value={article.cover}
54
+ className="mt-10 aspect-[48/25] w-full rounded-[8px] border border-zinc-950 object-cover"
55
+ loading="eager"
56
+ fetchPriority="high"
57
+ />
58
+ <RichText
59
+ value={article.body}
60
+ className="mx-auto mt-12 max-w-2xl text-lg leading-8 text-zinc-700 [&_a]:cursor-pointer [&_a]:font-semibold [&_a]:text-orange-700 [&_a]:underline [&_a]:underline-offset-4 [&_h2]:mt-12 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:tracking-[-0.02em] [&_h2]:text-zinc-950 [&_h2+p]:mt-4 [&_h3]:mt-8 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:text-zinc-950 [&_li+li]:mt-2 [&_p+p]:mt-6 [&_pre]:mt-6 [&_pre]:overflow-x-auto [&_pre]:rounded-[8px] [&_pre]:bg-zinc-950 [&_pre]:p-5 [&_pre]:text-[15px] [&_pre]:leading-7 [&_pre]:whitespace-pre-wrap [&_pre]:text-zinc-100 [&_pre+p]:mt-6 [&_ul]:mt-6 [&_ul]:list-disc [&_ul]:pl-6 [&_ul+p]:mt-6"
61
+ />
62
+ </div>
63
+ </article>
64
+ </main>
65
+ );
66
+ };
67
+
68
+ export default ArticleDetail;
@@ -0,0 +1,32 @@
1
+ import ArticleContent from '@/schemas/Article';
2
+ import HomePageContent from '@/schemas/HomePage';
3
+
4
+ import '../font.css';
5
+ import { FALLBACK_ARTICLES, FALLBACK_PAGE } from './fallback-content';
6
+ import { ContentFallback, TutorialPage } from './SiteContent';
7
+
8
+ const Index = () => {
9
+ const { data: page, isLoading, isError } = HomePageContent.useGet();
10
+ const { data: articles = [], isLoading: articlesLoading, isError: articlesError } = ArticleContent.useGet();
11
+
12
+ // While loading, the page renders the seed copy through the same
13
+ // components, so the live values reconcile in place with no visible
14
+ // change and no layout shift. Keeping one JSX position avoids a remount
15
+ // on the fallback-to-live switch. Errors keep the seed copy up too —
16
+ // the query refetches on focus/reconnect, so an unreachable backend
17
+ // self-heals; the empty message is reserved for genuinely absent content.
18
+ const showTutorial = Boolean(page) || isLoading || articlesLoading || isError || articlesError;
19
+ const resolvedArticles = articles.length > 0 ? articles : articlesLoading ? FALLBACK_ARTICLES : articles;
20
+
21
+ return (
22
+ <div className="fimo-starter-font min-h-screen bg-white text-zinc-950">
23
+ {showTutorial ? (
24
+ <TutorialPage page={page ?? FALLBACK_PAGE} articles={page ? resolvedArticles : FALLBACK_ARTICLES} />
25
+ ) : (
26
+ <ContentFallback />
27
+ )}
28
+ </div>
29
+ );
30
+ };
31
+
32
+ export default Index;
@@ -0,0 +1,413 @@
1
+ import { Image, RichText, Text } from 'fimo-react';
2
+ import { Bot, Check, Copy, Database, GitBranch, MousePointerClick } from 'lucide-react';
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import { Link } from 'react-router';
5
+
6
+ import type { Article } from '@/schemas/Article';
7
+ import type { HomePage } from '@/schemas/HomePage';
8
+
9
+ interface TutorialPageProps {
10
+ page: HomePage;
11
+ articles: Article[];
12
+ }
13
+
14
+ const COMPLETED_ACTIONS_KEY = 'fimo-welcome-completed-actions-v2';
15
+ const ARTICLE_COVER_CLASS = 'aspect-[48/25] w-full border-b border-zinc-200 bg-white';
16
+ const OUTLINE_BUTTON_CLASS =
17
+ 'mt-4 w-full cursor-pointer rounded-md border border-orange-600/40 py-2 text-[13px] font-semibold text-orange-700 transition-colors hover:border-orange-600 hover:bg-orange-50';
18
+
19
+ export function TutorialPage({ page, articles }: TutorialPageProps) {
20
+ return (
21
+ <main className="min-h-screen bg-white px-4 py-10 text-zinc-950 sm:px-6">
22
+ <article className="relative mx-auto max-w-6xl border-x border-t border-zinc-200">
23
+ <Hero page={page} />
24
+ <Tutorial page={page} />
25
+ <div className="relative h-14 border-b border-zinc-200" />
26
+ <Articles articles={articles} />
27
+ <div className="relative h-14 border-b border-zinc-200">
28
+ <GridDot edgeX="left" edgeY="top" />
29
+ <GridDot x="33.333%" edgeY="top" />
30
+ <GridDot x="66.666%" edgeY="top" />
31
+ <GridDot edgeX="right" edgeY="top" />
32
+ <GridDot edgeX="left" edgeY="bottom" />
33
+ <GridDot x="50%" edgeY="bottom" />
34
+ <GridDot edgeX="right" edgeY="bottom" />
35
+ </div>
36
+ <WhatComesNext page={page} />
37
+ <div className="relative">
38
+ <GridDot edgeX="left" edgeY="top" />
39
+ <GridDot x="50%" edgeY="top" />
40
+ <GridDot edgeX="right" edgeY="top" />
41
+ </div>
42
+ </article>
43
+ </main>
44
+ );
45
+ }
46
+
47
+ // Fimo grid intersection marker, light theme. The edge clip mirrors the
48
+ // website's GridDot: it stops the halo from spilling outside the column.
49
+ function GridDot({ x, edgeX, edgeY }: { x?: string; edgeX?: 'left' | 'right'; edgeY: 'top' | 'bottom' }) {
50
+ const style: React.CSSProperties = {};
51
+ if (x) {
52
+ style.left = x;
53
+ style.marginLeft = -2;
54
+ }
55
+ if (edgeX === 'left') {
56
+ style.left = 0;
57
+ style.marginLeft = -2;
58
+ style.clipPath = 'inset(-6px -6px -6px 0)';
59
+ }
60
+ if (edgeX === 'right') {
61
+ style.right = 0;
62
+ style.marginRight = -1;
63
+ style.clipPath = 'inset(-6px 0 -6px -6px)';
64
+ }
65
+ if (edgeY === 'top') {
66
+ style.top = -2;
67
+ } else {
68
+ style.bottom = -2;
69
+ }
70
+
71
+ return (
72
+ <span
73
+ aria-hidden
74
+ className="absolute z-10 size-[3px] rounded-full bg-zinc-200 shadow-[0_0_0_5px_#ffffff]"
75
+ style={style}
76
+ />
77
+ );
78
+ }
79
+
80
+ function Hero({ page }: { page: HomePage }) {
81
+ return (
82
+ <section className="relative border-b border-zinc-200 px-6 py-10 text-center">
83
+ <GridDot edgeX="left" edgeY="top" />
84
+ <GridDot edgeX="right" edgeY="top" />
85
+ <Text value={page.title} as="h1" className="text-[32px] font-extrabold tracking-[-0.04em] sm:text-4xl" />
86
+ <RichText value={page.intro} className="mx-auto mt-2 max-w-xl text-[15px] leading-6 text-zinc-600" />
87
+ <GridDot edgeX="left" edgeY="bottom" />
88
+ <GridDot x="25%" edgeY="bottom" />
89
+ <GridDot x="50%" edgeY="bottom" />
90
+ <GridDot x="75%" edgeY="bottom" />
91
+ <GridDot edgeX="right" edgeY="bottom" />
92
+ </section>
93
+ );
94
+ }
95
+
96
+ function Tutorial({ page }: { page: HomePage }) {
97
+ const [completedActions, setCompletedActions] = useState<string[]>(readCompletedActions);
98
+ const currentStepIndex = page.steps.findIndex((step) => !completedActions.includes(String(step.action)));
99
+
100
+ const completeAction = useCallback((action: string) => {
101
+ setCompletedActions((current) => {
102
+ if (current.includes(action)) {
103
+ return current;
104
+ }
105
+
106
+ const next = [...current, action];
107
+ window.sessionStorage.setItem(COMPLETED_ACTIONS_KEY, JSON.stringify(next));
108
+ return next;
109
+ });
110
+ }, []);
111
+
112
+ const [editActive, setEditActive] = useState(false);
113
+
114
+ useEffect(() => {
115
+ const onMessage = (event: MessageEvent) => {
116
+ if (event.data?.type !== 'mode/change') {
117
+ return;
118
+ }
119
+ const isEdit = event.data?.payload?.mode === 'edit';
120
+ setEditActive(isEdit);
121
+ if (isEdit) {
122
+ completeAction('toggleEditMode');
123
+ }
124
+ };
125
+
126
+ window.addEventListener('message', onMessage);
127
+ return () => window.removeEventListener('message', onMessage);
128
+ }, [completeAction]);
129
+
130
+ return (
131
+ <section
132
+ id="tour"
133
+ className="grid border-b border-zinc-200 max-lg:divide-y max-lg:divide-zinc-200 sm:max-lg:grid-cols-2 lg:grid-cols-4 lg:divide-x lg:divide-zinc-200"
134
+ >
135
+ {page.steps.map((step, index) => {
136
+ const action = String(step.action);
137
+ const completed = completedActions.includes(action);
138
+ const current = !completed && index === currentStepIndex;
139
+
140
+ return (
141
+ <div key={step._key} className="step-cell flex flex-col p-6">
142
+ <div className="flex items-center justify-between">
143
+ <span className="flex items-center gap-2.5">
144
+ <span
145
+ aria-label={completed ? `Step ${index + 1} completed` : `Step ${index + 1}`}
146
+ className={`flex size-9 items-center justify-center rounded-full text-[16px] font-extrabold transition-colors duration-300 ${
147
+ completed
148
+ ? 'bg-emerald-500 text-white'
149
+ : current
150
+ ? 'bg-orange-600 text-white'
151
+ : 'border-2 border-zinc-200 text-zinc-400'
152
+ }`}
153
+ >
154
+ {completed ? <Check className="size-5" aria-hidden /> : <Text value={step.number} />}
155
+ </span>
156
+ {current && index === 0 ? (
157
+ <span className="fimo-mono text-[10px] font-medium text-orange-600">START HERE</span>
158
+ ) : null}
159
+ </span>
160
+ <StepIcon action={action} />
161
+ </div>
162
+ <Text value={step.title} as="h2" className="mt-4 text-[15px] font-bold" />
163
+ <Text value={step.body} as="div" className="mt-1 flex-1 text-[13px] leading-5 text-zinc-600">
164
+ {(value) => <InstructionText value={value} />}
165
+ </Text>
166
+ <TutorialAction
167
+ action={action}
168
+ label={step.actionLabel}
169
+ editActive={editActive}
170
+ primary={current}
171
+ onClick={() => {
172
+ completeAction(action);
173
+ postShortcut(action);
174
+ }}
175
+ />
176
+ </div>
177
+ );
178
+ })}
179
+ </section>
180
+ );
181
+ }
182
+
183
+ function StepIcon({ action }: { action: string }) {
184
+ const ActionIcon =
185
+ action === 'toggleEditMode'
186
+ ? MousePointerClick
187
+ : action === 'openHomePage'
188
+ ? Database
189
+ : action === 'openBranches'
190
+ ? GitBranch
191
+ : Bot;
192
+
193
+ return <ActionIcon className="step-icon size-[17px] text-zinc-950" strokeWidth={1.8} aria-hidden />;
194
+ }
195
+
196
+ function InstructionText({ value }: { value: string }) {
197
+ const lines = value
198
+ .split('\n')
199
+ .map((line) => line.trim())
200
+ .filter(Boolean);
201
+ const instructions = lines.filter((line) => /^-\s/.test(line));
202
+ const explanation = lines.filter((line) => !/^-\s/.test(line)).join(' ');
203
+
204
+ return (
205
+ <>
206
+ {explanation ? <p>{formatInstruction(explanation)}</p> : null}
207
+ {instructions.length > 0 ? (
208
+ <div className={explanation ? 'mt-2.5' : ''}>
209
+ <span className="fimo-mono text-[10px] font-medium text-orange-600">ACTION</span>
210
+ <ul className="mt-1 space-y-1.5">
211
+ {instructions.map((instruction) => (
212
+ <li key={instruction} className="font-medium text-zinc-800">
213
+ {formatInstruction(instruction.replace(/^-\s*/, ''))}
214
+ </li>
215
+ ))}
216
+ </ul>
217
+ </div>
218
+ ) : null}
219
+ </>
220
+ );
221
+ }
222
+
223
+ function formatInstruction(value: string) {
224
+ return value.split(/(“[^”]+”)/g).map((part, index) => {
225
+ if (part.startsWith('“') && part.endsWith('”')) {
226
+ return (
227
+ <mark
228
+ key={`${part}-${index}`}
229
+ className="rounded border border-zinc-200 bg-zinc-100 px-1 py-px font-semibold text-zinc-900"
230
+ >
231
+ {part.slice(1, -1)}
232
+ </mark>
233
+ );
234
+ }
235
+
236
+ return part;
237
+ });
238
+ }
239
+
240
+ // The Studio paints the static snapshot build first and swaps in the dev
241
+ // server a few seconds later; Edit mode only works after that swap. The
242
+ // snapshot is the one context that is a production build inside an iframe,
243
+ // which is how the page knows to explain the wait on hover.
244
+ const isStaticStudioSnapshot = () => !import.meta.env.DEV && typeof window !== 'undefined' && window.parent !== window;
245
+
246
+ function TutorialAction({
247
+ action,
248
+ label,
249
+ editActive,
250
+ primary,
251
+ onClick,
252
+ }: {
253
+ action: string;
254
+ label: HomePage['steps'][number]['actionLabel'];
255
+ editActive: boolean;
256
+ primary: boolean;
257
+ onClick: () => void;
258
+ }) {
259
+ // While Edit mode is active, preview.js captures page clicks for element
260
+ // selection, so an in-page button cannot receive them. The Studio shows
261
+ // the exit guidance anchored on its own Edit mode toggle instead.
262
+ if (action === 'toggleEditMode' && editActive) {
263
+ return null;
264
+ }
265
+ if (action === 'toggleEditMode' && isStaticStudioSnapshot()) {
266
+ // The snapshot is replaced by the dev document the moment the dev
267
+ // server can serve Edit mode, so this loading state flips to the real
268
+ // button exactly when the feature becomes available.
269
+ return (
270
+ <span className="group relative mt-4 inline-flex w-full">
271
+ <span
272
+ role="tooltip"
273
+ className="pointer-events-none absolute bottom-full left-0 mb-2 w-max max-w-64 rounded-md bg-zinc-950 px-3 py-2 text-xs font-medium text-white opacity-0 transition-opacity duration-150 group-focus-within:opacity-100 group-hover:opacity-100"
274
+ >
275
+ Edit mode is not available yet. It activates in a few seconds.
276
+ </span>
277
+ <span
278
+ aria-busy="true"
279
+ className="flex w-full cursor-default items-center justify-center rounded-md bg-orange-600/70 py-2 text-[13px] font-semibold text-white"
280
+ >
281
+ Loading edit mode, wait
282
+ <span className="fimo-dots ml-0.5" aria-hidden>
283
+ <span>.</span>
284
+ <span>.</span>
285
+ <span>.</span>
286
+ </span>
287
+ </span>
288
+ </span>
289
+ );
290
+ }
291
+
292
+ return <ActionButton label={label} primary={primary} onClick={onClick} />;
293
+ }
294
+
295
+ function ActionButton({
296
+ label,
297
+ primary,
298
+ onClick,
299
+ className = '',
300
+ }: {
301
+ label: HomePage['steps'][number]['actionLabel'];
302
+ primary: boolean;
303
+ onClick: () => void;
304
+ className?: string;
305
+ }) {
306
+ return (
307
+ <button
308
+ type="button"
309
+ onClick={onClick}
310
+ className={`${
311
+ primary
312
+ ? 'mt-4 w-full cursor-pointer rounded-md bg-orange-600 py-2 text-[13px] font-semibold text-white transition-colors hover:bg-orange-700'
313
+ : OUTLINE_BUTTON_CLASS
314
+ } ${className}`}
315
+ >
316
+ <Text value={label} />
317
+ </button>
318
+ );
319
+ }
320
+
321
+ function Articles({ articles }: { articles: Article[] }) {
322
+ return (
323
+ <section className="grid border-b border-zinc-200 max-md:divide-y max-md:divide-zinc-200 md:grid-cols-3 md:divide-x md:divide-zinc-200">
324
+ {articles.map((article, index) => (
325
+ <Link
326
+ key={article.id}
327
+ to={`/articles/${String(article.slug)}`}
328
+ className="group cursor-pointer transition-colors hover:bg-zinc-50 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-orange-600"
329
+ >
330
+ {article.cover ? (
331
+ <Image value={article.cover} className={`${ARTICLE_COVER_CLASS} !object-cover`} loading="lazy" />
332
+ ) : (
333
+ <div className={ARTICLE_COVER_CLASS} aria-hidden />
334
+ )}
335
+ <div className="p-6 pt-5">
336
+ <p className="fimo-mono text-[11px] text-zinc-400">ARTICLES / 0{index + 1}</p>
337
+ <Text value={article.title} as="h3" className="mt-2 line-clamp-2 text-[15px] font-bold" />
338
+ <Text value={article.summary} as="p" className="mt-1.5 line-clamp-2 text-[13px] leading-5 text-zinc-600" />
339
+ <span className="mt-4 inline-block text-[13px] font-bold text-orange-600 group-hover:text-orange-700">
340
+ Read in the CMS →
341
+ </span>
342
+ </div>
343
+ </Link>
344
+ ))}
345
+ </section>
346
+ );
347
+ }
348
+
349
+ function WhatComesNext({ page }: { page: HomePage }) {
350
+ return (
351
+ <section className="grid border-b border-zinc-200 max-lg:divide-y max-lg:divide-zinc-200 lg:grid-cols-2 lg:divide-x lg:divide-zinc-200">
352
+ <div className="p-6 sm:p-8">
353
+ <p className="fimo-mono text-[11px] font-medium text-orange-600">WHAT COMES NEXT</p>
354
+ <Text value={page.migrationTitle} as="h3" className="mt-2 text-xl font-bold tracking-[-0.02em]" />
355
+ <RichText value={page.migrationBody} className="mt-1.5 max-w-md text-[13px] leading-5 text-zinc-600" />
356
+ <CopyPrompt value={page.migrationPrompt} />
357
+ </div>
358
+ <div className="p-6 sm:p-8">
359
+ <p className="fimo-mono text-[11px] font-medium text-zinc-400">OR START FRESH</p>
360
+ <Text value={page.buildTitle} as="h3" className="mt-2 text-xl font-bold tracking-[-0.02em]" />
361
+ <RichText value={page.buildBody} className="mt-1.5 max-w-md text-[13px] leading-5 text-zinc-600" />
362
+ </div>
363
+ </section>
364
+ );
365
+ }
366
+
367
+ function CopyPrompt({ value }: { value: HomePage['migrationPrompt'] }) {
368
+ const [copied, setCopied] = useState(false);
369
+
370
+ const copy = async () => {
371
+ await navigator.clipboard.writeText(String(value));
372
+ setCopied(true);
373
+ window.setTimeout(() => setCopied(false), 1800);
374
+ };
375
+
376
+ return (
377
+ <button
378
+ type="button"
379
+ onClick={copy}
380
+ className="mt-4 inline-flex cursor-pointer items-center gap-2 rounded-md bg-orange-600 px-4 py-2 text-[13px] font-semibold text-white transition-colors hover:bg-orange-700"
381
+ >
382
+ {copied ? <Check className="size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
383
+ {copied ? 'Copied' : 'Copy migration prompt'}
384
+ </button>
385
+ );
386
+ }
387
+
388
+ function readCompletedActions(): string[] {
389
+ if (typeof window === 'undefined') {
390
+ return [];
391
+ }
392
+
393
+ try {
394
+ const value = JSON.parse(window.sessionStorage.getItem(COMPLETED_ACTIONS_KEY) ?? '[]');
395
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
396
+ } catch {
397
+ return [];
398
+ }
399
+ }
400
+
401
+ function postShortcut(event: string) {
402
+ window.parent.postMessage({ type: 'shortcut', payload: { event } }, '*');
403
+ }
404
+
405
+ export function ContentFallback() {
406
+ return (
407
+ <div className="bg-white px-3 py-3 sm:px-6 sm:py-8">
408
+ <div className="mx-auto min-h-screen max-w-2xl px-6 py-16 sm:px-12">
409
+ <p className="text-center text-zinc-600">Tutorial content is not available yet.</p>
410
+ </div>
411
+ </div>
412
+ );
413
+ }