Skip to content
Level Up LifeOS
Local workspace

Devlog

How Level Up was built

A project about evidence should show its work. These entries record the product decisions, engineering tradeoffs, failures, and changes that shaped Level Up.

2026-09-29

When a Counter Became a Protocol

LevelUp used to save LifeOS as one JSON snapshot. Complete a mission, add its XP to a number, push a record into an array, and overwrite localStorage. That is a perfectly reasonable first version. It is also the point where "local-first" often stops meaning anything deeper than "we did not build a backend."

The product now asks users to invest in a history: goals, proof of work, skill levels, streaks, and a companion that remembers the work. If that history is worth building, it has to survive more than the happy path. What happens when two tabs complete work at nearly the same time? When a packet is imported twice? When a laptop clock moves backward? When a write is damaged halfway through? When a device has been offline and rejoins later? A prettier screen does not answer any of those questions.

So the counter became a protocol.

Choosing the boundary

The first decision was not to distribute everything. The older reader, highlights, study mode, OS workspace, and companion each have functioning local stores and migrations. Rewriting every one would create a huge blast radius and hide the interesting part under conversion work. Nexus v1 owns the new LifeOS boundary only: profile, preferences, and completed real-world missions.

The UI still consumes the same LifeState shape. Underneath it, though, that shape is now a materialized view of immutable operations:

  • profile.set
  • preferences.patch
  • mission.complete

This kept Today, Journey, Coach, Progress, Profile, and Milo functioning while the source of truth changed below them. It also gave the old snapshot a clear role: compatibility for older backups, not authority for new writes.

XP is a projection, not a mutation

The most important model change was removing "increment XP" as a stored event. A counter cannot tell whether the same command has already arrived. Nexus stores a mission completion with a stable mission ID. XP, skill XP, streaks, and totals are derived from the winning completion facts.

That means the same packet can arrive once, twice, or twenty times and still produce one reward. If two offline replicas both claim the same mission, a hybrid logical clock and actor ID choose the same winner on every device. The losing operation remains inspectable in the uncompacted log, but it cannot mint extra progress.

Making offline order irrelevant

Every browser document writes as a distinct actor. Its operations have a monotonic sequence, a version-vector dependency snapshot, a hybrid logical clock, and a canonical SHA-256 digest linked to the actor's previous operation. Merge validates the envelope, workspace, payload, digest, next sequence, and previous head before admitting an operation.

The first convergence test deliberately creates two replicas, cuts the link, writes different missions and preferences on each side, and delivers the two envelopes in opposite directions. The materialized states and replica digests must match. A separate test winds the wall clock backward and verifies that the logical counter preserves order.

The failure tests mattered as much as the success test. They modify a signed payload, remove an actor sequence, create two different operations with the same actor and sequence, repeat the same envelope, and tamper with encrypted ciphertext. Each one must fail without changing progress.

Storage is now a small durability system

The synchronous path writes the verified replica to localStorage before the UI announces it. IndexedDB holds a strict-durability mirror. BroadcastChannel moves idempotent envelopes between live tabs, with the browser storage event as a fallback. Before a local append, the tab refreshes from the write-ahead copy so it can absorb work from another actor.

Logs cannot grow forever. Compaction folds a verified prefix into a checkpoint containing the projection, version-vector frontier, and the last digest for every actor. New operations continue from those heads. The normal command path checkpoints automatically after 512 retained operations; the Sync Lab also lets a reviewer trigger it explicitly after a warning.

This is not a server database pretending to fit in a browser. Storage quota, private browsing policies, and a user clearing site data still exist. The design makes those boundaries visible and creates two independent local copies; it does not promise magic durability.

Encryption without inventing a cloud

GitHub Pages cannot run a private sync service, and pretending a cloud existed would be worse than shipping a manual transport. A Nexus transfer exports the real envelope as AES-GCM-256 ciphertext. PBKDF2-HMAC-SHA-256 derives the key from the user's phrase with a random 16-byte salt and 210,000 iterations. Each packet gets a random 12-byte IV and authenticates its version, room ID, and creation time as additional data.

