fimo 2.2.0-staging.9 → 2.2.0

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 (94) hide show
  1. package/assets/agent-templates/_default/config.yaml +6 -0
  2. package/assets/agent-templates/broken-link-sweeper/config.yaml +1 -0
  3. package/assets/content-templates/shared/types.eta +65 -6
  4. package/assets/skills/fimo-cli/SKILL.md +5 -3
  5. package/assets/skills/fimo-cli/references/agents.md +28 -3
  6. package/assets/skills/fimo-cli/references/assets.md +38 -2
  7. package/assets/skills/fimo-cli/references/branches.md +9 -8
  8. package/assets/skills/fimo-cli/references/content.md +1 -1
  9. package/assets/skills/fimo-cli/references/credits.md +12 -19
  10. package/assets/skills/fimo-cli/references/deploy.md +6 -6
  11. package/assets/skills/fimo-cli/references/history.md +2 -2
  12. package/assets/skills/fimo-cli/references/inbox.md +86 -0
  13. package/assets/skills/fimo-cli/references/init.md +7 -6
  14. package/assets/skills/fimo-cli/references/invite.md +11 -9
  15. package/assets/skills/fimo-cli/references/labels.md +3 -11
  16. package/assets/skills/fimo-cli/references/locales.md +5 -3
  17. package/assets/skills/fimo-cli/references/preview.md +1 -2
  18. package/assets/skills/fimo-cli/references/projects.md +8 -8
  19. package/assets/skills/fimo-cli/references/schedules.md +2 -2
  20. package/assets/skills/fimo-cli/references/webhooks.md +4 -4
  21. package/assets/skills/fimo-code/references/assets.md +22 -11
  22. package/assets/skills/fimo-code/references/content/clients.md +15 -1
  23. package/assets/skills/fimo-code/references/content/schema.md +1 -0
  24. package/assets/skills/fimo-code/references/frameworks/astro.md +1 -1
  25. package/assets/skills/fimo-code/references/frameworks/next/app-router.md +12 -1
  26. package/assets/skills/fimo-code/references/frameworks/next/locales.md +2 -2
  27. package/assets/skills/fimo-code/references/frameworks/next.md +1 -1
  28. package/assets/skills/fimo-code/references/locales.md +4 -2
  29. package/assets/skills/fimo-migration/SKILL.md +54 -16
  30. package/assets/skills/fimo-migration/references/frameworks/nextjs.md +8 -0
  31. package/assets/skills/fimo-migration/references/media.md +11 -0
  32. package/assets/skills/fimo-migration/references/qa.md +30 -4
  33. package/assets/skills/fimo-migration/references/questions-and-improvements.md +188 -0
  34. package/assets/skills/fimo-migration/references/workers/content-planner.md +8 -3
  35. package/assets/skills/fimo-migration/references/workers/validator.md +20 -6
  36. package/assets/skills/fimo-migration/references/workers.md +17 -6
  37. package/assets/skills/fimo-migration/references/workflow.md +36 -6
  38. package/assets/skills/fimo-studio/SKILL.md +3 -1
  39. package/assets/skills/fimo-studio/references/agents.md +25 -0
  40. package/assets/skills/fimo-studio/references/ai-chat.md +1 -2
  41. package/assets/skills/fimo-studio/references/billing-and-credits.md +14 -7
  42. package/assets/skills/fimo-studio/references/webhooks.md +1 -1
  43. package/dist/cli/bundle.json +2 -2
  44. package/dist/cli/index.js +207 -176
  45. package/dist/runtime/content/serialization.d.ts +4 -3
  46. package/dist/runtime/content/serialization.js +4 -3
  47. package/dist/runtime/primitives/lib/primitives.d.ts +15 -7
  48. package/dist/runtime/primitives/lib/primitives.js +14 -1
  49. package/dist/runtime/shared/local-runtime-env.d.ts +2 -2
  50. package/dist/runtime/shared/local-runtime-env.js +14 -9
  51. package/dist/runtime/templates.d.ts +9 -2
  52. package/dist/runtime/templates.js +36 -7
  53. package/dist/scripts/validate-schemas.js +10 -15
  54. package/package.json +10 -8
  55. package/release.json +2 -2
  56. package/templates/astro/AGENTS.md +1 -1
  57. package/templates/astro/package.json +2 -2
  58. package/templates/nextjs/AGENTS.md +2 -2
  59. package/templates/nextjs/package.json +3 -3
  60. package/templates/nextjs/src/app/layout.tsx +4 -1
  61. package/templates/react-router/AGENTS.md +2 -2
  62. package/templates/react-router/package.json +4 -4
  63. package/templates/sveltekit/AGENTS.md +1 -1
  64. package/templates/sveltekit/package.json +2 -2
  65. package/templates/vite/AGENTS.md +5 -1
  66. package/templates/vite/package.json +4 -4
  67. package/templates/welcome/manifest.json +655 -0
  68. package/templates/welcome/media/analytics-overview.avif +0 -0
  69. package/templates/welcome/media/autonomous-loop.avif +0 -0
  70. package/templates/welcome/media/editable-preview.avif +0 -0
  71. package/templates/welcome/media/media-library.avif +0 -0
  72. package/templates/welcome/media/slack-teams.avif +0 -0
  73. package/templates/welcome/template/.fimo/agents/welcome-guide/GOAL.md +12 -0
  74. package/templates/welcome/template/.fimo/agents/welcome-guide/capabilities.yaml +3 -0
  75. package/templates/welcome/template/.fimo/agents/welcome-guide/config.yaml +8 -0
  76. package/templates/welcome/template/.fimo/config.json +53 -0
  77. package/templates/welcome/template/src/font.css +60 -0
  78. package/templates/welcome/template/src/pages/ArticleDetail.tsx +104 -0
  79. package/templates/welcome/template/src/pages/Index.tsx +36 -0
  80. package/templates/welcome/template/src/pages/SiteContent.tsx +674 -0
  81. package/templates/welcome/template/src/pages/fallback-content.ts +152 -0
  82. package/templates/welcome/template/src/routes.ts +9 -0
  83. package/templates/welcome/template/src/schemas/Article.json +34 -0
  84. package/templates/welcome/template/src/schemas/Article.ts +54 -0
  85. package/templates/welcome/template/src/schemas/Article.types.ts +93 -0
  86. package/templates/welcome/template/src/schemas/Author.json +17 -0
  87. package/templates/welcome/template/src/schemas/Author.ts +51 -0
  88. package/templates/welcome/template/src/schemas/Author.types.ts +54 -0
  89. package/templates/welcome/template/src/schemas/Category.json +17 -0
  90. package/templates/welcome/template/src/schemas/Category.ts +51 -0
  91. package/templates/welcome/template/src/schemas/Category.types.ts +54 -0
  92. package/templates/welcome/template/src/schemas/HomePage.json +110 -0
  93. package/templates/welcome/template/src/schemas/HomePage.ts +22 -0
  94. package/templates/welcome/template/src/schemas/HomePage.types.ts +116 -0
