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.
- package/assets/agent-templates/_default/config.yaml +6 -0
- package/assets/skills/fimo-cli/references/agents.md +3 -2
- package/dist/cli/bundle.json +1 -1
- package/dist/cli/index.js +1 -1
- package/package.json +8 -8
- package/templates/astro/package.json +2 -2
- package/templates/nextjs/package.json +3 -3
- package/templates/react-router/package.json +4 -4
- package/templates/sveltekit/package.json +2 -2
- package/templates/vite/package.json +4 -4
- package/templates/welcome/manifest.json +306 -55
- package/templates/welcome/template/.fimo/agents/welcome-guide/GOAL.md +9 -4
- package/templates/welcome/template/.fimo/agents/welcome-guide/capabilities.yaml +3 -0
- package/templates/welcome/template/.fimo/agents/welcome-guide/config.yaml +4 -4
- package/templates/welcome/template/src/pages/ArticleDetail.tsx +42 -6
- package/templates/welcome/template/src/pages/Index.tsx +6 -2
- package/templates/welcome/template/src/pages/SiteContent.tsx +296 -35
- package/templates/welcome/template/src/pages/fallback-content.ts +34 -15
- package/templates/welcome/template/src/schemas/Article.json +27 -5
- package/templates/welcome/template/src/schemas/Article.ts +16 -21
- package/templates/welcome/template/src/schemas/Article.types.ts +40 -20
- package/templates/welcome/template/src/schemas/Author.json +17 -0
- package/templates/welcome/template/src/schemas/Author.ts +51 -0
- package/templates/welcome/template/src/schemas/Author.types.ts +54 -0
- package/templates/welcome/template/src/schemas/Category.json +17 -0
- package/templates/welcome/template/src/schemas/Category.ts +51 -0
- package/templates/welcome/template/src/schemas/Category.types.ts +54 -0
- package/templates/welcome/template/src/schemas/HomePage.json +94 -23
- package/templates/welcome/template/src/schemas/HomePage.ts +22 -0
- package/templates/welcome/template/src/schemas/HomePage.types.ts +2 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
version: 1
|
|
2
|
-
description: '
|
|
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">
|
|
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
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
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 {
|
|
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 {
|
|
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:
|
|
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
|
-
<
|
|
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({
|
|
97
|
-
|
|
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(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
275
|
+
if (event.data?.type === 'mode/change') {
|
|
276
|
+
setEditActive(event.data?.payload?.mode === 'edit');
|
|
117
277
|
return;
|
|
118
278
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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="
|
|
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-
|
|
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
|
-
|
|
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">
|
|
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]" />
|