The phrase is not saved. A wrong phrase or changed byte fails authentication before merge. The ordinary complete backup remains readable JSON, now including the full Nexus replica so it preserves provenance. The UI calls out that the backup is not encrypted instead of letting users infer security from the other feature.

The engine room had to be visible

Deep infrastructure that nobody can see is difficult to evaluate and difficult to trust. /sync-lab/ is an operations console for the actual local replica. It shows the version-vector frontier, actor count, retained operation suffix, state digest, checkpoint, and current audit result. Its transfer controls call the real Web Crypto functions.

The lower half is a two-node field test. Pull the cable, write on A and B, create a same-mission conflict, and reconnect. The display only says CONVERGED when both replicas have the same digest. It is not an animation standing in for a system; it is a small client of the system.

What broke while building it

The first convergence assertion failed by one millisecond. Both replicas had identical domain operations, but LifeState.updatedAt was using envelope arrival time. That meant delivery order leaked into the supposedly convergent state. The fix was to derive the timestamp from the winning operation clocks, not replica metadata.

The first clean production rebuild also crashed inside Turbopack while restoring a truncated cache block. Moving only the generated .next cache aside and building from source fixed it; application code and TypeScript were not the cause. Recording that distinction matters because deleting arbitrary state is not an acceptable general response to a storage problem.

AI assistance and ownership

This transformation used an AI coding agent for repository inspection, implementation, test generation, documentation, and QA assistance under a detailed product brief. The protocol is not pasted from an external project, and the repository contains the full implementation and tests rather than a hosted black box. The design decisions, limitations, failures, and verification are documented so they can be reviewed from the code instead of being accepted on the strength of a claim.

The remaining limitation is equally important: there is no opaque hosted relay, device-key signature system, or Byzantine consensus. The current deployment supports deterministic local replicas, same-origin live reconciliation, and explicit encrypted device transfer. A future relay should store only encrypted envelopes; until it exists, the product says it does not exist.

2026-09-24

Making One Creature Aware of the Whole App

Level Up did not have one giant global state object. That was a good thing for the product and an awkward thing for a persistent companion.

Reading progress, highlights, quizzes, reflections and streaks already lived in small local stores. Goals, roadmaps, tasks, focus sessions and reviews lived in a separate typed operating-system store with its own reducer and completion journal.

The wrong solution would have been to import mascot code into every page.

The companion now has two inputs instead:

small Level Up stores ----> pending domain-event queue
                                   |
OS completion journal ------------+
                                   |
                                   v
                            companion pump
                                   |
                                   v
                              pure reducer

The OS adapter keeps a cursor, so the same completed focus session is never replayed just because React rendered again. Smaller stores emit bounded pending events and wake the global runtime with a browser event. Both paths end in the same reducer.

The result is that the visible character knows about the app without owning the app.

That separation matters. A focus page can remain a focus page. A chapter can remain a chapter. Milo only receives the domain fact: FOCUS_STARTED, CHAPTER_COMPLETED, QUIZ_FAILED, HIGHLIGHT_CREATED, and so on.

The event journal is also the debugging surface. If the room changes, I can inspect which event caused it instead of guessing which animation fired.

2026-09-24

A One-Minute Demo Without Faking the Engine

A persistent companion has a demo problem: the interesting version gets better after the user has accumulated real history.

A reviewer should not need five focus sessions and a week of progress to see that.

The Companion Space now includes a disposable reviewer fixture. It uses the same production reducer as the saved companion, but starts from a deterministic in-memory state and never overwrites the user's local record.

The demo steps are deliberately small:

  • start focus
  • complete focus
  • open a Health chapter
  • complete the chapter
  • reach a roadmap milestone

