@materialwebunofficial/antigravity-themes 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,709 @@
1
+ // Antigravity Themes host: applies one theme from a bundle, remembers the choice, and
2
+ // adds a "Theme" menu to the title bar (Ctrl+Shift+F12 cycles themes; not
3
+ // Ctrl+Alt+<letter>: Windows reads Ctrl+Alt as AltGr, which types
4
+ // characters on many keyboard layouts). In MD3E the title bar's menus and Theme fold
5
+ // into one app menu button (the package's <md-menu>).
6
+ //
7
+ // Context-free on purpose: it only touches the DOM and document.cookie. The
8
+ // injector (lib/injector.mjs) evaluates it in every Antigravity window.
9
+ function agyThemeHost(bundle) {
10
+ // Bump when this file's code changes. A same-version host only takes the
11
+ // new bundle; an older one would keep running its old code through
12
+ // update(), so it is disposed and replaced (same task: no flash).
13
+ const HOST_VERSION = 17; // also bump when a module it starts (context-card, turn-tokens, loading-/progress-indicator, code-tools) changes: the host starts them once
14
+ const existing = window.__agyThemeHost;
15
+ if (existing?.version === HOST_VERSION) return existing.update(bundle);
16
+ existing?.dispose();
17
+
18
+ const COOKIE = 'agy-theme';
19
+ const readChoice = () => (document.cookie.match(/(?:^|;\s*)agy-theme=([^;]+)/) || [])[1];
20
+ const saveChoice = (key) => {
21
+ document.cookie = `${COOKIE}=${key}; Max-Age=31536000; Path=/; SameSite=Strict; Secure`;
22
+ };
23
+
24
+ let options = new Map();
25
+ let current = null;
26
+
27
+ // Fonts come as bytes and are registered with the FontFace API: no
28
+ // network fetch, no CSP font-src, and no 300 KB data URI in the CSS text.
29
+ const loadedFonts = new Set();
30
+ function ensureFonts() {
31
+ for (const f of bundle.fonts?.[current.theme] ?? []) {
32
+ const key = `${f.family}|${f.weight}|${f.stretch}`;
33
+ if (loadedFonts.has(key) || !document.fonts) continue;
34
+ loadedFonts.add(key);
35
+ try {
36
+ const bytes = Uint8Array.from(atob(f.data), (c) => c.charCodeAt(0));
37
+ const face = new FontFace(f.family, bytes.buffer, { weight: f.weight, stretch: f.stretch, style: 'normal', display: 'swap' });
38
+ document.fonts.add(face);
39
+ face.load().catch(() => {});
40
+ } catch {
41
+ // a theme without its font still works on the fallback stack
42
+ }
43
+ }
44
+ }
45
+
46
+ // Light or dark follows the app's own setting (System / Light / Dark),
47
+ // which it marks on <body> (theme-light / theme-dark). The themes' token
48
+ // blocks live on :root, so the mode is mirrored there as data-agy-mode.
49
+ // No mark yet (startup) counts as dark, the themes' default.
50
+ function readMode() {
51
+ const body = document.body;
52
+ if (!body) return 'dark';
53
+ const light = body.classList.contains('theme-light')
54
+ || (body.classList.contains('theme-standalone') && !body.classList.contains('dark'));
55
+ return light ? 'light' : 'dark';
56
+ }
57
+ function syncMode() {
58
+ const root = document.documentElement;
59
+ const mode = readMode();
60
+ if (!root || root.getAttribute('data-agy-mode') === mode) return false;
61
+ root.setAttribute('data-agy-mode', mode);
62
+ return true;
63
+ }
64
+ const modeObserver = new MutationObserver(() => {
65
+ watchMode();
66
+ if (!syncMode()) return;
67
+ announceTheme();
68
+ // The app re-sends its own (mis-parsed) overlay color on theme changes
69
+ requestAnimationFrame(() => syncTitleBarOverlay());
70
+ setTimeout(syncTitleBarOverlay, 300);
71
+ });
72
+ // The vendored md3e-web components cache their colors; the package's own
73
+ // window event makes them read the tokens again (the app uses no such event)
74
+ const announceTheme = () => window.dispatchEvent(new Event('theme-change'));
75
+ let modeTarget = null;
76
+ function watchMode() {
77
+ const target = document.body ?? document.documentElement;
78
+ if (!target || target === modeTarget) return;
79
+ modeObserver.disconnect();
80
+ modeTarget = target;
81
+ if (target === document.body) modeObserver.observe(target, { attributes: true, attributeFilter: ['class'] });
82
+ else modeObserver.observe(target, { childList: true }); // until <body> exists
83
+ }
84
+
85
+ function apply() {
86
+ const root = document.documentElement;
87
+ if (!root || !current) return;
88
+ ensureFonts();
89
+ watchMode();
90
+ syncMode();
91
+ root.setAttribute('data-agy-theme', current.theme);
92
+ if (current.seed) root.setAttribute('data-agy-seed', current.seed);
93
+ else root.removeAttribute('data-agy-seed');
94
+ let style = document.getElementById('agy-theme-css');
95
+ if (!style) {
96
+ style = document.createElement('style');
97
+ style.id = 'agy-theme-css';
98
+ }
99
+ const css = bundle.themes[current.theme] || '';
100
+ if (style.textContent !== css) style.textContent = css;
101
+ // Last in <head> so equal-specificity ties go to the theme
102
+ const parent = document.head || root;
103
+ if (style.parentNode !== parent || style !== parent.lastElementChild) parent.appendChild(style);
104
+ announceTheme();
105
+ renderMenu();
106
+ if (current.theme !== 'md3e') closeAppMenu();
107
+ // Themes change the title bar's padding and font: re-anchor the menu
108
+ requestAnimationFrame(() => { position(); syncTitleBarOverlay(); });
109
+ }
110
+
111
+ // The window controls (minimize/maximize/close) are drawn by Windows in
112
+ // the color the app passes to setTitleBarOverlay. The app derives it from
113
+ // --sidebar by taking the first three numbers of the computed color, which
114
+ // turns a non-RGB serialization such as lch(12% 4 190) into rgb(12, 4, 190):
115
+ // the bright blue behind the close button. Resolve through a canvas, which
116
+ // always reads back sRGB bytes. electronNative is frozen (contextBridge),
117
+ // so it cannot be wrapped; re-assert on focus instead.
118
+ let overlayCanvas = null;
119
+ function cssHex(varName) {
120
+ const probe = document.createElement('div');
121
+ probe.style.color = `var(${varName})`;
122
+ document.body.appendChild(probe);
123
+ const color = getComputedStyle(probe).color;
124
+ probe.remove();
125
+ overlayCanvas ??= document.createElement('canvas');
126
+ overlayCanvas.width = overlayCanvas.height = 1;
127
+ const ctx = overlayCanvas.getContext('2d', { willReadFrequently: true });
128
+ ctx.clearRect(0, 0, 1, 1);
129
+ ctx.fillStyle = color;
130
+ ctx.fillRect(0, 0, 1, 1);
131
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
132
+ return `#${[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
133
+ }
134
+ function syncTitleBarOverlay() {
135
+ if (!document.body || !window.electronNative?.setTitleBarOverlay) return;
136
+ try {
137
+ window.electronNative.setTitleBarOverlay({ color: cssHex('--sidebar'), symbolColor: cssHex('--foreground') });
138
+ } catch {
139
+ // older shells without the overlay API
140
+ }
141
+ }
142
+
143
+ function select(key) {
144
+ const next = options.get(key);
145
+ if (!next) return;
146
+ current = next;
147
+ saveChoice(key);
148
+ apply();
149
+ }
150
+
151
+ function update(nextBundle) {
152
+ bundle = nextBundle;
153
+ options = new Map(bundle.options.map((o) => [o.key, o]));
154
+ current = options.get(readChoice()) || options.get(current?.key) || options.get(bundle.default) || bundle.options[0];
155
+ apply();
156
+ return api;
157
+ }
158
+
159
+ // ---- Title bar menu (shadow DOM on <body>, outside React's tree) --------
160
+ let hostEl = null, shadow = null, trigger = null, menu = null, retry = 0;
161
+ let appMenu = null, appTrigger = null;
162
+
163
+ function mount() {
164
+ if (hostEl || !document.body) return;
165
+ hostEl = document.createElement('div');
166
+ hostEl.id = 'agy-theme-switcher';
167
+ hostEl.style.cssText = 'position:fixed;top:0;left:0;z-index:2147483000;display:none;';
168
+ shadow = hostEl.attachShadow({ mode: 'open' });
169
+ shadow.innerHTML = `
170
+ <style>
171
+ :host {
172
+ all: initial;
173
+ font-family: var(--font-sans, "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif);
174
+ font-size: 13px;
175
+ }
176
+ button, [role="menuitemradio"] { -webkit-app-region: no-drag; }
177
+ .trigger {
178
+ display: flex; align-items: center; padding: 0 8px; border: 0; border-radius: 6px;
179
+ background: transparent; color: var(--muted-foreground, #999); font: inherit; cursor: default;
180
+ }
181
+ .trigger:hover, .trigger[aria-expanded="true"] { background: var(--secondary, #333); color: var(--foreground, #eee); }
182
+ .menu {
183
+ position: absolute; top: calc(100% + 6px); left: 0; min-width: 230px; padding: 6px;
184
+ border: 1px solid var(--border, #333); border-radius: 12px;
185
+ background: var(--card, #1c1c1f); color: var(--foreground, #eee);
186
+ box-shadow: 0 16px 40px -12px rgb(0 0 0 / 0.75);
187
+ }
188
+ .menu[hidden] { display: none; }
189
+ [role="menuitemradio"] {
190
+ display: flex; align-items: center; gap: 10px; height: 32px; padding: 0 10px;
191
+ border-radius: 8px; font: inherit; color: inherit; cursor: default; outline: none;
192
+ }
193
+ [role="menuitemradio"]:hover, [role="menuitemradio"]:focus-visible { background: var(--secondary, #333); }
194
+ .swatch { width: 12px; height: 12px; border-radius: 999px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.2); }
195
+ .name { flex: 1; }
196
+ .check { visibility: hidden; }
197
+ [aria-checked="true"] .check { visibility: visible; }
198
+ .hint { padding: 6px 10px 2px; color: var(--muted-foreground, #999); font-size: 11px; }
199
+
200
+ /* MD3E: the md-menu anatomy of md3e.css section 50 (one
201
+ surface-container-low group, 16px, level 2; 44px items with their
202
+ shape 4px in, 12px at the ends; selected = tertiary-container pill
203
+ and the Rounded check), opening with the menu's scale + fade */
204
+ :host-context([data-agy-theme="md3e"]) .trigger:hover { background: var(--md-hover); color: var(--md-sys-color-on-surface); }
205
+ :host-context([data-agy-theme="md3e"]) .trigger[aria-expanded="true"] {
206
+ background: var(--md-sys-color-secondary-container); color: var(--md-sys-color-on-secondary-container);
207
+ }
208
+ :host-context([data-agy-theme="md3e"]) .menu {
209
+ top: calc(100% + 4px); min-width: 260px; padding: 4px 0; border: 0; border-radius: 16px;
210
+ background: var(--md-sys-color-surface-container-low); color: var(--md-sys-color-on-surface);
211
+ box-shadow: var(--md-sys-elevation-level2);
212
+ font: var(--md-sys-typescale-label-large); letter-spacing: var(--md-sys-typescale-label-large-tracking);
213
+ transform-origin: top left; transition: scale var(--md-fast), opacity var(--md-color-fast);
214
+ }
215
+ @starting-style { :host-context([data-agy-theme="md3e"]) .menu { scale: 0.8; opacity: 0; } }
216
+ :host-context([data-agy-theme="md3e"]) [role="menuitemradio"] {
217
+ position: relative; isolation: isolate; height: 44px; gap: 12px; padding: 0 16px; border-radius: 0;
218
+ }
219
+ :host-context([data-agy-theme="md3e"]) [role="menuitemradio"]::before {
220
+ content: ""; position: absolute; inset: 0 4px; z-index: -1; border-radius: 4px;
221
+ transition: background-color var(--md-state);
222
+ }
223
+ :host-context([data-agy-theme="md3e"]) [role="menuitemradio"]:first-child::before { border-radius: 12px 12px 4px 4px; }
224
+ :host-context([data-agy-theme="md3e"]) [role="menuitemradio"]:hover,
225
+ :host-context([data-agy-theme="md3e"]) [role="menuitemradio"]:focus-visible { background: transparent; }
226
+ :host-context([data-agy-theme="md3e"]) [role="menuitemradio"]:hover::before { background: color-mix(in srgb, currentColor 8%, transparent); }
227
+ :host-context([data-agy-theme="md3e"]) [role="menuitemradio"]:focus-visible::before { background: color-mix(in srgb, currentColor 10%, transparent); }
228
+ :host-context([data-agy-theme="md3e"]) [aria-checked="true"] { color: var(--md-sys-color-on-tertiary-container); }
229
+ :host-context([data-agy-theme="md3e"]) [aria-checked="true"]::before { border-radius: 12px; background: var(--md-sys-color-tertiary-container); }
230
+ :host-context([data-agy-theme="md3e"]) [aria-checked="true"]:hover::before {
231
+ background: color-mix(in srgb, var(--md-sys-color-on-tertiary-container) 8%, var(--md-sys-color-tertiary-container));
232
+ }
233
+ :host-context([data-agy-theme="md3e"]) .swatch { width: 16px; height: 16px; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--md-sys-color-on-surface) 24%, transparent); }
234
+ :host-context([data-agy-theme="md3e"]) .check {
235
+ width: 20px; height: 20px; font-size: 0; background: currentColor;
236
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'%3E%3Cpath d='M389 -369 688 -668Q699 -679 714 -679Q728 -679 739 -668Q750 -657 750 -642Q750 -628 739 -617L415 -292Q404 -281 390 -281Q375 -281 364 -292L221 -435Q210 -446 210 -460Q210 -475 221 -486Q232 -497 246 -497Q261 -497 272 -486Z'/%3E%3C/svg%3E") center / contain no-repeat;
237
+ }
238
+ :host-context([data-agy-theme="md3e"]) .hint {
239
+ padding: 8px 16px 6px; font: var(--md-sys-typescale-label-medium); color: var(--md-sys-color-on-surface-variant);
240
+ }
241
+
242
+ /* MD3E app menu: an xsmall M3 icon button (32px, 20px icon); the
243
+ <md-menu> it opens draws itself */
244
+ .appmenu { display: none; }
245
+ :host-context([data-agy-theme="md3e"][data-agy-appmenu]) .trigger,
246
+ :host-context([data-agy-theme="md3e"][data-agy-appmenu]) .menu { display: none !important; }
247
+ :host-context([data-agy-theme="md3e"][data-agy-appmenu]) .appmenu { display: block; }
248
+ .appmenu-trigger {
249
+ display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 0;
250
+ border-radius: 9999px; background: transparent; color: var(--md-sys-color-on-surface-variant);
251
+ cursor: default; outline: none; transition: background-color var(--md-state), color var(--md-state);
252
+ }
253
+ .appmenu-trigger:hover { background: var(--md-hover); color: var(--md-sys-color-on-surface); }
254
+ .appmenu-trigger[aria-expanded="true"] {
255
+ background: var(--md-sys-color-secondary-container); color: var(--md-sys-color-on-secondary-container);
256
+ }
257
+ .appmenu-trigger:focus-visible { outline: 3px solid var(--md-sys-color-secondary); outline-offset: 2px; }
258
+ .appmenu svg { display: block; width: 20px; height: 20px; fill: currentColor; }
259
+ .appmenu .swatch { width: 16px; height: 16px; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--md-sys-color-on-surface) 24%, transparent); }
260
+ </style>
261
+ <button class="trigger" aria-haspopup="menu" aria-expanded="false">Theme</button>
262
+ <div class="menu" role="menu" aria-label="Theme" hidden></div>`;
263
+ trigger = shadow.querySelector('.trigger');
264
+ menu = shadow.querySelector('.menu');
265
+ mountAppMenu();
266
+ trigger.addEventListener('click', () => (menu.hidden ? openMenu() : closeMenu()));
267
+ menu.addEventListener('click', (e) => {
268
+ const item = e.target.closest('[role="menuitemradio"]');
269
+ if (item) { select(item.dataset.key); closeMenu(); }
270
+ });
271
+ menu.addEventListener('keydown', onMenuKey);
272
+ document.body.appendChild(hostEl);
273
+ renderMenu();
274
+ position();
275
+ }
276
+
277
+ function renderMenu() {
278
+ if (!menu) return;
279
+ menu.innerHTML = '';
280
+ for (const o of options.values()) {
281
+ const item = document.createElement('div');
282
+ item.setAttribute('role', 'menuitemradio');
283
+ item.setAttribute('aria-checked', String(o.key === current?.key));
284
+ item.tabIndex = -1;
285
+ item.dataset.key = o.key;
286
+ item.innerHTML = '<span class="swatch"></span><span class="name"></span><span class="check">✓</span>';
287
+ item.querySelector('.swatch').style.background = o.swatch || 'transparent';
288
+ item.querySelector('.name').textContent = o.name;
289
+ menu.appendChild(item);
290
+ }
291
+ const hint = document.createElement('div');
292
+ hint.className = 'hint';
293
+ hint.textContent = 'Ctrl+Shift+F12: next theme';
294
+ menu.appendChild(hint);
295
+ }
296
+
297
+ // ---- MD3E app menu ----------------------------------------------------------
298
+ // A text menu bar has no place in an M3 top app bar, so in MD3E the title
299
+ // bar's menus (Antigravity, Workspace, View, Window) and Theme sit behind one
300
+ // icon button that opens the package's <md-menu>, each menu a cascading
301
+ // submenu. The app stays the source of truth: md3e.css only hides its menu
302
+ // bar (while data-agy-appmenu is set), the items are read from its menu
303
+ // components' props on every open, and choosing one runs the app's own
304
+ // handler. Without the vendored component the app's menu bar stays.
305
+ const SVG_NS = 'http://www.w3.org/2000/svg';
306
+ // Material Symbols Rounded (FILL 0, opsz 20, wght 400), as in md3e-icons.css
307
+ const GLYPHS = {
308
+ menu: 'M180 -264Q165 -264 154 -274Q144 -285 144 -300Q144 -315 154 -326Q165 -336 180 -336H780Q795 -336 806 -326Q816 -315 816 -300Q816 -285 806 -274Q795 -264 780 -264ZM180 -444Q165 -444 154 -454Q144 -465 144 -480Q144 -495 154 -506Q165 -516 180 -516H780Q795 -516 806 -506Q816 -495 816 -480Q816 -465 806 -454Q795 -444 780 -444ZM180 -624Q165 -624 154 -634Q144 -645 144 -660Q144 -675 154 -686Q165 -696 180 -696H780Q795 -696 806 -686Q816 -675 816 -660Q816 -645 806 -634Q795 -624 780 -624Z',
309
+ info: 'M516 -324V-492Q516 -507 506 -518Q495 -528 480 -528Q465 -528 454 -518Q444 -507 444 -492V-324Q444 -309 454 -298Q465 -288 480 -288Q495 -288 506 -298Q516 -309 516 -324ZM516 -636Q516 -651 506 -662Q495 -672 480 -672Q465 -672 454 -662Q444 -651 444 -636Q444 -621 454 -610Q465 -600 480 -600Q495 -600 506 -610Q516 -621 516 -636ZM480 -96Q401 -96 331 -126Q261 -156 208 -208Q156 -261 126 -331Q96 -401 96 -480Q96 -560 126 -630Q156 -699 208 -752Q261 -804 331 -834Q401 -864 480 -864Q560 -864 630 -834Q699 -804 752 -752Q804 -699 834 -629Q864 -560 864 -480Q864 -401 834 -331Q804 -261 752 -208Q699 -156 629 -126Q560 -96 480 -96ZM480 -168Q610 -168 701 -259Q792 -350 792 -480Q792 -610 701 -701Q610 -792 480 -792Q350 -792 259 -701Q168 -610 168 -480Q168 -350 259 -259Q350 -168 480 -168Z',
310
+ workspaces: 'M240 -144Q180 -144 138 -186Q96 -228 96 -288Q96 -348 138 -390Q180 -432 240 -432Q300 -432 342 -390Q384 -348 384 -288Q384 -228 342 -186Q300 -144 240 -144ZM720 -144Q660 -144 618 -186Q576 -228 576 -288Q576 -348 618 -390Q660 -432 720 -432Q780 -432 822 -390Q864 -348 864 -288Q864 -228 822 -186Q780 -144 720 -144ZM240 -216Q270 -216 291 -237Q312 -258 312 -288Q312 -318 291 -339Q270 -360 240 -360Q210 -360 189 -339Q168 -318 168 -288Q168 -258 189 -237Q210 -216 240 -216ZM720 -216Q750 -216 771 -237Q792 -258 792 -288Q792 -318 771 -339Q750 -360 720 -360Q690 -360 669 -339Q648 -318 648 -288Q648 -258 669 -237Q690 -216 720 -216ZM480 -528Q420 -528 378 -570Q336 -612 336 -672Q336 -732 378 -774Q420 -816 480 -816Q540 -816 582 -774Q624 -732 624 -672Q624 -612 582 -570Q540 -528 480 -528ZM480 -600Q510 -600 531 -621Q552 -642 552 -672Q552 -702 531 -723Q510 -744 480 -744Q450 -744 429 -723Q408 -702 408 -672Q408 -642 429 -621Q450 -600 480 -600Z',
311
+ visibility: 'M480 -312Q550 -312 599 -361Q648 -410 648 -480Q648 -550 599 -599Q550 -648 480 -648Q410 -648 361 -599Q312 -550 312 -480Q312 -410 361 -361Q410 -312 480 -312ZM480 -384Q440 -384 412 -412Q384 -440 384 -480Q384 -520 412 -548Q440 -576 480 -576Q520 -576 548 -548Q576 -520 576 -480Q576 -440 548 -412Q520 -384 480 -384ZM68 -445Q63 -453 61 -462Q59 -471 59 -480Q59 -489 61 -498Q63 -507 68 -515Q132 -629 241 -698Q350 -768 480 -768Q610 -768 719 -698Q828 -629 892 -515Q897 -507 899 -498Q901 -489 901 -480Q901 -471 899 -462Q897 -453 892 -445Q828 -331 719 -262Q610 -192 480 -192Q350 -192 241 -262Q132 -331 68 -445ZM833 -480Q782 -580 687 -638Q592 -696 480 -696Q368 -696 273 -638Q178 -580 127 -480Q178 -380 273 -322Q368 -264 480 -264Q592 -264 687 -322Q782 -380 833 -480Z',
312
+ select_window: 'M168 -96Q138 -96 117 -117Q96 -138 96 -168V-504Q96 -534 117 -555Q138 -576 168 -576H240V-792Q240 -822 261 -843Q282 -864 312 -864H792Q822 -864 843 -843Q864 -822 864 -792V-456Q864 -426 843 -405Q822 -384 792 -384H720V-168Q720 -138 699 -117Q678 -96 648 -96ZM168 -168H648Q648 -168 648 -168Q648 -168 648 -168V-432H168V-168Q168 -168 168 -168Q168 -168 168 -168ZM720 -456H792Q792 -456 792 -456Q792 -456 792 -456V-720H312V-576H648Q678 -576 699 -555Q720 -534 720 -504Z',
313
+ palette: 'M480 -96Q401 -96 331 -126Q261 -156 208 -208Q156 -261 126 -331Q96 -401 96 -480Q96 -560 126 -630Q157 -699 210 -752Q264 -804 336 -834Q407 -864 488 -864Q566 -864 634 -837Q703 -810 754 -763Q805 -716 834 -653Q864 -590 864 -518Q864 -422 797 -355Q730 -288 634 -288H566Q558 -288 552 -283Q546 -278 546 -270Q546 -255 561 -245Q576 -235 576 -192Q576 -155 549 -126Q522 -96 480 -96ZM264 -444Q289 -444 306 -462Q324 -479 324 -504Q324 -529 306 -546Q289 -564 264 -564Q239 -564 222 -546Q204 -529 204 -504Q204 -479 222 -462Q239 -444 264 -444ZM384 -588Q409 -588 426 -606Q444 -623 444 -648Q444 -673 426 -690Q409 -708 384 -708Q359 -708 342 -690Q324 -673 324 -648Q324 -623 342 -606Q359 -588 384 -588ZM576 -588Q601 -588 618 -606Q636 -623 636 -648Q636 -673 618 -690Q601 -708 576 -708Q551 -708 534 -690Q516 -673 516 -648Q516 -623 534 -606Q551 -588 576 -588ZM696 -444Q721 -444 738 -462Q756 -479 756 -504Q756 -529 738 -546Q721 -564 696 -564Q671 -564 654 -546Q636 -529 636 -504Q636 -479 654 -462Q671 -444 696 -444ZM480 -168Q491 -168 498 -176Q504 -185 504 -192Q504 -208 489 -220Q474 -232 474 -270Q474 -308 500 -334Q527 -360 565 -360H634Q700 -360 746 -406Q792 -452 792 -518Q792 -633 704 -712Q615 -792 488 -792Q354 -792 261 -701Q168 -610 168 -480Q168 -350 259 -259Q350 -168 480 -168Z',
314
+ };
315
+ // the first menu is the product's own (its title is the app name)
316
+ const MENU_GLYPHS = { Workspace: 'workspaces', View: 'visibility', Window: 'select_window' };
317
+ // "Version …" is a label in the app's menu, not a dead command: read, not 38%
318
+ const INFO_COLORS = JSON.stringify({ disabledTextColor: 'var(--md-sys-color-on-surface-variant)' });
319
+ let appMenus = [];
320
+
321
+ function glyph(name, slot) {
322
+ const svg = document.createElementNS(SVG_NS, 'svg');
323
+ svg.setAttribute('viewBox', '0 -960 960 960');
324
+ svg.setAttribute('aria-hidden', 'true');
325
+ if (slot) svg.setAttribute('slot', slot);
326
+ const path = document.createElementNS(SVG_NS, 'path');
327
+ path.setAttribute('d', GLYPHS[name]);
328
+ svg.appendChild(path);
329
+ return svg;
330
+ }
331
+ function menuItem(headline, attrs = {}) {
332
+ const item = document.createElement('md-menu-item');
333
+ item.setAttribute('headline', headline);
334
+ for (const [name, value] of Object.entries(attrs)) {
335
+ if (value != null && value !== false) item.setAttribute(name, value === true ? '' : String(value));
336
+ }
337
+ return item;
338
+ }
339
+ function submenu(label, children) {
340
+ const sub = document.createElement('md-menu');
341
+ sub.slot = 'submenu';
342
+ sub.setAttribute('label', label);
343
+ sub.append(...children);
344
+ return sub;
345
+ }
346
+
347
+ function mountAppMenu() {
348
+ if (!customElements.get('md-menu')) return;
349
+ appMenu = document.createElement('md-menu');
350
+ appMenu.className = 'appmenu';
351
+ appMenu.setAttribute('label', 'Application menu');
352
+ appTrigger = document.createElement('button');
353
+ appTrigger.className = 'appmenu-trigger';
354
+ appTrigger.slot = 'trigger';
355
+ appTrigger.setAttribute('aria-label', 'Application menu');
356
+ appTrigger.title = 'Menu';
357
+ appTrigger.appendChild(glyph('menu'));
358
+ appMenu.appendChild(appTrigger);
359
+ // The items are built before the press, while the pointer comes over the
360
+ // button or it takes focus: built on the click, the ~25 new elements cost
361
+ // the opening its first frame. The click and keys only catch a change.
362
+ appTrigger.addEventListener('pointerenter', buildAppMenu);
363
+ appTrigger.addEventListener('focus', buildAppMenu);
364
+ appTrigger.addEventListener('click', buildAppMenu);
365
+ appTrigger.addEventListener('keydown', (e) => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') buildAppMenu(); });
366
+ appMenu.addEventListener('select', onAppMenuSelect);
367
+ shadow.appendChild(appMenu);
368
+ document.documentElement.setAttribute('data-agy-appmenu', '');
369
+ }
370
+
371
+ // The app's menu bar renders one component per menu, { title, options:
372
+ // [{ label, shortcut, onClick, nonSelectable }] }: read from React's fiber
373
+ function readAppMenus() {
374
+ const bar = document.querySelector('[data-testid="title-menu-bar"]');
375
+ const key = bar && Object.keys(bar).find((k) => k.startsWith('__reactFiber$'));
376
+ const menus = [];
377
+ for (let f = key ? bar[key].child : null; f; f = f.sibling) {
378
+ const p = f.memoizedProps;
379
+ if (typeof p?.title === 'string' && Array.isArray(p.options) && p.options.length) menus.push({ title: p.title, options: p.options });
380
+ }
381
+ return menus;
382
+ }
383
+
384
+ let builtFor = '';
385
+ function buildAppMenu() {
386
+ if (!appMenu || appMenu.open) return;
387
+ const menus = readAppMenus();
388
+ // rebuilt only when the app's menus or the chosen theme changed
389
+ const signature = JSON.stringify([current?.key, menus.map((m) => [m.title, m.options.map((o) => [String(o.label), o.shortcut, !!o.nonSelectable])])]);
390
+ if (signature === builtFor) { appMenus = menus; return; }
391
+ builtFor = signature;
392
+ appMenus = menus;
393
+ const items = appMenus.map((m, i) => {
394
+ const top = menuItem(m.title);
395
+ top.append(glyph(MENU_GLYPHS[m.title] || 'info', 'start'), submenu(m.title, m.options.map((o, j) => menuItem(String(o.label), {
396
+ value: `app:${i}:${j}`,
397
+ 'trailing-text': typeof o.shortcut === 'string' && o.shortcut ? o.shortcut : null,
398
+ disabled: !!o.nonSelectable,
399
+ colors: o.nonSelectable ? INFO_COLORS : null,
400
+ }))));
401
+ return top;
402
+ });
403
+ // Theme: the choices as one single-select group, "Next theme" as another
404
+ const choices = document.createElement('md-menu-group');
405
+ for (const o of options.values()) {
406
+ const item = menuItem(o.name, { value: `theme:${o.key}`, 'selection-mode': 'single', selected: o.key === current?.key, 'selected-icon': 'check' });
407
+ const swatch = document.createElement('span');
408
+ swatch.className = 'swatch';
409
+ swatch.slot = 'end';
410
+ swatch.style.background = o.swatch || 'transparent';
411
+ item.appendChild(swatch);
412
+ choices.appendChild(item);
413
+ }
414
+ const cycle = document.createElement('md-menu-group');
415
+ cycle.appendChild(menuItem('Next theme', { value: 'theme:next', 'trailing-text': 'Ctrl+Shift+F12' }));
416
+ const theme = menuItem('Theme');
417
+ theme.append(glyph('palette', 'start'), submenu('Theme', [choices, cycle]));
418
+ for (const el of [...appMenu.children]) if (el !== appTrigger) el.remove();
419
+ appMenu.append(...items, theme);
420
+ }
421
+
422
+ function onAppMenuSelect(e) {
423
+ const [kind, a, b] = String(e.detail?.item?.value ?? '').split(':');
424
+ // Close without handing focus back to the button: the command may move it
425
+ // (Command Palette, New Conversation)
426
+ e.preventDefault();
427
+ closeAppMenu();
428
+ if (kind === 'theme') {
429
+ if (a === 'next') cycleTheme();
430
+ else select(a);
431
+ } else if (kind === 'app') {
432
+ try {
433
+ appMenus[Number(a)]?.options[Number(b)]?.onClick?.();
434
+ } catch {
435
+ // the app's own handler failed; its menu would have failed the same
436
+ }
437
+ }
438
+ }
439
+ function closeAppMenu() {
440
+ if (!appMenu?.open) return;
441
+ if (typeof appMenu._requestClose === 'function') appMenu._requestClose(false);
442
+ else appMenu.open = false;
443
+ }
444
+
445
+ function position() {
446
+ if (!hostEl) return;
447
+ // MD3E: the app menu button at the bar's start, its icon in line with
448
+ // the drawer's icons below
449
+ if (appMenu && current?.theme === 'md3e') {
450
+ const bar = document.querySelector('[data-testid="title-menu-bar"]');
451
+ if (!bar) {
452
+ hostEl.style.display = 'none';
453
+ if (retry++ < 60) setTimeout(position, 500);
454
+ return;
455
+ }
456
+ const r = bar.getBoundingClientRect();
457
+ hostEl.style.display = 'block';
458
+ hostEl.style.transform = `translate(${Math.round(r.left + 22)}px, ${Math.round(r.top + (r.height - 32) / 2)}px)`;
459
+ return;
460
+ }
461
+ const items = document.querySelectorAll('[data-testid="title-menu-bar-item"]');
462
+ const last = items[items.length - 1];
463
+ if (!last) {
464
+ hostEl.style.display = 'none';
465
+ if (retry++ < 60) setTimeout(position, 500);
466
+ return;
467
+ }
468
+ const r = last.getBoundingClientRect();
469
+ const prev = items[items.length - 2]?.getBoundingClientRect();
470
+ const gap = prev ? Math.max(0, r.left - prev.right) : 2;
471
+ const cs = getComputedStyle(last);
472
+ hostEl.style.display = 'block';
473
+ hostEl.style.transform = `translate(${Math.round(r.right + gap)}px, ${Math.round(r.top)}px)`;
474
+ trigger.style.height = `${r.height}px`;
475
+ trigger.style.font = cs.font;
476
+ trigger.style.letterSpacing = cs.letterSpacing;
477
+ trigger.style.padding = cs.padding;
478
+ trigger.style.borderRadius = cs.borderRadius;
479
+ }
480
+
481
+ function openMenu() {
482
+ position();
483
+ menu.hidden = false;
484
+ trigger.setAttribute('aria-expanded', 'true');
485
+ (menu.querySelector('[aria-checked="true"]') || menu.querySelector('[role="menuitemradio"]'))?.focus();
486
+ }
487
+ function closeMenu() {
488
+ if (!menu || menu.hidden) return;
489
+ menu.hidden = true;
490
+ trigger.setAttribute('aria-expanded', 'false');
491
+ }
492
+ function onMenuKey(e) {
493
+ const items = [...menu.querySelectorAll('[role="menuitemradio"]')];
494
+ const i = items.indexOf(shadow.activeElement);
495
+ if (e.key === 'ArrowDown') { items[(i + 1) % items.length].focus(); e.preventDefault(); }
496
+ else if (e.key === 'ArrowUp') { items[(i - 1 + items.length) % items.length].focus(); e.preventDefault(); }
497
+ else if (e.key === 'Enter' || e.key === ' ') { if (items[i]) { select(items[i].dataset.key); closeMenu(); } e.preventDefault(); }
498
+ else if (e.key === 'Escape') { closeMenu(); trigger.focus(); }
499
+ }
500
+
501
+ // ---- M3 ripple ------------------------------------------------------------
502
+ const RIPPLE_TARGETS = 'button, a[href], [role="button"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"], [role="tab"], [data-testid="conversation-row-sidebar"]';
503
+ const PRESSED_OPACITY = 0.1;
504
+ let rippleLayer = null;
505
+ const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)');
506
+
507
+ function startRipple(e) {
508
+ if (!(bundle.ripple ?? []).includes(current?.theme) || e.button !== 0 || reducedMotion?.matches) return;
509
+ if (hostEl && e.composedPath().includes(hostEl)) return;
510
+ const target = e.target instanceof Element ? e.target.closest(RIPPLE_TARGETS) : null;
511
+ if (!target || target.isContentEditable) return;
512
+ const x = e.clientX, y = e.clientY, start = performance.now();
513
+ let released = false, onRelease = null;
514
+ const release = () => {
515
+ released = true;
516
+ window.removeEventListener('pointerup', release, true);
517
+ window.removeEventListener('pointercancel', release, true);
518
+ onRelease?.();
519
+ };
520
+ window.addEventListener('pointerup', release, true);
521
+ window.addEventListener('pointercancel', release, true);
522
+ requestAnimationFrame(() => {
523
+ const box = target.getBoundingClientRect();
524
+ if (box.width < 8 || box.height < 8) return;
525
+ const cs = getComputedStyle(target);
526
+ // md-menu items draw their shape 4px inside the row (::before): ripple there
527
+ const shape = target.matches('[role="menu"] [role^="menuitem"]') ? getComputedStyle(target, '::before') : null;
528
+ const inset = shape?.content && shape.content !== 'none' ? parseFloat(shape.left) || 0 : 0;
529
+ const r = { left: box.left + inset, top: box.top, width: box.width - inset * 2, height: box.height };
530
+ const radius = inset ? shape.borderRadius : cs.borderRadius;
531
+ if (!rippleLayer?.isConnected) {
532
+ rippleLayer = document.createElement('div');
533
+ rippleLayer.id = 'agy-ripple-layer';
534
+ rippleLayer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147482000;contain:strict;';
535
+ document.body.appendChild(rippleLayer);
536
+ }
537
+ const clip = document.createElement('div');
538
+ clip.style.cssText = `position:absolute;left:${r.left}px;top:${r.top}px;width:${r.width}px;height:${r.height}px;border-radius:${radius};overflow:hidden;`;
539
+ const endR = Math.hypot(r.width, r.height) / 2 + 10;
540
+ const startScale = (Math.max(r.width, r.height) * 0.3) / endR;
541
+ const cx = r.width / 2, cy = r.height / 2;
542
+ const ink = document.createElement('div');
543
+ ink.style.cssText = `position:absolute;left:${cx - endR}px;top:${cy - endR}px;width:${endR * 2}px;height:${endR * 2}px;border-radius:50%;background:${cs.color};opacity:0;`;
544
+ clip.appendChild(ink);
545
+ rippleLayer.appendChild(clip);
546
+ ink.animate(
547
+ [{ transform: `translate(${x - r.left - cx}px, ${y - r.top - cy}px) scale(${startScale})` }, { transform: 'none' }],
548
+ { duration: 225, easing: 'cubic-bezier(0.4, 0, 0.2, 1)', fill: 'forwards' },
549
+ );
550
+ ink.animate([{ opacity: 0 }, { opacity: PRESSED_OPACITY }], { duration: 75, fill: 'forwards' });
551
+ const fadeOut = () => {
552
+ const wait = Math.max(0, 225 - (performance.now() - start));
553
+ setTimeout(() => {
554
+ ink.animate([{ opacity: PRESSED_OPACITY }, { opacity: 0 }], { duration: 150, fill: 'forwards' })
555
+ .finished.finally(() => clip.remove());
556
+ setTimeout(() => clip.remove(), 400);
557
+ }, wait);
558
+ };
559
+ if (released) fadeOut();
560
+ else onRelease = fadeOut;
561
+ });
562
+ }
563
+
564
+ // ---- Expansion: animated open and close ------------------------------------
565
+ // The app swaps the region in and out at once. Opening grows the new region
566
+ // from 0; closing plays a clone of the old one down to 0. Both ride the M3
567
+ // Expressive springs from the tokens: height the spatial (medium) spring,
568
+ // opacity the effects (fast) one.
569
+ const COLLAPSIBLES = [
570
+ { toggle: '[data-testid="changed-files-card-header"]', card: '[data-testid="changed-files-card"]', region: ':scope > .relative:not([data-agy-ghost])' },
571
+ ];
572
+ function spring(name) {
573
+ const cs = getComputedStyle(document.documentElement);
574
+ const duration = parseFloat(cs.getPropertyValue(`--md-sys-motion-${name}-duration`));
575
+ const easing = cs.getPropertyValue(`--md-sys-motion-${name}-easing`).trim();
576
+ return duration && easing ? { duration, easing } : { duration: 300, easing: 'cubic-bezier(0.2, 0, 0, 1)' };
577
+ }
578
+ // closing holds the end state until the clone is removed; opening must not,
579
+ // or the region would keep a fixed height after it
580
+ function animateRegion(el, from, to, closing) {
581
+ const fill = closing ? 'forwards' : 'none';
582
+ el.animate({ opacity: closing ? [1, 0] : [0, 1] }, { ...spring('effect-fast'), fill });
583
+ return el.animate([{ height: `${from}px`, clipPath: 'inset(0)' }, { height: `${to}px`, clipPath: 'inset(0)' }], { ...spring('spatial-medium'), fill });
584
+ }
585
+ function onClickCapture(e) {
586
+ if (!(bundle.ripple ?? []).includes(current?.theme) || reducedMotion?.matches) return;
587
+ const t = e.target instanceof Element ? e.target : null;
588
+ for (const c of COLLAPSIBLES) {
589
+ const toggle = t?.closest(c.toggle);
590
+ if (!toggle || t.closest('button, a') && toggle.contains(t.closest('button, a'))) continue;
591
+ const card = toggle.closest(c.card);
592
+ if (!card) return;
593
+ card.querySelectorAll(':scope > [data-agy-ghost]').forEach((g) => g.remove());
594
+ const region = card.querySelector(c.region);
595
+ if (!region) {
596
+ // collapsed: React adds the region before the next frame
597
+ requestAnimationFrame(() => {
598
+ const opened = card.isConnected && card.querySelector(c.region);
599
+ if (opened) animateRegion(opened, 0, opened.getBoundingClientRect().height, false);
600
+ });
601
+ return;
602
+ }
603
+ const height = region.getBoundingClientRect().height;
604
+ const scrolled = [...region.querySelectorAll('.overflow-y-auto')].map((el) => el.scrollTop);
605
+ const ghost = region.cloneNode(true);
606
+ ghost.setAttribute('data-agy-ghost', '');
607
+ ghost.setAttribute('aria-hidden', 'true');
608
+ ghost.inert = true;
609
+ ghost.style.overflow = 'clip';
610
+ requestAnimationFrame(() => {
611
+ if (region.isConnected || !toggle.isConnected) return;
612
+ toggle.after(ghost);
613
+ ghost.querySelectorAll('.overflow-y-auto').forEach((el, i) => (el.scrollTop = scrolled[i] ?? 0));
614
+ animateRegion(ghost, height, 0, true).finished.finally(() => ghost.remove());
615
+ setTimeout(() => ghost.remove(), 900);
616
+ });
617
+ return;
618
+ }
619
+ }
620
+
621
+ // ---- Global listeners -----------------------------------------------------
622
+ function onPointerDown(e) {
623
+ startRipple(e);
624
+ if (hostEl && !e.composedPath().includes(hostEl)) closeMenu();
625
+ const transcript = document.querySelector('[data-testid="autoscroll-viewport"]');
626
+ if (transcript && !transcript.classList.contains('no-focus-agent-input')) transcript.classList.add('no-focus-agent-input');
627
+ }
628
+ function cycleTheme() {
629
+ const keys = [...options.keys()];
630
+ select(keys[(keys.indexOf(current.key) + 1) % keys.length]);
631
+ }
632
+ function onKeyDown(e) {
633
+ if (e.ctrlKey && e.shiftKey && !e.altKey && e.key === 'F12') {
634
+ cycleTheme();
635
+ e.preventDefault();
636
+ }
637
+ }
638
+ window.addEventListener('pointerdown', onPointerDown, true);
639
+ window.addEventListener('click', onClickCapture, true);
640
+ window.addEventListener('keydown', onKeyDown, true);
641
+ window.addEventListener('resize', position);
642
+ window.addEventListener('focus', syncTitleBarOverlay);
643
+ document.fonts?.addEventListener('loadingdone', position);
644
+
645
+ // Start the Overview panel's Context card; every theme gets it
646
+ const contextCard = typeof agyContextCard === 'function' ? agyContextCard() : null;
647
+ // ...and the token line under each finished turn
648
+ const turnTokens = typeof agyTurnTokens === 'function' ? agyTurnTokens() : null;
649
+ // ...and, in MD3E, the M3 loading indicator in the agent's "Working" row
650
+ const loadingIndicator = typeof agyLoadingIndicator === 'function' ? agyLoadingIndicator() : null;
651
+ // ...and wavy progress bars on running background work
652
+ const progressIndicators = typeof agyProgressIndicators === 'function' ? agyProgressIndicators() : null;
653
+ // ...and Wrap lines / Line numbers in every code block's header
654
+ const codeTools = typeof agyCodeTools === 'function' ? agyCodeTools() : null;
655
+
656
+ function dispose() {
657
+ window.removeEventListener('pointerdown', onPointerDown, true);
658
+ window.removeEventListener('click', onClickCapture, true);
659
+ window.removeEventListener('keydown', onKeyDown, true);
660
+ window.removeEventListener('resize', position);
661
+ window.removeEventListener('focus', syncTitleBarOverlay);
662
+ document.fonts?.removeEventListener('loadingdone', position);
663
+ hostEl?.remove();
664
+ document.documentElement?.removeAttribute('data-agy-appmenu');
665
+ rippleLayer?.remove();
666
+ contextCard?.dispose();
667
+ turnTokens?.dispose();
668
+ loadingIndicator?.dispose();
669
+ progressIndicators?.dispose();
670
+ codeTools?.dispose();
671
+ document.querySelectorAll('[data-agy-ghost]').forEach((g) => g.remove());
672
+ document.getElementById('agy-theme-css')?.remove();
673
+ document.documentElement?.removeAttribute('data-agy-theme');
674
+ document.documentElement?.removeAttribute('data-agy-seed');
675
+ modeObserver.disconnect();
676
+ document.documentElement?.removeAttribute('data-agy-mode');
677
+ document.querySelectorAll('[data-testid="autoscroll-viewport"].no-focus-agent-input')
678
+ .forEach((el) => el.classList.remove('no-focus-agent-input'));
679
+ syncTitleBarOverlay();
680
+ delete window.__agyThemeHost;
681
+ delete window.agyTheme;
682
+ }
683
+
684
+ const api = {
685
+ version: HOST_VERSION,
686
+ update,
687
+ dispose,
688
+ select,
689
+ get: () => current?.key,
690
+ options: () => bundle.options.map(({ key, name }) => ({ key, name })),
691
+ };
692
+ window.__agyThemeHost = api;
693
+ window.agyTheme = api;
694
+
695
+ update(bundle);
696
+ if (!document.documentElement) {
697
+ new MutationObserver((_, mo) => {
698
+ if (document.documentElement) { mo.disconnect(); apply(); }
699
+ }).observe(document, { childList: true });
700
+ }
701
+ if (document.readyState === 'loading') {
702
+ document.addEventListener('DOMContentLoaded', () => { apply(); mount(); }, { once: true });
703
+ } else {
704
+ mount();
705
+ }
706
+ return api;
707
+ }
708
+
709
+ if (typeof module === 'object' && module.exports) module.exports = agyThemeHost;