@trylayout/qa 0.1.2 → 0.1.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.
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Layout QA
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@trylayout/qa?label=npm)](https://www.npmjs.com/package/@trylayout/qa)
4
+ [![CI](https://github.com/Layout-App/layout-qa/actions/workflows/ci.yml/badge.svg)](https://github.com/Layout-App/layout-qa/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-black.svg)](LICENSE)
6
+
3
7
  Layout QA is a local browser QA protocol and runner for AI-built frontends. It runs deterministic flows against a local or preview URL, captures screenshots at meaningful checkpoints, checks browser health, and writes a static HTML report.
4
8
 
5
9
  The core loop is intentionally local:
@@ -7,10 +11,15 @@ The core loop is intentionally local:
7
11
  ```bash
8
12
  npx @trylayout/qa init
9
13
  npx @trylayout/qa run --target-url http://localhost:5173 --scenario happy_path --open
14
+ npx @trylayout/qa run --target-url http://localhost:5173 --scenario happy_path --viewport 390x844 --open
10
15
  ```
11
16
 
12
17
  No account, upload, hosted service, or external docs are required.
13
18
 
19
+ ## Example Report
20
+
21
+ ![Layout QA HTML report for a synthetic CRM demo](docs/assets/layout-qa-report.png)
22
+
14
23
  Package names:
15
24
 
16
25
  - Canonical npm package: `@trylayout/qa`
@@ -28,8 +37,8 @@ npx layout-qa run --target-url http://localhost:5173 --scenario happy_path --ope
28
37
 
29
38
  Frontend agents can move faster when they have a visual feedback loop they can run themselves. Layout gives the agent a small protocol:
30
39
 
31
- - Wire deterministic API/auth mocks behind a local env flag such as `VITE_LAYOUT_QA_MOCKS=1`.
32
- - Switch mock states with `localStorage["layout.qa.scenario"]`.
40
+ - Wire deterministic API/auth responses behind a local env flag such as `VITE_LAYOUT_QA_MOCKS=1`.
41
+ - Switch response states with `localStorage["layout.qa.scenario"]`.
33
42
  - Declare high-value browser flows in `.layout/qa-flows.json`.
34
43
  - Run the CLI locally and inspect the generated screenshots/report.
35
44
 
@@ -70,7 +79,7 @@ Create a starter flow manifest:
70
79
  npx @trylayout/qa init
71
80
  ```
72
81
 
73
- Start your app with whatever mock flag your project uses:
82
+ Start your app with whatever local QA flag your project uses:
74
83
 
75
84
  ```bash
76
85
  VITE_LAYOUT_QA_MOCKS=1 npm run dev
@@ -88,7 +97,7 @@ npx @trylayout/qa run \
88
97
  Each run writes:
89
98
 
90
99
  ```text
91
- .layout/runs/<timestamp-scenario>/
100
+ .layout/runs/<timestamp-scenario-viewport>/
92
101
  index.html
93
102
  result.json
94
103
  screenshots/
@@ -110,9 +119,10 @@ Options:
110
119
 
111
120
  ```text
112
121
  --target-url <url> URL of the running frontend to test.
113
- --scenario <name> Mock scenario to activate. Defaults to happy_path.
122
+ --scenario <name> Scenario to activate. Defaults to happy_path.
114
123
  --flows <path> Flow manifest path. Defaults to .layout/qa-flows.json.
115
124
  --out <path> Artifact directory. Defaults to .layout/runs.
125
+ --viewport <value> Viewport preset or size. Use desktop, tablet, mobile, or WIDTHxHEIGHT. Defaults to desktop.
116
126
  --timeout <ms> Browser run timeout. Defaults to 60000.
117
127
  --headed Show the browser instead of running headless.
118
128
  --open Open the generated local HTML report after the run.
@@ -172,6 +182,8 @@ Step fields:
172
182
  - `label`: optional human-readable report label.
173
183
  - `screenshot`: set `true` to capture a screenshot after the step.
174
184
  - `timeoutMs`: optional per-step timeout.
185
+ - `tolerance`: optional pixel tolerance for layout assertions.
186
+ - `minWidth`, `maxWidth`, `minHeight`, `maxHeight`: optional `assert_box` constraints.
175
187
 
176
188
  Supported step types:
177
189
 
@@ -181,6 +193,9 @@ Supported step types:
181
193
  - `assert_visible_text`: require visible `text`.
182
194
  - `wait_for_text`: alias for a visible text wait.
183
195
  - `assert_url`: require current URL to equal `url` or contain `contains`.
196
+ - `assert_no_horizontal_overflow`: require the page not to overflow the viewport horizontally.
197
+ - `assert_in_viewport`: require a `selector` or visible `text` to have a nonzero box intersecting the viewport.
198
+ - `assert_box`: require a `selector` or visible `text` to satisfy width/height constraints.
184
199
  - `screenshot`: capture a screenshot checkpoint.
185
200
 
186
201
  Examples:
@@ -197,7 +212,44 @@ Examples:
197
212
  { "id": "settings_url", "type": "assert_url", "contains": "/settings" }
198
213
  ```
199
214
 
200
- ## Mock Scenarios
215
+ ```json
216
+ { "id": "no_overflow", "type": "assert_no_horizontal_overflow" }
217
+ ```
218
+
219
+ ```json
220
+ { "id": "main_visible", "type": "assert_in_viewport", "selector": "main" }
221
+ ```
222
+
223
+ ```json
224
+ {
225
+ "id": "primary_cta_size",
226
+ "type": "assert_box",
227
+ "selector": "[data-qa='primary-cta']",
228
+ "minWidth": 120,
229
+ "maxHeight": 56
230
+ }
231
+ ```
232
+
233
+ ## Viewports
234
+
235
+ The runner defaults to the desktop viewport, `1280x900`. Use `--viewport` to run the same flow at a preset or exact size:
236
+
237
+ ```bash
238
+ npx @trylayout/qa run --target-url http://localhost:5173 --viewport desktop
239
+ npx @trylayout/qa run --target-url http://localhost:5173 --viewport tablet
240
+ npx @trylayout/qa run --target-url http://localhost:5173 --viewport mobile
241
+ npx @trylayout/qa run --target-url http://localhost:5173 --viewport 390x844
242
+ ```
243
+
244
+ Presets:
245
+
246
+ - `desktop`: `1280x900`
247
+ - `tablet`: `768x1024`
248
+ - `mobile`: `390x844`
249
+
250
+ The selected viewport is written to `result.json`, shown in the HTML report, and included in the run directory name.
251
+
252
+ ## Scenarios
201
253
 
202
254
  Before the app loads, the runner sets:
203
255
 
@@ -206,7 +258,7 @@ localStorage.setItem("layout.qa.scenario", "<scenario>");
206
258
  sessionStorage.setItem("layout.qa.runner", "1");
207
259
  ```
208
260
 
209
- Your app can use `layout.qa.scenario` to switch deterministic mock states:
261
+ Your app can use `layout.qa.scenario` to switch deterministic API/auth response states:
210
262
 
211
263
  - `happy_path`: normal populated data.
212
264
  - `empty`: successful responses with empty states.
@@ -227,14 +279,14 @@ Create a local-only browser QA loop that an agent can run while changing fronten
227
279
  Rules:
228
280
  - Do not add a standalone mock server.
229
281
  - Do not require a hosted Layout service.
230
- - Keep all mock data local to this app.
231
- - Gate mocks behind a local-only env flag such as VITE_LAYOUT_QA_MOCKS=1, NEXT_PUBLIC_LAYOUT_QA_MOCKS=1, or the framework-appropriate equivalent.
232
- - Use localStorage["layout.qa.scenario"] to select at least happy_path, empty, and error mock states.
282
+ - Keep all deterministic response fixtures local to this app.
283
+ - Gate deterministic API/auth responses behind a local-only env flag such as VITE_LAYOUT_QA_MOCKS=1, NEXT_PUBLIC_LAYOUT_QA_MOCKS=1, or the framework-appropriate equivalent.
284
+ - Use localStorage["layout.qa.scenario"] to select at least happy_path, empty, and error response states.
233
285
  - Hide any local QA switcher or debug controls when sessionStorage["layout.qa.runner"] === "1".
234
286
 
235
287
  Implementation:
236
288
  - Add deterministic API fixtures for the highest-value frontend route.
237
- - If the app has a central auth/session abstraction, add a deterministic mock user only when the Layout QA env flag is enabled.
289
+ - If the app has a central auth/session abstraction, add a deterministic QA user only when the Layout QA env flag is enabled.
238
290
  - If auth is scattered or provider-SDK-only, leave a clear note in the PR/code comments and start with public or logged-out flows.
239
291
  - Add .layout/qa-flows.json with one smoke flow for the most important page.
240
292
  - Prefer visible text and stable selectors.
@@ -281,8 +333,14 @@ This package is intentionally small:
281
333
  - It does run Playwright against an already-running frontend.
282
334
  - It does write local screenshots and an HTML report.
283
335
  - It does support deterministic scenario switching.
336
+ - It does support explicit viewport sizing.
337
+ - It does support lightweight layout assertions.
284
338
  - It does not build or host your app.
285
339
  - It does not upload results.
286
340
  - It does not perform AI review by itself.
287
341
 
288
342
  Those hosted/reporting layers can be added later without changing the local protocol.
343
+
344
+ ## Feedback
345
+
346
+ Issues and examples are welcome in [GitHub Issues](https://github.com/Layout-App/layout-qa/issues). You can also reach me on X at [@tscepo](https://x.com/tscepo).
@@ -9,6 +9,7 @@ const path_1 = __importDefault(require("path"));
9
9
  const flows_1 = require("../flows");
10
10
  const runner_1 = require("../runner");
11
11
  const report_1 = require("../report");
12
+ const viewports_1 = require("../viewports");
12
13
  function printHelp() {
13
14
  process.stdout.write(`Layout QA CLI
14
15
 
@@ -25,9 +26,10 @@ Commands:
25
26
 
26
27
  Options:
27
28
  --target-url <url> URL of the running frontend to test.
28
- --scenario <name> Mock scenario to activate. Defaults to happy_path.
29
+ --scenario <name> Scenario to activate. Defaults to happy_path.
29
30
  --flows <path> Flow manifest path. Defaults to .layout/qa-flows.json.
30
31
  --out <path> Artifact directory. Defaults to .layout/runs.
32
+ --viewport <value> Viewport preset or size. Use desktop, tablet, mobile, or WIDTHxHEIGHT. Defaults to desktop.
31
33
  --timeout <ms> Browser run timeout. Defaults to LAYOUT_QA_TEST_TIMEOUT_MS or 60000.
32
34
  --headed Show the browser instead of running headless.
33
35
  --open Open the generated local HTML report after the run.
@@ -59,6 +61,7 @@ function parseArgs(args) {
59
61
  scenario: readFlag(args, '--scenario') || 'happy_path',
60
62
  flowsPath: readFlag(args, '--flows'),
61
63
  outDir: readFlag(args, '--out'),
64
+ viewport: (0, viewports_1.parseViewport)(readFlag(args, '--viewport')),
62
65
  timeoutMs: parsedTimeoutMs,
63
66
  headed: hasFlag(args, '--headed'),
64
67
  json: hasFlag(args, '--json'),
@@ -92,6 +95,9 @@ function printHumanSummary(input) {
92
95
  process.stdout.write(`\nLayout QA ${passed ? 'passed' : 'failed'}\n` +
93
96
  `Scenario: ${input.scenario}\n` +
94
97
  `Target: ${input.targetUrl}\n` +
98
+ `Viewport: ${input.result.viewport
99
+ ? (0, viewports_1.formatViewport)(input.result.viewport)
100
+ : 'unavailable'}\n` +
95
101
  `Final URL: ${input.result.finalUrl || 'unavailable'}\n` +
96
102
  `Flow: ${input.result.flow?.name || 'None'} (${input.result.flow?.source || 'none'})\n` +
97
103
  `Manifest: ${input.manifestFound ? input.manifestPath : 'not found; default smoke'}\n\n`);
@@ -142,11 +148,12 @@ async function runCommand(options) {
142
148
  flow,
143
149
  timeoutMs: options.timeoutMs || (0, flows_1.getTestTimeoutMs)(),
144
150
  headless: !options.headed,
151
+ viewport: options.viewport,
145
152
  });
146
153
  }
147
154
  catch (error) {
148
155
  const message = error instanceof Error ? error.message : String(error);
149
- result = (0, runner_1.buildRunnerErrorResult)(message);
156
+ result = (0, runner_1.buildRunnerErrorResult)(message, options.viewport);
150
157
  }
151
158
  const artifacts = await (0, report_1.writeArtifacts)({
152
159
  outDir: options.outDir,
@@ -162,6 +169,7 @@ async function runCommand(options) {
162
169
  status: passed ? 'passed' : 'failed',
163
170
  scenario: options.scenario,
164
171
  targetUrl: options.targetUrl,
172
+ viewport: options.viewport,
165
173
  manifestPath,
166
174
  manifestFound,
167
175
  artifacts,
package/build/flows.js CHANGED
@@ -59,6 +59,11 @@ function normalizeFlowStep(value, index) {
59
59
  exact: booleanValue(value.exact),
60
60
  screenshot: booleanValue(value.screenshot, type === 'screenshot'),
61
61
  timeoutMs: numberValue(value.timeoutMs),
62
+ tolerance: numberValue(value.tolerance),
63
+ minWidth: numberValue(value.minWidth),
64
+ maxWidth: numberValue(value.maxWidth),
65
+ minHeight: numberValue(value.minHeight),
66
+ maxHeight: numberValue(value.maxHeight),
62
67
  };
63
68
  }
64
69
  function normalizeFlow(value, index) {
package/build/index.d.ts CHANGED
@@ -2,3 +2,4 @@ export * from './flows';
2
2
  export * from './report';
3
3
  export * from './runner';
4
4
  export * from './types';
5
+ export * from './viewports';
package/build/index.js CHANGED
@@ -18,3 +18,4 @@ __exportStar(require("./flows"), exports);
18
18
  __exportStar(require("./report"), exports);
19
19
  __exportStar(require("./runner"), exports);
20
20
  __exportStar(require("./types"), exports);
21
+ __exportStar(require("./viewports"), exports);
package/build/report.js CHANGED
@@ -12,6 +12,7 @@ const path_1 = __importDefault(require("path"));
12
12
  const child_process_1 = require("child_process");
13
13
  const flows_1 = require("./flows");
14
14
  const runner_1 = require("./runner");
15
+ const viewports_1 = require("./viewports");
15
16
  function safeName(value) {
16
17
  return value.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '');
17
18
  }
@@ -56,7 +57,7 @@ function renderStatusBadge(status) {
56
57
  function renderCheckList(result) {
57
58
  return result.checks
58
59
  .map(check => `<li class="row">
59
- <span class="status ${check.passed ? 'passed' : 'failed'}">${check.passed ? 'PASS' : 'FAIL'}</span>
60
+ <span class="status ${check.passed ? 'passed' : 'failed'}">${check.passed ? 'Pass' : 'Fail'}</span>
60
61
  <div>
61
62
  <p class="row-title">${escapeHtml(check.label)}</p>
62
63
  ${check.detail
@@ -111,6 +112,7 @@ function renderReport(input) {
111
112
  : '';
112
113
  const resultHref = relativeHref(input.runDir, input.resultPath);
113
114
  const flow = input.result.flow;
115
+ const viewport = input.result.viewport;
114
116
  const failedCheckCount = input.result.checks.filter(check => !check.passed).length;
115
117
  return `<!doctype html>
116
118
  <html lang="en">
@@ -121,81 +123,149 @@ function renderReport(input) {
121
123
  <style>
122
124
  :root {
123
125
  color-scheme: light;
124
- --bg: #f7f7f2;
125
- --panel: #fffdf8;
126
- --line: #e4e1d8;
127
- --text: #1b1a17;
128
- --muted: #65635d;
129
- --green: #2f7a45;
130
- --green-bg: #e6f4ea;
131
- --red: #9b2c2c;
132
- --red-bg: #fff0f0;
133
- --amber: #7a5c14;
134
- --amber-bg: #fff5d6;
126
+ --bg: #ffffff;
127
+ --surface: #f5f5f7;
128
+ --surface-strong: #fbfbfd;
129
+ --line: #d2d2d7;
130
+ --line-soft: #e8e8ed;
131
+ --text: #1d1d1f;
132
+ --muted: #6e6e73;
133
+ --blue: #06c;
134
+ --green: #248a3d;
135
+ --red: #d70015;
136
+ --amber: #b25000;
135
137
  }
136
138
  * { box-sizing: border-box; }
137
139
  body {
138
140
  margin: 0;
139
141
  background: var(--bg);
140
142
  color: var(--text);
141
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
142
- line-height: 1.5;
143
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
144
+ line-height: 1.47059;
145
+ -webkit-font-smoothing: antialiased;
146
+ text-rendering: optimizeLegibility;
147
+ }
148
+ main { max-width: 1180px; margin: 0 auto; padding: 56px 28px 72px; }
149
+ header.page {
150
+ display: flex;
151
+ justify-content: space-between;
152
+ gap: 32px;
153
+ align-items: flex-start;
154
+ border-bottom: 1px solid var(--line-soft);
155
+ padding-bottom: 36px;
143
156
  }
144
- main { max-width: 1120px; margin: 0 auto; padding: 32px 24px 56px; }
145
- header.page { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; border-bottom: 1px solid var(--line); padding-bottom: 24px; }
146
157
  h1, h2, h3, p { margin: 0; }
147
- h1 { font-size: clamp(2rem, 5vw, 4rem); line-height: 1; font-weight: 600; letter-spacing: 0; }
148
- h2 { font-size: 1rem; font-weight: 650; margin-bottom: 12px; }
149
- h3 { font-size: 1rem; font-weight: 650; }
150
- section { margin-top: 28px; }
151
- a { color: inherit; }
152
- .eyebrow { color: var(--muted); font-size: .78rem; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; }
153
- .summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 24px; }
158
+ h1 {
159
+ font-size: clamp(2.75rem, 7vw, 5.5rem);
160
+ line-height: .95;
161
+ font-weight: 500;
162
+ letter-spacing: 0;
163
+ }
164
+ h2 { font-size: 1.375rem; line-height: 1.2; font-weight: 500; margin-bottom: 18px; }
165
+ h3 { font-size: 1.0625rem; line-height: 1.25; font-weight: 500; }
166
+ section { margin-top: 42px; }
167
+ a { color: var(--blue); text-decoration: none; }
168
+ a:hover { text-decoration: underline; }
169
+ .eyebrow {
170
+ color: var(--muted);
171
+ font-size: .8125rem;
172
+ font-weight: 500;
173
+ text-transform: uppercase;
174
+ letter-spacing: .04em;
175
+ margin-bottom: 10px;
176
+ }
177
+ .summary {
178
+ display: grid;
179
+ grid-template-columns: repeat(5, minmax(0, 1fr));
180
+ gap: 1px;
181
+ margin-top: 32px;
182
+ border: 1px solid var(--line-soft);
183
+ border-radius: 14px;
184
+ overflow: hidden;
185
+ background: var(--line-soft);
186
+ }
154
187
  .metric, .panel, .step, .issue {
155
- border: 1px solid var(--line);
156
- background: var(--panel);
157
- border-radius: 8px;
188
+ background: var(--surface-strong);
189
+ }
190
+ .metric { padding: 18px 20px; min-width: 0; }
191
+ .metric dt { color: var(--muted); font-size: .8125rem; }
192
+ .metric dd { margin: 5px 0 0; font-weight: 500; overflow-wrap: anywhere; }
193
+ .panel {
194
+ padding: 22px;
195
+ border: 1px solid var(--line-soft);
196
+ border-radius: 14px;
197
+ }
198
+ .stack { display: grid; gap: 0; list-style: none; margin: 0; padding: 0; }
199
+ .row {
200
+ display: grid;
201
+ grid-template-columns: 64px minmax(0, 1fr);
202
+ gap: 16px;
203
+ padding: 14px 0;
204
+ border-top: 1px solid var(--line-soft);
158
205
  }
159
- .metric { padding: 14px; min-width: 0; }
160
- .metric dt { color: var(--muted); font-size: .82rem; }
161
- .metric dd { margin: 4px 0 0; font-weight: 650; overflow-wrap: anywhere; }
162
- .panel { padding: 16px; }
163
- .stack { display: grid; gap: 10px; list-style: none; margin: 0; padding: 0; }
164
- .row { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 12px; padding: 10px 0; border-top: 1px solid var(--line); }
165
206
  .row:first-child { border-top: 0; padding-top: 0; }
166
207
  .row:last-child { padding-bottom: 0; }
167
- .row-title { font-weight: 650; overflow-wrap: anywhere; }
168
- .muted { color: var(--muted); font-size: .92rem; overflow-wrap: anywhere; }
169
- .detail { margin-top: 8px; color: #3f3d38; overflow-wrap: anywhere; }
208
+ .row-title { font-weight: 500; overflow-wrap: anywhere; }
209
+ .muted { color: var(--muted); font-size: .9375rem; overflow-wrap: anywhere; }
210
+ .detail { margin-top: 10px; color: #424245; overflow-wrap: anywhere; }
170
211
  .break { word-break: break-all; }
171
- .status { font-size: .78rem; font-weight: 750; padding-top: 2px; }
212
+ .status { font-size: .8125rem; font-weight: 500; padding-top: 2px; }
172
213
  .status.passed { color: var(--green); }
173
214
  .status.failed { color: var(--red); }
174
215
  .badge {
175
216
  display: inline-flex;
176
217
  align-items: center;
177
- border-radius: 6px;
178
- border: 1px solid var(--line);
179
- padding: 4px 8px;
180
- font-size: .82rem;
181
- font-weight: 650;
218
+ border-radius: 999px;
219
+ border: 1px solid var(--line-soft);
220
+ padding: 5px 10px;
221
+ font-size: .875rem;
222
+ font-weight: 500;
182
223
  text-transform: capitalize;
183
224
  white-space: nowrap;
225
+ background: var(--surface);
226
+ }
227
+ .badge.passed { color: var(--green); }
228
+ .badge.failed { color: var(--red); }
229
+ .badge.skipped { color: var(--amber); }
230
+ .badge.hero { font-size: 1rem; padding: 8px 14px; }
231
+ .step {
232
+ padding: 22px;
233
+ margin-top: 16px;
234
+ border: 1px solid var(--line-soft);
235
+ border-radius: 14px;
184
236
  }
185
- .badge.passed { color: var(--green); background: var(--green-bg); border-color: #c8e6d0; }
186
- .badge.failed { color: var(--red); background: var(--red-bg); border-color: #f0c9c9; }
187
- .badge.skipped { color: var(--amber); background: var(--amber-bg); border-color: #f0dc9f; }
188
- .badge.hero { font-size: .95rem; padding: 8px 12px; }
189
- .step { padding: 16px; margin-top: 12px; }
190
237
  .step-header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; }
191
- .screenshot-link { display: block; margin-top: 14px; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: white; }
238
+ .screenshot-link {
239
+ display: block;
240
+ margin-top: 18px;
241
+ border: 1px solid var(--line);
242
+ border-radius: 12px;
243
+ overflow: hidden;
244
+ background: white;
245
+ box-shadow: 0 12px 36px rgba(0, 0, 0, .06);
246
+ }
247
+ .screenshot-link:hover { text-decoration: none; }
192
248
  img { display: block; width: 100%; height: auto; }
193
- .issue { padding: 12px; border-color: #f0c9c9; background: #fff8f8; }
194
- .next { border-color: #d9d6cb; }
249
+ .issue {
250
+ padding: 16px;
251
+ border: 1px solid #ffd7d9;
252
+ border-radius: 12px;
253
+ background: #fff7f7;
254
+ }
255
+ .next { background: var(--surface); }
195
256
  .empty { color: var(--muted); }
196
- .footer { margin-top: 28px; color: var(--muted); font-size: .9rem; }
257
+ .footer {
258
+ margin-top: 42px;
259
+ padding-top: 20px;
260
+ border-top: 1px solid var(--line-soft);
261
+ color: var(--muted);
262
+ font-size: .9375rem;
263
+ overflow-wrap: anywhere;
264
+ }
265
+ ul:not(.stack) { margin: 14px 0 0; padding-left: 1.2rem; color: #424245; }
266
+ li + li { margin-top: 6px; }
197
267
  @media (max-width: 760px) {
198
- main { padding: 24px 16px 40px; }
268
+ main { padding: 34px 18px 48px; }
199
269
  header.page { display: grid; }
200
270
  .summary { grid-template-columns: 1fr; }
201
271
  }
@@ -215,6 +285,7 @@ function renderReport(input) {
215
285
  <div class="metric"><dt>Scenario</dt><dd>${escapeHtml(input.scenario)}</dd></div>
216
286
  <div class="metric"><dt>Flow</dt><dd>${escapeHtml(flow?.name || 'None')}</dd></div>
217
287
  <div class="metric"><dt>Target URL</dt><dd>${escapeHtml(input.targetUrl)}</dd></div>
288
+ <div class="metric"><dt>Viewport</dt><dd>${escapeHtml(viewport ? (0, viewports_1.formatViewport)(viewport) : 'unavailable')}</dd></div>
218
289
  <div class="metric"><dt>Final URL</dt><dd>${escapeHtml(input.result.finalUrl || 'unavailable')}</dd></div>
219
290
  </dl>
220
291
 
@@ -265,7 +336,8 @@ async function writeArtifacts(input) {
265
336
  ? path_1.default.resolve(process.cwd(), input.outDir)
266
337
  : await (0, flows_1.resolveDefaultPath)('.layout/runs');
267
338
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
268
- const runDir = path_1.default.join(outRoot, `${stamp}-${safeName(input.scenario)}`);
339
+ const viewportId = input.result.viewport?.id || 'viewport_unknown';
340
+ const runDir = path_1.default.join(outRoot, `${stamp}-${safeName(input.scenario)}-${safeName(viewportId)}`);
269
341
  const screenshotsDir = path_1.default.join(runDir, 'screenshots');
270
342
  await promises_1.default.mkdir(screenshotsDir, { recursive: true });
271
343
  const screenshots = [];
package/build/runner.d.ts CHANGED
@@ -1,10 +1,11 @@
1
- import { LoadedQaFlow, QaTestRunCheck, QaTestRunFlowResult, QaTestRunIssue, QaTestRunNextAction, QaTestRunResult } from './types';
1
+ import { LoadedQaFlow, QaTestRunCheck, QaTestRunFlowResult, QaTestRunIssue, QaTestRunNextAction, QaTestRunResult, QaViewport } from './types';
2
2
  export declare function runLayoutQaBrowser(input: {
3
3
  targetUrl: string;
4
4
  scenario: string;
5
5
  flow: LoadedQaFlow;
6
6
  timeoutMs?: number;
7
7
  headless?: boolean;
8
+ viewport?: QaViewport;
8
9
  }): Promise<{
9
10
  finalUrl: string;
10
11
  title: string;
@@ -13,10 +14,11 @@ export declare function runLayoutQaBrowser(input: {
13
14
  screenshotDataUrl: string | undefined;
14
15
  screenshotBytes: number;
15
16
  bodyTextSample: string;
17
+ viewport: QaViewport;
16
18
  checks: QaTestRunCheck[];
17
19
  issues: QaTestRunIssue[];
18
20
  flow: QaTestRunFlowResult;
19
21
  nextAction: QaTestRunNextAction;
20
22
  }>;
21
- export declare function buildRunnerErrorResult(message: string): QaTestRunResult;
23
+ export declare function buildRunnerErrorResult(message: string, viewport?: QaViewport): QaTestRunResult;
22
24
  export declare function isQaRunPassed(result: QaTestRunResult): boolean;
package/build/runner.js CHANGED
@@ -38,6 +38,7 @@ exports.buildRunnerErrorResult = buildRunnerErrorResult;
38
38
  exports.isQaRunPassed = isQaRunPassed;
39
39
  const url_1 = require("url");
40
40
  const flows_1 = require("./flows");
41
+ const viewports_1 = require("./viewports");
41
42
  function truncate(value, maxLength = 1000) {
42
43
  return value.length <= maxLength ? value : `${value.slice(0, maxLength)}...`;
43
44
  }
@@ -80,7 +81,7 @@ function buildChecks(input) {
80
81
  },
81
82
  {
82
83
  id: 'scenario_ready',
83
- label: 'Mock scenario is available',
84
+ label: 'Scenario flag is available',
84
85
  passed: scenarioReady,
85
86
  detail: input.controlsPresent
86
87
  ? `Layout QA controls detected; requested ${input.scenario}.`
@@ -125,11 +126,11 @@ function buildNextAction(input) {
125
126
  return {
126
127
  category: 'target_unreachable',
127
128
  title: 'Target URL did not load cleanly',
128
- detail: 'Layout could not reach the target page well enough to evaluate mock states.',
129
+ detail: 'Layout could not reach the target page well enough to evaluate deterministic response states.',
129
130
  docsPath: flows_1.FLOW_MANIFEST_PATH,
130
131
  nextSteps: [
131
132
  'Start the app or deploy preview URL before running Layout QA.',
132
- 'Use the URL where the frontend is served with the Layout mock env flag enabled.',
133
+ 'Use the URL where the frontend is served with the Layout QA env flag enabled.',
133
134
  'Retry the same scenario after the target URL is reachable from the runner.',
134
135
  ],
135
136
  };
@@ -137,13 +138,13 @@ function buildNextAction(input) {
137
138
  if (failedCheckIds.has('scenario_ready')) {
138
139
  return {
139
140
  category: 'fixtures',
140
- title: 'Mock scenario was not active',
141
- detail: 'The page loaded, but Layout could not confirm that the requested mock scenario was available.',
141
+ title: 'Scenario flag was not active',
142
+ detail: 'The page loaded, but Layout could not confirm that the requested scenario was available.',
142
143
  docsPath: flows_1.FLOW_MANIFEST_PATH,
143
144
  nextSteps: [
144
- 'Confirm the target is running with the Layout mock env flag set to 1.',
145
+ 'Confirm the target is running with the Layout QA env flag set to 1.',
145
146
  'Check that the app reads localStorage["layout.qa.scenario"] before API calls run.',
146
- `Review ${flows_1.FLOW_MANIFEST_PATH}, the Layout QA docs, and the fixture file for missing handlers.`,
147
+ `Review ${flows_1.FLOW_MANIFEST_PATH}, the Layout QA docs, and the API/auth response fixtures for missing handlers.`,
147
148
  ],
148
149
  };
149
150
  }
@@ -153,11 +154,11 @@ function buildNextAction(input) {
153
154
  category: 'flow',
154
155
  title: 'Flow step needs review',
155
156
  detail: failedStep?.detail ||
156
- 'The target loaded with mocks, but a declared QA flow step failed.',
157
+ 'The target loaded with deterministic responses, but a declared QA flow step failed.',
157
158
  docsPath: flows_1.FLOW_MANIFEST_PATH,
158
159
  nextSteps: [
159
160
  `Inspect ${flows_1.FLOW_MANIFEST_PATH} and confirm the failing step still matches the app UI.`,
160
- 'Update selectors, visible text assertions, or scenario fixtures so the flow follows real user behavior.',
161
+ 'Update selectors, visible text assertions, or scenario responses so the flow follows real user behavior.',
161
162
  `Use ${flows_1.QA_DOCS_URL} for the supported flow step schema.`,
162
163
  ],
163
164
  };
@@ -168,24 +169,24 @@ function buildNextAction(input) {
168
169
  return {
169
170
  category: 'browser_errors',
170
171
  title: 'Browser errors need review',
171
- detail: 'The target loaded with mocks, but browser errors or failed requests were observed.',
172
+ detail: 'The target loaded with deterministic responses, but browser errors or failed requests were observed.',
172
173
  docsPath: flows_1.FLOW_MANIFEST_PATH,
173
174
  nextSteps: [
174
175
  'Inspect the issues captured on this QA run.',
175
- 'Add or correct fixtures for unhandled frontend API requests.',
176
- 'Fix app code that throws under the selected mock scenario, then rerun.',
176
+ 'Add or correct fixtures for unhandled frontend API/auth requests.',
177
+ 'Fix app code that throws under the selected scenario, then rerun.',
177
178
  ],
178
179
  };
179
180
  }
180
181
  if (appearsToBePublicOrAuthSurface(input.bodyTextSample)) {
181
182
  return {
182
183
  category: 'auth_boundary',
183
- title: 'Public surface reached; wire mock auth next',
184
- detail: 'The run passed the basic mock-browser checks, but the page appears to be a logged-out or public surface. Authenticated flows need a mockable auth boundary before Layout can test them end to end.',
184
+ title: 'Public surface reached; wire deterministic auth next',
185
+ detail: 'The run passed the basic browser checks, but the page appears to be a logged-out or public surface. Authenticated flows need a deterministic auth boundary before Layout can test them end to end.',
185
186
  docsPath: flows_1.FLOW_MANIFEST_PATH,
186
187
  nextSteps: [
187
- `Use ${flows_1.FLOW_MANIFEST_PATH} and the Layout QA docs to add or confirm a central mock auth boundary.`,
188
- 'Expose a deterministic mock user/session only when the Layout mock env flag is enabled.',
188
+ `Use ${flows_1.FLOW_MANIFEST_PATH} and the Layout QA docs to add or confirm a central auth boundary for QA runs.`,
189
+ 'Expose a deterministic user/session only when the Layout QA env flag is enabled.',
189
190
  'Point the next QA run at an authenticated route and rerun happy_path, empty, and error scenarios.',
190
191
  ],
191
192
  };
@@ -193,11 +194,11 @@ function buildNextAction(input) {
193
194
  return {
194
195
  category: 'ready',
195
196
  title: 'Ready for deeper flow coverage',
196
- detail: 'The target loaded with the requested mock scenario and no basic browser issues were detected.',
197
+ detail: 'The target loaded with the requested scenario and no basic browser issues were detected.',
197
198
  docsPath: flows_1.FLOW_MANIFEST_PATH,
198
199
  nextSteps: [
199
200
  `Add route-specific Playwright-style flow steps to ${flows_1.FLOW_MANIFEST_PATH} for the highest-value user path.`,
200
- 'Expand fixtures for any API calls encountered by that flow.',
201
+ 'Expand deterministic API/auth responses for any requests encountered by that flow.',
201
202
  'Run the same flow across happy_path, empty, and error scenarios.',
202
203
  ],
203
204
  };
@@ -230,6 +231,131 @@ function requireStepValue(value, field) {
230
231
  }
231
232
  return value;
232
233
  }
234
+ function stepLocator(page, step, exact, requiredField) {
235
+ if (step.selector) {
236
+ return page.locator(step.selector).first();
237
+ }
238
+ const text = requireStepValue(step.text, requiredField);
239
+ return page.getByText(text, { exact }).first();
240
+ }
241
+ function formatBox(box) {
242
+ return `${Math.round(box.width)}x${Math.round(box.height)} at ${Math.round(box.x)},${Math.round(box.y)}`;
243
+ }
244
+ async function assertNoHorizontalOverflow(page, tolerance = 1) {
245
+ const result = (await page.evaluate(checkTolerance => {
246
+ function elementLabel(element) {
247
+ const tagName = element.tagName.toLowerCase();
248
+ const testId = element.getAttribute('data-testid') ||
249
+ element.getAttribute('data-qa') ||
250
+ '';
251
+ if (testId)
252
+ return `${tagName}[data-qa/testid="${testId}"]`;
253
+ if (element.id)
254
+ return `${tagName}#${element.id}`;
255
+ const className = Array.from(element.classList).slice(0, 3).join('.');
256
+ return className ? `${tagName}.${className}` : tagName;
257
+ }
258
+ const root = document.documentElement;
259
+ const body = document.body;
260
+ const viewportWidth = window.innerWidth;
261
+ const scrollWidth = Math.max(root?.scrollWidth || 0, body?.scrollWidth || 0);
262
+ const overflowPx = Math.max(0, scrollWidth - viewportWidth);
263
+ const offenders = Array.from(document.body?.querySelectorAll('*') || [])
264
+ .map(element => {
265
+ const rect = element.getBoundingClientRect();
266
+ if (rect.width <= 0 || rect.height <= 0)
267
+ return null;
268
+ const rightOverflow = Math.max(0, rect.right - viewportWidth);
269
+ const leftOverflow = Math.max(0, -rect.left);
270
+ const elementOverflowPx = Math.max(rightOverflow, leftOverflow);
271
+ if (elementOverflowPx <= checkTolerance)
272
+ return null;
273
+ return {
274
+ label: elementLabel(element),
275
+ x: Math.round(rect.x),
276
+ width: Math.round(rect.width),
277
+ right: Math.round(rect.right),
278
+ overflowPx: Math.round(elementOverflowPx),
279
+ };
280
+ })
281
+ .filter((entry) => Boolean(entry))
282
+ .sort((a, b) => b.overflowPx - a.overflowPx)
283
+ .slice(0, 5);
284
+ return {
285
+ viewportWidth,
286
+ scrollWidth,
287
+ overflowPx,
288
+ offenders,
289
+ };
290
+ }, tolerance));
291
+ if (result.overflowPx > tolerance) {
292
+ const offenderDetail = result.offenders.length
293
+ ? ` Offenders: ${result.offenders
294
+ .map(offender => `${offender.label} overflowed ${offender.overflowPx}px (x=${offender.x}, width=${offender.width}, right=${offender.right})`)
295
+ .join('; ')}.`
296
+ : '';
297
+ throw new Error(`Horizontal overflow detected: document width ${result.scrollWidth}px exceeds viewport ${result.viewportWidth}px by ${result.overflowPx}px.${offenderDetail}`);
298
+ }
299
+ return `No horizontal overflow: document width ${result.scrollWidth}px, viewport ${result.viewportWidth}px.`;
300
+ }
301
+ async function assertElementInViewport(input) {
302
+ const locator = stepLocator(input.page, input.step, input.exact, 'selector or text');
303
+ await locator.waitFor({ state: 'visible', timeout: input.timeoutMs });
304
+ const box = await locator.boundingBox();
305
+ if (!box || box.width <= 0 || box.height <= 0) {
306
+ throw new Error('Expected element to have a visible nonzero layout box.');
307
+ }
308
+ const viewport = input.page.viewportSize();
309
+ if (!viewport) {
310
+ throw new Error('Could not read the current browser viewport size.');
311
+ }
312
+ const tolerance = input.step.tolerance ?? 1;
313
+ const right = box.x + box.width;
314
+ const bottom = box.y + box.height;
315
+ const visibleWidth = Math.min(right, viewport.width) - Math.max(box.x, 0);
316
+ const visibleHeight = Math.min(bottom, viewport.height) - Math.max(box.y, 0);
317
+ if (visibleWidth <= tolerance || visibleHeight <= tolerance) {
318
+ throw new Error(`Expected element to intersect viewport ${viewport.width}x${viewport.height}, got box ${formatBox(box)}.`);
319
+ }
320
+ return `Element intersects viewport ${viewport.width}x${viewport.height}: ${formatBox(box)}.`;
321
+ }
322
+ function definedBoxConstraints(step) {
323
+ return [
324
+ ['minWidth', step.minWidth],
325
+ ['maxWidth', step.maxWidth],
326
+ ['minHeight', step.minHeight],
327
+ ['maxHeight', step.maxHeight],
328
+ ];
329
+ }
330
+ async function assertElementBox(input) {
331
+ const constraints = definedBoxConstraints(input.step).filter(([, value]) => value !== undefined);
332
+ if (constraints.length === 0) {
333
+ throw new Error('assert_box requires at least one of minWidth, maxWidth, minHeight, or maxHeight.');
334
+ }
335
+ const locator = stepLocator(input.page, input.step, input.exact, 'selector or text');
336
+ await locator.waitFor({ state: 'visible', timeout: input.timeoutMs });
337
+ const box = await locator.boundingBox();
338
+ if (!box || box.width <= 0 || box.height <= 0) {
339
+ throw new Error('Expected element to have a visible nonzero layout box.');
340
+ }
341
+ const failures = [];
342
+ if (input.step.minWidth !== undefined && box.width < input.step.minWidth) {
343
+ failures.push(`width ${Math.round(box.width)}px is below minWidth ${input.step.minWidth}px`);
344
+ }
345
+ if (input.step.maxWidth !== undefined && box.width > input.step.maxWidth) {
346
+ failures.push(`width ${Math.round(box.width)}px is above maxWidth ${input.step.maxWidth}px`);
347
+ }
348
+ if (input.step.minHeight !== undefined && box.height < input.step.minHeight) {
349
+ failures.push(`height ${Math.round(box.height)}px is below minHeight ${input.step.minHeight}px`);
350
+ }
351
+ if (input.step.maxHeight !== undefined && box.height > input.step.maxHeight) {
352
+ failures.push(`height ${Math.round(box.height)}px is above maxHeight ${input.step.maxHeight}px`);
353
+ }
354
+ if (failures.length > 0) {
355
+ throw new Error(`Element box ${formatBox(box)} failed constraints: ${failures.join('; ')}.`);
356
+ }
357
+ return `Element box matched constraints: ${formatBox(box)}.`;
358
+ }
233
359
  async function caseSensitiveGoto(page, targetUrl, timeoutMs) {
234
360
  return page.goto(targetUrl, {
235
361
  waitUntil: 'domcontentloaded',
@@ -287,6 +413,25 @@ async function executeFlowStep(input) {
287
413
  }
288
414
  return `URL matched: ${currentUrl}`;
289
415
  }
416
+ if (input.step.type === 'assert_no_horizontal_overflow') {
417
+ return assertNoHorizontalOverflow(input.page, input.step.tolerance ?? 1);
418
+ }
419
+ if (input.step.type === 'assert_in_viewport') {
420
+ return assertElementInViewport({
421
+ page: input.page,
422
+ step: input.step,
423
+ exact,
424
+ timeoutMs: stepTimeout,
425
+ });
426
+ }
427
+ if (input.step.type === 'assert_box') {
428
+ return assertElementBox({
429
+ page: input.page,
430
+ step: input.step,
431
+ exact,
432
+ timeoutMs: stepTimeout,
433
+ });
434
+ }
290
435
  if (input.step.type === 'screenshot') {
291
436
  return 'Captured screenshot checkpoint.';
292
437
  }
@@ -345,13 +490,14 @@ async function runFlow(input) {
345
490
  }
346
491
  async function runLayoutQaBrowser(input) {
347
492
  const timeoutMs = input.timeoutMs || flows_1.DEFAULT_TEST_TIMEOUT_MS;
493
+ const viewport = input.viewport || viewports_1.DEFAULT_VIEWPORT;
348
494
  const issues = [];
349
495
  const { chromium } = await Promise.resolve().then(() => __importStar(require('playwright')));
350
496
  const browser = await chromium.launch({ headless: input.headless !== false });
351
497
  try {
352
498
  const context = await browser.newContext({
353
499
  ignoreHTTPSErrors: true,
354
- viewport: { width: 1280, height: 900 },
500
+ viewport: { width: viewport.width, height: viewport.height },
355
501
  });
356
502
  await context.addInitScript({
357
503
  content: `
@@ -444,6 +590,7 @@ async function runLayoutQaBrowser(input) {
444
590
  screenshotDataUrl,
445
591
  screenshotBytes: screenshot.byteLength,
446
592
  bodyTextSample: pageState.bodyTextSample,
593
+ viewport,
447
594
  checks,
448
595
  issues: issues.slice(0, 20),
449
596
  flow: flowResult,
@@ -464,8 +611,9 @@ async function runLayoutQaBrowser(input) {
464
611
  });
465
612
  }
466
613
  }
467
- function buildRunnerErrorResult(message) {
614
+ function buildRunnerErrorResult(message, viewport = viewports_1.DEFAULT_VIEWPORT) {
468
615
  return {
616
+ viewport,
469
617
  checks: [
470
618
  {
471
619
  id: 'runner_error',
@@ -487,8 +635,9 @@ function buildRunnerErrorResult(message) {
487
635
  docsPath: flows_1.FLOW_MANIFEST_PATH,
488
636
  nextSteps: [
489
637
  'Confirm the target URL is reachable by the runner.',
490
- 'Confirm the app is served with the Layout mock env flag enabled.',
638
+ 'Confirm the app is served with the Layout QA env flag enabled.',
491
639
  'Retry after the target loads consistently in a browser.',
640
+ `Viewport used for this run: ${(0, viewports_1.formatViewport)(viewport)}.`,
492
641
  ],
493
642
  },
494
643
  };
package/build/types.d.ts CHANGED
@@ -27,6 +27,11 @@ export type QaTestRunFlowStepResult = {
27
27
  screenshotDataUrl?: string;
28
28
  screenshotBytes?: number;
29
29
  };
30
+ export type QaViewport = {
31
+ id: string;
32
+ width: number;
33
+ height: number;
34
+ };
30
35
  export type QaTestRunFlowResult = {
31
36
  id: string;
32
37
  name: string;
@@ -41,6 +46,7 @@ export type QaTestRunResult = {
41
46
  screenshotDataUrl?: string;
42
47
  screenshotBytes?: number;
43
48
  bodyTextSample?: string;
49
+ viewport?: QaViewport;
44
50
  checks: QaTestRunCheck[];
45
51
  issues: QaTestRunIssue[];
46
52
  flow?: QaTestRunFlowResult;
@@ -58,6 +64,11 @@ export type QaFlowStep = {
58
64
  exact?: boolean;
59
65
  screenshot?: boolean;
60
66
  timeoutMs?: number;
67
+ tolerance?: number;
68
+ minWidth?: number;
69
+ maxWidth?: number;
70
+ minHeight?: number;
71
+ maxHeight?: number;
61
72
  };
62
73
  export type QaFlowDefinition = {
63
74
  id: string;
@@ -0,0 +1,5 @@
1
+ import { QaViewport } from './types';
2
+ export declare const DEFAULT_VIEWPORT: QaViewport;
3
+ export declare const VIEWPORT_PRESETS: Record<string, QaViewport>;
4
+ export declare function parseViewport(value?: string): QaViewport;
5
+ export declare function formatViewport(viewport: QaViewport): string;
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VIEWPORT_PRESETS = exports.DEFAULT_VIEWPORT = void 0;
4
+ exports.parseViewport = parseViewport;
5
+ exports.formatViewport = formatViewport;
6
+ exports.DEFAULT_VIEWPORT = {
7
+ id: 'desktop',
8
+ width: 1280,
9
+ height: 900,
10
+ };
11
+ exports.VIEWPORT_PRESETS = {
12
+ desktop: exports.DEFAULT_VIEWPORT,
13
+ tablet: {
14
+ id: 'tablet',
15
+ width: 768,
16
+ height: 1024,
17
+ },
18
+ mobile: {
19
+ id: 'mobile',
20
+ width: 390,
21
+ height: 844,
22
+ },
23
+ };
24
+ function cloneViewport(viewport) {
25
+ return { ...viewport };
26
+ }
27
+ function parseViewport(value) {
28
+ const raw = (value || '').trim().toLowerCase();
29
+ if (!raw)
30
+ return cloneViewport(exports.DEFAULT_VIEWPORT);
31
+ const preset = exports.VIEWPORT_PRESETS[raw];
32
+ if (preset)
33
+ return cloneViewport(preset);
34
+ const match = raw.match(/^(\d{2,5})x(\d{2,5})$/);
35
+ if (!match) {
36
+ throw new Error(`Unsupported viewport "${value}". Use desktop, tablet, mobile, or WIDTHxHEIGHT.`);
37
+ }
38
+ const width = Number(match[1]);
39
+ const height = Number(match[2]);
40
+ if (!Number.isInteger(width) ||
41
+ !Number.isInteger(height) ||
42
+ width <= 0 ||
43
+ height <= 0) {
44
+ throw new Error(`Viewport "${value}" must use positive integer dimensions.`);
45
+ }
46
+ return {
47
+ id: `${width}x${height}`,
48
+ width,
49
+ height,
50
+ };
51
+ }
52
+ function formatViewport(viewport) {
53
+ return `${viewport.id} (${viewport.width}x${viewport.height})`;
54
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trylayout/qa",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Local browser QA runner and HTML reports for AI-built frontends.",
5
5
  "license": "MIT",
6
6
  "author": "Layout",
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "files": [
28
28
  "build",
29
+ "docs/assets/layout-qa-report.png",
29
30
  "README.md",
30
31
  "LICENSE"
31
32
  ],
@@ -38,6 +39,7 @@
38
39
  "scripts": {
39
40
  "build": "tsc",
40
41
  "check": "tsc --noEmit",
42
+ "publish:alias": "cd packages/layout-qa && npm publish --access public",
41
43
  "prepack": "npm run build"
42
44
  },
43
45
  "dependencies": {