The fixture starts one focus session below the desk-lamp threshold. Completing the demo session therefore produces a visible room change immediately while still exercising the real unlock rule.

The Engine tab shows the resulting traits, current behaviour, route/context and event journal. The goal is not a staged video trick. It is to let somebody inspect the system while it is happening.

This also became a useful development tool. When a room object does not appear, the question is no longer "did the animation break?" It is "what event entered the reducer, what state came out, and which unlock rule qualified?"

2026-09-24

Teaching Milo to Live Without AI

The easiest version of this feature would have been an LLM with a cartoon face.

I did not want that.

The core behaviour is a deterministic selector running on top of persistent state. Important context wins first:

  1. milestone reaction
  2. active focus session
  3. recovery
  4. night rest
  5. chapter context
  6. ambient behaviour

A Health chapter can make Milo stretch. Wealth can shift him toward the tiny ledger. Love can produce writing. Self can produce reading or thinking.

Idle behaviour is selected from a small authored pool using the companion seed and an idle tick. That means it can vary without becoming impossible to reproduce in tests.

The character itself is lightweight SVG/CSS. React changes semantic state; CSS owns the continuous motion. There is no animation-frame React render loop and no model request deciding where an arm should move.

I also removed negative relationship/confidence penalties from failed quizzes, interrupted sessions and broken streaks. Failure is useful input, but a self-improvement pet should not become an emotional punishment system.

AI can still be optional flavour elsewhere in Level Up. It is not the brain required to keep the companion alive.

2026-08-19

Eight Hours, Two Fewer Things

"Simplify the home page." That was the assignment. Eight hours later, the page has a privacy badge, a hue that shifts with the clock, and two fewer visible things than it could have had. This is the story of how that happened, and it is not a story about restraint. Not at first.

We already told you about the declutter: the hero contents box came out, the four parts of the book moved into the nav as an accordion, and every element left on the page had to answer a question the reader actually asked (commit 14400ef). Then the scorecard push landed - knowledge checks, themes, badges, offline support, even this devlog - and the home page started feeling like the plain cousin at a loud party. A reader said it still read as boring. Fair. So we asked the dangerous question: how do we make it feel alive?

That question produced a design brief with roughly twenty-six answers. A dynamic hero momentum card. A visual pillar constellation. Micro-interactions, scroll-triggered typography, a breathing background gradient, an activity heatmap, an inline quiz widget, an XP header, a time-of-day aesthetic shift, a privacy badge. All of it local-first by construction: the site is a Next.js 16 static export to GitHub Pages (basePath /LevelUp), so anything "alive" has to come from the browser's own localStorage - keys like levelup-streak-v1 and levelup-quiz-v1 - zero APIs, zero telemetry.

Then came the QuoteCard.

  • Commit 6601138 added it: a React 19 client component that fetches /data/quotes.json on mount, picks a random quote, and renders it with a "Read chapter" link. Locally: build green, 22/22 tests, eslint clean.
  • GitHub Actions failed eight times in a row. The same phantom TypeScript error every run: TS7006, parameter 'array' implicitly has an 'any' type.
  • The spiral: a type fix, a @ts-ignore, a @ts-nocheck, then empty commits to force re-runs. The commit messages tell the story honestly: "force CI re-run", "another CI trigger", "force CI re-re-re-run". Every re-run failed identically while the same tree passed locally.

It was never a code bug. It was the environment disagreeing with us, and it ate a day before we accepted that.

What actually shipped was restraint (commit 13e3391): a PrivacyBadge -"100% Private · On-Device Storage Only", shown once per session after two seconds - and a ThemeTimeShift, a tiny client component that reads the clock once on mount and nudges the --hue CSS variable from crisp and bright in the morning toward moody and calm at night. Two small things. The 26-idea brief was cut to four priorities, and only two shipped. The quote card that cost us a day of CI is not even on the page anymore; it was deleted in commit 13e3391.

