feat(list): live preview thumbnails, built from shape and never content
A 26x34 thumbnail on every row of the sheet list, in the same slot and the same place as the Docs app's, by agreement between the two sessions — the two file lists have to have the same row height or they stop reading as one product. **It is built from the SHAPE of a sheet, never its contents.** It reads which cells exist and what formats they carry — two columns of the cells table and the interned formats they point at — and never opens a sealed value. A preview that cannot read a cell cannot leak one, so the one rule needs no argument here and no exemption, and the picture costs two indexed queries rather than opening a Handle and recomputing a formula graph. TestAPreviewNeverCarriesACell is the gate, with canary values nothing in an SVG of rectangles could emit by accident. Cached in memory, never in a column. The content would be safe in a column; the reason not to is that a preview column becomes a second thing to remember to seal later, and the plaintext-grep gate structurally cannot catch it — a picture of a grid contains no fixture words, so that test would pass while the obligation went unmet. An untestable obligation is the argument, not disclosure. (Docs reached this first and it is theirs.) Invalidation is by construction: an entry hits only when its stored nanosecond timestamp equals the one on the row the list just read, so every write that bumps UpdatedAt invalidates it and there is no Forget() for a future write path to forget. Compared as UnixNano int64s, never time.Time with == — a time parsed from SQLite is never == one made in memory, so that cache would silently never hit and look exactly like one that works. Fills are theme-resolved the way the grid's are, so a green total row is green in the thumbnail in both themes. The drawing scales to the sheet's used range rather than a fixed window: at this size a quarter-filled box reads as a smudge rather than as a small sheet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0182f7QfojuuqdS4Xd2fYFaL
8 files changed,
+692
−4
internal/sheets/app.go+7 −1internal/sheets/handlers_sheets.go+15 −2internal/sheets/preview.go+236 −0internal/sheets/preview_test.go+185 −0internal/sheets/previewcache.go+97 −0internal/sheets/previewsvg.go+115 −0internal/sheets/static/sheets.css+27 −0internal/sheets/templates/sheets.html+10 −1
diff --git a/internal/sheets/app.go b/internal/sheets/app.go| index 69fffe6..49fbe15 100644 |
| --- a/internal/sheets/app.go |
| +++ b/internal/sheets/app.go |
| @@ -74,6 +74,11 @@ type App struct { |
| // jobPages is the status page, its polled fragment and its event |
| // stream, over that registry. |
| jobPages *jobs.Handlers |
| + |
| + // previews holds the sheet list's rendered thumbnails. One per |
| + // process, and in memory only — see previewcache.go for why a |
| + // column would be the wrong place even though the content is safe. |
| + previews *previewCache |
| } |
| // New builds an App. |
| @@ -87,7 +92,8 @@ func New(cfg Config) *App { |
| hub: live.NewHub(), |
| // nil logger means slog.Default(), which is exactly what main |
| // passes rastrillo everywhere else. |
| - jobs: jobs.New(nil), |
| + jobs: jobs.New(nil), |
| + previews: newPreviewCache(), |
| } |
| // NewHandlers' only failure is a nil field in a Config this |
| // function just built, so a failure here is this line being wrong |
diff --git a/internal/sheets/handlers_sheets.go b/internal/sheets/handlers_sheets.go| index 0f79ea3..e04c191 100644 |
| --- a/internal/sheets/handlers_sheets.go |
| +++ b/internal/sheets/handlers_sheets.go |
| @@ -2,6 +2,7 @@ package sheets |
| import ( |
| "errors" |
| + "html/template" |
| "net/http" |
| "strconv" |
| @@ -9,9 +10,17 @@ import ( |
| "gorm.io/gorm" |
| ) |
| +// sheetRow is one line of the sheet list: a summary plus its |
| +// thumbnail, rendered here rather than in the template because the |
| +// template cannot call a method that needs the app. |
| +type sheetRow struct { |
| + SheetSummary |
| + Preview template.HTML |
| +} |
| + |
| // sheetListView is the sheet list's payload. |
| type sheetListView struct { |
| - Sheets []SheetSummary |
| + Sheets []sheetRow |
| // ImportError is a refused upload's explanation, empty on an |
| // ordinary render. It lives on the list rather than in a flash |
| // because the list is where the import form is: a refusal shown |
| @@ -83,8 +92,12 @@ func (a *App) renderSheetList(w http.ResponseWriter, r *http.Request, status int |
| a.fail(w, r, err) |
| return |
| } |
| + rows := make([]sheetRow, 0, len(list)) |
| + for _, s := range list { |
| + rows = append(rows, sheetRow{SheetSummary: s, Preview: a.previewFor(s)}) |
| + } |
| a.render(w, r, "sheets", "Your sheets — Oficina Sheets", status, |
| - sheetListView{Sheets: list, ImportError: importErr}) |
| + sheetListView{Sheets: rows, ImportError: importErr}) |
| } |
| // handleSheetCreate makes a sheet and sends you to it. |
diff --git a/internal/sheets/preview.go b/internal/sheets/preview.go| new file mode 100644 |
| index 0000000..9472e77 |
| --- /dev/null |
| +++ b/internal/sheets/preview.go |
| @@ -0,0 +1,236 @@ |
| +package sheets |
| + |
| +import ( |
| + "sort" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| +) |
| + |
| +// The list's live preview thumbnails. |
| +// |
| +// **The preview is built from the SHAPE of a sheet, never its |
| +// contents.** It reads which cells exist and what formats they carry — |
| +// two columns of the cells table and the interned formats they point at |
| +// — and it never opens a sealed value. That is not a shortcut, it is |
| +// the design: a preview that cannot read a cell cannot leak one, so the |
| +// one rule needs no argument here and no exemption. It also means |
| +// building one costs two indexed queries rather than opening a Handle |
| +// and recomputing a formula graph, which is what makes a list of twenty |
| +// sheets affordable at all. |
| +// |
| +// What it therefore shows is real and honest: where the data is, which |
| +// rows are formatted, what colour they are. What it cannot show is the |
| +// text, which at this size would be a grey smudge in any case — Docs |
| +// reached the same conclusion about its own line boxes. |
| +// |
| +// Shared with the Docs app by agreement: same 26x34 slot, same place in |
| +// the row, so the two file lists read as one product rather than as two |
| +// apps that both happen to have thumbnails. |
| + |
| +// The sampled window. Enough of the top-left to recognise a sheet by, |
| +// and no more: past about this the bars are under a pixel and the |
| +// preview stops being a picture of anything. |
| +const ( |
| + previewRows = 12 |
| + previewCols = 8 |
| +) |
| + |
| +// previewCell is one mark in a thumbnail. |
| +// |
| +// Fill is a CSS colour to paint behind the bar, already resolved to |
| +// something a browser can read: an author's literal hex from an |
| +// imported file, or an oklch() that resolves the app's own hue and |
| +// chroma against the theme's lightness custom property. Empty for an |
| +// unfilled cell. |
| +type previewCell struct { |
| + R, C int |
| + Fill string |
| + Bold bool |
| +} |
| + |
| +// preview is what a list row draws. |
| +// |
| +// Empty reports a sheet with nothing in the sampled window, which |
| +// renders as the empty bordered box rather than as a fabricated |
| +// picture. Docs does the same for an empty document, deliberately: a |
| +// preview that invents content is worse than one that admits there is |
| +// none. |
| +type preview struct { |
| + Cells []previewCell |
| + // Rows and Cols are the extent actually DRAWN, not the window |
| + // sampled. A sheet four columns wide fills the thumbnail rather |
| + // than drawing four specks in the corner of a box sized for eight — |
| + // at 26 by 34 pixels a quarter-filled picture reads as a smudge, |
| + // not as a small sheet. The sampled window still caps it, so a |
| + // large sheet and a very large one look alike, which is honest: |
| + // past that the bars are under a pixel either way. |
| + Rows, Cols int |
| + Empty bool |
| +} |
| + |
| +// buildPreview reads a tab's shape. |
| +// |
| +// Two queries and no Handle. The row and column records give position, |
| +// the cells table gives presence and a format id, and the formats table |
| +// gives the rest. Nothing here can reach a sealed column. |
| +func (a *App) buildPreview(tabID uint64) preview { |
| + if a.db == nil || tabID == 0 { |
| + return preview{Empty: true} |
| + } |
| + |
| + // Positions first: the cells table keys on stable row and column |
| + // ids, and a preview is a picture of a grid, so the ids have to |
| + // become coordinates before anything can be drawn. |
| + rowPos := a.rowPositions(tabID) |
| + colPos := a.colPositions(tabID) |
| + if len(rowPos) == 0 || len(colPos) == 0 { |
| + return preview{Empty: true} |
| + } |
| + |
| + type cellRow struct { |
| + RowID, ColID uint64 |
| + FormatID *uint64 |
| + } |
| + var cells []cellRow |
| + if err := a.db.Model(&store.Cell{}). |
| + Select("row_id", "col_id", "format_id"). |
| + Where("tab_id = ?", tabID). |
| + Find(&cells).Error; err != nil { |
| + return preview{Empty: true} |
| + } |
| + |
| + seen := map[uint64]bool{} |
| + ids := make([]uint64, 0, len(cells)) |
| + for _, c := range cells { |
| + if c.FormatID == nil || seen[*c.FormatID] { |
| + continue |
| + } |
| + seen[*c.FormatID] = true |
| + ids = append(ids, *c.FormatID) |
| + } |
| + formats := a.previewFormats(ids) |
| + |
| + out := make([]previewCell, 0, len(cells)) |
| + for _, c := range cells { |
| + r, ok := rowPos[c.RowID] |
| + if !ok || r >= previewRows { |
| + continue |
| + } |
| + col, ok := colPos[c.ColID] |
| + if !ok || col >= previewCols { |
| + continue |
| + } |
| + pc := previewCell{R: r, C: col} |
| + if c.FormatID != nil { |
| + if f, ok := formats[*c.FormatID]; ok { |
| + pc.Bold = f.Bold |
| + pc.Fill = previewFill(f) |
| + } |
| + } |
| + out = append(out, pc) |
| + } |
| + if len(out) == 0 { |
| + return preview{Empty: true} |
| + } |
| + // Sorted so the markup is stable: two renders of an unchanged sheet |
| + // must produce identical bytes, or the cache below is comparing |
| + // timestamps to decide something that changes anyway. |
| + sort.Slice(out, func(i, j int) bool { |
| + if out[i].R != out[j].R { |
| + return out[i].R < out[j].R |
| + } |
| + return out[i].C < out[j].C |
| + }) |
| + |
| + maxR, maxC := 0, 0 |
| + for _, c := range out { |
| + if c.R > maxR { |
| + maxR = c.R |
| + } |
| + if c.C > maxC { |
| + maxC = c.C |
| + } |
| + } |
| + // Floors, so a single cell does not become one enormous bar filling |
| + // the whole thumbnail — which would make a nearly empty sheet look |
| + // like the fullest one in the list. |
| + return preview{Cells: out, Rows: atLeast(maxR+1, 4), Cols: atLeast(maxC+1, 3)} |
| +} |
| + |
| +func atLeast(v, floor int) int { |
| + if v < floor { |
| + return floor |
| + } |
| + return v |
| +} |
| + |
| +// rowPositions and colPositions read a tab's stable ids as grid |
| +// coordinates. |
| +// |
| +// Two functions rather than one generic, because Row and Col are |
| +// structurally identical and semantically not: making them one |
| +// parameter of the same call is the kind of tidiness that later makes |
| +// somebody pass a row where a column belongs and get a picture that is |
| +// silently transposed. |
| +func (a *App) rowPositions(tabID uint64) map[uint64]int { |
| + var rows []store.Row |
| + if err := a.db.Select("id", "position").Where("tab_id = ?", tabID). |
| + Find(&rows).Error; err != nil { |
| + return nil |
| + } |
| + out := make(map[uint64]int, len(rows)) |
| + for _, r := range rows { |
| + out[r.ID] = r.Position |
| + } |
| + return out |
| +} |
| + |
| +func (a *App) colPositions(tabID uint64) map[uint64]int { |
| + var cols []store.Col |
| + if err := a.db.Select("id", "position").Where("tab_id = ?", tabID). |
| + Find(&cols).Error; err != nil { |
| + return nil |
| + } |
| + out := make(map[uint64]int, len(cols)) |
| + for _, c := range cols { |
| + out[c.ID] = c.Position |
| + } |
| + return out |
| +} |
| + |
| +// previewFormats reads exactly the interned formats a preview refers |
| +// to. Interned, so a column of identically styled cells is one row here |
| +// — reading them all would be reading the whole table for a picture |
| +// that uses two of its columns. |
| +func (a *App) previewFormats(ids []uint64) map[uint64]store.Format { |
| + out := map[uint64]store.Format{} |
| + if len(ids) == 0 { |
| + return out |
| + } |
| + var rows []store.Format |
| + if err := a.db.Select("id", "bold", "fill_hex", "fill_intent_h", "fill_intent_c"). |
| + Where("id IN ?", ids).Find(&rows).Error; err != nil { |
| + return out |
| + } |
| + for _, f := range rows { |
| + out[f.ID] = f |
| + } |
| + return out |
| +} |
| + |
| +// previewFill resolves a format's fill to a CSS colour, or "". |
| +// |
| +// The same two-case rule the grid paints by: an imported file's literal |
| +// is drawn as the file authored it, and a colour this app applied is |
| +// hue and chroma resolved against the theme's own lightness — so a |
| +// thumbnail follows light and dark the way the sheet it previews does. |
| +func previewFill(f store.Format) string { |
| + if hex := safeHex(f.FillHex); hex != "" { |
| + return hex |
| + } |
| + if f.FillIntentH == nil || f.FillIntentC == nil { |
| + return "" |
| + } |
| + return "oklch(var(--sheet-fill-l) " + |
| + trimFloat(*f.FillIntentC) + " " + trimFloat(*f.FillIntentH) + ")" |
| +} |
diff --git a/internal/sheets/preview_test.go b/internal/sheets/preview_test.go| new file mode 100644 |
| index 0000000..ea6a12e |
| --- /dev/null |
| +++ b/internal/sheets/preview_test.go |
| @@ -0,0 +1,185 @@ |
| +package sheets |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// listBody fetches the sheet list as the signed-in viewer. |
| +func listBody(t *testing.T, app *App, cookies []*http.Cookie) string { |
| + t.Helper() |
| + rec := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets", nil), cookies)) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET /sheets = %d", rec.Code) |
| + } |
| + return rec.Body.String() |
| +} |
| + |
| +// firstPreview cuts the first thumbnail out of a rendered list. |
| +// |
| +// It anchors on the SLOT and not on "<svg", which is the mistake this |
| +// helper exists to stop anybody making twice: the layout's account menu |
| +// renders a chevron icon further up the page, so a search for the first |
| +// <svg> finds that one, and every assertion about the thumbnail then |
| +// passes or fails on the wrong element. |
| +func firstPreview(t *testing.T, page string) string { |
| + t.Helper() |
| + i := strings.Index(page, "data-preview-slot") |
| + if i < 0 { |
| + t.Fatal("the list rendered no thumbnail slot at all, so this test proves nothing") |
| + } |
| + rest := page[i:] |
| + start := strings.Index(rest, "<svg") |
| + end := strings.Index(rest, "</svg>") |
| + if start < 0 || end < 0 { |
| + t.Fatalf("the thumbnail slot holds no SVG: %.200s", rest) |
| + } |
| + return rest[start : end+6] |
| +} |
| + |
| +// TestAPreviewNeverCarriesACell is the gate on the property the whole |
| +// design rests on. |
| +// |
| +// The thumbnail is built from which cells EXIST and what formats they |
| +// carry — never from a cell's value. That is what makes it safe to |
| +// cache, safe to render on every list load, and safe from the one rule |
| +// without needing an exemption or an argument. A refactor that reached |
| +// for the value to make the bars prettier would break it silently: the |
| +// picture would look better and nobody would notice what it now |
| +// contained. |
| +// |
| +// The fixture words are deliberately unusual. "3228.33" would be a |
| +// weak canary — a coordinate or a width could produce those digits by |
| +// accident — so the values here are strings nothing else in an SVG of |
| +// rectangles could plausibly emit. |
| +func TestAPreviewNeverCarriesACell(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, cells := newSheet(t, app, cookies, "Ledger") |
| + |
| + secrets := []string{"Zanzibar", "Quixotry", "Vellichor"} |
| + for i, word := range secrets { |
| + body := `{"r":` + itoa(i) + `,"c":0,"raw":"` + word + `"}` |
| + if rec := postCell(t, app, cookies, cells, body); rec.Code != http.StatusOK { |
| + t.Fatalf("write %q = %d", word, rec.Code) |
| + } |
| + } |
| + _ = s |
| + |
| + svg := firstPreview(t, listBody(t, app, cookies)) |
| + |
| + for _, word := range secrets { |
| + if strings.Contains(svg, word) { |
| + t.Errorf("the thumbnail contains the cell value %q — a preview is built "+ |
| + "from the sheet's shape, never its contents, and that is the whole "+ |
| + "reason it needs no seal reasoning", word) |
| + } |
| + } |
| + // It must still be a picture OF something: a test that passes |
| + // because nothing is drawn would pass forever. |
| + if strings.Count(svg, "<rect") < 4 { |
| + t.Errorf("the thumbnail has %d rects for a sheet with three filled cells — "+ |
| + "it is not drawing the shape it is supposed to", strings.Count(svg, "<rect")) |
| + } |
| +} |
| + |
| +// itoa is strconv.Itoa for a single digit, which is all this file |
| +// needs; a full import for three row numbers is noise. |
| +func itoa(i int) string { return string(rune('0' + i)) } |
| + |
| +// TestAnEmptySheetPreviewsAsAnEmptyBox pins the honest answer for a |
| +// sheet with nothing in it: the box, not a fabricated picture. |
| +func TestAnEmptySheetPreviewsAsAnEmptyBox(t *testing.T) { |
| + app, cookies := signIn(t) |
| + newSheet(t, app, cookies, "Nothing yet") |
| + |
| + svg := firstPreview(t, listBody(t, app, cookies)) |
| + if n := strings.Count(svg, "<rect"); n != 1 { |
| + t.Errorf("an empty sheet drew %d rects, want 1 (the border box) — a preview "+ |
| + "that invents content is worse than one that admits there is none", n) |
| + } |
| +} |
| + |
| +// TestThePreviewCacheHitsAndThenMisses is the test that would have |
| +// caught the trap Docs named: comparing time.Time with == instead of |
| +// comparing UnixNano int64s. |
| +// |
| +// A cache that never hits is invisible — every load simply re-renders |
| +// and looks correct — so the only way to know is to ask it. |
| +func TestThePreviewCacheHitsAndThenMisses(t *testing.T) { |
| + c := newPreviewCache() |
| + at := time.Now().UTC() |
| + |
| + c.put(7, at.UnixNano(), "<svg/>") |
| + |
| + if _, ok := c.get(7, at.UnixNano()); !ok { |
| + t.Error("a cache entry did not hit for the timestamp it was stored under — " + |
| + "which is what happens when times are compared with == rather than by " + |
| + "UnixNano, and it fails silently by re-rendering forever") |
| + } |
| + if _, ok := c.get(7, at.Add(time.Nanosecond).UnixNano()); ok { |
| + t.Error("an entry hit for a NEWER timestamp — an edit would not invalidate it, " + |
| + "and the list would show a preview of the sheet as it used to be") |
| + } |
| + if _, ok := c.get(8, at.UnixNano()); ok { |
| + t.Error("an entry hit for a different sheet") |
| + } |
| +} |
| + |
| +// TestThePreviewCacheIsBounded proves the eviction is real. An unbounded |
| +// map keyed on sheet id is a slow leak, and the only symptom is memory. |
| +func TestThePreviewCacheIsBounded(t *testing.T) { |
| + c := newPreviewCache() |
| + for i := 0; i < maxPreviewEntries+10; i++ { |
| + c.put(uint64(i), int64(i), "<svg/>") |
| + } |
| + c.mu.RLock() |
| + n := len(c.at) |
| + c.mu.RUnlock() |
| + if n > maxPreviewEntries { |
| + t.Errorf("the cache holds %d entries, past its %d bound", n, maxPreviewEntries) |
| + } |
| +} |
| + |
| +// TestAFillReachesThePreview checks the picture actually carries the |
| +// formatting it claims to — including that a colour this app applied is |
| +// emitted as an intent, so the thumbnail follows the reader's theme the |
| +// way the grid does. |
| +func TestAFillReachesThePreview(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Coloured") |
| + |
| + if rec := postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":0,"c2":2,"fill":"green"}`); rec.Code != http.StatusOK { |
| + t.Fatalf("fill = %d: %s", rec.Code, rec.Body.String()) |
| + } |
| + |
| + svg := firstPreview(t, listBody(t, app, cookies)) |
| + if !strings.Contains(svg, "oklch(var(--sheet-fill-l)") { |
| + t.Error("the thumbnail has no theme-resolved fill — a colour this app applied " + |
| + "is hue and chroma with no lightness, so a baked hex here would be a " + |
| + "light-mode swatch sitting on a dark list") |
| + } |
| +} |
| + |
| +// TestSafeHexRefusesWhatIsNotAColour guards the one place a value from |
| +// an uploaded document reaches an SVG attribute. |
| +func TestSafeHexRefusesWhatIsNotAColour(t *testing.T) { |
| + for _, bad := range []string{ |
| + "", "red", "#FFEB9C", "FFEB9", "FFEB9CC", "url(x)", `"/><script>`, |
| + "FFEB9G", "oklch(1 0 0)", |
| + } { |
| + if got := safeHex(bad); got != "" { |
| + t.Errorf("safeHex(%q) = %q, want empty", bad, got) |
| + } |
| + } |
| + if got := safeHex("FFEB9C"); got != "#FFEB9C" { |
| + t.Errorf("safeHex(6 digits) = %q", got) |
| + } |
| + // XLSX writes ARGB; the alpha is dropped, as it is everywhere else |
| + // this app handles a fill hex. |
| + if got := safeHex("FFFFEB9C"); got != "#FFEB9C" { |
| + t.Errorf("safeHex(8 digits) = %q", got) |
| + } |
| +} |
diff --git a/internal/sheets/previewcache.go b/internal/sheets/previewcache.go| new file mode 100644 |
| index 0000000..22bca9b |
| --- /dev/null |
| +++ b/internal/sheets/previewcache.go |
| @@ -0,0 +1,97 @@ |
| +package sheets |
| + |
| +import ( |
| + "html/template" |
| + "sync" |
| +) |
| + |
| +// previewCache holds rendered thumbnails in memory. |
| +// |
| +// **In memory only, never in a column.** The preview contains no cell |
| +// text — buildPreview cannot read one — so it is not plaintext even |
| +// before you seal it, and a column would be safe. The reason not to |
| +// have one is different and better: a preview column becomes a second |
| +// thing to remember to seal later, and the plaintext-grep gate that |
| +// guards the one rule structurally cannot catch it. A picture of a grid |
| +// contains no fixture words, so that test would pass while the |
| +// obligation quietly went unmet. An untestable obligation is the |
| +// argument, not disclosure. (Docs reached this first and it is theirs.) |
| +// |
| +// **Invalidation is by construction, not by a call.** An entry is a hit |
| +// only when the stored nanosecond timestamp equals the one on the row |
| +// the list just read, so any write that bumps UpdatedAt invalidates it |
| +// and there is no Forget() for a future write path to forget. Every |
| +// write path in this app already bumps it: the cell POST, the format |
| +// POST, import, and the two scriptless paths through publishReport. |
| +// |
| +// Two traps, both learned from Docs and both real: |
| +// |
| +// - Compare UnixNano int64s, NEVER time.Time with ==. A time parsed |
| +// out of SQLite and a time.Now().UTC() in memory carry different |
| +// monotonic and location fields, so == is never true and the cache |
| +// silently never hits — it goes on rendering every row on every |
| +// load and looks exactly like a cache that works. |
| +// - Take the timestamp from the LIST QUERY's row, not from a struct |
| +// that has been through a write path, which may not have written |
| +// the field back. |
| +type previewCache struct { |
| + mu sync.RWMutex |
| + at map[uint64]previewEntry |
| +} |
| + |
| +type previewEntry struct { |
| + nanos int64 |
| + svg template.HTML |
| +} |
| + |
| +// maxPreviewEntries bounds the map. |
| +// |
| +// A cache with no bound is a slow leak keyed on however many sheets an |
| +// instance accumulates. The eviction is deliberately crude — past the |
| +// bound the map is dropped whole — because the alternative is an LRU |
| +// with a linked list, and the thing being protected is a few kilobytes |
| +// of markup that costs two indexed queries to rebuild. |
| +const maxPreviewEntries = 512 |
| + |
| +func newPreviewCache() *previewCache { |
| + return &previewCache{at: map[uint64]previewEntry{}} |
| +} |
| + |
| +// get returns the cached thumbnail for a sheet if it was rendered from |
| +// exactly this version of it. |
| +func (c *previewCache) get(sheetID uint64, nanos int64) (template.HTML, bool) { |
| + if c == nil { |
| + return "", false |
| + } |
| + c.mu.RLock() |
| + e, ok := c.at[sheetID] |
| + c.mu.RUnlock() |
| + if !ok || e.nanos != nanos { |
| + return "", false |
| + } |
| + return e.svg, true |
| +} |
| + |
| +func (c *previewCache) put(sheetID uint64, nanos int64, svg template.HTML) { |
| + if c == nil { |
| + return |
| + } |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if len(c.at) >= maxPreviewEntries { |
| + c.at = map[uint64]previewEntry{} |
| + } |
| + c.at[sheetID] = previewEntry{nanos: nanos, svg: svg} |
| +} |
| + |
| +// previewFor is what the list calls: cached if it can be, rendered if |
| +// it cannot. |
| +func (a *App) previewFor(s SheetSummary) template.HTML { |
| + nanos := s.UpdatedAt.UnixNano() |
| + if svg, ok := a.previews.get(s.ID, nanos); ok { |
| + return svg |
| + } |
| + svg := previewSVG(a.buildPreview(s.TabID)) |
| + a.previews.put(s.ID, nanos, svg) |
| + return svg |
| +} |
diff --git a/internal/sheets/previewsvg.go b/internal/sheets/previewsvg.go| new file mode 100644 |
| index 0000000..9b725fb |
| --- /dev/null |
| +++ b/internal/sheets/previewsvg.go |
| @@ -0,0 +1,115 @@ |
| +package sheets |
| + |
| +import ( |
| + "html/template" |
| + "regexp" |
| + "strconv" |
| + "strings" |
| +) |
| + |
| +// The thumbnail's geometry. |
| +// |
| +// 26 by 34, matching the Docs app's slot exactly and by agreement. A |
| +// spreadsheet is a landscape thing and this box is not, which is a |
| +// deliberate trade: the two apps' file lists have to have the same row |
| +// height or they stop reading as one product, and that matters more |
| +// than the aspect of a picture two centimetres tall. |
| +const ( |
| + previewW = 26.0 |
| + previewH = 34.0 |
| + // The bar inside a cell, as a fraction of the cell. A mark, not a |
| + // fill: the cell is where the data is, the bar is that there is |
| + // some. |
| + previewInsetX = 0.14 |
| + previewBarTop = 0.28 |
| + previewBarH = 0.44 |
| +) |
| + |
| +// safeHex accepts a fill hex from a document and returns a CSS colour, |
| +// or "". |
| +// |
| +// Six or eight hex digits and nothing else. The value came out of a |
| +// spreadsheet somebody uploaded and is about to be interpolated into an |
| +// SVG attribute, so this is the check that keeps a crafted "colour" |
| +// from being anything but a colour. html/template would escape the |
| +// attribute anyway; this makes the value correct rather than merely |
| +// harmless. |
| +var hexOnly = regexp.MustCompile(`^[0-9A-Fa-f]{6}$|^[0-9A-Fa-f]{8}$`) |
| + |
| +func safeHex(v string) string { |
| + if !hexOnly.MatchString(v) { |
| + return "" |
| + } |
| + if len(v) == 8 { |
| + v = v[2:] // XLSX writes ARGB; the alpha is always FF here |
| + } |
| + return "#" + v |
| +} |
| + |
| +// trimFloat renders a number for a CSS function without an exponent or |
| +// a trailing run of zeroes, so the markup is stable and short. |
| +func trimFloat(f float64) string { |
| + s := strconv.FormatFloat(f, 'f', 4, 64) |
| + s = strings.TrimRight(s, "0") |
| + return strings.TrimSuffix(s, ".") |
| +} |
| + |
| +// previewSVG renders a thumbnail. |
| +// |
| +// Inline SVG, built server-side, on read. Not a raster and not a file: |
| +// a rendered preview in the bucket would be an object derived from a |
| +// sealed sheet, and the argument about whether it counts as content is |
| +// one this app should never have to have. It also lets the SVG use the |
| +// page's own custom properties — --sheet-fill-l resolves per theme, so |
| +// the thumbnail follows light and dark the way the grid does. |
| +// |
| +// The returned value is template.HTML because it IS markup. Every |
| +// value interpolated into it is a number this file formatted or a |
| +// colour safeHex validated; nothing from a document reaches it |
| +// unchecked. |
| +func previewSVG(p preview) template.HTML { |
| + var b strings.Builder |
| + b.WriteString(`<svg viewBox="0 0 26 34" width="26" height="34" `) |
| + b.WriteString(`role="img" aria-hidden="true" focusable="false" data-preview>`) |
| + b.WriteString(`<rect x="0.5" y="0.5" width="25" height="33" rx="1.5" `) |
| + b.WriteString(`fill="var(--rst-surface)" stroke="var(--rst-line)"/>`) |
| + |
| + if !p.Empty && p.Rows > 0 && p.Cols > 0 { |
| + cw := previewW / float64(p.Cols) |
| + ch := previewH / float64(p.Rows) |
| + for _, c := range p.Cells { |
| + x := float64(c.C) * cw |
| + y := float64(c.R) * ch |
| + |
| + // The fill covers the whole cell, because that is what a |
| + // fill does. It is drawn first so the bar sits on it. |
| + if c.Fill != "" { |
| + rect(&b, x, y, cw, ch, c.Fill, 0) |
| + } |
| + // The bar stands in for content. Bold cells are darker, |
| + // which is what makes a header row read as a header row at |
| + // this size — the one piece of formatting that survives |
| + // being two millimetres tall. |
| + ink := "var(--rst-text-faint)" |
| + if c.Bold { |
| + ink = "var(--rst-text-muted)" |
| + } |
| + rect(&b, |
| + x+cw*previewInsetX, y+ch*previewBarTop, |
| + cw*(1-2*previewInsetX), ch*previewBarH, |
| + ink, 0.4) |
| + } |
| + } |
| + |
| + b.WriteString(`</svg>`) |
| + return template.HTML(b.String()) |
| +} |
| + |
| +func rect(b *strings.Builder, x, y, w, h float64, fill string, rx float64) { |
| + b.WriteString(`<rect x="` + trimFloat(x) + `" y="` + trimFloat(y) + |
| + `" width="` + trimFloat(w) + `" height="` + trimFloat(h) + `"`) |
| + if rx > 0 { |
| + b.WriteString(` rx="` + trimFloat(rx) + `"`) |
| + } |
| + b.WriteString(` fill="` + fill + `"/>`) |
| +} |
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css| index 0cff160..8b0f593 100644 |
| --- a/internal/sheets/static/sheets.css |
| +++ b/internal/sheets/static/sheets.css |
| @@ -854,3 +854,30 @@ |
| [data-sheet-grid] .jss_worksheet > tbody > tr > td[data-k] { |
| color: var(--sheet-k); |
| } |
| + |
| +/* ── The list's thumbnails ─────────────────────────────────────────── |
| + * |
| + * A 26x34 slot at the row's left edge, the same box in the same place |
| + * as the Docs app's, so the two file lists have the same row height and |
| + * read as one product rather than two apps that both grew thumbnails. |
| + * |
| + * The slot is reserved whether or not there is anything to draw: an |
| + * empty sheet renders the empty bordered box, so a row never changes |
| + * height when a sheet gains its first cell. */ |
| +/* [rst-lrow] is a grid, and --rst-cols is rastrillo's own hook for |
| + * saying what its columns are. Setting it is how a row gains a |
| + * thumbnail without overriding anything: without it the row has one |
| + * column and the picture stacks above the title. */ |
| +[data-sheet-list] [rst-lrow] { |
| + --rst-cols: 26px 1fr; |
| +} |
| + |
| +[data-preview-slot] { |
| + display: block; |
| + block-size: 34px; |
| + inline-size: 26px; |
| +} |
| + |
| +[data-preview] { |
| + display: block; |
| +} |
diff --git a/internal/sheets/templates/sheets.html b/internal/sheets/templates/sheets.html| index 7e94a84..2dbef11 100644 |
| --- a/internal/sheets/templates/sheets.html |
| +++ b/internal/sheets/templates/sheets.html |
| @@ -60,9 +60,18 @@ first worksheet is imported. You can also drag a file onto this box.</p> |
| </form> |
| {{if .Data.Sheets}} |
| -<ul rst-list> |
| +<ul rst-list data-sheet-list> |
| {{range .Data.Sheets}} |
| <li rst-lrow> |
| +{{/* The thumbnail. Built from the SHAPE of the sheet — which cells |
| + exist, and what colour they are — and never from a cell's |
| + contents, which is why it cannot leak one. aria-hidden, because |
| + the link beside it already says which sheet this is and a |
| + screen reader gains nothing from "image" twice. |
| + |
| + The same 26x34 slot in the same place as the Docs app's, by |
| + agreement, so the two file lists read as one product. */}} |
| +<span data-preview-slot>{{.Preview}}</span> |
| <div rst-row-main> |
| <a href="/sheets/{{.ID}}">{{.Title}}</a> |
| <span rst-row-sub>Last changed {{.UpdatedAt.Format "2 Jan 2006, 15:04"}}</span> |