fimo 2.2.0-staging.16 → 2.2.0-staging.18

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 (30) hide show
  1. package/assets/agent-templates/_default/config.yaml +6 -0
  2. package/assets/skills/fimo-cli/references/agents.md +3 -2
  3. package/dist/cli/bundle.json +1 -1
  4. package/dist/cli/index.js +1 -1
  5. package/package.json +8 -8
  6. package/templates/astro/package.json +2 -2
  7. package/templates/nextjs/package.json +3 -3
  8. package/templates/react-router/package.json +4 -4
  9. package/templates/sveltekit/package.json +2 -2
  10. package/templates/vite/package.json +4 -4
  11. package/templates/welcome/manifest.json +306 -55
  12. package/templates/welcome/template/.fimo/agents/welcome-guide/GOAL.md +9 -4
  13. package/templates/welcome/template/.fimo/agents/welcome-guide/capabilities.yaml +3 -0
  14. package/templates/welcome/template/.fimo/agents/welcome-guide/config.yaml +4 -4
  15. package/templates/welcome/template/src/pages/ArticleDetail.tsx +42 -6
  16. package/templates/welcome/template/src/pages/Index.tsx +6 -2
  17. package/templates/welcome/template/src/pages/SiteContent.tsx +296 -35
  18. package/templates/welcome/template/src/pages/fallback-content.ts +34 -15
  19. package/templates/welcome/template/src/schemas/Article.json +27 -5
  20. package/templates/welcome/template/src/schemas/Article.ts +16 -21
  21. package/templates/welcome/template/src/schemas/Article.types.ts +40 -20
  22. package/templates/welcome/template/src/schemas/Author.json +17 -0
  23. package/templates/welcome/template/src/schemas/Author.ts +51 -0
  24. package/templates/welcome/template/src/schemas/Author.types.ts +54 -0
  25. package/templates/welcome/template/src/schemas/Category.json +17 -0
  26. package/templates/welcome/template/src/schemas/Category.ts +51 -0
  27. package/templates/welcome/template/src/schemas/Category.types.ts +54 -0
  28. package/templates/welcome/template/src/schemas/HomePage.json +94 -23
  29. package/templates/welcome/template/src/schemas/HomePage.ts +22 -0
  30. package/templates/welcome/template/src/schemas/HomePage.types.ts +2 -0
@@ -1,8 +1,8 @@
1
1
  version: 1
2
- description: 'Send one tiny onboarding pulse so you can see a scheduled agent run.'
2
+ description: 'Reviews the site copy and proposes one small improvement for you to approve.'
3
3
 
4
+ # Manual only. Pool preparation creates the tutorial's deterministic proposal
5
+ # without firing this trigger; keeping the trigger allows later real runs while
6
+ # avoiding a schedule that would keep spawning task environments.
4
7
  triggers:
5
8
  - type: manual
6
- - type: schedule
7
- cron: '*/2 * * * *'
8
- tz: 'UTC'
@@ -1,14 +1,45 @@
1
1
  import { Image, RichText, Text } from 'fimo-react';
2
2
  import { ArrowLeft } from 'lucide-react';
3
+ import { useEffect, useRef } from 'react';
3
4
  import { Link, useParams } from 'react-router';
4
5
 
5
6
  import ArticleContent from '@/schemas/Article';
6
7
 
7
8
  import '../font.css';
9
+ import { ARTICLE_POPULATE, AuthorLine, useStudioScrollRelay } from './SiteContent';
8
10
 
