@swiftbrowser/web 0.5.6 → 0.6.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,77 @@
1
+ /**
2
+ * The site's layout, and what the app page knows about the site it is in.
3
+ *
4
+ * A site holds one app or several (node/config.ts, `siteLayout`):
5
+ *
6
+ * - `app`: one app, the site is that app. The app page is `/` (the dev server
7
+ * serves it there; a build writes it as `index.html` and nothing at `/app/`),
8
+ * there is no landing page, and the site's `/manifest.webmanifest` is the
9
+ * app's own, starting at `/`.
10
+ * - `landing`: the landing page at `/` lists the apps, each at `/app/?app=<name>`.
11
+ *
12
+ * Both layouts give the app page `window.__SB_SITE__` (the layout, the app
13
+ * the page runs without `?app=`, its display name, and the page mode a wide
14
+ * screen gets), which the inline scripts of app/index.html read before the
15
+ * first paint; src/main.ts gets the same through `define`s.
16
+ */
17
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
18
+ import path from 'node:path';
19
+ import { appManifestJson } from './app-manifests.js';
20
+ /** The manifest an `app` layout site serves at its root, and the start URL its pages use. */
21
+ export const ROOT_START_URL = '/';
22
+ const isAppPage = (pathname) => /(^|\/)app\/(index\.html)?$/.test(pathname.split('?')[0] ?? '');
23
+ /** The `pathname?query` of a request to the app page that serves `url` in the `app` layout, or null. */
24
+ export function rewriteRootRequest(url) {
25
+ const [pathname = '', query] = url.split('?', 2);
26
+ if (pathname !== '/' && pathname !== '/index.html')
27
+ return null;
28
+ return `/app/${query !== undefined ? `?${query}` : ''}`;
29
+ }
30
+ export function siteLayoutPlugin(info, app) {
31
+ const single = info.layout === 'app' ? app : undefined;
32
+ const rootManifest = single ? appManifestJson(single, { startUrl: ROOT_START_URL }) : null;
33
+ let outDir = '';
34
+ return {
35
+ name: 'swiftbrowser:site-layout',
36
+ configResolved(config) {
37
+ outDir = path.resolve(config.root, config.build.outDir);
38
+ },
39
+ configureServer(server) {
40
+ if (!rootManifest)
41
+ return;
42
+ // Before Vite's own middlewares: `/` is the app page, and the root
43
+ // manifest is the app's (over the one in public/).
44
+ server.middlewares.use((req, res, next) => {
45
+ const url = req.url ?? '';
46
+ if (url.split('?')[0] === '/manifest.webmanifest') {
47
+ res.setHeader('Content-Type', 'application/manifest+json');
48
+ res.setHeader('Cache-Control', 'no-cache');
49
+ res.end(rootManifest);
50
+ return;
51
+ }
52
+ const rewritten = rewriteRootRequest(url);
53
+ if (rewritten !== null)
54
+ req.url = rewritten;
55
+ next();
56
+ });
57
+ },
58
+ transformIndexHtml(_html, ctx) {
59
+ if (!isAppPage(ctx.path))
60
+ return;
61
+ return [{ tag: 'script', injectTo: 'head-prepend', children: `window.__SB_SITE__ = ${JSON.stringify(info)};` }];
62
+ },
63
+ closeBundle() {
64
+ if (!rootManifest)
65
+ return;
66
+ // After everything is written (Vite's bundle, then public/): the app
67
+ // page becomes the site's root page. `app/` stays for the OAuth callback
68
+ // page public/ puts there (src/webAuth.ts).
69
+ const appPage = path.join(outDir, 'app', 'index.html');
70
+ if (existsSync(appPage))
71
+ renameSync(appPage, path.join(outDir, 'index.html'));
72
+ mkdirSync(outDir, { recursive: true });
73
+ writeFileSync(path.join(outDir, 'manifest.webmanifest'), rootManifest);
74
+ },
75
+ };
76
+ }
77
+ //# sourceMappingURL=site-layout.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"site-layout.js","sourceRoot":"","sources":["../../../plugins/site-layout.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
@@ -49,11 +49,26 @@ export function manifestIconPath(app: string): string {
49
49
  return `/${MANIFEST_DIR}/${app}-icon.png`;
50
50
  }
