On this page1. Guiding principle

Onboarding

How a new user comes to understand and use SkillNet. This document sets the model (principles + flows), the architecture that supports it, and a phased plan. It is the measuring stick: any future onboarding screen is checked against this.

Related: degraded-mode-ux.md (states without a key), personalization.md (learner profile), audience-modes.md (organization / individual).


1. Guiding principle

It should set free, not tie down. SkillNet sells freedom and adaptation; onboarding has to feel the same. The filter for every decision: does this stop the user or free them?

From there, six pillars:

  1. The platform is never empty. It starts with a demo course already set up (seed). No blank dashboard.
  2. Guidance yes, but the door always open. A “step 1 of N” by default (many people want it and it is the path that converts the most), closable, reopenable, and remembers progress. Closing it does not punish with emptiness: you land on the full platform.
  3. Value is discovered by touching it, instantly. The first win is pre-generated content (fast, no key, no waiting). Never a live generation as a welcome (slow = defeat).
  4. The differentiator is the generative UI. These are not templates with swapped text: each lesson’s interface is composed for that content and that person. It is made evident with an ignorable nudge (“look at the same lesson for another person → it is different”).
  5. Personalization is offered or inferred, never imposed. Capturing “how you learn” is optional; what is not asked is learned from behavior.
  6. The key (API key) unlocks, it does not charge. Without a key you explore the demo; with a key you do it with your own content. You reach the key once value has already been seen and is wanted.

2. The architectural idea: capability-driven onboarding

The elegant point: onboarding, no-key degradation, and smart defaults are the same problem“based on what is available and who you are, show one thing or another”. They are solved with a single source of truth instead of if (hasKey) scattered across the code.

                 ┌──────────────────────┐
   settings/env  │  Capabilities        │  ai, generation, tutor, tts, images,
   keys          │  (what AI is there?) │  google_login → GET /setup/status
                 └──────────┬───────────┘
                            │  useCapabilities()
        ┌───────────────────┼────────────────────┐
        ▼                   ▼                    ▼
  Onboarding steps    UI elements          Degraded banners
  (filtered)          (<Gated requires>)   (degraded-mode-ux)

2.1 Capabilities — the source of truth

An object derived from the presence (and validity) of keys, exposed by the backend:

type CapabilityStatus = 'ready' | 'degraded' | 'blocked'
type CapabilityReason =
  | 'missing_api_key'   // no usable key for that capability's provider
  | 'not_configured'    // provider deliberately switched off in config
  | 'provider_quota'    // a recent 429/402 from the provider
  | 'provider_down'     // recent hard failures or timeouts

interface Capability {
  status: CapabilityStatus
  reason?: CapabilityReason | null
  hint?: string | null   // ADMIN ONLY; always null on public responses
}

interface Capabilities {
  ai: Capability           // a usable LLM exists (nothing works without this)
  generation: Capability   // generate courses/lessons
  tutor: Capability        // tutor chat
  tts: Capability          // voice (mascot / podcast) — degrades to the offline engine, never blocks
  images: Capability       // infographics, slide illustrations — blocks, does not degrade
  google_login: Capability // "Sign in with Google" is configured
}

A boolean could hide a control and could not explain one, so every deployment that was missing something looked the same. GET /setup/status is public and pre-authentication: it carries status and reason, and hint always as null — a hint names environment variables, and telling an anonymous caller which key a deployment is missing is configuration disclosure. The version with hint lives at GET /settings/capabilities, which requires an admin. Both responses also carry media_requirements: which capabilities each media kind needs, so the client keeps no copy of that table.

  • Backend: computed in one place (presence of LLM_API_KEY, TTS_API_KEY, OPENROUTER_API_KEY). Today GET /setup/status already exists and is the natural place (we already added onboarding_enabled to it).
  • Frontend: a useCapabilities() hook. Any AI piece consults it; nobody hardcodes “there is a key”.

2.2 Declarative gating (static element vs. needs-a-key)

Instead of scattering conditionals, a single component/hook:

<Gated requires="tutor">
  <TutorPromptChip />        {/* only renders if capabilities.tutor */}
</Gated>

Rule: the AI element turns on only if its capability is present. Mind the check: a capability is an object and every object is truthy, so a bare capabilities[name] is always on — ask for its status (isReady / isAvailable in api/setup.ts).

hide is one of two modes. The other, mode="explain", does the opposite on purpose: it leaves the control visible and inert with the reason attached, for the places where hiding would confuse more than explaining. See degraded-mode-ux.md §5. Examples of hide:

Always (static / pre-baked) requires (turns on with a key)
Demo course (seed), viewing it Free-form “other” option in questions (generation/ai)
Contrast nudge (two people, seed) Generated preview / “create your first course” (generation)
Fixed presets for the questions Chat with the tutor (tutor)
Tour (joyride) Personalization that regenerates on the fly (generation)

Design key: the no-key side must feel complete (the pre-baked content carries the value). The AI part is additive, not “the good stuff was hidden”.

2.3 Onboarding as data, not as a hardcoded flow

The tour is a declarative list of steps; joyride just consumes it. Adding/removing/reordering = editing data.

interface OnboardingStep {
  id: string
  role: 'employee' | 'admin'
  target: string           // selector of the element to highlight
  title: string; body: string
  requires?: keyof Capabilities   // step is skipped if the capability is missing
  order: number
}

Runtime filter: steps.filter(role).filter(s => !s.requires || flags[s.requires]), where flags comes from flattening the capabilities to booleans (readyFlags, in ProductTour.tsx). Asking capabilities[s.requires] directly filters nothing: they are objects, and every object is truthy. A step that talks about the tutor disappears without a key, with no ad-hoc branches.

