Phase 1: Compass/NHS skeleton (Go, SQLite, session login, Docker, Caddy)

Phase 1 of AGENTS.md §11: the app runs on .13 in its own container on port
8792, behind Caddy at school.lab.audasmedia.com.au, with its own session login
and GET /health.

- Go service (Go 1.25, modernc SQLite, no cgo) with its own image.
- SQLite schema per AGENTS.md §7: children, items, scans, notifications,
  session (internal/store/migrations/001_init.sql).
- Separate session login (signed cookie, CSRF double-submit), not SSO.
- HTMX server-rendered shell using the Family Home Lab tokens.css: canvas
  #f6f5f4, action blue #0075de, Inter, 8px grid, hairline borders, pill CTAs,
  dark mode, phone-friendly.
- Fail loudly (FR-12): a missing or failed scan shows as an explicit warning or
  error banner, never as an empty day.
- Dockerfile + docker-compose.yml (own container, own port 8792; uses
  network_mode bridge because .13 has no free default Docker address pool).
- Caddy snippet for .35 in deploy/caddy/, deployment notes, and
  deploy/verify-phase1.sh.

No school scan in this phase; the Compass login and fetch are Phase 2.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-15 13:21:20 +11:00
parent 2fb223b993
commit 7b98e487c0
29 changed files with 2287 additions and 0 deletions

207
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,207 @@
// Package auth implements the service's own session login (AGENTS.md §9 Auth:
// a separate session login, not SSO). Sessions are stateless, signed cookies.
// A random CSRF cookie uses the double-submit pattern for forms.
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookie = "school_session"
csrfCookie = "school_csrf"
)
// Manager issues and validates sessions.
type Manager struct {
// Secret signs every cookie. Required.
Secret []byte
// TTL is the session lifetime.
TTL time.Duration
// Secure sets the Secure cookie attribute. Keep true behind HTTPS.
Secure bool
// Username is the single allowed login name.
Username string
// passwordHash is a bcrypt hash, or nil when only a plaintext password is set.
passwordHash []byte
// plaintext is the fallback password, or empty when a hash is set.
plaintext string
}
// New builds a Manager. Exactly one of bcryptHash or plaintext must be set.
func New(secret []byte, ttl time.Duration, secure bool, username, bcryptHash, plaintext string) (*Manager, error) {
if len(secret) < 16 {
return nil, errors.New("session secret too short")
}
m := &Manager{Secret: secret, TTL: ttl, Secure: secure, Username: username}
switch {
case bcryptHash != "":
if _, err := bcrypt.Cost([]byte(bcryptHash)); err != nil {
return nil, fmt.Errorf("SCHOOL_ADMIN_PASSWORD_HASH is not a valid bcrypt hash: %w", err)
}
m.passwordHash = []byte(bcryptHash)
case plaintext != "":
m.plaintext = plaintext
default:
return nil, errors.New("no admin password configured")
}
return m, nil
}
// Hashed reports whether the manager verifies against a bcrypt hash.
func (m *Manager) Hashed() bool { return m.passwordHash != nil }
// CheckCredentials validates a username and password in constant time where
// possible. It never reveals which part was wrong.
func (m *Manager) CheckCredentials(username, password string) bool {
userOK := subtle.ConstantTimeCompare([]byte(username), []byte(m.Username)) == 1
passOK := false
if m.passwordHash != nil {
passOK = bcrypt.CompareHashAndPassword(m.passwordHash, []byte(password)) == nil
} else {
passOK = subtle.ConstantTimeCompare([]byte(password), []byte(m.plaintext)) == 1
}
return userOK && passOK
}
// Issue writes a signed session cookie and the matching CSRF cookie.
func (m *Manager) Issue(w http.ResponseWriter) {
csrf := randomToken()
expires := time.Now().Add(m.TTL).Unix()
payload := strings.Join([]string{m.Username, csrf, strconv.FormatInt(expires, 10)}, "\n")
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: m.sign(payload),
Path: "/",
HttpOnly: true,
Secure: m.Secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(expires, 0),
MaxAge: int(m.TTL.Seconds()),
})
http.SetCookie(w, &http.Cookie{
Name: csrfCookie,
Value: csrf,
Path: "/",
HttpOnly: true,
Secure: m.Secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(expires, 0),
MaxAge: int(m.TTL.Seconds()),
})
}
// Clear removes the session and CSRF cookies.
func (m *Manager) Clear(w http.ResponseWriter) {
for _, name := range []string{sessionCookie, csrfCookie} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
HttpOnly: true,
Secure: m.Secure,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
}
// Authenticated reports whether the request carries a valid, unexpired session.
func (m *Manager) Authenticated(r *http.Request) bool {
c, err := r.Cookie(sessionCookie)
if err != nil {
return false
}
payload, ok := m.verify(c.Value)
if !ok {
return false
}
parts := strings.Split(payload, "\n")
if len(parts) != 3 || parts[0] != m.Username {
return false
}
expires, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil || time.Now().Unix() > expires {
return false
}
return true
}
// EnsureCSRF returns the CSRF token for a form, setting the cookie if absent.
func (m *Manager) EnsureCSRF(w http.ResponseWriter, r *http.Request) string {
if c, err := r.Cookie(csrfCookie); err == nil && c.Value != "" {
return c.Value
}
token := randomToken()
http.SetCookie(w, &http.Cookie{
Name: csrfCookie,
Value: token,
Path: "/",
HttpOnly: true,
Secure: m.Secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(m.TTL.Seconds()),
})
return token
}
// CheckCSRF compares the submitted form token with the CSRF cookie.
func (m *Manager) CheckCSRF(r *http.Request) bool {
c, err := r.Cookie(csrfCookie)
if err != nil || c.Value == "" {
return false
}
form := r.PostFormValue("csrf")
return subtle.ConstantTimeCompare([]byte(form), []byte(c.Value)) == 1
}
func (m *Manager) sign(payload string) string {
mac := hmac.New(sha256.New, m.Secret)
mac.Write([]byte(payload))
sig := mac.Sum(nil)
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
base64.RawURLEncoding.EncodeToString(sig)
}
func (m *Manager) verify(token string) (string, bool) {
encoded, sigEncoded, ok := strings.Cut(token, ".")
if !ok {
return "", false
}
payload, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return "", false
}
gotSig, err := base64.RawURLEncoding.DecodeString(sigEncoded)
if err != nil {
return "", false
}
mac := hmac.New(sha256.New, m.Secret)
mac.Write(payload)
if !hmac.Equal(gotSig, mac.Sum(nil)) {
return "", false
}
return string(payload), true
}
func randomToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("auth: random source failed: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(b)
}

