Files
member-console/internal/server/anatomy.go
T
cgalo5758 408fa6f5a6 Add page anatomy parts and UI quality gate
- Add shared ui_*.html parts (pageHeader, sectionHeader, statusBadge,
  emptyState) parsed into every template set
- Add anatomy lint rules with a shrinking allowlist and screen-coverage
  check
- Add make screens capture harness with contact sheets and baseline diff
- Compose member and FedWiki regions server-side so pages arrive
  complete
- Rebuild Domains and Integrations on the parts as pilots
2026-08-30 04:05:31 -05:00

138 lines
5.2 KiB
Go

package server
import (
"fmt"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
)
// Page anatomy (docs/design-system.md §6; spec page-anatomy). These are the
// values the shared parts under templates/partials/ui_*.html render from.
// Pages build them in Go, usually as methods on their page data, so a
// template never composes a title, a badge class, or an empty state itself.
// Link is a labelled destination for a header slot.
type Link struct {
Label string
URL string
// Primary marks the page's single filled action (chrome-conventions
// "One dominant primary action per screen"); every other action renders
// in the outline style.
Primary bool
}
// PageHeader is what the pageHeader part renders: the page title at the one
// size, an optional back affordance above it, an optional one-sentence lead
// below it, and one right-hand slot holding either a muted count or one
// action control.
type PageHeader struct {
Title string
Lead string
Back *Link
Count string
Action *Link
}
// Check enforces the one-slot rule at render time: html/template aborts the
// execution when a method returns a non-nil error, so a page that supplies
// both a count and an action fails to render instead of rendering both.
func (h PageHeader) Check() (string, error) {
if h.Count != "" && h.Action != nil {
return "", fmt.Errorf("page header %q: the right-hand slot holds a count or an action, not both", h.Title)
}
return "", nil
}
// SectionHeader is what the sectionHeader part (and its sectionSummary
// variant for a <details> disclosure) renders: an h2 at the one section
// size with an optional muted count or one action.
type SectionHeader struct {
Title string
Count string
Action *Link
}
// Badge is what the statusBadge part renders. Tone is one of success,
// secondary, warning, danger, info, light; Title is an optional tooltip.
type Badge struct {
Label string
Tone string
Title string
}
// WithTitle returns the badge with a tooltip.
func (b Badge) WithTitle(title string) Badge {
b.Title = title
return b
}
// badgeMap is the one place a state becomes a label and a tone. Keys are the
// state strings the handlers emit (database enums, registry states, and the
// console's own readiness words). Labels are title case. Add a state here
// before emitting it; TestStatusBadgeMapIsComplete fails on the ones it
// knows about that are missing.
var badgeMap = map[string]Badge{
// Lifecycle
"active": {Label: "Active", Tone: "success"},
"inactive": {Label: "Inactive", Tone: "secondary"},
"pending": {Label: "Pending", Tone: "warning"},
"expired": {Label: "Expired", Tone: "secondary"},
"canceled": {Label: "Canceled", Tone: "secondary"},
"released": {Label: "Released", Tone: "secondary"},
"revoked": {Label: "Revoked", Tone: "secondary"},
"superseded": {Label: "Superseded", Tone: "secondary"},
"live": {Label: "Live", Tone: "success"},
"draft": {Label: "Draft", Tone: "secondary"},
"published": {Label: "Published", Tone: "success"},
"retired": {Label: "Retired", Tone: "secondary"},
// Readiness
"configured": {Label: "Configured", Tone: "success"},
"not_configured": {Label: "Not configured", Tone: "warning"},
"verified": {Label: "Verified", Tone: "success"},
"unverified": {Label: "Unverified", Tone: "secondary"},
"servable": {Label: "Servable", Tone: "success"},
"unservable": {Label: "Unservable", Tone: "secondary"},
"seen": {Label: "Seen", Tone: "info"},
// Kinds (neutral)
domains.KindOperatorRoot: {Label: "Operator root", Tone: "light"},
domains.KindMember: {Label: "Member", Tone: "light"},
domains.KindExternal: {Label: "External", Tone: "light"},
// Payments and projection
"paid": {Label: "Paid", Tone: "success"},
"unpaid": {Label: "Unpaid", Tone: "warning"},
"overdue": {Label: "Overdue", Tone: "danger"},
"synced": {Label: "Synced", Tone: "success"},
"not_mapped": {Label: "Not mapped", Tone: "secondary"},
"sync_pending": {Label: "Sync pending", Tone: "warning"},
"sync_failed": {Label: "Sync failed", Tone: "danger"},
}
// StatusBadge maps a state to its badge. An unknown state renders as a
// secondary badge with the raw state as its label, so it is visible rather
// than styled; the map's completeness test names it.
func StatusBadge(state string) Badge {
if b, ok := badgeMap[state]; ok {
return b
}
return Badge{Label: state, Tone: "secondary"}
}
// knownStates are the state strings the handlers emit through StatusBadge,
// gathered from the packages that define them so the completeness test
// follows their constants.
var knownStates = []string{
domains.StatusPending, domains.StatusActive, domains.StatusExpired, domains.StatusCanceled, domains.StatusReleased,
domains.KindOperatorRoot, domains.KindMember, domains.KindExternal,
string(integration.StateActive),
"not_configured", "servable", "unservable", "seen",
}
// countLabel renders "1 live claim" / "3 live claims" for header slots.
func countLabel(n int, singular, plural string) string {
if n == 1 {
return fmt.Sprintf("%d %s", n, singular)
}
return fmt.Sprintf("%d %s", n, plural)
}