@deeeed/metamask-harness 0.34.2 → 0.34.4

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 (35) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/adapters/mobile/source-freshness.js +51 -14
  3. package/dist/adapters.js +5 -2
  4. package/dist/recipe-security.js +1 -0
  5. package/library/actions/core/perps/_controller.mjs +75 -23
  6. package/library/actions/core/perps/read_account.mjs +6 -12
  7. package/library/actions/core/perps/read_snapshot.mjs +66 -0
  8. package/library/actions/extension/performance/_navigation-memory.mjs +372 -0
  9. package/library/actions/extension/performance/compare_idle_navigation_memory.mjs +18 -0
  10. package/library/actions/extension/performance/measure_detached_dom.mjs +18 -0
  11. package/library/actions/extension/performance/measure_navigation_memory.mjs +18 -0
  12. package/library/actions/mobile/app/network-control.mjs +24 -12
  13. package/library/manifests/core.action-manifest.json +55 -0
  14. package/library/manifests/extension.action-manifest.json +100 -0
  15. package/library/recipes/core/perps/snapshot.recipe.json +25 -0
  16. package/library/recipes/extension/performance/navigation-memory.recipe.json +175 -0
  17. package/package.json +1 -1
  18. package/scripts/site-contrast.mjs +66 -10
  19. package/site/architecture.html +22 -16
  20. package/site/assets/progress.mjs +47 -2
  21. package/site/assets/style.css +181 -1
  22. package/site/cheatsheet.html +14 -12
  23. package/site/how-it-works.html +693 -0
  24. package/site/index.html +133 -643
  25. package/site/perps.html +7 -6
  26. package/site/recipes.html +23 -17
  27. package/site/reviewers.html +7 -6
  28. package/site/tutorials/index.html +7 -6
  29. package/site/tutorials/v1.html +12 -11
  30. package/site/tutorials/v2.html +7 -6
  31. package/site/tutorials/v3.html +15 -11
  32. package/site/tutorials/v4.html +6 -5
  33. package/site/tutorials/v5.html +6 -5
  34. package/site/tutorials/v6.html +6 -5
  35. package/site/tutorials/v7.html +6 -5
