Add first-run onboarding flow

cmd/librenotes/web/public/onboarding.js triggers after a session
is verified and runs only when the tenant-scoped key
"onboarded" is unset.

Behaviour:
- Reads the dismissal flag from authClient.tenantStore() so each
  tenant's state is isolated and survives logout-then-login on
  the same device only if they're the same user.
- Lists /api/notes; if the notebook is empty, PUTs a sample
  "welcome" note so a brand-new user has somewhere to land. We
  don't seed when notes already exist (covers signing in on a
  second device for the first time).
- Opens app.html's <dialog id="onboarding-dialog"> with showModal
  and persists "onboarded": true on submit so returning users
  never see it again.
- Seed failures are logged but do not block the dialog —
  onboarding shouldn't depend on a successful network round
  trip. The dialog just won't have a sample note to point at.

Dialog content covers the four user-guide concepts: notes-as-
markdown, [[wikilinks]], offline-first sync, tenant isolation.

Service worker precaches onboarding.js so the dialog is also
available to offline-first returning visitors.

Closes #32.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 23:19:13 +02:00
parent e3c86a92c0
commit 3b8847efa1
5 changed files with 164 additions and 0 deletions

View File

@@ -25,6 +25,29 @@
<pre id="whoami-output"></pre>
</main>
<dialog id="onboarding-dialog">
<form method="dialog">
<h2>Welcome to librenotes</h2>
<p>A few things to know before you start:</p>
<ul>
<li><strong>Notes are Markdown.</strong> The first
<code># Heading</code> line is the title.</li>
<li><strong>Wikilinks.</strong> Connect notes with
<code>[[note-id]]</code>. Click an unresolved link to
create the target.</li>
<li><strong>Offline-first.</strong> Edits queue locally and
push when you reconnect.</li>
<li><strong>Your data, your tenant.</strong> Notes live in
a sandboxed directory only you can read.</li>
</ul>
<p>We've seeded a <code>welcome</code> note as a starting
point. Edit it, link from it, delete it — it's yours.</p>
<menu>
<button value="ok" autofocus>Get started</button>
</menu>
</form>
</dialog>
<dialog id="conflict-dialog">
<form method="dialog" id="conflict-form">
<h2>Sync conflict</h2>
@@ -51,6 +74,7 @@
<script src="/auth-client.js"></script>
<script src="/notes-cache.js"></script>
<script src="/sync.js"></script>
<script src="/onboarding.js"></script>
<script src="/app.js"></script>
<script src="/pwa.js" defer></script>
</body>

View File

@@ -81,4 +81,11 @@
// Kick off the sync controller. It registers online/offline
// listeners and runs an initial reconciliation pass when online.
if (window.notesSync) window.notesSync.start();
// First-run onboarding. Skips silently for returning users.
if (window.notesOnboarding) {
window.notesOnboarding.start().catch(function (e) {
console.warn("librenotes: onboarding error", e);
});
}
})();

View File

@@ -0,0 +1,106 @@
// onboarding.js — first-run welcome flow.
//
// Triggered by app.js after the session is verified. Behaviour:
// 1. Read the tenant-scoped key "onboarded" from localStorage.
// If truthy, skip silently — returning users never see this.
// 2. Otherwise, seed a sample welcome note via the notes API
// (only if the tenant has zero notes — we don't overwrite an
// existing notebook that the user already populated).
// 3. Open the welcome dialog. Dismissing the dialog persists
// "onboarded" so we never show it again on this device.
//
// Concepts surfaced in the welcome content match the user guide:
// notes-as-markdown, wikilinks, graph view (forthcoming),
// offline cache.
(function () {
"use strict";
const STORE_KEY = "onboarded";
const SAMPLE_ID = "welcome";
async function listNotes() {
const resp = await window.authClient.apiFetch("/api/notes");
if (!resp.ok) throw new Error("list: " + resp.status);
return resp.json();
}
async function seedWelcomeNote() {
const sample = {
title: "Welcome to librenotes",
content: [
"You're in. This note exists so you have somewhere to start.",
"",
"## A few things to know",
"",
"- Notes are Markdown files. The first `# Heading` is the title.",
"- Link between notes with `[[note-id]]`. Targets that don't",
" exist yet are created when you click the link.",
"- Edits sync automatically. Offline edits queue and push when",
" you reconnect.",
"- Your data lives only in your tenant directory; no other user",
" can read it.",
"",
"## Try it",
"",
"Edit this note. Create a new one. Link them with `[[..]]`.",
"When you're ready, see the [user guide](/docs/user-guide.md)",
"for more.",
"",
].join("\n"),
};
const resp = await window.authClient.apiFetch(
"/api/notes/" + SAMPLE_ID,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sample),
}
);
if (!resp.ok) throw new Error("seed: " + resp.status);
}
function openDialog() {
const dlg = document.getElementById("onboarding-dialog");
if (!dlg) return;
if (typeof dlg.showModal === "function") dlg.showModal();
else dlg.setAttribute("open", "");
}
function dismissOnConfirm() {
const dlg = document.getElementById("onboarding-dialog");
if (!dlg) return;
const form = dlg.querySelector("form");
if (!form) return;
form.addEventListener("submit", function () {
const store = window.authClient.tenantStore();
store.set(STORE_KEY, true);
});
}
async function start() {
if (!window.authClient || !window.authClient.isAuthenticated()) return;
const store = window.authClient.tenantStore();
if (store.get(STORE_KEY, false)) return;
// Best-effort seed: only when the notebook is empty so we don't
// disturb a user who has already created their own notes (e.g.
// signing in on a second device for the first time).
try {
const notes = await listNotes();
if (Array.isArray(notes) && notes.length === 0) {
await seedWelcomeNote();
}
} catch (e) {
// If the seed fails (offline, transient error), still show
// the dialog — onboarding shouldn't block on a network round
// trip. We just won't have a sample note to point at.
console.warn("librenotes: onboarding seed failed", e);
}
dismissOnConfirm();
openDialog();
}
window.notesOnboarding = { start };
})();

View File

@@ -319,3 +319,29 @@ a:hover { text-decoration: underline; }
background: transparent;
color: var(--accent);
}
/* Onboarding dialog reuses the conflict dialog visuals but
needs a slightly different size and list styling. */
#onboarding-dialog::backdrop { background: rgba(0,0,0,0.4); }
#onboarding-dialog {
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--bg);
color: var(--fg);
padding: 1.75rem;
max-width: min(36rem, 90vw);
width: 100%;
}
#onboarding-dialog h2 { margin-top: 0; }
#onboarding-dialog ul { padding-left: 1.25rem; }
#onboarding-dialog li { margin: 0.4rem 0; }
#onboarding-dialog menu { padding: 0; margin: 1rem 0 0; }
#onboarding-dialog button {
padding: 0.55rem 1.1rem;
border-radius: 0.35rem;
border: 0;
background: var(--accent);
color: var(--accent-fg);
cursor: pointer;
font-weight: 500;
}

View File

@@ -22,6 +22,7 @@ const PRECACHE = [
"/auth-client.js",
"/notes-cache.js",
"/sync.js",
"/onboarding.js",
"/login.js",
"/verify.js",
"/app.js",