detached cf2b13e

feat(systemviews): add themed shell and full config views

commit
cf2b13e20a51f37f64cf17f97a6a3988fec5f453
author
brice · · 2026-04-12 09:00 UTC
parent
a8b7d7d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
33 files · +2304 -181
M assets/systemviews/index.html +2 -0
@@ -3,7 +3,9 @@
33 <head>
44 <meta charset="utf-8">
55 <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <meta name="color-scheme" content="dark light">
67 <title>systemviews</title>
8+ <link rel="stylesheet" href="./systemviews.css">
79 <script src="./wasm_exec.js"></script>
810 <script>
911 const go = new Go();
@@ -3,7 +3,9 @@
3 <head>3 <head>
4 <meta charset="utf-8">4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">5 <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <meta name="color-scheme" content="dark light">
6 <title>systemviews</title>7 <title>systemviews</title>
8+ <link rel="stylesheet" href="./systemviews.css">
7 <script src="./wasm_exec.js"></script>9 <script src="./wasm_exec.js"></script>
8 <script>10 <script>
9 const go = new Go();11 const go = new Go();
A assets/systemviews/systemviews.css +151 -0
@@ -0,0 +1,151 @@
1+:root {
2+ color-scheme: dark light;
3+ --sv-bg: #0f1115;
4+ --sv-fg: #e8ebf0;
5+ --sv-muted: #9aa3b2;
6+ --sv-border: #2a313d;
7+}
8+
9+body {
10+ margin: 0;
11+ font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
12+ font-size: 14px;
13+ background: var(--sv-bg, #0f1115);
14+ color: var(--sv-fg, #e8ebf0);
15+}
16+
17+.sv-app {
18+ min-height: 100vh;
19+}
20+
21+.sv-shell {
22+ display: grid;
23+ gap: 1rem;
24+ padding: 1rem;
25+}
26+
27+.sv-header {
28+ background: var(--sv-surface-variant, #1d2129);
29+ border: 1px solid var(--sv-border, #2a313d);
30+ border-radius: 0.75rem;
31+ padding: 1rem;
32+}
33+
34+.sv-section {
35+ background: var(--sv-surface, #171a21);
36+ border: 1px solid var(--sv-border, #2a313d);
37+ border-radius: 0.75rem;
38+ padding: 1rem;
39+}
40+
41+.sv-meta {
42+ color: var(--sv-muted, #9aa3b2);
43+ font-size: 12px;
44+}
45+
46+.sv-group {
47+ margin-top: 1rem;
48+ padding: 1rem;
49+ border: 1px solid var(--sv-border, #2a313d);
50+ border-radius: 0.75rem;
51+ background: var(--sv-surface, #171a21);
52+}
53+
54+.sv-group-header {
55+ display: flex;
56+ justify-content: space-between;
57+ gap: 1rem;
58+ align-items: baseline;
59+}
60+
61+.sv-group-header h4 {
62+ margin: 0;
63+}
64+
65+.sv-group-body {
66+ display: grid;
67+ gap: 0.75rem;
68+ margin-top: 0.75rem;
69+}
70+
71+.sv-binding {
72+ padding-top: 0.75rem;
73+ border-top: 1px solid var(--sv-border, #2a313d);
74+ display: grid;
75+ gap: 0.5rem;
76+}
77+
78+.sv-group-body > .sv-binding:first-child {
79+ padding-top: 0;
80+ border-top: 0;
81+}
82+
83+.sv-binding-header {
84+ display: flex;
85+ justify-content: space-between;
86+ gap: 1rem;
87+ align-items: flex-start;
88+}
89+
90+.sv-binding-description {
91+ font-weight: 600;
92+}
93+
94+.sv-binding-keys {
95+ display: flex;
96+ flex-wrap: wrap;
97+ gap: 0.35rem;
98+ justify-content: flex-end;
99+}
100+
101+.sv-key {
102+ display: inline-flex;
103+ align-items: center;
104+ padding: 0.2rem 0.45rem;
105+ border-radius: 0.45rem;
106+ border: 1px solid var(--sv-border, #2a313d);
107+ background: var(--sv-surface-variant, #1d2129);
108+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
109+ font-size: 12px;
110+}
111+
112+.sv-binding-meta {
113+ display: flex;
114+ flex-wrap: wrap;
115+ gap: 0.35rem 1rem;
116+ color: var(--sv-muted, #9aa3b2);
117+ font-size: 12px;
118+}
119+
120+.sv-empty {
121+ color: var(--sv-muted, #9aa3b2);
122+}
123+
124+.sv-kv {
125+ margin: 0.75rem 0 0;
126+ display: grid;
127+ gap: 0.25rem;
128+}
129+
130+.sv-kv-row {
131+ display: grid;
132+ grid-template-columns: minmax(12rem, 35%) minmax(0, 1fr);
133+ gap: 0.25rem 1rem;
134+ padding: 0.5rem 0;
135+}
136+
137+.sv-kv-row + .sv-kv-row {
138+ border-top: 1px solid var(--sv-border, #2a313d);
139+}
140+
141+.sv-kv-row dt,
142+.sv-kv-row dd {
143+ margin: 0;
144+ min-width: 0;
145+ overflow-wrap: anywhere;
146+}
147+
148+.sv-kv-row dt {
149+ color: var(--sv-muted, #9aa3b2);
150+ font-weight: 600;
151+}
@@ -0,0 +1,151 @@
1+:root {
2+ color-scheme: dark light;
3+ --sv-bg: #0f1115;
4+ --sv-fg: #e8ebf0;
5+ --sv-muted: #9aa3b2;
6+ --sv-border: #2a313d;
7+}
8+
9+body {
10+ margin: 0;
11+ font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
12+ font-size: 14px;
13+ background: var(--sv-bg, #0f1115);
14+ color: var(--sv-fg, #e8ebf0);
15+}
16+
17+.sv-app {
18+ min-height: 100vh;
19+}
20+
21+.sv-shell {
22+ display: grid;
23+ gap: 1rem;
24+ padding: 1rem;
25+}
26+
27+.sv-header {
28+ background: var(--sv-surface-variant, #1d2129);
29+ border: 1px solid var(--sv-border, #2a313d);
30+ border-radius: 0.75rem;
31+ padding: 1rem;
32+}
33+
34+.sv-section {
35+ background: var(--sv-surface, #171a21);
36+ border: 1px solid var(--sv-border, #2a313d);
37+ border-radius: 0.75rem;
38+ padding: 1rem;
39+}
40+
41+.sv-meta {
42+ color: var(--sv-muted, #9aa3b2);
43+ font-size: 12px;
44+}
45+
46+.sv-group {
47+ margin-top: 1rem;
48+ padding: 1rem;
49+ border: 1px solid var(--sv-border, #2a313d);
50+ border-radius: 0.75rem;
51+ background: var(--sv-surface, #171a21);
52+}
53+
54+.sv-group-header {
55+ display: flex;
56+ justify-content: space-between;
57+ gap: 1rem;
58+ align-items: baseline;
59+}
60+
61+.sv-group-header h4 {
62+ margin: 0;
63+}
64+
65+.sv-group-body {
66+ display: grid;
67+ gap: 0.75rem;
68+ margin-top: 0.75rem;
69+}
70+
71+.sv-binding {
72+ padding-top: 0.75rem;
73+ border-top: 1px solid var(--sv-border, #2a313d);
74+ display: grid;
75+ gap: 0.5rem;
76+}
77+
78+.sv-group-body > .sv-binding:first-child {
79+ padding-top: 0;
80+ border-top: 0;
81+}
82+
83+.sv-binding-header {
84+ display: flex;
85+ justify-content: space-between;
86+ gap: 1rem;
87+ align-items: flex-start;
88+}
89+
90+.sv-binding-description {
91+ font-weight: 600;
92+}
93+
94+.sv-binding-keys {
95+ display: flex;
96+ flex-wrap: wrap;
97+ gap: 0.35rem;
98+ justify-content: flex-end;
99+}
100+
101+.sv-key {
102+ display: inline-flex;
103+ align-items: center;
104+ padding: 0.2rem 0.45rem;
105+ border-radius: 0.45rem;
106+ border: 1px solid var(--sv-border, #2a313d);
107+ background: var(--sv-surface-variant, #1d2129);
108+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
109+ font-size: 12px;
110+}
111+
112+.sv-binding-meta {
113+ display: flex;
114+ flex-wrap: wrap;
115+ gap: 0.35rem 1rem;
116+ color: var(--sv-muted, #9aa3b2);
117+ font-size: 12px;
118+}
119+
120+.sv-empty {
121+ color: var(--sv-muted, #9aa3b2);
122+}
123+
124+.sv-kv {
125+ margin: 0.75rem 0 0;
126+ display: grid;
127+ gap: 0.25rem;
128+}
129+
130+.sv-kv-row {
131+ display: grid;
132+ grid-template-columns: minmax(12rem, 35%) minmax(0, 1fr);
133+ gap: 0.25rem 1rem;
134+ padding: 0.5rem 0;
135+}
136+
137+.sv-kv-row + .sv-kv-row {
138+ border-top: 1px solid var(--sv-border, #2a313d);
139+}
140+
141+.sv-kv-row dt,
142+.sv-kv-row dd {
143+ margin: 0;
144+ min-width: 0;
145+ overflow-wrap: anywhere;
146+}
147+
148+.sv-kv-row dt {
149+ color: var(--sv-muted, #9aa3b2);
150+ font-weight: 600;
151+}
A cmd/systemviews/bootstrap.go +60 -0
@@ -0,0 +1,60 @@
1+package main
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/ui/systemviews"
8+)
9+
10+type bridgeServices interface {
11+ port.SystemviewHistoryService
12+ port.SystemviewFavoritesService
13+ port.SystemviewConfigService
14+}
15+
16+func newBridgeApp(dom systemviews.DOM, locationURI string, bridge bridgeServices) *systemviews.App {
17+ return systemviews.NewApp(systemviews.Dependencies{
18+ DOM: dom,
19+ History: bridge,
20+ Favorites: bridge,
21+ Config: bridgeConfigProxy{bridge: bridge, route: systemviews.ParseRoute(locationURI)},
22+ LocationURI: locationURI,
23+ })
24+}
25+
26+type bridgeConfigProxy struct {
27+ bridge bridgeServices
28+ route systemviews.Route
29+}
30+
31+func (p bridgeConfigProxy) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {
32+ return p.bridge.Current(ctx)
33+}
34+
35+func (p bridgeConfigProxy) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {
36+ return p.bridge.Default(ctx)
37+}
38+
39+func (p bridgeConfigProxy) Save(ctx context.Context, cfg port.WebUIConfig) error {
40+ return p.bridge.Save(ctx, cfg)
41+}
42+
43+func (p bridgeConfigProxy) GetKeybindings(ctx context.Context) (any, error) {
44+ if p.route != systemviews.RouteConfig {
45+ return nil, nil
46+ }
47+ return p.bridge.GetKeybindings(ctx)
48+}
49+
50+func (p bridgeConfigProxy) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (any, error) {
51+ return p.bridge.SetKeybinding(ctx, req)
52+}
53+
54+func (p bridgeConfigProxy) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {
55+ return p.bridge.ResetKeybinding(ctx, req)
56+}
57+
58+func (p bridgeConfigProxy) ResetAllKeybindings(ctx context.Context) error {
59+ return p.bridge.ResetAllKeybindings(ctx)
60+}
@@ -0,0 +1,60 @@
1+package main
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/ui/systemviews"
8+)
9+
10+type bridgeServices interface {
11+ port.SystemviewHistoryService
12+ port.SystemviewFavoritesService
13+ port.SystemviewConfigService
14+}
15+
16+func newBridgeApp(dom systemviews.DOM, locationURI string, bridge bridgeServices) *systemviews.App {
17+ return systemviews.NewApp(systemviews.Dependencies{
18+ DOM: dom,
19+ History: bridge,
20+ Favorites: bridge,
21+ Config: bridgeConfigProxy{bridge: bridge, route: systemviews.ParseRoute(locationURI)},
22+ LocationURI: locationURI,
23+ })
24+}
25+
26+type bridgeConfigProxy struct {
27+ bridge bridgeServices
28+ route systemviews.Route
29+}
30+
31+func (p bridgeConfigProxy) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {
32+ return p.bridge.Current(ctx)
33+}
34+
35+func (p bridgeConfigProxy) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {
36+ return p.bridge.Default(ctx)
37+}
38+
39+func (p bridgeConfigProxy) Save(ctx context.Context, cfg port.WebUIConfig) error {
40+ return p.bridge.Save(ctx, cfg)
41+}
42+
43+func (p bridgeConfigProxy) GetKeybindings(ctx context.Context) (any, error) {
44+ if p.route != systemviews.RouteConfig {
45+ return nil, nil
46+ }
47+ return p.bridge.GetKeybindings(ctx)
48+}
49+
50+func (p bridgeConfigProxy) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (any, error) {
51+ return p.bridge.SetKeybinding(ctx, req)
52+}
53+
54+func (p bridgeConfigProxy) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {
55+ return p.bridge.ResetKeybinding(ctx, req)
56+}
57+
58+func (p bridgeConfigProxy) ResetAllKeybindings(ctx context.Context) error {
59+ return p.bridge.ResetAllKeybindings(ctx)
60+}
A cmd/systemviews/bootstrap_test.go +197 -0
@@ -0,0 +1,197 @@
1+package main
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestNewBridgeApp_WiresHistoryService(t *testing.T) {
14+ t.Parallel()
15+
16+ bridge := &fakeBridgeService{historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}}}
17+ app := newBridgeApp(&fakeDOM{}, "dumb://history", bridge)
18+
19+ require.NoError(t, app.LoadInitial(context.Background()))
20+ assert.True(t, bridge.calledHistory)
21+ assert.False(t, bridge.calledFavorites)
22+ assert.True(t, bridge.calledConfig)
23+ assert.False(t, bridge.calledKeybindings)
24+}
25+
26+func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) {
27+ t.Parallel()
28+
29+ bridge := &fakeBridgeService{
30+ historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}},
31+ currentConfig: port.SystemviewConfigPayload{
32+ Appearance: port.WebUIAppearanceConfig{
33+ ColorScheme: "prefer-dark",
34+ DarkPalette: port.ColorPalette{
35+ Background: "#111111",
36+ Surface: "#1a1a1a",
37+ SurfaceVariant: "#2a2a2a",
38+ Text: "#f5f5f5",
39+ Muted: "#a0a0a0",
40+ Accent: "#66aaff",
41+ Border: "#333333",
42+ },
43+ },
44+ },
45+ }
46+ dom := &fakeDOM{}
47+ app := newBridgeApp(dom, "dumb://history", bridge)
48+
49+ require.NoError(t, app.Run())
50+ assert.True(t, bridge.calledHistory)
51+ assert.True(t, bridge.calledConfig)
52+ assert.False(t, bridge.calledKeybindings)
53+ assert.Contains(t, dom.html, `class="sv-app sv-dark"`)
54+ assert.Contains(t, dom.html, `--sv-background: #111111;`)
55+}
56+
57+func TestNewBridgeApp_WiresFavoritesService(t *testing.T) {
58+ t.Parallel()
59+
60+ bridge := &fakeBridgeService{
61+ favorites: []*entity.Favorite{{Title: "Example", URL: "https://example.com"}},
62+ folders: []*entity.Folder{{Name: "Read Later"}},
63+ tags: []*entity.Tag{{Name: "Go"}},
64+ }
65+ app := newBridgeApp(&fakeDOM{}, "dumb://favorites", bridge)
66+
67+ require.NoError(t, app.LoadInitial(context.Background()))
68+ assert.False(t, bridge.calledHistory)
69+ assert.True(t, bridge.calledFavorites)
70+ assert.True(t, bridge.calledConfig)
71+ assert.False(t, bridge.calledKeybindings)
72+}
73+
74+func TestNewBridgeApp_WiresConfigService(t *testing.T) {
75+ t.Parallel()
76+
77+ bridge := &fakeBridgeService{
78+ currentConfig: port.SystemviewConfigPayload{EngineType: "webkit"},
79+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{DisplayName: "Default"}}},
80+ }
81+ app := newBridgeApp(&fakeDOM{}, "dumb://config", bridge)
82+
83+ require.NoError(t, app.LoadInitial(context.Background()))
84+ assert.False(t, bridge.calledHistory)
85+ assert.False(t, bridge.calledFavorites)
86+ assert.True(t, bridge.calledConfig)
87+ assert.True(t, bridge.calledKeybindings)
88+}
89+
90+type fakeDOM struct {
91+ html string
92+}
93+
94+func (f *fakeDOM) Mount(html string) error {
95+ f.html = html
96+ return nil
97+}
98+
99+type fakeBridgeService struct {
100+ calledHistory bool
101+ calledFavorites bool
102+ calledConfig bool
103+ calledKeybindings bool
104+
105+ historyEntries []*entity.HistoryEntry
106+ favorites []*entity.Favorite
107+ folders []*entity.Folder
108+ tags []*entity.Tag
109+ currentConfig port.SystemviewConfigPayload
110+ keybindings any
111+}
112+
113+func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.HistoryEntry, error) {
114+ f.calledHistory = true
115+ return f.historyEntries, nil
116+}
117+
118+func (*fakeBridgeService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) {
119+ return nil, nil
120+}
121+
122+func (*fakeBridgeService) DeleteEntry(context.Context, int64) error { return nil }
123+
124+func (*fakeBridgeService) DeleteRange(context.Context, string) error { return nil }
125+
126+func (*fakeBridgeService) Analytics(context.Context) (*entity.HistoryAnalytics, error) {
127+ return nil, nil
128+}
129+
130+func (*fakeBridgeService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) {
131+ return nil, nil
132+}
133+
134+func (*fakeBridgeService) DeleteDomain(context.Context, string) error { return nil }
135+
136+func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) {
137+ f.calledFavorites = true
138+ return f.favorites, nil
139+}
140+
141+func (f *fakeBridgeService) ListFolders(context.Context) ([]*entity.Folder, error) {
142+ return f.folders, nil
143+}
144+
145+func (f *fakeBridgeService) ListTags(context.Context) ([]*entity.Tag, error) {
146+ return f.tags, nil
147+}
148+
149+func (*fakeBridgeService) SetShortcut(context.Context, int64, *int) error { return nil }
150+
151+func (*fakeBridgeService) SetFolder(context.Context, int64, *int64) error { return nil }
152+
153+func (*fakeBridgeService) CreateFolder(context.Context, string, *int64) (*entity.Folder, error) {
154+ return nil, nil
155+}
156+
157+func (*fakeBridgeService) UpdateFolder(context.Context, int64, string, string) error { return nil }
158+
159+func (*fakeBridgeService) DeleteFolder(context.Context, int64) error { return nil }
160+
161+func (*fakeBridgeService) CreateTag(context.Context, string, string) (*entity.Tag, error) {
162+ return nil, nil
163+}
164+
165+func (*fakeBridgeService) UpdateTag(context.Context, int64, string, string) error { return nil }
166+
167+func (*fakeBridgeService) DeleteTag(context.Context, int64) error { return nil }
168+
169+func (*fakeBridgeService) AssignTag(context.Context, int64, int64) error { return nil }
170+
171+func (*fakeBridgeService) RemoveTag(context.Context, int64, int64) error { return nil }
172+
173+func (f *fakeBridgeService) Current(context.Context) (port.SystemviewConfigPayload, error) {
174+ f.calledConfig = true
175+ return f.currentConfig, nil
176+}
177+
178+func (*fakeBridgeService) Default(context.Context) (port.SystemviewConfigPayload, error) {
179+ return port.SystemviewConfigPayload{}, nil
180+}
181+
182+func (*fakeBridgeService) Save(context.Context, port.WebUIConfig) error { return nil }
183+
184+func (f *fakeBridgeService) GetKeybindings(context.Context) (any, error) {
185+ f.calledKeybindings = true
186+ return f.keybindings, nil
187+}
188+
189+func (*fakeBridgeService) SetKeybinding(context.Context, port.SetKeybindingRequest) (any, error) {
190+ return nil, nil
191+}
192+
193+func (*fakeBridgeService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error {
194+ return nil
195+}
196+
197+func (*fakeBridgeService) ResetAllKeybindings(context.Context) error { return nil }
@@ -0,0 +1,197 @@
1+package main
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestNewBridgeApp_WiresHistoryService(t *testing.T) {
14+ t.Parallel()
15+
16+ bridge := &fakeBridgeService{historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}}}
17+ app := newBridgeApp(&fakeDOM{}, "dumb://history", bridge)
18+
19+ require.NoError(t, app.LoadInitial(context.Background()))
20+ assert.True(t, bridge.calledHistory)
21+ assert.False(t, bridge.calledFavorites)
22+ assert.True(t, bridge.calledConfig)
23+ assert.False(t, bridge.calledKeybindings)
24+}
25+
26+func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) {
27+ t.Parallel()
28+
29+ bridge := &fakeBridgeService{
30+ historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}},
31+ currentConfig: port.SystemviewConfigPayload{
32+ Appearance: port.WebUIAppearanceConfig{
33+ ColorScheme: "prefer-dark",
34+ DarkPalette: port.ColorPalette{
35+ Background: "#111111",
36+ Surface: "#1a1a1a",
37+ SurfaceVariant: "#2a2a2a",
38+ Text: "#f5f5f5",
39+ Muted: "#a0a0a0",
40+ Accent: "#66aaff",
41+ Border: "#333333",
42+ },
43+ },
44+ },
45+ }
46+ dom := &fakeDOM{}
47+ app := newBridgeApp(dom, "dumb://history", bridge)
48+
49+ require.NoError(t, app.Run())
50+ assert.True(t, bridge.calledHistory)
51+ assert.True(t, bridge.calledConfig)
52+ assert.False(t, bridge.calledKeybindings)
53+ assert.Contains(t, dom.html, `class="sv-app sv-dark"`)
54+ assert.Contains(t, dom.html, `--sv-background: #111111;`)
55+}
56+
57+func TestNewBridgeApp_WiresFavoritesService(t *testing.T) {
58+ t.Parallel()
59+
60+ bridge := &fakeBridgeService{
61+ favorites: []*entity.Favorite{{Title: "Example", URL: "https://example.com"}},
62+ folders: []*entity.Folder{{Name: "Read Later"}},
63+ tags: []*entity.Tag{{Name: "Go"}},
64+ }
65+ app := newBridgeApp(&fakeDOM{}, "dumb://favorites", bridge)
66+
67+ require.NoError(t, app.LoadInitial(context.Background()))
68+ assert.False(t, bridge.calledHistory)
69+ assert.True(t, bridge.calledFavorites)
70+ assert.True(t, bridge.calledConfig)
71+ assert.False(t, bridge.calledKeybindings)
72+}
73+
74+func TestNewBridgeApp_WiresConfigService(t *testing.T) {
75+ t.Parallel()
76+
77+ bridge := &fakeBridgeService{
78+ currentConfig: port.SystemviewConfigPayload{EngineType: "webkit"},
79+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{DisplayName: "Default"}}},
80+ }
81+ app := newBridgeApp(&fakeDOM{}, "dumb://config", bridge)
82+
83+ require.NoError(t, app.LoadInitial(context.Background()))
84+ assert.False(t, bridge.calledHistory)
85+ assert.False(t, bridge.calledFavorites)
86+ assert.True(t, bridge.calledConfig)
87+ assert.True(t, bridge.calledKeybindings)
88+}
89+
90+type fakeDOM struct {
91+ html string
92+}
93+
94+func (f *fakeDOM) Mount(html string) error {
95+ f.html = html
96+ return nil
97+}
98+
99+type fakeBridgeService struct {
100+ calledHistory bool
101+ calledFavorites bool
102+ calledConfig bool
103+ calledKeybindings bool
104+
105+ historyEntries []*entity.HistoryEntry
106+ favorites []*entity.Favorite
107+ folders []*entity.Folder
108+ tags []*entity.Tag
109+ currentConfig port.SystemviewConfigPayload
110+ keybindings any
111+}
112+
113+func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.HistoryEntry, error) {
114+ f.calledHistory = true
115+ return f.historyEntries, nil
116+}
117+
118+func (*fakeBridgeService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) {
119+ return nil, nil
120+}
121+
122+func (*fakeBridgeService) DeleteEntry(context.Context, int64) error { return nil }
123+
124+func (*fakeBridgeService) DeleteRange(context.Context, string) error { return nil }
125+
126+func (*fakeBridgeService) Analytics(context.Context) (*entity.HistoryAnalytics, error) {
127+ return nil, nil
128+}
129+
130+func (*fakeBridgeService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) {
131+ return nil, nil
132+}
133+
134+func (*fakeBridgeService) DeleteDomain(context.Context, string) error { return nil }
135+
136+func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) {
137+ f.calledFavorites = true
138+ return f.favorites, nil
139+}
140+
141+func (f *fakeBridgeService) ListFolders(context.Context) ([]*entity.Folder, error) {
142+ return f.folders, nil
143+}
144+
145+func (f *fakeBridgeService) ListTags(context.Context) ([]*entity.Tag, error) {
146+ return f.tags, nil
147+}
148+
149+func (*fakeBridgeService) SetShortcut(context.Context, int64, *int) error { return nil }
150+
151+func (*fakeBridgeService) SetFolder(context.Context, int64, *int64) error { return nil }
152+
153+func (*fakeBridgeService) CreateFolder(context.Context, string, *int64) (*entity.Folder, error) {
154+ return nil, nil
155+}
156+
157+func (*fakeBridgeService) UpdateFolder(context.Context, int64, string, string) error { return nil }
158+
159+func (*fakeBridgeService) DeleteFolder(context.Context, int64) error { return nil }
160+
161+func (*fakeBridgeService) CreateTag(context.Context, string, string) (*entity.Tag, error) {
162+ return nil, nil
163+}
164+
165+func (*fakeBridgeService) UpdateTag(context.Context, int64, string, string) error { return nil }
166+
167+func (*fakeBridgeService) DeleteTag(context.Context, int64) error { return nil }
168+
169+func (*fakeBridgeService) AssignTag(context.Context, int64, int64) error { return nil }
170+
171+func (*fakeBridgeService) RemoveTag(context.Context, int64, int64) error { return nil }
172+
173+func (f *fakeBridgeService) Current(context.Context) (port.SystemviewConfigPayload, error) {
174+ f.calledConfig = true
175+ return f.currentConfig, nil
176+}
177+
178+func (*fakeBridgeService) Default(context.Context) (port.SystemviewConfigPayload, error) {
179+ return port.SystemviewConfigPayload{}, nil
180+}
181+
182+func (*fakeBridgeService) Save(context.Context, port.WebUIConfig) error { return nil }
183+
184+func (f *fakeBridgeService) GetKeybindings(context.Context) (any, error) {
185+ f.calledKeybindings = true
186+ return f.keybindings, nil
187+}
188+
189+func (*fakeBridgeService) SetKeybinding(context.Context, port.SetKeybindingRequest) (any, error) {
190+ return nil, nil
191+}
192+
193+func (*fakeBridgeService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error {
194+ return nil
195+}
196+
197+func (*fakeBridgeService) ResetAllKeybindings(context.Context) error { return nil }
M cmd/systemviews/main.go +3 -4
@@ -5,14 +5,13 @@ package main
55 import (
66 "syscall/js"
77
8+ "github.com/bnema/dumber/internal/infrastructure/systemviewsbridge"
89 "github.com/bnema/dumber/internal/ui/systemviews"
910 )
1011
1112 func main() {
12- app := systemviews.NewApp(systemviews.Dependencies{
13- DOM: systemviews.NewDOM(),
14- LocationURI: js.Global().Get("location").Get("href").String(),
15- })
13+ bridge := systemviewsbridge.NewBrowserClient()
14+ app := newBridgeApp(systemviews.NewDOM(), js.Global().Get("location").Get("href").String(), bridge)
1615
1716 if err := app.Run(); err != nil {
1817 js.Global().Get("console").Call("error", err.Error())
@@ -5,14 +5,13 @@ package main
5 import (5 import (
6 "syscall/js"6 "syscall/js"
7 7
8+ "github.com/bnema/dumber/internal/infrastructure/systemviewsbridge"
8 "github.com/bnema/dumber/internal/ui/systemviews"9 "github.com/bnema/dumber/internal/ui/systemviews"
9 )10 )
10 11
11 func main() {12 func main() {
12- app := systemviews.NewApp(systemviews.Dependencies{13+ bridge := systemviewsbridge.NewBrowserClient()
13- DOM: systemviews.NewDOM(),14+ app := newBridgeApp(systemviews.NewDOM(), js.Global().Get("location").Get("href").String(), bridge)
14- LocationURI: js.Global().Get("location").Get("href").String(),
15- })
16 15
17 if err := app.Run(); err != nil {16 if err := app.Run(); err != nil {
18 js.Global().Get("console").Call("error", err.Error())17 js.Global().Get("console").Call("error", err.Error())
M internal/application/usecase/search_history.go +4 -1
@@ -157,7 +157,10 @@ func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.
157157
158158 // ClearRange deletes history entries for a named range.
159159 func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error {
160- cutoff, all := historydomain.DeleteRangeCutoff(rangeID, time.Now())
160+ cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now())
161+ if !ok {
162+ return fmt.Errorf("unknown history delete range: %q", rangeID)
163+ }
161164 if all {
162165 return uc.ClearAll(ctx)
163166 }
@@ -157,7 +157,10 @@ func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.
157 157
158 // ClearRange deletes history entries for a named range.158 // ClearRange deletes history entries for a named range.
159 func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error {159 func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error {
160- cutoff, all := historydomain.DeleteRangeCutoff(rangeID, time.Now())160+ cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now())
161+ if !ok {
162+ return fmt.Errorf("unknown history delete range: %q", rangeID)
163+ }
161 if all {164 if all {
162 return uc.ClearAll(ctx)165 return uc.ClearAll(ctx)
163 }166 }
M internal/application/usecase/search_history_test.go +12 -0
@@ -164,3 +164,15 @@ func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) {
164164 err := uc.ClearRange(ctx, "all")
165165 require.NoError(t, err)
166166 }
167+
168+func TestSearchHistoryUseCase_ClearRange_UnknownRangeDoesNotDelete(t *testing.T) {
169+ ctx := testContext()
170+
171+ historyRepo := repomocks.NewMockHistoryRepository(t)
172+
173+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
174+
175+ err := uc.ClearRange(ctx, "bogus")
176+ require.Error(t, err)
177+ assert.Contains(t, err.Error(), "unknown history delete range")
178+}
@@ -164,3 +164,15 @@ func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) {
164 err := uc.ClearRange(ctx, "all")164 err := uc.ClearRange(ctx, "all")
165 require.NoError(t, err)165 require.NoError(t, err)
166 }166 }
167+
168+func TestSearchHistoryUseCase_ClearRange_UnknownRangeDoesNotDelete(t *testing.T) {
169+ ctx := testContext()
170+
171+ historyRepo := repomocks.NewMockHistoryRepository(t)
172+
173+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
174+
175+ err := uc.ClearRange(ctx, "bogus")
176+ require.Error(t, err)
177+ assert.Contains(t, err.Error(), "unknown history delete range")
178+}
M internal/domain/history/delete_range.go +9 -8
@@ -3,20 +3,21 @@ package history
33 import "time"
44
55 // DeleteRangeCutoff resolves a delete-range identifier to a cutoff time.
6-// The boolean reports whether the range means "delete all".
7-func DeleteRangeCutoff(rangeID string, now time.Time) (time.Time, bool) {
6+// The booleans report whether the range means "delete all" and whether the
7+// identifier was recognized.
8+func DeleteRangeCutoff(rangeID string, now time.Time) (time.Time, bool, bool) {
89 switch rangeID {
910 case "hour":
10- return now.Add(-time.Hour), false
11+ return now.Add(-time.Hour), false, true
1112 case "day":
12- return now.AddDate(0, 0, -1), false
13+ return now.AddDate(0, 0, -1), false, true
1314 case "week":
14- return now.AddDate(0, 0, -7), false
15+ return now.AddDate(0, 0, -7), false, true
1516 case "month":
16- return now.AddDate(0, -1, 0), false
17+ return now.AddDate(0, -1, 0), false, true
1718 case "all":
18- return time.Time{}, true
19+ return time.Time{}, true, true
1920 default:
20- return now, false
21+ return time.Time{}, false, false
2122 }
2223 }
@@ -3,20 +3,21 @@ package history
3 import "time"3 import "time"
4 4
5 // DeleteRangeCutoff resolves a delete-range identifier to a cutoff time.5 // DeleteRangeCutoff resolves a delete-range identifier to a cutoff time.
6-// The boolean reports whether the range means "delete all".6+// The booleans report whether the range means "delete all" and whether the
7-func DeleteRangeCutoff(rangeID string, now time.Time) (time.Time, bool) {7+// identifier was recognized.
8+func DeleteRangeCutoff(rangeID string, now time.Time) (time.Time, bool, bool) {
8 switch rangeID {9 switch rangeID {
9 case "hour":10 case "hour":
10- return now.Add(-time.Hour), false11+ return now.Add(-time.Hour), false, true
11 case "day":12 case "day":
12- return now.AddDate(0, 0, -1), false13+ return now.AddDate(0, 0, -1), false, true
13 case "week":14 case "week":
14- return now.AddDate(0, 0, -7), false15+ return now.AddDate(0, 0, -7), false, true
15 case "month":16 case "month":
16- return now.AddDate(0, -1, 0), false17+ return now.AddDate(0, -1, 0), false, true
17 case "all":18 case "all":
18- return time.Time{}, true19+ return time.Time{}, true, true
19 default:20 default:
20- return now, false21+ return time.Time{}, false, false
21 }22 }
22 }23 }
M internal/domain/history/delete_range_test.go +11 -6
@@ -13,23 +13,28 @@ func TestDeleteRangeCutoff(t *testing.T) {
1313 rangeID string
1414 want time.Time
1515 wantAll bool
16+ wantOK bool
1617 }{
17- {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false},
18- {name: "day", rangeID: "day", want: now.AddDate(0, 0, -1), wantAll: false},
19- {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false},
20- {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false},
21- {name: "all", rangeID: "all", want: time.Time{}, wantAll: true},
18+ {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false, wantOK: true},
19+ {name: "day", rangeID: "day", want: now.AddDate(0, 0, -1), wantAll: false, wantOK: true},
20+ {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false, wantOK: true},
21+ {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false, wantOK: true},
22+ {name: "all", rangeID: "all", want: time.Time{}, wantAll: true, wantOK: true},
23+ {name: "unknown", rangeID: "bogus", want: time.Time{}, wantAll: false, wantOK: false},
2224 }
2325
2426 for _, tt := range tests {
2527 t.Run(tt.name, func(t *testing.T) {
26- got, gotAll := DeleteRangeCutoff(tt.rangeID, now)
28+ got, gotAll, gotOK := DeleteRangeCutoff(tt.rangeID, now)
2729 if !got.Equal(tt.want) {
2830 t.Fatalf("DeleteRangeCutoff() cutoff = %v, want %v", got, tt.want)
2931 }
3032 if gotAll != tt.wantAll {
3133 t.Fatalf("DeleteRangeCutoff() all = %v, want %v", gotAll, tt.wantAll)
3234 }
35+ if gotOK != tt.wantOK {
36+ t.Fatalf("DeleteRangeCutoff() ok = %v, want %v", gotOK, tt.wantOK)
37+ }
3338 })
3439 }
3540 }
@@ -13,23 +13,28 @@ func TestDeleteRangeCutoff(t *testing.T) {
13 rangeID string13 rangeID string
14 want time.Time14 want time.Time
15 wantAll bool15 wantAll bool
16+ wantOK bool
16 }{17 }{
17- {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false},18+ {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false, wantOK: true},
18- {name: "day", rangeID: "day", want: now.AddDate(0, 0, -1), wantAll: false},19+ {name: "day", rangeID: "day", want: now.AddDate(0, 0, -1), wantAll: false, wantOK: true},
19- {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false},20+ {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false, wantOK: true},
20- {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false},21+ {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false, wantOK: true},
21- {name: "all", rangeID: "all", want: time.Time{}, wantAll: true},22+ {name: "all", rangeID: "all", want: time.Time{}, wantAll: true, wantOK: true},
23+ {name: "unknown", rangeID: "bogus", want: time.Time{}, wantAll: false, wantOK: false},
22 }24 }
23 25
24 for _, tt := range tests {26 for _, tt := range tests {
25 t.Run(tt.name, func(t *testing.T) {27 t.Run(tt.name, func(t *testing.T) {
26- got, gotAll := DeleteRangeCutoff(tt.rangeID, now)28+ got, gotAll, gotOK := DeleteRangeCutoff(tt.rangeID, now)
27 if !got.Equal(tt.want) {29 if !got.Equal(tt.want) {
28 t.Fatalf("DeleteRangeCutoff() cutoff = %v, want %v", got, tt.want)30 t.Fatalf("DeleteRangeCutoff() cutoff = %v, want %v", got, tt.want)
29 }31 }
30 if gotAll != tt.wantAll {32 if gotAll != tt.wantAll {
31 t.Fatalf("DeleteRangeCutoff() all = %v, want %v", gotAll, tt.wantAll)33 t.Fatalf("DeleteRangeCutoff() all = %v, want %v", gotAll, tt.wantAll)
32 }34 }
35+ if gotOK != tt.wantOK {
36+ t.Fatalf("DeleteRangeCutoff() ok = %v, want %v", gotOK, tt.wantOK)
37+ }
33 })38 })
34 }39 }
35 }40 }
M internal/infrastructure/cef/scheme_handler.go +5 -1
@@ -351,7 +351,7 @@ func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool)
351351 return assetDirForPageHost(u.Host), path, true
352352 }
353353
354- // Handle opaque URLs like dumb:home.
354+ // Handle opaque URLs like dumb:history.
355355 if root, ok := pageRootFiles[u.Opaque]; ok {
356356 return assetDirForPageHost(u.Opaque), root, true
357357 }
@@ -384,6 +384,10 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
384384 return "", "", false
385385 }
386386
387+ if !strings.Contains(path, "/") && strings.Contains(path, ".") {
388+ return assetDirForPageHost(historyPath), path, true
389+ }
390+
387391 return "", "", false
388392 }
389393
@@ -351,7 +351,7 @@ func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool)
351 return assetDirForPageHost(u.Host), path, true351 return assetDirForPageHost(u.Host), path, true
352 }352 }
353 353
354- // Handle opaque URLs like dumb:home.354+ // Handle opaque URLs like dumb:history.
355 if root, ok := pageRootFiles[u.Opaque]; ok {355 if root, ok := pageRootFiles[u.Opaque]; ok {
356 return assetDirForPageHost(u.Opaque), root, true356 return assetDirForPageHost(u.Opaque), root, true
357 }357 }
@@ -384,6 +384,10 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
384 return "", "", false384 return "", "", false
385 }385 }
386 386
387+ if !strings.Contains(path, "/") && strings.Contains(path, ".") {
388+ return assetDirForPageHost(historyPath), path, true
389+ }
390+
387 return "", "", false391 return "", "", false
388 }392 }
389 393
M internal/infrastructure/cef/scheme_handler_systemviews_test.go +4 -0
@@ -17,8 +17,12 @@ func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) {
1717 {name: "history host", raw: "https://dumber.invalid/history", wantDir: "systemviews", wantRel: indexHTML},
1818 {name: "favorites host", raw: "https://dumber.invalid/favorites", wantDir: "systemviews", wantRel: indexHTML},
1919 {name: "config host", raw: "https://dumber.invalid/config", wantDir: "systemviews", wantRel: indexHTML},
20+ {name: "root wasm exec", raw: "https://dumber.invalid/wasm_exec.js", wantDir: "systemviews", wantRel: "wasm_exec.js"},
21+ {name: "root wasm asset", raw: "https://dumber.invalid/systemviews.wasm", wantDir: "systemviews", wantRel: "systemviews.wasm"},
22+ {name: "root css asset", raw: "https://dumber.invalid/systemviews.css", wantDir: "systemviews", wantRel: "systemviews.css"},
2023 {name: "history opaque", raw: "dumb:history", wantDir: "systemviews", wantRel: indexHTML},
2124 {name: "history sub-asset", raw: "https://dumber.invalid/history/wasm_exec.js", wantDir: "systemviews", wantRel: "wasm_exec.js"},
25+ {name: "history css asset", raw: "https://dumber.invalid/history/systemviews.css", wantDir: "systemviews", wantRel: "systemviews.css"},
2226 }
2327
2428 for _, tt := range tests {
@@ -17,8 +17,12 @@ func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) {
17 {name: "history host", raw: "https://dumber.invalid/history", wantDir: "systemviews", wantRel: indexHTML},17 {name: "history host", raw: "https://dumber.invalid/history", wantDir: "systemviews", wantRel: indexHTML},
18 {name: "favorites host", raw: "https://dumber.invalid/favorites", wantDir: "systemviews", wantRel: indexHTML},18 {name: "favorites host", raw: "https://dumber.invalid/favorites", wantDir: "systemviews", wantRel: indexHTML},
19 {name: "config host", raw: "https://dumber.invalid/config", wantDir: "systemviews", wantRel: indexHTML},19 {name: "config host", raw: "https://dumber.invalid/config", wantDir: "systemviews", wantRel: indexHTML},
20+ {name: "root wasm exec", raw: "https://dumber.invalid/wasm_exec.js", wantDir: "systemviews", wantRel: "wasm_exec.js"},
21+ {name: "root wasm asset", raw: "https://dumber.invalid/systemviews.wasm", wantDir: "systemviews", wantRel: "systemviews.wasm"},
22+ {name: "root css asset", raw: "https://dumber.invalid/systemviews.css", wantDir: "systemviews", wantRel: "systemviews.css"},
20 {name: "history opaque", raw: "dumb:history", wantDir: "systemviews", wantRel: indexHTML},23 {name: "history opaque", raw: "dumb:history", wantDir: "systemviews", wantRel: indexHTML},
21 {name: "history sub-asset", raw: "https://dumber.invalid/history/wasm_exec.js", wantDir: "systemviews", wantRel: "wasm_exec.js"},24 {name: "history sub-asset", raw: "https://dumber.invalid/history/wasm_exec.js", wantDir: "systemviews", wantRel: "wasm_exec.js"},
25+ {name: "history css asset", raw: "https://dumber.invalid/history/systemviews.css", wantDir: "systemviews", wantRel: "systemviews.css"},
22 }26 }
23 27
24 for _, tt := range tests {28 for _, tt := range tests {
A internal/infrastructure/systemviewsbridge/browser_transport.go +115 -0
@@ -0,0 +1,115 @@
1+package systemviewsbridge
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+)
9+
10+type browserRequest struct {
11+ messageType string
12+ requestID string
13+ body []byte
14+ directAPI bool
15+}
16+
17+func decodeBrowserRequest(body []byte) (browserRequest, error) {
18+ var envelope struct {
19+ Type string `json:"type"`
20+ Payload json.RawMessage `json:"payload"`
21+ }
22+ if err := json.Unmarshal(body, &envelope); err != nil {
23+ return browserRequest{}, fmt.Errorf("unmarshal browser request: %w", err)
24+ }
25+ if envelope.Type == "" {
26+ return browserRequest{}, fmt.Errorf("browser request missing type")
27+ }
28+
29+ var payload struct {
30+ RequestID string `json:"requestId"`
31+ }
32+ if len(envelope.Payload) != 0 {
33+ if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
34+ return browserRequest{}, fmt.Errorf("unmarshal request payload: %w", err)
35+ }
36+ }
37+
38+ return browserRequest{
39+ messageType: envelope.Type,
40+ requestID: payload.RequestID,
41+ body: body,
42+ directAPI: strings.HasPrefix(envelope.Type, "/api/"),
43+ }, nil
44+}
45+
46+func wrapDirectAPIResponse(requestID string, payload []byte) ([]byte, error) {
47+ return json.Marshal(bridgeResponse{
48+ RequestID: requestID,
49+ Success: true,
50+ Data: json.RawMessage(payload),
51+ })
52+}
53+
54+func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) {
55+ trimmed := bytes.TrimSpace(body)
56+ if len(trimmed) == 0 {
57+ return nil, fmt.Errorf("empty bridge shim response")
58+ }
59+
60+ var topLevel map[string]json.RawMessage
61+ if err := json.Unmarshal(trimmed, &topLevel); err != nil {
62+ return nil, fmt.Errorf("unmarshal top-level bridge response: %w", err)
63+ }
64+
65+ _, hasSuccess := topLevel["success"]
66+ _, hasRequestID := topLevel["requestId"]
67+ _, hasCallback := topLevel["_callback"]
68+ _, hasData := topLevel["data"]
69+
70+ if (hasSuccess || hasRequestID) && !hasCallback {
71+ var resp bridgeResponse
72+ if err := json.Unmarshal(trimmed, &resp); err != nil {
73+ return nil, fmt.Errorf("unmarshal direct bridge response: %w", err)
74+ }
75+ if resp.RequestID == "" {
76+ resp.RequestID = requestID
77+ }
78+ return json.Marshal(resp)
79+ }
80+
81+ if !hasData {
82+ return nil, fmt.Errorf("wrapped bridge response missing data")
83+ }
84+
85+ var wrapped struct {
86+ Data json.RawMessage `json:"data,omitempty"`
87+ Error string `json:"error,omitempty"`
88+ }
89+ if err := json.Unmarshal(trimmed, &wrapped); err != nil {
90+ return nil, fmt.Errorf("unmarshal wrapped bridge response: %w", err)
91+ }
92+ if wrapped.Error != "" {
93+ return json.Marshal(bridgeResponse{RequestID: requestID, Error: wrapped.Error})
94+ }
95+ if len(wrapped.Data) == 0 {
96+ return json.Marshal(bridgeResponse{RequestID: requestID, Success: true})
97+ }
98+
99+ var nested bridgeResponse
100+ if err := json.Unmarshal(wrapped.Data, &nested); err == nil {
101+ hasNestedEnvelope := nested.Success || nested.RequestID != "" || nested.Error != ""
102+ if hasNestedEnvelope {
103+ if nested.RequestID == "" {
104+ nested.RequestID = requestID
105+ }
106+ return json.Marshal(nested)
107+ }
108+ }
109+
110+ return json.Marshal(bridgeResponse{
111+ RequestID: requestID,
112+ Success: true,
113+ Data: wrapped.Data,
114+ })
115+}
@@ -0,0 +1,115 @@
1+package systemviewsbridge
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+)
9+
10+type browserRequest struct {
11+ messageType string
12+ requestID string
13+ body []byte
14+ directAPI bool
15+}
16+
17+func decodeBrowserRequest(body []byte) (browserRequest, error) {
18+ var envelope struct {
19+ Type string `json:"type"`
20+ Payload json.RawMessage `json:"payload"`
21+ }
22+ if err := json.Unmarshal(body, &envelope); err != nil {
23+ return browserRequest{}, fmt.Errorf("unmarshal browser request: %w", err)
24+ }
25+ if envelope.Type == "" {
26+ return browserRequest{}, fmt.Errorf("browser request missing type")
27+ }
28+
29+ var payload struct {
30+ RequestID string `json:"requestId"`
31+ }
32+ if len(envelope.Payload) != 0 {
33+ if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
34+ return browserRequest{}, fmt.Errorf("unmarshal request payload: %w", err)
35+ }
36+ }
37+
38+ return browserRequest{
39+ messageType: envelope.Type,
40+ requestID: payload.RequestID,
41+ body: body,
42+ directAPI: strings.HasPrefix(envelope.Type, "/api/"),
43+ }, nil
44+}
45+
46+func wrapDirectAPIResponse(requestID string, payload []byte) ([]byte, error) {
47+ return json.Marshal(bridgeResponse{
48+ RequestID: requestID,
49+ Success: true,
50+ Data: json.RawMessage(payload),
51+ })
52+}
53+
54+func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) {
55+ trimmed := bytes.TrimSpace(body)
56+ if len(trimmed) == 0 {
57+ return nil, fmt.Errorf("empty bridge shim response")
58+ }
59+
60+ var topLevel map[string]json.RawMessage
61+ if err := json.Unmarshal(trimmed, &topLevel); err != nil {
62+ return nil, fmt.Errorf("unmarshal top-level bridge response: %w", err)
63+ }
64+
65+ _, hasSuccess := topLevel["success"]
66+ _, hasRequestID := topLevel["requestId"]
67+ _, hasCallback := topLevel["_callback"]
68+ _, hasData := topLevel["data"]
69+
70+ if (hasSuccess || hasRequestID) && !hasCallback {
71+ var resp bridgeResponse
72+ if err := json.Unmarshal(trimmed, &resp); err != nil {
73+ return nil, fmt.Errorf("unmarshal direct bridge response: %w", err)
74+ }
75+ if resp.RequestID == "" {
76+ resp.RequestID = requestID
77+ }
78+ return json.Marshal(resp)
79+ }
80+
81+ if !hasData {
82+ return nil, fmt.Errorf("wrapped bridge response missing data")
83+ }
84+
85+ var wrapped struct {
86+ Data json.RawMessage `json:"data,omitempty"`
87+ Error string `json:"error,omitempty"`
88+ }
89+ if err := json.Unmarshal(trimmed, &wrapped); err != nil {
90+ return nil, fmt.Errorf("unmarshal wrapped bridge response: %w", err)
91+ }
92+ if wrapped.Error != "" {
93+ return json.Marshal(bridgeResponse{RequestID: requestID, Error: wrapped.Error})
94+ }
95+ if len(wrapped.Data) == 0 {
96+ return json.Marshal(bridgeResponse{RequestID: requestID, Success: true})
97+ }
98+
99+ var nested bridgeResponse
100+ if err := json.Unmarshal(wrapped.Data, &nested); err == nil {
101+ hasNestedEnvelope := nested.Success || nested.RequestID != "" || nested.Error != ""
102+ if hasNestedEnvelope {
103+ if nested.RequestID == "" {
104+ nested.RequestID = requestID
105+ }
106+ return json.Marshal(nested)
107+ }
108+ }
109+
110+ return json.Marshal(bridgeResponse{
111+ RequestID: requestID,
112+ Success: true,
113+ Data: wrapped.Data,
114+ })
115+}
A internal/infrastructure/systemviewsbridge/browser_transport_js.go +278 -0
@@ -0,0 +1,278 @@
1+//go:build js && wasm
2+
3+package systemviewsbridge
4+
5+import (
6+ "bytes"
7+ "context"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "strings"
12+ "syscall/js"
13+)
14+
15+type callbackPlan struct {
16+ success string
17+ failure string
18+}
19+
20+func NewBrowserClient() *Client {
21+ return NewClient(&webkitTransport{}, &browserTransport{})
22+}
23+
24+func callbackPlanForMessage(msgType string) (callbackPlan, bool) {
25+ switch msgType {
26+ case "history_timeline", "history_search_fts", "history_delete_entry", "history_delete_range", "history_analytics",
27+ "history_domain_stats", "history_delete_domain", "favorite_list", "folder_list", "tag_list",
28+ "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete",
29+ "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove":
30+ return callbackPlan{success: "__dumber_homepage_response", failure: "__dumber_error"}, true
31+ case "save_config":
32+ return callbackPlan{success: "__dumber_config_saved", failure: "__dumber_config_error"}, true
33+ case "get_keybindings":
34+ return callbackPlan{success: "__dumber_keybindings_loaded", failure: "__dumber_keybindings_error"}, true
35+ case "set_keybinding":
36+ return callbackPlan{success: "__dumber_keybinding_set", failure: "__dumber_keybinding_set_error"}, true
37+ case "reset_keybinding":
38+ return callbackPlan{success: "__dumber_keybinding_reset", failure: "__dumber_keybinding_reset_error"}, true
39+ case "reset_all_keybindings":
40+ return callbackPlan{success: "__dumber_keybindings_reset_all", failure: "__dumber_keybindings_reset_all_error"}, true
41+ default:
42+ return callbackPlan{}, false
43+ }
44+}
45+
46+type browserTransport struct{}
47+
48+func (*browserTransport) Available() bool {
49+ window := js.Global().Get("window")
50+ if !window.Truthy() {
51+ return false
52+ }
53+ return window.Get("fetch").Truthy() || window.Get("dumber").Truthy()
54+}
55+
56+func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error) {
57+ req, err := decodeBrowserRequest(body)
58+ if err != nil {
59+ return nil, err
60+ }
61+ if req.directAPI {
62+ return fetchDirectAPI(ctx, req.requestID, req.messageType)
63+ }
64+
65+ window := js.Global().Get("window")
66+ bridge := window.Get("dumber")
67+ if !bridge.Truthy() || !bridge.Get("postMessage").Truthy() {
68+ return nil, errors.New("browser bridge shim not available")
69+ }
70+
71+ promise := bridge.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body)))
72+ value, err := awaitPromise(ctx, promise)
73+ if err != nil {
74+ return nil, err
75+ }
76+ raw, err := stringifyValue(value)
77+ if err != nil {
78+ return nil, err
79+ }
80+ return normalizeBridgeShimResponse(req.requestID, raw)
81+}
82+
83+type webkitTransport struct{}
84+
85+func (*webkitTransport) Available() bool {
86+ window := js.Global().Get("window")
87+ if !window.Truthy() {
88+ return false
89+ }
90+ webkit := window.Get("webkit")
91+ if !webkit.Truthy() {
92+ return false
93+ }
94+ handlers := webkit.Get("messageHandlers")
95+ if !handlers.Truthy() {
96+ return false
97+ }
98+ return handlers.Get("dumber").Truthy()
99+}
100+
101+func (*webkitTransport) Send(ctx context.Context, body []byte) ([]byte, error) {
102+ req, err := decodeBrowserRequest(body)
103+ if err != nil {
104+ return nil, err
105+ }
106+ if req.directAPI {
107+ return fetchDirectAPI(ctx, req.requestID, req.messageType)
108+ }
109+
110+ plan, ok := callbackPlanForMessage(req.messageType)
111+ if !ok {
112+ return nil, fmt.Errorf("no WebKit callback plan for message type %q", req.messageType)
113+ }
114+
115+ window := js.Global().Get("window")
116+ handler := window.Get("webkit").Get("messageHandlers").Get("dumber")
117+ if !handler.Truthy() {
118+ return nil, errors.New("webkit message handler not available")
119+ }
120+
121+ type result struct {
122+ body []byte
123+ err error
124+ }
125+ resultCh := make(chan result, 1)
126+
127+ successFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
128+ payload, err := stringifyArgs(args)
129+ if err != nil {
130+ resultCh <- result{err: err}
131+ return nil
132+ }
133+ normalized, err := normalizeNativeSuccessResponse(req.requestID, payload)
134+ resultCh <- result{body: normalized, err: err}
135+ return nil
136+ })
137+ errorFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
138+ payload, err := stringifyArgs(args)
139+ if err != nil {
140+ resultCh <- result{err: err}
141+ return nil
142+ }
143+ normalized, err := normalizeNativeErrorResponse(req.requestID, payload)
144+ resultCh <- result{body: normalized, err: err}
145+ return nil
146+ })
147+ defer successFn.Release()
148+ defer errorFn.Release()
149+
150+ previousSuccess := window.Get(plan.success)
151+ previousError := window.Get(plan.failure)
152+ window.Set(plan.success, successFn)
153+ window.Set(plan.failure, errorFn)
154+ defer window.Set(plan.success, previousSuccess)
155+ defer window.Set(plan.failure, previousError)
156+
157+ handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body)))
158+
159+ select {
160+ case res := <-resultCh:
161+ return res.body, res.err
162+ case <-ctx.Done():
163+ return nil, ctx.Err()
164+ }
165+}
166+
167+func fetchDirectAPI(ctx context.Context, requestID, endpoint string) ([]byte, error) {
168+ response, err := awaitPromise(ctx, js.Global().Call("fetch", endpoint))
169+ if err != nil {
170+ return nil, err
171+ }
172+ if !response.Get("ok").Bool() {
173+ status := response.Get("status").Int()
174+ statusText := response.Get("statusText").String()
175+ if statusText == "" {
176+ statusText = "request failed"
177+ }
178+ return nil, fmt.Errorf("direct API %s failed: %d %s", endpoint, status, statusText)
179+ }
180+ data, err := awaitPromise(ctx, response.Call("json"))
181+ if err != nil {
182+ return nil, err
183+ }
184+ raw, err := stringifyValue(data)
185+ if err != nil {
186+ return nil, err
187+ }
188+ return wrapDirectAPIResponse(requestID, raw)
189+}
190+
191+func normalizeNativeSuccessResponse(requestID string, payload []byte) ([]byte, error) {
192+ trimmed := bytes.TrimSpace(payload)
193+ if len(trimmed) == 0 {
194+ return json.Marshal(bridgeResponse{RequestID: requestID, Success: true})
195+ }
196+
197+ var resp bridgeResponse
198+ if err := json.Unmarshal(trimmed, &resp); err == nil {
199+ hasEnvelope := resp.Success || resp.RequestID != "" || resp.Error != ""
200+ if hasEnvelope {
201+ if resp.RequestID == "" {
202+ resp.RequestID = requestID
203+ }
204+ return json.Marshal(resp)
205+ }
206+ }
207+
208+ return json.Marshal(bridgeResponse{RequestID: requestID, Success: true, Data: json.RawMessage(trimmed)})
209+}
210+
211+func normalizeNativeErrorResponse(requestID string, payload []byte) ([]byte, error) {
212+ trimmed := bytes.TrimSpace(payload)
213+ var message string
214+ if len(trimmed) == 0 {
215+ message = "bridge request failed"
216+ } else if err := json.Unmarshal(trimmed, &message); err != nil {
217+ message = strings.Trim(string(trimmed), `"`)
218+ }
219+ if message == "" {
220+ message = "bridge request failed"
221+ }
222+ return json.Marshal(bridgeResponse{RequestID: requestID, Error: message})
223+}
224+
225+func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) {
226+ type result struct {
227+ value js.Value
228+ err error
229+ }
230+ resultCh := make(chan result, 1)
231+
232+ thenFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
233+ if len(args) == 0 {
234+ resultCh <- result{value: js.Undefined()}
235+ return nil
236+ }
237+ resultCh <- result{value: args[0]}
238+ return nil
239+ })
240+ catchFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
241+ if len(args) == 0 {
242+ resultCh <- result{err: errors.New("promise rejected")}
243+ return nil
244+ }
245+ resultCh <- result{err: errors.New(args[0].String())}
246+ return nil
247+ })
248+ defer thenFn.Release()
249+ defer catchFn.Release()
250+
251+ promise.Call("then", thenFn).Call("catch", catchFn)
252+
253+ select {
254+ case res := <-resultCh:
255+ return res.value, res.err
256+ case <-ctx.Done():
257+ return js.Undefined(), ctx.Err()
258+ }
259+}
260+
261+func stringifyArgs(args []js.Value) ([]byte, error) {
262+ if len(args) == 0 {
263+ return nil, nil
264+ }
265+ return stringifyValue(args[0])
266+}
267+
268+func stringifyValue(value js.Value) ([]byte, error) {
269+ jsonValue := js.Global().Get("JSON")
270+ if !jsonValue.Truthy() {
271+ return nil, errors.New("JSON global not available")
272+ }
273+ stringified := jsonValue.Call("stringify", value)
274+ if !stringified.Truthy() {
275+ return nil, errors.New("failed to stringify JS value")
276+ }
277+ return []byte(stringified.String()), nil
278+}
@@ -0,0 +1,278 @@
1+//go:build js && wasm
2+
3+package systemviewsbridge
4+
5+import (
6+ "bytes"
7+ "context"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "strings"
12+ "syscall/js"
13+)
14+
15+type callbackPlan struct {
16+ success string
17+ failure string
18+}
19+
20+func NewBrowserClient() *Client {
21+ return NewClient(&webkitTransport{}, &browserTransport{})
22+}
23+
24+func callbackPlanForMessage(msgType string) (callbackPlan, bool) {
25+ switch msgType {
26+ case "history_timeline", "history_search_fts", "history_delete_entry", "history_delete_range", "history_analytics",
27+ "history_domain_stats", "history_delete_domain", "favorite_list", "folder_list", "tag_list",
28+ "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete",
29+ "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove":
30+ return callbackPlan{success: "__dumber_homepage_response", failure: "__dumber_error"}, true
31+ case "save_config":
32+ return callbackPlan{success: "__dumber_config_saved", failure: "__dumber_config_error"}, true
33+ case "get_keybindings":
34+ return callbackPlan{success: "__dumber_keybindings_loaded", failure: "__dumber_keybindings_error"}, true
35+ case "set_keybinding":
36+ return callbackPlan{success: "__dumber_keybinding_set", failure: "__dumber_keybinding_set_error"}, true
37+ case "reset_keybinding":
38+ return callbackPlan{success: "__dumber_keybinding_reset", failure: "__dumber_keybinding_reset_error"}, true
39+ case "reset_all_keybindings":
40+ return callbackPlan{success: "__dumber_keybindings_reset_all", failure: "__dumber_keybindings_reset_all_error"}, true
41+ default:
42+ return callbackPlan{}, false
43+ }
44+}
45+
46+type browserTransport struct{}
47+
48+func (*browserTransport) Available() bool {
49+ window := js.Global().Get("window")
50+ if !window.Truthy() {
51+ return false
52+ }
53+ return window.Get("fetch").Truthy() || window.Get("dumber").Truthy()
54+}
55+
56+func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error) {
57+ req, err := decodeBrowserRequest(body)
58+ if err != nil {
59+ return nil, err
60+ }
61+ if req.directAPI {
62+ return fetchDirectAPI(ctx, req.requestID, req.messageType)
63+ }
64+
65+ window := js.Global().Get("window")
66+ bridge := window.Get("dumber")
67+ if !bridge.Truthy() || !bridge.Get("postMessage").Truthy() {
68+ return nil, errors.New("browser bridge shim not available")
69+ }
70+
71+ promise := bridge.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body)))
72+ value, err := awaitPromise(ctx, promise)
73+ if err != nil {
74+ return nil, err
75+ }
76+ raw, err := stringifyValue(value)
77+ if err != nil {
78+ return nil, err
79+ }
80+ return normalizeBridgeShimResponse(req.requestID, raw)
81+}
82+
83+type webkitTransport struct{}
84+
85+func (*webkitTransport) Available() bool {
86+ window := js.Global().Get("window")
87+ if !window.Truthy() {
88+ return false
89+ }
90+ webkit := window.Get("webkit")
91+ if !webkit.Truthy() {
92+ return false
93+ }
94+ handlers := webkit.Get("messageHandlers")
95+ if !handlers.Truthy() {
96+ return false
97+ }
98+ return handlers.Get("dumber").Truthy()
99+}
100+
101+func (*webkitTransport) Send(ctx context.Context, body []byte) ([]byte, error) {
102+ req, err := decodeBrowserRequest(body)
103+ if err != nil {
104+ return nil, err
105+ }
106+ if req.directAPI {
107+ return fetchDirectAPI(ctx, req.requestID, req.messageType)
108+ }
109+
110+ plan, ok := callbackPlanForMessage(req.messageType)
111+ if !ok {
112+ return nil, fmt.Errorf("no WebKit callback plan for message type %q", req.messageType)
113+ }
114+
115+ window := js.Global().Get("window")
116+ handler := window.Get("webkit").Get("messageHandlers").Get("dumber")
117+ if !handler.Truthy() {
118+ return nil, errors.New("webkit message handler not available")
119+ }
120+
121+ type result struct {
122+ body []byte
123+ err error
124+ }
125+ resultCh := make(chan result, 1)
126+
127+ successFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
128+ payload, err := stringifyArgs(args)
129+ if err != nil {
130+ resultCh <- result{err: err}
131+ return nil
132+ }
133+ normalized, err := normalizeNativeSuccessResponse(req.requestID, payload)
134+ resultCh <- result{body: normalized, err: err}
135+ return nil
136+ })
137+ errorFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
138+ payload, err := stringifyArgs(args)
139+ if err != nil {
140+ resultCh <- result{err: err}
141+ return nil
142+ }
143+ normalized, err := normalizeNativeErrorResponse(req.requestID, payload)
144+ resultCh <- result{body: normalized, err: err}
145+ return nil
146+ })
147+ defer successFn.Release()
148+ defer errorFn.Release()
149+
150+ previousSuccess := window.Get(plan.success)
151+ previousError := window.Get(plan.failure)
152+ window.Set(plan.success, successFn)
153+ window.Set(plan.failure, errorFn)
154+ defer window.Set(plan.success, previousSuccess)
155+ defer window.Set(plan.failure, previousError)
156+
157+ handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body)))
158+
159+ select {
160+ case res := <-resultCh:
161+ return res.body, res.err
162+ case <-ctx.Done():
163+ return nil, ctx.Err()
164+ }
165+}
166+
167+func fetchDirectAPI(ctx context.Context, requestID, endpoint string) ([]byte, error) {
168+ response, err := awaitPromise(ctx, js.Global().Call("fetch", endpoint))
169+ if err != nil {
170+ return nil, err
171+ }
172+ if !response.Get("ok").Bool() {
173+ status := response.Get("status").Int()
174+ statusText := response.Get("statusText").String()
175+ if statusText == "" {
176+ statusText = "request failed"
177+ }
178+ return nil, fmt.Errorf("direct API %s failed: %d %s", endpoint, status, statusText)
179+ }
180+ data, err := awaitPromise(ctx, response.Call("json"))
181+ if err != nil {
182+ return nil, err
183+ }
184+ raw, err := stringifyValue(data)
185+ if err != nil {
186+ return nil, err
187+ }
188+ return wrapDirectAPIResponse(requestID, raw)
189+}
190+
191+func normalizeNativeSuccessResponse(requestID string, payload []byte) ([]byte, error) {
192+ trimmed := bytes.TrimSpace(payload)
193+ if len(trimmed) == 0 {
194+ return json.Marshal(bridgeResponse{RequestID: requestID, Success: true})
195+ }
196+
197+ var resp bridgeResponse
198+ if err := json.Unmarshal(trimmed, &resp); err == nil {
199+ hasEnvelope := resp.Success || resp.RequestID != "" || resp.Error != ""
200+ if hasEnvelope {
201+ if resp.RequestID == "" {
202+ resp.RequestID = requestID
203+ }
204+ return json.Marshal(resp)
205+ }
206+ }
207+
208+ return json.Marshal(bridgeResponse{RequestID: requestID, Success: true, Data: json.RawMessage(trimmed)})
209+}
210+
211+func normalizeNativeErrorResponse(requestID string, payload []byte) ([]byte, error) {
212+ trimmed := bytes.TrimSpace(payload)
213+ var message string
214+ if len(trimmed) == 0 {
215+ message = "bridge request failed"
216+ } else if err := json.Unmarshal(trimmed, &message); err != nil {
217+ message = strings.Trim(string(trimmed), `"`)
218+ }
219+ if message == "" {
220+ message = "bridge request failed"
221+ }
222+ return json.Marshal(bridgeResponse{RequestID: requestID, Error: message})
223+}
224+
225+func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) {
226+ type result struct {
227+ value js.Value
228+ err error
229+ }
230+ resultCh := make(chan result, 1)
231+
232+ thenFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
233+ if len(args) == 0 {
234+ resultCh <- result{value: js.Undefined()}
235+ return nil
236+ }
237+ resultCh <- result{value: args[0]}
238+ return nil
239+ })
240+ catchFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
241+ if len(args) == 0 {
242+ resultCh <- result{err: errors.New("promise rejected")}
243+ return nil
244+ }
245+ resultCh <- result{err: errors.New(args[0].String())}
246+ return nil
247+ })
248+ defer thenFn.Release()
249+ defer catchFn.Release()
250+
251+ promise.Call("then", thenFn).Call("catch", catchFn)
252+
253+ select {
254+ case res := <-resultCh:
255+ return res.value, res.err
256+ case <-ctx.Done():
257+ return js.Undefined(), ctx.Err()
258+ }
259+}
260+
261+func stringifyArgs(args []js.Value) ([]byte, error) {
262+ if len(args) == 0 {
263+ return nil, nil
264+ }
265+ return stringifyValue(args[0])
266+}
267+
268+func stringifyValue(value js.Value) ([]byte, error) {
269+ jsonValue := js.Global().Get("JSON")
270+ if !jsonValue.Truthy() {
271+ return nil, errors.New("JSON global not available")
272+ }
273+ stringified := jsonValue.Call("stringify", value)
274+ if !stringified.Truthy() {
275+ return nil, errors.New("failed to stringify JS value")
276+ }
277+ return []byte(stringified.String()), nil
278+}
A internal/infrastructure/systemviewsbridge/browser_transport_test.go +130 -0
@@ -0,0 +1,130 @@
1+package systemviewsbridge
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "testing"
7+)
8+
9+func TestDecodeBrowserRequest_DirectConfigEndpoint(t *testing.T) {
10+ t.Parallel()
11+
12+ body, err := buildMessageEnvelope("/api/config", struct {
13+ RequestID string `json:"requestId"`
14+ }{RequestID: "req-1"})
15+ if err != nil {
16+ t.Fatalf("buildMessageEnvelope() error = %v", err)
17+ }
18+
19+ req, err := decodeBrowserRequest(body)
20+ if err != nil {
21+ t.Fatalf("decodeBrowserRequest() error = %v", err)
22+ }
23+ if req.messageType != "/api/config" {
24+ t.Fatalf("messageType = %q, want %q", req.messageType, "/api/config")
25+ }
26+ if req.requestID != "req-1" {
27+ t.Fatalf("requestID = %q, want %q", req.requestID, "req-1")
28+ }
29+ if !req.directAPI {
30+ t.Fatal("directAPI = false, want true")
31+ }
32+}
33+
34+func TestDecodeBrowserRequest_MessageEnvelopeUsesBridgeShim(t *testing.T) {
35+ t.Parallel()
36+
37+ body, err := buildMessageEnvelope("history_timeline", struct {
38+ RequestID string `json:"requestId"`
39+ Limit int `json:"limit"`
40+ }{RequestID: "req-2", Limit: 25})
41+ if err != nil {
42+ t.Fatalf("buildMessageEnvelope() error = %v", err)
43+ }
44+
45+ req, err := decodeBrowserRequest(body)
46+ if err != nil {
47+ t.Fatalf("decodeBrowserRequest() error = %v", err)
48+ }
49+ if req.messageType != "history_timeline" {
50+ t.Fatalf("messageType = %q, want %q", req.messageType, "history_timeline")
51+ }
52+ if req.requestID != "req-2" {
53+ t.Fatalf("requestID = %q, want %q", req.requestID, "req-2")
54+ }
55+ if req.directAPI {
56+ t.Fatal("directAPI = true, want false")
57+ }
58+ if !bytes.Equal(req.body, body) {
59+ t.Fatalf("body = %s, want %s", req.body, body)
60+ }
61+}
62+
63+func TestNormalizeBridgeShimResponse_UnwrapsNestedBridgeEnvelope(t *testing.T) {
64+ t.Parallel()
65+
66+ got, err := normalizeBridgeShimResponse("req-3", []byte(`{"data":{"requestId":"req-3","success":true,"data":[{"title":"Example"}]},"_callback":"__dumber_homepage_response"}`))
67+ if err != nil {
68+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
69+ }
70+
71+ var resp bridgeResponse
72+ if err := json.Unmarshal(got, &resp); err != nil {
73+ t.Fatalf("json.Unmarshal() error = %v", err)
74+ }
75+ if !resp.Success {
76+ t.Fatal("success = false, want true")
77+ }
78+ if resp.RequestID != "req-3" {
79+ t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-3")
80+ }
81+ if string(resp.Data) != `[{"title":"Example"}]` {
82+ t.Fatalf("data = %s, want %s", resp.Data, `[{"title":"Example"}]`)
83+ }
84+}
85+
86+func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) {
87+ t.Parallel()
88+
89+ got, err := normalizeBridgeShimResponse("req-4", []byte(`{"data":{"status":"success"},"_callback":"__dumber_config_saved"}`))
90+ if err != nil {
91+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
92+ }
93+
94+ var resp bridgeResponse
95+ if err := json.Unmarshal(got, &resp); err != nil {
96+ t.Fatalf("json.Unmarshal() error = %v", err)
97+ }
98+ if !resp.Success {
99+ t.Fatal("success = false, want true")
100+ }
101+ if resp.RequestID != "req-4" {
102+ t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-4")
103+ }
104+ if string(resp.Data) != `{"status":"success"}` {
105+ t.Fatalf("data = %s, want %s", resp.Data, `{"status":"success"}`)
106+ }
107+}
108+
109+func TestWrapDirectAPIResponse_PreservesPayload(t *testing.T) {
110+ t.Parallel()
111+
112+ got, err := wrapDirectAPIResponse("req-5", []byte(`{"engine_type":"webkit"}`))
113+ if err != nil {
114+ t.Fatalf("wrapDirectAPIResponse() error = %v", err)
115+ }
116+
117+ var resp bridgeResponse
118+ if err := json.Unmarshal(got, &resp); err != nil {
119+ t.Fatalf("json.Unmarshal() error = %v", err)
120+ }
121+ if !resp.Success {
122+ t.Fatal("success = false, want true")
123+ }
124+ if resp.RequestID != "req-5" {
125+ t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-5")
126+ }
127+ if string(resp.Data) != `{"engine_type":"webkit"}` {
128+ t.Fatalf("data = %s, want %s", resp.Data, `{"engine_type":"webkit"}`)
129+ }
130+}
@@ -0,0 +1,130 @@
1+package systemviewsbridge
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "testing"
7+)
8+
9+func TestDecodeBrowserRequest_DirectConfigEndpoint(t *testing.T) {
10+ t.Parallel()
11+
12+ body, err := buildMessageEnvelope("/api/config", struct {
13+ RequestID string `json:"requestId"`
14+ }{RequestID: "req-1"})
15+ if err != nil {
16+ t.Fatalf("buildMessageEnvelope() error = %v", err)
17+ }
18+
19+ req, err := decodeBrowserRequest(body)
20+ if err != nil {
21+ t.Fatalf("decodeBrowserRequest() error = %v", err)
22+ }
23+ if req.messageType != "/api/config" {
24+ t.Fatalf("messageType = %q, want %q", req.messageType, "/api/config")
25+ }
26+ if req.requestID != "req-1" {
27+ t.Fatalf("requestID = %q, want %q", req.requestID, "req-1")
28+ }
29+ if !req.directAPI {
30+ t.Fatal("directAPI = false, want true")
31+ }
32+}
33+
34+func TestDecodeBrowserRequest_MessageEnvelopeUsesBridgeShim(t *testing.T) {
35+ t.Parallel()
36+
37+ body, err := buildMessageEnvelope("history_timeline", struct {
38+ RequestID string `json:"requestId"`
39+ Limit int `json:"limit"`
40+ }{RequestID: "req-2", Limit: 25})
41+ if err != nil {
42+ t.Fatalf("buildMessageEnvelope() error = %v", err)
43+ }
44+
45+ req, err := decodeBrowserRequest(body)
46+ if err != nil {
47+ t.Fatalf("decodeBrowserRequest() error = %v", err)
48+ }
49+ if req.messageType != "history_timeline" {
50+ t.Fatalf("messageType = %q, want %q", req.messageType, "history_timeline")
51+ }
52+ if req.requestID != "req-2" {
53+ t.Fatalf("requestID = %q, want %q", req.requestID, "req-2")
54+ }
55+ if req.directAPI {
56+ t.Fatal("directAPI = true, want false")
57+ }
58+ if !bytes.Equal(req.body, body) {
59+ t.Fatalf("body = %s, want %s", req.body, body)
60+ }
61+}
62+
63+func TestNormalizeBridgeShimResponse_UnwrapsNestedBridgeEnvelope(t *testing.T) {
64+ t.Parallel()
65+
66+ got, err := normalizeBridgeShimResponse("req-3", []byte(`{"data":{"requestId":"req-3","success":true,"data":[{"title":"Example"}]},"_callback":"__dumber_homepage_response"}`))
67+ if err != nil {
68+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
69+ }
70+
71+ var resp bridgeResponse
72+ if err := json.Unmarshal(got, &resp); err != nil {
73+ t.Fatalf("json.Unmarshal() error = %v", err)
74+ }
75+ if !resp.Success {
76+ t.Fatal("success = false, want true")
77+ }
78+ if resp.RequestID != "req-3" {
79+ t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-3")
80+ }
81+ if string(resp.Data) != `[{"title":"Example"}]` {
82+ t.Fatalf("data = %s, want %s", resp.Data, `[{"title":"Example"}]`)
83+ }
84+}
85+
86+func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) {
87+ t.Parallel()
88+
89+ got, err := normalizeBridgeShimResponse("req-4", []byte(`{"data":{"status":"success"},"_callback":"__dumber_config_saved"}`))
90+ if err != nil {
91+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
92+ }
93+
94+ var resp bridgeResponse
95+ if err := json.Unmarshal(got, &resp); err != nil {
96+ t.Fatalf("json.Unmarshal() error = %v", err)
97+ }
98+ if !resp.Success {
99+ t.Fatal("success = false, want true")
100+ }
101+ if resp.RequestID != "req-4" {
102+ t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-4")
103+ }
104+ if string(resp.Data) != `{"status":"success"}` {
105+ t.Fatalf("data = %s, want %s", resp.Data, `{"status":"success"}`)
106+ }
107+}
108+
109+func TestWrapDirectAPIResponse_PreservesPayload(t *testing.T) {
110+ t.Parallel()
111+
112+ got, err := wrapDirectAPIResponse("req-5", []byte(`{"engine_type":"webkit"}`))
113+ if err != nil {
114+ t.Fatalf("wrapDirectAPIResponse() error = %v", err)
115+ }
116+
117+ var resp bridgeResponse
118+ if err := json.Unmarshal(got, &resp); err != nil {
119+ t.Fatalf("json.Unmarshal() error = %v", err)
120+ }
121+ if !resp.Success {
122+ t.Fatal("success = false, want true")
123+ }
124+ if resp.RequestID != "req-5" {
125+ t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-5")
126+ }
127+ if string(resp.Data) != `{"engine_type":"webkit"}` {
128+ t.Fatalf("data = %s, want %s", resp.Data, `{"engine_type":"webkit"}`)
129+ }
130+}
M internal/infrastructure/systemviewsbridge/client.go +5 -2
@@ -24,6 +24,8 @@ type Client struct {
2424 }
2525
2626 var _ port.SystemviewConfigService = (*Client)(nil)
27+var _ port.SystemviewHistoryService = (*Client)(nil)
28+var _ port.SystemviewFavoritesService = (*Client)(nil)
2729
2830 var requestSeq atomic.Uint64
2931
@@ -179,12 +181,13 @@ func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int6
179181 return err
180182 }
181183
182-func (c *Client) CreateFolder(ctx context.Context, name string, _ *int64) (*entity.Folder, error) {
184+func (c *Client) CreateFolder(ctx context.Context, name string, parentID *int64) (*entity.Folder, error) {
183185 return request[*entity.Folder](c, ctx, "folder_create", struct {
184186 RequestID string `json:"requestId"`
185187 Name string `json:"name"`
186188 Icon *string `json:"icon"`
187- }{RequestID: nextRequestID(), Name: name, Icon: nil})
189+ ParentID *int64 `json:"parent_id,omitempty"`
190+ }{RequestID: nextRequestID(), Name: name, Icon: nil, ParentID: parentID})
188191 }
189192
190193 func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error {
@@ -24,6 +24,8 @@ type Client struct {
24 }24 }
25 25
26 var _ port.SystemviewConfigService = (*Client)(nil)26 var _ port.SystemviewConfigService = (*Client)(nil)
27+var _ port.SystemviewHistoryService = (*Client)(nil)
28+var _ port.SystemviewFavoritesService = (*Client)(nil)
27 29
28 var requestSeq atomic.Uint6430 var requestSeq atomic.Uint64
29 31
@@ -179,12 +181,13 @@ func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int6
179 return err181 return err
180 }182 }
181 183
182-func (c *Client) CreateFolder(ctx context.Context, name string, _ *int64) (*entity.Folder, error) {184+func (c *Client) CreateFolder(ctx context.Context, name string, parentID *int64) (*entity.Folder, error) {
183 return request[*entity.Folder](c, ctx, "folder_create", struct {185 return request[*entity.Folder](c, ctx, "folder_create", struct {
184 RequestID string `json:"requestId"`186 RequestID string `json:"requestId"`
185 Name string `json:"name"`187 Name string `json:"name"`
186 Icon *string `json:"icon"`188 Icon *string `json:"icon"`
187- }{RequestID: nextRequestID(), Name: name, Icon: nil})189+ ParentID *int64 `json:"parent_id,omitempty"`
190+ }{RequestID: nextRequestID(), Name: name, Icon: nil, ParentID: parentID})
188 }191 }
189 192
190 func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error {193 func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error {
M internal/infrastructure/systemviewsbridge/client_test.go +52 -0
@@ -356,6 +356,58 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
356356 }
357357 }
358358
359+func TestClientCreateFolderIncludesParentID(t *testing.T) {
360+ t.Parallel()
361+
362+ parentID := int64(42)
363+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"","parent_id":42}}`)}
364+ client := NewClient(transport, nil)
365+
366+ folder, err := client.CreateFolder(context.Background(), "Nested", &parentID)
367+ if err != nil {
368+ t.Fatalf("CreateFolder() error = %v", err)
369+ }
370+ if folder == nil {
371+ t.Fatal("CreateFolder() returned nil folder")
372+ }
373+ if folder.Name != "Nested" {
374+ t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested")
375+ }
376+ if folder.ParentID == nil || int64(*folder.ParentID) != parentID {
377+ t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID)
378+ }
379+
380+ var msg port.WebUIMessage
381+ if err := json.Unmarshal(transport.last, &msg); err != nil {
382+ t.Fatalf("unmarshal sent envelope: %v", err)
383+ }
384+ if msg.Type != "folder_create" {
385+ t.Fatalf("sent type = %q, want %q", msg.Type, "folder_create")
386+ }
387+
388+ var payload struct {
389+ RequestID string `json:"requestId"`
390+ Name string `json:"name"`
391+ Icon *string `json:"icon"`
392+ ParentID *int64 `json:"parent_id"`
393+ }
394+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
395+ t.Fatalf("unmarshal sent payload: %v", err)
396+ }
397+ if payload.ParentID == nil || *payload.ParentID != parentID {
398+ t.Fatalf("sent parent_id = %#v, want %d", payload.ParentID, parentID)
399+ }
400+ if payload.Name != "Nested" {
401+ t.Fatalf("sent name = %q, want %q", payload.Name, "Nested")
402+ }
403+ if payload.Icon != nil {
404+ t.Fatalf("sent icon = %#v, want nil", payload.Icon)
405+ }
406+ if payload.RequestID == "" {
407+ t.Fatal("sent requestId was empty")
408+ }
409+}
410+
359411 type fakeTransport struct {
360412 available bool
361413 called bool
@@ -356,6 +356,58 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
356 }356 }
357 }357 }
358 358
359+func TestClientCreateFolderIncludesParentID(t *testing.T) {
360+ t.Parallel()
361+
362+ parentID := int64(42)
363+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"","parent_id":42}}`)}
364+ client := NewClient(transport, nil)
365+
366+ folder, err := client.CreateFolder(context.Background(), "Nested", &parentID)
367+ if err != nil {
368+ t.Fatalf("CreateFolder() error = %v", err)
369+ }
370+ if folder == nil {
371+ t.Fatal("CreateFolder() returned nil folder")
372+ }
373+ if folder.Name != "Nested" {
374+ t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested")
375+ }
376+ if folder.ParentID == nil || int64(*folder.ParentID) != parentID {
377+ t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID)
378+ }
379+
380+ var msg port.WebUIMessage
381+ if err := json.Unmarshal(transport.last, &msg); err != nil {
382+ t.Fatalf("unmarshal sent envelope: %v", err)
383+ }
384+ if msg.Type != "folder_create" {
385+ t.Fatalf("sent type = %q, want %q", msg.Type, "folder_create")
386+ }
387+
388+ var payload struct {
389+ RequestID string `json:"requestId"`
390+ Name string `json:"name"`
391+ Icon *string `json:"icon"`
392+ ParentID *int64 `json:"parent_id"`
393+ }
394+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
395+ t.Fatalf("unmarshal sent payload: %v", err)
396+ }
397+ if payload.ParentID == nil || *payload.ParentID != parentID {
398+ t.Fatalf("sent parent_id = %#v, want %d", payload.ParentID, parentID)
399+ }
400+ if payload.Name != "Nested" {
401+ t.Fatalf("sent name = %q, want %q", payload.Name, "Nested")
402+ }
403+ if payload.Icon != nil {
404+ t.Fatalf("sent icon = %#v, want nil", payload.Icon)
405+ }
406+ if payload.RequestID == "" {
407+ t.Fatal("sent requestId was empty")
408+ }
409+}
410+
359 type fakeTransport struct {411 type fakeTransport struct {
360 available bool412 available bool
361 called bool413 called bool
M internal/infrastructure/webkit/scheme_handler.go +27 -6
@@ -352,6 +352,28 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
352352 }
353353 }
354354
355+func shouldAddCORSHeaders(path string) bool {
356+ path = strings.TrimSpace(path)
357+ return strings.HasPrefix(path, "/api/") || strings.HasSuffix(path, ".wasm")
358+}
359+
360+func responseHeadersForPath(path, contentType string) map[string]string {
361+ if !shouldAddCORSHeaders(path) {
362+ return nil
363+ }
364+
365+ headers := map[string]string{
366+ "Access-Control-Allow-Origin": "*",
367+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
368+ "Access-Control-Allow-Headers": "Content-Type",
369+ "Access-Control-Max-Age": "86400",
370+ }
371+ if contentType != "" {
372+ headers["Content-Type"] = contentType
373+ }
374+ return headers
375+}
376+
355377 // sendResponse sends the response back to WebKit.
356378 func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {
357379 if response == nil {
@@ -384,13 +406,12 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response
384406 schemeResp.SetStatus(uint(response.StatusCode), nil)
385407
386408 // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch().
387- // We only add CORS headers for our internal API endpoints.
388- if strings.HasPrefix(req.GetPath(), "/api/") {
409+ // Add CORS headers to fetch-backed endpoints, including the wasm runtime asset.
410+ if headers := responseHeadersForPath(req.GetPath(), contentType); len(headers) > 0 {
389411 hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
390- hdrs.Append("Access-Control-Allow-Origin", "*")
391- hdrs.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
392- hdrs.Append("Access-Control-Allow-Headers", "Content-Type")
393- hdrs.Append("Access-Control-Max-Age", "86400")
412+ for name, value := range headers {
413+ hdrs.Append(name, value)
414+ }
394415 schemeResp.SetHttpHeaders(hdrs)
395416 }
396417
@@ -352,6 +352,28 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
352 }352 }
353 }353 }
354 354
355+func shouldAddCORSHeaders(path string) bool {
356+ path = strings.TrimSpace(path)
357+ return strings.HasPrefix(path, "/api/") || strings.HasSuffix(path, ".wasm")
358+}
359+
360+func responseHeadersForPath(path, contentType string) map[string]string {
361+ if !shouldAddCORSHeaders(path) {
362+ return nil
363+ }
364+
365+ headers := map[string]string{
366+ "Access-Control-Allow-Origin": "*",
367+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
368+ "Access-Control-Allow-Headers": "Content-Type",
369+ "Access-Control-Max-Age": "86400",
370+ }
371+ if contentType != "" {
372+ headers["Content-Type"] = contentType
373+ }
374+ return headers
375+}
376+
355 // sendResponse sends the response back to WebKit.377 // sendResponse sends the response back to WebKit.
356 func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {378 func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {
357 if response == nil {379 if response == nil {
@@ -384,13 +406,12 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response
384 schemeResp.SetStatus(uint(response.StatusCode), nil)406 schemeResp.SetStatus(uint(response.StatusCode), nil)
385 407
386 // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch().408 // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch().
387- // We only add CORS headers for our internal API endpoints.409+ // Add CORS headers to fetch-backed endpoints, including the wasm runtime asset.
388- if strings.HasPrefix(req.GetPath(), "/api/") {410+ if headers := responseHeadersForPath(req.GetPath(), contentType); len(headers) > 0 {
389 hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)411 hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
390- hdrs.Append("Access-Control-Allow-Origin", "*")412+ for name, value := range headers {
391- hdrs.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS")413+ hdrs.Append(name, value)
392- hdrs.Append("Access-Control-Allow-Headers", "Content-Type")414+ }
393- hdrs.Append("Access-Control-Max-Age", "86400")
394 schemeResp.SetHttpHeaders(hdrs)415 schemeResp.SetHttpHeaders(hdrs)
395 }416 }
396 417
M internal/infrastructure/webkit/scheme_handler_test.go +45 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "net/http"
66 "net/url"
7+ "strings"
78 "testing"
89
910 "github.com/bnema/dumber/assets"
@@ -104,6 +105,19 @@ func TestHandleAsset_ConfigOpaqueFormServesSystemviewsShell(t *testing.T) {
104105 assert.Contains(t, string(resp.Data), "systemviews.wasm")
105106 }
106107
108+func TestHandleAsset_SystemviewsCSSIsServed(t *testing.T) {
109+ h := NewDumbSchemeHandler(context.Background())
110+ h.SetAssets(assets.WebUIAssets)
111+
112+ u, err := url.Parse("dumb://history/systemviews.css")
113+ require.NoError(t, err)
114+
115+ resp := h.handleAsset(u)
116+ require.NotNil(t, resp)
117+ assert.Equal(t, "text/css", strings.Split(resp.ContentType, ";")[0])
118+ assert.Contains(t, string(resp.Data), ".sv-app")
119+}
120+
107121 func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) {
108122 h := NewDumbSchemeHandler(context.Background())
109123 h.SetAssets(assets.WebUIAssets)
@@ -168,3 +182,34 @@ func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) {
168182 assert.Equal(t, "application/json", defaultResp.ContentType)
169183 assert.Equal(t, []byte(`{"default":true}`), defaultResp.Data)
170184 }
185+
186+func TestShouldAddCORSHeaders(t *testing.T) {
187+ t.Parallel()
188+
189+ tests := []struct {
190+ name string
191+ path string
192+ want bool
193+ }{
194+ {name: "api route", path: "/api/config", want: true},
195+ {name: "wasm asset", path: "/systemviews.wasm", want: true},
196+ {name: "html shell", path: "/", want: false},
197+ {name: "js asset", path: "/wasm_exec.js", want: false},
198+ }
199+
200+ for _, tt := range tests {
201+ t.Run(tt.name, func(t *testing.T) {
202+ t.Parallel()
203+ assert.Equal(t, tt.want, shouldAddCORSHeaders(tt.path))
204+ })
205+ }
206+}
207+
208+func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) {
209+ t.Parallel()
210+
211+ headers := responseHeadersForPath("/systemviews.wasm", "application/wasm")
212+ assert.Equal(t, "application/wasm", headers["Content-Type"])
213+ assert.Equal(t, "*", headers["Access-Control-Allow-Origin"])
214+ assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"])
215+}
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "net/http"5 "net/http"
6 "net/url"6 "net/url"
7+ "strings"
7 "testing"8 "testing"
8 9
9 "github.com/bnema/dumber/assets"10 "github.com/bnema/dumber/assets"
@@ -104,6 +105,19 @@ func TestHandleAsset_ConfigOpaqueFormServesSystemviewsShell(t *testing.T) {
104 assert.Contains(t, string(resp.Data), "systemviews.wasm")105 assert.Contains(t, string(resp.Data), "systemviews.wasm")
105 }106 }
106 107
108+func TestHandleAsset_SystemviewsCSSIsServed(t *testing.T) {
109+ h := NewDumbSchemeHandler(context.Background())
110+ h.SetAssets(assets.WebUIAssets)
111+
112+ u, err := url.Parse("dumb://history/systemviews.css")
113+ require.NoError(t, err)
114+
115+ resp := h.handleAsset(u)
116+ require.NotNil(t, resp)
117+ assert.Equal(t, "text/css", strings.Split(resp.ContentType, ";")[0])
118+ assert.Contains(t, string(resp.Data), ".sv-app")
119+}
120+
107 func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) {121 func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) {
108 h := NewDumbSchemeHandler(context.Background())122 h := NewDumbSchemeHandler(context.Background())
109 h.SetAssets(assets.WebUIAssets)123 h.SetAssets(assets.WebUIAssets)
@@ -168,3 +182,34 @@ func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) {
168 assert.Equal(t, "application/json", defaultResp.ContentType)182 assert.Equal(t, "application/json", defaultResp.ContentType)
169 assert.Equal(t, []byte(`{"default":true}`), defaultResp.Data)183 assert.Equal(t, []byte(`{"default":true}`), defaultResp.Data)
170 }184 }
185+
186+func TestShouldAddCORSHeaders(t *testing.T) {
187+ t.Parallel()
188+
189+ tests := []struct {
190+ name string
191+ path string
192+ want bool
193+ }{
194+ {name: "api route", path: "/api/config", want: true},
195+ {name: "wasm asset", path: "/systemviews.wasm", want: true},
196+ {name: "html shell", path: "/", want: false},
197+ {name: "js asset", path: "/wasm_exec.js", want: false},
198+ }
199+
200+ for _, tt := range tests {
201+ t.Run(tt.name, func(t *testing.T) {
202+ t.Parallel()
203+ assert.Equal(t, tt.want, shouldAddCORSHeaders(tt.path))
204+ })
205+ }
206+}
207+
208+func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) {
209+ t.Parallel()
210+
211+ headers := responseHeadersForPath("/systemviews.wasm", "application/wasm")
212+ assert.Equal(t, "application/wasm", headers["Content-Type"])
213+ assert.Equal(t, "*", headers["Access-Control-Allow-Origin"])
214+ assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"])
215+}
A internal/infrastructure/webutil/mime_test.go +11 -0
@@ -0,0 +1,11 @@
1+package webutil
2+
3+import "testing"
4+
5+func TestGetMimeType_WASM(t *testing.T) {
6+ t.Parallel()
7+
8+ if got := GetMimeType("systemviews.wasm"); got != "application/wasm" {
9+ t.Fatalf("GetMimeType(systemviews.wasm) = %q, want %q", got, "application/wasm")
10+ }
11+}
@@ -0,0 +1,11 @@
1+package webutil
2+
3+import "testing"
4+
5+func TestGetMimeType_WASM(t *testing.T) {
6+ t.Parallel()
7+
8+ if got := GetMimeType("systemviews.wasm"); got != "application/wasm" {
9+ t.Fatalf("GetMimeType(systemviews.wasm) = %q, want %q", got, "application/wasm")
10+ }
11+}
M internal/ui/systemviews/app.go +53 -7
@@ -19,6 +19,7 @@ type Dependencies struct {
1919 type App struct {
2020 deps Dependencies
2121 currentRoute Route
22+ shellTheme shellTheme
2223 historyEntries []*entity.HistoryEntry
2324 favorites []*entity.Favorite
2425 folders []*entity.Folder
@@ -54,6 +55,7 @@ func (a *App) LoadInitial(ctx context.Context) error {
5455 if a == nil {
5556 return errors.New("app is nil")
5657 }
58+ a.loadShellTheme(ctx)
5759 if a.currentRoute == "" || a.currentRoute == RouteUnknown {
5860 a.currentRoute = ParseRoute(a.deps.LocationURI)
5961 }
@@ -67,7 +69,11 @@ func (a *App) LoadInitial(ctx context.Context) error {
6769 return a.loadConfigRoute(ctx)
6870 default:
6971 a.resetRouteState()
70- a.renderedHTML = placeholderHTML(a.currentRoute)
72+ a.renderedHTML = renderAppFrame(renderedPage{
73+ route: a.currentRoute,
74+ subtitle: string(a.currentRoute),
75+ body: placeholderHTML(a.currentRoute),
76+ }, a.shellTheme)
7177 return nil
7278 }
7379 }
@@ -75,7 +81,11 @@ func (a *App) LoadInitial(ctx context.Context) error {
7581 func (a *App) loadHistoryRoute(ctx context.Context) error {
7682 if a.deps.History == nil {
7783 a.resetRouteState()
78- a.renderedHTML = placeholderHTML(a.currentRoute)
84+ a.renderedHTML = renderAppFrame(renderedPage{
85+ route: a.currentRoute,
86+ subtitle: "Recent visits",
87+ body: placeholderHTML(a.currentRoute),
88+ }, a.shellTheme)
7989 return nil
8090 }
8191
@@ -87,14 +97,22 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
8797 a.folders = nil
8898 a.tags = nil
8999 a.historyEntries = entries
90- a.renderedHTML = historyHTML(entries)
100+ a.renderedHTML = renderAppFrame(renderedPage{
101+ route: RouteHistory,
102+ subtitle: "Recent visits",
103+ body: historyHTML(entries),
104+ }, a.shellTheme)
91105 return nil
92106 }
93107
94108 func (a *App) loadFavoritesRoute(ctx context.Context) error {
95109 if a.deps.Favorites == nil {
96110 a.resetRouteState()
97- a.renderedHTML = placeholderHTML(a.currentRoute)
111+ a.renderedHTML = renderAppFrame(renderedPage{
112+ route: a.currentRoute,
113+ subtitle: "Saved bookmarks",
114+ body: placeholderHTML(a.currentRoute),
115+ }, a.shellTheme)
98116 return nil
99117 }
100118
@@ -115,14 +133,22 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error {
115133 a.favorites = favorites
116134 a.folders = folders
117135 a.tags = tags
118- a.renderedHTML = favoritesHTML(favorites, folders, tags)
136+ a.renderedHTML = renderAppFrame(renderedPage{
137+ route: RouteFavorites,
138+ subtitle: "Saved bookmarks",
139+ body: favoritesHTML(favorites, folders, tags),
140+ }, a.shellTheme)
119141 return nil
120142 }
121143
122144 func (a *App) loadConfigRoute(ctx context.Context) error {
123145 if a.deps.Config == nil {
124146 a.resetRouteState()
125- a.renderedHTML = placeholderHTML(a.currentRoute)
147+ a.renderedHTML = renderAppFrame(renderedPage{
148+ route: a.currentRoute,
149+ subtitle: "Browser settings",
150+ body: placeholderHTML(a.currentRoute),
151+ }, a.shellTheme)
126152 return nil
127153 }
128154
@@ -141,10 +167,30 @@ func (a *App) loadConfigRoute(ctx context.Context) error {
141167 a.tags = nil
142168 a.config = &config
143169 a.keybindings = keybindings
144- a.renderedHTML = configHTML(config, keybindings)
170+ a.renderedHTML = renderAppFrame(renderedPage{
171+ route: RouteConfig,
172+ subtitle: "Browser settings",
173+ body: configHTML(config, keybindings),
174+ }, a.shellTheme)
145175 return nil
146176 }
147177
178+func (a *App) loadShellTheme(ctx context.Context) {
179+ if a == nil {
180+ return
181+ }
182+
183+ a.shellTheme = shellTheme{}
184+ if a.deps.Config == nil {
185+ return
186+ }
187+
188+ config, err := a.deps.Config.Current(ctx)
189+ if err == nil {
190+ a.shellTheme = resolveShellTheme(config.Appearance)
191+ }
192+}
193+
148194 func (a *App) resetRouteState() {
149195 a.historyEntries = nil
150196 a.favorites = nil
@@ -19,6 +19,7 @@ type Dependencies struct {
19 type App struct {19 type App struct {
20 deps Dependencies20 deps Dependencies
21 currentRoute Route21 currentRoute Route
22+ shellTheme shellTheme
22 historyEntries []*entity.HistoryEntry23 historyEntries []*entity.HistoryEntry
23 favorites []*entity.Favorite24 favorites []*entity.Favorite
24 folders []*entity.Folder25 folders []*entity.Folder
@@ -54,6 +55,7 @@ func (a *App) LoadInitial(ctx context.Context) error {
54 if a == nil {55 if a == nil {
55 return errors.New("app is nil")56 return errors.New("app is nil")
56 }57 }
58+ a.loadShellTheme(ctx)
57 if a.currentRoute == "" || a.currentRoute == RouteUnknown {59 if a.currentRoute == "" || a.currentRoute == RouteUnknown {
58 a.currentRoute = ParseRoute(a.deps.LocationURI)60 a.currentRoute = ParseRoute(a.deps.LocationURI)
59 }61 }
@@ -67,7 +69,11 @@ func (a *App) LoadInitial(ctx context.Context) error {
67 return a.loadConfigRoute(ctx)69 return a.loadConfigRoute(ctx)
68 default:70 default:
69 a.resetRouteState()71 a.resetRouteState()
70- a.renderedHTML = placeholderHTML(a.currentRoute)72+ a.renderedHTML = renderAppFrame(renderedPage{
73+ route: a.currentRoute,
74+ subtitle: string(a.currentRoute),
75+ body: placeholderHTML(a.currentRoute),
76+ }, a.shellTheme)
71 return nil77 return nil
72 }78 }
73 }79 }
@@ -75,7 +81,11 @@ func (a *App) LoadInitial(ctx context.Context) error {
75 func (a *App) loadHistoryRoute(ctx context.Context) error {81 func (a *App) loadHistoryRoute(ctx context.Context) error {
76 if a.deps.History == nil {82 if a.deps.History == nil {
77 a.resetRouteState()83 a.resetRouteState()
78- a.renderedHTML = placeholderHTML(a.currentRoute)84+ a.renderedHTML = renderAppFrame(renderedPage{
85+ route: a.currentRoute,
86+ subtitle: "Recent visits",
87+ body: placeholderHTML(a.currentRoute),
88+ }, a.shellTheme)
79 return nil89 return nil
80 }90 }
81 91
@@ -87,14 +97,22 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
87 a.folders = nil97 a.folders = nil
88 a.tags = nil98 a.tags = nil
89 a.historyEntries = entries99 a.historyEntries = entries
90- a.renderedHTML = historyHTML(entries)100+ a.renderedHTML = renderAppFrame(renderedPage{
101+ route: RouteHistory,
102+ subtitle: "Recent visits",
103+ body: historyHTML(entries),
104+ }, a.shellTheme)
91 return nil105 return nil
92 }106 }
93 107
94 func (a *App) loadFavoritesRoute(ctx context.Context) error {108 func (a *App) loadFavoritesRoute(ctx context.Context) error {
95 if a.deps.Favorites == nil {109 if a.deps.Favorites == nil {
96 a.resetRouteState()110 a.resetRouteState()
97- a.renderedHTML = placeholderHTML(a.currentRoute)111+ a.renderedHTML = renderAppFrame(renderedPage{
112+ route: a.currentRoute,
113+ subtitle: "Saved bookmarks",
114+ body: placeholderHTML(a.currentRoute),
115+ }, a.shellTheme)
98 return nil116 return nil
99 }117 }
100 118
@@ -115,14 +133,22 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error {
115 a.favorites = favorites133 a.favorites = favorites
116 a.folders = folders134 a.folders = folders
117 a.tags = tags135 a.tags = tags
118- a.renderedHTML = favoritesHTML(favorites, folders, tags)136+ a.renderedHTML = renderAppFrame(renderedPage{
137+ route: RouteFavorites,
138+ subtitle: "Saved bookmarks",
139+ body: favoritesHTML(favorites, folders, tags),
140+ }, a.shellTheme)
119 return nil141 return nil
120 }142 }
121 143
122 func (a *App) loadConfigRoute(ctx context.Context) error {144 func (a *App) loadConfigRoute(ctx context.Context) error {
123 if a.deps.Config == nil {145 if a.deps.Config == nil {
124 a.resetRouteState()146 a.resetRouteState()
125- a.renderedHTML = placeholderHTML(a.currentRoute)147+ a.renderedHTML = renderAppFrame(renderedPage{
148+ route: a.currentRoute,
149+ subtitle: "Browser settings",
150+ body: placeholderHTML(a.currentRoute),
151+ }, a.shellTheme)
126 return nil152 return nil
127 }153 }
128 154
@@ -141,10 +167,30 @@ func (a *App) loadConfigRoute(ctx context.Context) error {
141 a.tags = nil167 a.tags = nil
142 a.config = &config168 a.config = &config
143 a.keybindings = keybindings169 a.keybindings = keybindings
144- a.renderedHTML = configHTML(config, keybindings)170+ a.renderedHTML = renderAppFrame(renderedPage{
171+ route: RouteConfig,
172+ subtitle: "Browser settings",
173+ body: configHTML(config, keybindings),
174+ }, a.shellTheme)
145 return nil175 return nil
146 }176 }
147 177
178+func (a *App) loadShellTheme(ctx context.Context) {
179+ if a == nil {
180+ return
181+ }
182+
183+ a.shellTheme = shellTheme{}
184+ if a.deps.Config == nil {
185+ return
186+ }
187+
188+ config, err := a.deps.Config.Current(ctx)
189+ if err == nil {
190+ a.shellTheme = resolveShellTheme(config.Appearance)
191+ }
192+}
193+
148 func (a *App) resetRouteState() {194 func (a *App) resetRouteState() {
149 a.historyEntries = nil195 a.historyEntries = nil
150 a.favorites = nil196 a.favorites = nil
M internal/ui/systemviews/app_test.go +179 -7
@@ -52,7 +52,7 @@ func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) {
5252 assert.Contains(t, dom.html, "systemviews")
5353 }
5454
55-func TestAppLoadInitialHistoryRouteRendersEntries(t *testing.T) {
55+func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
5656 t.Parallel()
5757
5858 history := &fakeHistoryService{entries: []*entity.HistoryEntry{{
@@ -73,6 +73,62 @@ func TestAppLoadInitialHistoryRouteRendersEntries(t *testing.T) {
7373 assert.True(t, history.called)
7474 assert.Equal(t, 25, history.limit)
7575 assert.Equal(t, 0, history.offset)
76+
77+ // Shell frame present, no full document wrapper.
78+ assert.NotContains(t, app.renderedHTML, "<html")
79+ assert.NotContains(t, app.renderedHTML, "<head")
80+ assert.NotContains(t, app.renderedHTML, "<body")
81+ assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
82+ assert.Contains(t, app.renderedHTML, `data-route="history"`)
83+ assert.Contains(t, app.renderedHTML, `class="sv-section"`)
84+ assert.Contains(t, app.renderedHTML, `class="sv-list"`)
85+}
86+
87+func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) {
88+ t.Parallel()
89+
90+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{
91+ URL: "https://example.com",
92+ Title: "Example",
93+ }}}
94+ config := &fakeConfigService{current: port.SystemviewConfigPayload{
95+ Appearance: port.WebUIAppearanceConfig{
96+ ColorScheme: "prefer-light",
97+ LightPalette: port.ColorPalette{
98+ Background: "#ffffff",
99+ Surface: "#fafafa",
100+ SurfaceVariant: "#eeeeee",
101+ Text: "#111111",
102+ Muted: "#666666",
103+ Accent: "#0055ff",
104+ Border: "#dddddd",
105+ },
106+ DarkPalette: port.ColorPalette{
107+ Background: "#111111",
108+ Surface: "#1a1a1a",
109+ SurfaceVariant: "#2a2a2a",
110+ Text: "#f5f5f5",
111+ Muted: "#a0a0a0",
112+ Accent: "#66aaff",
113+ Border: "#333333",
114+ },
115+ SansFont: "Inter",
116+ SerifFont: "Georgia",
117+ MonospaceFont: "JetBrains Mono",
118+ DefaultFontSize: 16,
119+ },
120+ }}
121+
122+ app := NewApp(Dependencies{
123+ Config: config,
124+ History: history,
125+ LocationURI: "dumb://history",
126+ })
127+
128+ require.NoError(t, app.LoadInitial(context.Background()))
129+ assert.Contains(t, app.renderedHTML, `class="sv-app sv-light"`)
130+ assert.Contains(t, app.renderedHTML, `--sv-background: #ffffff;`)
131+ assert.Contains(t, app.renderedHTML, `--sv-surface-variant: #eeeeee;`)
76132 }
77133
78134 func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
@@ -101,18 +157,92 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
101157 assert.True(t, favorites.calledList)
102158 assert.True(t, favorites.calledFolders)
103159 assert.True(t, favorites.calledTags)
160+
161+ // Shell frame present, no full document wrapper.
162+ assert.NotContains(t, app.renderedHTML, "<html")
163+ assert.NotContains(t, app.renderedHTML, "<head")
164+ assert.NotContains(t, app.renderedHTML, "<body")
165+ assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
166+ assert.Contains(t, app.renderedHTML, `data-route="favorites"`)
167+ assert.Contains(t, app.renderedHTML, `class="sv-section"`)
168+ assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
104169 }
105170
106171 func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
107172 t.Parallel()
108173
109174 config := port.SystemviewConfigPayload{
110- EngineType: "webkit",
111- DefaultSearchEngine: "DuckDuckGo",
175+ EngineType: "webkit",
176+ Appearance: port.WebUIAppearanceConfig{
177+ ColorScheme: "prefer-dark",
178+ SansFont: "Inter",
179+ SerifFont: "Georgia",
180+ MonospaceFont: "JetBrains Mono",
181+ DefaultFontSize: 16,
182+ LightPalette: port.ColorPalette{
183+ Background: "#ffffff",
184+ Surface: "#fafafa",
185+ SurfaceVariant: "#eeeeee",
186+ Text: "#111111",
187+ Muted: "#666666",
188+ Accent: "#0055ff",
189+ Border: "#dddddd",
190+ },
191+ DarkPalette: port.ColorPalette{
192+ Background: "#111111",
193+ Surface: "#1a1a1a",
194+ SurfaceVariant: "#2a2a2a",
195+ Text: "#f5f5f5",
196+ Muted: "#a0a0a0",
197+ Accent: "#66aaff",
198+ Border: "#333333",
199+ },
200+ },
201+ Performance: port.SystemviewPerformancePayload{
202+ Profile: "balanced",
203+ Custom: port.SystemviewCustomPerformancePayload{
204+ SkiaCPUThreads: 4,
205+ SkiaGPUThreads: 2,
206+ WebProcessMemoryMB: 512,
207+ NetworkProcessMemoryMB: 128,
208+ WebViewPoolPrewarm: 1,
209+ },
210+ Hardware: port.SystemviewHardwarePayload{
211+ CPUCores: 8,
212+ CPUThreads: 16,
213+ TotalRAMMB: 32768,
214+ GPUVendor: "NVIDIA",
215+ GPUName: "RTX 4060",
216+ VRAMMB: 8192,
217+ },
218+ },
219+ DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
220+ SearchShortcuts: map[string]port.SearchShortcut{
221+ "ddg": {
222+ URL: "https://duckduckgo.com/?q=%s",
223+ Description: "DuckDuckGo",
224+ },
225+ },
112226 }
113227 service := &fakeConfigService{
114- current: config,
115- keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{Mode: "default", DisplayName: "Default", Bindings: []port.KeybindingEntry{{Action: "open", Description: "Open", Keys: []string{"ctrl+o"}}}}}},
228+ current: config,
229+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{
230+ {
231+ Mode: "default",
232+ DisplayName: "Default",
233+ Bindings: []port.KeybindingEntry{
234+ {Action: "open", Description: "Open", Keys: []string{"ctrl+o"}, DefaultKeys: []string{"ctrl+o"}},
235+ {Action: "new-tab", Description: "New tab", Keys: []string{"ctrl+t"}, DefaultKeys: []string{"ctrl+shift+t"}, IsCustom: true},
236+ },
237+ },
238+ {
239+ Mode: "search",
240+ DisplayName: "Search",
241+ Bindings: []port.KeybindingEntry{
242+ {Action: "find", Description: "Find", Keys: []string{"ctrl+f"}, DefaultKeys: []string{"ctrl+f"}},
243+ },
244+ },
245+ }},
116246 }
117247
118248 app := NewApp(Dependencies{
@@ -125,10 +255,52 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
125255 require.NotNil(t, app.config)
126256 assert.Equal(t, "webkit", app.config.EngineType)
127257 assert.Contains(t, app.renderedHTML, "webkit")
128- assert.Contains(t, app.renderedHTML, "DuckDuckGo")
129- assert.Contains(t, app.renderedHTML, "1 group")
258+ assert.Contains(t, app.renderedHTML, "https://duckduckgo.com/?q=%s")
259+ assert.Contains(t, app.renderedHTML, "appearance.color_scheme")
260+ assert.Contains(t, app.renderedHTML, "prefer-dark")
261+ assert.Contains(t, app.renderedHTML, "appearance.sans_font")
262+ assert.Contains(t, app.renderedHTML, "Inter")
263+ assert.Contains(t, app.renderedHTML, "appearance.light_palette.background")
264+ assert.Contains(t, app.renderedHTML, "#ffffff")
265+ assert.Contains(t, app.renderedHTML, "performance.profile")
266+ assert.Contains(t, app.renderedHTML, "balanced")
267+ assert.Contains(t, app.renderedHTML, "performance.custom.skia_cpu_threads")
268+ assert.Contains(t, app.renderedHTML, "4")
269+ assert.Contains(t, app.renderedHTML, "performance.hardware.gpu_vendor")
270+ assert.Contains(t, app.renderedHTML, "NVIDIA")
271+ assert.Contains(t, app.renderedHTML, "search_shortcuts.ddg.url")
272+ assert.Contains(t, app.renderedHTML, "Default")
273+ assert.Contains(t, app.renderedHTML, "Search")
274+ assert.Contains(t, app.renderedHTML, "Open")
275+ assert.Contains(t, app.renderedHTML, "ctrl+o")
276+ assert.Contains(t, app.renderedHTML, "New tab")
277+ assert.Contains(t, app.renderedHTML, "ctrl+t")
278+ assert.Contains(t, app.renderedHTML, "ctrl+shift+t")
279+ assert.Contains(t, app.renderedHTML, "Find")
280+ assert.Contains(t, app.renderedHTML, "open")
281+ assert.Contains(t, app.renderedHTML, "default")
282+ assert.Contains(t, app.renderedHTML, "custom")
283+ assert.NotContains(t, app.renderedHTML, "groups[0].bindings[0].action")
130284 assert.True(t, service.calledCurrent)
131285 assert.True(t, service.calledKeybindings)
286+
287+ // Shell frame present, no full document wrapper.
288+ assert.NotContains(t, app.renderedHTML, "<html")
289+ assert.NotContains(t, app.renderedHTML, "<head")
290+ assert.NotContains(t, app.renderedHTML, "<body")
291+ assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
292+ assert.Contains(t, app.renderedHTML, `data-route="config"`)
293+ assert.Contains(t, app.renderedHTML, `class="sv-section"`)
294+ assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
295+}
296+
297+func TestConfigHTMLFallsBackForMalformedKeybindings(t *testing.T) {
298+ t.Parallel()
299+
300+ html := configHTML(port.SystemviewConfigPayload{}, map[string]any{"groups": "oops"})
301+
302+ assert.Contains(t, html, "Keybindings unavailable")
303+ assert.NotContains(t, html, "groups[0].bindings[0].action")
132304 }
133305
134306 type fakeDOM struct {
@@ -52,7 +52,7 @@ func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) {
52 assert.Contains(t, dom.html, "systemviews")52 assert.Contains(t, dom.html, "systemviews")
53 }53 }
54 54
55-func TestAppLoadInitialHistoryRouteRendersEntries(t *testing.T) {55+func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
56 t.Parallel()56 t.Parallel()
57 57
58 history := &fakeHistoryService{entries: []*entity.HistoryEntry{{58 history := &fakeHistoryService{entries: []*entity.HistoryEntry{{
@@ -73,6 +73,62 @@ func TestAppLoadInitialHistoryRouteRendersEntries(t *testing.T) {
73 assert.True(t, history.called)73 assert.True(t, history.called)
74 assert.Equal(t, 25, history.limit)74 assert.Equal(t, 25, history.limit)
75 assert.Equal(t, 0, history.offset)75 assert.Equal(t, 0, history.offset)
76+
77+ // Shell frame present, no full document wrapper.
78+ assert.NotContains(t, app.renderedHTML, "<html")
79+ assert.NotContains(t, app.renderedHTML, "<head")
80+ assert.NotContains(t, app.renderedHTML, "<body")
81+ assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
82+ assert.Contains(t, app.renderedHTML, `data-route="history"`)
83+ assert.Contains(t, app.renderedHTML, `class="sv-section"`)
84+ assert.Contains(t, app.renderedHTML, `class="sv-list"`)
85+}
86+
87+func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) {
88+ t.Parallel()
89+
90+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{
91+ URL: "https://example.com",
92+ Title: "Example",
93+ }}}
94+ config := &fakeConfigService{current: port.SystemviewConfigPayload{
95+ Appearance: port.WebUIAppearanceConfig{
96+ ColorScheme: "prefer-light",
97+ LightPalette: port.ColorPalette{
98+ Background: "#ffffff",
99+ Surface: "#fafafa",
100+ SurfaceVariant: "#eeeeee",
101+ Text: "#111111",
102+ Muted: "#666666",
103+ Accent: "#0055ff",
104+ Border: "#dddddd",
105+ },
106+ DarkPalette: port.ColorPalette{
107+ Background: "#111111",
108+ Surface: "#1a1a1a",
109+ SurfaceVariant: "#2a2a2a",
110+ Text: "#f5f5f5",
111+ Muted: "#a0a0a0",
112+ Accent: "#66aaff",
113+ Border: "#333333",
114+ },
115+ SansFont: "Inter",
116+ SerifFont: "Georgia",
117+ MonospaceFont: "JetBrains Mono",
118+ DefaultFontSize: 16,
119+ },
120+ }}
121+
122+ app := NewApp(Dependencies{
123+ Config: config,
124+ History: history,
125+ LocationURI: "dumb://history",
126+ })
127+
128+ require.NoError(t, app.LoadInitial(context.Background()))
129+ assert.Contains(t, app.renderedHTML, `class="sv-app sv-light"`)
130+ assert.Contains(t, app.renderedHTML, `--sv-background: #ffffff;`)
131+ assert.Contains(t, app.renderedHTML, `--sv-surface-variant: #eeeeee;`)
76 }132 }
77 133
78 func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {134 func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
@@ -101,18 +157,92 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
101 assert.True(t, favorites.calledList)157 assert.True(t, favorites.calledList)
102 assert.True(t, favorites.calledFolders)158 assert.True(t, favorites.calledFolders)
103 assert.True(t, favorites.calledTags)159 assert.True(t, favorites.calledTags)
160+
161+ // Shell frame present, no full document wrapper.
162+ assert.NotContains(t, app.renderedHTML, "<html")
163+ assert.NotContains(t, app.renderedHTML, "<head")
164+ assert.NotContains(t, app.renderedHTML, "<body")
165+ assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
166+ assert.Contains(t, app.renderedHTML, `data-route="favorites"`)
167+ assert.Contains(t, app.renderedHTML, `class="sv-section"`)
168+ assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
104 }169 }
105 170
106 func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {171 func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
107 t.Parallel()172 t.Parallel()
108 173
109 config := port.SystemviewConfigPayload{174 config := port.SystemviewConfigPayload{
110- EngineType: "webkit",175+ EngineType: "webkit",
111- DefaultSearchEngine: "DuckDuckGo",176+ Appearance: port.WebUIAppearanceConfig{
177+ ColorScheme: "prefer-dark",
178+ SansFont: "Inter",
179+ SerifFont: "Georgia",
180+ MonospaceFont: "JetBrains Mono",
181+ DefaultFontSize: 16,
182+ LightPalette: port.ColorPalette{
183+ Background: "#ffffff",
184+ Surface: "#fafafa",
185+ SurfaceVariant: "#eeeeee",
186+ Text: "#111111",
187+ Muted: "#666666",
188+ Accent: "#0055ff",
189+ Border: "#dddddd",
190+ },
191+ DarkPalette: port.ColorPalette{
192+ Background: "#111111",
193+ Surface: "#1a1a1a",
194+ SurfaceVariant: "#2a2a2a",
195+ Text: "#f5f5f5",
196+ Muted: "#a0a0a0",
197+ Accent: "#66aaff",
198+ Border: "#333333",
199+ },
200+ },
201+ Performance: port.SystemviewPerformancePayload{
202+ Profile: "balanced",
203+ Custom: port.SystemviewCustomPerformancePayload{
204+ SkiaCPUThreads: 4,
205+ SkiaGPUThreads: 2,
206+ WebProcessMemoryMB: 512,
207+ NetworkProcessMemoryMB: 128,
208+ WebViewPoolPrewarm: 1,
209+ },
210+ Hardware: port.SystemviewHardwarePayload{
211+ CPUCores: 8,
212+ CPUThreads: 16,
213+ TotalRAMMB: 32768,
214+ GPUVendor: "NVIDIA",
215+ GPUName: "RTX 4060",
216+ VRAMMB: 8192,
217+ },
218+ },
219+ DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
220+ SearchShortcuts: map[string]port.SearchShortcut{
221+ "ddg": {
222+ URL: "https://duckduckgo.com/?q=%s",
223+ Description: "DuckDuckGo",
224+ },
225+ },
112 }226 }
113 service := &fakeConfigService{227 service := &fakeConfigService{
114- current: config,228+ current: config,
115- keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{Mode: "default", DisplayName: "Default", Bindings: []port.KeybindingEntry{{Action: "open", Description: "Open", Keys: []string{"ctrl+o"}}}}}},229+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{
230+ {
231+ Mode: "default",
232+ DisplayName: "Default",
233+ Bindings: []port.KeybindingEntry{
234+ {Action: "open", Description: "Open", Keys: []string{"ctrl+o"}, DefaultKeys: []string{"ctrl+o"}},
235+ {Action: "new-tab", Description: "New tab", Keys: []string{"ctrl+t"}, DefaultKeys: []string{"ctrl+shift+t"}, IsCustom: true},
236+ },
237+ },
238+ {
239+ Mode: "search",
240+ DisplayName: "Search",
241+ Bindings: []port.KeybindingEntry{
242+ {Action: "find", Description: "Find", Keys: []string{"ctrl+f"}, DefaultKeys: []string{"ctrl+f"}},
243+ },
244+ },
245+ }},
116 }246 }
117 247
118 app := NewApp(Dependencies{248 app := NewApp(Dependencies{
@@ -125,10 +255,52 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
125 require.NotNil(t, app.config)255 require.NotNil(t, app.config)
126 assert.Equal(t, "webkit", app.config.EngineType)256 assert.Equal(t, "webkit", app.config.EngineType)
127 assert.Contains(t, app.renderedHTML, "webkit")257 assert.Contains(t, app.renderedHTML, "webkit")
128- assert.Contains(t, app.renderedHTML, "DuckDuckGo")258+ assert.Contains(t, app.renderedHTML, "https://duckduckgo.com/?q=%s")
129- assert.Contains(t, app.renderedHTML, "1 group")259+ assert.Contains(t, app.renderedHTML, "appearance.color_scheme")
260+ assert.Contains(t, app.renderedHTML, "prefer-dark")
261+ assert.Contains(t, app.renderedHTML, "appearance.sans_font")
262+ assert.Contains(t, app.renderedHTML, "Inter")
263+ assert.Contains(t, app.renderedHTML, "appearance.light_palette.background")
264+ assert.Contains(t, app.renderedHTML, "#ffffff")
265+ assert.Contains(t, app.renderedHTML, "performance.profile")
266+ assert.Contains(t, app.renderedHTML, "balanced")
267+ assert.Contains(t, app.renderedHTML, "performance.custom.skia_cpu_threads")
268+ assert.Contains(t, app.renderedHTML, "4")
269+ assert.Contains(t, app.renderedHTML, "performance.hardware.gpu_vendor")
270+ assert.Contains(t, app.renderedHTML, "NVIDIA")
271+ assert.Contains(t, app.renderedHTML, "search_shortcuts.ddg.url")
272+ assert.Contains(t, app.renderedHTML, "Default")
273+ assert.Contains(t, app.renderedHTML, "Search")
274+ assert.Contains(t, app.renderedHTML, "Open")
275+ assert.Contains(t, app.renderedHTML, "ctrl+o")
276+ assert.Contains(t, app.renderedHTML, "New tab")
277+ assert.Contains(t, app.renderedHTML, "ctrl+t")
278+ assert.Contains(t, app.renderedHTML, "ctrl+shift+t")
279+ assert.Contains(t, app.renderedHTML, "Find")
280+ assert.Contains(t, app.renderedHTML, "open")
281+ assert.Contains(t, app.renderedHTML, "default")
282+ assert.Contains(t, app.renderedHTML, "custom")
283+ assert.NotContains(t, app.renderedHTML, "groups[0].bindings[0].action")
130 assert.True(t, service.calledCurrent)284 assert.True(t, service.calledCurrent)
131 assert.True(t, service.calledKeybindings)285 assert.True(t, service.calledKeybindings)
286+
287+ // Shell frame present, no full document wrapper.
288+ assert.NotContains(t, app.renderedHTML, "<html")
289+ assert.NotContains(t, app.renderedHTML, "<head")
290+ assert.NotContains(t, app.renderedHTML, "<body")
291+ assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
292+ assert.Contains(t, app.renderedHTML, `data-route="config"`)
293+ assert.Contains(t, app.renderedHTML, `class="sv-section"`)
294+ assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
295+}
296+
297+func TestConfigHTMLFallsBackForMalformedKeybindings(t *testing.T) {
298+ t.Parallel()
299+
300+ html := configHTML(port.SystemviewConfigPayload{}, map[string]any{"groups": "oops"})
301+
302+ assert.Contains(t, html, "Keybindings unavailable")
303+ assert.NotContains(t, html, "groups[0].bindings[0].action")
132 }304 }
133 305
134 type fakeDOM struct {306 type fakeDOM struct {
M internal/ui/systemviews/config_view.go +422 -51
@@ -3,6 +3,8 @@ package systemviews
33 import (
44 "fmt"
55 "html"
6+ "reflect"
7+ "sort"
68 "strings"
79
810 "github.com/bnema/dumber/internal/application/port"
@@ -14,93 +16,462 @@ func configHTML(cfg port.SystemviewConfigPayload, keybindings any) string {
1416 if loaded {
1517 status = "loaded"
1618 }
17-
18- return fmt.Sprintf(`<!doctype html>
19-<html lang="en">
20-<head>
21- <meta charset="utf-8">
22- <meta name="viewport" content="width=device-width, initial-scale=1">
23- <title>systemviews config</title>
24-</head>
25-<body>
26- <main id="app" data-route="config">
27- <h1>Config</h1>
28- <p>Engine: %s</p>
29- <p>Default search engine: %s</p>
30- <p>Keybindings: %s (%d %s, %d %s)</p>
31- </main>
32-</body>
33-</html>`,
34- html.EscapeString(cfg.EngineType),
35- html.EscapeString(cfg.DefaultSearchEngine),
19+ keybindingSummary := fmt.Sprintf(
20+ "Keybindings %s (%d %s, %d %s)",
3621 status,
37- groups, plural(groups, "group"),
38- bindings, plural(bindings, "binding"),
22+ groups,
23+ plural(groups, "group"),
24+ bindings,
25+ plural(bindings, "binding"),
3926 )
27+ content := []string{
28+ metaHTML(keybindingSummary),
29+ "<h3>Config values</h3>" + kvHTML(flattenValues(cfg)),
30+ "<h3>Keybindings</h3>" + renderKeybindingsSection(keybindings),
31+ }
32+
33+ return sectionHTML("", "Config", strings.Join(content, ""))
34+}
35+
36+func flattenValues(value any) []kvPair {
37+ rows := make([]kvPair, 0)
38+ flattenValue(&rows, "", reflect.ValueOf(value))
39+ return rows
40+}
41+
42+func flattenValue(rows *[]kvPair, path string, value reflect.Value) {
43+ if !value.IsValid() {
44+ appendFlatValue(rows, path, "null")
45+ return
46+ }
47+
48+ for value.Kind() == reflect.Interface {
49+ if value.IsNil() {
50+ appendFlatValue(rows, path, "null")
51+ return
52+ }
53+ value = value.Elem()
54+ }
55+
56+ for value.Kind() == reflect.Pointer {
57+ if value.IsNil() {
58+ appendFlatValue(rows, path, "null")
59+ return
60+ }
61+ value = value.Elem()
62+ }
63+
64+ switch value.Kind() {
65+ case reflect.Struct:
66+ typeOfValue := value.Type()
67+ for i := 0; i < value.NumField(); i++ {
68+ field := typeOfValue.Field(i)
69+ if !field.IsExported() {
70+ continue
71+ }
72+ name := jsonFieldName(field)
73+ if name == "-" {
74+ continue
75+ }
76+ nextPath := name
77+ if path != "" {
78+ nextPath = path + "." + name
79+ }
80+ flattenValue(rows, nextPath, value.Field(i))
81+ }
82+ case reflect.Map:
83+ if value.Len() == 0 {
84+ appendFlatValue(rows, path, "{}")
85+ return
86+ }
87+
88+ keys := value.MapKeys()
89+ sort.Slice(keys, func(i, j int) bool {
90+ return mapKeyString(keys[i]) < mapKeyString(keys[j])
91+ })
92+
93+ for _, key := range keys {
94+ name := mapKeyString(key)
95+ nextPath := name
96+ if path != "" {
97+ nextPath = path + "." + name
98+ }
99+ flattenValue(rows, nextPath, value.MapIndex(key))
100+ }
101+ case reflect.Slice, reflect.Array:
102+ if value.Len() == 0 {
103+ appendFlatValue(rows, path, "[]")
104+ return
105+ }
106+
107+ for i := 0; i < value.Len(); i++ {
108+ nextPath := fmt.Sprintf("%s[%d]", path, i)
109+ if path == "" {
110+ nextPath = fmt.Sprintf("[%d]", i)
111+ }
112+ flattenValue(rows, nextPath, value.Index(i))
113+ }
114+ default:
115+ appendFlatValue(rows, path, formatFlatValue(value))
116+ }
117+}
118+
119+func appendFlatValue(rows *[]kvPair, path, value string) {
120+ if strings.TrimSpace(path) == "" {
121+ path = "value"
122+ }
123+ *rows = append(*rows, kvPair{Label: path, Value: value})
124+}
125+
126+func formatFlatValue(value reflect.Value) string {
127+ if !value.IsValid() {
128+ return "null"
129+ }
130+
131+ switch value.Kind() {
132+ case reflect.String:
133+ return value.String()
134+ case reflect.Bool:
135+ return fmt.Sprint(value.Bool())
136+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
137+ return fmt.Sprint(value.Int())
138+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
139+ return fmt.Sprint(value.Uint())
140+ case reflect.Float32, reflect.Float64:
141+ return fmt.Sprint(value.Float())
142+ case reflect.Complex64, reflect.Complex128:
143+ return fmt.Sprint(value.Complex())
144+ default:
145+ return fmt.Sprint(value.Interface())
146+ }
147+}
148+
149+func jsonFieldName(field reflect.StructField) string {
150+ name := field.Tag.Get("json")
151+ if name == "" {
152+ return field.Name
153+ }
154+ if idx := strings.Index(name, ","); idx >= 0 {
155+ name = name[:idx]
156+ }
157+ if name == "" {
158+ return field.Name
159+ }
160+ return name
161+}
162+
163+func mapKeyString(value reflect.Value) string {
164+ if value.Kind() == reflect.String {
165+ return value.String()
166+ }
167+ return fmt.Sprint(value.Interface())
40168 }
41169
42170 func summarizeKeybindings(keybindings any) (groups, bindings int, loaded bool) {
171+ renderedGroups, ok := collectKeybindingGroups(keybindings)
172+ if !ok {
173+ return 0, 0, false
174+ }
175+
176+ for _, group := range renderedGroups {
177+ groups++
178+ bindings += len(group.Bindings)
179+ }
180+
181+ return groups, bindings, true
182+}
183+
184+func renderKeybindingsSection(keybindings any) string {
185+ groups, ok := collectKeybindingGroups(keybindings)
186+ if !ok {
187+ return emptyStateHTML("Keybindings unavailable")
188+ }
189+ if len(groups) == 0 {
190+ return emptyStateHTML("No keybindings configured")
191+ }
192+
193+ return renderKeybindingGroups(groups)
194+}
195+
196+func collectKeybindingGroups(keybindings any) ([]renderedKeybindingGroup, bool) {
43197 switch v := keybindings.(type) {
44198 case nil:
45- return 0, 0, false
199+ return nil, false
46200 case port.KeybindingsConfig:
47- return countKeybindingGroups(v.Groups), countKeybindingBindings(v.Groups), true
201+ return convertPortKeybindingGroups(v.Groups), true
48202 case *port.KeybindingsConfig:
49203 if v == nil {
50- return 0, 0, false
204+ return nil, false
51205 }
52- return countKeybindingGroups(v.Groups), countKeybindingBindings(v.Groups), true
206+ return convertPortKeybindingGroups(v.Groups), true
53207 case map[string]any:
54- return countBridgeKeybindings(v)
208+ return convertBridgeKeybindingGroups(v)
55209 default:
56- return 1, 0, true
210+ return nil, false
211+ }
212+}
213+
214+func convertPortKeybindingGroups(groups []port.KeybindingGroup) []renderedKeybindingGroup {
215+ rendered := make([]renderedKeybindingGroup, 0, len(groups))
216+ for _, group := range groups {
217+ rendered = append(rendered, renderedKeybindingGroup{
218+ DisplayName: strings.TrimSpace(group.DisplayName),
219+ Mode: strings.TrimSpace(group.Mode),
220+ Activation: strings.TrimSpace(group.Activation),
221+ Bindings: convertPortKeybindingEntries(group.Bindings),
222+ })
57223 }
224+ return rendered
58225 }
59226
60-func countBridgeKeybindings(data map[string]any) (groups, bindings int, loaded bool) {
61- groupValue, ok := data["groups"]
227+func convertPortKeybindingEntries(bindings []port.KeybindingEntry) []renderedKeybinding {
228+ rendered := make([]renderedKeybinding, 0, len(bindings))
229+ for _, binding := range bindings {
230+ rendered = append(rendered, renderedKeybinding{
231+ Description: strings.TrimSpace(binding.Description),
232+ Action: strings.TrimSpace(binding.Action),
233+ Keys: append([]string(nil), binding.Keys...),
234+ DefaultKeys: append([]string(nil), binding.DefaultKeys...),
235+ IsCustom: binding.IsCustom,
236+ })
237+ }
238+ return rendered
239+}
240+
241+func convertBridgeKeybindingGroups(data map[string]any) ([]renderedKeybindingGroup, bool) {
242+ rawGroups, ok := data["groups"]
243+ if !ok || rawGroups == nil {
244+ return nil, false
245+ }
246+
247+ groupValues, ok := anySlice(rawGroups)
62248 if !ok {
63- return 1, 0, true
249+ return nil, false
250+ }
251+
252+ rendered := make([]renderedKeybindingGroup, 0, len(groupValues))
253+ for _, rawGroup := range groupValues {
254+ groupMap, ok := rawGroup.(map[string]any)
255+ if !ok {
256+ return nil, false
257+ }
258+ group, ok := convertBridgeKeybindingGroup(groupMap)
259+ if !ok {
260+ return nil, false
261+ }
262+ rendered = append(rendered, group)
263+ }
264+
265+ return rendered, true
266+}
267+
268+func convertBridgeKeybindingGroup(group map[string]any) (renderedKeybindingGroup, bool) {
269+ bindings := []renderedKeybinding{}
270+ if rawBindings, ok := group["bindings"]; ok && rawBindings != nil {
271+ converted, ok := convertBridgeKeybindingEntries(rawBindings)
272+ if !ok {
273+ return renderedKeybindingGroup{}, false
274+ }
275+ bindings = converted
64276 }
65277
66- groupList, ok := groupValue.([]any)
278+ return renderedKeybindingGroup{
279+ DisplayName: strings.TrimSpace(stringValue(group["display_name"])),
280+ Mode: strings.TrimSpace(stringValue(group["mode"])),
281+ Activation: strings.TrimSpace(stringValue(group["activation"])),
282+ Bindings: bindings,
283+ }, true
284+}
285+
286+func convertBridgeKeybindingEntries(rawBindings any) ([]renderedKeybinding, bool) {
287+ bindingValues, ok := anySlice(rawBindings)
67288 if !ok {
68- return 1, 0, true
289+ return nil, false
69290 }
70291
71- for _, group := range groupList {
72- groupMap, ok := group.(map[string]any)
292+ rendered := make([]renderedKeybinding, 0, len(bindingValues))
293+ for _, rawBinding := range bindingValues {
294+ bindingMap, ok := rawBinding.(map[string]any)
73295 if !ok {
74- continue
296+ return nil, false
75297 }
76- groups++
77- if bindingValue, ok := groupMap["bindings"]; ok {
78- if bindingList, ok := bindingValue.([]any); ok {
79- bindings += len(bindingList)
298+ rendered = append(rendered, renderedKeybinding{
299+ Description: strings.TrimSpace(stringValue(bindingMap["description"])),
300+ Action: strings.TrimSpace(stringValue(bindingMap["action"])),
301+ Keys: stringSlice(bindingMap["keys"]),
302+ DefaultKeys: stringSlice(bindingMap["default_keys"]),
303+ IsCustom: boolValue(bindingMap["is_custom"]),
304+ })
305+ }
306+
307+ return rendered, true
308+}
309+
310+func anySlice(value any) ([]any, bool) {
311+ switch v := value.(type) {
312+ case []any:
313+ return v, true
314+ case []map[string]any:
315+ items := make([]any, len(v))
316+ for i, item := range v {
317+ items[i] = item
318+ }
319+ return items, true
320+ default:
321+ return nil, false
322+ }
323+}
324+
325+func stringValue(value any) string {
326+ s, _ := value.(string)
327+ return s
328+}
329+
330+func stringSlice(value any) []string {
331+ switch v := value.(type) {
332+ case []string:
333+ return append([]string(nil), v...)
334+ case []any:
335+ out := make([]string, 0, len(v))
336+ for _, item := range v {
337+ text, ok := item.(string)
338+ if !ok {
339+ return nil
80340 }
341+ out = append(out, text)
81342 }
343+ return out
344+ default:
345+ return nil
82346 }
347+}
83348
84- return groups, bindings, true
349+func boolValue(value any) bool {
350+ b, _ := value.(bool)
351+ return b
85352 }
86353
87-func countKeybindingGroups(groups []port.KeybindingGroup) int {
88- count := 0
354+func renderKeybindingGroups(groups []renderedKeybindingGroup) string {
355+ var rendered strings.Builder
89356 for _, group := range groups {
90- if group.Mode == "" && group.DisplayName == "" && len(group.Bindings) == 0 {
91- continue
357+ rendered.WriteString(renderKeybindingGroup(group))
358+ }
359+ return rendered.String()
360+}
361+
362+type renderedKeybindingGroup struct {
363+ DisplayName string
364+ Mode string
365+ Activation string
366+ Bindings []renderedKeybinding
367+}
368+
369+type renderedKeybinding struct {
370+ Description string
371+ Action string
372+ Keys []string
373+ DefaultKeys []string
374+ IsCustom bool
375+}
376+
377+func renderKeybindingGroup(group renderedKeybindingGroup) string {
378+ title := group.DisplayName
379+ if strings.TrimSpace(title) == "" {
380+ title = group.Mode
381+ }
382+ if strings.TrimSpace(title) == "" {
383+ title = "Keybinding group"
384+ }
385+
386+ var body strings.Builder
387+ body.WriteString(`<div class="sv-group">`)
388+ body.WriteString(`<div class="sv-group-header">`)
389+ body.WriteString(`<div>`)
390+ body.WriteString(`<h4>` + html.EscapeString(title) + `</h4>`)
391+
392+ meta := make([]string, 0, 2)
393+ if strings.TrimSpace(group.Mode) != "" {
394+ meta = append(meta, "mode: "+group.Mode)
395+ }
396+ if strings.TrimSpace(group.Activation) != "" {
397+ meta = append(meta, "activation: "+group.Activation)
398+ }
399+ if len(meta) > 0 {
400+ body.WriteString(`<p class="sv-meta">` + html.EscapeString(strings.Join(meta, " • ")) + `</p>`)
401+ }
402+ body.WriteString(`</div></div>`)
403+ body.WriteString(`<div class="sv-group-body">`)
404+ if len(group.Bindings) == 0 {
405+ body.WriteString(emptyStateHTML("No bindings in this group"))
406+ } else {
407+ for _, binding := range group.Bindings {
408+ body.WriteString(renderKeybinding(binding))
92409 }
93- count++
94410 }
95- return count
411+ body.WriteString(`</div></div>`)
412+
413+ return body.String()
96414 }
97415
98-func countKeybindingBindings(groups []port.KeybindingGroup) int {
99- count := 0
100- for _, group := range groups {
101- count += len(group.Bindings)
416+func renderKeybinding(binding renderedKeybinding) string {
417+ description := strings.TrimSpace(binding.Description)
418+ if description == "" {
419+ description = strings.TrimSpace(binding.Action)
420+ }
421+ if description == "" {
422+ description = "Unnamed binding"
423+ }
424+
425+ action := strings.TrimSpace(binding.Action)
426+ if action == "" {
427+ action = "binding"
428+ }
429+
430+ custom := binding.IsCustom || !sameStringSlice(binding.Keys, binding.DefaultKeys)
431+ status := "default"
432+ if custom {
433+ status = "custom"
434+ }
435+
436+ var body strings.Builder
437+ body.WriteString(`<div class="sv-binding">`)
438+ body.WriteString(`<div class="sv-binding-header">`)
439+ body.WriteString(`<div class="sv-binding-description">` + html.EscapeString(description) + `</div>`)
440+ body.WriteString(`<div class="sv-binding-keys">` + keyListHTML(binding.Keys) + `</div>`)
441+ body.WriteString(`</div>`)
442+ body.WriteString(`<div class="sv-binding-meta">`)
443+ body.WriteString(`<span>` + html.EscapeString("action: "+action) + `</span>`)
444+ body.WriteString(`<span>` + html.EscapeString(status) + `</span>`)
445+ if custom && len(binding.DefaultKeys) > 0 {
446+ body.WriteString(`<span>default: ` + keyListHTML(binding.DefaultKeys) + `</span>`)
447+ }
448+ body.WriteString(`</div></div>`)
449+
450+ return body.String()
451+}
452+
453+func keyListHTML(keys []string) string {
454+ if len(keys) == 0 {
455+ return `<span class="sv-key sv-key-empty">Unassigned</span>`
456+ }
457+
458+ parts := make([]string, 0, len(keys))
459+ for _, key := range keys {
460+ parts = append(parts, `<span class="sv-key">`+html.EscapeString(key)+`</span>`)
461+ }
462+ return strings.Join(parts, " ")
463+}
464+
465+func sameStringSlice(a, b []string) bool {
466+ if len(a) != len(b) {
467+ return false
468+ }
469+ for i := range a {
470+ if a[i] != b[i] {
471+ return false
472+ }
102473 }
103- return count
474+ return true
104475 }
105476
106477 func plural(count int, singular string) string {
@@ -3,6 +3,8 @@ package systemviews
3 import (3 import (
4 "fmt"4 "fmt"
5 "html"5 "html"
6+ "reflect"
7+ "sort"
6 "strings"8 "strings"
7 9
8 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
@@ -14,93 +16,462 @@ func configHTML(cfg port.SystemviewConfigPayload, keybindings any) string {
14 if loaded {16 if loaded {
15 status = "loaded"17 status = "loaded"
16 }18 }
17-19+ keybindingSummary := fmt.Sprintf(
18- return fmt.Sprintf(`<!doctype html>20+ "Keybindings %s (%d %s, %d %s)",
19-<html lang="en">
20-<head>
21- <meta charset="utf-8">
22- <meta name="viewport" content="width=device-width, initial-scale=1">
23- <title>systemviews config</title>
24-</head>
25-<body>
26- <main id="app" data-route="config">
27- <h1>Config</h1>
28- <p>Engine: %s</p>
29- <p>Default search engine: %s</p>
30- <p>Keybindings: %s (%d %s, %d %s)</p>
31- </main>
32-</body>
33-</html>`,
34- html.EscapeString(cfg.EngineType),
35- html.EscapeString(cfg.DefaultSearchEngine),
36 status,21 status,
37- groups, plural(groups, "group"),22+ groups,
38- bindings, plural(bindings, "binding"),23+ plural(groups, "group"),
24+ bindings,
25+ plural(bindings, "binding"),
39 )26 )
27+ content := []string{
28+ metaHTML(keybindingSummary),
29+ "<h3>Config values</h3>" + kvHTML(flattenValues(cfg)),
30+ "<h3>Keybindings</h3>" + renderKeybindingsSection(keybindings),
31+ }
32+
33+ return sectionHTML("", "Config", strings.Join(content, ""))
34+}
35+
36+func flattenValues(value any) []kvPair {
37+ rows := make([]kvPair, 0)
38+ flattenValue(&rows, "", reflect.ValueOf(value))
39+ return rows
40+}
41+
42+func flattenValue(rows *[]kvPair, path string, value reflect.Value) {
43+ if !value.IsValid() {
44+ appendFlatValue(rows, path, "null")
45+ return
46+ }
47+
48+ for value.Kind() == reflect.Interface {
49+ if value.IsNil() {
50+ appendFlatValue(rows, path, "null")
51+ return
52+ }
53+ value = value.Elem()
54+ }
55+
56+ for value.Kind() == reflect.Pointer {
57+ if value.IsNil() {
58+ appendFlatValue(rows, path, "null")
59+ return
60+ }
61+ value = value.Elem()
62+ }
63+
64+ switch value.Kind() {
65+ case reflect.Struct:
66+ typeOfValue := value.Type()
67+ for i := 0; i < value.NumField(); i++ {
68+ field := typeOfValue.Field(i)
69+ if !field.IsExported() {
70+ continue
71+ }
72+ name := jsonFieldName(field)
73+ if name == "-" {
74+ continue
75+ }
76+ nextPath := name
77+ if path != "" {
78+ nextPath = path + "." + name
79+ }
80+ flattenValue(rows, nextPath, value.Field(i))
81+ }
82+ case reflect.Map:
83+ if value.Len() == 0 {
84+ appendFlatValue(rows, path, "{}")
85+ return
86+ }
87+
88+ keys := value.MapKeys()
89+ sort.Slice(keys, func(i, j int) bool {
90+ return mapKeyString(keys[i]) < mapKeyString(keys[j])
91+ })
92+
93+ for _, key := range keys {
94+ name := mapKeyString(key)
95+ nextPath := name
96+ if path != "" {
97+ nextPath = path + "." + name
98+ }
99+ flattenValue(rows, nextPath, value.MapIndex(key))
100+ }
101+ case reflect.Slice, reflect.Array:
102+ if value.Len() == 0 {
103+ appendFlatValue(rows, path, "[]")
104+ return
105+ }
106+
107+ for i := 0; i < value.Len(); i++ {
108+ nextPath := fmt.Sprintf("%s[%d]", path, i)
109+ if path == "" {
110+ nextPath = fmt.Sprintf("[%d]", i)
111+ }
112+ flattenValue(rows, nextPath, value.Index(i))
113+ }
114+ default:
115+ appendFlatValue(rows, path, formatFlatValue(value))
116+ }
117+}
118+
119+func appendFlatValue(rows *[]kvPair, path, value string) {
120+ if strings.TrimSpace(path) == "" {
121+ path = "value"
122+ }
123+ *rows = append(*rows, kvPair{Label: path, Value: value})
124+}
125+
126+func formatFlatValue(value reflect.Value) string {
127+ if !value.IsValid() {
128+ return "null"
129+ }
130+
131+ switch value.Kind() {
132+ case reflect.String:
133+ return value.String()
134+ case reflect.Bool:
135+ return fmt.Sprint(value.Bool())
136+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
137+ return fmt.Sprint(value.Int())
138+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
139+ return fmt.Sprint(value.Uint())
140+ case reflect.Float32, reflect.Float64:
141+ return fmt.Sprint(value.Float())
142+ case reflect.Complex64, reflect.Complex128:
143+ return fmt.Sprint(value.Complex())
144+ default:
145+ return fmt.Sprint(value.Interface())
146+ }
147+}
148+
149+func jsonFieldName(field reflect.StructField) string {
150+ name := field.Tag.Get("json")
151+ if name == "" {
152+ return field.Name
153+ }
154+ if idx := strings.Index(name, ","); idx >= 0 {
155+ name = name[:idx]
156+ }
157+ if name == "" {
158+ return field.Name
159+ }
160+ return name
161+}
162+
163+func mapKeyString(value reflect.Value) string {
164+ if value.Kind() == reflect.String {
165+ return value.String()
166+ }
167+ return fmt.Sprint(value.Interface())
40 }168 }
41 169
42 func summarizeKeybindings(keybindings any) (groups, bindings int, loaded bool) {170 func summarizeKeybindings(keybindings any) (groups, bindings int, loaded bool) {
171+ renderedGroups, ok := collectKeybindingGroups(keybindings)
172+ if !ok {
173+ return 0, 0, false
174+ }
175+
176+ for _, group := range renderedGroups {
177+ groups++
178+ bindings += len(group.Bindings)
179+ }
180+
181+ return groups, bindings, true
182+}
183+
184+func renderKeybindingsSection(keybindings any) string {
185+ groups, ok := collectKeybindingGroups(keybindings)
186+ if !ok {
187+ return emptyStateHTML("Keybindings unavailable")
188+ }
189+ if len(groups) == 0 {
190+ return emptyStateHTML("No keybindings configured")
191+ }
192+
193+ return renderKeybindingGroups(groups)
194+}
195+
196+func collectKeybindingGroups(keybindings any) ([]renderedKeybindingGroup, bool) {
43 switch v := keybindings.(type) {197 switch v := keybindings.(type) {
44 case nil:198 case nil:
45- return 0, 0, false199+ return nil, false
46 case port.KeybindingsConfig:200 case port.KeybindingsConfig:
47- return countKeybindingGroups(v.Groups), countKeybindingBindings(v.Groups), true201+ return convertPortKeybindingGroups(v.Groups), true
48 case *port.KeybindingsConfig:202 case *port.KeybindingsConfig:
49 if v == nil {203 if v == nil {
50- return 0, 0, false204+ return nil, false
51 }205 }
52- return countKeybindingGroups(v.Groups), countKeybindingBindings(v.Groups), true206+ return convertPortKeybindingGroups(v.Groups), true
53 case map[string]any:207 case map[string]any:
54- return countBridgeKeybindings(v)208+ return convertBridgeKeybindingGroups(v)
55 default:209 default:
56- return 1, 0, true210+ return nil, false
211+ }
212+}
213+
214+func convertPortKeybindingGroups(groups []port.KeybindingGroup) []renderedKeybindingGroup {
215+ rendered := make([]renderedKeybindingGroup, 0, len(groups))
216+ for _, group := range groups {
217+ rendered = append(rendered, renderedKeybindingGroup{
218+ DisplayName: strings.TrimSpace(group.DisplayName),
219+ Mode: strings.TrimSpace(group.Mode),
220+ Activation: strings.TrimSpace(group.Activation),
221+ Bindings: convertPortKeybindingEntries(group.Bindings),
222+ })
57 }223 }
224+ return rendered
58 }225 }
59 226
60-func countBridgeKeybindings(data map[string]any) (groups, bindings int, loaded bool) {227+func convertPortKeybindingEntries(bindings []port.KeybindingEntry) []renderedKeybinding {
61- groupValue, ok := data["groups"]228+ rendered := make([]renderedKeybinding, 0, len(bindings))
229+ for _, binding := range bindings {
230+ rendered = append(rendered, renderedKeybinding{
231+ Description: strings.TrimSpace(binding.Description),
232+ Action: strings.TrimSpace(binding.Action),
233+ Keys: append([]string(nil), binding.Keys...),
234+ DefaultKeys: append([]string(nil), binding.DefaultKeys...),
235+ IsCustom: binding.IsCustom,
236+ })
237+ }
238+ return rendered
239+}
240+
241+func convertBridgeKeybindingGroups(data map[string]any) ([]renderedKeybindingGroup, bool) {
242+ rawGroups, ok := data["groups"]
243+ if !ok || rawGroups == nil {
244+ return nil, false
245+ }
246+
247+ groupValues, ok := anySlice(rawGroups)
62 if !ok {248 if !ok {
63- return 1, 0, true249+ return nil, false
250+ }
251+
252+ rendered := make([]renderedKeybindingGroup, 0, len(groupValues))
253+ for _, rawGroup := range groupValues {
254+ groupMap, ok := rawGroup.(map[string]any)
255+ if !ok {
256+ return nil, false
257+ }
258+ group, ok := convertBridgeKeybindingGroup(groupMap)
259+ if !ok {
260+ return nil, false
261+ }
262+ rendered = append(rendered, group)
263+ }
264+
265+ return rendered, true
266+}
267+
268+func convertBridgeKeybindingGroup(group map[string]any) (renderedKeybindingGroup, bool) {
269+ bindings := []renderedKeybinding{}
270+ if rawBindings, ok := group["bindings"]; ok && rawBindings != nil {
271+ converted, ok := convertBridgeKeybindingEntries(rawBindings)
272+ if !ok {
273+ return renderedKeybindingGroup{}, false
274+ }
275+ bindings = converted
64 }276 }
65 277
66- groupList, ok := groupValue.([]any)278+ return renderedKeybindingGroup{
279+ DisplayName: strings.TrimSpace(stringValue(group["display_name"])),
280+ Mode: strings.TrimSpace(stringValue(group["mode"])),
281+ Activation: strings.TrimSpace(stringValue(group["activation"])),
282+ Bindings: bindings,
283+ }, true
284+}
285+
286+func convertBridgeKeybindingEntries(rawBindings any) ([]renderedKeybinding, bool) {
287+ bindingValues, ok := anySlice(rawBindings)
67 if !ok {288 if !ok {
68- return 1, 0, true289+ return nil, false
69 }290 }
70 291
71- for _, group := range groupList {292+ rendered := make([]renderedKeybinding, 0, len(bindingValues))
72- groupMap, ok := group.(map[string]any)293+ for _, rawBinding := range bindingValues {
294+ bindingMap, ok := rawBinding.(map[string]any)
73 if !ok {295 if !ok {
74- continue296+ return nil, false
75 }297 }
76- groups++298+ rendered = append(rendered, renderedKeybinding{
77- if bindingValue, ok := groupMap["bindings"]; ok {299+ Description: strings.TrimSpace(stringValue(bindingMap["description"])),
78- if bindingList, ok := bindingValue.([]any); ok {300+ Action: strings.TrimSpace(stringValue(bindingMap["action"])),
79- bindings += len(bindingList)301+ Keys: stringSlice(bindingMap["keys"]),
302+ DefaultKeys: stringSlice(bindingMap["default_keys"]),
303+ IsCustom: boolValue(bindingMap["is_custom"]),
304+ })
305+ }
306+
307+ return rendered, true
308+}
309+
310+func anySlice(value any) ([]any, bool) {
311+ switch v := value.(type) {
312+ case []any:
313+ return v, true
314+ case []map[string]any:
315+ items := make([]any, len(v))
316+ for i, item := range v {
317+ items[i] = item
318+ }
319+ return items, true
320+ default:
321+ return nil, false
322+ }
323+}
324+
325+func stringValue(value any) string {
326+ s, _ := value.(string)
327+ return s
328+}
329+
330+func stringSlice(value any) []string {
331+ switch v := value.(type) {
332+ case []string:
333+ return append([]string(nil), v...)
334+ case []any:
335+ out := make([]string, 0, len(v))
336+ for _, item := range v {
337+ text, ok := item.(string)
338+ if !ok {
339+ return nil
80 }340 }
341+ out = append(out, text)
81 }342 }
343+ return out
344+ default:
345+ return nil
82 }346 }
347+}
83 348
84- return groups, bindings, true349+func boolValue(value any) bool {
350+ b, _ := value.(bool)
351+ return b
85 }352 }
86 353
87-func countKeybindingGroups(groups []port.KeybindingGroup) int {354+func renderKeybindingGroups(groups []renderedKeybindingGroup) string {
88- count := 0355+ var rendered strings.Builder
89 for _, group := range groups {356 for _, group := range groups {
90- if group.Mode == "" && group.DisplayName == "" && len(group.Bindings) == 0 {357+ rendered.WriteString(renderKeybindingGroup(group))
91- continue358+ }
359+ return rendered.String()
360+}
361+
362+type renderedKeybindingGroup struct {
363+ DisplayName string
364+ Mode string
365+ Activation string
366+ Bindings []renderedKeybinding
367+}
368+
369+type renderedKeybinding struct {
370+ Description string
371+ Action string
372+ Keys []string
373+ DefaultKeys []string
374+ IsCustom bool
375+}
376+
377+func renderKeybindingGroup(group renderedKeybindingGroup) string {
378+ title := group.DisplayName
379+ if strings.TrimSpace(title) == "" {
380+ title = group.Mode
381+ }
382+ if strings.TrimSpace(title) == "" {
383+ title = "Keybinding group"
384+ }
385+
386+ var body strings.Builder
387+ body.WriteString(`<div class="sv-group">`)
388+ body.WriteString(`<div class="sv-group-header">`)
389+ body.WriteString(`<div>`)
390+ body.WriteString(`<h4>` + html.EscapeString(title) + `</h4>`)
391+
392+ meta := make([]string, 0, 2)
393+ if strings.TrimSpace(group.Mode) != "" {
394+ meta = append(meta, "mode: "+group.Mode)
395+ }
396+ if strings.TrimSpace(group.Activation) != "" {
397+ meta = append(meta, "activation: "+group.Activation)
398+ }
399+ if len(meta) > 0 {
400+ body.WriteString(`<p class="sv-meta">` + html.EscapeString(strings.Join(meta, " • ")) + `</p>`)
401+ }
402+ body.WriteString(`</div></div>`)
403+ body.WriteString(`<div class="sv-group-body">`)
404+ if len(group.Bindings) == 0 {
405+ body.WriteString(emptyStateHTML("No bindings in this group"))
406+ } else {
407+ for _, binding := range group.Bindings {
408+ body.WriteString(renderKeybinding(binding))
92 }409 }
93- count++
94 }410 }
95- return count411+ body.WriteString(`</div></div>`)
412+
413+ return body.String()
96 }414 }
97 415
98-func countKeybindingBindings(groups []port.KeybindingGroup) int {416+func renderKeybinding(binding renderedKeybinding) string {
99- count := 0417+ description := strings.TrimSpace(binding.Description)
100- for _, group := range groups {418+ if description == "" {
101- count += len(group.Bindings)419+ description = strings.TrimSpace(binding.Action)
420+ }
421+ if description == "" {
422+ description = "Unnamed binding"
423+ }
424+
425+ action := strings.TrimSpace(binding.Action)
426+ if action == "" {
427+ action = "binding"
428+ }
429+
430+ custom := binding.IsCustom || !sameStringSlice(binding.Keys, binding.DefaultKeys)
431+ status := "default"
432+ if custom {
433+ status = "custom"
434+ }
435+
436+ var body strings.Builder
437+ body.WriteString(`<div class="sv-binding">`)
438+ body.WriteString(`<div class="sv-binding-header">`)
439+ body.WriteString(`<div class="sv-binding-description">` + html.EscapeString(description) + `</div>`)
440+ body.WriteString(`<div class="sv-binding-keys">` + keyListHTML(binding.Keys) + `</div>`)
441+ body.WriteString(`</div>`)
442+ body.WriteString(`<div class="sv-binding-meta">`)
443+ body.WriteString(`<span>` + html.EscapeString("action: "+action) + `</span>`)
444+ body.WriteString(`<span>` + html.EscapeString(status) + `</span>`)
445+ if custom && len(binding.DefaultKeys) > 0 {
446+ body.WriteString(`<span>default: ` + keyListHTML(binding.DefaultKeys) + `</span>`)
447+ }
448+ body.WriteString(`</div></div>`)
449+
450+ return body.String()
451+}
452+
453+func keyListHTML(keys []string) string {
454+ if len(keys) == 0 {
455+ return `<span class="sv-key sv-key-empty">Unassigned</span>`
456+ }
457+
458+ parts := make([]string, 0, len(keys))
459+ for _, key := range keys {
460+ parts = append(parts, `<span class="sv-key">`+html.EscapeString(key)+`</span>`)
461+ }
462+ return strings.Join(parts, " ")
463+}
464+
465+func sameStringSlice(a, b []string) bool {
466+ if len(a) != len(b) {
467+ return false
468+ }
469+ for i := range a {
470+ if a[i] != b[i] {
471+ return false
472+ }
102 }473 }
103- return count474+ return true
104 }475 }
105 476
106 func plural(count int, singular string) string {477 func plural(count int, singular string) string {
M internal/ui/systemviews/dom.go +1 -11
@@ -15,15 +15,5 @@ func placeholderHTML(route Route) string {
1515 name = "unknown"
1616 }
1717
18- return fmt.Sprintf(`<!doctype html>
19-<html lang="en">
20-<head>
21- <meta charset="utf-8">
22- <meta name="viewport" content="width=device-width, initial-scale=1">
23- <title>systemviews</title>
24-</head>
25-<body>
26- <main id="app" data-route="%s">systemviews %s</main>
27-</body>
28-</html>`, name, name)
18+ return fmt.Sprintf(`<p>systemviews %s</p>`, name)
2919 }
@@ -15,15 +15,5 @@ func placeholderHTML(route Route) string {
15 name = "unknown"15 name = "unknown"
16 }16 }
17 17
18- return fmt.Sprintf(`<!doctype html>18+ return fmt.Sprintf(`<p>systemviews %s</p>`, name)
19-<html lang="en">
20-<head>
21- <meta charset="utf-8">
22- <meta name="viewport" content="width=device-width, initial-scale=1">
23- <title>systemviews</title>
24-</head>
25-<body>
26- <main id="app" data-route="%s">systemviews %s</main>
27-</body>
28-</html>`, name, name)
29 }19 }
M internal/ui/systemviews/favorites_view.go +12 -50
@@ -9,39 +9,18 @@ import (
99 )
1010
1111 func favoritesHTML(favorites []*entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string {
12- return fmt.Sprintf(`<!doctype html>
13-<html lang="en">
14-<head>
15- <meta charset="utf-8">
16- <meta name="viewport" content="width=device-width, initial-scale=1">
17- <title>systemviews favorites</title>
18-</head>
19-<body>
20- <main id="app" data-route="favorites">
21- <h1>Favorites</h1>
22- <p>%d favorites · %d folders · %d tags</p>
23- <section>
24- <h2>Favorites</h2>
25- <ul>%s</ul>
26- </section>
27- <section>
28- <h2>Folders</h2>
29- <ul>%s</ul>
30- </section>
31- <section>
32- <h2>Tags</h2>
33- <ul>%s</ul>
34- </section>
35- </main>
36-</body>
37-</html>`, len(favorites), len(folders), len(tags), favoriteItemsHTML(favorites), folderItemsHTML(folders), tagItemsHTML(tags))
12+ favoritesSummary := fmt.Sprintf("%d favorites · %d folders · %d tags", len(favorites), len(folders), len(tags))
13+ foldersSummary := fmt.Sprintf("%d folders", len(folders))
14+ tagsSummary := fmt.Sprintf("%d tags", len(tags))
15+ sections := []string{
16+ sectionHTML("", "Favorites", metaHTML(favoritesSummary)+listHTML(favoriteItemsHTML(favorites), "No favorites")),
17+ sectionHTML("", "Folders", metaHTML(foldersSummary)+listHTML(folderItemsHTML(folders), "No folders")),
18+ sectionHTML("", "Tags", metaHTML(tagsSummary)+listHTML(tagItemsHTML(tags), "No tags")),
19+ }
20+ return strings.Join(sections, "")
3821 }
3922
4023 func favoriteItemsHTML(favorites []*entity.Favorite) string {
41- if len(favorites) == 0 {
42- return "<li>No favorites</li>"
43- }
44-
4524 var items strings.Builder
4625 for _, favorite := range favorites {
4726 if favorite == nil {
@@ -51,46 +30,29 @@ func favoriteItemsHTML(favorites []*entity.Favorite) string {
5130 if label == "" {
5231 label = favorite.URL
5332 }
54- _, _ = fmt.Fprintf(&items, `<li><a href="%s">%s</a></li>`, html.EscapeString(favorite.URL), html.EscapeString(label))
55- }
56- if items.Len() == 0 {
57- return "<li>No favorites</li>"
33+ items.WriteString(listRowHTML(linkHTML(favorite.URL, label)))
5834 }
5935 return items.String()
6036 }
6137
6238 func folderItemsHTML(folders []*entity.Folder) string {
63- if len(folders) == 0 {
64- return "<li>No folders</li>"
65- }
66-
6739 var items strings.Builder
6840 for _, folder := range folders {
6941 if folder == nil {
7042 continue
7143 }
72- _, _ = fmt.Fprintf(&items, "<li>%s</li>", html.EscapeString(folder.Name))
73- }
74- if items.Len() == 0 {
75- return "<li>No folders</li>"
44+ items.WriteString(listRowHTML(html.EscapeString(folder.Name)))
7645 }
7746 return items.String()
7847 }
7948
8049 func tagItemsHTML(tags []*entity.Tag) string {
81- if len(tags) == 0 {
82- return "<li>No tags</li>"
83- }
84-
8550 var items strings.Builder
8651 for _, tag := range tags {
8752 if tag == nil {
8853 continue
8954 }
90- _, _ = fmt.Fprintf(&items, "<li>%s</li>", html.EscapeString(tag.Name))
91- }
92- if items.Len() == 0 {
93- return "<li>No tags</li>"
55+ items.WriteString(listRowHTML(html.EscapeString(tag.Name)))
9456 }
9557 return items.String()
9658 }
@@ -9,39 +9,18 @@ import (
9 )9 )
10 10
11 func favoritesHTML(favorites []*entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string {11 func favoritesHTML(favorites []*entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string {
12- return fmt.Sprintf(`<!doctype html>12+ favoritesSummary := fmt.Sprintf("%d favorites · %d folders · %d tags", len(favorites), len(folders), len(tags))
13-<html lang="en">13+ foldersSummary := fmt.Sprintf("%d folders", len(folders))
14-<head>14+ tagsSummary := fmt.Sprintf("%d tags", len(tags))
15- <meta charset="utf-8">15+ sections := []string{
16- <meta name="viewport" content="width=device-width, initial-scale=1">16+ sectionHTML("", "Favorites", metaHTML(favoritesSummary)+listHTML(favoriteItemsHTML(favorites), "No favorites")),
17- <title>systemviews favorites</title>17+ sectionHTML("", "Folders", metaHTML(foldersSummary)+listHTML(folderItemsHTML(folders), "No folders")),
18-</head>18+ sectionHTML("", "Tags", metaHTML(tagsSummary)+listHTML(tagItemsHTML(tags), "No tags")),
19-<body>19+ }
20- <main id="app" data-route="favorites">20+ return strings.Join(sections, "")
21- <h1>Favorites</h1>
22- <p>%d favorites · %d folders · %d tags</p>
23- <section>
24- <h2>Favorites</h2>
25- <ul>%s</ul>
26- </section>
27- <section>
28- <h2>Folders</h2>
29- <ul>%s</ul>
30- </section>
31- <section>
32- <h2>Tags</h2>
33- <ul>%s</ul>
34- </section>
35- </main>
36-</body>
37-</html>`, len(favorites), len(folders), len(tags), favoriteItemsHTML(favorites), folderItemsHTML(folders), tagItemsHTML(tags))
38 }21 }
39 22
40 func favoriteItemsHTML(favorites []*entity.Favorite) string {23 func favoriteItemsHTML(favorites []*entity.Favorite) string {
41- if len(favorites) == 0 {
42- return "<li>No favorites</li>"
43- }
44-
45 var items strings.Builder24 var items strings.Builder
46 for _, favorite := range favorites {25 for _, favorite := range favorites {
47 if favorite == nil {26 if favorite == nil {
@@ -51,46 +30,29 @@ func favoriteItemsHTML(favorites []*entity.Favorite) string {
51 if label == "" {30 if label == "" {
52 label = favorite.URL31 label = favorite.URL
53 }32 }
54- _, _ = fmt.Fprintf(&items, `<li><a href="%s">%s</a></li>`, html.EscapeString(favorite.URL), html.EscapeString(label))33+ items.WriteString(listRowHTML(linkHTML(favorite.URL, label)))
55- }
56- if items.Len() == 0 {
57- return "<li>No favorites</li>"
58 }34 }
59 return items.String()35 return items.String()
60 }36 }
61 37
62 func folderItemsHTML(folders []*entity.Folder) string {38 func folderItemsHTML(folders []*entity.Folder) string {
63- if len(folders) == 0 {
64- return "<li>No folders</li>"
65- }
66-
67 var items strings.Builder39 var items strings.Builder
68 for _, folder := range folders {40 for _, folder := range folders {
69 if folder == nil {41 if folder == nil {
70 continue42 continue
71 }43 }
72- _, _ = fmt.Fprintf(&items, "<li>%s</li>", html.EscapeString(folder.Name))44+ items.WriteString(listRowHTML(html.EscapeString(folder.Name)))
73- }
74- if items.Len() == 0 {
75- return "<li>No folders</li>"
76 }45 }
77 return items.String()46 return items.String()
78 }47 }
79 48
80 func tagItemsHTML(tags []*entity.Tag) string {49 func tagItemsHTML(tags []*entity.Tag) string {
81- if len(tags) == 0 {
82- return "<li>No tags</li>"
83- }
84-
85 var items strings.Builder50 var items strings.Builder
86 for _, tag := range tags {51 for _, tag := range tags {
87 if tag == nil {52 if tag == nil {
88 continue53 continue
89 }54 }
90- _, _ = fmt.Fprintf(&items, "<li>%s</li>", html.EscapeString(tag.Name))55+ items.WriteString(listRowHTML(html.EscapeString(tag.Name)))
91- }
92- if items.Len() == 0 {
93- return "<li>No tags</li>"
94 }56 }
95 return items.String()57 return items.String()
96 }58 }
M internal/ui/systemviews/history_view.go +24 -27
@@ -2,41 +2,38 @@ package systemviews
22
33 import (
44 "fmt"
5- "html"
65 "strings"
76
87 "github.com/bnema/dumber/internal/domain/entity"
98 )
109
1110 func historyHTML(entries []*entity.HistoryEntry) string {
11+ items := historyItemsHTML(entries)
12+ return sectionHTML("", "History", metaHTML(fmt.Sprintf("%d entries", countHistoryEntries(entries)))+listHTML(items, "No history entries"))
13+}
14+
15+func historyItemsHTML(entries []*entity.HistoryEntry) string {
1216 var items strings.Builder
13- if len(entries) == 0 {
14- items.WriteString("<li>No history entries</li>")
15- } else {
16- for _, entry := range entries {
17- if entry == nil {
18- continue
19- }
20- label := entry.Title
21- if label == "" {
22- label = entry.URL
23- }
24- _, _ = fmt.Fprintf(&items, `<li><a href="%s">%s</a></li>`, html.EscapeString(entry.URL), html.EscapeString(label))
17+ for _, entry := range entries {
18+ if entry == nil {
19+ continue
2520 }
21+ label := entry.Title
22+ if label == "" {
23+ label = entry.URL
24+ }
25+ items.WriteString(listRowHTML(linkHTML(entry.URL, label)))
2626 }
2727
28- return fmt.Sprintf(`<!doctype html>
29-<html lang="en">
30-<head>
31- <meta charset="utf-8">
32- <meta name="viewport" content="width=device-width, initial-scale=1">
33- <title>systemviews history</title>
34-</head>
35-<body>
36- <main id="app" data-route="history">
37- <h1>History</h1>
38- <ul>%s</ul>
39- </main>
40-</body>
41-</html>`, items.String())
28+ return items.String()
29+}
30+
31+func countHistoryEntries(entries []*entity.HistoryEntry) int {
32+ count := 0
33+ for _, entry := range entries {
34+ if entry != nil {
35+ count++
36+ }
37+ }
38+ return count
4239 }
@@ -2,41 +2,38 @@ package systemviews
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "html"
6 "strings"5 "strings"
7 6
8 "github.com/bnema/dumber/internal/domain/entity"7 "github.com/bnema/dumber/internal/domain/entity"
9 )8 )
10 9
11 func historyHTML(entries []*entity.HistoryEntry) string {10 func historyHTML(entries []*entity.HistoryEntry) string {
11+ items := historyItemsHTML(entries)
12+ return sectionHTML("", "History", metaHTML(fmt.Sprintf("%d entries", countHistoryEntries(entries)))+listHTML(items, "No history entries"))
13+}
14+
15+func historyItemsHTML(entries []*entity.HistoryEntry) string {
12 var items strings.Builder16 var items strings.Builder
13- if len(entries) == 0 {17+ for _, entry := range entries {
14- items.WriteString("<li>No history entries</li>")18+ if entry == nil {
15- } else {19+ continue
16- for _, entry := range entries {
17- if entry == nil {
18- continue
19- }
20- label := entry.Title
21- if label == "" {
22- label = entry.URL
23- }
24- _, _ = fmt.Fprintf(&items, `<li><a href="%s">%s</a></li>`, html.EscapeString(entry.URL), html.EscapeString(label))
25 }20 }
21+ label := entry.Title
22+ if label == "" {
23+ label = entry.URL
24+ }
25+ items.WriteString(listRowHTML(linkHTML(entry.URL, label)))
26 }26 }
27 27
28- return fmt.Sprintf(`<!doctype html>28+ return items.String()
29-<html lang="en">29+}
30-<head>30+
31- <meta charset="utf-8">31+func countHistoryEntries(entries []*entity.HistoryEntry) int {
32- <meta name="viewport" content="width=device-width, initial-scale=1">32+ count := 0
33- <title>systemviews history</title>33+ for _, entry := range entries {
34-</head>34+ if entry != nil {
35-<body>35+ count++
36- <main id="app" data-route="history">36+ }
37- <h1>History</h1>37+ }
38- <ul>%s</ul>38+ return count
39- </main>
40-</body>
41-</html>`, items.String())
42 }39 }
A internal/ui/systemviews/href.go +32 -0
@@ -0,0 +1,32 @@
1+package systemviews
2+
3+import (
4+ "net/url"
5+ "strings"
6+)
7+
8+func sanitizeHref(raw string) string {
9+ trimmed := strings.TrimSpace(raw)
10+ if trimmed == "" {
11+ return "#"
12+ }
13+
14+ parsed, err := url.Parse(trimmed)
15+ if err != nil {
16+ return "#"
17+ }
18+
19+ if parsed.Scheme == "" {
20+ if parsed.Host != "" {
21+ return "#"
22+ }
23+ return trimmed
24+ }
25+
26+ switch strings.ToLower(parsed.Scheme) {
27+ case "http", "https", "dumb":
28+ return trimmed
29+ default:
30+ return "#"
31+ }
32+}
@@ -0,0 +1,32 @@
1+package systemviews
2+
3+import (
4+ "net/url"
5+ "strings"
6+)
7+
8+func sanitizeHref(raw string) string {
9+ trimmed := strings.TrimSpace(raw)
10+ if trimmed == "" {
11+ return "#"
12+ }
13+
14+ parsed, err := url.Parse(trimmed)
15+ if err != nil {
16+ return "#"
17+ }
18+
19+ if parsed.Scheme == "" {
20+ if parsed.Host != "" {
21+ return "#"
22+ }
23+ return trimmed
24+ }
25+
26+ switch strings.ToLower(parsed.Scheme) {
27+ case "http", "https", "dumb":
28+ return trimmed
29+ default:
30+ return "#"
31+ }
32+}
A internal/ui/systemviews/href_test.go +35 -0
@@ -0,0 +1,35 @@
1+package systemviews
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestHistoryHTMLSanitizesHrefSchemes(t *testing.T) {
12+ t.Parallel()
13+
14+ html := historyHTML([]*entity.HistoryEntry{
15+ {URL: "javascript:alert(1)", Title: "Bad"},
16+ {URL: "https://example.com", Title: "Good"},
17+ })
18+
19+ require.NotContains(t, html, `href="javascript:alert(1)"`)
20+ assert.Contains(t, html, `href="#"`)
21+ assert.Contains(t, html, `href="https://example.com"`)
22+}
23+
24+func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) {
25+ t.Parallel()
26+
27+ html := favoriteItemsHTML([]*entity.Favorite{
28+ {URL: "javascript:alert(1)", Title: "Bad"},
29+ {URL: "https://example.com", Title: "Good"},
30+ })
31+
32+ require.NotContains(t, html, `href="javascript:alert(1)"`)
33+ assert.Contains(t, html, `href="#"`)
34+ assert.Contains(t, html, `href="https://example.com"`)
35+}
@@ -0,0 +1,35 @@
1+package systemviews
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestHistoryHTMLSanitizesHrefSchemes(t *testing.T) {
12+ t.Parallel()
13+
14+ html := historyHTML([]*entity.HistoryEntry{
15+ {URL: "javascript:alert(1)", Title: "Bad"},
16+ {URL: "https://example.com", Title: "Good"},
17+ })
18+
19+ require.NotContains(t, html, `href="javascript:alert(1)"`)
20+ assert.Contains(t, html, `href="#"`)
21+ assert.Contains(t, html, `href="https://example.com"`)
22+}
23+
24+func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) {
25+ t.Parallel()
26+
27+ html := favoriteItemsHTML([]*entity.Favorite{
28+ {URL: "javascript:alert(1)", Title: "Bad"},
29+ {URL: "https://example.com", Title: "Good"},
30+ })
31+
32+ require.NotContains(t, html, `href="javascript:alert(1)"`)
33+ assert.Contains(t, html, `href="#"`)
34+ assert.Contains(t, html, `href="https://example.com"`)
35+}
A internal/ui/systemviews/prefers_dark_js.go +20 -0
@@ -0,0 +1,20 @@
1+//go:build js && wasm
2+
3+package systemviews
4+
5+import "syscall/js"
6+
7+var currentPrefersDarkImpl = func() bool {
8+ window := js.Global()
9+ if !window.Truthy() {
10+ return false
11+ }
12+
13+ matchMedia := window.Get("matchMedia")
14+ if !matchMedia.Truthy() {
15+ return false
16+ }
17+
18+ media := matchMedia.Invoke("(prefers-color-scheme: dark)")
19+ return media.Truthy() && media.Get("matches").Bool()
20+}
@@ -0,0 +1,20 @@
1+//go:build js && wasm
2+
3+package systemviews
4+
5+import "syscall/js"
6+
7+var currentPrefersDarkImpl = func() bool {
8+ window := js.Global()
9+ if !window.Truthy() {
10+ return false
11+ }
12+
13+ matchMedia := window.Get("matchMedia")
14+ if !matchMedia.Truthy() {
15+ return false
16+ }
17+
18+ media := matchMedia.Invoke("(prefers-color-scheme: dark)")
19+ return media.Truthy() && media.Get("matches").Bool()
20+}
A internal/ui/systemviews/prefers_dark_stub.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !js || !wasm
2+
3+package systemviews
4+
5+var currentPrefersDarkImpl = func() bool { return false }
@@ -0,0 +1,5 @@
1+//go:build !js || !wasm
2+
3+package systemviews
4+
5+var currentPrefersDarkImpl = func() bool { return false }
A internal/ui/systemviews/shell.go +111 -0
@@ -0,0 +1,111 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "html"
6+ "strings"
7+)
8+
9+type kvPair struct {
10+ Label string
11+ Value string
12+}
13+
14+// renderedPage holds the data needed by renderAppFrame to produce the shell
15+// fragment. Route views populate this; the app frame wraps it.
16+type renderedPage struct {
17+ route Route
18+ subtitle string
19+ body string
20+}
21+
22+// renderAppFrame produces a self-contained HTML fragment (no <html>, <head>,
23+// or <body>) that wraps route content in the shared shell.
24+func renderAppFrame(page renderedPage, theme shellTheme) string {
25+ rootClass := "sv-app"
26+ themeClass := theme.RootClass
27+ if themeClass == "" {
28+ themeClass = "sv-dark"
29+ }
30+ rootClass += " " + themeClass
31+
32+ var styleAttr string
33+ if theme.InlineVars != "" {
34+ styleAttr = fmt.Sprintf(` style=%q`, html.EscapeString(theme.InlineVars))
35+ }
36+
37+ routeAttr := html.EscapeString(string(page.route))
38+
39+ var subtitle string
40+ if page.subtitle != "" {
41+ subtitle = fmt.Sprintf(`<h1 class="sv-title">%s</h1>`, html.EscapeString(page.subtitle))
42+ }
43+
44+ return fmt.Sprintf(`<div class=%q data-route=%q%s><div class=%q>%s<div class=%q>%s</div></div></div>`,
45+ html.EscapeString(rootClass),
46+ routeAttr,
47+ styleAttr,
48+ "sv-shell",
49+ subtitle,
50+ "sv-content",
51+ page.body,
52+ )
53+}
54+
55+func listHTML(rows, emptyMessage string) string {
56+ if strings.TrimSpace(rows) == "" {
57+ return emptyStateHTML(emptyMessage)
58+ }
59+
60+ return fmt.Sprintf(`<ul class="sv-list">%s</ul>`, rows)
61+}
62+
63+func listRowHTML(inner string) string {
64+ return fmt.Sprintf(`<li class="sv-list-row">%s</li>`, inner)
65+}
66+
67+func linkHTML(href, label string) string {
68+ return fmt.Sprintf(`<a class=%q href=%q>%s</a>`, "sv-link", html.EscapeString(sanitizeHref(href)), html.EscapeString(label))
69+}
70+
71+func metaHTML(text string) string {
72+ return fmt.Sprintf(`<p class="sv-meta">%s</p>`, html.EscapeString(text))
73+}
74+
75+func kvHTML(rows []kvPair) string {
76+ var items strings.Builder
77+ for _, row := range rows {
78+ if strings.TrimSpace(row.Label) == "" && strings.TrimSpace(row.Value) == "" {
79+ continue
80+ }
81+ items.WriteString(kvRowHTML(row.Label, row.Value))
82+ }
83+ if items.Len() == 0 {
84+ return emptyStateHTML("No configuration details")
85+ }
86+
87+ return fmt.Sprintf(`<dl class="sv-kv">%s</dl>`, items.String())
88+}
89+
90+func kvRowHTML(label, value string) string {
91+ return fmt.Sprintf(`<div class=%q><dt>%s</dt><dd>%s</dd></div>`, "sv-kv-row", html.EscapeString(label), html.EscapeString(value))
92+}
93+
94+// sectionHTML wraps content in a styled section with a heading.
95+func sectionHTML(class, title, inner string) string {
96+ classes := "sv-section"
97+ if strings.TrimSpace(class) != "" {
98+ classes += " " + strings.TrimSpace(class)
99+ }
100+
101+ return fmt.Sprintf(`<section class=%q><h2>%s</h2>%s</section>`,
102+ html.EscapeString(classes),
103+ html.EscapeString(title),
104+ inner,
105+ )
106+}
107+
108+// emptyStateHTML returns a standard empty-state placeholder.
109+func emptyStateHTML(message string) string {
110+ return fmt.Sprintf(`<div class="sv-empty"><p>%s</p></div>`, html.EscapeString(message))
111+}
@@ -0,0 +1,111 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "html"
6+ "strings"
7+)
8+
9+type kvPair struct {
10+ Label string
11+ Value string
12+}
13+
14+// renderedPage holds the data needed by renderAppFrame to produce the shell
15+// fragment. Route views populate this; the app frame wraps it.
16+type renderedPage struct {
17+ route Route
18+ subtitle string
19+ body string
20+}
21+
22+// renderAppFrame produces a self-contained HTML fragment (no <html>, <head>,
23+// or <body>) that wraps route content in the shared shell.
24+func renderAppFrame(page renderedPage, theme shellTheme) string {
25+ rootClass := "sv-app"
26+ themeClass := theme.RootClass
27+ if themeClass == "" {
28+ themeClass = "sv-dark"
29+ }
30+ rootClass += " " + themeClass
31+
32+ var styleAttr string
33+ if theme.InlineVars != "" {
34+ styleAttr = fmt.Sprintf(` style=%q`, html.EscapeString(theme.InlineVars))
35+ }
36+
37+ routeAttr := html.EscapeString(string(page.route))
38+
39+ var subtitle string
40+ if page.subtitle != "" {
41+ subtitle = fmt.Sprintf(`<h1 class="sv-title">%s</h1>`, html.EscapeString(page.subtitle))
42+ }
43+
44+ return fmt.Sprintf(`<div class=%q data-route=%q%s><div class=%q>%s<div class=%q>%s</div></div></div>`,
45+ html.EscapeString(rootClass),
46+ routeAttr,
47+ styleAttr,
48+ "sv-shell",
49+ subtitle,
50+ "sv-content",
51+ page.body,
52+ )
53+}
54+
55+func listHTML(rows, emptyMessage string) string {
56+ if strings.TrimSpace(rows) == "" {
57+ return emptyStateHTML(emptyMessage)
58+ }
59+
60+ return fmt.Sprintf(`<ul class="sv-list">%s</ul>`, rows)
61+}
62+
63+func listRowHTML(inner string) string {
64+ return fmt.Sprintf(`<li class="sv-list-row">%s</li>`, inner)
65+}
66+
67+func linkHTML(href, label string) string {
68+ return fmt.Sprintf(`<a class=%q href=%q>%s</a>`, "sv-link", html.EscapeString(sanitizeHref(href)), html.EscapeString(label))
69+}
70+
71+func metaHTML(text string) string {
72+ return fmt.Sprintf(`<p class="sv-meta">%s</p>`, html.EscapeString(text))
73+}
74+
75+func kvHTML(rows []kvPair) string {
76+ var items strings.Builder
77+ for _, row := range rows {
78+ if strings.TrimSpace(row.Label) == "" && strings.TrimSpace(row.Value) == "" {
79+ continue
80+ }
81+ items.WriteString(kvRowHTML(row.Label, row.Value))
82+ }
83+ if items.Len() == 0 {
84+ return emptyStateHTML("No configuration details")
85+ }
86+
87+ return fmt.Sprintf(`<dl class="sv-kv">%s</dl>`, items.String())
88+}
89+
90+func kvRowHTML(label, value string) string {
91+ return fmt.Sprintf(`<div class=%q><dt>%s</dt><dd>%s</dd></div>`, "sv-kv-row", html.EscapeString(label), html.EscapeString(value))
92+}
93+
94+// sectionHTML wraps content in a styled section with a heading.
95+func sectionHTML(class, title, inner string) string {
96+ classes := "sv-section"
97+ if strings.TrimSpace(class) != "" {
98+ classes += " " + strings.TrimSpace(class)
99+ }
100+
101+ return fmt.Sprintf(`<section class=%q><h2>%s</h2>%s</section>`,
102+ html.EscapeString(classes),
103+ html.EscapeString(title),
104+ inner,
105+ )
106+}
107+
108+// emptyStateHTML returns a standard empty-state placeholder.
109+func emptyStateHTML(message string) string {
110+ return fmt.Sprintf(`<div class="sv-empty"><p>%s</p></div>`, html.EscapeString(message))
111+}
A internal/ui/systemviews/shell_test.go +130 -0
@@ -0,0 +1,130 @@
1+package systemviews
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestRenderAppFrame_ReturnsFragment(t *testing.T) {
10+ t.Parallel()
11+
12+ page := renderedPage{
13+ route: RouteHistory,
14+ subtitle: "History",
15+ body: `<ul><li>hello</li></ul>`,
16+ }
17+ theme := shellTheme{RootClass: "sv-dark"}
18+
19+ out := renderAppFrame(page, theme)
20+
21+ // Must be a fragment — no full document tags.
22+ assert.NotContains(t, out, "<html")
23+ assert.NotContains(t, out, "<head")
24+ assert.NotContains(t, out, "<body")
25+
26+ // Root div carries theme class.
27+ assert.Contains(t, out, `class="sv-app sv-dark"`)
28+
29+ // Route is on the root element.
30+ assert.Contains(t, out, `data-route="history"`)
31+
32+ // Shell wrapper present.
33+ assert.Contains(t, out, `class="sv-shell"`)
34+
35+ // Body content passed through.
36+ assert.Contains(t, out, `<ul><li>hello</li></ul>`)
37+
38+ // Subtitle rendered.
39+ assert.Contains(t, out, "History")
40+}
41+
42+func TestRenderAppFrame_DefaultThemeClass(t *testing.T) {
43+ t.Parallel()
44+
45+ page := renderedPage{
46+ route: RouteFavorites,
47+ subtitle: "Favorites",
48+ body: `<p>content</p>`,
49+ }
50+ // Empty theme — should still get sv-app with sv-dark default.
51+ out := renderAppFrame(page, shellTheme{})
52+
53+ assert.Contains(t, out, `class="sv-app sv-dark"`)
54+ assert.Contains(t, out, `data-route="favorites"`)
55+}
56+
57+func TestRenderAppFrame_InlineVarsApplied(t *testing.T) {
58+ t.Parallel()
59+
60+ page := renderedPage{
61+ route: RouteConfig,
62+ subtitle: "Config",
63+ body: `<p>settings</p>`,
64+ }
65+ theme := shellTheme{
66+ RootClass: "sv-light",
67+ InlineVars: "--sv-background: #fff;",
68+ }
69+
70+ out := renderAppFrame(page, theme)
71+
72+ assert.Contains(t, out, `style="--sv-background: #fff;"`)
73+ assert.Contains(t, out, `class="sv-app sv-light"`)
74+}
75+
76+func TestListHTMLWrapsRows(t *testing.T) {
77+ t.Parallel()
78+
79+ out := listHTML(`<li class="sv-list-row">entry</li>`, "Nothing here")
80+
81+ assert.Contains(t, out, `class="sv-list"`)
82+ assert.Contains(t, out, `class="sv-list-row"`)
83+ assert.Contains(t, out, "entry")
84+}
85+
86+func TestListHTMLEmptyState(t *testing.T) {
87+ t.Parallel()
88+
89+ out := listHTML("", "Nothing here")
90+
91+ assert.Contains(t, out, `class="sv-empty"`)
92+ assert.Contains(t, out, "Nothing here")
93+}
94+
95+func TestLinkHTMLSanitizesHref(t *testing.T) {
96+ t.Parallel()
97+
98+ out := linkHTML("javascript:alert(1)", "Bad")
99+
100+ assert.Contains(t, out, `class="sv-link"`)
101+ assert.Contains(t, out, `href="#"`)
102+ assert.Contains(t, out, "Bad")
103+}
104+
105+func TestKVHTMLRendersPairs(t *testing.T) {
106+ t.Parallel()
107+
108+ out := kvHTML([]kvPair{{Label: "Engine", Value: "webkit"}})
109+
110+ assert.Contains(t, out, `class="sv-kv"`)
111+ assert.Contains(t, out, "Engine")
112+ assert.Contains(t, out, "webkit")
113+}
114+
115+func TestSectionHTML(t *testing.T) {
116+ t.Parallel()
117+
118+ out := sectionHTML("test-section", "Test Title", "<p>inner</p>")
119+ assert.Contains(t, out, `class="sv-section test-section"`)
120+ assert.Contains(t, out, "Test Title")
121+ assert.Contains(t, out, "<p>inner</p>")
122+}
123+
124+func TestEmptyStateHTML(t *testing.T) {
125+ t.Parallel()
126+
127+ out := emptyStateHTML("Nothing here")
128+ assert.Contains(t, out, `class="sv-empty"`)
129+ assert.Contains(t, out, "Nothing here")
130+}
@@ -0,0 +1,130 @@
1+package systemviews
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestRenderAppFrame_ReturnsFragment(t *testing.T) {
10+ t.Parallel()
11+
12+ page := renderedPage{
13+ route: RouteHistory,
14+ subtitle: "History",
15+ body: `<ul><li>hello</li></ul>`,
16+ }
17+ theme := shellTheme{RootClass: "sv-dark"}
18+
19+ out := renderAppFrame(page, theme)
20+
21+ // Must be a fragment — no full document tags.
22+ assert.NotContains(t, out, "<html")
23+ assert.NotContains(t, out, "<head")
24+ assert.NotContains(t, out, "<body")
25+
26+ // Root div carries theme class.
27+ assert.Contains(t, out, `class="sv-app sv-dark"`)
28+
29+ // Route is on the root element.
30+ assert.Contains(t, out, `data-route="history"`)
31+
32+ // Shell wrapper present.
33+ assert.Contains(t, out, `class="sv-shell"`)
34+
35+ // Body content passed through.
36+ assert.Contains(t, out, `<ul><li>hello</li></ul>`)
37+
38+ // Subtitle rendered.
39+ assert.Contains(t, out, "History")
40+}
41+
42+func TestRenderAppFrame_DefaultThemeClass(t *testing.T) {
43+ t.Parallel()
44+
45+ page := renderedPage{
46+ route: RouteFavorites,
47+ subtitle: "Favorites",
48+ body: `<p>content</p>`,
49+ }
50+ // Empty theme — should still get sv-app with sv-dark default.
51+ out := renderAppFrame(page, shellTheme{})
52+
53+ assert.Contains(t, out, `class="sv-app sv-dark"`)
54+ assert.Contains(t, out, `data-route="favorites"`)
55+}
56+
57+func TestRenderAppFrame_InlineVarsApplied(t *testing.T) {
58+ t.Parallel()
59+
60+ page := renderedPage{
61+ route: RouteConfig,
62+ subtitle: "Config",
63+ body: `<p>settings</p>`,
64+ }
65+ theme := shellTheme{
66+ RootClass: "sv-light",
67+ InlineVars: "--sv-background: #fff;",
68+ }
69+
70+ out := renderAppFrame(page, theme)
71+
72+ assert.Contains(t, out, `style="--sv-background: #fff;"`)
73+ assert.Contains(t, out, `class="sv-app sv-light"`)
74+}
75+
76+func TestListHTMLWrapsRows(t *testing.T) {
77+ t.Parallel()
78+
79+ out := listHTML(`<li class="sv-list-row">entry</li>`, "Nothing here")
80+
81+ assert.Contains(t, out, `class="sv-list"`)
82+ assert.Contains(t, out, `class="sv-list-row"`)
83+ assert.Contains(t, out, "entry")
84+}
85+
86+func TestListHTMLEmptyState(t *testing.T) {
87+ t.Parallel()
88+
89+ out := listHTML("", "Nothing here")
90+
91+ assert.Contains(t, out, `class="sv-empty"`)
92+ assert.Contains(t, out, "Nothing here")
93+}
94+
95+func TestLinkHTMLSanitizesHref(t *testing.T) {
96+ t.Parallel()
97+
98+ out := linkHTML("javascript:alert(1)", "Bad")
99+
100+ assert.Contains(t, out, `class="sv-link"`)
101+ assert.Contains(t, out, `href="#"`)
102+ assert.Contains(t, out, "Bad")
103+}
104+
105+func TestKVHTMLRendersPairs(t *testing.T) {
106+ t.Parallel()
107+
108+ out := kvHTML([]kvPair{{Label: "Engine", Value: "webkit"}})
109+
110+ assert.Contains(t, out, `class="sv-kv"`)
111+ assert.Contains(t, out, "Engine")
112+ assert.Contains(t, out, "webkit")
113+}
114+
115+func TestSectionHTML(t *testing.T) {
116+ t.Parallel()
117+
118+ out := sectionHTML("test-section", "Test Title", "<p>inner</p>")
119+ assert.Contains(t, out, `class="sv-section test-section"`)
120+ assert.Contains(t, out, "Test Title")
121+ assert.Contains(t, out, "<p>inner</p>")
122+}
123+
124+func TestEmptyStateHTML(t *testing.T) {
125+ t.Parallel()
126+
127+ out := emptyStateHTML("Nothing here")
128+ assert.Contains(t, out, `class="sv-empty"`)
129+ assert.Contains(t, out, "Nothing here")
130+}
A internal/ui/systemviews/theme.go +89 -0
@@ -0,0 +1,89 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+type shellTheme struct {
11+ RootClass string
12+ InlineVars string
13+}
14+
15+const (
16+ shellDarkClass = "sv-dark"
17+ shellLightClass = "sv-light"
18+)
19+
20+func currentPrefersDark() bool {
21+ return currentPrefersDarkImpl()
22+}
23+
24+func resolveShellTheme(appearance port.WebUIAppearanceConfig) shellTheme {
25+ if isZeroAppearance(appearance) {
26+ return shellTheme{}
27+ }
28+
29+ var palette port.ColorPalette
30+ var rootClass string
31+
32+ switch strings.ToLower(strings.TrimSpace(appearance.ColorScheme)) {
33+ case "prefer-dark":
34+ palette = appearance.DarkPalette
35+ rootClass = shellDarkClass
36+ case "prefer-light":
37+ palette = appearance.LightPalette
38+ rootClass = shellLightClass
39+ default:
40+ if currentPrefersDark() {
41+ palette = appearance.DarkPalette
42+ rootClass = shellDarkClass
43+ } else {
44+ palette = appearance.LightPalette
45+ rootClass = shellLightClass
46+ }
47+ }
48+
49+ return shellTheme{
50+ RootClass: rootClass,
51+ InlineVars: buildInlineVars(appearance, palette),
52+ }
53+}
54+
55+func buildInlineVars(appearance port.WebUIAppearanceConfig, palette port.ColorPalette) string {
56+ parts := make([]string, 0, 11)
57+ appendInlineVar := func(name, value string) {
58+ if strings.TrimSpace(value) == "" {
59+ return
60+ }
61+ parts = append(parts, fmt.Sprintf("%s: %s;", name, value))
62+ }
63+
64+ appendInlineVar("--sv-background", palette.Background)
65+ appendInlineVar("--sv-surface", palette.Surface)
66+ appendInlineVar("--sv-surface-variant", palette.SurfaceVariant)
67+ appendInlineVar("--sv-text", palette.Text)
68+ appendInlineVar("--sv-muted", palette.Muted)
69+ appendInlineVar("--sv-accent", palette.Accent)
70+ appendInlineVar("--sv-border", palette.Border)
71+ appendInlineVar("--sv-font-sans", appearance.SansFont)
72+ appendInlineVar("--sv-font-serif", appearance.SerifFont)
73+ appendInlineVar("--sv-font-mono", appearance.MonospaceFont)
74+ if appearance.DefaultFontSize > 0 {
75+ parts = append(parts, fmt.Sprintf("--sv-font-size: %dpx;", appearance.DefaultFontSize))
76+ }
77+
78+ return strings.Join(parts, " ")
79+}
80+
81+func isZeroAppearance(appearance port.WebUIAppearanceConfig) bool {
82+ return appearance.ColorScheme == "" &&
83+ appearance.SansFont == "" &&
84+ appearance.SerifFont == "" &&
85+ appearance.MonospaceFont == "" &&
86+ appearance.DefaultFontSize == 0 &&
87+ appearance.LightPalette == (port.ColorPalette{}) &&
88+ appearance.DarkPalette == (port.ColorPalette{})
89+}
@@ -0,0 +1,89 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+type shellTheme struct {
11+ RootClass string
12+ InlineVars string
13+}
14+
15+const (
16+ shellDarkClass = "sv-dark"
17+ shellLightClass = "sv-light"
18+)
19+
20+func currentPrefersDark() bool {
21+ return currentPrefersDarkImpl()
22+}
23+
24+func resolveShellTheme(appearance port.WebUIAppearanceConfig) shellTheme {
25+ if isZeroAppearance(appearance) {
26+ return shellTheme{}
27+ }
28+
29+ var palette port.ColorPalette
30+ var rootClass string
31+
32+ switch strings.ToLower(strings.TrimSpace(appearance.ColorScheme)) {
33+ case "prefer-dark":
34+ palette = appearance.DarkPalette
35+ rootClass = shellDarkClass
36+ case "prefer-light":
37+ palette = appearance.LightPalette
38+ rootClass = shellLightClass
39+ default:
40+ if currentPrefersDark() {
41+ palette = appearance.DarkPalette
42+ rootClass = shellDarkClass
43+ } else {
44+ palette = appearance.LightPalette
45+ rootClass = shellLightClass
46+ }
47+ }
48+
49+ return shellTheme{
50+ RootClass: rootClass,
51+ InlineVars: buildInlineVars(appearance, palette),
52+ }
53+}
54+
55+func buildInlineVars(appearance port.WebUIAppearanceConfig, palette port.ColorPalette) string {
56+ parts := make([]string, 0, 11)
57+ appendInlineVar := func(name, value string) {
58+ if strings.TrimSpace(value) == "" {
59+ return
60+ }
61+ parts = append(parts, fmt.Sprintf("%s: %s;", name, value))
62+ }
63+
64+ appendInlineVar("--sv-background", palette.Background)
65+ appendInlineVar("--sv-surface", palette.Surface)
66+ appendInlineVar("--sv-surface-variant", palette.SurfaceVariant)
67+ appendInlineVar("--sv-text", palette.Text)
68+ appendInlineVar("--sv-muted", palette.Muted)
69+ appendInlineVar("--sv-accent", palette.Accent)
70+ appendInlineVar("--sv-border", palette.Border)
71+ appendInlineVar("--sv-font-sans", appearance.SansFont)
72+ appendInlineVar("--sv-font-serif", appearance.SerifFont)
73+ appendInlineVar("--sv-font-mono", appearance.MonospaceFont)
74+ if appearance.DefaultFontSize > 0 {
75+ parts = append(parts, fmt.Sprintf("--sv-font-size: %dpx;", appearance.DefaultFontSize))
76+ }
77+
78+ return strings.Join(parts, " ")
79+}
80+
81+func isZeroAppearance(appearance port.WebUIAppearanceConfig) bool {
82+ return appearance.ColorScheme == "" &&
83+ appearance.SansFont == "" &&
84+ appearance.SerifFont == "" &&
85+ appearance.MonospaceFont == "" &&
86+ appearance.DefaultFontSize == 0 &&
87+ appearance.LightPalette == (port.ColorPalette{}) &&
88+ appearance.DarkPalette == (port.ColorPalette{})
89+}
A internal/ui/systemviews/theme_test.go +70 -0
@@ -0,0 +1,70 @@
1+package systemviews
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestResolveShellTheme(t *testing.T) {
11+ orig := currentPrefersDarkImpl
12+ t.Cleanup(func() {
13+ currentPrefersDarkImpl = orig
14+ })
15+
16+ appearance := port.WebUIAppearanceConfig{
17+ SansFont: "Inter",
18+ SerifFont: "Georgia",
19+ MonospaceFont: "JetBrains Mono",
20+ DefaultFontSize: 16,
21+ ColorScheme: "default",
22+ LightPalette: port.ColorPalette{
23+ Background: "#ffffff",
24+ Surface: "#f8f8f8",
25+ SurfaceVariant: "#eeeeee",
26+ Text: "#111111",
27+ Muted: "#666666",
28+ Accent: "#0055ff",
29+ Border: "#dddddd",
30+ },
31+ DarkPalette: port.ColorPalette{
32+ Background: "#111111",
33+ Surface: "#1a1a1a",
34+ SurfaceVariant: "#2a2a2a",
35+ Text: "#f5f5f5",
36+ Muted: "#a0a0a0",
37+ Accent: "#66aaff",
38+ Border: "#333333",
39+ },
40+ }
41+
42+ tests := []struct {
43+ name string
44+ colorScheme string
45+ prefersDark bool
46+ wantClass string
47+ wantBackground string
48+ wantSurfaceVariant string
49+ }{
50+ {name: "prefer-dark", colorScheme: "prefer-dark", prefersDark: false, wantClass: "sv-dark", wantBackground: "#111111", wantSurfaceVariant: "#2a2a2a"},
51+ {name: "prefer-light", colorScheme: "prefer-light", prefersDark: true, wantClass: "sv-light", wantBackground: "#ffffff", wantSurfaceVariant: "#eeeeee"},
52+ {name: "default prefers dark", colorScheme: "default", prefersDark: true, wantClass: "sv-dark", wantBackground: "#111111", wantSurfaceVariant: "#2a2a2a"},
53+ {name: "default prefers light", colorScheme: "default", prefersDark: false, wantClass: "sv-light", wantBackground: "#ffffff", wantSurfaceVariant: "#eeeeee"},
54+ }
55+
56+ for _, tt := range tests {
57+ t.Run(tt.name, func(t *testing.T) {
58+ currentPrefersDarkImpl = func() bool { return tt.prefersDark }
59+ appearance.ColorScheme = tt.colorScheme
60+
61+ got := resolveShellTheme(appearance)
62+
63+ assert.Equal(t, tt.wantClass, got.RootClass)
64+ assert.Contains(t, got.InlineVars, "--sv-background")
65+ assert.Contains(t, got.InlineVars, "--sv-font-sans")
66+ assert.Contains(t, got.InlineVars, tt.wantBackground)
67+ assert.Contains(t, got.InlineVars, tt.wantSurfaceVariant)
68+ })
69+ }
70+}
@@ -0,0 +1,70 @@
1+package systemviews
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestResolveShellTheme(t *testing.T) {
11+ orig := currentPrefersDarkImpl
12+ t.Cleanup(func() {
13+ currentPrefersDarkImpl = orig
14+ })
15+
16+ appearance := port.WebUIAppearanceConfig{
17+ SansFont: "Inter",
18+ SerifFont: "Georgia",
19+ MonospaceFont: "JetBrains Mono",
20+ DefaultFontSize: 16,
21+ ColorScheme: "default",
22+ LightPalette: port.ColorPalette{
23+ Background: "#ffffff",
24+ Surface: "#f8f8f8",
25+ SurfaceVariant: "#eeeeee",
26+ Text: "#111111",
27+ Muted: "#666666",
28+ Accent: "#0055ff",
29+ Border: "#dddddd",
30+ },
31+ DarkPalette: port.ColorPalette{
32+ Background: "#111111",
33+ Surface: "#1a1a1a",
34+ SurfaceVariant: "#2a2a2a",
35+ Text: "#f5f5f5",
36+ Muted: "#a0a0a0",
37+ Accent: "#66aaff",
38+ Border: "#333333",
39+ },
40+ }
41+
42+ tests := []struct {
43+ name string
44+ colorScheme string
45+ prefersDark bool
46+ wantClass string
47+ wantBackground string
48+ wantSurfaceVariant string
49+ }{
50+ {name: "prefer-dark", colorScheme: "prefer-dark", prefersDark: false, wantClass: "sv-dark", wantBackground: "#111111", wantSurfaceVariant: "#2a2a2a"},
51+ {name: "prefer-light", colorScheme: "prefer-light", prefersDark: true, wantClass: "sv-light", wantBackground: "#ffffff", wantSurfaceVariant: "#eeeeee"},
52+ {name: "default prefers dark", colorScheme: "default", prefersDark: true, wantClass: "sv-dark", wantBackground: "#111111", wantSurfaceVariant: "#2a2a2a"},
53+ {name: "default prefers light", colorScheme: "default", prefersDark: false, wantClass: "sv-light", wantBackground: "#ffffff", wantSurfaceVariant: "#eeeeee"},
54+ }
55+
56+ for _, tt := range tests {
57+ t.Run(tt.name, func(t *testing.T) {
58+ currentPrefersDarkImpl = func() bool { return tt.prefersDark }
59+ appearance.ColorScheme = tt.colorScheme
60+
61+ got := resolveShellTheme(appearance)
62+
63+ assert.Equal(t, tt.wantClass, got.RootClass)
64+ assert.Contains(t, got.InlineVars, "--sv-background")
65+ assert.Contains(t, got.InlineVars, "--sv-font-sans")
66+ assert.Contains(t, got.InlineVars, tt.wantBackground)
67+ assert.Contains(t, got.InlineVars, tt.wantSurfaceVariant)
68+ })
69+ }
70+}

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