Skip to content

Commit ade803b

Browse files
phildenhoffclaude
andauthored
feat(first-run): teaching welcome that names both ways into a library (#125)
The cold-start screen was a bare title + single button. Redesign it as a quiet, on-brand floating card (DESIGN.md: neutral chrome, modest title, single accent on the one primary action, soft shadow on the card only) that teaches the two ways in — open an existing Calibre library, or create a fresh one — which also clarifies the folder picker's two outcomes. Split into a pure FirstTimeSetupView (Storybook-friendly, mirrors BookPage) plus a thin behavior wrapper that now surfaces a busy state while the library is opened or created. Adds Pages/FirstTimeSetup stories (Default + Busy) for isolated light/dark iteration. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f039d1d commit ade803b

3 files changed

Lines changed: 192 additions & 40 deletions

File tree

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,89 @@
1-
/* Centered welcome column filling the window, matching the old
2-
* Mantine Stack (align center, top-justified, full viewport height). */
1+
/* First-run welcome. One floating panel centered on the window background,
2+
* matching DESIGN.md: neutral chrome, modest (not hero) title, the single
3+
* accent reserved for the one primary action, soft shadow on the card only. */
34
.page {
5+
display: flex;
6+
align-items: center;
7+
justify-content: center;
8+
min-height: 100vh;
9+
padding: 24px;
10+
background: var(--ctd-bg);
11+
}
12+
13+
.card {
414
display: flex;
515
flex-direction: column;
616
align-items: center;
7-
justify-content: flex-start;
8-
gap: 16px;
9-
height: 100vh;
10-
padding: 12px;
17+
text-align: center;
18+
width: 100%;
19+
max-width: 380px;
20+
padding: 32px 32px 24px;
21+
background: var(--ctd-surface);
22+
border: 1px solid var(--ctd-border);
23+
border-radius: var(--ctd-radius-panel);
24+
box-shadow: var(--ctd-shadow-soft);
25+
}
26+
27+
.mark {
28+
display: inline-flex;
29+
font-size: 30px;
30+
line-height: 1;
31+
color: var(--ctd-ink-soft);
1132
}
1233

1334
.title {
14-
margin: 0;
15-
font-size: 26px;
16-
font-weight: 700;
35+
margin: 14px 0 0;
36+
font-size: 20px;
37+
font-weight: 600;
1738
line-height: 1.3;
1839
color: var(--ctd-ink);
1940
}
2041

21-
.text {
22-
margin: 0;
42+
.lede {
43+
margin: 6px 0 20px;
2344
font-size: 13px;
45+
line-height: 1.5;
46+
color: var(--ctd-ink-soft);
47+
max-width: 30ch;
48+
}
49+
50+
.busy {
51+
display: inline-flex;
52+
align-items: center;
53+
gap: 8px;
54+
}
55+
56+
/* Teaching rows: name the two ways in. Hairline-separated from the action,
57+
* left-aligned so the lead-in reads as a scannable label. */
58+
.paths {
59+
list-style: none;
60+
margin: 20px 0 0;
61+
padding: 16px 0 0;
62+
width: 100%;
63+
border-top: 1px solid var(--ctd-border);
64+
display: flex;
65+
flex-direction: column;
66+
gap: 12px;
67+
}
68+
69+
.path {
70+
display: flex;
71+
align-items: flex-start;
72+
gap: 10px;
73+
text-align: left;
74+
font-size: 12.5px;
2475
line-height: 1.45;
76+
color: var(--ctd-ink-soft);
77+
}
78+
79+
.pathIcon {
80+
flex: none;
81+
margin-top: 1px;
82+
font-size: 15px;
83+
color: var(--ctd-ink-soft);
84+
}
85+
86+
.pathLead {
87+
font-weight: 600;
2588
color: var(--ctd-ink);
26-
text-align: center;
2789
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { FirstTimeSetupView } from "./firstTimeSetup";
3+
4+
const meta: Meta<typeof FirstTimeSetupView> = {
5+
title: "Pages/FirstTimeSetup",
6+
component: FirstTimeSetupView,
7+
parameters: { layout: "fullscreen" },
8+
args: {
9+
onChooseFolder: () => {},
10+
},
11+
};
12+
export default meta;
13+
14+
type Story = StoryObj<typeof FirstTimeSetupView>;
15+
16+
/** Cold first launch: no library configured yet. */
17+
export const Default: Story = {};
18+
19+
/** After a folder is picked, while the library is opened or created. */
20+
export const Busy: Story = {
21+
args: { busy: true },
22+
};
Lines changed: 96 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,89 @@
11
import { commands } from "@/bindings";
2-
import { Button } from "@/components/ui";
2+
import { F7BookFill } from "@/components/icons/F7BookFill";
3+
import { FluentLibraryFilled } from "@/components/icons/FluentLibraryFilled";
4+
import { Button, Spinner } from "@/components/ui";
35
import { safeAsyncEventHandler } from "@/lib/async";
46
import { usePlatform } from "@/lib/platform/context";
57
import { useLibraryStore } from "@/stores/library/store";
68
import { createLibrary, setActiveLibrary } from "@/stores/settings/actions";
9+
import { useState } from "react";
710
import styles from "./firstTimeSetup.module.css";
811

12+
export interface FirstTimeSetupViewProps {
13+
/** Opens the folder picker and provisions the chosen library. */
14+
onChooseFolder: () => void;
15+
/** While a library is being opened or created, the action shows progress. */
16+
busy?: boolean;
17+
}
18+
19+
/**
20+
* First-run welcome. The teaching counterpart to the empty-library state: the
21+
* library doesn't exist yet, so this names the two ways in (open an existing
22+
* Calibre library, or create a fresh one) before handing off to the folder
23+
* picker. Pure renderer — behaviour lives in {@link FirstTimeSetup}.
24+
*/
25+
export const FirstTimeSetupView = ({
26+
onChooseFolder,
27+
busy = false,
28+
}: FirstTimeSetupViewProps) => {
29+
return (
30+
<div className={styles.page}>
31+
<section className={styles.card} aria-labelledby="fts-title">
32+
<span className={styles.mark} aria-hidden="true">
33+
<FluentLibraryFilled />
34+
</span>
35+
36+
<h1 id="fts-title" className={styles.title}>
37+
Welcome to Citadel
38+
</h1>
39+
<p className={styles.lede}>
40+
Citadel reads and manages your Calibre library. Point it at a folder
41+
to get started.
42+
</p>
43+
44+
<Button
45+
variant="primary"
46+
size="md"
47+
fullWidth
48+
autoFocus
49+
disabled={busy}
50+
onClick={onChooseFolder}
51+
>
52+
{busy ? (
53+
<span className={styles.busy}>
54+
<Spinner size={14} />
55+
Setting up your library…
56+
</span>
57+
) : (
58+
"Choose library folder…"
59+
)}
60+
</Button>
61+
62+
<ul className={styles.paths}>
63+
<li className={styles.path}>
64+
<FluentLibraryFilled className={styles.pathIcon} aria-hidden />
65+
<span>
66+
<span className={styles.pathLead}>Already use Calibre?</span> Pick
67+
your existing library folder to open it here.
68+
</span>
69+
</li>
70+
<li className={styles.path}>
71+
<F7BookFill className={styles.pathIcon} aria-hidden />
72+
<span>
73+
<span className={styles.pathLead}>New to Calibre?</span> Choose an
74+
empty folder and Citadel creates a library in it.
75+
</span>
76+
</li>
77+
</ul>
78+
</section>
79+
</div>
80+
);
81+
};
82+
983
export const FirstTimeSetup = () => {
1084
const actions = useLibraryStore((state) => state.actions);
1185
const platform = usePlatform();
86+
const [busy, setBusy] = useState(false);
1287

1388
const openFilePicker = async (): Promise<
1489
| { type: "existing library selected"; path: string }
@@ -27,31 +102,24 @@ export const FirstTimeSetup = () => {
27102
}
28103
return { type: "new library selected", path };
29104
};
30-
return (
31-
<div className={styles.page}>
32-
<h1 className={styles.title}>Welcome to Citadel!</h1>
33-
34-
<p className={styles.text}>
35-
Select an existing Calibre library, or choose where to create a new
36-
library.
37-
</p>
38-
<Button
39-
variant="primary"
40-
onClick={safeAsyncEventHandler(async () => {
41-
const returnStatus = await openFilePicker();
42-
if (returnStatus.type === "invalid library path selected") {
43-
return;
44-
}
45-
46-
if (returnStatus.type === "new library selected") {
47-
await actions.createLibrary(returnStatus.path);
48-
}
49-
const newLibraryId = await createLibrary(returnStatus.path);
50-
await setActiveLibrary(newLibraryId);
51-
})}
52-
>
53-
Choose Calibre library folder
54-
</Button>
55-
</div>
56-
);
105+
106+
const onChooseFolder = safeAsyncEventHandler(async () => {
107+
const returnStatus = await openFilePicker();
108+
if (returnStatus.type === "invalid library path selected") {
109+
return;
110+
}
111+
112+
setBusy(true);
113+
try {
114+
if (returnStatus.type === "new library selected") {
115+
await actions.createLibrary(returnStatus.path);
116+
}
117+
const newLibraryId = await createLibrary(returnStatus.path);
118+
await setActiveLibrary(newLibraryId);
119+
} finally {
120+
setBusy(false);
121+
}
122+
});
123+
124+
return <FirstTimeSetupView onChooseFolder={onChooseFolder} busy={busy} />;
57125
};

0 commit comments

Comments
 (0)