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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
106
cmd/librenotes/web/public/onboarding.js
Normal file
106
cmd/librenotes/web/public/onboarding.js
Normal 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 };
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const PRECACHE = [
|
||||
"/auth-client.js",
|
||||
"/notes-cache.js",
|
||||
"/sync.js",
|
||||
"/onboarding.js",
|
||||
"/login.js",
|
||||
"/verify.js",
|
||||
"/app.js",
|
||||
|
||||
Reference in New Issue
Block a user