oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1# The app's first screens — Implementation Plan
2
3> **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.
4
5**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.
6
7**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.
8
9**Tech Stack:** Go 1.26, rastrillo `v0.21.0`, `internal/core` (wazero + the Rust crate), SQLite via rastrillo's `db`.
10
11**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).
12
13## Why this plan, and why it is not the layout engine
14
15Spec §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.
16
17So 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.
18
19## Global Constraints
20
21- **`CGO_ENABLED=0` everywhere.** No cgo, ever.
22- **rastrillo pinned to `v0.21.0` exactly** — below it, the attribute grammar renders unstyled.
23- **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.
24- **Never draw entropy from `yrs`.** Document GUIDs and client ids are assigned by the app and stored. `clippy.toml` enforces this.
25- **`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`.
26- **No user-visible string is hardcoded** — every one is a catalog key (§10.3).
27- Work on a branch; push regular commits; squash-merge when done, approved, and CI green.
28
29---
30
31### Task 1: The app skeleton, served and reachable
32
33**Files:**
34- Create: `cmd/docs/main.go`, `internal/docs/app.go`, `internal/docs/handlers.go`, `internal/docs/render.go`
35- Modify: `go.mod`
36
37**Interfaces:**
38- Produces: `docs.New(cfg) (*App, error)` and an `http.Handler`; a `GET /` route rendering a page through rastrillo's shell.
39
40- [ ] **Step 1: Add rastrillo, pinned**
41
42```bash
43CGO_ENABLED=0 go get github.com/carlosframework/rastrillo@v0.21.0
44```
45
46Confirm `go.mod` records `v0.21.0` exactly, not a pseudo-version.
47
48**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:
49
50```bash
51cat "$(CGO_ENABLED=0 go env GOMODCACHE)/github.com/carlosframework/rastrillo@v0.21.0/SKILL.md"
52```
53
54Follow its five-file shape rather than inventing one. If it contradicts anything in this plan, **SKILL.md wins** — say so in your report.
55
56- [ ] **Step 2: Write a failing test**
57
58`internal/docs/app_test.go`:
59
60```go
61package docs_test
62
63import (
64 "net/http"
65 "net/http/httptest"
66 "testing"
67
68 "github.com/carlosframework/docs/internal/docs"
69)
70
71func TestRootRendersAPage(t *testing.T) {
72 app, err := docs.NewForTest(t)
73 if err != nil {
74 t.Fatalf("NewForTest: %v", err)
75 }
76 req := httptest.NewRequest(http.MethodGet, "/", nil)
77 rec := httptest.NewRecorder()
78 app.Handler().ServeHTTP(rec, req)
79
80 if rec.Code != http.StatusOK {
81 t.Fatalf("GET /: status %d, want 200", rec.Code)
82 }
83 if ct := rec.Header().Get("Content-Type"); ct == "" {
84 t.Error("no Content-Type on the response")
85 }
86}
87```
88
89`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).
90
91- [ ] **Step 3: Run it and watch it fail**
92
93```bash
94CGO_ENABLED=0 go test ./internal/docs/ -run TestRootRendersAPage
95```
96
97Expected: build failure — package `docs` does not exist.
98
99- [ ] **Step 4: Implement the skeleton**
100
101Follow 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.
102
103Link `ui.TokensCSS()` and `ui.ThemeCSS("day")` — take the bytes from the module, do not copy files by hand.
104
105- [ ] **Step 5: Run the test and watch it pass**
106
107```bash
108CGO_ENABLED=0 go test ./internal/docs/ -run TestRootRendersAPage -v
109```
110
111- [ ] **Step 6: Run it for real and look at it**
112
113```bash
114CGO_ENABLED=0 go build -o /tmp/docs ./cmd/docs && /tmp/docs -addr :8099
115```
116
117Then `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.**
118
119- [ ] **Step 7: Commit**
120
121```bash
122git add cmd internal/docs go.mod go.sum
123git commit -m "app: rastrillo skeleton serving a page
124
125rastrillo v0.21.0 pinned exactly -- below it the attribute grammar
126renders unstyled. Follows SKILL.md's five-file shape rather than an
127invented one."
128```
129
130---
131
132### Task 2: Documents in SQLite, with the core supplying their text
133
134**Files:**
135- Create: `internal/docs/models.go`, `internal/docs/store.go`, `internal/docs/store_test.go`
136
137**Interfaces:**
138- Consumes: `core.New`, `(*Runtime).NewDoc(ctx, guid, clientID)`, `(*Handle).Apply`, `(*Handle).Text` from plan 1.
139- Produces: `Store.Create(ctx, title) (*Document, error)`, `Store.List(ctx) ([]*Document, error)`, `Store.Get(ctx, sqid) (*Document, error)`, `Store.Text(ctx, doc) (string, error)`.
140
141- [ ] **Step 1: Write the failing test**
142
143`internal/docs/store_test.go`:
144
145```go
146package docs_test
147
148import (
149 "context"
150 "testing"
151
152 "github.com/carlosframework/docs/internal/docs"
153)
154
155func TestCreateListGetRoundTrip(t *testing.T) {
156 app, err := docs.NewForTest(t)
157 if err != nil {
158 t.Fatalf("NewForTest: %v", err)
159 }
160 ctx := context.Background()
161
162 made, err := app.Store().Create(ctx, "First document")
163 if err != nil {
164 t.Fatalf("Create: %v", err)
165 }
166 if made.Sqid == "" {
167 t.Fatal("Create returned an empty sqid")
168 }
169 if made.GUID == "" || made.ClientID == 0 {
170 t.Fatal("Create must assign a GUID and client id, never draw them from yrs")
171 }
172
173 list, err := app.Store().List(ctx)
174 if err != nil {
175 t.Fatalf("List: %v", err)
176 }
177 if len(list) != 1 || list[0].Title != "First document" {
178 t.Fatalf("List = %+v, want one document titled First document", list)
179 }
180
181 got, err := app.Store().Get(ctx, made.Sqid)
182 if err != nil {
183 t.Fatalf("Get: %v", err)
184 }
185 if got.ID != made.ID {
186 t.Errorf("Get returned id %d, want %d", got.ID, made.ID)
187 }
188}
189
190func TestTextComesFromTheCore(t *testing.T) {
191 app, err := docs.NewForTest(t)
192 if err != nil {
193 t.Fatalf("NewForTest: %v", err)
194 }
195 ctx := context.Background()
196
197 doc, err := app.Store().Create(ctx, "With content")
198 if err != nil {
199 t.Fatalf("Create: %v", err)
200 }
201
202 // The "hello" fixture is a real Yjs update produced by browser yjs.
203 update := docs.ReadFixture(t, "hello")
204 if err := app.Store().ApplyUpdate(ctx, doc, update); err != nil {
205 t.Fatalf("ApplyUpdate: %v", err)
206 }
207
208 text, err := app.Store().Text(ctx, doc)
209 if err != nil {
210 t.Fatalf("Text: %v", err)
211 }
212 if text != "hello world" {
213 t.Errorf("Text = %q, want %q — the text must come from the CRDT, not a column", text, "hello world")
214 }
215}
216```
217
218`ReadFixture` loads `fixtures/data/<name>.update.bin`, in `internal/docs/testing.go`.
219
220- [ ] **Step 2: Run it and watch it fail**
221
222```bash
223CGO_ENABLED=0 go test ./internal/docs/ -run 'TestCreateListGetRoundTrip|TestTextComesFromTheCore'
224```
225
226- [ ] **Step 3: Implement**
227
228Schema, per spec §4 but only what this plan needs:
229
230```sql
231CREATE TABLE IF NOT EXISTS documents (
232 id INTEGER PRIMARY KEY,
233 sqid TEXT NOT NULL UNIQUE,
234 title TEXT NOT NULL,
235 guid TEXT NOT NULL,
236 client_id INTEGER NOT NULL,
237 created_at DATETIME NOT NULL,
238 updated_at DATETIME NOT NULL,
239 deleted_at DATETIME
240);
241CREATE TABLE IF NOT EXISTS doc_updates (
242 id INTEGER PRIMARY KEY,
243 doc_id INTEGER NOT NULL,
244 seq INTEGER NOT NULL,
245 update_bin BLOB NOT NULL,
246 created_at DATETIME NOT NULL
247);
248```
249
250Migrations are idempotent `CREATE TABLE IF NOT EXISTS` plus an additive `ALTER` list — rastrillo handles this; follow its pattern.
251
252**`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.
253
254**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.
255
256- [ ] **Step 4: Run the tests and watch them pass**
257
258```bash
259CGO_ENABLED=0 go test ./internal/docs/ -v
260```
261
262- [ ] **Step 5: Commit**
263
264```bash
265git add internal/docs
266git commit -m "app: documents in SQLite, text from the CRDT core
267
268Content is not a column -- Text replays the update log through the
269wazero core. GUID and client id are assigned by the app and stored,
270never drawn from yrs, which is the Go side of the rule clippy.toml
271enforces in Rust."
272```
273
274---
275
276### Task 3: The document list
277
278**Files:**
279- Create: `internal/docs/views/list.html`, `internal/docs/list_test.go`
280- Modify: `internal/docs/handlers.go`, `internal/docs/render.go`
281
282**Interfaces:**
283- Consumes: `Store.List` from Task 2.
284- Produces: `GET /` rendering the list.
285
286- [ ] **Step 1: Write the failing test**
287
288```go
289func TestListShowsDocuments(t *testing.T) {
290 app, _ := docs.NewForTest(t)
291 ctx := context.Background()
292 if _, err := app.Store().Create(ctx, "Board minutes"); err != nil {
293 t.Fatal(err)
294 }
295
296 req := httptest.NewRequest(http.MethodGet, "/", nil)
297 rec := httptest.NewRecorder()
298 app.Handler().ServeHTTP(rec, req)
299
300 body := rec.Body.String()
301 if rec.Code != http.StatusOK {
302 t.Fatalf("status %d", rec.Code)
303 }
304 if !strings.Contains(body, "Board minutes") {
305 t.Error("the document's title is not on the page")
306 }
307 if strings.Contains(body, `class="rst-`) {
308 t.Error("class vocabulary found — this app writes the attribute grammar (Global Constraints)")
309 }
310}
311
312func TestEmptyListSaysSo(t *testing.T) {
313 app, _ := docs.NewForTest(t)
314 req := httptest.NewRequest(http.MethodGet, "/", nil)
315 rec := httptest.NewRecorder()
316 app.Handler().ServeHTTP(rec, req)
317 if rec.Code != http.StatusOK {
318 t.Fatalf("status %d", rec.Code)
319 }
320 // An empty state is a designed screen, not a blank page.
321 if len(strings.TrimSpace(rec.Body.String())) < 200 {
322 t.Error("empty list rendered almost nothing — there should be an empty state")
323 }
324}
325```
326
327The class-vocabulary assertion is a real gate: it makes the markup constraint mechanical rather than remembered.
328
329- [ ] **Step 2: Run and watch fail.** `CGO_ENABLED=0 go test ./internal/docs/ -run TestListShows`
330
331- [ ] **Step 3: Implement the list**
332
333Per 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.
334
335Design the row so a 26×34 thumbnail drops into its left edge later without reflowing anything else — thumbnails come with the layout engine.
336
337Use rastrillo's shell and partials; write the attribute grammar; every string a catalog key.
338
339- [ ] **Step 4: Run and watch pass.**
340
341- [ ] **Step 5: Commit**
342
343```bash
344git add internal/docs
345git commit -m "app: the document list
346
347Per spec §6.4 and Paul's jank rulings: a list, newest first, no template
348carousel, no filter chrome, no view toggles. The row reserves space a
349page-one thumbnail drops into once the layout engine exists.
350
351The test asserts no class-vocabulary markup, which makes the attribute
352grammar constraint mechanical rather than remembered."
353```
354
355---
356
357### Task 4: Create and read a document
358
359**Files:**
360- Create: `internal/docs/views/show.html`
361- Modify: `internal/docs/handlers.go`, `internal/docs/list_test.go`
362
363**Interfaces:**
364- Produces: `POST /documents` (create, redirect to the new document) and `GET /d/{sqid}` (read-only render).
365
366- [ ] **Step 1: Write the failing tests**
367
368```go
369func TestCreateThenRead(t *testing.T) {
370 app, _ := docs.NewForTest(t)
371
372 form := strings.NewReader("title=Minutes")
373 req := httptest.NewRequest(http.MethodPost, "/documents", form)
374 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
375 rec := httptest.NewRecorder()
376 app.Handler().ServeHTTP(rec, req)
377
378 if rec.Code != http.StatusSeeOther {
379 t.Fatalf("POST /documents: status %d, want 303", rec.Code)
380 }
381 loc := rec.Header().Get("Location")
382 if !strings.HasPrefix(loc, "/d/") {
383 t.Fatalf("Location = %q, want /d/<sqid>", loc)
384 }
385
386 req2 := httptest.NewRequest(http.MethodGet, loc, nil)
387 rec2 := httptest.NewRecorder()
388 app.Handler().ServeHTTP(rec2, req2)
389 if rec2.Code != http.StatusOK {
390 t.Fatalf("GET %s: status %d", loc, rec2.Code)
391 }
392 if !strings.Contains(rec2.Body.String(), "Minutes") {
393 t.Error("the document's title is not on its own page")
394 }
395}
396
397func TestUnknownDocument404s(t *testing.T) {
398 app, _ := docs.NewForTest(t)
399 req := httptest.NewRequest(http.MethodGet, "/d/nosuchthing", nil)
400 rec := httptest.NewRecorder()
401 app.Handler().ServeHTTP(rec, req)
402 if rec.Code != http.StatusNotFound {
403 t.Errorf("status %d, want 404 — a document you cannot see does not exist", rec.Code)
404 }
405}
406```
407
408The 404 test encodes spec §4.2's contract: not-yours is indistinguishable from not-there.
409
410- [ ] **Step 2: Run and watch fail.**
411
412- [ ] **Step 3: Implement**
413
414`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).
415
416`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.
417
418Give the canvas `color-scheme: light` and paint it from literals, per spec §10.2, so it stays paper in a dark theme.
419
420- [ ] **Step 4: Run and watch pass.**
421
422- [ ] **Step 5: Commit**
423
424```bash
425git add internal/docs
426git commit -m "app: create and read a document
427
428POST redirects 303 so a refresh does not repost; both work with no
429JavaScript. The read view takes its text from the CRDT through wazero
430rather than a column, and its canvas opts out of the theme's scheme so
431paper stays paper in the dark."
432```
433
434---
435
436### Task 5: A way in, that cannot ship
437
438**Files:**
439- Create: `internal/docs/devauth.go`, `internal/docs/devauth_test.go`
440- Modify: `cmd/docs/main.go`
441
442**Interfaces:**
443- Produces: a development-only identity so the app is reachable in a browser.
444
445Spec §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.
446
447- [ ] **Step 1: Write the failing test**
448
449```go
450func TestDevAuthRefusesOutsideDevelopment(t *testing.T) {
451 // The guard is the point of this task: a build without the dev flag
452 // must refuse to start rather than silently authenticate everyone.
453 _, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: "production"})
454 if err == nil {
455 t.Fatal("dev auth must refuse to start outside development")
456 }
457 if !strings.Contains(err.Error(), "development") {
458 t.Errorf("error should say why: %v", err)
459 }
460}
461
462func TestDevAuthGivesAnIdentityInDevelopment(t *testing.T) {
463 app, err := docs.NewForTest(t) // NewForTest sets Env: development
464 if err != nil {
465 t.Fatal(err)
466 }
467 req := httptest.NewRequest(http.MethodGet, "/", nil)
468 rec := httptest.NewRecorder()
469 app.Handler().ServeHTTP(rec, req)
470 if rec.Code == http.StatusUnauthorized {
471 t.Error("dev auth should provide an identity in development")
472 }
473}
474```
475
476- [ ] **Step 2: Run and watch fail.**
477
478- [ ] **Step 3: Implement**
479
480A 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.
481
482Put 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.
483
484- [ ] **Step 4: Run and watch pass.**
485
486- [ ] **Step 5: Commit**
487
488```bash
489git add internal/docs cmd
490git commit -m "app: a development-only identity, guarded so it cannot ship
491
492Spec §8.1 puts sign-in at the home's credential origin and the home does
493not exist yet. This refuses to start outside development rather than
494silently authenticating everyone, and it is deleted when assertion
495verification lands."
496```
497
498---
499
500### Task 6: Look at it in a browser
501
502**Files:**
503- Create: `scripts/run-dev.sh`
504- Modify: `.amadan/ci.d/10-gate`, `AGENTS.md`
505
506- [ ] **Step 1: Add the run script**
507
508`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.
509
510- [ ] **Step 2: Run it and drive it**
511
512Start it, then exercise the real flows over HTTP and **report the actual output at each step**:
513
514```bash
515./scripts/run-dev.sh &
516sleep 2
517curl -s localhost:8099/ | head -30
518curl -s -X POST localhost:8099/documents -d 'title=Overnight test' -i | head -5
519curl -s localhost:8099/d/<sqid-from-the-redirect> | head -40
520```
521
522Confirm: the list renders, creating redirects, the document page shows its title, and the page links the design system's CSS.
523
524- [ ] **Step 3: Verify the markup constraint held**
525
526```bash
527curl -s localhost:8099/ | grep -c 'class="rst-' || true
528```
529
530Expected: 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.
531
532- [ ] **Step 4: Extend the gate**
533
534Add `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`.
535
536- [ ] **Step 5: Commit and confirm CI**
537
538```bash
539git add scripts .amadan AGENTS.md
540git commit -m "app: a one-command dev run, and the gate covers the app"
541git push
542```
543
544Then confirm `amadan ci status oficina/docs -branch <branch>` is exit 0.
545
546---
547
548## Self-Review
549
550**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.
551
552**Placeholder scan:** no TBDs. Two things are deliberately deferred with a comment rather than a silent omission: content sealing (§2) and thumbnails (§6.4).
553
554**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.
555
556**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.
557