linear-grab-bridge 0.25.0 → 0.25.2
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/linear-grab-bridge.mjs +16 -3
- package/package.json +1 -1
- package/slop-scan.global.js +3 -3
package/linear-grab-bridge.mjs
CHANGED
|
@@ -37,7 +37,7 @@ const flag = (name, fallback) => {
|
|
|
37
37
|
const PORT = Number(flag('--port', '4577'));
|
|
38
38
|
const DIR = flag('--dir', process.cwd());
|
|
39
39
|
const CLAUDE_BIN = flag('--claude', 'claude');
|
|
40
|
-
const VERSION = '0.25.
|
|
40
|
+
const VERSION = '0.25.2';
|
|
41
41
|
|
|
42
42
|
// ---- audit subcommand dispatch ---------------------------------------------
|
|
43
43
|
// `npx linear-grab-bridge audit` is a headless design gate — it sweeps a
|
|
@@ -1366,7 +1366,15 @@ async function runAudit() {
|
|
|
1366
1366
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1367
1367
|
const evalRes = await cdp.send(
|
|
1368
1368
|
'Runtime.evaluate',
|
|
1369
|
-
{
|
|
1369
|
+
{
|
|
1370
|
+
// Attributed run resolves Component @ file:line via the page's
|
|
1371
|
+
// own react-grab (loaded by linear-grab's script tag); falls
|
|
1372
|
+
// back to the sync scan on older bundles.
|
|
1373
|
+
expression:
|
|
1374
|
+
'__SLOP_SCAN__.runAttributed ? __SLOP_SCAN__.runAttributed() : __SLOP_SCAN__.run()',
|
|
1375
|
+
awaitPromise: true,
|
|
1376
|
+
returnByValue: true,
|
|
1377
|
+
},
|
|
1370
1378
|
sessionId,
|
|
1371
1379
|
);
|
|
1372
1380
|
if (evalRes.exceptionDetails) {
|
|
@@ -1539,6 +1547,9 @@ function appendNdjson(file, findings) {
|
|
|
1539
1547
|
description: f.description,
|
|
1540
1548
|
selector: f.selector,
|
|
1541
1549
|
evidence: f.evidence,
|
|
1550
|
+
component: f.component ?? null,
|
|
1551
|
+
source: f.source ?? null,
|
|
1552
|
+
count: f.count ?? 1,
|
|
1542
1553
|
isNew: !!f.isNew,
|
|
1543
1554
|
}),
|
|
1544
1555
|
)
|
|
@@ -1600,8 +1611,10 @@ function writeReportAndNdjson(findings, { url, routes, themes, outPath, ndjsonPa
|
|
|
1600
1611
|
'',
|
|
1601
1612
|
);
|
|
1602
1613
|
for (const f of list) {
|
|
1614
|
+
const where = f.component || f.source ? ` — ${[f.component, f.source].filter(Boolean).join(' @ ')}` : '';
|
|
1615
|
+
const times = (f.count ?? 1) > 1 ? ` ×${f.count}` : '';
|
|
1603
1616
|
lines.push(
|
|
1604
|
-
`- \`${f.selector}
|
|
1617
|
+
`- \`${f.selector}\`${times} — ${f.evidence}${where} [${f.themes.join('/')}]${f.isNew ? ' **NEW**' : ''}`,
|
|
1605
1618
|
);
|
|
1606
1619
|
}
|
|
1607
1620
|
lines.push('');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linear-grab-bridge",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.2",
|
|
4
4
|
"description": "Local bridge for Linear Grab — delegate issues from the browser panel to headless Claude Code sessions running in your repo, with live status and an upload relay.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/slop-scan.global.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
(function(){function e(e){return e.split(`,`).map(e=>{let t=e.trim();return t.endsWith(`ms`)?parseFloat(t)||0:t.endsWith(`s`)?(parseFloat(t)||0)*1e3:0})}var t=new Set(`width.height.min-width.min-height.max-width.max-height.top.left.right.bottom.inset.margin.margin-top.margin-right.margin-bottom.margin-left.padding.padding-top.padding-right.padding-bottom.padding-left.font-size.line-height.letter-spacing.box-shadow.filter.background-position.grid-template-columns.grid-template-rows.flex-basis.gap`.split(`.`));function n(e){return e.includes(`all`)?[`all (includes layout/paint props)`]:e.filter(e=>t.has(e))}function r(e){let t=i(e);if(t===`ease-in`||t===`ease-in-out`)return t;let n=t.match(/cubic-bezier\(\s*([\d.]+)\s*,\s*(-?[\d.]+)/);if(n){let e=parseFloat(n[1]),r=parseFloat(n[2]);if(e>=.4&&r<e/2)return t}return null}function i(e){let t=0;for(let n=0;n<e.length;n++){let r=e[n];if(r===`(`)t++;else if(r===`)`)t--;else if(r===`,`&&t===0)return e.slice(0,n).trim()}return e.trim()}var a=`[data-slot*="content"], [role="menu"], [role="dialog"], [role="listbox"]`,o=new Set([`width`,`height`,`flex-basis`,`grid-template-columns`,`grid-template-rows`,`grid-template-areas`]);function s(e,t){if(e instanceof SVGElement)return!0;try{if(e.closest(`svg`))return!0}catch{}try{if(e.closest(a))return!0}catch{}try{if(e.closest(`[data-panel]`))return!t||t.length===0||t.every(e=>o.has(e))}catch{}return!1}function c(e){let t=e.id?`#${e.id}`:``,n=typeof e.className==`string`&&e.className?`.${e.className.split(/\s+/).filter(Boolean).slice(0,2).join(`.`)}`:``;return`${e.tagName.toLowerCase()}${t}${n}`}function l(e,t,n){return{ruleId:e.id,part:e.part,severity:e.severity,description:e.description,selector:c(t),evidence:n,el:typeof WeakRef<`u`?new WeakRef(t):void 0}}function u(e){return e.getBoundingClientRect()}function d(e){return e.split(`/`)[0].trim().split(/\s+/).filter(Boolean).map(e=>e.endsWith(`px`)?parseFloat(e):NaN)}function f(e){let t=e.match(/rgba?\(([^)]+)\)/);return t?t[1].split(`,`).map(e=>e.trim()).join(`,`):null}function p(e,t){let n=t,r=n.maskImage||n.webkitMaskImage||``;if(r&&r!==`none`)return r;let i=(e.getAttribute(`style`)??``).match(/(?:^|;|\s)(?:-webkit-)?mask-image\s*:\s*([^;]+)/i);return i&&i[1].trim()&&i[1].trim()!==`none`?i[1].trim():``}var m=new Set([0,8,12,16]),h={light:f(`rgb(240, 240, 241)`),dark:f(`rgb(35, 35, 37)`)},g=new Set([`#50e3c2`,`#ee0000`,`#f5a623`]),_=`button, a, [role="button"], [role="tab"], [role="menuitem"], [role="option"], input, textarea, select`;function v(e){let t=[],n=e=>{for(let r of Array.from(e)){let e=r;if(e.cssRules&&e.cssRules.length){n(e.cssRules);continue}let i=r.selectorText;i&&i.includes(`:hover`)&&t.push(i.replace(/:hover/g,``))}};for(let t of Array.from(e.styleSheets))try{n(t.cssRules)}catch{}return t}function y(e,t){for(let n of t)if(n.trim())try{if(e.matches(n))return!0}catch{}return!1}function b(e){return!e.matches(`button, [role="button"]`)||e.querySelectorAll(`svg`).length===0?!1:(e.textContent??``).trim().length===0}function x(e){let t=e.parentElement;for(;t;){try{let e=S(t).overflowY;if(e===`auto`||e===`scroll`)return t}catch{}t=t.parentElement}return null}var S=e=>getComputedStyle(e),C={id:`radius-vocabulary`,part:`§40`,severity:`error`,description:`§40: radius vocabulary is {8, 12, 16, full} — never rounded-md/[4px]/[6px]/[10px]. Off-vocabulary radius.`,check(e){let t=[];for(let n of e.els){let r=e.css(n).borderRadius;if(!r||r===`0px`)continue;let i=d(r);if(i.some(e=>Number.isNaN(e)))continue;let a=Math.max(...i);if(a===0)continue;let o=u(n),s=Math.min(o.width||0,o.height||0);s>0&&a>=s/2-.5||i.every(e=>m.has(e))||t.push(l(this,n,`border-radius: ${r}`))}return t}},w={id:`row-radius-token`,part:`§40`,severity:`error`,description:`§40: list rows consume --row-radius (8px) or 0 — no bespoke per-surface row radius.`,check(e){let t=[];for(let n of e.els){let r=u(n).height;if(r<26||r>50||!x(n))continue;let i=n.parentElement;if(!i||Array.from(i.children).filter(e=>{let t=u(e);return Math.abs(t.height-r)<2}).length<3)continue;let a=d(e.css(n).borderRadius);if(a.some(e=>Number.isNaN(e)))continue;let o=Math.max(0,...a);o===0||o===8||t.push(l(this,n,`row radius ${e.css(n).borderRadius} (want 0 or var(--row-radius)=8px)`))}return t}},T={id:`active-fill-token`,part:`§11`,severity:`error`,description:`§11: active/selected fill must be var(--selected) (theme-safe) — never bg-secondary (#F0F0F1 / #232325).`,check(e){let t=[],n=null;try{let t=e.root.documentElement??document.documentElement;t&&(n=f(e.css(t).getPropertyValue(`--selected`).trim()))}catch{}let r=[h.light,h.dark].filter(Boolean);if(n&&r.includes(n)){let t=e.root.documentElement??document.documentElement;return[l({id:this.id,part:this.part,severity:`warn`,description:`§11: active-fill-token unverifiable — --selected resolves EQUAL to bg-secondary this theme; cannot distinguish the token from the anti-pattern.`},t??e.els[0],`--selected == bg-secondary (${n})`)]}for(let n of e.els){if(!(n.matches(`[role="tab"], [role="button"], [aria-selected], [role="option"]`)||n.closest(`[role="tablist"], [role="menu"], [data-rail], nav`)))continue;let i=f(e.css(n).backgroundColor);i&&r.includes(i)&&t.push(l(this,n,`background bg-secondary (${e.css(n).backgroundColor}) — use var(--selected)`))}return t}},E={id:`inline-style-state`,part:`§40`,severity:`error`,description:`§40: the shared row-action class owns ALL visual states; inline styles carry LAYOUT ONLY — an inline background/color kills the class state.`,check(e){let t=[];for(let n of e.els){let e=typeof n.className==`string`?n.className:``;if(!/pb-row-action|row-action/.test(e))continue;let r=n.getAttribute(`style`)??``;/(^|;|\s)(background|color)\s*:/.test(r)&&t.push(l(this,n,`inline style="${r}" on a row-action element`))}return t}},D={id:`icon-button-no-hover`,part:`§40`,severity:`warn`,description:`§40/Part 10: every interactive control changes color on hover — icon-only button with no :hover rule and no .pb-row-action.`,check(e){let t=[],n=v(e.root.styleSheets?e.root:document);for(let r of e.els){if(!b(r))continue;let i=typeof r.className==`string`?r.className:``;/pb-row-action|row-action/.test(i)||r.hasAttribute(`data-framer-appear-id`)||Array.from(r.attributes).some(e=>e.name.startsWith(`data-framer`))||e.css(r).willChange.includes(`transform`)||y(r,n)||t.push(l(this,r,`icon-only ${r.tagName.toLowerCase()} with no hover rule / .pb-row-action`))}return t}},O={id:`icon-button-no-tooltip`,part:`§7`,severity:`warn`,description:`§7: every icon-only button gets the shared Tooltip — needs aria-describedby / data-slot="tooltip-trigger" (menu-row title exempt).`,check(e){let t=[];for(let n of e.els)b(n)&&(n.hasAttribute(`aria-describedby`)||n.getAttribute(`data-slot`)===`tooltip-trigger`||n.closest(`[data-slot="tooltip-trigger"]`)||n.closest(`[role="menuitem"]`)&&n.hasAttribute(`title`)||t.push(l(this,n,`icon-only ${n.tagName.toLowerCase()} with no tooltip trigger / aria-describedby`)));return t}},k={id:`unvirtualized-scroller`,part:`§0`,severity:`error`,description:`§0: every dynamic-length scrolling list virtualizes — a tall scroller with 60+ direct children is unvirtualized.`,check(e){let t=[];for(let n of e.els){let r=e.css(n);if(![r.overflowY,r.overflowX,r.overflow].some(e=>e===`auto`||e===`scroll`)||n.scrollHeight<=2*n.clientHeight)continue;let i=n.children.length;i<=60||t.push(l(this,n,`${i} direct children, scrollHeight ${n.scrollHeight} > 2×clientHeight ${n.clientHeight}`))}return t}},A={id:`scroll-no-fade`,part:`§1`,severity:`warn`,description:`§1: a horizontal scroller needs a truthful scroll fade — no scroll-fade-x/scroll-fade-x-js class and no mask-image.`,check(e){let t=[];for(let n of e.els){if(n.scrollWidth<=n.clientWidth+8)continue;let r=typeof n.className==`string`?n.className:``;/scroll-fade-x(-js)?/.test(r)||p(n,e.css(n))||t.push(l(this,n,`horizontal scroller (scrollWidth ${n.scrollWidth} > clientWidth ${n.clientWidth}) with no fade`))}return t}},j={id:`phantom-fade`,part:`§1`,severity:`warn`,description:`§1: a fade must NEVER paint when nothing is hidden — mask-image present but scrollWidth ≤ clientWidth (the fade lies).`,check(e){let t=[];for(let n of e.els)p(n,e.css(n))&&(n.scrollWidth>n.clientWidth||t.push(l(this,n,`mask-image fade but scrollWidth ${n.scrollWidth} ≤ clientWidth ${n.clientWidth}`)));return t}},M={id:`scrollbar-flush`,part:`Part 5`,severity:`warn`,description:`Part 5: scrollables carry asymmetric right padding so content clears the scrollbar — padding-right < 8px while overflowing vertically with content reaching the right edge.`,check(e){let t=[];for(let n of e.els){let r=e.css(n);if(![r.overflowY,r.overflow].some(e=>e===`auto`||e===`scroll`)||n.scrollHeight<=n.clientHeight)continue;let i=parseFloat(r.paddingRight)||0;if(i>=8)continue;let a=u(n);Array.from(n.children).some(e=>{let t=u(e);return a.right-t.right<=4})&&t.push(l(this,n,`padding-right ${i}px (<8px) with content flush to the scrollbar`))}return t}},N=/^("?(Geist|Geist Mono)"?|ui-monospace|ui-sans-serif|-apple-system|system-ui)/i,P={id:`font-family`,part:`Part 5`,severity:`warn`,description:`Part 5: Geist / Geist Mono ONLY — computed font-family does not start with the Geist / ui-monospace fallback chain.`,check(e){let t=[];for(let n of e.els){if(!q(n))continue;let r=e.css(n).fontFamily;r&&(N.test(r.trim())||t.push(l(this,n,`font-family: ${r}`)))}return t}},F={id:`uppercase-label`,part:`§25`,severity:`error`,description:`§25: group/section labels are sentence-case — never uppercase (text-transform: uppercase on small <13px text).`,check(e){let t=[];for(let n of e.els){if(!q(n))continue;let r=e.css(n);r.textTransform===`uppercase`&&((parseFloat(r.fontSize)||0)>=13||t.push(l(this,n,`text-transform: uppercase at ${r.fontSize}`)))}return t}},I={id:`hardcoded-status-hex`,part:`Part 5`,severity:`warn`,description:`Part 5: raw hex only for the status trio (#50E3C2/#EE0000/#F5A623) — a saturated non-token color on small UI text.`,check(e){let t=[];for(let n of e.els){if(!q(n))continue;let r=(n.getAttribute(`style`)??``).match(/#[0-9a-fA-F]{6}/g)??[],i=e.css(n);if((parseFloat(i.fontSize)||0)>=16)continue;for(let e of r)g.has(e.toLowerCase())||t.push(l(this,n,`hardcoded ${e} (not the brand status trio)`));let a=f(i.color);if(a&&r.length===0){let[e,r,o]=a.split(`,`).map(e=>parseFloat(e));Number.isFinite(e)&&J(e,r,o)&&t.push(l(this,n,`saturated non-token color ${i.color} on small UI text`))}}return t}},L={id:`editor-shadow`,part:`Part 5`,severity:`error`,description:`Part 5: editor surfaces are FLAT — box-shadow that is not a 0.5px hairline ring / approved popover/card/input token shadow.`,check(e){let t=[];if(!(e.root.querySelector?.(`[data-editor-surface]`)||typeof location<`u`&&/\/editor\//.test(location.pathname)))return t;for(let n of e.els){if(!n.closest(`[data-editor-surface]`)&&!/\/editor\//.test(typeof location<`u`?location.pathname:``))continue;let r=e.css(n).boxShadow;!r||r===`none`||Y(r)||t.push(l(this,n,`box-shadow: ${r}`))}return t}},R={id:`focusring-clip`,part:`§24`,severity:`warn`,description:`§24/Part 5: focus rings never clip — a focusable element whose nearest overflow-hidden ancestor sits <4px from its border box.`,check(e){let t=[];for(let n of e.els){if(!n.matches(_)&&n.getAttribute(`tabindex`)==null)continue;let r=n.parentElement;for(;r;){let t=e.css(r);if([t.overflow,t.overflowX,t.overflowY].some(e=>e===`hidden`))break;r=r.parentElement}if(!r)continue;let i=u(n),a=u(r);if(i.width===0&&i.height===0)continue;let o=Math.min(i.left-a.left,a.right-i.right,i.top-a.top,a.bottom-i.bottom);o>=4||t.push(l(this,n,`overflow-hidden ancestor ${o.toFixed(1)}px from border box — focus ring will clip`))}return t}},z={id:`fixed-width-in-pane`,part:`§24`,severity:`warn`,description:`§24: no fixed field widths in resizable panes — w-[Npx] (N ≥ 200) inside a [data-panel] / flex-basis sibling clips at the pane edge.`,check(e){let t=[];for(let n of e.els){if(!n.closest(`[data-panel]`))continue;let e=typeof n.className==`string`?n.className:``,r=n.getAttribute(`style`)??``,i=e.match(/w-\[(\d+)px\]/)??r.match(/(?:^|;|\s)width\s*:\s*(\d+)px/);if(!i)continue;let a=parseFloat(i[1]);a<200||t.push(l(this,n,`fixed width ${a}px inside a resizable pane — use w-full max-w-[${a}px]`))}return t}},B={id:`menu-icons-all-or-none`,part:`§21`,severity:`error`,description:`§21: within one menu ALL items carry a leading icon or NONE do — a mixed set reads broken.`,check(e){let t=[],n=Array.from(e.root.querySelectorAll(`[role="menu"]`));for(let e of n){let n=Array.from(e.querySelectorAll(`[role="menuitem"]`));if(n.length<2)continue;let r=n.filter(e=>e.querySelector(`svg`)).length;r===0||r===n.length||t.push(l(this,e,`${r}/${n.length} menu items have a leading icon`))}return t}},V={id:`menu-icon-mixed-weight`,part:`§21`,severity:`warn`,description:`§21: menu icons are ONE stroke family — some filled (fill=currentColor) and some stroke-based, or rendered sizes differing >2px, mixes two design systems.`,check(e){let t=[],n=Array.from(e.root.querySelectorAll(`[role="menu"]`));for(let e of n){let n=Array.from(e.querySelectorAll(`[role="menuitem"] svg`));if(n.length<2)continue;let r=0,i=0,a=[];for(let e of n){Array.from(e.querySelectorAll(`path, circle, rect, polygon`)).some(e=>{let t=(e.getAttribute(`fill`)??``).toLowerCase();return t===`currentcolor`||t&&t!==`none`})?r++:i++;let t=u(e);a.push(Math.max(t.width,t.height))}let o=Math.max(...a),s=Math.min(...a),c=r>0&&i>0,d=o>0&&o-s>2;!c&&!d||t.push(l(this,e,c?`${r} filled + ${i} stroke-based icons in one menu`:`icon sizes differ by ${(o-s).toFixed(1)}px`))}return t}},H={id:`chrome-selectable`,part:`Part 5`,severity:`warn`,description:`Part 5: chrome (rails/pills/tabs) is select-none — a rail/pill/tab without user-select:none.`,check(e){let t=[];for(let n of e.els){if(!(n.matches(`[role="tab"]`)||n.closest(`[role="tablist"], [data-rail], nav`)||/\bpill\b/.test(typeof n.className==`string`?n.className:``)))continue;let r=e.css(n).userSelect||e.css(n).webkitUserSelect;r!==`none`&&t.push(l(this,n,`chrome element without user-select:none (user-select: ${r||`auto`})`))}return t}},U={id:`cursor-mismatch`,part:`Part 5`,severity:`warn`,description:`Part 5: cursor semantics — buttons get pointer, static text gets default. A button with cursor:text, or static text with cursor:pointer and no interactive ancestor.`,check(e){let t=[];for(let n of e.els){let r=e.css(n).cursor;if(n.matches(`button, [role="button"]`)&&r===`text`){t.push(l(this,n,`interactive control with cursor:text`));continue}if(r===`pointer`&&n.matches(`p, h1, h2, h3, h4, h5, h6, span, label`)){if(n.closest(`button, a, [role="button"], [onclick]`)||n.matches(`button, a, [role="button"]`))continue;t.push(l(this,n,`static ${n.tagName.toLowerCase()} with cursor:pointer and no interactive ancestor`))}}return t}};function W(e){let t=e.transitionProperty.split(`,`).map(e=>e.trim()).filter(Boolean);return t.length===1&&t[0]===`none`?[]:t}var G=[C,w,T,E,D,O,k,A,j,M,P,F,I,L,R,z,B,V,H,U,{id:`transition-all`,part:`§42`,severity:`error`,description:`§42: never transition-all — scope to what changes (transition-colors / transition-[transform,...]).`,check(e){let t=[];for(let n of e.els){if(!n.matches(_))continue;let r=W(e.css(n));r.includes(`all`)&&(s(n,r)||t.push(l(this,n,`transition-property: all`)))}return t}},{id:`transition-paint-prop`,part:`§42`,severity:`error`,description:`§42: never transition layout/paint props (box-shadow, width, height, top/left, margin, padding) — they reflow/repaint every frame; focus rings SNAP, shadows animate a pseudo-element opacity.`,check(e){let t=[];for(let r of e.els){let i=W(e.css(r)),a=n(i).filter(e=>e!==`all (includes layout/paint props)`);a.length!==0&&(s(r,i)||t.push(l(this,r,`transitions layout/paint props: ${a.join(`, `)}`)))}return t}},{id:`transition-too-slow`,part:`§42`,severity:`error`,description:`§42: interaction feedback is ≤100ms (duration-75 for small controls) — the 100ms theme default is fine; an explicit duration-150+ crept in.`,check(t){let n=[];for(let r of t.els){if(!r.matches(_))continue;let i=t.css(r),a=W(i);if(a.length===0||s(r,a))continue;let o=e(i.transitionDuration),c=Math.max(0,...o);c<=100||n.push(l(this,r,`transition-duration ${c}ms (>100ms) on [${a.join(`, `)}]`))}return n}},{id:`ease-in-feedback`,part:`§42`,severity:`warn`,description:`§42: house curve is cubic-bezier(.2,0,.1,1) ease-out — never ease-in / ease-in-out (or an ease-in-shaped bezier) on feedback.`,check(e){let t=[];for(let n of e.els){if(!n.matches(_))continue;let i=e.css(n),a=W(i);if(a.length===0||s(n,a))continue;let o=r(i.transitionTimingFunction);o&&t.push(l(this,n,`transition-timing-function: ${o}`))}return t}},{id:`press-instant-rule-missing`,part:`§42`,severity:`error`,description:"§42: the global press-instant rule (globals.css) must exist — `button:active{transition-duration:0s}` (or transition:none). Absent = press feedback animates instead of snapping.",check(e){let t=e.root.styleSheets?e.root:document,n=!1,r=e=>{for(let t of Array.from(e)){if(n)return;let e=t;if(e.cssRules&&e.cssRules.length){r(e.cssRules);continue}let i=t,a=i.selectorText;if(a&&/:active\b/.test(a)&&/(^|[\s,])(button|a|\[role=("|')?(button|tab|menuitem|option)("|')?\])/i.test(a))try{let e=i.style.transitionDuration,t=i.style.transition;if(e===`0s`||/(^|\s)none(\s|$)/.test(t)||/\b0s\b/.test(t)){n=!0;return}}catch{}}};try{for(let e of Array.from(t.styleSheets)){if(n)break;try{r(e.cssRules)}catch{}}}catch{}if(n)return[];let i=e.root.documentElement??e.els[0]??document.documentElement;return[l(this,i,"no `button:active { transition-duration: 0s }` rule on this page")]}}],K=[];function q(e){for(let t of Array.from(e.childNodes))if(t.nodeType===3&&(t.textContent??``).trim().length>0)return!0;return!1}function J(e,t,n){let r=Math.max(e,t,n);return r<40?!1:r-Math.min(e,t,n)>60}function Y(e){return!!(/\b0px\s+0px\s+0px\s+0?\.?5px\b/.test(e)||/\b0px?\s+0px?\s+0px?\s+0?\.?5px\b/.test(e))}function X(e=typeof document<`u`?document:{}){let t=new Map,n=e=>{let n=t.get(e);return n||(n=getComputedStyle(e),t.set(e,n)),n};S=n;let r=Array.from(e.querySelectorAll(`*`)),i=[],a=e.documentElement??null,o=e.body??null;for(let e of r){if(e===a||e===o||e.closest(`#linear-grab-root, #claude-agent-glow-border, [id^="react-scan"]`))continue;let t=u(e);t.width===0&&t.height===0&&e.children.length===0||i.push(e)}let s={root:e,els:i,css:n},c=[];for(let e of[...G,...K])try{c.push(...e.check(s))}catch{}return S=e=>getComputedStyle(e),c}function Z(e){let t=new Map;for(let n of e){let e=t.get(n.ruleId);e?e.push(n):t.set(n.ruleId,[n])}let n=Array.from(t.entries()).sort((e,t)=>{let n=e[1][0].severity===`error`?0:1,r=t[1][0].severity===`error`?0:1;return n===r?t[1].length===e[1].length?e[0].localeCompare(t[0]):t[1].length-e[1].length:n-r}),r=e.length,i=e.filter(e=>e.severity===`error`).length,a=new Set(e.map(e=>e.page).filter(Boolean)).size>1,o=[`# Slop scan — ${i} error${i===1?``:`s`}, ${r-i} warn (${r} total)`,``];for(let[e,t]of n){let n=t[0];o.push(`## ${e} — ${n.severity} ×${t.length} [${n.part}]`),o.push(n.description);for(let e of t)o.push(`- ${a&&e.page?`\`${e.page}\` `:``}\`${e.selector}\` — ${e.evidence}`);o.push(``)}return o.join(`
|
|
2
|
-
`)}function Q(e){return[`Below is a design-system SLOP report from my running app — each finding is a`,"live-DOM violation of the Linear/Protocolbase primitives contract (`Linear","-primitives.md`). Every rule cites its §id. Goal: zero errors. Rules:",``,`1. Fix at the TOKEN / VARIANT level, not the call sites. Most of these come`,` from shared row/button/menu variants — change --row-radius, --selected,`,` .pb-row-action, the Button/Input variant, or the globals.css §42 rules —`,` not one component at a time.`,`2. Cite the primitives §id in your fix (each finding carries it). Radius →`,` §40 vocabulary {8,12,16,full}; active fill → §11 var(--selected); menus →`,` §21 all-or-none icons + one stroke family; uppercase labels → §25;`,` editor flatness → Part 5; scrollers → §0–1 virtualize + truthful fade.`,`3. §42 group (transition-all / paint-prop / too-slow / ease-in / press-`,` instant): scope transitions to what changes, drop box-shadow/width/height`,` from transition lists, keep feedback ≤100ms (duration-75 small controls),`,` use ease-out (cubic-bezier(.2,0,.1,1)), and keep the global`," `button:active{transition-duration:0s}` press-instant rule.",`4. Do NOT touch EXEMPT intentional motion — popover/menu/dialog entry-exit,`,` svg icon micro-motion, and panel width/height transitions are §42-blessed.`,` The scan already excludes them; do not "fix" them.`,`5. 'unverifiable' warnings mean the token collapsed to the anti-pattern this`,` theme — verify the intent in BOTH themes, do not just silence it
|
|
3
|
-
`)}function $(){return X(document).map(({el:e,...t})=>t)}window.__SLOP_SCAN__={version:`0.25.
|
|
1
|
+
(function(){function e(e){return e.split(`,`).map(e=>{let t=e.trim();return t.endsWith(`ms`)?parseFloat(t)||0:t.endsWith(`s`)?(parseFloat(t)||0)*1e3:0})}var t=new Set(`width.height.min-width.min-height.max-width.max-height.top.left.right.bottom.inset.margin.margin-top.margin-right.margin-bottom.margin-left.padding.padding-top.padding-right.padding-bottom.padding-left.font-size.line-height.letter-spacing.box-shadow.filter.background-position.grid-template-columns.grid-template-rows.flex-basis.gap`.split(`.`));function n(e){return e.includes(`all`)?[`all (includes layout/paint props)`]:e.filter(e=>t.has(e))}function r(e){let t=i(e);if(t===`ease-in`||t===`ease-in-out`)return t;let n=t.match(/cubic-bezier\(\s*([\d.]+)\s*,\s*(-?[\d.]+)/);if(n){let e=parseFloat(n[1]),r=parseFloat(n[2]);if(e>=.4&&r<e/2)return t}return null}function i(e){let t=0;for(let n=0;n<e.length;n++){let r=e[n];if(r===`(`)t++;else if(r===`)`)t--;else if(r===`,`&&t===0)return e.slice(0,n).trim()}return e.trim()}var a=`[data-slot*="content"], [role="menu"], [role="dialog"], [role="listbox"]`,o=new Set([`width`,`height`,`flex-basis`,`grid-template-columns`,`grid-template-rows`,`grid-template-areas`]);function s(e,t){if(e instanceof SVGElement)return!0;try{if(e.closest(`svg`))return!0}catch{}try{if(e.closest(a))return!0}catch{}try{if(e.closest(`[data-panel]`))return!t||t.length===0||t.every(e=>o.has(e))}catch{}return!1}function c(e){let t=e.id?`#${e.id}`:``,n=typeof e.className==`string`&&e.className?`.${e.className.split(/\s+/).filter(Boolean).slice(0,2).join(`.`)}`:``;return`${e.tagName.toLowerCase()}${t}${n}`}function l(e,t,n){return{ruleId:e.id,part:e.part,severity:e.severity,description:e.description,selector:c(t),evidence:n,el:typeof WeakRef<`u`?new WeakRef(t):void 0}}function u(e){return e.getBoundingClientRect()}function d(e){return e.split(`/`)[0].trim().split(/\s+/).filter(Boolean).map(e=>e.endsWith(`px`)?parseFloat(e):NaN)}function f(e){let t=e.match(/rgba?\(([^)]+)\)/);return t?t[1].split(`,`).map(e=>e.trim()).join(`,`):null}function p(e,t){let n=t,r=n.maskImage||n.webkitMaskImage||``;if(r&&r!==`none`)return r;let i=(e.getAttribute(`style`)??``).match(/(?:^|;|\s)(?:-webkit-)?mask-image\s*:\s*([^;]+)/i);return i&&i[1].trim()&&i[1].trim()!==`none`?i[1].trim():``}var m=new Set([0,8,12,16]),h={light:f(`rgb(240, 240, 241)`),dark:f(`rgb(35, 35, 37)`)},g=new Set([`#50e3c2`,`#ee0000`,`#f5a623`]),_=`button, a, [role="button"], [role="tab"], [role="menuitem"], [role="option"], input, textarea, select`;function v(e){let t=[],n=e=>{for(let r of Array.from(e)){let e=r;if(e.cssRules&&e.cssRules.length){n(e.cssRules);continue}let i=r.selectorText;i&&i.includes(`:hover`)&&t.push(i.replace(/:hover/g,``))}};for(let t of Array.from(e.styleSheets))try{n(t.cssRules)}catch{}return t}function y(e,t){for(let n of t)if(n.trim())try{if(e.matches(n))return!0}catch{}return!1}function b(e){return!e.matches(`button, [role="button"]`)||e.querySelectorAll(`svg`).length===0?!1:(e.textContent??``).trim().length===0}function x(e){let t=e.parentElement;for(;t;){try{let e=S(t).overflowY;if(e===`auto`||e===`scroll`)return t}catch{}t=t.parentElement}return null}var S=e=>getComputedStyle(e),C={id:`radius-vocabulary`,part:`§40`,severity:`error`,description:`§40: radius vocabulary is {8, 12, 16, full} — never rounded-md/[4px]/[6px]/[10px]. Off-vocabulary radius.`,check(e){let t=[];for(let n of e.els){let r=e.css(n).borderRadius;if(!r||r===`0px`)continue;let i=d(r);if(i.some(e=>Number.isNaN(e)))continue;let a=Math.max(...i);if(a===0)continue;let o=u(n),s=Math.min(o.width||0,o.height||0);s>0&&a>=s/2-.5||i.every(e=>m.has(e))||t.push(l(this,n,`border-radius: ${r}`))}return t}},w={id:`row-radius-token`,part:`§40`,severity:`error`,description:`§40: list rows consume --row-radius (8px) or 0 — no bespoke per-surface row radius.`,check(e){let t=[];for(let n of e.els){let r=u(n).height;if(r<26||r>50||!x(n))continue;let i=n.parentElement;if(!i||Array.from(i.children).filter(e=>{let t=u(e);return Math.abs(t.height-r)<2}).length<3)continue;let a=d(e.css(n).borderRadius);if(a.some(e=>Number.isNaN(e)))continue;let o=Math.max(0,...a);o===0||o===8||t.push(l(this,n,`row radius ${e.css(n).borderRadius} (want 0 or var(--row-radius)=8px)`))}return t}},T={id:`active-fill-token`,part:`§11`,severity:`error`,description:`§11: active/selected fill must be var(--selected) (theme-safe) — never bg-secondary (#F0F0F1 / #232325).`,check(e){let t=[],n=null;try{let t=e.root.documentElement??document.documentElement;t&&(n=f(e.css(t).getPropertyValue(`--selected`).trim()))}catch{}let r=[h.light,h.dark].filter(Boolean);if(n&&r.includes(n)){let t=e.root.documentElement??document.documentElement;return[l({id:this.id,part:this.part,severity:`warn`,description:`§11: active-fill-token unverifiable — --selected resolves EQUAL to bg-secondary this theme; cannot distinguish the token from the anti-pattern.`},t??e.els[0],`--selected == bg-secondary (${n})`)]}for(let n of e.els){if(!(n.matches(`[role="tab"], [role="button"], [aria-selected], [role="option"]`)||n.closest(`[role="tablist"], [role="menu"], [data-rail], nav`)))continue;let i=f(e.css(n).backgroundColor);i&&r.includes(i)&&t.push(l(this,n,`background bg-secondary (${e.css(n).backgroundColor}) — use var(--selected)`))}return t}},E={id:`inline-style-state`,part:`§40`,severity:`error`,description:`§40: the shared row-action class owns ALL visual states; inline styles carry LAYOUT ONLY — an inline background/color kills the class state.`,check(e){let t=[];for(let n of e.els){let e=typeof n.className==`string`?n.className:``;if(!/pb-row-action|row-action/.test(e))continue;let r=n.getAttribute(`style`)??``;/(^|;|\s)(background|color)\s*:/.test(r)&&t.push(l(this,n,`inline style="${r}" on a row-action element`))}return t}},D={id:`icon-button-no-hover`,part:`§40`,severity:`warn`,description:`§40/Part 10: every interactive control changes color on hover — icon-only button with no :hover rule and no .pb-row-action.`,check(e){let t=[],n=v(e.root.styleSheets?e.root:document);for(let r of e.els){if(!b(r))continue;let i=typeof r.className==`string`?r.className:``;/pb-row-action|row-action/.test(i)||r.hasAttribute(`data-framer-appear-id`)||Array.from(r.attributes).some(e=>e.name.startsWith(`data-framer`))||e.css(r).willChange.includes(`transform`)||y(r,n)||t.push(l(this,r,`icon-only ${r.tagName.toLowerCase()} with no hover rule / .pb-row-action`))}return t}},O={id:`icon-button-no-tooltip`,part:`§7`,severity:`warn`,description:`§7: every icon-only button gets the shared Tooltip — needs aria-describedby / data-slot="tooltip-trigger" (menu-row title exempt).`,check(e){let t=[];for(let n of e.els)b(n)&&(n.hasAttribute(`aria-describedby`)||n.getAttribute(`data-slot`)===`tooltip-trigger`||n.closest(`[data-slot="tooltip-trigger"]`)||n.closest(`[role="menuitem"]`)&&n.hasAttribute(`title`)||t.push(l(this,n,`icon-only ${n.tagName.toLowerCase()} with no tooltip trigger / aria-describedby`)));return t}},k={id:`unvirtualized-scroller`,part:`§0`,severity:`error`,description:`§0: every dynamic-length scrolling list virtualizes — a tall scroller with 60+ direct children is unvirtualized.`,check(e){let t=[];for(let n of e.els){let r=e.css(n);if(![r.overflowY,r.overflowX,r.overflow].some(e=>e===`auto`||e===`scroll`)||n.scrollHeight<=2*n.clientHeight)continue;let i=n.children.length;i<=60||t.push(l(this,n,`${i} direct children, scrollHeight ${n.scrollHeight} > 2×clientHeight ${n.clientHeight}`))}return t}},A={id:`scroll-no-fade`,part:`§1`,severity:`warn`,description:`§1: a horizontal scroller needs a truthful scroll fade — no scroll-fade-x/scroll-fade-x-js class and no mask-image.`,check(e){let t=[];for(let n of e.els){let r=e.css(n).overflowX;if(r!==`auto`&&r!==`scroll`||n.clientWidth<24||n.scrollWidth<=n.clientWidth+8)continue;let i=typeof n.className==`string`?n.className:``;/scroll-fade-x(-js)?/.test(i)||p(n,e.css(n))||t.push(l(this,n,`horizontal scroller (scrollWidth ${n.scrollWidth} > clientWidth ${n.clientWidth}) with no fade`))}return t}},j={id:`phantom-fade`,part:`§1`,severity:`warn`,description:`§1: a fade must NEVER paint when nothing is hidden — mask-image present but scrollWidth ≤ clientWidth (the fade lies).`,check(e){let t=[];for(let n of e.els)p(n,e.css(n))&&(n.scrollWidth>n.clientWidth||t.push(l(this,n,`mask-image fade but scrollWidth ${n.scrollWidth} ≤ clientWidth ${n.clientWidth}`)));return t}},M={id:`scrollbar-flush`,part:`Part 5`,severity:`warn`,description:`Part 5: scrollables carry asymmetric right padding so content clears the scrollbar — padding-right < 8px while overflowing vertically with content reaching the right edge.`,check(e){let t=[];for(let n of e.els){let r=e.css(n);if(![r.overflowY,r.overflow].some(e=>e===`auto`||e===`scroll`)||n.scrollHeight<=n.clientHeight)continue;let i=parseFloat(r.paddingRight)||0;if(i>=8)continue;let a=u(n);Array.from(n.children).some(e=>{let t=u(e);return a.right-t.right<=4})&&t.push(l(this,n,`padding-right ${i}px (<8px) with content flush to the scrollbar`))}return t}},N=/^("?(Geist|Geist Mono)"?|ui-monospace|ui-sans-serif|-apple-system|system-ui)/i,P={id:`font-family`,part:`Part 5`,severity:`warn`,description:`Part 5: Geist / Geist Mono ONLY — computed font-family does not start with the Geist / ui-monospace fallback chain.`,check(e){let t=[];for(let n of e.els){if(!q(n))continue;let r=e.css(n).fontFamily;r&&(N.test(r.trim())||t.push(l(this,n,`font-family: ${r}`)))}return t}},F={id:`uppercase-label`,part:`§25`,severity:`error`,description:`§25: group/section labels are sentence-case — never uppercase (text-transform: uppercase on small <13px text).`,check(e){let t=[];for(let n of e.els){if(!q(n))continue;let r=e.css(n);r.textTransform===`uppercase`&&((parseFloat(r.fontSize)||0)>=13||t.push(l(this,n,`text-transform: uppercase at ${r.fontSize}`)))}return t}},I={id:`hardcoded-status-hex`,part:`Part 5`,severity:`warn`,description:`Part 5: raw hex only for the status trio (#50E3C2/#EE0000/#F5A623) — a saturated non-token color on small UI text.`,check(e){let t=[],n=new Set;for(let e of g){let t=f(`rgb(${parseInt(e.slice(1,3),16)}, ${parseInt(e.slice(3,5),16)}, ${parseInt(e.slice(5,7),16)})`);t&&n.add(t)}try{let t=e.root,r=t.documentElement;if(r&&t.body){let i=e.css(r),a=t.createElement(`span`);a.style.display=`none`,t.body.appendChild(a);for(let e=0;e<i.length;e++){let t=i[e];if(!t.startsWith(`--`))continue;let r=i.getPropertyValue(t).trim();if(!r||r.length>40||(a.style.color=``,a.style.color=r,!a.style.color))continue;let o=f(getComputedStyle(a).color);o&&n.add(o)}a.remove()}}catch{}for(let r of e.els){if(!q(r))continue;let i=(r.getAttribute(`style`)??``).match(/#[0-9a-fA-F]{6}/g)??[],a=e.css(r);if((parseFloat(a.fontSize)||0)>=16)continue;for(let e of i)g.has(e.toLowerCase())||t.push(l(this,r,`hardcoded ${e} (not the brand status trio)`));let o=f(a.color);if(o&&i.length===0&&!n.has(o)){let[e,n,i]=o.split(`,`).map(e=>parseFloat(e));Number.isFinite(e)&&J(e,n,i)&&t.push(l(this,r,`saturated non-token color ${a.color} on small UI text`))}}return t}},L={id:`editor-shadow`,part:`Part 5`,severity:`error`,description:`Part 5: editor surfaces are FLAT — box-shadow that is not a 0.5px hairline ring / approved popover/card/input token shadow.`,check(e){let t=[];if(!(e.root.querySelector?.(`[data-editor-surface]`)||typeof location<`u`&&/\/editor\//.test(location.pathname)))return t;for(let n of e.els){if(!n.closest(`[data-editor-surface]`)&&!/\/editor\//.test(typeof location<`u`?location.pathname:``))continue;let r=e.css(n).boxShadow;!r||r===`none`||Y(r)||t.push(l(this,n,`box-shadow: ${r}`))}return t}},R={id:`focusring-clip`,part:`§24`,severity:`warn`,description:`§24/Part 5: focus rings never clip — a focusable element whose nearest overflow-hidden ancestor sits <4px from its border box.`,check(e){let t=[];for(let n of e.els){if(!n.matches(_)&&n.getAttribute(`tabindex`)==null)continue;let r=n.parentElement;for(;r;){let t=e.css(r);if([t.overflow,t.overflowX,t.overflowY].some(e=>e===`hidden`))break;r=r.parentElement}if(!r)continue;let i=u(n),a=u(r);if(i.width===0&&i.height===0)continue;let o=Math.min(i.left-a.left,a.right-i.right,i.top-a.top,a.bottom-i.bottom);o>=4||t.push(l(this,n,`overflow-hidden ancestor ${o.toFixed(1)}px from border box — focus ring will clip`))}return t}},z={id:`fixed-width-in-pane`,part:`§24`,severity:`warn`,description:`§24: no fixed field widths in resizable panes — w-[Npx] (N ≥ 200) inside a [data-panel] / flex-basis sibling clips at the pane edge.`,check(e){let t=[];for(let n of e.els){if(!n.closest(`[data-panel]`))continue;let e=typeof n.className==`string`?n.className:``,r=n.getAttribute(`style`)??``,i=e.match(/w-\[(\d+)px\]/)??r.match(/(?:^|;|\s)width\s*:\s*(\d+)px/);if(!i)continue;let a=parseFloat(i[1]);a<200||t.push(l(this,n,`fixed width ${a}px inside a resizable pane — use w-full max-w-[${a}px]`))}return t}},B={id:`menu-icons-all-or-none`,part:`§21`,severity:`error`,description:`§21: within one menu ALL items carry a leading icon or NONE do — a mixed set reads broken.`,check(e){let t=[],n=Array.from(e.root.querySelectorAll(`[role="menu"]`));for(let e of n){let n=Array.from(e.querySelectorAll(`[role="menuitem"]`));if(n.length<2)continue;let r=n.filter(e=>e.querySelector(`svg`)).length;r===0||r===n.length||t.push(l(this,e,`${r}/${n.length} menu items have a leading icon`))}return t}},V={id:`menu-icon-mixed-weight`,part:`§21`,severity:`warn`,description:`§21: menu icons are ONE stroke family — some filled (fill=currentColor) and some stroke-based, or rendered sizes differing >2px, mixes two design systems.`,check(e){let t=[],n=Array.from(e.root.querySelectorAll(`[role="menu"]`));for(let e of n){let n=Array.from(e.querySelectorAll(`[role="menuitem"] svg`));if(n.length<2)continue;let r=0,i=0,a=[];for(let e of n){Array.from(e.querySelectorAll(`path, circle, rect, polygon`)).some(e=>{let t=(e.getAttribute(`fill`)??``).toLowerCase();return t===`currentcolor`||t&&t!==`none`})?r++:i++;let t=u(e);a.push(Math.max(t.width,t.height))}let o=Math.max(...a),s=Math.min(...a),c=r>0&&i>0,d=o>0&&o-s>2;!c&&!d||t.push(l(this,e,c?`${r} filled + ${i} stroke-based icons in one menu`:`icon sizes differ by ${(o-s).toFixed(1)}px`))}return t}},H={id:`chrome-selectable`,part:`Part 5`,severity:`warn`,description:`Part 5: chrome (rails/pills/tabs) is select-none — a rail/pill/tab without user-select:none.`,check(e){let t=[];for(let n of e.els){if(!(n.matches(`[role="tab"]`)||n.closest(`[role="tablist"], [data-rail], nav`)||/\bpill\b/.test(typeof n.className==`string`?n.className:``)))continue;let r=e.css(n).userSelect||e.css(n).webkitUserSelect;r!==`none`&&t.push(l(this,n,`chrome element without user-select:none (user-select: ${r||`auto`})`))}return t}},U={id:`cursor-mismatch`,part:`Part 5`,severity:`warn`,description:`Part 5: cursor semantics — buttons get pointer, static text gets default. A button with cursor:text, or static text with cursor:pointer and no interactive ancestor.`,check(e){let t=[];for(let n of e.els){let r=e.css(n).cursor;if(n.matches(`button, [role="button"]`)&&r===`text`){t.push(l(this,n,`interactive control with cursor:text`));continue}if(r===`pointer`&&n.matches(`p, h1, h2, h3, h4, h5, h6, span`)){if(n.closest(`button, a, [role="button"], [onclick], label`)||n.matches(`button, a, [role="button"]`))continue;t.push(l(this,n,`static ${n.tagName.toLowerCase()} with cursor:pointer and no interactive ancestor`))}}return t}};function W(e){let t=e.transitionProperty.split(`,`).map(e=>e.trim()).filter(Boolean);return t.length===1&&t[0]===`none`?[]:t}var G=[C,w,T,E,D,O,k,A,j,M,P,F,I,L,R,z,B,V,H,U,{id:`transition-all`,part:`§42`,severity:`error`,description:`§42: never transition-all — scope to what changes (transition-colors / transition-[transform,...]).`,check(e){let t=[];for(let n of e.els){if(!n.matches(_))continue;let r=W(e.css(n));r.includes(`all`)&&(s(n,r)||t.push(l(this,n,`transition-property: all`)))}return t}},{id:`transition-paint-prop`,part:`§42`,severity:`error`,description:`§42: never transition layout/paint props (box-shadow, width, height, top/left, margin, padding) — they reflow/repaint every frame; focus rings SNAP, shadows animate a pseudo-element opacity.`,check(e){let t=[];for(let r of e.els){let i=W(e.css(r)),a=n(i).filter(e=>e!==`all (includes layout/paint props)`);a.length!==0&&(s(r,i)||t.push(l(this,r,`transitions layout/paint props: ${a.join(`, `)}`)))}return t}},{id:`transition-too-slow`,part:`§42`,severity:`error`,description:`§42: interaction feedback is ≤100ms (duration-75 for small controls) — the 100ms theme default is fine; an explicit duration-150+ crept in.`,check(t){let n=[];for(let r of t.els){if(!r.matches(_))continue;let i=t.css(r),a=W(i);if(a.length===0||s(r,a))continue;let o=e(i.transitionDuration),c=Math.max(0,...o);c<=100||n.push(l(this,r,`transition-duration ${c}ms (>100ms) on [${a.join(`, `)}]`))}return n}},{id:`ease-in-feedback`,part:`§42`,severity:`warn`,description:`§42: house curve is cubic-bezier(.2,0,.1,1) ease-out — never ease-in / ease-in-out (or an ease-in-shaped bezier) on feedback.`,check(e){let t=[];for(let n of e.els){if(!n.matches(_))continue;let i=e.css(n),a=W(i);if(a.length===0||s(n,a))continue;let o=r(i.transitionTimingFunction);o&&t.push(l(this,n,`transition-timing-function: ${o}`))}return t}},{id:`press-instant-rule-missing`,part:`§42`,severity:`error`,description:"§42: the global press-instant rule (globals.css) must exist — `button:active{transition-duration:0s}` (or transition:none). Absent = press feedback animates instead of snapping.",check(e){let t=e.root.styleSheets?e.root:document,n=!1,r=e=>{for(let t of Array.from(e)){if(n)return;let e=t;if(e.cssRules&&e.cssRules.length){r(e.cssRules);continue}let i=t,a=i.selectorText;if(a&&/:active\b/.test(a)&&/(^|[\s,])(button|a|\[role=("|')?(button|tab|menuitem|option)("|')?\])/i.test(a))try{let e=i.style.transitionDuration,t=i.style.transition;if(e===`0s`||/(^|\s)none(\s|$)/.test(t)||/\b0s\b/.test(t)){n=!0;return}}catch{}}};try{for(let e of Array.from(t.styleSheets)){if(n)break;try{r(e.cssRules)}catch{}}}catch{}if(n)return[];let i=e.root.documentElement??e.els[0]??document.documentElement;return[l(this,i,"no `button:active { transition-duration: 0s }` rule on this page")]}}],K=[];function q(e){for(let t of Array.from(e.childNodes))if(t.nodeType===3&&(t.textContent??``).trim().length>0)return!0;return!1}function J(e,t,n){let r=Math.max(e,t,n);return r<40?!1:r-Math.min(e,t,n)>60}function Y(e){return!!(/\b0px\s+0px\s+0px\s+0?\.?5px\b/.test(e)||/\b0px?\s+0px?\s+0px?\s+0?\.?5px\b/.test(e))}function X(e=typeof document<`u`?document:{}){let t=new Map,n=e=>{let n=t.get(e);return n||(n=getComputedStyle(e),t.set(e,n)),n};S=n;let r=Array.from(e.querySelectorAll(`*`)),i=[],a=e.documentElement??null,o=e.body??null,s=(a?.getBoundingClientRect().width??0)>0;for(let e of r){if(e===a||e===o||e.closest(`#linear-grab-root, #claude-agent-glow-border, [id^="react-scan"]`))continue;let t=u(e);if(s){if(t.width<2||t.height<2)continue}else if(t.width===0&&t.height===0&&e.children.length===0)continue;i.push(e)}let c={root:e,els:i,css:n},l=[];for(let e of[...G,...K])try{l.push(...e.check(c))}catch{}S=e=>getComputedStyle(e);let d=new Map;for(let e of l){let t=`${e.ruleId}|${e.selector}|${e.evidence}`,n=d.get(t);n?n.count=(n.count??1)+1:d.set(t,e)}return[...d.values()]}function Z(e){let t=new Map;for(let n of e){let e=t.get(n.ruleId);e?e.push(n):t.set(n.ruleId,[n])}let n=Array.from(t.entries()).sort((e,t)=>{let n=e[1][0].severity===`error`?0:1,r=t[1][0].severity===`error`?0:1;return n===r?t[1].length===e[1].length?e[0].localeCompare(t[0]):t[1].length-e[1].length:n-r}),r=e.length,i=e.filter(e=>e.severity===`error`).length,a=new Set(e.map(e=>e.page).filter(Boolean)).size>1,o=[`# Slop scan — ${i} error${i===1?``:`s`}, ${r-i} warn (${r} total)`,``];for(let[e,t]of n){let n=t[0],r=t.reduce((e,t)=>e+(t.count??1),0);o.push(`## ${e} — ${n.severity} ×${r} [${n.part}]`),o.push(n.description);for(let e of t){let t=e.component||e.source?` — ${[e.component,e.source].filter(Boolean).join(` @ `)}`:``,n=(e.count??1)>1?` ×${e.count}`:``;o.push(`- ${a&&e.page?`\`${e.page}\` `:``}\`${e.selector}\`${n} — ${e.evidence}${t}`)}o.push(``)}return o.join(`
|
|
2
|
+
`)}function Q(e){return[`Below is a design-system SLOP report from my running app — each finding is a`,"live-DOM violation of the Linear/Protocolbase primitives contract (`Linear","-primitives.md`). Every rule cites its §id. Goal: zero errors. Rules:",``,`1. Fix at the TOKEN / VARIANT level, not the call sites. Most of these come`,` from shared row/button/menu variants — change --row-radius, --selected,`,` .pb-row-action, the Button/Input variant, or the globals.css §42 rules —`,` not one component at a time.`,`2. Cite the primitives §id in your fix (each finding carries it). Radius →`,` §40 vocabulary {8,12,16,full}; active fill → §11 var(--selected); menus →`,` §21 all-or-none icons + one stroke family; uppercase labels → §25;`,` editor flatness → Part 5; scrollers → §0–1 virtualize + truthful fade.`,`3. §42 group (transition-all / paint-prop / too-slow / ease-in / press-`,` instant): scope transitions to what changes, drop box-shadow/width/height`,` from transition lists, keep feedback ≤100ms (duration-75 small controls),`,` use ease-out (cubic-bezier(.2,0,.1,1)), and keep the global`," `button:active{transition-duration:0s}` press-instant rule.",`4. Do NOT touch EXEMPT intentional motion — popover/menu/dialog entry-exit,`,` svg icon micro-motion, and panel width/height transitions are §42-blessed.`,` The scan already excludes them; do not "fix" them.`,`5. 'unverifiable' warnings mean the token collapsed to the anti-pattern this`,` theme — verify the intent in BOTH themes, do not just silence it.`,"6. Findings carry `Component @ file:line` when the fiber source resolved.",` When one file:line repeats across many components it is the nearest-`,` debug-fiber fallback — locate the real code by the selector classes.`,`7. PROOF REQUIRED: after the change I will re-run the scan — it must show 0`,` errors. State what you changed per token/variant; do not claim done blind.`,``,`---`,``,Z(e)].join(`
|
|
3
|
+
`)}function $(){return X(document).map(({el:e,...t})=>t)}async function ee(){let e=X(document),t=window.__REACT_GRAB__;if(t&&(t.getDisplayName||t.getSource)){let n=new Map;for(let t of e){let e=t.el?.deref();if(!e)continue;let r=n.get(e);r?r.push(t):n.set(e,[t])}let r=300;for(let[e,i]of n){if(r--<=0)break;let n=null,a=null;try{n=t.getDisplayName?.(e)??null}catch{}try{let n=await t.getSource?.(e);n?.filePath&&(a=`${n.filePath}${n.lineNumber==null?``:`:${n.lineNumber}`}`)}catch{}if(!(!n&&!a))for(let e of i)e.component=n,e.source=a}}return e.map(({el:e,...t})=>t)}window.__SLOP_SCAN__={version:`0.25.2`,run:$,runAttributed:ee,report:Z,prompt:Q}})();
|