@motion-proto/live-tokens 0.80.0 → 0.81.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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.81.0 — Skill Atlas links name a block
4
+
5
+ ### Added
6
+
7
+ - **A Skill Atlas link opens one block.** Clicking a card or badge writes its
8
+ link to the address bar, named after the card title and the badge label:
9
+ `#set-type/write-the-font-pairing/voice`. Opening the link selects that
10
+ block and scrolls both panes to it. `#set-type` still opens the skill, and
11
+ a link to a block that no longer exists opens its skill.
12
+
3
13
  ## 0.80.0 — The build runs the design checks
4
14
 
5
15
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.80.0",
3
+ "version": "0.81.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -1,19 +1,20 @@
1
1
  <script lang="ts">
2
- import { tick } from 'svelte';
2
+ import { onMount, tick } from 'svelte';
3
3
  import { navigate } from '../core/routing/router';
4
4
  import Button from '../../system/components/Button.svelte';
5
5
  import TabBar from '../../system/components/TabBar.svelte';
6
6
  import SourcePane from './SourcePane.svelte';
7
7
  import TreeCanvas from './TreeCanvas.svelte';
8
+ import { linkHash, linkTargets, resolveLink } from './atlasLink';
8
9
  import { SKILL_DOC, skillDocs } from './skillSources';
9
10
  import { skillTrees } from './skillTrees';
10
11
  import type { LineRange, Selection } from './types';
11
12
 
12
- // `/skills#set-type` opens that skill, so a link can hand someone one tree
13
- // rather than the atlas front door.
14
- const linked = window.location.hash.slice(1);
15
- let active = $state(linked in skillTrees ? linked : Object.keys(skillTrees)[0]);
16
- let selection: Selection | null = $state(null);
13
+ // `#set-type` opens that skill and `#set-type/write-the-font-pairing/voice`
14
+ // also selects that block, so a link can hand someone one step of one tree.
15
+ const linked = resolveLink(window.location.hash, skillTrees);
16
+ let active = $state(linked?.skill ?? Object.keys(skillTrees)[0]);
17
+ let selection: Selection | null = $state(linked?.target ?? null);
17
18
  /** Which of the skill's documents the source pane shows. */
18
19
  let doc: string = $state(SKILL_DOC);
19
20
 
@@ -43,27 +44,21 @@
43
44
 
44
45
  function selectTarget(key: string, label: string, range: LineRange) {
45
46
  selection = { key, label, lines: range };
47
+ shareSelection();
46
48
  // Every range cites SKILL.md, so a step selected while a reference is open
47
49
  // would otherwise highlight nothing.
48
50
  doc = SKILL_DOC;
49
51
  scrollSourceTo(range[0]);
50
52
  }
51
53
 
52
- /** Every target a line could belong to, node and chip alike. */
53
- function targets(): Selection[] {
54
- return tree.nodes.flatMap((node) => [
55
- ...(node.lines ? [{ key: node.id, label: node.title, lines: node.lines }] : []),
56
- ...(node.chips ?? []).map((chip, i) => ({
57
- key: `${node.id}:${i}`,
58
- label: chip.label,
59
- lines: chip.lines,
60
- })),
61
- ]);
54
+ function shareSelection() {
55
+ const target = linkTargets(tree).find((t) => t.key === selection?.key) ?? null;
56
+ history.replaceState(null, '', `${window.location.pathname}${linkHash(active, target)}`);
62
57
  }
63
58
 
64
59
  /** Reverse lookup: the narrowest target whose range covers this line wins. */
65
60
  function selectFromLine(lineNumber: number) {
66
- const covering = targets()
61
+ const covering = linkTargets(tree)
67
62
  .filter(({ lines: [from, to] }) => lineNumber >= from && lineNumber <= to)
68
63
  .sort((a, b) => a.lines[1] - a.lines[0] - (b.lines[1] - b.lines[0]));
69
64
 
@@ -71,8 +66,13 @@
71
66
  if (!best) return;
72
67
 
73
68
  selection = best;
69
+ shareSelection();
70
+ scrollTreeTo(best.key);
71
+ }
72
+
73
+ function scrollTreeTo(key: string) {
74
74
  treePane
75
- ?.querySelector(`[data-node="${best.key.split(':')[0]}"]`)
75
+ ?.querySelector(`[data-node="${key.split(':')[0]}"]`)
76
76
  ?.scrollIntoView({ block: 'center', behavior: 'smooth' });
77
77
  }
78
78
 
@@ -88,11 +88,34 @@
88
88
  active = id;
89
89
  selection = null;
90
90
  doc = SKILL_DOC;
91
- history.replaceState(null, '', `${window.location.pathname}#${id}`);
91
+ shareSelection();
92
92
  treePane?.scrollTo({ top: 0 });
93
93
  sourcePane?.scrollTo({ top: 0 });
94
94
  }
