@materialwebunofficial/antigravity-themes 1.0.5 → 1.0.13

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.
package/lib/plugin.mjs ADDED
@@ -0,0 +1,98 @@
1
+ // The Antigravity plugin that carries the keeper (keeper.mjs). Antigravity
2
+ // discovers plugins in ~/.gemini/config/plugins/, outside its install folder,
3
+ // and starts their sidecars itself at every launch, with its own Node: so the
4
+ // native install survives updates with no process of ours left running.
5
+ import { cpSync, existsSync, mkdirSync, renameSync, rmdirSync, writeFileSync } from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { removeTree } from './asar.mjs';
9
+ import { installDir } from './paths.mjs';
10
+
11
+ export const PLUGIN = 'antigravity-themes';
12
+ export const pluginDir = () => path.join(os.homedir(), '.gemini', 'config', 'plugins', PLUGIN);
13
+
14
+ // What the keeper needs to build the scripts again after an update
15
+ const RUNTIME = ['lib', 'src', 'themes', 'vendor', 'package.json', 'LICENSE', 'NOTICE'];
16
+
17
+ // Material Symbols Rounded "palette"
18
+ const LOGO = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="#6f9fe8" d="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 -444Z"/></svg>
19
+ `;
20
+
21
+ const README = `# Antigravity Themes
22
+
23
+ Installed by \`antigravity-themes install --native\`. The themes themselves are
24
+ written into Antigravity's own files (resources/app); this plugin only keeps
25
+ them there. Its one sidecar, the keeper, runs once each time Antigravity
26
+ starts:
27
+
28
+ - when the themes are in place, it exits at once;
29
+ - after an Antigravity update, which replaces the app's files, it puts the
30
+ themes back and applies them to the open windows straight away.
31
+
32
+ Turning this plugin off stops the keeper: the themes then last until the next
33
+ Antigravity update. To remove everything, run
34
+ \`npx -y @materialwebunofficial/antigravity-themes uninstall\`.
35
+ `;
36
+
37
+ const KEEPER = `// Started by Antigravity with every launch (restart_policy "never": once per
38
+ // start). The work is in runtime/lib/keeper.mjs.
39
+ import { runKeeper } from '../../runtime/lib/keeper.mjs';
40
+
41
+ await runKeeper();
42
+ `;
43
+
44
+ export function installPlugin(root, { build, version }) {
45
+ const staging = path.join(installDir(), 'plugin-staging');
46
+ removeTree(staging);
47
+ mkdirSync(path.join(staging, 'sidecars', 'keeper'), { recursive: true });
48
+ mkdirSync(path.join(staging, 'assets'), { recursive: true });
49
+ for (const entry of RUNTIME) {
50
+ const from = path.join(root, entry);
51
+ if (existsSync(from)) cpSync(from, path.join(staging, 'runtime', entry), { recursive: true });
52
+ }
53
+ writeFileSync(path.join(staging, 'runtime', 'build.json'), `${JSON.stringify({ build, version }, null, 2)}\n`);
54
+ writeFileSync(path.join(staging, 'plugin.json'), `${JSON.stringify({
55
+ name: PLUGIN,
56
+ displayName: 'Antigravity Themes',
57
+ description: 'Keeps the Matte, Paper, Nordic and Material 3 Expressive themes installed across Antigravity updates.',
58
+ version,
59
+ logo: 'assets/logo.svg',
60
+ homepage: 'https://github.com/materialwebunofficial/antigravity-themes',
61
+ }, null, 2)}\n`);
62
+ writeFileSync(path.join(staging, 'README.md'), README);
63
+ writeFileSync(path.join(staging, 'assets', 'logo.svg'), LOGO);
64
+ writeFileSync(path.join(staging, 'sidecars', 'keeper', 'sidecar.json'), `${JSON.stringify({
65
+ command: 'node',
66
+ args: ['keeper.mjs'],
67
+ restart_policy: 'never',
68
+ display_name: 'Antigravity Themes keeper',
69
+ description: 'At each start, puts the themes back if an Antigravity update removed them; otherwise exits at once.',
70
+ }, null, 2)}\n`);
71
+ writeFileSync(path.join(staging, 'sidecars', 'keeper', 'keeper.mjs'), KEEPER);
72
+
73
+ // Moved in whole (same volume: a rename), so Antigravity never discovers
74
+ // half a plugin. A keeper still running keeps its folder busy: then copy.
75
+ const dest = pluginDir();
76
+ mkdirSync(path.dirname(dest), { recursive: true });
77
+ try {
78
+ if (existsSync(dest)) {
79
+ const old = path.join(installDir(), 'plugin-old');
80
+ removeTree(old);
81
+ renameSync(dest, old);
82
+ removeTree(old);
83
+ }
84
+ renameSync(staging, dest);
85
+ } catch {
86
+ cpSync(staging, dest, { recursive: true, force: true });
87
+ removeTree(staging);
88
+ }
89
+ try { rmdirSync(installDir()); } catch { /* not empty (a background install) or gone */ }
90
+ return dest;
91
+ }
92
+
93
+ export function removePlugin() {
94
+ const dest = pluginDir();
95
+ if (!existsSync(dest)) return null;
96
+ removeTree(dest);
97
+ return dest;
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@materialwebunofficial/antigravity-themes",
3
- "version": "1.0.5",
3
+ "version": "1.0.13",
4
4
  "description": "Matte, Paper, Nordic, and Material 3 Expressive themes for Antigravity Desktop, applied without changing the app's files.",
5
5
  "homepage": "https://github.com/materialwebunofficial/antigravity-themes#readme",
6
6
  "repository": {
package/src/code-tools.js CHANGED
@@ -120,7 +120,7 @@ function agyCodeTools() {
120
120
  }
121
121
 
122
122
  apply();
123
- const timer = setInterval(scan, 2000);
123
+ const timer = setInterval(scan, 10000);
124
124
  scan();
125
125
 
126
126
  return {
@@ -21,7 +21,7 @@
21
21
  // seen part-way says which step its count starts from.
22
22
  // Warnings at 80% and 95% of the window: one quiet line under the summary.
23
23
  function agyContextCard() {
24
- const WINDOWS = [[/^gemini/i, 1048576], [/^claude/i, 200000], [/gpt-oss/i, 131072]];
24
+ const WINDOWS = [[/^gemini/i, 256000], [/^claude/i, 200000], [/gpt-oss/i, 131072]];
25
25
  const LEVELS = [[0.95, 'critical', 'The context window is almost full.'],
26
26
  [0.8, 'warn', 'The context window is filling up.']];
27
27
  const GROUP_NAMES = { 1: 'System prompt', 2: 'Tools', 3: 'Rules', 4: 'MCP tools', 5: 'Skills', 6: 'Subagents', 7: 'Messages', 8: 'Workflows', 9: 'Plugins', 10: 'Flows' };
@@ -0,0 +1,282 @@
1
+ // AGY frame host: the themes inside a sidecar frame (Swarm and other plugin
2
+ // panes). Each is a cross-origin plugin:// iframe, so the main window's theme
3
+ // host can neither style it nor share its fonts; this script is injected into
4
+ // the frame itself and takes only what a frame needs: the theme's frame
5
+ // stylesheet (themes/frames/*.css) and the mode, seed and font the window
6
+ // tells it about.
7
+ //
8
+ // Protocol (postMessage with the parent window, which runs theme-host.js):
9
+ // frame -> parent { type: 'agy-theme-query', fonts?: true }
10
+ // parent -> frame { type: 'agy-theme-change', theme, seed, mode }
11
+ // parent -> frame { type: 'agy-theme-fonts', theme, fonts: [{ family, weight, stretch, buffer }] }
12
+ // The last answer is kept in localStorage, so a frame that loads later paints
13
+ // with its theme at once instead of flashing the stock look.
14
+ function agyFrameHost(bundle) {
15
+ const VERSION = 1;
16
+ const existing = window.__agyFrameHost;
17
+ if (existing?.version === VERSION) return existing.update(bundle);
18
+ existing?.dispose();
19
+ // Before 1.0.13 the window's full host ran in frames too, and the window
20
+ // wrote every one of its custom properties inline on this <html>, where they
21
+ // would outrank the frame stylesheet. Only the app's own tokens belong there.
22
+ try { window.__agyThemeHost?.dispose?.(); } catch { /* not ours to fix */ }
23
+ try {
24
+ const inline = document.documentElement.style;
25
+ for (const name of [...inline]) {
26
+ if (/^--(sw-|o2-|md-|m-|p-|n-|agy-|nord\d)/.test(name)) inline.removeProperty(name);
27
+ }
28
+ } catch { /* no root yet: nothing was written */ }
29
+
30
+ const STORE = 'agy-frame-theme';
31
+ const read = () => {
32
+ try { return JSON.parse(localStorage.getItem(STORE)) || null; } catch { return null; }
33
+ };
34
+ const save = (state) => {
35
+ try { localStorage.setItem(STORE, JSON.stringify(state)); } catch { /* storage blocked: the query answers again */ }
36
+ };
37
+
38
+ let current = null; // { theme, seed, mode }
39
+ let answered = false;
40
+ const fontsLoaded = new Set(); // themes whose font faces are registered
41
+ let fontsPending = false; // asked for them, no answer yet
42
+ const features = new Map(); // theme -> { dispose } for the theme's own script
43
+
44
+ // A query asks for the theme's fonts too while the frame lacks them; the
45
+ // window may not be listening yet (at startup the frame can load first), so
46
+ // every later query asks again until they arrive
47
+ const needsFonts = (theme) => (bundle.fonts || []).includes(theme) && !fontsLoaded.has(theme);
48
+ const ask = () => {
49
+ const fonts = needsFonts(current?.theme);
50
+ if (fonts) fontsPending = true;
51
+ try { window.parent.postMessage(fonts ? { type: 'agy-theme-query', fonts: true } : { type: 'agy-theme-query' }, '*'); } catch { /* detached */ }
52
+ };
53
+
54
+ function apply() {
55
+ const root = document.documentElement;
56
+ if (!root) return;
57
+ const theme = current?.theme;
58
+ const css = (theme && bundle.frames[theme]) || '';
59
+ let style = document.getElementById('agy-theme-css');
60
+ if (!css) {
61
+ root.removeAttribute('data-agy-theme');
62
+ root.removeAttribute('data-agy-seed');
63
+ root.removeAttribute('data-agy-mode');
64
+ style?.remove();
65
+ } else {
66
+ root.setAttribute('data-agy-theme', theme);
67
+ if (current.seed) root.setAttribute('data-agy-seed', current.seed);
68
+ else root.removeAttribute('data-agy-seed');
69
+ root.setAttribute('data-agy-mode', current.mode === 'light' ? 'light' : 'dark');
70
+ if (!style) {
71
+ style = document.createElement('style');
72
+ style.id = 'agy-theme-css';
73
+ }
74
+ if (style.textContent !== css) style.textContent = css;
75
+ // Last, after the frame's own sheets: equal-specificity ties go to the
76
+ // theme (before <head> exists it goes on <html>; moved at DOMContentLoaded)
77
+ const parent = document.head || root;
78
+ if (style.parentNode !== parent || style !== parent.lastElementChild) parent.appendChild(style);
79
+ if (needsFonts(theme) && !fontsPending) ask();
80
+ }
81
+ for (const [name, feature] of features) {
82
+ if (name !== theme) { feature.dispose(); features.delete(name); }
83
+ }
84
+ const start = theme && !features.has(theme) && FEATURES[theme];
85
+ if (start) {
86
+ try { features.set(theme, start()); } catch { /* the stylesheet still applies */ }
87
+ }
88
+ // A sidecar follows the app's theme by watching this element's inline
89
+ // style, where the SDK writes the app's tokens. The theme changes what
90
+ // those tokens resolve to without touching them, so it leaves its own mark
91
+ // there: Swarm then re-reads its light/dark scheme and hands the tokens on
92
+ // to its narrator frame.
93
+ if (css) root.style.setProperty('--agy-frame-theme', `${theme} ${current.seed ?? '-'} ${current.mode}`);
94
+ else root.style.removeProperty('--agy-frame-theme');
95
+ // md3e-web components cache their colors; this is the package's own signal
96
+ // to read the tokens again (a new seed or mode)
97
+ window.dispatchEvent(new Event('theme-change'));
98
+ }
99
+
100
+ function onMessage(e) {
101
+ const d = e.data;
102
+ if (!d || e.source !== window.parent || e.source === window) return;
103
+ if (d.type === 'agy-theme-change') {
104
+ answered = true;
105
+ const next = { theme: typeof d.theme === 'string' ? d.theme : null, seed: typeof d.seed === 'string' ? d.seed : null, mode: d.mode === 'light' ? 'light' : 'dark' };
106
+ if (current && next.theme === current.theme && next.seed === current.seed && next.mode === current.mode) {
107
+ // the theme painted from storage: only its fonts may still be missing
108
+ if (needsFonts(next.theme) && !fontsPending) ask();
109
+ return;
110
+ }
111
+ current = next;
112
+ save(current);
113
+ apply();
114
+ } else if (d.type === 'agy-theme-fonts' && Array.isArray(d.fonts) && document.fonts) {
115
+ fontsPending = false;
116
+ if (fontsLoaded.has(d.theme)) return;
117
+ fontsLoaded.add(d.theme);
118
+ for (const f of d.fonts) {
119
+ try {
120
+ const face = new FontFace(f.family, f.buffer, { weight: f.weight, stretch: f.stretch, style: 'normal', display: 'swap' });
121
+ document.fonts.add(face);
122
+ face.load().catch(() => {});
123
+ } catch {
124
+ // the theme still reads on its fallback stack
125
+ }
126
+ }
127
+ }
128
+ }
129
+ window.addEventListener('message', onMessage);
130
+
131
+ // ---- Posts written with literal \uXXXX escapes -------------------------------
132
+ // A model that passes its post as a JSON string sometimes escapes it twice,
133
+ // and the board then shows "\u00e9" where an accented letter belongs. Swarm
134
+ // prints a post as it was stored, so the escapes are decoded here, in the
135
+ // text nodes of what the page adds (never inside code: there an escape is
136
+ // meant). Every theme gets this, the app's own included. Swarm clamps a post
137
+ // to two lines by measuring it, which it did on the longer text: measured
138
+ // again.
139
+ const ESCAPE = /\\u([0-9a-fA-F]{4})/g;
140
+ function decodeEscapes(node) {
141
+ if (node.nodeType !== 1 || !node.textContent.includes('\\u')) return;
142
+ const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
143
+ const clamps = new Set();
144
+ for (let text = walker.nextNode(); text; text = walker.nextNode()) {
145
+ const value = text.nodeValue;
146
+ if (!value.includes('\\u') || text.parentElement?.closest('code, pre, script, style')) continue;
147
+ const decoded = value.replace(ESCAPE, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
148
+ if (decoded === value) continue;
149
+ text.nodeValue = decoded;
150
+ const body = text.parentElement?.closest('.feed .msg-body');
151
+ if (body) clamps.add(body);
152
+ }
153
+ for (const body of clamps) {
154
+ const open = body.classList.contains('open');
155
+ body.classList.add('clamped');
156
+ body.classList.remove('open');
157
+ const overflows = body.scrollHeight > body.clientHeight + 4;
158
+ if (open) body.classList.add('open');
159
+ body.classList.toggle('clamped', overflows);
160
+ body.nextElementSibling?.classList.toggle('hidden', !overflows);
161
+ }
162
+ }
163
+ const escapeObserver = new MutationObserver((records) => {
164
+ for (const record of records) {
165
+ for (const node of record.addedNodes) decodeEscapes(node);
166
+ }
167
+ });
168
+ function watchEscapes() {
169
+ if (!document.body) return;
170
+ decodeEscapes(document.body);
171
+ escapeObserver.observe(document.body, { childList: true, subtree: true });
172
+ }
173
+ if (document.body) watchEscapes();
174
+ else document.addEventListener('DOMContentLoaded', watchEscapes, { once: true });
175
+
176
+ // ---- MD3E: the Live status is the package's <md-loading-indicator> --------
177
+ // Swarm marks "updates automatically" with a pulsing dot. M3 Expressive has
178
+ // a component for exactly that, live activity: the loading indicator. It
179
+ // rests as its determinate shape (one drawn frame, no animation loop), morphs
180
+ // for three steps when new posts arrive, and runs while the board is
181
+ // reconnecting. frames/md3e.css hides the dot it replaces.
182
+ function md3eFrame() {
183
+ if (!customElements.get('md-loading-indicator')) {
184
+ try { bundle.md3e?.(); } catch { return { dispose() {} }; }
185
+ if (!customElements.get('md-loading-indicator')) return { dispose() {} };
186
+ }
187
+ const MORPH_MS = 650 * 3;
188
+ let live = null, indicator = null, still = null, observer = null, timer = 0, mounted = false;
189
+ // At rest the indicator is determinate: one drawn frame, no frame loop
190
+ const make = () => {
191
+ const el = document.createElement('md-loading-indicator');
192
+ el.className = 'agy-live';
193
+ el.setAttribute('size', '24');
194
+ el.setAttribute('progress', '1');
195
+ el.setAttribute('aria-hidden', 'true'); // the label next to it says it
196
+ return el;
197
+ };
198
+
199
+ function sync() {
200
+ if (!indicator) return;
201
+ const busy = live.classList.contains('stale') || timer !== 0;
202
+ if (busy) indicator.removeAttribute('progress');
203
+ else if (indicator.getAttribute('progress') !== '1') indicator.setAttribute('progress', '1');
204
+ }
205
+ function onClass() {
206
+ if (live.classList.contains('flash') && !timer) {
207
+ timer = setTimeout(() => { timer = 0; sync(); }, MORPH_MS);
208
+ }
209
+ sync();
210
+ }
211
+ function mount() {
212
+ if (mounted) return;
213
+ live = document.getElementById('live');
214
+ if (!live) return;
215
+ mounted = true;
216
+ indicator = make();
217
+ live.prepend(indicator);
218
+ // "Watching for a swarm", on the empty state: the same mark, still
219
+ const hint = document.querySelector('.empty-hint');
220
+ if (hint) {
221
+ still = make();
222
+ hint.prepend(still);
223
+ }
224
+ observer = new MutationObserver(onClass);
225
+ observer.observe(live, { attributes: true, attributeFilter: ['class'] });
226
+ sync();
227
+ }
228
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true });
229
+ else mount();
230
+
231
+ return {
232
+ dispose() {
233
+ document.removeEventListener('DOMContentLoaded', mount);
234
+ observer?.disconnect();
235
+ clearTimeout(timer);
236
+ indicator?.remove();
237
+ still?.remove();
238
+ mounted = true; // a late DOMContentLoaded must not mount after dispose
239
+ },
240
+ };
241
+ }
242
+ const FEATURES = { md3e: md3eFrame };
243
+
244
+ function update(nextBundle) {
245
+ bundle = nextBundle;
246
+ apply();
247
+ return api;
248
+ }
249
+ function dispose() {
250
+ window.removeEventListener('message', onMessage);
251
+ document.removeEventListener('DOMContentLoaded', watchEscapes);
252
+ escapeObserver.disconnect();
253
+ for (const feature of features.values()) feature.dispose();
254
+ features.clear();
255
+ document.getElementById('agy-theme-css')?.remove();
256
+ const root = document.documentElement;
257
+ root?.removeAttribute('data-agy-theme');
258
+ root?.removeAttribute('data-agy-seed');
259
+ root?.removeAttribute('data-agy-mode');
260
+ root?.style.removeProperty('--agy-frame-theme');
261
+ delete window.__agyFrameHost;
262
+ delete window.__agyFrameScript; // the frame script may run here again
263
+ }
264
+
265
+ const api = { version: VERSION, update, dispose, get: () => current?.theme ?? null };
266
+ window.__agyFrameHost = api;
267
+
268
+ current = read();
269
+ apply();
270
+ // Injected at the start of the document, the stylesheet may have gone on
271
+ // <html> ahead of the frame's own: put it last in <head> once it exists
272
+ if (document.readyState === 'loading') {
273
+ document.addEventListener('DOMContentLoaded', () => { if (window.__agyFrameHost === api) apply(); }, { once: true });
274
+ }
275
+ // The window's host may start after this frame (the injector reaches the
276
+ // targets in any order): ask again until it answers
277
+ ask();
278
+ for (const delay of [400, 1500, 4000]) setTimeout(() => { if (!answered && window.__agyFrameHost === api) ask(); }, delay);
279
+ return api;
280
+ }
281
+
282
+ if (typeof module === 'object' && module.exports) module.exports = agyFrameHost;
@@ -60,7 +60,7 @@ function agyLoadingIndicator() {
60
60
  viewport?.querySelectorAll(ROW).forEach(decorate);
61
61
  }
62
62
 
63
- const timer = setInterval(scan, 2000);
63
+ const timer = setInterval(scan, 10000);
64
64
  scan();
65
65
 
66
66
  return {
@@ -84,7 +84,7 @@ function agyProgressIndicators() {
84
84
  }
85
85
  }
86
86
 
87
- const timer = setInterval(scan, 2000);
87
+ const timer = setInterval(scan, 10000);
88
88
  scan();
89
89
 
90
90
  return {
@@ -96,3 +96,51 @@ function agyProgressIndicators() {
96
96
  },
97
97
  };
98
98
  }
99
+
100
+ // Usage rings (Settings > Models & Usage, the model menu's usage view): M3
101
+ // Expressive's circular indicator leaves a gap between the arc and its
102
+ // track; the app draws a full track circle under the arc. In MD3E the track
103
+ // is cut to what the arc leaves, minus the gap at both ends, through two
104
+ // variables md3e.css reads (other themes ignore them). A 1ms CSS animation on
105
+ // the arc announces each ring as it mounts, so nothing watches the page; one
106
+ // observer follows the value of the rings already seen.
107
+ function agyQuotaRings() {
108
+ const ARC = '[data-testid="quota-progress-circle"]';
109
+ const GAP = 4; // M3's indicator-track gap, px (the ring is drawn 1:1)
110
+ const style = document.createElement('style');
111
+ style.id = 'agy-rings-css';
112
+ style.textContent = `
113
+ :root[data-agy-theme="md3e"] ${ARC} { animation: agy-ring-mount 1ms; }
114
+ @keyframes agy-ring-mount { to { opacity: 1; } }`;
115
+ (document.head || document.documentElement).appendChild(style);
116
+
117
+ function fit(arc) {
118
+ const track = arc.parentElement?.querySelector('circle:first-child');
119
+ if (!track || track === arc) return;
120
+ const length = parseFloat(arc.getAttribute('stroke-dasharray'));
121
+ const offset = parseFloat(arc.getAttribute('stroke-dashoffset')) || 0;
122
+ if (!(length > 0)) return;
123
+ const cap = (parseFloat(getComputedStyle(arc).strokeWidth) || 4) / 2;
124
+ const value = Math.max(0, length - offset);
125
+ const skip = value ? GAP + 2 * cap : 0; // round caps reach past both ends
126
+ const rest = Math.max(0, length - value - 2 * skip);
127
+ track.style.setProperty('--agy-track-dash', `${rest.toFixed(2)} ${length.toFixed(2)}`);
128
+ track.style.setProperty('--agy-track-offset', `${(-(value + skip)).toFixed(2)}`);
129
+ track.style.setProperty('--agy-track-opacity', rest > 0.5 ? '1' : '0'); // a 0-length dash still paints its round caps
130
+ }
131
+ const observer = new MutationObserver((records) => records.forEach((r) => fit(r.target)));
132
+ function onAnimationStart(e) {
133
+ if (e.animationName !== 'agy-ring-mount') return;
134
+ fit(e.target);
135
+ observer.observe(e.target, { attributes: true, attributeFilter: ['stroke-dashoffset', 'stroke-dasharray'] });
136
+ }
137
+ document.addEventListener('animationstart', onAnimationStart, true);
138
+
139
+ return {
140
+ dispose() {
141
+ document.removeEventListener('animationstart', onAnimationStart, true);
142
+ observer.disconnect();
143
+ style.remove();
144
+ },
145
+ };
146
+ }