@motion-proto/live-tokens 0.64.3 → 0.65.1

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,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.65.1 — applyFontStacks returns what it wrote
4
+
5
+ ### Changed
6
+
7
+ - **`applyFontStacks` returns the variables it wrote.** It writes the `--font-*`
8
+ stacks from a list it kept to itself, so a consumer tracking what it had
9
+ applied — to tear those vars down when switching looks — had to hand-maintain
10
+ a copy of that list. A copy falls behind silently the moment a stack is added
11
+ here, and the missed variable stays stuck at the outgoing look's value:
12
+ `--font-editorial` did exactly that to a site that had listed the original
13
+ four. The return value is additive, so existing calls keep working.
14
+
15
+ ```ts
16
+ applied = [...applied, ...applyFontStacks(theme.fontStacks, theme.fontSources)];
17
+ ```
18
+
19
+ ## 0.65.0 — A link the router cannot serve is the browser's
20
+
21
+ ### Fixed
22
+
23
+ - **The router hijacked links it could not serve.** Its click interception
24
+ claimed every anchor whose `href` began with `/`, reading neither `target`,
25
+ `download`, `rel`, nor whether any route rendered the path. So a link to a
26
+ file the origin serves — a PDF or an image under `public/`, a download, a
27
+ server endpoint — had its click cancelled and its path pushed at a router with
28
+ nothing to show for it, and the link silently did nothing. A left-click is now
29
+ claimed only when the anchor asks for ordinary same-tab navigation and a route
30
+ actually renders the path; everything else loads for real. Protocol-relative
31
+ hrefs (`//host/x`) are no longer mistaken for local paths either — they start
32
+ with `/`, and `pushState` throws on a cross-origin URL.
33
+
34
+ ### Changed (breaking)
35
+
36
+ - **An unrouted path now performs a real navigation.** `pages['/']` is a
37
+ fallback for *rendering* an unmatched path, not a claim on it, so the router
38
+ no longer intercepts clicks on paths no route declares. On an SPA-rewriting
39
+ host the page still lands on the `pages['/']` fallback, now via a page load
40
+ rather than a client-side swap; on a host without the rewrite the URL 404s
41
+ instead of quietly rendering the home page. Return your own entry from
42
+ `resolve()` to keep claiming such paths.
43
+
3
44
  ## 0.64.3 — The add button names the act
4
45
 
5
46
  ### Changed
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.3",
3
+ "version": "0.65.1",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -129,12 +129,27 @@ export function resolveFontStackValues(
129
129
  * Compose each stack into its resolved "family1, family2, ..." string and
130
130
  * write it to the matching --font-* variable on :root (and parent :root when
131
131
  * in an iframe) via the same pipeline used for color variables.
132
+ *
133
+ * Returns the variables it set, so a caller tracking what it has applied — to
134
+ * tear those vars down when switching looks — can record them without keeping
135
+ * its own copy of the stack list. A hand-maintained copy silently falls behind
136
+ * whenever a stack is added here, leaving the missed variable stuck at the
137
+ * outgoing look's value; `--font-editorial` did exactly that.
132
138
  */
133
- export function applyFontStacks(stacks: FontStack[], sources: FontSource[]): void {
139
+ export function applyFontStacks(
140
+ stacks: FontStack[],
141
+ sources: FontSource[],
142
+ ): FontStackVariable[] {
134
143
  const resolved = resolveFontStackValues(stacks, sources);
144
+ const applied: FontStackVariable[] = [];
135
145
  for (const name of FONT_STACK_VARIABLES) {
136
146
  const value = resolved[name];
137
- if (value) setCssVar(name, value);
138
- else removeCssVar(name);
147
+ if (value) {
148
+ setCssVar(name, value);
149
+ applied.push(name);
150
+ } else {
151
+ removeCssVar(name);
152
+ }
139
153
  }
154
+ return applied;
140
155
  }
@@ -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