95
95
 
96
+ function openLink(hash: string) {
97
+ const link = resolveLink(hash, skillTrees);
98
+ if (!link) return;
99
+ active = link.skill;
100
+ selection = link.target;
101
+ doc = SKILL_DOC;
102
+ if (!link.target) return;
103
+ const { key, lines: [from] } = link.target;
104
+ // The cards and source rows of a newly opened tab exist only after render.
105
+ tick().then(() => {
106
+ scrollTreeTo(key);
107
+ scrollSourceTo(from);
108
+ });
109
+ }
110
+
111
+ onMount(() => {
112
+ openLink(window.location.hash);
113
+ // A link pasted into this tab changes only the hash, so the page never reloads.
114
+ const onHash = () => openLink(window.location.hash);
115
+ window.addEventListener('hashchange', onHash);
116
+ return () => window.removeEventListener('hashchange', onHash);
117
+ });
118
+
96
119
  let treePane: HTMLElement | undefined = $state();
97
120
  let sourcePane: HTMLElement | undefined = $state();
98
121
  </script>
@@ -0,0 +1,43 @@
1
+ import type { Selection, SkillTree } from './types';
2
+
3
+ /** A selectable target with the path segment its shared link names it by. */
4
+ export interface LinkedTarget extends Selection {
5
+ path: string;
6
+ }
7
+
8
+ export function slug(text: string): string {
9
+ return text
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, '-')
12
+ .replace(/^-|-$/g, '');
13
+ }
14
+
15
+ /** Every target in the tree, node and chip alike. A chip's path nests under its card's. */
16
+ export function linkTargets(tree: SkillTree): LinkedTarget[] {
17
+ return tree.nodes.flatMap((node) => [
18
+ ...(node.lines
19
+ ? [{ key: node.id, label: node.title, lines: node.lines, path: slug(node.title) }]
20
+ : []),
21
+ ...(node.chips ?? []).map((chip, i) => ({
22
+ key: `${node.id}:${i}`,
23
+ label: chip.label,
24
+ lines: chip.lines,
25
+ path: `${slug(node.title)}/${slug(chip.label)}`,
26
+ })),
27
+ ]);
28
+ }
29
+
30
+ export function linkHash(skill: string, target: LinkedTarget | null): string {
31
+ return target ? `#${skill}/${target.path}` : `#${skill}`;
32
+ }
33
+
34
+ /** Reads `#set-type/write-the-font-pairing/voice`. An unknown block still opens its skill. */
35
+ export function resolveLink(
36
+ hash: string,
37
+ trees: Record<string, SkillTree>,
38
+ ): { skill: string; target: LinkedTarget | null } | null {
39
+ const [skill, ...rest] = decodeURIComponent(hash.replace(/^#/, '')).split('/');
40
+ if (!(skill in trees)) return null;
41
+ const path = rest.join('/');
42
+ return { skill, target: linkTargets(trees[skill]).find((t) => t.path === path) ?? null };
43
+ }