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>
|
<pre id="whoami-output"></pre>
|
||||||
</main>
|
</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">
|
<dialog id="conflict-dialog">
|
||||||
<form method="dialog" id="conflict-form">
|
<form method="dialog" id="conflict-form">
|
||||||
<h2>Sync conflict</h2>
|
<h2>Sync conflict</h2>
|
||||||
@@ -51,6 +74,7 @@
|
|||||||
<script src="/auth-client.js"></script>
|
<script src="/auth-client.js"></script>
|
||||||
<script src="/notes-cache.js"></script>
|
<script src="/notes-cache.js"></script>
|
||||||
<script src="/sync.js"></script>
|
<script src="/sync.js"></script>
|
||||||
|
<script src="/onboarding.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
<script src="/pwa.js" defer></script>
|
<script src="/pwa.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -81,4 +81,11 @@
|
|||||||
// Kick off the sync controller. It registers online/offline
|
// Kick off the sync controller. It registers online/offline
|
||||||
// listeners and runs an initial reconciliation pass when online.
|
// listeners and runs an initial reconciliation pass when online.
|
||||||
if (window.notesSync) window.notesSync.start();
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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;
|
background: transparent;
|
||||||
color: var(--accent);
|
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",
|
"/auth-client.js",
|
||||||
"/notes-cache.js",
|
"/notes-cache.js",
|
||||||
"/sync.js",
|
"/sync.js",
|
||||||
|
"/onboarding.js",
|
||||||
"/login.js",
|
"/login.js",
|
||||||
"/verify.js",
|
"/verify.js",
|
||||||
"/app.js",
|
"/app.js",
|
||||||
|
|||||||
Reference in New Issue
Block a user