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.
- package/assets/skills/fimo-cli/SKILL.md +1 -1
- package/assets/skills/fimo-cli/references/assets.md +38 -2
- package/assets/skills/fimo-cli/references/content.md +1 -1
- package/assets/skills/fimo-code/references/content/schema.md +1 -0
- package/assets/skills/fimo-migration/SKILL.md +44 -13
- package/assets/skills/fimo-migration/references/frameworks/nextjs.md +8 -0
- package/assets/skills/fimo-migration/references/qa.md +30 -4
- package/assets/skills/fimo-migration/references/questions-and-improvements.md +188 -0
- package/assets/skills/fimo-migration/references/workers/content-planner.md +8 -3
- package/assets/skills/fimo-migration/references/workers/validator.md +20 -6
- package/assets/skills/fimo-migration/references/workers.md +17 -6
- package/assets/skills/fimo-migration/references/workflow.md +33 -6
- package/assets/skills/fimo-studio/SKILL.md +3 -1
- package/assets/skills/fimo-studio/references/agents.md +25 -0
- package/dist/cli/bundle.json +1 -1
- package/dist/cli/index.js +12 -11
- package/dist/runtime/templates.d.ts +1 -0
- package/dist/runtime/templates.js +3 -0
- package/dist/scripts/validate-schemas.js +10 -15
- package/package.json +8 -7
- 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 +404 -0
- package/templates/welcome/media/analytics-overview.avif +0 -0
- package/templates/welcome/media/autonomous-loop.avif +0 -0
- package/templates/welcome/media/editable-preview.avif +0 -0
- package/templates/welcome/media/media-library.avif +0 -0
- package/templates/welcome/media/slack-teams.avif +0 -0
- package/templates/welcome/template/.fimo/agents/welcome-guide/GOAL.md +7 -0
- package/templates/welcome/template/.fimo/agents/welcome-guide/config.yaml +8 -0
- package/templates/welcome/template/.fimo/config.json +53 -0
- package/templates/welcome/template/src/font.css +60 -0
- package/templates/welcome/template/src/pages/ArticleDetail.tsx +68 -0
- package/templates/welcome/template/src/pages/Index.tsx +32 -0
- package/templates/welcome/template/src/pages/SiteContent.tsx +413 -0
- package/templates/welcome/template/src/pages/fallback-content.ts +133 -0
- package/templates/welcome/template/src/routes.ts +9 -0
- package/templates/welcome/template/src/schemas/Article.json +12 -0
- package/templates/welcome/template/src/schemas/Article.ts +59 -0
- package/templates/welcome/template/src/schemas/Article.types.ts +73 -0
- package/templates/welcome/template/src/schemas/HomePage.json +39 -0
- 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
|
+
}
|