51
51
 
52
- /** The app page URL that runs `app` (matches `appPath` in src/landing.ts). */
52
+ /** The app page URL that runs `app` in the `landing` layout (matches `appPath` in src/landing.ts). */
53
53
  export function appStartUrl(app: string): string {
54
54
  return `/app/?app=${encodeURIComponent(app)}`;
55
55
  }
56
56
 
57
+ export interface ManifestOptions {
58
+ /**
59
+ * Where the app starts, and the manifest's scope (its directory): the app
60
+ * page with `?app=` by default; `/` in a site that is the one app
61
+ * (plugins/site-layout.ts).
62
+ */
63
+ startUrl?: string;
64
+ }
65
+
66
+ /** The scope of a manifest starting at `start`: the directory of the URL's path. */
67
+ function scopeOf(start: string): string {
68
+ const pathname = start.split('?')[0] ?? '/';
69
+ return pathname.endsWith('/') ? pathname : pathname.slice(0, pathname.lastIndexOf('/') + 1) || '/';
70
+ }
71
+
57
72
  /** Default description, for a spec without one. */
58
73
  export function defaultDescription(spec: ManifestSpec): string {
59
74
  return `The ${displayNameOf(spec)} app, compiled to WebAssembly and rendered in the browser.`;
@@ -74,10 +89,10 @@ function iconSizes(file: string): string {
74
89
  * and the page draws its own background under it, and the light/dark
75
90
  * `theme-color` metas in app/index.html cover the browser UI.
76
91
  */
77
- export function appManifest(spec: ManifestSpec | string): AppManifest {
92
+ export function appManifest(spec: ManifestSpec | string, options: ManifestOptions = {}): AppManifest {
78
93
  const app: ManifestSpec = typeof spec === 'string' ? { name: spec } : spec;
79
94
  if (!APP_NAME.test(app.name)) throw new Error(`Invalid app name: ${JSON.stringify(app.name)}`);
80
- const start = appStartUrl(app.name);
95
+ const start = options.startUrl ?? appStartUrl(app.name);
81
96
  const displayName = displayNameOf(app);
82
97
  return {
83
98
  id: start,
@@ -85,7 +100,7 @@ export function appManifest(spec: ManifestSpec | string): AppManifest {
85
100
  short_name: displayName,
86
101
  description: app.description ?? defaultDescription(app),
87
102
  start_url: start,
88
- scope: '/app/',
103
+ scope: scopeOf(start),
89
104
  display: 'standalone',
90
105
  orientation: 'portrait',
91
106
  background_color: '#000000',
@@ -98,8 +113,8 @@ export function appManifest(spec: ManifestSpec | string): AppManifest {
98
113
  };
99
114
  }
100
115
 
101
- export function appManifestJson(spec: ManifestSpec | string): string {
102
- return JSON.stringify(appManifest(spec), null, 2) + '\n';
116
+ export function appManifestJson(spec: ManifestSpec | string, options: ManifestOptions = {}): string {
117
+ return JSON.stringify(appManifest(spec, options), null, 2) + '\n';
103
118
  }
104
119
 
105
120
  /** The app named by a manifest request URL, or null for any other URL. */
@@ -121,7 +136,11 @@ export function appIconUrls(specs: readonly ManifestSpec[]): Record<string, stri
121
136
  return icons;
122
137
  }
123
138
 
124
- export function appManifests(specs: readonly ManifestSpec[], included: readonly { name: string; icon?: string }[] = []): Plugin {
139
+ export function appManifests(
140
+ specs: readonly ManifestSpec[],
141
+ included: readonly { name: string; icon?: string }[] = [],
142
+ options: ManifestOptions = {},
143
+ ): Plugin {
125
144
  const byName = new Map(specs.map((spec) => [spec.name, spec]));
126
145
  // The apps of included sites (plugins/included-sites.ts) bring their icons.
127
146
  const icons = { ...appIconUrls(specs), ...Object.fromEntries(included.flatMap((app) => (app.icon ? [[app.name, app.icon]] : []))) };
@@ -142,7 +161,7 @@ export function appManifests(specs: readonly ManifestSpec[], included: readonly
142
161
  }
143
162
  res.setHeader('Content-Type', 'application/manifest+json');
144
163
  res.setHeader('Cache-Control', 'no-cache');
145
- res.end(appManifestJson(spec));
164
+ res.end(appManifestJson(spec, options));
146
165
  return;
147
166
  }
148
167
  const iconApp = iconRequestApp(url);
@@ -174,7 +193,7 @@ export function appManifests(specs: readonly ManifestSpec[], included: readonly
174
193
  this.emitFile({
175
194
  type: 'asset',
176
195
  fileName: `${MANIFEST_DIR}/${spec.name}.webmanifest`,
177
- source: appManifestJson(spec),
196
+ source: appManifestJson(spec, options),
178
197
  });
179
198
  if (spec.icon) {
180
199
  if (!existsSync(spec.icon)) {
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The site's layout, and what the app page knows about the site it is in.
3
+ *
4
+ * A site holds one app or several (node/config.ts, `siteLayout`):
5
+ *
6
+ * - `app`: one app, the site is that app. The app page is `/` (the dev server
7
+ * serves it there; a build writes it as `index.html` and nothing at `/app/`),
8
+ * there is no landing page, and the site's `/manifest.webmanifest` is the
9
+ * app's own, starting at `/`.
10
+ * - `landing`: the landing page at `/` lists the apps, each at `/app/?app=<name>`.
11
+ *
12
+ * Both layouts give the app page `window.__SB_SITE__` (the layout, the app
13
+ * the page runs without `?app=`, its display name, and the page mode a wide
14
+ * screen gets), which the inline scripts of app/index.html read before the
15
+ * first paint; src/main.ts gets the same through `define`s.
16
+ */
17
+
18
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
19
+ import path from 'node:path';
20
+ import type { Plugin, ResolvedConfig } from 'vite';
21
+ import { appManifestJson, type ManifestSpec } from './app-manifests.js';
22
+
23
+ export type SiteLayout = 'app' | 'landing';
24
+
25
+ /**
26
+ * What the app page shows on a wide screen (a desktop browser): `auto` is the
27
+ * iPhone frame with the dev controls, device mode on a touch screen; `device`
28
+ * fills the window everywhere, as a deployed app should (src/deviceMode.ts).
29
+ * `?mode=` on the URL overrides either.
30
+ */
31
+ export type SitePageMode = 'auto' | 'device';
32
+
33
+ /** `window.__SB_SITE__` on the app page. */
34
+ export interface SiteInfo {
35
+ layout: SiteLayout;
36
+ /** The app the page runs when the URL names none. */
37
+ defaultApp: string;
38
+ /** Its display name (the document title in the `app` layout). */
39
+ displayName: string;
40
+ pageMode: SitePageMode;
41
+ }
42
+
43
+ /** The manifest an `app` layout site serves at its root, and the start URL its pages use. */
44
+ export const ROOT_START_URL = '/';
45
+
46
+ const isAppPage = (pathname: string): boolean => /(^|\/)app\/(index\.html)?$/.test(pathname.split('?')[0] ?? '');
47
+
48
+ /** The `pathname?query` of a request to the app page that serves `url` in the `app` layout, or null. */
49
+ export function rewriteRootRequest(url: string): string | null {
50
+ const [pathname = '', query] = url.split('?', 2);
51
+ if (pathname !== '/' && pathname !== '/index.html') return null;
52
+ return `/app/${query !== undefined ? `?${query}` : ''}`;
53
+ }
54
+
55
+ export function siteLayoutPlugin(info: SiteInfo, app: ManifestSpec | undefined): Plugin {
56
+ const single = info.layout === 'app' ? app : undefined;
57
+ const rootManifest = single ? appManifestJson(single, { startUrl: ROOT_START_URL }) : null;
58
+ let outDir = '';
59
+ return {
60
+ name: 'swiftbrowser:site-layout',
61
+ configResolved(config: ResolvedConfig) {
62
+ outDir = path.resolve(config.root, config.build.outDir);
63
+ },
64
+ configureServer(server) {
65
+ if (!rootManifest) return;
66
+ // Before Vite's own middlewares: `/` is the app page, and the root
67
+ // manifest is the app's (over the one in public/).
68
+ server.middlewares.use((req, res, next) => {
69
+ const url = req.url ?? '';
70
+ if (url.split('?')[0] === '/manifest.webmanifest') {
71
+ res.setHeader('Content-Type', 'application/manifest+json');
72
+ res.setHeader('Cache-Control', 'no-cache');
73
+ res.end(rootManifest);
74
+ return;
75
+ }
76
+ const rewritten = rewriteRootRequest(url);
77
+ if (rewritten !== null) req.url = rewritten;
78
+ next();
79
+ });
80
+ },
81
+ transformIndexHtml(_html, ctx) {
82
+ if (!isAppPage(ctx.path)) return;
83
+ return [{ tag: 'script', injectTo: 'head-prepend', children: `window.__SB_SITE__ = ${JSON.stringify(info)};` }];
84
+ },
85
+ closeBundle() {
86
+ if (!rootManifest) return;
87
+ // After everything is written (Vite's bundle, then public/): the app
88
+ // page becomes the site's root page. `app/` stays for the OAuth callback
89
+ // page public/ puts there (src/webAuth.ts).
90
+ const appPage = path.join(outDir, 'app', 'index.html');
91
+ if (existsSync(appPage)) renameSync(appPage, path.join(outDir, 'index.html'));
92
+ mkdirSync(outDir, { recursive: true });
93
+ writeFileSync(path.join(outDir, 'manifest.webmanifest'), rootManifest);
94
+ },
95
+ };
96
+ }
package/src/animation.ts CHANGED
@@ -25,6 +25,12 @@
25
25
  *
26
26
  * `Animation` maps to CSS easings; `spring` becomes a `linear()` easing
27
27
  * sampled from a damped spring where supported.
28
+ *
29
+ * iOS 26's bar toolbars animate on their own: when an item comes or goes,
30
+ * or an item resizes, the glass capsule grows or shrinks with a spring and
31
+ * the items slide, the new one scaling and fading in, the old one out, in a
32
+ * pass nothing else animates (`implicitToolbars`). The commit's animation,
33
+ * or an `.animation` on the toolbar, wins when there is one.
28
34
  */
29
35
 
30
36
  import type { Animation, Edge, Transition, TransitionPart } from './protocol';
@@ -278,6 +284,68 @@ function springPosition(t: number, omega: number, zeta: number): number {
278
284
  /** SwiftUI's default transition for views inserted or removed with animation. */
279
285
  export const DEFAULT_TRANSITION: Transition = { kind: 'opacity' };
280
286
 
287
+ /** The spring a bar toolbar's groups change with (iOS 26), and how its items come and go. */
288
+ export const TOOLBAR_ANIMATION: Animation = { type: 'spring', duration: 0.5, bounce: 0.2 };
289
+ export const TOOLBAR_TRANSITION: Transition = { kind: 'scale', scale: 0.6 };
290
+ /** The `kind` of a bottom bar glass capsule's frame change (src/layoutDom.ts): chrome, not an element. */
291
+ export const BOTTOM_GLASS_KIND = 'bottomglass';
292
+
293
+ const BAR_PLACEMENTS: ReadonlySet<string> = new Set(['leading', 'trailing', 'principal', 'bottom']);
294
+
295
+ /** Whether `el` is a toolbar group in a navigation bar or bottom bar (not a `ToolbarSpacer`). */
296
+ export function isBarToolbar(el: Element | null | undefined): el is HTMLElement {
297
+ return (
298
+ el instanceof HTMLElement &&
299
+ el.classList.contains('sb-toolbar') &&
300
+ !el.hasAttribute('data-sb-spacer') &&
301
+ BAR_PLACEMENTS.has(el.dataset['sbPlacement'] ?? '')
302
+ );
303
+ }
304
+
305
+ /** The bar toolbar `el` is, or is an item of (`parent`: where a removed item was). */
306
+ export function barToolbarOf(el: HTMLElement, parent: Element | null = el.parentElement): HTMLElement | null {
307
+ if (isBarToolbar(el)) return el;
308
+ return isBarToolbar(parent) ? parent : null;
309
+ }
310
+
311
+ /** The transition a view without one of its own enters and leaves with: a bar item's scales. */
312
+ function implicitTransitionOf(el: HTMLElement, parent: Element | null = el.parentElement): Transition | undefined {
313
+ return barToolbarOf(el, parent) ? TOOLBAR_TRANSITION : undefined;
314
+ }
315
+
316
+ /**
317
+ * The bar toolbars whose groups this pass changed, each with its screen: one
318
+ * that gained or lost an item (or came or went itself, on a screen already
319
+ * there), or whose capsule or an item of it resized. A group that only
320
+ * moved (the screen resized, a More button appeared beside it) is not one:
321
+ * its items are as they were.
322
+ */
323
+ export function implicitToolbars(pass: PassRecord, frames: readonly FrameChange[]): Map<HTMLElement, Element | null> {
324
+ const out = new Map<HTMLElement, Element | null>();
325
+ const isNew = (toolbar: HTMLElement): boolean => pass.inserted.has(toolbar) || hasAncestorIn(toolbar, pass.inserted);
326
+ for (const change of frames) {
327
+ const toolbar = barToolbarOf(change.el);
328
+ if (!toolbar || !change.from || !change.el.isConnected || isNew(toolbar)) continue;
329
+ const { from, to } = change;
330
+ if (change.el === toolbar && from.width === to.width && from.height === to.height) continue;
331
+ out.set(toolbar, toolbar.closest('.sb-navscreen'));
332
+ }
333
+ for (const el of pass.inserted) {
334
+ const toolbar = barToolbarOf(el);
335
+ if (!toolbar || !el.isConnected || hasAncestorIn(toolbar, pass.inserted)) continue;
336
+ // A whole new group enters as one; an item of a group already there is the group's change.
337
+ if (el !== toolbar && pass.inserted.has(toolbar)) continue;
338
+ out.set(toolbar, toolbar.closest('.sb-navscreen'));
339
+ }
340
+ for (const rec of pass.removed) {
341
+ const toolbar = barToolbarOf(rec.el, rec.ancestors[0] ?? null);
342
+ if (!toolbar) continue;
343
+ // A removed group is detached: its screen is among the ancestors it had.
344
+ out.set(toolbar, toolbar.isConnected ? toolbar.closest('.sb-navscreen') : (rec.ancestors.find((a) => a.classList.contains('sb-navscreen')) ?? null));
345
+ }
346
+ return out;
347
+ }
348
+
281
349
  interface TransitionFrame {
282
350
  opacity?: number;
283
351
  transform?: string;
@@ -426,6 +494,14 @@ export class Animator {
426
494
  for (const [el, rec] of pass.updated) {
427
495
  if (rec.tokenChanged) tokenRoots.set(el, rec.implicit ? commitAnimation : rec.animation);
428
496
  }
497
+ // iOS 26: a bar toolbar's groups change with a spring of their own (see
498
+ // the top of the file); the bottom bar's glass capsules, beside their
499
+ // toolbars rather than around them, follow the screen's bottom toolbars.
500
+ const glassScreens = new Set<Element | null>();
501
+ for (const [toolbar, screen] of implicitToolbars(pass, frames)) {
502
+ if (!tokenRoots.has(toolbar)) tokenRoots.set(toolbar, commitAnimation ?? TOOLBAR_ANIMATION);
503
+ if (toolbar.dataset['sbPlacement'] === 'bottom') glassScreens.add(screen);
504
+ }
429
505
  if (!commitAnimation && tokenRoots.size === 0) {
430
506
  // Not animated: frames still tweening toward a box this pass replaced
431
507
  // carry on to the new box in the time they had left.
@@ -472,7 +548,10 @@ export class Animator {
472
548
  const { el, from, to } = change;
473
549
  if (!from || !el.isConnected || pass.inserted.has(el)) continue;
474
550
  if (this.selfAnimating.has(change.kind)) continue;
475
- const animation = animationFor(ancestorsAndSelf(el));
551
+ let animation = animationFor(ancestorsAndSelf(el));
552
+ if (animation === false && change.kind === BOTTOM_GLASS_KIND && glassScreens.has(el.parentElement)) {
553
+ animation = TOOLBAR_ANIMATION;
554
+ }
476
555
  if (animation === false) {
477
556
  // Moved in place, unless it carries on with a tween of its own.
478
557
  if (!this.retargetFrame(change, null)) jumped.set(el, { x: to.x - from.x, y: to.y - from.y });
@@ -524,7 +603,7 @@ export class Animator {
524
603
  if (this.selfAnimating.has(el.dataset['sbKind'] ?? '')) continue;
525
604
  const animation = animationFor(ancestorsAndSelf(el));
526
605
  if (animation === false) continue;
527
- this.enter(el, transitionOf(el), animation);
606
+ this.enter(el, transitionOf(el) ?? implicitTransitionOf(el), animation);
528
607
  }
529
608
 
530
609
  // Content transitions: the old text leaves as the new one arrives.
@@ -785,10 +864,14 @@ export class Animator {
785
864
  * play its transition backwards; the clone is dropped when done.
786
865
  */
787
866
  exit(rec: RemovedRecord, animation: Animation | undefined): HTMLElement | null {
788
- const end = transitionStart(rec.transition, true);
867
+ const parent = rec.ancestors[0] ?? null;
868
+ const end = transitionStart(rec.transition ?? implicitTransitionOf(rec.el, parent), true);
789
869
  if (isIdentity(end) || !rec.rect) return null;
790
870
  const clone = ghostClone(rec.el);
791
871
  clone.classList.add(EXIT_CLONE_CLASS);
872
+ // A bar item's clone leaves its toolbar: the stylesheet draws it as the
873
+ // toolbar did (the symbol's size and weight, the label color).
874
+ if (rec.el !== parent && isBarToolbar(parent)) clone.dataset['sbBarItem'] = parent.dataset['sbPlacement'] ?? '';
792
875
  clone.setAttribute('aria-hidden', 'true');
793
876
  clone.toggleAttribute('inert', true);
794
877
  const s = clone.style;
package/src/deviceMode.ts CHANGED
@@ -9,7 +9,10 @@
9
9
  *
10
10
  * `?mode=device` / `?mode=frame` force a mode; otherwise a coarse pointer
11
11
  * means a touch device at any width (Phase 7: a tablet fills the viewport
12
- * instead of showing the phone bezel; the old 500 px limit is gone). The
12
+ * instead of showing the phone bezel; the old 500 px limit is gone), and a
13
+ * site built for a deployment (`__SB_PAGE_MODE__` of `device`, see
14
+ * plugins/site-layout.ts) is device mode on every screen: a desktop browser
15
+ * gets the app's regular-width (iPad) layout, not a phone in a bezel. The
13
16
  * inline script in app/index.html applies the same rule before the first
14
17
  * paint.
15
18
  */
@@ -18,15 +21,19 @@ import type { EdgeInsets } from './layout';
18
21
 
19
22
  export type PageMode = 'frame' | 'device';
20
23
 
24
+ /** The site's default for a fine pointer: `auto` is the frame, `device` the full window. */
25
+ export type DefaultPageMode = 'auto' | 'device';
26
+
21
27
  export function decidePageMode(
22
28
  search: string = location.search,
23
29
  win: Pick<Window, 'matchMedia'> = window,
30
+ defaultMode: DefaultPageMode = 'auto',
24
31
  ): PageMode {
25
32
  const mode = new URLSearchParams(search).get('mode');
26
33
  if (mode === 'device') return 'device';
27
34
  if (mode === 'frame') return 'frame';
28
35
  const coarse = typeof win.matchMedia === 'function' && win.matchMedia('(pointer: coarse)').matches;
29
- return coarse ? 'device' : 'frame';
36
+ return coarse || defaultMode === 'device' ? 'device' : 'frame';
30
37
  }
31
38
 
32
39
  /** Mark the document so styles.css can switch the chrome. */
package/src/gaugeRing.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  * (the `ring` chrome rect). In a 100-unit box: a capacity gauge is a full
5
5
  * track with the value filled clockwise from the top; an `accessoryCircular`
6
6
  * gauge is an arc of 270° open at the bottom, filled from its start to the
7
- * value. The track is the fill color, the value the tint (`--sb-tint`, else
8
- * the accent color), with round caps (styles.css).
7
+ * value. The value is the tint (`--sb-tint`, else the primary color), the
8
+ * track the same at 35%, with round caps (styles.css).
9
9
  */
10
10
 
11
11
  const SVG_NS = 'http://www.w3.org/2000/svg';