@@ -0,0 +1,674 @@
1
+ import { Image, RichText, Text } from 'fimo-react';
2
+ import { Bot, Check, Copy, Database, GitBranch, MousePointerClick, X } from 'lucide-react';
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import { Link } from 'react-router';
5
+
6
+ import type { ArticleResult } from '@/schemas/Article';
7
+ import type { HomePage } from '@/schemas/HomePage';
8
+
9
+ import { FALLBACK_PAGE } from './fallback-content';
10
+
11
+ /**
12
+ * Every article read in this site resolves its two references, so the cards
13
+ * and the detail page can name the author and the category. `populate` is
14
+ * depth-one: without it both fields come back as bare `{ id }`.
15
+ */
16
+ export const ARTICLE_POPULATE = ['author', 'category'] as const;
17
+ export type TutorialArticle = ArticleResult<undefined, typeof ARTICLE_POPULATE>;
18
+
19
+ interface TutorialPageProps {
20
+ page: HomePage;
21
+ articles: TutorialArticle[];
22
+ }
23
+
24
+ const COMPLETED_ACTIONS_KEY = 'fimo-welcome-completed-actions-v2';
25
+ const DISMISSED_KEY = 'fimo-welcome-dismissed';
26
+ const ARTICLE_COVER_CLASS = 'aspect-[48/25] w-full border-b border-zinc-200 bg-white';
27
+ const OUTLINE_BUTTON_CLASS =
28
+ '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';
29
+
30
+ export function TutorialPage({ page, articles }: TutorialPageProps) {
31
+ useStudioScrollRelay();
32
+
33
+ const [completedActions, setCompletedActions] = useState<string[]>(readCompletedActions);
34
+ const allDone = page.steps.length > 0 && page.steps.every((step) => completedActions.includes(String(step.action)));
35
+ const [dismissed, dismiss] = useOnboardingDismissed();
36
+ const showNudge = allDone && !dismissed;
37
+
38
+ return (
39
+ <main className="min-h-screen bg-white px-4 py-10 text-zinc-950 sm:px-6">
40
+ <img src="/fimo-logo-black.svg" alt="Fimo" className="mx-auto mb-8 h-7 w-auto" />
41
+ <article className="relative mx-auto max-w-6xl border-x border-t border-zinc-200">
42
+ <Hero page={page} />
43
+ <Tutorial page={page} completedActions={completedActions} setCompletedActions={setCompletedActions} />
44
+ <DeveloperCallout />
45
+ <Articles articles={articles} showNudge={showNudge} onDismiss={dismiss} />
46
+ <div className="relative h-14 border-b border-zinc-200">
47
+ <GridDot edgeX="left" edgeY="top" />
48
+ <GridDot x="33.333%" edgeY="top" />
49
+ <GridDot x="66.666%" edgeY="top" />
50
+ <GridDot edgeX="right" edgeY="top" />
51
+ <GridDot edgeX="left" edgeY="bottom" />
52
+ <GridDot x="50%" edgeY="bottom" />
53
+ <GridDot edgeX="right" edgeY="bottom" />
54
+ </div>
55
+ <WhatComesNext page={page} showNudge={showNudge} onDismiss={dismiss} />
56
+ <div className="relative">
57
+ <GridDot edgeX="left" edgeY="top" />
58
+ <GridDot x="50%" edgeY="top" />
59
+ <GridDot edgeX="right" edgeY="top" />
60
+ </div>
61
+ <footer className="relative border-b border-zinc-200 px-8 py-6 text-center">
62
+ <Text value={page.footerNote} className="text-[12px] leading-5 text-zinc-500" />
63
+ </footer>
64
+ </article>
65
+ </main>
66
+ );
67
+ }
68
+
69
+ // The one tinted band in the grid: a quiet orange wash that reads as an
70
+ // aside for developers without breaking the page's white sobriety.
71
+ function DeveloperCallout() {
72
+ return (
73
+ <div className="flex items-center gap-3 border-b border-zinc-200 bg-orange-50/80 px-6 py-7">
74
+ <span className="fimo-mono text-[12px] text-orange-600">{'</>'}</span>
75
+ <span className="text-[14px] font-bold text-zinc-950">
76
+ You are a developer?{' '}
77
+ <a
78
+ href="https://fimo.ai/docs"
79
+ target="_blank"
80
+ rel="noreferrer"
81
+ className="cursor-pointer font-medium text-orange-600 underline underline-offset-4 transition-colors hover:text-orange-700"
82
+ >
83
+ Use our CLI & start from the terminal here.
84
+ </a>
85
+ </span>
86
+ </div>
87
+ );
88
+ }
89
+
90
+ // Fimo grid intersection marker, light theme. The edge clip mirrors the
91
+ // website's GridDot: it stops the halo from spilling outside the column.
92
+ function GridDot({ x, edgeX, edgeY }: { x?: string; edgeX?: 'left' | 'right'; edgeY: 'top' | 'bottom' }) {
93
+ const style: React.CSSProperties = {};
94
+ if (x) {
95
+ style.left = x;
96
+ style.marginLeft = -2;
97
+ }
98
+ if (edgeX === 'left') {
99
+ style.left = 0;
100
+ style.marginLeft = -2;
101
+ style.clipPath = 'inset(-6px -6px -6px 0)';
102
+ }
103
+ if (edgeX === 'right') {
104
+ style.right = 0;
105
+ style.marginRight = -1;
106
+ style.clipPath = 'inset(-6px 0 -6px -6px)';
107
+ }
108
+ if (edgeY === 'top') {
109
+ style.top = -2;
110
+ } else {
111
+ style.bottom = -2;
112
+ }
113
+
114
+ return (
115
+ <span
116
+ aria-hidden
117
+ className="absolute z-10 size-[3px] rounded-full bg-zinc-200 shadow-[0_0_0_5px_#ffffff]"
118
+ style={style}
119
+ />
120
+ );
121
+ }
122
+
123
+ /**
124
+ * Closing a hint closes that hint, here and now. Whether it also ends the
125
+ * tour is the Studio's call — it counts closes across both surfaces and asks
126
+ * before stopping — so the close is reported upwards and the page adopts
127
+ * whatever comes back, which is also how the decision reaches the other
128
+ * preview origins (each has its own storage).
129
+ */
130
+ function useOnboardingDismissed(): [boolean, () => void] {
131
+ const [dismissed, setDismissed] = useState(() => window.sessionStorage.getItem(DISMISSED_KEY) === '1');
132
+
133
+ useEffect(() => {
134
+ const onMessage = (event: MessageEvent) => {
135
+ if (event.data?.type !== 'fimo:welcome:steps-sync' || event.data.dismissed !== true) {
136
+ return;
137
+ }
138
+ window.sessionStorage.setItem(DISMISSED_KEY, '1');
139
+ setDismissed(true);
140
+ };
141
+ window.addEventListener('message', onMessage);
142
+ return () => window.removeEventListener('message', onMessage);
143
+ }, []);
144
+
145
+ const dismiss = useCallback(() => {
146
+ // Local, so the bubble goes away on the click that asked for it; the
147
+ // Studio decides whether the tour itself is over.
148
+ setDismissed(true);
149
+ window.parent.postMessage({ type: 'fimo:welcome:dismiss' }, '*');
150
+ }, []);
151
+
152
+ return [dismissed, dismiss];
153
+ }
154
+
155
+ // The finished tour hands over to the rest of the page with the same orange
156
+ // voice the Studio bubbles use.
157
+ function SectionNudge({ text, onDismiss }: { text: string; onDismiss: () => void }) {
158
+ return (
159
+ <span className="absolute -top-5 left-8 z-10 inline-flex max-w-72 rounded-lg bg-orange-600 py-3 pr-10 pl-4 text-sm leading-5 font-semibold text-white shadow-xl shadow-orange-600/30">
160
+ {text}
161
+ <button
162
+ type="button"
163
+ aria-label="Dismiss the onboarding"
164
+ onClick={onDismiss}
165
+ className="absolute top-2 right-2 cursor-pointer rounded p-0.5 text-white/70 transition-colors hover:bg-white/15 hover:text-white"
166
+ >
167
+ <X className="size-3.5" aria-hidden />
168
+ </button>
169
+ <span className="absolute top-full left-8 border-[6px] border-transparent border-t-orange-600" aria-hidden />
170
+ </span>
171
+ );
172
+ }
173
+
174
+ // The Studio paints a static snapshot of this page first and swaps in the
175
+ // dev server mid-read. Both documents are cross-origin to the Studio, so
176
+ // the page itself reports its scroll position up, and restores the one the
177
+ // Studio hands back after the swap (retrying briefly: late images can
178
+ // still be growing the page when the position arrives).
179
+ export function useStudioScrollRelay() {
180
+ useEffect(() => {
181
+ if (window.parent === window) {
182
+ return;
183
+ }
184
+
185
+ let reportFrame = 0;
186
+ const report = () => {
187
+ cancelAnimationFrame(reportFrame);
188
+ reportFrame = requestAnimationFrame(() => {
189
+ window.parent.postMessage({ type: 'fimo:welcome:scroll', y: window.scrollY }, '*');
190
+ });
191
+ };
192
+
193
+ let restoreTimer = 0;
194
+ const cancelRestore = () => window.clearInterval(restoreTimer);
195
+ const onMessage = (event: MessageEvent) => {
196
+ if (event.data?.type !== 'fimo:welcome:restore-scroll' || typeof event.data.y !== 'number') {
197
+ return;
198
+ }
199
+
200
+ const y = event.data.y;
201
+ const deadline = Date.now() + 2000;
202
+ cancelRestore();
203
+ const attempt = () => {
204
+ window.scrollTo(0, y);
205
+ if (Math.abs(window.scrollY - y) < 2 || Date.now() > deadline) {
206
+ cancelRestore();
207
+ }
208
+ };
209
+ attempt();
210
+ restoreTimer = window.setInterval(attempt, 100);
211
+ };
212
+
213
+ window.addEventListener('scroll', report, { passive: true });
214
+ window.addEventListener('wheel', cancelRestore, { passive: true });
215
+ window.addEventListener('touchmove', cancelRestore, { passive: true });
216
+ window.addEventListener('message', onMessage);
217
+ return () => {
218
+ cancelAnimationFrame(reportFrame);
219
+ cancelRestore();
220
+ window.removeEventListener('scroll', report);
221
+ window.removeEventListener('wheel', cancelRestore);
222
+ window.removeEventListener('touchmove', cancelRestore);
223
+ window.removeEventListener('message', onMessage);
224
+ };
225
+ }, []);
226
+ }
227
+
228
+ function Hero({ page }: { page: HomePage }) {
229
+ return (
230
+ <section className="relative border-b border-zinc-200 px-6 py-10 text-center">
231
+ <GridDot edgeX="left" edgeY="top" />
232
+ <GridDot edgeX="right" edgeY="top" />
233
+ <Text value={page.title} as="h1" className="text-[32px] font-extrabold tracking-[-0.04em] sm:text-4xl" />
234
+ <RichText value={page.intro} className="mx-auto mt-2 max-w-xl text-[15px] leading-6 text-zinc-600" />
235
+ <GridDot edgeX="left" edgeY="bottom" />
236
+ <GridDot x="25%" edgeY="bottom" />
237
+ <GridDot x="50%" edgeY="bottom" />
238
+ <GridDot x="75%" edgeY="bottom" />
239
+ <GridDot edgeX="right" edgeY="bottom" />
240
+ </section>
241
+ );
242
+ }
243
+
244
+ function Tutorial({
245
+ page,
246
+ completedActions,
247
+ setCompletedActions,
248
+ }: {
249
+ page: HomePage;
250
+ completedActions: string[];
251
+ setCompletedActions: React.Dispatch<React.SetStateAction<string[]>>;
252
+ }) {
253
+ const currentStepIndex = page.steps.findIndex((step) => !completedActions.includes(String(step.action)));
254
+
255
+ const completeAction = useCallback(
256
+ (action: string) => {
257
+ // Persisted and announced synchronously: a step CTA can navigate the
258
+ // Studio away and tear this document down before React flushes, and a
259
+ // completion living only in a pending state update dies with it.
260
+ const current = readCompletedActions();
261
+ if (!current.includes(action)) {
262
+ const next = [...current, action];
263
+ window.sessionStorage.setItem(COMPLETED_ACTIONS_KEY, JSON.stringify(next));
264
+ window.parent.postMessage({ type: 'fimo:welcome:steps', steps: next }, '*');
265
+ }
266
+ setCompletedActions((state) => (state.includes(action) ? state : [...state, action]));
267
+ },
268
+ [setCompletedActions]
269
+ );
270
+
271
+ const [editActive, setEditActive] = useState(false);
272
+
273
+ useEffect(() => {
274
+ const onMessage = (event: MessageEvent) => {
275
+ if (event.data?.type === 'mode/change') {
276
+ setEditActive(event.data?.payload?.mode === 'edit');
277
+ return;
278
+ }
279
+ // The Studio owns step completion per project: every preview origin
280
+ // (snapshot, main dev, each branch dev) has isolated storage, so the
281
+ // local record alone would restart the tour on each of them.
282
+ if (event.data?.type === 'fimo:welcome:steps-sync' && Array.isArray(event.data.steps)) {
283
+ const incoming = event.data.steps.filter((step: unknown): step is string => typeof step === 'string');
284
+ setCompletedActions((current) => {
285
+ const merged = [...new Set([...current, ...incoming])];
286
+ if (merged.length === current.length) {
287
+ return current;
288
+ }
289
+ window.sessionStorage.setItem(COMPLETED_ACTIONS_KEY, JSON.stringify(merged));
290
+ return merged;
291
+ });
292
+ }
293
+ };
294
+
295
+ window.addEventListener('message', onMessage);
296
+ return () => window.removeEventListener('message', onMessage);
297
+ }, []);
298
+
299
+ // Announces on load (the Studio answers with the merged set) and reports
300
+ // every completion so other preview documents inherit it.
301
+ useEffect(() => {
302
+ window.parent.postMessage({ type: 'fimo:welcome:steps', steps: completedActions }, '*');
303
+ }, [completedActions]);
304
+
305
+ // Step 1 is the edit, not the doorway: activating Edit mode proves
306
+ // nothing, so the step only completes once the title differs from the
307
+ // seed copy — whichever surface the change came from.
308
+ const titleChanged = String(page.title) !== String(FALLBACK_PAGE.title);
309
+ useEffect(() => {
310
+ if (titleChanged) {
311
+ completeAction('toggleEditMode');
312
+ }
313
+ }, [titleChanged, completeAction]);
314
+
315
+ // Step 4 is the accepted proposal, not the visit: once the welcome
316
+ // guide's footer change has been merged into this env, the footer no
317
+ // longer matches the seed copy.
318
+ const footerChanged = String(page.footerNote) !== String(FALLBACK_PAGE.footerNote);
319
+ useEffect(() => {
320
+ if (footerChanged) {
321
+ completeAction('openAgents');
322
+ }
323
+ }, [footerChanged, completeAction]);
324
+
325
+ return (
326
+ <section
327
+ id="tour"
328
+ 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"
329
+ >
330
+ {page.steps.map((step, index) => {
331
+ const action = String(step.action);
332
+ const completed = completedActions.includes(action);
333
+ const current = !completed && index === currentStepIndex;
334
+
335
+ return (
336
+ <div key={step._key} className="step-cell flex flex-col p-6">
337
+ <div className="flex items-center justify-between">
338
+ <span className="flex items-center gap-2.5">
339
+ <span
340
+ aria-label={completed ? `Step ${index + 1} completed` : `Step ${index + 1}`}
341
+ className={`flex size-9 items-center justify-center rounded-full text-[16px] font-extrabold transition-colors duration-300 ${
342
+ completed
343
+ ? 'bg-emerald-500 text-white'
344
+ : current
345
+ ? 'bg-orange-600 text-white'
346
+ : 'border-2 border-zinc-200 text-zinc-400'
347
+ }`}
348
+ >
349
+ {completed ? <Check className="size-5" aria-hidden /> : <Text value={step.number} />}
350
+ </span>
351
+ {current && index === 0 ? (
352
+ <span className="fimo-mono text-[10px] font-medium text-orange-600">START HERE</span>
353
+ ) : null}
354
+ </span>
355
+ <StepIcon action={action} />
356
+ </div>
357
+ <Text value={step.title} as="h2" className="mt-4 text-[15px] font-bold" />
358
+ <Text value={step.body} as="div" className="mt-1 flex-1 text-[13px] leading-5 text-zinc-600">
359
+ {(value) => <InstructionText value={value} />}
360
+ </Text>
361
+ <TutorialAction
362
+ action={action}
363
+ label={step.actionLabel}
364
+ editActive={editActive}
365
+ primary={current}
366
+ onClick={() => {
367
+ // Steps 1 and 4 complete on their real outcome (the title
368
+ // edit, the accepted merge — the Studio reports both), not
369
+ // on opening the door.
370
+ if (action !== 'toggleEditMode' && action !== 'openAgents') {
371
+ completeAction(action);
372
+ }
373
+ postShortcut(action);
374
+ }}
375
+ />
376
+ </div>
377
+ );
378
+ })}
379
+ </section>
380
+ );
381
+ }
382
+
383
+ function StepIcon({ action }: { action: string }) {
384
+ const ActionIcon =
385
+ action === 'toggleEditMode'
386
+ ? MousePointerClick
387
+ : action === 'openHomePage'
388
+ ? Database
389
+ : action === 'openBranches'
390
+ ? GitBranch
391
+ : Bot;
392
+
393
+ return <ActionIcon className="step-icon size-[17px] text-zinc-950" strokeWidth={1.8} aria-hidden />;
394
+ }
395
+
396
+ function InstructionText({ value }: { value: string }) {
397
+ const lines = value
398
+ .split('\n')
399
+ .map((line) => line.trim())
400
+ .filter(Boolean);
401
+ const instructions = lines.filter((line) => /^-\s/.test(line));
402
+ const explanation = lines.filter((line) => !/^-\s/.test(line)).join(' ');
403
+
404
+ return (
405
+ <>
406
+ {explanation ? <p className="font-medium">{formatInstruction(explanation)}</p> : null}
407
+ {instructions.length > 0 ? (
408
+ <div className={explanation ? 'mt-2.5' : ''}>
409
+ <span className="text-[11px] font-extrabold tracking-[0.05em] text-black uppercase">Action</span>
410
+ <ul className="mt-1 space-y-1.5">
411
+ {instructions.map((instruction) => (
412
+ <li key={instruction} className="font-semibold text-zinc-900">
413
+ {formatInstruction(instruction.replace(/^-\s*/, ''))}
414
+ </li>
415
+ ))}
416
+ </ul>
417
+ </div>
418
+ ) : null}
419
+ </>
420
+ );
421
+ }
422
+
423
+ function formatInstruction(value: string) {
424
+ return value.split(/(“[^”]+”)/g).map((part, index) => {
425
+ if (part.startsWith('“') && part.endsWith('”')) {
426
+ return (
427
+ <mark
428
+ key={`${part}-${index}`}
429
+ className="rounded border border-zinc-200 bg-zinc-100 px-1 py-px font-semibold text-zinc-900"
430
+ >
431
+ {part.slice(1, -1)}
432
+ </mark>
433
+ );
434
+ }
435
+
436
+ return part;
437
+ });
438
+ }
439
+
440
+ // The Studio paints the static snapshot build first and swaps in the dev
441
+ // server a few seconds later; Edit mode only works after that swap. The
442
+ // snapshot is the one context that is a production build inside an iframe,
443
+ // which is how the page knows to explain the wait on hover.
444
+ const isStaticStudioSnapshot = () => !import.meta.env.DEV && typeof window !== 'undefined' && window.parent !== window;
445
+
446
+ function TutorialAction({
447
+ action,
448
+ label,
449
+ editActive,
450
+ primary,
451
+ onClick,
452
+ }: {
453
+ action: string;
454
+ label: HomePage['steps'][number]['actionLabel'];
455
+ editActive: boolean;
456
+ primary: boolean;
457
+ onClick: () => void;
458
+ }) {
459
+ // While Edit mode is active, preview.js captures page clicks for element
460
+ // selection, so an in-page button cannot receive them. The Studio shows
461
+ // the exit guidance anchored on its own Edit mode toggle instead.
462
+ if (action === 'toggleEditMode' && editActive) {
463
+ return null;
464
+ }
465
+ if (action === 'toggleEditMode' && isStaticStudioSnapshot()) {
466
+ // The snapshot is replaced by the dev document the moment the dev
467
+ // server can serve Edit mode, so this loading state flips to the real
468
+ // button exactly when the feature becomes available.
469
+ return (
470
+ <span className="group relative mt-4 inline-flex w-full">
471
+ <span
472
+ role="tooltip"
473
+ 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"
474
+ >
475
+ Edit mode is not available yet. It activates in a few seconds.
476
+ </span>
477
+ <span
478
+ aria-busy="true"
479
+ className="flex w-full cursor-default items-center justify-center rounded-md bg-orange-600/70 py-2 text-[13px] font-semibold text-white"
480
+ >
481
+ Loading edit mode, wait
482
+ <span className="fimo-dots ml-0.5" aria-hidden>
483
+ <span>.</span>
484
+ <span>.</span>
485
+ <span>.</span>
486
+ </span>
487
+ </span>
488
+ </span>
489
+ );
490
+ }
491
+
492
+ return <ActionButton label={label} primary={primary} onClick={onClick} shortcut={action} />;
493
+ }
494
+
495
+ function ActionButton({
496
+ label,
497
+ primary,
498
+ onClick,
499
+ className = '',
500
+ shortcut,
501
+ }: {
502
+ label: HomePage['steps'][number]['actionLabel'];
503
+ primary: boolean;
504
+ onClick: () => void;
505
+ className?: string;
506
+ /** Names the Studio shortcut this button fires, so the generated static
507
+ * snapshot can rewire it without matching on button copy. */
508
+ shortcut?: string;
509
+ }) {
510
+ return (
511
+ <button
512
+ type="button"
513
+ data-shortcut={shortcut}
514
+ onClick={onClick}
515
+ className={`${
516
+ primary
517
+ ? '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'
518
+ : OUTLINE_BUTTON_CLASS
519
+ } ${className}`}
520
+ >
521
+ <Text value={label} />
522
+ </button>
523
+ );
524
+ }
525
+
526
+ /**
527
+ * The byline shared by the article cards and the article page. Both fields
528
+ * come from the linked Author entry, so editing that one entry renames the
529
+ * author everywhere it appears — the point of a reference.
530
+ */
531
+ export function AuthorLine({
532
+ author,
533
+ className = '',
534
+ }: {
535
+ author: NonNullable<TutorialArticle['author']>;
536
+ className?: string;
537
+ }) {
538
+ return (
539
+ <p className={`text-[12px] leading-5 text-zinc-500 ${className}`}>
540
+ By <Text value={author.name} as="span" className="font-semibold text-zinc-700" />
541
+ {/* `FimoString` is a String object, so a cleared role is truthy — the
542
+ raw value is what decides whether the separator earns its place. */}
543
+ {String(author.role) ? (
544
+ <>
545
+ {' · '}
546
+ <Text value={author.role} as="span" />
547
+ </>
548
+ ) : null}
549
+ </p>
550
+ );
551
+ }
552
+
553
+ function Articles({
554
+ articles,
555
+ showNudge,
556
+ onDismiss,
557
+ }: {
558
+ articles: TutorialArticle[];
559
+ showNudge: boolean;
560
+ onDismiss: () => void;
561
+ }) {
562
+ return (
563
+ <section className="relative 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">
564
+ {showNudge && (
565
+ <SectionNudge
566
+ text="Tour done! Each concept has a short guide here — worth five more minutes."
567
+ onDismiss={onDismiss}
568
+ />
569
+ )}
570
+ {articles.map((article, index) => (
571
+ <Link
572
+ key={article.id}
573
+ to={`/articles/${String(article.slug)}`}
574
+ className="group cursor-pointer transition-colors hover:bg-zinc-50 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-orange-600"
575
+ >
576
+ {article.cover ? (
577
+ <Image value={article.cover} className={`${ARTICLE_COVER_CLASS} !object-cover`} loading="lazy" />
578
+ ) : (
579
+ <div className={ARTICLE_COVER_CLASS} aria-hidden />
580
+ )}
581
+ <div className="p-6 pt-5">
582
+ <p className="fimo-mono text-[11px] text-zinc-400">
583
+ ARTICLES / 0{index + 1}
584
+ {article.category ? (
585
+ <>
586
+ {' / '}
587
+ <Text value={article.category.name} as="span" className="text-orange-600 uppercase" />
588
+ </>
589
+ ) : null}
590
+ </p>
591
+ <Text value={article.title} as="h3" className="mt-2 line-clamp-2 text-[15px] font-bold" />
592
+ <Text value={article.summary} as="p" className="mt-1.5 line-clamp-2 text-[13px] leading-5 text-zinc-600" />
593
+ {article.author ? <AuthorLine author={article.author} className="mt-3" /> : null}
594
+ <span className="mt-4 inline-block text-[13px] font-bold text-orange-600 group-hover:text-orange-700">
595
+ Read in the CMS →
596
+ </span>
597
+ </div>
598
+ </Link>
599
+ ))}
600
+ </section>
601
+ );
602
+ }
603
+
604
+ function WhatComesNext({ page, showNudge, onDismiss }: { page: HomePage; showNudge: boolean; onDismiss: () => void }) {
605
+ return (
606
+ <section className="relative 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">
607
+ {showNudge && (
608
+ <SectionNudge
609
+ text="You are ready. Migrate or create your own website, and enjoy Fimo on a real project!"
610
+ onDismiss={onDismiss}
611
+ />
612
+ )}
613
+ <div className="p-6 sm:p-8">
614
+ <p className="fimo-mono text-[11px] font-medium text-orange-600">WHAT COMES NEXT</p>
615
+ <Text value={page.migrationTitle} as="h3" className="mt-2 text-xl font-bold tracking-[-0.02em]" />
616
+ <RichText value={page.migrationBody} className="mt-1.5 max-w-md text-[13px] leading-5 text-zinc-600" />
617
+ <CopyPrompt value={page.migrationPrompt} />
618
+ </div>
619
+ <div className="p-6 sm:p-8">
620
+ <p className="fimo-mono text-[11px] font-medium text-zinc-400">OR START FRESH</p>
621
+ <Text value={page.buildTitle} as="h3" className="mt-2 text-xl font-bold tracking-[-0.02em]" />
622
+ <RichText value={page.buildBody} className="mt-1.5 max-w-md text-[13px] leading-5 text-zinc-600" />
623
+ </div>
624
+ </section>
625
+ );
626
+ }
627
+
628
+ function CopyPrompt({ value }: { value: HomePage['migrationPrompt'] }) {
629
+ const [copied, setCopied] = useState(false);
630
+
631
+ const copy = async () => {
632
+ await navigator.clipboard.writeText(String(value));
633
+ setCopied(true);
634
+ window.setTimeout(() => setCopied(false), 1800);
635
+ };
636
+
637
+ return (
638
+ <button
639
+ type="button"
640
+ onClick={copy}
641
+ 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"
642
+ >
643
+ {copied ? <Check className="size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
644
+ {copied ? 'Copied' : 'Copy migration prompt'}
645
+ </button>
646
+ );
647
+ }
648
+
649
+ function readCompletedActions(): string[] {
650
+ if (typeof window === 'undefined') {
651
+ return [];
652
+ }
653
+
654
+ try {
655
+ const value = JSON.parse(window.sessionStorage.getItem(COMPLETED_ACTIONS_KEY) ?? '[]');
656
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
657
+ } catch {
658
+ return [];
659
+ }
660
+ }
661
+
662
+ function postShortcut(event: string) {
663
+ window.parent.postMessage({ type: 'shortcut', payload: { event } }, '*');
664
+ }
665
+
666
+ export function ContentFallback() {
667
+ return (
668
+ <div className="bg-white px-3 py-3 sm:px-6 sm:py-8">
669
+ <div className="mx-auto min-h-screen max-w-2xl px-6 py-16 sm:px-12">
670
+ <p className="text-center text-zinc-600">Tutorial content is not available yet.</p>
671
+ </div>
672
+ </div>
673
+ );
674
+ }