@@ -0,0 +1,175 @@
1
+ {
2
+ "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
3
+ "title": "Extension navigation retained-memory audit",
4
+ "description": "Measures post-GC retained nodes and listeners across eight representative Extension screens, verifies detached DOM does not grow, and separates navigation-attributable listener growth from an equal-duration idle control.",
5
+ "proofTargets": [
6
+ {
7
+ "id": "screen-slopes",
8
+ "claim": "Eight representative Extension screens remain within separate post-GC retained-node and listener growth bounds during navigation churn."
9
+ },
10
+ {
11
+ "id": "detached-dom",
12
+ "claim": "Detached DOM retained after forced garbage collection does not grow beyond the declared bound."
13
+ },
14
+ {
15
+ "id": "idle-control",
16
+ "claim": "Navigation-attributable listener growth remains within its declared bound after subtracting an equal-duration idle control."
17
+ }
18
+ ],
19
+ "workflow": {
20
+ "entry": "require-cdp",
21
+ "nodes": {
22
+ "require-cdp": {
23
+ "action": "cdp.target",
24
+ "required": true,
25
+ "require_reachable": true,
26
+ "intent": "Confirm the live Extension CDP target is reachable before measuring retained memory.",
27
+ "next": "ensure-unlocked"
28
+ },
29
+ "ensure-unlocked": {
30
+ "action": "metamask.wallet.ensure_unlocked",
31
+ "intent": "Ensure the representative screens are reachable from an unlocked wallet.",
32
+ "next": "market-detail"
33
+ },
34
+ "market-detail": {
35
+ "action": "metamask.performance.measure_navigation_memory",
36
+ "screen_hash": "#/perps/market/ETH",
37
+ "screen_test_id": "perps-market-detail-page",
38
+ "return_hash": "#/",
39
+ "cycles": 8,
40
+ "timeout_ms": 400000,
41
+ "max_nodes_per_cycle": 2,
42
+ "max_listeners_per_cycle": 4,
43
+ "intent": "Measure the post-GC navigation slope for Perps market detail.",
44
+ "proves": ["screen-slopes"],
45
+ "next": "order-entry"
46
+ },
47
+ "order-entry": {
48
+ "action": "metamask.performance.measure_navigation_memory",
49
+ "screen_hash": "#/perps/trade/ETH?direction=long&mode=new",
50
+ "screen_test_id": "perps-order-entry-page",
51
+ "return_hash": "#/",
52
+ "cycles": 6,
53
+ "timeout_ms": 400000,
54
+ "max_nodes_per_cycle": 2,
55
+ "max_listeners_per_cycle": 4,
56
+ "intent": "Measure the post-GC navigation slope for Perps order entry.",
57
+ "proves": ["screen-slopes"],
58
+ "next": "perps-home"
59
+ },
60
+ "perps-home": {
61
+ "action": "metamask.performance.measure_navigation_memory",
62
+ "screen_hash": "#/perps-home",
63
+ "screen_test_id": "perps-home-page",
64
+ "return_hash": "#/",
65
+ "cycles": 6,
66
+ "timeout_ms": 400000,
67
+ "max_nodes_per_cycle": 2,
68
+ "max_listeners_per_cycle": 4,
69
+ "intent": "Measure the post-GC navigation slope for Perps home.",
70
+ "proves": ["screen-slopes"],
71
+ "next": "market-list"
72
+ },
73
+ "market-list": {
74
+ "action": "metamask.performance.measure_navigation_memory",
75
+ "screen_hash": "#/perps/market-list",
76
+ "screen_test_id": "market-list-view",
77
+ "return_hash": "#/",
78
+ "cycles": 6,
79
+ "timeout_ms": 400000,
80
+ "max_nodes_per_cycle": 2,
81
+ "max_listeners_per_cycle": 4,
82
+ "intent": "Measure the post-GC navigation slope for the Perps market list.",
83
+ "proves": ["screen-slopes"],
84
+ "next": "perps-activity"
85
+ },
86
+ "perps-activity": {
87
+ "action": "metamask.performance.measure_navigation_memory",
88
+ "screen_hash": "#/perps/activity",
89
+ "screen_test_id": "perps-activity-page",
90
+ "return_hash": "#/",
91
+ "cycles": 6,
92
+ "timeout_ms": 400000,
93
+ "max_nodes_per_cycle": 2,
94
+ "max_listeners_per_cycle": 4,
95
+ "intent": "Measure the post-GC navigation slope for Perps activity.",
96
+ "proves": ["screen-slopes"],
97
+ "next": "asset-details"
98
+ },
99
+ "asset-details": {
100
+ "action": "metamask.performance.measure_navigation_memory",
101
+ "screen_hash": "#/asset/0x1/",
102
+ "screen_test_id": "asset-price-chart",
103
+ "return_hash": "#/",
104
+ "cycles": 6,
105
+ "timeout_ms": 400000,
106
+ "max_nodes_per_cycle": 2,
107
+ "max_listeners_per_cycle": 4,
108
+ "intent": "Measure the post-GC navigation slope for asset details.",
109
+ "proves": ["screen-slopes"],
110
+ "next": "swaps-bridge"
111
+ },
112
+ "swaps-bridge": {
113
+ "action": "metamask.performance.measure_navigation_memory",
114
+ "screen_hash": "#/cross-chain/swaps/prepare-bridge-page",
115
+ "screen_test_id": "bridge-source-button",
116
+ "return_hash": "#/",
117
+ "cycles": 6,
118
+ "timeout_ms": 400000,
119
+ "max_nodes_per_cycle": 2,
120
+ "max_listeners_per_cycle": 4,
121
+ "intent": "Measure the post-GC navigation slope for Swaps and Bridge.",
122
+ "proves": ["screen-slopes"],
123
+ "next": "settings-control"
124
+ },
125
+ "settings-control": {
126
+ "action": "metamask.performance.measure_navigation_memory",
127
+ "screen_hash": "#/settings",
128
+ "screen_test_id": "settings-tab-bar-grouped",
129
+ "return_hash": "#/",
130
+ "cycles": 6,
131
+ "timeout_ms": 400000,
132
+ "max_nodes_per_cycle": 2,
133
+ "max_listeners_per_cycle": 4,
134
+ "intent": "Measure a non-Perps control screen with the same retained-memory gate.",
135
+ "proves": ["screen-slopes"],
136
+ "next": "detached-market-detail"
137
+ },
138
+ "detached-market-detail": {
139
+ "action": "metamask.performance.measure_detached_dom",
140
+ "screen_hash": "#/perps/market/ETH",
141
+ "screen_test_id": "perps-market-detail-page",
142
+ "return_hash": "#/",
143
+ "cycles": 5,
144
+ "timeout_ms": 400000,
145
+ "max_detached_growth": 50,
146
+ "intent": "Compare retained detached DOM before and after market-detail navigation churn.",
147
+ "proves": ["detached-dom"],
148
+ "next": "idle-control-market-detail"
149
+ },
150
+ "idle-control-market-detail": {
151
+ "action": "metamask.performance.compare_idle_navigation_memory",
152
+ "screen_hash": "#/perps/market/ETH",
153
+ "screen_test_id": "perps-market-detail-page",
154
+ "return_hash": "#/",
155
+ "rounds": 8,
156
+ "round_duration_ms": 10000,
157
+ "timeout_ms": 400000,
158
+ "max_attributable_listeners_per_cycle": 2,
159
+ "intent": "Subtract equal-duration idle growth from the market-detail navigation slope.",
160
+ "proves": ["idle-control"],
161
+ "next": "return-home"
162
+ },
163
+ "return-home": {
164
+ "action": "ui.navigate",
165
+ "page": "home",
166
+ "intent": "Leave the Extension on a known screen after the audit.",
167
+ "next": "done"
168
+ },
169
+ "done": {
170
+ "action": "end",
171
+ "status": "pass"
172
+ }
173
+ }
174
+ }
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.34.2",
3
+ "version": "0.34.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -146,6 +146,7 @@ const REVEAL = String.raw`
146
146
  const d = document.getElementById(l.getAttribute('aria-controls'));
147
147
  if (d) d.hidden = false;
148
148
  });