2.4 Onboarding state — per user, persisted, reopenable

interface OnboardingState { completed: boolean; dismissedAt?: string; lastStepId?: string }
  • Orthogonal to routing: it never forces a redirect (the ONBOARDING_ENABLED flag already exists).
  • MVP: localStorage. Later phase: per-user field (cross-device).
  • Reopenable from a persistent “?”; lastStepId gives the “remember where I was” behavior.

2.5 Smart defaults — a resolver from the org archetype

The admin gives a minimal hint (education / enterprise) → a resolver maps archetype → defaults.

type Archetype = 'education' | 'enterprise' | ...
function resolveDefaults(a: Archetype): OrgSettings   // e.g. enterprise ⇒ mascot off
  • One single resolver, not scattered conditionals.
  • Values are defaults, always overridable (user/org).
  • Every automatic decision is shown and reversible (“We turned off the mascot because this is an enterprise environment — change it here”). Never silent magic.

2.6 Shared demo = first-class asset

The test course is a seed present in every deployment (is_demo), an example for both roles: the admin sees “this is what gets generated”, the employee does it. Visible without a key (already generated). With a key, the same course becomes conversable (tutor) and adaptable/regenerable — the key does not change what you see, it changes how much you can do with it.


3. Flows (one pattern, two fillings)

Branches only on the role; each branch is minimal. Common structure: framing → one capture that matters → first win.

3.1 Employee (account created by the admin; never touches the key)

  1. Login → non-empty home (enrolled in the demo / their courses) + a gentle “start here”.
  2. Default, closable tour: “open your first lesson” → tap a lesson that is already pre-generated and personalized (instant, prewarm). Aha = rich and adapts.
  3. Optional micro-capture: “how do you like things explained? (optional)” → presets + “other” (the “other” only with a key). Skippable → default profile + behavioral inference.
  4. Contrast nudge (ignorable): “here is how we explain it to another person” → same material, different UI → captures personalization.
  5. From there, just learn.

3.2 Admin / owner (self-hosts and sets up the organization)

  1. Minimal setup (already exists) + one archetype question (education/enterprise → defaults).
  2. Non-empty dashboard with the demo course + closable tour.
  3. Discover the generative UI by touching the demo (same two-person contrast).
  4. “Make it yours” moment: “Create your first course”. If there is no key → “connect your AI” (why + link to the provider + paste + live validation). It is not a toll: you arrive here already convinced. Meanwhile, everything is explorable; AI actions show “connect a key” in their place.
  5. Real generation = in the background + notification. Never a loading bar as a welcome.
  6. Invite employees.

4. Phased plan (cheap → expensive; nothing complex up front)

Tour engine: react-joyride (spotlight, “step 1 of N”, skip/close, step control). We provide the data and state (§2.3, §2.4), not the engine.

Status (2026-08-21): Phase 0 ✅, Phase 2 infra (Capabilities/<Gated>) ✅, and the degraded-mode banner ✅ are done and on main; the admin tour (Phase 1) too. What remains, with a decision/design step in between: the 3-way archetype + resolveDefaults, the contrast nudge, the API key from the UI, background generation, and Phase 3.

Phase 0 — MVP ✅ DONE

  • Non-empty home with the demo (seed). ✅
  • Joyride tour for employees (home → first pre-generated lesson), closable, localStorage. ✅
  • Non-blocking profile (ONBOARDING_ENABLED + existing capture). ✅
  • Only static elements. ✅

Phase 1 — make the differentiator visible ◑ PARTIAL (remainder: pending design)

  • Contrast nudge (two people, seed data) — pending.
  • Admin tour — done: role-aware ProductTour (same joyride, admin steps).
  • 1 smart default: enterprise/education archetype → resolveDefaults (enterprise turns off the mascot) + the line that explains it — pending (needs archetype backend).

Phase 2 — the self-hosted unlock (the AI layer) ◑ PARTIAL

  • Capabilities + useCapabilities() + <Gated> (§2.1–2.2) — infra done (backend derives it from the keys and exposes it at /setup/status; the frontend consumes it).
  • Degraded-mode banners (CapabilityHealthBanner in admin) — done.
  • API key from the UI (paste + validate) — pending (storage/security decision).
  • ○ Turn on the requires: free-form “other”, generated preview, tutor chat.
  • Background generation + notification.

Phase 3 — the complex part, last ○ PENDING

  • Infer the profile from behavior (what they open/re-read/skip). Separable signals module.
  • Cross-device onboarding state, finer archetypes.

5. What already exists vs. what’s missing

Already exists (✅) Missing (○)
Setup wizard + welcome (logo/mascot/degraded), login, home Archetype question + resolveDefaults
Demo course (seed) + pre-generated lessons (prewarm) Contrast nudge (two people)
Role-aware joyride tour (employee + admin), reopenable, per-role state API key in the UI (paste+validate) — today in .env
Profile capture + ONBOARDING_ENABLED flag Background generation + requires (“other”/preview/tutor)
Capabilities + useCapabilities + <Gated> + degraded-mode banner Phase 3 (infer profile, cross-device)

6. Summary in one sentence

Full platform from the start · guidance by default but always closable and reopenable · value by touching, not by telling · profile offered or inferred, never imposed · self-chosen and reversible defaults · the key unlocks, it does not charge — all under a clean engine: the app declares what it needs (capability × role) and a resolver decides what to show and how to degrade.