@monoes/monodesign 1.0.0 → 1.1.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/NOTICE.md ADDED
@@ -0,0 +1,21 @@
1
+ # Third-Party Notices
2
+
3
+ This package includes content derived from third-party work, used under the terms of its original license.
4
+
5
+ ## Impeccable
6
+
7
+ The design engine (`cli/engine/`, including the 46-rule antipattern registry and detector), the skill command references and router (`skill/SKILL.src.md`, `skill/reference/`, `skill/commands/`), the live browser iteration mode (`skill/scripts/live*.mjs` and helpers), the design-check hooks, and the sub-agent definitions (`skill/agents/`) are adapted from Paul Bakaus's Impeccable (v3.2.1), rebranded and integrated for monomind as monodesign.
8
+
9
+ **Original work:** https://github.com/pbakaus/impeccable
10
+ **Original license:** Apache-2.0
11
+ **Author:** Paul Bakaus
12
+
13
+ Pattern adapted from impeccable (Apache-2.0) — rebranded for monomind
14
+
15
+ ## Platform Design Skills
16
+
17
+ The `skill/reference/ios.md` and `skill/reference/android.md` platform reference files are distilled from ehmo's `platform-design-skills` (Apple Human Interface Guidelines and Material Design 3 rules), rewritten in the skill's voice.
18
+
19
+ **Original work:** https://github.com/ehmo/platform-design-skills
20
+ **Original license:** MIT
21
+ **Author:** ehmo
@@ -7,6 +7,7 @@ import { finding } from '../../findings.mjs';
7
7
  import { filterByProviders } from '../../registry/antipatterns.mjs';
8
8
  import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
9
9
  import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
10
+ import { launchDetectionBrowser, normalizeBrowserHandle } from './drivers.mjs';
10
11
 
11
12
  function serializeDesignSystemForBrowser(designSystem) {
12
13
  if (!designSystem?.present) return null;
@@ -101,7 +102,8 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
101
102
  }
102
103
 
103
104
  // ---------------------------------------------------------------------------
104
- // Puppeteer detection (for URLs)
105
+ // Browser detection (for URLs) — driver-based: monobrowse (native CDP,
106
+ // preferred) with puppeteer as fallback. See ./drivers.mjs for the seam.
105
107
  // ---------------------------------------------------------------------------
106
108
 
