oficina / docs Public

The document list shows each document's real page one

The 26x34 box at the left of every row has been empty since Task 3,
reserved with a comment saying it was reserved and not faked. It now
holds a rendering of the document's first page: the line boxes the PDF
exporter's own layout placed, drawn as SVG rects in page coordinates
so the browser does the scaling. Real line breaking, real font
metrics, real margins and real alignment — a short paragraph draws a
short bar because it is a short paragraph.

Spec decision 11 asked for real page-one thumbnails and 11.4 ruled
they needed internal/raster/. 11.4 is amended rather than ignored: at
26x34 a US-Letter page is ~23pt to the pixel, so a glyph is a fraction
of a pixel and a rasterizer would spend a whole pipeline to draw a
smudge. At this size the line boxes are the rendering. What is elided
is the glyphs, not the layout, and raster moves to 14's trigger list
against the day a bigger thumbnail is wanted.

The title is deliberately not drawn. The PDF puts one on its first
page because a file has to say what it is; views/show.html keeps it
off the paper entirely, and a thumbnail is a picture of the paper.
Drawing it would show a heading the reader has never seen, beside the
very title it duplicates, and would give every empty document a
one-bar preview of content it does not have.

Cached in memory, never in SQLite. A bar carries no characters, so
this is not plaintext — but a preview column would be a second thing
to remember to seal when sealing lands, and 2's plaintext-grep gate
could never catch the lapse, because a shape contains none of the
fixture's words. Keeping it in memory creates no obligation to
forget. Keyed on updated_at in Unix nanoseconds, so an edit
invalidates by construction and no writer has to remember to evict;
ApplyUpdate now records that as an invariant it owns, because
something depends on it. Compared as int64: a time from the sqlite
driver and one from time.Now() are never ==, and that cache would
silently never hit.

The list is the page that must work, so a document whose structure
will not read logs once, caches the failure and renders the empty box
rather than 500ing the whole list, and a cold render lays out at most
40 documents before leaving the rest to warm on later renders. A cache
hit spends no budget, so a warm list never reaches it.

Both new end-to-end tests were seen to fail: never drawing a thumbnail
reddens the seeded-list test, and putting the title back on the paper
reddens the empty-document test — the latter only after its fixture
was given a title, because with a blank one the mutation was a no-op
and the test could not distinguish "no" from "I can't see it".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JM96sbqzZLJgqYmAs4j5y
Paul Campbell pushed by paul@keymail.dev f12967ca1a8eb5502451ccb239985bbf8ee91911 parent 748a3a0
8 files changed, +551 −16
  • docs/superpowers/specs/2026-08-30-docs-design.md +21 −2
  • internal/docs/app.go +1 −1
  • internal/docs/handlers.go +27 −6
  • internal/docs/list_test.go +79 −0
  • internal/docs/preview.go +262 −0
  • internal/docs/preview_internal_test.go +111 −0
  • internal/docs/store.go +12 −0
  • internal/docs/views/list.html +38 −7

CI passed — run details

