oficina / fichas Public

Table and cards views, a read view apart from editing, and a markup gate

Built to round 1 of the mockups, in Calma.

The customer list is now two views of one list — a table and cards —
switched by a link (?view=table / ?view=cards) so it works with scripts
off and can be bookmarked. The table's last column is each card's latest
call, loaded as one query for the newest entry per card rather than a
card's whole history. The card page is a read view — details, notes, and
the call log with its add form, since logging a call is adding, not
editing — and editing moved to /cards/{id}/edit, which saves back to the
read view. It used to BE the edit form, so opening a customer put every
field in an input.

App pages use rastrillo's sidebar shell with the card types in the rail;
sign-in and errors keep the plain column, because Calma keeps app chrome
out of the place the suite meets you.

The gate that stops this recurring: TestTemplatesUseOnlyDefinedAttributes
fails on any rst-* or fichas-* attribute a template writes that no
stylesheet defines. It failed first on exactly the bug that shipped —
rst-stack and rst-muted in the index and card pages — and passes now that
they are gone. TestTemplateIconsExist does the same for icon slugs, since
icons.Icon returns "" for one it does not know.

fichas' own rules live in static/fichas.css, under a fichas- prefix. They
cannot go in theme.css, which is estilo's generated theme pinned byte for
byte by TestThemeIsEstilosForFichas.

scripts/preview serves the real app over synthetic data, signed in, on
loopback only, so a screen can be looked at before it ships. Every screen
in this commit was screenshotted through it first.

Not built, pending Paul: adding fields and types from the table view, the
card-layout editor, the written summary, and saved views.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev baefe6e76e758591e413a79ec5d8552370c225ad parent 7f69c90
14 files changed, +901 −91
  • internal/fichas/app.go +1 −0
  • internal/fichas/handlers.go +84 −5
  • internal/fichas/read.go +78 −2
  • internal/fichas/render.go +92 −14
  • internal/fichas/static/fichas.css +226 −0
  • internal/fichas/templates/card_delete.html +4 −2
  • internal/fichas/templates/card_edit.html +27 −0
  • internal/fichas/templates/card_new.html +5 −2
  • internal/fichas/templates/card_show.html +38 −47
  • internal/fichas/templates/index.html +44 −19
  • internal/fichas/templates/shell.html +44 −0
  • internal/fichastest/markup_test.go +87 −0
  • internal/fichastest/surface_test.go +57 −0
  • scripts/preview/main.go +114 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/fichas/cmd/fichas	[no test files]
