@adukiorg/anza 0.4.1 → 0.4.3

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.
Files changed (43) hide show
  1. package/bin/anza/anza-linux-arm64 +0 -0
  2. package/bin/anza/anza-linux-x64 +0 -0
  3. package/bin/anza/anza-macos-arm64 +0 -0
  4. package/bin/anza/anza-macos-x64 +0 -0
  5. package/bin/anza/anza-windows-x64.exe +0 -0
  6. package/package.json +1 -1
  7. package/src/core/api/index.js +2 -2
  8. package/src/core/offline/sync.js +2 -2
  9. package/src/core/router/cascade.js +23 -18
  10. package/src/core/router/container.js +6 -2
  11. package/src/core/router/graph.js +46 -15
  12. package/src/core/router/index.js +7 -1
  13. package/src/core/router/intercept.js +397 -100
  14. package/src/core/router/match.js +30 -0
  15. package/src/core/router/transitions.js +4 -3
  16. package/src/core/ui/define/container.js +3 -13
  17. package/src/core/ui/define/element.js +58 -8
  18. package/src/core/ui/define/orchestrator.js +8 -4
  19. package/src/core/ui/defs/dock.js +27 -14
  20. package/src/core/ui/defs/page.js +75 -29
  21. package/src/core/ui/defs/spec.js +81 -11
  22. package/src/elements/data/list/style.css +1 -1
  23. package/src/elements/data/table/index.js +2 -1
  24. package/src/elements/data/table/style.css +1 -1
  25. package/src/elements/feedback/alert/style.css +1 -1
  26. package/src/styles/base.css +22 -21
  27. package/src/styles/reset.css +0 -2
  28. package/src/tokens/index.css +1 -4
  29. package/src/tokens/primitives/colors.css +9 -64
  30. package/src/tokens/primitives/motion.css +6 -18
  31. package/src/tokens/primitives/spacing.css +7 -12
  32. package/src/tokens/primitives/typography.css +12 -42
  33. package/src/tokens/registered/colors.css +5 -96
  34. package/src/tokens/registered/dimensions.css +6 -19
  35. package/src/tokens/semantic/contrast.css +8 -36
  36. package/src/tokens/semantic/dark.css +13 -48
  37. package/src/tokens/semantic/light.css +13 -44
  38. package/src/tokens/semantic/transitions.css +26 -13
  39. package/CHANGELOG.md +0 -360
  40. package/src/tokens/primitives/radius.css +0 -16
  41. package/src/tokens/primitives/shadow.css +0 -34
  42. package/src/tokens/primitives/zindex.css +0 -18
  43. package/src/tokens/semantic/components.css +0 -123
@@ -8,16 +8,321 @@
8
8
  * Source: doc 09 — Routing §2, §5, §9, §13
9
9
  */
10
10
 
11
- import { match } from './match.js';
12
- import { transitions } from './transitions.js';
13
- import { getContainer } from './container.js';
14
- import { isCallback, runCallback } from './handler.js';
15
11
  import { boot, reset as resetBoot } from './boot.js';
12
+ import { isCallback, runCallback } from './handler.js';
13
+
16
14
  import { ensure } from './cascade.js';