129
internal/config/config.go Normal file
View File

@@ -0,0 +1,129 @@
// Package config loads the Compass/NHS service configuration from the process
// environment. Every secret comes from the environment (environment.d); nothing
// is compiled into the binary and nothing secret is read from a committed file.
package config
import (
"errors"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
// Embed the IANA time zone database so SCHOOL_TIMEZONE resolves even in a
// container without tzdata.
_ "time/tzdata"
)
// Config is the fully resolved runtime configuration.
type Config struct {
// Addr is the listen address, for example ":8792".
Addr string
// DBPath is the SQLite database file, for example "/data/school.db".
DBPath string
// SessionSecret signs the session cookie. Required, >= 16 bytes.
SessionSecret []byte
// AdminUsername is the single dashboard login name (Sam).
AdminUsername string
// AdminPassword is the plaintext password. Optional if AdminPasswordHash is set.
AdminPassword string
// AdminPasswordHash is a bcrypt hash. Optional if AdminPassword is set.
AdminPasswordHash string
// CookieSecure sets the Secure attribute on cookies. Keep true behind HTTPS.
CookieSecure bool
// SessionTTL is how long a session cookie stays valid.
SessionTTL time.Duration
// LogLevel is the slog level.
LogLevel slog.Level
// AppEnv is a label such as "production" or "development".
AppEnv string
// Version is injected at build time.
Version string
// Location is the family timezone. Due dates and briefs are wall-clock, so
// this must be the family's zone, not the container default of UTC.
Location *time.Location
}
// DefaultTimezone is the family timezone used when SCHOOL_TIMEZONE is unset.
const DefaultTimezone = "Australia/Sydney"
// Load reads and validates the configuration.
func Load() (*Config, error) {
cfg := &Config{
Addr: env("SCHOOL_ADDR", ":8792"),
DBPath: env("SCHOOL_DB_PATH", "/data/school.db"),
AdminUsername: env("SCHOOL_ADMIN_USERNAME", "sam"),
AdminPassword: os.Getenv("SCHOOL_ADMIN_PASSWORD"),
AdminPasswordHash: os.Getenv("SCHOOL_ADMIN_PASSWORD_HASH"),
CookieSecure: envBool("SCHOOL_COOKIE_SECURE", true),
AppEnv: env("SCHOOL_APP_ENV", "production"),
}
secret := os.Getenv("SCHOOL_SESSION_SECRET")
if len(secret) < 16 {
return nil, errors.New("SCHOOL_SESSION_SECRET must be set and at least 16 characters")
}
cfg.SessionSecret = []byte(secret)
if cfg.AdminPassword == "" && cfg.AdminPasswordHash == "" {
return nil, errors.New("set SCHOOL_ADMIN_PASSWORD or SCHOOL_ADMIN_PASSWORD_HASH")
}
if cfg.AdminUsername == "" {
return nil, errors.New("SCHOOL_ADMIN_USERNAME must not be empty")
}
ttl, err := time.ParseDuration(env("SCHOOL_SESSION_TTL", "12h"))
if err != nil {
return nil, fmt.Errorf("SCHOOL_SESSION_TTL: %w", err)
}
cfg.SessionTTL = ttl
lvl, err := parseLevel(env("SCHOOL_LOG_LEVEL", "info"))
if err != nil {
return nil, err
}
cfg.LogLevel = lvl
loc, err := time.LoadLocation(env("SCHOOL_TIMEZONE", DefaultTimezone))
if err != nil {
return nil, fmt.Errorf("SCHOOL_TIMEZONE: %w", err)
}
cfg.Location = loc
return cfg, nil
}
func env(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func envBool(key string, fallback bool) bool {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
b, err := strconv.ParseBool(v)
if err != nil {
return fallback
}
return b
}
func parseLevel(s string) (slog.Level, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
return slog.LevelDebug, nil
case "info", "":
return slog.LevelInfo, nil
case "warn", "warning":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
default:
return 0, fmt.Errorf("SCHOOL_LOG_LEVEL: unknown level %q", s)
}
}

View File