diff --git a/docs/superpowers/specs/2026-08-30-docs-design.md b/docs/superpowers/specs/2026-08-30-docs-design.md
index 665797b..aacca1a 100644
--- a/docs/superpowers/specs/2026-08-30-docs-design.md
+++ b/docs/superpowers/specs/2026-08-30-docs-design.md
@@ -937,6 +937,20 @@ scale, and PNG composition — a pipeline nothing else needs, since the PDF path
embeds fonts and never rasterizes. `internal/raster/` is a real component.
Thumbnails stay, because they are worth it (§6.4), but they are not free.
+**Amended 2026-09-02, after building them.** The conflation was real and the
+conclusion was half right. What this missed is the *scale*: the list's
+thumbnail is 26×34, so a US-Letter page is about 23 points to the pixel and a
+glyph is a fraction of a pixel wide. There is nothing to rasterize at that
+size — a rasterizer would spend the whole pipeline above to produce a grey
+smudge. So the shipped thumbnail draws the line boxes, and they are real
+ones: `export.PageOne` runs the PDF exporter's own layout, with its own font
+metrics and its own line breaking, and reports where every line of page one
+actually landed. Decision 11's "real rendered" is satisfied in the sense that
+matters — the row shows where this document's first page really breaks — and
+what is elided is the glyphs, not the layout. `internal/raster/` remains
+unbuilt and remains what a *larger* thumbnail would need; it moved from §13's
+sequence to §14's trigger list.
+
### 11.5 Blobs
**All blob storage goes through `blobs.Sealed`** (`blobs/blobs.go:218`); the
@@ -1024,11 +1038,15 @@ Corrected.
6. Sharing: grants, guests, link shares, the home outbox.
7. Markdown, then DOCX, then PDF.
8. Comments; version capture and restore; the outline pane.
-9. Thumbnails.
+9. ~~Thumbnails.~~ **Done 2026-09-02, and not the way this line assumed.**
+ They did not wait for the layout engine: the PDF exporter's layout was
+ enough to report where page one's lines fall, which at 26×34 is the whole
+ of what a thumbnail shows. See §11.4's amendment.
**Slip order, written rather than discovered:** the outline pane goes first,
then offline. **The layout engine cannot slip**, because pagination, PDF and
-thumbnails all fall out of it.
+thumbnails all fall out of it. *(Thumbnails turned out not to — see §11.4's
+amendment. Pagination and PDF still do.)*
---
@@ -1044,6 +1062,7 @@ thumbnails all fall out of it.
| Pageless mode | Long-document editing proves painful in page view |
| Embedded live Sheets ranges | Both apps stable, and the seam between two collaboration models designed deliberately |
| Templates | Somebody has made the same document three times |
+| Rasterized thumbnails (§11.4's `internal/raster/`) | A thumbnail is wanted at a size where glyphs are legible. The document list ships a real one already — `export.PageOne` reports where the PDF exporter's own layout put every line of page one, and the row draws those line boxes as SVG. At 26×34 that IS the rendering: a page is ~23pt to the pixel, so a glyph is a fraction of one and rasterizing it would cost a great deal to draw a smudge. What is elided is the glyphs, not the layout |
---
diff --git a/internal/docs/app.go b/internal/docs/app.go
index 757f415..581c7aa 100644
--- a/internal/docs/app.go
+++ b/internal/docs/app.go
@@ -401,7 +401,7 @@ func New(cfg Config) (*App, error) {
return nil, fmt.Errorf("docs: build auth: %w", err)
}
- h := &handlers{render: render, logger: logger, store: store, hub: newHub()}
+ h := &handlers{render: render, logger: logger, store: store, hub: newHub(), previews: newPreviews()}
r := chi.NewRouter()
// Mounted above every route, per rastrillo SKILL.md §5: refuses a
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index f626cad..d747f4d 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -46,6 +46,11 @@ type handlers struct {
// sample seeding, which runs before any editor can be open.
hub *hub
+ // previews is the document list's thumbnail cache (preview.go).
+ // Here rather than on Store for the same reason hub is: Store is
+ // persistence, and a laid-out page of line boxes is presentation.
+ previews *previews
+
// streamHeartbeat and streamTTL override stream.go's own defaults.
// Zero means "use the default", so nothing but a test ever sets
// them — see heartbeatInterval/ttlInterval for why the seam
@@ -122,6 +127,13 @@ type docRow struct {
Href string
Title string
When string
+
+ // Thumb is the document's first page as line boxes (preview.go),
+ // drawn by list.html into the row's reserved 26x34 box. No bars
+ // means no thumbnail — an empty document, one whose structure
+ // could not be read, or one past a cold render's budget — and
+ // renders as the empty box the row has always shown.
+ Thumb thumbView
}
// owner resolves the signed-in person's Store.Person id for r —
@@ -146,10 +158,16 @@ func (h *handlers) owner(r *http.Request) (int64, error) {
// document. Cut, deliberately, not by oversight: no template
// carousel, no "Owned by anyone"/"Last opened by me" filter chrome, no
// grid/list/AZ/folder toggle, no commercial or platform chrome. The
-// row reserves a 26×34 slot at its left edge (list.html's
-// [docs-thumb]) for the page-one thumbnail a later plan's layout
-// engine renders there — reserved, not faked: today it is an empty,
-// bordered box.
+// row's 26×34 slot at its left edge (list.html's [docs-thumb]) now
+// holds a real rendering of the document's page one, drawn from the
+// PDF exporter's own layout through export.PageOne (preview.go). It
+// was reserved and deliberately empty until that was possible —
+// "reserved, not faked" — and the reservation did its job: the box is
+// the same box, so filling it reflowed nothing.
+//
+// What is drawn is where the text is, not the text: at this size a
+// glyph is a fraction of a pixel. Spec §11.4's raster pipeline is
+// still unbuilt and still what a larger thumbnail would need.
//
// A row shows title and when it was last touched, not who: List is
// already scoped to the signed-in viewer's own documents (Store.List's
@@ -197,11 +215,13 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
}
now := time.Now().UTC()
data.Searching = true
+ thumb := h.thumbnailer(r.Context())
for _, d := range hits {
data.Results = append(data.Results, docRow{
Href: "/d/" + d.Sqid,
Title: d.Title,
When: formatWhen(now, d.UpdatedAt),
+ Thumb: thumb(d),
})
}
if err := h.render.page(w, r, "list", data); err != nil {
@@ -217,7 +237,7 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
return
}
- data.Groups = groupByRecency(r, time.Now().UTC(), docs)
+ data.Groups = groupByRecency(r, time.Now().UTC(), docs, h.thumbnailer(r.Context()))
if err := h.render.page(w, r, "list", data); err != nil {
h.logger.Error("docs: render list", "err", err)
http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
@@ -678,7 +698,7 @@ func updateIsClientFault(err error) bool {
// group labels ("Today … Yesterday … Today") the instant an older
// document got a new update. See store_test.go's
// TestListOrdersByUpdatedAt.
-func groupByRecency(r *http.Request, now time.Time, docs []*Document) []docGroup {
+func groupByRecency(r *http.Request, now time.Time, docs []*Document, thumb func(*Document) thumbView) []docGroup {
var groups []docGroup
var key string
for _, d := range docs {
@@ -692,6 +712,7 @@ func groupByRecency(r *http.Request, now time.Time, docs []*Document) []docGroup
Href: "/d/" + d.Sqid,
Title: d.Title,
When: formatWhen(now, d.UpdatedAt),
+ Thumb: thumb(d),
})
}
return groups
diff --git a/internal/docs/list_test.go b/internal/docs/list_test.go
index 61e25ea..4209e7f 100644
--- a/internal/docs/list_test.go
+++ b/internal/docs/list_test.go
@@ -83,6 +83,85 @@ func TestListShowsDocuments(t *testing.T) {
}
}
+// The end-to-end one: a document with real content on its first page
+// gets a real thumbnail drawn from that page's own layout, and a
+// document with nothing on it gets the empty box the row has always
+// shown rather than an invented preview.
+//
+// It goes through the whole path deliberately — seed a document with
+// prose in it, replay it through the core, lay out page one, render
+// the list — because every unit below this is a table test on a
+// struct, and the thing that actually has to work is that a person
+// signing in sees their documents.
+func TestListDrawsAThumbnailOfPageOne(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ ctx := context.Background()
+ owner := app.TestPerson(t, "reader@example.com")
+ if err := docs.Seed(ctx, app.Store(), owner); err != nil {
+ t.Fatalf("Seed: %v", err)
+ }
+ cookies := signInCookies(t, app, "reader@example.com")
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ body := rec.Body.String()
+
+ if n := strings.Count(body, "<svg"); n == 0 {
+ t.Fatal("no thumbnail was drawn for any seeded document")
+ }
+ if n := strings.Count(body, "<rect"); n < 10 {
+ t.Errorf("only %d line boxes across the whole list — a seeded document has more than that on its first page", n)
+ }
+ // The viewBox is the page, so the browser does the scaling and the
+ // thumbnail is not distorted to fit its box.
+ if !strings.Contains(body, `viewBox="0 0 612 792"`) {
+ t.Error("the thumbnail is not drawn in page coordinates")
+ }
+}
+
+func TestAnEmptyDocumentGetsNoThumbnail(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ ctx := context.Background()
+ owner := app.TestPerson(t, "reader@example.com")
+ // Create leaves a document with no updates at all: nothing has
+ // been typed into it, so its first page has nothing on it. It is
+ // given a title because every document in the real app has one
+ // (the create handler substitutes "Untitled document" for a blank
+ // field) — and because a titleless fixture would make this test
+ // unable to see the thing it is here to check: the title must not
+ // be laid out onto the paper, and with no title there is nothing
+ // to lay out either way.
+ if _, err := app.Store().Create(ctx, owner, "Untitled document"); err != nil {
+ t.Fatal(err)
+ }
+ cookies := signInCookies(t, app, "reader@example.com")
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ // Asserted on the empty box rather than on the absence of any
+ // <svg> on the page: signing a new person in seeds them sample
+ // documents (Store.seedIfEnabled), so this list has real
+ // thumbnails on it too, and "no thumbnail anywhere" would be a
+ // test of the seeder rather than of the empty document.
+ if !strings.Contains(rec.Body.String(), `<span docs-thumb aria-hidden="true"></span>`) {
+ t.Error("an empty document did not render the empty box — it is meant to stay empty rather than show an invented preview")
+ }
+}
+
func TestEmptyListSaysSo(t *testing.T) {
app, _ := docs.NewForTest(t)
cookies := signInCookies(t, app, "reader@example.com")
diff --git a/internal/docs/preview.go b/internal/docs/preview.go
new file mode 100644
index 0000000..23b8823
--- /dev/null
+++ b/internal/docs/preview.go
@@ -0,0 +1,262 @@
+package docs
+
+import (
+ "context"
+ "sync"
+
+ "github.com/carlosframework/docs/internal/export"
+)
+
+// The thumbnail on a document's row, and where it comes from.
+//
+// Spec decision 11 and §6.4 ask the document list for "real rendered
+// page-one thumbnails"; §11.4 rules that a raster thumbnail needs a
+// pipeline this app does not have. Both are honoured here rather than
+// one of them being quietly dropped. The geometry is real — it is the
+// PDF exporter's own layout, through export.PageOne, so a row shows
+// where the document's first page actually breaks its lines — and no
+// raster is involved, because at 26x34 px a US-Letter page is about
+// 23 points to the pixel and a glyph is a fraction of one. What is
+// drawn is where the text is, at the size the text is. When the layout
+// engine and internal/raster land (spec §13), this keeps its shape and
+// rasterizes what PageOne already reports.
+//
+// This replaces the empty box list.html reserved for it. That box was
+// deliberately not a fake — "reserved space, not a placeholder image"
+// — and the bar that had to be cleared to fill it was showing
+// something true about the document, not something plausible.
+
+// previewBar is one line of the first page, in PAGE POINTS — the same
+// coordinate space export.PageOne reports and the same one the SVG's
+// viewBox declares, so the browser does the scaling and this code does
+// no arithmetic that could disagree with the layout.
+//
+// Ints, not floats: they are written straight into an SVG attribute by
+// html/template, and a float would render in Go's shortest-round-trip
+// form (1e+06 for a large one), which is not SVG number syntax. Points
+// are fine enough at this scale — one point is a twenty-third of a
+// pixel in the rendered box.
+type previewBar struct {
+ X, Y, W, H int
+}
+
+// bars converts a laid-out first page into the rectangles that stand
+// in for its lines.
+//
+// The height of a bar is derived from the type size rather than from
+// the line box, so a heading draws heavier than body text and the
+// difference is the real ratio between them. It is deliberately taller
+// than the text's own x-height: at this scale a bar of literally
+// correct height is a third of a pixel, and the sub-pixel coverage a
+// browser resolves that to varies enough between renderers to be worth
+// not depending on. minBarPt is the floor that keeps the faintest line
+// visible; it is a legibility decision, and the only one here that is
+// not simply reported from the layout.
+func bars(lines []export.LineBox) []previewBar {
+ const (
+ barOfSize = 0.66 // of the type size, roughly its cap height
+ minBarPt = 9.0
+ )
+ if len(lines) == 0 {
+ return nil
+ }
+ out := make([]previewBar, 0, len(lines))
+ for _, l := range lines {
+ h := l.Size * barOfSize
+ if h < minBarPt {
+ h = minBarPt
+ }
+ // Centred in its own line box, which is where the text it
+ // stands for sits.
+ y := l.Top + (l.Height-h)/2
+ out = append(out, previewBar{
+ X: int(l.Left + 0.5),
+ Y: int(y + 0.5),
+ W: int(l.Width + 0.5),
+ H: int(h + 0.5),
+ })
+ }
+ return out
+}
+
+// previews is the document list's thumbnail cache.
+//
+// IN MEMORY, AND NOT IN THE DATABASE — the one decision in this file
+// worth arguing with, so here is the argument. A previewBar carries no
+// characters: it is four numbers, and the document's words never leave
+// export.PageOne (see LineBox's own note). So this is not plaintext,
+// and writing it to SQLite would not put document text at rest.
+//
+// It is still not written down, for a reason that outlives that. Spec
+// §2 seals content at rest and §2's boundary test greps raw SQLite
+// bytes for a fixture's known plaintext. A preview column would be a
+// second thing to remember to seal when sealing lands, and that grep
+// could never catch a lapse, because a shape contains none of the
+// fixture's words: the check that guards the seal rule would pass
+// while the obligation went unmet. Keeping the cache in memory creates
+// no obligation to forget. §2.1's leak list already blesses the
+// running instance holding exactly this — item 1 names thumbnails as
+// what the trade buys.
+//
+// Freshness is by construction rather than by invalidation. The key is
+// the document's updated_at in Unix nanoseconds, so an edit makes the
+// stored entry unreachable without any writer having to remember to
+// evict it — and a write path added later cannot forget a call it does
+// not have to make. What it does rely on is that every content write
+// bumps updated_at; Store.ApplyUpdate's own comment now records that
+// as an invariant rather than leaving it as a habit.
+//
+// Two traps, both live:
+//
+// - Compare UnixNano, never time.Time with ==. A time read back
+// through the sqlite driver and one made with time.Now().UTC() are
+// never == even at the same instant, and a cache keyed on that
+// comparison silently never hits.
+// - Take the timestamp from the row the list query returned, never
+// from a *Document that has been through ApplyUpdate: that method
+// bumps the column, not the struct it was handed.
+type previews struct {
+ mu sync.Mutex
+ // by is keyed on Document.ID. Bounded rather than evicted in LRU
+ // order: at this size the bookkeeping costs more than the misses
+ // it would save, and a miss is one page of layout.
+ by map[int64]previewEntry
+}
+
+type previewEntry struct {
+ updatedAtNanos int64
+ // bars is nil for a document with nothing on its first page, which
+ // is a real answer and is cached like any other — an empty
+ // document must not be replayed on every list render.
+ bars []previewBar
+ // failed records that this document's structure could not be read
+ // at this version. Cached deliberately: a document whose log will
+ // not replay would otherwise be retried on every page view for as
+ // long as it stays broken, turning one bad row into a permanent
+ // error-log fountain, and it will not fix itself before its
+ // updated_at changes.
+ failed bool
+}
+
+// maxPreviews bounds the map. Past it the whole map is dropped rather
+// than trimmed: this is a cache in front of a bounded amount of work,
+// so the cost of being wrong is one page of layout per document on the
+// next render, and that is not worth a heap of bookkeeping to avoid.
+const maxPreviews = 512
+
+func newPreviews() *previews { return &previews{by: map[int64]previewEntry{}} }
+
+// get returns the cached bars for doc at its current updated_at, and
+// whether there was a usable entry. A cached failure returns (nil,
+// true): the answer is known and it is "draw nothing".
+func (p *previews) get(id int64, updatedAtNanos int64) ([]previewBar, bool) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ e, ok := p.by[id]
+ if !ok || e.updatedAtNanos != updatedAtNanos {
+ return nil, false
+ }
+ return e.bars, true
+}
+
+func (p *previews) put(id int64, updatedAtNanos int64, b []previewBar, failed bool) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if len(p.by) >= maxPreviews {
+ p.by = map[int64]previewEntry{}
+ }
+ p.by[id] = previewEntry{updatedAtNanos: updatedAtNanos, bars: b, failed: failed}
+}
+
+// thumbnail returns the bars for one document's first page, from the
+// cache when it can and by laying the page out when it cannot.
+//
+// It never returns an error. A document whose structure will not read
+// gets no thumbnail and the row renders the empty box — the failure is
+// logged once per version and cached, because the list is the one page
+// that must work, and a single unreplayable document turning it into a
+// 500 would be a worse bug than the one it is reporting.
+func (h *handlers) thumbnail(ctx context.Context, doc *Document) []previewBar {
+ nanos := doc.UpdatedAt.UnixNano()
+ if b, ok := h.previews.get(doc.ID, nanos); ok {
+ return b
+ }
+
+ structure, err := h.store.JSON(ctx, doc)
+ if err != nil {
+ h.logger.Error("docs: read structure for a list thumbnail", "doc_id", doc.ID, "err", err)
+ h.previews.put(doc.ID, nanos, nil, true)
+ return nil
+ }
+ // Deliberately no title. export.PageOne lays out whatever Title
+ // the Document carries, because the PDF exporter puts one at the
+ // top of its first page — a file that does not say which document
+ // it is, is worse than one that repeats a heading. A thumbnail is
+ // not a file: it is a picture of the paper, and views/show.html
+ // keeps the title off the paper entirely (it lives in the shell
+ // bar, chrome around the document rather than on it). Laying it in
+ // here would draw a heading the reader has never seen on the page,
+ // directly beside the very title it duplicates — and would give
+ // every empty document a one-bar preview of content it does not
+ // have.
+ parsed, err := export.Parse("", structure)
+ if err != nil {
+ h.logger.Error("docs: parse structure for a list thumbnail", "doc_id", doc.ID, "err", err)
+ h.previews.put(doc.ID, nanos, nil, true)
+ return nil
+ }
+
+ b := bars(export.PageOne(parsed))
+ h.previews.put(doc.ID, nanos, b, false)
+ return b
+}
+
+// thumbnailBudget is how many documents one list render will lay out
+// on a cold cache before it stops and leaves the rest to fill in on
+// later renders.
+//
+// The list is the app's landing page and it has no pagination, so
+// without a bound the first render after a restart costs one page of
+// layout per document a person owns, serially, before a byte of HTML
+// is written. With one, a cold cache costs a bounded amount and warms
+// over the next few renders; a warm cache never reaches the bound at
+// all, because a hit does not spend from it.
+const thumbnailBudget = 40
+
+// thumbnailer returns the function the row builders call for each
+// document, holding one render's budget between them.
+//
+// A cache hit costs nothing and spends nothing, which is what keeps a
+// warm list from ever reaching the bound: the budget exists for the
+// cold case, and the cold case is the only one that is slow.
+func (h *handlers) thumbnailer(ctx context.Context) func(*Document) thumbView {
+ budget := thumbnailBudget
+ return func(d *Document) thumbView {
+ if b, ok := h.previews.get(d.ID, d.UpdatedAt.UnixNano()); ok {
+ return newThumbView(b)
+ }
+ if budget <= 0 {
+ return newThumbView(nil)
+ }
+ budget--
+ return newThumbView(h.thumbnail(ctx, d))
+ }
+}
+
+// thumbView is what list.html's "thumb" template is handed: the bars,
+// and the viewBox they are drawn in.
+//
+// The viewBox travels with them rather than being written into the
+// template as a literal, so it is derived from the exporter's own page
+// constants — the SVG is the page at its own scale, and [docs-thumb]'s
+// 26x34 box does the whole reduction. A literal in the markup would be
+// a fourth place US-Letter is written down and the first one nothing
+// holds to the other three.
+type thumbView struct {
+ Bars []previewBar
+ W, H int
+}
+
+func newThumbView(b []previewBar) thumbView {
+ return thumbView{Bars: b, W: int(export.PageWidthPt), H: int(export.PageHeightPt)}
+}
diff --git a/internal/docs/preview_internal_test.go b/internal/docs/preview_internal_test.go
new file mode 100644
index 0000000..9f91b53
--- /dev/null
+++ b/internal/docs/preview_internal_test.go
@@ -0,0 +1,111 @@
+package docs
+
+import (
+ "testing"
+
+ "github.com/carlosframework/docs/internal/export"
+)
+
+// bars is the only arithmetic in the thumbnail path that is not simply
+// reported from the layout, so it is the part worth pinning.
+
+func TestBarsOfNothingIsNothing(t *testing.T) {
+ if got := bars(nil); got != nil {
+ t.Errorf("got %+v, want nil", got)
+ }
+}
+
+func TestABarSitsInsideTheLineItStandsFor(t *testing.T) {
+ line := export.LineBox{Left: 72, Top: 100, Width: 300, Height: 16.8, Size: 12}
+ got := bars([]export.LineBox{line})
+ if len(got) != 1 {
+ t.Fatalf("got %d bars, want 1", len(got))
+ }
+ b := got[0]
+ if b.X != 72 {
+ t.Errorf("X = %d, want the line's own left edge 72", b.X)
+ }
+ if b.W != 300 {
+ t.Errorf("W = %d, want the line's measured width 300", b.W)
+ }
+ // Vertically centred in the line box, and never taller than it —
+ // a bar that overflowed its line would overlap its neighbour and
+ // a page of text would read as one solid block.
+ if float64(b.Y) < line.Top-1 || float64(b.Y+b.H) > line.Top+line.Height+1 {
+ t.Errorf("bar [%d, %d] is not inside the line box [%.1f, %.1f]",
+ b.Y, b.Y+b.H, line.Top, line.Top+line.Height)
+ }
+}
+
+// A heading draws heavier than body text, and it does so because the
+// type really is bigger — this is what carries the distinction without
+// colour (WCAG 2.2, and AGENTS.md's "colour never carries meaning
+// alone").
+func TestAHeadingBarIsTallerThanABodyBar(t *testing.T) {
+ got := bars([]export.LineBox{
+ {Left: 72, Top: 100, Width: 200, Height: 25, Size: 19.2},
+ {Left: 72, Top: 130, Width: 200, Height: 16.8, Size: 12},
+ })
+ if len(got) != 2 {
+ t.Fatalf("got %d bars, want 2", len(got))
+ }
+ if got[0].H <= got[1].H {
+ t.Errorf("heading bar %d is not taller than body bar %d", got[0].H, got[1].H)
+ }
+}
+
+// The floor. A bar of literally correct height at this scale is a
+// fraction of a pixel; without a minimum, small type vanishes.
+func TestATinyLineStillDrawsSomething(t *testing.T) {
+ got := bars([]export.LineBox{{Left: 72, Top: 100, Width: 200, Height: 4, Size: 1}})
+ if len(got) != 1 || got[0].H <= 0 {
+ t.Fatalf("a tiny line drew nothing: %+v", got)
+ }
+}
+
+// The cache's whole freshness story is this test: an edit changes
+// updated_at, and a changed updated_at makes the old entry
+// unreachable without anyone having called an invalidate.
+func TestAChangedUpdatedAtMissesTheCache(t *testing.T) {
+ p := newPreviews()
+ want := []previewBar{{X: 1, Y: 2, W: 3, H: 4}}
+ p.put(7, 1000, want, false)
+
+ if got, ok := p.get(7, 1000); !ok || len(got) != 1 || got[0] != want[0] {
+ t.Errorf("same version did not hit: got %+v ok=%v", got, ok)
+ }
+ if _, ok := p.get(7, 1001); ok {
+ t.Error("a document edited since caching still hit the cache")
+ }
+ if _, ok := p.get(8, 1000); ok {
+ t.Error("a different document hit another document's entry")
+ }
+}
+
+// A cached failure is a hit, not a miss: the answer is known and it is
+// "draw nothing". Without this a document whose log will not replay is
+// laid out again on every single list render.
+func TestACachedFailureIsAHit(t *testing.T) {
+ p := newPreviews()
+ p.put(7, 1000, nil, true)
+ got, ok := p.get(7, 1000)
+ if !ok {
+ t.Fatal("a cached failure missed, so it will be retried on every render")
+ }
+ if got != nil {
+ t.Errorf("a cached failure returned bars: %+v", got)
+ }
+}
+
+func TestTheCacheIsBounded(t *testing.T) {
+ p := newPreviews()
+ for i := int64(0); i < maxPreviews+10; i++ {
+ p.put(i, 1, []previewBar{{X: 1}}, false)
+ }
+ p.mu.Lock()
+ n := len(p.by)
+ p.mu.Unlock()
+ if n > maxPreviews {
+ t.Errorf("cache holds %d entries, past its %d bound", n, maxPreviews)
+ }
+}
diff --git a/internal/docs/store.go b/internal/docs/store.go
index a091d8b..846eeea 100644
--- a/internal/docs/store.go
+++ b/internal/docs/store.go
@@ -706,6 +706,18 @@ func (s *Store) currentSeq(ctx context.Context, docID int64) (int64, error) {
// owner makes the Where match zero rows, First returns
// gorm.ErrRecordNotFound, and this method returns before replay is
// even called — no core touched, no row written.
+//
+// AN INVARIANT THIS METHOD OWNS, written down because something now
+// depends on it: every write that changes a document's content bumps
+// its updated_at. The document list's thumbnail cache (preview.go) is
+// keyed on that column, so a content write that left it alone would
+// serve a preview of the document as it used to be, with nothing
+// anywhere raising a hand. Today this is the only content write, so
+// the invariant holds by having one writer; the writes that will test
+// it are version restore (spec §14) and any future log compaction.
+// Note also that this bumps the COLUMN and not the *Document it was
+// handed — a caller holding that struct has a stale UpdatedAt, which
+// is why the cache takes its key from the row Store.List returned.
func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, update []byte) (int64, error) {
var ownerCheck Document
if err := owned(s.db.WithContext(ctx), doc.OwnerID).
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html
index 5c997ae..6338aad 100644
--- a/internal/docs/views/list.html
+++ b/internal/docs/views/list.html
@@ -59,12 +59,27 @@
shared partial because a page template is where a shell block
override has to live for the clone that page is parsed into. */}}
{{define "account"}}<form method="post" action="/signout"><button type="submit">{{.SignOutLabel}}</button></form>{{end}}
+{{/* thumb — one document's page one, drawn from the line boxes
+ preview.go reports. aria-hidden because it is decorative: it is
+ wholly redundant with the document's own title beside it, and
+ there is no non-visual equivalent of "the shape of the
+ paragraphs" to offer instead. Deliberately no <title>, no role
+ and no tabindex — see AGENTS.md on a role being a promise.
+
+ An empty .Thumb renders the bordered box with nothing in it,
+ which is what an empty document, and a document whose structure
+ could not be read, both get. */}}
+{{define "thumb"}}<span docs-thumb aria-hidden="true">{{if .Bars}}<svg viewBox="0 0 {{.W}} {{.H}}" preserveAspectRatio="xMidYMin slice">{{range .Bars}}<rect x="{{.X}}" y="{{.Y}}" width="{{.W}}" height="{{.H}}"/>{{end}}</svg>{{end}}</span>{{end}}
{{define "content"}}<style>
- /* [docs-thumb] is reserved space, not a placeholder image: a later
- plan's layout engine draws a real rendering of the document's
- page one here, at exactly this box, so nothing else on the row
- reflows the day that lands. 26×34 is literal — the brief's own
- figure, close to a US-letter page's aspect ratio at row scale. */
+ /* [docs-thumb] holds a real rendering of the document's page one:
+ the <svg> inside it is the line boxes the PDF exporter's own
+ layout placed, through export.PageOne (see preview.go). It was
+ reserved and deliberately empty until that existed — "reserved
+ space, not a placeholder image" — and the box is unchanged now
+ that it is filled, which is what that reservation was for.
+ 26×34 is literal: the brief's own figure, and within a percent
+ of a US-Letter page's aspect at row scale, so the page inside it
+ is not distorted to fit. */
[docs-thumb] {
display: block;
flex: none;
@@ -73,6 +88,22 @@
border: 1px solid var(--rst-line-strong);
border-radius: var(--rst-radius-sm);
background: var(--rst-surface);
+ overflow: hidden;
+ }
+ [docs-thumb] > svg {
+ display: block;
+ inline-size: 100%;
+ block-size: 100%;
+ }
+ /* The fill is a token rather than a presentation attribute on each
+ rect: var() in an SVG presentation attribute has a patchy support
+ history, and a token here follows the theme the way every other
+ colour on this page does. Nothing about the thumbnail carries
+ meaning by colour alone — a heading reads as a heading because
+ its bar is taller, which is the real ratio between the two type
+ sizes. */
+ [docs-thumb] rect {
+ fill: var(--rst-text-muted);
}
[docs-group-label] {
margin: 0;
@@ -242,7 +273,7 @@
<div rst-list>
{{range .Results}}
<div rst-row>
- <span docs-thumb aria-hidden="true"></span>
+ {{template "thumb" .Thumb}}
<span rst-row-main><a href="{{.Href}}">{{.Title}}</a></span>
<span docs-row-when>{{.When}}</span>
</div>
@@ -261,7 +292,7 @@
<h2 docs-group-label>{{.Label}}</h2>
{{range .Rows}}
<div rst-row>
- <span docs-thumb aria-hidden="true"></span>
+ {{template "thumb" .Thumb}}
<span rst-row-main><a href="{{.Href}}">{{.Title}}</a></span>
<span docs-row-when>{{.When}}</span>
</div>