@adukiorg/anza 0.4.1 → 0.4.2

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