107
109
  async function detectUrl(url, options = {}) {
@@ -109,20 +111,9 @@ async function detectUrl(url, options = {}) {
109
111
  const waitUntil = options?.waitUntil || 'networkidle0';
110
112
  const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
111
113
  const viewport = options?.viewport || { width: 1280, height: 800 };
112
- const externalBrowser = options?.browser || null;
113
- let puppeteer;
114
- if (!externalBrowser) {
115
- try {
116
- puppeteer = await profileStepAsync(profile, {
117
- engine: 'browser',
118
- phase: 'setup',
119
- ruleId: 'import-puppeteer',
120
- target: url,
121
- }, () => import('puppeteer'));
122
- } catch {
123
- throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
124
- }
125
- }
114
+ // options.browser accepts either a driver handle (from createBrowserDetector)
115
+ // or a raw puppeteer Browser (legacy callers).
116
+ const externalBrowser = options?.browser ? normalizeBrowserHandle(options.browser) : null;
126
117
 
127
118
  // Read the browser detection script — reuse it instead of reimplementing
128
119
  const browserScriptPath = path.resolve(
@@ -143,16 +134,12 @@ async function detectUrl(url, options = {}) {
143
134
  throw new Error(`Browser script not found at ${browserScriptPath}`);
144
135
  }
145
136
 
146
- // CI runners (GitHub Actions Ubuntu) block unprivileged user namespaces, so
147
- // Chrome can't initialize its sandbox there. Disable the sandbox only when
148
- // running in CI; local users keep the default hardened launch.
149
- const launchArgs = process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [];
150
137
  const browser = externalBrowser || await profileStepAsync(profile, {
151
138
  engine: 'browser',
152
139
  phase: 'load',
153
140
  ruleId: 'launch-browser',
154
141
  target: url,
155
- }, () => puppeteer.default.launch({ headless: true, args: launchArgs }));
142
+ }, () => launchDetectionBrowser({ launchArgs: options?.launchArgs, headless: true }));
156
143
  const page = await profileStepAsync(profile, {
157
144
  engine: 'browser',
158
145
  phase: 'load',
@@ -243,17 +230,12 @@ async function detectUrl(url, options = {}) {
243
230
  }
244
231
 
245
232
  async function createBrowserDetector(options = {}) {
246
- let puppeteer;
247
- try {
248
- puppeteer = await import('puppeteer');
249
- } catch {
250
- throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
251
- }
252
- const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []);
253
- const browser = options.browser || await puppeteer.default.launch({
254
- headless: options.headless ?? true,
255
- args: launchArgs,
256
- });
233
+ // Pooled reuse for multiple URLs. `browser` is a driver handle (monobrowse
234
+ // or puppeteer); pass a raw puppeteer Browser via options.browser to reuse
235
+ // an externally managed instance.
236
+ const browser = options.browser
237
+ ? normalizeBrowserHandle(options.browser)
238
+ : await launchDetectionBrowser({ launchArgs: options.launchArgs, headless: options.headless ?? true });
257
239
  const ownsBrowser = !options.browser;
258
240
  const defaults = {
259
241
  waitUntil: options.waitUntil || 'load',
@@ -0,0 +1,259 @@
1
+ // Pattern adapted from impeccable (Apache-2.0) — rebranded for monomind
2
+ //
3
+ // Browser driver seam for URL detection.
4
+ //
5
+ // The detection engine (detect-url.mjs) and the visual-contrast fallback
6
+ // (engines/visual/screenshot-contrast.mjs) only need a tiny, puppeteer-shaped
7
+ // page surface:
8
+ //
9
+ // browser: { driverName, newPage(): Promise<page>, close(): Promise<void> }
10
+ // page: {
11
+ // setViewport({ width, height })
12
+ // goto(url, { waitUntil, timeout })
13
+ // evaluate(fnOrExpressionString, ...serializableArgs) -> serializable value
14
+ // screenshot({ encoding: 'base64', clip?, captureBeyondViewport? }) -> base64 string
15
+ // close()
16
+ // }
17
+ //
18
+ // Two drivers implement it:
19
+ // - monobrowse (preferred): @monoes/monobrowse, the monorepo's native CDP
20
+ // client. Drives a locally installed Chrome/Edge/Chromium — no bundled
21
+ // Chromium download needed. Supports the full surface including clip
22
+ // screenshots (Page.captureScreenshot) and evaluate roundtrips
23
+ // (Runtime.evaluate), so the visual-contrast pixel fallback runs under it.
24
+ // - puppeteer (fallback): the original optionalDependency path, kept for
25
+ // environments that have puppeteer + its downloaded Chrome but no system
26
+ // browser. A raw puppeteer Page already satisfies the page surface, so the
27
+ // puppeteer driver returns real puppeteer pages untouched.
28
+ //
29
+ // Selection: MONODESIGN_BROWSER_DRIVER=monobrowse|puppeteer forces one;
30
+ // otherwise monobrowse is tried first and puppeteer is the fallback.
31
+
32
+ const DRIVER_ENV = 'MONODESIGN_BROWSER_DRIVER';
33
+
34
+ // Ports we launch headless Chrome on for detection runs. Deliberately away
35
+ // from 9222 (the default `monomind browse` port): we must never attach to a
36
+ // browser we did not launch, because browser.close() issues Browser.close.
37
+ const MONOBROWSE_PORT_MIN = 9520;
38
+ const MONOBROWSE_PORT_RANGE = 380;
39
+
40
+ function serializeEvalArg(arg) {
41
+ const json = JSON.stringify(arg);
42
+ return json === undefined ? 'undefined' : json;
43
+ }
44
+
45
+ function mapWaitUntil(waitUntil) {
46
+ if (waitUntil === 'domcontentloaded') return 'domcontentloaded';
47
+ if (waitUntil === 'networkidle0' || waitUntil === 'networkidle2' || waitUntil === 'networkidle') return 'networkidle';
48
+ return 'load';
49
+ }
50
+
51
+ function withTimeout(promise, ms, label) {
52
+ let handle;
53
+ const timeout = new Promise((_, reject) => {
54
+ handle = setTimeout(() => reject(new Error(`Timeout waiting for ${label} after ${ms}ms`)), ms);
55
+ handle.unref?.();
56
+ });
57
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(handle));
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // monobrowse driver
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function createMonobrowsePage(mb, client, sessionId, targetId, port) {
65
+ return {
66
+ async setViewport({ width, height }) {
67
+ await mb.setViewport(client, sessionId, width, height);
68
+ },
69
+ async goto(url, { waitUntil = 'load', timeout = 30000 } = {}) {
70
+ const condition = mapWaitUntil(waitUntil);
71
+ if (condition === 'networkidle') {
72
+ const nav = await client.send('Page.navigate', { url }, sessionId);
73
+ if (nav?.errorText) throw new Error(`Navigation to ${url} failed: ${nav.errorText}`);
74
+ await mb.waitForLoad(client, sessionId, 'networkidle', timeout);
75
+ return;
76
+ }
77
+ // Register the lifecycle listener BEFORE navigating so a fast page load
78
+ // cannot slip past it (waitForLoad's readyState pre-check would otherwise
79
+ // race against the previous document's readyState).
80
+ const event = condition === 'load' ? 'Page.loadEventFired' : 'Page.domContentEventFired';
81
+ const [eventPromise, cancel] = client.onceWithOff(event, sessionId);
82
+ try {
83
+ const nav = await client.send('Page.navigate', { url }, sessionId);
84
+ if (nav?.errorText) throw new Error(`Navigation to ${url} failed: ${nav.errorText}`);
85
+ await withTimeout(eventPromise, timeout, condition);
86
+ } finally {
87
+ cancel();
88
+ }
89
+ },
90
+ async evaluate(fnOrExpression, ...args) {
91
+ const expression = typeof fnOrExpression === 'function'
92
+ ? `(${String(fnOrExpression)})(${args.map(serializeEvalArg).join(', ')})`
93
+ : String(fnOrExpression);
94
+ const result = await client.send('Runtime.evaluate', {
95
+ expression,
96
+ returnByValue: true,
97
+ awaitPromise: true,
98
+ }, sessionId);
99
+ if (result?.exceptionDetails) {
100
+ const details = result.exceptionDetails;
101
+ throw new Error(details.exception?.description || details.exception?.value || details.text || 'Evaluation failed');
102
+ }
103
+ return result?.result?.value;
104
+ },
105
+ // Always returns a base64 string (the only mode the engine uses).
106
+ async screenshot({ clip } = {}) {
107
+ const params = { format: 'png' };
108
+ if (clip) {
109
+ params.clip = { x: clip.x, y: clip.y, width: clip.width, height: clip.height, scale: 1 };
110
+ params.captureBeyondViewport = true;
111
+ }
112
+ const result = await client.send('Page.captureScreenshot', params, sessionId);
113
+ return result?.data;
114
+ },
115
+ async close() {
116
+ // HTTP endpoint works regardless of which domains the ws connection has.
117
+ try {
118
+ await fetch(`http://127.0.0.1:${port}/json/close/${encodeURIComponent(targetId)}`);
119
+ } catch {
120
+ // Tab may already be gone; browser close cleans up anything left.
121
+ }
122
+ client.close();
123
+ },
124
+ };
125
+ }
126
+
127
+ async function launchMonobrowseBrowser(options = {}) {
128
+ const mb = await import('@monoes/monobrowse');
129
+ const headless = options.headless ?? true;
130
+ const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []);
131
+
132
+ const forcedPort = Number(process.env.MONODESIGN_MONOBROWSE_PORT);
133
+ let port = null;
134
+ if (Number.isInteger(forcedPort) && forcedPort > 0) {
135
+ port = forcedPort;
136
+ } else {
137
+ // Pick a free-looking port; skip ports where a CDP endpoint already
138
+ // listens so we never take over (and later Browser.close) someone
139
+ // else's browser session.
140
+ for (let attempt = 0; attempt < 5 && port === null; attempt++) {
141
+ const candidate = MONOBROWSE_PORT_MIN + Math.floor(Math.random() * MONOBROWSE_PORT_RANGE);
142
+ if (!(await mb.isPortOpen(candidate))) port = candidate;
143
+ }
144
+ if (port === null) throw new Error('Could not find a free CDP port for monobrowse');
145
+ }
146
+
147
+ const launchedPort = await mb.launchBrowser({ port, headless, args: launchArgs });
148
+ // Control connection (to the initial about:blank tab) — used for lifecycle
149
+ // commands; detection pages get their own dedicated connections.
150
+ const control = await mb.connectToTarget(launchedPort);
151
+
152
+ async function openPage() {
153
+ const target = await mb.fetchNewTarget(launchedPort, 'about:blank');
154
+ const wsUrl = target.webSocketDebuggerUrl || `ws://127.0.0.1:${launchedPort}/devtools/page/${target.id}`;
155
+ const client = new mb.CdpClient();
156
+ await client.connect(wsUrl);
157
+ const { sessionId } = await client.send('Target.attachToTarget', { targetId: target.id, flatten: true });
158
+ await Promise.all([
159
+ client.send('Page.enable', {}, sessionId),
160
+ client.send('Runtime.enable', {}, sessionId),
161
+ client.send('Network.enable', {}, sessionId),
162
+ ]);
163
+ return createMonobrowsePage(mb, client, sessionId, target.id, launchedPort);
164
+ }
165
+
166
+ return {
167
+ driverName: 'monobrowse',
168
+ newPage: openPage,
169
+ async close() {
170
+ try {
171
+ if (typeof mb.closeBrowser === 'function') {
172
+ // Newer monobrowse: graceful Browser.close with PID-kill fallback.
173
+ await mb.closeBrowser(control.client, launchedPort);
174
+ } else {
175
+ // Published 1.0.3 has no closeBrowser export — send the CDP
176
+ // Browser.close command directly (best effort, short timeout).
177
+ await withTimeout(control.client.send('Browser.close', {}), 3000, 'Browser.close').catch(() => {});
178
+ }
179
+ } finally {
180
+ control.client.close();
181
+ }
182
+ },
183
+ };
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // puppeteer driver (original behavior, kept as fallback)
188
+ // ---------------------------------------------------------------------------
189
+
190
+ function wrapPuppeteerBrowser(browser) {
191
+ return {
192
+ driverName: 'puppeteer',
193
+ raw: browser,
194
+ newPage: () => browser.newPage(),
195
+ close: () => browser.close(),
196
+ };
197
+ }
198
+
199
+ async function launchPuppeteerBrowser(options = {}) {
200
+ let puppeteer;
201
+ try {
202
+ puppeteer = await import('puppeteer');
203
+ } catch {
204
+ throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
205
+ }
206
+ // CI runners (GitHub Actions Ubuntu) block unprivileged user namespaces, so
207
+ // Chrome can't initialize its sandbox there. Disable the sandbox only when
208
+ // running in CI; local users keep the default hardened launch.
209
+ const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []);
210
+ const browser = await puppeteer.default.launch({
211
+ headless: options.headless ?? true,
212
+ args: launchArgs,
213
+ });
214
+ return wrapPuppeteerBrowser(browser);
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Selection
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /** Accepts either a driver browser handle or a raw puppeteer Browser. */
222
+ function normalizeBrowserHandle(browser) {
223
+ if (!browser) return null;
224
+ if (browser.driverName && typeof browser.newPage === 'function') return browser;
225
+ if (typeof browser.newPage === 'function') return wrapPuppeteerBrowser(browser);
226
+ throw new Error('Unsupported browser handle passed to detectUrl (expected a driver handle or puppeteer Browser)');
227
+ }
228
+
229
+ async function launchDetectionBrowser(options = {}) {
230
+ const forced = (process.env[DRIVER_ENV] || '').trim().toLowerCase();
231
+ if (forced && forced !== 'monobrowse' && forced !== 'puppeteer') {
232
+ throw new Error(`Invalid ${DRIVER_ENV}=${forced} (expected "monobrowse" or "puppeteer")`);
233
+ }
234
+ const order = forced ? [forced] : ['monobrowse', 'puppeteer'];
235
+ const errors = [];
236
+ for (const name of order) {
237
+ try {
238
+ return name === 'monobrowse'
239
+ ? await launchMonobrowseBrowser(options)
240
+ : await launchPuppeteerBrowser(options);
241
+ } catch (error) {
242
+ errors.push(`${name}: ${error?.message || error}`);
243
+ }
244
+ }
245
+ throw new Error(
246
+ 'A browser is required for URL scanning and no driver could start one ' +
247
+ `(${errors.join(' | ')}). ` +
248
+ 'Install Google Chrome/Chromium/Edge for the native @monoes/monobrowse driver, ' +
249
+ 'or puppeteer is required for URL scanning. Install: npm install puppeteer'
250
+ );
251
+ }
252
+
253
+ export {
254
+ launchDetectionBrowser,
255
+ launchMonobrowseBrowser,
256
+ launchPuppeteerBrowser,
257
+ normalizeBrowserHandle,
258
+ wrapPuppeteerBrowser,
259
+ };
@@ -1,4 +1,13 @@
1
1
  // Pattern adapted from impeccable (Apache-2.0) — rebranded for monomind
2
+ //
3
+ // Visual-contrast pixel fallback. `page` is the driver page abstraction from
4
+ // ../browser/drivers.mjs (a raw puppeteer Page also satisfies it): it only
5
+ // needs `evaluate(fn, arg)` roundtrips and `screenshot({ encoding: 'base64',
6
+ // clip, captureBeyondViewport })` returning a base64 PNG string. The logic is
7
+ // driver-agnostic: hide the glyphs via CSS, screenshot the clip twice, and
8
+ // diff the pixels in-page on a canvas. Both the monobrowse (CDP
9
+ // Page.captureScreenshot + Runtime.evaluate) and puppeteer drivers support
10
+ // this fully.
2
11
  function sanitizeScreenshotClip(clip, viewport) {
3
12
  if (!clip) return null;
4
13
  const x = Math.max(0, Math.floor(clip.x || 0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monodesign",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Frontend design intelligence for AI agents \u2014 design tokens, antipattern detection, and the monodesign skill",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -16,7 +16,8 @@
16
16
  "files": [
17
17
  "cli/",
18
18
  "skill/",
19
- "src/"
19
+ "src/",
20
+ "NOTICE.md"
20
21
  ],
21
22
  "scripts": {
22
23
  "build": "tsc",
@@ -44,6 +45,7 @@
44
45
  "marked": "^18.0.0"
45
46
  },
46
47
  "optionalDependencies": {
48
+ "@monoes/monobrowse": "^1.0.3",
47
49
  "puppeteer": "^25.1.0"
48
50
  }
49
51
  }