@motion-proto/live-tokens 0.64.2 → 0.65.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.65.0 — A link the router cannot serve is the browser's
4
+
5
+ ### Fixed
6
+
7
+ - **The router hijacked links it could not serve.** Its click interception
8
+ claimed every anchor whose `href` began with `/`, reading neither `target`,
9
+ `download`, `rel`, nor whether any route rendered the path. So a link to a
10
+ file the origin serves — a PDF or an image under `public/`, a download, a
11
+ server endpoint — had its click cancelled and its path pushed at a router with
12
+ nothing to show for it, and the link silently did nothing. A left-click is now
13
+ claimed only when the anchor asks for ordinary same-tab navigation and a route
14
+ actually renders the path; everything else loads for real. Protocol-relative
15
+ hrefs (`//host/x`) are no longer mistaken for local paths either — they start
16
+ with `/`, and `pushState` throws on a cross-origin URL.
17
+
18
+ ### Changed (breaking)
19
+
20
+ - **An unrouted path now performs a real navigation.** `pages['/']` is a
21
+ fallback for *rendering* an unmatched path, not a claim on it, so the router
22
+ no longer intercepts clicks on paths no route declares. On an SPA-rewriting
23
+ host the page still lands on the `pages['/']` fallback, now via a page load
24
+ rather than a client-side swap; on a host without the rewrite the URL 404s
25
+ instead of quietly rendering the home page. Return your own entry from
26
+ `resolve()` to keep claiming such paths.
27
+
28
+ ## 0.64.3 — The add button names the act
29
+
30
+ ### Changed
31
+
32
+ - **The add button names the act, not the font.** It read `+ add Domine` — the
33
+ next family it would reach for — so the label changed under the pointer as a
34
+ stack filled up, and a control that renames itself between clicks is hard to
35
+ aim at. It now reads `+ add font`, and still falls back to `+ add fallback`
36
+ once every project font is in the stack.
37
+
3
38
  ## 0.64.2 — A stack can gain a second font
4
39
 
5
40
  ### Fixed
package/README.md CHANGED
@@ -84,6 +84,8 @@ bootLiveTokens(App, '#app');
84
84
 
85
85
  For routes you cannot enumerate ahead of time (a `/:id`, a path prefix, a page shown only under some condition), add a `resolve` function from the current path to a `RouteEntry` and return `null` to fall through. Resolution order is `pages[path]`, then `resolve(path)`, then the `pages['/']` fallback, so adding `resolve` never changes how existing entries match. A resolved entry can carry `props`, letting one component serve many paths, and its `source` gives the dynamic route a working "Page Source" button.
86
86
 
