@monoes/monodesign 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/bin/cli.js +5 -0
- package/cli/engine/browser/injected/index.mjs +25 -0
- package/cli/engine/detect-antipatterns-browser.js +356 -0
- package/cli/engine/engines/regex/detect-text.mjs +63 -0
- package/cli/engine/engines/static-html/detect-html.mjs +19 -0
- package/cli/engine/fix/css-regions.mjs +181 -0
- package/cli/engine/fix/fixers.mjs +373 -0
- package/cli/engine/fix/index.mjs +426 -0
- package/cli/engine/registry/antipatterns.mjs +49 -0
- package/cli/engine/rules/checks.mjs +292 -0
- package/package.json +2 -2
- package/skill/SKILL.src.md +1 -1
- package/skill/reference/antipatterns-catalog.md +47 -2
- package/skill/reference/critique.md +1 -1
- package/skill/reference/live.md +0 -8
- package/skill/reference/polish.md +2 -2
- package/skill/scripts/context-signals.mjs +39 -5
- package/skill/scripts/critique-storage.mjs +175 -1
- package/skill/scripts/live/session-store.mjs +5 -1
- package/src/rules.ts +60 -3
|
@@ -2629,6 +2629,288 @@ function checkElementTextOverflowDOM(el) {
|
|
|
2629
2629
|
return [];
|
|
2630
2630
|
}
|
|
2631
2631
|
|
|
2632
|
+
// ─── Section 7: Stylesheet-level accessibility rules ─────────────────────────
|
|
2633
|
+
// These operate on raw CSS text (concatenated <style> blocks + linked sheets),
|
|
2634
|
+
// not per-element computed style — because :hover / :focus-visible / @media
|
|
2635
|
+
// conditions can't be read off a resting element. Shared by the regex, static
|
|
2636
|
+
// and browser engines (each hands over its own CSS text).
|
|
2637
|
+
|
|
2638
|
+
// Parse flat CSS into { selector, body, dark } tuples, tracking one level of
|
|
2639
|
+
// dark-scheme context (a `@media (prefers-color-scheme: dark)` ancestor).
|
|
2640
|
+
// @supports / @layer / @container / @scope are transparently descended.
|
|
2641
|
+
// @font-face / @keyframes and other at-rules are skipped.
|
|
2642
|
+
function collectCssRules(css, dark = false, depth = 0) {
|
|
2643
|
+
const out = [];
|
|
2644
|
+
if (!css || depth > 6) return out;
|
|
2645
|
+
const src = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
2646
|
+
let i = 0;
|
|
2647
|
+
while (i < src.length) {
|
|
2648
|
+
const braceStart = src.indexOf('{', i);
|
|
2649
|
+
if (braceStart === -1) break;
|
|
2650
|
+
const prelude = src.slice(i, braceStart).trim();
|
|
2651
|
+
let d = 1, j = braceStart + 1;
|
|
2652
|
+
for (; j < src.length; j++) {
|
|
2653
|
+
if (src[j] === '{') d++;
|
|
2654
|
+
else if (src[j] === '}') { d--; if (d === 0) break; }
|
|
2655
|
+
}
|
|
2656
|
+
const body = src.slice(braceStart + 1, j);
|
|
2657
|
+
i = j + 1;
|
|
2658
|
+
if (!prelude) continue;
|
|
2659
|
+
if (prelude.startsWith('@')) {
|
|
2660
|
+
const atName = prelude.slice(1).split(/[\s(]/)[0].toLowerCase();
|
|
2661
|
+
if (['media', 'supports', 'layer', 'container', 'scope'].includes(atName)) {
|
|
2662
|
+
const isDarkMedia = atName === 'media' && /prefers-color-scheme\s*:\s*dark/i.test(prelude);
|
|
2663
|
+
out.push(...collectCssRules(body, dark || isDarkMedia, depth + 1));
|
|
2664
|
+
}
|
|
2665
|
+
continue;
|
|
2666
|
+
}
|
|
2667
|
+
for (const sel of prelude.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
2668
|
+
out.push({ selector: sel, body, dark });
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
return out;
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
// --- missing-focus-visible ---
|
|
2675
|
+
function selectorTargetsInteractive(sel) {
|
|
2676
|
+
return /(^|[\s>+~(,])(a|button|input|select|textarea|summary)(\b|[.:#[])/i.test(sel)
|
|
2677
|
+
|| /:focus\b/i.test(sel)
|
|
2678
|
+
|| /\[role\s*=\s*["']?button["']?\]/i.test(sel);
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
function checkFocusVisible(cssText) {
|
|
2682
|
+
if (!cssText) return [];
|
|
2683
|
+
const rules = collectCssRules(cssText);
|
|
2684
|
+
let suppressor = null;
|
|
2685
|
+
let hasFocusVisible = false;
|
|
2686
|
+
let hasFocusReplacement = false;
|
|
2687
|
+
for (const r of rules) {
|
|
2688
|
+
const sel = r.selector.toLowerCase();
|
|
2689
|
+
const body = r.body.toLowerCase();
|
|
2690
|
+
if (/:focus-visible/.test(sel)) {
|
|
2691
|
+
// Any :focus-visible styling counts as deliberate focus handling.
|
|
2692
|
+
hasFocusVisible = true;
|
|
2693
|
+
}
|
|
2694
|
+
const suppresses = /outline\s*:\s*(none|0(?:px)?)\b/.test(body)
|
|
2695
|
+
|| /outline-style\s*:\s*none/.test(body)
|
|
2696
|
+
|| /outline-width\s*:\s*0(?:px)?\b/.test(body);
|
|
2697
|
+
if (suppresses && selectorTargetsInteractive(sel) && !/:focus-visible/.test(sel)) {
|
|
2698
|
+
suppressor = suppressor || r;
|
|
2699
|
+
}
|
|
2700
|
+
// A plain :focus rule that draws a visible affordance is a valid fallback.
|
|
2701
|
+
if (/:focus\b/.test(sel) && !/:focus-visible/.test(sel)) {
|
|
2702
|
+
const olVal = /outline\s*:\s*([^;}]+)/.exec(body);
|
|
2703
|
+
const outlineReplaces = olVal && !/^\s*(none|0(?:px)?|inherit|initial|unset)\s*$/.test(olVal[1]);
|
|
2704
|
+
if (/box-shadow\s*:/.test(body)
|
|
2705
|
+
|| outlineReplaces
|
|
2706
|
+
|| /outline-(?:color|width|style)\s*:\s*(?!none|0(?:px)?\b)/.test(body)
|
|
2707
|
+
|| /border(?:-\w+)?(?:-color)?\s*:/.test(body)) {
|
|
2708
|
+
hasFocusReplacement = true;
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
if (suppressor && !hasFocusVisible && !hasFocusReplacement) {
|
|
2713
|
+
return [{ id: 'missing-focus-visible', snippet: `"${suppressor.selector}" removes outline with no :focus-visible replacement` }];
|
|
2714
|
+
}
|
|
2715
|
+
return [];
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
// --- hover-only-affordance ---
|
|
2719
|
+
function lastCompound(sel) {
|
|
2720
|
+
const parts = sel.split(/\s*[>+~]\s*|\s+/).filter(Boolean);
|
|
2721
|
+
const last = parts[parts.length - 1] || sel;
|
|
2722
|
+
return last.replace(/:{1,2}[a-z-]+(\([^)]*\))?/gi, '').trim().toLowerCase();
|
|
2723
|
+
}
|
|
2724
|
+
function bodyRevealsVisible(body) {
|
|
2725
|
+
const b = body.toLowerCase();
|
|
2726
|
+
if (/display\s*:\s*(block|flex|grid|inline(?:-\w+)?|table|list-item)/.test(b)) return true;
|
|
2727
|
+
if (/visibility\s*:\s*visible/.test(b)) return true;
|
|
2728
|
+
if (/opacity\s*:\s*(?:1(?:\.0+)?|0?\.[1-9]\d*|\.\d*[1-9])\b/.test(b)) return true;
|
|
2729
|
+
return false;
|
|
2730
|
+
}
|
|
2731
|
+
function bodyHidesByDefault(body) {
|
|
2732
|
+
const b = body.toLowerCase();
|
|
2733
|
+
return /display\s*:\s*none/.test(b)
|
|
2734
|
+
|| /visibility\s*:\s*hidden/.test(b)
|
|
2735
|
+
|| /opacity\s*:\s*0(?:\.0+)?\b/.test(b);
|
|
2736
|
+
}
|
|
2737
|
+
function checkHoverOnlyAffordance(cssText) {
|
|
2738
|
+
if (!cssText) return [];
|
|
2739
|
+
const rules = collectCssRules(cssText);
|
|
2740
|
+
const focusTargets = new Set();
|
|
2741
|
+
const hiddenTargets = new Set();
|
|
2742
|
+
for (const r of rules) {
|
|
2743
|
+
const sel = r.selector;
|
|
2744
|
+
if (/:(focus|focus-within|focus-visible|active)\b/i.test(sel) && bodyRevealsVisible(r.body)) {
|
|
2745
|
+
focusTargets.add(lastCompound(sel));
|
|
2746
|
+
}
|
|
2747
|
+
if (!/:hover\b/i.test(sel) && bodyHidesByDefault(r.body)) {
|
|
2748
|
+
hiddenTargets.add(lastCompound(sel));
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
const findings = [];
|
|
2752
|
+
const seen = new Set();
|
|
2753
|
+
for (const r of rules) {
|
|
2754
|
+
const sel = r.selector;
|
|
2755
|
+
if (!/:hover\b/i.test(sel)) continue;
|
|
2756
|
+
if (!bodyRevealsVisible(r.body)) continue;
|
|
2757
|
+
const parts = sel.split(/\s*[>+~]\s*|\s+/).filter(Boolean);
|
|
2758
|
+
if (parts.length < 2) continue; // hover on the subject itself — can't hide+reveal
|
|
2759
|
+
if (/:hover\b/i.test(parts[parts.length - 1])) continue; // :hover is on the revealed subject
|
|
2760
|
+
const target = lastCompound(sel);
|
|
2761
|
+
if (!target || !hiddenTargets.has(target)) continue;
|
|
2762
|
+
if (focusTargets.has(target)) continue;
|
|
2763
|
+
if (seen.has(target)) continue;
|
|
2764
|
+
seen.add(target);
|
|
2765
|
+
findings.push({ id: 'hover-only-affordance', snippet: `"${target}" is revealed on :hover with no focus/active equivalent` });
|
|
2766
|
+
}
|
|
2767
|
+
return findings;
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
// --- dark-scheme-contrast-blindspot ---
|
|
2771
|
+
const DARK_SCOPE_RE = /(?:^|[\s>+~(,])(?:html|:root|body)?(?:\.dark|\.theme-dark|\.dark-mode)\b|\[data-theme\s*[~|]?=\s*["']?dark["']?\]|\[data-mode\s*=\s*["']?dark["']?\]|\[data-color-scheme\s*=\s*["']?dark["']?\]/i;
|
|
2772
|
+
function stripDarkScope(sel) {
|
|
2773
|
+
return sel
|
|
2774
|
+
.replace(/(?:html|:root|body)?(?:\.dark|\.theme-dark|\.dark-mode)\b/gi, '')
|
|
2775
|
+
.replace(/\[data-(?:theme|mode|color-scheme)\s*[~|]?=\s*["']?dark["']?\]/gi, '')
|
|
2776
|
+
.replace(/\s*[>+~]\s*/g, ' ')
|
|
2777
|
+
.replace(/\s+/g, ' ')
|
|
2778
|
+
.trim();
|
|
2779
|
+
}
|
|
2780
|
+
function normalizeSelKey(sel) {
|
|
2781
|
+
return sel.replace(/\s*[>+~]\s*/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
2782
|
+
}
|
|
2783
|
+
function declHasColor(body) { return /(?:^|[;{\s])color\s*:/i.test(body); }
|
|
2784
|
+
function declHasBg(body) { return /(?:^|[;{\s])background(?:-color)?\s*:/i.test(body); }
|
|
2785
|
+
function checkDarkSchemeContrast(cssText) {
|
|
2786
|
+
if (!cssText) return [];
|
|
2787
|
+
const rules = collectCssRules(cssText);
|
|
2788
|
+
const hasDarkMedia = /@media[^{]*prefers-color-scheme\s*:\s*dark/i.test(cssText);
|
|
2789
|
+
const hasDarkSelectors = rules.some(r => DARK_SCOPE_RE.test(r.selector));
|
|
2790
|
+
if (!hasDarkMedia && !hasDarkSelectors) return [];
|
|
2791
|
+
|
|
2792
|
+
const light = new Map(); // base selector → { color, bg }
|
|
2793
|
+
const darkAgg = new Map();
|
|
2794
|
+
for (const r of rules) {
|
|
2795
|
+
const scopedDark = DARK_SCOPE_RE.test(r.selector);
|
|
2796
|
+
const isDark = r.dark || scopedDark;
|
|
2797
|
+
if (isDark) {
|
|
2798
|
+
const base = normalizeSelKey(scopedDark ? stripDarkScope(r.selector) : r.selector);
|
|
2799
|
+
if (!base) continue;
|
|
2800
|
+
const e = darkAgg.get(base) || { color: false, bg: false };
|
|
2801
|
+
if (declHasColor(r.body)) e.color = true;
|
|
2802
|
+
if (declHasBg(r.body)) e.bg = true;
|
|
2803
|
+
darkAgg.set(base, e);
|
|
2804
|
+
} else {
|
|
2805
|
+
const base = normalizeSelKey(r.selector);
|
|
2806
|
+
if (!base) continue;
|
|
2807
|
+
const e = light.get(base) || { color: false, bg: false };
|
|
2808
|
+
if (declHasColor(r.body)) e.color = true;
|
|
2809
|
+
if (declHasBg(r.body)) e.bg = true;
|
|
2810
|
+
light.set(base, e);
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
const findings = [];
|
|
2815
|
+
for (const [base, d] of darkAgg) {
|
|
2816
|
+
if (d.color === d.bg) continue; // sets both or neither — not a split pair
|
|
2817
|
+
const lp = light.get(base);
|
|
2818
|
+
if (!lp || !(lp.color && lp.bg)) continue; // light scheme must pair both
|
|
2819
|
+
const changed = d.bg ? 'background' : 'text color';
|
|
2820
|
+
const missing = d.bg ? 'text color' : 'background';
|
|
2821
|
+
findings.push({ id: 'dark-scheme-contrast-blindspot', snippet: `"${base}" overrides ${changed} in dark scheme but not ${missing}` });
|
|
2822
|
+
}
|
|
2823
|
+
return findings;
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
// --- image-missing-dimensions ---
|
|
2827
|
+
function checkImageDimensions({ hasWidthAttr, hasHeightAttr, style }) {
|
|
2828
|
+
const s = String(style || '').toLowerCase();
|
|
2829
|
+
if (hasWidthAttr && hasHeightAttr) return [];
|
|
2830
|
+
if (/aspect-?ratio\s*:/.test(s)) return [];
|
|
2831
|
+
const heightM = /(?:^|[;{\s])height\s*:\s*([^;]+)/.exec(s);
|
|
2832
|
+
if (heightM && !/^\s*(auto|inherit|initial|unset|0(?:px)?)\s*$/.test(heightM[1])) return [];
|
|
2833
|
+
return [{ id: 'image-missing-dimensions', snippet: '<img> without width/height attributes or CSS aspect-ratio (layout shift risk)' }];
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
function checkElementImageDimensions(el, style) {
|
|
2837
|
+
const hasWidthAttr = (el.getAttribute?.('width') ?? el.attribs?.width) != null;
|
|
2838
|
+
const hasHeightAttr = (el.getAttribute?.('height') ?? el.attribs?.height) != null;
|
|
2839
|
+
const parts = [el.getAttribute?.('style') || el.attribs?.style || ''];
|
|
2840
|
+
const ar = style && (style.aspectRatio || style['aspect-ratio']);
|
|
2841
|
+
if (ar && ar !== 'auto') parts.push(`aspect-ratio:${ar}`);
|
|
2842
|
+
const h = style && style.height;
|
|
2843
|
+
if (h && h !== 'auto' && h !== '') parts.push(`height:${h}`);
|
|
2844
|
+
return checkImageDimensions({ hasWidthAttr, hasHeightAttr, style: parts.join(';') });
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
function checkElementImageDimensionsDOM(el) {
|
|
2848
|
+
const hasWidthAttr = el.getAttribute('width') != null;
|
|
2849
|
+
const hasHeightAttr = el.getAttribute('height') != null;
|
|
2850
|
+
// Browser computed `height` always resolves to a used px value after layout,
|
|
2851
|
+
// so it can't tell a reserved height from a rendered one — only use inline
|
|
2852
|
+
// style + the computed aspect-ratio (which stays 'auto' unless authored).
|
|
2853
|
+
const parts = [el.getAttribute('style') || ''];
|
|
2854
|
+
const style = getComputedStyle(el);
|
|
2855
|
+
const ar = style.aspectRatio || '';
|
|
2856
|
+
if (ar && ar !== 'auto') parts.push(`aspect-ratio:${ar}`);
|
|
2857
|
+
return checkImageDimensions({ hasWidthAttr, hasHeightAttr, style: parts.join(';') });
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
// --- small-touch-target ---
|
|
2861
|
+
const CLICKABLE_INPUT_TYPES = new Set(['button', 'submit', 'reset', 'checkbox', 'radio', 'image']);
|
|
2862
|
+
const CLICKABLE_ROLES = new Set(['button', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'tab', 'switch', 'checkbox', 'radio', 'option']);
|
|
2863
|
+
function isClickableControl(tag, role, href, inputType) {
|
|
2864
|
+
if (tag === 'button' || tag === 'select' || tag === 'summary') return true;
|
|
2865
|
+
if (tag === 'a') return href != null && href !== '';
|
|
2866
|
+
if (tag === 'input') return CLICKABLE_INPUT_TYPES.has(String(inputType || '').toLowerCase());
|
|
2867
|
+
if (role && CLICKABLE_ROLES.has(role)) return true;
|
|
2868
|
+
return false;
|
|
2869
|
+
}
|
|
2870
|
+
function checkSmallTouchTarget({ tag, role, href, inputType, widthPx, heightPx, isInlineLinkInProse }) {
|
|
2871
|
+
if (!isClickableControl(tag, role, href, inputType)) return [];
|
|
2872
|
+
if (isInlineLinkInProse) return [];
|
|
2873
|
+
if (!(widthPx > 0) || !(heightPx > 0)) return [];
|
|
2874
|
+
if (widthPx >= 44 && heightPx >= 44) return [];
|
|
2875
|
+
const roleLabel = role && !['button', 'a', 'input', 'select'].includes(tag) ? `[role=${role}]` : '';
|
|
2876
|
+
return [{ id: 'small-touch-target', snippet: `<${tag}>${roleLabel} renders ${Math.round(widthPx)}x${Math.round(heightPx)}px (min 44x44)` }];
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
const PROSE_ANCESTOR_SELECTOR = 'p,li,blockquote,figcaption,dd,dt';
|
|
2880
|
+
function checkElementSmallTouchTarget(el, style, tag) {
|
|
2881
|
+
// Static path: only provable when width AND height are explicit px lengths
|
|
2882
|
+
// (jsdom performs no layout; rem/%/auto can't be resolved to a real box).
|
|
2883
|
+
const wRaw = style.width || '';
|
|
2884
|
+
const hRaw = style.height || '';
|
|
2885
|
+
if (!/px\s*$/.test(wRaw) || !/px\s*$/.test(hRaw)) return [];
|
|
2886
|
+
const widthPx = parseFloat(wRaw);
|
|
2887
|
+
const heightPx = parseFloat(hRaw);
|
|
2888
|
+
const href = el.getAttribute?.('href');
|
|
2889
|
+
const role = (el.getAttribute?.('role') || '').toLowerCase();
|
|
2890
|
+
const inputType = el.getAttribute?.('type') || '';
|
|
2891
|
+
// An <a> is a prose text link unless it is explicitly laid out as a block
|
|
2892
|
+
// (button-like). jsdom returns '' for the default inline display, so key
|
|
2893
|
+
// off "not blockish" rather than "== inline".
|
|
2894
|
+
const isBlockish = /^(block|flex|grid|inline-block|inline-flex|table)/.test(style.display || '');
|
|
2895
|
+
const inProse = !!(el.closest && el.closest(PROSE_ANCESTOR_SELECTOR));
|
|
2896
|
+
const isInlineLinkInProse = tag === 'a' && !isBlockish && inProse;
|
|
2897
|
+
return checkSmallTouchTarget({ tag, role, href, inputType, widthPx, heightPx, isInlineLinkInProse });
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
function checkElementSmallTouchTargetDOM(el) {
|
|
2901
|
+
const tag = el.tagName.toLowerCase();
|
|
2902
|
+
const rect = el.getBoundingClientRect();
|
|
2903
|
+
if (!(rect.width > 0) || !(rect.height > 0)) return [];
|
|
2904
|
+
const href = el.getAttribute('href');
|
|
2905
|
+
const role = (el.getAttribute('role') || '').toLowerCase();
|
|
2906
|
+
const inputType = el.getAttribute('type') || '';
|
|
2907
|
+
const style = getComputedStyle(el);
|
|
2908
|
+
const isBlockish = /^(block|flex|grid|inline-block|inline-flex|table)/.test(style.display || '');
|
|
2909
|
+
const inProse = !!(el.closest && el.closest(PROSE_ANCESTOR_SELECTOR));
|
|
2910
|
+
const isInlineLinkInProse = tag === 'a' && !isBlockish && inProse;
|
|
2911
|
+
return checkSmallTouchTarget({ tag, role, href, inputType, widthPx: rect.width, heightPx: rect.height, isInlineLinkInProse });
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2632
2914
|
export {
|
|
2633
2915
|
checkBorders,
|
|
2634
2916
|
isEmojiOnlyText,
|
|
@@ -2700,4 +2982,14 @@ export {
|
|
|
2700
2982
|
checkElementClippedOverflowDOM,
|
|
2701
2983
|
isScreenReaderOnlyTextStyle,
|
|
2702
2984
|
checkElementTextOverflowDOM,
|
|
2985
|
+
collectCssRules,
|
|
2986
|
+
checkFocusVisible,
|
|
2987
|
+
checkHoverOnlyAffordance,
|
|
2988
|
+
checkDarkSchemeContrast,
|
|
2989
|
+
checkImageDimensions,
|
|
2990
|
+
checkElementImageDimensions,
|
|
2991
|
+
checkElementImageDimensionsDOM,
|
|
2992
|
+
checkSmallTouchTarget,
|
|
2993
|
+
checkElementSmallTouchTarget,
|
|
2994
|
+
checkElementSmallTouchTargetDOM,
|
|
2703
2995
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@monoes/monodesign",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Frontend design intelligence for AI agents \u2014 design tokens, antipattern detection, and the monodesign skill",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
23
|
"build": "tsc",
|
|
24
|
-
"test": "node --test 'tests/*.test.*' 'tests/lib/*.test.*'",
|
|
24
|
+
"test": "node --experimental-strip-types --test 'tests/*.test.*' 'tests/lib/*.test.*'",
|
|
25
25
|
"sync": "node scripts/sync-skill.mjs",
|
|
26
26
|
"prepare": "node scripts/sync-skill.mjs"
|
|
27
27
|
},
|
package/skill/SKILL.src.md
CHANGED
|
@@ -171,7 +171,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
|
|
|
171
171
|
|
|
172
172
|
## Antipattern detection
|
|
173
173
|
|
|
174
|
-
Before finalizing any design, run the bundled detector. It checks
|
|
174
|
+
Before finalizing any design, run the bundled detector. It checks 51 known design antipatterns across two categories (**slop** — AI tells; **quality** — design principle violations):
|
|
175
175
|
|
|
176
176
|
```bash
|
|
177
177
|
monomind design detect <file-or-dir> # human-readable report
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
<!-- Synced from the monodesign v3.2.1 registry. Source of truth: cli/engine/registry/antipatterns.mjs -->
|
|
2
2
|
# Antipatterns Catalog
|
|
3
3
|
|
|
4
|
-
All
|
|
4
|
+
All 51 known design antipatterns with detection rules and remediation. These are what `monomind design detect` checks. Run this on any HTML/CSS target before presenting work.
|
|
5
5
|
|
|
6
6
|
## Categories
|
|
7
7
|
|
|
8
8
|
These are the two categories the `monomind design detect` engine uses:
|
|
9
9
|
|
|
10
10
|
- **slop** — AI tells that signal lack of intentional design (27 patterns)
|
|
11
|
-
- **quality** — Design principle violations including contrast, motion, readability, and semantic structure (
|
|
11
|
+
- **quality** — Design principle violations including contrast, motion, readability, and semantic structure (24 patterns)
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
@@ -226,6 +226,14 @@ These are the two categories the `monomind design detect` engine uses:
|
|
|
226
226
|
**Why it's wrong:** Cramped elements are hard to read, harder to click, and communicate low quality. 8px is the absolute minimum; 16px is the default.
|
|
227
227
|
**Fix:** Minimum 12px padding inside cards. Minimum 10px vertical + 16px horizontal on buttons. Minimum 44×44px touch targets.
|
|
228
228
|
|
|
229
|
+
### `dark-scheme-contrast-blindspot` — Dark-scheme contrast blindspot
|
|
230
|
+
|
|
231
|
+
**Detect** (advisory): The page ships dark styling (a `@media (prefers-color-scheme: dark)` block or a `.dark` / `[data-theme=dark]` scope), but for some selector the dark override changes the background without changing the paired text color — or the reverse — while the light scheme defined both.
|
|
232
|
+
|
|
233
|
+
**Why it's wrong**: A half-updated color pair often collapses to unreadable contrast in dark mode (dark text left on a newly-dark background). It's the most common dark-mode regression and easy to miss because the light scheme looks fine.
|
|
234
|
+
|
|
235
|
+
**Fix**: Override text and background together for the same selector, or verify the inherited half still contrasts against the changed half. Prefer semantic tokens (`--fg` / `--bg`) that flip as a pair.
|
|
236
|
+
|
|
229
237
|
### `design-system-color` — Color outside DESIGN.md
|
|
230
238
|
|
|
231
239
|
**Detect**: A literal color value used in CSS that is not declared in the project's `DESIGN.md` palette or its tonal ramps.
|
|
@@ -263,6 +271,22 @@ These are the two categories the `monomind design detect` engine uses:
|
|
|
263
271
|
**Why it's wrong:** Gray text on colored backgrounds almost always fails WCAG contrast requirements and looks muddy.
|
|
264
272
|
**Fix:** Use white or the appropriate neutral from the color ramp. Never use a generic gray on a tinted background.
|
|
265
273
|
|
|
274
|
+
### `hover-only-affordance` — Functionality gated behind hover only
|
|
275
|
+
|
|
276
|
+
**Detect**: An element is hidden by default (`display: none`, `visibility: hidden`, or `opacity: 0`) and revealed only via a `:hover` rule on an ancestor, with no matching `:focus`, `:focus-within`, or `:active` rule.
|
|
277
|
+
|
|
278
|
+
**Why it's wrong**: Hover doesn't exist on touch devices and can't be reached by keyboard. Anything gated behind hover alone is invisible and unusable for a large share of users.
|
|
279
|
+
|
|
280
|
+
**Fix**: Mirror every hover reveal with a `:focus-within` (or `:active`) rule targeting the same element, so the affordance is reachable without a pointer.
|
|
281
|
+
|
|
282
|
+
### `image-missing-dimensions` — Image without reserved dimensions
|
|
283
|
+
|
|
284
|
+
**Detect**: An `<img>` ships without both `width` and `height` attributes and without a CSS `aspect-ratio` or explicit height.
|
|
285
|
+
|
|
286
|
+
**Why it's wrong**: The browser can't reserve space before the image loads, so surrounding content jumps when it arrives — cumulative layout shift (CLS), a Core Web Vitals failure and a jarring experience.
|
|
287
|
+
|
|
288
|
+
**Fix**: Set `width` and `height` attributes (the browser derives the aspect ratio), or give the image a CSS `aspect-ratio`. Either reserves the box before load.
|
|
289
|
+
|
|
266
290
|
### `justified-text` — Justified text
|
|
267
291
|
**Detect:** `text-align: justify` on paragraph elements.
|
|
268
292
|
**Why it's wrong:** CSS text justification creates uneven word spacing (rivers of white) because browsers don't hyphenate automatically. It reads as broken on web.
|
|
@@ -283,11 +307,27 @@ These are the two categories the `monomind design detect` engine uses:
|
|
|
283
307
|
**Why it's wrong:** Low contrast text fails WCAG AA requirements and creates genuine barriers for users with low vision, in bright ambient light, or on low-quality displays.
|
|
284
308
|
**Fix:** Increase the contrast between text and background. Use white or a high-lightness neutral on dark backgrounds; use Deep Graphite (`oklch(10% 0 0)`) or Soft Charcoal (`oklch(25% 0 0)`) on light ones. Tool: check with `monomind design detect` or the WebAIM contrast checker.
|
|
285
309
|
|
|
310
|
+
### `missing-focus-visible` — Suppressed focus outline with no replacement
|
|
311
|
+
|
|
312
|
+
**Detect**: An interactive element (link, button, input, `[role=button]`) removes its focus outline (`outline: none` / `0`), but the stylesheet never provides a `:focus-visible` or `:focus` replacement.
|
|
313
|
+
|
|
314
|
+
**Why it's wrong**: Removing the outline with no substitute strands keyboard users — there's no visible indication of where focus is. It's one of the most common and most damaging accessibility regressions.
|
|
315
|
+
|
|
316
|
+
**Fix**: Remove the suppression, or pair it with a visible `:focus-visible` ring — an `outline`, `box-shadow`, or `border` that clearly marks the focused control.
|
|
317
|
+
|
|
286
318
|
### `skipped-heading` — Skipped heading level
|
|
287
319
|
**Detect:** An `h3` or deeper element that is not preceded by an `h2` in the same section, or an `h2` not preceded by an `h1`.
|
|
288
320
|
**Why it's wrong:** Skipped headings break screen reader navigation and document outline semantics.
|
|
289
321
|
**Fix:** Maintain sequential heading hierarchy. If you need the visual size of `h3` without the `h2` parent, use a `h2` with class-based styling.
|
|
290
322
|
|
|
323
|
+
### `small-touch-target` — Touch target below 44px
|
|
324
|
+
|
|
325
|
+
**Detect**: A clickable control (`button`, link, clickable `input`, `[role=button]`) renders smaller than 44×44px on one or both axes. Inline text links inside prose are exempt.
|
|
326
|
+
|
|
327
|
+
**Why it's wrong**: Fingers are imprecise. Targets below ~44×44px (the WCAG 2.5.5 / platform HIG minimum) are missed and mis-tapped, especially on mobile and for users with motor impairments.
|
|
328
|
+
|
|
329
|
+
**Fix**: Give standalone controls at least 44×44px of hit area via `padding` or `min-width` / `min-height`. Spacing between adjacent targets helps too.
|
|
330
|
+
|
|
291
331
|
### `text-overflow` — Content overflowing its container
|
|
292
332
|
|
|
293
333
|
**Detect**: Content renders wider than its container — `scrollWidth > clientWidth` on any non-scroll element, or a horizontal scrollbar appears on the page.
|
|
@@ -349,16 +389,21 @@ These are the two categories the `monomind design detect` engine uses:
|
|
|
349
389
|
| `broken-image` | quality | Real image or remove tag |
|
|
350
390
|
| `clipped-overflow-container` | quality | Portal or visible overflow |
|
|
351
391
|
| `cramped-padding` | quality | 16px minimum |
|
|
392
|
+
| `dark-scheme-contrast-blindspot` | quality | Flip text + background as a pair |
|
|
352
393
|
| `design-system-color` | quality | Use palette token or update DESIGN.md |
|
|
353
394
|
| `design-system-font` | quality | Use type system or update DESIGN.md |
|
|
354
395
|
| `design-system-font-size` | quality | Use type-ramp step or update DESIGN.md |
|
|
355
396
|
| `design-system-radius` | quality | Use radius token or update DESIGN.md |
|
|
356
397
|
| `gray-on-color` | quality | White or appropriate ramp tone |
|
|
398
|
+
| `hover-only-affordance` | quality | Mirror hover with :focus-within |
|
|
399
|
+
| `image-missing-dimensions` | quality | width/height attrs or aspect-ratio |
|
|
357
400
|
| `justified-text` | quality | text-align: left |
|
|
358
401
|
| `layout-transition` | quality | transform + opacity only |
|
|
359
402
|
| `line-length` | quality | max-width: 65ch |
|
|
360
403
|
| `low-contrast` | quality | 4.5:1 normal, 3:1 large text |
|
|
404
|
+
| `missing-focus-visible` | quality | Add a :focus-visible ring |
|
|
361
405
|
| `skipped-heading` | quality | Sequential h1→h2→h3 |
|
|
406
|
+
| `small-touch-target` | quality | 44×44px minimum hit area |
|
|
362
407
|
| `text-overflow` | quality | overflow-wrap + constrained widths |
|
|
363
408
|
| `tight-leading` | quality | line-height: 1.6 |
|
|
364
409
|
| `tiny-text` | quality | 16px minimum body |
|
|
@@ -215,7 +215,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
|
|
215
215
|
MONODESIGN_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"p0_count":<n>,"p1_count":<n>}' \
|
|
216
216
|
node {{scripts_path}}/critique-storage.mjs write <slug> <body-file>
|
|
217
217
|
```
|
|
218
|
-
The helper prints the absolute path it wrote.
|
|
218
|
+
The helper prints the absolute path it wrote. As part of the write it also best-effort mirrors a compact record (score, P0/P1 counts, date, slug, path) into monomind's persistent memory under the `design-critique` namespace, so design health carries across sessions. This mirror is silent and never fails the critique: if the monomind CLI is unavailable it is skipped, and setting `MONODESIGN_NO_MEMORY=1` disables it entirely. Do not report mirror status unless the user asks.
|
|
219
219
|
|
|
220
220
|
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
|
|
221
221
|
|
package/skill/reference/live.md
CHANGED
|
@@ -685,10 +685,6 @@ const __monodesignLiveDev =
|
|
|
685
685
|
- **SvelteKit**: edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
|
|
686
686
|
- **Nuxt + nuxt-security**: edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
|
|
687
687
|
|
|
688
|
-
Reference outputs:
|
|
689
|
-
- `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js)
|
|
690
|
-
- `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js` (SvelteKit)
|
|
691
|
-
|
|
692
688
|
Idempotency: if `__monodesignLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
|
|
693
689
|
|
|
694
690
|
#### append-string
|
|
@@ -711,10 +707,6 @@ Per-framework specifics:
|
|
|
711
707
|
- **Next.js inline `headers()`**: edit `next.config.*`, splicing the variable into the CSP value.
|
|
712
708
|
- **Nuxt `routeRules`**: edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
|
|
713
709
|
|
|
714
|
-
Reference outputs:
|
|
715
|
-
- `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js)
|
|
716
|
-
- `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts` (Nuxt)
|
|
717
|
-
|
|
718
710
|
### Troubleshooting
|
|
719
711
|
|
|
720
712
|
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.monodesign/live/config.json` and re-run `live.mjs`: setup will ask again.
|
|
@@ -38,9 +38,9 @@ Understand the current state and goals before touching anything:
|
|
|
38
38
|
4. **Pull in any prior critique** (optional signal): If `{{command_prefix}}monodesign critique` has been run on the same target, its priority issues are a useful prior for what to address first. Resolve the target to a file path or URL, then:
|
|
39
39
|
```bash
|
|
40
40
|
slug=$(node {{scripts_path}}/critique-storage.mjs slug "<resolved>")
|
|
41
|
-
node {{scripts_path}}/critique-storage.mjs
|
|
41
|
+
node {{scripts_path}}/critique-storage.mjs recall "$slug"
|
|
42
42
|
```
|
|
43
|
-
Exit 0 with
|
|
43
|
+
Exit 0 prints a compact markdown block: latest score, score trend with direction, the open P0/P1 item titles, and the snapshot path. Fold the P0/P1 items into your polish list and mention the snapshot path so the user sees what you read. If you need the full snapshot body behind an item, read the printed snapshot path directly (or run the `latest` subcommand). Exit 2 = no snapshot, continue without it. The critique is one input among many. Do your own pass either way.
|
|
44
44
|
|
|
45
45
|
5. **Triage cosmetic vs functional**: Classify each issue as **cosmetic** (looks off, doesn't impede the user) or **functional** (breaks, blocks, or confuses the experience). When polish time is tight, functional issues ship first; cosmetic ones can land in a follow-up. Quality should be consistent; never perfect one corner while leaving another rough.
|
|
46
46
|
|
|
@@ -23,6 +23,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
23
23
|
import { execFileSync } from 'node:child_process';
|
|
24
24
|
import { loadContext, extractRegister, extractPlatform } from './context.mjs';
|
|
25
25
|
import { getCritiqueDir } from './lib/monodesign-paths.mjs';
|
|
26
|
+
import { readTrend, extractIssueLines, trendDirection } from './critique-storage.mjs';
|
|
26
27
|
|
|
27
28
|
/** Is there code here at all, or just context files / an empty repo? */
|
|
28
29
|
function hasCode(cwd) {
|
|
@@ -36,7 +37,10 @@ function hasCode(cwd) {
|
|
|
36
37
|
/**
|
|
37
38
|
* The most recent critique snapshot across all targets. Filenames are
|
|
38
39
|
* timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.
|
|
39
|
-
* Parses the small frontmatter for score + P0/P1 counts
|
|
40
|
+
* Parses the small frontmatter for score + P0/P1 counts, plus two "design
|
|
41
|
+
* health" extras: the score trend for the same slug (last 3 runs, with a
|
|
42
|
+
* direction) and the open [P0] issue titles from the snapshot body — enough
|
|
43
|
+
* for a session to start design-health-aware without an LLM pass.
|
|
40
44
|
*/
|
|
41
45
|
function latestCritique(cwd) {
|
|
42
46
|
try {
|
|
@@ -55,13 +59,43 @@ function latestCritique(cwd) {
|
|
|
55
59
|
const n = Number(v);
|
|
56
60
|
return Number.isFinite(n) ? n : null;
|
|
57
61
|
};
|
|
62
|
+
const slug = get('slug');
|
|
63
|
+
// Trend across the last 3 runs for the same slug. Frontmatter written by
|
|
64
|
+
// critique-storage carries `total_score`; older/hand-written snapshots may
|
|
65
|
+
// carry `score` — accept either. Best-effort: never let this probe throw.
|
|
66
|
+
let trend = null;
|
|
67
|
+
try {
|
|
68
|
+
if (slug) {
|
|
69
|
+
const rows = readTrend(slug, { limit: 3, cwd });
|
|
70
|
+
const scores = rows
|
|
71
|
+
.map((r) => num(r.total_score ?? r.score))
|
|
72
|
+
.filter((n) => n !== null);
|
|
73
|
+
if (scores.length) trend = { scores, direction: trendDirection(scores) };
|
|
74
|
+
}
|
|
75
|
+
} catch { /* trend stays null */ }
|
|
76
|
+
let openP0 = [];
|
|
77
|
+
try {
|
|
78
|
+
openP0 = extractIssueLines(text).p0;
|
|
79
|
+
} catch { /* openP0 stays empty */ }
|
|
80
|
+
// Frontmatter key fallbacks: context fixtures use score/p0/p1, the
|
|
81
|
+
// critique flow's MONODESIGN_CRITIQUE_META uses total_score/p0_count/
|
|
82
|
+
// p1_count. `get` returns null for missing keys; keep null (not 0).
|
|
83
|
+
const numKey = (...keys) => {
|
|
84
|
+
for (const k of keys) {
|
|
85
|
+
const v = get(k);
|
|
86
|
+
if (v !== null) return num(v);
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
};
|
|
58
90
|
return {
|
|
59
|
-
slug
|
|
60
|
-
score:
|
|
61
|
-
p0:
|
|
62
|
-
p1:
|
|
91
|
+
slug,
|
|
92
|
+
score: numKey('score', 'total_score'),
|
|
93
|
+
p0: numKey('p0', 'p0_count'),
|
|
94
|
+
p1: numKey('p1', 'p1_count'),
|
|
63
95
|
timestamp: get('timestamp'),
|
|
64
96
|
file: path.relative(cwd, path.join(dir, newest)),
|
|
97
|
+
trend,
|
|
98
|
+
openP0,
|
|
65
99
|
};
|
|
66
100
|
} catch {
|
|
67
101
|
return null;
|