@trueclone/cli 0.1.0 → 0.2.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/README.md +20 -9
- package/bin/trueclone.mjs +8 -1
- package/package.json +1 -1
- package/scripts/capture.mjs +68 -9
- package/scripts/diff.mjs +16 -0
- package/scripts/interact.mjs +7 -3
- package/scripts/lib.mjs +35 -1
- package/scripts/next.mjs +227 -11
- package/scripts/reveals.mjs +191 -0
- package/scripts/seed.mjs +466 -21
- package/scripts/states.mjs +2 -1
- package/scripts/widgets.mjs +169 -0
- package/skill/SKILL.md +23 -3
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Record the discrete states of a page's widgets (tabs, menus, accordions) so the clone can replay them. Many
|
|
2
|
+
// component libraries render only the active panel, so the other tabs' content and closed menus' content aren't in
|
|
3
|
+
// the captured page at all; the only faithful source is the original after each click.
|
|
4
|
+
//
|
|
5
|
+
// For every control (role="tab", or a button with aria-expanded) the reference page is clicked, and the region the
|
|
6
|
+
// control changes is snapshotted: the smallest element containing both the control and the panel it points at
|
|
7
|
+
// (aria-controls), falling back to its section. Controls are marked data-tc-trigger and regions data-tc-scope
|
|
8
|
+
// before clicking, so the snapshots carry the marks and `trueclone seed` can wire the clone: a click on a trigger
|
|
9
|
+
// swaps its region to the recorded state. Markup only, never the site's scripts; template mode gets placeholders.
|
|
10
|
+
//
|
|
11
|
+
// Not covered yet: popovers rendered in a portal at the end of <body> (comboboxes, tooltips); they are listed.
|
|
12
|
+
//
|
|
13
|
+
// usage: node scripts/widgets.mjs <name> [--viewport desktop] -> sites/<name>/reference/widgets.<vp>.json
|
|
14
|
+
import fs from 'node:fs/promises';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { VIEWPORTS, parseArgs, openBrowser, newPage, waitStill } from './lib.mjs';
|
|
17
|
+
|
|
18
|
+
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
19
|
+
const [name] = pos;
|
|
20
|
+
if (!name) {
|
|
21
|
+
console.error('usage: trueclone widgets <name> [--viewport desktop]');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
const root = path.resolve('sites', name);
|
|
25
|
+
const meta = JSON.parse(await fs.readFile(path.join(root, 'reference', 'meta.json'), 'utf8'));
|
|
26
|
+
const vp = VIEWPORTS.find((v) => v.name === (flags.viewport || 'desktop'));
|
|
27
|
+
|
|
28
|
+
// Marks every control and its region; returns the groups (a tablist, or a single expandable button).
|
|
29
|
+
function markInPage() {
|
|
30
|
+
const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
31
|
+
const pathOf = (el) => {
|
|
32
|
+
const parts = [];
|
|
33
|
+
for (let n = el; n && n !== document.body; n = n.parentElement) {
|
|
34
|
+
const sibs = [...n.parentElement.children].filter((c) => !SKIP.has(c.tagName));
|
|
35
|
+
parts.unshift(`${n.tagName.toLowerCase()}:${sibs.indexOf(n) + 1}`);
|
|
36
|
+
}
|
|
37
|
+
return parts.join('>');
|
|
38
|
+
};
|
|
39
|
+
const lca = (a, b) => { for (let n = a; n; n = n.parentElement) if (n.contains(b)) return n; return null; };
|
|
40
|
+
const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 2 && r.height > 2; };
|
|
41
|
+
const groups = [];
|
|
42
|
+
const portals = [];
|
|
43
|
+
// tabs, grouped by their tablist
|
|
44
|
+
const lists = new Set([...document.querySelectorAll('[role="tab"]')].filter(visible).map((t) => t.closest('[role="tablist"]') || t.parentElement));
|
|
45
|
+
for (const list of lists) {
|
|
46
|
+
const tabs = [...list.querySelectorAll('[role="tab"]')].filter(visible);
|
|
47
|
+
if (tabs.length < 2) continue;
|
|
48
|
+
const selected = tabs.find((t) => t.getAttribute('aria-selected') === 'true') || tabs[0];
|
|
49
|
+
const panel = selected.getAttribute('aria-controls') && document.getElementById(selected.getAttribute('aria-controls'));
|
|
50
|
+
let scope = panel ? lca(list, panel) : list.closest('section, header, footer, main > div') || list.parentElement;
|
|
51
|
+
if (!scope || scope === document.body) scope = list.closest('section, header, footer') || list.parentElement;
|
|
52
|
+
groups.push({ kind: 'tabs', scope, triggers: tabs, initial: tabs.indexOf(selected) });
|
|
53
|
+
}
|
|
54
|
+
// expandable buttons (menus, accordions, disclosures), not inside a tablist
|
|
55
|
+
for (const btn of [...document.querySelectorAll('button[aria-expanded], [role="button"][aria-expanded]')].filter(visible)) {
|
|
56
|
+
if (btn.closest('[role="tablist"]')) continue;
|
|
57
|
+
const popup = btn.getAttribute('aria-haspopup');
|
|
58
|
+
if (btn.getAttribute('role') === 'combobox' || popup === 'listbox' || popup === 'dialog') { portals.push(btn); continue; }
|
|
59
|
+
const scope = btn.closest('header, nav, section, footer, li') || btn.parentElement;
|
|
60
|
+
groups.push({ kind: 'expand', scope, triggers: [btn], initial: btn.getAttribute('aria-expanded') === 'true' ? 0 : -1 });
|
|
61
|
+
}
|
|
62
|
+
// an element can be the region of several widgets (all the menus of one nav): marks are space-separated lists
|
|
63
|
+
const addMark = (el, v) => { const cur = (el.getAttribute('data-tc-scope') || '').split(' ').filter(Boolean); if (!cur.includes(v)) cur.push(v); el.setAttribute('data-tc-scope', cur.join(' ')); };
|
|
64
|
+
groups.forEach((g, i) => {
|
|
65
|
+
addMark(g.scope, String(i));
|
|
66
|
+
g.triggers.forEach((t, j) => t.setAttribute('data-tc-trigger', `${i}:${j}`));
|
|
67
|
+
});
|
|
68
|
+
window.__tcGroups = groups;
|
|
69
|
+
return {
|
|
70
|
+
groups: groups.map((g, i) => ({ i, kind: g.kind, scopePath: pathOf(g.scope), triggers: g.triggers.map((t) => ({ path: pathOf(t), label: (t.getAttribute('aria-label') || t.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40) })), initial: g.initial })),
|
|
71
|
+
portals: portals.map((b) => (b.getAttribute('aria-label') || b.textContent || '').trim().slice(0, 40)),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Frameworks re-render a region when its state changes, which drops the marks on anything inside it (a tab set
|
|
76
|
+
// nested in another tab's panel). Marks are re-applied by path before every click and every snapshot.
|
|
77
|
+
function remarkInPage(groups) {
|
|
78
|
+
const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
79
|
+
const resolve = (p) => {
|
|
80
|
+
let el = document.body;
|
|
81
|
+
for (const part of p.split('>')) {
|
|
82
|
+
const [tag, n] = part.split(':');
|
|
83
|
+
el = [...el.children].filter((c) => !SKIP.has(c.tagName))[Number(n) - 1];
|
|
84
|
+
if (!el || el.tagName.toLowerCase() !== tag) return null;
|
|
85
|
+
}
|
|
86
|
+
return el;
|
|
87
|
+
};
|
|
88
|
+
for (const g of groups) {
|
|
89
|
+
const scope = resolve(g.scopePath);
|
|
90
|
+
if (scope) { const cur = (scope.getAttribute('data-tc-scope') || '').split(' ').filter(Boolean); if (!cur.includes(String(g.i))) cur.push(String(g.i)); scope.setAttribute('data-tc-scope', cur.join(' ')); }
|
|
91
|
+
g.triggers.forEach((t, j) => resolve(t.path)?.setAttribute('data-tc-trigger', `${g.i}:${j}`));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Menus often render their panel outside the trigger's own container (a shared viewport after the list): once open,
|
|
96
|
+
// the region becomes the smallest element holding both the trigger and the panel it controls.
|
|
97
|
+
function widenScopeInPage([i, sel]) {
|
|
98
|
+
const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
99
|
+
const pathOf = (el) => {
|
|
100
|
+
const parts = [];
|
|
101
|
+
for (let n = el; n && n !== document.body; n = n.parentElement) {
|
|
102
|
+
const sibs = [...n.parentElement.children].filter((c) => !SKIP.has(c.tagName));
|
|
103
|
+
parts.unshift(`${n.tagName.toLowerCase()}:${sibs.indexOf(n) + 1}`);
|
|
104
|
+
}
|
|
105
|
+
return parts.join('>');
|
|
106
|
+
};
|
|
107
|
+
const btn = document.querySelector(sel);
|
|
108
|
+
const panel = btn?.getAttribute('aria-controls') && document.getElementById(btn.getAttribute('aria-controls'));
|
|
109
|
+
if (!btn || !panel) return null;
|
|
110
|
+
let scope = btn;
|
|
111
|
+
while (scope && !scope.contains(panel)) scope = scope.parentElement;
|
|
112
|
+
if (!scope || scope === document.body) return null;
|
|
113
|
+
for (const old of document.querySelectorAll(`[data-tc-scope~="${i}"]`)) {
|
|
114
|
+
const rest = old.getAttribute('data-tc-scope').split(' ').filter((v) => v !== String(i));
|
|
115
|
+
if (rest.length) old.setAttribute('data-tc-scope', rest.join(' ')); else old.removeAttribute('data-tc-scope');
|
|
116
|
+
}
|
|
117
|
+
const cur = (scope.getAttribute('data-tc-scope') || '').split(' ').filter(Boolean);
|
|
118
|
+
cur.push(String(i));
|
|
119
|
+
scope.setAttribute('data-tc-scope', cur.join(' '));
|
|
120
|
+
return pathOf(scope);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const browser = await openBrowser(flags.cdp);
|
|
124
|
+
let result;
|
|
125
|
+
try {
|
|
126
|
+
const { page } = await newPage(browser, vp, { reducedMotion: 'reduce' });
|
|
127
|
+
await page.goto(meta.url, { waitUntil: 'load', timeout: 60000 });
|
|
128
|
+
await waitStill(page, { timeout: 15000, interval: 200 });
|
|
129
|
+
const found = await page.evaluate(markInPage);
|
|
130
|
+
console.log(`${found.groups.length} widget group(s): ${found.groups.filter((g) => g.kind === 'tabs').length} tab sets, ${found.groups.filter((g) => g.kind === 'expand').length} expandable${found.portals.length ? `; not recorded (portal popovers): ${found.portals.join(', ')}` : ''}`);
|
|
131
|
+
for (const g of found.groups) {
|
|
132
|
+
g.states = [];
|
|
133
|
+
const snapshot = async () => { await page.evaluate(remarkInPage, found.groups); return page.evaluate((i) => document.querySelector(`[data-tc-scope~="${i}"]`)?.innerHTML ?? null, g.i); };
|
|
134
|
+
for (let j = 0; j < g.triggers.length; j++) {
|
|
135
|
+
await page.evaluate(remarkInPage, found.groups);
|
|
136
|
+
const handle = await page.$(`[data-tc-trigger="${g.i}:${j}"]`);
|
|
137
|
+
if (!handle) { g.states.push(null); continue; }
|
|
138
|
+
await handle.scrollIntoViewIfNeeded().catch(() => {});
|
|
139
|
+
await handle.click({ timeout: 5000 }).catch(() => {});
|
|
140
|
+
await page.waitForTimeout(350);
|
|
141
|
+
await waitStill(page, { timeout: 2500 });
|
|
142
|
+
if (g.kind === 'expand') {
|
|
143
|
+
const wider = await page.evaluate(widenScopeInPage, [g.i, `[data-tc-trigger="${g.i}:${j}"]`]);
|
|
144
|
+
if (wider) g.scopePath = wider;
|
|
145
|
+
}
|
|
146
|
+
g.states.push(await snapshot());
|
|
147
|
+
if (g.kind === 'expand') { // the closed state, so the clone can close it again
|
|
148
|
+
await page.evaluate(remarkInPage, found.groups);
|
|
149
|
+
await page.click(`[data-tc-trigger="${g.i}:${j}"]`, { timeout: 5000 }).catch(() => {});
|
|
150
|
+
await page.waitForTimeout(350);
|
|
151
|
+
g.closed = await snapshot();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// back to the initial state before the next group (tabs: click the initially selected one)
|
|
155
|
+
if (g.kind === 'tabs' && g.initial >= 0) {
|
|
156
|
+
await page.evaluate(remarkInPage, found.groups);
|
|
157
|
+
await page.click(`[data-tc-trigger="${g.i}:${g.initial}"]`, { timeout: 5000 }).catch(() => {});
|
|
158
|
+
await page.waitForTimeout(350);
|
|
159
|
+
}
|
|
160
|
+
const changed = new Set(g.states.filter(Boolean)).size;
|
|
161
|
+
console.log(` ${g.kind.padEnd(6)} ${g.triggers.map((t) => t.label).slice(0, 6).join(' | ')}${g.triggers.length > 6 ? ' ...' : ''}: ${changed} distinct state(s)`);
|
|
162
|
+
}
|
|
163
|
+
result = found;
|
|
164
|
+
} finally {
|
|
165
|
+
if (!flags.cdp) await browser.close();
|
|
166
|
+
}
|
|
167
|
+
const out = path.join(root, 'reference', `widgets.${vp.name}.json`);
|
|
168
|
+
await fs.writeFile(out, JSON.stringify({ url: meta.url, viewport: vp, at: new Date().toISOString(), ...result }, null, 2));
|
|
169
|
+
console.log(`-> ${path.relative(process.cwd(), out)}`);
|
package/skill/SKILL.md
CHANGED
|
@@ -66,13 +66,28 @@ Review the draft: role names, the `notes` prose (overview, do's and don'ts), any
|
|
|
66
66
|
|
|
67
67
|
## 4. Build (`clone/`)
|
|
68
68
|
|
|
69
|
-
**
|
|
69
|
+
**Start every clone from the mirror** (both modes):
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
```powershell
|
|
72
|
+
npx trueclone reveals <name> # records scroll reveals frame by frame + class changes caused by scrolling (any library)
|
|
73
|
+
npx trueclone widgets <name> # clicks every tab and menu and records each state (they often aren't in the page until clicked)
|
|
74
|
+
npx trueclone seed <name> # clone/ from the script-free mirror; replays what reveals and widgets recorded
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- **Exact mode:** the site's own markup, text, stylesheets and media at local paths.
|
|
78
|
+
- **Template mode:** the same markup and stylesheets, but every text becomes lorem words of the same laid-out width (lines break where the reference's break), every image, video, large SVG and iframe a same-size placeholder, CSS backgrounds a transparent one, every link `#`, brand metadata and tracking hints dropped. No text or media of the site ends up in `clone/`; fonts stay for local study (swap licensed fonts before reusing the result).
|
|
79
|
+
- **Recorded behaviour comes with it:** one-shot scroll reveals replay their measured curve (delay, duration, easing, spring overshoot) with the Web Animations API, and scroll class changes (a header that gets its backdrop past 12px) toggle the same classes at the same point. Both live in `clone/trueclone-reveals.js`; don't rewrite them by hand.
|
|
80
|
+
- **Recorded widgets come with it too** (template mode): every tab set and expandable menu, with each state the original showed after a click, placeholdered like the page. `clone/trueclone-widgets.js` swaps the region on click (menus in a header or nav also on hover). Popovers rendered in a portal (comboboxes, tooltips) are listed as not replayed: build those in `script.js`. In interaction scenarios, `selector@n` picks the n-th match in `at`, `click` and `expectChange`; mind hidden duplicates (a mobile menu button is often the first match).
|
|
81
|
+
|
|
82
|
+
Then write `clone/script.js`: every remaining interaction in your own code, using the parameters you read from the reference (timings, physics constants, offsets, breakpoints). Never copy the site's scripts. JS-set initial states, such as a `<details>` closed on phones, are part of the layout, so check every viewport. Data the page fetches live (a star count, a feed) stays as captured; no requests to third parties.
|
|
83
|
+
|
|
84
|
+
A seed is where layout ends up at 99%+ with zero hand-written CSS; your work is behaviour. Build by hand (below) only what the mirror can't carry.
|
|
85
|
+
|
|
86
|
+
**Building parts by hand:**
|
|
72
87
|
|
|
73
88
|
- `styles.css` starts with `@import url("tokens.css");` and uses its variables. Never hard-code a token value.
|
|
74
89
|
- Use the site's breakpoints and root font-size behaviour (in `design-tokens.json`, under `layout`).
|
|
75
|
-
-
|
|
90
|
+
- Match `sections.json` heights. In template mode write your own placeholder words, then run `npx trueclone textshape <name> --write` (desktop, then `--viewport tablet` and `mobile`) so lines break where the reference's break. Use `--list "<ref row>" "<clone row>"` for repeated lists (calendar rows, cards), and give every data row its own placeholder text.
|
|
76
91
|
|
|
77
92
|
**Both modes:**
|
|
78
93
|
|
|
@@ -117,6 +132,11 @@ How detail measures, so you can trust it:
|
|
|
117
132
|
- through `pointer-events: none` overlays, and never on content the browser doesn't paint (closed `<details>`);
|
|
118
133
|
- colours written by scroll-scrubbed JS show up as in-between values: that is a motion difference, so check the motion inventory.
|
|
119
134
|
|
|
135
|
+
How layout and states measure:
|
|
136
|
+
- the structure mask hides text everywhere, gradient text (`background-clip: text`) and `::before/::after` photo backgrounds included;
|
|
137
|
+
- content that a component mounts only while in view (marked `data-tc-viewmount` by capture) can't appear in a full-page still on either side: layout paints its box the same on both and reports the count; the scroll-state check, with a real viewport, scores it;
|
|
138
|
+
- endless animations (marquees, rotating borders) are frozen on their first frame on both sides before every state screenshot: their phase depends on load time, not on the clone.
|
|
139
|
+
|
|
120
140
|
The live site can change after capture (a redeploy changes its CSS bundle name). When live-site tools (detail, rules) and screenshot scores (layout, states) disagree about a section, re-capture (`capture`, then `states <name>`); that also resets the cached reference measurements.
|
|
121
141
|
|
|
122
142
|
## 6. Next.js (after the HTML clone passes)
|