Eight hours, two additions, and two fewer things than we could have shipped. We are prouder of what we did not build than of what we did. The rule: subtraction is a loop, not a one-time cleanup - and stopping, with the brief still on the table, is the feature.

2026-08-19

Under the Hood: A Static Site That Thinks

A book about evidence should show its work — and this site is no exception. Here is what happens under the hood when you open a chapter, search, or adjust your reading experience.

Local storage, the only "server"

Every dynamic thing on this page derives from the browser's own storage. No APIs, no telemetry, no third-party scripts. The key namespaces live under levelup-*:

  • levelup-streak-v1 — current/best streak, last date
  • levelup-quiz-v1 — per-chapter quiz scores {slug: {score, total, ts}}
  • levelup-highlights-v1 — array of {id, slug, quote, createdAt}
  • levelup-reflections-v1 — {slug: text}
  • levelup-progress-v1 — {slug: {complete, maxScroll, updatedAt}}
  • levelup-reader-scale — 1, 0.85, 1.15, or 1.3
  • levelup-reader-lh — line-height preset: tight/normal/airy
  • levelup-reader-font — font family: source/serif
  • levelup-reader-contrast — true enables a higher-contrast token set

Read/write is orchestrated via useSyncExternalStore in lib/activity.ts and lib/progress.ts: a store subscribe/emit pattern that keeps React cellular and persisted across sessions. The core contract is in addHighlight, saveQuizResult, recordActivity, and markComplete — each reads the current snapshot, mutates it, JSON-stringifies it, and writes it back. If window.localStorage is absent (e.g. private browsing), the fallbacks are empty arrays/objections so the UI degrades gracefully.

// From lib/activity.ts — highlight storage snapshot
function readJson<T>(key: string, fallback: T): T {
  try {
    const raw = window.localStorage.getItem(key);
    return raw ? (JSON.parse(raw) as T) : fallback;
  } catch {
    return fallback;
  }
}
// From lib/search.ts — MiniSearch engine bootstrap
import MiniSearch from "minisearch";

export async function getSearchEngine() {
  if (engine) return engine;
  const base = process.env.NEXT_PUBLIC_BASE_PATH || "";
  const res = await fetch(`${window.location.origin}${base}/data/search-index.json`);
  const docs = (await res.json()) as Doc[];
  engine = new MiniSearch({
    fields: ["title", "sub", "teaser", "text"],
    storeFields: ["id", "type", "title", "sub", "teaser", "url"],
    searchOptions: {
      boost: { title: 4, sub: 2, teaser: 1.5 },
      fuzzy: 0.2,
      prefix: true,
    },
  });
  engine.addAll(docs);
  return engine;
}

How search works

When you press ⌘K (or click the magnifying glass), a Modal opens and fetches the prebuilt index at public/data/search-index.json (generated at build time by scripts/build-data.mjs). MiniSearch initializes on first open and is cached for the session. Typing ≥2 characters triggers engine.search(query) with fuzzy matching, prefix support, and title/sub/teaser boosting. Results appear instantly — the entire index is ~30KB and lives in the browser.

Reader customization

Three knobs live in local storage and affect the .book-prose root:

  • Font size (A−/A+): scales font-size via --reader-scale (85–130%).
  • Line height (tight/normal/airy): sets data-reader-lh, which maps to line-height in globals.css.
  • Font family (serif/sans): sets data-reader-font, which maps to font-family in globals.css.
  • High contrast (toggle): sets data-contrast="high", which swaps the colour palette in globals.css for stronger ink/line contrast and neutralizes muted text-ink-faint roles.

All four values persist across sessions and are honored on page load without a round-trip.

How this was made (AI transparency)

The raw text of the twenty-eight chapters was originally processed by an LLM to extract key concepts, evidence grades, and protocol references — roughly 20 000 lines of transcript compressed into structured data. The LLM also helped clean up syntax and format the devlog entries you're reading now.

