detached c7dc5f2

feat(config-webui): add reset defaults button + /api/config/default

commit
c7dc5f29cbbc243c3ac1be1f55b331ad041534bf
author
brice · · 2025-12-20 18:47 UTC
parent
ee9c157
signature
unsigned
7 files · +889 -3
M internal/infrastructure/webkit/scheme_handler.go +130 -1
@@ -3,6 +3,8 @@ package webkit
33 import (
44 "context"
55 "embed"
6+ "encoding/json"
7+ "fmt"
68 "io/fs"
79 "mime"
810 "net/http"
@@ -11,7 +13,9 @@ import (
1113 "strings"
1214 "sync"
1315
16+ "github.com/bnema/dumber/internal/infrastructure/config"
1417 "github.com/bnema/dumber/internal/logging"
18+ "github.com/bnema/puregotk-webkit/soup"
1519 "github.com/bnema/puregotk-webkit/webkit"
1620 "github.com/jwijenbergh/puregotk/v4/gio"
1721 "github.com/rs/zerolog"
@@ -20,6 +24,7 @@ import (
2024 // Scheme path constants
2125 const (
2226 HomePath = "home"
27+ ConfigPath = "config"
2328 BlockedPath = "blocked"
2429 IndexHTML = "index.html"
2530 )
@@ -95,6 +100,98 @@ func (h *DumbSchemeHandler) registerDefaults() {
95100 StatusCode: http.StatusOK,
96101 }
97102 }))
103+
104+ // API: Get current config (used by dumb://config)
105+ h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
106+ if req.Method != "GET" {
107+ return nil
108+ }
109+
110+ cfg := config.Get()
111+ resp := struct {
112+ Appearance struct {
113+ SansFont string `json:"sans_font"`
114+ SerifFont string `json:"serif_font"`
115+ MonospaceFont string `json:"monospace_font"`
116+ DefaultFontSize int `json:"default_font_size"`
117+ ColorScheme string `json:"color_scheme"`
118+ LightPalette config.ColorPalette `json:"light_palette"`
119+ DarkPalette config.ColorPalette `json:"dark_palette"`
120+ } `json:"appearance"`
121+ DefaultUIScale float64 `json:"default_ui_scale"`
122+ DefaultSearchEngine string `json:"default_search_engine"`
123+ }{
124+ DefaultUIScale: cfg.DefaultUIScale,
125+ DefaultSearchEngine: cfg.DefaultSearchEngine,
126+ }
127+ resp.Appearance.SansFont = cfg.Appearance.SansFont
128+ resp.Appearance.SerifFont = cfg.Appearance.SerifFont
129+ resp.Appearance.MonospaceFont = cfg.Appearance.MonospaceFont
130+ resp.Appearance.DefaultFontSize = cfg.Appearance.DefaultFontSize
131+ resp.Appearance.ColorScheme = cfg.Appearance.ColorScheme
132+ resp.Appearance.LightPalette = cfg.Appearance.LightPalette
133+ resp.Appearance.DarkPalette = cfg.Appearance.DarkPalette
134+
135+ data, err := json.Marshal(resp)
136+ if err != nil {
137+ return &SchemeResponse{
138+ Data: []byte(fmt.Sprintf(`{"error": "%s"}`, err)),
139+ ContentType: "application/json",
140+ StatusCode: http.StatusInternalServerError,
141+ }
142+ }
143+ return &SchemeResponse{
144+ Data: data,
145+ ContentType: "application/json",
146+ StatusCode: http.StatusOK,
147+ }
148+ }))
149+
150+ // API: Get default config (used by Reset Defaults in dumb://config)
151+ h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
152+ if req.Method != "GET" {
153+ return nil
154+ }
155+
156+ defaults := config.DefaultConfig()
157+ resp := struct {
158+ Appearance struct {
159+ SansFont string `json:"sans_font"`
160+ SerifFont string `json:"serif_font"`
161+ MonospaceFont string `json:"monospace_font"`
162+ DefaultFontSize int `json:"default_font_size"`
163+ ColorScheme string `json:"color_scheme"`
164+ LightPalette config.ColorPalette `json:"light_palette"`
165+ DarkPalette config.ColorPalette `json:"dark_palette"`
166+ } `json:"appearance"`
167+ DefaultUIScale float64 `json:"default_ui_scale"`
168+ DefaultSearchEngine string `json:"default_search_engine"`
169+ }{
170+ DefaultUIScale: defaults.DefaultUIScale,
171+ DefaultSearchEngine: defaults.DefaultSearchEngine,
172+ }
173+ resp.Appearance.SansFont = defaults.Appearance.SansFont
174+ resp.Appearance.SerifFont = defaults.Appearance.SerifFont
175+ resp.Appearance.MonospaceFont = defaults.Appearance.MonospaceFont
176+ resp.Appearance.DefaultFontSize = defaults.Appearance.DefaultFontSize
177+ resp.Appearance.ColorScheme = defaults.Appearance.ColorScheme
178+ resp.Appearance.LightPalette = defaults.Appearance.LightPalette
179+ resp.Appearance.DarkPalette = defaults.Appearance.DarkPalette
180+
181+ data, err := json.Marshal(resp)
182+ if err != nil {
183+ return &SchemeResponse{
184+ Data: []byte(fmt.Sprintf(`{"error": "%s"}`, err)),
185+ ContentType: "application/json",
186+ StatusCode: http.StatusInternalServerError,
187+ }
188+ }
189+ return &SchemeResponse{
190+ Data: data,
191+ ContentType: "application/json",
192+ StatusCode: http.StatusOK,
193+ }
194+ }))
98195 }
99196
100197 // RegisterPage registers a handler for a specific path.
@@ -139,7 +236,22 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
139236 return
140237 }
141238
142- // Try to serve from embedded assets first
239+ // API endpoints should never be treated as static assets
240+ if strings.HasPrefix(schemeReq.Path, "/api/") {
241+ h.mu.RLock()
242+ handler, ok := h.handlers[schemeReq.Path]
243+ if !ok {
244+ handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
245+ }
246+ h.mu.RUnlock()
247+ if ok {
248+ response := handler.Handle(schemeReq)
249+ h.sendResponse(req, response)
250+ return
251+ }
252+ }
253+
254+ // Try to serve from embedded assets
143255 if response := h.handleAsset(u); response != nil {
144256 h.sendResponse(req, response)
145257 return
@@ -191,6 +303,12 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
191303 // dumb://home/<asset> → serve asset
192304 case host == HomePath && path != "":
193305 relPath = path
306+ // dumb://config or dumb://config/ → config.html
307+ case host == ConfigPath && (path == "" || path == "/"):
308+ relPath = "config.html"
309+ // dumb://config/<asset> → serve asset
310+ case host == ConfigPath && path != "":
311+ relPath = path
194312 // dumb://blocked or dumb://blocked/ → blocked.html
195313 case host == BlockedPath && (path == "" || path == "/"):
196314 relPath = "blocked.html"
@@ -308,6 +426,17 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response
308426 schemeResp.SetContentType(contentType)
309427 schemeResp.SetStatus(uint(response.StatusCode), nil)
310428
429+ // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch().
430+ // We only add CORS headers for our internal API endpoints.
431+ if strings.HasPrefix(req.GetPath(), "/api/") {
432+ hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
433+ hdrs.Append("Access-Control-Allow-Origin", "*")
434+ hdrs.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
435+ hdrs.Append("Access-Control-Allow-Headers", "Content-Type")
436+ hdrs.Append("Access-Control-Max-Age", "86400")
437+ schemeResp.SetHttpHeaders(hdrs)
438+ }
439+
311440 req.FinishWithResponse(schemeResp)
312441 }
313442
@@ -3,6 +3,8 @@ package webkit
3 import (3 import (
4 "context"4 "context"
5 "embed"5 "embed"
6+ "encoding/json"
7+ "fmt"
6 "io/fs"8 "io/fs"
7 "mime"9 "mime"
8 "net/http"10 "net/http"
@@ -11,7 +13,9 @@ import (
11 "strings"13 "strings"
12 "sync"14 "sync"
13 15
16+ "github.com/bnema/dumber/internal/infrastructure/config"
14 "github.com/bnema/dumber/internal/logging"17 "github.com/bnema/dumber/internal/logging"
18+ "github.com/bnema/puregotk-webkit/soup"
15 "github.com/bnema/puregotk-webkit/webkit"19 "github.com/bnema/puregotk-webkit/webkit"
16 "github.com/jwijenbergh/puregotk/v4/gio"20 "github.com/jwijenbergh/puregotk/v4/gio"
17 "github.com/rs/zerolog"21 "github.com/rs/zerolog"
@@ -20,6 +24,7 @@ import (
20 // Scheme path constants24 // Scheme path constants
21 const (25 const (
22 HomePath = "home"26 HomePath = "home"
27+ ConfigPath = "config"
23 BlockedPath = "blocked"28 BlockedPath = "blocked"
24 IndexHTML = "index.html"29 IndexHTML = "index.html"
25 )30 )
@@ -95,6 +100,98 @@ func (h *DumbSchemeHandler) registerDefaults() {
95 StatusCode: http.StatusOK,100 StatusCode: http.StatusOK,
96 }101 }
97 }))102 }))
103+
104+ // API: Get current config (used by dumb://config)
105+ h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
106+ if req.Method != "GET" {
107+ return nil
108+ }
109+
110+ cfg := config.Get()
111+ resp := struct {
112+ Appearance struct {
113+ SansFont string `json:"sans_font"`
114+ SerifFont string `json:"serif_font"`
115+ MonospaceFont string `json:"monospace_font"`
116+ DefaultFontSize int `json:"default_font_size"`
117+ ColorScheme string `json:"color_scheme"`
118+ LightPalette config.ColorPalette `json:"light_palette"`
119+ DarkPalette config.ColorPalette `json:"dark_palette"`
120+ } `json:"appearance"`
121+ DefaultUIScale float64 `json:"default_ui_scale"`
122+ DefaultSearchEngine string `json:"default_search_engine"`
123+ }{
124+ DefaultUIScale: cfg.DefaultUIScale,
125+ DefaultSearchEngine: cfg.DefaultSearchEngine,
126+ }
127+ resp.Appearance.SansFont = cfg.Appearance.SansFont
128+ resp.Appearance.SerifFont = cfg.Appearance.SerifFont
129+ resp.Appearance.MonospaceFont = cfg.Appearance.MonospaceFont
130+ resp.Appearance.DefaultFontSize = cfg.Appearance.DefaultFontSize
131+ resp.Appearance.ColorScheme = cfg.Appearance.ColorScheme
132+ resp.Appearance.LightPalette = cfg.Appearance.LightPalette
133+ resp.Appearance.DarkPalette = cfg.Appearance.DarkPalette
134+
135+ data, err := json.Marshal(resp)
136+ if err != nil {
137+ return &SchemeResponse{
138+ Data: []byte(fmt.Sprintf(`{"error": "%s"}`, err)),
139+ ContentType: "application/json",
140+ StatusCode: http.StatusInternalServerError,
141+ }
142+ }
143+ return &SchemeResponse{
144+ Data: data,
145+ ContentType: "application/json",
146+ StatusCode: http.StatusOK,
147+ }
148+ }))
149+
150+ // API: Get default config (used by Reset Defaults in dumb://config)
151+ h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
152+ if req.Method != "GET" {
153+ return nil
154+ }
155+
156+ defaults := config.DefaultConfig()
157+ resp := struct {
158+ Appearance struct {
159+ SansFont string `json:"sans_font"`
160+ SerifFont string `json:"serif_font"`
161+ MonospaceFont string `json:"monospace_font"`
162+ DefaultFontSize int `json:"default_font_size"`
163+ ColorScheme string `json:"color_scheme"`
164+ LightPalette config.ColorPalette `json:"light_palette"`
165+ DarkPalette config.ColorPalette `json:"dark_palette"`
166+ } `json:"appearance"`
167+ DefaultUIScale float64 `json:"default_ui_scale"`
168+ DefaultSearchEngine string `json:"default_search_engine"`
169+ }{
170+ DefaultUIScale: defaults.DefaultUIScale,
171+ DefaultSearchEngine: defaults.DefaultSearchEngine,
172+ }
173+ resp.Appearance.SansFont = defaults.Appearance.SansFont
174+ resp.Appearance.SerifFont = defaults.Appearance.SerifFont
175+ resp.Appearance.MonospaceFont = defaults.Appearance.MonospaceFont
176+ resp.Appearance.DefaultFontSize = defaults.Appearance.DefaultFontSize
177+ resp.Appearance.ColorScheme = defaults.Appearance.ColorScheme
178+ resp.Appearance.LightPalette = defaults.Appearance.LightPalette
179+ resp.Appearance.DarkPalette = defaults.Appearance.DarkPalette
180+
181+ data, err := json.Marshal(resp)
182+ if err != nil {
183+ return &SchemeResponse{
184+ Data: []byte(fmt.Sprintf(`{"error": "%s"}`, err)),
185+ ContentType: "application/json",
186+ StatusCode: http.StatusInternalServerError,
187+ }
188+ }
189+ return &SchemeResponse{
190+ Data: data,
191+ ContentType: "application/json",
192+ StatusCode: http.StatusOK,
193+ }
194+ }))
98 }195 }
99 196
100 // RegisterPage registers a handler for a specific path.197 // RegisterPage registers a handler for a specific path.
@@ -139,7 +236,22 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
139 return236 return
140 }237 }
141 238
142- // Try to serve from embedded assets first239+ // API endpoints should never be treated as static assets
240+ if strings.HasPrefix(schemeReq.Path, "/api/") {
241+ h.mu.RLock()
242+ handler, ok := h.handlers[schemeReq.Path]
243+ if !ok {
244+ handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
245+ }
246+ h.mu.RUnlock()
247+ if ok {
248+ response := handler.Handle(schemeReq)
249+ h.sendResponse(req, response)
250+ return
251+ }
252+ }
253+
254+ // Try to serve from embedded assets
143 if response := h.handleAsset(u); response != nil {255 if response := h.handleAsset(u); response != nil {
144 h.sendResponse(req, response)256 h.sendResponse(req, response)
145 return257 return
@@ -191,6 +303,12 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
191 // dumb://home/<asset> → serve asset303 // dumb://home/<asset> → serve asset
192 case host == HomePath && path != "":304 case host == HomePath && path != "":
193 relPath = path305 relPath = path
306+ // dumb://config or dumb://config/ → config.html
307+ case host == ConfigPath && (path == "" || path == "/"):
308+ relPath = "config.html"
309+ // dumb://config/<asset> → serve asset
310+ case host == ConfigPath && path != "":
311+ relPath = path
194 // dumb://blocked or dumb://blocked/ → blocked.html312 // dumb://blocked or dumb://blocked/ → blocked.html
195 case host == BlockedPath && (path == "" || path == "/"):313 case host == BlockedPath && (path == "" || path == "/"):
196 relPath = "blocked.html"314 relPath = "blocked.html"
@@ -308,6 +426,17 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response
308 schemeResp.SetContentType(contentType)426 schemeResp.SetContentType(contentType)
309 schemeResp.SetStatus(uint(response.StatusCode), nil)427 schemeResp.SetStatus(uint(response.StatusCode), nil)
310 428
429+ // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch().
430+ // We only add CORS headers for our internal API endpoints.
431+ if strings.HasPrefix(req.GetPath(), "/api/") {
432+ hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
433+ hdrs.Append("Access-Control-Allow-Origin", "*")
434+ hdrs.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
435+ hdrs.Append("Access-Control-Allow-Headers", "Content-Type")
436+ hdrs.Append("Access-Control-Max-Age", "86400")
437+ schemeResp.SetHttpHeaders(hdrs)
438+ }
439+
311 req.FinishWithResponse(schemeResp)440 req.FinishWithResponse(schemeResp)
312 }441 }
313 442
M webui/package.json +3 -1
@@ -4,12 +4,14 @@
44 "private": true,
55 "type": "module",
66 "scripts": {
7- "build": "VITE_PAGE=homepage vite build && VITE_PAGE=blocked vite build",
7+ "build": "VITE_PAGE=homepage vite build && VITE_PAGE=blocked vite build && VITE_PAGE=config vite build",
88 "build:homepage": "VITE_PAGE=homepage vite build",
99 "build:blocked": "VITE_PAGE=blocked vite build",
10+ "build:config": "VITE_PAGE=config vite build",
1011 "dev": "VITE_PAGE=homepage vite build --watch",
1112 "dev:homepage": "VITE_PAGE=homepage vite build --watch",
1213 "dev:blocked": "VITE_PAGE=blocked vite build --watch",
14+ "dev:config": "VITE_PAGE=config vite build --watch",
1315 "check": "svelte-check",
1416 "lint": "eslint src --fix",
1517 "fmt": "prettier --write ."
@@ -4,12 +4,14 @@
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "scripts": {6 "scripts": {
7- "build": "VITE_PAGE=homepage vite build && VITE_PAGE=blocked vite build",7+ "build": "VITE_PAGE=homepage vite build && VITE_PAGE=blocked vite build && VITE_PAGE=config vite build",
8 "build:homepage": "VITE_PAGE=homepage vite build",8 "build:homepage": "VITE_PAGE=homepage vite build",
9 "build:blocked": "VITE_PAGE=blocked vite build",9 "build:blocked": "VITE_PAGE=blocked vite build",
10+ "build:config": "VITE_PAGE=config vite build",
10 "dev": "VITE_PAGE=homepage vite build --watch",11 "dev": "VITE_PAGE=homepage vite build --watch",
11 "dev:homepage": "VITE_PAGE=homepage vite build --watch",12 "dev:homepage": "VITE_PAGE=homepage vite build --watch",
12 "dev:blocked": "VITE_PAGE=blocked vite build --watch",13 "dev:blocked": "VITE_PAGE=blocked vite build --watch",
14+ "dev:config": "VITE_PAGE=config vite build --watch",
13 "check": "svelte-check",15 "check": "svelte-check",
14 "lint": "eslint src --fix",16 "lint": "eslint src --fix",
15 "fmt": "prettier --write ."17 "fmt": "prettier --write ."
A webui/src/pages/ConfigPage.svelte +479 -0
@@ -0,0 +1,479 @@
1+<script lang="ts">
2+ import { onMount } from "svelte";
3+ import ConfigShell from "./config/ConfigShell.svelte";
4+
5+ type ColorPalette = {
6+ background: string;
7+ surface: string;
8+ surface_variant: string;
9+ text: string;
10+ muted: string;
11+ accent: string;
12+ border: string;
13+ };
14+
15+ type ConfigDTO = {
16+ appearance: {
17+ sans_font: string;
18+ serif_font: string;
19+ monospace_font: string;
20+ default_font_size: number;
21+ color_scheme: string;
22+ light_palette: ColorPalette;
23+ dark_palette: ColorPalette;
24+ };
25+ default_ui_scale: number;
26+ default_search_engine: string;
27+ };
28+
29+ const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [
30+ { key: "background", label: "Background" },
31+ { key: "surface", label: "Surface" },
32+ { key: "surface_variant", label: "Surface Variant" },
33+ { key: "text", label: "Text" },
34+ { key: "muted", label: "Muted" },
35+ { key: "accent", label: "Accent" },
36+ { key: "border", label: "Border" },
37+ ];
38+
39+ function isHexColor(value: string): boolean {
40+ return /^#[0-9a-fA-F]{6}$/.test(value);
41+ }
42+
43+
44+ let config = $state<ConfigDTO | null>(null);
45+ let loading = $state(true);
46+ let error = $state<string | null>(null);
47+ let saving = $state(false);
48+ let saveSuccess = $state(false);
49+ let themeEvents = $state(0);
50+
51+ async function fetchConfig() {
52+ try {
53+ const response = await fetch("/api/config");
54+ if (!response.ok) throw new Error("Failed to fetch config");
55+ config = (await response.json()) as ConfigDTO;
56+ } catch (e: any) {
57+ error = e.message;
58+ } finally {
59+ loading = false;
60+ }
61+ }
62+
63+ async function resetToDefaults() {
64+ error = null;
65+ saveSuccess = false;
66+
67+ const confirmed = window.confirm(
68+ "Reset this page to default settings? (You still need to click Save to persist.)",
69+ );
70+ if (!confirmed) return;
71+
72+ try {
73+ const response = await fetch("/api/config/default");
74+ if (!response.ok) throw new Error("Failed to fetch default config");
75+ config = (await response.json()) as ConfigDTO;
76+ } catch (e: any) {
77+ error = e.message;
78+ }
79+ }
80+
81+ function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
82+ const bridge = (window as any).webkit?.messageHandlers?.dumber;
83+ if (bridge && typeof bridge.postMessage === "function") {
84+ return bridge;
85+ }
86+ return null;
87+ }
88+
89+ function getWebViewId(): number {
90+ return (window as any).__dumber_webview_id || 0;
91+ }
92+
93+ async function saveConfig() {
94+ saving = true;
95+ saveSuccess = false;
96+ error = null;
97+
98+ try {
99+ const bridge = getWebKitBridge();
100+ const webviewId = getWebViewId();
101+
102+ console.debug("[config] save click", {
103+ hasBridge: Boolean(bridge),
104+ webviewId,
105+ hasConfig: Boolean(config),
106+ });
107+
108+ if (!bridge) {
109+ throw new Error("WebKit bridge not available (not running inside Dumber)");
110+ }
111+ if (!webviewId) {
112+ throw new Error("Missing window.__dumber_webview_id; cannot dispatch/receive callbacks");
113+ }
114+ if (!config) {
115+ throw new Error("Config not loaded");
116+ }
117+
118+ let timeout: number | null = window.setTimeout(() => {
119+ if (saving) {
120+ console.warn("[config] save timed out (no callback)");
121+ error = "Save timed out (no response from native handler)";
122+ saving = false;
123+ }
124+ }, 8000);
125+
126+ const clearSaveTimeout = () => {
127+ if (timeout != null) {
128+ window.clearTimeout(timeout);
129+ timeout = null;
130+ }
131+ };
132+
133+ (window as any).__dumber_config_saved = (resp?: unknown) => {
134+ clearSaveTimeout();
135+ console.debug("[config] save success", resp);
136+ saveSuccess = true;
137+ saving = false;
138+
139+ // Refresh config state from backend (in case watcher normalized values)
140+ fetchConfig();
141+
142+ setTimeout(() => {
143+ saveSuccess = false;
144+ }, 3000);
145+ };
146+ (window as any).__dumber_config_error = (msg: unknown) => {
147+ clearSaveTimeout();
148+ console.error("[config] save error", msg);
149+ error = typeof msg === "string" ? msg : "Failed to save config";
150+ saving = false;
151+ };
152+
153+ const payload = $state.snapshot(config);
154+
155+ try {
156+ bridge.postMessage({
157+ type: "save_config",
158+ webview_id: webviewId,
159+ payload,
160+ });
161+ console.debug("[config] postMessage sent", { payloadBytes: JSON.stringify(payload).length });
162+ } catch (postErr) {
163+ console.error("[config] postMessage threw", postErr);
164+ throw postErr;
165+ }
166+ } catch (e: any) {
167+ console.error("[config] save exception", e);
168+ error = e.message;
169+ saving = false;
170+ }
171+ }
172+
173+ onMount(() => {
174+ console.debug("[config] mount", {
175+ webviewId: (window as any).__dumber_webview_id,
176+ hasBridge: Boolean((window as any).webkit?.messageHandlers?.dumber),
177+ });
178+
179+ const onThemeChanged = (e: Event) => {
180+ themeEvents += 1;
181+ console.debug("[config] theme changed", (e as CustomEvent).detail);
182+ };
183+ window.addEventListener('dumber:theme-changed', onThemeChanged);
184+
185+ fetchConfig();
186+
187+ return () => {
188+ window.removeEventListener('dumber:theme-changed', onThemeChanged);
189+ };
190+ });
191+</script>
192+
193+<ConfigShell>
194+ <div class="config-content">
195+ {#if loading}
196+ <div class="loading">LOADING…</div>
197+ {:else if error}
198+ <div class="error">
199+ <strong>ERROR</strong>
200+ <span>{error}</span>
201+ </div>
202+ {/if}
203+
204+ {#if config}
205+ <div class="stack">
206+ <!-- Appearance Section -->
207+ <section class="border border-border bg-card p-6 text-card-foreground">
208+ <h2 class="mb-6 text-xl font-semibold">Appearance</h2>
209+
210+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
211+ <div class="space-y-2">
212+ <label for="sans_font" class="block text-sm font-medium text-muted-foreground">Sans-Serif Font</label>
213+ <input
214+ id="sans_font"
215+ type="text"
216+ bind:value={config.appearance.sans_font}
217+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
218+ />
219+ </div>
220+
221+ <div class="space-y-2">
222+ <label for="serif_font" class="block text-sm font-medium text-muted-foreground">Serif Font</label>
223+ <input
224+ id="serif_font"
225+ type="text"
226+ bind:value={config.appearance.serif_font}
227+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
228+ />
229+ </div>
230+
231+ <div class="space-y-2">
232+ <label for="monospace_font" class="block text-sm font-medium text-muted-foreground">Monospace Font</label>
233+ <input
234+ id="monospace_font"
235+ type="text"
236+ bind:value={config.appearance.monospace_font}
237+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
238+ />
239+ </div>
240+
241+ <div class="space-y-2">
242+ <label for="font_size" class="block text-sm font-medium text-muted-foreground">Default Font Size</label>
243+ <input
244+ id="font_size"
245+ type="number"
246+ bind:value={config.appearance.default_font_size}
247+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
248+ />
249+ </div>
250+
251+ <div class="space-y-2">
252+ <label for="color_scheme" class="block text-sm font-medium text-muted-foreground">Color Scheme</label>
253+ <select
254+ id="color_scheme"
255+ bind:value={config.appearance.color_scheme}
256+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
257+ >
258+ <option value="default">Follow System</option>
259+ <option value="prefer-dark">Always Dark</option>
260+ <option value="prefer-light">Always Light</option>
261+ </select>
262+ </div>
263+
264+ <div class="space-y-2">
265+ <label for="ui_scale" class="block text-sm font-medium text-muted-foreground">UI Scale</label>
266+ <input
267+ id="ui_scale"
268+ type="number"
269+ step="0.1"
270+ bind:value={config.default_ui_scale}
271+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
272+ />
273+ </div>
274+ </div>
275+
276+ <div class="mt-8 space-y-8">
277+ <div>
278+ <h3 class="mb-3 text-base font-semibold">Theme: Light</h3>
279+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
280+ {#each PALETTE_FIELDS as field (field.key)}
281+ <div class="space-y-2">
282+ <label class="block text-sm font-medium text-muted-foreground" for={`light_${field.key}`}
283+ >{field.label}</label
284+ >
285+ <div class="flex items-center gap-3">
286+ <input
287+ id={`light_${field.key}`}
288+ type="color"
289+ value={config.appearance.light_palette[field.key]}
290+ oninput={(e) => {
291+ const v = (e.currentTarget as HTMLInputElement).value;
292+ config!.appearance.light_palette[field.key] = v;
293+ }}
294+ class="h-10 w-12 border border-input bg-background"
295+ />
296+ <input
297+ type="text"
298+ bind:value={config.appearance.light_palette[field.key]}
299+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
300+ />
301+ </div>
302+ {#if !isHexColor(config.appearance.light_palette[field.key])}
303+ <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
304+ {/if}
305+ </div>
306+ {/each}
307+ </div>
308+ </div>
309+
310+ <div>
311+ <h3 class="mb-3 text-base font-semibold">Theme: Dark</h3>
312+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
313+ {#each PALETTE_FIELDS as field (field.key)}
314+ <div class="space-y-2">
315+ <label class="block text-sm font-medium text-muted-foreground" for={`dark_${field.key}`}
316+ >{field.label}</label
317+ >
318+ <div class="flex items-center gap-3">
319+ <input
320+ id={`dark_${field.key}`}
321+ type="color"
322+ value={config.appearance.dark_palette[field.key]}
323+ oninput={(e) => {
324+ const v = (e.currentTarget as HTMLInputElement).value;
325+ config!.appearance.dark_palette[field.key] = v;
326+ }}
327+ class="h-10 w-12 border border-input bg-background"
328+ />
329+ <input
330+ type="text"
331+ bind:value={config.appearance.dark_palette[field.key]}
332+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
333+ />
334+ </div>
335+ {#if !isHexColor(config.appearance.dark_palette[field.key])}
336+ <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
337+ {/if}
338+ </div>
339+ {/each}
340+ </div>
341+ </div>
342+ </div>
343+ </section>
344+
345+ <!-- Search Section -->
346+ <section class="border border-border bg-card p-6 text-card-foreground">
347+ <h2 class="mb-6 text-xl font-semibold">Search</h2>
348+ <div class="space-y-4">
349+ <div class="space-y-2">
350+ <label for="search_engine" class="block text-sm font-medium text-muted-foreground">Default Search Engine (use %s for query)</label>
351+ <input
352+ id="search_engine"
353+ type="text"
354+ bind:value={config.default_search_engine}
355+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
356+ />
357+ </div>
358+ </div>
359+ </section>
360+
361+ <div class="actions">
362+ {#if saveSuccess}
363+ <span class="saved">SAVED</span>
364+ {/if}
365+ {#if themeEvents > 0}
366+ <span class="hint">THEME×{themeEvents}</span>
367+ {/if}
368+ <button onclick={resetToDefaults} disabled={saving} class="reset-button" type="button">
369+ RESET DEFAULTS
370+ </button>
371+ <button onclick={saveConfig} disabled={saving} class="save-button" type="button">
372+ {saving ? "SAVING…" : "SAVE"}
373+ </button>
374+ </div>
375+ </div>
376+ {/if}
377+</div>
378+</ConfigShell>
379+
380+<style>
381+ .config-content {
382+ width: 100%;
383+ max-width: 980px;
384+ margin: 0 auto;
385+ padding: 1.5rem 1.25rem 2rem;
386+ }
387+
388+ .stack {
389+ display: flex;
390+ flex-direction: column;
391+ gap: 1.25rem;
392+ }
393+
394+ .loading {
395+ padding: 2rem 0;
396+ color: var(--muted-foreground);
397+ letter-spacing: 0.1em;
398+ font-size: 0.8rem;
399+ }
400+
401+ .error {
402+ padding: 0.75rem 1rem;
403+ border: 1px solid color-mix(in srgb, var(--border) 50%, #ef4444 50%);
404+ background: color-mix(in srgb, var(--card) 70%, #ef4444 30%);
405+ display: flex;
406+ flex-direction: column;
407+ gap: 0.25rem;
408+ }
409+
410+ .actions {
411+ display: flex;
412+ align-items: center;
413+ justify-content: flex-end;
414+ gap: 0.75rem;
415+ padding-top: 0.75rem;
416+ border-top: 1px solid var(--border);
417+ }
418+
419+ .saved {
420+ font-size: 0.7rem;
421+ letter-spacing: 0.12em;
422+ color: var(--primary, #4ade80);
423+ }
424+
425+ .hint {
426+ font-size: 0.7rem;
427+ letter-spacing: 0.12em;
428+ color: var(--muted-foreground);
429+ }
430+
431+ .reset-button {
432+ display: inline-flex;
433+ align-items: center;
434+ justify-content: center;
435+ padding: 0.55rem 1rem;
436+ font-size: 0.7rem;
437+ font-weight: 600;
438+ letter-spacing: 0.12em;
439+ color: var(--foreground);
440+ background: transparent;
441+ border: 1px solid var(--border);
442+ cursor: pointer;
443+ transition: all 120ms ease;
444+ }
445+
446+ .reset-button:hover {
447+ background: color-mix(in srgb, var(--card) 25%, transparent);
448+ }
449+
450+ .reset-button:disabled {
451+ opacity: 0.6;
452+ cursor: not-allowed;
453+ }
454+
455+ .save-button {
456+ display: inline-flex;
457+ align-items: center;
458+ justify-content: center;
459+ padding: 0.55rem 1rem;
460+ font-size: 0.75rem;
461+ font-weight: 600;
462+ letter-spacing: 0.12em;
463+ color: var(--background);
464+ background: var(--primary, #4ade80);
465+ border: 1px solid color-mix(in srgb, var(--primary, #4ade80) 70%, var(--border) 30%);
466+ cursor: pointer;
467+ transition: all 120ms ease;
468+ }
469+
470+ .save-button:hover {
471+ filter: brightness(1.02);
472+ }
473+
474+ .save-button:disabled {
475+ opacity: 0.6;
476+ cursor: not-allowed;
477+ }
478+</style>
479+
@@ -0,0 +1,479 @@
1+<script lang="ts">
2+ import { onMount } from "svelte";
3+ import ConfigShell from "./config/ConfigShell.svelte";
4+
5+ type ColorPalette = {
6+ background: string;
7+ surface: string;
8+ surface_variant: string;
9+ text: string;
10+ muted: string;
11+ accent: string;
12+ border: string;
13+ };
14+
15+ type ConfigDTO = {
16+ appearance: {
17+ sans_font: string;
18+ serif_font: string;
19+ monospace_font: string;
20+ default_font_size: number;
21+ color_scheme: string;
22+ light_palette: ColorPalette;
23+ dark_palette: ColorPalette;
24+ };
25+ default_ui_scale: number;
26+ default_search_engine: string;
27+ };
28+
29+ const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [
30+ { key: "background", label: "Background" },
31+ { key: "surface", label: "Surface" },
32+ { key: "surface_variant", label: "Surface Variant" },
33+ { key: "text", label: "Text" },
34+ { key: "muted", label: "Muted" },
35+ { key: "accent", label: "Accent" },
36+ { key: "border", label: "Border" },
37+ ];
38+
39+ function isHexColor(value: string): boolean {
40+ return /^#[0-9a-fA-F]{6}$/.test(value);
41+ }
42+
43+
44+ let config = $state<ConfigDTO | null>(null);
45+ let loading = $state(true);
46+ let error = $state<string | null>(null);
47+ let saving = $state(false);
48+ let saveSuccess = $state(false);
49+ let themeEvents = $state(0);
50+
51+ async function fetchConfig() {
52+ try {
53+ const response = await fetch("/api/config");
54+ if (!response.ok) throw new Error("Failed to fetch config");
55+ config = (await response.json()) as ConfigDTO;
56+ } catch (e: any) {
57+ error = e.message;
58+ } finally {
59+ loading = false;
60+ }
61+ }
62+
63+ async function resetToDefaults() {
64+ error = null;
65+ saveSuccess = false;
66+
67+ const confirmed = window.confirm(
68+ "Reset this page to default settings? (You still need to click Save to persist.)",
69+ );
70+ if (!confirmed) return;
71+
72+ try {
73+ const response = await fetch("/api/config/default");
74+ if (!response.ok) throw new Error("Failed to fetch default config");
75+ config = (await response.json()) as ConfigDTO;
76+ } catch (e: any) {
77+ error = e.message;
78+ }
79+ }
80+
81+ function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
82+ const bridge = (window as any).webkit?.messageHandlers?.dumber;
83+ if (bridge && typeof bridge.postMessage === "function") {
84+ return bridge;
85+ }
86+ return null;
87+ }
88+
89+ function getWebViewId(): number {
90+ return (window as any).__dumber_webview_id || 0;
91+ }
92+
93+ async function saveConfig() {
94+ saving = true;
95+ saveSuccess = false;
96+ error = null;
97+
98+ try {
99+ const bridge = getWebKitBridge();
100+ const webviewId = getWebViewId();
101+
102+ console.debug("[config] save click", {
103+ hasBridge: Boolean(bridge),
104+ webviewId,
105+ hasConfig: Boolean(config),
106+ });
107+
108+ if (!bridge) {
109+ throw new Error("WebKit bridge not available (not running inside Dumber)");
110+ }
111+ if (!webviewId) {
112+ throw new Error("Missing window.__dumber_webview_id; cannot dispatch/receive callbacks");
113+ }
114+ if (!config) {
115+ throw new Error("Config not loaded");
116+ }
117+
118+ let timeout: number | null = window.setTimeout(() => {
119+ if (saving) {
120+ console.warn("[config] save timed out (no callback)");
121+ error = "Save timed out (no response from native handler)";
122+ saving = false;
123+ }
124+ }, 8000);
125+
126+ const clearSaveTimeout = () => {
127+ if (timeout != null) {
128+ window.clearTimeout(timeout);
129+ timeout = null;
130+ }
131+ };
132+
133+ (window as any).__dumber_config_saved = (resp?: unknown) => {
134+ clearSaveTimeout();
135+ console.debug("[config] save success", resp);
136+ saveSuccess = true;
137+ saving = false;
138+
139+ // Refresh config state from backend (in case watcher normalized values)
140+ fetchConfig();
141+
142+ setTimeout(() => {
143+ saveSuccess = false;
144+ }, 3000);
145+ };
146+ (window as any).__dumber_config_error = (msg: unknown) => {
147+ clearSaveTimeout();
148+ console.error("[config] save error", msg);
149+ error = typeof msg === "string" ? msg : "Failed to save config";
150+ saving = false;
151+ };
152+
153+ const payload = $state.snapshot(config);
154+
155+ try {
156+ bridge.postMessage({
157+ type: "save_config",
158+ webview_id: webviewId,
159+ payload,
160+ });
161+ console.debug("[config] postMessage sent", { payloadBytes: JSON.stringify(payload).length });
162+ } catch (postErr) {
163+ console.error("[config] postMessage threw", postErr);
164+ throw postErr;
165+ }
166+ } catch (e: any) {
167+ console.error("[config] save exception", e);
168+ error = e.message;
169+ saving = false;
170+ }
171+ }
172+
173+ onMount(() => {
174+ console.debug("[config] mount", {
175+ webviewId: (window as any).__dumber_webview_id,
176+ hasBridge: Boolean((window as any).webkit?.messageHandlers?.dumber),
177+ });
178+
179+ const onThemeChanged = (e: Event) => {
180+ themeEvents += 1;
181+ console.debug("[config] theme changed", (e as CustomEvent).detail);
182+ };
183+ window.addEventListener('dumber:theme-changed', onThemeChanged);
184+
185+ fetchConfig();
186+
187+ return () => {
188+ window.removeEventListener('dumber:theme-changed', onThemeChanged);
189+ };
190+ });
191+</script>
192+
193+<ConfigShell>
194+ <div class="config-content">
195+ {#if loading}
196+ <div class="loading">LOADING…</div>
197+ {:else if error}
198+ <div class="error">
199+ <strong>ERROR</strong>
200+ <span>{error}</span>
201+ </div>
202+ {/if}
203+
204+ {#if config}
205+ <div class="stack">
206+ <!-- Appearance Section -->
207+ <section class="border border-border bg-card p-6 text-card-foreground">
208+ <h2 class="mb-6 text-xl font-semibold">Appearance</h2>
209+
210+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
211+ <div class="space-y-2">
212+ <label for="sans_font" class="block text-sm font-medium text-muted-foreground">Sans-Serif Font</label>
213+ <input
214+ id="sans_font"
215+ type="text"
216+ bind:value={config.appearance.sans_font}
217+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
218+ />
219+ </div>
220+
221+ <div class="space-y-2">
222+ <label for="serif_font" class="block text-sm font-medium text-muted-foreground">Serif Font</label>
223+ <input
224+ id="serif_font"
225+ type="text"
226+ bind:value={config.appearance.serif_font}
227+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
228+ />
229+ </div>
230+
231+ <div class="space-y-2">
232+ <label for="monospace_font" class="block text-sm font-medium text-muted-foreground">Monospace Font</label>
233+ <input
234+ id="monospace_font"
235+ type="text"
236+ bind:value={config.appearance.monospace_font}
237+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
238+ />
239+ </div>
240+
241+ <div class="space-y-2">
242+ <label for="font_size" class="block text-sm font-medium text-muted-foreground">Default Font Size</label>
243+ <input
244+ id="font_size"
245+ type="number"
246+ bind:value={config.appearance.default_font_size}
247+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
248+ />
249+ </div>
250+
251+ <div class="space-y-2">
252+ <label for="color_scheme" class="block text-sm font-medium text-muted-foreground">Color Scheme</label>
253+ <select
254+ id="color_scheme"
255+ bind:value={config.appearance.color_scheme}
256+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
257+ >
258+ <option value="default">Follow System</option>
259+ <option value="prefer-dark">Always Dark</option>
260+ <option value="prefer-light">Always Light</option>
261+ </select>
262+ </div>
263+
264+ <div class="space-y-2">
265+ <label for="ui_scale" class="block text-sm font-medium text-muted-foreground">UI Scale</label>
266+ <input
267+ id="ui_scale"
268+ type="number"
269+ step="0.1"
270+ bind:value={config.default_ui_scale}
271+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
272+ />
273+ </div>
274+ </div>
275+
276+ <div class="mt-8 space-y-8">
277+ <div>
278+ <h3 class="mb-3 text-base font-semibold">Theme: Light</h3>
279+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
280+ {#each PALETTE_FIELDS as field (field.key)}
281+ <div class="space-y-2">
282+ <label class="block text-sm font-medium text-muted-foreground" for={`light_${field.key}`}
283+ >{field.label}</label
284+ >
285+ <div class="flex items-center gap-3">
286+ <input
287+ id={`light_${field.key}`}
288+ type="color"
289+ value={config.appearance.light_palette[field.key]}
290+ oninput={(e) => {
291+ const v = (e.currentTarget as HTMLInputElement).value;
292+ config!.appearance.light_palette[field.key] = v;
293+ }}
294+ class="h-10 w-12 border border-input bg-background"
295+ />
296+ <input
297+ type="text"
298+ bind:value={config.appearance.light_palette[field.key]}
299+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
300+ />
301+ </div>
302+ {#if !isHexColor(config.appearance.light_palette[field.key])}
303+ <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
304+ {/if}
305+ </div>
306+ {/each}
307+ </div>
308+ </div>
309+
310+ <div>
311+ <h3 class="mb-3 text-base font-semibold">Theme: Dark</h3>
312+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
313+ {#each PALETTE_FIELDS as field (field.key)}
314+ <div class="space-y-2">
315+ <label class="block text-sm font-medium text-muted-foreground" for={`dark_${field.key}`}
316+ >{field.label}</label
317+ >
318+ <div class="flex items-center gap-3">
319+ <input
320+ id={`dark_${field.key}`}
321+ type="color"
322+ value={config.appearance.dark_palette[field.key]}
323+ oninput={(e) => {
324+ const v = (e.currentTarget as HTMLInputElement).value;
325+ config!.appearance.dark_palette[field.key] = v;
326+ }}
327+ class="h-10 w-12 border border-input bg-background"
328+ />
329+ <input
330+ type="text"
331+ bind:value={config.appearance.dark_palette[field.key]}
332+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
333+ />
334+ </div>
335+ {#if !isHexColor(config.appearance.dark_palette[field.key])}
336+ <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
337+ {/if}
338+ </div>
339+ {/each}
340+ </div>
341+ </div>
342+ </div>
343+ </section>
344+
345+ <!-- Search Section -->
346+ <section class="border border-border bg-card p-6 text-card-foreground">
347+ <h2 class="mb-6 text-xl font-semibold">Search</h2>
348+ <div class="space-y-4">
349+ <div class="space-y-2">
350+ <label for="search_engine" class="block text-sm font-medium text-muted-foreground">Default Search Engine (use %s for query)</label>
351+ <input
352+ id="search_engine"
353+ type="text"
354+ bind:value={config.default_search_engine}
355+ class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring"
356+ />
357+ </div>
358+ </div>
359+ </section>
360+
361+ <div class="actions">
362+ {#if saveSuccess}
363+ <span class="saved">SAVED</span>
364+ {/if}
365+ {#if themeEvents > 0}
366+ <span class="hint">THEME×{themeEvents}</span>
367+ {/if}
368+ <button onclick={resetToDefaults} disabled={saving} class="reset-button" type="button">
369+ RESET DEFAULTS
370+ </button>
371+ <button onclick={saveConfig} disabled={saving} class="save-button" type="button">
372+ {saving ? "SAVING…" : "SAVE"}
373+ </button>
374+ </div>
375+ </div>
376+ {/if}
377+</div>
378+</ConfigShell>
379+
380+<style>
381+ .config-content {
382+ width: 100%;
383+ max-width: 980px;
384+ margin: 0 auto;
385+ padding: 1.5rem 1.25rem 2rem;
386+ }
387+
388+ .stack {
389+ display: flex;
390+ flex-direction: column;
391+ gap: 1.25rem;
392+ }
393+
394+ .loading {
395+ padding: 2rem 0;
396+ color: var(--muted-foreground);
397+ letter-spacing: 0.1em;
398+ font-size: 0.8rem;
399+ }
400+
401+ .error {
402+ padding: 0.75rem 1rem;
403+ border: 1px solid color-mix(in srgb, var(--border) 50%, #ef4444 50%);
404+ background: color-mix(in srgb, var(--card) 70%, #ef4444 30%);
405+ display: flex;
406+ flex-direction: column;
407+ gap: 0.25rem;
408+ }
409+
410+ .actions {
411+ display: flex;
412+ align-items: center;
413+ justify-content: flex-end;
414+ gap: 0.75rem;
415+ padding-top: 0.75rem;
416+ border-top: 1px solid var(--border);
417+ }
418+
419+ .saved {
420+ font-size: 0.7rem;
421+ letter-spacing: 0.12em;
422+ color: var(--primary, #4ade80);
423+ }
424+
425+ .hint {
426+ font-size: 0.7rem;
427+ letter-spacing: 0.12em;
428+ color: var(--muted-foreground);
429+ }
430+
431+ .reset-button {
432+ display: inline-flex;
433+ align-items: center;
434+ justify-content: center;
435+ padding: 0.55rem 1rem;
436+ font-size: 0.7rem;
437+ font-weight: 600;
438+ letter-spacing: 0.12em;
439+ color: var(--foreground);
440+ background: transparent;
441+ border: 1px solid var(--border);
442+ cursor: pointer;
443+ transition: all 120ms ease;
444+ }
445+
446+ .reset-button:hover {
447+ background: color-mix(in srgb, var(--card) 25%, transparent);
448+ }
449+
450+ .reset-button:disabled {
451+ opacity: 0.6;
452+ cursor: not-allowed;
453+ }
454+
455+ .save-button {
456+ display: inline-flex;
457+ align-items: center;
458+ justify-content: center;
459+ padding: 0.55rem 1rem;
460+ font-size: 0.75rem;
461+ font-weight: 600;
462+ letter-spacing: 0.12em;
463+ color: var(--background);
464+ background: var(--primary, #4ade80);
465+ border: 1px solid color-mix(in srgb, var(--primary, #4ade80) 70%, var(--border) 30%);
466+ cursor: pointer;
467+ transition: all 120ms ease;
468+ }
469+
470+ .save-button:hover {
471+ filter: brightness(1.02);
472+ }
473+
474+ .save-button:disabled {
475+ opacity: 0.6;
476+ cursor: not-allowed;
477+ }
478+</style>
479+
A webui/src/pages/config.ts +10 -0
@@ -0,0 +1,10 @@
1+import { mount } from "svelte";
2+import ConfigPage from "./ConfigPage.svelte";
3+import "../lib/styles.css";
4+
5+// Mount the config page
6+const app = mount(ConfigPage, {
7+ target: document.body,
8+});
9+
10+export default app;
@@ -0,0 +1,10 @@
1+import { mount } from "svelte";
2+import ConfigPage from "./ConfigPage.svelte";
3+import "../lib/styles.css";
4+
5+// Mount the config page
6+const app = mount(ConfigPage, {
7+ target: document.body,
8+});
9+
10+export default app;
A webui/src/pages/config/ConfigShell.svelte +228 -0
@@ -0,0 +1,228 @@
1+<script lang="ts">
2+ import { onMount } from 'svelte';
3+ import { Home } from '@lucide/svelte';
4+
5+ interface Props {
6+ children?: import('svelte').Snippet;
7+ }
8+
9+ let { children }: Props = $props();
10+
11+ function navigateWithViewTransition(url: string) {
12+ const doc = document as any;
13+
14+ if (typeof doc?.startViewTransition !== 'function') {
15+ window.location.href = url;
16+ return;
17+ }
18+
19+ const transition = doc.startViewTransition(() => {
20+ document.documentElement.dataset.vt = 'leaving';
21+ });
22+
23+ transition.finished.finally(() => {
24+ window.location.href = url;
25+ });
26+ }
27+
28+ onMount(() => {
29+ // Avoid default WebKit focus rings.
30+ document.documentElement.dataset.vt = 'ready';
31+ });
32+</script>
33+
34+<svelte:head>
35+ <title>dumb://config</title>
36+ <meta name="description" content="Dumber Browser - Settings" />
37+ {@html `<style>
38+ html, body { margin: 0; padding: 0; }
39+ html { background: var(--background, #0a0a0a); }
40+ body { background: var(--background, #0a0a0a); color: var(--foreground, #e5e5e5); }
41+ *:focus { outline: none !important; }
42+ *:focus-visible { outline: none !important; }
43+ button:focus, input:focus, a:focus { outline: none !important; box-shadow: none !important; }
44+ * { -webkit-overflow-scrolling: touch; }
45+ </style>`}
46+</svelte:head>
47+
48+<div class="config-shell browser-ui">
49+ <div class="terminal-frame">
50+ <header class="terminal-header">
51+ <div class="header-left">
52+ <span class="terminal-path">dumb://config</span>
53+ </div>
54+ <div class="header-right">
55+ <button
56+ class="action-button"
57+ type="button"
58+ onclick={() => navigateWithViewTransition('dumb://home')}
59+ title="Back to Home"
60+ >
61+ <Home size={14} strokeWidth={2} />
62+ <span class="button-text">HOME</span>
63+ </button>
64+ </div>
65+ </header>
66+
67+ <main class="terminal-body scrollable">
68+ {#if children}
69+ {@render children()}
70+ {/if}
71+ </main>
72+
73+ <footer class="terminal-footer">
74+ <span class="footer-text">Dumber Browser</span>
75+ </footer>
76+ </div>
77+</div>
78+
79+<style>
80+ :global(html),
81+ :global(body) {
82+ height: 100%;
83+ }
84+
85+ :global(body) {
86+ overflow: hidden;
87+ overscroll-behavior: contain;
88+ }
89+
90+ .config-shell {
91+ height: 100vh;
92+ min-height: 100vh;
93+ display: flex;
94+ flex-direction: column;
95+ font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
96+ line-height: 1.5;
97+ color: var(--foreground);
98+ background: var(--background);
99+ overflow: hidden;
100+ }
101+
102+ .terminal-frame {
103+ flex: 1;
104+ display: grid;
105+ grid-template-rows: auto 1fr auto;
106+ width: 100%;
107+ height: 100%;
108+ background: color-mix(in srgb, var(--card) 60%, var(--background) 40%);
109+ }
110+
111+ :global(.dark) .terminal-frame {
112+ box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.5);
113+ }
114+
115+ .terminal-header {
116+ display: flex;
117+ align-items: center;
118+ justify-content: space-between;
119+ gap: 1rem;
120+ padding: 0.75rem 1rem;
121+ min-height: 3.5rem;
122+ border-bottom: 1px solid var(--border);
123+ background: color-mix(in srgb, var(--background) 90%, var(--card) 10%);
124+ position: sticky;
125+ top: 0;
126+ z-index: 5;
127+ }
128+
129+ .header-left {
130+ display: flex;
131+ align-items: center;
132+ gap: 1.5rem;
133+ }
134+
135+ .terminal-path {
136+ font-size: 0.8rem;
137+ font-weight: 600;
138+ color: var(--primary, #4ade80);
139+ letter-spacing: 0.05em;
140+ }
141+
142+ .header-right {
143+ display: flex;
144+ align-items: center;
145+ gap: 0.5rem;
146+ }
147+
148+ .action-button {
149+ display: flex;
150+ align-items: center;
151+ gap: 0.5rem;
152+ padding: 0.4rem 0.75rem;
153+ font-size: 0.7rem;
154+ font-weight: 500;
155+ letter-spacing: 0.08em;
156+ color: var(--muted-foreground);
157+ background: transparent;
158+ border-width: 1px;
159+ border-style: solid;
160+ border-color: var(--border);
161+ cursor: pointer;
162+ transition: all 120ms ease;
163+ }
164+
165+ .action-button:hover,
166+ .action-button:focus-visible {
167+ color: var(--foreground);
168+ border-color: color-mix(in srgb, var(--border) 50%, var(--foreground) 50%);
169+ background: color-mix(in srgb, var(--card) 30%, transparent);
170+ outline: none;
171+ }
172+
173+ .terminal-body {
174+ display: flex;
175+ flex-direction: column;
176+ min-height: 0;
177+ overflow: hidden;
178+ background: var(--background);
179+ }
180+
181+ .terminal-body.scrollable {
182+ overflow-y: auto;
183+ -webkit-overflow-scrolling: touch;
184+ touch-action: pan-y;
185+ }
186+
187+ .terminal-footer {
188+ display: flex;
189+ align-items: center;
190+ justify-content: center;
191+ padding: 0.75rem 1rem;
192+ border-top: 1px solid var(--border);
193+ background: color-mix(in srgb, var(--background) 92%, var(--card) 8%);
194+ }
195+
196+ .footer-text {
197+ font-size: 0.7rem;
198+ letter-spacing: 0.08em;
199+ color: var(--muted-foreground);
200+ }
201+
202+ /* View Transition helpers */
203+ :global(html[data-vt='leaving']) {
204+ view-transition-name: config-root;
205+ }
206+
207+ :global(::view-transition-old(config-root)) {
208+ animation: vt-fade-out 180ms ease both;
209+ }
210+
211+ :global(::view-transition-new(config-root)) {
212+ animation: vt-fade-in 220ms ease both;
213+ }
214+
215+ @keyframes vt-fade-out {
216+ to {
217+ opacity: 0;
218+ transform: translateY(6px);
219+ }
220+ }
221+
222+ @keyframes vt-fade-in {
223+ from {
224+ opacity: 0;
225+ transform: translateY(-6px);
226+ }
227+ }
228+</style>
@@ -0,0 +1,228 @@
1+<script lang="ts">
2+ import { onMount } from 'svelte';
3+ import { Home } from '@lucide/svelte';
4+
5+ interface Props {
6+ children?: import('svelte').Snippet;
7+ }
8+
9+ let { children }: Props = $props();
10+
11+ function navigateWithViewTransition(url: string) {
12+ const doc = document as any;
13+
14+ if (typeof doc?.startViewTransition !== 'function') {
15+ window.location.href = url;
16+ return;
17+ }
18+
19+ const transition = doc.startViewTransition(() => {
20+ document.documentElement.dataset.vt = 'leaving';
21+ });
22+
23+ transition.finished.finally(() => {
24+ window.location.href = url;
25+ });
26+ }
27+
28+ onMount(() => {
29+ // Avoid default WebKit focus rings.
30+ document.documentElement.dataset.vt = 'ready';
31+ });
32+</script>
33+
34+<svelte:head>
35+ <title>dumb://config</title>
36+ <meta name="description" content="Dumber Browser - Settings" />
37+ {@html `<style>
38+ html, body { margin: 0; padding: 0; }
39+ html { background: var(--background, #0a0a0a); }
40+ body { background: var(--background, #0a0a0a); color: var(--foreground, #e5e5e5); }
41+ *:focus { outline: none !important; }
42+ *:focus-visible { outline: none !important; }
43+ button:focus, input:focus, a:focus { outline: none !important; box-shadow: none !important; }
44+ * { -webkit-overflow-scrolling: touch; }
45+ </style>`}
46+</svelte:head>
47+
48+<div class="config-shell browser-ui">
49+ <div class="terminal-frame">
50+ <header class="terminal-header">
51+ <div class="header-left">
52+ <span class="terminal-path">dumb://config</span>
53+ </div>
54+ <div class="header-right">
55+ <button
56+ class="action-button"
57+ type="button"
58+ onclick={() => navigateWithViewTransition('dumb://home')}
59+ title="Back to Home"
60+ >
61+ <Home size={14} strokeWidth={2} />
62+ <span class="button-text">HOME</span>
63+ </button>
64+ </div>
65+ </header>
66+
67+ <main class="terminal-body scrollable">
68+ {#if children}
69+ {@render children()}
70+ {/if}
71+ </main>
72+
73+ <footer class="terminal-footer">
74+ <span class="footer-text">Dumber Browser</span>
75+ </footer>
76+ </div>
77+</div>
78+
79+<style>
80+ :global(html),
81+ :global(body) {
82+ height: 100%;
83+ }
84+
85+ :global(body) {
86+ overflow: hidden;
87+ overscroll-behavior: contain;
88+ }
89+
90+ .config-shell {
91+ height: 100vh;
92+ min-height: 100vh;
93+ display: flex;
94+ flex-direction: column;
95+ font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
96+ line-height: 1.5;
97+ color: var(--foreground);
98+ background: var(--background);
99+ overflow: hidden;
100+ }
101+
102+ .terminal-frame {
103+ flex: 1;
104+ display: grid;
105+ grid-template-rows: auto 1fr auto;
106+ width: 100%;
107+ height: 100%;
108+ background: color-mix(in srgb, var(--card) 60%, var(--background) 40%);
109+ }
110+
111+ :global(.dark) .terminal-frame {
112+ box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.5);
113+ }
114+
115+ .terminal-header {
116+ display: flex;
117+ align-items: center;
118+ justify-content: space-between;
119+ gap: 1rem;
120+ padding: 0.75rem 1rem;
121+ min-height: 3.5rem;
122+ border-bottom: 1px solid var(--border);
123+ background: color-mix(in srgb, var(--background) 90%, var(--card) 10%);
124+ position: sticky;
125+ top: 0;
126+ z-index: 5;
127+ }
128+
129+ .header-left {
130+ display: flex;
131+ align-items: center;
132+ gap: 1.5rem;
133+ }
134+
135+ .terminal-path {
136+ font-size: 0.8rem;
137+ font-weight: 600;
138+ color: var(--primary, #4ade80);
139+ letter-spacing: 0.05em;
140+ }
141+
142+ .header-right {
143+ display: flex;
144+ align-items: center;
145+ gap: 0.5rem;
146+ }
147+
148+ .action-button {
149+ display: flex;
150+ align-items: center;
151+ gap: 0.5rem;
152+ padding: 0.4rem 0.75rem;
153+ font-size: 0.7rem;
154+ font-weight: 500;
155+ letter-spacing: 0.08em;
156+ color: var(--muted-foreground);
157+ background: transparent;
158+ border-width: 1px;
159+ border-style: solid;
160+ border-color: var(--border);
161+ cursor: pointer;
162+ transition: all 120ms ease;
163+ }
164+
165+ .action-button:hover,
166+ .action-button:focus-visible {
167+ color: var(--foreground);
168+ border-color: color-mix(in srgb, var(--border) 50%, var(--foreground) 50%);
169+ background: color-mix(in srgb, var(--card) 30%, transparent);
170+ outline: none;
171+ }
172+
173+ .terminal-body {
174+ display: flex;
175+ flex-direction: column;
176+ min-height: 0;
177+ overflow: hidden;
178+ background: var(--background);
179+ }
180+
181+ .terminal-body.scrollable {
182+ overflow-y: auto;
183+ -webkit-overflow-scrolling: touch;
184+ touch-action: pan-y;
185+ }
186+
187+ .terminal-footer {
188+ display: flex;
189+ align-items: center;
190+ justify-content: center;
191+ padding: 0.75rem 1rem;
192+ border-top: 1px solid var(--border);
193+ background: color-mix(in srgb, var(--background) 92%, var(--card) 8%);
194+ }
195+
196+ .footer-text {
197+ font-size: 0.7rem;
198+ letter-spacing: 0.08em;
199+ color: var(--muted-foreground);
200+ }
201+
202+ /* View Transition helpers */
203+ :global(html[data-vt='leaving']) {
204+ view-transition-name: config-root;
205+ }
206+
207+ :global(::view-transition-old(config-root)) {
208+ animation: vt-fade-out 180ms ease both;
209+ }
210+
211+ :global(::view-transition-new(config-root)) {
212+ animation: vt-fade-in 220ms ease both;
213+ }
214+
215+ @keyframes vt-fade-out {
216+ to {
217+ opacity: 0;
218+ transform: translateY(6px);
219+ }
220+ }
221+
222+ @keyframes vt-fade-in {
223+ from {
224+ opacity: 0;
225+ transform: translateY(-6px);
226+ }
227+ }
228+</style>
M webui/src/pages/homepage/shell/HomepageShell.svelte +27 -1
@@ -6,7 +6,7 @@
66 import CommandPalette from './CommandPalette.svelte';
77 import StatusBar from './StatusBar.svelte';
88 import KeyboardHints from './KeyboardHints.svelte';
9- import { History, Star, BarChart3, Search, Moon, Sun } from '@lucide/svelte';
9+ import { History, Star, BarChart3, Search, Moon, Sun, Settings } from '@lucide/svelte';
1010
1111 // Props for slot content
1212 interface Props {
@@ -34,6 +34,23 @@
3434 themeMode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
3535 };
3636
37+ function navigateWithViewTransition(url: string) {
38+ const doc = document as any;
39+
40+ if (typeof doc?.startViewTransition !== 'function') {
41+ window.location.href = url;
42+ return;
43+ }
44+
45+ const transition = doc.startViewTransition(() => {
46+ document.documentElement.dataset.vt = 'leaving';
47+ });
48+
49+ transition.finished.finally(() => {
50+ window.location.href = url;
51+ });
52+ }
53+
3754 const toggleTheme = () => {
3855 const nextMode = themeMode === 'dark' ? 'light' : 'dark';
3956 const manager = (window as any).__dumber_color_scheme_manager as
@@ -173,6 +190,15 @@
173190 <span class="button-text">CMD</span>
174191 <kbd class="kbd-hint">/</kbd>
175192 </button>
193+ <button
194+ class="action-button"
195+ type="button"
196+ onclick={() => navigateWithViewTransition('dumb://config')}
197+ title="Settings"
198+ >
199+ <Settings size={14} strokeWidth={2} />
200+ <span class="button-text">CFG</span>
201+ </button>
176202 <button
177203 class="theme-toggle"
178204 type="button"
@@ -6,7 +6,7 @@
6 import CommandPalette from './CommandPalette.svelte';6 import CommandPalette from './CommandPalette.svelte';
7 import StatusBar from './StatusBar.svelte';7 import StatusBar from './StatusBar.svelte';
8 import KeyboardHints from './KeyboardHints.svelte';8 import KeyboardHints from './KeyboardHints.svelte';
9- import { History, Star, BarChart3, Search, Moon, Sun } from '@lucide/svelte';9+ import { History, Star, BarChart3, Search, Moon, Sun, Settings } from '@lucide/svelte';
10 10
11 // Props for slot content11 // Props for slot content
12 interface Props {12 interface Props {
@@ -34,6 +34,23 @@
34 themeMode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';34 themeMode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
35 };35 };
36 36
37+ function navigateWithViewTransition(url: string) {
38+ const doc = document as any;
39+
40+ if (typeof doc?.startViewTransition !== 'function') {
41+ window.location.href = url;
42+ return;
43+ }
44+
45+ const transition = doc.startViewTransition(() => {
46+ document.documentElement.dataset.vt = 'leaving';
47+ });
48+
49+ transition.finished.finally(() => {
50+ window.location.href = url;
51+ });
52+ }
53+
37 const toggleTheme = () => {54 const toggleTheme = () => {
38 const nextMode = themeMode === 'dark' ? 'light' : 'dark';55 const nextMode = themeMode === 'dark' ? 'light' : 'dark';
39 const manager = (window as any).__dumber_color_scheme_manager as56 const manager = (window as any).__dumber_color_scheme_manager as
@@ -173,6 +190,15 @@
173 <span class="button-text">CMD</span>190 <span class="button-text">CMD</span>
174 <kbd class="kbd-hint">/</kbd>191 <kbd class="kbd-hint">/</kbd>
175 </button>192 </button>
193+ <button
194+ class="action-button"
195+ type="button"
196+ onclick={() => navigateWithViewTransition('dumb://config')}
197+ title="Settings"
198+ >
199+ <Settings size={14} strokeWidth={2} />
200+ <span class="button-text">CFG</span>
201+ </button>
176 <button202 <button
177 class="theme-toggle"203 class="theme-toggle"
178 type="button"204 type="button"
M webui/vite.config.ts +12 -0
@@ -29,6 +29,18 @@ const pages = {
2929 filename: "blocked.html",
3030 },
3131 },
32+ config: {
33+ entry: "src/pages/config.ts",
34+ output: "config.min.js",
35+ global: "DumberConfig",
36+ html: {
37+ name: "config",
38+ title: "Settings",
39+ script: "config.min.js",
40+ css: "style.css",
41+ filename: "config.html",
42+ },
43+ },
3244 } as const;
3345
3446 // Get page from VITE_PAGE env var, default to building all
@@ -29,6 +29,18 @@ const pages = {
29 filename: "blocked.html",29 filename: "blocked.html",
30 },30 },
31 },31 },
32+ config: {
33+ entry: "src/pages/config.ts",
34+ output: "config.min.js",
35+ global: "DumberConfig",
36+ html: {
37+ name: "config",
38+ title: "Settings",
39+ script: "config.min.js",
40+ css: "style.css",
41+ filename: "config.html",
42+ },
43+ },
32 } as const;44 } as const;
33 45
34 // Get page from VITE_PAGE env var, default to building all46 // Get page from VITE_PAGE env var, default to building all

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close