detached b53c09b

feat(theme): inline shadow styles and bundle color scheme script

commit
b53c09ba1104785fed43deded554426a46910e58
author
bamen · · 2025-09-17 05:17 UTC
parent
fb65c7d
signature
unsigned
14 files · +3561 -3233
M gui/package-lock.json +3128 -3122 generated file: collapsed
M gui/package.json +3 -4
@@ -4,11 +4,13 @@
44 "private": true,
55 "type": "module",
66 "scripts": {
7- "build": "npm run build:gui && npm run build:homepage",
7+ "build": "npm run build:color-scheme && npm run build:gui && npm run build:homepage",
88 "build:gui": "vite build --config vite.config.gui.ts",
99 "build:homepage": "vite build --config vite.config.homepage.ts",
10+ "build:color-scheme": "vite build --config vite.config.color-scheme.ts",
1011 "dev": "npm run build:gui -- --watch",
1112 "dev:homepage": "npm run build:homepage -- --watch",
13+ "dev:color-scheme": "npm run build:color-scheme -- --watch",
1214 "check": "svelte-check",
1315 "lint": "eslint src --fix",
1416 "generate:types": "node ../scripts/generate-types.js"
@@ -29,8 +31,5 @@
2931 "typescript": "^5.3.0",
3032 "typescript-eslint": "^8.43.0",
3133 "vite": "^5.0.0"
32- },
33- "dependencies": {
34- "@lucide/svelte": "^0.544.0"
3534 }
3635 }
@@ -4,11 +4,13 @@
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "scripts": {6 "scripts": {
7- "build": "npm run build:gui && npm run build:homepage",7+ "build": "npm run build:color-scheme && npm run build:gui && npm run build:homepage",
8 "build:gui": "vite build --config vite.config.gui.ts",8 "build:gui": "vite build --config vite.config.gui.ts",
9 "build:homepage": "vite build --config vite.config.homepage.ts",9 "build:homepage": "vite build --config vite.config.homepage.ts",
10+ "build:color-scheme": "vite build --config vite.config.color-scheme.ts",
10 "dev": "npm run build:gui -- --watch",11 "dev": "npm run build:gui -- --watch",
11 "dev:homepage": "npm run build:homepage -- --watch",12 "dev:homepage": "npm run build:homepage -- --watch",
13+ "dev:color-scheme": "npm run build:color-scheme -- --watch",
12 "check": "svelte-check",14 "check": "svelte-check",
13 "lint": "eslint src --fix",15 "lint": "eslint src --fix",
14 "generate:types": "node ../scripts/generate-types.js"16 "generate:types": "node ../scripts/generate-types.js"
@@ -29,8 +31,5 @@
29 "typescript": "^5.3.0",31 "typescript": "^5.3.0",
30 "typescript-eslint": "^8.43.0",32 "typescript-eslint": "^8.43.0",
31 "vite": "^5.0.0"33 "vite": "^5.0.0"
32- },
33- "dependencies": {
34- "@lucide/svelte": "^0.544.0"
35 }34 }
36 }35 }
M gui/src/components/toast/Toast.svelte +109 -77
@@ -12,12 +12,24 @@
1212 let { id, message, duration = 2500, type = 'info', onDismiss }: ToastProps = $props();
1313
1414 let isExiting = $state(false);
15+ let isDarkMode = $state(false);
16+ let themeObserver: MutationObserver | undefined;
1517 let timeoutId: number | undefined;
1618
1719 onMount(() => {
1820 timeoutId = window.setTimeout(() => {
1921 dismiss();
2022 }, duration);
23+
24+ syncTheme();
25+
26+ if (typeof window !== 'undefined' && 'MutationObserver' in window) {
27+ themeObserver = new MutationObserver(syncTheme);
28+ themeObserver.observe(document.documentElement, {
29+ attributes: true,
30+ attributeFilter: ['class']
31+ });
32+ }
2133 });
2234
2335 onDestroy(() => {
@@ -25,6 +37,11 @@
2537 window.clearTimeout(timeoutId);
2638 timeoutId = undefined;
2739 }
40+
41+ if (themeObserver) {
42+ themeObserver.disconnect();
43+ themeObserver = undefined;
44+ }
2845 });
2946
3047 function dismiss() {
@@ -42,109 +59,124 @@
4259 function handleClick() {
4360 dismiss();
4461 }
62+
63+ function syncTheme() {
64+ if (typeof document === 'undefined') {
65+ return;
66+ }
67+
68+ const prefersDark = document.documentElement.classList.contains('dark');
69+
70+ if (prefersDark) {
71+ isDarkMode = true;
72+ return;
73+ }
74+
75+ try {
76+ isDarkMode = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
77+ } catch {
78+ isDarkMode = false;
79+ }
80+ }
4581 </script>
4682
4783 <button
48- class="toast-base {type === 'success' ? 'toast-success' : type === 'error' ? 'toast-error' : 'toast-info'} {isExiting ? 'exiting' : ''}"
84+ class={`toast ${isDarkMode ? 'toast-dark' : 'toast-light'} toast-${type} ${isExiting ? 'toast-exit' : 'toast-enter'}`}
4985 onclick={handleClick}
5086 data-toast-id={id}
5187 type="button"
5288 aria-label="Dismiss notification"
5389 title="Click to dismiss"
5490 >
55- <div class="toast-content">
91+ <div class="toast-message">
5692 {message || 'Notification'}
5793 </div>
58- <style>
59- .toast-content {
60- width: 100%;
61- min-width: 9rem; /* ~min-w-36 */
62- padding: 1rem 1.25rem; /* px-5 py-4 */
63- text-align: center;
64- color: #fff;
65- font-weight: 600;
66- font-size: 0.875rem; /* text-sm */
67- border-radius: 0.75rem; /* rounded-xl */
68- box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); /* shadow-2xl */
69- backdrop-filter: blur(6px);
70- border: 1px solid rgba(255,255,255,0.18);
71- transition: all 250ms ease;
72- }
73-
74- .toast-success .toast-content {
75- background: linear-gradient(135deg, rgba(34,197,94,0.9), rgba(21,128,61,0.9));
76- border-color: rgba(134,239,172,0.3);
77- }
78- .toast-error .toast-content {
79- background: linear-gradient(135deg, rgba(239,68,68,0.9), rgba(185,28,28,0.9));
80- border-color: rgba(252,165,165,0.3);
81- }
82- .toast-info .toast-content {
83- background: linear-gradient(135deg, rgba(59,130,246,0.9), rgba(29,78,216,0.9));
84- border-color: rgba(147,197,253,0.3);
85- }
86- </style>
8794 </button>
8895
8996 <style>
90- .toast-base {
91- /* Reset button styles */
92- border: none;
93- background: none;
94- padding: 0;
95- font: inherit;
96- text-align: left;
97-
98- /* Toast functionality */
97+ .toast {
98+ background-color: var(--dynamic-surface);
99+ border: 1px solid var(--dynamic-border);
100+ border-radius: 0;
101+ box-shadow: 0 8px 12px rgba(0, 0, 0, 0.08);
99102 cursor: pointer;
100- user-select: none;
101- position: relative;
102- overflow: visible;
103-
104- /* Layout */
105- display: block;
106- visibility: visible;
107- z-index: 2147483647;
103+ display: inline-flex;
104+ margin: 0;
108105 pointer-events: auto;
106+ padding: 0;
107+ transition: transform 0.2s ease, opacity 0.2s ease;
108+ }
109109
110- /* Animations */
111- animation: slideIn 0.3s ease-out forwards;
112- transition: transform 0.2s ease, filter 0.2s ease;
110+ .toast-enter {
111+ opacity: 1;
112+ transform: translateY(0);
113113 }
114114
115- .toast-base.exiting {
116- animation: slideOut 0.25s ease-in forwards;
115+ .toast-exit {
116+ opacity: 0;
117+ transform: translateY(-16px);
117118 }
118119
119- @keyframes slideIn {
120- from {
121- transform: translateX(-100%);
122- opacity: 0;
123- }
124- to {
125- transform: translateX(0);
126- opacity: 1;
127- }
120+ .toast-message {
121+ color: var(--dynamic-text);
122+ font-size: 0.875rem;
123+ font-weight: 600;
124+ min-width: 9rem;
125+ padding: 0.75rem 1rem;
126+ text-align: center;
128127 }
129128
130- @keyframes slideOut {
131- from {
132- transform: translateX(0);
133- opacity: 1;
134- }
135- to {
136- transform: translateX(-100%);
137- opacity: 0;
138- }
129+ .toast-light.toast-info {
130+ background-color: rgb(239 246 255);
131+ border-color: rgb(191 219 254);
132+ }
133+
134+ .toast-dark.toast-info {
135+ background-color: rgb(59 130 246 / 0.2);
136+ border-color: rgb(59 130 246);
139137 }
140138
141- .toast-base:hover {
142- filter: brightness(1.1);
143- transform: scale(1.02);
139+ .toast-light.toast-success {
140+ background-color: rgb(240 253 244);
141+ border-color: rgb(187 247 208);
144142 }
145143
146- .toast-base:active {
147- transform: scale(0.98);
144+ .toast-dark.toast-success {
145+ background-color: rgb(22 163 74 / 0.2);
146+ border-color: rgb(22 163 74);
148147 }
149148
150-</style>⏎̸
149+ .toast-light.toast-error {
150+ background-color: rgb(254 242 242);
151+ border-color: rgb(254 202 202);
152+ }
153+
154+ .toast-dark.toast-error {
155+ background-color: rgb(220 38 38 / 0.2);
156+ border-color: rgb(220 38 38);
157+ }
158+
159+ .toast-light.toast-info .toast-message {
160+ color: rgb(59 130 246);
161+ }
162+
163+ .toast-dark.toast-info .toast-message {
164+ color: rgb(191 219 254);
165+ }
166+
167+ .toast-light.toast-success .toast-message {
168+ color: rgb(22 163 74);
169+ }
170+
171+ .toast-dark.toast-success .toast-message {
172+ color: rgb(187 247 208);
173+ }
174+
175+ .toast-light.toast-error .toast-message {
176+ color: rgb(220 38 38);
177+ }
178+
179+ .toast-dark.toast-error .toast-message {
180+ color: rgb(254 202 202);
181+ }
182+</style>
@@ -12,12 +12,24 @@
12 let { id, message, duration = 2500, type = 'info', onDismiss }: ToastProps = $props();12 let { id, message, duration = 2500, type = 'info', onDismiss }: ToastProps = $props();
13 13
14 let isExiting = $state(false);14 let isExiting = $state(false);
15+ let isDarkMode = $state(false);
16+ let themeObserver: MutationObserver | undefined;
15 let timeoutId: number | undefined;17 let timeoutId: number | undefined;
16 18
17 onMount(() => {19 onMount(() => {
18 timeoutId = window.setTimeout(() => {20 timeoutId = window.setTimeout(() => {
19 dismiss();21 dismiss();
20 }, duration);22 }, duration);
23+
24+ syncTheme();
25+
26+ if (typeof window !== 'undefined' && 'MutationObserver' in window) {
27+ themeObserver = new MutationObserver(syncTheme);
28+ themeObserver.observe(document.documentElement, {
29+ attributes: true,
30+ attributeFilter: ['class']
31+ });
32+ }
21 });33 });
22 34
23 onDestroy(() => {35 onDestroy(() => {
@@ -25,6 +37,11 @@
25 window.clearTimeout(timeoutId);37 window.clearTimeout(timeoutId);
26 timeoutId = undefined;38 timeoutId = undefined;
27 }39 }
40+
41+ if (themeObserver) {
42+ themeObserver.disconnect();
43+ themeObserver = undefined;
44+ }
28 });45 });
29 46
30 function dismiss() {47 function dismiss() {
@@ -42,109 +59,124 @@
42 function handleClick() {59 function handleClick() {
43 dismiss();60 dismiss();
44 }61 }
62+
63+ function syncTheme() {
64+ if (typeof document === 'undefined') {
65+ return;
66+ }
67+
68+ const prefersDark = document.documentElement.classList.contains('dark');
69+
70+ if (prefersDark) {
71+ isDarkMode = true;
72+ return;
73+ }
74+
75+ try {
76+ isDarkMode = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
77+ } catch {
78+ isDarkMode = false;
79+ }
80+ }
45 </script>81 </script>
46 82
47 <button83 <button
48- class="toast-base {type === 'success' ? 'toast-success' : type === 'error' ? 'toast-error' : 'toast-info'} {isExiting ? 'exiting' : ''}"84+ class={`toast ${isDarkMode ? 'toast-dark' : 'toast-light'} toast-${type} ${isExiting ? 'toast-exit' : 'toast-enter'}`}
49 onclick={handleClick}85 onclick={handleClick}
50 data-toast-id={id}86 data-toast-id={id}
51 type="button"87 type="button"
52 aria-label="Dismiss notification"88 aria-label="Dismiss notification"
53 title="Click to dismiss"89 title="Click to dismiss"
54 >90 >
55- <div class="toast-content">91+ <div class="toast-message">
56 {message || 'Notification'}92 {message || 'Notification'}
57 </div>93 </div>
58- <style>
59- .toast-content {
60- width: 100%;
61- min-width: 9rem; /* ~min-w-36 */
62- padding: 1rem 1.25rem; /* px-5 py-4 */
63- text-align: center;
64- color: #fff;
65- font-weight: 600;
66- font-size: 0.875rem; /* text-sm */
67- border-radius: 0.75rem; /* rounded-xl */
68- box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); /* shadow-2xl */
69- backdrop-filter: blur(6px);
70- border: 1px solid rgba(255,255,255,0.18);
71- transition: all 250ms ease;
72- }
73-
74- .toast-success .toast-content {
75- background: linear-gradient(135deg, rgba(34,197,94,0.9), rgba(21,128,61,0.9));
76- border-color: rgba(134,239,172,0.3);
77- }
78- .toast-error .toast-content {
79- background: linear-gradient(135deg, rgba(239,68,68,0.9), rgba(185,28,28,0.9));
80- border-color: rgba(252,165,165,0.3);
81- }
82- .toast-info .toast-content {
83- background: linear-gradient(135deg, rgba(59,130,246,0.9), rgba(29,78,216,0.9));
84- border-color: rgba(147,197,253,0.3);
85- }
86- </style>
87 </button>94 </button>
88 95
89 <style>96 <style>
90- .toast-base {97+ .toast {
91- /* Reset button styles */98+ background-color: var(--dynamic-surface);
92- border: none;99+ border: 1px solid var(--dynamic-border);
93- background: none;100+ border-radius: 0;
94- padding: 0;101+ box-shadow: 0 8px 12px rgba(0, 0, 0, 0.08);
95- font: inherit;
96- text-align: left;
97-
98- /* Toast functionality */
99 cursor: pointer;102 cursor: pointer;
100- user-select: none;103+ display: inline-flex;
101- position: relative;104+ margin: 0;
102- overflow: visible;
103-
104- /* Layout */
105- display: block;
106- visibility: visible;
107- z-index: 2147483647;
108 pointer-events: auto;105 pointer-events: auto;
106+ padding: 0;
107+ transition: transform 0.2s ease, opacity 0.2s ease;
108+ }
109 109
110- /* Animations */110+ .toast-enter {
111- animation: slideIn 0.3s ease-out forwards;111+ opacity: 1;
112- transition: transform 0.2s ease, filter 0.2s ease;112+ transform: translateY(0);
113 }113 }
114 114
115- .toast-base.exiting {115+ .toast-exit {
116- animation: slideOut 0.25s ease-in forwards;116+ opacity: 0;
117+ transform: translateY(-16px);
117 }118 }
118 119
119- @keyframes slideIn {120+ .toast-message {
120- from {121+ color: var(--dynamic-text);
121- transform: translateX(-100%);122+ font-size: 0.875rem;
122- opacity: 0;123+ font-weight: 600;
123- }124+ min-width: 9rem;
124- to {125+ padding: 0.75rem 1rem;
125- transform: translateX(0);126+ text-align: center;
126- opacity: 1;
127- }
128 }127 }
129 128
130- @keyframes slideOut {129+ .toast-light.toast-info {
131- from {130+ background-color: rgb(239 246 255);
132- transform: translateX(0);131+ border-color: rgb(191 219 254);
133- opacity: 1;132+ }
134- }133+
135- to {134+ .toast-dark.toast-info {
136- transform: translateX(-100%);135+ background-color: rgb(59 130 246 / 0.2);
137- opacity: 0;136+ border-color: rgb(59 130 246);
138- }
139 }137 }
140 138
141- .toast-base:hover {139+ .toast-light.toast-success {
142- filter: brightness(1.1);140+ background-color: rgb(240 253 244);
143- transform: scale(1.02);141+ border-color: rgb(187 247 208);
144 }142 }
145 143
146- .toast-base:active {144+ .toast-dark.toast-success {
147- transform: scale(0.98);145+ background-color: rgb(22 163 74 / 0.2);
146+ border-color: rgb(22 163 74);
148 }147 }
149 148
150-</style>⏎̸149+ .toast-light.toast-error {
150+ background-color: rgb(254 242 242);
151+ border-color: rgb(254 202 202);
152+ }
153+
154+ .toast-dark.toast-error {
155+ background-color: rgb(220 38 38 / 0.2);
156+ border-color: rgb(220 38 38);
157+ }
158+
159+ .toast-light.toast-info .toast-message {
160+ color: rgb(59 130 246);
161+ }
162+
163+ .toast-dark.toast-info .toast-message {
164+ color: rgb(191 219 254);
165+ }
166+
167+ .toast-light.toast-success .toast-message {
168+ color: rgb(22 163 74);
169+ }
170+
171+ .toast-dark.toast-success .toast-message {
172+ color: rgb(187 247 208);
173+ }
174+
175+ .toast-light.toast-error .toast-message {
176+ color: rgb(220 38 38);
177+ }
178+
179+ .toast-dark.toast-error .toast-message {
180+ color: rgb(254 202 202);
181+ }
182+</style>
M gui/src/components/toast/ToastContainer.svelte +13 -19
@@ -124,22 +124,7 @@
124124 });
125125 </script>
126126
127-<div
128- class="toast-container"
129- style="
130- position: fixed;
131- top: 20px;
132- left: 20px;
133- z-index: 2147483647;
134- pointer-events: none;
135- display: flex;
136- flex-direction: column;
137- gap: 8px;
138- max-width: 320px;
139- opacity: 1;
140- backdrop-filter: blur(2px);
141- "
142->
127+<div class="browser-component-root toast-container">
143128 {#each toasts as toast (toast.id)}
144129 <Toast
145130 id={toast.id}
@@ -152,6 +137,15 @@
152137 </div>
153138
154139 <style>
155- .toast-container { contain: layout style; }
156- /* The Toast button handles its own pointer events */
157-</style>⏎̸
140+ .toast-container {
141+ display: flex;
142+ flex-direction: column;
143+ gap: 0.5rem;
144+ left: 0.5rem;
145+ max-width: 20rem;
146+ pointer-events: none;
147+ position: fixed;
148+ top: 0.5rem;
149+ z-index: 2147483647;
150+ }
151+</style>
@@ -124,22 +124,7 @@
124 });124 });
125 </script>125 </script>
126 126
127-<div127+<div class="browser-component-root toast-container">
128- class="toast-container"
129- style="
130- position: fixed;
131- top: 20px;
132- left: 20px;
133- z-index: 2147483647;
134- pointer-events: none;
135- display: flex;
136- flex-direction: column;
137- gap: 8px;
138- max-width: 320px;
139- opacity: 1;
140- backdrop-filter: blur(2px);
141- "
142->
143 {#each toasts as toast (toast.id)}128 {#each toasts as toast (toast.id)}
144 <Toast129 <Toast
145 id={toast.id}130 id={toast.id}
@@ -152,6 +137,15 @@
152 </div>137 </div>
153 138
154 <style>139 <style>
155- .toast-container { contain: layout style; }140+ .toast-container {
156- /* The Toast button handles its own pointer events */141+ display: flex;
157-</style>⏎̸142+ flex-direction: column;
143+ gap: 0.5rem;
144+ left: 0.5rem;
145+ max-width: 20rem;
146+ pointer-events: none;
147+ position: fixed;
148+ top: 0.5rem;
149+ z-index: 2147483647;
150+ }
151+</style>
A gui/src/injected/modules/color-scheme.ts +161 -0
@@ -0,0 +1,161 @@
1+/**
2+ * Color Scheme Detection Module
3+ *
4+ * Handles GTK theme detection and applies appropriate color scheme preferences
5+ * to web pages. This module is injected at document-start to ensure proper
6+ * theme initialization before page content loads.
7+ */
8+
9+declare global {
10+ interface Window {
11+ __dumber_gtk_prefers_dark?: boolean;
12+ __dumber_setTheme?: (theme: string) => void;
13+ }
14+}
15+
16+interface ColorSchemeConfig {
17+ isDark: boolean;
18+ colorScheme: string;
19+ metaContent: string;
20+ rootStyle: string;
21+}
22+
23+class ColorSchemeManager {
24+ private config: ColorSchemeConfig;
25+
26+ constructor() {
27+ // Use GTK preference if available, otherwise detect from system
28+ const prefersDark = window.__dumber_gtk_prefers_dark ?? false;
29+
30+ this.config = {
31+ isDark: prefersDark,
32+ colorScheme: prefersDark ? 'dark' : 'light',
33+ metaContent: prefersDark ? 'dark light' : 'light dark',
34+ rootStyle: prefersDark ? ':root{color-scheme:dark;}' : ':root{color-scheme:light;}'
35+ };
36+
37+ this.initialize();
38+ }
39+
40+ private initialize(): void {
41+ try {
42+ console.log(`[dumber] color-scheme set: ${this.config.colorScheme}`);
43+ console.log(`[dumber] GTK detected color mode: ${this.config.colorScheme}`);
44+
45+ // Notify native layer of theme preference
46+ this.notifyNativeLayer();
47+
48+ // Inject color-scheme meta tag
49+ this.injectColorSchemeMeta();
50+
51+ // Apply root styles
52+ this.applyRootStyles();
53+
54+ // Override matchMedia for proper prefers-color-scheme support
55+ this.overrideMatchMedia();
56+
57+ // Set up theme integration with existing UI components
58+ this.setupThemeIntegration();
59+
60+ } catch (error) {
61+ console.warn('[dumber] color-scheme injection failed', error);
62+ }
63+ }
64+
65+ private notifyNativeLayer(): void {
66+ try {
67+ (window as any).webkit?.messageHandlers?.dumber?.postMessage(
68+ JSON.stringify({
69+ type: 'theme',
70+ value: this.config.colorScheme
71+ })
72+ );
73+ } catch (error) {
74+ // Ignore errors - native layer may not be available
75+ }
76+ }
77+
78+ private injectColorSchemeMeta(): void {
79+ const meta = document.createElement('meta');
80+ meta.name = 'color-scheme';
81+ meta.content = this.config.metaContent;
82+ document.documentElement.appendChild(meta);
83+ }
84+
85+ private applyRootStyles(): void {
86+ const style = document.createElement('style');
87+ style.textContent = this.config.rootStyle;
88+ document.documentElement.appendChild(style);
89+ }
90+
91+ private overrideMatchMedia(): void {
92+ const darkQuery = '(prefers-color-scheme: dark)';
93+ const lightQuery = '(prefers-color-scheme: light)';
94+ const originalMatchMedia = window.matchMedia;
95+
96+ window.matchMedia = (query: string): MediaQueryList => {
97+ if (typeof query === 'string' && (query.includes(darkQuery) || query.includes(lightQuery))) {
98+ // Create a mock MediaQueryList that reflects our GTK preference
99+ const matches = query.includes('dark') ? this.config.isDark : !this.config.isDark;
100+
101+ return {
102+ matches,
103+ media: query,
104+ onchange: null,
105+ addListener: () => {},
106+ removeListener: () => {},
107+ addEventListener: () => {},
108+ removeEventListener: () => {},
109+ dispatchEvent: () => false
110+ } as MediaQueryList;
111+ }
112+
113+ // For non-color-scheme queries, use the original implementation
114+ return originalMatchMedia.call(window, query);
115+ };
116+ }
117+
118+ private setupThemeIntegration(): void {
119+ // Integrate with unified theme setter if available
120+ if (window.__dumber_setTheme) {
121+ window.__dumber_setTheme(this.config.colorScheme);
122+ } else {
123+ // Retry after a short delay in case the bridge hasn't loaded yet
124+ setTimeout(() => {
125+ if (window.__dumber_setTheme) {
126+ window.__dumber_setTheme(this.config.colorScheme);
127+ } else {
128+ // Fallback: directly apply dark class for Tailwind compatibility
129+ console.warn('[dumber] Theme setter not available, using fallback');
130+ if (this.config.isDark) {
131+ document.documentElement.classList.add('dark');
132+ } else {
133+ document.documentElement.classList.remove('dark');
134+ }
135+ }
136+ }, 100);
137+ }
138+ }
139+
140+ /**
141+ * Updates the color scheme preference at runtime
142+ * @param isDark Whether to use dark mode
143+ */
144+ public updateColorScheme(isDark: boolean): void {
145+ this.config.isDark = isDark;
146+ this.config.colorScheme = isDark ? 'dark' : 'light';
147+ this.config.metaContent = isDark ? 'dark light' : 'light dark';
148+ this.config.rootStyle = isDark ? ':root{color-scheme:dark;}' : ':root{color-scheme:light;}';
149+
150+ // Re-apply all configurations
151+ this.initialize();
152+ }
153+}
154+
155+// Initialize color scheme management
156+const colorSchemeManager = new ColorSchemeManager();
157+
158+// Export for potential runtime updates
159+(window as any).__dumber_color_scheme_manager = colorSchemeManager;
160+
161+export default colorSchemeManager;⏎̸
@@ -0,0 +1,161 @@
1+/**
2+ * Color Scheme Detection Module
3+ *
4+ * Handles GTK theme detection and applies appropriate color scheme preferences
5+ * to web pages. This module is injected at document-start to ensure proper
6+ * theme initialization before page content loads.
7+ */
8+
9+declare global {
10+ interface Window {
11+ __dumber_gtk_prefers_dark?: boolean;
12+ __dumber_setTheme?: (theme: string) => void;
13+ }
14+}
15+
16+interface ColorSchemeConfig {
17+ isDark: boolean;
18+ colorScheme: string;
19+ metaContent: string;
20+ rootStyle: string;
21+}
22+
23+class ColorSchemeManager {
24+ private config: ColorSchemeConfig;
25+
26+ constructor() {
27+ // Use GTK preference if available, otherwise detect from system
28+ const prefersDark = window.__dumber_gtk_prefers_dark ?? false;
29+
30+ this.config = {
31+ isDark: prefersDark,
32+ colorScheme: prefersDark ? 'dark' : 'light',
33+ metaContent: prefersDark ? 'dark light' : 'light dark',
34+ rootStyle: prefersDark ? ':root{color-scheme:dark;}' : ':root{color-scheme:light;}'
35+ };
36+
37+ this.initialize();
38+ }
39+
40+ private initialize(): void {
41+ try {
42+ console.log(`[dumber] color-scheme set: ${this.config.colorScheme}`);
43+ console.log(`[dumber] GTK detected color mode: ${this.config.colorScheme}`);
44+
45+ // Notify native layer of theme preference
46+ this.notifyNativeLayer();
47+
48+ // Inject color-scheme meta tag
49+ this.injectColorSchemeMeta();
50+
51+ // Apply root styles
52+ this.applyRootStyles();
53+
54+ // Override matchMedia for proper prefers-color-scheme support
55+ this.overrideMatchMedia();
56+
57+ // Set up theme integration with existing UI components
58+ this.setupThemeIntegration();
59+
60+ } catch (error) {
61+ console.warn('[dumber] color-scheme injection failed', error);
62+ }
63+ }
64+
65+ private notifyNativeLayer(): void {
66+ try {
67+ (window as any).webkit?.messageHandlers?.dumber?.postMessage(
68+ JSON.stringify({
69+ type: 'theme',
70+ value: this.config.colorScheme
71+ })
72+ );
73+ } catch (error) {
74+ // Ignore errors - native layer may not be available
75+ }
76+ }
77+
78+ private injectColorSchemeMeta(): void {
79+ const meta = document.createElement('meta');
80+ meta.name = 'color-scheme';
81+ meta.content = this.config.metaContent;
82+ document.documentElement.appendChild(meta);
83+ }
84+
85+ private applyRootStyles(): void {
86+ const style = document.createElement('style');
87+ style.textContent = this.config.rootStyle;
88+ document.documentElement.appendChild(style);
89+ }
90+
91+ private overrideMatchMedia(): void {
92+ const darkQuery = '(prefers-color-scheme: dark)';
93+ const lightQuery = '(prefers-color-scheme: light)';
94+ const originalMatchMedia = window.matchMedia;
95+
96+ window.matchMedia = (query: string): MediaQueryList => {
97+ if (typeof query === 'string' && (query.includes(darkQuery) || query.includes(lightQuery))) {
98+ // Create a mock MediaQueryList that reflects our GTK preference
99+ const matches = query.includes('dark') ? this.config.isDark : !this.config.isDark;
100+
101+ return {
102+ matches,
103+ media: query,
104+ onchange: null,
105+ addListener: () => {},
106+ removeListener: () => {},
107+ addEventListener: () => {},
108+ removeEventListener: () => {},
109+ dispatchEvent: () => false
110+ } as MediaQueryList;
111+ }
112+
113+ // For non-color-scheme queries, use the original implementation
114+ return originalMatchMedia.call(window, query);
115+ };
116+ }
117+
118+ private setupThemeIntegration(): void {
119+ // Integrate with unified theme setter if available
120+ if (window.__dumber_setTheme) {
121+ window.__dumber_setTheme(this.config.colorScheme);
122+ } else {
123+ // Retry after a short delay in case the bridge hasn't loaded yet
124+ setTimeout(() => {
125+ if (window.__dumber_setTheme) {
126+ window.__dumber_setTheme(this.config.colorScheme);
127+ } else {
128+ // Fallback: directly apply dark class for Tailwind compatibility
129+ console.warn('[dumber] Theme setter not available, using fallback');
130+ if (this.config.isDark) {
131+ document.documentElement.classList.add('dark');
132+ } else {
133+ document.documentElement.classList.remove('dark');
134+ }
135+ }
136+ }, 100);
137+ }
138+ }
139+
140+ /**
141+ * Updates the color scheme preference at runtime
142+ * @param isDark Whether to use dark mode
143+ */
144+ public updateColorScheme(isDark: boolean): void {
145+ this.config.isDark = isDark;
146+ this.config.colorScheme = isDark ? 'dark' : 'light';
147+ this.config.metaContent = isDark ? 'dark light' : 'light dark';
148+ this.config.rootStyle = isDark ? ':root{color-scheme:dark;}' : ':root{color-scheme:light;}';
149+
150+ // Re-apply all configurations
151+ this.initialize();
152+ }
153+}
154+
155+// Initialize color scheme management
156+const colorSchemeManager = new ColorSchemeManager();
157+
158+// Export for potential runtime updates
159+(window as any).__dumber_color_scheme_manager = colorSchemeManager;
160+
161+export default colorSchemeManager;⏎̸
M gui/src/injected/modules/shadowHost.ts +64 -0
@@ -5,6 +5,12 @@
55 * (omnibox, toast, etc.), with a minimal CSS reset applied once.
66 */
77
8+// Import the compiled Tailwind bundle as a string so we can inject it
9+// directly into the shadow root. The ?inline suffix makes Vite embed the
10+// processed CSS, ensuring all utility classes are available without relying
11+// on runtime @import resolution (which fails inside the injected shadow DOM).
12+import globalStyles from '../../styles/tailwind.css?inline';
13+
814 // Track shadow-root initialization to avoid duplicate reset injection
915 const shadowResetApplied = new WeakSet<ShadowRoot>();
1016
@@ -39,6 +45,17 @@ export function getGlobalShadowRoot(): ShadowRoot {
3945
4046 const shadowRoot = host.shadowRoot ?? host.attachShadow({ mode: 'open' });
4147
48+ // Reflect current theme from document root to the shadow host so :host(.dark) works
49+ try {
50+ const isDark = document.documentElement.classList.contains('dark');
51+ host.classList.toggle('dark', isDark);
52+ // Keep it in sync if the theme toggles later
53+ const observer = new MutationObserver(() => {
54+ host.classList.toggle('dark', document.documentElement.classList.contains('dark'));
55+ });
56+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
57+ } catch { /* no-op */ }
58+
4259 // Inject a minimal reset and base styles into the shadow root once
4360 if (!shadowResetApplied.has(shadowRoot)) {
4461 const resetStyle = document.createElement('style');
@@ -48,6 +65,53 @@ export function getGlobalShadowRoot(): ShadowRoot {
4865 :host { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', Arial, sans-serif; }
4966 `;
5067 shadowRoot.appendChild(resetStyle);
68+
69+ // Ensure dynamic design tokens exist within the shadow tree. Tailwind v4
70+ // utilities rely on these CSS variables. We set light defaults and a dark variant on :host.
71+ const tokensStyle = document.createElement('style');
72+ tokensStyle.textContent = `
73+ :host {
74+ --dynamic-bg: var(--color-browser-bg);
75+ --dynamic-surface: var(--color-browser-surface);
76+ --dynamic-text: var(--color-browser-text);
77+ --dynamic-muted: var(--color-browser-muted);
78+ --dynamic-accent: var(--color-browser-accent);
79+ --dynamic-border: var(--color-browser-border);
80+ }
81+ :host(.dark) {
82+ --dynamic-bg: rgb(17 24 39);
83+ --dynamic-surface: rgb(31 41 55);
84+ --dynamic-text: rgb(243 244 246);
85+ --dynamic-muted: rgb(156 163 175);
86+ --dynamic-accent: rgb(96 165 250);
87+ --dynamic-border: rgb(55 65 81);
88+ }
89+ `;
90+ shadowRoot.appendChild(tokensStyle);
91+
92+ // Inject the global GUI stylesheet so components rendered inside the shadow
93+ // root (e.g., toasts, omnibox) receive their styles
94+ try {
95+ // Prefer constructable stylesheets when available
96+ const hasConstructable = typeof CSSStyleSheet !== 'undefined';
97+ const supportsAdopted = 'adoptedStyleSheets' in shadowRoot;
98+ if (supportsAdopted && hasConstructable) {
99+ const sheet = new CSSStyleSheet();
100+ sheet.replaceSync(globalStyles);
101+ const rootWithSheets = shadowRoot as ShadowRoot & { adoptedStyleSheets: CSSStyleSheet[] };
102+ rootWithSheets.adoptedStyleSheets = [...(rootWithSheets.adoptedStyleSheets ?? []), sheet];
103+ } else {
104+ // Fallback: append a <style> element with the global CSS
105+ const styleTag = document.createElement('style');
106+ styleTag.textContent = globalStyles;
107+ shadowRoot.appendChild(styleTag);
108+ }
109+ } catch {
110+ // Final fallback if anything above fails
111+ const styleTag = document.createElement('style');
112+ styleTag.textContent = globalStyles;
113+ shadowRoot.appendChild(styleTag);
114+ }
51115 shadowResetApplied.add(shadowRoot);
52116 }
53117
@@ -5,6 +5,12 @@
5 * (omnibox, toast, etc.), with a minimal CSS reset applied once.5 * (omnibox, toast, etc.), with a minimal CSS reset applied once.
6 */6 */
7 7
8+// Import the compiled Tailwind bundle as a string so we can inject it
9+// directly into the shadow root. The ?inline suffix makes Vite embed the
10+// processed CSS, ensuring all utility classes are available without relying
11+// on runtime @import resolution (which fails inside the injected shadow DOM).
12+import globalStyles from '../../styles/tailwind.css?inline';
13+
8 // Track shadow-root initialization to avoid duplicate reset injection14 // Track shadow-root initialization to avoid duplicate reset injection
9 const shadowResetApplied = new WeakSet<ShadowRoot>();15 const shadowResetApplied = new WeakSet<ShadowRoot>();
10 16
@@ -39,6 +45,17 @@ export function getGlobalShadowRoot(): ShadowRoot {
39 45
40 const shadowRoot = host.shadowRoot ?? host.attachShadow({ mode: 'open' });46 const shadowRoot = host.shadowRoot ?? host.attachShadow({ mode: 'open' });
41 47
48+ // Reflect current theme from document root to the shadow host so :host(.dark) works
49+ try {
50+ const isDark = document.documentElement.classList.contains('dark');
51+ host.classList.toggle('dark', isDark);
52+ // Keep it in sync if the theme toggles later
53+ const observer = new MutationObserver(() => {
54+ host.classList.toggle('dark', document.documentElement.classList.contains('dark'));
55+ });
56+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
57+ } catch { /* no-op */ }
58+
42 // Inject a minimal reset and base styles into the shadow root once59 // Inject a minimal reset and base styles into the shadow root once
43 if (!shadowResetApplied.has(shadowRoot)) {60 if (!shadowResetApplied.has(shadowRoot)) {
44 const resetStyle = document.createElement('style');61 const resetStyle = document.createElement('style');
@@ -48,6 +65,53 @@ export function getGlobalShadowRoot(): ShadowRoot {
48 :host { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', Arial, sans-serif; }65 :host { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', Arial, sans-serif; }
49 `;66 `;
50 shadowRoot.appendChild(resetStyle);67 shadowRoot.appendChild(resetStyle);
68+
69+ // Ensure dynamic design tokens exist within the shadow tree. Tailwind v4
70+ // utilities rely on these CSS variables. We set light defaults and a dark variant on :host.
71+ const tokensStyle = document.createElement('style');
72+ tokensStyle.textContent = `
73+ :host {
74+ --dynamic-bg: var(--color-browser-bg);
75+ --dynamic-surface: var(--color-browser-surface);
76+ --dynamic-text: var(--color-browser-text);
77+ --dynamic-muted: var(--color-browser-muted);
78+ --dynamic-accent: var(--color-browser-accent);
79+ --dynamic-border: var(--color-browser-border);
80+ }
81+ :host(.dark) {
82+ --dynamic-bg: rgb(17 24 39);
83+ --dynamic-surface: rgb(31 41 55);
84+ --dynamic-text: rgb(243 244 246);
85+ --dynamic-muted: rgb(156 163 175);
86+ --dynamic-accent: rgb(96 165 250);
87+ --dynamic-border: rgb(55 65 81);
88+ }
89+ `;
90+ shadowRoot.appendChild(tokensStyle);
91+
92+ // Inject the global GUI stylesheet so components rendered inside the shadow
93+ // root (e.g., toasts, omnibox) receive their styles
94+ try {
95+ // Prefer constructable stylesheets when available
96+ const hasConstructable = typeof CSSStyleSheet !== 'undefined';
97+ const supportsAdopted = 'adoptedStyleSheets' in shadowRoot;
98+ if (supportsAdopted && hasConstructable) {
99+ const sheet = new CSSStyleSheet();
100+ sheet.replaceSync(globalStyles);
101+ const rootWithSheets = shadowRoot as ShadowRoot & { adoptedStyleSheets: CSSStyleSheet[] };
102+ rootWithSheets.adoptedStyleSheets = [...(rootWithSheets.adoptedStyleSheets ?? []), sheet];
103+ } else {
104+ // Fallback: append a <style> element with the global CSS
105+ const styleTag = document.createElement('style');
106+ styleTag.textContent = globalStyles;
107+ shadowRoot.appendChild(styleTag);
108+ }
109+ } catch {
110+ // Final fallback if anything above fails
111+ const styleTag = document.createElement('style');
112+ styleTag.textContent = globalStyles;
113+ shadowRoot.appendChild(styleTag);
114+ }
51 shadowResetApplied.add(shadowRoot);115 shadowResetApplied.add(shadowRoot);
52 }116 }
53 117
M gui/src/injected/modules/toast.ts +0 -1
@@ -8,7 +8,6 @@
88 import { mount, flushSync } from 'svelte';
99 import ToastContainer from '$components/toast/ToastContainer.svelte';
1010 import { ensureShadowMount } from './shadowHost';
11-import '$lib/styles.css';
1211
1312 export interface ToastConfig {
1413 theme?: 'light' | 'dark';
@@ -8,7 +8,6 @@
8 import { mount, flushSync } from 'svelte';8 import { mount, flushSync } from 'svelte';
9 import ToastContainer from '$components/toast/ToastContainer.svelte';9 import ToastContainer from '$components/toast/ToastContainer.svelte';
10 import { ensureShadowMount } from './shadowHost';10 import { ensureShadowMount } from './shadowHost';
11-import '$lib/styles.css';
12 11
13 export interface ToastConfig {12 export interface ToastConfig {
14 theme?: 'light' | 'dark';13 theme?: 'light' | 'dark';
A gui/src/injected/styles-entry.css +2 -0
@@ -0,0 +1,2 @@
1+/* Vite/PostCSS/Tailwind processed CSS entry for Shadow DOM injection */
2+@import '../lib/styles.css';
@@ -0,0 +1,2 @@
1+/* Vite/PostCSS/Tailwind processed CSS entry for Shadow DOM injection */
2+@import '../lib/styles.css';
M gui/src/styles/tailwind.css +5 -2
@@ -41,11 +41,14 @@
4141
4242 /* Toast notification styles */
4343 .toast-base {
44- border-radius: 0.5rem;
44+ border-radius: 0.375rem; /* rounded-md */
4545 box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
46- padding: 0.75rem 1rem;
46+ padding: 0.25rem 0.5rem;
4747 max-width: 24rem;
4848 pointer-events: auto;
49+ background-color: var(--dynamic-surface);
50+ color: var(--dynamic-text);
51+ border: 1px solid var(--dynamic-border);
4952 }
5053
5154 .toast-success {
@@ -41,11 +41,14 @@
41 41
42 /* Toast notification styles */42 /* Toast notification styles */
43 .toast-base {43 .toast-base {
44- border-radius: 0.5rem;44+ border-radius: 0.375rem; /* rounded-md */
45 box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);45 box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
46- padding: 0.75rem 1rem;46+ padding: 0.25rem 0.5rem;
47 max-width: 24rem;47 max-width: 24rem;
48 pointer-events: auto;48 pointer-events: auto;
49+ background-color: var(--dynamic-surface);
50+ color: var(--dynamic-text);
51+ border: 1px solid var(--dynamic-border);
49 }52 }
50 53
51 .toast-success {54 .toast-success {
A gui/src/types/inline-css.d.ts +4 -0
@@ -0,0 +1,4 @@
1+declare module '*.css?inline' {
2+ const content: string;
3+ export default content;
4+}
@@ -0,0 +1,4 @@
1+declare module '*.css?inline' {
2+ const content: string;
3+ export default content;
4+}
A gui/src/types/raw-css.d.ts +4 -0
@@ -0,0 +1,4 @@
1+declare module '*.css?raw' {
2+ const content: string;
3+ export default content;
4+}
@@ -0,0 +1,4 @@
1+declare module '*.css?raw' {
2+ const content: string;
3+ export default content;
4+}
A gui/vite.config.color-scheme.ts +28 -0
@@ -0,0 +1,28 @@
1+import { defineConfig } from 'vite';
2+import { resolve } from 'path';
3+
4+export default defineConfig({
5+ build: {
6+ rollupOptions: {
7+ input: resolve(__dirname, 'src/injected/modules/color-scheme.ts'),
8+ output: {
9+ dir: '../assets/gui',
10+ format: 'iife',
11+ entryFileNames: 'color-scheme.js',
12+ inlineDynamicImports: true,
13+ manualChunks: undefined, // Prevent chunk splitting
14+ },
15+ },
16+ emptyOutDir: false, // Don't clear the directory since GUI bundle is also built here
17+ target: ['es2020', 'chrome91', 'firefox90'],
18+ minify: true,
19+ sourcemap: false,
20+ },
21+ resolve: {
22+ alias: {
23+ // Alias for easier imports
24+ '$lib': resolve(__dirname, 'src/lib'),
25+ '$components': resolve(__dirname, 'src/components'),
26+ },
27+ },
28+});⏎̸
@@ -0,0 +1,28 @@
1+import { defineConfig } from 'vite';
2+import { resolve } from 'path';
3+
4+export default defineConfig({
5+ build: {
6+ rollupOptions: {
7+ input: resolve(__dirname, 'src/injected/modules/color-scheme.ts'),
8+ output: {
9+ dir: '../assets/gui',
10+ format: 'iife',
11+ entryFileNames: 'color-scheme.js',
12+ inlineDynamicImports: true,
13+ manualChunks: undefined, // Prevent chunk splitting
14+ },
15+ },
16+ emptyOutDir: false, // Don't clear the directory since GUI bundle is also built here
17+ target: ['es2020', 'chrome91', 'firefox90'],
18+ minify: true,
19+ sourcemap: false,
20+ },
21+ resolve: {
22+ alias: {
23+ // Alias for easier imports
24+ '$lib': resolve(__dirname, 'src/lib'),
25+ '$components': resolve(__dirname, 'src/components'),
26+ },
27+ },
28+});⏎̸
M gui/vite.config.gui.ts +7 -4
@@ -4,7 +4,9 @@ import { resolve } from 'path';
44
55 export default defineConfig({
66 plugins: [
7- svelte(),
7+ svelte({
8+ emitCss: false, // Inline component CSS into JS bundle
9+ }),
810 ],
911 build: {
1012 rollupOptions: {
@@ -15,15 +17,16 @@ export default defineConfig({
1517 entryFileNames: 'gui.min.js',
1618 name: '__dumberGUI',
1719 inlineDynamicImports: true,
18- assetFileNames: '[name].[ext]', // Don't hash asset names
20+ manualChunks: undefined, // Prevent chunk splitting
21+ assetFileNames: '[name].[ext]', // Don't hash asset names since they're inlined
1922 },
2023 },
2124 emptyOutDir: false,
2225 target: ['es2020', 'chrome91', 'firefox90'],
2326 minify: true,
2427 sourcemap: false,
25- cssCodeSplit: false, // Keep CSS in single file
26- assetsInlineLimit: 0, // Don't inline assets by default
28+ cssCodeSplit: false, // Prevent CSS code splitting
29+ assetsInlineLimit: 100000000, // Inline all assets as data URLs (100MB limit)
2730 },
2831 resolve: {
2932 alias: {
@@ -4,7 +4,9 @@ import { resolve } from 'path';
4 4
5 export default defineConfig({5 export default defineConfig({
6 plugins: [6 plugins: [
7- svelte(),7+ svelte({
8+ emitCss: false, // Inline component CSS into JS bundle
9+ }),
8 ],10 ],
9 build: {11 build: {
10 rollupOptions: {12 rollupOptions: {
@@ -15,15 +17,16 @@ export default defineConfig({
15 entryFileNames: 'gui.min.js',17 entryFileNames: 'gui.min.js',
16 name: '__dumberGUI',18 name: '__dumberGUI',
17 inlineDynamicImports: true,19 inlineDynamicImports: true,
18- assetFileNames: '[name].[ext]', // Don't hash asset names20+ manualChunks: undefined, // Prevent chunk splitting
21+ assetFileNames: '[name].[ext]', // Don't hash asset names since they're inlined
19 },22 },
20 },23 },
21 emptyOutDir: false,24 emptyOutDir: false,
22 target: ['es2020', 'chrome91', 'firefox90'],25 target: ['es2020', 'chrome91', 'firefox90'],
23 minify: true,26 minify: true,
24 sourcemap: false,27 sourcemap: false,
25- cssCodeSplit: false, // Keep CSS in single file28+ cssCodeSplit: false, // Prevent CSS code splitting
26- assetsInlineLimit: 0, // Don't inline assets by default29+ assetsInlineLimit: 100000000, // Inline all assets as data URLs (100MB limit)
27 },30 },
28 resolve: {31 resolve: {
29 alias: {32 alias: {
M pkg/webkit/webview_cgo.go +33 -4
@@ -61,6 +61,7 @@ static WebKitWebView* on_create_new_window(WebKitWebView* web_view,
6161 }
6262 static void connect_tls_error_handler(WebKitWebView* wv);
6363
64+
6465 // Dialog callback structure for modern AlertDialog async handling
6566 typedef struct {
6667 int response;
@@ -143,6 +144,7 @@ static GtkWidget* new_webview_with_ucm_and_session(const char* data_dir, const c
143144
144145 // Connect TLS error handler
145146 connect_tls_error_handler(WEBKIT_WEB_VIEW(w));
147+
146148 if (out_ucm) { *out_ucm = u; }
147149 return w;
148150 }
@@ -1295,11 +1297,26 @@ func (w *WebView) enableUserContentManager(cfg *Config) {
12951297 } else {
12961298 log.Printf("[theme] GTK prefers: light")
12971299 }
1300+ // Load color scheme script from compiled GUI assets instead of inline
12981301 var schemeJS string
1299- if preferDark {
1300- schemeJS = "(() => { try { const d=true; const cs=d?'dark':'light'; console.log('[dumber] color-scheme set:' + cs); try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({type:'theme', value: cs})) }catch(_){} const meta=document.createElement('meta'); meta.name='color-scheme'; meta.content='dark light'; document.documentElement.appendChild(meta); const s=document.createElement('style'); s.textContent=':root{color-scheme:dark;}'; document.documentElement.appendChild(s); const qD='(prefers-color-scheme: dark)'; const qL='(prefers-color-scheme: light)'; const orig=window.matchMedia; window.matchMedia=function(q){ if(typeof q==='string'&&(q.includes(qD)||q.includes(qL))){ const m={matches:q.includes('dark')?d:!d,media:q,onchange:null,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){},dispatchEvent(){return false;}}; return m;} return orig.call(window,q); }; } catch(e){ console.warn('[dumber] color-scheme injection failed', e) } })();"
1301- } else {
1302- schemeJS = "(() => { try { const d=false; const cs=d?'dark':'light'; console.log('[dumber] color-scheme set:' + cs); try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({type:'theme', value: cs})) }catch(_){} const meta=document.createElement('meta'); meta.name='color-scheme'; meta.content='light dark'; document.documentElement.appendChild(meta); const s=document.createElement('style'); s.textContent=':root{color-scheme:light;}'; document.documentElement.appendChild(s); const qD='(prefers-color-scheme: dark)'; const qL='(prefers-color-scheme: light)'; const orig=window.matchMedia; window.matchMedia=function(q){ if(typeof q==='string'&&(q.includes(qD)||q.includes(qL))){ const m={matches:q.includes('dark')?d:!d,media:q,onchange:null,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){},dispatchEvent(){return false;}}; return m;} return orig.call(window,q); }; } catch(e){ console.warn('[dumber] color-scheme injection failed', e) } })();"
1302+ if cfg != nil && cfg.Assets != nil {
1303+ if schemeBytes, err := cfg.Assets.ReadFile("assets/gui/color-scheme.js"); err == nil {
1304+ baseScript := string(schemeBytes)
1305+ // Inject the GTK preference as a parameter
1306+ if preferDark {
1307+ schemeJS = fmt.Sprintf("window.__dumber_gtk_prefers_dark = true; %s", baseScript)
1308+ } else {
1309+ schemeJS = fmt.Sprintf("window.__dumber_gtk_prefers_dark = false; %s", baseScript)
1310+ }
1311+ } else {
1312+ log.Printf("[webkit] Warning: Failed to load color-scheme.js from assets, falling back to basic theme detection: %v", err)
1313+ // Simple fallback if the compiled script is not available
1314+ if preferDark {
1315+ schemeJS = `console.log('[dumber] GTK detected color mode: dark'); document.documentElement.classList.add('dark');`
1316+ } else {
1317+ schemeJS = `console.log('[dumber] GTK detected color mode: light'); document.documentElement.classList.remove('dark');`
1318+ }
1319+ }
13031320 }
13041321 cScheme := C.CString(schemeJS)
13051322 defer C.free(unsafe.Pointer(cScheme))
@@ -1335,6 +1352,18 @@ func (w *WebView) enableUserContentManager(cfg *Config) {
13351352 bridge := `(() => { try {
13361353 if (window.__dumber_page_bridge_installed) return;
13371354 window.__dumber_page_bridge_installed = true;
1355+
1356+ // Theme setter function for GTK theme integration
1357+ window.__dumber_setTheme = (theme) => {
1358+ window.__dumber_initial_theme = theme;
1359+ console.log('[dumber] Setting theme to:', theme);
1360+ if (theme === 'dark') {
1361+ document.documentElement.classList.add('dark');
1362+ } else {
1363+ document.documentElement.classList.remove('dark');
1364+ }
1365+ };
1366+
13381367 // Ensure unified API object
13391368 window.__dumber = window.__dumber || {};
13401369 // Toast namespace
@@ -61,6 +61,7 @@ static WebKitWebView* on_create_new_window(WebKitWebView* web_view,
61 }61 }
62 static void connect_tls_error_handler(WebKitWebView* wv);62 static void connect_tls_error_handler(WebKitWebView* wv);
63 63
64+
64 // Dialog callback structure for modern AlertDialog async handling65 // Dialog callback structure for modern AlertDialog async handling
65 typedef struct {66 typedef struct {
66 int response;67 int response;
@@ -143,6 +144,7 @@ static GtkWidget* new_webview_with_ucm_and_session(const char* data_dir, const c
143 144
144 // Connect TLS error handler145 // Connect TLS error handler
145 connect_tls_error_handler(WEBKIT_WEB_VIEW(w));146 connect_tls_error_handler(WEBKIT_WEB_VIEW(w));
147+
146 if (out_ucm) { *out_ucm = u; }148 if (out_ucm) { *out_ucm = u; }
147 return w;149 return w;
148 }150 }
@@ -1295,11 +1297,26 @@ func (w *WebView) enableUserContentManager(cfg *Config) {
1295 } else {1297 } else {
1296 log.Printf("[theme] GTK prefers: light")1298 log.Printf("[theme] GTK prefers: light")
1297 }1299 }
1300+ // Load color scheme script from compiled GUI assets instead of inline
1298 var schemeJS string1301 var schemeJS string
1299- if preferDark {1302+ if cfg != nil && cfg.Assets != nil {
1300- schemeJS = "(() => { try { const d=true; const cs=d?'dark':'light'; console.log('[dumber] color-scheme set:' + cs); try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({type:'theme', value: cs})) }catch(_){} const meta=document.createElement('meta'); meta.name='color-scheme'; meta.content='dark light'; document.documentElement.appendChild(meta); const s=document.createElement('style'); s.textContent=':root{color-scheme:dark;}'; document.documentElement.appendChild(s); const qD='(prefers-color-scheme: dark)'; const qL='(prefers-color-scheme: light)'; const orig=window.matchMedia; window.matchMedia=function(q){ if(typeof q==='string'&&(q.includes(qD)||q.includes(qL))){ const m={matches:q.includes('dark')?d:!d,media:q,onchange:null,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){},dispatchEvent(){return false;}}; return m;} return orig.call(window,q); }; } catch(e){ console.warn('[dumber] color-scheme injection failed', e) } })();"1303+ if schemeBytes, err := cfg.Assets.ReadFile("assets/gui/color-scheme.js"); err == nil {
1301- } else {1304+ baseScript := string(schemeBytes)
1302- schemeJS = "(() => { try { const d=false; const cs=d?'dark':'light'; console.log('[dumber] color-scheme set:' + cs); try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({type:'theme', value: cs})) }catch(_){} const meta=document.createElement('meta'); meta.name='color-scheme'; meta.content='light dark'; document.documentElement.appendChild(meta); const s=document.createElement('style'); s.textContent=':root{color-scheme:light;}'; document.documentElement.appendChild(s); const qD='(prefers-color-scheme: dark)'; const qL='(prefers-color-scheme: light)'; const orig=window.matchMedia; window.matchMedia=function(q){ if(typeof q==='string'&&(q.includes(qD)||q.includes(qL))){ const m={matches:q.includes('dark')?d:!d,media:q,onchange:null,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){},dispatchEvent(){return false;}}; return m;} return orig.call(window,q); }; } catch(e){ console.warn('[dumber] color-scheme injection failed', e) } })();"1305+ // Inject the GTK preference as a parameter
1306+ if preferDark {
1307+ schemeJS = fmt.Sprintf("window.__dumber_gtk_prefers_dark = true; %s", baseScript)
1308+ } else {
1309+ schemeJS = fmt.Sprintf("window.__dumber_gtk_prefers_dark = false; %s", baseScript)
1310+ }
1311+ } else {
1312+ log.Printf("[webkit] Warning: Failed to load color-scheme.js from assets, falling back to basic theme detection: %v", err)
1313+ // Simple fallback if the compiled script is not available
1314+ if preferDark {
1315+ schemeJS = `console.log('[dumber] GTK detected color mode: dark'); document.documentElement.classList.add('dark');`
1316+ } else {
1317+ schemeJS = `console.log('[dumber] GTK detected color mode: light'); document.documentElement.classList.remove('dark');`
1318+ }
1319+ }
1303 }1320 }
1304 cScheme := C.CString(schemeJS)1321 cScheme := C.CString(schemeJS)
1305 defer C.free(unsafe.Pointer(cScheme))1322 defer C.free(unsafe.Pointer(cScheme))
@@ -1335,6 +1352,18 @@ func (w *WebView) enableUserContentManager(cfg *Config) {
1335 bridge := `(() => { try {1352 bridge := `(() => { try {
1336 if (window.__dumber_page_bridge_installed) return; 1353 if (window.__dumber_page_bridge_installed) return;
1337 window.__dumber_page_bridge_installed = true;1354 window.__dumber_page_bridge_installed = true;
1355+
1356+ // Theme setter function for GTK theme integration
1357+ window.__dumber_setTheme = (theme) => {
1358+ window.__dumber_initial_theme = theme;
1359+ console.log('[dumber] Setting theme to:', theme);
1360+ if (theme === 'dark') {
1361+ document.documentElement.classList.add('dark');
1362+ } else {
1363+ document.documentElement.classList.remove('dark');
1364+ }
1365+ };
1366+
1338 // Ensure unified API object1367 // Ensure unified API object
1339 window.__dumber = window.__dumber || {};1368 window.__dumber = window.__dumber || {};
1340 // Toast namespace1369 // Toast namespace

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