87
+ Link-click interception follows the same route table. A left-click becomes an in-app `navigate()` only when the anchor asks for ordinary same-tab navigation — no `target`, `download`, `rel="external"`, or modifier key — and `pages` or `resolve` claims the path. Anything else keeps the browser's own handling, so a link to a PDF or an image under `public/`, to a download, or to a path no route declares loads for real. Note that the `pages['/']` fallback renders an unmatched path without claiming it: link to a path no route declares and you get a page load, not a client-side swap.
88
+
87
89
  ```svelte
88
90
  <LiveTokensRouter
89
91
  pages={{ '/': { lazy: () => import('./Home.svelte'), label: 'Home' } }}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.64.2",
3
+ "version": "0.65.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -64,6 +64,67 @@
64
64
  ): RouteEntry | null {
65
65
  return pages[route] ?? resolve?.(route) ?? pages['/'] ?? null;
66
66
  }
67
+
68
+ /** The click facts the interception decision reads, so it can be tested
69
+ without constructing a DOM MouseEvent. */
70
+ export interface LinkClick {
71
+ button: number;
72
+ ctrlKey: boolean;
73
+ metaKey: boolean;
74
+ shiftKey: boolean;
75
+ altKey: boolean;
76
+ defaultPrevented: boolean;
77
+ }
78
+
79
+ /**
80
+ * The path a left-click on `anchor` should navigate to in-app, or `null` to
81
+ * leave the click to the browser.
82
+ *
83
+ * Interception cancels the click, so every link this claims and cannot serve
84
+ * is a link that silently does nothing. It therefore claims a link only when
85
+ * the anchor asks for ordinary same-tab navigation *and* `claimsRoute` says a
86
+ * route renders that path — which leaves anything else the origin serves (a
87
+ * PDF or an image under `public/`, a download, a server-rendered endpoint) to
88
+ * load for real.
89
+ *
90
+ * `pages['/']` is a fallback for *rendering* an unmatched path, not a claim on
91
+ * it: treating it as one is what made every unrouted URL look interceptable.
92
+ */
93
+ export function resolveLinkNavigation(
94
+ anchor: Pick<Element, 'getAttribute' | 'hasAttribute'>,
95
+ e: LinkClick,
96
+ claimsRoute: (pathname: string) => boolean,
97
+ base: string,
98
+ ): string | null {
99
+ if (e.defaultPrevented) return null;
100
+ // Middle- and right-clicks reach `auxclick`, not `click`, in current
101
+ // browsers; the guard costs nothing and keeps the contract explicit.
102
+ if (e.button !== 0) return null;
103
+ if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return null;
104
+
105
+ const target = anchor.getAttribute('target');
106
+ if (target && target !== '_self') return null;
107
+ if (anchor.hasAttribute('download')) return null;
108
+ if (anchor.getAttribute('rel')?.split(/\s+/).includes('external')) return null;
109
+
110
+ const href = anchor.getAttribute('href');
111
+ // Relative and absolute-URL hrefs stay the browser's; only root-relative
112
+ // paths are candidates. `//host/x` starts with `/` and is cross-origin, and
113
+ // pushState would throw on it, so the origin check below has to run too.
114
+ if (!href?.startsWith('/')) return null;
115
+
116
+ let url: URL;
117
+ try {
118
+ url = new URL(href, base);
119
+ } catch {
120
+ return null;
121
+ }
122
+ if (url.origin !== new URL(base).origin) return null;
123
+
124
+ if (!claimsRoute(url.pathname)) return null;
125
+
126
+ return url.pathname + url.search + url.hash;
127
+ }
67
128
  </script>
68
129
 
69
130
  <script lang="ts">
@@ -176,17 +237,29 @@
176
237
  return Promise.resolve({ default: null as unknown as Component<any, any, any> });
177
238
  });
178
239
 
179
- // In-app link interception: turn left-clicks on internal `/...` anchors into
180
- // navigate() calls so router state updates without a full reload. Modifier
181
- // keys (cmd/ctrl/shift/alt) pass through to the browser's default handling.
240
+ // Which paths this router can actually render. The `pages['/']` fallback in
241
+ // resolveRoute is deliberately not consulted: it renders an unmatched path,
242
+ // it does not claim one.
243
+ function claimsRoute(pathname: string): boolean {
244
+ if (pathname in pages) return true;
245
+ if (isDev && editorEnabled && pathname === editorPath) return true;
246
+ if (isDev && componentsEnabled && pathname === componentsPath) return true;
247
+ if (isDev && colorsEnabled && pathname === colorsPath) return true;
248
+ if (isDev && docsEnabled && pathname === docsPath) return true;
249
+ return !!resolve?.(pathname);
250
+ }
251
+
252
+ // In-app link interception: turn left-clicks on anchors this router can serve
253
+ // into navigate() calls, so route state updates without a full reload.
254
+ // Everything else — a new tab, a download, a file under `public/`, another
255
+ // origin — keeps the browser's own handling.
182
256
  function handleClick(e: MouseEvent) {
183
- const anchor = (e.target as HTMLElement).closest('a[href]');
257
+ const anchor = (e.target as Element | null)?.closest?.('a[href]');
184
258
  if (!anchor) return;
185
- const href = anchor.getAttribute('href');
186
- if (!href || !href.startsWith('/')) return;
187
- if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
259
+ const path = resolveLinkNavigation(anchor, e, claimsRoute, window.location.href);
260
+ if (!path) return;
188
261
  e.preventDefault();
189
- navigate(href);
262
+ navigate(path);
190
263
  }
191
264
  </script>
192
265
 
@@ -193,8 +193,7 @@
193
193
  }
194
194
 
195
195
  function addLabel(variable: FontStackVariable): string {
196
- const next = nextAddableSlot(variable);
197
- return next?.kind === 'project' ? `+ add ${slotDisplayName(next)}` : '+ add fallback';
196
+ return nextAddableSlot(variable)?.kind === 'project' ? '+ add font' : '+ add fallback';
198
197
  }
199
198
 
200
199
  /** A font joins the other fonts, above the fallbacks; a fallback lands just