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
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. |