ok  	amadan.net/oficina/fichas/internal/fichas	0.220s
?   	amadan.net/oficina/fichas/internal/fichas/icons	[no test files]
ok  	amadan.net/oficina/fichas/internal/fichas/seal	(cached)
ok  	amadan.net/oficina/fichas/internal/fichastest	0.229s
?   	amadan.net/oficina/fichas/scripts/mockups	[no test files]
?   	amadan.net/oficina/fichas/scripts/preview	[no test files]
?   	amadan.net/oficina/fichas/scripts/writeestilo	[no test files]
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/internal/fichas/app.go b/internal/fichas/app.go
index a89c721..6df16dc 100644
--- a/internal/fichas/app.go
+++ b/internal/fichas/app.go
@@ -82,6 +82,7 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, *app, error
r.Get("/cards/new", a.newCard)
r.Post("/cards", a.createCard)
r.Get("/cards/{id}", a.showCard)
+ r.Get("/cards/{id}/edit", a.editCard)
r.Post("/cards/{id}", a.updateCard)
r.Post("/cards/{id}/entries", a.addEntry)
// A destructive action gets its own URL, never a modal.
diff --git a/internal/fichas/handlers.go b/internal/fichas/handlers.go
index a31aacd..979e212 100644
--- a/internal/fichas/handlers.go
+++ b/internal/fichas/handlers.go
@@ -109,14 +109,78 @@ func (a *app) authOr500(w http.ResponseWriter, r *http.Request, which authRoute)
}
}
+// NavItem is one entry in the sidebar.
+type NavItem struct {
+ Label, Href string
+ Current bool
+}
+
+// page renders a shell page with what every shell page needs: the card
+// types in the sidebar, and who is signed in.
+func (a *app) page(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
+ nav, err := a.nav(r)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ data["Nav"] = nav
+ me, _ := a.identity(r)
+ data["Me"] = me
+ render(w, name, data)
+}
+
+// nav lists the card types. Scoped to cards: an entry type (Call log) is
+// something you add to a card, not somewhere you go.
+func (a *app) nav(r *http.Request) ([]NavItem, error) {
+ var types []CardType
+ if err := a.db.G.WithContext(r.Context()).Where("scope = ?", ScopeCard).Order("id").Find(&types).Error; err != nil {
+ return nil, err
+ }
+ out := make([]NavItem, 0, len(types))
+ for _, ct := range types {
+ label, err := openLabel(a.sealer, ct.SealedLabel)
+ if err != nil {
+ return nil, err
+ }
+ // Slice 1 has one card type and it lives at /. More types get
+ // their own paths when the type designer lands.
+ out = append(out, NavItem{Label: pluralLabel(label), Href: "/", Current: ct.Key == TypeCustomer})
+ }
+ return out, nil
+}
+
+// pluralLabel names a collection: a type is "Customer", its list is
+// "Customers". Deliberately naive; a type designer gets its own field for
+// this rather than growing English rules here.
+func pluralLabel(s string) string {
+ if s == "" || strings.HasSuffix(s, "s") {
+ return s
+ }
+ return s + "s"
+}
+
+// index is the record list, as a table or as cards — the view switch Paul
+// asked for. The view is a query parameter so it is a link, works with
+// scripts off, and can be bookmarked.
func (a *app) index(w http.ResponseWriter, r *http.Request) {
cards, err := ListCards(r.Context(), a.db.G, a.sealer, TypeCustomer)
if err != nil {
a.fail(w, r, err)
return
}
- render(w, "index", map[string]any{
- "Cards": cards,
+ fields, err := CardFields(r.Context(), a.db.G, a.sealer, TypeCustomer)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ view := r.URL.Query().Get("view")
+ if view != "cards" {
+ view = "table"
+ }
+ a.page(w, r, "index", map[string]any{
+ "Cards": cards,
+ "Fields": fields,
+ "View": view,
})
}
@@ -126,7 +190,7 @@ func (a *app) newCard(w http.ResponseWriter, r *http.Request) {
a.fail(w, r, err)
return
}
- render(w, "card_new", map[string]any{
+ a.page(w, r, "card_new", map[string]any{
"Fields": fields,
})
}
@@ -165,12 +229,27 @@ func (a *app) showCard(w http.ResponseWriter, r *http.Request) {
a.fail(w, r, err)
return
}
- render(w, "card_show", map[string]any{
+ a.page(w, r, "card_show", map[string]any{
"Card": card,
"EntryFields": entryFields,
})
}
+// editCard is the form, on its own URL. It used to BE the card page, which
+// put every field in an input the moment you opened a customer.
+func (a *app) editCard(w http.ResponseWriter, r *http.Request) {
+ id, ok := pathID(w, r)
+ if !ok {
+ return
+ }
+ card, err := LoadCard(r.Context(), a.db.G, a.sealer, id)
+ if err != nil {
+ a.notFoundOrFail(w, r, err)
+ return
+ }
+ a.page(w, r, "card_edit", map[string]any{"Card": card})
+}
+
func (a *app) updateCard(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
@@ -230,7 +309,7 @@ func (a *app) confirmDelete(w http.ResponseWriter, r *http.Request) {
a.notFoundOrFail(w, r, err)
return
}
- render(w, "card_delete", map[string]any{"Card": card})
+ a.page(w, r, "card_delete", map[string]any{"Card": card})
}
func (a *app) deleteCard(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/fichas/read.go b/internal/fichas/read.go
index 4df66ca..c216e08 100644
--- a/internal/fichas/read.go
+++ b/internal/fichas/read.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"sort"
+ "time"
"amadan.net/oficina/fichas/internal/fichas/seal"
"gorm.io/gorm"
@@ -34,6 +35,9 @@ type CardView struct {
TypeLabel string
Fields []FieldView
Entries []EntryView
+ // Latest is the most recent entry, filled by ListCards for the table's
+ // "Last call" column and the card face. Nil when there is none.
+ Latest *EntryView
}
// Title is what a card is called in a list. The first printed text field
@@ -163,13 +167,46 @@ func ListCards(ctx context.Context, db *gorm.DB, s *seal.Sealer, typeKey string)
byCard[v.CardID][v.FieldDefID] = v.SealedValue
}
+ latest, err := latestEntries(g, s, ids)
+ if err != nil {
+ return nil, err
+ }
+
out := make([]CardView, 0, len(cards))
for _, c := range cards {
fields, err := fieldViews(s, append([]FieldDef(nil), defs...), byCard[c.ID])
if err != nil {
return nil, err
}
- out = append(out, CardView{Card: c, TypeKey: ct.Key, TypeLabel: typeLabel, Fields: fields})
+ out = append(out, CardView{Card: c, TypeKey: ct.Key, TypeLabel: typeLabel, Fields: fields, Latest: latest[c.ID]})
+ }
+ return out, nil
+}
+
+// latestEntries returns each card's most recent entry, unsealed.
+//
+// One query for the newest entry id per card, then the ordinary loader
+// for just those rows — so a list page unseals one entry per card, not a
+// card's whole history.
+func latestEntries(g *gorm.DB, s *seal.Sealer, cardIDs []int64) (map[int64]*EntryView, error) {
+ var newest []struct {
+ CardID int64
+ ID int64
+ }
+ if err := g.Model(&Entry{}).Select("card_id, MAX(id) AS id").
+ Where("card_id IN ?", cardIDs).Group("card_id").Scan(&newest).Error; err != nil {
+ return nil, fmt.Errorf("fichas: finding latest entries: %w", err)
+ }
+ out := map[int64]*EntryView{}
+ for _, n := range newest {
+ views, err := loadEntriesWhere(g, s, "id = ?", n.ID)
+ if err != nil {
+ return nil, err
+ }
+ if len(views) == 1 {
+ v := views[0]
+ out[n.CardID] = &v
+ }
}
return out, nil
}
@@ -218,8 +255,12 @@ func LoadCard(ctx context.Context, db *gorm.DB, s *seal.Sealer, cardID int64) (C
}
func loadEntries(g *gorm.DB, s *seal.Sealer, cardID int64) ([]EntryView, error) {
+ return loadEntriesWhere(g, s, "card_id = ?", cardID)
+}
+
+func loadEntriesWhere(g *gorm.DB, s *seal.Sealer, where string, arg any) ([]EntryView, error) {
var entries []Entry
- if err := g.Where("card_id = ?", cardID).Order("created_at desc, id desc").Find(&entries).Error; err != nil {
+ if err := g.Where(where, arg).Order("created_at desc, id desc").Find(&entries).Error; err != nil {
return nil, fmt.Errorf("fichas: loading entries: %w", err)
}
if len(entries) == 0 {
@@ -334,3 +375,38 @@ func memberByAddress(ctx context.Context, db *gorm.DB, s *seal.Sealer, address s
}
return m, nil
}
+
+// DetailItems is the read view's fact list: every field except the title
+// and long text, in order, with "—" for an empty one so the shape of the
+// card does not change as it fills in.
+func (c CardView) DetailItems() []map[string]any {
+ var out []map[string]any
+ for _, f := range c.Fields {
+ if f.Def.Key == FieldName || f.Def.Key == FieldNotes {
+ continue
+ }
+ v := f.Value
+ if v == "" {
+ v = "—"
+ } else if f.Def.Kind == KindDate {
+ v = displayDate(v)
+ }
+ out = append(out, map[string]any{"Label": f.Label, "Value": v})
+ }
+ return out
+}
+
+// displayDate shows a stored "2026-10-02" as "2 October 2026".
+func displayDate(v string) string {
+ d, err := time.Parse("2006-01-02", v)
+ if err != nil {
+ return v
+ }
+ return d.Format("2 January 2006")
+}
+
+// EnsureMember is memberByAddress for callers outside the package — the
+// preview server, which needs a real member to attribute its seed data to.
+func EnsureMember(ctx context.Context, db *gorm.DB, s *seal.Sealer, address string) (Member, error) {
+ return memberByAddress(ctx, db, s, address)
+}
diff --git a/internal/fichas/render.go b/internal/fichas/render.go
index c30ce8a..bebf9a9 100644
--- a/internal/fichas/render.go
+++ b/internal/fichas/render.go
@@ -3,8 +3,10 @@ package fichas
import (
"bytes"
"embed"
+ "fmt"
"html/template"
"net/http"
+ "time"
"github.com/carlosframework/rastrillo"
"github.com/carlosframework/rastrillo/ui"
@@ -26,24 +28,28 @@ var assets = rastrillo.NewAssets(appFS)
var pages = map[string]*template.Template{}
func init() {
- for _, name := range []string{"index", "errors", "card_new", "card_show", "card_delete", "signin"} {
+ // Two shells. The app's own pages use rastrillo's sidebar shell
+ // (templates/shell.html); sign-in and errors keep the plain column
+ // (templates/layout.html), because Calma keeps app chrome out of the
+ // place the suite meets you. Both define "layout".
+ shells := map[string]string{
+ "index": "shell", "card_new": "shell", "card_show": "shell",
+ "card_edit": "shell", "card_delete": "shell",
+ "signin": "layout", "errors": "layout",
+ }
+ for name, shell := range shells {
pages[name] = template.Must(
- // ui's partials and their helpers first, so every page can
- // call one without registering anything. WithIcons points
- // ui's two icon seams at the app's own set
- // (internal/fichas/icons, scaffolded by rastrillo new) —
- // both of them, always: passing only icon would leave
- // iconAssets on the framework default and silently break
- // cdn and js delivery. iconAssets is whatever <head> markup
- // the chosen delivery needs, empty for the vendored-inline
- // default, so the layout calls it unconditionally and
- // switching delivery later needs no template edit.
template.Must(template.New("layout").
Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
- Funcs(template.FuncMap{"asset": assets.Path, "options": selectOptions}).
+ Funcs(template.FuncMap{
+ "asset": assets.Path,
+ "options": selectOptions,
+ "ago": ago,
+ "dateAgo": dateAgo,
+ "stageTone": stageTone,
+ }).
ParseFS(ui.Templates(), "*.html")).
- // Then the app's own: the shell and this page's body.
- ParseFS(appFS, "templates/layout.html", "templates/"+name+".html"))
+ ParseFS(appFS, "templates/"+shell+".html", "templates/"+name+".html"))
}
}
@@ -85,3 +91,75 @@ func selectOptions(values []string, current string) []map[string]any {
}
return out
}
+
+// ago is a past moment in words, for a <time> whose title carries the
+// absolute date (AGENTS.md: dates relative, absolute in a title).
+func ago(when time.Time) string { return relative(time.Now(), when) }
+
+// dateAgo is ago for a stored date value ("2026-10-02"), which can be in
+// the future — a next action usually is. Anything that does not parse is
+// returned unchanged rather than hidden.
+func dateAgo(v string) string {
+ d, err := time.ParseInLocation("2006-01-02", v, time.Local)
+ if err != nil {
+ return v
+ }
+ now := time.Now()
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
+ days := int(d.Sub(today).Hours() / 24)
+ switch {
+ case days == 0:
+ return "today"
+ case days == 1:
+ return "tomorrow"
+ case days == -1:
+ return "yesterday"
+ case days > 1 && days < 14:
+ return fmt.Sprintf("in %d days", days)
+ case days < -1 && days > -14:
+ return fmt.Sprintf("%d days ago", -days)
+ default:
+ return d.Format("2 January 2006")
+ }
+}
+
+func relative(now, when time.Time) string {
+ d := now.Sub(when)
+ switch {
+ case d < time.Minute:
+ return "just now"
+ case d < time.Hour:
+ return plural(int(d.Minutes()), "minute") + " ago"
+ case d < 24*time.Hour:
+ return plural(int(d.Hours()), "hour") + " ago"
+ case d < 14*24*time.Hour:
+ return plural(int(d.Hours()/24), "day") + " ago"
+ case d < 60*24*time.Hour:
+ return plural(int(d.Hours()/24/7), "week") + " ago"
+ default:
+ return when.Format("2 January 2006")
+ }
+}
+
+func plural(n int, unit string) string {
+ if n == 1 {
+ return "1 " + unit
+ }
+ return fmt.Sprintf("%d %ss", n, unit)
+}
+
+// stageTone maps the seeded Stage values to status-pill tones. The
+// partial's own docs put this in the app: resolving a raw value to a tone
+// is product logic. A value it does not know is neutral, never hidden.
+func stageTone(stage string) string {
+ switch stage {
+ case "Proposal":
+ return "warning"
+ case "Won":
+ return "positive"
+ case "Lost":
+ return "negative"
+ default:
+ return "neutral"
+ }
+}
diff --git a/internal/fichas/static/fichas.css b/internal/fichas/static/fichas.css
new file mode 100644
index 0000000..2fc5bb8
--- /dev/null
+++ b/internal/fichas/static/fichas.css
@@ -0,0 +1,226 @@
+/* fichas.css — this app's own CSS, beside estilo's theme.
+ *
+ * Everything here is something rastrillo's ui partials do not provide.
+ * It is prefixed fichas-, never rst-, so the line between the framework's
+ * vocabulary and this app's additions is visible in the markup itself.
+ * theme.css is estilo's generated theme and is pinned byte-for-byte by
+ * TestThemeIsEstilosForFichas, so nothing of fichas' own goes there.
+ * TestTemplatesUseOnlyDefinedAttributes pins every rst-* and fichas-*
+ * attribute a template writes to a rule in one of the app's stylesheets —
+ * the check that would have caught rst-stack and rst-muted, the two
+ * invented attributes that shipped the first index with a bullet point and
+ * an underlined purple link.
+ *
+ * Only tokens.css custom properties are used, so the Calma theme and the
+ * light/dark switch apply unchanged.
+ *
+ * Calma's rules (estilo AGENTS.md, "Theme"), applied here:
+ * - Inside an app, only the icons carry colour. Links are text-coloured,
+ * underlined on hover only; the accent is for the one primary action,
+ * the current selection's soft tint, and nothing else.
+ * - White cards on a cool off-white page, 12px shapes, a long low shadow,
+ * one grey text scale.
+ * - A list item is a semibold title over a muted line. No bullets.
+ */
+
+/* ---- Quiet secondary text. The thing rst-muted was pretending to be. */
+[fichas-meta] {
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
+ margin: 0;
+}
+
+/* ---- The table view: a record grid. rastrillo has lists, not grids. */
+[fichas-grid-wrap] {
+ overflow-x: auto;
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius);
+ background: var(--rst-surface);
+}
+[fichas-grid] {
+ inline-size: 100%;
+ border-collapse: collapse;
+ font-size: var(--rst-fs-sm);
+}
+[fichas-grid] th,
+[fichas-grid] td {
+ padding: var(--rst-sp-2) var(--rst-sp-3);
+ text-align: start;
+ border-block-end: 1px solid var(--rst-line);
+ white-space: nowrap;
+ vertical-align: middle;
+}
+[fichas-grid] thead th {
+ position: sticky;
+ inset-block-start: 0;
+ background: var(--rst-surface-2);
+ font-weight: 600;
+}
+[fichas-grid] th small {
+ display: block;
+ font-weight: 400;
+ color: var(--rst-text-faint);
+ font-size: var(--rst-fs-xs);
+}
+[fichas-grid] tbody tr:hover { background: var(--rst-surface-2); }
+[fichas-grid] tbody tr:last-child td { border-block-end: 0; }
+[fichas-grid] td:first-child a { font-weight: 600; color: var(--rst-text); text-decoration: none; }
+[fichas-grid] td:first-child a:hover { text-decoration: underline; }
+[fichas-grid] [fichas-add-col] a { color: var(--rst-text-muted); text-decoration: none; font-weight: 500; }
+[fichas-grid] [fichas-add-col] a:hover { color: var(--rst-text); }
+[fichas-grid] [fichas-empty-cell] { color: var(--rst-text-faint); }
+
+/* ---- The cards view: a responsive grid of card faces. */
+[fichas-card-grid] {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
+ gap: var(--rst-sp-4);
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+[fichas-card-grid] > li { display: flex; }
+[fichas-card-grid] [rst-card] {
+ /* rst-card is a bare container by design; rst-box's padding, so a card
+ face and a box read as the same material. */
+ padding: 1.1rem 1.25rem;
+ box-shadow: var(--rst-shadow-lift);
+ border-radius: var(--rst-radius);
+ display: flex;
+ flex-direction: column;
+ gap: var(--rst-sp-2);
+ inline-size: 100%;
+}
+[fichas-card-title] {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--rst-sp-2);
+ margin: 0;
+ font-size: var(--rst-fs-base);
+}
+[fichas-card-title] a { color: var(--rst-text); text-decoration: none; }
+[fichas-card-title] a:hover { text-decoration: underline; }
+[fichas-card-quote] {
+ margin: 0;
+ padding-inline-start: var(--rst-sp-2);
+ border-inline-start: 2px solid var(--rst-line-strong);
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
+}
+
+/* ---- Toolbar row: the view switch on one side, actions on the other. */
+[fichas-toolbar] {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--rst-sp-3);
+ margin-block-end: var(--rst-sp-4);
+}
+
+/* ---- Show view: the record's story beside its facts. */
+[fichas-split] {
+ display: grid;
+ grid-template-columns: minmax(0, 2fr) minmax(16rem, 1fr);
+ gap: var(--rst-sp-5);
+ align-items: start;
+}
+@media (max-width: 52rem) { [fichas-split] { grid-template-columns: 1fr; } }
+/* rst-box brings its own vertical margin; a flex gap on top of it doubled
+ every space on the show page. Let the boxes space themselves. */
+[fichas-split] > * { min-inline-size: 0; }
+[fichas-split] > * > [rst-box]:first-child { margin-block-start: 0; }
+[rst-box] form + [fichas-timeline] { margin-block-start: var(--rst-sp-5); }
+
+[fichas-timeline] {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--rst-sp-3);
+}
+[fichas-timeline] > li {
+ display: grid;
+ grid-template-columns: 7.5rem 1fr;
+ gap: var(--rst-sp-3);
+}
+[fichas-timeline] time { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); }
+[fichas-timeline] p { margin: 0; }
+
+[fichas-cite] {
+ font-size: var(--rst-fs-xs);
+ vertical-align: super;
+ color: var(--rst-text-muted);
+ text-decoration: none;
+}
+[fichas-cite]:hover { color: var(--rst-text); text-decoration: underline; }
+
+/* ---- Card layout editor: options beside a live preview. */
+[fichas-layout-editor] {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(17rem, 22rem);
+ gap: var(--rst-sp-5);
+ align-items: start;
+}
+@media (max-width: 52rem) { [fichas-layout-editor] { grid-template-columns: 1fr; } }
+
+/* ---- A destructive action set apart at the foot of an edit page. */
+[fichas-danger-zone] {
+ margin-block-start: var(--rst-sp-6);
+ padding-block-start: var(--rst-sp-4);
+ border-block-start: 1px solid var(--rst-line);
+}
+
+/* ---- Calma's app bar: icon + app name + quiet context, one action. */
+[fichas-appbar] {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--rst-sp-3);
+ margin-block-end: var(--rst-sp-5);
+}
+[fichas-appbar-title] {
+ display: flex;
+ align-items: baseline;
+ gap: var(--rst-sp-2);
+ margin: 0;
+ font-size: 1.5rem;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+}
+[fichas-appbar-title] span {
+ font-size: var(--rst-fs-base);
+ font-weight: 400;
+ color: var(--rst-text-muted);
+ letter-spacing: 0;
+}
+[fichas-appbar-actions] { display: flex; gap: var(--rst-sp-2); }
+
+/* ---- Links in running text: text-coloured, underline on hover. */
+[fichas-link] { color: var(--rst-text); text-decoration: none; }
+[fichas-link]:hover { text-decoration: underline; }
+
+/* ---- The record bar sits under the view switch, full width, with room. */
+[fichas-toolbar] + [rst-lbar] { margin-block-end: var(--rst-sp-4); }
+
+/* ---- Sidebar icons on the text's centre line, not above its baseline. */
+[rst-shell-nav] a { display: flex; align-items: center; gap: var(--rst-sp-2); }
+[rst-shell-nav] a .icon { flex-shrink: 0; }
+
+/* ---- A many-of-many choice as a quiet checklist. The choice-card
+ component tints every checked option with the accent, which is right
+ for one-of-few and far too loud for five-of-eight under Calma. */
+[fichas-checklist] {
+ border: 0;
+ padding: 0;
+ margin: 0 0 var(--rst-sp-4);
+ display: flex;
+ flex-direction: column;
+ gap: var(--rst-sp-2);
+}
+[fichas-checklist] legend { font-weight: 600; margin-block-end: var(--rst-sp-2); }
+
+/* ---- A form's measure: fields read as a column, not a banner. */
+[fichas-form] { max-inline-size: 40rem; }
diff --git a/internal/fichas/templates/card_delete.html b/internal/fichas/templates/card_delete.html
index fd085f2..fa6d5e8 100644
--- a/internal/fichas/templates/card_delete.html
+++ b/internal/fichas/templates/card_delete.html
@@ -1,6 +1,8 @@
{{define "content"}}
{{$c := .Card}}
-{{template "page-header" (dict "Title" "Delete this customer?")}}
+<header fichas-appbar>
+ <h1 fichas-appbar-title>Delete this customer?</h1>
+</header>
{{template "callout" (dict
"Tone" "warning"
@@ -12,5 +14,5 @@
"Label" "Delete this customer"
"Danger" true)}}
-<p><a href="/cards/{{$c.Card.ID}}">Keep it</a></p>
+<p><a fichas-link href="/cards/{{$c.Card.ID}}">Keep it</a></p>
{{end}}
diff --git a/internal/fichas/templates/card_edit.html b/internal/fichas/templates/card_edit.html
new file mode 100644
index 0000000..70177ef
--- /dev/null
+++ b/internal/fichas/templates/card_edit.html
@@ -0,0 +1,27 @@
+{{define "title"}}Edit {{.Card.Title}} · fichas{{end}}
+{{define "content"}}
+{{$c := .Card}}
+<header fichas-appbar>
+ <h1 fichas-appbar-title>Edit {{$c.Title}} <span>{{$c.TypeLabel}}</span></h1>
+</header>
+
+<form method="post" action="/cards/{{$c.Card.ID}}" fichas-form>
+ {{range $f := $c.Fields}}
+ {{if eq $f.Def.Kind "enum"}}
+ {{template "field-select" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label "Options" (options $f.Options $f.Value))}}
+ {{else if eq $f.Def.Kind "date"}}
+ {{template "field-date" (dict "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value)}}
+ {{else if eq $f.Def.Key "f_notes"}}
+ {{template "field-textarea" (dict "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value "Rows" 4)}}
+ {{else}}
+ {{template "field" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value)}}
+ {{end}}
+ {{end}}
+ {{template "form-foot" (dict "Submit" "Save changes" "CancelHref" (printf "/cards/%d" $c.Card.ID) "CancelLabel" "Cancel")}}
+</form>
+
+<div fichas-danger-zone>
+ <a rst-btn="danger" href="/cards/{{$c.Card.ID}}/delete">Delete this customer…</a>
+ <p fichas-meta>Opens a confirmation page. The change log keeps who deleted it.</p>
+</div>
+{{end}}
diff --git a/internal/fichas/templates/card_new.html b/internal/fichas/templates/card_new.html
index dc402c3..5879ec4 100644
--- a/internal/fichas/templates/card_new.html
+++ b/internal/fichas/templates/card_new.html
@@ -1,7 +1,10 @@
+{{define "title"}}Add a customer · fichas{{end}}
{{define "content"}}
-{{template "page-header" (dict "Title" "Add a customer")}}
+<header fichas-appbar>
+ <h1 fichas-appbar-title>Add a customer</h1>
+</header>
-<form method="post" action="/cards">
+<form method="post" action="/cards" fichas-form>
{{range $i, $f := .Fields}}
{{if eq $f.Def.Kind "enum"}}
{{template "field-select" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label
diff --git a/internal/fichas/templates/card_show.html b/internal/fichas/templates/card_show.html
index 0c43ed1..1b002f0 100644
--- a/internal/fichas/templates/card_show.html
+++ b/internal/fichas/templates/card_show.html
@@ -1,53 +1,44 @@
+{{define "title"}}{{.Card.Title}} · fichas{{end}}
{{define "content"}}
{{$c := .Card}}
-{{template "page-header" (dict "Title" $c.Title "Sub" $c.TypeLabel)}}
+<header fichas-appbar>
+ <h1 fichas-appbar-title>{{$c.Title}} <span>{{$c.TypeLabel}} · updated <time title="{{$c.Card.UpdatedAt.Format "2 January 2006, 15:04"}}">{{ago $c.Card.UpdatedAt}}</time></span></h1>
+ <div fichas-appbar-actions><a rst-btn="primary" href="/cards/{{$c.Card.ID}}/edit">{{icon "pencil"}} Edit</a></div>
+</header>
-<form method="post" action="/cards/{{$c.Card.ID}}">
- {{range $f := $c.Fields}}
- {{if eq $f.Def.Kind "enum"}}
- {{template "field-select" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label
- "Options" (options $f.Options $f.Value))}}
- {{else if eq $f.Def.Kind "date"}}
- {{template "field-date" (dict "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value)}}
- {{else if eq $f.Def.Key "f_notes"}}
- {{template "field-textarea" (dict "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value "Rows" 4)}}
- {{else}}
- {{template "field" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value)}}
- {{end}}
- {{end}}
- {{template "form-foot" (dict "Submit" "Save changes" "CancelHref" "/" "CancelLabel" "Cancel")}}
-</form>
-
-<h2>Call log</h2>
+<div fichas-split>
+ <section>
+ <div rst-box>
+ <div rst-box-head><h2>Call log</h2></div>
+ <form method="post" action="/cards/{{$c.Card.ID}}/entries">
+ {{range $f := .EntryFields}}{{if eq $f.Def.Key "f_body"}}
+ {{template "field-textarea" (dict "Name" $f.Def.Key "Label" "Log a call" "Rows" 2)}}
+ {{end}}{{end}}
+ {{template "form-foot" (dict "Submit" "Log this call")}}
+ </form>
+ {{if $c.Entries}}
+ <ol fichas-timeline>
+ {{range $c.Entries}}
+ <li><time datetime="{{.Entry.CreatedAt.Format "2006-01-02"}}" title="{{.Entry.CreatedAt.Format "2 January 2006, 15:04"}}">{{ago .Entry.CreatedAt}}</time><p>{{.Body}}</p></li>
+ {{end}}
+ </ol>
+ {{else}}
+ <p fichas-meta>No calls logged yet. Each one you add here builds this customer's history.</p>
+ {{end}}
+ </div>
+ </section>
-<form method="post" action="/cards/{{$c.Card.ID}}/entries">
- {{range $f := .EntryFields}}
- {{if eq $f.Def.Kind "date"}}
- {{template "field-date" (dict "Name" $f.Def.Key "Label" $f.Label)}}
- {{else if eq $f.Def.Key "f_body"}}
- {{template "field-textarea" (dict "Name" $f.Def.Key "Label" $f.Label "Rows" 3)}}
- {{else}}
- {{template "field" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label)}}
+ <aside>
+ <div rst-box>
+ <div rst-box-head><h2>Details</h2></div>
+ {{template "detail-list" (dict "Items" $c.DetailItems)}}
+ </div>
+ {{with ($c.Field "f_notes").Value}}
+ <div rst-box>
+ <div rst-box-head><h2>Notes</h2></div>
+ <p>{{.}}</p>
+ </div>
{{end}}
- {{end}}
- {{template "form-foot" (dict "Submit" "Log this call")}}
-</form>
-
-{{if $c.Entries}}
-<ol rst-stack>
- {{range $c.Entries}}
- <li rst-card>
- {{template "badge" (dict "Label" .TypeLabel)}}
- <p>{{.Body}}</p>
- <p rst-muted><time datetime="{{.Entry.CreatedAt.Format "2006-01-02"}}" title="{{.Entry.CreatedAt.Format "2 January 2006, 15:04"}}">{{.Entry.CreatedAt.Format "2 January 2006"}}</time></p>
- </li>
- {{end}}
-</ol>
-{{else}}
-{{template "empty-state" (dict
- "Title" "No calls logged"
- "Body" "Every call you log here feeds the summary on this card. The form above is the whole of it.")}}
-{{end}}
-
-<p><a rst-btn="danger" href="/cards/{{$c.Card.ID}}/delete">Delete this customer</a></p>
+ </aside>
+</div>
{{end}}
diff --git a/internal/fichas/templates/index.html b/internal/fichas/templates/index.html
index bd246e9..8a74420 100644
--- a/internal/fichas/templates/index.html
+++ b/internal/fichas/templates/index.html
@@ -1,27 +1,52 @@
+{{define "title"}}Customers · fichas{{end}}
{{define "content"}}
-{{template "page-header" (dict
- "Title" "Customers"
- "Sub" "Everything this team is working on, most recently touched first."
- "ActionHref" "/cards/new"
- "ActionLabel" "Add a customer"
- "ActionIcon" "plus")}}
-
-{{if .Anonymous}}{{template "callout" (dict
- "Tone" "warning"
- "Title" "This instance cannot name who is writing"
- "Body" "Sign-in has not landed yet, so nothing can be saved. The event log records who made every change, and it will not record a guess.")}}{{end}}
+<header fichas-appbar>
+ <h1 fichas-appbar-title>Customers <span>{{len .Cards}} {{if eq (len .Cards) 1}}record{{else}}records{{end}}</span></h1>
+ <div fichas-appbar-actions><a rst-btn="primary" href="/cards/new">{{icon "plus"}} Add a customer</a></div>
+</header>
{{if .Cards}}
-<ul rst-stack>
- {{range .Cards}}
- <li rst-card>
- <h2><a href="/cards/{{.Card.ID}}">{{.Title}}</a></h2>
- {{with (.Field "f_stage").Value}}{{template "status-pill" (dict "Label" . "Tone" "neutral")}}{{end}}
- {{with (.Field "f_email").Value}}<p rst-muted>{{.}}</p>{{end}}
- {{with (.Field "f_next_action").Value}}<p rst-muted>Next action <time datetime="{{.}}" title="{{.}}">{{.}}</time></p>{{end}}
- </li>
+<div fichas-toolbar>
+ {{template "seg-tabs" (dict "Label" "View customers as" "Items" (list
+ (dict "Label" "Table" "Href" "/?view=table" "Current" (eq .View "table"))
+ (dict "Label" "Cards" "Href" "/?view=cards" "Current" (eq .View "cards"))))}}
+</div>
+
+{{if eq .View "table"}}
+<div fichas-grid-wrap>
+<table fichas-grid>
+ <thead><tr>
+ {{range .Fields}}{{if ne .Def.Key "f_notes"}}<th scope="col">{{.Label}}</th>{{end}}{{end}}
+ <th scope="col">Last call</th>
+ </tr></thead>
+ <tbody>
+ {{range $c := .Cards}}
+ <tr>
+ {{range $f := $.Fields}}{{if ne $f.Def.Key "f_notes"}}{{$v := ($c.Field $f.Def.Key).Value}}
+ {{if eq $f.Def.Key "f_name"}}<td><a href="/cards/{{$c.Card.ID}}">{{$c.Title}}</a></td>
+ {{else if not $v}}<td><span fichas-empty-cell>—</span></td>
+ {{else if eq $f.Def.Key "f_stage"}}<td>{{template "status-pill" (dict "Label" $v "Tone" (stageTone $v))}}</td>
+ {{else if eq $f.Def.Kind "date"}}<td><time datetime="{{$v}}" title="{{$v}}">{{dateAgo $v}}</time></td>
+ {{else}}<td>{{$v}}</td>{{end}}
+ {{end}}{{end}}
+ <td>{{with $c.Latest}}<time datetime="{{.Entry.CreatedAt.Format "2006-01-02"}}" title="{{.Entry.CreatedAt.Format "2 January 2006, 15:04"}}">{{ago .Entry.CreatedAt}}</time>{{else}}<span fichas-empty-cell>never</span>{{end}}</td>
+ </tr>
{{end}}
+ </tbody>
+</table>
+</div>
+{{else}}
+<ul fichas-card-grid>
+{{range .Cards}}
+ <li><article rst-card>
+ <h2 fichas-card-title><a href="/cards/{{.Card.ID}}">{{.Title}}</a>{{with (.Field "f_stage").Value}}{{template "status-pill" (dict "Label" . "Tone" (stageTone .))}}{{end}}</h2>
+ {{with (.Field "f_email").Value}}<p fichas-meta>{{.}}</p>{{end}}
+ {{with .Latest}}{{with .Body}}<p fichas-card-quote>{{.}}</p>{{end}}{{end}}
+ <p fichas-meta>{{with (.Field "f_next_action").Value}}Next action <time datetime="{{.}}" title="{{.}}">{{dateAgo .}}</time>{{else}}{{with .Latest}}Last call <time title="{{.Entry.CreatedAt.Format "2 January 2006"}}">{{ago .Entry.CreatedAt}}</time>{{else}}No calls yet{{end}}{{end}}</p>
+ </article></li>
+{{end}}
</ul>
+{{end}}
{{else}}
{{template "empty-state" (dict
"Title" "No customers yet"
diff --git a/internal/fichas/templates/shell.html b/internal/fichas/templates/shell.html
new file mode 100644
index 0000000..feac1fc
--- /dev/null
+++ b/internal/fichas/templates/shell.html
@@ -0,0 +1,44 @@
+{{define "layout"}}<!doctype html>
+<html lang="en" dir="ltr">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{block "title" .}}fichas{{end}}</title>
+<link rel="icon" type="image/png" sizes="32x32" href="{{asset "static/brand/icon-32.png"}}">
+<link rel="icon" type="image/svg+xml" href="{{asset "static/brand/icon.svg"}}">
+<link rel="apple-touch-icon" sizes="180x180" href="{{asset "static/brand/apple-touch-icon.png"}}">
+<link rel="stylesheet" href="/static/estilo/fonts/v1/fonts.css">
+<link rel="stylesheet" href="{{asset "static/tokens.css"}}">
+<link rel="stylesheet" href="{{asset "static/busy.css"}}">
+<link rel="stylesheet" href="{{asset "static/theme.css"}}">
+<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}">
+<link rel="stylesheet" href="{{asset "static/fichas.css"}}">
+<script defer src="{{asset "static/rastrillo.js"}}"></script>
+<script defer src="{{asset "static/busy.js"}}"></script>
+{{iconAssets}}
+<script defer src="{{asset "static/select.js"}}"></script>
+<script defer src="{{asset "static/datetime.js"}}"></script>
+</head>
+<body>
+{{/* rastrillo's sidebar shell (ui/layouts/sidebar.html), with the app's
+ own head. Sign-in and errors keep the plain column in layout.html:
+ Calma keeps app chrome out of the place the suite meets you. */}}
+<div rst-shell-sidebar>
+<a rst-skip href="#main">{{T "rastrillo.ui.shell_skip"}}</a>
+<details rst-shell-chrome><summary>{{icon "menu"}}{{T "rastrillo.ui.shell_menu"}}</summary></details>
+<aside rst-shell-rail>
+<a rst-shell-brand href="/"><span estilo-brand><img estilo-brand-icon src="{{asset "static/brand/icon.svg"}}" alt="">fichas</span></a>
+<nav rst-shell-nav>
+{{range .Nav}}<a href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{icon "users"}} {{.Label}}</a>
+{{end}}</nav>
+<div rst-shell-rail-foot><form method="post" action="/signout"><span fichas-meta>{{.Me}}</span> <button rst-btn="ghost" type="submit">Sign out</button></form></div>
+</aside>
+<main rst-shell-main id="main">
+<div rst-page>
+{{template "content" .}}
+</div>
+</main>
+</div>
+</body>
+</html>
+{{end}}
diff --git a/internal/fichastest/markup_test.go b/internal/fichastest/markup_test.go
new file mode 100644
index 0000000..5745d82
--- /dev/null
+++ b/internal/fichastest/markup_test.go
@@ -0,0 +1,87 @@
+package fichastest
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/fichas/internal/fichas/icons"
+)
+
+var (
+ tagRe = regexp.MustCompile(`<[a-zA-Z][^>]*>`)
+ attrRe = regexp.MustCompile(`\s((?:rst|fichas)-[a-z0-9-]+)`)
+ selectorRe = regexp.MustCompile(`\[((?:rst|fichas)-[a-z0-9-]+)`)
+ iconRe = regexp.MustCompile(`\{\{-?\s*icon\s+"([a-z0-9-]+)"`)
+)
+
+func read(t *testing.T, path string) string {
+ t.Helper()
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("reading %s: %v", path, err)
+ }
+ return string(b)
+}
+
+// TestTemplatesUseOnlyDefinedAttributes: every rst-* or fichas-* attribute a
+// template writes must have a rule in one of the app's stylesheets.
+//
+// The first index shipped rst-stack and rst-muted, which rastrillo does not
+// have. With no rule behind them the browser's defaults showed through — a
+// bullet point and an underlined purple link — and nothing failed. Only
+// attributes in THIS app's templates are checked: what rastrillo's partials
+// emit is the framework's business, and some of those are script hooks.
+func TestTemplatesUseOnlyDefinedAttributes(t *testing.T) {
+ defined := map[string]bool{}
+ sheets, _ := filepath.Glob("../fichas/static/*.css")
+ brand, _ := filepath.Glob("../fichas/static/brand/*.css")
+ for _, css := range append(sheets, brand...) {
+ for _, m := range selectorRe.FindAllStringSubmatch(read(t, css), -1) {
+ defined[m[1]] = true
+ }
+ }
+ if !defined["rst-btn"] || !defined["fichas-grid"] {
+ t.Fatal("the stylesheets were not found or not parsed; this test would pass vacuously")
+ }
+
+ templates, _ := filepath.Glob("../fichas/templates/*.html")
+ if len(templates) == 0 {
+ t.Fatal("no templates found; this test would pass vacuously")
+ }
+ var bad []string
+ for _, f := range templates {
+ for _, tag := range tagRe.FindAllString(read(t, f), -1) {
+ for _, m := range attrRe.FindAllStringSubmatch(tag, -1) {
+ if !defined[m[1]] {
+ bad = append(bad, filepath.Base(f)+": "+m[1])
+ }
+ }
+ }
+ }
+ sort.Strings(bad)
+ for _, b := range bad {
+ t.Errorf("%s has no rule in any stylesheet — an invented attribute renders as browser defaults", b)
+ }
+}
+
+// TestTemplateIconsExist: icons.Icon returns "" for a slug it does not know,
+// so a missing icon renders as nothing. Six of the first seven the mockups
+// used were missing, and nothing noticed until a screenshot.
+func TestTemplateIconsExist(t *testing.T) {
+ templates, _ := filepath.Glob("../fichas/templates/*.html")
+ var missing []string
+ for _, f := range templates {
+ for _, m := range iconRe.FindAllStringSubmatch(read(t, f), -1) {
+ if icons.Icon(m[1]) == "" {
+ missing = append(missing, filepath.Base(f)+": "+m[1])
+ }
+ }
+ }
+ if len(missing) > 0 {
+ t.Errorf("icon slugs not in internal/fichas/icons:\n %s", strings.Join(missing, "\n "))
+ }
+}
diff --git a/internal/fichastest/surface_test.go b/internal/fichastest/surface_test.go
index b0f1d12..b31cb85 100644
--- a/internal/fichastest/surface_test.go
+++ b/internal/fichastest/surface_test.go
@@ -136,3 +136,60 @@ func TestNoPageNeedsJavaScript(t *testing.T) {
}
}
}
+
+// The card page used to BE the edit form: opening a customer put every
+// field in an input. Reading and editing are separate views now.
+func TestTheCardPageIsReadOnly(t *testing.T) {
+ h := newApp(t)
+ path := createACustomer(t, h, "Cnoc Teoranta")
+ body := get(t, h, path).Body.String()
+ for _, field := range []string{`name="f_name"`, `name="f_email"`, `name="f_stage"`} {
+ if strings.Contains(body, field) {
+ t.Errorf("the read view has an input for %s", field)
+ }
+ }
+ if !strings.Contains(body, `href="`+path+`/edit"`) {
+ t.Error("the read view does not link to editing")
+ }
+ // Logging a call stays on the read view: it is adding, not editing.
+ if !strings.Contains(body, `name="f_body"`) {
+ t.Error("the read view lost the log-a-call form")
+ }
+}
+
+func TestEditingHasItsOwnPage(t *testing.T) {
+ h := newApp(t)
+ path := createACustomer(t, h, "Cnoc Teoranta")
+ rec := get(t, h, path+"/edit")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET %s/edit: status %d", path, rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), `value="Cnoc Teoranta"`) {
+ t.Error("the edit form does not carry the current value")
+ }
+ // Saving lands back on the read view, not on the form.
+ save := post(t, h, path, url.Values{"f_name": {"Cnoc Teoranta"}, "f_notes": {"renewal is due in March"}})
+ if loc := save.Header().Get("Location"); loc != path {
+ t.Errorf("saving redirected to %q, want the read view %q", loc, path)
+ }
+}
+
+// Table and Cards are two views of one list, switched by a link so it works
+// with scripts off and can be bookmarked.
+func TestTheListHasTableAndCardsViews(t *testing.T) {
+ h := newApp(t)
+ createACustomer(t, h, "Cnoc Teoranta")
+ table := get(t, h, "/").Body.String()
+ if !strings.Contains(table, "fichas-grid") || strings.Contains(table, "fichas-card-grid") {
+ t.Error("the default view is not the table")
+ }
+ cards := get(t, h, "/?view=cards").Body.String()
+ if !strings.Contains(cards, "fichas-card-grid") {
+ t.Error("?view=cards does not show cards")
+ }
+ for _, body := range []string{table, cards} {
+ if !strings.Contains(body, `href="/?view=table"`) || !strings.Contains(body, `href="/?view=cards"`) {
+ t.Error("a list view is missing the Table · Cards switch")
+ }
+ }
+}
diff --git a/scripts/preview/main.go b/scripts/preview/main.go
new file mode 100644
index 0000000..85da3f3
--- /dev/null
+++ b/scripts/preview/main.go
@@ -0,0 +1,114 @@
+// Command preview serves the REAL app — real handlers, templates and
+// store — over a throwaway database of synthetic customers, already signed
+// in, on loopback. It exists so a screen can be looked at before it ships:
+// the first index went live with a bullet point and an underlined link
+// that no test could see.
+//
+// go run ./scripts/preview # then open http://127.0.0.1:8746/
+//
+// Signed in through AppForTest, which says what it does in its name; this
+// binary binds 127.0.0.1 only and refuses to run otherwise.
+package main
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+
+ fichas "amadan.net/oficina/fichas/internal/fichas"
+ "github.com/carlosframework/rastrillo/db"
+)
+
+const (
+ addr = "127.0.0.1:8746"
+ owner = "paul@example.com"
+)
+
+func main() {
+ dir, err := os.MkdirTemp("", "fichas-preview-")
+ check(err)
+ defer os.RemoveAll(dir)
+
+ dek := make([]byte, 32)
+ _, err = rand.Read(dek)
+ check(err)
+ env := map[string]string{
+ "FICHAS_DEK": base64.StdEncoding.EncodeToString(dek),
+ "FICHAS_INSTANCE_KEY": "preview",
+ "FICHAS_OWNERS": owner,
+ "FICHAS_ORIGIN": "http://" + addr,
+ "FICHAS_ALLOW_LOGGED_MAIL": "1",
+ }
+ cfg, err := fichas.LoadConfig(func(k string) string { return env[k] })
+ check(err)
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(filepath.Join(dir, "preview.db"), logger)
+ check(err)
+ mux, err := fichas.AppForTest(d, cfg, logger, owner)
+ check(err)
+ check(seed(d, cfg))
+
+ fmt.Println("fichas preview on http://" + addr + "/ (synthetic data, signed in as " + owner + ")")
+ check(http.ListenAndServe(addr, mux))
+}
+
+// seed writes synthetic customers through the real store, so the preview
+// exercises the same write path — events and all — that production does.
+// .example is RFC 2606's reserved TLD.
+func seed(d *db.DB, cfg fichas.Config) error {
+ ctx := context.Background()
+ m, err := fichas.EnsureMember(ctx, d.G, cfg.Sealer, owner)
+ if err != nil {
+ return err
+ }
+ me := fichas.Person(m.ID)
+ type call struct{ body string }
+ customers := []struct {
+ values map[string]string
+ calls []string
+ }{
+ {map[string]string{fichas.FieldName: "Brightwater Labs", fichas.FieldDomain: "brightwater.example",
+ fichas.FieldEmail: "ana@brightwater.example", fichas.FieldPhone: "+353 1 555 0142",
+ fichas.FieldStage: "Proposal", fichas.FieldNextAction: "2026-10-02",
+ fichas.FieldNotes: "Renewal is tied to their grant cycle. Ana is the budget holder; Tom signs."},
+ []string{"Met at the conference. Asked for a follow-up after their board meets.",
+ "Intro call. Current tool is a shared spreadsheet; they lose track of follow-ups.",
+ "Walked Ana through the proposal. She wants the pilot scoped to one department first."}},
+ {map[string]string{fichas.FieldName: "Cedar & Pike Architects", fichas.FieldDomain: "cedarpike.example",
+ fichas.FieldEmail: "office@cedarpike.example", fichas.FieldStage: "Lead"}, nil},
+ {map[string]string{fichas.FieldName: "Halden Robotics", fichas.FieldDomain: "halden.example",
+ fichas.FieldEmail: "ops@halden.example", fichas.FieldPhone: "+44 20 5550 1877",
+ fichas.FieldStage: "Contacted", fichas.FieldNextAction: "2026-09-30"},
+ []string{"Left a voicemail; followed up by email."}},
+ {map[string]string{fichas.FieldName: "Oriel Housing Trust", fichas.FieldDomain: "oriel.example",
+ fichas.FieldEmail: "it@oriel.example", fichas.FieldStage: "Won"},
+ []string{"Signed. Rollout starts next month."}},
+ }
+ for _, c := range customers {
+ card, err := fichas.CreateCard(ctx, d.G, cfg.Sealer, fichas.TypeCustomer, me, c.values)
+ if err != nil {
+ return err
+ }
+ for _, body := range c.calls {
+ if _, err := fichas.AddEntry(ctx, d.G, cfg.Sealer, card.ID, me, fichas.TypeCallLog, "manual",
+ map[string]string{fichas.FieldBody: body}); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func check(err error) {
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "preview:", err)
+ os.Exit(1)
+ }
+}