From c92d4bf95310239c56bddc04c290f163215d7b22 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Tue, 28 Apr 2026 23:19:13 +0200 Subject: [PATCH] Add first-run onboarding flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- cmd/librenotes/web/public/app.html | 24 ++++++ cmd/librenotes/web/public/app.js | 7 ++ cmd/librenotes/web/public/onboarding.js | 106 ++++++++++++++++++++++++ cmd/librenotes/web/public/style.css | 26 ++++++ cmd/librenotes/web/public/sw.js | 1 + 5 files changed, 164 insertions(+) create mode 100644 cmd/librenotes/web/public/onboarding.js diff --git a/cmd/librenotes/web/public/app.html b/cmd/librenotes/web/public/app.html index 85ddb5d..94082b9 100644 --- a/cmd/librenotes/web/public/app.html +++ b/cmd/librenotes/web/public/app.html @@ -25,6 +25,29 @@

   
 
+  
+    
+

Welcome to librenotes

+

A few things to know before you start:

+
    +
  • Notes are Markdown. The first + # Heading line is the title.
  • +
  • Wikilinks. Connect notes with + [[note-id]]. Click an unresolved link to + create the target.
  • +
  • Offline-first. Edits queue locally and + push when you reconnect.
  • +
  • Your data, your tenant. Notes live in + a sandboxed directory only you can read.
  • +
+

We've seeded a welcome note as a starting + point. Edit it, link from it, delete it — it's yours.

+ + + +
+
+

Sync conflict

@@ -51,6 +74,7 @@ + diff --git a/cmd/librenotes/web/public/app.js b/cmd/librenotes/web/public/app.js index 3966e13..ac50479 100644 --- a/cmd/librenotes/web/public/app.js +++ b/cmd/librenotes/web/public/app.js @@ -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); + }); + } })(); diff --git a/cmd/librenotes/web/public/onboarding.js b/cmd/librenotes/web/public/onboarding.js new file mode 100644 index 0000000..804ce0e --- /dev/null +++ b/cmd/librenotes/web/public/onboarding.js @@ -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 }; +})(); diff --git a/cmd/librenotes/web/public/style.css b/cmd/librenotes/web/public/style.css index 0ddb877..06ff641 100644 --- a/cmd/librenotes/web/public/style.css +++ b/cmd/librenotes/web/public/style.css @@ -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; +} diff --git a/cmd/librenotes/web/public/sw.js b/cmd/librenotes/web/public/sw.js index 4c633f7..aeeda57 100644 --- a/cmd/librenotes/web/public/sw.js +++ b/cmd/librenotes/web/public/sw.js @@ -22,6 +22,7 @@ const PRECACHE = [ "/auth-client.js", "/notes-cache.js", "/sync.js", + "/onboarding.js", "/login.js", "/verify.js", "/app.js",