The architecture, UI, state layer, Navigation accordion, search indexing, build pipeline, and deployment workflow are all mine. I wrote the localStorage handlers, structured the nested accordion menu, built the reading progress tracker, and saw the project through from static export to GitHub Pages deploy. The LLM was a utility for compressing raw transcript text into structured chapters — not a substitute for hands-on code ownership.

Demo

<video controls autoplay loop style="max-width: 100%; height: auto;"> <source src="/devlog/demo-reading-flow.mp4" type="video/mp4"> Your browser does not support the video tag. </video> <video controls autoplay loop style="max-width: 100%; height: auto;"> <source src="/devlog/demo-theme-switcher.mp4" type="video/mp4"> Your browser does not support the video tag. </video>

(Videos are short GIF/MP4 exports of the reading flow and theme/typography switcher captured via headless Chrome + ffmpeg. They are not embedded in the live deployed site but appear in this devlog for transparency.)

2026-08-17

The Scorecard Push

Every chapter used to be a wall of text with a scroll bar. This update turned the reading experience into something a reader can do things with:

  • Knowledge checks — each chapter ends with three questions drawn from its own key concepts, evidence grades, and protocols, plus a reflection prompt. No API, no accounts: the questions are generated deterministically at build time and the answers are checked locally.
  • Text highlighting — select a passage, mark it, and it stays highlighted.
  • A scroll-spy table of contents — the chapter sidebar now follows you down the page.
  • J and K keyboard shortcuts — flip between chapters without touching the mouse.
  • Group banners — the four parts of the book announce themselves at chapters 1, 12, 16, and 21.
  • Backup & restore — export your progress, highlights, quiz scores, and reflections as a JSON file; import them on any other device.
  • Levels and badges — local gamification with honest rules: XP for chapters, quizzes, highlights, and reflections; nine badges from First Step to Quiz Master.
  • Reader themes — Light, Dark, Deep Work (low-glare sepia), and Cyberpunk.
  • Offline support — a service worker precaches the shell and data so the book works without a connection.
  • A devlog — this page, because a book about evidence should show its work.

Everything is derived from real chapter data, everything is local-first, and nothing phones home.

Before and after

The home page before this push (the first version shipped with the book) and after the declutter:

Home page before the declutter

Home page after the declutter

2026-08-17

Why the Home Page Got Simpler

The first version of this site crammed a lot onto the home page: hero copy, a big "Contents" box that listed the book's four parts, a "how to read" section, stats, and three call-to-action buttons. It looked like a pilot's cockpit — dense, impressive, and hard to know where to start.

A reader gave feedback that landed hard: the page was trying too hard, and the contents box made the table of contents feel like a rerun. The fix was subtraction:

  • Removed the hero contents box entirely. The chapters grid still lives one click away, and the four-part grouping moved into the navigation itself.
  • Replaced the nav's chapter list with an accordion menu (Table of Contents) that collapses the four parts of the book — Build the Belief Engine, Move to Action, Train Body & Mind, Build the Business — so the 28 chapters stop intimidating before you click.
  • Kept the stats row and the "how to read" section, because both answer the real questions: is this verified? and how long will this take?

The rule that came out of it: every element on the home page must answer a question the reader actually asked. If it doesn't, it gets cut.

2026-08-16

Plain English, Same Evidence

The book started as a dense distillation of twenty-eight self-development trainings. Dense is good for reference; it is bad for reading. So every chapter went through a plain-English pass with three hard rules:

  • Quotes stay verbatim. If the source said it, we do not paraphrase it.
  • Grades stay honest. Every A–D evidence grade survived untouched. The audit page still shows exactly why each claim got what it got.
  • Numbers and protocol references stay exact. Protocol 2.7 is still 2.7.

Twenty-two of the twenty-eight chapters changed in some way; six were already plain enough. Across the whole book, the word count stayed nearly identical (44,212 → 44,141) — this was a rewrite for clarity, not for length.

The style target was simple: if a sentence needed reading twice, it got rewritten once.