leglas 1.2.0 → 1.2.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/README.md CHANGED
@@ -21,30 +21,36 @@
21
21
 
22
22
  <p align="center"><i>Every direction in the rail, the selected one running as your actual app. Arrow keys flip between them.</i></p>
23
23
 
24
- Code is becoming the source of truth. Features go from prompt to
25
- working code in minutes, and mockups/design files eventually lag behind the product and drift out of sync. The
26
- fastest teams already design in the medium they ship. Leglas is built
27
- for working that way: it lets you explore many design directions at
28
- once, live, in your own app.
29
-
30
- The goal is to help devs and designers try many variations of a component, feature, page or user-flow quickly and make coming up with ideas extremely easy.
31
-
32
- Ask your agent for a handful of directions for the landing page,
33
- or the checkout component, or the empty states, or your onboarding flow. Leglas runs them all as your
34
- actual app, side by side in one place, and holds your notes on each. Explore far and wide without losing focus: you see more ideas
35
- without losing your opinion of any of them.
36
-
37
- And because every variation is the real product, your judgment is real
24
+ Code is becoming the source of truth. Features go from prompt to working
25
+ code in minutes, while mockups and design files fall behind the product
26
+ they describe. The fastest teams already design in the medium they ship, and
27
+ Leglas is built for working that way: many design directions, explored at
28
+ once, live in your own app.
29
+
30
+ The goal is to help devs and designers try many variations of a component,
31
+ feature, page or user-flow quickly and make coming up with ideas extremely
32
+ easy.
33
+
34
+ Ask your agent for a handful of directions for the landing page, the
35
+ checkout, the empty states or your onboarding flow. Leglas runs every one
36
+ of them as your actual app, in one place, and keeps your notes on each.
37
+ Explore far and wide without losing focus: you see more ideas without
38
+ losing your opinion of any of them.
39
+
40
+ And because every direction is the real product, your judgment is real
38
41
  too. Everything behaves the way it will in production, motion and data
39
- included. Choosing between two directions is choosing between two
40
- things that already exist, and the winner never has to be rebuilt from
41
- a picture or design file.
42
+ included. Choosing between two directions is choosing between two things
43
+ that already exist, and the winner never has to be rebuilt from a picture.
42
44
 
43
- Your app doesn't change to make any of this work. Leglas proxies the same
44
- dev server in your project: one config file to delete when you're done
45
- and sessions that clean up after themselves.
45
+ Leglas asks nothing of your app: no package, no import, no build step. It
46
+ sits in front of the dev server you already run, and its config file is
47
+ optional. New directions go in `.leglas/`, a folder Leglas adds to your
48
+ `.gitignore`, and keeping a winner clears the rest away. Sessions clean up
49
+ after themselves too: stopping Leglas shuts down everything it started,
50
+ from dev servers and branch checkouts to the share tunnel and any agent
51
+ still at work.
46
52
 
47
- ## What it does
53
+ ## What Leglas does
48
54
 
49
55
  <p align="center">
50
56
  <img src="https://raw.githubusercontent.com/FredAmartey/leglas/main/.github/assets/screenshots/compare-artboards.jpg" width="900" alt="The Leglas interface: the rail on the left, and two directions running side by side as the real app, each labelled with its name and the width it is drawn at." />
@@ -53,11 +59,12 @@ and sessions that clean up after themselves.
53
59
  <p align="center"><i>Two directions for the same page, running side by side as the actual app.</i></p>
54
60
 
55
61
  - Put any two directions side by side when it gets hard to choose.
56
- - Name each direction, drag to reorder, set aside the ones that don't feel
57
- right. Your opinion of every idea survives a long exploration.
62
+ - Name each direction, drag to reorder and set aside the ones that don't
63
+ feel right. Your opinion of every idea survives a long exploration.
58
64
  - Share the rail with someone who has no repo: a client, a cofounder, a
59
65
  teammate on another machine. They get the real app, in your order, and
