oficina / docs Public

Plan 2: the app's first screens

Re-sequenced ahead of the layout engine, deliberately. Spec §13 puts
layout next, but the goal is something testable in a browser and a
readable document does not need pagination -- so this plan renders a
document as flowing HTML and page geometry, PDF and thumbnails all wait
for the plan after it.

Six tasks: rastrillo skeleton, documents in SQLite with their text coming
from the CRDT through wazero rather than a column, the list, create and
read, a development identity guarded so it refuses to start outside
development, and a one-command dev run.

Two constraints are made mechanical rather than remembered: a test
asserts our templates emit no class-vocabulary markup, and the 404 test
encodes the not-yours-is-not-there contract.

Carries plan 1's known limit forward explicitly -- internal/core caps its
update log at 4096 with no state export, which this plan will not hit but
the next one might.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
Paul Campbell pushed by paul@keymail.dev 4a3ddc4256d96fded7cd98771b3469a1d256fd0c parent d42c91b
1 file changed, +556 −0
  • docs/superpowers/plans/2026-08-31-app-first-screens.md +556 −0

CI passed — run details

diff --git a/docs/superpowers/plans/2026-08-31-app-first-screens.md b/docs/superpowers/plans/2026-08-31-app-first-screens.md
new file mode 100644
index 0000000..2c09ff1
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-31-app-first-screens.md
@@ -0,0 +1,556 @@
+# The app's first screens — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A rastrillo app you can open in a browser — list your documents, create one, and read one — with the document's text coming from the real CRDT core through wazero.
+
+**Architecture:** rastrillo `v0.21.0` for the app shell, routes, templates and SQLite. Documents are rows; their content is a Yjs update log applied through `internal/core` (the wazero host built in plan 1). Server-rendered throughout: no editor yet, no JavaScript beyond what rastrillo ships.
+
+**Tech Stack:** Go 1.26, rastrillo `v0.21.0`, `internal/core` (wazero + the Rust crate), SQLite via rastrillo's `db`.
+
+**Spec:** `docs/superpowers/specs/2026-08-30-docs-design.md` — §3 (system shape), §4 (data model), §6.2 (where zero-JS stops), §6.4 (the document list), §10 (design system).
+
+## Why this plan, and why it is not the layout engine
+
+Spec §13 sequences the layout engine next. **Re-sequenced deliberately**: the goal is something testable in a browser, and a readable document does not need pagination. Page geometry, PDF and thumbnails all come from the layout engine and all wait for the plan after this one.
+
+So this plan renders a document as ordinary flowing HTML. §6.4's real page-one thumbnails are **out of scope** — the list shows documents without them, and the row layout is designed so thumbnails drop in later without a rework.
+
+## Global Constraints
+
+- **`CGO_ENABLED=0` everywhere.** No cgo, ever.
+- **rastrillo pinned to `v0.21.0` exactly** — below it, the attribute grammar renders unstyled.
+- **Markup is the bare `rst-*` attribute grammar** (`<div rst-box>`), variants in the value matched with `~=`. **Adopt per screen, not per element** — mixed authoring inside one component matches neither spelling by design.
+- **Never draw entropy from `yrs`.** Document GUIDs and client ids are assigned by the app and stored. `clippy.toml` enforces this.
+- **`internal/core` is falsification-grade.** Its update log is capped at 4096 and it has no state export. This plan may create documents and apply a handful of updates; it must not assume the package is production-ready, and must not paper over `ErrUpdateLogFull`.
+- **No user-visible string is hardcoded** — every one is a catalog key (§10.3).
+- Work on a branch; push regular commits; squash-merge when done, approved, and CI green.
+
+---
+
+### Task 1: The app skeleton, served and reachable
+
+**Files:**
+- Create: `cmd/docs/main.go`, `internal/docs/app.go`, `internal/docs/handlers.go`, `internal/docs/render.go`
+- Modify: `go.mod`
+
+**Interfaces:**
+- Produces: `docs.New(cfg) (*App, error)` and an `http.Handler`; a `GET /` route rendering a page through rastrillo's shell.
+
+- [ ] **Step 1: Add rastrillo, pinned**
+
+```bash
+CGO_ENABLED=0 go get github.com/carlosframework/rastrillo@v0.21.0
+```
+
+Confirm `go.mod` records `v0.21.0` exactly, not a pseudo-version.
+
+**Read `SKILL.md` from the module before writing any app code** — it is the authoritative authoring doc and carries the app shape, scoping rules and identity plugins:
+
+```bash
+cat "$(CGO_ENABLED=0 go env GOMODCACHE)/github.com/carlosframework/rastrillo@v0.21.0/SKILL.md"
+```
+
+Follow its five-file shape rather than inventing one. If it contradicts anything in this plan, **SKILL.md wins** — say so in your report.
+
+- [ ] **Step 2: Write a failing test**
+
+`internal/docs/app_test.go`:
+
+```go
+package docs_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+func TestRootRendersAPage(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /: status %d, want 200", rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); ct == "" {
+ t.Error("no Content-Type on the response")
+ }
+}
+```
+
+`NewForTest` builds an App against a temp SQLite file and is the constructor every later test uses — put it in `internal/docs/testing.go` (not `_test.go`, so other packages' tests can use it).
+
+- [ ] **Step 3: Run it and watch it fail**
+
+```bash
+CGO_ENABLED=0 go test ./internal/docs/ -run TestRootRendersAPage
+```
+
+Expected: build failure — package `docs` does not exist.
+
+- [ ] **Step 4: Implement the skeleton**
+
+Follow SKILL.md's shape. The app needs: a chi router from rastrillo, a SQLite handle via rastrillo's `db`, `html/template` views, and rastrillo's shell partials for the page chrome. Wire every route in one place so `httptest` exercises the real router.
+
+Link `ui.TokensCSS()` and `ui.ThemeCSS("day")` — take the bytes from the module, do not copy files by hand.
+
+- [ ] **Step 5: Run the test and watch it pass**
+
+```bash
+CGO_ENABLED=0 go test ./internal/docs/ -run TestRootRendersAPage -v
+```
+
+- [ ] **Step 6: Run it for real and look at it**
+
+```bash
+CGO_ENABLED=0 go build -o /tmp/docs ./cmd/docs && /tmp/docs -addr :8099
+```
+
+Then `curl -s localhost:8099/ | head -40`. Confirm it is a real HTML page with the design system's CSS linked, not a bare string. **Report the actual HTML head.**
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add cmd internal/docs go.mod go.sum
+git commit -m "app: rastrillo skeleton serving a page
+
+rastrillo v0.21.0 pinned exactly -- below it the attribute grammar
+renders unstyled. Follows SKILL.md's five-file shape rather than an
+invented one."
+```
+
+---
+
+### Task 2: Documents in SQLite, with the core supplying their text
+
+**Files:**
+- Create: `internal/docs/models.go`, `internal/docs/store.go`, `internal/docs/store_test.go`
+
+**Interfaces:**
+- Consumes: `core.New`, `(*Runtime).NewDoc(ctx, guid, clientID)`, `(*Handle).Apply`, `(*Handle).Text` from plan 1.
+- Produces: `Store.Create(ctx, title) (*Document, error)`, `Store.List(ctx) ([]*Document, error)`, `Store.Get(ctx, sqid) (*Document, error)`, `Store.Text(ctx, doc) (string, error)`.
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/docs/store_test.go`:
+
+```go
+package docs_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+func TestCreateListGetRoundTrip(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ ctx := context.Background()
+
+ made, err := app.Store().Create(ctx, "First document")
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ if made.Sqid == "" {
+ t.Fatal("Create returned an empty sqid")
+ }
+ if made.GUID == "" || made.ClientID == 0 {
+ t.Fatal("Create must assign a GUID and client id, never draw them from yrs")
+ }
+
+ list, err := app.Store().List(ctx)
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(list) != 1 || list[0].Title != "First document" {
+ t.Fatalf("List = %+v, want one document titled First document", list)
+ }
+
+ got, err := app.Store().Get(ctx, made.Sqid)
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if got.ID != made.ID {
+ t.Errorf("Get returned id %d, want %d", got.ID, made.ID)
+ }
+}
+
+func TestTextComesFromTheCore(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ ctx := context.Background()
+
+ doc, err := app.Store().Create(ctx, "With content")
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+
+ // The "hello" fixture is a real Yjs update produced by browser yjs.
+ update := docs.ReadFixture(t, "hello")
+ if err := app.Store().ApplyUpdate(ctx, doc, update); err != nil {
+ t.Fatalf("ApplyUpdate: %v", err)
+ }
+
+ text, err := app.Store().Text(ctx, doc)
+ if err != nil {
+ t.Fatalf("Text: %v", err)
+ }
+ if text != "hello world" {
+ t.Errorf("Text = %q, want %q — the text must come from the CRDT, not a column", text, "hello world")
+ }
+}
+```
+
+`ReadFixture` loads `fixtures/data/<name>.update.bin`, in `internal/docs/testing.go`.
+
+- [ ] **Step 2: Run it and watch it fail**
+
+```bash
+CGO_ENABLED=0 go test ./internal/docs/ -run 'TestCreateListGetRoundTrip|TestTextComesFromTheCore'
+```
+
+- [ ] **Step 3: Implement**
+
+Schema, per spec §4 but only what this plan needs:
+
+```sql
+CREATE TABLE IF NOT EXISTS documents (
+ id INTEGER PRIMARY KEY,
+ sqid TEXT NOT NULL UNIQUE,
+ title TEXT NOT NULL,
+ guid TEXT NOT NULL,
+ client_id INTEGER NOT NULL,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ deleted_at DATETIME
+);
+CREATE TABLE IF NOT EXISTS doc_updates (
+ id INTEGER PRIMARY KEY,
+ doc_id INTEGER NOT NULL,
+ seq INTEGER NOT NULL,
+ update_bin BLOB NOT NULL,
+ created_at DATETIME NOT NULL
+);
+```
+
+Migrations are idempotent `CREATE TABLE IF NOT EXISTS` plus an additive `ALTER` list — rastrillo handles this; follow its pattern.
+
+**`guid` and `client_id` are assigned by the app** (a UUID from Go's stdlib-adjacent source and a counter or random int64 from `math/rand/v2`), stored, and passed to `core.NewDoc`. They are never drawn from `yrs` — `clippy.toml` bans that on the Rust side and this is the Go side of the same rule.
+
+**Content is NOT a column.** `Store.Text` opens a core handle, replays the document's updates in `seq` order, and reads the text back. Sealing is out of scope for this plan (spec §2 is a later concern) — but leave a comment saying so, so nobody reads the plaintext blob as a decision.
+
+- [ ] **Step 4: Run the tests and watch them pass**
+
+```bash
+CGO_ENABLED=0 go test ./internal/docs/ -v
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/docs
+git commit -m "app: documents in SQLite, text from the CRDT core
+
+Content is not a column -- Text replays the update log through the
+wazero core. GUID and client id are assigned by the app and stored,
+never drawn from yrs, which is the Go side of the rule clippy.toml
+enforces in Rust."
+```
+
+---
+
+### Task 3: The document list
+
+**Files:**
+- Create: `internal/docs/views/list.html`, `internal/docs/list_test.go`
+- Modify: `internal/docs/handlers.go`, `internal/docs/render.go`
+
+**Interfaces:**
+- Consumes: `Store.List` from Task 2.
+- Produces: `GET /` rendering the list.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestListShowsDocuments(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ ctx := context.Background()
+ if _, err := app.Store().Create(ctx, "Board minutes"); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+
+ body := rec.Body.String()
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ if !strings.Contains(body, "Board minutes") {
+ t.Error("the document's title is not on the page")
+ }
+ if strings.Contains(body, `class="rst-`) {
+ t.Error("class vocabulary found — this app writes the attribute grammar (Global Constraints)")
+ }
+}
+
+func TestEmptyListSaysSo(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ // An empty state is a designed screen, not a blank page.
+ if len(strings.TrimSpace(rec.Body.String())) < 200 {
+ t.Error("empty list rendered almost nothing — there should be an empty state")
+ }
+}
+```
+
+The class-vocabulary assertion is a real gate: it makes the markup constraint mechanical rather than remembered.
+
+- [ ] **Step 2: Run and watch fail.** `CGO_ENABLED=0 go test ./internal/docs/ -run TestListShows`
+
+- [ ] **Step 3: Implement the list**
+
+Per spec §6.4: a list, newest first, grouped by recency, one row per document showing title, who touched it and when. **Cut**, per Paul's four jank rulings: no template carousel, no "Owned by anyone" filter chrome, no grid/list/AZ toggles, no commercial or platform chrome.
+
+Design the row so a 26×34 thumbnail drops into its left edge later without reflowing anything else — thumbnails come with the layout engine.
+
+Use rastrillo's shell and partials; write the attribute grammar; every string a catalog key.
+
+- [ ] **Step 4: Run and watch pass.**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/docs
+git commit -m "app: the document list
+
+Per spec §6.4 and Paul's jank rulings: a list, newest first, no template
+carousel, no filter chrome, no view toggles. The row reserves space a
+page-one thumbnail drops into once the layout engine exists.
+
+The test asserts no class-vocabulary markup, which makes the attribute
+grammar constraint mechanical rather than remembered."
+```
+
+---
+
+### Task 4: Create and read a document
+
+**Files:**
+- Create: `internal/docs/views/show.html`
+- Modify: `internal/docs/handlers.go`, `internal/docs/list_test.go`
+
+**Interfaces:**
+- Produces: `POST /documents` (create, redirect to the new document) and `GET /d/{sqid}` (read-only render).
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestCreateThenRead(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+
+ form := strings.NewReader("title=Minutes")
+ req := httptest.NewRequest(http.MethodPost, "/documents", form)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /documents: status %d, want 303", rec.Code)
+ }
+ loc := rec.Header().Get("Location")
+ if !strings.HasPrefix(loc, "/d/") {
+ t.Fatalf("Location = %q, want /d/<sqid>", loc)
+ }
+
+ req2 := httptest.NewRequest(http.MethodGet, loc, nil)
+ rec2 := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec2, req2)
+ if rec2.Code != http.StatusOK {
+ t.Fatalf("GET %s: status %d", loc, rec2.Code)
+ }
+ if !strings.Contains(rec2.Body.String(), "Minutes") {
+ t.Error("the document's title is not on its own page")
+ }
+}
+
+func TestUnknownDocument404s(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ req := httptest.NewRequest(http.MethodGet, "/d/nosuchthing", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("status %d, want 404 — a document you cannot see does not exist", rec.Code)
+ }
+}
+```
+
+The 404 test encodes spec §4.2's contract: not-yours is indistinguishable from not-there.
+
+- [ ] **Step 2: Run and watch fail.**
+
+- [ ] **Step 3: Implement**
+
+`POST /documents` creates and redirects with **303 See Other**, so a refresh does not repost. Creation is a plain form post that works with no JavaScript (§6.2).
+
+`GET /d/{sqid}` renders the document read-only: its title, and its text from `Store.Text` — the CRDT, through wazero, not a column. Render the text as flowing HTML inside a page-shaped canvas: white, centred, with margins. **No pagination** — that arrives with the layout engine, and the container is built so page breaks can be drawn into it later.
+
+Give the canvas `color-scheme: light` and paint it from literals, per spec §10.2, so it stays paper in a dark theme.
+
+- [ ] **Step 4: Run and watch pass.**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/docs
+git commit -m "app: create and read a document
+
+POST redirects 303 so a refresh does not repost; both work with no
+JavaScript. The read view takes its text from the CRDT through wazero
+rather than a column, and its canvas opts out of the theme's scheme so
+paper stays paper in the dark."
+```
+
+---
+
+### Task 5: A way in, that cannot ship
+
+**Files:**
+- Create: `internal/docs/devauth.go`, `internal/docs/devauth_test.go`
+- Modify: `cmd/docs/main.go`
+
+**Interfaces:**
+- Produces: a development-only identity so the app is reachable in a browser.
+
+Spec §8.1 puts sign-in at the home's credential origin. **The home does not exist yet.** This task provides a local stand-in and makes it structurally impossible to ship.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestDevAuthRefusesOutsideDevelopment(t *testing.T) {
+ // The guard is the point of this task: a build without the dev flag
+ // must refuse to start rather than silently authenticate everyone.
+ _, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: "production"})
+ if err == nil {
+ t.Fatal("dev auth must refuse to start outside development")
+ }
+ if !strings.Contains(err.Error(), "development") {
+ t.Errorf("error should say why: %v", err)
+ }
+}
+
+func TestDevAuthGivesAnIdentityInDevelopment(t *testing.T) {
+ app, err := docs.NewForTest(t) // NewForTest sets Env: development
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code == http.StatusUnauthorized {
+ t.Error("dev auth should provide an identity in development")
+ }
+}
+```
+
+- [ ] **Step 2: Run and watch fail.**
+
+- [ ] **Step 3: Implement**
+
+A single fixed development identity, enabled only when `Env == "development"` **and** an explicit `-dev-auth` flag is set. Constructing it in any other environment returns an error that names the reason — the app refuses to start rather than starting insecure.
+
+Put a comment at the top of the file saying, in one sentence, that this exists because the home does not, and that it is deleted when §8.1's assertion verification lands.
+
+- [ ] **Step 4: Run and watch pass.**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/docs cmd
+git commit -m "app: a development-only identity, guarded so it cannot ship
+
+Spec §8.1 puts sign-in at the home's credential origin and the home does
+not exist yet. This refuses to start outside development rather than
+silently authenticating everyone, and it is deleted when assertion
+verification lands."
+```
+
+---
+
+### Task 6: Look at it in a browser
+
+**Files:**
+- Create: `scripts/run-dev.sh`
+- Modify: `.amadan/ci.d/10-gate`, `AGENTS.md`
+
+- [ ] **Step 1: Add the run script**
+
+`scripts/run-dev.sh` builds and runs the app with dev auth on a fixed port against a scratch database, so anyone can see it in one command. Print the URL.
+
+- [ ] **Step 2: Run it and drive it**
+
+Start it, then exercise the real flows over HTTP and **report the actual output at each step**:
+
+```bash
+./scripts/run-dev.sh &
+sleep 2
+curl -s localhost:8099/ | head -30
+curl -s -X POST localhost:8099/documents -d 'title=Overnight test' -i | head -5
+curl -s localhost:8099/d/<sqid-from-the-redirect> | head -40
+```
+
+Confirm: the list renders, creating redirects, the document page shows its title, and the page links the design system's CSS.
+
+- [ ] **Step 3: Verify the markup constraint held**
+
+```bash
+curl -s localhost:8099/ | grep -c 'class="rst-' || true
+```
+
+Expected: 0 from our own templates. **If rastrillo's own partials emit classes, that is expected and fine** — they are rastrillo's output, not ours (spec §10.1.1). Report which came from where.
+
+- [ ] **Step 4: Extend the gate**
+
+Add `CGO_ENABLED=0 go build ./...` to `.amadan/ci.d/10-gate` if it is not already covered, and note the app in `.amadan/README.md`.
+
+- [ ] **Step 5: Commit and confirm CI**
+
+```bash
+git add scripts .amadan AGENTS.md
+git commit -m "app: a one-command dev run, and the gate covers the app"
+git push
+```
+
+Then confirm `amadan ci status oficina/docs -branch <branch>` is exit 0.
+
+---
+
+## Self-Review
+
+**Spec coverage.** Implements §3's shape, §4's documents and update log (minus sealing, deliberately deferred and commented), §6.2's zero-JS create-and-read, §6.4's list minus thumbnails, §10's design system and canvas scheme opt-out. Does **not** implement: the layout engine and pagination (next plan), the editor, collaboration, sharing, import/export, or real identity — §8.1 is stubbed with a guard that refuses to ship.
+
+**Placeholder scan:** no TBDs. Two things are deliberately deferred with a comment rather than a silent omission: content sealing (§2) and thumbnails (§6.4).
+
+**Type consistency:** `Store.Create/List/Get/Text/ApplyUpdate` are defined in Task 2 and consumed in Tasks 3 and 4; `NewForTest` and `ReadFixture` are defined in Task 1's step 2 and used throughout; `core.NewDoc(ctx, guid, clientID)` matches plan 1's shipped signature.
+
+**Known risk carried from plan 1:** `internal/core` caps its update log at 4096 and has no state export. This plan applies few updates per document, so it will not hit the cap — but any task that starts replaying long histories must stop and raise it rather than working around it.