oficina / docs Public

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. That gate
required more than page.html's own markup: rastrillo's vendored column
shell hard-codes class="rst-skip"/class="rst-page" outside any
overridable block, so render.go now rewrites those two spots into
their attribute twins before parsing (tokens.css pairs every rst-
class 1:1 with an attribute selector of equal specificity, so this
changes no styling).

A row shows title and when, not who — Document carries no author
column yet and there is no identity to attribute a row to until Task
5. The empty state is a designed screen, not a blank page, and makes
no promise this task can't keep: no create-document call to action,
since that route doesn't exist until Task 4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
Paul Campbell pushed by paul@keymail.dev dae98da8ebf50b9eb6fb2ff751da24c0ffc6493d parent be96717
5 files changed, +306 −59
  • internal/docs/app.go +2 −2
  • internal/docs/handlers.go +127 −22
  • internal/docs/list_test.go +48 −0
  • internal/docs/render.go +59 −35
  • internal/docs/views/list.html +70 −0
diff --git a/internal/docs/app.go b/internal/docs/app.go
index 08a7738..acf1921 100644
--- a/internal/docs/app.go
+++ b/internal/docs/app.go
@@ -103,10 +103,10 @@ func New(cfg Config) (*App, error) {
return nil, fmt.Errorf("docs: build renderer: %w", err)
}
- h := &handlers{render: render, logger: logger}
+ h := &handlers{render: render, logger: logger, store: store}
r := chi.NewRouter()
- r.Get("/", h.home)
+ r.Get("/", h.list)
r.Handle("/static/*", render.assets.Handler())
mux := http.NewServeMux()
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index b491dfe..fec8348 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -3,40 +3,145 @@ package docs
import (
"log/slog"
"net/http"
+ "time"
"github.com/carlosframework/rastrillo"
)
// handlers holds what the app's HTTP handlers need — rastrillo
-// SKILL.md's "one handler struct holding d.G", minus d.G: Task 1 has
-// no data model yet, so it carries only the renderer. Task 2 adds a
-// *gorm.DB-backed store here.
+// SKILL.md's "one handler struct holding d.G", here Store (Task 2)
+// instead: this app reads and writes documents through Store, never
+// through a *gorm.DB of its own.
type handlers struct {
render *renderer
logger *slog.Logger
+ store *Store
}
-// homePage is GET /'s view data. Every field is resolved through
-// rastrillo.T from Catalog (render.go) rather than written as a
-// literal — see homeTemplateSrc, which only ever prints these fields,
-// never a string of its own.
-type homePage struct {
+// listPage is GET /'s view data (spec §6.4). Title and the group
+// labels are resolved through rastrillo.T from Catalog (render.go);
+// a row's Title and When are the document's own data, not authored
+// copy, so list.html prints them directly rather than through T.
+type listPage struct {
+ Title string
+ Groups []docGroup
+ EmptyTitle string
+ EmptyBody string
+}
+
+// docGroup is one recency bucket ("Today", "This week", ...) and the
+// rows in it, in the order Store.List returned them — newest first.
+type docGroup struct {
+ Label string
+ Rows []docRow
+}
+
+// docRow is one document's row. Href points at the read-only view
+// Task 4 wires at GET /d/{sqid}: this task does not add that route,
+// only the link the later task's route answers, so a click 404s until
+// then.
+type docRow struct {
+ Href string
Title string
- Lede string
- Body string
-}
-
-// home serves GET /: the app's first screen, rendered through
-// rastrillo's shell. Tasks 3 and 4 replace this with the document
-// list and, for now, this is the whole app.
-func (h *handlers) home(w http.ResponseWriter, r *http.Request) {
- data := homePage{
- Title: rastrillo.T(r, "docs.home.title"),
- Lede: rastrillo.T(r, "docs.home.lede"),
- Body: rastrillo.T(r, "docs.home.body"),
+ When string
+}
+
+// list serves GET /: the document list, spec §6.4 and Paul's four
+// rulings against it — newest first, grouped by recency, one row per
+// 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.
+//
+// A row shows title and when it was last touched, not who: Document
+// carries no author column yet (Task 5 adds identity), and rendering
+// a "who" today would mean inventing one.
+func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
+ docs, err := h.store.List(r.Context())
+ if err != nil {
+ h.logger.Error("docs: list documents", "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
}
- if err := h.render.page(w, r, "home", data); err != nil {
- h.logger.Error("docs: render home", "err", err)
+
+ data := listPage{
+ Title: rastrillo.T(r, "docs.list.title"),
+ Groups: groupByRecency(r, time.Now().UTC(), docs),
+ EmptyTitle: rastrillo.T(r, "docs.list.empty.title"),
+ EmptyBody: rastrillo.T(r, "docs.list.empty.body"),
+ }
+ 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)
}
}
+
+// groupByRecency buckets docs — already newest-first, per Store.List
+// — into recency groups without re-sorting: consecutive documents
+// that share a bucket join the same group, and a new bucket starts a
+// new group the moment recencyBucket's key changes from the previous
+// document's. That is safe only because recencyBucket is monotonic in
+// time (a later document's bucket never sorts before an earlier one's)
+// and docs arrives already ordered by the same clock.
+func groupByRecency(r *http.Request, now time.Time, docs []*Document) []docGroup {
+ var groups []docGroup
+ var key string
+ for _, d := range docs {
+ k, labelKey := recencyBucket(now, d.UpdatedAt)
+ if len(groups) == 0 || k != key {
+ groups = append(groups, docGroup{Label: rastrillo.T(r, labelKey)})
+ key = k
+ }
+ g := &groups[len(groups)-1]
+ g.Rows = append(g.Rows, docRow{
+ Href: "/d/" + d.Sqid,
+ Title: d.Title,
+ When: formatWhen(now, d.UpdatedAt),
+ })
+ }
+ return groups
+}
+
+// recencyBucket returns a stable bucket key and the catalog key for
+// its label, comparing calendar days in UTC — Document.CreatedAt and
+// UpdatedAt are both stored in UTC (Store.Create), and there is no
+// viewer time zone to convert to yet, since there is no signed-in
+// viewer (Task 5). time.Truncate(24*time.Hour) lines up with the
+// calendar day specifically because both arguments are UTC: the day
+// never shifts under DST the way it can in a zone that observes it.
+func recencyBucket(now, t time.Time) (key, labelKey string) {
+ days := int(now.Truncate(24*time.Hour).Sub(t.Truncate(24*time.Hour)).Hours() / 24)
+ switch {
+ case days <= 0:
+ return "today", "docs.list.group.today"
+ case days == 1:
+ return "yesterday", "docs.list.group.yesterday"
+ case days <= 6:
+ return "week", "docs.list.group.week"
+ default:
+ return "earlier", "docs.list.group.earlier"
+ }
+}
+
+// formatWhen renders t for a row's trailing timestamp: a time of day
+// for today, otherwise a date, with the year appended only once t
+// falls in a year other than now's.
+//
+// This is English-only, hardcoded Go layout strings rather than
+// something Intl-backed, matching this app's single declared locale
+// (Locales, above). datetime.js's date fields get away with the
+// browser's own Intl for exactly this reason; there is no Go-side
+// equivalent wired up here, so a second locale needs this to move
+// rather than simply gaining a catalog entry.
+func formatWhen(now, t time.Time) string {
+ if now.Truncate(24 * time.Hour).Equal(t.Truncate(24 * time.Hour)) {
+ return t.Format("3:04 PM")
+ }
+ if now.Year() == t.Year() {
+ return t.Format("Jan 2")
+ }
+ return t.Format("Jan 2, 2006")
+}
diff --git a/internal/docs/list_test.go b/internal/docs/list_test.go
new file mode 100644
index 0000000..f1d7a4a
--- /dev/null
+++ b/internal/docs/list_test.go
@@ -0,0 +1,48 @@
+package docs_test
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+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")
+ }
+}
diff --git a/internal/docs/render.go b/internal/docs/render.go
index 7e85bed..c74e447 100644
--- a/internal/docs/render.go
+++ b/internal/docs/render.go
@@ -2,10 +2,12 @@ package docs
import (
"bytes"
+ "embed"
"fmt"
"html/template"
"io/fs"
"net/http"
+ "strings"
"testing/fstest"
"github.com/carlosframework/rastrillo"
@@ -26,11 +28,24 @@ const (
// template or a handler), resolved through rastrillo.T at render
// time. cmd/docs/main.go wires it in as rastrillo.Options.BaseCatalog.
var Catalog = rastrillo.Catalog{
- "docs.home.title": "CARLOS Docs",
- "docs.home.lede": "A document editor, warming up.",
- "docs.home.body": "This is the app's first screen: routed, and rendered " +
- "through rastrillo's shell. Documents, and a way to sign in, land in " +
- "the tasks that follow.",
+ // docs.list.* — GET /, spec §6.4. Title is the screen's noun, per
+ // page-header's own contract ("Posts", not "Home"). The four group
+ // labels are recency buckets (handlers.go's recencyBucket); a
+ // document's own title and its row's timestamp are data, not
+ // authored copy, so neither is a catalog key.
+ "docs.list.title": "Documents",
+ "docs.list.group.today": "Today",
+ "docs.list.group.yesterday": "Yesterday",
+ "docs.list.group.week": "This week",
+ "docs.list.group.earlier": "Earlier",
+
+ // docs.list.empty.* — the empty state (Paul's ruling: "An empty
+ // list is a designed screen, not a blank page"). It does not
+ // promise a button: Task 4 is what makes creating a document
+ // possible, and this task must not render a call to action for a
+ // route that does not exist yet.
+ "docs.list.empty.title": "No documents yet",
+ "docs.list.empty.body": "Documents you create will show up here, newest first.",
// docs.error.generic is the one fallback error string this task
// has: a render failure with nowhere styled to land yet (SKILL.md
@@ -50,36 +65,21 @@ var Locales = []string{"en"}
const DefaultLocale = "en"
-// pageTemplates holds one Go template source per page, by name. Per
-// SKILL.md's render.go: "parses one *template.Template per page
-// (layout + page), so two pages can both define \"content\"" — a
-// shared tree could not, since html/template forbids two
-// {{define "content"}} blocks in one tree. Task 3 and Task 4 add
-// "list" and "show" here.
+// viewsFS embeds this app's own page templates — SKILL.md's "one
+// *template.Template per page (layout + page)" pattern, the page half
+// as a real file per Task 3's file list rather than an inline Go
+// string (as Task 1's home page was): a page with a group loop and a
+// style block reads better as markup than as a quoted constant.
+//
+//go:embed views/*.html
+var viewsFS embed.FS
+
+// pageTemplates maps a page name to its file under views/. Task 4
+// adds "show" here.
var pageTemplates = map[string]string{
- "home": homeTemplateSrc,
+ "list": "views/list.html",
}
-// homeTemplateSrc is GET /'s page. It writes the bare rst-* attribute
-// grammar (never class="rst-*") per this app's markup convention,
-// adopted for the whole screen — rastrillo's own shell chrome
-// (rastrillo's Layout, parsed in below) keeps its own class spelling,
-// since that is vendored infrastructure this app calls, not markup it
-// authors.
-const homeTemplateSrc = `
-{{define "title"}}{{.Title}}{{end}}
-{{define "content"}}<header rst-page-header>
- <div rst-page-header-titles>
- <h1>{{.Title}}</h1>
- <p rst-page-header-sub>{{.Lede}}</p>
- </div>
-</header>
-<section rst-box>
- <p>{{.Body}}</p>
-</section>
-{{end}}
-`
-
// renderer owns the app's parsed templates and its fingerprinted
// static-asset registry.
type renderer struct {
@@ -111,18 +111,18 @@ func newRenderer() (*renderer, error) {
if !ok {
return nil, fmt.Errorf("docs: rastrillo ui: shell %q not shipped", shellLayout)
}
- base, err = base.Parse(string(layout))
+ base, err = base.Parse(string(shellAttributeGrammar(layout)))
if err != nil {
return nil, fmt.Errorf("docs: parse rastrillo shell %q: %w", shellLayout, err)
}
pages := make(map[string]*template.Template, len(pageTemplates))
- for name, src := range pageTemplates {
+ for name, path := range pageTemplates {
page, err := base.Clone()
if err != nil {
return nil, fmt.Errorf("docs: clone base template for %q: %w", name, err)
}
- if page, err = page.Parse(src); err != nil {
+ if page, err = page.ParseFS(viewsFS, path); err != nil {
return nil, fmt.Errorf("docs: parse page %q: %w", name, err)
}
pages[name] = page
@@ -131,6 +131,30 @@ func newRenderer() (*renderer, error) {
return &renderer{pages: pages, assets: assets}, nil
}
+// shellAttributeGrammar rewrites the two class="rst-*" spots
+// rastrillo's vendored column shell hard-codes — the skip link and
+// the <main> wrapper — into their bare-attribute twins, [rst-skip]
+// and [rst-page]. Neither is behind an overridable {{block}}, so an
+// app cannot reach them by redefining a shell block the way it can
+// "brand" or "nav": this is the one place the shell's own bytes have
+// to be edited instead.
+//
+// This changes no styling. tokens.css's header comment ("TWO
+// SPELLINGS, ON PURPOSE, FOR ONE RELEASE") pairs every rst- class with
+// an attribute selector of equal specificity precisely so a caller can
+// do this — ui_test.go's TestEveryClassSelectorHasAnAttributeTwin
+// holds both the pairing and the specificity upstream. Skipping this
+// step would leave two class="rst-*" occurrences on every page this
+// app serves, regardless of what markup Task 3 and its neighbors write
+// themselves — this app's own "never class=\"rst-*\"" rule has to
+// cover the shell, not just the content each page owns.
+func shellAttributeGrammar(layout []byte) []byte {
+ s := string(layout)
+ s = strings.ReplaceAll(s, `class="rst-skip"`, `rst-skip`)
+ s = strings.ReplaceAll(s, `class="rst-page"`, `rst-page`)
+ return []byte(s)
+}
+
// page renders the named page's "layout" template — its own content
// wrapped in rastrillo's shell — into w.
func (rd *renderer) page(w http.ResponseWriter, r *http.Request, name string, data any) error {
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html
new file mode 100644
index 0000000..b7d2c1b
--- /dev/null
+++ b/internal/docs/views/list.html
@@ -0,0 +1,70 @@
+{{/* list — GET /, the document list (spec §6.4). handlers.go's list
+ builds the view data: Title and the group labels already resolved
+ through rastrillo.T, EmptyTitle/EmptyBody the same; a row's Title
+ and When are the document's own data and are printed as-is.
+
+ Writes the bare rst-* attribute grammar throughout, never
+ class="rst-*" — this app's whole-screen markup convention (Task
+ 1's home page set it; render.go's shellAttributeGrammar carries
+ it into the two spots rastrillo's own shell hard-codes). The
+ three [docs-*] selectors below are this app's own vocabulary, not
+ rastrillo's — SKILL.md §7's "ui is rastrillo's vocabulary, not
+ yours" is why they are not spelled rst-*, and they carry their
+ own tiny stylesheet here rather than a static/ file this
+ hand-built app does not otherwise have one of. */}}
+{{define "title"}}{{.Title}}{{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] {
+ display: block;
+ flex: none;
+ inline-size: 26px;
+ block-size: 34px;
+ border: 1px solid var(--rst-line-strong);
+ border-radius: var(--rst-radius-sm);
+ background: var(--rst-surface);
+ }
+ [docs-group-label] {
+ margin: 0;
+ padding: var(--rst-sp-3) var(--rst-sp-3) var(--rst-sp-1);
+ font-size: var(--rst-fs-xs);
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--rst-text-muted);
+ }
+ [docs-row-when] {
+ flex: none;
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
+ }
+</style>
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>{{.Title}}</h1>
+ </div>
+</header>
+{{if .Groups}}
+<div rst-list>
+ {{range .Groups}}
+ <h2 docs-group-label>{{.Label}}</h2>
+ {{range .Rows}}
+ <div rst-row>
+ <span docs-thumb aria-hidden="true"></span>
+ <span rst-row-main><a href="{{.Href}}">{{.Title}}</a></span>
+ <span docs-row-when>{{.When}}</span>
+ </div>
+ {{end}}
+ {{end}}
+</div>
+{{else}}
+<div rst-empty>
+ <h2 rst-empty-title>{{.EmptyTitle}}</h2>
+ <p rst-empty-body>{{.EmptyBody}}</p>
+</div>
+{{end}}
+{{end}}