149
+ document.querySelectorAll('[role="tabpanel"][hidden]').forEach((p) => { p.hidden = false; });
149
150
  return true;
150
151
  })()
151
152
  `;
@@ -331,6 +332,9 @@ async function auditPage(browserEndpoint, url) {
331
332
  async function checkBehavior(browserEndpoint, port, pages) {
332
333
  const base = `http://127.0.0.1:${port}/`;
333
334
  await withPage(browserEndpoint, `${base}index.html`, async (page) => {
335
+ // Step 1 is the hero call to action; it was a collapsible before, so accept
336
+ // either shape. Step 2 is a tabbed set of prompts, one per kind of work —
337
+ // every one of them has to survive a copy, including the hidden panels.
334
338
  const copied = await page.evaluate(String.raw`
335
339
  (async () => {
336
340
  Object.defineProperty(navigator, 'clipboard', {
@@ -342,20 +346,58 @@ async function checkBehavior(browserEndpoint, port, pages) {
342
346
  },
343
347
  },
344
348
  });
345
- // The onboarding prompt is the hero call to action; it was a collapsible
346
- // before, so accept either shape.
347
- const button = document.querySelector('.cmd-hero .copy, .prompt .copy');
348
- if (!button) throw new Error('copy button was not initialized');
349
- button.click();
350
- await new Promise((resolve) => setTimeout(resolve, 25));
351
- return window.__siteCopied;
349
+ async function grab(button) {
350
+ if (!button) throw new Error('copy button was not initialized');
351
+ window.__siteCopied = null;
352
+ button.click();
353
+ await new Promise((resolve) => setTimeout(resolve, 25));
354
+ return window.__siteCopied;
355
+ }
356
+ const out = { setup: await grab(document.querySelector('.cmd-hero .copy, .prompt .copy')), work: [] };
357
+ for (const tab of document.querySelectorAll('[role="tab"]')) {
358
+ tab.click();
359
+ const panel = document.getElementById(tab.getAttribute('aria-controls'));
360
+ if (!panel || panel.hidden) throw new Error('tab ' + tab.id + ' did not reveal its panel');
361
+ out.work.push(await grab(panel.querySelector('.cmd .copy')));
362
+ }
363
+ return out;
352
364
  })()
353
365
  `);
354
- const expected = 'mm-harness run <recipe> --artifacts-dir ./first-recipe-artifacts';
355
- if (!copied?.includes(expected)) {
356
- throw new Error(`copy prompt omitted "${expected}"`);
366
+ // Setting up installs the harness and the skills; the work prompts invoke
367
+ // them by their exact installed names.
368
+ const expected = [
369
+ ['npm i -g @deeeed/metamask-harness@latest', copied.setup],
370
+ ['yarn skills', copied.setup],
371
+ ['/mms-recipe-pr-qa-review', copied.work.join('\n')],
372
+ ['/mms-recipe-cook', copied.work.join('\n')],
373
+ ];
374
+ const missing = expected.filter(([line, text]) => !text?.includes(line)).map(([line]) => line);
375
+ if (missing.length) {
376
+ throw new Error(`copy prompt omitted ${missing.map((m) => `"${m}"`).join(', ')}`);
377
+ }
378
+ if (copied.work.length < 3) {
379
+ throw new Error(`expected three work prompts, copied ${copied.work.length}`);
357
380
  }
358
381
 
382
+ // The landing page is the lobby: one prompt, one way onward, nothing else.
383
+ const lobby = await page.evaluate(String.raw`
384
+ (() => ({
385
+ nav: document.querySelectorAll('.nav a').length,
386
+ steps: document.querySelectorAll('.step[data-step]').length,
387
+ depth: document.querySelector('a[href="how-it-works.html"]') !== null,
388
+ anchor: document.getElementById('prompt') !== null,
389
+ }))()
390
+ `);
391
+ if (lobby.nav || lobby.steps || !lobby.depth || !lobby.anchor) {
392
+ throw new Error(
393
+ `landing page is not a lobby: ${lobby.nav} nav links, ${lobby.steps} steps, ` +
394
+ `depth link ${lobby.depth}, prompt anchor ${lobby.anchor}`,
395
+ );
396
+ }
397
+ });
398
+
399
+ // The walkthrough and its saved progress live one click deeper.
400
+ await withPage(browserEndpoint, `${base}how-it-works.html`, async (page) => {
359
401
  const progress = await page.evaluate(String.raw`
360
402
  (() => {
361
403
  const box = document.querySelector('.step-check');
@@ -426,6 +468,20 @@ async function checkBehavior(browserEndpoint, port, pages) {
426
468
  if (broken.length) {
427
469
  throw new Error(`${relative} has broken internal links: ${broken.join(', ')}`);
428
470
  }
471
+ // Every page past the lobby owes the reader one click back to the prompt.
472
+ if (relative !== 'index.html') {
473
+ const back = await page.evaluate(String.raw`
474
+ (() => {
475
+ const link = document.querySelector('.nav a.nav-cta');
476
+ if (!link) return null;
477
+ const url = new URL(link.getAttribute('href'), location.href);
478
+ return url.pathname + url.hash;
479
+ })()
480
+ `);
481
+ if (back !== '/index.html#prompt') {
482
+ throw new Error(`${relative} has no nav link back to the setup prompt (found ${back})`);
483
+ }
484
+ }
429
485
  for (const width of [834, 390]) {
430
486
  await page.send('Emulation.setDeviceMetricsOverride', {
431
487
  width,
@@ -14,11 +14,12 @@
14
14
  <div class="wrap topbar-inner">
15
15
  <a class="brand" href="index.html">
16
16
  <span class="brand-mark" aria-hidden="true"></span>
17
- <span class="brand-name">mm-harness</span>
17
+ <span class="brand-name">recipes</span>
18
18
  </a>
19
19
  <nav class="nav" aria-label="Main">
20
- <a href="index.html">Start Here</a>
21
- <a href="recipes.html">Recipes</a>
20
+ <a class="nav-cta" href="index.html#prompt">Quick start</a>
21
+ <a href="how-it-works.html">How it works</a>
22
+ <a href="recipes.html">Recipe anatomy</a>
22
23
  <a href="perps.html">Perps</a>
23
24
  <a href="cheatsheet.html">Cheatsheet</a>
24
25
  <a href="architecture.html" aria-current="page">Architecture</a>
@@ -30,11 +31,12 @@
30
31
 
31
32
  <main>
32
33
  <section class="wrap hero" style="padding-bottom:1rem">
33
- <span class="eyebrow">The stack</span>
34
+ <span class="eyebrow">The stack behind a recipe</span>
34
35
  <h1>How the pieces fit together</h1>
35
36
  <p class="lede">
36
- Four moving parts, two of which people routinely confuse. What each layer is, where it lives, which
37
- workflow you are in, and the mistakes that cost newcomers their first afternoon.
37
+ The recipe is the unit of trust; the rest of the stack runs it. Four moving parts, two of which
38
+ people routinely confuse. What each layer is, where it lives, which workflow you are in, and the
39
+ mistakes that cost newcomers their first afternoon.
38
40
  </p>
39
41
  </section>
40
42
 
@@ -45,8 +47,9 @@
45
47
  <span class="k">A recipe proves a task with actions.</span> Recipes are JSON graphs of typed
46
48
  actions; <code>mm-harness</code> executes them against a real app and produces an evidence bundle
47
49
  a reviewer can trust. <strong>Skills</strong> teach an agent a workflow — author a recipe, review
48
- a PR, validate a release. <strong>Recipe libraries</strong> hold each team's recipes and domain
49
- actions. Compose in that order, against the product repo under test.
50
+ a PR, validate a release and are the layer you work through; they call the harness, which runs
51
+ the recipe. <strong>Recipe libraries</strong> hold each team's recipes and domain actions.
52
+ Compose in that order, against the product repo under test.
50
53
  </p>
51
54
  </div>
52
55
  </section>
@@ -67,9 +70,12 @@
67
70
  release — turning "figure it out" into a procedure with gates you can steer.
68
71
  </p>
69
72
  <p>
70
- Skills live in the internal <code>Consensys/skills</code> repo, installed per checkout with that
71
- repo's tooling. <strong>You do not need skills to run a recipe</strong>, only to have an agent
72
- follow a proven workflow instead of improvising one.
73
+ Skills come from the public <code>MetaMask/skills</code> repo plus the internal
74
+ <code>Consensys/skills</code> overlay, which needs access to that org; private skills override
75
+ public ones on a name collision. Clone each, point <code>METAMASK_SKILLS_DIR</code> and
76
+ <code>CONSENSYS_SKILLS_DIR</code> at them, then install per checkout with
77
+ <code>yarn skills</code>. <strong>You do not need skills to run a recipe</strong>, only to have
78
+ an agent follow a proven workflow instead of improvising one.
73
79
  <a href="tutorials/v3.html">V3 walks the install.</a>
74
80
  </p>
75
81
  </div>
@@ -129,8 +135,8 @@
129
135
  </p>
130
136
  <p style="margin-bottom:0">
131
137
  They differ, and the harness says so rather than pretending otherwise: Core is headless with
132
- nothing to launch and no UI actions; Mobile carries video capture; Extension is screenshots
133
- only.
138
+ nothing to launch and no UI actions; Extension and iOS can record full-run video; Android
139
+ replay video is not implemented yet, so use screenshot evidence there.
134
140
  </p>
135
141
  </div>
136
142
  </div>
@@ -466,7 +472,7 @@
466
472
 
467
473
  <hr class="sep">
468
474
  <div class="btn-row">
469
- <a class="btn btn-primary" href="index.html#steps">Do the walkthrough →</a>
475
+ <a class="btn btn-primary" href="how-it-works.html#steps">Do the walkthrough →</a>
470
476
  <a class="btn btn-ghost" href="reviewers.html">Read an evidence bundle</a>
471
477
  </div>
472
478
  </section>
@@ -474,8 +480,8 @@
474
480
 
475
481
  <footer class="footer">
476
482
  <div class="wrap">
477
- <p>Internal getting-started guide for the MetaMask agentic coding workflow. Not official MetaMask product documentation.</p>
478
- <p>Verified against mm-harness 0.33+.</p>
483
+ <p>Internal getting-started guide for proving MetaMask changes with recipes. Not official MetaMask product documentation.</p>
484
+ <p>Sample output moves between releases; trust your terminal over this page.</p>
479
485
  </div>
480
486
  </footer>
481
487
 
@@ -1,9 +1,9 @@
1
1
  /*
2
2
  * Shared behaviour for the getting-started site: checklist progress, copy
3
- * buttons, platform filters, and the layer diagram. No dependencies, no
3
+ * buttons, platform filters, tabs, and the layer diagram. No dependencies, no
4
4
  * network, no tracking. Progress lives in localStorage under
5
5
  * `mmh.progress.<page>`, where <page> comes from body[data-progress-page].
6
- * Pages that share a namespace share their state (Start Here and the V1
6
+ * Pages that share a namespace share their state (the How it works walkthrough and the V1
7
7
  * tutorial are deliberately the same checklist).
8
8
  */
9
9
  (function () {
@@ -235,6 +235,50 @@
235
235
  select('all');
236
236
  }
237
237
 
238
+ /* ---------- tabs ---------- */
239
+
240
+ /*
241
+ * One tablist per group, roving tabindex: only the selected tab is in the
242
+ * tab order, arrows move between them. Panels stay in the DOM so their
243
+ * commands remain copyable the moment a tab is shown.
244
+ */
245
+ function initTabs() {
246
+ [].slice.call(document.querySelectorAll('[role="tablist"]')).forEach(function (list) {
247
+ var tabs = [].slice.call(list.querySelectorAll('[role="tab"]'));
248
+ if (!tabs.length) return;
249
+
250
+ function select(tab, focus) {
251
+ tabs.forEach(function (other) {
252
+ var on = other === tab;
253
+ other.setAttribute('aria-selected', String(on));
254
+ other.tabIndex = on ? 0 : -1;
255
+ var panel = document.getElementById(other.getAttribute('aria-controls'));
256
+ if (panel) panel.hidden = !on;
257
+ });
258
+ if (focus) tab.focus();
259
+ }
260
+
261
+ tabs.forEach(function (tab, i) {
262
+ tab.addEventListener('click', function () { select(tab, false); });
263
+ tab.addEventListener('keydown', function (e) {
264
+ var next = null;
265
+ if (e.key === 'ArrowRight') next = tabs[(i + 1) % tabs.length];
266
+ if (e.key === 'ArrowLeft') next = tabs[(i - 1 + tabs.length) % tabs.length];
267
+ if (e.key === 'Home') next = tabs[0];
268
+ if (e.key === 'End') next = tabs[tabs.length - 1];
269
+ if (!next) return;
270
+ e.preventDefault();
271
+ select(next, true);
272
+ });
273
+ });
274
+
275
+ var current = tabs.filter(function (t) {
276
+ return t.getAttribute('aria-selected') === 'true';
277
+ })[0];
278
+ select(current || tabs[0], false);
279
+ });
280
+ }
281
+
238
282
  /* ---------- layer diagram ---------- */
239
283
 
240
284
  function initLayers() {
@@ -261,6 +305,7 @@
261
305
  initCopy();
262
306
  initCellCopy();
263
307
  initFilters();
308
+ initTabs();
264
309
  initLayers();
265
310
  }
266
311
 
@@ -37,7 +37,9 @@
37
37
 
38
38
  *, *::before, *::after { box-sizing: border-box; }
39
39
 
40
- html { scroll-behavior: smooth; scroll-padding-top: 92px; }
40
+ /* Clears the sticky bar plus the progress rail (114px together), so an anchor
41
+ never parks its heading underneath them. */
42
+ html { scroll-behavior: smooth; scroll-padding-top: 116px; }
41
43
 
42
44
  body {
43
45
  margin: 0;
@@ -158,6 +160,11 @@ main { padding-bottom: 6rem; }
158
160
  .nav a:hover { color: var(--text); background: var(--surface); text-decoration: none; }
159
161
  .nav a[aria-current="page"] { color: var(--accent); background: var(--accent-soft); }
160
162
 
163
+ /* The way back to the setup prompt. It leads the nav and stays visible when the
164
+ rest of the bar scrolls, because it is the one link every page owes a reader. */
165
+ .nav a.nav-cta { color: var(--accent); border: 1px solid var(--accent-line); }
166
+ .nav a.nav-cta:hover { background: var(--accent-soft); color: var(--accent); }
167
+
161
168
  /* ---------- progress rail ---------- */
162
169
 
163
170
  .progress-rail {
@@ -248,6 +255,169 @@ main { padding-bottom: 6rem; }
248
255
  color: var(--text-faint);
249
256
  }
250
257
 
258
+ /* ---------- lobby (front page) ----------
259
+ The entry page carries one idea and one call to action: no nav, no rail, no
260
+ checklist. Everything else in the site hangs off the single button below. */
261
+
262
+ .lobby {
263
+ display: flex;
264
+ align-items: center;
265
+ min-height: 100vh;
266
+ padding: 3rem 0 4rem;
267
+ }
268
+
269
+ .lobby-inner {
270
+ width: min(760px, calc(100% - 3rem));
271
+ margin: 0 auto;
272
+ text-align: center;
273
+ }
274
+
275
+ .lobby-mark {
276
+ display: block;
277
+ width: 26px;
278
+ height: 26px;
279
+ margin: 0 auto 2rem;
280
+ border-radius: 7px;
281
+ background: linear-gradient(145deg, var(--accent) 0%, var(--accent-deep) 100%);
282
+ transform: rotate(45deg);
283
+ box-shadow: 0 0 0 1px rgba(246, 133, 27, 0.3), 0 4px 18px rgba(246, 133, 27, 0.25);
284
+ }
285
+
286
+ .lobby h1 {
287
+ font-size: clamp(2rem, 4.6vw, 2.85rem);
288
+ max-width: 24ch;
289
+ margin: 0 auto 0.7rem;
290
+ /* Break at the comma rather than orphaning the last word. */
291
+ text-wrap: balance;
292
+ }
293
+
294
+ .lobby-lede {
295
+ font-size: 1.11rem;
296
+ line-height: 1.62;
297
+ color: var(--text-dim);
298
+ max-width: 60ch;
299
+ margin: 0 auto 1.1rem;
300
+ }
301
+
302
+ .lobby-sub {
303
+ font-size: 0.95rem;
304
+ color: var(--text-faint);
305
+ max-width: 54ch;
306
+ margin: 0 auto 1.5rem;
307
+ }
308
+
309
+ /* Code is never centred, even when its surroundings are. */
310
+ .lobby .cmd { text-align: left; }
311
+
312
+ /* The prompt is meant to be copied, not read: keep it short enough that the
313
+ button under it stays in view, and say so with a fade at the cut. */
314
+ /* Wider right gutter than the shared block: the copy control is bigger here. */
315
+ .lobby .cmd-hero pre { max-height: 15rem; font-size: 0.8rem; padding-right: 5rem; }
316
+
317
+ .lobby .cmd-hero::after {
318
+ content: "";
319
+ position: absolute;
320
+ left: 0;
321
+ right: 0;
322
+ bottom: 0;
323
+ height: 2.6rem;
324
+ pointer-events: none;
325
+ background: linear-gradient(to bottom, rgba(11, 12, 16, 0), #0b0c10);
326
+ }
327
+
328
+ /* Copying is the call to action here, so the control is a button, not a hint. */
329
+ .lobby .cmd-hero .copy {
330
+ font-size: 0.78rem;
331
+ font-weight: 600;
332
+ padding: 0.38rem 0.85rem;
333
+ color: #17120b;
334
+ background: var(--accent);
335
+ border-color: var(--accent);
336
+ }
337
+ .lobby .cmd-hero .copy:hover { color: #17120b; background: #ff9630; border-color: #ff9630; }
338
+ .lobby .cmd-hero .copy[data-copied="1"] {
339
+ color: #0a1a10;
340
+ background: var(--pass);
341
+ border-color: var(--pass);
342
+ }
343
+
344
+ /* ---------- quick start (lobby) ----------
345
+ Two steps: set up once, then pick the work. Left-aligned inside the centred
346
+ lobby, because everything in here is read line by line. */
347
+
348
+ .qs { text-align: left; margin-top: 1.8rem; }
349
+
350
+ .qs-step + .qs-step { margin-top: 2.6rem; }
351
+
352
+ .qs-title {
353
+ display: flex;
354
+ align-items: center;
355
+ gap: 0.65rem;
356
+ font-size: 1.16rem;
357
+ font-weight: 640;
358
+ margin: 0 0 0.35rem;
359
+ }
360
+
361
+ .qs-num {
362
+ flex: none;
363
+ width: 1.6rem;
364
+ height: 1.6rem;
365
+ display: grid;
366
+ place-items: center;
367
+ border-radius: 50%;
368
+ background: var(--accent-soft);
369
+ border: 1px solid var(--accent-line);
370
+ color: var(--accent);
371
+ font-family: var(--mono);
372
+ font-size: 0.8rem;
373
+ }
374
+
375
+ .qs-why { color: var(--text-dim); font-size: 0.95rem; margin-bottom: 0.9rem; }
376
+
377
+ /* ---------- tabs ---------- */
378
+
379
+ .tablist {
380
+ display: flex;
381
+ flex-wrap: wrap;
382
+ gap: 0.4rem;
383
+ margin-bottom: 0.9rem;
384
+ }
385
+
386
+ .tab {
387
+ font: 560 0.9rem var(--sans);
388
+ color: var(--text-dim);
389
+ background: var(--surface);
390
+ border: 1px solid var(--line);
391
+ border-radius: 99px;
392
+ padding: 0.4rem 1rem;
393
+ cursor: pointer;
394
+ transition: all 0.16s var(--ease);
395
+ }
396
+ .tab:hover { color: var(--text); border-color: var(--accent-line); }
397
+ .tab[aria-selected="true"] {
398
+ color: var(--accent);
399
+ background: var(--accent-soft);
400
+ border-color: var(--accent-line);
401
+ }
402
+
403
+ .qs-panel[hidden] { display: none; }
404
+ .qs-panel:focus-visible { outline-offset: 4px; }
405
+
406
+ .qs-panel-why { color: var(--text-dim); font-size: 0.92rem; margin-bottom: 0.5rem; }
407
+
408
+ /* These prompts are the call to action too, so their copy control never hides. */
409
+ .qs-panel .cmd .copy { opacity: 1; }
410
+ .qs-panel .cmd pre { white-space: pre-wrap; word-break: break-word; font-size: 0.82rem; }
411
+
412
+ .lobby-cta { display: flex; justify-content: center; gap: 0.7rem; flex-wrap: wrap; margin-top: 1.6rem; }
413
+
414
+ .lobby-fine {
415
+ margin: 2.4rem auto 0;
416
+ max-width: 56ch;
417
+ font-size: 0.86rem;
418
+ color: var(--text-faint);
419
+ }
420
+
251
421
  /* ---------- cards ---------- */
252
422
 
253
423
  .card {
@@ -639,6 +809,16 @@ tr[hidden], .filter-hidden { display: none !important; }
639
809
 
640
810
  .stack { display: flex; flex-direction: column; gap: 0.55rem; margin: 1.8rem 0; }
641
811
 
812
+ /* Names which end of the diagram is the one you touch. */
813
+ .stack-caption {
814
+ font-family: var(--mono);
815
+ font-size: 0.78rem;
816
+ letter-spacing: 0.04em;
817
+ color: var(--text-faint);
818
+ margin: 1.6rem 0 0.55rem;
819
+ }
820
+ .stack-caption + .stack { margin-top: 0; }
821
+
642
822
  .layer {
643
823
  width: 100%;
644
824
  text-align: left;