@@ -0,0 +1,67 @@
-- Migration 001 — Phase 1 foundation (AGENTS.md §7).
--
-- Every item is stored with its child, source page, type, subject, due date and
-- link (FR-5). Scans are append-only and record success or an explicit error,
-- so a failed scan can never be mistaken for "nothing due" (FR-12).
CREATE TABLE IF NOT EXISTS children (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
year TEXT,
compass_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- One row per message, task or calendar event read from Compass or Teams.
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY,
child_id INTEGER NOT NULL REFERENCES children(id),
source TEXT NOT NULL DEFAULT 'compass' CHECK (source IN ('compass', 'teams')),
type TEXT NOT NULL CHECK (type IN ('message', 'task', 'event')),
subject TEXT,
teacher TEXT,
title TEXT NOT NULL,
body TEXT,
due_at TEXT,
url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_items_child ON items(child_id);
CREATE INDEX IF NOT EXISTS idx_items_due ON items(child_id, due_at);
CREATE INDEX IF NOT EXISTS idx_items_type ON items(child_id, type);
-- Append-only scan log (FR-13). ok = 0 with a message is a loud failure.
CREATE TABLE IF NOT EXISTS scans (
id INTEGER PRIMARY KEY,
child_id INTEGER REFERENCES children(id),
started_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT,
pages_fetched INTEGER NOT NULL DEFAULT 0,
items_found INTEGER NOT NULL DEFAULT 0,
ok INTEGER NOT NULL DEFAULT 0,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_scans_started ON scans(started_at);
-- One row per delivered brief (FR-9, FR-14).
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY,
recipient TEXT NOT NULL,
summary TEXT NOT NULL,
sent_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_notifications_sent ON notifications(sent_at);
-- Harness session state per child. SECRET: session cookies and tokens live
-- here, in the database volume only. Never exported, never logged (AGENTS.md
-- §10 rule 3).
CREATE TABLE IF NOT EXISTS session (
child_id INTEGER PRIMARY KEY REFERENCES children(id),
state TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

271
internal/store/store.go Normal file
View File

@@ -0,0 +1,271 @@
// Package store owns the SQLite database (AGENTS.md §7). It is the single
// source of truth for stored items, scans, notifications and session state.
package store
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"sort"
"time"
_ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo)
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// Store wraps the database handle.
type Store struct {
db *sql.DB
}
// Open opens (creating if needed) the SQLite database at path.
func Open(path string) (*Store, error) {
dsn := fmt.Sprintf(
"file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)",
path,
)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// modernc/sqlite serialises writes; one connection avoids lock churn.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
return &Store{db: db}, nil
}
// DB exposes the handle for read-only queries.
func (s *Store) DB() *sql.DB { return s.db }
// Ping verifies the database is reachable.
func (s *Store) Ping(ctx context.Context) error {
return s.db.PingContext(ctx)
}
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
// Migrate applies every embedded migration that has not run yet. It tracks the
// applied version with PRAGMA user_version, so reruns are safe.
func (s *Store) Migrate(ctx context.Context) error {
files, err := migrationFiles()
if err != nil {
return err
}
var version int
if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
return fmt.Errorf("read user_version: %w", err)
}
for i := version; i < len(files); i++ {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %d: %w", i+1, err)
}
if _, err := tx.ExecContext(ctx, files[i].sql); err != nil {
_ = tx.Rollback()
return fmt.Errorf("migration %s: %w", files[i].name, err)
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("set user_version %d: %w", i+1, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", files[i].name, err)
}
}
return nil
}
type migration struct {
name string
sql string
}
func migrationFiles() ([]migration, error) {
entries, err := fs.ReadDir(migrationFS, "migrations")
if err != nil {
return nil, fmt.Errorf("read migrations: %w", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
names = append(names, e.Name())
}
}
sort.Strings(names) // 001_, 002_, ... apply in order
out := make([]migration, 0, len(names))
for _, n := range names {
b, err := fs.ReadFile(migrationFS, "migrations/"+n)
if err != nil {
return nil, fmt.Errorf("read migration %s: %w", n, err)
}
out = append(out, migration{name: n, sql: string(b)})
}
return out, nil
}
// Child is one school child (AGENTS.md §7).
type Child struct {
ID int64
Name string
Year string
CompassID string
CreatedAt time.Time
}
// SeedChildren inserts the given names if they are not present yet. It is
// idempotent, so it is safe to call on every start. It never deletes a child.
func (s *Store) SeedChildren(ctx context.Context, names ...string) error {
for _, name := range names {
if _, err := s.db.ExecContext(ctx,
`INSERT INTO children (name) VALUES (?) ON CONFLICT(name) DO NOTHING`, name); err != nil {
return fmt.Errorf("seed child %q: %w", name, err)
}
}
return nil
}
// ListChildren returns every child, oldest first.
func (s *Store) ListChildren(ctx context.Context) ([]Child, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, COALESCE(year, ''), COALESCE(compass_id, ''), created_at
FROM children ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("list children: %w", err)
}
defer rows.Close()
var out []Child
for rows.Next() {
var c Child
var created string
if err := rows.Scan(&c.ID, &c.Name, &c.Year, &c.CompassID, &created); err != nil {
return nil, fmt.Errorf("scan child: %w", err)
}
c.CreatedAt = parseTime(created)
out = append(out, c)
}
return out, rows.Err()
}
// Scan is one row of the append-only scan log (FR-13).
type Scan struct {
ID int64 `json:"id"`
ChildID *int64 `json:"child_id,omitempty"`
ChildName string `json:"child,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
PagesFetched int `json:"pages_fetched"`
ItemsFound int `json:"items_found"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
// ScanStatus summarises scan health for the dashboard. It exists so that a
// failed scan is shown as an explicit failure and never as an empty day
// (FR-12).
type ScanStatus struct {
// EverRun is false until the first scan is recorded.
EverRun bool `json:"ever_run"`
// Total is the number of scan rows.
Total int `json:"total"`
// Failed is the number of rows with ok = 0.
Failed int `json:"failed"`
// Last is the most recent scan, when one exists.
Last *Scan `json:"last,omitempty"`
}
// LastScan returns the most recent scan row, or nil when none exists.
func (s *Store) LastScan(ctx context.Context) (*Scan, error) {
row := s.db.QueryRowContext(ctx,
`SELECT sc.id, sc.child_id, COALESCE(ch.name, ''), sc.started_at,
sc.finished_at, sc.pages_fetched, sc.items_found, sc.ok, COALESCE(sc.error, '')
FROM scans sc LEFT JOIN children ch ON ch.id = sc.child_id
ORDER BY sc.started_at DESC, sc.id DESC LIMIT 1`)
sc, err := scanRow(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &sc, nil
}
// ScanSummary builds the scan health status shown on the dashboard.
func (s *Store) ScanSummary(ctx context.Context) (ScanStatus, error) {
var st ScanStatus
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END), 0) FROM scans`).
Scan(&st.Total, &st.Failed); err != nil {
return st, fmt.Errorf("scan summary: %w", err)
}
st.EverRun = st.Total > 0
last, err := s.LastScan(ctx)
if err != nil {
return st, err
}
st.Last = last
return st, nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanRow(r rowScanner) (Scan, error) {
var sc Scan
var childID sql.NullInt64
var started string
var finished sql.NullString
var ok int
if err := r.Scan(&sc.ID, &childID, &sc.ChildName, &started, &finished,
&sc.PagesFetched, &sc.ItemsFound, &ok, &sc.Error); err != nil {
return sc, err
}
if childID.Valid {
sc.ChildID = &childID.Int64
}
sc.StartedAt = parseTime(started)
if finished.Valid {
t := parseTime(finished.String)
sc.FinishedAt = &t
}
sc.OK = ok == 1
return sc, nil
}
// parseTime accepts SQLite's default datetime format and RFC3339.
func parseTime(s string) time.Time {
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} {
if t, err := time.ParseInLocation(layout, s, time.UTC); err == nil {
return t
}
}
return time.Time{}
}
// RecordScan appends one scan row. It exists so later phases and the tests can
// prove the fail-loudly path. A non-empty errMsg records a failure even when
// nothing was fetched.
func (s *Store) RecordScan(ctx context.Context, childID *int64, pages, items int, ok bool, errMsg string) (int64, error) {
okInt := 0
if ok {
okInt = 1
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO scans (child_id, finished_at, pages_fetched, items_found, ok, error)
VALUES (?, datetime('now'), ?, ?, ?, NULLIF(?, ''))`,
childID, pages, items, okInt, errMsg)
if err != nil {
return 0, fmt.Errorf("record scan: %w", err)
}
return res.LastInsertId()
}

