Status: draft for review · 2026-06-03 · target repo mobius-os/app-notes
A boxy, card-based notes app for Möbius. Notes are markdown that renders live as you type (Obsidian "Live Preview" style), with inline images and file attachments, LaTeX math, handy formatting shortcuts, pinning, per-note color, and instant search. It is beautiful, consistent with Möbius (charcoal + violet, Inter / JetBrains Mono), and follows good UI/UX practice. It works offline, keeps a git history, and when offline edits collide on reconnect it auto-merges, or invokes an agent when auto-merge fails. Notes live on disk as plain markdown so the dreaming agent can read them cleanly.
Ships as an app-store app (mobius-os/app-notes): one index.jsx, one
mobius.json, a generated icon.png, and no recurring cron jobs — plus a small,
well-precedented platform change that vendors the editor foundation
(CodeMirror 6 + KaTeX) under /vendor, exactly as three.js and React are
vendored today (see §4, §13). Sizing justifies it: CM6 is smaller than the
already-vendored three.js (§4). The only other optional platform touch is one
line in the dreaming skill (see §9).
We do not reference or imitate the Google Keep brand. The name is Notes
(slug notes); easy to rename before ship.
- Real-time collaborative cursors / CRDT. Single-owner; per-note 3-way merge is enough. (The runtime explicitly scopes CRDTs out.)
- Multi-device branch sync. We do per-note reconcile, not per-device git branches.
- A server endpoint for atomic conditional writes. We work entirely within the existing storage API + owner-agent primitives.
- Sharing notes between apps or users.
(Confirmed by reading the codebase, not assumed.)
- A mini-app is one JSX file esbuild-compiles server-side. React 19.2 comes
from the importmap (
/vendor/react). Apps may use esm.sh libs at runtime; for offline, esm.sh modules are runtime-cached by the service worker (url.hostname === 'esm.sh'→ CacheFirst) — a warm-cache model: a dep works offline only after one online load.mobius.json'sesm_depsis metadata only (no backend wiring). window.mobius.storage—get/getText/getBlob,set/setText/setBlob,remove,list,subscribe*,pendingCount. Reads are SWR; writes go through an IndexedDB outbox that is last-write-wins per path, coalesced, drained under a cross-context Web Lock. There is no conditional/atomic write. Blob cap 25 MiB. Per-path in-tab serialization; cross-runtime reads can momentarily diverge and self-heal.- Sandbox:
allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation-by-user-activation. Noallow-modals→ noalert/confirm/prompt; build in-app modals. Same-origin →fetch('/api/…')works with the owner JWT. CSPconnect-src 'self' https://esm.sh. - User-rendered HTML must pass DOMPurify.
- Jobs: apps may ship
job.sh/fetch.sh. A recurring cron is optional, but Notes does not install one. - App→agent with tools: an app token cannot call
/api/aiwith tools. Tool-capable agents come from the owner shell viawindow.parent.postMessage({type:'moebius:new-chat', draft}). - The dreaming agent runs nightly with full Bash/Read over
/data; it discovers app data by inspecting/data/apps/<id>/…(no advertised contract today). Writes the knowledge graph to/data/shared/memory/. - App data dir is keyed by the numeric app id at runtime
(
/data/apps/<numericId>/…), addressed from the app asstorage.<op>('<path>')(the runtime prefixes the app root). On-disk source is/data/apps/<slug>/index.jsx.
Möbius theme via CSS vars (never hardcode structural colors): --bg,
--surface, --surface2, --border, --text, --muted, --accent,
--accent-fg, --green, --danger, --font, --mono. The UI lives in a
single module-level stylesheet (src/ui/css.js) rendered once as
<style>{CSS}</style>, with mobius-ui:* fence comments around shared shapes.
Inline style={} is reserved for genuinely measured runtime values, such as
popover coordinates.
Grid (home). Responsive, content-height note cards in a CSS grid using
repeat(auto-fill, minmax(min(100%, 190px), 1fr)), so phones, tablets, and wide
web windows all settle into native-feeling track counts. A Pinned section
sits above Others. Each card shows:
title, a read-only markdown preview (truncated, with checkboxes + a first
inline image thumbnail), color accent, and a bottom action strip. Tap a card →
editor. A top bar holds search (instant,
client-side over title+body) and a persistent + button creates a note —
there is no inline capture row and no view toggle. Empty state is a friendly
prompt with a direct New note action, not a blank screen.
Large notebooks keep lightweight card shells in the grid but hydrate markdown,
thumbnails, and action toolbars only near the viewport. One shared observer plus
CSS content-visibility bounds CPU/DOM work without a virtualization dependency;
parsed preview HTML stays cached while offscreen DOM and object URLs are released.
Card actions (no native dialogs): pin/unpin, color picker (muted ink-tone palette: slate / moss / sand / clay / plum + default, mixed from theme tokens), lock/unlock, delete (in-app ConfirmModal). Locked notes are read-only in the editor and cannot be deleted until unlocked. There is no archive and there are no tags (legacy notes carrying either still open fine; the fields are ignored).
Editor (note open). Over-grid note surface, Keep-style, with the
live-inline markdown editor (§4). Header: back, pin, one image/file
attachment action, color, lock, type, delete, and status when needed; the
open note does not repeat the app title. Auto-saves (debounced) — no save
button; opening (or a no-op flush) never bumps updated, only a real edit
does, and the grid sorts by last edit. Back or outside-click returns to grid.
Status. Silent when healthy: saving/pending is plumbing and never
rendered. Only Offline and an actionable Resolving… conflict state
surface. A conflicted note shows a small banner:
"Edited in two places — merging…" and, when the agent finishes, updates live
via subscribe.
Accessibility / polish. Real buttons with aria-label; focus rings; 44px
touch targets; reduced-motion respected; no layout shift on image load
(reserve aspect ratio). Hidden scrollbars (frame already does this).
CM6 is the engine behind Obsidian Live Preview: markdown tokens hide + style in place on inactive lines, raw syntax shows only on the cursor's line, and image/checkbox/embed syntax is replaced by widget decorations. That is exactly "renders as markdown as you type."
- Delivery: vendor the editor foundation under
/vendoras a general platform dependency (revised after sizing). Measured minimal CM6 set = 556 KB raw / 188 KB gzip; KaTeX JS = 259 KB / 74 KB gzip — smaller than the already-vendored three.js (~1.2 MB / ~340 KB gzip) and ~React-sized. So CM6 + KaTeX are vendored exactly like three/React: an image-buildnpm install+ a small bundle script copies them to/app/static/vendor/codemirror@6/and/vendor/katex@…/(KaTeX CSS + fonts copied too, for offline math), andapp-frame.html's importmap gainscodemirror+katexspecifiers. - Pull only the pieces we use (owner's steer): the vendored CM6 bundle
includes only
@codemirror/state,@codemirror/view,@codemirror/commands,@codemirror/language,@codemirror/lang-markdown(+ our extension) — nobasicSetup, autocomplete, lint, or search — so it stays ~React-sized. - Build CM6 as ONE bundled module exposed under importmap specifier
codemirror. A single bundle makes@codemirror/statea guaranteed singleton (the instanceof gotcha disappears), is precached → bulletproof offline (no esm.sh warm-cache fragility), and is reusable by future apps (a code editor, the latex app, a wiki).marked+DOMPurifystay on esm.sh (tiny, already proven offline in mind/news). - Live-preview extension: a small custom decoration set: heading sizing,
bold/italic/strike/code styling, hide markers off the active line, task-list
checkboxes as interactive widgets (toggle writes back to the doc),
![]()image syntax →<img>widget resolved from an attachment blob, and[[file]]/attachment refs → a file chip widget. - LaTeX math: inline
$…$and block$$…$$rendered live with KaTeX (esm.sh; the shell already ships KaTeX) through the same widget-decoration mechanism as images/checkboxes — math renders in place, and the raw$…$source reappears on the cursor's line for editing. Card previews render math read-only too. - Shortcuts (the "edit directly, nicely" ask): a small custom keymap —
Cmd/Ctrl-B / -I for bold/italic, heading cycle, link, inline code, toggle
checkbox — plus markdown input rules (Enter continues a list / quote /
task;
>/-/1./- [ ]auto-format) and an optional/slash menu (h1, todo, image, table, math). All cheap via CM6's keymap/transaction API; no heavy autocomplete extension needed. - Inline images / files: 📎 or paste/drop → read File →
setBlobtoattachments/<sha256>.<ext>(content-addressed, dedup, immutable) → insert(image) or an attachment chip token (file). Widgets load blobs viagetBlob→ object URL (revoked on unmount). Enforce the 25 MiB cap with an in-app message; large media → guidance to attach a link instead. - Read-only card preview uses lightweight
marked+ DOMPurify (both already proven offline in mind/news), not CM6 — cheap to render many cards.
v1.2.9 migration — persistence moved onto the platform
useDocumentprimitive. Each note is now a JSON document atnotes/<noteId>.jsonholding{ "meta": {…frontmatter fields…}, "body": "<markdown string>" }— the body is still the note's markdown, so dreaming-agent readability is preserved; only the on-disk envelope changed (markdown-frontmatter → JSON), because the runtime'swindow.mobius.createUseDocumentwriter is JSON-only. The app's homemade durability machinery — the shadow-IndexedDB outbox, the per-device base/working copies with a monotonic seq + seq-CASpromote, thedrafts/working copies, and the reconcile driver — is deleted: the platform runtime's serialized per-path writer + offline outbox now own durability, andmerge3/mergeMeta(unchanged) are theuseDocumentmerge for concurrent same-note edits. A one-time idempotent startup pass migrates any legacynotes/<id>.md→notes/<id>.json. Theconflicts/descriptor contract is unchanged; the "Resolve now" resolver now reads/writes the.jsondocument. The rest of this section describes the original model; the per-note merge/conflict SEMANTICS it documents still hold, only the file format and the durability mechanism moved.
notes/<noteId>.json canonical note: { meta, body } (body = markdown)
attachments/<sha256>.<ext> content-addressed blobs (images + files)
conflicts/<noteId>/<conflictId>.json immutable, versioned conflict descriptors
leases/<noteId>.json resolver lease (resolverId, leaseUntil, descriptorHash)
index.json DERIVED cache (rebuildable); never authoritative
notes-meta.json self-describing data contract for dreaming
.git/ optional server-side history
(Pre-v1.2.9: notes/<noteId>.md frontmatter-markdown + a drafts/ working-copy
tree — both retired by the migration above.)
Note file = YAML frontmatter + markdown body:
---
id: 9f3c… # stable uuid
title: Weekend list
pinned: true
color: violet
tags: [home, shopping]
created: 2026-06-03T09:00:00Z
updated: 2026-06-03T10:12:00Z
mobius_rev: 7 # monotonically bumped on each canonical write
parent_rev: 6 # rev this write was based on
content_hash: sha256… # hash of the body (+ normalized frontmatter)
attachments: [sha256…, sha256…]
---
# Weekend list
- [x] bread
- [ ] eggs
content_hash+mobius_rev/parent_revin frontmatter are the authoritative base identity (Codex's key correction — IndexedDB is a per-device UX cache only, never the source of truth).index.jsonis strictly derived (title/snippet/pinned/color/updated for fast grid load). If stale or conflicting, discard and rebuild fromnotes/*.md. Never merge it.- Attachments are content-addressed and immutable → no in-place mutation,
no attachment merge conflicts; a note edit just changes which
shait references.
The platform is last-write-wins with no atomic write, so we must not let the outbox replay a stale full-file write over a completed merge. Design rules:
- Never write offline edits to the canonical path. Offline (and, simplest,
all in-progress) edits write to per-device draft paths
drafts/<deviceId>/<noteId>/<opId>.md. These are unique per device+op, so the outbox LWW can never clobber another device's work.deviceIdis a stable uuid in app-local IndexedDB;opIdper save. - Reconcile on reconnect (and on app open when online). For each note with
a pending draft, fetch canonical
notes/<id>.md:- canonical
content_hash == draft.baseHash→ fast-forward: writemineto canonical withparent_rev = baseRev,mobius_rev = baseRev+1; clear the draft. - else diff3(base, mine, server):
- clean → write merged to canonical (new rev,
parent_revs:[mine, server]); clear the draft. - conflict → write an immutable descriptor (below); leave canonical untouched; surface "merging…"; the agent resolves (§8).
- clean → write merged to canonical (new rev,
- canonical
- Re-check before every canonical write. Immediately before writing a
merge/fast-forward result, re-read canonical and verify its
content_hashstill equals what the merge was computed against. If it changed (another device landed a write in the gap), abort and create/append a conflict instead of writing. This is best-effort CAS on top of LWW — it closes most of the race the platform can't close atomically; the residual window is documented and caught by the next reconcile. - diff3 scope: merge the markdown body line-wise (a small vendored
diff3, esm.sh, warm-cached). Frontmatter is not diff3'd — it's merged
field-wise (union tags, last-writer title/color/pin by
updated, recompute rev/hash). Body conflict markers, if any survive, are what the agent resolves.
Conflict descriptor (immutable; unique path keeps two distinct conflicts from overwriting each other):
conflicts/<noteId>/<baseHash>.<mineHash>.<serverHash>.json
{ noteId, baseRev, baseHash, base, mineHash, mine, serverHash, server,
observedServerHash, attachmentsMine, attachmentsServer,
createdByDeviceId, opId, status: "open", createdAt }
Notes no longer installs a recurring snapshot cron. If a manual maintenance job
is run, it should operate over notes/ only:
.gitinitialized on first run;git add notes/ && git commitwhen dirty.- Ignores
drafts/,conflicts/,leases/,index.json, and any temp/rename-in-progress files → never snapshots a transitional state. - Canonical writes use atomic rename (write
…/<id>.md.tmp, fsync, rename) so the snapshot never sees a half-written file. - Gives history/audit, time-travel, and dreaming a clean log. (App-source git — feature 084 — is separate and untouched; this is note-content git.)
When a descriptor exists, a tool-capable agent resolves it on demand:
- A "Resolve now" button posts
moebius:new-chatwith a draft pointing the owner agent at the descriptor.
Safe resolution (lease + verify, idempotent):
- Lease: atomically claim
leases/<noteId>.json({resolverId, leaseUntil, descriptorHash}); if a live lease exists, skip (prevents two resolver agents from double-resolving). - Re-verify: read canonical; require
content_hash == descriptor.serverHash. If it changed, abandon (write a superseding conflict), don't clobber. - Merge: 3-way merge
base/mine/server(the agent reasons about semantics, not just lines — its real advantage over diff3), preserving attachment refs. - Write canonical with
parent_revs:[mineRev, serverRev], new rev/hash via atomic rename. - Finalize: only if the written note's hash matches what it wrote, set
descriptor
status:"resolved", release the lease. The app'ssubscribeonnotes/<id>.mdupdates the card live.
This is the "code empowers the agent" path Möbius is built around: no server-side merge engine, a smart agent handles what a sanitizer can't.
- Notes are plain
notes/<id>.md(frontmatter + markdown) → dreaming reads them by globbing, no new API.notes-meta.jsonadvertises the contract:{ "app":"notes", "data_type":"notes", "format":"markdown+frontmatter", "dir":"notes/", "index":"index.json", "attachments":"attachments/" }. - Optional, additive (the only possible platform touch): one line in
backend/scripts/seed-skills/dreaming.mdtelling the nightly agent: "if a notes app is installed, readnotes-meta.json→ fold note titles/snippets into the knowledge graph." Shipped as a separate tiny PR; the app is fully functional without it.
Per the owner's steer: don't prescribe a metaphor. Write a one-paragraph app
description, attach the existing app icons (gym/news/mind/atlas icon.png)
as style references, and dispatch codex with an explicit imagegen call
("use your image-generation tool to produce a 512×512 PNG at /tmp/…, in the
style of these reference icons — glossy 3D, Möbius infinity-motif family,
charcoal + violet, no dark/horror imagery"). Generate a few to distinct temp
paths, pick the best, save as icon.png. (Asking Codex to "design an icon"
yields SVG code; the imagegen tool must be named explicitly — documented
gotcha.)
Mirrors gym/news conventions:
{
"id": "notes",
"name": "Notes",
"version": "1.0.0",
"description": "Markdown notes that render as you type — pin, color, search, inline images & files. Works offline; git-tracked; conflicts auto-merge or get an agent.",
"author": "mobius-os",
"license": "MIT",
"homepage": "https://github.com/mobius-os/app-notes",
"entry": "index.jsx",
"icon": "icon.png",
"offline_capable": true,
"permissions": { "cross_app_access": "none", "share_with_apps": "none" },
"runtime": { "imports": ["react", "react-dom", "codemirror", "katex"], "esm_deps": ["marked", "dompurify"] },
"storage_seeds": { "notes-meta.json": { "app": "notes", "data_type": "notes", "format": "markdown+frontmatter", "dir": "notes/", "index": "index.json", "attachments": "attachments/" } }
}Notes does not install a recurring cron job. Merge conflicts are resolved on-demand through the in-app "Resolve now" agent handoff.
- All rendered note HTML (card previews + any editor HTML widgets) → DOMPurify.
- No
window.confirm/alert/prompt; in-appConfirmModal. - Respect the 25 MiB blob cap; reject oversize before write.
connect-src 'self' https://esm.sh— all deps from esm.sh or same-origin; no other CDNs.- Same-origin
fetch('/api/storage/…')uses the owner JWT (already wired by the runtime viawindow.mobius.storage).
- Build all, ship once (chosen): implement every layer before review/install.
- Platform change (small, separate mobius PR), done first: vendor CM6 +
KaTeX under
/vendor— Dockerfilenpm install+ abuild-codemirror-vendor.mjs(modeled on the existingbuild-react-vendor.mjs) that emits one CM6 bundle, copy KaTeX CSS/fonts, addcodemirror+kateximportmap entries toapp-frame.html. Deploy the shell tomobius-test, verify the specifiers resolve same-origin and work offline, then build the app against them. - New repo
mobius-os/app-notes(local:/home/hmzmrzx/projects/mobius-os/app-notes/), matching the other app-* repos:index.jsx,mobius.json,icon.png,README.md,LICENSE. - Verify in
mobius-test(port 8001) end-to-end (never prod): install, drive with agent-browser, exercise offline + a forced conflict + the agent resolver, screenshot. - Register in the app store, then ship to prod on the owner's explicit gate (prod is the shared container; sibling sessions may be active).
- Optional dreaming-skill line as a separate small PR.
- Vendor the editor foundation (mobius worktree): build the single CM6
bundle + KaTeX vendor step +
app-frame.htmlimportmap entries; deploy tomobius-test; confirmimport {EditorView,…} from 'codemirror'+katexresolve same-origin and work offline. - Repo scaffold + manifest + storage seeds; logo via codex imagegen.
- Storage layer module (note CRUD, frontmatter, content-hash, derived index, content-addressed attachments) with unit tests.
- Grid + card + pin + color + search + ConfirmModal.
- Live-inline editor (CM6 + decorations + inline image/file widgets).
- Offline drafts + reconcile + diff3 + immutable conflict descriptors.
- Leased, verify-before-write "Resolve now" agent handoff.
notes-meta.json+ dreaming layout; optional dreaming-skill line.- Test-container e2e (incl. forced-conflict + resolver); screenshots.
- App-store registration; gated prod ship.
- Unit (node --test or in-app harness): frontmatter parse/serialize, content-hash stability, diff3 fast-forward/clean/conflict cases, derived-index rebuild, attachment content-addressing/dedup.
- Compile-smoke: esbuild-compile
index.jsx(the mini-app build path). - e2e in mobius-test: create/edit/pin/color/search; inline image + file; live-preview render; offline edit → reconnect → fast-forward; forced conflict (edit same note on two simulated devices) → descriptor written → resolver merges → card updates live; sync-status chip transitions.
- Dreaming: confirm a dreaming run can read
notes-meta.json+notes/*.md.
- Editor-foundation vendoring — building a correct single CM6 bundle (right
exports, singleton
state) + KaTeX (CSS + fonts copied for offline math) and wiring theapp-frame.htmlimportmap + Dockerfile vendor step. Well-precedented (three/React) but the bundle script needs care. Lower-risk than the esm.sh warm-cache path it replaces, and reusable by future apps. - Reconcile-before-write CAS residual window — inherent to LWW storage; bounded + caught by next reconcile; documented, acceptable for single-owner.
deviceIdlongevity — IndexedDB can be evicted; if lost, a device gets a new id (a new draft namespace) — safe (no clobber), at worst an extra conflict. Acceptable.- App-store registration mechanics — confirm how an app enters the store catalog (curated registry vs manifest URL) during delivery.
- Manual resolver ergonomics — keep "Resolve now" clear and recoverable when a descriptor exists.
CRDT/realtime; per-device git branches; a new server merge endpoint; cross-app note sharing; encryption-at-rest beyond what Möbius already provides.
Vendoring CM6 + KaTeX as general deps points at a small set of powerful, reusable libraries worth growing as a shared foundation (sizes measured, min+gzip):
| Capability | Library | gzip |
|---|---|---|
| Editor (this app) | CodeMirror 6 (minimal set) | 188 KB |
| Math (this app) | KaTeX (JS) | 74 KB |
| In-browser git + filesystem | isomorphic-git + lightning-fs | 83 KB |
| Alt. filesystem | @zenfs/core | 91 KB |
| File-tree UI | react-arborist | 32 KB |
| 3-way merge / diff | node-diff3 / diff-match-patch | 2 KB / 6 KB |
isomorphic-git + lightning-fs is the "file management" analogue to CM6 — a
complete client-side git + POSIX-ish filesystem in ~83 KB gz — and the natural
foundation for the LaTeX app (multi-file projects + versioning) and future
file/code/wiki apps. This Notes app deliberately does NOT use it: its
per-file drafts + diff3 design is simpler and correct for
cross-device quick-capture, and isomorphic-git would not ease the
sync-through-a-blob-store problem (you would still build a storage-backed
remote). Recommendation: vendor these on first real use (e.g. a
latex/file-management track), not speculatively.