ugly-app 0.1.489 → 0.1.490

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.
@@ -1,2 +1,2 @@
1
- export declare const CLI_VERSION = "0.1.489";
1
+ export declare const CLI_VERSION = "0.1.490";
2
2
  //# sourceMappingURL=version.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.489";
2
+ export const CLI_VERSION = "0.1.490";
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-app",
3
- "version": "0.1.489",
3
+ "version": "0.1.490",
4
4
  "type": "module",
5
5
  "main": "./dist/server/index.js",
6
6
  "exports": {
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.489";
2
+ export const CLI_VERSION = "0.1.490";
@@ -231,9 +231,9 @@ Dispatch ALL active personas as **parallel subagents** (`delegate_parallel`). If
231
231
  node bots/feedback/run-bot.mjs {slug} --capture
232
232
  ```
233
233
 
234
- This writes fresh PNGs + element maps to `bots/journal/cycles/screenshots/{slug}/<page>.png` and `<page>.element-map.json` reflecting the CURRENT site. No feedback is submitted yet.
234
+ This writes fresh PNGs + element maps to `bots/journal/cycles/screenshots/{slug}/<page>.png` and `<page>.element-map.json` reflecting the CURRENT site. **As of ugly-app 0.1.489 the driver also writes `<page>.ux-report.json`** — a structured `UglyInspectReport` capturing layout shift, animation jank, overlapping interactive controls, safe-area violations, and mobile keyboard coverage during a measurement window that includes the page's default interaction script (click first interactive `[data-id]`, wait, scroll). No feedback is submitted yet.
235
235
 
236
- 3. **LOOK at each page.** For each PNG under `bots/journal/cycles/screenshots/{slug}/`, inspect it using vision — the persona **must actually see** the page before writing findings:
236
+ 3. **LOOK at each page AND READ the UX report.** For each PNG under `bots/journal/cycles/screenshots/{slug}/`, inspect it using vision — the persona **must actually see** the page before writing findings — AND read the sibling `<page>.ux-report.json` to ground the visual critique in measured defects:
237
237
 
238
238
  - If `analyze_image` is available (ugly-studio coding-agent):
239
239
 
@@ -251,6 +251,15 @@ Dispatch ALL active personas as **parallel subagents** (`delegate_parallel`). If
251
251
 
252
252
  - Cross-reference with the matching `<page>.element-map.json` to resolve specific `dataId` / source-file references when you find something worth reporting.
253
253
 
254
+ - **Mine `<page>.ux-report.json`** for objective defects the screenshot alone cannot show. Treat each of these as a finding worth a feedback row — quote the offending selector in the message:
255
+ - `safeAreaViolations[]` → `{ "type": "bug", "message": "<selector> spills outside iOS safe area on <side> edge" }`
256
+ - `overlaps[]` → `{ "type": "design", "message": "<a.selector> and <b.selector> overlap by Npx — likely a z-index or layout bug" }`
257
+ - `keyboard.coveredInputs[]` → `{ "type": "bug", "message": "<selector> is covered by the on-screen keyboard when focused" }`
258
+ - `cls.total > 0.1` → `{ "type": "design", "message": "page shifts significantly during load: <cls.total> across N spikes" }`
259
+ - `animations[].droppedFrames > 2` → `{ "type": "bug", "message": "<element> animation drops M frames over Tms" }`
260
+
261
+ Visual critique still flows from the screenshot — the structured signals add the movement-and-mobile dimension a single frame can't show.
262
+
254
263
  **Hard rule — no exceptions, including cycle 001:** every active persona must perform ≥1 vision call (`analyze_image` or Read on a PNG) against its **own** captured screenshots before its findings.json is written. Writing findings.json for a persona without a logged vision call for that same persona is a **cycle failure** — stop the cycle and surface it. "All bots see the same blank canvas, I'll just author findings in a batch" is the exact shortcut this rule exists to prevent: feedback authored from imagination instead of from what the persona actually saw is dead weight that pollutes the swarm's signal.
255
264
 
256
265
  4. **AUTHOR findings.json.** Write 1–5 findings to `bots/feedback/active/{slug}/findings.json`:
@@ -0,0 +1,72 @@
1
+ ---
2
+ name: verify-ux
3
+ description: Probe the running app for objective UX defects (CLS, animation jank, control overlap, safe-area violations, keyboard covering inputs) after UI changes
4
+ user-invocable: true
5
+ ---
6
+
7
+ After meaningful UI changes — new page, new popup, layout edits, animation work, mobile preview switch — call `inspect_ux` and act on what it finds. Don't declare UI work done without one clean probe.
8
+
9
+ ## When to run
10
+
11
+ - Created or edited a page → probe its primary route + each interactive flow
12
+ - Added/modified a popup, modal, toast, or accordion → probe the open transition
13
+ - Touched animations, transitions, or scroll-driven effects → probe with `actions` driving the trigger
14
+ - Switched a screen to a new device profile → probe `device: 'ios'` and `device: 'android'`
15
+ - Added a form with focusable inputs near the bottom of a page → probe `simulate_keyboard: true`
16
+
17
+ ## How to call
18
+
19
+ `inspect_ux` runs `window.__uglyInspect()` inside an off-screen browser pointed at your session's dev server. Observers run continuously from page load; the tool reads a measurement WINDOW so you can drive interactions and see what happened during them.
20
+
21
+ Examples:
22
+
23
+ ```ts
24
+ // Initial page load on iOS
25
+ inspect_ux({ url_path: '/feed', device: 'ios' })
26
+
27
+ // SPA navigation jank
28
+ inspect_ux({ actions: [{ click: '[data-id=settings-link]' }, { wait: 800 }] })
29
+
30
+ // Popup entry transition
31
+ inspect_ux({ url_path: '/profile', actions: [{ click: '[data-id=edit-btn]' }, { wait: 600 }] })
32
+
33
+ // Mobile keyboard covering input
34
+ inspect_ux({
35
+ url_path: '/signup',
36
+ device: 'ios',
37
+ actions: [
38
+ { focus: '[data-id=email-field]' },
39
+ { simulate_keyboard: true },
40
+ { wait: 400 },
41
+ ],
42
+ })
43
+
44
+ // Scroll-driven animations
45
+ inspect_ux({ actions: [{ scroll: { to: 2000 } }, { wait: 1200 }] })
46
+ ```
47
+
48
+ ## What to act on
49
+
50
+ The returned `UglyInspectReport` is structured JSON. Treat each of these as a build failure that must be fixed before declaring done:
51
+
52
+ | Field | Defect threshold |
53
+ |---|---|
54
+ | `cls.total` | > 0.1 — significant layout shift during the window |
55
+ | `longTasks[]` | Any entry > 100ms while an animation is in flight (`during` set) |
56
+ | `overlaps[]` | Any entry — two interactive controls share screen space |
57
+ | `safeAreaViolations[]` | Any entry — interactive element spills under notch / home indicator |
58
+ | `keyboard.coveredInputs[]` | Any entry — focused input rendered behind the on-screen keyboard |
59
+ | `animations[].droppedFrames` | > 2 dropped per animation |
60
+ | `popups[].droppedFrames` | > 2 — popup entry stutters |
61
+
62
+ For each defect:
63
+
64
+ 1. Quote the offending `selector` from the report in your fix description.
65
+ 2. Make the smallest CSS / layout change that resolves it (move the element inside the safe rect, raise z-index, defer the layout work, add a placeholder for async content, etc.).
66
+ 3. Re-run `inspect_ux` with the same arguments to confirm the defect is gone before moving on.
67
+
68
+ ## Don't
69
+
70
+ - Don't use `inspect_ux` for *aesthetic* judgments ("does this look pretty?"). For those, use `dev_server_screenshot` + `analyze_image`. `inspect_ux` is for objective, measurable defects only.
71
+ - Don't gate trivial fixes (typo, copy change, color tweak) on a clean inspect_ux run.
72
+ - Don't accept "the report has overlaps but they're decorative" without checking — if the report flagged it, the elements are tagged as interactive (`[data-id]`, `[role]`, `button`, `input`, `a`).
@@ -106,6 +106,22 @@ Every endpoint is accessible via both WebSocket (`socket.request(name, input)`)
106
106
  versions which include accessibility attributes and element map support
107
107
  - **Always** set `aria-label` on icon-only buttons and non-text interactive elements
108
108
 
109
+ ## UX inspection (`window.__uglyInspect()`)
110
+
111
+ Every page exposes a runtime inspection API installed by `bootstrapApp` at boot. It reports objective UX defects: layout shift (CLS), animation jank, overlapping interactive controls, safe-area violations, mobile keyboard coverage, popup mount cost, SPA route transitions. Observers run continuously from page load and keep a rolling 500-entry ring buffer per signal.
112
+
113
+ **Call from:**
114
+ - **Playwright tests / bots** — `import { inspectWindow, expectClean, setDevice, simulateKeyboard, waitForApp } from 'ugly-app/playwright'`
115
+ - **Browser console during dev** — `window.__uglyInspect({ since?: number })` returns a `UglyInspectReport` JSON; `window.__uglyInspectMark()` returns a timestamp for windowed reads
116
+ - **Studio coding agent** — call the `inspect_ux` tool with optional `actions[]` to drive navigation/clicks/keyboard before reading
117
+
118
+ `UglyInspectReport` type and all entry types are exported from `ugly-app/client` and re-exported from `ugly-app/playwright` so tests don't have to reach into client code.
119
+
120
+ **Rules:**
121
+ - **Never** call `page.evaluate('window.__uglyInspect(...)')` directly — use `ugly-app/playwright` so renames and shape changes propagate
122
+ - **Treat as build failures** in test code: any `safeAreaViolations`, `overlaps`, `keyboard.coveredInputs`, or `animations[].droppedFrames > 2`
123
+ - The expensive scans (overlap, safe-area) only run when `__uglyInspect()` is called — observers themselves are essentially free
124
+
109
125
  ## Feedback system
110
126
  Feedback button is always at `[data-id="feedback-button"]` (bottom-right).
111
127
  User feedback history: `GET /my_feedback` (requires auth cookie).
@@ -188,21 +188,65 @@ function routeFilenames(route) {
188
188
  slugForFile,
189
189
  screenshotPath: path.join(SCREENSHOTS_DIR, `${slugForFile}.png`),
190
190
  elementMapPath: path.join(SCREENSHOTS_DIR, `${slugForFile}.element-map.json`),
191
+ uxReportPath: path.join(SCREENSHOTS_DIR, `${slugForFile}.ux-report.json`),
191
192
  url: `${BASE_URL}${route}`,
192
193
  };
193
194
  }
194
195
 
195
196
  async function captureRoute(page, route) {
196
- const { screenshotPath, elementMapPath, url } = routeFilenames(route);
197
+ const { screenshotPath, elementMapPath, uxReportPath, url } =
198
+ routeFilenames(route);
197
199
  try {
198
200
  await page.goto(url, { waitUntil: 'networkidle', timeout: 15_000 });
199
201
  } catch (err) {
200
202
  console.error(`[${SLUG}] failed to load ${url}: ${err.message}`);
201
203
  return false;
202
204
  }
205
+ // Open a UX measurement window, drive a default interaction script
206
+ // (click first interactive [data-id], wait, scroll halfway, back to
207
+ // top), then read window.__uglyInspect() inside the page so the
208
+ // report covers actual movement, not just static layout.
209
+ let uxReport = null;
210
+ try {
211
+ const mark = await page.evaluate(
212
+ () => window.__uglyInspectMark?.() ?? Date.now(),
213
+ );
214
+ // Default interaction: nudge the page so transitions / scroll-driven
215
+ // animations fire. Personas can override by editing this script
216
+ // alongside pages.json.
217
+ await page
218
+ .evaluate(async () => {
219
+ const firstInteractive = document.querySelector(
220
+ '[data-id]:not([data-id=feedback-button])',
221
+ );
222
+ if (
223
+ firstInteractive &&
224
+ firstInteractive.tagName !== 'INPUT' &&
225
+ firstInteractive.tagName !== 'TEXTAREA'
226
+ ) {
227
+ firstInteractive.click?.();
228
+ }
229
+ await new Promise((r) => setTimeout(r, 400));
230
+ window.scrollTo({ top: 800, behavior: 'smooth' });
231
+ await new Promise((r) => setTimeout(r, 600));
232
+ window.scrollTo({ top: 0, behavior: 'smooth' });
233
+ })
234
+ .catch(() => undefined);
235
+ await page.waitForTimeout(800);
236
+ uxReport = await page.evaluate(
237
+ (s) => window.__uglyInspect?.({ since: s }) ?? null,
238
+ mark,
239
+ );
240
+ } catch {
241
+ // window.__uglyInspect not installed (pre-0.1.489 ugly-app) — leave
242
+ // uxReport null so downstream skills can detect the absence.
243
+ }
203
244
  await page.screenshot({ path: screenshotPath, fullPage: true });
204
245
  const elementMap = await captureElementMap(page);
205
246
  fs.writeFileSync(elementMapPath, JSON.stringify(elementMap, null, 2));
247
+ if (uxReport) {
248
+ fs.writeFileSync(uxReportPath, JSON.stringify(uxReport, null, 2));
249
+ }
206
250
  return true;
207
251
  }
208
252
 
@@ -36,4 +36,8 @@ export const allPages = {
36
36
  ['test/worker']: lazyPage(() => import('./pages/WorkerTestPage')),
37
37
  ['test/strings']: lazyPage(() => import('./pages/StringsTestPage')),
38
38
  ['test/safe-area']: lazyPage(() => import('./pages/SafeAreaTestPage')),
39
+ ['test/inspect-fixture']: lazyPage(() => import('./pages/InspectFixturePage')),
40
+ ['test/inspect-fixture-other']: lazyPage(
41
+ () => import('./pages/InspectFixtureOtherPage'),
42
+ ),
39
43
  } satisfies PageMap<AppPages>;
@@ -0,0 +1,14 @@
1
+ import { PageLayout, Text } from 'ugly-app/client';
2
+
3
+ /** SPA-nav target for inspect-fixture tests. */
4
+ export default function InspectFixtureOtherPage() {
5
+ return (
6
+ <PageLayout>
7
+ <div style={{ padding: 16 }}>
8
+ <Text data-id="other-page-title" size="xl" weight="bold">
9
+ Other page
10
+ </Text>
11
+ </div>
12
+ </PageLayout>
13
+ );
14
+ }
@@ -0,0 +1,216 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Input, PageLayout, Text, useRouter } from 'ugly-app/client';
3
+
4
+ /**
5
+ * Test fixture for `window.__uglyInspect()` / `ugly-app/playwright`.
6
+ * Each `?simulate=` value renders a known-bad layout so the inspect
7
+ * helper can assert "yes the report caught it".
8
+ *
9
+ * Used by:
10
+ * - templates/tests/e2e/inspect.spec.ts (Tier 2 in the studio spec)
11
+ * - the studio integration test in app/studio/tests/inspect-ux-end-to-end.test.ts (Tier 3)
12
+ */
13
+ export default function InspectFixturePage() {
14
+ const router = useRouter();
15
+ const params =
16
+ typeof window === 'undefined'
17
+ ? new URLSearchParams()
18
+ : new URLSearchParams(window.location.search);
19
+ const simulate = params.get('simulate') ?? '';
20
+ const [delayedMounted, setDelayedMounted] = useState(false);
21
+
22
+ useEffect(() => {
23
+ if (simulate === 'cls') {
24
+ const t = setTimeout(() => setDelayedMounted(true), 400);
25
+ return () => clearTimeout(t);
26
+ }
27
+ return undefined;
28
+ }, [simulate]);
29
+
30
+ return (
31
+ <PageLayout>
32
+ <div
33
+ style={{
34
+ padding: 16,
35
+ display: 'flex',
36
+ flexDirection: 'column',
37
+ gap: 12,
38
+ }}
39
+ >
40
+ <Text size="xl" weight="bold">
41
+ Inspect Fixture · simulate={simulate || 'none'}
42
+ </Text>
43
+
44
+ {/* Default content — always rendered so [data-id] selector fires */}
45
+ <button
46
+ data-id="nav-to-other"
47
+ onClick={() => router.push('test/inspect-fixture-other', {})}
48
+ >
49
+ Navigate to other page
50
+ </button>
51
+
52
+ <button
53
+ data-id="nav-to-cls-page"
54
+ onClick={() =>
55
+ router.push('test/inspect-fixture', { simulate: 'cls' })
56
+ }
57
+ >
58
+ Navigate (re-load) with CLS sim
59
+ </button>
60
+
61
+ {simulate === 'cls' && (
62
+ <>
63
+ {/* Forces a layout shift when the delayed block mounts and
64
+ shoves siblings down by ~240px. */}
65
+ {delayedMounted && (
66
+ <div
67
+ data-id="delayed-banner"
68
+ style={{
69
+ height: 240,
70
+ background: 'crimson',
71
+ color: 'white',
72
+ padding: 16,
73
+ }}
74
+ >
75
+ Delayed mount — pushes content down
76
+ </div>
77
+ )}
78
+ <div data-id="below-the-shift" style={{ padding: 24 }}>
79
+ This block moves when the delayed banner mounts.
80
+ </div>
81
+ </>
82
+ )}
83
+
84
+ {simulate === 'overlap' && (
85
+ <div style={{ position: 'relative', height: 200 }}>
86
+ <button
87
+ data-id="overlap-a"
88
+ style={{
89
+ position: 'absolute',
90
+ left: 40,
91
+ top: 40,
92
+ width: 160,
93
+ height: 60,
94
+ }}
95
+ >
96
+ Button A
97
+ </button>
98
+ <button
99
+ data-id="overlap-b"
100
+ style={{
101
+ position: 'absolute',
102
+ left: 120,
103
+ top: 60,
104
+ width: 160,
105
+ height: 60,
106
+ }}
107
+ >
108
+ Button B
109
+ </button>
110
+ </div>
111
+ )}
112
+
113
+ {simulate === 'safearea' && (
114
+ <button
115
+ data-id="fab"
116
+ style={{
117
+ position: 'fixed',
118
+ left: 16,
119
+ right: 16,
120
+ bottom: 0,
121
+ height: 56,
122
+ background: '#0a84ff',
123
+ color: 'white',
124
+ border: 0,
125
+ }}
126
+ >
127
+ Floating action button (no bottom safe-area padding)
128
+ </button>
129
+ )}
130
+
131
+ {simulate === 'keyboard' && (
132
+ <div style={{ paddingTop: 600 }}>
133
+ <Input
134
+ data-id="fixture-input"
135
+ value=""
136
+ onChange={() => {}}
137
+ placeholder="Focus me to test keyboard coverage"
138
+ />
139
+ </div>
140
+ )}
141
+
142
+ {simulate === 'jank' && <JankSim />}
143
+
144
+ {simulate === 'popup' && <PopupSim />}
145
+ </div>
146
+ </PageLayout>
147
+ );
148
+ }
149
+
150
+ function JankSim() {
151
+ const [running, setRunning] = useState(false);
152
+ return (
153
+ <>
154
+ <button
155
+ data-id="start-jank"
156
+ onClick={() => {
157
+ setRunning(true);
158
+ // Spawn a long task during the animation.
159
+ const start = performance.now();
160
+ while (performance.now() - start < 250) {
161
+ // Busy-loop — guaranteed long task.
162
+ }
163
+ setTimeout(() => setRunning(false), 1000);
164
+ }}
165
+ >
166
+ Start janky animation
167
+ </button>
168
+ {running && (
169
+ <div
170
+ data-id="janky-animation"
171
+ style={{
172
+ width: 100,
173
+ height: 100,
174
+ background: 'orange',
175
+ animation: 'spin 800ms linear',
176
+ }}
177
+ />
178
+ )}
179
+ <style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
180
+ </>
181
+ );
182
+ }
183
+
184
+ function PopupSim() {
185
+ const [open, setOpen] = useState(false);
186
+ return (
187
+ <>
188
+ <button data-id="open-modal" onClick={() => setOpen(true)}>
189
+ Open modal
190
+ </button>
191
+ {open && (
192
+ <div
193
+ role="dialog"
194
+ data-id="modal-content"
195
+ style={{
196
+ position: 'fixed',
197
+ inset: 0,
198
+ display: 'flex',
199
+ alignItems: 'center',
200
+ justifyContent: 'center',
201
+ background: 'rgba(0,0,0,0.5)',
202
+ animation: 'fade-in 300ms ease',
203
+ }}
204
+ >
205
+ <div style={{ background: 'white', padding: 24, borderRadius: 8 }}>
206
+ <Text>Modal body</Text>
207
+ <button data-id="close-modal" onClick={() => setOpen(false)}>
208
+ Close
209
+ </button>
210
+ </div>
211
+ </div>
212
+ )}
213
+ <style>{`@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }`}</style>
214
+ </>
215
+ );
216
+ }
@@ -35,6 +35,10 @@ export const pages = definePages({
35
35
  'test/worker': definePage<{}>({ auth: true }),
36
36
  'test/strings': definePage<{}>({ auth: false }),
37
37
  'test/safe-area': definePage<{}>({ auth: false }),
38
+ 'test/inspect-fixture': definePage<{
39
+ simulate?: 'cls' | 'overlap' | 'safearea' | 'keyboard' | 'jank' | 'popup';
40
+ }>({ auth: false }),
41
+ 'test/inspect-fixture-other': definePage<{}>({ auth: false }),
38
42
  });
39
43
 
40
44
  export type AppPages = typeof pages;