From bbf4a2cbe2f007f69a36aeaa512a3a686ef843b9 Mon Sep 17 00:00:00 2001 From: Luan Nguyen Date: Wed, 11 Nov 2026 21:37:49 +1100 Subject: [PATCH] feat(setup): add optional Markdown onboarding --- web/src/setup/preference.ts | 83 ++++++++++++++ web/src/styles/setup.css | 149 ++++++++++++++++++++++++++ web/src/ui/FirstRunSetup.tsx | 145 +++++++++++++++++++++++++ web/tests/ui/setup-preference.test.ts | 85 +++++++++++++++ 4 files changed, 462 insertions(+) create mode 100644 web/src/setup/preference.ts create mode 100644 web/src/styles/setup.css create mode 100644 web/src/ui/FirstRunSetup.tsx create mode 100644 web/tests/ui/setup-preference.test.ts diff --git a/web/src/setup/preference.ts b/web/src/setup/preference.ts new file mode 100644 index 0000000..e43119b --- /dev/null +++ b/web/src/setup/preference.ts @@ -0,0 +1,83 @@ +export const SETUP_PREFERENCE_KEY = "notes.setup"; +type HintStorage = Pick; +export interface SetupPreference { + isDismissed(): boolean; + dismiss(): Readonly<{ persisted: boolean }>; + subscribe(listener: () => void): () => void; +} + +/** Optional profile introduction only: never account or library state. */ +export function createSetupPreference( + storage: () => HintStorage | null, + events?: EventTarget, +): SetupPreference { + let dismissedHere = false; + const listeners = new Set<() => void>(); + function notify() { + for (const listener of listeners) { + try { + listener(); + } catch { + /* One observer cannot block completion. */ + } + } + } + const changed = (event: Event) => { + const key = (event as StorageEvent).key; + if (key === SETUP_PREFERENCE_KEY || key === null) notify(); + }; + return { + isDismissed() { + if (dismissedHere) return true; + try { + const raw = storage()?.getItem(SETUP_PREFERENCE_KEY); + if (!raw || raw.length > 256) return false; + const value: unknown = JSON.parse(raw); + if (!value || typeof value !== "object" || Array.isArray(value)) + return false; + const hint = value as Record; + dismissedHere = + Object.keys(hint).length === 2 && + Object.hasOwn(hint, "version") && + Object.hasOwn(hint, "dismissed") && + hint.version === 1 && + hint.dismissed === true; + } catch { + /* Missing/denied preferences never prevent browsing. */ + } + return dismissedHere; + }, + dismiss() { + dismissedHere = true; + let persisted = false; + try { + const current = storage(); + if (current) { + current.setItem( + SETUP_PREFERENCE_KEY, + '{"version":1,"dismissed":true}', + ); + persisted = true; + } + } catch { + /* The context latch prevents repeated optional takeover. */ + } + notify(); + return Object.freeze({ persisted }); + }, + subscribe(listener) { + if (listeners.size === 0) events?.addEventListener("storage", changed); + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) + events?.removeEventListener("storage", changed); + }; + }, + }; +} + +export const profileSetupPreference = createSetupPreference( + () => (typeof localStorage === "undefined" ? null : localStorage), + typeof window === "undefined" ? undefined : window, +); diff --git a/web/src/styles/setup.css b/web/src/styles/setup.css new file mode 100644 index 0000000..d5ef0c9 --- /dev/null +++ b/web/src/styles/setup.css @@ -0,0 +1,149 @@ +.first-run-setup { + width: min(620px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + padding: 36px 40px; + display: flex; + flex-direction: column; + gap: 18px; + background: var(--main); + color: var(--t1); +} +.first-run-setup:not([open]) { + display: none; +} +.first-run-setup > h2 { + order: 0; + margin: 0; + font-size: 24px; + font-weight: 500; + letter-spacing: -0.02em; +} +.setup-step { + order: -1; + font-size: 12px; + color: var(--t3); +} +.setup-intro { + margin: 0; + color: var(--t3); +} +.setup-progress { + height: 6px; + border-radius: 999px; + background: var(--line2); + overflow: hidden; +} +.setup-progress i { + display: block; + height: 100%; + background: var(--t1); + border-radius: inherit; +} +.setup-choice { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid var(--line2); + background: var(--main); + min-width: 0; +} +.setup-choice.current { + border-color: var(--t1); + box-shadow: inset 0 0 0 1px var(--t1); +} +.setup-choice > svg, +.setup-number { + flex: none; + margin-top: 2px; + width: 18px; +} +.setup-choice > div:first-of-type { + flex: 1; + min-width: 0; +} +.setup-choice b { + display: block; + font-size: 13px; + font-weight: 500; + overflow-wrap: anywhere; +} +.setup-choice small { + display: block; + color: var(--t3); + line-height: 1.5; + margin-top: 3px; +} +.setup-status, +.setup-number { + font-size: 11px; + color: var(--t3); + white-space: nowrap; +} +.setup-actions { + display: flex; + gap: 6px; + flex: none; +} +.setup-footer { + display: flex; + justify-content: flex-end; +} +.empty.library-empty { + margin: 0; + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 10px; + padding: 0 28px 40px; + text-align: left; +} +.library-empty h2 { + margin: 0; + font-size: 18px; + font-weight: 500; +} +.library-empty p { + margin: 0; + max-width: 540px; +} +.library-empty-icon { + width: 40px; + height: 40px; + border-radius: 12px; + display: grid; + place-items: center; + background: var(--raise); + color: var(--t1); +} +.library-empty-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.library-empty-actions button { + margin: 0; +} +.library-local-copy { + font-size: 12px; + color: var(--t3); +} +@media (max-width: 600px) { + .first-run-setup { + padding: 24px 20px; + gap: 14px; + } + .setup-choice { + flex-wrap: wrap; + } + .setup-actions { + width: 100%; + justify-content: flex-end; + } + .empty.library-empty { + padding: 24px 20px 40px; + } +} diff --git a/web/src/ui/FirstRunSetup.tsx b/web/src/ui/FirstRunSetup.tsx new file mode 100644 index 0000000..685012d --- /dev/null +++ b/web/src/ui/FirstRunSetup.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from "react"; +import { Check } from "lucide-react"; +import { Dialog } from "./primitives"; +import "../styles/setup.css"; +export type SetupImportState = + | "idle" + | "picking" + | "reading" + | "review" + | "running" + | "failed" + | "completed"; +export function FirstRunSetup({ + email, + environment, + readOnly = false, + importState, + importedCount = 0, + remainingCount = 0, + importMessage = "", + onChooseMarkdown, + onComplete, + onClose, +}: { + email: string; + environment: "desktop" | "browser"; + readOnly?: boolean; + importState: SetupImportState; + importedCount?: number; + remainingCount?: number; + importMessage?: string; + onChooseMarkdown: () => void; + onComplete: () => void; + onClose: () => void; +}) { + const [step, setStep] = useState<"import" | "sync">("import"); + const busy = + ["picking", "reading", "review", "running"].includes(importState) || + remainingCount > 0; + useEffect(() => { + if (importState === "completed") setStep("sync"); + }, [importState]); + return ( + { + if (!busy) onClose(); + }} + > + Step {step === "import" ? 2 : 3} of 3 +

+ Every step is optional. You can come back to each one in Settings. +

+ +
+
+
+ 2 +
+ Bring in your notes + + Import Markdown files. Your originals stay where they are. + + Apple Notes import isn’t available in this beta. + {readOnly && ( + Import is unavailable while Notes is read-only. + )} + {importedCount > 0 && ( + + {importedCount}{" "} + {importedCount === 1 ? "note imported" : "notes imported"}. + + )} + {importState === "picking" && ( + Choose Markdown files. + )} + {importState === "reading" && ( + Reading Markdown files… + )} + {importState === "running" && ( + Importing notes… + )} + {importState === "failed" && ( + + {importMessage || + "Import didn’t finish. Review the remaining files."} + + )} +
+ {step === "import" ? ( +
+ + +
+ ) : ( + + {importState === "completed" ? "Done" : "Skipped"} + + )} +
+
+ 3 +
+ Sync your devices + + Sync isn’t available in this beta. Your notes stay{" "} + {environment === "desktop" + ? "in this app on this device" + : "in this browser profile"} + . + +
+ Planned + {step === "sync" && ( + + )} +
+
+ +
+
+ ); +} diff --git a/web/tests/ui/setup-preference.test.ts b/web/tests/ui/setup-preference.test.ts new file mode 100644 index 0000000..37aec21 --- /dev/null +++ b/web/tests/ui/setup-preference.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createSetupPreference, + SETUP_PREFERENCE_KEY, +} from "../../src/setup/preference"; + +describe("optional setup profile preference", () => { + it("accepts only the bounded versioned dismissal hint", () => { + let raw: string | null = null; + const preference = createSetupPreference(() => ({ + getItem: () => raw, + setItem: vi.fn(), + })); + for (const value of [ + null, + "bad-json", + '{"version":2,"dismissed":true}', + '{"version":1,"dismissed":false}', + '{"version":1,"dismissed":true,"accountId":"private"}', + " ".repeat(300), + ]) { + raw = value; + expect(preference.isDismissed()).toBe(false); + } + raw = '{"version":1,"dismissed":true}'; + expect(preference.isDismissed()).toBe(true); + raw = null; + expect(preference.isDismissed()).toBe(true); + }); + it("keeps Notes usable and dismissal latched when accessing storage is denied", () => { + const preference = createSetupPreference(() => { + throw new DOMException("Denied", "SecurityError"); + }); + expect(preference.isDismissed()).toBe(false); + expect(preference.dismiss()).toEqual({ persisted: false }); + expect(preference.isDismissed()).toBe(true); + }); + it("latches and notifies even when the write fails", () => { + const storage = { + getItem: vi.fn(() => null), + setItem: vi.fn(() => { + throw new Error("Quota"); + }), + }; + const preference = createSetupPreference(() => storage); + const seen: boolean[] = []; + preference.subscribe(() => seen.push(preference.isDismissed())); + expect(preference.dismiss()).toEqual({ persisted: false }); + expect(seen).toEqual([true]); + expect(preference.isDismissed()).toBe(true); + expect(storage.getItem).not.toHaveBeenCalled(); + }); + it("writes only a small profile hint with no account or content fields", () => { + const storage = { getItem: vi.fn(() => null), setItem: vi.fn() }; + const preference = createSetupPreference(() => storage); + expect(preference.dismiss()).toEqual({ persisted: true }); + expect(storage.setItem).toHaveBeenCalledExactlyOnceWith( + SETUP_PREFERENCE_KEY, + '{"version":1,"dismissed":true}', + ); + }); + it("observes only the hint or storage-clear events and releases its event listener", () => { + const events = new EventTarget(); + const remove = vi.spyOn(events, "removeEventListener"); + const preference = createSetupPreference( + () => ({ getItem: () => null, setItem: vi.fn() }), + events, + ); + const listener = vi.fn(); + const stop = preference.subscribe(listener); + events.dispatchEvent(new StorageEvent("storage", { key: "notes.theme" })); + expect(listener).not.toHaveBeenCalled(); + events.dispatchEvent( + new StorageEvent("storage", { key: SETUP_PREFERENCE_KEY }), + ); + events.dispatchEvent(new StorageEvent("storage", { key: null })); + expect(listener).toHaveBeenCalledTimes(2); + stop(); + expect(remove).toHaveBeenCalledOnce(); + events.dispatchEvent( + new StorageEvent("storage", { key: SETUP_PREFERENCE_KEY }), + ); + expect(listener).toHaveBeenCalledTimes(2); + }); +});