60
- cannot change anything. See [Sharing](https://github.com/FredAmartey/leglas/blob/main/docs/sharing.md).
66
+ cannot change anything. Share it from the interface, from a terminal with
67
+ `npx leglas share` or by asking your agent. See [Sharing](https://github.com/FredAmartey/leglas/blob/main/docs/sharing.md).
61
68
  - Compare things no design tool can hold: three git branches, a local build
62
69
  against production, yesterday's direction against today's.
63
70
  - Hand the workflow to your coding agent. `leglas init` teaches it, and
@@ -67,13 +74,14 @@ and sessions that clean up after themselves.
67
74
  - Ask for changes without leaving the comparison. Describe what you want on
68
75
  the direction you are looking at, or point at it, and Leglas turns that
69
76
  into a precise request for your agent, file path and screenshots
70
- included. The agents on your machine are one click away next to the send
71
- button. Your agent, your subscription, no keys.
72
- - A change makes a variant, not a rewrite. The result registers under the
77
+ included. Pick any agent on your machine from beside the send button.
78
+ Your agent, your subscription, no keys.
79
+ - A change makes a variant, not a rewrite. The result appears under the
73
80
  direction it came from, with the original still there to compare against.
74
81
  - Keep the winner with one command. Leglas moves it into your source tree,
75
- writes down what the exploration decided and clears the rest away.
76
- - No app yet? Plain HTML files compare the same way, no dev server needed.
82
+ writes down what the exploration decided and cleans up after itself.
83
+ - No app yet? Leglas compares plain HTML files the same way, with no dev
84
+ server.
77
85
 
78
86
  ## Get started
79
87
 
@@ -99,16 +107,18 @@ export default {
99
107
  };
100
108
  ```
101
109
 
102
- Each preview is a URL your dev server already answers. Every field, plain
103
- HTML files with no dev server, git branches side by side and how the proxy
104
- works are in [Setting up a project](https://github.com/FredAmartey/leglas/blob/main/docs/configuration.md).
110
+ Each preview is a URL your dev server already answers. [Setting up a
111
+ project](https://github.com/FredAmartey/leglas/blob/main/docs/configuration.md)
112
+ covers every field, plain HTML files with no dev server, git branches side
113
+ by side and how the proxy works.
105
114
 
106
115
  It works with whatever you are building in. Leglas never imports or
107
116
  executes your framework, so the target can be Next, Vite, Remix, SvelteKit,
108
- Astro or a folder of static files. It needs Node 24 or newer, and nothing
109
- else: `npx` fetches the CLI on first use and starts from the cache after
110
- that. To pin a version for a project, `npm install -D leglas`; to type
111
- `leglas` without the prefix, `npm install -g leglas`.
117
+ Astro or a folder of static files. It needs Node 24 or newer and nothing
118
+ else; `npx` fetches it the first time you run it. Leglas tells you when a
119
+ newer version is out and can update itself. To pin a version for a
120
+ project, `npm install -D leglas`; to type `leglas` without `npx`,
121
+ `npm install -g leglas`.
112
122
 
113
123
  ## With your agent
114
124
 
@@ -121,9 +131,9 @@ pricing page" as a Leglas exploration in any project, including ones that
121
131
  have never seen Leglas. In a project, `npx leglas init` writes the whole
122
132
  workflow into `AGENTS.md`, so Claude Code, Cursor, Codex or whatever you
123
133
  switch to next opens the project already knowing how to add design
124
- directions to it. The one rule that workflow centers on, the commands an
125
- agent runs, the MCP server and the Agent Plugin are in
126
- [Working with agents](https://github.com/FredAmartey/leglas/blob/main/docs/agents.md).
134
+ directions to it. [Working with agents](https://github.com/FredAmartey/leglas/blob/main/docs/agents.md)
135
+ has the one rule that workflow is built around, the commands an agent runs,
136
+ the MCP server and the Agent Plugin.
127
137
 
128
138
  ## Documentation
129
139
 
package/dist/bin.js CHANGED
@@ -2427,6 +2427,12 @@ var LOCATOR = `(function (selector, text, tag) {
2427
2427
  if (r.width === 0 && r.height === 0) return null;
2428
2428
  return { x: r.left + window.scrollX, y: r.top + window.scrollY, width: r.width, height: r.height };
2429
2429
  })`;
2430
+ var DRAWN_WITH = /* @__PURE__ */ new Set(["Script", "Stylesheet", "Font", "Image"]);
2431
+ var LAID_OUT = `(async () => {
2432
+ await new Promise((next) => requestAnimationFrame(() => requestAnimationFrame(next)));
2433
+ if (document.fonts) await document.fonts.ready;
2434
+ return true;
2435
+ })()`;
2430
2436
  function clamp(value, low, high) {
2431
2437
  return Math.min(high, Math.max(low, value));
2432
2438
  }
@@ -2508,6 +2514,22 @@ async function render(page, input) {
2508
2514
  errors.push(message2);
2509
2515
  };
2510
2516
  let documentStatus = null;
2517
+ const pending = /* @__PURE__ */ new Set();
2518
+ const afterLoadScripts = /* @__PURE__ */ new Set();
2519
+ let loadFired = false;
2520
+ let scriptLanded = null;
2521
+ let asked = 0;
2522
+ let emptied = () => {
2523
+ };
2524
+ const received = (id) => {
2525
+ if (afterLoadScripts.delete(id))
2526
+ scriptLanded = Date.now();
2527
+ if (pending.delete(id) && pending.size === 0)
2528
+ emptied();
2529
+ };
2530
+ const landed = () => pending.size === 0 ? Promise.resolve() : new Promise((resolve5) => {
2531
+ emptied = resolve5;
2532
+ });
2511
2533
  const unlisten = [
2512
2534
  page.on("Network.responseReceived", (params) => {
2513
2535
  if (documentStatus !== null || params?.type !== "Document")
@@ -2516,6 +2538,25 @@ async function render(page, input) {
2516
2538
  if (isNumber(status))
2517
2539
  documentStatus = status;
2518
2540
  }),
2541
+ page.on("Network.requestWillBeSent", (params) => {
2542
+ const id = params?.requestId;
2543
+ if (!DRAWN_WITH.has(params?.type) || !isString(id))
2544
+ return;
2545
+ if (!pending.has(id)) {
2546
+ asked += 1;
2547
+ if (loadFired && params?.type === "Script")
2548
+ afterLoadScripts.add(id);
2549
+ }
2550
+ pending.add(id);
2551
+ }),
2552
+ page.on("Network.loadingFinished", (params) => {
2553
+ if (isString(params?.requestId))
2554
+ received(params.requestId);
2555
+ }),
2556
+ page.on("Network.loadingFailed", (params) => {
2557
+ if (isString(params?.requestId))
2558
+ received(params.requestId);
2559
+ }),
2519
2560
  page.on("Runtime.exceptionThrown", (params) => remember(params?.exceptionDetails?.exception?.description ?? params?.exceptionDetails?.text)),
2520
2561
  page.on("Runtime.consoleAPICalled", (params) => {
2521
2562
  if (params?.type !== "error")
@@ -2542,7 +2583,10 @@ async function render(page, input) {
2542
2583
  const load = new Promise((resolve5) => {
2543
2584
  loaded = resolve5;
2544
2585
  });
2545
- const stopLoad = page.on("Page.loadEventFired", () => loaded());
2586
+ const stopLoad = page.on("Page.loadEventFired", () => {
2587
+ loadFired = true;
2588
+ loaded();
2589
+ });
2546
2590
  unlisten.push(stopLoad);
2547
2591
  const navigation = await page.send("Page.navigate", { url: input.url });
2548
2592
  if (isString(navigation.errorText) && navigation.errorText !== "") {
@@ -2553,12 +2597,28 @@ async function render(page, input) {
2553
2597
  if (documentStatus !== null && documentStatus >= 500) {
2554
2598
  throw new Error(`The page did not load: the app answered HTTP ${documentStatus}.`);
2555
2599
  }
2556
- await bounded(page.send("Runtime.evaluate", {
2557
- expression: "document.fonts ? document.fonts.ready.then(() => true) : true",
2558
- awaitPromise: true,
2559
- returnByValue: true
2560
- }), 2e3, void 0).catch(() => {
2561
- });
2600
+ const until = Date.now() + 2e3;
2601
+ let waitedForScript = null;
2602
+ while (Date.now() < until) {
2603
+ await bounded(landed(), until - Date.now(), void 0);
2604
+ const before = asked;
2605
+ const answered = await bounded(page.send("Runtime.evaluate", {
2606
+ expression: LAID_OUT,
2607
+ awaitPromise: true,
2608
+ returnByValue: true
2609
+ }), Math.max(0, until - Date.now()), void 0).then(() => true, () => false);
2610
+ if (!answered)
2611
+ break;
2612
+ if (pending.size !== 0 || asked !== before)
2613
+ continue;
2614
+ const latestScript = scriptLanded;
2615
+ if (latestScript === null || latestScript === waitedForScript)
2616
+ break;
2617
+ const remaining = Math.min(latestScript + 300, until) - Date.now();
2618
+ if (remaining > 0)
2619
+ await new Promise((resolve5) => setTimeout(resolve5, remaining));
2620
+ waitedForScript = latestScript;
2621
+ }
2562
2622
  await bounded(page.send("Runtime.evaluate", {
2563
2623
  expression: `(async () => {
2564
2624
  // The page stops itself before the caller's deadline can. Without
@@ -9260,8 +9320,10 @@ type SearchParams = Record<string, string | string[] | undefined>;
9260
9320
  /**
9261
9321
  * Production always renders the fallback, whatever the URL says, so a branch
9262
9322
  * point that reaches a deployed build cannot expose an unreleased direction.
9323
+ * It is not exported because a file whose exports are all components hot-swaps
9324
+ * in place, while an extra export makes every edit reload the page.
9263
9325
  */
9264
- export function resolve${titleCase(slug).replace(/\s/g, "")}(searchParams: SearchParams): Direction {
9326
+ function resolve${titleCase(slug).replace(/\s/g, "")}(searchParams: SearchParams): Direction {
9265
9327
  if (process.env.NODE_ENV === "production") return FALLBACK;
9266
9328
  const raw = searchParams["v-${slug}"];
9267
9329
  const value = Array.isArray(raw) ? raw[0] : raw;
@@ -9307,8 +9369,10 @@ function isProduction(): boolean {
9307
9369
  /**
9308
9370
  * Production always renders the fallback, whatever the URL says, so a branch
9309
9371
  * point that reaches a deployed build cannot expose an unreleased direction.
9372
+ * It is not exported because a file whose exports are all components hot-swaps
9373
+ * in place, while an extra export makes every edit reload the page.
9310
9374
  */
9311
- export function resolve${name}(): Direction {
9375
+ function resolve${name}(): Direction {
9312
9376
  if (isProduction()) return FALLBACK;
9313
9377
  const value = new URLSearchParams(window.location.search).get("v-${slug}");
9314
9378
  return value !== null && value in DIRECTIONS ? (value as Direction) : FALLBACK;
package/dist/index.js CHANGED
@@ -598,8 +598,10 @@ type SearchParams = Record<string, string | string[] | undefined>;
598
598
  /**
599
599
  * Production always renders the fallback, whatever the URL says, so a branch
600
600
  * point that reaches a deployed build cannot expose an unreleased direction.
601
+ * It is not exported because a file whose exports are all components hot-swaps
602
+ * in place, while an extra export makes every edit reload the page.
601
603
  */
602
- export function resolve${titleCase(slug).replace(/\s/g, "")}(searchParams: SearchParams): Direction {
604
+ function resolve${titleCase(slug).replace(/\s/g, "")}(searchParams: SearchParams): Direction {
603
605
  if (process.env.NODE_ENV === "production") return FALLBACK;
604
606
  const raw = searchParams["v-${slug}"];
605
607
  const value = Array.isArray(raw) ? raw[0] : raw;
@@ -645,8 +647,10 @@ function isProduction(): boolean {
645
647
  /**
646
648
  * Production always renders the fallback, whatever the URL says, so a branch
647
649
  * point that reaches a deployed build cannot expose an unreleased direction.
650
+ * It is not exported because a file whose exports are all components hot-swaps
651
+ * in place, while an extra export makes every edit reload the page.
648
652
  */
649
- export function resolve${name}(): Direction {
653
+ function resolve${name}(): Direction {
650
654
  if (isProduction()) return FALLBACK;
651
655
  const value = new URLSearchParams(window.location.search).get("v-${slug}");
652
656
  return value !== null && value in DIRECTIONS ? (value as Direction) : FALLBACK;
@@ -2819,6 +2823,12 @@ var LOCATOR = `(function (selector, text, tag) {
2819
2823
  if (r.width === 0 && r.height === 0) return null;
2820
2824
  return { x: r.left + window.scrollX, y: r.top + window.scrollY, width: r.width, height: r.height };
2821
2825
  })`;
2826
+ var DRAWN_WITH = /* @__PURE__ */ new Set(["Script", "Stylesheet", "Font", "Image"]);
2827
+ var LAID_OUT = `(async () => {
2828
+ await new Promise((next) => requestAnimationFrame(() => requestAnimationFrame(next)));
2829
+ if (document.fonts) await document.fonts.ready;
2830
+ return true;
2831
+ })()`;
2822
2832
  function clamp(value, low, high) {
2823
2833
  return Math.min(high, Math.max(low, value));
2824
2834
  }
@@ -2900,6 +2910,22 @@ async function render(page, input) {
2900
2910
  errors.push(message2);
2901
2911
  };
2902
2912
  let documentStatus = null;
2913
+ const pending = /* @__PURE__ */ new Set();
2914
+ const afterLoadScripts = /* @__PURE__ */ new Set();
2915
+ let loadFired = false;
2916
+ let scriptLanded = null;
2917
+ let asked = 0;
2918
+ let emptied = () => {
2919
+ };
2920
+ const received = (id) => {
2921
+ if (afterLoadScripts.delete(id))
2922
+ scriptLanded = Date.now();
2923
+ if (pending.delete(id) && pending.size === 0)
2924
+ emptied();
2925
+ };
2926
+ const landed = () => pending.size === 0 ? Promise.resolve() : new Promise((resolve5) => {
2927
+ emptied = resolve5;
2928
+ });
2903
2929
  const unlisten = [
2904
2930
  page.on("Network.responseReceived", (params) => {
2905
2931
  if (documentStatus !== null || params?.type !== "Document")
@@ -2908,6 +2934,25 @@ async function render(page, input) {
2908
2934
  if (isNumber(status))
2909
2935
  documentStatus = status;
2910
2936
  }),
2937
+ page.on("Network.requestWillBeSent", (params) => {
2938
+ const id = params?.requestId;
2939
+ if (!DRAWN_WITH.has(params?.type) || !isString(id))
2940
+ return;
2941
+ if (!pending.has(id)) {
2942
+ asked += 1;
2943
+ if (loadFired && params?.type === "Script")
2944
+ afterLoadScripts.add(id);
2945
+ }
2946
+ pending.add(id);
2947
+ }),
2948
+ page.on("Network.loadingFinished", (params) => {
2949
+ if (isString(params?.requestId))
2950
+ received(params.requestId);
2951
+ }),
2952
+ page.on("Network.loadingFailed", (params) => {
2953
+ if (isString(params?.requestId))
2954
+ received(params.requestId);
2955
+ }),
2911
2956
  page.on("Runtime.exceptionThrown", (params) => remember(params?.exceptionDetails?.exception?.description ?? params?.exceptionDetails?.text)),
2912
2957
  page.on("Runtime.consoleAPICalled", (params) => {
2913
2958
  if (params?.type !== "error")
@@ -2934,7 +2979,10 @@ async function render(page, input) {
2934
2979
  const load = new Promise((resolve5) => {
2935
2980
  loaded = resolve5;
2936
2981
  });
2937
- const stopLoad = page.on("Page.loadEventFired", () => loaded());
2982
+ const stopLoad = page.on("Page.loadEventFired", () => {
2983
+ loadFired = true;
2984
+ loaded();
2985
+ });
2938
2986
  unlisten.push(stopLoad);
2939
2987
  const navigation = await page.send("Page.navigate", { url: input.url });
2940
2988
  if (isString(navigation.errorText) && navigation.errorText !== "") {
@@ -2945,12 +2993,28 @@ async function render(page, input) {
2945
2993
  if (documentStatus !== null && documentStatus >= 500) {
2946
2994
  throw new Error(`The page did not load: the app answered HTTP ${documentStatus}.`);
2947
2995
  }
2948
- await bounded(page.send("Runtime.evaluate", {
2949
- expression: "document.fonts ? document.fonts.ready.then(() => true) : true",
2950
- awaitPromise: true,
2951
- returnByValue: true
2952
- }), 2e3, void 0).catch(() => {
2953
- });
2996
+ const until = Date.now() + 2e3;
2997
+ let waitedForScript = null;
2998
+ while (Date.now() < until) {
2999
+ await bounded(landed(), until - Date.now(), void 0);
3000
+ const before = asked;
3001
+ const answered = await bounded(page.send("Runtime.evaluate", {
3002
+ expression: LAID_OUT,
3003
+ awaitPromise: true,
3004
+ returnByValue: true
3005
+ }), Math.max(0, until - Date.now()), void 0).then(() => true, () => false);
3006
+ if (!answered)
3007
+ break;
3008
+ if (pending.size !== 0 || asked !== before)
3009
+ continue;
3010
+ const latestScript = scriptLanded;
3011
+ if (latestScript === null || latestScript === waitedForScript)
3012
+ break;
3013
+ const remaining = Math.min(latestScript + 300, until) - Date.now();
3014
+ if (remaining > 0)
3015
+ await new Promise((resolve5) => setTimeout(resolve5, remaining));
3016
+ waitedForScript = latestScript;
3017
+ }
2954
3018
  await bounded(page.send("Runtime.evaluate", {
2955
3019
  expression: `(async () => {
2956
3020
  // The page stops itself before the caller's deadline can. Without
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leglas",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Compare design directions inside your own running app.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,8 +28,8 @@
28
28
  "types": "./dist/index.d.ts",
29
29
  "devDependencies": {
30
30
  "tsup": "^8.5.0",
31
- "@leglas/server": "0.0.0",
32
- "@leglas/shell": "0.0.0"
31
+ "@leglas/shell": "0.0.0",
32
+ "@leglas/server": "0.0.0"
33
33
  },
34
34
  "engines": {
35
35
  "node": ">=24"