@ctxr/skill-frontend-excellence 0.1.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.
@@ -0,0 +1,457 @@
1
+ # Data Visualization
2
+
3
+ Framework-agnostic guidance on charts, tables, and data-dense interfaces. Library-agnostic; the same principles apply to D3, Chart.js, Recharts, ECharts, Highcharts, Vega-Lite, and others.
4
+
5
+ ## The Hierarchy of Data Display
6
+
7
+ When showing data, walk this list. The first that fits is the right choice.
8
+
9
+ 1. **One number with context.** "$48,231 monthly recurring revenue" beats a chart with one data point.
10
+ 2. **Comparison or ranking** (top 5, top 10). A list with bars beats a pie chart.
11
+ 3. **Trend over time.** A line chart.
12
+ 4. **Distribution.** Histogram, box plot.
13
+ 5. **Composition.** Stacked bar, treemap.
14
+ 6. **Relationship between two variables.** Scatter plot.
15
+ 7. **Geographic.** Map.
16
+ 8. **Detailed records.** Table.
17
+
18
+ If you reach for a chart and a table or summary number works, use the simpler thing.
19
+
20
+ ## Choosing the Right Chart
21
+
22
+ | Data | Best chart | Avoid |
23
+ |------|-----------|-------|
24
+ | Single value | Stat card with sparkline | Chart |
25
+ | Trend over time | Line chart | Pie chart |
26
+ | Compare 2-10 categories | Bar chart (horizontal if labels long) | Pie chart |
27
+ | Compare 10-30 categories | Horizontal bar | Vertical bar (cramped on mobile) |
28
+ | Compare > 30 categories | Top N + "Other" or table | Bar chart with all |
29
+ | Composition (parts of whole, 2-5 categories) | Pie or donut | Pie with > 5 |
30
+ | Composition over time | Stacked area | Pie carousel |
31
+ | Distribution | Histogram or box plot | Bar chart of raw values |
32
+ | Two-variable relationship | Scatter plot | Two side-by-side bar charts |
33
+ | Multiple metrics over time | Multi-line, small multiples | Stacked line (hides individual values) |
34
+ | Geographic | Choropleth map | Pie chart |
35
+ | Hierarchical composition | Treemap or sunburst | Stacked bar |
36
+ | Funnel (sequential drop-off) | Funnel chart | Bar chart |
37
+ | Network / relationships | Graph / network diagram | Table |
38
+
39
+ ### Pie/donut rules
40
+
41
+ - Maximum 5 categories. Beyond that, switch to bar.
42
+ - Sum to 100%.
43
+ - Order by size (largest first, clockwise from 12 o'clock).
44
+ - Direct-label slices when space allows; legend if not.
45
+ - Don't 3D pie. Ever.
46
+ - Don't compare two pies side by side; use a stacked bar.
47
+
48
+ ### Bar chart rules
49
+
50
+ - Bar baseline at zero. Always.
51
+ - Vertical bars for short labels, horizontal for long.
52
+ - Order by value (descending) unless there's a natural order (months, ranks).
53
+ - Spacing between bars: about 30-40% of bar width.
54
+ - Categorical x-axis on horizontal; numerical on vertical.
55
+
56
+ ### Line chart rules
57
+
58
+ - Time on the x-axis.
59
+ - Multiple lines: maximum 5 before they tangle.
60
+ - Distinct color or dash pattern per line.
61
+ - Direct-label lines at the right end where space allows.
62
+ - Connect data points; don't put markers on every point unless you want emphasis.
63
+
64
+ ## Axes
65
+
66
+ ### Labels
67
+
68
+ - Always label both axes.
69
+ - Include units in the axis label or as a suffix on tick labels ("$", "%", "ms").
70
+ - Don't rotate labels 90 degrees on horizontal bars; just go horizontal-bar layout.
71
+ - Auto-skip labels when they overlap (every other, or larger interval).
72
+
73
+ ### Ticks
74
+
75
+ - Use round numbers (10, 20, 50, 100), not 7, 23, 47.
76
+ - 4-7 ticks per axis is usually right.
77
+ - Y-axis baseline at zero for bar charts. Truncating the y-axis exaggerates differences.
78
+ - For dramatic small variations, add a "broken axis" indicator (zigzag) to be honest about it.
79
+
80
+ ### Time axis
81
+
82
+ - Match granularity to the time range:
83
+ - < 1 day: hours
84
+ - 1-30 days: days
85
+ - 1-12 months: weeks or months
86
+ - 1-5 years: months or quarters
87
+ - > 5 years: years
88
+ - Allow user to change granularity (day/week/month).
89
+ - Show the date format consistent with the user's locale.
90
+
91
+ ### Number formatting
92
+
93
+ - Locale-aware via `Intl.NumberFormat`.
94
+ - Compact notation for large numbers: `1.2M`, `4.7K`.
95
+ - Currency: respect locale and currency code.
96
+ - Percentages: 1 decimal (`23.4%`) for finance, no decimals (`23%`) for categorical.
97
+
98
+ ## Color in Charts
99
+
100
+ ### Single series
101
+
102
+ - Use the brand primary or a single neutral.
103
+ - Add a slight gradient (top to bottom) only on filled area charts; never on bar charts (looks gimmicky).
104
+
105
+ ### Multiple series
106
+
107
+ - Sequential (ordered, e.g., low to high values): single hue, light to dark. Use a perceptually uniform scale (viridis, magma).
108
+ - Diverging (centered, e.g., positive vs negative): two-hue diverging palette (blue-white-red).
109
+ - Categorical (no order, e.g., Series A vs Series B vs Series C): qualitative palette, distinct hues. Use a colorblind-safe palette.
110
+
111
+ ### Colorblind-safe palettes
112
+
113
+ Avoid red/green pairs alone. Use:
114
+
115
+ - Tol's "bright" palette: blue, red, green, yellow, cyan, purple, gray (distinguishable in most colorblind types).
116
+ - ColorBrewer's "Set2" or "Dark2".
117
+ - Always supplement color with shape/pattern/label so meaning isn't lost.
118
+
119
+ ### Pattern alongside color
120
+
121
+ For stacked bars or pies, use a subtle hatching/dot pattern in addition to color. Color carries the primary signal, pattern carries redundancy:
122
+
123
+ ```css
124
+ .series-a { fill: var(--color-1); }
125
+ .series-b { fill: var(--color-2); background-image: repeating-linear-gradient(45deg, transparent, transparent 4px, rgba(0,0,0,0.1) 4px, rgba(0,0,0,0.1) 8px); }
126
+ ```
127
+
128
+ ### Contrast against background
129
+
130
+ Data marks (bars, lines) need at least 3:1 contrast against the chart background. Text labels need 4.5:1.
131
+
132
+ In dark mode, lighten the data marks. Don't just keep light-mode colors.
133
+
134
+ ## Legends
135
+
136
+ - Always show a legend for multi-series charts.
137
+ - Place near the chart, not detached below a scroll fold.
138
+ - Order matches data (largest to smallest, or alphabetical).
139
+ - For interactive charts, legends are clickable to toggle series visibility.
140
+ - Direct labeling (label at the line/bar) often beats a separate legend.
141
+
142
+ ## Tooltips
143
+
144
+ - Show on hover (desktop) or tap (mobile).
145
+ - Show the exact value (numeric labels are too small for crowded charts; tooltips fill in).
146
+ - Label which series, the x value, and the y value.
147
+ - Anchor near the cursor; don't make the user scan.
148
+ - Persist long enough to read (don't dismiss on tiny mouse movement).
149
+
150
+ ```
151
+ [X-axis label, e.g., March 12]
152
+ Series A: 12,400 (+18%)
153
+ Series B: 8,200 (-3%)
154
+ ```
155
+
156
+ For accessibility, tooltip content must be reachable via keyboard (focus on data point reveals tooltip).
157
+
158
+ ## Direct Labeling
159
+
160
+ For small datasets, label values directly on the chart instead of forcing the eye to travel to an axis:
161
+
162
+ - Bar chart with 3-5 bars: put the value on or above each bar.
163
+ - Line chart: put the value at the rightmost point of each line.
164
+ - Pie chart: put values inside slices (or outside with a leader line if too small).
165
+
166
+ ## Animation
167
+
168
+ ### Entrance animation
169
+
170
+ - Bars rise from the baseline, 400-600ms staggered by 30-50ms per bar.
171
+ - Lines draw from left to right, 600-800ms total.
172
+ - Pies sweep from 12 o'clock clockwise, 800-1000ms.
173
+
174
+ ### Update animation
175
+
176
+ When data changes:
177
+
178
+ - Animate from old to new values, 300-500ms.
179
+ - Use ease-out.
180
+ - Animate the path's `d` attribute or use a library helper for smooth transitions.
181
+
182
+ ### When to disable
183
+
184
+ - `prefers-reduced-motion: reduce`: skip entrance animation; show data immediately.
185
+ - Live-updating data views: no animation on every refresh; just update.
186
+ - Print: no animation.
187
+
188
+ ```js
189
+ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
190
+ chart.options.animation = reduced ? false : { duration: 600, easing: 'easeOutQuart' };
191
+ ```
192
+
193
+ ## Accessibility
194
+
195
+ ### Screen readers
196
+
197
+ Charts are inherently visual. Provide a text alternative:
198
+
199
+ - Concise summary near the chart describing the key insight (one sentence stating the trend or comparison the chart shows).
200
+ - A data table either inline or in a `<details>` toggle, with the same data the chart shows.
201
+ - For complex charts, use `aria-describedby` to link the chart to a longer description.
202
+
203
+ ```html
204
+ <figure>
205
+ <figcaption>Chart caption describing what is plotted</figcaption>
206
+ <div class="chart" role="img" aria-labelledby="chart-summary">
207
+ <svg>...</svg>
208
+ </div>
209
+ <p id="chart-summary">
210
+ One sentence stating the key insight the chart conveys, in plain language, with the headline numbers.
211
+ </p>
212
+ <details>
213
+ <summary>View data table</summary>
214
+ <table>
215
+ ...
216
+ </table>
217
+ </details>
218
+ </figure>
219
+ ```
220
+
221
+ ### Keyboard
222
+
223
+ - Interactive elements (data points, legend items) must be focusable.
224
+ - Arrow keys navigate between data points (left/right for adjacent, up/down for series).
225
+ - Enter/Space activates (toggles legend, drills down).
226
+ - Esc closes any popover or expanded view.
227
+
228
+ ### Touch targets
229
+
230
+ Interactive chart elements (data points, legend toggles) need at least 44x44 touch area. For dense charts, expand the hit area beyond the visual element:
231
+
232
+ ```html
233
+ <circle cx="100" cy="50" r="4" />
234
+ <circle cx="100" cy="50" r="22" fill="transparent" pointer-events="all" />
235
+ ```
236
+
237
+ The visible dot is 4px; the touch target is 22px (44px diameter).
238
+
239
+ ## Responsive Charts
240
+
241
+ ### Reflow at small widths
242
+
243
+ Charts must adapt or simplify on small screens:
244
+
245
+ - Vertical bars become horizontal bars (long labels easier).
246
+ - Multi-line charts collapse to top-N or to small multiples.
247
+ - Pie charts stack labels below.
248
+ - Axis ticks reduce to 3-5 from 7-10.
249
+
250
+ ### Container query
251
+
252
+ Charts that appear in different layout slots benefit from container queries:
253
+
254
+ ```css
255
+ .chart-container { container-type: inline-size; }
256
+
257
+ @container (max-width: 480px) {
258
+ .chart .legend { display: none; }
259
+ .chart .axis-label { font-size: 0.75rem; }
260
+ }
261
+ ```
262
+
263
+ ### Re-render on resize
264
+
265
+ For SVG-based charts, listen to resize and re-render with new dimensions. Throttle to 100-200ms.
266
+
267
+ ## Loading and Error States
268
+
269
+ ### Loading
270
+
271
+ - Show a skeleton or shimmer matching the chart's eventual shape.
272
+ - Don't show a blank axis frame; that looks broken.
273
+
274
+ ### Empty data
275
+
276
+ - Show a message: "No data for this period yet."
277
+ - Optionally a placeholder chart (greyed out) with the message overlaid.
278
+ - Action: "Try a different range" or "Connect data source".
279
+
280
+ ### Error
281
+
282
+ - Show an error message with retry: "Couldn't load chart. Retry?"
283
+ - Don't show a broken/clipped chart.
284
+
285
+ ## Large Datasets
286
+
287
+ For 1000+ data points:
288
+
289
+ - **Aggregate.** Group by hour/day/week to reduce point count.
290
+ - **Sample.** Show every Nth point.
291
+ - **Summarize.** Show the trend; provide drill-down for detail.
292
+ - **Virtualize.** For long tables, only render visible rows.
293
+ - **Server-side processing.** Aggregate on the server; ship summarized data.
294
+ - **Web Workers.** For client-side heavy aggregation, move work off the main thread.
295
+
296
+ Anti-pattern: shipping 50,000 points to a `<canvas>` chart and expecting smooth interaction.
297
+
298
+ ## Tables
299
+
300
+ ### Anatomy
301
+
302
+ ```html
303
+ <table>
304
+ <caption>Table caption describing the data</caption>
305
+ <thead>
306
+ <tr>
307
+ <th scope="col">Row category</th>
308
+ <th scope="col">Metric A</th>
309
+ <th scope="col">Metric B</th>
310
+ </tr>
311
+ </thead>
312
+ <tbody>
313
+ <tr>
314
+ <th scope="row">Row label</th>
315
+ <td>4,200,000</td>
316
+ <td>+12%</td>
317
+ </tr>
318
+ ...
319
+ </tbody>
320
+ </table>
321
+ ```
322
+
323
+ - `<caption>` describes the table.
324
+ - `<th scope="col">` for column headers, `<th scope="row">` for row headers.
325
+ - For complex tables (multi-level headers), use `headers="..."` to associate cells with their headers.
326
+
327
+ ### Visual rules
328
+
329
+ - Tabular figures (`font-variant-numeric: tabular-nums`) for numeric columns.
330
+ - Right-align numbers, left-align text, center-align symbols/icons.
331
+ - Subtle row dividers (low-contrast borders) rather than alternating row colors. Alternating colors look dated and reduce readability.
332
+ - Sticky header for long tables (`position: sticky` on `<thead>`).
333
+ - Hover state on rows for navigability.
334
+
335
+ ### Sorting
336
+
337
+ - Click column header to sort ascending; click again for descending.
338
+ - Show current sort direction with an arrow icon.
339
+ - Use `aria-sort="ascending|descending|none"` on the column header.
340
+ - Multi-column sort: hold Shift to add additional sorts.
341
+
342
+ ### Filtering
343
+
344
+ - Per-column filters: dropdown, search, range slider depending on data type.
345
+ - Active filters visible as chips above the table.
346
+ - "Clear all" link.
347
+
348
+ ### Selection
349
+
350
+ - Checkbox per row.
351
+ - Select-all checkbox in the header (with indeterminate state for partial selection).
352
+ - Show count of selected rows.
353
+ - Bulk actions appear when rows selected.
354
+
355
+ ### Pagination vs infinite scroll
356
+
357
+ - **Pagination** for tables: predictable, accessible, supports deep linking.
358
+ - **Infinite scroll** for activity feeds: low-stakes, sequential reading.
359
+ - Don't mix.
360
+
361
+ ### Mobile tables
362
+
363
+ For wide tables on small screens:
364
+
365
+ - **Horizontal scroll** with sticky first column (the row identifier).
366
+ - **Card view**: each row becomes a card, fields as label/value pairs.
367
+ - **Collapse**: show a few key columns, expand row to see all.
368
+
369
+ ```css
370
+ .table-scroll {
371
+ overflow-x: auto;
372
+ scrollbar-width: thin;
373
+ }
374
+
375
+ .table-scroll table {
376
+ min-inline-size: 720px;
377
+ }
378
+ ```
379
+
380
+ ## Stat Cards / KPI Tiles
381
+
382
+ For data summaries, the most important number gets a stat card:
383
+
384
+ ```
385
+ [ Stat Card ]
386
+ [ Label: short description of the metric ]
387
+ [ Value: the formatted number, largest type ]
388
+ [ Change: direction icon + delta vs comparison period ]
389
+ [ Sparkline: subtle line chart of recent history ]
390
+ ```
391
+
392
+ Rules:
393
+
394
+ - Label first (small, muted).
395
+ - Value largest (display type).
396
+ - Change indicator with direction icon and color (green up, red down). Pair with text so color isn't the only signal.
397
+ - Optional sparkline. Subtle (single neutral color), no axis.
398
+ - Optional click affordance to drill down.
399
+
400
+ ## Real-Time Updates
401
+
402
+ For live-updating data:
403
+
404
+ - Update without animation (or very subtle pulse on changed values).
405
+ - Don't shift other content. New rows arrive at the top with a brief highlight.
406
+ - Allow pause/resume so users can read without flicker.
407
+ - Show last-updated timestamp.
408
+
409
+ ## Common Data Viz Mistakes
410
+
411
+ - Pie chart with > 5 categories.
412
+ - Bar chart with truncated y-axis (exaggerates differences).
413
+ - Line chart with too many series tangling.
414
+ - 3D charts.
415
+ - Tooltip showing only a value with no series/x-axis context.
416
+ - Color-only encoding (red good / red bad without text or icon).
417
+ - Chart that's a bar chart on desktop and a different chart entirely on mobile (jarring).
418
+ - Chart with no axis labels.
419
+ - Chart with no units.
420
+ - Chart that loads with a flicker (no skeleton, then sudden content).
421
+ - Animation that runs on every data refresh (every 5s in a live data view).
422
+ - Chart in dark mode with light-mode colors.
423
+ - Inaccessible chart with no text alternative.
424
+ - Tables without sorting or filtering on data-heavy views.
425
+ - Tables with row colors so loud they distract from data.
426
+ - Numbers without locale formatting (US format in a German UI).
427
+ - Tooltip that doesn't work on touch.
428
+
429
+ ## Self-Healing for Data Viz
430
+
431
+ Before declaring work complete:
432
+
433
+ - [ ] Right chart for the data
434
+ - [ ] Bar charts have zero baseline
435
+ - [ ] Pies have <= 5 categories
436
+ - [ ] Axes labeled with units
437
+ - [ ] Colorblind-safe palette
438
+ - [ ] Color is never the only signal (paired with shape/pattern/label)
439
+ - [ ] Legend shown and accessible
440
+ - [ ] Tooltip works on hover AND tap
441
+ - [ ] Chart has text alternative (summary or table)
442
+ - [ ] Interactive elements keyboard-accessible
443
+ - [ ] Touch targets >= 44x44
444
+ - [ ] Responsive: simplifies/reflows on small screens
445
+ - [ ] Loading skeleton matches eventual layout
446
+ - [ ] Empty state has a message and action
447
+ - [ ] Error state has retry
448
+ - [ ] Numbers locale-formatted
449
+ - [ ] Animation respects `prefers-reduced-motion`
450
+ - [ ] Tested in light AND dark mode
451
+ - [ ] Tables: sorting, sticky header, mobile-adaptive
452
+
453
+ ## See Also
454
+
455
+ - [accessibility.md](accessibility.md) for chart and table accessibility
456
+ - [responsive.md](responsive.md) for chart reflow patterns
457
+ - [motion.md](motion.md) for chart animation
@@ -0,0 +1,152 @@
1
+ # Common Visual Defects and Geometry Checks
2
+
3
+ A symptom-to-fix lookup for visible defects, plus a canonical programmatic geometry sweep that runs against every audited route at both capture viewports. Use this file when something looks wrong and you want the standard fix, or when you want a deterministic check that catches issues screenshots can miss.
4
+
5
+ ## How to Use
6
+
7
+ 1. Identify the symptom on the rendered page or in a screenshot.
8
+ 2. Look it up in the defect table below.
9
+ 3. Apply the standard fix at the right layer (component, page, or design token).
10
+ 4. Re-run the geometry sweep on the affected route at both audit viewports.
11
+ 5. Re-capture before-and-after screenshots and confirm the fix did not regress neighbors.
12
+
13
+ The defect table is one big table by design: one search lands you on the symptom regardless of which topic it might also live under. Topical references point readers here rather than restating the table.
14
+
15
+ ## Defect Lookup Table
16
+
17
+ | Defect | Likely cause | Fix |
18
+ |--------|--------------|-----|
19
+ | Button shifts oddly on hover | Parent and child both transform | Disable child transform inside the animated card or move the transform to one layer |
20
+ | Dark line after sticky header | Border/shadow/background mismatch between header and section top | Normalize header border, shadow, and section top background so the seam is invisible |
21
+ | Dropdown not centered | Absolute position tied to item edge | Use `left: 50%`, `transform: translateX(-50%)`, clamp width on small screens |
22
+ | Mobile dropdown off-canvas | Desktop hover rule overrides mobile rule | Add a media-specific override and constrain the dropdown to the viewport |
23
+ | Card row uneven heights | Content-dependent heights with no stretch | Use grid stretch, flex column, `height: 100%`, and stable `min-height` |
24
+ | Only nested CTA clickable | Card visually behaves as a link but the anchor wraps only the CTA | Make the full card an anchor, or use a button-card pattern with a stretched pseudo-element on the title link; never nest anchors, which is invalid HTML per the `<a>` content model |
25
+ | Duplicate arrows | CSS pseudo-element arrow plus a text or icon arrow | Choose one arrow source and suppress the other by component class |
26
+ | Pricing implies wrong plan | Ambiguous trial or setup wording on shared cards | State paid plan terms and fees plainly; remove repeated misleading phrases |
27
+ | Social icons too far apart or too small | Independent margins and small hit boxes | Use fixed 44px boxes and `gap` |
28
+ | Cross or check icon off-center | Pseudo-element dimensions mismatch the icon box | Use a square icon box with absolutely centered pseudo-elements, or inline SVG |
29
+ | Mobile horizontal scroll | Hidden off-canvas element, wide image, long unbreakable word, or `100vw` misuse | Inspect `scrollWidth`; constrain media; use `min-width: 0` on grid children; avoid `width: 100vw` inside padded containers |
30
+ | Low-quality page versus reference | Thin sections, repeated template copy, generic stock images | Add useful sections, vary copy, use relevant images, tighten hierarchy |
31
+ | Same widget looks different across pages | Duplicated markup, page-local CSS, no component contract | Extract a shared component or normalize markup and classes to one contract; see [components.md](components.md) |
32
+ | Many card variants without purpose | Ad hoc class combinations applied page by page | Define named variants in the component and map old usages into them |
33
+ | Integration or logo tiles inconsistent in size | Each page hand-codes image and card rules | Create one logo tile component with a fixed image box, label treatment, and grid behavior |
34
+ | CTA bands vary randomly | Page-specific final sections | Create one final-CTA component with explicit variants for centered, split, compact, or gradient |
35
+ | Focus ring clipped | Parent uses `overflow: hidden` with insufficient padding | Move the ring to an `outline` (which paints outside the box) or add internal padding so the ring fits |
36
+ | Focus ring invisible on dark surfaces | Single-color ring chosen for light mode only | Use a ring color with 3:1 contrast against both surface and resting element state, in both light and dark mode |
37
+ | Hero image causes CLS | Missing intrinsic `width` and `height` | Declare width and height as attributes; reserve space with `aspect-ratio` |
38
+ | Late-loading hero image hurts LCP | Hero image is `loading="lazy"` or behind hydration | Use `loading="eager"` and `fetchpriority="high"` on the hero LCP image only |
39
+ | Webfont flash on first paint | `font-display: block` or no `font-display` set | Use `font-display: swap` for body and `optional` for display; preload at most one critical weight |
40
+ | Unstyled flash on hydration | Critical CSS not inlined | Inline above-the-fold CSS under 14 KB so it fits in the first round trip |
41
+ | Button label truncates on mobile | Fixed width or `white-space: nowrap` with insufficient room | Constrain by `max-width`, allow wrap, or shorten the label |
42
+ | Form validation appears late | Validation runs on every keystroke, then debounces | Validate on blur for new errors; clear errors live as the user types |
43
+ | Modal scrolls with the page | Body scroll not locked when modal is open | Lock body scroll on open, restore on close; restore the scroll position |
44
+ | Modal traps keyboard but not screen reader | Background is interactive in the accessibility tree | Mark background `inert` (or `aria-hidden="true"` plus `pointer-events: none`) while modal is open |
45
+ | Skip link overflows the viewport while hidden | `position: absolute; left: -9999px` can still extend the document layout box, or the focus state inherits the off-screen position | Use the modern `clip-path: inset(100%)` plus `position: absolute; width: 1px; height: 1px; overflow: hidden` pattern when hidden; switch to `clip-path: inset(0); position: fixed; top: 1rem; left: 1rem` on `:focus` |
46
+ | Tables overflow on mobile | Fixed-width table without scroll wrapper | Wrap in a scroll container with `overflow-x: auto`, or transform to cards below the breakpoint |
47
+ | Empty state is blank | No specific message and no primary action | Add a specific message, the condition that fills it, and a primary action that resolves it |
48
+
49
+ ## Programmatic Geometry Sweep
50
+
51
+ The geometry sweep catches defects that are easy to miss in a screenshot pass: sub-44 hit targets buried in long pages, hidden text overflow on edge cases, viewport bleed from a single rogue element, duplicate arrows that read fine in isolation but stack across siblings.
52
+
53
+ Run from a headless browser of your choice (Puppeteer, Playwright, or equivalent). The snippet below uses only standard DOM and CSSOM APIs:
54
+
55
+ ```js
56
+ const issues = await page.evaluate(() => {
57
+ const out = [];
58
+ const visible = (el) => {
59
+ const r = el.getBoundingClientRect();
60
+ const cs = getComputedStyle(el);
61
+ return r.width > 0 && r.height > 0 && cs.display !== "none"
62
+ && cs.visibility !== "hidden" && parseFloat(cs.opacity) > 0;
63
+ };
64
+ const nameFor = (el) =>
65
+ el.tagName.toLowerCase()
66
+ + (el.className
67
+ ? "." + String(el.className).trim().split(/\s+/).slice(0, 3).join(".")
68
+ : "");
69
+
70
+ if (document.scrollingElement.scrollWidth > window.innerWidth + 1) {
71
+ out.push({ type: "viewport-bleed", value: document.scrollingElement.scrollWidth, viewport: window.innerWidth });
72
+ }
73
+
74
+ for (const el of document.querySelectorAll("body *")) {
75
+ if (!visible(el)) continue;
76
+ const cs = getComputedStyle(el);
77
+ if (el.clientWidth > 0 && el.scrollWidth > el.clientWidth + 2 && cs.overflowX === "visible") {
78
+ const text = (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80);
79
+ if (text && !["HTML", "BODY", "MAIN", "SECTION"].includes(el.tagName)) {
80
+ out.push({ type: "text-overflow", selector: nameFor(el), text });
81
+ }
82
+ }
83
+ }
84
+
85
+ for (const el of document.querySelectorAll("a, button, input, select, textarea, [role='button'], .btn")) {
86
+ if (!visible(el)) continue;
87
+ const r = el.getBoundingClientRect();
88
+ if (r.width < 44 || r.height < 44) {
89
+ out.push({ type: "small-target", selector: nameFor(el), width: Math.round(r.width), height: Math.round(r.height) });
90
+ }
91
+ }
92
+
93
+ for (const el of document.querySelectorAll("a, .btn-text")) {
94
+ const text = (el.textContent || "").replace(/\s+/g, " ").trim();
95
+ if (/→\s*→|›\s*›|»\s*»/.test(text)) out.push({ type: "duplicate-arrow", text });
96
+ }
97
+
98
+ return out;
99
+ });
100
+ ```
101
+
102
+ The sweep returns nine check categories. Run it on every audited route at both capture viewports.
103
+
104
+ | # | Check | Triggers when |
105
+ |---|-------|---------------|
106
+ | 1 | Viewport bleed | `document.scrollingElement.scrollWidth` exceeds `window.innerWidth` by more than 1 px |
107
+ | 2 | Visible text overflow | An element's `scrollWidth` exceeds its `clientWidth` by more than 2 px while `overflow-x` is `visible` |
108
+ | 3 | Small interactive target | A visible interactive element measures less than 44 by 44 CSS pixels |
109
+ | 4 | Duplicate arrow | A link or text-button label contains stacked arrow glyphs (`→ →`, `› ›`, `» »`) |
110
+ | 5 | Pricing or card clickable mismatch | A card visually behaves as a link but only an inner CTA is the anchor (extend the sweep with a per-project selector list) |
111
+ | 6 | Header or dropdown centering | The dropdown's horizontal center is more than 4 px from its trigger center (extend with the per-project selector) |
112
+ | 7 | Mobile drawer scroll lock | Body scroll is not locked while the drawer is open (extend with a per-project trigger sequence) |
113
+ | 8 | Focus state presence | A keyboard-focusable element produces no visible outline change on focus (extend with a per-project focus drive) |
114
+ | 9 | Project-specific extension | Open slot for negative letter-spacing, banned colors, or any rule the project standardizes on |
115
+
116
+ The base snippet covers checks 1 through 4 directly. Checks 5 through 8 require selectors and triggers specific to the project under audit; add them to the same `evaluate` call. Check 9 is the slot for any rule the project enforces on top of the standard nine.
117
+
118
+ Filter false positives only when you can explain them: hidden off-canvas content, intentionally overflowing dropdown internals that do not affect the page viewport, or a tap target that is intentionally part of a larger ancestor hit area. Document the filter so the next run does not re-discover it.
119
+
120
+ ## Per-Check Thresholds
121
+
122
+ The thresholds below align with the North Star Targets in the entry SKILL.md and the touch-target rule in [ui-ux.md](ui-ux.md). They do not introduce new bars; they make the bars measurable.
123
+
124
+ | Check | Threshold | Source |
125
+ |-------|-----------|--------|
126
+ | Capture viewports | `1440x900` desktop, `375x812` mobile | [responsive.md](responsive.md) breakpoints table |
127
+ | Touch target minimum | 44 by 44 CSS pixels | Rule 25 in SKILL.md, [ui-ux.md](ui-ux.md) hit targets table |
128
+ | Horizontal overflow tolerance | 0 px on mobile, 0 px on desktop | [responsive.md](responsive.md) |
129
+ | Duplicate arrow count | 0 across all visible labels | This file, defect table |
130
+ | Dropdown centering tolerance | 4 px from trigger center | This file, geometry sweep |
131
+ | Focus ring contrast | 3:1 against surface and resting state | [accessibility.md](accessibility.md) contrast targets |
132
+ | LCP image declared dimensions | Both `width` and `height` present | Rule 1 in SKILL.md, [performance.md](performance.md) |
133
+
134
+ A run is "clean" when every check returns zero issues at both capture viewports.
135
+
136
+ ## Acceptance
137
+
138
+ A polish pass is not complete until:
139
+
140
+ - The geometry sweep returns zero issues on every audited route at `1440x900` and `375x812`.
141
+ - Every defect found in the lookup table has been fixed at the right layer (component, page, or design token).
142
+ - Re-captured screenshots show the fix and no regression on neighbors.
143
+
144
+ Until then, the work is not done. Do not present an incomplete visual pass as complete.
145
+
146
+ ## See Also
147
+
148
+ - [audit-workflow.md](audit-workflow.md) for the full multi-page audit procedure
149
+ - [components.md](components.md) for component contracts and drift detection
150
+ - [accessibility.md](accessibility.md) for focus, contrast, and skip-link patterns
151
+ - [responsive.md](responsive.md) for capture viewports and breakpoints
152
+ - [performance.md](performance.md) for LCP, font, and image strategy