15
+ import { getContainer } from './container.js';
16
+ import { get as graphGet } from './graph.js';
17
+ import { match } from './match.js';
18
+ import { specRegistry } from '../ui/define/state.js';
19
+
20
+ /**
21
+ * Built-in minimal 404 HTML rendered when no user notfound is configured.
22
+ * Kept intentionally plain so it inherits the app's base typography.
23
+ */
24
+ const DEFAULT_NOTFOUND_HTML = `
25
+ <div style="
26
+ display:flex;flex-direction:column;align-items:center;justify-content:center;
27
+ min-height:60vh;gap:1rem;padding:2rem;text-align:center;font-family:inherit;
28
+ ">
29
+ <span style="font-size:3rem;font-weight:800;opacity:.15;">404</span>
30
+ <p style="margin:0;font-size:1rem;opacity:.5;">Page not found</p>
31
+ <a href="/" style="font-size:.875rem;opacity:.6;text-decoration:none;">← Go home</a>
32
+ </div>
33
+ `;
34
+
35
+ /**
36
+ * Wraps a plain array with non-enumerable `first`, `last`, and named
37
+ * index getters. Mirrors spec.js makeAccessorArray for use in the router.
38
+ *
39
+ * @param {any[]} values
40
+ * @param {string[]} names
41
+ */
42
+ function makeAccessorArray(values, names) {
43
+ const arr = [...values];
44
+ Object.defineProperties(arr, {
45
+ first: { get() { return arr[0] ?? null; }, enumerable: false },
46
+ last: { get() { return arr[arr.length - 1] ?? null; }, enumerable: false },
47
+ });
48
+ names.forEach((name, i) => {
49
+ if (!(name in arr)) {
50
+ Object.defineProperty(arr, name, {
51
+ get() { return arr[i] ?? null; },
52
+ enumerable: false,
53
+ });
54
+ }
55
+ });
56
+ return arr;
57
+ }
58
+
59
+ /**
60
+ * Casts a URL string value to the declared type.
61
+ * @param {string} value
62
+ * @param {'string'|'number'} cast
63
+ */
64
+ function castValue(value, cast) {
65
+ if (cast === 'number') {
66
+ const n = Number(value);
67
+ return Number.isNaN(n) ? value : n;
68
+ }
69
+ return value;
70
+ }
71
+
72
+ /**
73
+ * Builds the typed params and query context arrays for a matched route.
74
+ *
75
+ * Reads the component's declared `params`/`query` contract from specRegistry.
76
+ * Falls back to the raw match params/query objects for undeclared segments.
77
+ *
78
+ * @param {string} tag - custom element tag
79
+ * @param {object} rawParams - raw segment map from trie/URLPattern match
80
+ * @param {URL|string} url - the matched URL (or string)
81
+ * @returns {{ params: any[], query: any[], raw: URLSearchParams }}
82
+ */
83
+ function buildRouteContext(tag, rawParams, url) {
84
+ const spec = specRegistry.get(tag);
85
+ const paramDecls = spec?.params ?? [];
86
+ const queryDecls = spec?.query ?? [];
87
+
88
+ // Build the URLSearchParams object from the URL.
89
+ let searchParams;
90
+ try {
91
+ const u = url instanceof URL ? url : new URL(url, globalThis.location?.href || 'http://localhost');
92
+ searchParams = u.searchParams;
93
+ } catch (_) {
94
+ searchParams = new URLSearchParams();
95
+ }
96
+
97
+ // Build params array from declarations in order, casting as needed.
98
+ // Undeclared segments that appear in rawParams are appended at the end.
99
+ let params;
100
+ if (paramDecls.length > 0) {
101
+ const declared = new Set(paramDecls.map(d => d.name));
102
+ const declaredValues = paramDecls.map(({ name, cast }) => {
103
+ const raw = rawParams[name];
104
+ return raw !== undefined ? castValue(raw, cast) : null;
105
+ });
106
+ // Append undeclared segments as strings so they remain accessible by index.
107
+ const extra = Object.entries(rawParams)
108
+ .filter(([k]) => !declared.has(k))
109
+ .map(([, v]) => v);
110
+ const allValues = [...declaredValues, ...extra];
111
+ const allNames = [...paramDecls.map(d => d.name)];
112
+ params = makeAccessorArray(allValues, allNames);
113
+ } else {
114
+ // No contract declared: expose raw params as an ordered array (string values).
115
+ const entries = Object.entries(rawParams ?? {});
116
+ params = makeAccessorArray(entries.map(([, v]) => v), entries.map(([k]) => k));
117
+ }
118
+
119
+ // Build query array from declarations in order, casting as needed.
120
+ let query;
121
+ if (queryDecls.length > 0) {
122
+ const declared = new Set(queryDecls.map(d => d.name));
123
+ const declaredValues = queryDecls.map(({ name, cast }) => {
124
+ const raw = searchParams.get(name);
125
+ return raw !== null ? castValue(raw, cast) : null;
126
+ });
127
+ // Extra undeclared query keys appended as strings.
128
+ const extra = [];
129
+ for (const [k, v] of searchParams.entries()) {
130
+ if (!declared.has(k)) extra.push(v);
131
+ }
132
+ const allValues = [...declaredValues, ...extra];
133
+ const allNames = [...queryDecls.map(d => d.name)];
134
+ query = makeAccessorArray(allValues, allNames);
135
+ } else {
136
+ // No contract: flat array of all query values in iteration order.
137
+ const entries = [...searchParams.entries()];
138
+ query = makeAccessorArray(entries.map(([, v]) => v), entries.map(([k]) => k));
139
+ }
140
+
141
+ return { params, query, raw: searchParams };
142
+ }
143
+
144
+ /**
145
+ * Pushes params and query values reactively onto a live element instance
146
+ * without re-instantiating it. Used for layout-invariant param-only navigations.
147
+ *
148
+ * @param {HTMLElement} el - the live element
149
+ * @param {string} tag - for looking up the contract in specRegistry
150
+ * @param {object} rawParams - raw segment map
151
+ * @param {URL|string} url - the target URL
152
+ */
153
+ function pushToElement(el, tag, rawParams, url) {
154
+ const spec = specRegistry.get(tag);
155
+ const paramDecls = spec?.params ?? [];
156
+ const queryDecls = spec?.query ?? [];
157
+
158
+ let searchParams;
159
+ try {
160
+ const u = url instanceof URL ? url : new URL(url, globalThis.location?.href || 'http://localhost');
161
+ searchParams = u.searchParams;
162
+ } catch (_) {
163
+ searchParams = new URLSearchParams();
164
+ }
165
+
166
+ // Push path params.
167
+ for (const { name, cast } of paramDecls) {
168
+ const raw = rawParams[name];
169
+ if (raw !== undefined) el[name] = castValue(raw, cast);
170
+ }
171
+ // Push any undeclared params as strings.
172
+ const declaredNames = new Set(paramDecls.map(d => d.name));
173
+ for (const [k, v] of Object.entries(rawParams ?? {})) {
174
+ if (!declaredNames.has(k)) el[k] = v;
175
+ }
176
+
177
+ // Push query params.
178
+ for (const { name, cast } of queryDecls) {
179
+ const raw = searchParams.get(name);
180
+ if (raw !== null) el[name] = castValue(raw, cast);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Async navigation pipeline — extracted from handler to prevent browser spinner.
186
+ * Runs fire-and-forget via queueMicrotask so the Navigation API handler resolves
187
+ * immediately, eliminating the loading indicator during async work.
188
+ *
189
+ * @param {NavigationEvent} event - The navigation event
190
+ * @param {boolean} precommitted - Whether precommitHandler ran (guards already checked)
191
+ */
192
+ async function pipe(event, precommitted) {
193
+ const destination = event.destination;
194
+ let routeMatch = null;
195
+ try {
196
+ routeMatch = await match(destination.url);
197
+ } catch (err) {
198
+ emit('error', { error: err, url: destination.url, route: null, phase: 'match' });
199
+ return;
200
+ }
201
+
202
+ if (routeMatch) {
203
+ for (const fn of transformers) {
204
+ try { fn(routeMatch); } catch (e) { console.error(e); }
205
+ }
206
+ }
207
+
208
+ let chain = [];
209
+ if (routeMatch) {
210
+ const meta = routeMatch.route?.meta ?? {};
211
+ chain = Array.isArray(meta.via) && meta.via.length
212
+ ? meta.via
213
+ : (meta.container ? [meta.container] : []);
214
+
215
+ try {
216
+ for (let i = 0; i < chain.length; i++) {
217
+ if (!getContainer(chain[i])) {
218
+ await ensure(chain[i], chain[i - 1] ?? 'main');
219
+ }
220
+ }
221
+ } catch (err) {
222
+ emit('error', { error: err, url: destination.url, route: routeMatch.route, phase: 'container' });
223
+ throw err;
224
+ }
225
+ }
226
+
227
+ if (!precommitted) {
228
+ for (const guardFn of guards) {
229
+ let redirectUrl;
230
+ try {
231
+ redirectUrl = await guardFn(destination, null);
232
+ } catch (err) {
233
+ emit('error', { error: err, url: destination.url, route: routeMatch?.route ?? null, phase: 'guard' });
234
+ return;
235
+ }
236
+ if (redirectUrl) {
237
+ window.navigation.navigate(redirectUrl, { history: 'replace' });
238
+ return;
239
+ }
240
+ }
241
+ }
242
+
243
+ if (routeMatch) {
244
+ if (isCallback(routeMatch.route.handler)) {
245
+ try {
246
+ await runCallback(routeMatch.route.handler, routeMatch.params, event);
247
+ } catch (err) {
248
+ emit('error', { error: err, url: destination.url, route: routeMatch.route, phase: 'handler' });
249
+ return;
250
+ }
251
+ }
252
+
253
+ const ctx = buildRouteContext(routeMatch.tag, routeMatch.params, destination.url);
254
+ emit('found', {
255
+ tag: routeMatch.tag,
256
+ params: ctx.params,
257
+ query: ctx.query,
258
+ raw: ctx.raw,
259
+ hash: routeMatch.hash,
260
+ chain: routeMatch.chain,
261
+ via: chain,
262
+ container: chain.at(-1) ?? null,
263
+ url: destination.url,
264
+ direction: event.navigationType
265
+ });
266
+ } else {
267
+ emit('notfound', { url: destination.url });
268
+ if (notFoundHandler) {
269
+ await notFoundHandler(event);
270
+ } else {
271
+ renderNotFound('main');
272
+ }
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Renders the not-found fallback into the deepest currently-live container.
278
+ * Walks the graph from the deepest live node upward until it finds a mounted
279
+ * dock element, then swaps a notfound div into it.
280
+ *
281
+ * Each dock may expose a static `notfound` property (set by dock() when the
282
+ * user supplies a notfound config). The deepest configured one wins.
283
+ *
284
+ * @param {string} [activeContainer='main'] - hint: the last-active container.
285
+ */
286
+ function renderNotFound(activeContainer = 'main') {
287
+ // Walk from the hinted container upward until we find a live element.
288
+ let name = activeContainer;
289
+ let host = null;
290
+ const visited = new Set();
291
+ while (name && !visited.has(name)) {
292
+ visited.add(name);
293
+ const el = getContainer(name);
294
+ if (el && el.isConnected) { host = el; break; }
295
+ const node = graphGet(name);
296
+ name = node?.parent?.name ?? null;
297
+ }
298
+ if (!host) {
299
+ // Last resort: use the root main element directly.
300
+ host = document.getElementById('main');
301
+ }
302
+ if (!host) return;
303
+
304
+ // Build the notfound element. Check for a user-defined template on the
305
+ // host element's class (set by dock() via the notfound config option).
306
+ const Cls = host.constructor;
307
+ const userHtml = Cls?.notfound ?? null;
308
+ const html = typeof userHtml === 'string' ? userHtml : DEFAULT_NOTFOUND_HTML;
309
+
310
+ const wrapper = document.createElement('div');
311
+ wrapper.setAttribute('role', 'status');
312
+ wrapper.setAttribute('aria-label', 'Page not found');
313
+ wrapper.innerHTML = html;
314
+
315
+ if (typeof host.swap === 'function') {
316
+ host.swap(wrapper);
317
+ } else {
318
+ host.replaceChildren(wrapper);
319
+ }
320
+ }
17
321
 
18
322
  let guards = [];
19
323
  let notFoundHandler = null;
20
324
  let ready = false;
325
+ let transformers = [];
21
326
 
22
327
  // Module-level root slots — captured once in setup(), cleared in destroy().
23
328
  let win = null;
@@ -89,6 +394,17 @@ export function addGuard(guardFn) {
89
394
  };
90
395
  }
91
396
 
397
+ /**
398
+ * Registers a global route transformer. Transformers can modify matched route metadata dynamically.
399
+ */
400
+ export function addTransformer(fn) {
401
+ transformers.push(fn);
402
+ return () => {
403
+ const idx = transformers.indexOf(fn);
404
+ if (idx !== -1) transformers.splice(idx, 1);
405
+ };
406
+ }
407
+
92
408
  /** Grouped guard API. */
93
409
  export const guardsApi = {
94
410
  add: addGuard,
@@ -128,20 +444,44 @@ export function setup() {
128
444
  // throws if the element is absent at boot time.
129
445
  if (mainEl) shell = new WeakRef(mainEl);
130
446
  navListener = (event) => {
131
- // Skip cross-origin navigations, file downloads, or same-document hash scrolls
447
+ // Only intercept same-origin navigations that the browser would otherwise
448
+ // follow as a full page load. External links, file downloads, and
449
+ // same-document hash scrolls are intentionally left to the browser.
132
450
  if (!event.canIntercept || event.hashChange || event.downloadRequest) {
133
451
  return;
134
452
  }
453
+ // Guard: only intercept same-origin URLs. Cross-origin clicks must
454
+ // navigate normally (open in browser / new tab).
455
+ try {
456
+ const dest = new URL(event.destination.url);
457
+ if (dest.origin !== location.origin) return;
458
+ } catch (_) { return; }
135
459
 
136
460
  const url = event.destination.url;
137
461
  let precommitted = false; // Scoped precommitted guard state (RT-02)
138
462
 
139
- event.intercept({
463
+ // Build intercept options. precommitHandler is only valid on cancelable
464
+ // events (Navigation API spec §4.3). Including it unconditionally throws
465
+ // InvalidStateError on non-cancelable navigations (e.g. back/forward).
466
+ const interceptOpts = {
467
+ focusReset: 'manual', // router owns focus — browser must not reset it
468
+ scroll: 'manual', // router owns scroll — browser must not reset it
140
469
  /**
141
- * Runs guards before URL changes.
142
- * Supports atomic redirection before URL commit (Chrome & Firefox).
470
+ * Executes DOM mutations, layout changes, and provides fallbacks for Safari.
471
+ * Always provided this is what prevents the browser from reloading.
143
472
  */
144
- async precommitHandler(controller) {
473
+ handler() {
474
+ // Resolve immediately — no browser spinner, no focus/scroll side effects.
475
+ // pipe() runs detached; the router's own emit() system handles success/error.
476
+ queueMicrotask(() => pipe(event, precommitted));
477
+ }
478
+ };
479
+
480
+ // Only attach precommitHandler on cancelable events (Navigation API §4.3).
481
+ // Non-cancelable navigations (e.g. browser back/forward in some browsers)
482
+ // throw InvalidStateError if precommitHandler is present.
483
+ if (event.cancelable) {
484
+ interceptOpts.precommitHandler = async function precommitHandler(controller) {
145
485
  precommitted = true;
146
486
  const destination = event.destination;
147
487
  for (const guardFn of guards) {
@@ -151,96 +491,10 @@ export function setup() {
151
491
  return;
152
492
  }
153
493
  }
154
- },
155
-
156
- /**
157
- * Executes DOM mutations, layout changes, and provides fallbacks for Safari.
158
- */
159
- handler() {
160
- (async () => {
161
- const destination = event.destination;
162
- let routeMatch = null;
163
- try {
164
- routeMatch = await match(destination.url);
165
- } catch (err) {
166
- emit('error', { error: err, url: destination.url, route: null, phase: 'match' });
167
- return;
168
- }
169
-
170
- // Layout resolution: ensure the route's container chain is mounted.
171
- // Routes declare an ordered `via` chain (root-to-leaf) or a single
172
- // `container`. Missing containers are mounted via cascade rather than
173
- // throwing — a hard refresh on a deep route can now build its own
174
- // layout instead of erroring out.
175
- if (routeMatch) {
176
- const meta = routeMatch.route?.meta ?? {};
177
- const chain = Array.isArray(meta.via) && meta.via.length
178
- ? meta.via
179
- : (meta.container ? [meta.container] : []);
180
-
181
- try {
182
- for (let i = 0; i < chain.length; i++) {
183
- if (!getContainer(chain[i])) {
184
- await ensure(chain[i], chain[i - 1] ?? 'main'); // was 'body'
185
- }
186
- }
187
- } catch (err) {
188
- emit('error', { error: err, url: destination.url, route: routeMatch.route, phase: 'container' });
189
- throw err;
190
- }
191
- }
192
-
193
- // Graceful Safari Fallback: Evaluate guards inside post-commit handler if precommit is ignored.
194
- if (!precommitted) {
195
- for (const guardFn of guards) {
196
- let redirectUrl;
197
- try {
198
- redirectUrl = await guardFn(destination, null);
199
- } catch (err) {
200
- emit('error', { error: err, url: destination.url, route: routeMatch?.route ?? null, phase: 'guard' });
201
- return;
202
- }
203
- if (redirectUrl) {
204
- window.navigation.navigate(redirectUrl, { history: 'replace' });
205
- return;
206
- }
207
- }
208
- }
494
+ };
495
+ }
209
496
 
210
- await transitions.run(async () => {
211
- if (routeMatch) {
212
- // Run callback handlers exactly once, here (never during match()).
213
- if (isCallback(routeMatch.route.handler)) {
214
- try {
215
- await runCallback(routeMatch.route.handler, routeMatch.params, event);
216
- } catch (err) {
217
- emit('error', { error: err, url: destination.url, route: routeMatch.route, phase: 'handler' });
218
- return;
219
- }
220
- }
221
-
222
- emit('found', {
223
- tag: routeMatch.tag,
224
- params: routeMatch.params,
225
- query: routeMatch.query,
226
- hash: routeMatch.hash,
227
- chain: routeMatch.chain,
228
- url: destination.url,
229
- direction: event.navigationType
230
- });
231
- } else {
232
- emit('notfound', { url: destination.url });
233
-
234
- if (notFoundHandler) {
235
- await notFoundHandler(event);
236
- } else {
237
- console.error(`Route matching failed and no not-found boundary handler was configured: ${destination.url}`);
238
- }
239
- }
240
- });
241
- })();
242
- }
243
- });
497
+ event.intercept(interceptOpts);
244
498
  };
245
499
 
246
500
  successListener = () => {
@@ -286,17 +540,59 @@ export function setup() {
286
540
  const url = window.navigation.currentEntry?.url || window.location.href;
287
541
  const routeMatch = await match(url);
288
542
  if (routeMatch) {
543
+ for (const fn of transformers) {
544
+ try { fn(routeMatch); } catch (e) { console.error(e); }
545
+ }
546
+ const meta = routeMatch.route?.meta ?? {};
547
+ const chain = Array.isArray(meta.via) && meta.via.length
548
+ ? meta.via
549
+ : (meta.container ? [meta.container] : []);
550
+
551
+ // Layout resolution: ensure the route's container chain is mounted on boot.
552
+ // Retry once after a frame — a dock element may still be connecting on
553
+ // the first attempt (especially on hard refresh when layout docks are
554
+ // mounting concurrently with the boot gate settling).
555
+ const mountChain = async () => {
556
+ for (let i = 0; i < chain.length; i++) {
557
+ if (!getContainer(chain[i])) {
558
+ await ensure(chain[i], chain[i - 1] ?? 'main');
559
+ }
560
+ }
561
+ };
562
+ try {
563
+ await mountChain();
564
+ } catch (err) {
565
+ // Yield one animation frame and retry before giving up.
566
+ await new Promise(r => {
567
+ if (typeof requestAnimationFrame !== 'undefined') requestAnimationFrame(r);
568
+ else setTimeout(r, 16);
569
+ });
570
+ try {
571
+ await mountChain();
572
+ } catch (retryErr) {
573
+ // Both attempts failed — emit error but do NOT throw so the router
574
+ // can still attempt a notfound render rather than leaving a blank page.
575
+ emit('error', { error: retryErr, url, route: routeMatch.route, phase: 'container' });
576
+ return;
577
+ }
578
+ }
579
+
580
+ const ctx = buildRouteContext(routeMatch.tag, routeMatch.params, url);
289
581
  emit('found', {
290
582
  tag: routeMatch.tag,
291
- params: routeMatch.params,
292
- query: routeMatch.query,
583
+ params: ctx.params,
584
+ query: ctx.query,
585
+ raw: ctx.raw,
293
586
  hash: routeMatch.hash,
294
587
  chain: routeMatch.chain,
588
+ via: chain,
589
+ container: chain.at(-1) ?? null,
295
590
  url,
296
591
  direction: 'load'
297
592
  });
298
593
  } else {
299
594
  emit('notfound', { url });
595
+ renderNotFound('main');
300
596
  }
301
597
  });
302
598
  }
@@ -321,6 +617,7 @@ export function destroy() {
321
617
  errorListener = null;
322
618
 
323
619
  guards = [];
620
+ transformers = [];
324
621
  notFoundHandler = null;
325
622
  resetBoot();
326
623
 
@@ -39,10 +39,40 @@ function getSpecificity(patternStr) {
39
39
  return 1; // Wildcard: low
40
40
  }
41
41
 
42
+ function conflict(pathA, pathB) {
43
+ if (pathA === pathB) return false;
44
+ const segsA = pathA.split('/').filter(s => s.length > 0);
45
+ const segsB = pathB.split('/').filter(s => s.length > 0);
46
+
47
+ if (segsA.length !== segsB.length) return false;
48
+
49
+ for (let i = 0; i < segsA.length; i++) {
50
+ const sa = segsA[i];
51
+ const sb = segsB[i];
52
+ const isDynA = sa.startsWith(':') || sa === '*' || sa.includes('*');
53
+ const isDynB = sb.startsWith(':') || sb === '*' || sb.includes('*');
54
+
55
+ if (!isDynA && !isDynB && sa !== sb) {
56
+ return false; // static mismatch
57
+ }
58
+ }
59
+
60
+ return true;
61
+ }
62
+
42
63
  /**
43
64
  * Registers a route mapping.
44
65
  */
45
66
  export function register(patternStr, handler, meta = {}) {
67
+ // Check conflicts with existing routes before registering
68
+ for (const r of routes) {
69
+ if (conflict(patternStr, r.patternStr)) {
70
+ console.warn(
71
+ `[anza] WARNING: Route pattern conflict: "${patternStr}" overlaps with "${r.patternStr}"`
72
+ );
73
+ }
74
+ }
75
+
46
76
  const route = {
47
77
  patternStr,
48
78
  handler,
@@ -21,12 +21,13 @@ function injectSheet() {
21
21
  try {
22
22
  const sheet = new CSSStyleSheet();
23
23
  sheet.replaceSync(`
24
- ::view-transition-group(root) {
24
+ /* Dock-swap group: element-scoped content transitions */
25
+ ::view-transition-group(dock-swap) {
25
26
  animation-duration: var(--transition-duration);
26
27
  animation-timing-function: var(--transition-easing);
27
28
  }
28
- ::view-transition-old(root),
29
- ::view-transition-new(root) {
29
+ ::view-transition-old(dock-swap),
30
+ ::view-transition-new(dock-swap) {
30
31
  animation-duration: var(--transition-duration);
31
32
  animation-timing-function: var(--transition-easing);
32
33
  background: var(--transition-bg);
@@ -53,7 +53,9 @@ export function container(tag, spec, base = import.meta.url) {
53
53
  delete this.dataset.transitionDirection;
54
54
  };
55
55
 
56
- // Strategy 1: Element-scoped transition (Chrome 147+, concurrent-safe)
56
+ // Only use element-scoped view transitions. Document-level
57
+ // startViewTransition() captures the entire page and causes
58
+ // chrome flicker — never use it as a fallback.
57
59
  if (typeof this.startViewTransition === 'function') {
58
60
  try {
59
61
  const vt = this.startViewTransition(doSwap);
@@ -64,18 +66,6 @@ export function container(tag, spec, base = import.meta.url) {
64
66
  return;
65
67
  }
66
68
 
67
- // Strategy 2: Document-scoped transition (Baseline Oct 2025)
68
- if (typeof document.startViewTransition === 'function') {
69
- try {
70
- const vt = document.startViewTransition(doSwap);
71
- await vt.ready;
72
- } catch (err) {
73
- if (err?.name !== 'AbortError') console.warn('[UI Container] Document VT aborted:', err);
74
- }
75
- return;
76
- }
77
-
78
- // Strategy 3: Synchronous direct swap
79
69
  doSwap();
80
70
  };
81
71
  }