Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 ef3e7d0e45 Leave controls disabled by a fieldset out of the Sheet focus trap
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (push) Successful in 1m28s
CI / ci (pull_request) Successful in 1m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:17:15 -04:00
dev_agentandClaude Opus 5.5 0fa9cfe611 Move Sheet focus only to controls that can take keyboard focus
CI / ci (push) Successful in 1m30s
CI / ci (pull_request) Successful in 1m29s
Review finding MQ-36-01: a hidden first button was chosen as the initial
focus target and as a Tab trap stop, so focus never entered the dialog.
Filter out controls with a negative tabIndex, a hidden/aria-hidden/inert
ancestor, display: none or visibility: hidden; on open, skip any control
whose focus() does not take, falling back to the dialog panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:14:15 -04:00
dev_agentandClaude Opus 5.5 1dd97b123c Add Sheet, ErrorBanner, Skeleton and EmptyState primitives
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (1 high)
CI / ci (pull_request) Successful in 1m28s
CI / ci (push) Successful in 1m28s
Sheet is a portalled modal bottom sheet: role="dialog" with aria-modal
and a required accessible name, focus moved in, trapped and restored on
close, Escape and scrim click call onClose, and the rest of <body> is
inert while open. It rises from translateY(100%) over 320 ms
var(--ease-out-soft) over a scrim fading to 50% neutral-900; under
prefers-reduced-motion it opens in place.

ErrorBanner is a muted neutral-100 Card with grape-700 text and a Retry
Button; Skeleton is a --color-surface pill with a 1.2 s opacity pulse
switched off under reduced motion; EmptyState has a Bagel title, body
and an optional primary Button action.

jsdom never matches prefers-reduced-motion, resolves var() colours to
transparent and does not expand the animation shorthand, so the new
src/test/cssRules.ts reads the CSS Modules' declared rules, the
reduced-motion media block and the hashed @keyframes directly.

