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
5 files changed,
+306
−59
internal/docs/app.go+2 −2internal/docs/handlers.go+127 −22internal/docs/list_test.go+48 −0internal/docs/render.go+59 −35internal/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}} |