react-doctor 0.0.35 → 0.0.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/cli.js +320 -153
- package/dist/cli.js.map +1 -1
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -1
- package/dist/react-doctor-plugin.d.ts.map +1 -1
- package/dist/react-doctor-plugin.js +549 -1
- package/dist/react-doctor-plugin.js.map +1 -1
- package/dist/skills/react-doctor/SKILL.md +32 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"react-doctor-plugin.d.ts","names":[],"sources":["../src/plugin/types.ts","../src/plugin/index.ts"],"mappings":";UAAiB,gBAAA;EACf,IAAA,EAAM,UAAA;EACN,OAAA;AAAA;AAAA,UAGe,WAAA;EACf,MAAA,GAAS,UAAA,EAAY,gBAAA;EACrB,WAAA;AAAA;AAAA,UAGe,YAAA;EAAA,CACd,QAAA,aAAqB,IAAA,EAAM,UAAA;AAAA;AAAA,UAGb,IAAA;EACf,MAAA,GAAS,OAAA,EAAS,WAAA,KAAgB,YAAA;AAAA;AAAA,UAGnB,UAAA;EACf,IAAA;IAAQ,IAAA;EAAA;EACR,KAAA,EAAO,MAAA,SAAe,IAAA;AAAA;AAAA,UAGP,UAAA;EACf,IAAA;EAAA,CACC,GAAA;AAAA;;;
|
|
1
|
+
{"version":3,"file":"react-doctor-plugin.d.ts","names":[],"sources":["../src/plugin/types.ts","../src/plugin/index.ts"],"mappings":";UAAiB,gBAAA;EACf,IAAA,EAAM,UAAA;EACN,OAAA;AAAA;AAAA,UAGe,WAAA;EACf,MAAA,GAAS,UAAA,EAAY,gBAAA;EACrB,WAAA;AAAA;AAAA,UAGe,YAAA;EAAA,CACd,QAAA,aAAqB,IAAA,EAAM,UAAA;AAAA;AAAA,UAGb,IAAA;EACf,MAAA,GAAS,OAAA,EAAS,WAAA,KAAgB,YAAA;AAAA;AAAA,UAGnB,UAAA;EACf,IAAA;IAAQ,IAAA;EAAA;EACR,KAAA,EAAO,MAAA,SAAe,IAAA;AAAA;AAAA,UAGP,UAAA;EACf,IAAA;EAAA,CACC,GAAA;AAAA;;;cCyGG,MAAA,EAAQ,UAAA"}
|
|
@@ -362,6 +362,24 @@ const LEGACY_SHADOW_STYLE_PROPERTIES = new Set([
|
|
|
362
362
|
"shadowRadius",
|
|
363
363
|
"elevation"
|
|
364
364
|
]);
|
|
365
|
+
const BOUNCE_ANIMATION_NAMES = new Set([
|
|
366
|
+
"bounce",
|
|
367
|
+
"elastic",
|
|
368
|
+
"wobble",
|
|
369
|
+
"jiggle",
|
|
370
|
+
"spring"
|
|
371
|
+
]);
|
|
372
|
+
const Z_INDEX_ABSURD_THRESHOLD = 100;
|
|
373
|
+
const INLINE_STYLE_PROPERTY_THRESHOLD = 8;
|
|
374
|
+
const SIDE_TAB_BORDER_WIDTH_WITHOUT_RADIUS_PX = 3;
|
|
375
|
+
const SIDE_TAB_BORDER_WIDTH_WITH_RADIUS_PX = 1;
|
|
376
|
+
const SIDE_TAB_TAILWIND_WIDTH_WITHOUT_RADIUS = 4;
|
|
377
|
+
const DARK_GLOW_BLUR_THRESHOLD_PX = 4;
|
|
378
|
+
const DARK_BACKGROUND_CHANNEL_MAX = 35;
|
|
379
|
+
const COLOR_CHROMA_THRESHOLD = 30;
|
|
380
|
+
const TINY_TEXT_THRESHOLD_PX = 12;
|
|
381
|
+
const WIDE_TRACKING_THRESHOLD_EM = .05;
|
|
382
|
+
const LONG_TRANSITION_DURATION_THRESHOLD_MS = 1e3;
|
|
365
383
|
|
|
366
384
|
//#endregion
|
|
367
385
|
//#region src/plugin/helpers.ts
|
|
@@ -638,6 +656,521 @@ const clientPassiveEventListeners = { create: (context) => ({ CallExpression(nod
|
|
|
638
656
|
});
|
|
639
657
|
} }) };
|
|
640
658
|
|
|
659
|
+
//#endregion
|
|
660
|
+
//#region src/plugin/rules/design.ts
|
|
661
|
+
const isOvershootCubicBezier = (value) => {
|
|
662
|
+
const match = value.match(/cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/);
|
|
663
|
+
if (!match) return false;
|
|
664
|
+
const controlY1 = parseFloat(match[2]);
|
|
665
|
+
const controlY2 = parseFloat(match[4]);
|
|
666
|
+
return controlY1 < -.1 || controlY1 > 1.1 || controlY2 < -.1 || controlY2 > 1.1;
|
|
667
|
+
};
|
|
668
|
+
const hasBounceAnimationName = (value) => {
|
|
669
|
+
const lowerValue = value.toLowerCase();
|
|
670
|
+
for (const name of BOUNCE_ANIMATION_NAMES) if (lowerValue.includes(name)) return true;
|
|
671
|
+
return false;
|
|
672
|
+
};
|
|
673
|
+
const getStringFromClassNameAttr = (node) => {
|
|
674
|
+
const classAttr = findJsxAttribute(node.attributes ?? [], "className");
|
|
675
|
+
if (!classAttr?.value) return null;
|
|
676
|
+
if (classAttr.value.type === "Literal" && typeof classAttr.value.value === "string") return classAttr.value.value;
|
|
677
|
+
if (classAttr.value.type === "JSXExpressionContainer" && classAttr.value.expression?.type === "Literal" && typeof classAttr.value.expression.value === "string") return classAttr.value.expression.value;
|
|
678
|
+
if (classAttr.value.type === "JSXExpressionContainer" && classAttr.value.expression?.type === "TemplateLiteral" && classAttr.value.expression.quasis?.length === 1) return classAttr.value.expression.quasis[0].value?.raw ?? null;
|
|
679
|
+
return null;
|
|
680
|
+
};
|
|
681
|
+
const getInlineStyleExpression = (node) => {
|
|
682
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "style") return null;
|
|
683
|
+
if (node.value?.type !== "JSXExpressionContainer") return null;
|
|
684
|
+
const expression = node.value.expression;
|
|
685
|
+
if (expression?.type !== "ObjectExpression") return null;
|
|
686
|
+
return expression;
|
|
687
|
+
};
|
|
688
|
+
const getStylePropertyStringValue = (property) => {
|
|
689
|
+
if (property.value?.type === "Literal" && typeof property.value.value === "string") return property.value.value;
|
|
690
|
+
return null;
|
|
691
|
+
};
|
|
692
|
+
const getStylePropertyNumberValue = (property) => {
|
|
693
|
+
if (property.value?.type === "Literal" && typeof property.value.value === "number") return property.value.value;
|
|
694
|
+
if (property.value?.type === "UnaryExpression" && property.value.operator === "-" && property.value.argument?.type === "Literal" && typeof property.value.argument.value === "number") return -property.value.argument.value;
|
|
695
|
+
return null;
|
|
696
|
+
};
|
|
697
|
+
const getStylePropertyKey = (property) => {
|
|
698
|
+
if (property.type !== "Property") return null;
|
|
699
|
+
if (property.key?.type === "Identifier") return property.key.name;
|
|
700
|
+
if (property.key?.type === "Literal" && typeof property.key.value === "string") return property.key.value;
|
|
701
|
+
return null;
|
|
702
|
+
};
|
|
703
|
+
const parseColorToRgb = (value) => {
|
|
704
|
+
const trimmed = value.trim().toLowerCase();
|
|
705
|
+
const hex6Match = trimmed.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
|
|
706
|
+
if (hex6Match) return {
|
|
707
|
+
red: parseInt(hex6Match[1], 16),
|
|
708
|
+
green: parseInt(hex6Match[2], 16),
|
|
709
|
+
blue: parseInt(hex6Match[3], 16)
|
|
710
|
+
};
|
|
711
|
+
const hex3Match = trimmed.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
|
|
712
|
+
if (hex3Match) return {
|
|
713
|
+
red: parseInt(hex3Match[1] + hex3Match[1], 16),
|
|
714
|
+
green: parseInt(hex3Match[2] + hex3Match[2], 16),
|
|
715
|
+
blue: parseInt(hex3Match[3] + hex3Match[3], 16)
|
|
716
|
+
};
|
|
717
|
+
const rgbMatch = trimmed.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
718
|
+
if (rgbMatch) return {
|
|
719
|
+
red: parseInt(rgbMatch[1], 10),
|
|
720
|
+
green: parseInt(rgbMatch[2], 10),
|
|
721
|
+
blue: parseInt(rgbMatch[3], 10)
|
|
722
|
+
};
|
|
723
|
+
return null;
|
|
724
|
+
};
|
|
725
|
+
const hasColorChroma = (parsed) => Math.max(parsed.red, parsed.green, parsed.blue) - Math.min(parsed.red, parsed.green, parsed.blue) >= COLOR_CHROMA_THRESHOLD;
|
|
726
|
+
const isNeutralBorderColor = (value) => {
|
|
727
|
+
const trimmed = value.trim().toLowerCase();
|
|
728
|
+
if ([
|
|
729
|
+
"gray",
|
|
730
|
+
"grey",
|
|
731
|
+
"silver",
|
|
732
|
+
"white",
|
|
733
|
+
"black",
|
|
734
|
+
"transparent",
|
|
735
|
+
"currentcolor"
|
|
736
|
+
].includes(trimmed)) return true;
|
|
737
|
+
const parsed = parseColorToRgb(trimmed);
|
|
738
|
+
if (parsed) return !hasColorChroma(parsed);
|
|
739
|
+
return false;
|
|
740
|
+
};
|
|
741
|
+
const extractBorderColorFromShorthand = (shorthandValue) => {
|
|
742
|
+
const afterSolid = shorthandValue.match(/solid\s+(.+)$/i);
|
|
743
|
+
if (!afterSolid) return null;
|
|
744
|
+
return afterSolid[1].trim();
|
|
745
|
+
};
|
|
746
|
+
const isPureBlackColor = (value) => {
|
|
747
|
+
const trimmed = value.trim().toLowerCase();
|
|
748
|
+
if (trimmed === "#000" || trimmed === "#000000") return true;
|
|
749
|
+
if (/^rgb\(\s*0\s*,\s*0\s*,\s*0\s*\)$/.test(trimmed)) return true;
|
|
750
|
+
return false;
|
|
751
|
+
};
|
|
752
|
+
const splitShadowLayers = (shadowValue) => shadowValue.split(/,(?![^(]*\))/);
|
|
753
|
+
const extractColorFromShadowLayer = (layer) => {
|
|
754
|
+
const rgbMatch = layer.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
755
|
+
if (rgbMatch) return {
|
|
756
|
+
red: parseInt(rgbMatch[1], 10),
|
|
757
|
+
green: parseInt(rgbMatch[2], 10),
|
|
758
|
+
blue: parseInt(rgbMatch[3], 10)
|
|
759
|
+
};
|
|
760
|
+
const hexMatch = layer.match(/#([0-9a-f]{3,6})\b/i);
|
|
761
|
+
if (hexMatch) return parseColorToRgb(`#${hexMatch[1]}`);
|
|
762
|
+
return null;
|
|
763
|
+
};
|
|
764
|
+
const parseShadowLayerBlur = (layer) => {
|
|
765
|
+
const numericTokens = [...layer.replace(/rgba?\([^)]*\)/g, "").replace(/#[0-9a-f]{3,8}\b/gi, "").matchAll(/(\d+(?:\.\d+)?)(px)?/g)].map((match) => parseFloat(match[1]));
|
|
766
|
+
return numericTokens.length >= 3 ? numericTokens[2] : 0;
|
|
767
|
+
};
|
|
768
|
+
const hasColoredGlowShadow = (shadowValue) => {
|
|
769
|
+
for (const layer of splitShadowLayers(shadowValue)) {
|
|
770
|
+
const color = extractColorFromShadowLayer(layer);
|
|
771
|
+
if (color && hasColorChroma(color) && parseShadowLayerBlur(layer) > DARK_GLOW_BLUR_THRESHOLD_PX) return true;
|
|
772
|
+
}
|
|
773
|
+
return false;
|
|
774
|
+
};
|
|
775
|
+
const isBackgroundDark = (bgValue) => {
|
|
776
|
+
const trimmed = bgValue.trim().toLowerCase();
|
|
777
|
+
if (isPureBlackColor(trimmed)) return true;
|
|
778
|
+
const parsed = parseColorToRgb(trimmed);
|
|
779
|
+
if (!parsed) return false;
|
|
780
|
+
return parsed.red <= DARK_BACKGROUND_CHANNEL_MAX && parsed.green <= DARK_BACKGROUND_CHANNEL_MAX && parsed.blue <= DARK_BACKGROUND_CHANNEL_MAX;
|
|
781
|
+
};
|
|
782
|
+
const BORDER_SIDE_KEYS = {
|
|
783
|
+
borderLeft: "left",
|
|
784
|
+
borderRight: "right",
|
|
785
|
+
borderInlineStart: "left",
|
|
786
|
+
borderInlineEnd: "right"
|
|
787
|
+
};
|
|
788
|
+
const BORDER_SIDE_WIDTH_KEYS = new Set([
|
|
789
|
+
"borderLeftWidth",
|
|
790
|
+
"borderRightWidth",
|
|
791
|
+
"borderInlineStartWidth",
|
|
792
|
+
"borderInlineEndWidth"
|
|
793
|
+
]);
|
|
794
|
+
const noInlineBounceEasing = { create: (context) => ({
|
|
795
|
+
JSXAttribute(node) {
|
|
796
|
+
const expression = getInlineStyleExpression(node);
|
|
797
|
+
if (!expression) return;
|
|
798
|
+
for (const property of expression.properties ?? []) {
|
|
799
|
+
const key = getStylePropertyKey(property);
|
|
800
|
+
if (!key) continue;
|
|
801
|
+
const value = getStylePropertyStringValue(property);
|
|
802
|
+
if (!value) continue;
|
|
803
|
+
if ((key === "transition" || key === "transitionTimingFunction" || key === "animation" || key === "animationTimingFunction") && isOvershootCubicBezier(value)) context.report({
|
|
804
|
+
node: property,
|
|
805
|
+
message: "Bounce/elastic easing feels dated — real objects decelerate smoothly. Use ease-out or cubic-bezier(0.16, 1, 0.3, 1) instead"
|
|
806
|
+
});
|
|
807
|
+
if ((key === "animation" || key === "animationName") && hasBounceAnimationName(value)) context.report({
|
|
808
|
+
node: property,
|
|
809
|
+
message: "Bounce/elastic animation name detected — these feel tacky. Use exponential easing (ease-out-quart/expo) for natural deceleration"
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
},
|
|
813
|
+
JSXOpeningElement(node) {
|
|
814
|
+
const classStr = getStringFromClassNameAttr(node);
|
|
815
|
+
if (!classStr) return;
|
|
816
|
+
if (/\banimate-bounce\b/.test(classStr)) context.report({
|
|
817
|
+
node,
|
|
818
|
+
message: "animate-bounce feels dated and tacky — use a subtle ease-out transform for natural deceleration"
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
}) };
|
|
822
|
+
const noZIndex9999 = { create: (context) => ({
|
|
823
|
+
JSXAttribute(node) {
|
|
824
|
+
const expression = getInlineStyleExpression(node);
|
|
825
|
+
if (!expression) return;
|
|
826
|
+
for (const property of expression.properties ?? []) {
|
|
827
|
+
if (getStylePropertyKey(property) !== "zIndex") continue;
|
|
828
|
+
const zValue = getStylePropertyNumberValue(property);
|
|
829
|
+
if (zValue !== null && Math.abs(zValue) >= Z_INDEX_ABSURD_THRESHOLD) context.report({
|
|
830
|
+
node: property,
|
|
831
|
+
message: `z-index: ${zValue} is arbitrarily high — use a deliberate z-index scale (1–50). Extreme values signal a stacking context problem, not a fix`
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
},
|
|
835
|
+
CallExpression(node) {
|
|
836
|
+
if (node.callee?.type !== "MemberExpression") return;
|
|
837
|
+
if (node.callee.property?.type !== "Identifier" || node.callee.property.name !== "create") return;
|
|
838
|
+
if (node.callee.object?.type !== "Identifier" || node.callee.object.name !== "StyleSheet") return;
|
|
839
|
+
const argument = node.arguments?.[0];
|
|
840
|
+
if (!argument || argument.type !== "ObjectExpression") return;
|
|
841
|
+
walkAst(argument, (child) => {
|
|
842
|
+
if (child.type !== "Property") return;
|
|
843
|
+
if (getStylePropertyKey(child) !== "zIndex") return;
|
|
844
|
+
if (child.value?.type === "Literal" && typeof child.value.value === "number") {
|
|
845
|
+
const zValue = child.value.value;
|
|
846
|
+
if (Math.abs(zValue) >= Z_INDEX_ABSURD_THRESHOLD) context.report({
|
|
847
|
+
node: child,
|
|
848
|
+
message: `z-index: ${zValue} is arbitrarily high — use a deliberate z-index scale (1–50). Extreme values signal a stacking context problem, not a fix`
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
}) };
|
|
854
|
+
const noInlineExhaustiveStyle = { create: (context) => ({ JSXAttribute(node) {
|
|
855
|
+
const expression = getInlineStyleExpression(node);
|
|
856
|
+
if (!expression) return;
|
|
857
|
+
const propertyCount = expression.properties?.filter((property) => property.type === "Property").length ?? 0;
|
|
858
|
+
if (propertyCount >= INLINE_STYLE_PROPERTY_THRESHOLD) context.report({
|
|
859
|
+
node: expression,
|
|
860
|
+
message: `${propertyCount} inline style properties — extract to a CSS class, CSS module, or styled component for maintainability and reuse`
|
|
861
|
+
});
|
|
862
|
+
} }) };
|
|
863
|
+
const noSideTabBorder = { create: (context) => ({
|
|
864
|
+
JSXAttribute(node) {
|
|
865
|
+
const expression = getInlineStyleExpression(node);
|
|
866
|
+
if (!expression) return;
|
|
867
|
+
let hasBorderRadius = false;
|
|
868
|
+
for (const property of expression.properties ?? []) if (getStylePropertyKey(property) === "borderRadius") {
|
|
869
|
+
const numValue = getStylePropertyNumberValue(property);
|
|
870
|
+
const strValue = getStylePropertyStringValue(property);
|
|
871
|
+
if (numValue !== null && numValue > 0 || strValue !== null && parseFloat(strValue) > 0) hasBorderRadius = true;
|
|
872
|
+
}
|
|
873
|
+
const threshold = hasBorderRadius ? SIDE_TAB_BORDER_WIDTH_WITH_RADIUS_PX : SIDE_TAB_BORDER_WIDTH_WITHOUT_RADIUS_PX;
|
|
874
|
+
for (const property of expression.properties ?? []) {
|
|
875
|
+
const key = getStylePropertyKey(property);
|
|
876
|
+
if (!key) continue;
|
|
877
|
+
if (key in BORDER_SIDE_KEYS) {
|
|
878
|
+
const value = getStylePropertyStringValue(property);
|
|
879
|
+
if (!value) continue;
|
|
880
|
+
const widthMatch = value.match(/^(\d+)px\s+solid/);
|
|
881
|
+
if (!widthMatch) continue;
|
|
882
|
+
const borderColor = extractBorderColorFromShorthand(value);
|
|
883
|
+
if (borderColor && isNeutralBorderColor(borderColor)) continue;
|
|
884
|
+
const width = parseInt(widthMatch[1], 10);
|
|
885
|
+
if (width >= threshold) context.report({
|
|
886
|
+
node: property,
|
|
887
|
+
message: `Thick one-sided border (${BORDER_SIDE_KEYS[key]}: ${width}px) — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it`
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
if (BORDER_SIDE_WIDTH_KEYS.has(key)) {
|
|
891
|
+
const numValue = getStylePropertyNumberValue(property);
|
|
892
|
+
const strValue = getStylePropertyStringValue(property);
|
|
893
|
+
const width = numValue ?? (strValue !== null ? parseFloat(strValue) : NaN);
|
|
894
|
+
if (isNaN(width)) continue;
|
|
895
|
+
const colorKey = key.replace("Width", "Color");
|
|
896
|
+
if (!expression.properties?.some((colorProperty) => {
|
|
897
|
+
if (getStylePropertyKey(colorProperty) !== colorKey) return false;
|
|
898
|
+
const colorValue = getStylePropertyStringValue(colorProperty);
|
|
899
|
+
return colorValue !== null && !isNeutralBorderColor(colorValue);
|
|
900
|
+
})) continue;
|
|
901
|
+
if (width >= threshold) context.report({
|
|
902
|
+
node: property,
|
|
903
|
+
message: `Thick one-sided border (${width}px) — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it`
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
},
|
|
908
|
+
JSXOpeningElement(node) {
|
|
909
|
+
const classStr = getStringFromClassNameAttr(node);
|
|
910
|
+
if (!classStr) return;
|
|
911
|
+
const sideMatch = classStr.match(/\bborder-[lrse]-(\d+)\b/);
|
|
912
|
+
if (!sideMatch) return;
|
|
913
|
+
if (/\bborder-(?:(?:gray|slate|zinc|neutral|stone)-\d+|white|black|transparent)\b/.test(classStr)) return;
|
|
914
|
+
if (parseInt(sideMatch[1], 10) >= (/\brounded(?:-(?!none\b)\w+)?\b/.test(classStr) && !/\brounded-none\b/.test(classStr) ? SIDE_TAB_BORDER_WIDTH_WITH_RADIUS_PX : SIDE_TAB_TAILWIND_WIDTH_WITHOUT_RADIUS)) context.report({
|
|
915
|
+
node,
|
|
916
|
+
message: `Thick one-sided border (${sideMatch[0]}) — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it`
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
}) };
|
|
920
|
+
const noPureBlackBackground = { create: (context) => ({
|
|
921
|
+
JSXAttribute(node) {
|
|
922
|
+
const expression = getInlineStyleExpression(node);
|
|
923
|
+
if (!expression) return;
|
|
924
|
+
for (const property of expression.properties ?? []) {
|
|
925
|
+
const key = getStylePropertyKey(property);
|
|
926
|
+
if (key !== "backgroundColor" && key !== "background") continue;
|
|
927
|
+
const value = getStylePropertyStringValue(property);
|
|
928
|
+
if (value && isPureBlackColor(value)) context.report({
|
|
929
|
+
node: property,
|
|
930
|
+
message: "Pure #000 background looks harsh — tint slightly toward your brand hue for a more refined feel (e.g. #0a0a0f)"
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
},
|
|
934
|
+
JSXOpeningElement(node) {
|
|
935
|
+
const classStr = getStringFromClassNameAttr(node);
|
|
936
|
+
if (!classStr) return;
|
|
937
|
+
if (/\bbg-black\b(?!\/)/.test(classStr)) context.report({
|
|
938
|
+
node,
|
|
939
|
+
message: "Pure black background (bg-black) looks harsh — use a near-black tinted toward your brand hue (e.g. bg-gray-950)"
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
}) };
|
|
943
|
+
const noGradientText = { create: (context) => ({
|
|
944
|
+
JSXAttribute(node) {
|
|
945
|
+
const expression = getInlineStyleExpression(node);
|
|
946
|
+
if (!expression) return;
|
|
947
|
+
let hasBackgroundClipText = false;
|
|
948
|
+
let hasGradientBackground = false;
|
|
949
|
+
for (const property of expression.properties ?? []) {
|
|
950
|
+
const key = getStylePropertyKey(property);
|
|
951
|
+
const value = getStylePropertyStringValue(property);
|
|
952
|
+
if (!key || !value) continue;
|
|
953
|
+
if ((key === "backgroundClip" || key === "WebkitBackgroundClip") && value === "text") hasBackgroundClipText = true;
|
|
954
|
+
if ((key === "backgroundImage" || key === "background") && value.includes("gradient")) hasGradientBackground = true;
|
|
955
|
+
}
|
|
956
|
+
if (hasBackgroundClipText && hasGradientBackground) context.report({
|
|
957
|
+
node,
|
|
958
|
+
message: "Gradient text (background-clip: text) is decorative rather than meaningful — a common AI tell. Use solid colors for text"
|
|
959
|
+
});
|
|
960
|
+
},
|
|
961
|
+
JSXOpeningElement(node) {
|
|
962
|
+
const classStr = getStringFromClassNameAttr(node);
|
|
963
|
+
if (!classStr) return;
|
|
964
|
+
if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) context.report({
|
|
965
|
+
node,
|
|
966
|
+
message: "Gradient text (bg-clip-text + bg-gradient) is decorative rather than meaningful — a common AI tell. Use solid colors for text"
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
}) };
|
|
970
|
+
const noDarkModeGlow = { create: (context) => ({ JSXAttribute(node) {
|
|
971
|
+
const expression = getInlineStyleExpression(node);
|
|
972
|
+
if (!expression) return;
|
|
973
|
+
let hasDarkBackground = false;
|
|
974
|
+
let shadowProperty = null;
|
|
975
|
+
let shadowValue = null;
|
|
976
|
+
for (const property of expression.properties ?? []) {
|
|
977
|
+
const key = getStylePropertyKey(property);
|
|
978
|
+
if (!key) continue;
|
|
979
|
+
if (key === "backgroundColor" || key === "background") {
|
|
980
|
+
const value = getStylePropertyStringValue(property);
|
|
981
|
+
if (value && isBackgroundDark(value)) hasDarkBackground = true;
|
|
982
|
+
}
|
|
983
|
+
if (key === "boxShadow") {
|
|
984
|
+
shadowProperty = property;
|
|
985
|
+
shadowValue = getStylePropertyStringValue(property);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
if (!hasDarkBackground || !shadowValue || !shadowProperty) return;
|
|
989
|
+
if (hasColoredGlowShadow(shadowValue)) context.report({
|
|
990
|
+
node: shadowProperty,
|
|
991
|
+
message: "Colored glow on dark background — the default AI-generated 'cool' look. Use subtle, purposeful lighting instead"
|
|
992
|
+
});
|
|
993
|
+
} }) };
|
|
994
|
+
const noJustifiedText = { create: (context) => ({ JSXAttribute(node) {
|
|
995
|
+
const expression = getInlineStyleExpression(node);
|
|
996
|
+
if (!expression) return;
|
|
997
|
+
let isJustified = false;
|
|
998
|
+
let hasHyphens = false;
|
|
999
|
+
for (const property of expression.properties ?? []) {
|
|
1000
|
+
const key = getStylePropertyKey(property);
|
|
1001
|
+
const value = getStylePropertyStringValue(property);
|
|
1002
|
+
if (!key || !value) continue;
|
|
1003
|
+
if (key === "textAlign" && value === "justify") isJustified = true;
|
|
1004
|
+
if ((key === "hyphens" || key === "WebkitHyphens") && value === "auto") hasHyphens = true;
|
|
1005
|
+
}
|
|
1006
|
+
if (isJustified && !hasHyphens) context.report({
|
|
1007
|
+
node,
|
|
1008
|
+
message: "Justified text without hyphens creates uneven word spacing (\"rivers of white\"). Use text-align: left, or add hyphens: auto"
|
|
1009
|
+
});
|
|
1010
|
+
} }) };
|
|
1011
|
+
const noTinyText = { create: (context) => ({ JSXAttribute(node) {
|
|
1012
|
+
const expression = getInlineStyleExpression(node);
|
|
1013
|
+
if (!expression) return;
|
|
1014
|
+
for (const property of expression.properties ?? []) {
|
|
1015
|
+
if (getStylePropertyKey(property) !== "fontSize") continue;
|
|
1016
|
+
let pxValue = null;
|
|
1017
|
+
const numValue = getStylePropertyNumberValue(property);
|
|
1018
|
+
const strValue = getStylePropertyStringValue(property);
|
|
1019
|
+
if (numValue !== null) pxValue = numValue;
|
|
1020
|
+
else if (strValue !== null) {
|
|
1021
|
+
const pxMatch = strValue.match(/^([\d.]+)px$/);
|
|
1022
|
+
if (pxMatch) pxValue = parseFloat(pxMatch[1]);
|
|
1023
|
+
const remMatch = strValue.match(/^([\d.]+)rem$/);
|
|
1024
|
+
if (remMatch) pxValue = parseFloat(remMatch[1]) * 16;
|
|
1025
|
+
}
|
|
1026
|
+
if (pxValue !== null && pxValue > 0 && pxValue < TINY_TEXT_THRESHOLD_PX) context.report({
|
|
1027
|
+
node: property,
|
|
1028
|
+
message: `Font size ${pxValue}px is too small — body text should be at least ${TINY_TEXT_THRESHOLD_PX}px for readability, 16px is ideal`
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
} }) };
|
|
1032
|
+
const noWideLetterSpacing = { create: (context) => ({ JSXAttribute(node) {
|
|
1033
|
+
const expression = getInlineStyleExpression(node);
|
|
1034
|
+
if (!expression) return;
|
|
1035
|
+
let isUppercase = false;
|
|
1036
|
+
let letterSpacingProperty = null;
|
|
1037
|
+
let letterSpacingEm = null;
|
|
1038
|
+
for (const property of expression.properties ?? []) {
|
|
1039
|
+
const key = getStylePropertyKey(property);
|
|
1040
|
+
if (!key) continue;
|
|
1041
|
+
if (key === "textTransform") {
|
|
1042
|
+
if (getStylePropertyStringValue(property) === "uppercase") isUppercase = true;
|
|
1043
|
+
}
|
|
1044
|
+
if (key === "letterSpacing") {
|
|
1045
|
+
letterSpacingProperty = property;
|
|
1046
|
+
const strValue = getStylePropertyStringValue(property);
|
|
1047
|
+
const numValue = getStylePropertyNumberValue(property);
|
|
1048
|
+
if (strValue) {
|
|
1049
|
+
const emMatch = strValue.match(/^([\d.]+)em$/);
|
|
1050
|
+
if (emMatch) letterSpacingEm = parseFloat(emMatch[1]);
|
|
1051
|
+
const pxMatch = strValue.match(/^([\d.]+)px$/);
|
|
1052
|
+
if (pxMatch) letterSpacingEm = parseFloat(pxMatch[1]) / 16;
|
|
1053
|
+
}
|
|
1054
|
+
if (numValue !== null && numValue > 0) letterSpacingEm = numValue / 16;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
if (!isUppercase && letterSpacingProperty && letterSpacingEm !== null && letterSpacingEm > WIDE_TRACKING_THRESHOLD_EM) context.report({
|
|
1058
|
+
node: letterSpacingProperty,
|
|
1059
|
+
message: `Letter spacing ${letterSpacingEm.toFixed(2)}em on body text disrupts natural character groupings. Reserve wide tracking for short uppercase labels only`
|
|
1060
|
+
});
|
|
1061
|
+
} }) };
|
|
1062
|
+
const noGrayOnColoredBackground = { create: (context) => ({ JSXOpeningElement(node) {
|
|
1063
|
+
const classStr = getStringFromClassNameAttr(node);
|
|
1064
|
+
if (!classStr) return;
|
|
1065
|
+
const grayTextMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
|
1066
|
+
const coloredBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
|
1067
|
+
if (grayTextMatch && coloredBgMatch) context.report({
|
|
1068
|
+
node,
|
|
1069
|
+
message: `Gray text (${grayTextMatch[0]}) on colored background (${coloredBgMatch[0]}) looks washed out — use a darker shade of the background color or white`
|
|
1070
|
+
});
|
|
1071
|
+
} }) };
|
|
1072
|
+
const noLayoutTransitionInline = { create: (context) => ({ JSXAttribute(node) {
|
|
1073
|
+
const expression = getInlineStyleExpression(node);
|
|
1074
|
+
if (!expression) return;
|
|
1075
|
+
for (const property of expression.properties ?? []) {
|
|
1076
|
+
const key = getStylePropertyKey(property);
|
|
1077
|
+
if (key !== "transition" && key !== "transitionProperty") continue;
|
|
1078
|
+
const value = getStylePropertyStringValue(property);
|
|
1079
|
+
if (!value) continue;
|
|
1080
|
+
const lower = value.toLowerCase();
|
|
1081
|
+
if (/\ball\b/.test(lower)) continue;
|
|
1082
|
+
const layoutMatch = lower.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/);
|
|
1083
|
+
if (layoutMatch) context.report({
|
|
1084
|
+
node: property,
|
|
1085
|
+
message: `Transitioning layout property "${layoutMatch[0]}" causes layout thrash every frame — use transform and opacity instead`
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
} }) };
|
|
1089
|
+
const noDisabledZoom = { create: (context) => ({ JSXOpeningElement(node) {
|
|
1090
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "meta") return;
|
|
1091
|
+
const nameAttr = findJsxAttribute(node.attributes ?? [], "name");
|
|
1092
|
+
if (!nameAttr?.value) return;
|
|
1093
|
+
if ((nameAttr.value.type === "Literal" ? nameAttr.value.value : null) !== "viewport") return;
|
|
1094
|
+
const contentAttr = findJsxAttribute(node.attributes ?? [], "content");
|
|
1095
|
+
if (!contentAttr?.value) return;
|
|
1096
|
+
const contentValue = contentAttr.value.type === "Literal" && typeof contentAttr.value.value === "string" ? contentAttr.value.value : null;
|
|
1097
|
+
if (!contentValue) return;
|
|
1098
|
+
const hasUserScalableNo = /user-scalable\s*=\s*no/i.test(contentValue);
|
|
1099
|
+
const maxScaleMatch = contentValue.match(/maximum-scale\s*=\s*([\d.]+)/i);
|
|
1100
|
+
const hasRestrictiveMaxScale = maxScaleMatch !== null && parseFloat(maxScaleMatch[1]) < 2;
|
|
1101
|
+
if (hasUserScalableNo && hasRestrictiveMaxScale) context.report({
|
|
1102
|
+
node,
|
|
1103
|
+
message: `user-scalable=no and maximum-scale=${maxScaleMatch[1]} disable pinch-to-zoom — this is an accessibility violation (WCAG 1.4.4). Remove both and fix layout if it breaks at 200% zoom`
|
|
1104
|
+
});
|
|
1105
|
+
else if (hasUserScalableNo) context.report({
|
|
1106
|
+
node,
|
|
1107
|
+
message: "user-scalable=no disables pinch-to-zoom — this is an accessibility violation (WCAG 1.4.4). Remove it and fix layout if it breaks at 200% zoom"
|
|
1108
|
+
});
|
|
1109
|
+
else if (hasRestrictiveMaxScale) context.report({
|
|
1110
|
+
node,
|
|
1111
|
+
message: `maximum-scale=${maxScaleMatch[1]} restricts zoom below 200% — this is an accessibility violation (WCAG 1.4.4). Use maximum-scale=5 or remove it`
|
|
1112
|
+
});
|
|
1113
|
+
} }) };
|
|
1114
|
+
const noOutlineNone = { create: (context) => ({ JSXAttribute(node) {
|
|
1115
|
+
const expression = getInlineStyleExpression(node);
|
|
1116
|
+
if (!expression) return;
|
|
1117
|
+
let hasOutlineNone = false;
|
|
1118
|
+
let outlineProperty = null;
|
|
1119
|
+
for (const property of expression.properties ?? []) {
|
|
1120
|
+
if (getStylePropertyKey(property) !== "outline") continue;
|
|
1121
|
+
const strValue = getStylePropertyStringValue(property);
|
|
1122
|
+
const numValue = getStylePropertyNumberValue(property);
|
|
1123
|
+
if (strValue === "none" || strValue === "0" || numValue === 0) {
|
|
1124
|
+
hasOutlineNone = true;
|
|
1125
|
+
outlineProperty = property;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
if (!hasOutlineNone || !outlineProperty) return;
|
|
1129
|
+
if (!expression.properties?.some((property) => {
|
|
1130
|
+
return getStylePropertyKey(property) === "boxShadow";
|
|
1131
|
+
})) context.report({
|
|
1132
|
+
node: outlineProperty,
|
|
1133
|
+
message: "outline: none removes keyboard focus visibility — use :focus-visible styling instead, or provide a box-shadow focus ring"
|
|
1134
|
+
});
|
|
1135
|
+
} }) };
|
|
1136
|
+
const noLongTransitionDuration = { create: (context) => ({ JSXAttribute(node) {
|
|
1137
|
+
const expression = getInlineStyleExpression(node);
|
|
1138
|
+
if (!expression) return;
|
|
1139
|
+
for (const property of expression.properties ?? []) {
|
|
1140
|
+
const key = getStylePropertyKey(property);
|
|
1141
|
+
if (!key) continue;
|
|
1142
|
+
const value = getStylePropertyStringValue(property);
|
|
1143
|
+
if (!value) continue;
|
|
1144
|
+
let durationMs = null;
|
|
1145
|
+
if (key === "transitionDuration" || key === "animationDuration") {
|
|
1146
|
+
let longestDurationPropertyMs = 0;
|
|
1147
|
+
for (const segment of value.split(",")) {
|
|
1148
|
+
const trimmedSegment = segment.trim();
|
|
1149
|
+
const msMatch = trimmedSegment.match(/^([\d.]+)ms$/);
|
|
1150
|
+
const secondsMatch = trimmedSegment.match(/^([\d.]+)s$/);
|
|
1151
|
+
if (msMatch) longestDurationPropertyMs = Math.max(longestDurationPropertyMs, parseFloat(msMatch[1]));
|
|
1152
|
+
else if (secondsMatch) longestDurationPropertyMs = Math.max(longestDurationPropertyMs, parseFloat(secondsMatch[1]) * 1e3);
|
|
1153
|
+
}
|
|
1154
|
+
if (longestDurationPropertyMs > 0) durationMs = longestDurationPropertyMs;
|
|
1155
|
+
}
|
|
1156
|
+
if (key === "transition" || key === "animation") {
|
|
1157
|
+
let longestDurationMs = 0;
|
|
1158
|
+
const segments = value.split(",");
|
|
1159
|
+
for (const segment of segments) {
|
|
1160
|
+
const firstTimeMatch = segment.match(/(?<![a-zA-Z\d])([\d.]+)(m?s)(?![a-zA-Z\d-])/);
|
|
1161
|
+
if (!firstTimeMatch) continue;
|
|
1162
|
+
const segmentDurationMs = firstTimeMatch[2] === "ms" ? parseFloat(firstTimeMatch[1]) : parseFloat(firstTimeMatch[1]) * 1e3;
|
|
1163
|
+
longestDurationMs = Math.max(longestDurationMs, segmentDurationMs);
|
|
1164
|
+
}
|
|
1165
|
+
if (longestDurationMs > 0) durationMs = longestDurationMs;
|
|
1166
|
+
}
|
|
1167
|
+
if (durationMs !== null && durationMs > LONG_TRANSITION_DURATION_THRESHOLD_MS) context.report({
|
|
1168
|
+
node: property,
|
|
1169
|
+
message: `${durationMs}ms transition is too slow for UI feedback — keep transitions under ${LONG_TRANSITION_DURATION_THRESHOLD_MS}ms. Use longer durations only for page-load hero animations`
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
} }) };
|
|
1173
|
+
|
|
641
1174
|
//#endregion
|
|
642
1175
|
//#region src/plugin/rules/correctness.ts
|
|
643
1176
|
const extractIndexName = (node) => {
|
|
@@ -2303,7 +2836,22 @@ const plugin = {
|
|
|
2303
2836
|
"query-no-void-query-fn": queryNoVoidQueryFn,
|
|
2304
2837
|
"query-no-query-in-effect": queryNoQueryInEffect,
|
|
2305
2838
|
"query-mutation-missing-invalidation": queryMutationMissingInvalidation,
|
|
2306
|
-
"query-no-usequery-for-mutation": queryNoUseQueryForMutation
|
|
2839
|
+
"query-no-usequery-for-mutation": queryNoUseQueryForMutation,
|
|
2840
|
+
"no-inline-bounce-easing": noInlineBounceEasing,
|
|
2841
|
+
"no-z-index-9999": noZIndex9999,
|
|
2842
|
+
"no-inline-exhaustive-style": noInlineExhaustiveStyle,
|
|
2843
|
+
"no-side-tab-border": noSideTabBorder,
|
|
2844
|
+
"no-pure-black-background": noPureBlackBackground,
|
|
2845
|
+
"no-gradient-text": noGradientText,
|
|
2846
|
+
"no-dark-mode-glow": noDarkModeGlow,
|
|
2847
|
+
"no-justified-text": noJustifiedText,
|
|
2848
|
+
"no-tiny-text": noTinyText,
|
|
2849
|
+
"no-wide-letter-spacing": noWideLetterSpacing,
|
|
2850
|
+
"no-gray-on-colored-background": noGrayOnColoredBackground,
|
|
2851
|
+
"no-layout-transition-inline": noLayoutTransitionInline,
|
|
2852
|
+
"no-disabled-zoom": noDisabledZoom,
|
|
2853
|
+
"no-outline-none": noOutlineNone,
|
|
2854
|
+
"no-long-transition-duration": noLongTransitionDuration
|
|
2307
2855
|
}
|
|
2308
2856
|
};
|
|
2309
2857
|
|