From 09076bd45178a7668116ba8deb1f7f8b484ab8ec Mon Sep 17 00:00:00 2001 From: setkyar Date: Tue, 2 Jun 2026 00:05:04 +0700 Subject: [PATCH 1/6] feat(settings): persist user settings server-side with a /settings page Move durable user preferences (theme, spinner, view layout, notifications + sound, cat-gatekeeper config) from per-browser localStorage to the existing pi-web.sqlite via a new settings KV table and GET/POST /api/settings. The theme is injected into the HTML shell server-side (a meta tag) so the page paints correctly before first paint; localStorage becomes a write-through cache hydrated from the server on load. Adds a dedicated /settings page (Go shell + Vite entry) and removes the appearance/notifications/spinner items from the nav dropdown, replacing the placeholder with a real link. Fixes live theme switching so the page background updates without a refresh, and uses subtle border tokens on the settings page for dark mode. --- docs/architecture/system-overview.md | 6 +- internal/app/app.go | 7 +- internal/frontend/assets.go | 7 +- internal/server/server.go | 19 +++ internal/server/settings.go | 144 ++++++++++++++++ internal/server/settings_test.go | 157 +++++++++++++++++ internal/ui/index_template.go | 17 +- internal/ui/live_menu.go | 20 +-- internal/ui/live_page.go | 36 +++- internal/ui/live_templates/settings.html | 138 +++++++++++++++ .../ui/live_templates/styles/settings.css | 160 ++++++++++++++++++ internal/ui/pwa.go | 8 + internal/ui/settings_page.go | 19 +++ web/src/index/index.js | 91 +++------- .../session/cat-gatekeeper/cat-settings.js | 3 +- web/src/session/chat/done-notifier.js | 14 +- web/src/session/live/command-menu.js | 63 ------- web/src/session/session.js | 3 + web/src/settings/settings.js | 121 +++++++++++++ web/src/shared/settings-store.js | 125 ++++++++++++++ web/src/shared/settings-store.test.js | 102 +++++++++++ web/src/shared/theme.js | 26 ++- web/vite.config.js | 1 + 23 files changed, 1105 insertions(+), 182 deletions(-) create mode 100644 internal/server/settings.go create mode 100644 internal/server/settings_test.go create mode 100644 internal/ui/live_templates/settings.html create mode 100644 internal/ui/live_templates/styles/settings.css create mode 100644 internal/ui/settings_page.go create mode 100644 web/src/settings/settings.js create mode 100644 web/src/shared/settings-store.js create mode 100644 web/src/shared/settings-store.test.js diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md index b57d86e..907f138 100644 --- a/docs/architecture/system-overview.md +++ b/docs/architecture/system-overview.md @@ -16,7 +16,7 @@ pi-web is a local HTTP server that lets you browse and interact with your pi cod | Live Updates | Server-Sent Events (SSE) | | Chat RPC | JSONL over stdin/stdout via `pi --mode rpc` | | Session Storage | JSONL files on disk; pi-web creates new session files and appends `session_info` for browser rename | -| Local DB | SQLite (`~/.pi/agent/pi-web.sqlite`) for per-project scratchpads | +| Local DB | SQLite (`~/.pi/agent/pi-web.sqlite`) for per-project scratchpads and server-backed user settings | | Auth | Token cookie/query/header (optional on localhost) | ## Component Diagram @@ -44,6 +44,7 @@ pi-web is a local HTTP server that lets you browse and interact with your pi cod │ │ │ GET / → handleIndex (Vite index bundle) │ │ GET /session → handleSession (Vite session bundle shell) │ +│ GET /settings → handleSettingsPage (Vite settings bundle shell) │ │ GET /api/session → handleApiSession (JSON) │ │ GET /api/sessions → handleApiSessions (JSON list) │ │ POST /api/chat → handleChat (multipart or JSON) │ @@ -56,6 +57,7 @@ pi-web is a local HTTP server that lets you browse and interact with your pi cod │ GET /api/worker-status → handleWorkerStatus │ │ GET /api/git/info / POST /api/git/rename-branch │ │ GET/POST /api/scratchpad → scratchpad (SQLite) │ +│ GET/POST /api/settings → user settings (SQLite, write-through cache) │ │ GET /api/sounds / GET /sounds/… (notification sounds) │ │ POST /share → handleShare (GitHub Gist) │ │ GET /events → handleEvents (SSE) │ @@ -127,7 +129,7 @@ name, while pi-web itself continues listening only on localhost. ├── session-status/ │ ├── 2026-01-15T10-30-00.000Z_a1b2c3d4.jsonl ← terminal writes here │ └── … -├── pi-web.sqlite ← scratchpads (and future local state) +├── pi-web.sqlite ← scratchpads + server-backed user settings └── pi-web/ ├── pi-web-state.json ← server state file ├── custom-themes.css ← optional user custom theme diff --git a/internal/app/app.go b/internal/app/app.go index 98f5898..79c3cfa 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -86,6 +86,7 @@ func Main(version string) { RenderIndex: ui.RenderIndex, RenderLiveSession: ui.RenderLiveSessionPage, RenderExportSession: ui.RenderExportSessionPage, + RenderSettings: ui.RenderSettings, Models: func(ctx context.Context) (json.RawMessage, error) { return defaultModelsCache.get(ctx) }, @@ -94,17 +95,21 @@ func Main(version string) { RunRestart: runRestart, }) + ui.SetThemeProvider(srv.ThemeSetting) + mux := http.NewServeMux() srv.Register(mux) ui.RegisterPWAHandlers(mux) dfs := web.DistFS() - if scripts, err := frontend.LoadScripts(dfs, frontend.IndexEntry, frontend.SessionEntry, frontend.LiveEntry); err == nil { + if scripts, err := frontend.LoadScripts(dfs, frontend.IndexEntry, frontend.SessionEntry, frontend.SettingsEntry, frontend.LiveEntry); err == nil { for _, script := range scripts { switch script.Entry { case frontend.IndexEntry: ui.SetIndexScriptPath(script.Path) case frontend.SessionEntry: ui.SetSessionScriptPath(script.Path) + case frontend.SettingsEntry: + ui.SetSettingsScriptPath(script.Path) } mux.HandleFunc(script.Path, frontend.ServeJS(script.JS, script.Path != "/static/assets/index.js")) } diff --git a/internal/frontend/assets.go b/internal/frontend/assets.go index e8cbf99..052a935 100644 --- a/internal/frontend/assets.go +++ b/internal/frontend/assets.go @@ -13,9 +13,10 @@ import ( ) const ( - IndexEntry = "src/index/index.js" - SessionEntry = "src/session/session.js" - LiveEntry = "src/live/live.js" + IndexEntry = "src/index/index.js" + SessionEntry = "src/session/session.js" + SettingsEntry = "src/settings/settings.js" + LiveEntry = "src/live/live.js" // Backward-compatible unexported aliases used by package tests. indexEntry = IndexEntry diff --git a/internal/server/server.go b/internal/server/server.go index 84c2ec6..41fc704 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -43,6 +43,7 @@ type Deps struct { RenderIndex func(w io.Writer, summaries []sessions.SessionSummary) error RenderLiveSession func(s sessions.Session) string RenderExportSession func(s sessions.Session, theme string) string + RenderSettings func(w io.Writer) error Models func(ctx context.Context) (json.RawMessage, error) Now func() time.Time // Updater reports current/latest version + changelog. Optional; when nil @@ -73,6 +74,7 @@ type Server struct { renderIndex func(w io.Writer, summaries []sessions.SessionSummary) error renderLiveSession func(s sessions.Session) string renderExportSession func(s sessions.Session, theme string) string + renderSettings func(w io.Writer) error models func(ctx context.Context) (json.RawMessage, error) lastKnown map[string]struct{} // session ids currently broadcast as running lastKnownMu sync.Mutex @@ -117,6 +119,14 @@ func New(deps Deps) *Server { if err != nil { fmt.Fprintf(os.Stderr, "failed to create scratchpads table: %v\n", err) } + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at DATETIME + )`) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create settings table: %v\n", err) + } } s := &Server{ @@ -131,6 +141,7 @@ func New(deps Deps) *Server { renderIndex: deps.RenderIndex, renderLiveSession: deps.RenderLiveSession, renderExportSession: deps.RenderExportSession, + renderSettings: deps.RenderSettings, models: deps.Models, lastKnown: make(map[string]struct{}), stopCh: make(chan struct{}), @@ -173,6 +184,7 @@ func (s *Server) Shutdown() { func (s *Server) Register(mux *http.ServeMux) { mux.HandleFunc("/", s.auth.Wrap(s.handleIndex)) mux.HandleFunc("/session", s.auth.Wrap(s.handleSession)) + mux.HandleFunc("/settings", s.auth.Wrap(s.handleSettingsPage)) mux.HandleFunc("/api/session", s.auth.Wrap(s.handleApiSession)) mux.HandleFunc("/api/sessions", s.auth.Wrap(s.handleApiSessions)) mux.HandleFunc("/api/chat", s.auth.Wrap(s.handleChat)) @@ -198,6 +210,13 @@ func (s *Server) Register(mux *http.ServeMux) { s.auth.Wrap(s.handleGetScratchpad)(w, r) } }) + mux.HandleFunc("/api/settings", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + s.auth.Wrap(s.handleSaveSettings)(w, r) + } else { + s.auth.Wrap(s.handleGetSettings)(w, r) + } + }) if s.push != nil { s.push.Register(mux, s.auth.Wrap) } diff --git a/internal/server/settings.go b/internal/server/settings.go new file mode 100644 index 0000000..1cd4484 --- /dev/null +++ b/internal/server/settings.go @@ -0,0 +1,144 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "time" +) + +// handleSettingsPage renders the global /settings page. +func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { + if s.renderSettings == nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.renderSettings(w); err != nil { + if !isBrokenPipe(err) { + fmt.Fprintf(os.Stderr, "settings template error: %v\n", err) + } + } +} + +// settingDefaults defines the server-backed user settings and their default +// values. The keys mirror the localStorage keys the frontend already uses so +// the write-through cache maps 1:1. Only keys listed here are accepted by +// POST /api/settings; everything else is ignored. Genuinely per-window or +// live-timer state (sidebar widths, focus countdown, tree toggles) is NOT +// listed here — it stays in localStorage only. +var settingDefaults = map[string]string{ + "pi-web-theme": "dark", + "pi-sessions:spinner-style": "runcat", + "pi-share:v1:notify-on-done": "false", + "pi-share:v1:done-sound": "cat.mp3", + "pi-sessions:view-layout": "timeline", + "pi-web:v1:cat:enabled": "true", + "pi-web:v1:cat:focus-min": "25", + "pi-web:v1:cat:break-min": "5", + "pi-web:v1:cat:bedtime": "23:00", + "pi-web:v1:cat:sleep-min": "2", +} + +// getSettings returns every server-backed setting: defaults overlaid with any +// values stored in the DB. Degrades gracefully to defaults when there is no DB. +func (s *Server) getSettings() map[string]string { + out := make(map[string]string, len(settingDefaults)) + for k, v := range settingDefaults { + out[k] = v + } + if s.db == nil { + return out + } + rows, err := s.db.Query("SELECT key, value FROM settings") + if err != nil { + return out + } + defer rows.Close() + for rows.Next() { + var key, value string + if err := rows.Scan(&key, &value); err != nil { + continue + } + // Only surface keys we still recognize. + if _, ok := settingDefaults[key]; ok { + out[key] = value + } + } + return out +} + +// getSetting returns a single server-backed setting, falling back to its +// default (or the supplied fallback for unknown keys). +func (s *Server) getSetting(key, fallback string) string { + if def, ok := settingDefaults[key]; ok { + fallback = def + } + if s.db == nil { + return fallback + } + var value string + err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value) + if err != nil { + return fallback + } + return value +} + +// ThemeSetting returns the persisted theme, used for server-side injection so +// the page paints the correct theme before any JS runs. +func (s *Server) ThemeSetting() string { + return s.getSetting("pi-web-theme", "dark") +} + +func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"settings": s.getSettings()}) +} + +func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var body struct { + Settings map[string]string `json:"settings"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid json body") + return + } + if len(body.Settings) == 0 { + writeJSONError(w, http.StatusBadRequest, "settings is required") + return + } + + // Without a DB, writes are a no-op but still report success so the + // write-through cache (localStorage) keeps working read-only. + if s.db == nil { + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "settings": s.getSettings()}) + return + } + + now := time.Now() + for key, value := range body.Settings { + if _, ok := settingDefaults[key]; !ok { + continue // ignore unknown keys + } + _, err := s.db.Exec(`INSERT INTO settings (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, + key, value, now) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to save settings: "+err.Error()) + return + } + } + + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "settings": s.getSettings()}) +} diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go new file mode 100644 index 0000000..227dcc1 --- /dev/null +++ b/internal/server/settings_test.go @@ -0,0 +1,157 @@ +package server + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + _ "modernc.org/sqlite" +) + +func newSettingsTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("failed to open test db: %v", err) + } + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at DATETIME + )`) + if err != nil { + t.Fatalf("failed to create test table: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func decodeSettings(t *testing.T, body []byte) map[string]string { + t.Helper() + var resp struct { + Settings map[string]string `json:"settings"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("failed to decode settings: %v (%s)", err, body) + } + return resp.Settings +} + +func TestHandleGetSettingsDefaults(t *testing.T) { + s := &Server{db: newSettingsTestDB(t)} + + req := httptest.NewRequest(http.MethodGet, "/api/settings", nil) + w := httptest.NewRecorder() + s.handleGetSettings(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + got := decodeSettings(t, w.Body.Bytes()) + if got["pi-web-theme"] != "dark" { + t.Errorf("expected default theme 'dark', got %q", got["pi-web-theme"]) + } + if len(got) != len(settingDefaults) { + t.Errorf("expected %d settings, got %d", len(settingDefaults), len(got)) + } +} + +func TestHandleSaveSettingsRoundTrip(t *testing.T) { + s := &Server{db: newSettingsTestDB(t)} + + body := bytes.NewBufferString(`{"settings":{"pi-web-theme":"nord","pi-sessions:spinner-style":"braille"}}`) + req := httptest.NewRequest(http.MethodPost, "/api/settings", body) + w := httptest.NewRecorder() + s.handleSaveSettings(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if got := s.getSetting("pi-web-theme", "dark"); got != "nord" { + t.Errorf("expected stored theme 'nord', got %q", got) + } + if got := s.ThemeSetting(); got != "nord" { + t.Errorf("ThemeSetting expected 'nord', got %q", got) + } + + // Other keys keep their defaults. + all := s.getSettings() + if all["pi-share:v1:done-sound"] != "cat.mp3" { + t.Errorf("expected default done-sound, got %q", all["pi-share:v1:done-sound"]) + } + if all["pi-sessions:spinner-style"] != "braille" { + t.Errorf("expected stored spinner 'braille', got %q", all["pi-sessions:spinner-style"]) + } +} + +func TestHandleSaveSettingsIgnoresUnknownKeys(t *testing.T) { + s := &Server{db: newSettingsTestDB(t)} + + body := bytes.NewBufferString(`{"settings":{"pi-web:v1:right-sidebar-width":"320","pi-web-theme":"light"}}`) + req := httptest.NewRequest(http.MethodPost, "/api/settings", body) + w := httptest.NewRecorder() + s.handleSaveSettings(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var stored string + err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", "pi-web:v1:right-sidebar-width").Scan(&stored) + if err != sql.ErrNoRows { + t.Errorf("unknown key should not be persisted, got value %q (err %v)", stored, err) + } + if s.getSetting("pi-web-theme", "dark") != "light" { + t.Errorf("known key in same request should still be saved") + } +} + +func TestHandleSaveSettingsEmptyBody(t *testing.T) { + s := &Server{db: newSettingsTestDB(t)} + req := httptest.NewRequest(http.MethodPost, "/api/settings", bytes.NewBufferString(`{"settings":{}}`)) + w := httptest.NewRecorder() + s.handleSaveSettings(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for empty settings, got %d", w.Code) + } +} + +func TestSettingsNoDBDegradesGracefully(t *testing.T) { + s := &Server{} // db == nil + + // Reads return defaults. + req := httptest.NewRequest(http.MethodGet, "/api/settings", nil) + w := httptest.NewRecorder() + s.handleGetSettings(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 without db, got %d", w.Code) + } + got := decodeSettings(t, w.Body.Bytes()) + if got["pi-web-theme"] != "dark" { + t.Errorf("expected default theme without db, got %q", got["pi-web-theme"]) + } + + // Writes are a no-op but report success. + body := bytes.NewBufferString(`{"settings":{"pi-web-theme":"nord"}}`) + req2 := httptest.NewRequest(http.MethodPost, "/api/settings", body) + w2 := httptest.NewRecorder() + s.handleSaveSettings(w2, req2) + if w2.Code != http.StatusOK { + t.Fatalf("expected 200 no-op write without db, got %d", w2.Code) + } + if s.ThemeSetting() != "dark" { + t.Errorf("expected theme to remain default without db") + } +} + +func TestHandleGetSettingsWrongMethod(t *testing.T) { + s := &Server{db: newSettingsTestDB(t)} + req := httptest.NewRequest(http.MethodDelete, "/api/settings", nil) + w := httptest.NewRecorder() + s.handleGetSettings(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", w.Code) + } +} diff --git a/internal/ui/index_template.go b/internal/ui/index_template.go index ee206c2..94f90a5 100644 --- a/internal/ui/index_template.go +++ b/internal/ui/index_template.go @@ -19,9 +19,11 @@ var indexTmplStr string // funcMap so the rendered +{{ liveDocumentEnd }} diff --git a/internal/ui/live_templates/styles/settings.css b/internal/ui/live_templates/styles/settings.css new file mode 100644 index 0000000..550e5e2 --- /dev/null +++ b/internal/ui/live_templates/styles/settings.css @@ -0,0 +1,160 @@ +.settings-page { + max-width: 720px; + margin: 0 auto; + padding: 24px 20px 80px; +} + +.settings-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 28px; +} + +.settings-back { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-soft); + text-decoration: none; + font-size: 0.9rem; + padding: 6px 10px; + border-radius: 8px; + border: 1px solid var(--dim); + background: var(--surface); +} + +.settings-back:hover { + color: var(--text); + background: var(--hover); +} + +.settings-header h1 { + font-size: 1.4rem; + margin: 0; + color: var(--text); +} + +.settings-section { + margin-bottom: 28px; + border: 1px solid var(--dim); + border-radius: 12px; + background: var(--surface); + overflow: hidden; +} + +.settings-section-title { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + padding: 14px 18px 6px; + font-weight: 600; +} + +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 18px; + border-top: 1px solid var(--dim); +} + +.settings-row:first-of-type { + border-top: none; +} + +.settings-row-label { + display: flex; + flex-direction: column; + gap: 3px; +} + +.settings-row-label .name { + color: var(--text); + font-size: 0.95rem; +} + +.settings-row-label .hint { + color: var(--muted); + font-size: 0.8rem; +} + +.settings-control select, +.settings-control input[type="number"], +.settings-control input[type="time"] { + background: var(--input-bg, var(--surface-2)); + color: var(--text); + border: 1px solid var(--dim); + border-radius: 8px; + padding: 7px 10px; + font-size: 0.9rem; + font-family: inherit; + min-width: 130px; +} + +.settings-control input[type="number"] { + min-width: 90px; +} + +/* Toggle switch */ +.settings-toggle { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + flex: none; +} + +.settings-toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.settings-toggle .slider { + position: absolute; + cursor: pointer; + inset: 0; + background: var(--surface-2); + border: 1px solid var(--dim); + border-radius: 999px; + transition: background 0.15s ease; +} + +.settings-toggle .slider::before { + content: ""; + position: absolute; + height: 16px; + width: 16px; + left: 3px; + top: 3px; + background: var(--text-soft); + border-radius: 50%; + transition: transform 0.15s ease, background 0.15s ease; +} + +.settings-toggle input:checked + .slider { + background: var(--accent); + border-color: var(--accent); +} + +.settings-toggle input:checked + .slider::before { + transform: translateX(20px); + background: #fff; +} + +.settings-saved-hint { + text-align: center; + color: var(--muted); + font-size: 0.8rem; + margin-top: 8px; + min-height: 1em; + transition: opacity 0.3s ease; + opacity: 0; +} + +.settings-saved-hint.visible { + opacity: 1; +} diff --git a/internal/ui/pwa.go b/internal/ui/pwa.go index 3301053..19fef0c 100644 --- a/internal/ui/pwa.go +++ b/internal/ui/pwa.go @@ -43,6 +43,9 @@ var menuCSS string //go:embed live_templates/styles/palette.css var paletteCSS string +//go:embed live_templates/styles/settings.css +var settingsCSS string + // registerPWAHandlers serves the manifest, service worker, and icons. // Routes are registered without auth: a manifest/icon leaks nothing // sensitive, and the service worker must be reachable for installability @@ -100,4 +103,9 @@ func RegisterPWAHandlers(mux *http.ServeMux) { w.Header().Set("Cache-Control", "no-cache") _, _ = w.Write([]byte(paletteCSS)) }) + mux.HandleFunc("/settings.css", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write([]byte(settingsCSS)) + }) } diff --git a/internal/ui/settings_page.go b/internal/ui/settings_page.go new file mode 100644 index 0000000..5e7856e --- /dev/null +++ b/internal/ui/settings_page.go @@ -0,0 +1,19 @@ +package ui + +import ( + _ "embed" + "html/template" + "io" +) + +//go:embed live_templates/settings.html +var settingsTmplStr string + +var settingsTmpl = template.Must(template.New("settings").Funcs(funcMap).Parse(settingsTmplStr)) + +// RenderSettings renders the global /settings page. Controls are populated and +// persisted client-side via /api/settings; the theme is injected server-side +// (shared meta tag) so the page paints in the correct theme before JS runs. +func RenderSettings(w io.Writer) error { + return settingsTmpl.Execute(w, nil) +} diff --git a/web/src/index/index.js b/web/src/index/index.js index 335e558..ccdd49e 100644 --- a/web/src/index/index.js +++ b/web/src/index/index.js @@ -1,17 +1,9 @@ import { createSessionsPage } from './sessions-page.js'; -import { - isDoneNotifyEnabled, - registerPushSubscription, - requestNotifyPermission, - setDoneNotifyEnabled, - unregisterPushSubscription, - setupSoundSelector, - playDoneSound, -} from '../session/chat/done-notifier.js'; import { setupKeyboardNav } from '../shared/keyboard-nav.js'; import { toggleTheme, syncThemeIcons } from '../shared/theme.js'; import { setupSessionListPalette } from '../shared/session-list-palette.js'; import { createVersionController } from '../shared/version.js'; +import { configureSettingsSync, hydrateSettings, writeSetting } from '../shared/settings-store.js'; export { createSessionsPage }; @@ -21,6 +13,8 @@ export function runIndexPage({ setTimeoutImpl = setTimeout, clearTimeoutImpl = clearTimeout, } = {}) { + configureSettingsSync({ fetchImpl: windowImpl.fetch ? windowImpl.fetch.bind(windowImpl) : undefined }); + const page = createSessionsPage({ root: documentImpl, setTimeoutImpl, @@ -43,10 +37,6 @@ export function runIndexPage({ const sessionPathInput = documentImpl.getElementById('sessionPath'); const recentLocations = documentImpl.getElementById('recentLocations'); const modalError = documentImpl.getElementById('modalError'); - const notifyToggle = documentImpl.getElementById('index-notify-toggle'); - const notifyStatus = documentImpl.getElementById('index-notify-status'); - const spinnerToggle = documentImpl.getElementById('index-spinner-toggle'); - const spinnerStatus = documentImpl.getElementById('index-spinner-status'); const layoutBtns = Array.from(documentImpl.querySelectorAll('[data-layout-btn]')); const layoutStorageKey = 'pi-sessions:view-layout'; @@ -171,7 +161,7 @@ export function runIndexPage({ layoutBtns.forEach((btn) => { btn.addEventListener('click', async () => { const layout = btn.dataset.layoutBtn === 'projects' ? 'projects' : 'timeline'; - try { windowImpl.localStorage.setItem(layoutStorageKey, layout); } catch (_) {} + writeSetting(layoutStorageKey, layout, { storage: windowImpl.localStorage }); setLayoutButtonState(layout); await page.setLayout(layout); markLayoutReady(); @@ -205,64 +195,6 @@ export function runIndexPage({ }); } - function syncNotifyMenuItem() { - if (!notifyToggle || !notifyStatus) return; - const enabled = isDoneNotifyEnabled({ storage: windowImpl.localStorage }); - notifyStatus.textContent = enabled ? 'ON' : 'OFF'; - notifyStatus.classList.toggle('on', enabled); - notifyToggle.setAttribute('aria-pressed', enabled ? 'true' : 'false'); - - const parent = notifyStatus.parentElement; - if (parent) { - const selector = parent.querySelector('.sound-selector'); - if (selector) { - selector.style.display = enabled ? '' : 'none'; - } - } - } - - if (notifyToggle) { - syncNotifyMenuItem(); - notifyToggle.addEventListener('click', async () => { - const enabled = isDoneNotifyEnabled({ storage: windowImpl.localStorage }); - if (enabled) { - setDoneNotifyEnabled(false, { storage: windowImpl.localStorage }); - await unregisterPushSubscription({ windowImpl }); - } else { - const permission = await requestNotifyPermission({ windowImpl }); - const granted = permission === 'granted'; - setDoneNotifyEnabled(granted, { storage: windowImpl.localStorage }); - if (granted) await registerPushSubscription({ windowImpl }); - } - syncNotifyMenuItem(); - }); - - setupSoundSelector({ - documentImpl, - windowImpl, - storage: windowImpl.localStorage, - fetchImpl: windowImpl.fetch.bind(windowImpl) - }); - } - - function syncSpinnerMenuItem() { - if (!spinnerToggle || !spinnerStatus) return; - const isRuncat = windowImpl.localStorage.getItem('pi-sessions:spinner-style') !== 'braille'; - spinnerStatus.textContent = isRuncat ? 'RUNCAT' : 'BRAILLE'; - spinnerStatus.classList.toggle('on', isRuncat); - spinnerToggle.setAttribute('aria-pressed', isRuncat ? 'true' : 'false'); - } - - if (spinnerToggle) { - syncSpinnerMenuItem(); - spinnerToggle.addEventListener('click', () => { - const current = windowImpl.localStorage.getItem('pi-sessions:spinner-style') === 'braille' ? 'braille' : 'runcat'; - const next = current === 'runcat' ? 'braille' : 'runcat'; - windowImpl.localStorage.setItem('pi-sessions:spinner-style', next); - syncSpinnerMenuItem(); - }); - } - async function openNewSessionModal() { showModal(); page.modal = true; @@ -372,6 +304,21 @@ export function runIndexPage({ } catch (_) {} setLayoutButtonState(initialLayout); + // Pull server-backed settings and reconcile the layout control. Theme is + // already injected server-side; this catches cross-browser drift for the + // default layout without blocking first paint. + hydrateSettings({ storage: windowImpl.localStorage }).then((settings) => { + if (!settings) return; + const serverLayout = settings[layoutStorageKey] === 'projects' ? 'projects' : 'timeline'; + if (serverLayout !== (documentImpl.documentElement.dataset.sessionLayout || 'timeline')) { + setLayoutButtonState(serverLayout); + page.setLayout(serverLayout) + .then(() => sessionPalette.refresh()) + .catch(() => {}) + .finally(markLayoutReady); + } + }); + page.filter(); sessionPalette.refresh(); page.subscribe(); diff --git a/web/src/session/cat-gatekeeper/cat-settings.js b/web/src/session/cat-gatekeeper/cat-settings.js index a42a72b..541dfb5 100644 --- a/web/src/session/cat-gatekeeper/cat-settings.js +++ b/web/src/session/cat-gatekeeper/cat-settings.js @@ -5,6 +5,7 @@ */ import { showSheet } from '../live/full-screen-sheet.js'; +import { writeSetting } from '../../shared/settings-store.js'; export const CAT_KEYS = { enabled: 'pi-web:v1:cat:enabled', @@ -62,7 +63,7 @@ export function loadCatSettings({ storage = globalThis.localStorage } = {}) { export function saveCatSettings(partial = {}, { storage = globalThis.localStorage } = {}) { const write = (key, value) => { - try { storage?.setItem(key, String(value)); } catch { /* ignore */ } + writeSetting(key, String(value), { storage }); }; if ('enabled' in partial) write(CAT_KEYS.enabled, !!partial.enabled); if ('focusMin' in partial) write(CAT_KEYS.focusMin, clampInt(partial.focusMin, LIMITS.focusMin, CAT_DEFAULTS.focusMin)); diff --git a/web/src/session/chat/done-notifier.js b/web/src/session/chat/done-notifier.js index 718d488..5867470 100644 --- a/web/src/session/chat/done-notifier.js +++ b/web/src/session/chat/done-notifier.js @@ -1,3 +1,5 @@ +import { writeSetting } from '../../shared/settings-store.js'; + export const DONE_NOTIFY_STORAGE_KEY = 'pi-share:v1:notify-on-done'; export const DONE_SOUND_STORAGE_KEY = 'pi-share:v1:done-sound'; @@ -10,11 +12,7 @@ export function isDoneNotifyEnabled({ storage = globalThis.localStorage } = {}) } export function setDoneNotifyEnabled(enabled, { storage = globalThis.localStorage } = {}) { - try { - storage?.setItem(DONE_NOTIFY_STORAGE_KEY, String(!!enabled)); - } catch { - // ignore - } + writeSetting(DONE_NOTIFY_STORAGE_KEY, String(!!enabled), { storage }); } export function getSelectedSound({ storage = globalThis.localStorage } = {}) { @@ -26,11 +24,7 @@ export function getSelectedSound({ storage = globalThis.localStorage } = {}) { } export function setSelectedSound(name, { storage = globalThis.localStorage } = {}) { - try { - storage?.setItem(DONE_SOUND_STORAGE_KEY, name || 'cat.mp3'); - } catch { - // ignore - } + writeSetting(DONE_SOUND_STORAGE_KEY, name || 'cat.mp3', { storage }); } export function playDoneSound({ windowImpl = window, audioSrc, storage = globalThis.localStorage } = {}) { diff --git a/web/src/session/live/command-menu.js b/web/src/session/live/command-menu.js index 05bec9a..2b038f7 100644 --- a/web/src/session/live/command-menu.js +++ b/web/src/session/live/command-menu.js @@ -1,11 +1,7 @@ -import { isDoneNotifyEnabled, setupSoundSelector } from '../chat/done-notifier.js'; -import { applyTheme, toggleTheme, syncThemeIcons } from '../../shared/theme.js'; import { showModelUsageModal } from './model-usage-modal.js'; import { showForkModal } from './fork-modal.js'; import { openVersionModal } from '../../shared/version.js'; -export { applyTheme, toggleTheme, syncThemeIcons }; - function chatUrl(path, sessionId) { return `${path}?id=${encodeURIComponent(sessionId)}`; } @@ -75,43 +71,10 @@ export function setupCommandMenu({ let open = false; - function syncNotifyToggle() { - const enabled = isDoneNotifyEnabled({ storage: windowImpl.localStorage }); - const mobileStatus = documentImpl.getElementById('mobile-command-notify-status'); - const desktopStatus = documentImpl.getElementById('command-menu-notify-status'); - [mobileStatus, desktopStatus].forEach((el) => { - if (!el) return; - el.textContent = enabled ? 'ON' : 'OFF'; - el.classList.toggle('on', enabled); - - const parent = el.parentElement; - if (parent) { - const selector = parent.querySelector('.sound-selector'); - if (selector) { - selector.style.display = enabled ? '' : 'none'; - } - } - }); - } - - function syncSpinnerToggle() { - const isRuncat = windowImpl.localStorage.getItem('pi-sessions:spinner-style') !== 'braille'; - const mobileStatus = documentImpl.getElementById('mobile-command-spinner-status'); - const desktopStatus = documentImpl.getElementById('command-menu-spinner-status'); - [mobileStatus, desktopStatus].forEach((el) => { - if (!el) return; - el.textContent = isRuncat ? 'RUNCAT' : 'BRAILLE'; - el.classList.toggle('on', isRuncat); - }); - } - function openMobilePanel() { if (!mobileBackdrop || !mobilePanel) return; mobileBackdrop.style.display = ''; mobilePanel.style.display = ''; - syncNotifyToggle(); - syncSpinnerToggle(); - syncThemeIcons(documentImpl); requestAnimationFrame(() => { mobileBackdrop.classList.add('open'); mobilePanel.classList.add('open'); @@ -132,9 +95,6 @@ export function setupCommandMenu({ function openDesktopPopover() { if (!desktopPopover) return; - syncNotifyToggle(); - syncSpinnerToggle(); - syncThemeIcons(documentImpl); desktopPopover.style.display = ''; requestAnimationFrame(() => { desktopPopover.classList.add('open'); @@ -195,26 +155,6 @@ export function setupCommandMenu({ function handleAction(action) { switch (action) { - case 'theme': { - toggleTheme(windowImpl, documentImpl); - syncThemeIcons(documentImpl); - showToast('Appearance updated', documentImpl, windowImpl); - break; - } - case 'notifications': { - clickHiddenButton('notify-toggle', documentImpl); - syncNotifyToggle(); - windowImpl.setTimeout(syncNotifyToggle, 400); - break; - } - case 'spinner': { - const current = windowImpl.localStorage.getItem('pi-sessions:spinner-style') === 'braille' ? 'braille' : 'runcat'; - const next = current === 'runcat' ? 'braille' : 'runcat'; - windowImpl.localStorage.setItem('pi-sessions:spinner-style', next); - syncSpinnerToggle(); - showToast(`Spinner set to ${next.toUpperCase()}`, documentImpl, windowImpl); - break; - } case 'share': { clickHiddenButton('share-btn', documentImpl); closeMenu(); @@ -361,7 +301,4 @@ export function setupCommandMenu({ handleAction(action); }); }); - - // Set up the sound selector dropdown - setupSoundSelector({ documentImpl, windowImpl, storage: windowImpl.localStorage, fetchImpl }); } \ No newline at end of file diff --git a/web/src/session/session.js b/web/src/session/session.js index f399880..7ffbffd 100644 --- a/web/src/session/session.js +++ b/web/src/session/session.js @@ -39,6 +39,7 @@ import { toggleTheme, syncThemeIcons } from '../shared/theme.js'; import { setupSessionListPalette } from '../shared/session-list-palette.js'; import { showShortcutsModal } from './live/shortcuts-modal.js'; import { setupCatGatekeeper } from './cat-gatekeeper/cat-gatekeeper.js'; +import { configureSettingsSync, hydrateSettings } from '../shared/settings-store.js'; export { buildSessionLookups, createSessionDataModel, decodeBase64JSON, getSessionSearchParams, loadSessionData, readSessionPayload } from './data/session-data.js'; export { buildActivePathIds, buildTree, buildTreeNodeMap, buildTreePrefix, findNewestLeaf, flattenTree, getPath } from './tree/session-tree.js'; export { createTreeRenderer } from './tree/tree-renderer.js'; @@ -66,6 +67,8 @@ function applyLazyHighlighting(documentImpl) { export function runSessionApp({ target = window } = {}) { const documentImpl = target.document; + configureSettingsSync({ fetchImpl: target.fetch ? target.fetch.bind(target) : undefined }); + hydrateSettings({ storage: target.localStorage }); target.marked = target.marked || marked; const dataModel = target.__piSessionDataModel || loadSessionData({ documentImpl, diff --git a/web/src/settings/settings.js b/web/src/settings/settings.js new file mode 100644 index 0000000..d082c48 --- /dev/null +++ b/web/src/settings/settings.js @@ -0,0 +1,121 @@ +import { configureSettingsSync, hydrateSettings, writeSetting } from '../shared/settings-store.js'; +import { applyTheme } from '../shared/theme.js'; +import { + fetchAvailableSounds, + getSelectedSound, + playDoneSound, + setDoneNotifyEnabled, + requestNotifyPermission, + registerPushSubscription, + unregisterPushSubscription, +} from '../session/chat/done-notifier.js'; + +export async function runSettingsPage({ + documentImpl = document, + windowImpl = window, +} = {}) { + const fetchImpl = windowImpl.fetch ? windowImpl.fetch.bind(windowImpl) : undefined; + configureSettingsSync({ fetchImpl }); + + const storage = windowImpl.localStorage; + const savedHint = documentImpl.querySelector('[data-settings-saved]'); + let savedTimer = null; + function flashSaved() { + if (!savedHint) return; + savedHint.classList.add('visible'); + windowImpl.clearTimeout(savedTimer); + savedTimer = windowImpl.setTimeout(() => savedHint.classList.remove('visible'), 1200); + } + + const controls = Array.from(documentImpl.querySelectorAll('[data-setting]')); + + // Populate the done-sound dropdown before applying stored values. + const soundSelect = documentImpl.querySelector('[data-setting-sound]'); + if (soundSelect) { + const data = await fetchAvailableSounds({ fetchImpl: fetchImpl || fetch }); + const sounds = data.sounds || ['cat.mp3', 'done.mp3']; + soundSelect.innerHTML = ''; + for (const name of sounds) { + const opt = documentImpl.createElement('option'); + opt.value = name; + opt.textContent = name; + soundSelect.appendChild(opt); + } + } + + // Pull the authoritative values from the server (falls back to the cache / + // defaults the server returns) and reflect them in the controls. + const settings = (await hydrateSettings({ fetchImpl, storage })) || readFromStorage(storage, controls); + + for (const el of controls) { + const key = el.dataset.setting; + const value = settings && key in settings ? settings[key] : storage?.getItem(key); + if (value == null) continue; + if (el.dataset.settingBool !== undefined) { + el.checked = String(value) === 'true'; + } else { + el.value = String(value); + } + } + + // Re-default the sound selector if the stored value is no longer available. + if (soundSelect && !Array.from(soundSelect.options).some((o) => o.value === soundSelect.value)) { + soundSelect.value = getSelectedSound({ storage }); + } + + for (const el of controls) { + const key = el.dataset.setting; + el.addEventListener('change', async () => { + if (el.dataset.settingTheme !== undefined) { + // applyTheme writes through (theme + cookie) and updates the DOM live. + applyTheme(windowImpl, documentImpl, el.value); + } else if (el.dataset.settingNotify !== undefined) { + await handleNotifyToggle(el); + } else if (el.dataset.settingBool !== undefined) { + writeSetting(key, el.checked ? 'true' : 'false', { storage }); + } else { + writeSetting(key, el.value, { storage }); + if (el.dataset.settingSound !== undefined) { + playDoneSound({ windowImpl, storage }); + } + } + flashSaved(); + }); + } + + // Enabling notifications also requests browser permission and registers a + // push subscription for THIS device (the subscription is per-device and is + // not part of the synced setting); disabling unregisters it. + async function handleNotifyToggle(el) { + if (!el.checked) { + setDoneNotifyEnabled(false, { storage }); + await unregisterPushSubscription({ windowImpl, fetchImpl: fetchImpl || fetch }); + return; + } + const permission = await requestNotifyPermission({ windowImpl }); + const granted = permission === 'granted'; + el.checked = granted; + setDoneNotifyEnabled(granted, { storage }); + if (granted) { + await registerPushSubscription({ windowImpl, fetchImpl: fetchImpl || fetch }); + } + } +} + +function readFromStorage(storage, controls) { + const out = {}; + for (const el of controls) { + const key = el.dataset.setting; + try { + const v = storage?.getItem(key); + if (v != null) out[key] = v; + } catch { + // ignore + } + } + return out; +} + +if (typeof document !== 'undefined' && typeof window !== 'undefined') { + runSettingsPage(); +} diff --git a/web/src/shared/settings-store.js b/web/src/shared/settings-store.js new file mode 100644 index 0000000..c333bb8 --- /dev/null +++ b/web/src/shared/settings-store.js @@ -0,0 +1,125 @@ +/** + * Write-through cache for server-backed user settings. + * + * The server (pi-web.sqlite `settings` table) is the source of truth. These + * keys are mirrored into localStorage so the UI can read them synchronously and + * paint without waiting on the network, but every change is written through to + * the server so it survives a restart and is shared across browsers hitting the + * same instance. + * + * Per-window / live-timer / ephemeral state (sidebar widths, focus countdown, + * tree toggle state, collapsed groups) is intentionally NOT listed here — it + * stays in localStorage only and is never synced. + */ + +// The localStorage keys that are server-backed. Mirrors settingDefaults in +// internal/server/settings.go. +export const SERVER_SETTING_KEYS = [ + 'pi-web-theme', + 'pi-sessions:spinner-style', + 'pi-share:v1:notify-on-done', + 'pi-share:v1:done-sound', + 'pi-sessions:view-layout', + 'pi-web:v1:cat:enabled', + 'pi-web:v1:cat:focus-min', + 'pi-web:v1:cat:break-min', + 'pi-web:v1:cat:bedtime', + 'pi-web:v1:cat:sleep-min', +]; + +// Network sync is disabled until a page entrypoint configures it. This keeps +// unit tests (which exercise the pure setters with a fake storage) free of +// network calls. +let syncFetch = null; + +export function configureSettingsSync({ fetchImpl } = {}) { + syncFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null); +} + +export function resetSettingsSyncForTests() { + syncFetch = null; +} + +function defaultStorage() { + try { + return globalThis.localStorage; + } catch { + return null; + } +} + +function postSettings(settings) { + if (!syncFetch) return; + try { + const p = syncFetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ settings }), + }); + if (p && typeof p.catch === 'function') p.catch(() => {}); + } catch { + // best-effort; localStorage already holds the value + } +} + +/** + * Write a single server-backed setting: update the localStorage cache and push + * it to the server. Unknown keys are written locally but not synced. + */ +export function writeSetting(key, value, { storage = defaultStorage() } = {}) { + const str = String(value); + try { + storage?.setItem(key, str); + } catch { + // ignore quota/availability errors + } + if (SERVER_SETTING_KEYS.includes(key)) { + postSettings({ [key]: str }); + } +} + +/** + * Write several settings at once (single POST). + */ +export function writeSettings(values, { storage = defaultStorage() } = {}) { + const toSync = {}; + for (const [key, value] of Object.entries(values || {})) { + const str = String(value); + try { + storage?.setItem(key, str); + } catch { + // ignore + } + if (SERVER_SETTING_KEYS.includes(key)) toSync[key] = str; + } + if (Object.keys(toSync).length > 0) postSettings(toSync); +} + +/** + * Pull server-backed settings from the server and seed the localStorage cache. + * Call once on page load. Resolves to the settings object (or null on failure). + * Writes are done directly to storage (not via writeSetting) so hydration does + * not echo the values straight back to the server. + */ +export async function hydrateSettings({ fetchImpl = syncFetch, storage = defaultStorage() } = {}) { + if (!fetchImpl) return null; + try { + const resp = await fetchImpl('/api/settings', { headers: { Accept: 'application/json' } }); + if (!resp.ok) return null; + const data = await resp.json(); + const settings = data && data.settings ? data.settings : null; + if (!settings) return null; + for (const key of SERVER_SETTING_KEYS) { + if (key in settings && settings[key] != null) { + try { + storage?.setItem(key, String(settings[key])); + } catch { + // ignore + } + } + } + return settings; + } catch { + return null; + } +} diff --git a/web/src/shared/settings-store.test.js b/web/src/shared/settings-store.test.js new file mode 100644 index 0000000..997a055 --- /dev/null +++ b/web/src/shared/settings-store.test.js @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + SERVER_SETTING_KEYS, + configureSettingsSync, + resetSettingsSyncForTests, + writeSetting, + writeSettings, + hydrateSettings, +} from './settings-store.js'; + +function fakeStorage() { + const map = new Map(); + return { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => map.set(k, String(v)), + removeItem: (k) => map.delete(k), + _map: map, + }; +} + +afterEach(() => { + resetSettingsSyncForTests(); +}); + +describe('writeSetting', () => { + it('writes to localStorage without posting when sync is not configured', () => { + const storage = fakeStorage(); + writeSetting('pi-web-theme', 'nord', { storage }); + expect(storage.getItem('pi-web-theme')).toBe('nord'); + }); + + it('posts a server-backed key through to /api/settings when sync is configured', () => { + const storage = fakeStorage(); + const fetchImpl = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })); + configureSettingsSync({ fetchImpl }); + + writeSetting('pi-web-theme', 'light', { storage }); + + expect(storage.getItem('pi-web-theme')).toBe('light'); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, opts] = fetchImpl.mock.calls[0]; + expect(url).toBe('/api/settings'); + expect(opts.method).toBe('POST'); + expect(JSON.parse(opts.body)).toEqual({ settings: { 'pi-web-theme': 'light' } }); + }); + + it('does not post unknown (non-server-backed) keys', () => { + const storage = fakeStorage(); + const fetchImpl = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })); + configureSettingsSync({ fetchImpl }); + + writeSetting('pi-web:v1:right-sidebar-width', '320', { storage }); + + expect(storage.getItem('pi-web:v1:right-sidebar-width')).toBe('320'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + +describe('writeSettings', () => { + it('batches server-backed keys into a single POST', () => { + const storage = fakeStorage(); + const fetchImpl = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })); + configureSettingsSync({ fetchImpl }); + + writeSettings({ 'pi-web-theme': 'dracula', 'pi-sessions:spinner-style': 'braille' }, { storage }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual({ + settings: { 'pi-web-theme': 'dracula', 'pi-sessions:spinner-style': 'braille' }, + }); + }); +}); + +describe('hydrateSettings', () => { + it('seeds localStorage from the server response', async () => { + const storage = fakeStorage(); + const settings = {}; + for (const k of SERVER_SETTING_KEYS) settings[k] = 'x'; + settings['pi-web-theme'] = 'nord'; + const fetchImpl = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ settings }) })); + + const result = await hydrateSettings({ fetchImpl, storage }); + + expect(result['pi-web-theme']).toBe('nord'); + expect(storage.getItem('pi-web-theme')).toBe('nord'); + expect(storage.getItem('pi-sessions:spinner-style')).toBe('x'); + }); + + it('returns null and leaves storage untouched on failure', async () => { + const storage = fakeStorage(); + const fetchImpl = vi.fn(() => Promise.resolve({ ok: false })); + const result = await hydrateSettings({ fetchImpl, storage }); + expect(result).toBeNull(); + expect(storage._map.size).toBe(0); + }); + + it('no-ops without a fetch impl', async () => { + const storage = fakeStorage(); + const result = await hydrateSettings({ fetchImpl: null, storage }); + expect(result).toBeNull(); + }); +}); diff --git a/web/src/shared/theme.js b/web/src/shared/theme.js index edf88b3..01f82d3 100644 --- a/web/src/shared/theme.js +++ b/web/src/shared/theme.js @@ -1,16 +1,26 @@ +import { writeSetting } from './settings-store.js'; + +// Body/chrome background colors per theme, kept in sync with the inline boot +// scripts in internal/ui/live_page.go. The boot script sets an inline +// background-color on before first paint; applyTheme must update that +// same inline style on a live theme switch, otherwise the page surround keeps +// the previous theme's color until the next reload. +const BODY_BGS = { dark: '#111116', light: '#f6f5f2', nord: '#2e3440', dracula: '#282a36' }; +const CHROME_BGS = { dark: '#0f0f14', light: '#ddddda', nord: '#292f3a', dracula: '#242631' }; + export function applyTheme(windowImpl, documentImpl, next) { next = next || 'dark'; documentImpl.documentElement.dataset.theme = next; - try { windowImpl.localStorage.setItem('pi-web-theme', next); } catch (e) {} + writeSetting('pi-web-theme', next, { storage: windowImpl.localStorage }); try { documentImpl.cookie = 'pi-web-theme=' + next + ';path=/;SameSite=Lax;max-age=31536000'; } catch (e) {} + + const wco = !!(windowImpl.navigator + && windowImpl.navigator.windowControlsOverlay + && windowImpl.navigator.windowControlsOverlay.visible); + const color = (wco ? CHROME_BGS : BODY_BGS)[next] || BODY_BGS.dark; + try { documentImpl.documentElement.style.backgroundColor = color; } catch (e) {} const meta = documentImpl.querySelector('meta[name="theme-color"]'); - if (meta) { - let color = '#111116'; - if (next === 'light') color = '#f6f5f2'; - else if (next === 'nord') color = '#2e3440'; - else if (next === 'dracula') color = '#282a36'; - meta.content = color; - } + if (meta) meta.content = color; } export function toggleTheme(windowImpl, documentImpl) { diff --git a/web/vite.config.js b/web/vite.config.js index 8ce4fd5..305d611 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -11,6 +11,7 @@ export default defineConfig({ input: { index: resolve(__dirname, 'src/index/index.js'), session: resolve(__dirname, 'src/session/session.js'), + settings: resolve(__dirname, 'src/settings/settings.js'), live: resolve(__dirname, 'src/live/live.js') }, output: { From e4f094c6d0460da80d178d2869ad799e4290db8b Mon Sep 17 00:00:00 2001 From: setkyar Date: Tue, 2 Jun 2026 10:50:11 +0700 Subject: [PATCH 2/6] feat(keyboard): open settings with Cmd/Ctrl+, Add a global Cmd/Ctrl+, shortcut (the standard preferences shortcut) that navigates to /settings. It works regardless of focus, like a native app, and is installed on every page via setupKeyboardNav. --- web/src/shared/keyboard-nav.js | 9 +++++++++ web/src/shared/keyboard-nav.test.js | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/web/src/shared/keyboard-nav.js b/web/src/shared/keyboard-nav.js index 69f2119..6c2f67a 100644 --- a/web/src/shared/keyboard-nav.js +++ b/web/src/shared/keyboard-nav.js @@ -51,6 +51,15 @@ export function setupKeyboardNav({ } }, { capture: true }); + // Cmd/Ctrl+, opens the global settings page (standard macOS preferences + // shortcut). Works regardless of focus, like a native app. + documentImpl.addEventListener('keydown', (e) => { + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key === ',') { + e.preventDefault(); + windowImpl.location.href = '/settings'; + } + }); + documentImpl.addEventListener('keydown', (e) => { if (e.metaKey || e.ctrlKey || e.altKey) return; if (isEditableTarget(documentImpl.activeElement)) return; diff --git a/web/src/shared/keyboard-nav.test.js b/web/src/shared/keyboard-nav.test.js index 526b879..8f56672 100644 --- a/web/src/shared/keyboard-nav.test.js +++ b/web/src/shared/keyboard-nav.test.js @@ -136,6 +136,33 @@ describe('setupKeyboardNav', () => { expect(escapeCall).toBeTruthy(); }); + it('navigates to /settings on Cmd+, (and Ctrl+,)', () => { + const doc = createMockDocument(); + const win = createMockWindow(); + win.location = { href: '' }; + + setupKeyboardNav({ windowImpl: win, documentImpl: doc }); + + const e1 = doc._dispatch('keydown', { key: ',', metaKey: true }); + expect(win.location.href).toBe('/settings'); + expect(e1.preventDefault).toHaveBeenCalled(); + + win.location.href = ''; + doc._dispatch('keydown', { key: ',', ctrlKey: true }); + expect(win.location.href).toBe('/settings'); + }); + + it('does not navigate to /settings on Cmd+Shift+,', () => { + const doc = createMockDocument(); + const win = createMockWindow(); + win.location = { href: '' }; + + setupKeyboardNav({ windowImpl: win, documentImpl: doc }); + doc._dispatch('keydown', { key: ',', metaKey: true, shiftKey: true }); + + expect(win.location.href).toBe(''); + }); + it('scrolls down on j', () => { const doc = createMockDocument(); const win = createMockWindow(); From ad914458b77a046928e6430d43045c209b720ec4 Mon Sep 17 00:00:00 2001 From: setkyar Date: Tue, 2 Jun 2026 10:50:19 +0700 Subject: [PATCH 3/6] refactor(menu): link Settings, reorder above Version, drop dead items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the Settings placeholder into a real /settings link with a ⌘, hint, move Settings above Version so Version always sits at the bottom, and remove the non-functional Import Session / Active Sessions / Archived Sessions items. --- internal/ui/live_menu.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/internal/ui/live_menu.go b/internal/ui/live_menu.go index b537dae..ac709f7 100644 --- a/internal/ui/live_menu.go +++ b/internal/ui/live_menu.go @@ -56,23 +56,18 @@ func homeMenuHTML() template.HTML { {Items: []liveMenuItem{ {Label: "New Session", Attrs: `data-new-session-btn role="menuitem"`}, {Label: "Manage Projects", Attrs: `id="manage-projects-btn" data-manage-projects-btn role="menuitem"`}, - {Label: "Import Session", Muted: true, Attrs: `role="menuitem"`}, - }}, - {Items: []liveMenuItem{ - {Label: "Active Sessions", Muted: true, Attrs: `role="menuitem"`}, - {Label: "Archived Sessions", Muted: true, Attrs: `role="menuitem"`}, }}, {Items: []liveMenuItem{ {Label: "Documentation", Href: "https://github.com/ygncode/pi-web/tree/main/docs", Attrs: `target="_blank" rel="noreferrer" role="menuitem"`}, {Label: "GitHub", Href: "https://github.com/ygncode/pi-web", Attrs: `target="_blank" rel="noreferrer" role="menuitem"`}, }}, {Items: []liveMenuItem{ + {Label: "Settings", Suffix: "⌘,", Href: "/settings", Attrs: `role="menuitem"`}, { Label: "Version", Suffix: ``, Attrs: `id="index-version-row" data-version-row role="menuitem"`, }, - {Label: "Settings", Suffix: "", Href: "/settings", Attrs: `role="menuitem"`}, }}, }, }) @@ -113,12 +108,12 @@ func sessionMenuHTML(id, class, bodyClass, itemClass, versionStatusID, container {Label: "GitHub", Href: "https://github.com/ygncode/pi-web", Attrs: `target="_blank" rel="noreferrer" role="menuitem"`}, }}, {Items: []liveMenuItem{ + {Label: "Settings", Suffix: "⌘,", Href: "/settings", Attrs: `role="menuitem"`}, { Label: "Version", Suffix: template.HTML(""), Attrs: `data-action="version" data-version-row role="menuitem"`, }, - {Label: "Settings", Suffix: "", Href: "/settings", Attrs: `role="menuitem"`}, }}, }, }) From e086e9a55d28e42eb0eca2ef7a81b0e9150c0ea2 Mon Sep 17 00:00:00 2001 From: setkyar Date: Tue, 2 Jun 2026 10:50:27 +0700 Subject: [PATCH 4/6] feat(cat-gatekeeper): add configurable wakeup time and snooze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fixed 8-hour sleep window with an explicit wakeup time (default 23:00–07:00) computed from bedtime→wakeup, and add a one-time snooze at the bedtime warning. Settings persistence for the new wakeup key lands with the settings-page changes. --- .../session/cat-gatekeeper/cat-gatekeeper.js | 65 +++++++++++++++---- .../cat-gatekeeper/cat-gatekeeper.test.js | 36 ++++++++++ .../session/cat-gatekeeper/cat-settings.js | 17 ++++- .../cat-gatekeeper/cat-settings.test.js | 4 +- 4 files changed, 107 insertions(+), 15 deletions(-) diff --git a/web/src/session/cat-gatekeeper/cat-gatekeeper.js b/web/src/session/cat-gatekeeper/cat-gatekeeper.js index 4d05cd2..1074e55 100644 --- a/web/src/session/cat-gatekeeper/cat-gatekeeper.js +++ b/web/src/session/cat-gatekeeper/cat-gatekeeper.js @@ -17,11 +17,10 @@ const TICK_MS = 1000; // Cap how much a single focus tick can subtract so a throttled/backgrounded // tab that fires a delayed tick can't burn a big chunk of focus time at once. const MAX_FOCUS_STEP_MS = 2000; -// How long after bedtime the goodnight nudge still fires (handles opening -// pi-web well after bedtime, including past midnight). -const SLEEP_WINDOW_MIN = 8 * 60; // Remaining-focus persistence is only trusted across short reloads. const FOCUS_RESTORE_MAX_AGE_MS = 30 * 60 * 1000; +// One-time snooze grants this much extra time at the bedtime soft warning. +const SNOOZE_MS = 5 * 60 * 1000; const FOCUS_REMAINING_KEY = 'pi-web:v1:cat:focus-remaining-ms'; const FOCUS_SAVED_AT_KEY = 'pi-web:v1:cat:focus-saved-at'; @@ -40,12 +39,22 @@ function formatMMSS(ms) { return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } -function bedtimeToMinutes(bedtime) { - const match = /^(\d{1,2}):(\d{2})$/.exec(String(bedtime)); - if (!match) return 23 * 60; +function timeToMinutes(value, fallbackMin) { + const match = /^(\d{1,2}):(\d{2})$/.exec(String(value)); + if (!match) return fallbackMin; return Number(match[1]) * 60 + Number(match[2]); } +// Minutes from bedtime to wakeup, wrapping past midnight. 0 means the window is +// empty (bedtime === wakeup) and the sleepy cat never triggers. +function sleepWindowMinutes(bedtime, wakeup) { + const bed = timeToMinutes(bedtime, 23 * 60); + const wake = timeToMinutes(wakeup, 7 * 60); + let span = (wake - bed) % 1440; + if (span < 0) span += 1440; + return span; +} + export function setupCatGatekeeper({ documentImpl = document, windowImpl = window, @@ -60,11 +69,13 @@ export function setupCatGatekeeper({ let intervalId = null; const state = { - phase: 'focus', // focus | break | sleep | sleep-locked + phase: 'focus', // focus | break | sleep | snooze | sleep-locked focusRemainingMs: 0, breakRemainingMs: 0, sleepElapsedMs: 0, sleepTriggered: false, + snoozeUsed: false, + snoozeRemainingMs: 0, lastTickAt: nowFn(), }; @@ -101,12 +112,14 @@ export function setupCatGatekeeper({ return focusTotalMs; } - function inSleepWindow(bedtime) { + function inSleepWindow(bedtime, wakeup) { + const window = sleepWindowMinutes(bedtime, wakeup); + if (window <= 0) return false; const d = new Date(nowFn()); const cur = d.getHours() * 60 + d.getMinutes(); - let diff = (cur - bedtimeToMinutes(bedtime)) % 1440; + let diff = (cur - timeToMinutes(bedtime, 23 * 60)) % 1440; if (diff < 0) diff += 1440; - return diff < SLEEP_WINDOW_MIN; + return diff < window; } // --- overlay ------------------------------------------------------------- @@ -122,8 +135,11 @@ export function setupCatGatekeeper({
+ `; documentImpl.body.appendChild(overlay); + const snoozeBtn = overlay.querySelector('[data-cat-snooze]'); + snoozeBtn?.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); snooze(); }); return overlay; } @@ -178,17 +194,22 @@ export function setupCatGatekeeper({ const el = ensureOverlay(); const timer = el.querySelector('[data-cat-timer]'); const msg = el.querySelector('[data-cat-message]'); + const snoozeBtn = el.querySelector('[data-cat-snooze]'); if (timer) { timer.style.display = ''; timer.textContent = formatMMSS(state.breakRemainingMs); } // Break shows only the countdown box (no message overlapping the cat). if (msg) { msg.textContent = ''; msg.style.display = 'none'; } + if (snoozeBtn) snoozeBtn.style.display = 'none'; } function renderSleep(locked) { const el = ensureOverlay(); const timer = el.querySelector('[data-cat-timer]'); const msg = el.querySelector('[data-cat-message]'); + const snoozeBtn = el.querySelector('[data-cat-snooze]'); if (timer) timer.style.display = 'none'; if (msg) { msg.style.display = ''; msg.textContent = locked ? 'Locked for the night — get some rest.' : 'Time to sleep!'; } + // Snooze is offered only during the soft warning, and only until used once. + if (snoozeBtn) snoozeBtn.style.display = (!locked && !state.snoozeUsed) ? '' : 'none'; if (locked) el.classList.add('cat-overlay--locked'); } @@ -216,6 +237,16 @@ export function setupCatGatekeeper({ renderSleep(false); } + // One-time bedtime snooze: dismisses the soft warning and grants SNOOZE_MS + // before the sleepy cat returns. Ignored once already used or past the warning. + function snooze() { + if (state.phase !== 'sleep' || state.snoozeUsed) return; + state.snoozeUsed = true; + state.phase = 'snooze'; + state.snoozeRemainingMs = SNOOZE_MS; + hideOverlay(); + } + function tick() { const now = nowFn(); const realDelta = Math.max(0, now - state.lastTickAt); @@ -229,7 +260,7 @@ export function setupCatGatekeeper({ // Bedtime overrides everything and, once triggered, is sticky for the session. if (state.phase !== 'sleep' && state.phase !== 'sleep-locked' - && !state.sleepTriggered && isActive() && inSleepWindow(cfg.bedtime)) { + && !state.sleepTriggered && isActive() && inSleepWindow(cfg.bedtime, cfg.wakeup)) { enterSleep(); return; } @@ -257,6 +288,15 @@ export function setupCatGatekeeper({ } break; } + case 'snooze': { + state.snoozeRemainingMs -= realDelta; + if (state.snoozeRemainingMs <= 0) { + // Still night? Bring the cat back. Otherwise the window passed — resume focus. + if (inSleepWindow(cfg.bedtime, cfg.wakeup)) enterSleep(); + else { state.phase = 'focus'; state.focusRemainingMs = cfg.focusMin * 60000; } + } + break; + } default: break; } @@ -269,6 +309,7 @@ export function setupCatGatekeeper({ if (!cfg.enabled) return 'Cat Gatekeeper is off.'; switch (state.phase) { case 'break': return `On a break — ${formatMMSS(state.breakRemainingMs)} left.`; + case 'snooze': return `Snoozed — back to bed in ${formatMMSS(state.snoozeRemainingMs)}.`; case 'sleep': case 'sleep-locked': return 'Bedtime — time to sleep.'; default: return `Next break in ${formatMMSS(state.focusRemainingMs)}.`; @@ -323,6 +364,6 @@ export function setupCatGatekeeper({ overlay = null; } - const controller = { start, destroy, tick, getStatusText, skipToBreak, openSettings, getState: () => ({ ...state }) }; + const controller = { start, destroy, tick, getStatusText, skipToBreak, snooze, openSettings, getState: () => ({ ...state }) }; return controller; } diff --git a/web/src/session/cat-gatekeeper/cat-gatekeeper.test.js b/web/src/session/cat-gatekeeper/cat-gatekeeper.test.js index fa62170..c037c06 100644 --- a/web/src/session/cat-gatekeeper/cat-gatekeeper.test.js +++ b/web/src/session/cat-gatekeeper/cat-gatekeeper.test.js @@ -129,4 +129,40 @@ describe('cat gatekeeper bedtime', () => { h.controller.start(); expect(h.controller.getState().phase).toBe('focus'); }); + + it('snoozes the soft warning once, then brings the cat back and locks', () => { + const h = harness({ hour: 23, minute: 0, settings: { bedtime: '23:00', wakeup: '07:00', sleepMin: 2 } }); + h.controller.start(); + expect(h.controller.getState().phase).toBe('sleep'); + expect(h.overlay().querySelector('[data-cat-snooze]').style.display).not.toBe('none'); + + // Click snooze: overlay dismisses and we wait out the 5-minute snooze. + h.overlay().querySelector('[data-cat-snooze]').click(); + expect(h.controller.getState().phase).toBe('snooze'); + expect(h.controller.getState().snoozeUsed).toBe(true); + expect(h.overlay().classList.contains('visible')).toBe(false); + + // After the snooze window, the sleepy cat returns (still bedtime). + h.tick(5 * 60000 + 1000); + expect(h.controller.getState().phase).toBe('sleep'); + // Snooze already used: the button is now hidden. + expect(h.overlay().querySelector('[data-cat-snooze]').style.display).toBe('none'); + + // A second snooze attempt is ignored. + h.controller.snooze(); + expect(h.controller.getState().phase).toBe('sleep'); + + // The reminder then locks as usual. + h.tick(2 * 60000 + 1000); + expect(h.controller.getState().phase).toBe('sleep-locked'); + }); + + it('does not snooze once locked', () => { + const h = harness({ hour: 23, minute: 0, settings: { bedtime: '23:00', sleepMin: 1 } }); + h.controller.start(); + h.tick(60_000 + 1000); + expect(h.controller.getState().phase).toBe('sleep-locked'); + h.controller.snooze(); + expect(h.controller.getState().phase).toBe('sleep-locked'); + }); }); diff --git a/web/src/session/cat-gatekeeper/cat-settings.js b/web/src/session/cat-gatekeeper/cat-settings.js index 541dfb5..d88e9ff 100644 --- a/web/src/session/cat-gatekeeper/cat-settings.js +++ b/web/src/session/cat-gatekeeper/cat-settings.js @@ -12,6 +12,7 @@ export const CAT_KEYS = { focusMin: 'pi-web:v1:cat:focus-min', breakMin: 'pi-web:v1:cat:break-min', bedtime: 'pi-web:v1:cat:bedtime', + wakeup: 'pi-web:v1:cat:wakeup', sleepMin: 'pi-web:v1:cat:sleep-min', }; @@ -20,7 +21,8 @@ export const CAT_DEFAULTS = { focusMin: 25, breakMin: 5, bedtime: '23:00', - sleepMin: 2, + wakeup: '07:00', + sleepMin: 5, }; const LIMITS = { @@ -57,6 +59,7 @@ export function loadCatSettings({ storage = globalThis.localStorage } = {}) { focusMin: clampInt(read(CAT_KEYS.focusMin), LIMITS.focusMin, CAT_DEFAULTS.focusMin), breakMin: clampInt(read(CAT_KEYS.breakMin), LIMITS.breakMin, CAT_DEFAULTS.breakMin), bedtime: normalizeBedtime(read(CAT_KEYS.bedtime)), + wakeup: normalizeBedtime(read(CAT_KEYS.wakeup), CAT_DEFAULTS.wakeup), sleepMin: clampInt(read(CAT_KEYS.sleepMin), LIMITS.sleepMin, CAT_DEFAULTS.sleepMin), }; } @@ -69,6 +72,7 @@ export function saveCatSettings(partial = {}, { storage = globalThis.localStorag if ('focusMin' in partial) write(CAT_KEYS.focusMin, clampInt(partial.focusMin, LIMITS.focusMin, CAT_DEFAULTS.focusMin)); if ('breakMin' in partial) write(CAT_KEYS.breakMin, clampInt(partial.breakMin, LIMITS.breakMin, CAT_DEFAULTS.breakMin)); if ('bedtime' in partial) write(CAT_KEYS.bedtime, normalizeBedtime(partial.bedtime)); + if ('wakeup' in partial) write(CAT_KEYS.wakeup, normalizeBedtime(partial.wakeup, CAT_DEFAULTS.wakeup)); if ('sleepMin' in partial) write(CAT_KEYS.sleepMin, clampInt(partial.sleepMin, LIMITS.sleepMin, CAT_DEFAULTS.sleepMin)); return loadCatSettings({ storage }); } @@ -153,6 +157,17 @@ export function showCatSettings({ }); root.appendChild(field('Bedtime', bedtime, 'When the cat says goodnight.')); + const wakeup = documentImpl.createElement('input'); + wakeup.type = 'time'; + wakeup.className = 'cat-settings-time'; + wakeup.value = settings.wakeup; + wakeup.addEventListener('change', () => { + const next = saveCatSettings({ wakeup: wakeup.value }, { storage }); + wakeup.value = next.wakeup; + onChange(next); + }); + root.appendChild(field('Wakeup', wakeup, 'When the cat lets you back in.')); + root.appendChild(field('Sleep reminder (minutes)', numberInput('sleepMin', settings.sleepMin), 'How long the sleepy cat stays before locking.')); // Live status: next break + skip-to-break. diff --git a/web/src/session/cat-gatekeeper/cat-settings.test.js b/web/src/session/cat-gatekeeper/cat-settings.test.js index ecf9e28..37f03e3 100644 --- a/web/src/session/cat-gatekeeper/cat-settings.test.js +++ b/web/src/session/cat-gatekeeper/cat-settings.test.js @@ -24,9 +24,9 @@ describe('cat settings storage', () => { it('round-trips saved values', () => { const storage = makeStorage(); - saveCatSettings({ enabled: false, focusMin: 50, breakMin: 10, bedtime: '22:30', sleepMin: 3 }, { storage }); + saveCatSettings({ enabled: false, focusMin: 50, breakMin: 10, bedtime: '22:30', wakeup: '06:30', sleepMin: 3 }, { storage }); expect(loadCatSettings({ storage })).toEqual({ - enabled: false, focusMin: 50, breakMin: 10, bedtime: '22:30', sleepMin: 3, + enabled: false, focusMin: 50, breakMin: 10, bedtime: '22:30', wakeup: '06:30', sleepMin: 3, }); }); From 22023bca3a043b6bec49486a78aa2c1fc0d4d1b4 Mon Sep 17 00:00:00 2001 From: setkyar Date: Tue, 2 Jun 2026 10:50:40 +0700 Subject: [PATCH 5/6] feat(settings): adjustable fonts/sizes, wakeup row, and smart back nav Add server-backed interface and content fonts (curated keywords, typed custom families, or Local Font Access detection) plus separate UI/content font sizes, all injected into the HTML shell so pages paint correctly before JS runs and applied live on the settings page. Add the Cat Gatekeeper wakeup row + its persistence plumbing, and make the settings back button return to the previous in-app page (e.g. a session detail) instead of always going home. --- internal/app/app.go | 1 + internal/server/settings.go | 93 ++++++++++-- internal/ui/live_page.go | 30 ++++ internal/ui/live_templates/settings.html | 71 ++++++++- internal/ui/live_templates/styles/index.css | 2 +- internal/ui/live_templates/styles/session.css | 31 +++- .../ui/live_templates/styles/settings.css | 28 +++- internal/ui/live_templates/styles/theme.css | 7 + web/src/settings/settings.js | 140 ++++++++++++++++++ web/src/settings/settings.test.js | 91 ++++++++++++ web/src/shared/fonts.js | 61 ++++++++ web/src/shared/fonts.test.js | 70 +++++++++ web/src/shared/settings-store.js | 5 + 13 files changed, 609 insertions(+), 21 deletions(-) create mode 100644 web/src/settings/settings.test.js create mode 100644 web/src/shared/fonts.js create mode 100644 web/src/shared/fonts.test.js diff --git a/internal/app/app.go b/internal/app/app.go index 79c3cfa..deff64c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -96,6 +96,7 @@ func Main(version string) { }) ui.SetThemeProvider(srv.ThemeSetting) + ui.SetFontProvider(srv.FontStyles) mux := http.NewServeMux() srv.Register(mux) diff --git a/internal/server/settings.go b/internal/server/settings.go index 1cd4484..87c3a22 100644 --- a/internal/server/settings.go +++ b/internal/server/settings.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" "os" + "strconv" + "strings" "time" ) @@ -29,16 +31,21 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { // live-timer state (sidebar widths, focus countdown, tree toggles) is NOT // listed here — it stays in localStorage only. var settingDefaults = map[string]string{ - "pi-web-theme": "dark", - "pi-sessions:spinner-style": "runcat", - "pi-share:v1:notify-on-done": "false", - "pi-share:v1:done-sound": "cat.mp3", - "pi-sessions:view-layout": "timeline", - "pi-web:v1:cat:enabled": "true", - "pi-web:v1:cat:focus-min": "25", - "pi-web:v1:cat:break-min": "5", - "pi-web:v1:cat:bedtime": "23:00", - "pi-web:v1:cat:sleep-min": "2", + "pi-web-theme": "dark", + "pi-web:v1:font-ui": "mono", + "pi-web:v1:font-content": "mono", + "pi-web:v1:font-ui-size": "12", + "pi-web:v1:font-content-size": "13", + "pi-sessions:spinner-style": "runcat", + "pi-share:v1:notify-on-done": "false", + "pi-share:v1:done-sound": "cat.mp3", + "pi-sessions:view-layout": "timeline", + "pi-web:v1:cat:enabled": "true", + "pi-web:v1:cat:focus-min": "25", + "pi-web:v1:cat:break-min": "5", + "pi-web:v1:cat:bedtime": "23:00", + "pi-web:v1:cat:wakeup": "07:00", + "pi-web:v1:cat:sleep-min": "2", } // getSettings returns every server-backed setting: defaults overlaid with any @@ -92,6 +99,72 @@ func (s *Server) ThemeSetting() string { return s.getSetting("pi-web-theme", "dark") } +// fontKeywords maps a curated font name to a full CSS font-family stack. The +// stored font value is either one of these keywords or a raw family name the +// user typed / picked from their installed fonts. Kept in sync with +// web/src/shared/fonts.js. +var fontKeywords = map[string]string{ + "mono": "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace", + "system": "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", + "sans": "Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", + "serif": "Georgia, 'Times New Roman', Times, serif", +} + +// sanitizeFontFamily strips a raw family name down to a safe subset so it can be +// injected into a CSS \n") b.WriteString("\n
- Sessions + Sessions

Settings

@@ -23,6 +23,66 @@

Settings

+
+
+ Interface font + Font for the app UI — menus, lists, headers. +
+
+ + +
+
+
+
+ Interface font size + Size of the app UI text (px). +
+
+ +
+
+
+
+ Content font + Font for rendered message content. Code stays monospace. +
+
+ + +
+
+
+
+ Content font size + Size of rendered message text (px). +
+
+ +
+
@@ -119,6 +179,15 @@

Settings

+
+
+ Wakeup + When the cat lets you back in. +
+
+ +
+
Sleep reminder (minutes) diff --git a/internal/ui/live_templates/styles/index.css b/internal/ui/live_templates/styles/index.css index 9e4eb5f..0f9b57a 100644 --- a/internal/ui/live_templates/styles/index.css +++ b/internal/ui/live_templates/styles/index.css @@ -18,7 +18,7 @@ html { background: var(--body-bg); } body { font-family: var(--font-sans); - font-size: 13px; + font-size: var(--font-size-ui); line-height: var(--line-height); color: var(--text-soft); background: var(--body-bg); diff --git a/internal/ui/live_templates/styles/session.css b/internal/ui/live_templates/styles/session.css index 54475ae..0dc27a0 100644 --- a/internal/ui/live_templates/styles/session.css +++ b/internal/ui/live_templates/styles/session.css @@ -434,7 +434,7 @@ body { font-family: var(--font-sans); - font-size: 12px; + font-size: var(--font-size-ui); line-height: var(--line-height); color: var(--text); background: var(--body-bg); @@ -1997,6 +1997,8 @@ .markdown-content { overflow-wrap: anywhere; min-width: 0; + font-family: var(--font-content); + font-size: var(--font-content-size); } /* Markdown headings */ @@ -2030,7 +2032,7 @@ color: var(--mdCode); padding: 0 4px; border-radius: 3px; - font-family: inherit; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: break-word; } @@ -4484,6 +4486,31 @@ color: rgba(207, 211, 255, 0.95); } + .cat-snooze { + position: absolute; + bottom: 9vh; + left: 50%; + transform: translateX(-50%); + z-index: 3; + font-family: var(--font-sans, sans-serif); + font-size: clamp(15px, 1.6vw, 19px); + font-weight: 600; + color: rgba(255, 255, 255, 0.95); + background: rgba(0, 0, 0, 0.58); + border: 1px solid rgba(255, 255, 255, 0.28); + border-radius: 999px; + padding: 14px 30px; + cursor: pointer; + backdrop-filter: blur(2px); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.45); + transition: background 0.15s ease, border-color 0.15s ease; + } + + .cat-snooze:hover { + background: rgba(0, 0, 0, 0.72); + border-color: rgba(255, 255, 255, 0.5); + } + /* --- settings sheet form --- */ .cat-settings { display: flex; flex-direction: column; gap: 4px; } diff --git a/internal/ui/live_templates/styles/settings.css b/internal/ui/live_templates/styles/settings.css index 550e5e2..e4463a6 100644 --- a/internal/ui/live_templates/styles/settings.css +++ b/internal/ui/live_templates/styles/settings.css @@ -2,6 +2,9 @@ max-width: 720px; margin: 0 auto; padding: 24px 20px 80px; + /* Base the page on the interface font size so it reflects the setting and + previews changes live (child text sizes are em-relative to this). */ + font-size: var(--font-size-ui); } .settings-header { @@ -17,7 +20,7 @@ gap: 6px; color: var(--text-soft); text-decoration: none; - font-size: 0.9rem; + font-size: 1em; padding: 6px 10px; border-radius: 8px; border: 1px solid var(--dim); @@ -30,7 +33,7 @@ } .settings-header h1 { - font-size: 1.4rem; + font-size: 1.6em; margin: 0; color: var(--text); } @@ -44,7 +47,7 @@ } .settings-section-title { - font-size: 0.75rem; + font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); @@ -73,12 +76,12 @@ .settings-row-label .name { color: var(--text); - font-size: 0.95rem; + font-size: 1.05em; } .settings-row-label .hint { color: var(--muted); - font-size: 0.8rem; + font-size: 0.9em; } .settings-control select, @@ -89,7 +92,7 @@ border: 1px solid var(--dim); border-radius: 8px; padding: 7px 10px; - font-size: 0.9rem; + font-size: 1em; font-family: inherit; min-width: 130px; } @@ -98,6 +101,17 @@ min-width: 90px; } +.settings-font-control { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; +} + +.settings-font-custom { + min-width: 160px; +} + /* Toggle switch */ .settings-toggle { position: relative; @@ -148,7 +162,7 @@ .settings-saved-hint { text-align: center; color: var(--muted); - font-size: 0.8rem; + font-size: 0.9em; margin-top: 8px; min-height: 1em; transition: opacity 0.3s ease; diff --git a/internal/ui/live_templates/styles/theme.css b/internal/ui/live_templates/styles/theme.css index ea1cea4..4b77a21 100644 --- a/internal/ui/live_templates/styles/theme.css +++ b/internal/ui/live_templates/styles/theme.css @@ -12,6 +12,13 @@ :root { --font-sans: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; + /* Font for rendered message content; falls back to the interface font and + is overridden by the server-injected #pi-web-fonts style when set. */ + --font-content: var(--font-sans); + /* Adjustable font sizes (overridden by the server-injected #pi-web-fonts + style and live by the settings page). */ + --font-size-ui: 12px; + --font-content-size: 13px; --palette-surface: var(--surface); --palette-surface-2: var(--surface-2); --palette-text-soft: var(--text-soft); diff --git a/web/src/settings/settings.js b/web/src/settings/settings.js index d082c48..54ad1c7 100644 --- a/web/src/settings/settings.js +++ b/web/src/settings/settings.js @@ -1,5 +1,6 @@ import { configureSettingsSync, hydrateSettings, writeSetting } from '../shared/settings-store.js'; import { applyTheme } from '../shared/theme.js'; +import { applyFonts } from '../shared/fonts.js'; import { fetchAvailableSounds, getSelectedSound, @@ -18,6 +19,11 @@ export async function runSettingsPage({ configureSettingsSync({ fetchImpl }); const storage = windowImpl.localStorage; + + // Back button: return to wherever the user came from (e.g. a session detail + // page) when they arrived from within the app, instead of always going home. + setupBackLink(documentImpl, windowImpl); + const savedHint = documentImpl.querySelector('[data-settings-saved]'); let savedTimer = null; function flashSaved() { @@ -69,6 +75,11 @@ export async function runSettingsPage({ if (el.dataset.settingTheme !== undefined) { // applyTheme writes through (theme + cookie) and updates the DOM live. applyTheme(windowImpl, documentImpl, el.value); + } else if (el.dataset.settingSize !== undefined) { + writeSetting(key, el.value, { storage }); + applyFonts(documentImpl, el.dataset.settingSize === 'ui' + ? { uiSize: el.value } + : { contentSize: el.value }); } else if (el.dataset.settingNotify !== undefined) { await handleNotifyToggle(el); } else if (el.dataset.settingBool !== undefined) { @@ -83,6 +94,104 @@ export async function runSettingsPage({ }); } + // Font family controls (separate from the generic loop): a curated select + // plus "Detect installed fonts…" (Local Font Access API) and "Custom…" paths. + const FONT_KEYS = { ui: 'pi-web:v1:font-ui', content: 'pi-web:v1:font-content' }; + const fontControls = []; + + function injectDetectedFonts(select, families) { + const old = select.querySelector('optgroup[data-detected]'); + if (old) old.remove(); + const group = documentImpl.createElement('optgroup'); + group.label = 'Installed'; + group.setAttribute('data-detected', ''); + for (const fam of families) { + const opt = documentImpl.createElement('option'); + opt.value = fam; + opt.textContent = fam; + group.appendChild(opt); + } + const actions = select.querySelector('optgroup[label="Actions"]'); + select.insertBefore(group, actions); + } + + async function detectInstalledFonts() { + if (typeof windowImpl.queryLocalFonts !== 'function') { + windowImpl.alert?.('This browser cannot list installed fonts. Use Custom… to type a font name.'); + return; + } + let families; + try { + const fonts = await windowImpl.queryLocalFonts(); + families = Array.from(new Set(fonts.map((f) => f.family))) + .sort((a, b) => a.localeCompare(b)); + } catch { + windowImpl.alert?.('Could not read installed fonts (permission denied). Use Custom… to type a font name.'); + return; + } + if (!families.length) return; + for (const ctrl of fontControls) { + injectDetectedFonts(ctrl.select, families); + ctrl.resync(); + } + flashSaved(); + } + + function setupFontControl(kind) { + const key = FONT_KEYS[kind]; + const select = documentImpl.querySelector(`[data-font-select="${kind}"]`); + const custom = documentImpl.querySelector(`[data-font-custom="${kind}"]`); + if (!select) return; + + const stored = () => (settings && key in settings ? settings[key] : storage?.getItem(key)) || 'mono'; + + function resync() { + const value = stored(); + const hasOption = Array.from(select.options).some((o) => o.value === value); + if (hasOption) { + select.value = value; + if (custom) custom.hidden = true; + } else { + select.value = '__custom__'; + if (custom) { custom.value = value; custom.hidden = false; } + } + } + + function commit(value) { + writeSetting(key, value, { storage }); + applyFonts(documentImpl, kind === 'ui' ? { ui: value } : { content: value }); + flashSaved(); + } + + select.addEventListener('change', async () => { + const v = select.value; + if (v === '__detect__') { + resync(); // revert the visible selection; detection runs async + await detectInstalledFonts(); + return; + } + if (v === '__custom__') { + if (custom) { custom.hidden = false; custom.focus(); } + return; + } + if (custom) custom.hidden = true; + commit(v); + }); + + if (custom) { + custom.addEventListener('change', () => { + const fam = custom.value.trim(); + if (fam) commit(fam); + }); + } + + fontControls.push({ select, resync }); + resync(); + } + + setupFontControl('ui'); + setupFontControl('content'); + // Enabling notifications also requests browser permission and registers a // push subscription for THIS device (the subscription is per-device and is // not part of the synced setting); disabling unregisters it. @@ -102,6 +211,37 @@ export async function runSettingsPage({ } } +// Wire the back link so it returns to the previous in-app page (a session +// detail page, the index, etc.) when the user navigated here from within the +// app. Falls back to the href="/" home link for direct visits. +export function setupBackLink(documentImpl, windowImpl) { + const link = documentImpl.querySelector('[data-settings-back]'); + if (!link) return; + + let fromApp = false; + try { + const ref = documentImpl.referrer; + fromApp = !!ref + && new URL(ref).origin === windowImpl.location.origin + && new URL(ref).pathname !== '/settings'; + } catch { + fromApp = false; + } + + if (fromApp) { + const label = link.querySelector('[data-settings-back-label]'); + if (label) label.textContent = 'Back'; + link.addEventListener('click', (e) => { + e.preventDefault(); + if (windowImpl.history && windowImpl.history.length > 1) { + windowImpl.history.back(); + } else { + windowImpl.location.href = '/'; + } + }); + } +} + function readFromStorage(storage, controls) { const out = {}; for (const el of controls) { diff --git a/web/src/settings/settings.test.js b/web/src/settings/settings.test.js new file mode 100644 index 0000000..f4b5ada --- /dev/null +++ b/web/src/settings/settings.test.js @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest'; +import { setupBackLink } from './settings.js'; + +function makeLink() { + const label = { textContent: 'Sessions' }; + const handlers = {}; + const link = { + querySelector: (sel) => (sel === '[data-settings-back-label]' ? label : null), + addEventListener: (type, fn) => { handlers[type] = fn; }, + _click() { + const e = { preventDefault: vi.fn() }; + handlers.click?.(e); + return e; + }, + _label: label, + _hasClick: () => !!handlers.click, + }; + return link; +} + +function makeDoc(link, referrer) { + return { + referrer, + querySelector: (sel) => (sel === '[data-settings-back]' ? link : null), + }; +} + +function makeWin({ historyLength = 3 } = {}) { + return { + location: { origin: 'http://localhost:31415', href: '' }, + history: { length: historyLength, back: vi.fn() }, + }; +} + +describe('setupBackLink', () => { + it('goes back in history when arriving from an in-app page', () => { + const link = makeLink(); + const doc = makeDoc(link, 'http://localhost:31415/session?id=abc'); + const win = makeWin(); + + setupBackLink(doc, win); + + expect(link._label.textContent).toBe('Back'); + const e = link._click(); + expect(e.preventDefault).toHaveBeenCalled(); + expect(win.history.back).toHaveBeenCalled(); + }); + + it('leaves the home link alone on a direct visit (no referrer)', () => { + const link = makeLink(); + const doc = makeDoc(link, ''); + const win = makeWin(); + + setupBackLink(doc, win); + + expect(link._label.textContent).toBe('Sessions'); + expect(link._hasClick()).toBe(false); + }); + + it('ignores a referrer from the settings page itself', () => { + const link = makeLink(); + const doc = makeDoc(link, 'http://localhost:31415/settings'); + const win = makeWin(); + + setupBackLink(doc, win); + + expect(link._hasClick()).toBe(false); + }); + + it('ignores a cross-origin referrer', () => { + const link = makeLink(); + const doc = makeDoc(link, 'https://evil.example.com/page'); + const win = makeWin(); + + setupBackLink(doc, win); + + expect(link._hasClick()).toBe(false); + }); + + it('falls back to home when there is no usable history', () => { + const link = makeLink(); + const doc = makeDoc(link, 'http://localhost:31415/session?id=abc'); + const win = makeWin({ historyLength: 1 }); + + setupBackLink(doc, win); + link._click(); + + expect(win.history.back).not.toHaveBeenCalled(); + expect(win.location.href).toBe('/'); + }); +}); diff --git a/web/src/shared/fonts.js b/web/src/shared/fonts.js new file mode 100644 index 0000000..ff6b408 --- /dev/null +++ b/web/src/shared/fonts.js @@ -0,0 +1,61 @@ +/** + * Font registry + helpers shared by the settings UI. + * + * A stored font value is either a curated keyword ("mono"/"system"/"sans"/ + * "serif") or a raw family name the user typed or picked from their installed + * fonts (via the Local Font Access API). Keywords resolve to a full CSS stack; + * raw families are sanitized, quoted, and given the monospace stack as a + * fallback. Kept in sync with the Go side in internal/server/settings.go (the + * server injects the same resolved values into the HTML shell so pages paint + * with the chosen fonts/sizes before any JS runs). + */ + +export const FONT_UI_KEY = 'pi-web:v1:font-ui'; +export const FONT_CONTENT_KEY = 'pi-web:v1:font-content'; +export const FONT_UI_SIZE_KEY = 'pi-web:v1:font-ui-size'; +export const FONT_CONTENT_SIZE_KEY = 'pi-web:v1:font-content-size'; + +export const FONT_MIN_SIZE = 8; +export const FONT_MAX_SIZE = 32; + +export const FONT_KEYWORDS = { + mono: "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace", + system: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", + sans: "Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", + serif: "Georgia, 'Times New Roman', Times, serif", +}; + +// Strip a raw family name to a safe subset (letters, digits, spaces, hyphens). +export function sanitizeFontFamily(value) { + return String(value || '') + .replace(/[^A-Za-z0-9 -]/g, '') + .trim() + .slice(0, 64); +} + +export function resolveFontStack(value) { + if (FONT_KEYWORDS[value]) return FONT_KEYWORDS[value]; + const family = sanitizeFontFamily(value); + if (!family) return FONT_KEYWORDS.mono; + return `'${family}', ${FONT_KEYWORDS.mono}`; +} + +export function clampSize(value, fallback = 12) { + const n = Math.round(Number(value)); + if (!Number.isFinite(n)) return fallback; + return Math.min(FONT_MAX_SIZE, Math.max(FONT_MIN_SIZE, n)); +} + +/** + * Apply font choices live by setting CSS custom properties on the root element + * (inline element-level styles win over any stylesheet rule). Any field may be + * omitted. + */ +export function applyFonts(documentImpl, { ui, content, uiSize, contentSize } = {}) { + const root = documentImpl?.documentElement; + if (!root || !root.style) return; + if (ui) root.style.setProperty('--font-sans', resolveFontStack(ui)); + if (content) root.style.setProperty('--font-content', resolveFontStack(content)); + if (uiSize != null && uiSize !== '') root.style.setProperty('--font-size-ui', `${clampSize(uiSize)}px`); + if (contentSize != null && contentSize !== '') root.style.setProperty('--font-content-size', `${clampSize(contentSize)}px`); +} diff --git a/web/src/shared/fonts.test.js b/web/src/shared/fonts.test.js new file mode 100644 index 0000000..678c67c --- /dev/null +++ b/web/src/shared/fonts.test.js @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + FONT_KEYWORDS, + resolveFontStack, + sanitizeFontFamily, + clampSize, + applyFonts, +} from './fonts.js'; + +describe('resolveFontStack', () => { + it('resolves known keywords to their stacks', () => { + expect(resolveFontStack('serif')).toBe(FONT_KEYWORDS.serif); + expect(resolveFontStack('system')).toBe(FONT_KEYWORDS.system); + }); + + it('quotes a raw family name and appends the mono fallback', () => { + expect(resolveFontStack('Fira Code')).toBe(`'Fira Code', ${FONT_KEYWORDS.mono}`); + }); + + it('falls back to mono for empty/garbage values', () => { + expect(resolveFontStack('')).toBe(FONT_KEYWORDS.mono); + expect(resolveFontStack('<<<>>>')).toBe(FONT_KEYWORDS.mono); + }); +}); + +describe('sanitizeFontFamily', () => { + it('strips unsafe characters but keeps letters, digits, spaces, hyphens', () => { + expect(sanitizeFontFamily('JetBrains Mono')).toBe('JetBrains Mono'); + expect(sanitizeFontFamily('Comic Sans <>{};"')).toBe('Comic Sans'); + expect(sanitizeFontFamily('SF-Pro 3')).toBe('SF-Pro 3'); + }); +}); + +describe('clampSize', () => { + it('clamps to the 8..32 range and rounds', () => { + expect(clampSize(4)).toBe(8); + expect(clampSize(99)).toBe(32); + expect(clampSize('13')).toBe(13); + expect(clampSize('abc', 12)).toBe(12); + }); +}); + +describe('applyFonts', () => { + function fakeDoc() { + const props = {}; + return { + _props: props, + documentElement: { style: { setProperty: (n, v) => { props[n] = v; } } }, + }; + } + + it('sets font-family and size custom properties', () => { + const doc = fakeDoc(); + applyFonts(doc, { ui: 'serif', uiSize: '16' }); + expect(doc._props['--font-sans']).toBe(FONT_KEYWORDS.serif); + expect(doc._props['--font-size-ui']).toBe('16px'); + expect(doc._props['--font-content']).toBeUndefined(); + }); + + it('clamps sizes when applied', () => { + const doc = fakeDoc(); + applyFonts(doc, { contentSize: '999' }); + expect(doc._props['--font-content-size']).toBe('32px'); + }); + + it('no-ops without a usable document', () => { + expect(() => applyFonts(null, { ui: 'serif' })).not.toThrow(); + expect(() => applyFonts({}, { ui: 'serif' })).not.toThrow(); + }); +}); diff --git a/web/src/shared/settings-store.js b/web/src/shared/settings-store.js index c333bb8..6d09274 100644 --- a/web/src/shared/settings-store.js +++ b/web/src/shared/settings-store.js @@ -16,6 +16,10 @@ // internal/server/settings.go. export const SERVER_SETTING_KEYS = [ 'pi-web-theme', + 'pi-web:v1:font-ui', + 'pi-web:v1:font-content', + 'pi-web:v1:font-ui-size', + 'pi-web:v1:font-content-size', 'pi-sessions:spinner-style', 'pi-share:v1:notify-on-done', 'pi-share:v1:done-sound', @@ -24,6 +28,7 @@ export const SERVER_SETTING_KEYS = [ 'pi-web:v1:cat:focus-min', 'pi-web:v1:cat:break-min', 'pi-web:v1:cat:bedtime', + 'pi-web:v1:cat:wakeup', 'pi-web:v1:cat:sleep-min', ]; From 9c7fc06d0ddb006366d981ebad197fa6ee7969c6 Mon Sep 17 00:00:00 2001 From: setkyar Date: Tue, 2 Jun 2026 11:19:34 +0700 Subject: [PATCH 6/6] refactor(server): reject unsupported methods before auth on GET/POST routes Add a getPostHandler helper that routes GET/POST to their handlers and returns 405 (with an Allow header) for any other method before the auth middleware runs. Apply it to /api/projects, /api/scratchpad, and /api/settings. --- internal/server/server.go | 40 +++++++++++++++----------------- internal/server/settings_test.go | 24 +++++++++++++++++++ 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index a865a9d..3f83de5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -206,30 +206,12 @@ func (s *Server) Register(mux *http.ServeMux) { mux.HandleFunc("/api/clone-session", s.auth.Wrap(s.handleApiCloneSession)) mux.HandleFunc("/api/rename-session", s.auth.Wrap(s.handleRenameSession)) mux.HandleFunc("/api/recent-locations", s.auth.Wrap(s.handleRecentLocations)) - mux.HandleFunc("/api/projects", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - s.auth.Wrap(s.handleUpdateProject)(w, r) - } else { - s.auth.Wrap(s.handleApiProjects)(w, r) - } - }) + mux.HandleFunc("/api/projects", s.getPostHandler(s.handleApiProjects, s.handleUpdateProject)) mux.HandleFunc("/api/git/info", s.auth.Wrap(s.handleGitInfo)) mux.HandleFunc("/api/git/rename-branch", s.auth.Wrap(s.handleGitRenameBranch)) mux.HandleFunc("/custom-themes.css", s.auth.Wrap(s.handleCustomThemes)) - mux.HandleFunc("/api/scratchpad", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - s.auth.Wrap(s.handleSaveScratchpad)(w, r) - } else { - s.auth.Wrap(s.handleGetScratchpad)(w, r) - } - }) - mux.HandleFunc("/api/settings", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - s.auth.Wrap(s.handleSaveSettings)(w, r) - } else { - s.auth.Wrap(s.handleGetSettings)(w, r) - } - }) + mux.HandleFunc("/api/scratchpad", s.getPostHandler(s.handleGetScratchpad, s.handleSaveScratchpad)) + mux.HandleFunc("/api/settings", s.getPostHandler(s.handleGetSettings, s.handleSaveSettings)) mux.HandleFunc("/api/btw", s.auth.Wrap(s.handleGetBtw)) mux.HandleFunc("/api/btw/new", s.auth.Wrap(s.handleNewBtw)) if s.push != nil { @@ -245,6 +227,22 @@ func (s *Server) Register(mux *http.ServeMux) { } } +// getPostHandler routes GET to get and POST to post, each wrapped with auth, +// and rejects any other method with 405 (and an Allow header) before auth runs. +func (s *Server) getPostHandler(get, post http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.auth.Wrap(get)(w, r) + case http.MethodPost: + s.auth.Wrap(post)(w, r) + default: + w.Header().Set("Allow", "GET, POST") + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + } + } +} + func (s *Server) loadSummaries() ([]sessions.SessionSummary, error) { return s.cache.LoadAll(s.sessionsDir) } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 227dcc1..af77941 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -146,6 +146,30 @@ func TestSettingsNoDBDegradesGracefully(t *testing.T) { } } +func TestGetPostHandlerRejectsOtherMethods(t *testing.T) { + s := &Server{} + called := false + h := s.getPostHandler( + func(w http.ResponseWriter, r *http.Request) { called = true }, + func(w http.ResponseWriter, r *http.Request) { called = true }, + ) + + for _, method := range []string{http.MethodDelete, http.MethodPut, http.MethodPatch} { + req := httptest.NewRequest(method, "/api/settings", nil) + w := httptest.NewRecorder() + h(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("%s: expected 405, got %d", method, w.Code) + } + if got := w.Header().Get("Allow"); got != "GET, POST" { + t.Errorf("%s: expected Allow 'GET, POST', got %q", method, got) + } + } + if called { + t.Error("get/post handlers should not run for unsupported methods") + } +} + func TestHandleGetSettingsWrongMethod(t *testing.T) { s := &Server{db: newSettingsTestDB(t)} req := httptest.NewRequest(http.MethodDelete, "/api/settings", nil)