Maestro task: 2f9b64dd-0c26-47b9-b2b8-557bdf506d68

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:09:47 -04:00
dev_agent 02f1dd6491 Feature 05 task 2: Core UI primitives (#34)
CI / ci (push) Successful in 1m31s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 16:34:59 -04:00
dev_agent 496f2e5375 Feature 05 task 1: Design tokens and self-hosted fonts (#32)
CI / ci (push) Successful in 1m29s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 16:16:25 -04:00
approver_agent 7cc96121e4 Merge pull request 'Feature 04: Platform Services - Secrets, Settings, Time and Jobs' (#30) from feature/49e2f2e2-c9c1-4c1b-8d66-753ceeadbf75-platform-services into dev
CI / ci (push) Successful in 1m28s
2026-10-03 15:50:01 -04:00
dev_agent bba9033ac5 Feature 04 integration fix: authenticated job enqueue API and progressive HTTP flow (#31)
CI / ci (push) Successful in 1m29s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (pull_request) Successful in 1m28s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 15:46:19 -04:00
dev_agent e050736178 Feature 04 task 5: Async job runner and GET /api/v1/jobs/{id} (#29)
CI / ci (push) Successful in 1m29s
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (1 medium)
CI / ci (pull_request) Successful in 1m28s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 15:24:01 -04:00
80 changed files with 4456 additions and 24 deletions
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"context"
"io"
"log/slog"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
)
// TestNewJobRunnerRunsNoop proves the runner run builds can execute a job:
// a noop job enqueued before Start, as at boot, succeeds with its result.
func TestNewJobRunnerRunsNoop(t *testing.T) {
ctx := context.Background()
db, err := openStore(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
runner, kinds, err := newJobRunner(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
id, err := kinds.builtins.Noop.Enqueue(ctx, jobs.NoopPayload{})
if err != nil {
t.Fatal(err)
}
jobsCtx, stopJobs := context.WithCancel(ctx)
if err := runner.Start(jobsCtx); err != nil {
stopJobs()
t.Fatal(err)
}
t.Cleanup(func() {
stopJobs()
runner.Wait()
})
deadline := time.Now().Add(5 * time.Second)
for {
job, err := runner.Get(ctx, id)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
if job.Kind != jobs.KindNoop || job.Status != jobs.StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil {
t.Fatalf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("noop job never finished; last %+v", job)
}
time.Sleep(5 * time.Millisecond)
}
}
+50 -1
View File
@@ -18,6 +18,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -27,6 +28,9 @@ import (
// version is set at build time with -ldflags "-X main.version=<version>".
var version = "dev"
// jobWorkers is the number of background jobs run at once.
const jobWorkers = 2
func main() {
cfg, err := config.Load()
if err != nil {
@@ -63,6 +67,10 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
settings := household.NewSettingsService(db)
runner, _, err := newJobRunner(db, logger)
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
@@ -80,10 +88,22 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, frontend.Assets())
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, runner, frontend.Assets())
if err != nil {
return err
}
// Start fails the jobs a previous process left running before any
// worker runs. The workers stop, and are waited for, before the
// database closes.
jobsCtx, stopJobs := context.WithCancel(ctx)
defer func() {
stopJobs()
runner.Wait()
}()
if err := runner.Start(jobsCtx); err != nil {
return err
}
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: router,
@@ -165,6 +185,35 @@ func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error
return household.SeedNotificationPrefs(ctx, db)
}
// jobKinds enqueues every job kind the server runs.
type jobKinds struct {
builtins *jobs.Builtins
}
// newJobRunner returns the job runner with every job kind registered, ready
// to Start.
func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds, error) {
runner, err := jobs.NewRunner(db, logger, jobWorkers)
if err != nil {
return nil, nil, err
}
kinds, err := registerJobKinds(runner)
if err != nil {
return nil, nil, err
}
return runner, kinds, nil
}
// registerJobKinds registers every job kind's handler on runner. Each
// feature's job kinds are registered here.
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
builtins, err := jobs.RegisterBuiltins(runner)
if err != nil {
return nil, err
}
return &jobKinds{builtins: builtins}, nil
}
// logListening logs the startup line. Only the public URL's origin is logged
// so no credential-bearing URL component can reach the log.
func logListening(logger *slog.Logger, cfg config.Config) {
+166
View File
@@ -0,0 +1,166 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: jobs.sql
package db
import (
"context"
"database/sql"
)
const claimNextJob = `-- name: ClaimNextJob :one
UPDATE jobs
SET status = 'running', started_at = ?1, updated_at = ?1
WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1)
RETURNING id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at
`
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
// when no job is queued.
func (q *Queries) ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error) {
row := q.db.QueryRowContext(ctx, claimNextJob, now)
var i Job
err := row.Scan(
&i.ID,
&i.Kind,
&i.Status,
&i.Payload,
&i.Result,
&i.Error,
&i.StartedAt,
&i.FinishedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const createJob = `-- name: CreateJob :exec
INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
VALUES (?, ?, 'queued', ?, ?, ?)
`
type CreateJobParams struct {
ID string
Kind string
Payload string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateJob(ctx context.Context, arg CreateJobParams) error {
_, err := q.db.ExecContext(ctx, createJob,
arg.ID,
arg.Kind,
arg.Payload,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const failJob = `-- name: FailJob :execrows
UPDATE jobs
SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2
WHERE id = ?3 AND status = 'running'
`
type FailJobParams struct {
Error sql.NullString
Now sql.NullString
ID string
}
func (q *Queries) FailJob(ctx context.Context, arg FailJobParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failJob, arg.Error, arg.Now, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const failRunningJobs = `-- name: FailRunningJobs :execrows
UPDATE jobs
SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2
WHERE status = 'running'
`
type FailRunningJobsParams struct {
Error sql.NullString
Now sql.NullString
}
// Fails every job left running, at boot before any worker starts.
func (q *Queries) FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failRunningJobs, arg.Error, arg.Now)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getJob = `-- name: GetJob :one
SELECT id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs
WHERE id = ?
`
func (q *Queries) GetJob(ctx context.Context, id string) (Job, error) {
row := q.db.QueryRowContext(ctx, getJob, id)
var i Job
err := row.Scan(
&i.ID,
&i.Kind,
&i.Status,
&i.Payload,
&i.Result,
&i.Error,
&i.StartedAt,
&i.FinishedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const publishJobResult = `-- name: PublishJobResult :execrows
UPDATE jobs
SET result = ?, updated_at = ?
WHERE id = ? AND status = 'running'
`
type PublishJobResultParams struct {
Result sql.NullString
UpdatedAt string
ID string
}
// Stores a partial result of a running job.
func (q *Queries) PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) {
result, err := q.db.ExecContext(ctx, publishJobResult, arg.Result, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const succeedJob = `-- name: SucceedJob :execrows
UPDATE jobs
SET status = 'succeeded', result = ?1, finished_at = ?2, updated_at = ?2
WHERE id = ?3 AND status = 'running'
`
type SucceedJobParams struct {
Result sql.NullString
Now sql.NullString
ID string
}
func (q *Queries) SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) {
result, err := q.db.ExecContext(ctx, succeedJob, arg.Result, arg.Now, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+12
View File
@@ -6,17 +6,25 @@ package db
import (
"context"
"database/sql"
)
type Querier interface {
// An unknown id returns sql.ErrNoRows.
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
// when no job is queued.
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
CreateJob(ctx context.Context, arg CreateJobParams) error
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
DeleteRecipeSteps(ctx context.Context, recipeID string) error
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
// Fails every job left running, at boot before any worker starts.
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
// Returns sql.ErrNoRows until the row exists.
GetAISettings(ctx context.Context) (AiSetting, error)
// Returns sql.ErrNoRows until the row exists.
@@ -26,6 +34,7 @@ type Querier interface {
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
// Returns sql.ErrNoRows until the row exists.
GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error)
GetJob(ctx context.Context, id string) (Job, error)
// Finds stored image bytes by content hash. The schema does not make sha256
// unique, so the earliest-created (lowest id) match is returned.
GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error)
@@ -36,8 +45,11 @@ type Querier interface {
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
// Stores a partial result of a running job.
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
// Creates the single row (id 1) or replaces every column except id and
+44
View File
@@ -0,0 +1,44 @@
package jobs
import (
"context"
"encoding/json"
)
// KindNoop is the framework's built-in job kind: it does no work and
// succeeds with {"ok":true}. It is the runner's own trivial kind, as Feature
// 09's trivial test skill is the harness's, and the startup and readiness
// job: a noop job reaching succeeded proves the runner claims, runs and
// records jobs.
const KindNoop = "noop"
// NoopPayload is the payload of a noop job.
type NoopPayload struct{}
// NoopResult is the result of a noop job.
type NoopResult struct{}
// PublicJSON returns {"ok":true}.
func (NoopResult) PublicJSON() ([]byte, error) {
return json.Marshal(struct {
OK bool `json:"ok"`
}{OK: true})
}
func runNoop(context.Context, NoopPayload, func(NoopResult) error) (NoopResult, error) {
return NoopResult{}, nil
}
// Builtins enqueues the framework's built-in job kinds.
type Builtins struct {
Noop *Kind[NoopPayload]
}
// RegisterBuiltins registers the framework's built-in job kinds on r.
func RegisterBuiltins(r *Runner) (*Builtins, error) {
noop, err := Register(r, KindNoop, runNoop)
if err != nil {
return nil, err
}
return &Builtins{Noop: noop}, nil
}
+461
View File
@@ -0,0 +1,461 @@
// Package jobs runs long work in the background. A job is a row in the jobs
// table: Enqueue inserts it queued, and a fixed pool of workers claims queued
// jobs oldest first, so the table itself is the queue and a job enqueued
// before a restart still runs after it.
//
// Each job kind has one handler, registered before Start. A handler takes a
// typed payload and returns a result implementing Public; it may publish
// partial results while it runs. The runner stores only what a result's
// PublicJSON returns and, for a failed job, only a failure code: either a
// *Failure code the kind declared at registration or one of the runner's own
// codes. Handler error text, which may quote a URL, token or response body
// from a dependency, is never stored, returned by the API or logged.
package jobs
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"regexp"
"sync"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Status is the state of a job.
type Status string
// Job states. A job moves queued → running → succeeded or failed.
const (
StatusQueued Status = "queued"
StatusRunning Status = "running"
StatusSucceeded Status = "succeeded"
StatusFailed Status = "failed"
)
// Failure codes the runner stores itself. A kind cannot declare them.
const (
// CodeInterrupted is the error of a job a previous process left running.
// It is fixed framework text, not a snake-case code, so no kind can
// declare it.
CodeInterrupted = "interrupted by restart"
// CodeInternal is the error of a job whose handler failed with anything
// but a *Failure with a code its kind declared, or whose payload or
// result could not be encoded. The underlying error is neither stored
// nor logged.
CodeInternal = "internal_error"
// CodeUnknownKind is the error of a job whose kind has no handler.
CodeUnknownKind = "unknown_kind"
)
// Claim retry backoff. A worker whose claim fails with a database error
// retries after claimRetryMin, doubling up to claimRetryMax, so a job queued
// when the error clears is claimed without waiting for a wake token.
const (
claimRetryMin = 50 * time.Millisecond
claimRetryMax = 5 * time.Second
)
// codePattern is the form of every failure code: lower-case snake case.
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
// ErrNotFound is returned by Get for an unknown job id.
var ErrNotFound = errors.New("jobs: job not found")
// ErrUnknownKind is returned by Runner.Enqueue for a kind with no
// registered handler.
var ErrUnknownKind = errors.New("jobs: unknown job kind")
// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does
// not decode into the kind's payload type.
var ErrInvalidPayload = errors.New("jobs: payload does not decode")
// Failure is a handler error reporting a failure code the household may
// see. Code must be one the job's kind declared at registration; the runner
// stores it as the job's error. Any other handler error, including a Failure
// with an undeclared code, is stored as CodeInternal.
type Failure struct {
Code string
}
func (f *Failure) Error() string { return "jobs: failure " + f.Code }
// Public is a job result. PublicJSON returns the JSON the runner stores and
// GET /api/v1/jobs/{id} returns, so a result type states exactly which of its
// data is public; nothing else of the value is ever encoded.
type Public interface {
PublicJSON() ([]byte, error)
}
// Handler runs one job with its payload. It may call publish any number of
// times with a partial result, which Get returns while the job runs; its
// return value is the final result. ctx is cancelled when the runner stops.
type Handler[P any, R Public] func(ctx context.Context, payload P, publish func(partial R) error) (R, error)
// Job is a job as reported by GET /api/v1/jobs/{id}. Result is the latest
// partial or final result, and is null until the handler publishes one.
// Error is set only on a failed job.
type Job struct {
ID string `json:"id"`
Kind string `json:"kind"`
Status Status `json:"status"`
Result json.RawMessage `json:"result"`
Error *string `json:"error"`
}
// runFunc is a registered Handler with its payload and results as JSON. It
// returns the failure code to store, or "" on success; the logged reason
// never quotes an error.
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
// handler is a registered kind: run runs a job, and check reports whether
// a payload decodes into the kind's payload type.
type handler struct {
run runFunc
check func(payload []byte) bool
}
// Runner owns the job handlers and the worker pool.
type Runner struct {
st *store.Store
logger *slog.Logger
workers int
// wake holds up to one token per worker; Enqueue adds one so an idle
// worker looks for the new job.
wake chan struct{}
// claim claims the oldest queued job, marking it running at now.
claim func(ctx context.Context, now sql.NullString) (db.Job, error)
mu sync.Mutex
handlers map[string]handler
started bool
wg sync.WaitGroup
}
// NewRunner returns a runner over st with workers concurrent workers.
func NewRunner(st *store.Store, logger *slog.Logger, workers int) (*Runner, error) {
if st == nil {
return nil, errors.New("jobs: nil store")
}
if logger == nil {
return nil, errors.New("jobs: nil logger")
}
if workers < 1 {
return nil, fmt.Errorf("jobs: %d workers, want at least 1", workers)
}
return &Runner{
st: st,
logger: logger,
workers: workers,
wake: make(chan struct{}, workers),
claim: db.New(st.Writer).ClaimNextJob,
handlers: make(map[string]handler),
}, nil
}
// Kind enqueues jobs of one registered kind.
type Kind[P any] struct {
r *Runner
name string
}
// Register makes h the handler for kind and returns the Kind that enqueues
// its jobs. failureCodes are the *Failure codes h may report. It must be
// called before Start, once per kind.
func Register[P any, R Public](r *Runner, kind string, h Handler[P, R], failureCodes ...string) (*Kind[P], error) {
if kind == "" {
return nil, errors.New("jobs: register: empty kind")
}
if h == nil {
return nil, fmt.Errorf("jobs: register %q: nil handler", kind)
}
declared := make(map[string]bool, len(failureCodes))
for _, code := range failureCodes {
if !codePattern.MatchString(code) {
return nil, fmt.Errorf("jobs: register %q: failure code %q is not lower-case snake case", kind, code)
}
if code == CodeInternal || code == CodeUnknownKind {
return nil, fmt.Errorf("jobs: register %q: failure code %q is reserved for the runner", kind, code)
}
declared[code] = true
}
r.mu.Lock()
defer r.mu.Unlock()
if r.started {
return nil, fmt.Errorf("jobs: register %q: runner already started", kind)
}
if _, ok := r.handlers[kind]; ok {
return nil, fmt.Errorf("jobs: register %q: kind already registered", kind)
}
run := func(ctx context.Context, payload string, publish func(json.RawMessage) error) (json.RawMessage, string, string) {
p, err := decodePayload[P]([]byte(payload))
if err != nil {
return nil, CodeInternal, "payload does not decode"
}
result, err := h(ctx, p, func(partial R) error {
data, err := publicJSON(partial)
if err != nil {
return err
}
return publish(data)
})
if err != nil {
var failure *Failure
if !errors.As(err, &failure) {
return nil, CodeInternal, "handler error"
}
if !declared[failure.Code] {
return nil, CodeInternal, "undeclared failure code"
}
return nil, failure.Code, "handler failure"
}
data, err := publicJSON(result)
if err != nil {
return nil, CodeInternal, "result does not encode"
}
return data, "", ""
}
check := func(payload []byte) bool {
_, err := decodePayload[P](payload)
return err == nil
}
r.handlers[kind] = handler{run: run, check: check}
return &Kind[P]{r: r, name: kind}, nil
}
// decodePayload decodes payload into P, refusing unknown fields.
func decodePayload[P any](payload []byte) (P, error) {
var p P
dec := json.NewDecoder(bytes.NewReader(payload))
dec.DisallowUnknownFields()
err := dec.Decode(&p)
return p, err
}
// publicJSON returns v's PublicJSON, refusing anything that is not one JSON
// value. Its error never quotes PublicJSON's error.
func publicJSON(v Public) (json.RawMessage, error) {
data, err := v.PublicJSON()
if err != nil || !json.Valid(data) {
return nil, errors.New("jobs: result has no valid public JSON")
}
return data, nil
}
// Enqueue stores a queued job with payload and returns its id. A job
// enqueued before Start waits for it.
func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) {
data, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
}
return k.r.enqueue(ctx, k.name, data)
}
// Enqueue stores a queued job of kind with its JSON payload and returns its
// id, as Kind.Enqueue does for a kind known at compile time. It returns
// ErrUnknownKind for a kind with no registered handler and
// ErrInvalidPayload for a payload that does not decode into the kind's
// payload type; neither stores a job.
func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) {
r.mu.Lock()
h, ok := r.handlers[kind]
r.mu.Unlock()
if !ok {
return "", ErrUnknownKind
}
if !h.check(payload) {
return "", ErrInvalidPayload
}
return r.enqueue(ctx, kind, payload)
}
// enqueue stores a queued job of kind with its encoded payload and wakes a
// worker.
func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) {
id := store.NewID()
now := store.Now().String()
if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{
ID: id,
Kind: kind,
Payload: string(payload),
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err)
}
select {
case r.wake <- struct{}{}:
default: // every worker already has a token and will look again
}
return id, nil
}
// Start fails every job a previous process left running with
// CodeInterrupted, then starts the workers. They stop when ctx is cancelled;
// Wait waits for them. A job whose handler returns an error after ctx is
// cancelled is left running, so the next Start fails it as interrupted.
func (r *Runner) Start(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.started {
return errors.New("jobs: runner already started")
}
now := store.Now().String()
n, err := db.New(r.st.Writer).FailRunningJobs(ctx, db.FailRunningJobsParams{
Error: sql.NullString{String: CodeInterrupted, Valid: true},
Now: sql.NullString{String: now, Valid: true},
})
if err != nil {
return fmt.Errorf("jobs: fail interrupted jobs: %w", err)
}
if n > 0 {
r.logger.WarnContext(ctx, "failed jobs interrupted by restart", "count", n)
}
r.started = true
for range r.workers {
r.wg.Add(1)
go r.work(ctx)
}
return nil
}
// Wait blocks until every worker has stopped.
func (r *Runner) Wait() {
r.wg.Wait()
}
// Get returns the job with id, or ErrNotFound.
func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
row, err := db.New(r.st.Reader).GetJob(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return Job{}, ErrNotFound
}
if err != nil {
return Job{}, fmt.Errorf("jobs: read job: %w", err)
}
job := Job{ID: row.ID, Kind: row.Kind, Status: Status(row.Status)}
if row.Result.Valid {
job.Result = json.RawMessage(row.Result.String)
}
if row.Error.Valid {
job.Error = &row.Error.String
}
return job, nil
}
// work claims and runs queued jobs until ctx is cancelled, waiting for a
// wake token whenever none is queued. A claim that fails with a database
// error is retried with backoff rather than waiting for a token, so a job
// already queued is not stranded by a transient error.
func (r *Runner) work(ctx context.Context) {
defer r.wg.Done()
backoff := claimRetryMin
for ctx.Err() == nil {
job, err := r.claim(ctx, sql.NullString{String: store.Now().String(), Valid: true})
switch {
case err == nil:
backoff = claimRetryMin
r.run(ctx, job)
continue
case errors.Is(err, sql.ErrNoRows):
backoff = claimRetryMin
select {
case <-r.wake:
case <-ctx.Done():
}
continue
case ctx.Err() != nil:
return
}
r.logger.ErrorContext(ctx, "claim job failed; retrying", "reason", "database error", "retry_in", backoff)
timer := time.NewTimer(backoff)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
}
backoff = min(2*backoff, claimRetryMax)
}
}
// run runs a claimed job to completion. The final write is made even when
// ctx has been cancelled, so a handler that finishes during shutdown is
// recorded.
func (r *Runner) run(ctx context.Context, job db.Job) {
write := context.WithoutCancel(ctx)
logger := r.logger.With("job_id", job.ID, "kind", job.Kind)
q := db.New(r.st.Writer)
h, ok := r.handlers[job.Kind] // read-only once started
if !ok {
logger.ErrorContext(ctx, "job failed", "code", CodeUnknownKind, "reason", "no handler registered for the kind")
r.fail(write, logger, job.ID, CodeUnknownKind)
return
}
publish := func(partial json.RawMessage) error {
n, err := q.PublishJobResult(ctx, db.PublishJobResultParams{
Result: sql.NullString{String: string(partial), Valid: true},
UpdatedAt: store.Now().String(),
ID: job.ID,
})
if err != nil {
return fmt.Errorf("jobs: publish result: %w", err)
}
if n != 1 {
return fmt.Errorf("jobs: publish result: job %s is not running", job.ID)
}
return nil
}
result, code, reason := h.run(ctx, job.Payload, publish)
if code != "" {
if ctx.Err() != nil {
logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start")
return
}
level := slog.LevelInfo
if code == CodeInternal {
level = slog.LevelError
}
logger.Log(ctx, level, "job failed", "code", code, "reason", reason)
r.fail(write, logger, job.ID, code)
return
}
n, err := q.SucceedJob(write, db.SucceedJobParams{
Result: sql.NullString{String: string(result), Valid: true},
Now: sql.NullString{String: store.Now().String(), Valid: true},
ID: job.ID,
})
if err == nil && n != 1 {
err = errors.New("job is not running")
}
if err != nil {
logger.ErrorContext(write, "record job success", "error", err)
}
}
// fail records the job as failed with code. The record errors it logs come
// from the database, never from a handler.
func (r *Runner) fail(ctx context.Context, logger *slog.Logger, id, code string) {
n, err := db.New(r.st.Writer).FailJob(ctx, db.FailJobParams{
Error: sql.NullString{String: code, Valid: true},
Now: sql.NullString{String: store.Now().String(), Valid: true},
ID: id,
})
if err == nil && n != 1 {
err = errors.New("job is not running")
}
if err != nil {
logger.ErrorContext(ctx, "record job failure", "error", err)
}
}
+606
View File
@@ -0,0 +1,606 @@
package jobs
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// sentinelSecret stands in for a credential a handler holds while it runs.
const sentinelSecret = "sk-sentinel-5f2c9a"
type countPayload struct {
Steps int `json:"steps"`
}
type countResult struct {
Done int `json:"done"`
}
func (c countResult) PublicJSON() ([]byte, error) { return json.Marshal(c) }
func migratedStore(t *testing.T) *store.Store {
t.Helper()
ctx := context.Background()
st, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
return st
}
func newRunner(t *testing.T, st *store.Store, workers int) *Runner {
t.Helper()
return newRunnerLogging(t, st, workers, io.Discard)
}
func newRunnerLogging(t *testing.T, st *store.Store, workers int, w io.Writer) *Runner {
t.Helper()
r, err := NewRunner(st, slog.New(slog.NewTextHandler(w, nil)), workers)
if err != nil {
t.Fatal(err)
}
return r
}
// logBuffer collects log output written by concurrent workers.
type logBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *logBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *logBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// start starts r and stops it when the test ends. The returned function
// stops it early and waits for its workers.
func start(t *testing.T, r *Runner) (stop func()) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
if err := r.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
stop = func() {
cancel()
r.Wait()
}
t.Cleanup(stop)
return stop
}
func register[P any, R Public](t *testing.T, r *Runner, kind string, h Handler[P, R], failureCodes ...string) *Kind[P] {
t.Helper()
k, err := Register(r, kind, h, failureCodes...)
if err != nil {
t.Fatal(err)
}
return k
}
func enqueue[P any](t *testing.T, k *Kind[P], payload P) string {
t.Helper()
id, err := k.Enqueue(context.Background(), payload)
if err != nil {
t.Fatal(err)
}
return id
}
// waitFor polls Get until done accepts the job, failing after five seconds.
func waitFor(t *testing.T, r *Runner, id string, done func(Job) bool) Job {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
job, err := r.Get(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if done(job) {
return job
}
if time.Now().After(deadline) {
t.Fatalf("job %s never reached the expected state; last %+v (result %s)", id, job, job.Result)
}
time.Sleep(5 * time.Millisecond)
}
}
func finished(j Job) bool { return j.Status == StatusSucceeded || j.Status == StatusFailed }
// rawRow returns every column of the job's row joined, for leak checks.
func rawRow(t *testing.T, st *store.Store, id string) string {
t.Helper()
var cols [10]any
ptrs := make([]any, len(cols))
for i := range cols {
ptrs[i] = &cols[i]
}
if err := st.Reader.QueryRow("SELECT * FROM jobs WHERE id = ?", id).Scan(ptrs...); err != nil {
t.Fatal(err)
}
return fmt.Sprint(cols[:]...)
}
func TestJobSucceeds(t *testing.T) {
t.Parallel()
st := migratedStore(t)
r := newRunner(t, st, 2)
count := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
return countResult{Done: p.Steps}, nil
})
start(t, r)
id := enqueue(t, count, countPayload{Steps: 3})
job := waitFor(t, r, id, finished)
if job.ID != id || job.Kind != "count" || job.Status != StatusSucceeded || job.Error != nil {
t.Fatalf("job = %+v, want succeeded count job %s with no error", job, id)
}
if string(job.Result) != `{"done":3}` {
t.Errorf("result = %s, want {\"done\":3}", job.Result)
}
var startedAt, finishedAt, createdAt, updatedAt, payload string
if err := st.Reader.QueryRow("SELECT started_at, finished_at, created_at, updated_at, payload FROM jobs WHERE id = ?", id).
Scan(&startedAt, &finishedAt, &createdAt, &updatedAt, &payload); err != nil {
t.Fatal(err)
}
for name, v := range map[string]string{"started_at": startedAt, "finished_at": finishedAt, "created_at": createdAt, "updated_at": updatedAt} {
var ts store.Timestamp
if err := ts.Scan(v); err != nil {
t.Errorf("%s = %q: %v", name, v, err)
}
}
if payload != `{"steps":3}` {
t.Errorf("payload = %s, want {\"steps\":3}", payload)
}
}
func TestJobFailsWithFailureCode(t *testing.T) {
t.Parallel()
st := migratedStore(t)
r := newRunner(t, st, 1)
k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{}, fmt.Errorf("suggest: %w", &Failure{Code: "no_regulars"})
}, "no_regulars")
start(t, r)
job := waitFor(t, r, enqueue(t, k, countPayload{}), finished)
if job.Status != StatusFailed || job.Error == nil || *job.Error != "no_regulars" {
t.Fatalf("job = %+v, want failed with code no_regulars", job)
}
if job.Result != nil {
t.Errorf("result = %s, want none", job.Result)
}
var stored string
if err := st.Reader.QueryRow("SELECT error FROM jobs WHERE id = ?", job.ID).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != "no_regulars" {
t.Errorf("stored error = %q, want only the code", stored)
}
}
func TestUndeclaredFailureCodeIsInternal(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 1)
k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{}, &Failure{Code: "not_declared"}
}, "no_regulars")
start(t, r)
job := waitFor(t, r, enqueue(t, k, countPayload{}), finished)
if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeInternal {
t.Fatalf("job = %+v, want failed with %q", job, CodeInternal)
}
}
// credential holds a secret the way config.Secret does: unexported, so
// encoding/json cannot see it.
type credential struct{ value string }
func (credential) String() string { return "[REDACTED]" }
// leakyResult holds a secret in exported and unexported fields; only
// Dinners is public.
type leakyResult struct {
Dinners []string
Key credential
Token string
apiKey string
}
func (l leakyResult) PublicJSON() ([]byte, error) {
return json.Marshal(struct {
Dinners []string `json:"dinners"`
}{l.Dinners})
}
// badResult has no valid public JSON; its encoding error quotes a secret.
type badResult struct{}
func (badResult) PublicJSON() ([]byte, error) {
return nil, fmt.Errorf("encode with key %s", sentinelSecret)
}
// TestSecretsNeverStoredOrLogged proves a handler holding a secret cannot
// surface it in the jobs row, Get's result or the log through its result,
// partial results, *Failure, error text or result encoding error.
func TestSecretsNeverStoredOrLogged(t *testing.T) {
t.Parallel()
st := migratedStore(t)
var logs logBuffer
r := newRunnerLogging(t, st, 1, &logs)
secretResult := leakyResult{Dinners: []string{"Tacos"}, Key: credential{sentinelSecret}, Token: sentinelSecret, apiKey: sentinelSecret}
typed := register(t, r, "typed", func(_ context.Context, _ countPayload, publish func(leakyResult) error) (leakyResult, error) {
if err := publish(secretResult); err != nil {
return leakyResult{}, err
}
return secretResult, nil
})
leakyError := register(t, r, "leaky-error", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
return leakyResult{}, fmt.Errorf("GET https://api.example.com/v1?key=%s: 401 %s", sentinelSecret, sentinelSecret)
})
leakyFailure := register(t, r, "leaky-failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
return leakyResult{}, &Failure{Code: sentinelSecret}
}, "quota_exceeded")
failure := register(t, r, "failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
return leakyResult{}, fmt.Errorf("call with key %s: %w", sentinelSecret, &Failure{Code: "quota_exceeded"})
}, "quota_exceeded")
badEncoding := register(t, r, "bad-encoding", func(context.Context, countPayload, func(badResult) error) (badResult, error) {
return badResult{}, nil
})
stop := start(t, r)
cases := []struct {
kind *Kind[countPayload]
wantStatus Status
wantResult string
wantError string
}{
{typed, StatusSucceeded, `{"dinners":["Tacos"]}`, ""},
{leakyError, StatusFailed, "", CodeInternal},
{leakyFailure, StatusFailed, "", CodeInternal},
{failure, StatusFailed, "", "quota_exceeded"},
{badEncoding, StatusFailed, "", CodeInternal},
}
var ids []string
for _, c := range cases {
job := waitFor(t, r, enqueue(t, c.kind, countPayload{}), finished)
ids = append(ids, job.ID)
gotError := ""
if job.Error != nil {
gotError = *job.Error
}
if job.Status != c.wantStatus || string(job.Result) != c.wantResult || gotError != c.wantError {
t.Errorf("%s job = %+v (result %s), want %s with result %q and error %q", job.Kind, job, job.Result, c.wantStatus, c.wantResult, c.wantError)
}
body, err := json.Marshal(job)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(body), sentinelSecret) {
t.Errorf("%s job as served contains the secret: %s", job.Kind, body)
}
}
stop()
for _, id := range ids {
if row := rawRow(t, st, id); strings.Contains(row, sentinelSecret) {
t.Errorf("job %s row contains the secret: %s", id, row)
}
}
if strings.Contains(logs.String(), sentinelSecret) {
t.Errorf("log contains the secret:\n%s", logs.String())
}
if !strings.Contains(logs.String(), "code="+CodeInternal) {
t.Errorf("log does not record the internal_error code:\n%s", logs.String())
}
}
func TestProgressiveResultsVisibleMidRun(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 1)
release := make(chan struct{})
k := register(t, r, "draft", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
for i := 1; i < p.Steps; i++ {
if err := publish(countResult{Done: i}); err != nil {
return countResult{}, err
}
select {
case <-release:
case <-ctx.Done():
return countResult{}, ctx.Err()
}
}
return countResult{Done: p.Steps}, nil
})
start(t, r)
id := enqueue(t, k, countPayload{Steps: 3})
for step := 1; step < 3; step++ {
want := fmt.Sprintf(`{"done":%d}`, step)
job := waitFor(t, r, id, func(j Job) bool { return string(j.Result) == want })
if job.Status != StatusRunning || job.Error != nil {
t.Fatalf("mid-run job = %+v, want running with no error", job)
}
release <- struct{}{}
}
job := waitFor(t, r, id, finished)
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
}
}
func TestRestartFailsInterruptedJobsAndRunsQueuedOnes(t *testing.T) {
t.Parallel()
st := migratedStore(t)
first := newRunner(t, st, 1)
started := make(chan struct{})
k := register(t, first, "slow", func(ctx context.Context, _ countPayload, publish func(countResult) error) (countResult, error) {
if err := publish(countResult{Done: 1}); err != nil {
return countResult{}, err
}
close(started)
<-ctx.Done()
return countResult{}, ctx.Err()
})
stop := start(t, first)
interrupted := enqueue(t, k, countPayload{})
<-started
waiting := enqueue(t, k, countPayload{}) // the only worker is busy
stop()
if job := waitFor(t, first, interrupted, func(Job) bool { return true }); job.Status != StatusRunning {
t.Fatalf("after shutdown job = %+v, want left running", job)
}
second := newRunner(t, st, 1)
register(t, second, "slow", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{Done: 9}, nil
})
start(t, second)
job := waitFor(t, second, interrupted, finished)
if job.Status != StatusFailed || job.Error == nil || *job.Error != "interrupted by restart" {
t.Errorf("interrupted job = %+v, want failed with %q", job, "interrupted by restart")
}
if string(job.Result) != `{"done":1}` {
t.Errorf("interrupted job result = %s, want the last partial result kept", job.Result)
}
if job := waitFor(t, second, waiting, finished); job.Status != StatusSucceeded {
t.Errorf("queued job = %+v, want run by the new runner", job)
}
}
func TestWorkerPoolIsBounded(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 2)
var running, peak atomic.Int32
release := make(chan struct{})
k := register(t, r, "busy", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
n := running.Add(1)
for {
p := peak.Load()
if n <= p || peak.CompareAndSwap(p, n) {
break
}
}
<-release
running.Add(-1)
return countResult{}, nil
})
start(t, r)
ids := make([]string, 5)
for i := range ids {
ids[i] = enqueue(t, k, countPayload{})
}
deadline := time.Now().Add(5 * time.Second)
for running.Load() < 2 {
if time.Now().After(deadline) {
t.Fatal("two workers never ran at once")
}
time.Sleep(5 * time.Millisecond)
}
close(release)
for _, id := range ids {
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded {
t.Errorf("job = %+v, want succeeded", job)
}
}
if got := peak.Load(); got != 2 {
t.Errorf("peak concurrent jobs = %d, want 2", got)
}
}
func TestJobWithoutHandlerFails(t *testing.T) {
t.Parallel()
st := migratedStore(t)
old := newRunner(t, st, 1)
k := register(t, old, "retired", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{}, nil
})
id := enqueue(t, k, countPayload{}) // never started
r := newRunner(t, st, 1)
start(t, r)
job := waitFor(t, r, id, finished)
if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeUnknownKind {
t.Errorf("job = %+v, want failed for a missing handler", job)
}
}
func TestGetUnknownID(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 1)
if _, err := r.Get(context.Background(), store.NewID()); !errors.Is(err, ErrNotFound) {
t.Errorf("Get unknown id error = %v, want ErrNotFound", err)
}
}
func TestRegisterRefusesBadRegistrations(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 1)
h := func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{}, nil
}
register(t, r, "count", h)
if _, err := Register(r, "count", h); err == nil {
t.Error("duplicate kind registered")
}
if _, err := Register(r, "", h); err == nil {
t.Error("empty kind registered")
}
if _, err := Register[countPayload, countResult](r, "nil", nil); err == nil {
t.Error("nil handler registered")
}
for _, code := range []string{"", "Bad Code", "sk-sentinel", CodeInternal, CodeInterrupted, CodeUnknownKind} {
if _, err := Register(r, "code", h, code); err == nil {
t.Errorf("failure code %q accepted", code)
}
}
start(t, r)
if _, err := Register(r, "late", h); err == nil {
t.Error("kind registered after Start")
}
if err := r.Start(context.Background()); err == nil {
t.Error("second Start succeeded")
}
}
func TestNewRunnerRefusesBadConfig(t *testing.T) {
t.Parallel()
st := migratedStore(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRunner(st, logger, 0); err == nil {
t.Error("zero workers accepted")
}
if _, err := NewRunner(nil, logger, 1); err == nil {
t.Error("nil store accepted")
}
if _, err := NewRunner(st, nil, 1); err == nil {
t.Error("nil logger accepted")
}
}
func TestNoopSucceeds(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 1)
builtins, err := RegisterBuiltins(r)
if err != nil {
t.Fatal(err)
}
start(t, r)
job := waitFor(t, r, enqueue(t, builtins.Noop, NoopPayload{}), finished)
if job.Kind != KindNoop || job.Status != StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil {
t.Errorf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result)
}
}
func TestClaimErrorIsRetried(t *testing.T) {
st := migratedStore(t)
logs := &logBuffer{}
r := newRunnerLogging(t, st, 1, logs)
k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
return countResult{Done: p.Steps}, nil
})
id := enqueue(t, k, countPayload{Steps: 3})
// Drop the wake token, as for a job queued before a restart, so only
// the retry can find the job.
<-r.wake
var claims atomic.Int32
claim := r.claim
r.claim = func(ctx context.Context, now sql.NullString) (db.Job, error) {
if claims.Add(1) == 1 {
return db.Job{}, errors.New("database is locked " + sentinelSecret)
}
return claim(ctx, now)
}
start(t, r)
job := waitFor(t, r, id, finished)
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
}
if n := claims.Load(); n < 2 {
t.Fatalf("claims = %d, want a retry after the error", n)
}
out := logs.String()
if !strings.Contains(out, "claim job failed; retrying") {
t.Fatalf("claim error not logged: %s", out)
}
if strings.Contains(out, sentinelSecret) {
t.Fatalf("claim error text logged: %s", out)
}
}
func TestRunnerEnqueueByKindName(t *testing.T) {
t.Parallel()
st := migratedStore(t)
r := newRunner(t, st, 1)
register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
return countResult{Done: p.Steps}, nil
})
ctx := context.Background()
if _, err := r.Enqueue(ctx, "no_such_kind", json.RawMessage(`{}`)); !errors.Is(err, ErrUnknownKind) {
t.Errorf("unknown kind error = %v, want ErrUnknownKind", err)
}
for _, payload := range []string{`{"steps":"three"}`, `{"other":1}`, `[]`, ``} {
if _, err := r.Enqueue(ctx, "count", json.RawMessage(payload)); !errors.Is(err, ErrInvalidPayload) {
t.Errorf("payload %q error = %v, want ErrInvalidPayload", payload, err)
}
}
var n int
if err := st.Reader.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("refused enqueues stored %d jobs, want 0", n)
}
id, err := r.Enqueue(ctx, "count", json.RawMessage(`{"steps":3}`))
if err != nil {
t.Fatal(err)
}
start(t, r)
job := waitFor(t, r, id, finished)
if job.Kind != "count" || job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
}
}
+107
View File
@@ -0,0 +1,107 @@
package server
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
)
// maxJobBody bounds a POST /jobs request body.
const maxJobBody = 64 << 10
// jobRequest is the POST /jobs body. Payload is required; a job kind that
// takes no input is sent the empty object {}.
type jobRequest struct {
Kind string `json:"kind"`
Payload json.RawMessage `json:"payload"`
}
// jobCreated is the POST /jobs response.
type jobCreated struct {
ID string `json:"id"`
}
// postJob serves POST /api/v1/jobs: it enqueues a job of a registered kind
// and returns 202 with its id, which GET /api/v1/jobs/{id} reports on.
func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
req, err := decodeJobRequest(http.MaxBytesReader(w, r.Body, maxJobBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
id, err := runner.Enqueue(r.Context(), req.Kind, req.Payload)
switch {
case errors.Is(err, jobs.ErrUnknownKind):
writeError(w, r, logger, http.StatusBadRequest, "unknown_kind", "unknown job kind")
return
case errors.Is(err, jobs.ErrInvalidPayload):
writeError(w, r, logger, http.StatusBadRequest, "invalid_payload", "payload does not match the job kind")
return
case err != nil:
logger.ErrorContext(r.Context(), "enqueue job", "kind", req.Kind, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusAccepted, jobCreated{ID: id})
}
}
// decodeJobRequest reads a single JSON object with a non-empty kind and a
// payload, refusing unknown fields, an omitted or null payload and trailing
// data.
func decodeJobRequest(body io.Reader) (jobRequest, error) {
data, err := io.ReadAll(body)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return jobRequest{}, fmt.Errorf("request body exceeds %d bytes", tooLarge.Limit)
}
return jobRequest{}, errors.New("cannot read request body")
}
var req jobRequest
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
return jobRequest{}, errors.New("request body must be a JSON object with kind and payload")
}
if _, err := dec.Token(); err != io.EOF {
return jobRequest{}, errors.New("request body must be a single JSON object")
}
if req.Kind == "" {
return jobRequest{}, errors.New("kind: required")
}
if req.Payload == nil {
return jobRequest{}, errors.New("payload: required")
}
if bytes.Equal(req.Payload, []byte("null")) {
return jobRequest{}, errors.New("payload: must not be null")
}
return req, nil
}
// getJob serves GET /api/v1/jobs/{id}: the job's id, kind, status, latest
// result and error.
func getJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
job, err := runner.Get(r.Context(), chi.URLParam(r, "id"))
if errors.Is(err, jobs.ErrNotFound) {
writeError(w, r, logger, http.StatusNotFound, "not_found", "job not found")
return
}
if err != nil {
logger.ErrorContext(r.Context(), "get job", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusOK, job)
}
}
+246
View File
@@ -0,0 +1,246 @@
package server
import (
"context"
"encoding/json"
"maps"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
type draftPayload struct{}
type draftResult struct {
Days int `json:"days"`
}
func (d draftResult) PublicJSON() ([]byte, error) { return json.Marshal(d) }
// startedRunner registers a "draft" kind that publishes {"days":1}, waits
// for release and returns {"days":7}, or fails with a *jobs.Failure when
// fail is closed first. It starts the runner and stops it when the test ends.
func startedRunner(t *testing.T, release, fail <-chan struct{}) (*jobs.Runner, *jobs.Kind[draftPayload]) {
t.Helper()
runner := newTestRunner(t)
kind, err := jobs.Register(runner, "draft", func(ctx context.Context, _ draftPayload, publish func(draftResult) error) (draftResult, error) {
if err := publish(draftResult{Days: 1}); err != nil {
return draftResult{}, err
}
select {
case <-release:
return draftResult{Days: 7}, nil
case <-fail:
return draftResult{}, &jobs.Failure{Code: "no_regulars"}
case <-ctx.Done():
return draftResult{}, ctx.Err()
}
}, "no_regulars")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
if err := runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
runner.Wait()
})
return runner, kind
}
// pollJob GETs the job until want accepts the body, failing after five
// seconds.
func pollJob(t *testing.T, h http.Handler, id string, want func(map[string]any) bool) map[string]any {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/jobs/"+id, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
}
if want(body) {
if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"error", "id", "kind", "result", "status"}) {
t.Errorf("body fields = %v, want id, kind, status, result, error", keys)
}
return body
}
if time.Now().After(deadline) {
t.Fatalf("job %s never reached the expected state; last %v", id, body)
}
time.Sleep(5 * time.Millisecond)
}
}
func resultDays(body map[string]any) float64 {
result, _ := body["result"].(map[string]any)
days, _ := result["days"].(float64)
return days
}
func TestGetJobShowsProgressThenResult(t *testing.T) {
t.Parallel()
release := make(chan struct{})
runner, kind := startedRunner(t, release, nil)
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
id, err := kind.Enqueue(context.Background(), draftPayload{})
if err != nil {
t.Fatal(err)
}
mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 })
if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil {
t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id)
}
close(release)
done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" })
if resultDays(done) != 7 || done["error"] != nil {
t.Errorf("finished body = %v, want result {\"days\":7} and no error", done)
}
}
func TestGetJobShowsFailure(t *testing.T) {
t.Parallel()
fail := make(chan struct{})
close(fail)
runner, kind := startedRunner(t, nil, fail)
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
id, err := kind.Enqueue(context.Background(), draftPayload{})
if err != nil {
t.Fatal(err)
}
body := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "failed" })
if body["error"] != "no_regulars" {
t.Errorf("error = %v, want the Failure code", body["error"])
}
}
func TestGetJobUnknownIDIsNotFound(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
rec, _ := serve(t, router, signedIn(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
var body errorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if want := (errorBody{Error: apiError{Code: "not_found", Message: "job not found"}}); body != want {
t.Errorf("body = %+v, want %+v", body, want)
}
}
func TestGetJobRequiresSession(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}
// TestPostJobThenGetProgressAndResult drives the whole flow over the
// router: POST enqueues a progressive "draft" job, GET reads its partial
// result while the handler is held mid-run, then its final result.
func TestPostJobThenGetProgressAndResult(t *testing.T) {
t.Parallel()
release := make(chan struct{})
runner, _ := startedRunner(t, release, nil)
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
rec, body := serve(t, router, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(`{"kind":"draft","payload":{}}`)))
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202: %s", rec.Code, rec.Body)
}
id, _ := body["id"].(string)
if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"id"}) || id == "" {
t.Fatalf("body = %v, want only a job id", body)
}
mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 })
if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil {
t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id)
}
close(release)
done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" })
if done["id"] != id || resultDays(done) != 7 || done["error"] != nil {
t.Errorf("finished body = %v, want result {\"days\":7} and no error", done)
}
}
func TestPostJobRefusesBadRequests(t *testing.T) {
t.Parallel()
runner, _ := startedRunner(t, nil, nil)
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
for _, tc := range []struct {
name, body, code, message string
}{
{"unknown kind", `{"kind":"no_such_kind","payload":{}}`, "unknown_kind", "unknown job kind"},
{"payload for another type", `{"kind":"draft","payload":{"days":3}}`, "invalid_payload", "payload does not match the job kind"},
{"payload not an object", `{"kind":"draft","payload":[1]}`, "invalid_payload", "payload does not match the job kind"},
{"missing payload", `{"kind":"draft"}`, "invalid_request", "payload: required"},
{"null payload", `{"kind":"draft","payload":null}`, "invalid_request", "payload: must not be null"},
{"missing kind", `{"payload":{}}`, "invalid_request", "kind: required"},
{"empty body", ``, "invalid_request", "request body must be a JSON object with kind and payload"},
{"not an object", `["draft"]`, "invalid_request", "request body must be a JSON object with kind and payload"},
{"unknown field", `{"kind":"draft","payload":{},"extra":1}`, "invalid_request", "request body must be a JSON object with kind and payload"},
{"trailing data", `{"kind":"draft","payload":{}} {}`, "invalid_request", "request body must be a single JSON object"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
router.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(tc.body)))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body)
}
var body errorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if want := (errorBody{Error: apiError{Code: tc.code, Message: tc.message}}); body != want {
t.Errorf("body = %+v, want %+v", body, want)
}
})
}
}
func TestPostJobNeedsSessionAndCSRF(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
const body = `{"kind":"noop","payload":{}}`
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body)))
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
t.Errorf("no session: %d %s, want 401 %s", rec.Code, rec.Body, unauthenticatedJSON)
}
for _, header := range []string{"", "wrong-token"} {
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body))
req.AddCookie(validSessionCookie)
if header != "" {
req.Header.Set(CSRFHeader, header)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || rec.Body.String() != csrfJSON {
t.Errorf("CSRF header %q: %d %s, want 403 %s", header, rec.Code, rec.Body, csrfJSON)
}
}
}
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+9 -2
View File
@@ -13,6 +13,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
)
// reservedPrefixes are owned by the API router and are never answered with the
@@ -28,13 +29,17 @@ type RouteMounter interface {
// has its client address and scheme resolved, honouring forwarded headers
// only from a peer in trusted (see resolveOrigin). version is reported by
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
// /media; settings serves /api/v1/settings/household; assets is the web build
// /media; settings serves /api/v1/settings/household; runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, assets fs.FS) (http.Handler, error) {
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, runner *jobs.Runner, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -63,6 +68,8 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Get("/me", me(logger))
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
r.Route("/api", func(r chi.Router) {
r.MethodNotAllowed(methodNotAllowed)
r.NotFound(notFound)
+36 -8
View File
@@ -15,6 +15,7 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
@@ -27,17 +28,17 @@ const (
func newTestRouter(t *testing.T, version string) http.Handler {
t.Helper()
return newTestRouterWith(t, version, newTestSettings(t))
return newTestRouterWith(t, version, newTestSettings(t), newTestRunner(t))
}
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService) http.Handler {
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
assets := fstest.MapFS{
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, runner, assets)
if err != nil {
t.Fatal(err)
}
@@ -70,9 +71,8 @@ func TestAuthRoutesMounted(t *testing.T) {
}
}
// newTestSettings returns a settings service over a migrated temp database
// seeded with zone America/New_York.
func newTestSettings(t *testing.T) *household.SettingsService {
// newTestStore returns a migrated temp database, closed when the test ends.
func newTestStore(t *testing.T) *store.Store {
t.Helper()
ctx := context.Background()
st, err := store.Open(ctx, t.TempDir())
@@ -87,21 +87,49 @@ func newTestSettings(t *testing.T) *household.SettingsService {
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
return st
}
// newTestSettings returns a settings service over a migrated temp database
// seeded with zone America/New_York.
func newTestSettings(t *testing.T) *household.SettingsService {
t.Helper()
st := newTestStore(t)
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
t.Fatal(err)
}
return household.NewSettingsService(st)
}
// newTestRunner returns an unstarted job runner over a migrated temp
// database.
func newTestRunner(t *testing.T) *jobs.Runner {
t.Helper()
runner, err := jobs.NewRunner(newTestStore(t), slog.New(slog.NewTextHandler(io.Discard, nil)), 1)
if err != nil {
t.Fatal(err)
}
return runner
}
func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestRunner(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
func TestHealth(t *testing.T) {
t.Parallel()
+3 -3
View File
@@ -84,7 +84,7 @@ func TestGetHouseholdSettings(t *testing.T) {
func TestPatchHouseholdSettings(t *testing.T) {
t.Parallel()
settings := newTestSettings(t)
router := newTestRouterWith(t, "dev", settings)
router := newTestRouterWith(t, "dev", settings, newTestRunner(t))
var notified []household.Settings
settings.Subscribe(func(s household.Settings) { notified = append(notified, s) })
@@ -146,7 +146,7 @@ func TestPatchHouseholdSettingsRejected(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
settings := newTestSettings(t)
router := newTestRouterWith(t, "dev", settings)
router := newTestRouterWith(t, "dev", settings, newTestRunner(t))
notified := 0
settings.Subscribe(func(household.Settings) { notified++ })
@@ -216,7 +216,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+51 -4
View File
@@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got)
}
}
@@ -196,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got)
}
if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
@@ -262,3 +262,50 @@ func TestMigrateReopenSameDataDirIsNoOp(t *testing.T) {
t.Errorf("schema changed on reopen:\n got %v\nwant %v", afterSchema, beforeSchema)
}
}
// TestMigrateJobsStatusMapsLegacyRows upgrades a database holding jobs in
// every 0001 status and checks 0003 carries each row over with its status
// mapped and every other column unchanged.
func TestMigrateJobsStatusMapsLegacyRows(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
before := fstest.MapFS{}
for _, name := range []string{"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql"} {
body, err := fs.ReadFile(migrations.FS, name)
if err != nil {
t.Fatal(err)
}
before[name] = &fstest.MapFile{Data: body}
}
if err := Migrate(ctx, s.Writer, before); err != nil {
t.Fatal(err)
}
legacy := map[string]string{"pending": "queued", "running": "running", "done": "succeeded", "failed": "failed"}
for old := range legacy {
if _, err := s.Writer.Exec(`INSERT INTO jobs (id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at)
VALUES (?, 'suggest', ?, '{"n":1}', '{"r":2}', 'boom', 's', 'f', 'c', 'u')`, "job-"+old, old); err != nil {
t.Fatalf("insert %s job: %v", old, err)
}
}
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
for old, want := range legacy {
var kind, status, payload, result, errText, startedAt, finishedAt, createdAt, updatedAt string
if err := s.Reader.QueryRow(`SELECT kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs WHERE id = ?`, "job-"+old).
Scan(&kind, &status, &payload, &result, &errText, &startedAt, &finishedAt, &createdAt, &updatedAt); err != nil {
t.Fatalf("read %s job: %v", old, err)
}
if status != want {
t.Errorf("%s job status = %q, want %q", old, status, want)
}
got := []string{kind, payload, result, errText, startedAt, finishedAt, createdAt, updatedAt}
if wantCols := []string{"suggest", `{"n":1}`, `{"r":2}`, "boom", "s", "f", "c", "u"}; !slices.Equal(got, wantCols) {
t.Errorf("%s job columns = %v, want %v", old, got, wantCols)
}
}
if tableExists(t, s, "jobs_new") {
t.Error("jobs_new left behind")
}
}
+2 -2
View File
@@ -165,7 +165,7 @@ func testRow(table string, i int) map[string]any {
r["attempts"] = 0
case "jobs":
r["kind"] = "suggest"
r["status"] = "pending"
r["status"] = "queued"
r["payload"] = "{}"
case "schedule_runs":
r["schedule"] = "plan_draft"
@@ -330,7 +330,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) {
{"notification_prefs", "chat", 2},
{"notification_outbox", "channel", "email"},
{"notification_outbox", "status", "queued"},
{"jobs", "status", "queued"},
{"jobs", "status", "bogus"},
{"schedule_runs", "schedule", "hourly"},
}
s := seededStore(t)
+32
View File
@@ -0,0 +1,32 @@
-- 0003_jobs_status: jobs.status becomes queued, running, succeeded, failed,
-- the states the job runner (internal/jobs) reports. SQLite cannot change a
-- CHECK in place, so the table is rebuilt: create the new table, copy every
-- row, drop the old one and rename. Nothing references jobs and it has no
-- indexes or triggers, so the rebuild touches no other schema object. Rows
-- written under the 0001 values are mapped (pending -> queued,
-- done -> succeeded) so the copy cannot fail on an existing database.
CREATE TABLE jobs_new (
id TEXT NOT NULL PRIMARY KEY,
kind TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
payload TEXT NOT NULL, -- JSON
result TEXT, -- JSON, written progressively
error TEXT,
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO jobs_new (id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at)
SELECT
id,
kind,
CASE status WHEN 'pending' THEN 'queued' WHEN 'done' THEN 'succeeded' ELSE status END,
payload, result, error, started_at, finished_at, created_at, updated_at
FROM jobs;
DROP TABLE jobs;
ALTER TABLE jobs_new RENAME TO jobs;
+37
View File
@@ -0,0 +1,37 @@
-- name: CreateJob :exec
INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
VALUES (?, ?, 'queued', ?, ?, ?);
-- name: ClaimNextJob :one
-- Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
-- when no job is queued.
UPDATE jobs
SET status = 'running', started_at = sqlc.arg(now), updated_at = sqlc.arg(now)
WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1)
RETURNING *;
-- name: PublishJobResult :execrows
-- Stores a partial result of a running job.
UPDATE jobs
SET result = ?, updated_at = ?
WHERE id = ? AND status = 'running';
-- name: SucceedJob :execrows
UPDATE jobs
SET status = 'succeeded', result = sqlc.arg(result), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
WHERE id = sqlc.arg(id) AND status = 'running';
-- name: FailJob :execrows
UPDATE jobs
SET status = 'failed', error = sqlc.arg(error), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
WHERE id = sqlc.arg(id) AND status = 'running';
-- name: FailRunningJobs :execrows
-- Fails every job left running, at boot before any worker starts.
UPDATE jobs
SET status = 'failed', error = sqlc.arg(error), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
WHERE status = 'running';
-- name: GetJob :one
SELECT * FROM jobs
WHERE id = ?;
+4 -2
View File
@@ -5,10 +5,12 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"build": "tsc --noEmit && vite build && npm run check:dist-fonts",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run"
"test": "npm run lint:colors && vitest run",
"lint:colors": "node scripts/lint-colors.mjs",
"check:dist-fonts": "node scripts/check-dist-fonts.mjs"
},
"dependencies": {
"@tanstack/react-query": "^5.104.1",
+32
View File
@@ -0,0 +1,32 @@
// Fails when the production build references Google Fonts: fonts are
// self-hosted, so dist must never point at fonts.googleapis.com or
// fonts.gstatic.com.
import { readFileSync, readdirSync } from 'node:fs'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
const distDir = fileURLToPath(new URL('../dist', import.meta.url))
const hosts = ['fonts.googleapis.com', 'fonts.gstatic.com']
function* files(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name)
if (entry.isDirectory()) yield* files(path)
else yield path
}
}
const violations = []
for (const path of files(distDir)) {
const content = readFileSync(path, 'latin1')
for (const host of hosts) {
if (content.includes(host)) violations.push(`${relative(distDir, path)}: ${host}`)
}
}
if (violations.length > 0) {
console.error('Production build references Google Fonts; fonts must be self-hosted:')
for (const v of violations) console.error(` ${v}`)
process.exit(1)
}
console.log('check:dist-fonts: dist makes no Google Fonts requests')
+42
View File
@@ -0,0 +1,42 @@
// Fails when a hex colour literal appears in any CSS or TypeScript file under
// web/ outside the design tokens: every colour must come from a custom
// property in tokens.css. Dependencies, build output and hidden directories
// (tool caches) are not source and are skipped.
import { readFileSync, readdirSync } from 'node:fs'
import { extname, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
const webRoot = fileURLToPath(new URL('..', import.meta.url))
const allowed = new Set(['src/styles/tokens.css'])
const skippedDirs = new Set([join(webRoot, 'node_modules'), join(webRoot, 'dist')])
const extensions = new Set(['.css', '.ts', '.tsx'])
const hexColour = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g
function* sourceFiles(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name)
if (entry.isDirectory()) {
if (!skippedDirs.has(path) && !entry.name.startsWith('.')) yield* sourceFiles(path)
} else if (extensions.has(extname(entry.name))) yield path
}
}
const violations = []
for (const path of sourceFiles(webRoot)) {
const name = relative(webRoot, path).split('\\').join('/')
if (allowed.has(name)) continue
readFileSync(path, 'utf8')
.split('\n')
.forEach((line, i) => {
for (const match of line.matchAll(hexColour)) {
violations.push(`${name}:${i + 1}: ${match[0]}`)
}
})
}
if (violations.length > 0) {
console.error('Hex colour literals found; use a token from src/styles/tokens.css:')
for (const v of violations) console.error(` ${v}`)
process.exit(1)
}
console.log('lint:colors: no hex colour literals outside src/styles/tokens.css')
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Bagel Fat Project Authors (https://github.com/JAMO-TYPEFACE/BagelFat)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+2
View File
@@ -1,6 +1,8 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import './styles/tokens.css'
import './styles/base.css'
import App from './App.tsx'
const root = document.getElementById('root')
+11
View File
@@ -0,0 +1,11 @@
/* App-wide base styles. Values come only from tokens.css. */
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
}
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
+116
View File
@@ -0,0 +1,116 @@
/* MealQuest design tokens — final palette "B4: Grape + teal blue on off-white".
Built on the Organic design system (styles.css in this bundle); these values OVERRIDE its defaults. */
/* Fonts are self-hosted from web/src/assets/fonts (OFL; licence texts alongside)
in place of the handoff's Google Fonts @import, so the app never calls Google.
Bagel Fat One: Google Fonts latin and latin-ext woff2 subsets.
Figtree: upstream static woff2 (github.com/erikdkennedy/figtree). */
@font-face {
font-family: "Bagel Fat One";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../assets/fonts/BagelFatOne-LatinExt.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Bagel Fat One";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../assets/fonts/BagelFatOne-Latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Figtree";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../assets/fonts/Figtree-Regular.woff2") format("woff2");
}
@font-face {
font-family: "Figtree";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("../assets/fonts/Figtree-SemiBold.woff2") format("woff2");
}
@font-face {
font-family: "Figtree";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("../assets/fonts/Figtree-Bold.woff2") format("woff2");
}
:root {
/* Ground + ink */
--color-bg: #f9f6f1; /* oklch(0.975 0.008 85) — page / phone background */
--color-surface: #eee9de; /* oklch(0.935 0.016 85) — cards, rows, inputs */
--color-text: #201e1d;
--color-divider: color-mix(in srgb, #201e1d 16%, transparent);
--on-accent: #f9f6f1; /* text/icons on --color-accent fills (never dark text on grape) */
/* Primary accent — Grape (hue 300) */
--color-accent: #5a358c; /* oklch(0.42 0.14 300) */
--color-accent-100: #f8f1ff;
--color-accent-200: #eedfff;
--color-accent-300: #dac3ff;
--color-accent-400: #c1a1f9;
--color-accent-500: #a47ee1;
--color-accent-600: #865fc0;
--color-accent-700: #68449b; /* accent-colored small text on light grounds */
--color-accent-800: #4a2d73;
--color-accent-900: #2d1a49;
/* Second accent — Teal blue (hue 215) */
--color-accent-2: #29889c; /* oklch(0.58 0.09 215) */
--color-accent-2-100: #e0fbff;
--color-accent-2-200: #bbf3ff;
--color-accent-2-300: #88e0f6;
--color-accent-2-400: #3fc7e4;
--color-accent-2-500: #00a9ca;
--color-accent-2-600: #008aaa; /* toggles ON, "Try it" button, + buttons */
--color-accent-2-700: #006b88;
--color-accent-2-800: #004c63; /* teal text on teal-100/200 fills */
--color-accent-2-900: #002f3e;
/* Neutrals (unchanged from Organic) */
--color-neutral-100: #f9f4ed;
--color-neutral-200: #eee7db;
--color-neutral-300: #dcd3c4;
--color-neutral-400: #c0b6a5;
--color-neutral-500: #a19786;
--color-neutral-600: #82796a;
--color-neutral-700: #645c50;
--color-neutral-800: #474238;
--color-neutral-900: #2e2b25;
/* Type */
--font-heading: "Bagel Fat One", system-ui, sans-serif; /* weight 400 only */
--font-body: "Figtree", system-ui, sans-serif; /* 400 / 600 / 700 */
--font-mono: ui-monospace, Menlo, monospace;
/* Radii */
--radius-sm: 8px;
--radius-md: 16px;
--radius-lg: 28px;
--radius-card: 32px; /* mobile hero cards */
--radius-pill: 999px; /* buttons, inputs, tags, nav */
/* Spacing (Organic, density 1.1) */
--space-1: 4.4px; --space-2: 8.8px; --space-3: 13.2px; --space-4: 17.6px; --space-6: 26.4px; --space-8: 35.2px;
/* Elevation */
--shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent);
--shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent);
--shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);
/* Motion */
--ease-spring: cubic-bezier(.34, 1.4, .64, 1); /* nav pill, snap-back, bumps */
--ease-out-soft: cubic-bezier(.2, .8, .2, 1); /* card promotion */
--ease-throw: cubic-bezier(.55, 0, .8, .4); /* fly-off right */
}
@media (prefers-reduced-motion: reduce) {
:root { --motion-scale: 0; }
}
+50
View File
@@ -0,0 +1,50 @@
// Reads the CSS Modules' rules straight from the stylesheets Vitest injects
// (css: true). jsdom's getComputedStyle cannot answer two things these
// primitives need checked: it resolves var() colours to transparent, and it
// evaluates only "all" and "screen" media queries, so styles inside
// @media (prefers-reduced-motion: reduce) never apply.
function cssRules(): CSSRule[] {
return Array.from(document.styleSheets).flatMap((sheet) => Array.from(sheet.cssRules))
}
function collect(element: Element, rules: CSSRule[], style: CSSStyleDeclaration) {
for (const rule of rules) {
if (!(rule instanceof CSSStyleRule) || !element.matches(rule.selectorText)) continue
for (const property of Array.from(rule.style)) {
style.setProperty(property, rule.style.getPropertyValue(property))
}
}
}
// The declarations the stylesheets give the element, as written. Read the
// shorthands the CSS uses (background, animation): jsdom does not expand
// animation, nor any shorthand holding a var(). Later rules win; specificity
// is not considered.
export function declaredStyle(element: Element): CSSStyleDeclaration {
const style = document.createElement('div').style
collect(element, cssRules(), style)
return style
}
// Only the declarations @media (prefers-reduced-motion: reduce) gives the element.
export function reducedMotionStyle(element: Element): CSSStyleDeclaration {
const style = document.createElement('div').style
for (const rule of cssRules()) {
if (rule instanceof CSSMediaRule && rule.conditionText.includes('prefers-reduced-motion: reduce')) {
collect(element, Array.from(rule.cssRules), style)
}
}
return style
}
// The @keyframes the element's animation runs. CSS Modules hash keyframe
// names, so this follows the element's own animation-name.
export function animationKeyframes(element: Element): CSSKeyframesRule {
const [name] = declaredStyle(element).animation.split(' ')
const keyframes = cssRules().find(
(rule): rule is CSSKeyframesRule => rule instanceof CSSKeyframesRule && rule.name === name,
)
if (!keyframes) throw new Error(`no @keyframes named ${JSON.stringify(name)}`)
return keyframes
}
+26
View File
@@ -0,0 +1,26 @@
import { expect } from 'vitest'
// Minimum tap target from the handoff's accessibility rules.
export const MIN_HIT_AREA = 44
function px(value: string): number {
return value.endsWith('px') ? Number.parseFloat(value) : 0
}
// The tap target an element's stylesheet guarantees: the larger of its
// declared size and declared minimum size, per axis. jsdom does no layout, so
// this reads the CSS Modules' computed values (Vitest runs with css: true).
// A dimension the CSS leaves unsized counts as 0 and fails the check.
export function hitArea(element: Element): { width: number; height: number } {
const style = getComputedStyle(element)
return {
width: Math.max(px(style.width), px(style.minWidth)),
height: Math.max(px(style.height), px(style.minHeight)),
}
}
export function expectMinHitArea(element: Element) {
const { width, height } = hitArea(element)
expect(width, 'hit area width').toBeGreaterThanOrEqual(MIN_HIT_AREA)
expect(height, 'hit area height').toBeGreaterThanOrEqual(MIN_HIT_AREA)
}
+63
View File
@@ -0,0 +1,63 @@
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-width: 44px;
min-height: 48px;
padding: 0 22px;
border: 1.5px solid transparent;
border-radius: var(--radius-pill);
font-family: var(--font-heading);
font-size: 15px;
font-weight: 400;
line-height: 1;
cursor: pointer;
transition: transform 100ms var(--ease-out-soft), background-color 100ms var(--ease-out-soft);
}
.button:active:not(:disabled) {
transform: scale(0.97);
}
.button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.primary {
background: var(--color-accent);
color: var(--on-accent);
}
.primary:hover:not(:disabled),
.primary:active:not(:disabled) {
background: var(--color-accent-800);
}
.outline {
background: transparent;
border-color: var(--color-neutral-300);
color: var(--color-text);
}
.outline:hover:not(:disabled),
.outline:active:not(:disabled) {
background: var(--color-neutral-200);
}
.dark {
background: var(--color-text);
color: var(--color-bg);
}
.dark:hover:not(:disabled),
.dark:active:not(:disabled) {
background: var(--color-neutral-800);
}
@media (prefers-reduced-motion: reduce) {
.button {
transition: none;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { createRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Button } from './Button.tsx'
import styles from './Button.module.css'
describe('Button', () => {
it('is a button that defaults to type="button" and the primary variant', () => {
render(<Button>Save changes</Button>)
const button = screen.getByRole('button', { name: 'Save changes' })
expect(button).toHaveAttribute('type', 'button')
expect(button).toHaveClass(styles.button!, styles.primary!)
})
it.each(['primary', 'outline', 'dark'] as const)('renders the %s variant with a ≥ 44 px hit area', (variant) => {
render(<Button variant={variant}>Go</Button>)
const button = screen.getByRole('button', { name: 'Go' })
expect(button).toHaveClass(styles[variant]!)
expectMinHitArea(button)
})
it('declares the 100 ms pressed scale transition', () => {
render(<Button>Go</Button>)
expect(getComputedStyle(screen.getByRole('button')).transition).toContain('transform 100ms')
})
it('does not fire onClick when disabled', () => {
const onClick = vi.fn()
render(
<Button disabled onClick={onClick}>
Go
</Button>,
)
const button = screen.getByRole('button', { name: 'Go' })
expect(button).toBeDisabled()
fireEvent.click(button)
expect(onClick).not.toHaveBeenCalled()
})
it('forwards the ref, rest props and an explicit type', () => {
const ref = createRef<HTMLButtonElement>()
render(
<Button ref={ref} type="submit" aria-describedby="hint">
Send
</Button>,
)
const button = screen.getByRole('button', { name: 'Send' })
expect(ref.current).toBe(button)
expect(button).toHaveAttribute('type', 'submit')
expect(button).toHaveAttribute('aria-describedby', 'hint')
})
})
+16
View File
@@ -0,0 +1,16 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './Button.module.css'
export type ButtonVariant = 'primary' | 'outline' | 'dark'
export type ButtonProps = ComponentPropsWithRef<'button'> & {
variant?: ButtonVariant
}
// Pill button. Primary is grape with --on-accent text; outline and dark are the
// secondary treatments from the handoff. `type` defaults to "button" so a
// Button inside a form never submits by accident.
export function Button({ variant = 'primary', type = 'button', className, ...rest }: ButtonProps) {
const classes = [styles.button, styles[variant], className].filter(Boolean).join(' ')
return <button type={type} className={classes} {...rest} />
}
+13
View File
@@ -0,0 +1,13 @@
.card {
padding: var(--space-4);
border-radius: var(--radius-lg);
color: var(--color-text);
}
.surface {
background: var(--color-surface);
}
.muted {
background: var(--color-neutral-100);
}
+18
View File
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { Card } from './Card.tsx'
import styles from './Card.module.css'
describe('Card', () => {
it('renders its children on the surface fill by default', () => {
render(<Card aria-label="Tonight">Tacos</Card>)
const card = screen.getByLabelText('Tonight')
expect(card).toHaveTextContent('Tacos')
expect(card).toHaveClass(styles.card!, styles.surface!)
})
it('supports the muted tone', () => {
render(<Card data-testid="card" tone="muted" />)
expect(screen.getByTestId('card')).toHaveClass(styles.muted!)
})
})
+14
View File
@@ -0,0 +1,14 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './Card.module.css'
export type CardTone = 'surface' | 'muted'
export type CardProps = ComponentPropsWithRef<'div'> & {
// surface = --color-surface; muted = the secondary neutral-100 fill.
tone?: CardTone
}
export function Card({ tone = 'surface', className, ...rest }: CardProps) {
const classes = [styles.card, styles[tone], className].filter(Boolean).join(' ')
return <div className={classes} {...rest} />
}
+63
View File
@@ -0,0 +1,63 @@
.root {
display: inline-flex;
align-items: center;
gap: var(--space-3);
min-width: 44px;
min-height: 44px;
cursor: pointer;
}
.input {
appearance: none;
display: grid;
place-content: center;
flex: none;
width: 22px;
height: 22px;
margin: 0;
border: 2px solid var(--color-neutral-400);
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
}
/* The check mark. */
.input::before {
content: "";
width: 12px;
height: 12px;
background: var(--on-accent);
clip-path: polygon(14% 44%, 0 59%, 40% 100%, 100% 21%, 85% 7%, 39% 69%);
opacity: 0;
transform: scale(0.8);
transition: transform 160ms var(--ease-spring), opacity 160ms var(--ease-out-soft);
}
.input:checked {
border-color: var(--color-accent);
background: var(--color-accent);
}
.input:checked::before {
opacity: 1;
transform: scale(1);
}
.input:disabled,
.input:disabled + .label {
opacity: 0.45;
cursor: not-allowed;
}
.label {
font-family: var(--font-body);
font-size: 15px;
font-weight: 600;
color: var(--color-text);
}
@media (prefers-reduced-motion: reduce) {
.input::before {
transition: none;
}
}
+36
View File
@@ -0,0 +1,36 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Checkbox } from './Checkbox.tsx'
describe('Checkbox', () => {
it('is a native checkbox named by its label', () => {
render(<Checkbox label="Chicken thighs" />)
const box = screen.getByRole('checkbox', { name: 'Chicken thighs' })
expect(box.tagName).toBe('INPUT')
expect(box).toHaveAttribute('type', 'checkbox')
expect(box).not.toBeChecked()
})
it('toggles when the input or its label text is clicked', () => {
render(<Checkbox label="Limes" />)
const box = screen.getByRole('checkbox', { name: 'Limes' })
fireEvent.click(box)
expect(box).toBeChecked()
fireEvent.click(screen.getByText('Limes'))
expect(box).not.toBeChecked()
})
it('reflects a controlled checked state and disabled', () => {
render(<Checkbox label="Rice" checked disabled readOnly />)
const box = screen.getByRole('checkbox', { name: 'Rice' })
expect(box).toBeChecked()
expect(box).toBeDisabled()
})
it('has a ≥ 44 px hit area (the wrapping label)', () => {
render(<Checkbox label="Rice" />)
const hit = screen.getByRole('checkbox', { name: 'Rice' }).closest('label')!
expectMinHitArea(hit)
})
})
+17
View File
@@ -0,0 +1,17 @@
import type { ComponentPropsWithRef, ReactNode } from 'react'
import styles from './Checkbox.module.css'
export type CheckboxProps = Omit<ComponentPropsWithRef<'input'>, 'type'> & {
label: ReactNode
}
// Native checkbox, styled. The wrapping label is the hit area (at least
// 44×44), so tapping the text toggles it too.
export function Checkbox({ label, className, ...rest }: CheckboxProps) {
return (
<label className={[styles.root, className].filter(Boolean).join(' ')}>
<input type="checkbox" className={styles.input} {...rest} />
<span className={styles.label}>{label}</span>
</label>
)
}
+35
View File
@@ -0,0 +1,35 @@
.chip {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 0 14px;
border-radius: var(--radius-pill);
background: var(--color-accent-2-200);
color: var(--color-accent-2-900);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
white-space: nowrap;
}
.removable {
padding-right: 0;
}
/* 44 px hit box around a small cross; negative margins keep the chip 36 px
tall and the cross close to the label. */
.remove {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 44px;
height: 44px;
margin: -4px 0 -4px -14px;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: inherit;
cursor: pointer;
}
+26
View File
@@ -0,0 +1,26 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Chip } from './Chip.tsx'
describe('Chip', () => {
it('has no remove button unless onRemove is given', () => {
render(<Chip label="Beef tacos" />)
expect(screen.getByText('Beef tacos')).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('exposes an accessible "Remove {label}" button that calls onRemove', () => {
const onRemove = vi.fn()
render(<Chip label="Beef tacos" onRemove={onRemove} />)
const remove = screen.getByRole('button', { name: 'Remove Beef tacos' })
expect(remove).toHaveAttribute('type', 'button')
fireEvent.click(remove)
expect(onRemove).toHaveBeenCalledOnce()
})
it('gives the remove button a ≥ 44 px hit area', () => {
render(<Chip label="Pizza night" onRemove={() => {}} />)
expectMinHitArea(screen.getByRole('button', { name: 'Remove Pizza night' }))
})
})
+24
View File
@@ -0,0 +1,24 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './Chip.module.css'
export type ChipProps = Omit<ComponentPropsWithRef<'span'>, 'children'> & {
label: string
// When set, the chip shows a "Remove {label}" button that calls it.
onRemove?: () => void
}
export function Chip({ label, onRemove, className, ...rest }: ChipProps) {
const classes = [styles.chip, onRemove && styles.removable, className].filter(Boolean).join(' ')
return (
<span className={classes} {...rest}>
{label}
{onRemove && (
<button type="button" className={styles.remove} aria-label={`Remove ${label}`} onClick={onRemove}>
<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
<path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" strokeWidth="2.75" strokeLinecap="round" />
</svg>
</button>
)}
</span>
)
}
+30
View File
@@ -0,0 +1,30 @@
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-8) var(--space-4);
color: var(--color-text);
text-align: center;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 400;
line-height: 1.15;
}
.body {
margin: 0;
max-width: 32ch;
font-family: var(--font-body);
font-size: 15px;
line-height: 1.45;
color: var(--color-neutral-700);
}
.action {
margin-top: var(--space-2);
}
+27
View File
@@ -0,0 +1,27 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { EmptyState } from './EmptyState.tsx'
describe('EmptyState', () => {
it('shows a Bagel title and body without an action by default', () => {
render(<EmptyState title="Quest log is empty">Swipe Try it on a Side quest to save it here.</EmptyState>)
const title = screen.getByRole('heading', { name: 'Quest log is empty' })
expect(getComputedStyle(title).fontFamily).toBe('var(--font-heading)')
expect(screen.getByText('Swipe Try it on a Side quest to save it here.')).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('renders the optional primary action and calls its handler', () => {
const onClick = vi.fn()
render(
<EmptyState title="No Regulars yet" action={{ label: 'Add a dinner', onClick }}>
Add the dinners you make most.
</EmptyState>,
)
const action = screen.getByRole('button', { name: 'Add a dinner' })
expectMinHitArea(action)
fireEvent.click(action)
expect(onClick).toHaveBeenCalledTimes(1)
})
})
+30
View File
@@ -0,0 +1,30 @@
import type { ReactNode } from 'react'
import { Button } from './Button.tsx'
import styles from './EmptyState.module.css'
export type EmptyStateAction = {
label: string
onClick: () => void
}
export type EmptyStateProps = {
title: string
children: ReactNode
// Optional primary action, rendered as a primary Button.
action?: EmptyStateAction
}
// Bagel title, body text and an optional primary action.
export function EmptyState({ title, children, action }: EmptyStateProps) {
return (
<section className={styles.empty}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.body}>{children}</p>
{action && (
<Button className={styles.action} onClick={action.onClick}>
{action.label}
</Button>
)}
</section>
)
}
+16
View File
@@ -0,0 +1,16 @@
.banner {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--color-neutral-100);
}
.message {
flex: 1;
margin: 0;
color: var(--color-accent-700);
font-family: var(--font-body);
font-size: 15px;
font-weight: 600;
line-height: 1.35;
}
+32
View File
@@ -0,0 +1,32 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { declaredStyle } from '../test/cssRules.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { ErrorBanner } from './ErrorBanner.tsx'
describe('ErrorBanner', () => {
it('announces the message and calls onRetry from the Retry button', () => {
const onRetry = vi.fn()
render(<ErrorBanner onRetry={onRetry}>Couldn't load the Supply run.</ErrorBanner>)
expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load the Supply run.")
const retry = screen.getByRole('button', { name: 'Retry' })
expectMinHitArea(retry)
fireEvent.click(retry)
expect(onRetry).toHaveBeenCalledTimes(1)
})
it('uses a caller-supplied retry label', () => {
render(
<ErrorBanner onRetry={() => {}} retryLabel="Rebuild week">
The plan didn't save.
</ErrorBanner>,
)
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument()
})
it('is a neutral-100 card with grape-700 text', () => {
render(<ErrorBanner onRetry={() => {}}>Offline</ErrorBanner>)
expect(declaredStyle(screen.getByRole('alert')).background).toBe('var(--color-neutral-100)')
expect(declaredStyle(screen.getByText('Offline')).color).toBe('var(--color-accent-700)')
})
})
+21
View File
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react'
import { Button } from './Button.tsx'
import { Card } from './Card.tsx'
import styles from './ErrorBanner.module.css'
export type ErrorBannerProps = {
// What went wrong, in the caller's words.
children: ReactNode
onRetry: () => void
retryLabel?: string
}
// Muted neutral-100 card with grape-700 text and a Retry button.
export function ErrorBanner({ children, onRetry, retryLabel = 'Retry' }: ErrorBannerProps) {
return (
<Card tone="muted" role="alert" className={styles.banner}>
<p className={styles.message}>{children}</p>
<Button onClick={onRetry}>{retryLabel}</Button>
</Card>
)
}
+74
View File
@@ -0,0 +1,74 @@
/* The button is the hit box; .face is the visible circle. A 40 px face sits in
a 44 px hit box whose negative margin keeps the 40 px layout footprint. */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: inherit;
cursor: pointer;
}
.button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.size40 {
width: 44px;
height: 44px;
margin: -2px;
}
.size62 {
width: 62px;
height: 62px;
}
.face {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-pill);
transition: transform 100ms var(--ease-out-soft);
}
.size40 .face {
width: 40px;
height: 40px;
}
.size62 .face {
width: 62px;
height: 62px;
box-shadow: var(--shadow-md);
}
.button:active:not(:disabled) .face {
transform: scale(0.97);
}
.surface {
background: var(--color-surface);
color: var(--color-text);
}
.accent {
background: var(--color-accent);
color: var(--on-accent);
}
.teal {
background: var(--color-accent-2-600);
color: var(--on-accent);
}
@media (prefers-reduced-motion: reduce) {
.face {
transition: none;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
import { IconButton } from './IconButton.tsx'
describe('IconButton', () => {
it('is named by its label, not its icon', () => {
const onClick = vi.fn()
render(
<IconButton label="Notifications" onClick={onClick}>
<svg />
</IconButton>,
)
const button = screen.getByRole('button', { name: 'Notifications' })
expect(button).toHaveAttribute('type', 'button')
fireEvent.click(button)
expect(onClick).toHaveBeenCalledOnce()
})
it('keeps a 44 px hit area around the 40 px circle', () => {
render(<IconButton label="Back" size={40} />)
const button = screen.getByRole('button', { name: 'Back' })
expectMinHitArea(button)
const face = button.firstElementChild!
expect(hitArea(face)).toEqual({ width: 40, height: 40 })
})
it('is 62 px at the large size', () => {
render(<IconButton label="Try it" size={62} tone="teal" />)
const button = screen.getByRole('button', { name: 'Try it' })
expectMinHitArea(button)
expect(hitArea(button)).toEqual({ width: 62, height: 62 })
})
})
+33
View File
@@ -0,0 +1,33 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './IconButton.module.css'
export type IconButtonSize = 40 | 62
export type IconButtonTone = 'surface' | 'accent' | 'teal'
export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'> & {
// Accessible name; an icon alone never names a button.
label: string
size?: IconButtonSize
tone?: IconButtonTone
}
// Round icon-only button. The 40 px size keeps a 44 px hit box (see the CSS);
// the 62 px size is the round action button with --shadow-md.
export function IconButton({
label,
size = 40,
tone = 'surface',
type = 'button',
className,
children,
...rest
}: IconButtonProps) {
const classes = [styles.button, styles[`size${size}`], className].filter(Boolean).join(' ')
return (
<button type={type} aria-label={label} className={classes} {...rest}>
<span className={[styles.face, styles[tone]].join(' ')} aria-hidden="true">
{children}
</span>
</button>
)
}
+69
View File
@@ -0,0 +1,69 @@
.row {
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
min-height: 44px;
padding: 14px 16px;
color: var(--color-text);
text-align: left;
}
.interactive {
padding: 0;
border-radius: var(--radius-md);
}
.interactive:hover {
background: var(--color-neutral-200);
}
.main {
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--space-3);
flex: 1 1 auto;
min-width: 44px;
min-height: 44px;
padding: 14px 16px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.interactive > .trailing {
padding-right: 16px;
}
.leading,
.trailing {
display: inline-flex;
align-items: center;
flex: none;
}
.body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
}
.title {
font-family: var(--font-body);
font-size: 15px;
font-weight: 700;
}
.meta {
font-family: var(--font-body);
font-size: 12px;
font-weight: 600;
color: var(--color-neutral-700);
}
+57
View File
@@ -0,0 +1,57 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { ListRow } from './ListRow.tsx'
import { Toggle } from './Toggle.tsx'
describe('ListRow', () => {
it('renders leading, title, meta and trailing slots without being interactive', () => {
render(
<ListRow
leading={<span>icon</span>}
title="Notifications"
meta="Push and Google Chat"
trailing={<span>chevron</span>}
/>,
)
for (const text of ['icon', 'Notifications', 'Push and Google Chat', 'chevron']) {
expect(screen.getByText(text)).toBeInTheDocument()
}
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('is a single button with a ≥ 44 px hit area when it has onClick', () => {
const onClick = vi.fn()
render(<ListRow title="10 new side quests to swipe" onClick={onClick} />)
const row = screen.getByRole('button', { name: '10 new side quests to swipe' })
expect(row).toHaveAttribute('type', 'button')
expectMinHitArea(row)
fireEvent.click(row)
expect(onClick).toHaveBeenCalledOnce()
})
it('keeps a trailing toggle outside the row button so each control fires only its own action', () => {
const onClick = vi.fn()
const onCheckedChange = vi.fn()
render(
<ListRow
title="Dinner reminders"
meta="Push at 4 pm"
trailing={<Toggle aria-label="Dinner reminders" checked={false} onCheckedChange={onCheckedChange} />}
onClick={onClick}
/>,
)
const row = screen.getByRole('button', { name: /^Dinner reminders/ })
const toggle = screen.getByRole('switch', { name: 'Dinner reminders' })
expect(row).not.toContainElement(toggle)
expect(row.querySelector('button')).toBeNull()
fireEvent.click(toggle)
expect(onCheckedChange).toHaveBeenCalledExactlyOnceWith(true)
expect(onClick).not.toHaveBeenCalled()
fireEvent.click(row)
expect(onClick).toHaveBeenCalledOnce()
expect(onCheckedChange).toHaveBeenCalledOnce()
})
})
+45
View File
@@ -0,0 +1,45 @@
import type { ReactNode } from 'react'
import styles from './ListRow.module.css'
export type ListRowProps = {
title: ReactNode
meta?: ReactNode
// Leading slot: icon, photo or avatar.
leading?: ReactNode
// Trailing slot: chevron, toggle, count or action.
trailing?: ReactNode
// When set, the leading slot, title and meta form one button. The trailing
// slot stays a sibling outside it, so a trailing toggle or action is never
// nested in the row's button and never triggers the row's action.
onClick?: () => void
className?: string
}
export function ListRow({ title, meta, leading, trailing, onClick, className }: ListRowProps) {
const main = (
<>
{leading && <span className={styles.leading}>{leading}</span>}
<span className={styles.body}>
<span className={styles.title}>{title}</span>
{meta && <span className={styles.meta}>{meta}</span>}
</span>
</>
)
const trailingSlot = trailing && <span className={styles.trailing}>{trailing}</span>
if (onClick) {
return (
<div className={[styles.row, styles.interactive, className].filter(Boolean).join(' ')}>
<button type="button" className={styles.main} onClick={onClick}>
{main}
</button>
{trailingSlot}
</div>
)
}
return (
<div className={[styles.row, className].filter(Boolean).join(' ')}>
{main}
{trailingSlot}
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
.track {
display: flex;
gap: 4px;
padding: 4px;
border-radius: var(--radius-pill);
background: var(--color-surface);
}
.option {
flex: 1 1 0;
min-width: 44px;
min-height: 44px;
padding: 0 var(--space-4);
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-neutral-700);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: background-color 150ms var(--ease-out-soft), box-shadow 150ms var(--ease-out-soft);
}
.selected {
background: var(--color-bg);
color: var(--color-text);
box-shadow: var(--shadow-sm);
}
@media (prefers-reduced-motion: reduce) {
.option {
transition: none;
}
}
+91
View File
@@ -0,0 +1,91 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Segmented } from './Segmented.tsx'
import styles from './Segmented.module.css'
const options = [
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
{ value: 'year', label: 'Year' },
] as const
type Period = (typeof options)[number]['value']
function Harness({ initial = 'week' }: { initial?: Period }) {
const [value, setValue] = useState<Period>(initial)
return <Segmented label="Archive period" options={options} value={value} onValueChange={setValue} />
}
function radio(name: string) {
return screen.getByRole('radio', { name })
}
describe('Segmented', () => {
it('is a named radiogroup of radios with one checked', () => {
render(<Harness />)
expect(screen.getByRole('radiogroup', { name: 'Archive period' })).toBeInTheDocument()
expect(screen.getAllByRole('radio')).toHaveLength(3)
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
expect(radio('Month')).toHaveAttribute('aria-checked', 'false')
expect(radio('Week')).toHaveClass(styles.selected!)
})
it('has a single tab stop on the selected option', () => {
render(<Harness initial="month" />)
expect(radio('Month')).toHaveAttribute('tabindex', '0')
expect(radio('Week')).toHaveAttribute('tabindex', '-1')
expect(radio('Year')).toHaveAttribute('tabindex', '-1')
})
it('moves selection and focus with the arrow keys, wrapping at the ends', () => {
render(<Harness />)
radio('Week').focus()
fireEvent.keyDown(radio('Week'), { key: 'ArrowRight' })
expect(radio('Month')).toHaveAttribute('aria-checked', 'true')
expect(radio('Month')).toHaveFocus()
fireEvent.keyDown(radio('Month'), { key: 'ArrowDown' })
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(radio('Year'), { key: 'ArrowRight' })
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
expect(radio('Week')).toHaveFocus()
fireEvent.keyDown(radio('Week'), { key: 'ArrowLeft' })
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(radio('Year'), { key: 'ArrowUp' })
expect(radio('Month')).toHaveAttribute('aria-checked', 'true')
expect(radio('Month')).toHaveFocus()
expect(radio('Year')).toHaveAttribute('aria-checked', 'false')
})
it('jumps to the first and last option with Home and End', () => {
render(<Harness initial="month" />)
fireEvent.keyDown(radio('Month'), { key: 'End' })
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(radio('Year'), { key: 'Home' })
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
})
it('selects an option on click', () => {
render(<Harness />)
fireEvent.click(radio('Year'))
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
expect(radio('Week')).toHaveAttribute('aria-checked', 'false')
})
it('gives every option a ≥ 44 px hit area', () => {
render(<Harness />)
for (const option of screen.getAllByRole('radio')) expectMinHitArea(option)
})
it('fails fast when the value is not one of the options', () => {
expect(() =>
render(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
).toThrow('value "decade" is not one of its options')
})
})
+76
View File
@@ -0,0 +1,76 @@
import { useRef, type KeyboardEvent } from 'react'
import styles from './Segmented.module.css'
export type SegmentedOption<T extends string> = {
value: T
label: string
}
export type SegmentedProps<T extends string> = {
// Accessible name of the radiogroup.
label: string
options: readonly SegmentedOption<T>[]
value: T
onValueChange: (value: T) => void
className?: string
}
const step: Record<string, number> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }
// Segmented control with radiogroup semantics: one tab stop (the selected
// option); arrow keys move the selection and focus, wrapping at the ends;
// Home and End jump to the first and last option.
export function Segmented<T extends string>({ label, options, value, onValueChange, className }: SegmentedProps<T>) {
const refs = useRef<(HTMLButtonElement | null)[]>([])
const selected = options.findIndex((option) => option.value === value)
if (selected === -1) {
throw new Error(`Segmented "${label}": value "${value}" is not one of its options`)
}
function select(index: number) {
const option = options[index]
if (!option) throw new Error(`Segmented "${label}": no option at index ${index}`)
onValueChange(option.value)
refs.current[index]?.focus()
}
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const delta = step[event.key]
let next: number
if (delta !== undefined) next = (selected + delta + options.length) % options.length
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = options.length - 1
else return
event.preventDefault()
select(next)
}
return (
<div
role="radiogroup"
aria-label={label}
className={[styles.track, className].filter(Boolean).join(' ')}
onKeyDown={onKeyDown}
>
{options.map((option, index) => {
const checked = index === selected
return (
<button
key={option.value}
ref={(el) => {
refs.current[index] = el
}}
type="button"
role="radio"
aria-checked={checked}
tabIndex={checked ? 0 : -1}
className={checked ? `${styles.option} ${styles.selected}` : styles.option}
onClick={() => select(index)}
>
{option.label}
</button>
)
})}
</div>
)
}
+57
View File
@@ -0,0 +1,57 @@
.root {
position: fixed;
inset: 0;
z-index: 100;
}
.scrim {
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
animation: fade 320ms var(--ease-out-soft);
}
.panel {
position: absolute;
right: 0;
bottom: 0;
left: 0;
max-height: 90dvh;
overflow-y: auto;
padding: var(--space-6) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
background: var(--color-bg);
color: var(--color-text);
box-shadow: var(--shadow-lg);
transform: translateY(0);
animation: rise 320ms var(--ease-out-soft);
}
.panel:focus {
outline: none;
}
@keyframes rise {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.scrim,
.panel {
animation: none;
}
}
+267
View File
@@ -0,0 +1,267 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import '../styles/tokens.css'
import { animationKeyframes, declaredStyle, reducedMotionStyle } from '../test/cssRules.ts'
import { Sheet } from './Sheet.tsx'
import styles from './Sheet.module.css'
function Harness({ withControls = true }: { withControls?: boolean }) {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Open Supply run
</button>
<Sheet aria-label="Supply run" open={open} onClose={() => setOpen(false)}>
<p>Grouped by dinner</p>
{withControls && (
<>
<button type="button">Try it</button>
<button type="button">Pass</button>
</>
)}
</Sheet>
</>
)
}
function openSheet() {
const opener = screen.getByRole('button', { name: 'Open Supply run' })
opener.focus()
fireEvent.click(opener)
return { opener, dialog: screen.getByRole('dialog', { name: 'Supply run' }) }
}
function scrim() {
const element = document.querySelector(`.${styles.scrim}`)
if (!element) throw new Error('scrim not rendered')
return element
}
describe('Sheet', () => {
it('renders nothing while closed', () => {
render(<Harness />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('is a labelled modal dialog', () => {
render(<Harness />)
const { dialog } = openSheet()
expect(dialog).toHaveAttribute('aria-modal', 'true')
})
it('accepts aria-labelledby as its accessible name', () => {
render(
<>
<h2 id="sheet-title">Quest log</h2>
<Sheet aria-labelledby="sheet-title" open onClose={() => {}}>
<p>Nothing yet</p>
</Sheet>
</>,
)
expect(screen.getByRole('dialog', { name: 'Quest log' })).toBeInTheDocument()
})
it('moves focus to the first control and restores it to the opener on close', () => {
render(<Harness />)
const { opener } = openSheet()
expect(screen.getByRole('button', { name: 'Try it' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(opener).toHaveFocus()
})
it('focuses the dialog itself when it has no focusable control', () => {
render(<Harness withControls={false} />)
const { dialog } = openSheet()
expect(dialog).toHaveFocus()
})
it('skips controls that cannot take keyboard focus when moving focus in', () => {
render(
<Sheet aria-label="Quest log" open onClose={() => {}}>
<button type="button" hidden>
Hidden
</button>
<button type="button" style={{ display: 'none' }}>
Not displayed
</button>
<button type="button" style={{ visibility: 'hidden' }}>
Invisible
</button>
<button type="button" tabIndex={-1}>
Not tabbable
</button>
<div aria-hidden="true">
<button type="button">Aria hidden</button>
</div>
<button type="button">Try it</button>
</Sheet>,
)
expect(screen.getByRole('button', { name: 'Try it' })).toHaveFocus()
})
it('focuses the dialog itself when every control is hidden', () => {
render(
<Sheet aria-label="Quest log" open onClose={() => {}}>
<button type="button" hidden>
Hidden
</button>
</Sheet>,
)
expect(screen.getByRole('dialog', { name: 'Quest log' })).toHaveFocus()
})
it('does not stop the Tab trap on a hidden control', () => {
render(
<Sheet aria-label="Quest log" open onClose={() => {}}>
<button type="button" hidden>
Hidden first
</button>
<button type="button">Try it</button>
<button type="button">Pass</button>
<button type="button" hidden>
Hidden last
</button>
</Sheet>,
)
const first = screen.getByRole('button', { name: 'Try it' })
const last = screen.getByRole('button', { name: 'Pass' })
last.focus()
fireEvent.keyDown(last, { key: 'Tab' })
expect(first).toHaveFocus()
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true })
expect(last).toHaveFocus()
})
it('does not stop the Tab trap on a control disabled by its fieldset', () => {
render(
<Sheet aria-label="Quest log" open onClose={() => {}}>
<fieldset disabled>
<button type="button">Disabled first</button>
</fieldset>
<button type="button">Try it</button>
<button type="button">Pass</button>
<fieldset disabled>
<button type="button">Disabled last</button>
</fieldset>
</Sheet>,
)
const first = screen.getByRole('button', { name: 'Try it' })
const last = screen.getByRole('button', { name: 'Pass' })
expect(first).toHaveFocus()
last.focus()
fireEvent.keyDown(last, { key: 'Tab' })
expect(first).toHaveFocus()
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true })
expect(last).toHaveFocus()
})
it('wraps Tab and Shift+Tab inside the sheet', () => {
render(<Harness />)
openSheet()
const first = screen.getByRole('button', { name: 'Try it' })
const last = screen.getByRole('button', { name: 'Pass' })
last.focus()
fireEvent.keyDown(last, { key: 'Tab' })
expect(first).toHaveFocus()
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true })
expect(last).toHaveFocus()
})
it('keeps focus on the dialog when Tab has nowhere to go', () => {
render(<Harness withControls={false} />)
const { dialog } = openSheet()
const tab = fireEvent.keyDown(dialog, { key: 'Tab' })
expect(tab).toBe(false) // default prevented
expect(dialog).toHaveFocus()
})
it('closes on Escape', () => {
const onClose = vi.fn()
render(
<Sheet aria-label="Supply run" open onClose={onClose}>
<button type="button">Try it</button>
</Sheet>,
)
fireEvent.keyDown(screen.getByRole('button', { name: 'Try it' }), { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('closes on a scrim click but not on a panel click', () => {
const onClose = vi.fn()
render(
<Sheet aria-label="Supply run" open onClose={onClose}>
<p>Grouped by dinner</p>
</Sheet>,
)
fireEvent.click(screen.getByRole('dialog'))
fireEvent.click(screen.getByText('Grouped by dinner'))
expect(onClose).not.toHaveBeenCalled()
fireEvent.click(scrim())
expect(onClose).toHaveBeenCalledTimes(1)
})
it('makes the background inert while open and restores it on close', () => {
const { container } = render(<Harness />)
openSheet()
const root = screen.getByRole('dialog').parentElement!
expect(root.parentElement).toBe(document.body)
expect(container).toHaveAttribute('inert')
expect(root).not.toHaveAttribute('inert')
fireEvent.click(scrim())
expect(container).not.toHaveAttribute('inert')
})
it('leaves an already-inert background element inert after close', () => {
const other = document.createElement('div')
other.setAttribute('inert', '')
document.body.append(other)
try {
render(<Harness />)
openSheet()
fireEvent.click(scrim())
expect(other).toHaveAttribute('inert')
} finally {
other.remove()
}
})
it('rises from 100% to 0 over 320 ms with the soft ease-out', () => {
render(<Harness />)
const { dialog } = openSheet()
expect(declaredStyle(dialog).animation.split(' ').slice(1)).toEqual(['320ms', 'var(--ease-out-soft)'])
const easing = getComputedStyle(document.documentElement).getPropertyValue('--ease-out-soft')
expect(easing.replace(/\s/g, '')).toBe('cubic-bezier(.2,.8,.2,1)')
const rise = animationKeyframes(dialog)
expect(rise.findRule('from')?.style.transform).toBe('translateY(100%)')
expect(rise.findRule('to')?.style.transform).toBe('translateY(0)')
})
it('fades the scrim in to 50% neutral-900', () => {
render(<Harness />)
openSheet()
const style = declaredStyle(scrim())
expect(style.background).toBe('color-mix(in srgb, var(--color-neutral-900) 50%, transparent)')
expect(style.animation.split(' ').slice(1)).toEqual(['320ms', 'var(--ease-out-soft)'])
const fade = animationKeyframes(scrim())
expect(fade.findRule('from')?.style.opacity).toBe('0')
expect(fade.findRule('to')?.style.opacity).toBe('1')
})
it('opens in place with no rise or fade under reduced motion', () => {
render(<Harness />)
const { dialog } = openSheet()
expect(reducedMotionStyle(dialog).animation).toBe('none')
expect(reducedMotionStyle(scrim()).animation).toBe('none')
expect(declaredStyle(dialog).transform).toBe('translateY(0)')
})
})
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import styles from './Sheet.module.css'
// The dialog's accessible name: exactly one of a label or a labelling element.
type SheetName =
| { 'aria-label': string; 'aria-labelledby'?: never }
| { 'aria-labelledby': string; 'aria-label'?: never }
export type SheetProps = SheetName & {
open: boolean
onClose: () => void
children: ReactNode
}
const focusableSelector = [
'a[href]',
'button',
'input',
'select',
'textarea',
'[tabindex]',
].join(',')
// Whether an element can take keyboard focus: being disabled (by its own
// attribute or a disabled fieldset), a negative tabIndex, a hidden, aria-hidden
// or inert ancestor (or self), display: none on the way up to the panel, or
// visibility: hidden all rule it out.
function isKeyboardFocusable(element: HTMLElement, panel: HTMLElement): boolean {
if (element.matches(':disabled')) return false
if (element.tabIndex < 0) return false
if (element.closest('[hidden], [aria-hidden="true"], [inert]')) return false
if (getComputedStyle(element).visibility === 'hidden') return false
for (let node: HTMLElement | null = element; node && node !== panel; node = node.parentElement) {
if (getComputedStyle(node).display === 'none') return false
}
return true
}
function focusables(panel: HTMLElement): HTMLElement[] {
return Array.from(panel.querySelectorAll<HTMLElement>(focusableSelector)).filter((element) =>
isKeyboardFocusable(element, panel),
)
}
// Focuses the first control that actually takes focus, or the panel if none does.
function focusFirst(panel: HTMLElement) {
for (const element of focusables(panel)) {
element.focus()
if (document.activeElement === element) return
}
panel.focus()
}
// Modal bottom sheet. Portalled to <body> so everything else there can be made
// inert while it is open. Focus moves in on open, Tab is trapped inside, and
// focus returns to the opener on close. Escape and a scrim click call onClose.
export function Sheet({ open, onClose, children, ...name }: SheetProps) {
const rootRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const root = rootRef.current
const panel = panelRef.current
if (!open || !root || !panel) return
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
const background = Array.from(document.body.children).filter(
(element) => element !== root && !element.hasAttribute('inert'),
)
for (const element of background) element.setAttribute('inert', '')
focusFirst(panel)
return () => {
for (const element of background) element.removeAttribute('inert')
opener?.focus()
}
}, [open])
if (!open) return null
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Escape') {
event.stopPropagation()
onClose()
return
}
if (event.key !== 'Tab' || !panelRef.current) return
const items = focusables(panelRef.current)
const first = items[0]
const last = items[items.length - 1]
if (!first || !last) {
event.preventDefault()
return
}
const active = document.activeElement
if (event.shiftKey && (active === first || active === panelRef.current)) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && active === last) {
event.preventDefault()
first.focus()
}
}
return createPortal(
<div ref={rootRef} className={styles.root}>
<div className={styles.scrim} aria-hidden="true" onClick={onClose} />
<div
ref={panelRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
className={styles.panel}
onKeyDown={onKeyDown}
{...name}
>
{children}
</div>
</div>,
document.body,
)
}
+24
View File
@@ -0,0 +1,24 @@
.skeleton {
display: block;
width: 100%;
height: 16px;
border-radius: var(--radius-pill);
background: var(--color-surface);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
+36
View File
@@ -0,0 +1,36 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { animationKeyframes, declaredStyle, reducedMotionStyle } from '../test/cssRules.ts'
import { Skeleton } from './Skeleton.tsx'
function skeleton(container: HTMLElement) {
return container.firstElementChild as HTMLElement
}
describe('Skeleton', () => {
it('is a hidden pill in --color-surface', () => {
const { container } = render(<Skeleton />)
const element = skeleton(container)
expect(element).toHaveAttribute('aria-hidden', 'true')
const style = declaredStyle(element)
expect(style.borderRadius).toBe('var(--radius-pill)')
expect(style.background).toBe('var(--color-surface)')
})
it('pulses its opacity over 1.2 s', () => {
const { container } = render(<Skeleton />)
expect(declaredStyle(skeleton(container)).animation.split(' ').slice(1)).toEqual(['1.2s', 'ease-in-out', 'infinite'])
expect(animationKeyframes(skeleton(container)).findRule('50%')?.style.opacity).toBe('0.45')
})
it('does not pulse under reduced motion', () => {
const { container } = render(<Skeleton />)
expect(reducedMotionStyle(skeleton(container)).animation).toBe('none')
})
it('merges a caller className and forwards style', () => {
const { container } = render(<Skeleton className="line" style={{ width: '60%' }} />)
expect(skeleton(container)).toHaveClass('line')
expect(skeleton(container).style.width).toBe('60%')
})
})
+12
View File
@@ -0,0 +1,12 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './Skeleton.module.css'
export type SkeletonProps = Omit<ComponentPropsWithRef<'div'>, 'children'>
// Loading placeholder: a pill in --color-surface with a 1.2 s opacity pulse.
// Full width and 16 px tall by default; size it with className or style.
// Hidden from assistive technology — announce loading on the container.
export function Skeleton({ className, ...rest }: SkeletonProps) {
const classes = [styles.skeleton, className].filter(Boolean).join(' ')
return <div aria-hidden="true" className={classes} {...rest} />
}
+61
View File
@@ -0,0 +1,61 @@
/* The input is the 44 px tall hit area; the track and knob are drawn by the
vendor pseudo-elements. --slider-fill is set inline from the value. */
.slider {
appearance: none;
display: block;
width: 100%;
min-width: 44px;
height: 44px;
margin: 0;
background: transparent;
cursor: pointer;
}
.slider:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.slider::-webkit-slider-runnable-track {
height: 8px;
border-radius: var(--radius-pill);
background: linear-gradient(
to right,
var(--color-accent) var(--slider-fill),
var(--color-neutral-300) var(--slider-fill)
);
}
.slider::-webkit-slider-thumb {
appearance: none;
box-sizing: border-box;
width: 24px;
height: 24px;
margin-top: -8px;
border: 3px solid var(--color-accent);
border-radius: var(--radius-pill);
background: var(--color-bg);
box-shadow: var(--shadow-sm);
}
.slider::-moz-range-track {
height: 8px;
border-radius: var(--radius-pill);
background: var(--color-neutral-300);
}
.slider::-moz-range-progress {
height: 8px;
border-radius: var(--radius-pill);
background: var(--color-accent);
}
.slider::-moz-range-thumb {
box-sizing: border-box;
width: 24px;
height: 24px;
border: 3px solid var(--color-accent);
border-radius: var(--radius-pill);
background: var(--color-bg);
box-shadow: var(--shadow-sm);
}
+69
View File
@@ -0,0 +1,69 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Slider } from './Slider.tsx'
function Harness() {
const [value, setValue] = useState(0.7)
return (
<Slider
aria-label="Creativity (temperature)"
min={0}
max={1}
step={0.1}
value={value}
onChange={(event) => setValue(Number(event.target.value))}
/>
)
}
describe('Slider', () => {
it('is a native slider exposing its range and value', () => {
render(<Harness />)
const slider = screen.getByRole('slider', { name: 'Creativity (temperature)' })
expect(slider).toHaveAttribute('type', 'range')
expect(slider).toHaveAttribute('min', '0')
expect(slider).toHaveAttribute('max', '1')
expect(slider).toHaveValue('0.7')
})
it('updates the grape fill with the value', () => {
render(<Harness />)
const slider = screen.getByRole('slider')
expect(slider.style.getPropertyValue('--slider-fill')).toBe('70%')
fireEvent.change(slider, { target: { value: '0.2' } })
expect(slider).toHaveValue('0.2')
expect(slider.style.getPropertyValue('--slider-fill')).toBe('20%')
})
it('has a ≥ 44 px hit area', () => {
render(<Harness />)
expectMinHitArea(screen.getByRole('slider'))
})
it('fails fast on an empty range', () => {
expect(() => render(<Slider aria-label="Bad" min={1} max={1} value={1} readOnly />)).toThrow(
'max (1) must be greater than min (1)',
)
})
it('accepts values at both ends of the range', () => {
render(
<>
<Slider aria-label="At min" min={0} max={1} value={0} readOnly />
<Slider aria-label="At max" min={0} max={1} value={1} readOnly />
</>,
)
expect(screen.getByRole('slider', { name: 'At min' }).style.getPropertyValue('--slider-fill')).toBe('0%')
expect(screen.getByRole('slider', { name: 'At max' }).style.getPropertyValue('--slider-fill')).toBe('100%')
})
it.each([
[2, 'value (2) must be within [0, 1]'],
[-0.1, 'value (-0.1) must be within [0, 1]'],
[Number.NaN, 'value (NaN) must be within [0, 1]'],
])('fails fast when the value %s is outside the range', (value, message) => {
expect(() => render(<Slider aria-label="Bad" min={0} max={1} value={value} readOnly />)).toThrow(message)
})
})
+30
View File
@@ -0,0 +1,30 @@
import type { CSSProperties, ComponentPropsWithRef } from 'react'
import styles from './Slider.module.css'
export type SliderProps = Omit<ComponentPropsWithRef<'input'>, 'type' | 'value' | 'min' | 'max'> & {
value: number
min: number
max: number
}
// Native range input, styled: 8 px track, grape fill up to the value, 24 px
// knob with a 3 px grape ring. Controlled, because the fill follows `value`.
// Name it with aria-label or aria-labelledby.
export function Slider({ value, min, max, className, style, ...rest }: SliderProps) {
if (!(max > min)) throw new Error(`Slider: max (${max}) must be greater than min (${min})`)
if (!(value >= min && value <= max)) {
throw new Error(`Slider: value (${value}) must be within [${min}, ${max}]`)
}
const fill = `${((value - min) / (max - min)) * 100}%`
return (
<input
type="range"
value={value}
min={min}
max={max}
className={[styles.slider, className].filter(Boolean).join(' ')}
style={{ ...style, '--slider-fill': fill } as CSSProperties}
{...rest}
/>
)
}
+21
View File
@@ -0,0 +1,21 @@
.tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: var(--radius-pill);
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.grape {
background: var(--color-accent-100);
color: var(--color-accent-800);
}
.teal {
background: var(--color-accent-2-200);
color: var(--color-accent-2-800);
}
+18
View File
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { Tag } from './Tag.tsx'
import styles from './Tag.module.css'
describe('Tag', () => {
it('renders a static, non-interactive label', () => {
render(<Tag>Draft</Tag>)
const tag = screen.getByText('Draft')
expect(tag).toHaveClass(styles.grape!)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('supports the teal tone', () => {
render(<Tag tone="teal">Synced</Tag>)
expect(screen.getByText('Synced')).toHaveClass(styles.teal!)
})
})
+14
View File
@@ -0,0 +1,14 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './Tag.module.css'
export type TagTone = 'grape' | 'teal'
export type TagProps = ComponentPropsWithRef<'span'> & {
tone?: TagTone
}
// Static label such as "Draft", "Side quest" or "Synced". Not interactive.
export function Tag({ tone = 'grape', className, ...rest }: TagProps) {
const classes = [styles.tag, styles[tone], className].filter(Boolean).join(' ')
return <span className={classes} {...rest} />
}
+46
View File
@@ -0,0 +1,46 @@
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.label {
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.hiddenLabel {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.input {
box-sizing: border-box;
min-width: 44px;
height: 48px;
padding: 0 20px;
border: 1px solid var(--color-neutral-300);
border-radius: var(--radius-pill);
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-body);
font-size: 16px;
}
.input::placeholder {
color: var(--color-neutral-700);
}
.input:disabled {
opacity: 0.45;
cursor: not-allowed;
}
+34
View File
@@ -0,0 +1,34 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { createRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
import { TextInput } from './TextInput.tsx'
describe('TextInput', () => {
it('is a textbox named by its label', () => {
const onChange = vi.fn()
render(<TextInput label="Dinner name" placeholder="e.g. Chicken enchiladas" onChange={onChange} />)
const input = screen.getByRole('textbox', { name: 'Dinner name' })
fireEvent.change(input, { target: { value: 'Chili' } })
expect(onChange).toHaveBeenCalledOnce()
expect(input).toHaveValue('Chili')
})
it('keeps the accessible name when the label is visually hidden', () => {
render(<TextInput label="Add a regular" labelHidden />)
expect(screen.getByRole('textbox', { name: 'Add a regular' })).toBeInTheDocument()
})
it('is a 48 px pill with a ≥ 44 px hit area', () => {
render(<TextInput label="Dinner name" />)
const input = screen.getByRole('textbox', { name: 'Dinner name' })
expectMinHitArea(input)
expect(hitArea(input).height).toBe(48)
})
it('forwards the ref', () => {
const ref = createRef<HTMLInputElement>()
render(<TextInput label="Dinner name" ref={ref} />)
expect(ref.current).toBe(screen.getByRole('textbox', { name: 'Dinner name' }))
})
})
+21
View File
@@ -0,0 +1,21 @@
import { useId, type ComponentPropsWithRef } from 'react'
import styles from './TextInput.module.css'
export type TextInputProps = Omit<ComponentPropsWithRef<'input'>, 'id'> & {
label: string
// Hide the label visually; it stays the input's accessible name.
labelHidden?: boolean
}
// 48 px pill input on the surface fill, always associated with its label.
export function TextInput({ label, labelHidden = false, type = 'text', className, ...rest }: TextInputProps) {
const id = useId()
return (
<div className={styles.field}>
<label htmlFor={id} className={labelHidden ? styles.hiddenLabel : styles.label}>
{label}
</label>
<input id={id} type={type} className={[styles.input, className].filter(Boolean).join(' ')} {...rest} />
</div>
)
}
+57
View File
@@ -0,0 +1,57 @@
/* The button is a 44 px hit box around the 40×24 track; negative margins keep
the 40×24 layout footprint. */
.toggle {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 44px;
height: 44px;
margin: -10px -2px;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
cursor: pointer;
}
.toggle:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.track {
position: relative;
width: 40px;
height: 24px;
border-radius: var(--radius-pill);
background: var(--color-neutral-300);
transition: background-color 150ms var(--ease-out-soft);
}
.knob {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: var(--radius-pill);
background: var(--color-bg);
box-shadow: var(--shadow-sm);
transition: transform 180ms var(--ease-spring);
}
.toggle[aria-checked='true'] .track {
background: var(--color-accent-2-600);
}
.toggle[aria-checked='true'] .knob {
transform: translateX(16px);
}
@media (prefers-reduced-motion: reduce) {
.track,
.knob {
transition: none;
}
}
+63
View File
@@ -0,0 +1,63 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
import { Toggle } from './Toggle.tsx'
function Harness() {
const [checked, setChecked] = useState(false)
return <Toggle aria-label="Push: New weekly plan ready" checked={checked} onCheckedChange={setChecked} />
}
function toggle() {
return screen.getByRole('switch', { name: 'Push: New weekly plan ready' })
}
describe('Toggle', () => {
it('is a switch reflecting its state in aria-checked', () => {
render(<Harness />)
expect(toggle()).toHaveAttribute('aria-checked', 'false')
fireEvent.click(toggle())
expect(toggle()).toHaveAttribute('aria-checked', 'true')
fireEvent.click(toggle())
expect(toggle()).toHaveAttribute('aria-checked', 'false')
})
it.each([' ', 'Enter'])('toggles with the %j key', (key) => {
render(<Harness />)
fireEvent.keyDown(toggle(), { key })
expect(toggle()).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(toggle(), { key })
expect(toggle()).toHaveAttribute('aria-checked', 'false')
})
it('suppresses native activation for Space and Enter so a key press toggles once', () => {
render(<Harness />)
expect(fireEvent.keyDown(toggle(), { key: ' ' })).toBe(false)
expect(fireEvent.keyUp(toggle(), { key: ' ' })).toBe(false)
expect(fireEvent.keyDown(toggle(), { key: 'Enter' })).toBe(false)
expect(fireEvent.keyDown(toggle(), { key: 'a' })).toBe(true)
})
it('ignores other keys', () => {
const onCheckedChange = vi.fn()
render(<Toggle aria-label="Chat" checked={false} onCheckedChange={onCheckedChange} />)
fireEvent.keyDown(screen.getByRole('switch', { name: 'Chat' }), { key: 'a' })
expect(onCheckedChange).not.toHaveBeenCalled()
})
it('is disabled when asked', () => {
const onCheckedChange = vi.fn()
render(<Toggle aria-label="Chat" checked={false} disabled onCheckedChange={onCheckedChange} />)
const sw = screen.getByRole('switch', { name: 'Chat' })
expect(sw).toBeDisabled()
fireEvent.click(sw)
expect(onCheckedChange).not.toHaveBeenCalled()
})
it('draws a 40×24 track inside a ≥ 44 px hit area', () => {
render(<Harness />)
expectMinHitArea(toggle())
expect(hitArea(toggle().firstElementChild!)).toEqual({ width: 40, height: 24 })
})
})
+42
View File
@@ -0,0 +1,42 @@
import type { ComponentPropsWithRef, KeyboardEvent } from 'react'
import styles from './Toggle.module.css'
export type ToggleProps = Omit<
ComponentPropsWithRef<'button'>,
'type' | 'role' | 'aria-checked' | 'onChange' | 'onClick' | 'onKeyDown' | 'onKeyUp' | 'children'
> & {
checked: boolean
onCheckedChange: (checked: boolean) => void
}
// 40×24 pill switch (role="switch"). Name it with aria-label or
// aria-labelledby. Space and Enter are handled here and their native button
// activation is suppressed, so each key press toggles exactly once.
export function Toggle({ checked, onCheckedChange, className, ...rest }: ToggleProps) {
function onKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key !== ' ' && event.key !== 'Enter') return
event.preventDefault()
onCheckedChange(!checked)
}
function onKeyUp(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key === ' ') event.preventDefault()
}
return (
<button
type="button"
role="switch"
aria-checked={checked}
className={[styles.toggle, className].filter(Boolean).join(' ')}
onClick={() => onCheckedChange(!checked)}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
{...rest}
>
<span className={styles.track} aria-hidden="true">
<span className={styles.knob} />
</span>
</button>
)
}
+15
View File
@@ -0,0 +1,15 @@
export { Button, type ButtonProps, type ButtonVariant } from './Button.tsx'
export { Card, type CardProps, type CardTone } from './Card.tsx'
export { Checkbox, type CheckboxProps } from './Checkbox.tsx'
export { Chip, type ChipProps } from './Chip.tsx'
export { EmptyState, type EmptyStateAction, type EmptyStateProps } from './EmptyState.tsx'
export { ErrorBanner, type ErrorBannerProps } from './ErrorBanner.tsx'
export { IconButton, type IconButtonProps, type IconButtonSize, type IconButtonTone } from './IconButton.tsx'
export { ListRow, type ListRowProps } from './ListRow.tsx'
export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx'
export { Sheet, type SheetProps } from './Sheet.tsx'
export { Skeleton, type SkeletonProps } from './Skeleton.tsx'
export { Slider, type SliderProps } from './Slider.tsx'
export { Tag, type TagProps, type TagTone } from './Tag.tsx'
export { TextInput, type TextInputProps } from './TextInput.tsx'
export { Toggle, type ToggleProps } from './Toggle.tsx'
+3
View File
@@ -14,6 +14,9 @@ export default defineConfig({
},
test: {
environment: 'jsdom',
// Inject CSS Modules into jsdom so tests can assert the sizes the
// stylesheets actually declare (for example 44 px hit areas).
css: true,
setupFiles: ['./src/test/setup.ts'],
},
})