fimo 2.2.0-staging.10 → 2.2.0-staging.11
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-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/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,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
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { Article } from '@/schemas/Article';
|
|
2
|
+
import type { HomePage } from '@/schemas/HomePage';
|
|
3
|
+
|
|
4
|
+
// Generated from the starter seed (../../manifest.json): the loading
|
|
5
|
+
// fallback renders the real TutorialPage with this data so the live CMS
|
|
6
|
+
// values swap in without any visible change. Regenerate when the seed
|
|
7
|
+
// copy changes — a drifted fallback flashes different text on load.
|
|
8
|
+
// Rich-text values carry { content: <tiptap doc> } like FimoRichText;
|
|
9
|
+
// the missing source symbol simply disables inline editing until the
|
|
10
|
+
// live values arrive.
|
|
11
|
+
|
|
12
|
+
export const FALLBACK_PAGE = {
|
|
13
|
+
title: 'This site is yours to change.',
|
|
14
|
+
intro: {
|
|
15
|
+
content: {
|
|
16
|
+
type: 'doc',
|
|
17
|
+
content: [
|
|
18
|
+
{
|
|
19
|
+
type: 'paragraph',
|
|
20
|
+
content: [
|
|
21
|
+
{
|
|
22
|
+
type: 'text',
|
|
23
|
+
text: 'Four actions, five minutes. Every change is safe to make in this tutorial project.',
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
startLabel: 'Start the 5-minute tour',
|
|
31
|
+
tourTitle: 'Try the essentials in five minutes',
|
|
32
|
+
tourIntro: 'Follow these steps in order. Every change is safe to make in this tutorial project.',
|
|
33
|
+
steps: [
|
|
34
|
+
{
|
|
35
|
+
_key: 'fallback-step-1',
|
|
36
|
+
number: '1',
|
|
37
|
+
title: 'Edit this page',
|
|
38
|
+
body: 'Edit mode lets you change text and images directly on the website.\n- Select the big title and replace it with “Welcome, [your name]”.',
|
|
39
|
+
actionLabel: 'Activate Edit mode',
|
|
40
|
+
action: 'toggleEditMode',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
_key: 'fallback-step-2',
|
|
44
|
+
number: '2',
|
|
45
|
+
title: 'Open the CMS',
|
|
46
|
+
body: 'The website content also lives in the CMS, as structured data.\n- Change “Title” and watch the site update.',
|
|
47
|
+
actionLabel: 'Jump to the Home page',
|
|
48
|
+
action: 'openHomePage',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
_key: 'fallback-step-3',
|
|
52
|
+
number: '3',
|
|
53
|
+
title: 'Explore branches',
|
|
54
|
+
body: 'Branches let people and agents work without touching the live site.\n- Try a change on “tutorial/first-change”.',
|
|
55
|
+
actionLabel: 'Show the branch menu',
|
|
56
|
+
action: 'openBranches',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
_key: 'fallback-step-4',
|
|
60
|
+
number: '4',
|
|
61
|
+
title: 'Meet your agent',
|
|
62
|
+
body: 'Your agent maintains this website on a schedule.\n- Click “Run now” and review the result and cost.',
|
|
63
|
+
actionLabel: 'Meet Welcome Guide',
|
|
64
|
+
action: 'openAgents',
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
articlesTitle: 'Continue learning in the CMS',
|
|
68
|
+
articlesIntro:
|
|
69
|
+
'These are real entries in the Articles collection. Open Content to edit their titles, summaries, bodies, and cover images.',
|
|
70
|
+
migrationTitle: 'Migrate my existing website',
|
|
71
|
+
migrationBody: {
|
|
72
|
+
content: {
|
|
73
|
+
type: 'doc',
|
|
74
|
+
content: [
|
|
75
|
+
{
|
|
76
|
+
type: 'paragraph',
|
|
77
|
+
content: [
|
|
78
|
+
{
|
|
79
|
+
type: 'text',
|
|
80
|
+
text: 'Open your existing local project with Codex, Claude Code, Cursor, or another coding agent. Copy the prompt below so the agent connects the website you already trust to Fimo, with a recoverable backup first.',
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
migrationPrompt:
|
|
88
|
+
'Before changing anything, create a recoverable backup of this website. If it uses Git, create a branch and commit the current state. If it does not use Git, duplicate the entire project folder and confirm the copy opens. Then inspect the existing codebase and migrate this website to Fimo in place. Preserve every route, visible piece of content, media asset, form, SEO field, redirect, and locale. Connect the existing implementation to Fimo rather than generating a new empty site. Report any item you cannot migrate safely before changing it.',
|
|
89
|
+
buildTitle: 'Create my website',
|
|
90
|
+
buildBody: {
|
|
91
|
+
content: {
|
|
92
|
+
type: 'doc',
|
|
93
|
+
content: [
|
|
94
|
+
{
|
|
95
|
+
type: 'paragraph',
|
|
96
|
+
content: [
|
|
97
|
+
{
|
|
98
|
+
type: 'text',
|
|
99
|
+
text: 'Use your Codex, Claude Code, Cursor, or other coding-agent subscription to design and build the first version in a local repository. Connect it to Fimo when the foundation is ready. Fimo starts where the first build ends: content, visual editing, environments, agents, and hosting.',
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
closingTitle: 'Fimo keeps finished websites autonomous',
|
|
107
|
+
closingBody:
|
|
108
|
+
'Fimo Credits are designed for the agents that maintain your website and for teammates who use Studio chat without a local coding tool.',
|
|
109
|
+
} as unknown as HomePage;
|
|
110
|
+
|
|
111
|
+
// Covers are omitted: the card's image box keeps its own aspect ratio, so
|
|
112
|
+
// the live images arrive without shifting the layout.
|
|
113
|
+
export const FALLBACK_ARTICLES = [
|
|
114
|
+
{
|
|
115
|
+
id: 'fallback-article-1',
|
|
116
|
+
slug: 'create-your-first-agent',
|
|
117
|
+
title: 'Create your first agent',
|
|
118
|
+
summary:
|
|
119
|
+
'Build a weekly SEO agent with one prompt: full audit, safe fixes applied to the CMS, and a report you can trust.',
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: 'fallback-article-2',
|
|
123
|
+
slug: 'branches-and-environments',
|
|
124
|
+
title: 'How branches and environments work',
|
|
125
|
+
summary: 'Branches isolate changes. Environments group related branches under shared deployment configuration.',
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: 'fallback-article-3',
|
|
129
|
+
slug: 'discover-the-analytics',
|
|
130
|
+
title: 'Discover the analytics',
|
|
131
|
+
summary: 'See who visits your website: pageviews, top pages, sources, devices, and countries.',
|
|
132
|
+
},
|
|
133
|
+
] as unknown as Article[];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RouteConfig } from '@react-router/dev/routes';
|
|
2
|
+
import { layout, route } from '@react-router/dev/routes';
|
|
3
|
+
|
|
4
|
+
export default [
|
|
5
|
+
layout('./pages/LocaleLayout.tsx', [
|
|
6
|
+
route(':locale?', './pages/Index.tsx', { id: 'home' }),
|
|
7
|
+
route(':locale?/articles/:slug', './pages/ArticleDetail.tsx', { id: 'article' }),
|
|
8
|
+
]),
|
|
9
|
+
] satisfies RouteConfig;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"uid": "Article",
|
|
3
|
+
"name": "Articles",
|
|
4
|
+
"isSingleton": false,
|
|
5
|
+
"i18n": { "enabled": false },
|
|
6
|
+
"fields": {
|
|
7
|
+
"title": { "type": "string", "required": true },
|
|
8
|
+
"summary": { "type": "text", "required": true },
|
|
9
|
+
"body": { "type": "richtext", "required": true },
|
|
10
|
+
"cover": { "type": "media", "required": true }
|
|
11
|
+
}
|
|
12
|
+
}
|