9
11
  const ArticleDetail = () => {
10
12
  const { slug = '' } = useParams();
11
- const { data: article, isLoading } = ArticleContent.useGetBySlug(slug);
13
+ const { data: article, isLoading } = ArticleContent.useGetBySlug(slug, { populate: ARTICLE_POPULATE });
14
+ useStudioScrollRelay();
15
+
16
+ const bodyRef = useRef<HTMLDivElement | null>(null);
17
+
18
+ // Prompts in articles double as one-click actions: a button under each
19
+ // code block hands its text to the Studio chat, which submits it for the
20
+ // user. Injected into the DOM because the rich-text renderer owns the
21
+ // markup; the guard keeps re-renders from stacking duplicates.
22
+ useEffect(() => {
23
+ const container = bodyRef.current;
24
+ if (!container) {
25
+ return;
26
+ }
27
+ for (const pre of Array.from(container.querySelectorAll('pre'))) {
28
+ if (pre.nextElementSibling?.classList.contains('fimo-prompt-cta')) {
29
+ continue;
30
+ }
31
+ const button = document.createElement('button');
32
+ button.type = 'button';
33
+ button.textContent = 'Ask Fimo to build this';
34
+ button.className =
35
+ 'fimo-prompt-cta mt-3 w-full cursor-pointer rounded-[8px] border border-orange-600 py-2.5 text-[15px] font-semibold text-orange-700 transition-colors hover:bg-orange-600 hover:text-white';
36
+ const text = pre.innerText;
37
+ button.addEventListener('click', () => {
38
+ window.parent.postMessage({ type: 'fimo:welcome:chat-prompt', text }, '*');
39
+ });
40
+ pre.insertAdjacentElement('afterend', button);
41
+ }
42
+ });
12
43
 
13
44
  if (!article) {
14
45
  return (
@@ -42,23 +73,28 @@ const ArticleDetail = () => {
42
73
  </Link>
43
74
  </header>
44
75
  <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>
76
+ <p className="text-xs font-semibold tracking-[0.18em] text-orange-700 uppercase">
77
+ {article.category ? <Text value={article.category.name} as="span" /> : 'Fimo tutorial'}
78
+ </p>
46
79
  <Text
47
80
  value={article.title}
48
81
  as="h1"
49
82
  className="mt-5 max-w-3xl text-4xl font-bold tracking-[-0.04em] sm:text-6xl"
50
83
  />
51
84
  <Text value={article.summary} as="p" className="mt-6 max-w-2xl text-lg leading-8 text-zinc-600" />
85
+ {article.author ? <AuthorLine author={article.author} className="mt-6 !text-[14px]" /> : null}
52
86
  <Image
53
87
  value={article.cover}
54
88
  className="mt-10 aspect-[48/25] w-full rounded-[8px] border border-zinc-950 object-cover"
55
89
  loading="eager"
56
90
  fetchPriority="high"
57
91
  />
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
- />
92
+ <div ref={bodyRef}>
93
+ <RichText
94
+ value={article.body}
95
+ 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"
96
+ />
97
+ </div>
62
98
  </div>
63
99
  </article>
64
100
  </main>
@@ -3,11 +3,15 @@ import HomePageContent from '@/schemas/HomePage';
3
3
 
4
4
  import '../font.css';
5
5
  import { FALLBACK_ARTICLES, FALLBACK_PAGE } from './fallback-content';
6
- import { ContentFallback, TutorialPage } from './SiteContent';
6
+ import { ARTICLE_POPULATE, ContentFallback, TutorialPage } from './SiteContent';
7
7
 
8
8
  const Index = () => {
9
9
  const { data: page, isLoading, isError } = HomePageContent.useGet();
10
- const { data: articles = [], isLoading: articlesLoading, isError: articlesError } = ArticleContent.useGet();
10
+ const {
11
+ data: articles = [],
12
+ isLoading: articlesLoading,
13
+ isError: articlesError,
14
+ } = ArticleContent.useGet({ populate: ARTICLE_POPULATE });
11
15
 
12
16
  // While loading, the page renders the seed copy through the same
13
17
  // components, so the live values reconcile in place with no visible
@@ -1,29 +1,48 @@
1
1
  import { Image, RichText, Text } from 'fimo-react';
2
- import { Bot, Check, Copy, Database, GitBranch, MousePointerClick } from 'lucide-react';
2
+ import { Bot, Check, Copy, Database, GitBranch, MousePointerClick, X } from 'lucide-react';
3
3
  import { useCallback, useEffect, useState } from 'react';
4
4
  import { Link } from 'react-router';
5
5
 
6
- import type { Article } from '@/schemas/Article';
6
+ import type { ArticleResult } from '@/schemas/Article';
7
7
  import type { HomePage } from '@/schemas/HomePage';
8
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
+
9
19
  interface TutorialPageProps {
10
20
  page: HomePage;
11
- articles: Article[];
21
+ articles: TutorialArticle[];
12
22
  }
13
23
 
14
24
  const COMPLETED_ACTIONS_KEY = 'fimo-welcome-completed-actions-v2';
25
+ const DISMISSED_KEY = 'fimo-welcome-dismissed';
15
26
  const ARTICLE_COVER_CLASS = 'aspect-[48/25] w-full border-b border-zinc-200 bg-white';
16
27
  const OUTLINE_BUTTON_CLASS =
17
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';
18
29
 
19
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
+
20
38
  return (
21
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" />
22
41
  <article className="relative mx-auto max-w-6xl border-x border-t border-zinc-200">
23
42
  <Hero page={page} />
24
- <Tutorial page={page} />
25
- <div className="relative h-14 border-b border-zinc-200" />
26
- <Articles articles={articles} />
43
+ <Tutorial page={page} completedActions={completedActions} setCompletedActions={setCompletedActions} />
44
+ <DeveloperCallout />
45
+ <Articles articles={articles} showNudge={showNudge} onDismiss={dismiss} />
27
46
  <div className="relative h-14 border-b border-zinc-200">
28
47
  <GridDot edgeX="left" edgeY="top" />
29
48
  <GridDot x="33.333%" edgeY="top" />
@@ -33,17 +52,41 @@ export function TutorialPage({ page, articles }: TutorialPageProps) {
33
52
  <GridDot x="50%" edgeY="bottom" />
34
53
  <GridDot edgeX="right" edgeY="bottom" />
35
54
  </div>
36
- <WhatComesNext page={page} />
55
+ <WhatComesNext page={page} showNudge={showNudge} onDismiss={dismiss} />
37
56
  <div className="relative">
38
57
  <GridDot edgeX="left" edgeY="top" />
39
58
  <GridDot x="50%" edgeY="top" />
40
59
  <GridDot edgeX="right" edgeY="top" />
41
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>
42
64
  </article>
43
65
  </main>
44
66
  );
45
67
  }
46
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
+
47
90
  // Fimo grid intersection marker, light theme. The edge clip mirrors the
48
91
  // website's GridDot: it stops the halo from spilling outside the column.
49
92
  function GridDot({ x, edgeX, edgeY }: { x?: string; edgeX?: 'left' | 'right'; edgeY: 'top' | 'bottom' }) {
@@ -77,6 +120,111 @@ function GridDot({ x, edgeX, edgeY }: { x?: string; edgeX?: 'left' | 'right'; ed
77
120
  );
78
121
  }
79
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
+
80
228
  function Hero({ page }: { page: HomePage }) {
81
229
  return (
82
230
  <section className="relative border-b border-zinc-200 px-6 py-10 text-center">
@@ -93,39 +241,86 @@ function Hero({ page }: { page: HomePage }) {
93
241
  );
94
242
  }
95
243
 
96
- function Tutorial({ page }: { page: HomePage }) {
97
- const [completedActions, setCompletedActions] = useState<string[]>(readCompletedActions);
244
+ function Tutorial({
245
+ page,
246
+ completedActions,
247
+ setCompletedActions,
248
+ }: {
249
+ page: HomePage;
250
+ completedActions: string[];
251
+ setCompletedActions: React.Dispatch<React.SetStateAction<string[]>>;
252
+ }) {
98
253
  const currentStepIndex = page.steps.findIndex((step) => !completedActions.includes(String(step.action)));
99
254
 
100
- const completeAction = useCallback((action: string) => {
101
- setCompletedActions((current) => {
102
- if (current.includes(action)) {
103
- return current;
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 }, '*');
104
265
  }
105
-
106
- const next = [...current, action];
107
- window.sessionStorage.setItem(COMPLETED_ACTIONS_KEY, JSON.stringify(next));
108
- return next;
109
- });
110
- }, []);
266
+ setCompletedActions((state) => (state.includes(action) ? state : [...state, action]));
267
+ },
268
+ [setCompletedActions]
269
+ );
111
270
 
112
271
  const [editActive, setEditActive] = useState(false);
113
272
 
114
273
  useEffect(() => {
115
274
  const onMessage = (event: MessageEvent) => {
116
- if (event.data?.type !== 'mode/change') {
275
+ if (event.data?.type === 'mode/change') {
276
+ setEditActive(event.data?.payload?.mode === 'edit');
117
277
  return;
118
278
  }
119
- const isEdit = event.data?.payload?.mode === 'edit';
120
- setEditActive(isEdit);
121
- if (isEdit) {
122
- completeAction('toggleEditMode');
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
+ });
123
292
  }
124
293
  };
125
294
 
126
295
  window.addEventListener('message', onMessage);
127
296
  return () => window.removeEventListener('message', onMessage);
128
- }, [completeAction]);
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]);
129
324
 
130
325
  return (
131
326
  <section
@@ -169,7 +364,12 @@ function Tutorial({ page }: { page: HomePage }) {
169
364
  editActive={editActive}
170
365
  primary={current}
171
366
  onClick={() => {
172
- completeAction(action);
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
+ }
173
373
  postShortcut(action);
174
374
  }}
175
375
  />
@@ -203,13 +403,13 @@ function InstructionText({ value }: { value: string }) {
203
403
 
204
404
  return (
205
405
  <>
206
- {explanation ? <p>{formatInstruction(explanation)}</p> : null}
406
+ {explanation ? <p className="font-medium">{formatInstruction(explanation)}</p> : null}
207
407
  {instructions.length > 0 ? (
208
408
  <div className={explanation ? 'mt-2.5' : ''}>
209
- <span className="fimo-mono text-[10px] font-medium text-orange-600">ACTION</span>
409
+ <span className="text-[11px] font-extrabold tracking-[0.05em] text-black uppercase">Action</span>
210
410
  <ul className="mt-1 space-y-1.5">
211
411
  {instructions.map((instruction) => (
212
- <li key={instruction} className="font-medium text-zinc-800">
412
+ <li key={instruction} className="font-semibold text-zinc-900">
213
413
  {formatInstruction(instruction.replace(/^-\s*/, ''))}
214
414
  </li>
215
415
  ))}
@@ -289,7 +489,7 @@ function TutorialAction({
289
489
  );
290
490
  }
291
491
 
292
- return <ActionButton label={label} primary={primary} onClick={onClick} />;
492
+ return <ActionButton label={label} primary={primary} onClick={onClick} shortcut={action} />;
293
493
  }
294
494
 
295
495
  function ActionButton({
@@ -297,15 +497,20 @@ function ActionButton({
297
497
  primary,
298
498
  onClick,
299
499
  className = '',
500
+ shortcut,
300
501
  }: {
301
502
  label: HomePage['steps'][number]['actionLabel'];
302
503
  primary: boolean;
303
504
  onClick: () => void;
304
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;
305
509
  }) {
306
510
  return (
307
511
  <button
308
512
  type="button"
513
+ data-shortcut={shortcut}
309
514
  onClick={onClick}
310
515
  className={`${
311
516
  primary
@@ -318,9 +523,50 @@ function ActionButton({
318
523
  );
319
524
  }
320
525
 
321
- function Articles({ articles }: { articles: Article[] }) {
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
+ }) {
322
562
  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">
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
+ )}
324
570
  {articles.map((article, index) => (
325
571
  <Link
326
572
  key={article.id}
@@ -333,9 +579,18 @@ function Articles({ articles }: { articles: Article[] }) {
333
579
  <div className={ARTICLE_COVER_CLASS} aria-hidden />
334
580
  )}
335
581
  <div className="p-6 pt-5">
336
- <p className="fimo-mono text-[11px] text-zinc-400">ARTICLES / 0{index + 1}</p>
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>
337
591
  <Text value={article.title} as="h3" className="mt-2 line-clamp-2 text-[15px] font-bold" />
338
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}
339
594
  <span className="mt-4 inline-block text-[13px] font-bold text-orange-600 group-hover:text-orange-700">
340
595
  Read in the CMS →
341
596
  </span>
@@ -346,9 +601,15 @@ function Articles({ articles }: { articles: Article[] }) {
346
601
  );
347
602
  }
348
603
 
349
- function WhatComesNext({ page }: { page: HomePage }) {
604
+ function WhatComesNext({ page, showNudge, onDismiss }: { page: HomePage; showNudge: boolean; onDismiss: () => void }) {
350
605
  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">
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
+ )}
352
613
  <div className="p-6 sm:p-8">
353
614
  <p className="fimo-mono text-[11px] font-medium text-orange-600">WHAT COMES NEXT</p>
354
615
  <Text value={page.migrationTitle} as="h3" className="mt-2 text-xl font-bold tracking-[-0.02em]" />