diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md
index dc04048..9a39010 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 and project visibility prefs |
+| Local DB | SQLite (`~/.pi/agent/pi-web.sqlite`) for per-project scratchpads, project visibility prefs, 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/POST /api/projects → project visibility prefs (SQLite) │
│ GET /api/sounds / GET /sounds/… (notification sounds) │
│ POST /share → handleShare (GitHub Gist) │
@@ -128,7 +130,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 + project visibility prefs
+├── pi-web.sqlite ← scratchpads + project visibility prefs + 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..deff64c 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,22 @@ func Main(version string) {
RunRestart: runRestart,
})
+ ui.SetThemeProvider(srv.ThemeSetting)
+ ui.SetFontProvider(srv.FontStyles)
+
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 1b1ec82..3f83de5 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)
+ }
if _, err := db.Exec(projectPrefsSchema); err != nil {
fmt.Fprintf(os.Stderr, "failed to create project_prefs table: %v\n", err)
}
@@ -137,6 +147,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{}),
@@ -179,6 +190,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))
@@ -194,23 +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/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 {
@@ -226,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.go b/internal/server/settings.go
new file mode 100644
index 0000000..87c3a22
--- /dev/null
+++ b/internal/server/settings.go
@@ -0,0 +1,217 @@
+package server
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "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-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
+// 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")
+}
+
+// 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
+
+
+
+ Appearance
+
+
+ Theme
+ Applies to every browser pointed at this instance.
+
+
+
+
+
+
+
+ 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).
+
+
+
+
+
+
+
+
+ Sessions List
+
+
+ Spinner style
+ Animation shown next to running sessions.
+
+
+
+
+
+
+
+ Default layout
+ How the sessions list is grouped on load.
+
+
+
+
+
+
+
+
+ Notifications
+
+
+ Notify when a response is ready
+ Plays a sound and shows a notification when chat completes.
+
+
+
+
+
+
+
+ Done sound
+ Sound played when a response is ready.
+
+
+
+
+
+
+
+
+ Cat Gatekeeper
+
+
+ Enable Cat Gatekeeper
+ A cat appears when it is time to rest.
+
+
+
+
+
+
+
+ Focus time (minutes)
+ Uninterrupted work before the cat appears.
+
+
+
+
+
+
+
+ Break time (minutes)
+ How long the cat keeps you away.
+
+
+
+
+
+
+
+ Bedtime
+ When the cat says goodnight.
+
+
+
+
+
+
+
+ Wakeup
+ When the cat lets you back in.
+
+
+
+
+
+
+
+ Sleep reminder (minutes)
+ How long the sleepy cat stays before locking.
+
+
+
+
+
+
+
+ Saved
+
+
+{{ liveServiceWorkerScript }}
+
+{{ liveDocumentEnd }}
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
new file mode 100644
index 0000000..e4463a6
--- /dev/null
+++ b/internal/ui/live_templates/styles/settings.css
@@ -0,0 +1,174 @@
+.settings-page {
+ 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 {
+ 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: 1em;
+ 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.6em;
+ 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.85em;
+ 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: 1.05em;
+}
+
+.settings-row-label .hint {
+ color: var(--muted);
+ font-size: 0.9em;
+}
+
+.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: 1em;
+ font-family: inherit;
+ min-width: 130px;
+}
+
+.settings-control input[type="number"] {
+ 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;
+ 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.9em;
+ margin-top: 8px;
+ min-height: 1em;
+ transition: opacity 0.3s ease;
+ opacity: 0;
+}
+
+.settings-saved-hint.visible {
+ opacity: 1;
+}
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/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 152be1c..b85ea11 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';
const manageProjectsBtns = Array.from(documentImpl.querySelectorAll('[data-manage-projects-btn]'));
@@ -184,7 +174,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();
@@ -218,64 +208,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;
@@ -629,6 +561,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-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 a42a72b..d88e9ff 100644
--- a/web/src/session/cat-gatekeeper/cat-settings.js
+++ b/web/src/session/cat-gatekeeper/cat-settings.js
@@ -5,12 +5,14 @@
*/
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',
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',
};
@@ -19,7 +21,8 @@ export const CAT_DEFAULTS = {
focusMin: 25,
breakMin: 5,
bedtime: '23:00',
- sleepMin: 2,
+ wakeup: '07:00',
+ sleepMin: 5,
};
const LIMITS = {
@@ -56,18 +59,20 @@ 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),
};
}
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));
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 });
}
@@ -152,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,
});
});
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 5b9fdb2..af6188e 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..54ad1c7
--- /dev/null
+++ b/web/src/settings/settings.js
@@ -0,0 +1,261 @@
+import { configureSettingsSync, hydrateSettings, writeSetting } from '../shared/settings-store.js';
+import { applyTheme } from '../shared/theme.js';
+import { applyFonts } from '../shared/fonts.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;
+
+ // 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() {
+ 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.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) {
+ writeSetting(key, el.checked ? 'true' : 'false', { storage });
+ } else {
+ writeSetting(key, el.value, { storage });
+ if (el.dataset.settingSound !== undefined) {
+ playDoneSound({ windowImpl, storage });
+ }
+ }
+ flashSaved();
+ });
+ }
+
+ // 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.
+ 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 });
+ }
+ }
+}
+
+// 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) {
+ 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/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/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();
diff --git a/web/src/shared/settings-store.js b/web/src/shared/settings-store.js
new file mode 100644
index 0000000..6d09274
--- /dev/null
+++ b/web/src/shared/settings-store.js
@@ -0,0 +1,130 @@
+/**
+ * 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-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',
+ '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:wakeup',
+ '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: {