324
internal/web/server.go Normal file
View File

@@ -0,0 +1,324 @@
// Package web is the HTTP surface of the Compass/NHS service: /health, the
// service's own session login, and the server-rendered HTMX dashboard
// (AGENTS.md §5, §9).
package web
import (
"context"
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
//go:embed templates/*.html templates/partials/*.html
var templateFS embed.FS
//go:embed static
var staticFS embed.FS
// Server holds the dependencies for the HTTP handlers.
type Server struct {
cfg *config.Config
store *store.Store
auth *auth.Manager
tmpl *template.Template
log *slog.Logger
}
// New builds a Server and parses the embedded templates.
func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger) (*Server, error) {
funcs := template.FuncMap{
"year": func() int { return time.Now().Year() },
"clock": func() string { return time.Now().Format("15:04:05 MST") },
}
tmpl, err := template.New("").Funcs(funcs).ParseFS(templateFS,
"templates/*.html", "templates/partials/*.html")
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
}
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log}, nil
}
// Handler returns the fully wrapped HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /login", s.handleLoginPage)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard))
mux.HandleFunc("GET /partials/scan-status", s.requireAuth(s.handleScanStatusPartial))
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("web: embedded static dir missing: " + err.Error())
}
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
return s.recoverer(s.securityHeaders(s.requestLogger(mux)))
}
// ---------- middleware ----------
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (s *Server) requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
s.log.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"remote", r.RemoteAddr,
"duration", time.Since(start).String(),
)
})
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; form-action 'self'; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic", "err", rec, "path", r.URL.Path)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.auth.Authenticated(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}
// ---------- handlers ----------
type healthResponse struct {
Status string `json:"status"`
Service string `json:"service"`
Version string `json:"version"`
Database string `json:"database"`
Scans store.ScanStatus `json:"scans"`
Time string `json:"time"`
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp := healthResponse{
Status: "ok",
Service: "compass-teams-nhs",
Version: s.cfg.Version,
Database: "ok",
Time: time.Now().UTC().Format(time.RFC3339),
}
code := http.StatusOK
if err := s.store.Ping(r.Context()); err != nil {
resp.Status = "error"
resp.Database = "error"
code = http.StatusServiceUnavailable
}
if st, err := s.store.ScanSummary(r.Context()); err == nil {
resp.Scans = st
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(resp)
}
type loginData struct {
CSRF string
Error string
Username string
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if s.auth.Authenticated(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
s.render(w, r, "login.html", loginData{CSRF: s.auth.EnsureCSRF(w, r)})
}
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
if !s.auth.CheckCSRF(r) {
s.log.Warn("login rejected: bad csrf token", "remote", r.RemoteAddr)
s.render(w, r, "login.html", loginData{
CSRF: s.auth.EnsureCSRF(w, r),
Error: "Session expired. Please try again.",
Username: username,
})
return
}
if !s.auth.CheckCredentials(username, r.PostFormValue("password")) {
s.log.Warn("login failed", "user", username, "remote", r.RemoteAddr)
w.WriteHeader(http.StatusUnauthorized)
s.render(w, r, "login.html", loginData{
CSRF: s.auth.EnsureCSRF(w, r),
Error: "Incorrect username or password.",
Username: username,
})
return
}
s.auth.Issue(w)
s.log.Info("login success", "user", username, "remote", r.RemoteAddr)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
s.auth.Clear(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// scanStatusView is the fail-loudly view of scan health (FR-12). It is shared
// by the dashboard and the HTMX partial.
type scanStatusView struct {
store.ScanStatus
// Level is "none", "error", "ok" or "warn". The template never renders an
// empty day as "nothing due".
Level string
Message string
LastAt string
}
func (s *Server) scanStatus(ctx context.Context) scanStatusView {
st, err := s.store.ScanSummary(ctx)
if err != nil {
s.log.Error("scan summary", "err", err)
return scanStatusView{Level: "error", Message: "Could not read the scan log: " + err.Error()}
}
v := scanStatusView{ScanStatus: st}
if !st.EverRun {
v.Level = "warn"
v.Message = "NO SCAN HAS RUN YET — do not read this as “nothing due”."
return v
}
if st.Last != nil && !st.Last.OK {
v.Level = "error"
v.Message = "SCAN FAILED — the last scan did not complete, so due work is UNKNOWN."
v.LastAt = st.Last.StartedAt.Format("Mon 02 Jan 2006 15:04")
return v
}
v.Level = "ok"
v.Message = "Last scan completed."
if st.Last != nil {
v.LastAt = st.Last.StartedAt.Format("Mon 02 Jan 2006 15:04")
}
return v
}
type childView struct {
store.Child
}
type dashboardData struct {
Title string
Username string
CSRF string
Version string
AppEnv string
Children []childView
Scan scanStatusView
ServerUp string
ErrMsg string
}
var serverStarted = time.Now()
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
children, err := s.store.ListChildren(ctx)
if err != nil {
s.log.Error("list children", "err", err)
}
views := make([]childView, 0, len(children))
for _, c := range children {
views = append(views, childView{Child: c})
}
s.render(w, r, "dashboard.html", dashboardData{
Title: "Dashboard",
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
Children: views,
Scan: s.scanStatus(ctx),
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")),
})
}
func (s *Server) handleScanStatusPartial(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
s.renderFragment(w, r, "scan-status.html", s.scanStatus(ctx))
}
func (s *Server) render(w http.ResponseWriter, _ *http.Request, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.log.Error("render", "template", name, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
func (s *Server) renderFragment(w http.ResponseWriter, _ *http.Request, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.log.Error("render fragment", "template", name, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}

328
internal/web/static/app.css Normal file
View File

@@ -0,0 +1,328 @@
/* Compass / Teams — Phase 1 UI
Built on the Family Home Lab design tokens (tokens.css).
Rules: canvas-soft app background, white cards, hairlines, one action blue,
pill CTAs, 8px grid, touch targets >= 44px, dark mode from the tokens. */
*, *::before, *::after { box-sizing: border-box; }
html { font-size: 16px; }
body {
margin: 0;
background: var(--canvas-soft);
color: var(--ink-secondary);
font: var(--type-body-md);
-webkit-font-smoothing: antialiased;
min-height: 100dvh;
}
h1, h2, h3 { margin: 0; color: var(--ink); }
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ---------- Login ---------- */
.login-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-lg);
padding: var(--space-lg);
background:
radial-gradient(1200px 600px at 15% 0%, #2b3d93 0%, transparent 60%),
var(--secondary);
}
.login-band {
width: 100%;
max-width: 960px;
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: var(--space-xxl);
align-items: center;
padding: var(--space-xxl) 0;
}
.login-eyebrow {
margin: 0 0 var(--space-sm);
font: var(--type-eyebrow);
text-transform: uppercase;
letter-spacing: 0.14em;
color: rgba(255, 255, 255, 0.72);
}
.login-headline {
margin: 0;
font: var(--type-display-1);
letter-spacing: -1.5px;
color: #ffffff;
}
.login-sub {
margin: var(--space-md) 0 0;
font: var(--type-body-md);
color: rgba(255, 255, 255, 0.78);
}
.auth-card {
background: var(--surface);
border-radius: var(--rounded-lg);
box-shadow: var(--shadow-level-2);
padding: var(--space-xl);
}
.auth-card__title {
font: var(--type-heading-2);
margin-bottom: var(--space-lg);
}
.auth-form { display: grid; gap: var(--space-md); }
.auth-error {
margin: 0 0 var(--space-md);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--sticker-red);
border-left-width: 3px;
border-radius: var(--rounded-sm);
color: var(--sticker-red);
background: color-mix(in srgb, var(--sticker-red) 8%, var(--surface));
font: var(--type-body-sm);
}
.field { display: grid; gap: var(--space-xs); }
.field__label {
font: var(--type-eyebrow);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-muted);
}
.text-input {
width: 100%;
min-height: 44px;
padding: 10px 12px;
font: var(--type-body-md);
color: var(--ink);
background: var(--surface);
border: 1px solid #dddddd;
border-radius: var(--rounded-xs);
}
[data-theme="dark"] .text-input { border-color: var(--hairline); }
.text-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: var(--shadow-level-1), 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent);
}
.login-foot {
font: var(--type-caption);
color: rgba(255, 255, 255, 0.55);
}
/* ---------- Buttons ---------- */
.button-primary,
.button-utility {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 10px 20px;
font: var(--type-button);
cursor: pointer;
transition: background 120ms ease, transform 80ms ease, box-shadow 120ms ease;
}
.button-primary {
border: 0;
border-radius: var(--rounded-full);
background: var(--primary);
color: var(--on-primary);
}
.button-primary:active { background: var(--primary-active); transform: scale(0.97); }
.button-primary:focus-visible {
outline: 3px solid color-mix(in srgb, var(--primary) 35%, transparent);
outline-offset: 2px;
}
.button-utility {
border: 1px solid var(--hairline);
border-radius: var(--rounded-md);
background: var(--surface);
color: var(--ink);
padding: 8px 14px;
}
.button-utility:hover { box-shadow: var(--shadow-level-1); }
/* ---------- App shell ---------- */
.app { display: flex; flex-direction: column; min-height: 100dvh; }
.nav-bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-sm) var(--space-lg);
background: var(--surface);
border-bottom: 1px solid var(--hairline);
}
.nav-bar__brand {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
color: var(--ink);
font: var(--type-title);
}
.nav-bar__brand:hover { text-decoration: none; }
.nav-bar__mark {
width: 12px; height: 12px; border-radius: var(--rounded-full);
background: var(--primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 18%, transparent);
}
.nav-bar__right { display: flex; align-items: center; gap: var(--space-md); }
.nav-bar__user { font: var(--type-body-sm); color: var(--ink-muted); }
.nav-bar__logout { margin: 0; }
.container {
width: 100%;
max-width: var(--container-max);
margin: 0 auto;
padding: var(--space-xxl) var(--space-lg);
display: grid;
gap: var(--space-xxl);
flex: 1;
}
.page-head { display: grid; gap: var(--space-xs); }
.heading-1 { font: var(--type-heading-1); letter-spacing: -0.8px; }
.heading-2 { font: var(--type-heading-2); }
.page-head__sub { margin: 0; color: var(--ink-muted); max-width: 62ch; }
.section { display: grid; gap: var(--space-md); }
.section__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.category-pill {
display: inline-flex;
align-items: center;
gap: var(--space-xxs);
padding: 4px 10px;
border-radius: var(--rounded-full);
background: var(--surface-2);
color: var(--ink-muted);
font: var(--type-eyebrow);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dot { width: 7px; height: 7px; border-radius: var(--rounded-full); background: var(--ink-faint); }
.dot--faint { background: var(--ink-faint); }
/* ---------- Flash ---------- */
.flash {
margin: 0;
padding: var(--space-sm) var(--space-md);
border-radius: var(--rounded-md);
border: 1px solid var(--hairline);
font: var(--type-body-sm);
}
.flash--error {
border-color: var(--sticker-red);
border-left-width: 3px;
color: var(--sticker-red);
background: color-mix(in srgb, var(--sticker-red) 8%, var(--surface));
}
/* ---------- Scan status (FR-12: fail loudly) ---------- */
.scan {
display: grid;
gap: var(--space-xs);
padding: var(--space-md) var(--space-lg);
border-radius: var(--rounded-lg);
border: 1px solid var(--hairline);
border-left-width: 4px;
background: var(--surface);
box-shadow: var(--shadow-level-1);
}
.scan__head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); }
.scan__pill { background: var(--surface-2); color: var(--ink-muted); }
.scan__message { margin: 0; font: var(--type-body-md); color: var(--ink-secondary); }
.scan__meta { margin: 0; font: var(--type-caption); color: var(--ink-muted); }
.scan--ok { border-left-color: var(--sticker-green); }
.scan--ok .scan__pill { background: color-mix(in srgb, var(--sticker-green) 16%, var(--surface)); color: #0f7a28; }
.scan--warn { border-left-color: var(--sticker-orange); background: color-mix(in srgb, var(--sticker-orange) 5%, var(--surface)); }
.scan--warn .scan__pill { background: color-mix(in srgb, var(--sticker-orange) 16%, var(--surface)); color: var(--sticker-orange-deep); }
.scan--error { border-left-color: var(--sticker-red); background: color-mix(in srgb, var(--sticker-red) 6%, var(--surface)); }
.scan--error .scan__pill { background: color-mix(in srgb, var(--sticker-red) 16%, var(--surface)); color: var(--sticker-red); }
.scan--error .scan__message { color: var(--sticker-red); font-weight: 600; }
/* ---------- Children ---------- */
.child-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--space-md);
}
.child-card {
display: grid;
gap: var(--space-xxs);
padding: var(--space-md) var(--space-lg);
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--rounded-lg);
box-shadow: var(--shadow-level-1);
}
.child-card__name { margin: 0; font: var(--type-title); color: var(--ink); }
.child-card__meta { margin: 0; font: var(--type-caption); color: var(--ink-muted); }
.child-card__body { margin: var(--space-xs) 0 0; font: var(--type-body-sm); color: var(--ink-muted); }
.empty-state {
display: grid;
gap: var(--space-xs);
padding: var(--space-xl);
text-align: center;
background: var(--surface);
border: 1px dashed var(--hairline);
border-radius: var(--rounded-lg);
}
.empty-state__title { margin: 0; font: var(--type-title); color: var(--ink); }
.empty-state__body { margin: 0; font: var(--type-body-sm); color: var(--ink-muted); }
/* ---------- Footer ---------- */
.app-foot {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
padding: var(--space-md) var(--space-lg);
border-top: 1px solid var(--hairline);
background: var(--surface);
font: var(--type-caption);
color: var(--ink-muted);
}
.app-foot__sep { color: var(--ink-faint); }
/* ---------- Responsive ---------- */
@media (max-width: 760px) {
.login-band { grid-template-columns: 1fr; gap: var(--space-xl); text-align: left; }
.login-headline { font: var(--type-heading-1); letter-spacing: -0.8px; }
.container { padding: var(--space-lg) var(--space-md); gap: var(--space-xl); }
.nav-bar { padding: var(--space-sm) var(--space-md); }
.nav-bar__user { display: none; }
.section__head { flex-wrap: wrap; }
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
internal/web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,55 @@
// Theme controller. The design tokens ship both palettes; nothing switches
// between them unless this runs. It follows the OS preference by default and
// remembers an explicit choice. Loaded with defer from /static/, so it is
// allowed by the page's `script-src 'self'` policy (no inline script).
(function () {
"use strict";
var KEY = "webrestrict.theme";
var root = document.documentElement;
var media = window.matchMedia("(prefers-color-scheme: dark)");
var stored = null;
try {
stored = window.localStorage.getItem(KEY);
} catch (e) {
stored = null;
}
function apply() {
var theme = stored || (media.matches ? "dark" : "light");
root.setAttribute("data-theme", theme);
var buttons = document.querySelectorAll("[data-theme-toggle]");
for (var i = 0; i < buttons.length; i++) {
buttons[i].textContent = theme === "dark" ? "Light" : "Dark";
buttons[i].setAttribute(
"aria-label",
"Switch to " + (theme === "dark" ? "light" : "dark") + " theme"
);
}
}
document.addEventListener("click", function (event) {
var target = event.target && event.target.closest
? event.target.closest("[data-theme-toggle]")
: null;
if (!target) {
return;
}
stored = root.getAttribute("data-theme") === "dark" ? "light" : "dark";
try {
window.localStorage.setItem(KEY, stored);
} catch (e) {
/* private mode: the choice just does not persist */
}
apply();
});
if (media.addEventListener) {
media.addEventListener("change", function () {
if (!stored) {
apply();
}
});
}
apply();
})();

View File

@@ -0,0 +1,110 @@
/* ============================================================
Family Home Lab — Design Tokens
Source: DESIGN.md (Notion design language)
Only structural/semantic tokens here. All values map 1:1.
============================================================ */
/* Inter (@fontsource) — self-hosted substitute for NotionInter */
@font-face { font-family: "Inter"; font-style: normal; font-weight: 400; font-display: swap; src: url("/static/fonts/inter-400.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 500; font-display: swap; src: url("/static/fonts/inter-500.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 600; font-display: swap; src: url("/static/fonts/inter-600.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 700; font-display: swap; src: url("/static/fonts/inter-700.woff2") format("woff2"); }
:root {
/* --- Surface --- */
--canvas-soft: #f6f5f4;
--surface: #ffffff;
--surface-2: #f2f2f1;
--hairline: #e6e6e6;
/* --- Text --- */
--ink: #000000;
--ink-secondary: #31302e;
--ink-muted: #615d59;
--ink-faint: #a39e98;
/* --- Structural accents --- */
--primary: #0075de; /* the ONLY action colour */
--primary-active: #005bab;
--on-primary: #ffffff;
--secondary: #213183; /* deep indigo — login band + greeting only */
/* --- Sticker palette (decorative only) --- */
--sticker-sky: #62aef0; /* chat */
--sticker-purple: #d6b6f6; /* image */
--sticker-pink: #ff64c8; /* video */
--sticker-orange: #dd5b00; /* audio */
--sticker-teal: #2a9d99; /* docs */
--sticker-green: #1aae39; /* status: online/saved */
--sticker-red: #d33a2b; /* status: failed / error (ON_OFF.md decision) */
--sticker-purple-deep: #391c57; /* illustration only */
--sticker-orange-deep: #793400; /* illustration only */
/* --- Typography (Inter, self-hosted via @font-face) --- */
--font-family: "Inter", -apple-system, system-ui, "Segoe UI",
Helvetica, Arial, sans-serif;
/* Font-family is baked into each token below: the CSS `font` shorthand
resets font-family to the UA default (serif) unless a family is written,
and it cannot carry letter-spacing — so weights/sizes ship inline and the
family is appended per token. */
--type-display-1: 700 48px/1.05 var(--font-family);
--type-heading-1: 700 32px/1.1 var(--font-family);
--type-heading-2: 700 22px/1.27 var(--font-family);
--type-title: 600 17px/1.4 var(--font-family);
--type-body-md: 400 15px/1.5 var(--font-family);
--type-body-sm: 400 14px/1.4 var(--font-family);
--type-button: 500 15px/1.4 var(--font-family);
--type-caption: 400 13px/1.43 var(--font-family);
--type-eyebrow: 600 12px/1.33 var(--font-family);
/* --- Layout (base unit 8px) --- */
--space-xxs: 4px;
--space-xs: 8px;
--space-sm: 12px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 28px;
--space-xxl: 32px;
--container-max: 1200px;
/* --- Elevation --- */
--shadow-level-1: rgba(0, 0, 0, 0.01) 0 0.175px 1px,
rgba(0, 0, 0, 0.02) 0 0.8px 3px,
rgba(0, 0, 0, 0.027) 0 2px 8px,
rgba(0, 0, 0, 0.04) 0 4px 18px;
--shadow-level-2: rgba(0, 0, 0, 0.0122) 0 0.175px 1px,
rgba(0, 0, 0, 0.024) 0 0.8px 3px,
rgba(0, 0, 0, 0.032) 0 2px 8px,
rgba(0, 0, 0, 0.047) 0 4px 18px,
rgba(0, 0, 0, 0.05) 0 23px 52px;
/* --- Shapes --- */
--rounded-xs: 4px;
--rounded-sm: 5px;
--rounded-md: 8px;
--rounded-lg: 12px;
--rounded-full: 9999px;
}
/* Dark mode — flip the semantic tokens only (components use var(--…)). */
[data-theme="dark"] {
--canvas-soft: #141518;
--surface: #1c1e22;
--surface-2: #24262c;
--hairline: #33363d;
--ink: #f5f6f7;
--ink-secondary: #dcdee2;
--ink-muted: #a9adb6;
--ink-faint: #727680;
--primary: #3d8be0;
--primary-active: #5d9ff0;
--on-primary: #ffffff;
--secondary: #10142a;
--shadow-level-1: rgba(0, 0, 0, 0.45) 0 1px 3px;
--shadow-level-2: rgba(0, 0, 0, 0.55) 0 5px 22px;
}
/* Numerals: enable OpenType lnum */
html { font-feature-settings: "lnum" 1; }

View File

@@ -0,0 +1,78 @@
{{define "dashboard.html"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{.Title}} — Compass / Teams</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/htmx.min.js" defer></script>
<script src="/static/theme.js" defer></script>
</head>
<body class="app">
<nav class="nav-bar">
<a class="nav-bar__brand" href="/">
<span class="nav-bar__mark" aria-hidden="true"></span>
Compass / Teams
</a>
<div class="nav-bar__right">
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
<span class="nav-bar__user">{{.Username}}</span>
<form method="post" action="/logout" class="nav-bar__logout">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="button-utility" type="submit">Sign out</button>
</form>
</div>
</nav>
<main class="container">
<header class="page-head">
<h1 class="heading-1">What is due</h1>
<p class="page-head__sub">Overdue work first. Compass for the schedule, Teams for homework.
This is Phase 1: the skeleton is live, the school scan is not connected yet.</p>
</header>
{{if .ErrMsg}}<p class="flash flash--error" role="alert">{{.ErrMsg}}</p>{{end}}
<div hx-get="/partials/scan-status" hx-trigger="every 60s" hx-swap="outerHTML">
{{template "scan-status.html" .Scan}}
</div>
<section class="section" aria-labelledby="children-title">
<div class="section__head">
<h2 id="children-title" class="heading-2">Children</h2>
<span class="category-pill"><span class="dot dot--faint"></span>{{len .Children}} on file</span>
</div>
{{if .Children}}
<ul class="child-list">
{{range .Children}}
<li class="child-card">
<p class="child-card__name">{{.Name}}</p>
<p class="child-card__meta">
{{if .Year}}Year {{.Year}}{{else}}Year not set{{end}}
{{if not .CompassID}}· Compass ID not set{{end}}
</p>
<p class="child-card__body">No items stored yet. The Compass scan arrives in Phase 2.</p>
</li>
{{end}}
</ul>
{{else}}
<div class="empty-state">
<p class="empty-state__title">No children on file</p>
<p class="empty-state__body">Harry and Finn are seeded on first start. If this is empty,
the database was not seeded.</p>
</div>
{{end}}
</section>
</main>
<footer class="app-foot">
<span>Compass / Teams · {{.Version}} · {{.AppEnv}}</span>
<span class="app-foot__sep">·</span>
<span>up {{.ServerUp}}</span>
<span class="app-foot__sep">·</span>
<span>{{year}} Family Home Lab</span>
</footer>
</body>
</html>{{end}}

View File

@@ -0,0 +1,41 @@
{{define "login.html"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Sign in — Compass / Teams</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/theme.js" defer></script>
</head>
<body class="login-page">
<main class="login-band">
<div class="login-band__inner">
<p class="login-eyebrow">Compass / Teams</p>
<h1 class="login-headline">Good to see you,<br>Sam.</h1>
<p class="login-sub">Sign in to see what Harry and Finn have due.</p>
</div>
<section class="auth-card" aria-labelledby="signin-title">
<h2 id="signin-title" class="auth-card__title">Sign in</h2>
{{if .Error}}<p class="auth-error" role="alert">{{.Error}}</p>{{end}}
<form method="post" action="/login" class="auth-form" autocomplete="on">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label class="field">
<span class="field__label">Username</span>
<input class="text-input" type="text" name="username" value="{{.Username}}"
autocomplete="username" autocapitalize="none" spellcheck="false" required>
</label>
<label class="field">
<span class="field__label">Password</span>
<input class="text-input" type="password" name="password"
autocomplete="current-password" required>
</label>
<button class="button-primary" type="submit">Sign in</button>
</form>
</section>
</main>
<footer class="login-foot">Family Home Lab · Compass / Teams</footer>
</body>
</html>{{end}}

View File

@@ -0,0 +1,19 @@
{{define "scan-status.html"}}
{{/* Fail-loudly scan banner (FR-12). A missing or failed scan is never shown as
"nothing due". */}}
<section class="scan scan--{{.Level}}" aria-labelledby="scan-title">
<div class="scan__head">
<h2 id="scan-title" class="heading-2">Scan status</h2>
<span class="category-pill scan__pill">
{{if eq .Level "ok"}}healthy
{{else if eq .Level "error"}}failed
{{else if eq .Level "warn"}}never run
{{else}}unknown{{end}}
</span>
</div>
<p class="scan__message">{{.Message}}</p>
{{if .LastAt}}<p class="scan__meta">Last scan: {{.LastAt}}
{{if .Last}}· {{.Last.PagesFetched}} pages · {{.Last.ItemsFound}} items{{end}}</p>{{end}}
{{if gt .Failed 0}}<p class="scan__meta">{{.Failed}} failed scan(s) on record.</p>{{end}}
</section>
{{end}}