shadscan-vue 0.1.2 → 0.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/CHANGELOG.md +18 -0
- package/README.md +13 -0
- package/dist/index.js +649 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -771,6 +771,145 @@ const hasScreenReaderText = (element) => {
|
|
|
771
771
|
};
|
|
772
772
|
const elementChildren = (element) => element.children.filter((child) => child.type === NodeTypes.ELEMENT);
|
|
773
773
|
//#endregion
|
|
774
|
+
//#region src/rules/accessibility/custom-controls-have-labels.ts
|
|
775
|
+
/**
|
|
776
|
+
* Controls that render no intrinsic text, so a screen reader has nothing to
|
|
777
|
+
* announce unless the author supplies a name. SelectTrigger is included rather
|
|
778
|
+
* than Select because the trigger is the focusable control.
|
|
779
|
+
*/
|
|
780
|
+
const CUSTOM_CONTROL_TAGS = /* @__PURE__ */ new Set([
|
|
781
|
+
"checkbox",
|
|
782
|
+
"combobox",
|
|
783
|
+
"input-otp",
|
|
784
|
+
"radio-group",
|
|
785
|
+
"select-trigger",
|
|
786
|
+
"slider",
|
|
787
|
+
"switch",
|
|
788
|
+
"toggle"
|
|
789
|
+
]);
|
|
790
|
+
const LABEL_TAGS = /* @__PURE__ */ new Set([
|
|
791
|
+
"label",
|
|
792
|
+
"form-label",
|
|
793
|
+
"field-label"
|
|
794
|
+
]);
|
|
795
|
+
const isCustomControl = (tag) => CUSTOM_CONTROL_TAGS.has(pascalToKebab(tag));
|
|
796
|
+
const customControlsHaveLabels = {
|
|
797
|
+
id: "custom-controls-have-labels",
|
|
798
|
+
title: "Custom controls have accessible labels",
|
|
799
|
+
description: "Controls such as Checkbox, Switch, Slider, and SelectTrigger render no text of their own, so each needs a label, an aria-label, or an aria-labelledby reference.",
|
|
800
|
+
category: "accessibility",
|
|
801
|
+
severity: "error",
|
|
802
|
+
confidence: "medium",
|
|
803
|
+
maxScore: 4,
|
|
804
|
+
adapters: [
|
|
805
|
+
"nuxt",
|
|
806
|
+
"vite-vue",
|
|
807
|
+
"generic-vue"
|
|
808
|
+
],
|
|
809
|
+
run: async ({ sources, result }) => {
|
|
810
|
+
const files = await sources();
|
|
811
|
+
const labelTargets = /* @__PURE__ */ new Map();
|
|
812
|
+
forEachElement$1(files, (element, file) => {
|
|
813
|
+
if (!LABEL_TAGS.has(pascalToKebab(element.tag))) return;
|
|
814
|
+
const target = findAttribute(element, "for")?.static;
|
|
815
|
+
if (target === void 0) return;
|
|
816
|
+
const targets = labelTargets.get(file.relPath) ?? /* @__PURE__ */ new Set();
|
|
817
|
+
targets.add(target);
|
|
818
|
+
labelTargets.set(file.relPath, targets);
|
|
819
|
+
});
|
|
820
|
+
const findings = [];
|
|
821
|
+
let evaluated = 0;
|
|
822
|
+
forEachElement$1(files, (element, file, ancestors) => {
|
|
823
|
+
if (!isCustomControl(element.tag) || isGeneratedUiPrimitive(file.relPath)) return;
|
|
824
|
+
evaluated += 1;
|
|
825
|
+
const id = findAttribute(element, "id")?.static;
|
|
826
|
+
const labelledByFor = id !== void 0 && labelTargets.get(file.relPath)?.has(id) === true;
|
|
827
|
+
const wrappedInLabel = ancestors.some((ancestor) => LABEL_TAGS.has(pascalToKebab(ancestor.tag)));
|
|
828
|
+
if (labelledByFor || wrappedInLabel || hasAriaName(element)) return;
|
|
829
|
+
findings.push({
|
|
830
|
+
message: `<${element.tag}> has no accessible name.`,
|
|
831
|
+
evidence: [{
|
|
832
|
+
path: file.relPath,
|
|
833
|
+
line: elementLine(element)
|
|
834
|
+
}],
|
|
835
|
+
remediation: "Associate a <Label for=\"…\"> with the control id, wrap the control in a label, or add aria-label / aria-labelledby."
|
|
836
|
+
});
|
|
837
|
+
});
|
|
838
|
+
if (evaluated === 0) return result.notApplicable();
|
|
839
|
+
return findings.length > 0 ? result.fail(findings) : result.pass();
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
//#endregion
|
|
843
|
+
//#region src/rules/accessibility/dialog-focus-trap-works.ts
|
|
844
|
+
const DIALOG_TAGS = /* @__PURE__ */ new Set([
|
|
845
|
+
"dialog",
|
|
846
|
+
"dialog-root",
|
|
847
|
+
"dialog-content",
|
|
848
|
+
"alert-dialog",
|
|
849
|
+
"alert-dialog-root",
|
|
850
|
+
"alert-dialog-content",
|
|
851
|
+
"drawer",
|
|
852
|
+
"drawer-content",
|
|
853
|
+
"sheet",
|
|
854
|
+
"sheet-content"
|
|
855
|
+
]);
|
|
856
|
+
const DIALOG_ROLES = /* @__PURE__ */ new Set(["dialog", "alertdialog"]);
|
|
857
|
+
/**
|
|
858
|
+
* reka-ui is the headless layer shadcn-vue is built on, so its dialog
|
|
859
|
+
* primitives already own focus containment and restoration. Vaul-vue is the
|
|
860
|
+
* drawer equivalent.
|
|
861
|
+
*/
|
|
862
|
+
const FOCUS_MANAGED_DEPENDENCIES = [
|
|
863
|
+
"reka-ui",
|
|
864
|
+
"radix-vue",
|
|
865
|
+
"vaul-vue",
|
|
866
|
+
"@headlessui/vue"
|
|
867
|
+
];
|
|
868
|
+
const isDialogTag = (tag) => DIALOG_TAGS.has(pascalToKebab(tag));
|
|
869
|
+
const findDialogSurface = (files) => {
|
|
870
|
+
for (const file of files) {
|
|
871
|
+
if (file.sfc?.templateAst === void 0 || isGeneratedUiPrimitive(file.relPath)) continue;
|
|
872
|
+
let found;
|
|
873
|
+
walkTemplate(file.sfc.templateAst, (element) => {
|
|
874
|
+
if (found !== void 0) return;
|
|
875
|
+
const role = findAttribute(element, "role")?.static;
|
|
876
|
+
if (isDialogTag(element.tag) || role !== void 0 && DIALOG_ROLES.has(role)) found = elementLine(element);
|
|
877
|
+
});
|
|
878
|
+
if (found !== void 0) return {
|
|
879
|
+
file,
|
|
880
|
+
line: found
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
const dialogFocusTrapWorks = {
|
|
885
|
+
id: "dialog-focus-trap-works",
|
|
886
|
+
title: "Dialogs trap and restore focus",
|
|
887
|
+
description: "Looks for a focus-managed primitive behind dialog-like surfaces. Focus containment and restoration cannot be proven statically, so this reports what backs the dialog rather than scoring it.",
|
|
888
|
+
category: "accessibility",
|
|
889
|
+
severity: "warning",
|
|
890
|
+
confidence: "low",
|
|
891
|
+
maxScore: 0,
|
|
892
|
+
adapters: [
|
|
893
|
+
"nuxt",
|
|
894
|
+
"vite-vue",
|
|
895
|
+
"generic-vue"
|
|
896
|
+
],
|
|
897
|
+
run: async ({ sources, discovery, result }) => {
|
|
898
|
+
const files = await sources();
|
|
899
|
+
const surface = findDialogSurface(files);
|
|
900
|
+
if (surface === void 0) return result.notApplicable();
|
|
901
|
+
if (FOCUS_MANAGED_DEPENDENCIES.find((dependency) => discovery.dependencies[dependency] !== void 0) !== void 0) return result.pass();
|
|
902
|
+
return result.advisory([{
|
|
903
|
+
message: "A dialog surface was found with no focus-managed primitive behind it.",
|
|
904
|
+
evidence: [{
|
|
905
|
+
path: surface.file.relPath,
|
|
906
|
+
line: surface.line
|
|
907
|
+
}],
|
|
908
|
+
remediation: "Build the dialog on reka-ui (the layer shadcn-vue uses) or another focus-managed primitive, then verify initial focus, Tab containment, Escape, and focus return in a browser."
|
|
909
|
+
}]);
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
//#endregion
|
|
774
913
|
//#region src/rules/accessibility/dialogs-have-accessible-names.ts
|
|
775
914
|
/**
|
|
776
915
|
* shadcn-vue dialog surfaces wrap reka-ui primitives. Both the shadcn wrapper
|
|
@@ -839,6 +978,130 @@ const dialogsHaveAccessibleNames = {
|
|
|
839
978
|
}
|
|
840
979
|
};
|
|
841
980
|
//#endregion
|
|
981
|
+
//#region src/rules/accessibility/keyboard-navigation-works.ts
|
|
982
|
+
const COMPOSITE_WIDGET_TAGS = /* @__PURE__ */ new Set([
|
|
983
|
+
"accordion",
|
|
984
|
+
"accordion-root",
|
|
985
|
+
"combobox",
|
|
986
|
+
"combobox-root",
|
|
987
|
+
"command",
|
|
988
|
+
"dropdown-menu",
|
|
989
|
+
"dropdown-menu-root",
|
|
990
|
+
"listbox",
|
|
991
|
+
"menu",
|
|
992
|
+
"menubar",
|
|
993
|
+
"navigation-menu",
|
|
994
|
+
"select",
|
|
995
|
+
"select-root",
|
|
996
|
+
"tabs",
|
|
997
|
+
"tabs-root",
|
|
998
|
+
"tree"
|
|
999
|
+
]);
|
|
1000
|
+
const COMPOSITE_ROLES = /* @__PURE__ */ new Set([
|
|
1001
|
+
"grid",
|
|
1002
|
+
"listbox",
|
|
1003
|
+
"menu",
|
|
1004
|
+
"menubar",
|
|
1005
|
+
"tablist",
|
|
1006
|
+
"tree"
|
|
1007
|
+
]);
|
|
1008
|
+
const KEYBOARD_PRIMITIVE_DEPENDENCIES = [
|
|
1009
|
+
"reka-ui",
|
|
1010
|
+
"radix-vue",
|
|
1011
|
+
"@headlessui/vue",
|
|
1012
|
+
"@ariakit/vue",
|
|
1013
|
+
"@zag-js/vue"
|
|
1014
|
+
];
|
|
1015
|
+
const isCompositeTag = (tag) => COMPOSITE_WIDGET_TAGS.has(pascalToKebab(tag));
|
|
1016
|
+
const findCompositeWidget = (files) => {
|
|
1017
|
+
for (const file of files) {
|
|
1018
|
+
if (file.sfc?.templateAst === void 0 || isGeneratedUiPrimitive(file.relPath)) continue;
|
|
1019
|
+
let found;
|
|
1020
|
+
walkTemplate(file.sfc.templateAst, (element) => {
|
|
1021
|
+
if (found !== void 0) return;
|
|
1022
|
+
const role = findAttribute(element, "role")?.static;
|
|
1023
|
+
if (isCompositeTag(element.tag) || role !== void 0 && COMPOSITE_ROLES.has(role)) found = elementLine(element);
|
|
1024
|
+
});
|
|
1025
|
+
if (found !== void 0) return {
|
|
1026
|
+
file,
|
|
1027
|
+
line: found
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
const keyboardNavigationWorks = {
|
|
1032
|
+
id: "keyboard-navigation-works",
|
|
1033
|
+
title: "Composite widgets support keyboard navigation",
|
|
1034
|
+
description: "Looks for a keyboard-aware primitive behind composite widgets such as menus, tabs, and listboxes. Arrow-key, Home/End, and focus behaviour cannot be proven statically, so this reports what backs the widget rather than scoring it.",
|
|
1035
|
+
category: "accessibility",
|
|
1036
|
+
severity: "warning",
|
|
1037
|
+
confidence: "low",
|
|
1038
|
+
maxScore: 0,
|
|
1039
|
+
adapters: [
|
|
1040
|
+
"nuxt",
|
|
1041
|
+
"vite-vue",
|
|
1042
|
+
"generic-vue"
|
|
1043
|
+
],
|
|
1044
|
+
run: async ({ sources, discovery, result }) => {
|
|
1045
|
+
const files = await sources();
|
|
1046
|
+
const widget = findCompositeWidget(files);
|
|
1047
|
+
if (widget === void 0) return result.notApplicable();
|
|
1048
|
+
if (Object.keys(discovery.dependencies).find((dependency) => dependency.startsWith("@zag-js/") || KEYBOARD_PRIMITIVE_DEPENDENCIES.includes(dependency)) !== void 0) return result.pass();
|
|
1049
|
+
return result.advisory([{
|
|
1050
|
+
message: "A composite widget was found with no keyboard-aware primitive behind it.",
|
|
1051
|
+
evidence: [{
|
|
1052
|
+
path: widget.file.relPath,
|
|
1053
|
+
line: widget.line
|
|
1054
|
+
}],
|
|
1055
|
+
remediation: "Build composite widgets on reka-ui (the layer shadcn-vue uses), or implement and test the widget's arrow-key, Home/End, Enter, Escape, and focus behaviour."
|
|
1056
|
+
}]);
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
//#endregion
|
|
1060
|
+
//#region src/rules/accessibility/status-messages-announced.ts
|
|
1061
|
+
/**
|
|
1062
|
+
* `error.statusMessage` is Nuxt's static field on a full-page error view, not
|
|
1063
|
+
* a status update pushed into an already-rendered page, so the leading
|
|
1064
|
+
* boundary excludes a property access.
|
|
1065
|
+
*/
|
|
1066
|
+
const DYNAMIC_STATUS_PATTERN = /(?<![.\w])(?:statusMessage|successMessage|errorMessage|progressMessage|setStatus|setMessage)\b|(?<![.\w])(?:pending|isPending|isLoading|loading)\s*\?/u;
|
|
1067
|
+
const ANNOUNCEMENT_PATTERN = /role\s*=\s*["'](?:status|alert)["']|aria-live\s*=|<(?:Toaster|ToastProvider|Sonner)\b|\btoast(?:\.|\s*\()/u;
|
|
1068
|
+
const lineOf$5 = (text, pattern) => {
|
|
1069
|
+
const index = text.search(pattern);
|
|
1070
|
+
if (index < 0) return 1;
|
|
1071
|
+
return text.slice(0, index).split("\n").length;
|
|
1072
|
+
};
|
|
1073
|
+
const statusMessagesAnnounced = {
|
|
1074
|
+
id: "status-messages-announced",
|
|
1075
|
+
title: "Status messages are announced",
|
|
1076
|
+
description: "Dynamic status messages should reach assistive technology through a live region or an accessible toast channel, otherwise a sighted user sees the update and a screen reader user does not.",
|
|
1077
|
+
category: "accessibility",
|
|
1078
|
+
severity: "warning",
|
|
1079
|
+
confidence: "medium",
|
|
1080
|
+
maxScore: 3,
|
|
1081
|
+
adapters: [
|
|
1082
|
+
"nuxt",
|
|
1083
|
+
"vite-vue",
|
|
1084
|
+
"generic-vue"
|
|
1085
|
+
],
|
|
1086
|
+
run: async ({ sources, result }) => {
|
|
1087
|
+
const candidates = (await sources()).filter((file) => file.kind === "vue" && !isGeneratedUiPrimitive(file.relPath) && DYNAMIC_STATUS_PATTERN.test(file.text));
|
|
1088
|
+
if (candidates.length === 0) return result.notApplicable();
|
|
1089
|
+
const findings = [];
|
|
1090
|
+
for (const file of candidates) {
|
|
1091
|
+
if (ANNOUNCEMENT_PATTERN.test(file.text)) continue;
|
|
1092
|
+
findings.push({
|
|
1093
|
+
message: "A dynamic status message has no live region or accessible toast channel.",
|
|
1094
|
+
evidence: [{
|
|
1095
|
+
path: file.relPath,
|
|
1096
|
+
line: lineOf$5(file.text, DYNAMIC_STATUS_PATTERN)
|
|
1097
|
+
}],
|
|
1098
|
+
remediation: "Render the update inside role=\"status\" or role=\"alert\" (or an aria-live region), or deliver it through a mounted toast provider."
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
return findings.length > 0 ? result.fail(findings) : result.pass();
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
//#endregion
|
|
842
1105
|
//#region src/rules/accessibility/heading-structure-sane.ts
|
|
843
1106
|
const HEADING_TAG = /^h([1-6])$/u;
|
|
844
1107
|
const isRoutableSurface = (relPath) => relPath.startsWith("pages/") || relPath.startsWith("app/pages/") || relPath.startsWith("src/views/") || relPath.startsWith("layouts/") || relPath.startsWith("app/layouts/") || relPath === "app.vue" || relPath === "app/app.vue" || relPath === "App.vue" || relPath === "src/App.vue";
|
|
@@ -1948,7 +2211,7 @@ const FOCUS_REPLACEMENT_CLASS_PATTERN = /\bfocus(?:-visible)?:(?:ring|outline|bo
|
|
|
1948
2211
|
const isExcludedPath = (relPath) => /(?:^|\/)components\/ui\//u.test(relPath);
|
|
1949
2212
|
const CSS_OUTLINE_NONE_PATTERN = /outline\s*:\s*none/giu;
|
|
1950
2213
|
const FOCUS_VISIBLE_RULE_PATTERN = /:focus(?:-visible)?\b[^{]*\{[^}]*(?:outline|box-shadow|border|ring)[^}]*\}/u;
|
|
1951
|
-
const lineOf$
|
|
2214
|
+
const lineOf$4 = (text, index) => text.slice(0, index).split("\n").length;
|
|
1952
2215
|
const scanTemplate = (file) => {
|
|
1953
2216
|
if (file.kind !== "vue" || file.sfc?.templateAst === void 0) return [];
|
|
1954
2217
|
const findings = [];
|
|
@@ -1976,7 +2239,7 @@ const scanStyle = (style) => {
|
|
|
1976
2239
|
message: `A CSS rule in ${style.relPath} sets \`outline: none\` without a visible :focus-visible replacement.`,
|
|
1977
2240
|
evidence: [{
|
|
1978
2241
|
path: style.relPath,
|
|
1979
|
-
line: lineOf$
|
|
2242
|
+
line: lineOf$4(style.text, match.index)
|
|
1980
2243
|
}],
|
|
1981
2244
|
remediation: "Add a `:focus-visible` rule that restores a visible outline, box-shadow, or border."
|
|
1982
2245
|
});
|
|
@@ -2029,7 +2292,7 @@ const TYPING_GUARD_PATTERN$1 = /\b(?:INPUT|TEXTAREA|SELECT)\b|isContentEditable|
|
|
|
2029
2292
|
const PRINTABLE_SINGLE_KEY_PATTERN = /\.key\s*===\s*['"][a-z0-9]['"]/iu;
|
|
2030
2293
|
const MODIFIER_PATTERN = /metaKey|ctrlKey|altKey/u;
|
|
2031
2294
|
const isSourceFile$1 = (file) => file.kind === "vue" || file.kind === "ts" || file.kind === "js";
|
|
2032
|
-
const lineOf = (text, index) => text.slice(0, index).split("\n").length;
|
|
2295
|
+
const lineOf$3 = (text, index) => text.slice(0, index).split("\n").length;
|
|
2033
2296
|
const globalHotkeysAreSafe = {
|
|
2034
2297
|
id: "global-hotkeys-are-safe",
|
|
2035
2298
|
title: "Global keyboard shortcuts are registered safely",
|
|
@@ -2055,7 +2318,7 @@ const globalHotkeysAreSafe = {
|
|
|
2055
2318
|
message: `A global keydown listener in ${file.relPath} is never removed, leaking across unmounts.`,
|
|
2056
2319
|
evidence: [{
|
|
2057
2320
|
path: file.relPath,
|
|
2058
|
-
line: lineOf(text, matches[0].index)
|
|
2321
|
+
line: lineOf$3(text, matches[0].index)
|
|
2059
2322
|
}],
|
|
2060
2323
|
remediation: "Remove the listener in onUnmounted/onBeforeUnmount, or use VueUse `useEventListener`/`onKeyStroke` which clean up automatically."
|
|
2061
2324
|
});
|
|
@@ -2063,7 +2326,7 @@ const globalHotkeysAreSafe = {
|
|
|
2063
2326
|
message: `A bare printable single-key shortcut in ${file.relPath} has no typing-target guard.`,
|
|
2064
2327
|
evidence: [{
|
|
2065
2328
|
path: file.relPath,
|
|
2066
|
-
line: lineOf(text, matches[0].index)
|
|
2329
|
+
line: lineOf$3(text, matches[0].index)
|
|
2067
2330
|
}],
|
|
2068
2331
|
remediation: "Guard the handler so it ignores events whose target is an INPUT, TEXTAREA, SELECT, or contenteditable element, or gate the shortcut behind a modifier key."
|
|
2069
2332
|
});
|
|
@@ -2303,6 +2566,72 @@ const themeHotkeyPresent = {
|
|
|
2303
2566
|
}
|
|
2304
2567
|
};
|
|
2305
2568
|
//#endregion
|
|
2569
|
+
//#region src/rules/interaction/typing-target-guard.ts
|
|
2570
|
+
const GLOBAL_KEY_BINDING = /addEventListener\s*\(\s*['"]keydown['"]|onKeyStroke\s*\(|useEventListener\s*\([^,]*,\s*['"]keydown['"]/u;
|
|
2571
|
+
const MAGIC_KEYS_SINGLE_LETTER = /\{\s*[^}]*\b(?<!meta_)(?<!ctrl_)(?<!alt_)(?<!shift_)[a-z]\b[^}]*\}\s*=\s*useMagicKeys/u;
|
|
2572
|
+
/**
|
|
2573
|
+
* A shortcut that fires while the user is typing steals the keystroke. The
|
|
2574
|
+
* guard is a check that the event target is not a field: tagName against
|
|
2575
|
+
* INPUT/TEXTAREA/SELECT, an instanceof against the matching element type, a
|
|
2576
|
+
* closest()/matches() selector, or isContentEditable.
|
|
2577
|
+
*/
|
|
2578
|
+
const TYPING_GUARD = /\b(?:INPUT|TEXTAREA|SELECT)\b|HTML(?:Input|TextArea|Select)Element|(?:closest|matches)\s*\(\s*['"][^'"]*(?:input|textarea|select)|isContentEditable|contenteditable/iu;
|
|
2579
|
+
const MODIFIER = /metaKey|ctrlKey|altKey|\b(?:meta|ctrl|alt)_/u;
|
|
2580
|
+
/**
|
|
2581
|
+
* Escape, Enter, Tab and the arrows are expected to work while a field has
|
|
2582
|
+
* focus — Escape closing a dialog is correct behaviour, not a stolen
|
|
2583
|
+
* keystroke. A handler that only reacts to those needs no typing guard.
|
|
2584
|
+
*/
|
|
2585
|
+
const NAVIGATION_KEYS = /^(?:Escape|Enter|Tab|Arrow[A-Za-z]+|Home|End|Page[A-Za-z]+)$/u;
|
|
2586
|
+
const reactsOnlyToNavigationKeys = (text) => {
|
|
2587
|
+
const compared = [...text.matchAll(/\.key\s*===\s*['"]([^'"]+)['"]/gu)].map((match) => match[1]);
|
|
2588
|
+
return compared.length > 0 && compared.every((key) => NAVIGATION_KEYS.test(key));
|
|
2589
|
+
};
|
|
2590
|
+
const lineOf$2 = (text, pattern) => {
|
|
2591
|
+
const index = text.search(pattern);
|
|
2592
|
+
if (index < 0) return 1;
|
|
2593
|
+
return text.slice(0, index).split("\n").length;
|
|
2594
|
+
};
|
|
2595
|
+
const typingTargetGuard = {
|
|
2596
|
+
id: "typing-target-guard",
|
|
2597
|
+
title: "Global shortcuts ignore keystrokes aimed at a field",
|
|
2598
|
+
description: "A global single-key shortcut should not fire while the user is typing. Handlers bound to the document need a check that the event target is not an input, textarea, select, or contenteditable element.",
|
|
2599
|
+
category: "interaction",
|
|
2600
|
+
severity: "warning",
|
|
2601
|
+
confidence: "medium",
|
|
2602
|
+
maxScore: 3,
|
|
2603
|
+
adapters: [
|
|
2604
|
+
"nuxt",
|
|
2605
|
+
"vite-vue",
|
|
2606
|
+
"generic-vue"
|
|
2607
|
+
],
|
|
2608
|
+
run: async ({ sources, result }) => {
|
|
2609
|
+
const files = await sources();
|
|
2610
|
+
const findings = [];
|
|
2611
|
+
let evaluated = 0;
|
|
2612
|
+
for (const file of files) {
|
|
2613
|
+
if (file.kind !== "vue" && file.kind !== "ts" && file.kind !== "js") continue;
|
|
2614
|
+
const bindsGlobalKey = GLOBAL_KEY_BINDING.test(file.text);
|
|
2615
|
+
const bindsBareLetter = MAGIC_KEYS_SINGLE_LETTER.test(file.text);
|
|
2616
|
+
if (!bindsGlobalKey && !bindsBareLetter) continue;
|
|
2617
|
+
if (!bindsBareLetter && MODIFIER.test(file.text) && !GLOBAL_KEY_BINDING.test(file.text)) continue;
|
|
2618
|
+
if (reactsOnlyToNavigationKeys(file.text)) continue;
|
|
2619
|
+
evaluated += 1;
|
|
2620
|
+
if (TYPING_GUARD.test(file.text)) continue;
|
|
2621
|
+
findings.push({
|
|
2622
|
+
message: "A global key handler has no guard for keystrokes aimed at a field.",
|
|
2623
|
+
evidence: [{
|
|
2624
|
+
path: file.relPath,
|
|
2625
|
+
line: lineOf$2(file.text, bindsGlobalKey ? GLOBAL_KEY_BINDING : MAGIC_KEYS_SINGLE_LETTER)
|
|
2626
|
+
}],
|
|
2627
|
+
remediation: "Return early when the event target is an input, textarea, select, or contenteditable element before acting on the key."
|
|
2628
|
+
});
|
|
2629
|
+
}
|
|
2630
|
+
if (evaluated === 0) return result.notApplicable();
|
|
2631
|
+
return findings.length > 0 ? result.fail(findings) : result.pass();
|
|
2632
|
+
}
|
|
2633
|
+
};
|
|
2634
|
+
//#endregion
|
|
2306
2635
|
//#region src/rules/forms/forms-shared.ts
|
|
2307
2636
|
const VALIDATION_PACKAGES = [
|
|
2308
2637
|
"vee-validate",
|
|
@@ -2559,6 +2888,68 @@ const groupedControlsHaveLegend = {
|
|
|
2559
2888
|
}
|
|
2560
2889
|
};
|
|
2561
2890
|
//#endregion
|
|
2891
|
+
//#region src/rules/forms/input-group-composition.ts
|
|
2892
|
+
const INPUT_GROUP_TAG = "input-group";
|
|
2893
|
+
const GROUP_PARTS = /* @__PURE__ */ new Set([
|
|
2894
|
+
"input-group-input",
|
|
2895
|
+
"input-group-textarea",
|
|
2896
|
+
"input-group-addon"
|
|
2897
|
+
]);
|
|
2898
|
+
const RAW_CONTROLS = /* @__PURE__ */ new Set(["input", "textarea"]);
|
|
2899
|
+
const inputGroupComposition = {
|
|
2900
|
+
id: "input-group-composition",
|
|
2901
|
+
title: "InputGroup uses its own parts",
|
|
2902
|
+
description: "An InputGroup wired with a raw input or textarea loses the padding, focus ring, and addon alignment its own parts provide, so the group renders correctly only by accident.",
|
|
2903
|
+
category: "forms",
|
|
2904
|
+
severity: "warning",
|
|
2905
|
+
confidence: "medium",
|
|
2906
|
+
maxScore: 0,
|
|
2907
|
+
adapters: [
|
|
2908
|
+
"nuxt",
|
|
2909
|
+
"vite-vue",
|
|
2910
|
+
"generic-vue"
|
|
2911
|
+
],
|
|
2912
|
+
run: async ({ sources, result }) => {
|
|
2913
|
+
const files = await sources();
|
|
2914
|
+
const findings = [];
|
|
2915
|
+
let instances = 0;
|
|
2916
|
+
for (const file of files) {
|
|
2917
|
+
if (file.sfc?.templateAst === void 0 || isGeneratedUiPrimitive(file.relPath)) continue;
|
|
2918
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2919
|
+
walkTemplate(file.sfc.templateAst, (element, ancestors) => {
|
|
2920
|
+
const tag = pascalToKebab(element.tag);
|
|
2921
|
+
if (tag === INPUT_GROUP_TAG) {
|
|
2922
|
+
groups.set(element, {
|
|
2923
|
+
line: elementLine(element),
|
|
2924
|
+
usesParts: false
|
|
2925
|
+
});
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
2928
|
+
const owner = ancestors.find((ancestor) => pascalToKebab(ancestor.tag) === INPUT_GROUP_TAG);
|
|
2929
|
+
if (owner === void 0) return;
|
|
2930
|
+
const entry = groups.get(owner);
|
|
2931
|
+
if (entry === void 0) return;
|
|
2932
|
+
if (GROUP_PARTS.has(tag)) {
|
|
2933
|
+
entry.usesParts = true;
|
|
2934
|
+
return;
|
|
2935
|
+
}
|
|
2936
|
+
if (RAW_CONTROLS.has(tag)) entry.rawControl ??= tag;
|
|
2937
|
+
});
|
|
2938
|
+
instances += groups.size;
|
|
2939
|
+
for (const entry of groups.values()) if (entry.rawControl !== void 0 && !entry.usesParts) findings.push({
|
|
2940
|
+
message: `<InputGroup> wraps a raw <${entry.rawControl}> instead of its own part.`,
|
|
2941
|
+
evidence: [{
|
|
2942
|
+
path: file.relPath,
|
|
2943
|
+
line: entry.line
|
|
2944
|
+
}],
|
|
2945
|
+
remediation: "Use InputGroupInput or InputGroupTextarea inside InputGroup, with InputGroupAddon for leading and trailing content."
|
|
2946
|
+
});
|
|
2947
|
+
}
|
|
2948
|
+
if (instances === 0) return result.notApplicable();
|
|
2949
|
+
return findings.length > 0 ? result.advisory(findings) : result.pass();
|
|
2950
|
+
}
|
|
2951
|
+
};
|
|
2952
|
+
//#endregion
|
|
2562
2953
|
//#region src/rules/forms/invalid-fields-associated-with-errors.ts
|
|
2563
2954
|
const ERROR_COMPONENT = /^(?:form-message|error-message|field-error)$/u;
|
|
2564
2955
|
const isFormControlWrapper = (tag) => pascalToKebab(tag) === "form-control";
|
|
@@ -3114,6 +3505,119 @@ const toastProviderPresent = {
|
|
|
3114
3505
|
}
|
|
3115
3506
|
};
|
|
3116
3507
|
//#endregion
|
|
3508
|
+
//#region src/rules/states/toast-runtime.ts
|
|
3509
|
+
const TOAST_CALL = /\btoast(?:\.(?:success|error|info|warning|promise|custom|message))?\s*\(/u;
|
|
3510
|
+
/**
|
|
3511
|
+
* The Vue toast runtimes shadcn-vue projects use. vue-sonner is what
|
|
3512
|
+
* shadcn-vue's own Sonner block installs; the others cover the common
|
|
3513
|
+
* alternatives.
|
|
3514
|
+
*/
|
|
3515
|
+
const TOAST_DEPENDENCIES = [
|
|
3516
|
+
"vue-sonner",
|
|
3517
|
+
"sonner",
|
|
3518
|
+
"@nuxt/ui",
|
|
3519
|
+
"vue-toastification",
|
|
3520
|
+
"vue3-toastify",
|
|
3521
|
+
"primevue"
|
|
3522
|
+
];
|
|
3523
|
+
const lineOf$1 = (text, pattern) => {
|
|
3524
|
+
const index = text.search(pattern);
|
|
3525
|
+
if (index < 0) return 1;
|
|
3526
|
+
return text.slice(0, index).split("\n").length;
|
|
3527
|
+
};
|
|
3528
|
+
const toastRuntime = {
|
|
3529
|
+
id: "toast-runtime",
|
|
3530
|
+
title: "Toast calls have a runtime behind them",
|
|
3531
|
+
description: "Code that calls toast() needs a toast library installed, otherwise the call resolves to nothing at runtime and the notification silently never appears.",
|
|
3532
|
+
category: "states",
|
|
3533
|
+
severity: "error",
|
|
3534
|
+
confidence: "high",
|
|
3535
|
+
maxScore: 3,
|
|
3536
|
+
adapters: [
|
|
3537
|
+
"nuxt",
|
|
3538
|
+
"vite-vue",
|
|
3539
|
+
"generic-vue"
|
|
3540
|
+
],
|
|
3541
|
+
run: async ({ sources, discovery, result }) => {
|
|
3542
|
+
const caller = (await sources()).find((file) => !isGeneratedUiPrimitive(file.relPath) && TOAST_CALL.test(file.text));
|
|
3543
|
+
if (caller === void 0) return result.notApplicable();
|
|
3544
|
+
if (TOAST_DEPENDENCIES.find((dependency) => discovery.dependencies[dependency] !== void 0) !== void 0) return result.pass();
|
|
3545
|
+
return result.fail([{
|
|
3546
|
+
message: "toast() is called but no toast runtime is installed.",
|
|
3547
|
+
evidence: [{
|
|
3548
|
+
path: caller.relPath,
|
|
3549
|
+
line: lineOf$1(caller.text, TOAST_CALL)
|
|
3550
|
+
}],
|
|
3551
|
+
remediation: "Install a toast runtime (shadcn-vue ships the Sonner block backed by vue-sonner) and mount its provider in the app shell."
|
|
3552
|
+
}]);
|
|
3553
|
+
}
|
|
3554
|
+
};
|
|
3555
|
+
//#endregion
|
|
3556
|
+
//#region src/rules/production-polish/alert-anatomy.ts
|
|
3557
|
+
const ALERT_TAG = "alert";
|
|
3558
|
+
const ALERT_TITLE_TAG = "alert-title";
|
|
3559
|
+
const ALERT_DESCRIPTION_TAG = "alert-description";
|
|
3560
|
+
const ICON_TAG$1 = /(?:^|-)icon$/u;
|
|
3561
|
+
const isIcon = (tag) => {
|
|
3562
|
+
const normalized = pascalToKebab(tag);
|
|
3563
|
+
return ICON_TAG$1.test(normalized) || normalized === "svg";
|
|
3564
|
+
};
|
|
3565
|
+
const alertAnatomy = {
|
|
3566
|
+
id: "alert-anatomy",
|
|
3567
|
+
title: "Alert matches its anatomy",
|
|
3568
|
+
description: "An Alert should carry an AlertTitle and at most one icon. A title-less alert announces as an unlabelled region, and a second icon competes with the first for meaning.",
|
|
3569
|
+
category: "production-polish",
|
|
3570
|
+
severity: "warning",
|
|
3571
|
+
confidence: "medium",
|
|
3572
|
+
maxScore: 0,
|
|
3573
|
+
adapters: [
|
|
3574
|
+
"nuxt",
|
|
3575
|
+
"vite-vue",
|
|
3576
|
+
"generic-vue"
|
|
3577
|
+
],
|
|
3578
|
+
run: async ({ sources, result }) => {
|
|
3579
|
+
const files = await sources();
|
|
3580
|
+
const findings = [];
|
|
3581
|
+
let instances = 0;
|
|
3582
|
+
for (const file of files) {
|
|
3583
|
+
if (file.sfc?.templateAst === void 0 || isGeneratedUiPrimitive(file.relPath)) continue;
|
|
3584
|
+
const alerts = /* @__PURE__ */ new Map();
|
|
3585
|
+
walkTemplate(file.sfc.templateAst, (element, ancestors) => {
|
|
3586
|
+
if (pascalToKebab(element.tag) === ALERT_TAG) {
|
|
3587
|
+
alerts.set(element, {
|
|
3588
|
+
line: elementLine(element),
|
|
3589
|
+
titles: 0,
|
|
3590
|
+
icons: 0
|
|
3591
|
+
});
|
|
3592
|
+
return;
|
|
3593
|
+
}
|
|
3594
|
+
const owner = ancestors.find((ancestor) => pascalToKebab(ancestor.tag) === ALERT_TAG);
|
|
3595
|
+
if (owner === void 0) return;
|
|
3596
|
+
const entry = alerts.get(owner);
|
|
3597
|
+
if (entry === void 0) return;
|
|
3598
|
+
if (pascalToKebab(element.tag) === ALERT_TITLE_TAG) entry.titles += 1;
|
|
3599
|
+
if (isIcon(element.tag)) entry.icons += 1;
|
|
3600
|
+
});
|
|
3601
|
+
instances += alerts.size;
|
|
3602
|
+
for (const entry of alerts.values()) {
|
|
3603
|
+
const problems = [];
|
|
3604
|
+
if (entry.titles === 0) problems.push("no AlertTitle");
|
|
3605
|
+
if (entry.icons > 1) problems.push(`${entry.icons} icons`);
|
|
3606
|
+
if (problems.length > 0) findings.push({
|
|
3607
|
+
message: `<Alert> has ${problems.join(" and ")}.`,
|
|
3608
|
+
evidence: [{
|
|
3609
|
+
path: file.relPath,
|
|
3610
|
+
line: entry.line
|
|
3611
|
+
}],
|
|
3612
|
+
remediation: `Compose Alert from its parts: one AlertTitle, an optional ${ALERT_DESCRIPTION_TAG.replace("-", "")}, and at most one leading icon.`
|
|
3613
|
+
});
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
if (instances === 0) return result.notApplicable();
|
|
3617
|
+
return findings.length > 0 ? result.advisory(findings) : result.pass();
|
|
3618
|
+
}
|
|
3619
|
+
};
|
|
3620
|
+
//#endregion
|
|
3117
3621
|
//#region src/rules/production-polish/polish-shared.ts
|
|
3118
3622
|
const SHELL_FILES = /* @__PURE__ */ new Set([
|
|
3119
3623
|
"app.vue",
|
|
@@ -3242,10 +3746,67 @@ const buttonIconsHaveDataIcon = {
|
|
|
3242
3746
|
}
|
|
3243
3747
|
};
|
|
3244
3748
|
//#endregion
|
|
3749
|
+
//#region src/rules/production-polish/color-contrast-passes.ts
|
|
3750
|
+
const COLOR_STYLE_PATTERN = /(?:color|background(?:-color)?|border-color|fill|stroke)\s*:|\b(?:bg|border|fill|stroke|text)-(?:accent|background|card|destructive|foreground|input|muted|popover|primary|ring|secondary|[a-z]+-\d{2,3})\b|#[\da-f]{3,8}\b|(?:hsl|oklch|rgb)a?\s*\(/iu;
|
|
3751
|
+
const lineOf = (text, pattern) => {
|
|
3752
|
+
const index = text.search(pattern);
|
|
3753
|
+
if (index < 0) return 1;
|
|
3754
|
+
return text.slice(0, index).split("\n").length;
|
|
3755
|
+
};
|
|
3756
|
+
const colorContrastPasses = {
|
|
3757
|
+
id: "color-contrast-passes",
|
|
3758
|
+
title: "Rendered colour contrast meets accessibility thresholds",
|
|
3759
|
+
description: "Marks styled colour pairs for browser verification. Computed contrast depends on cascade, theme, and opacity, none of which can be resolved from source, so this reports where to look rather than scoring it.",
|
|
3760
|
+
category: "production-polish",
|
|
3761
|
+
severity: "warning",
|
|
3762
|
+
confidence: "low",
|
|
3763
|
+
maxScore: 0,
|
|
3764
|
+
adapters: [
|
|
3765
|
+
"nuxt",
|
|
3766
|
+
"vite-vue",
|
|
3767
|
+
"generic-vue"
|
|
3768
|
+
],
|
|
3769
|
+
run: async ({ sources, result }) => {
|
|
3770
|
+
const styled = (await sources()).find((file) => !isGeneratedUiPrimitive(file.relPath) && COLOR_STYLE_PATTERN.test(file.text));
|
|
3771
|
+
if (styled === void 0) return result.notApplicable();
|
|
3772
|
+
return result.advisory([{
|
|
3773
|
+
message: "Colour styling is present, but computed foreground and background contrast cannot be established from source.",
|
|
3774
|
+
evidence: [{
|
|
3775
|
+
path: styled.relPath,
|
|
3776
|
+
line: lineOf(styled.text, COLOR_STYLE_PATTERN)
|
|
3777
|
+
}],
|
|
3778
|
+
remediation: "Check rendered states in every theme and viewport: 4.5:1 for body text, 3:1 for large text, and 3:1 for meaningful UI graphics and boundaries."
|
|
3779
|
+
}]);
|
|
3780
|
+
}
|
|
3781
|
+
};
|
|
3782
|
+
//#endregion
|
|
3245
3783
|
//#region src/rules/production-polish/metadata-title-description-complete.ts
|
|
3246
3784
|
const TITLE_PATTERN = /\b(?:title|titleTemplate)\s*:/u;
|
|
3247
3785
|
const DESCRIPTION_PATTERN = /\bdescription\s*:/u;
|
|
3248
3786
|
const HEAD_CALL_PATTERN = /\b(?:useSeoMeta|useHead)\s*\(/u;
|
|
3787
|
+
const COMPOSABLE_PATH = /(?:^|\/)composables\//u;
|
|
3788
|
+
const EXPORTED_COMPOSABLE = /export\s+(?:const|function|async\s+function)\s+(use[A-Z]\w*)/gu;
|
|
3789
|
+
/**
|
|
3790
|
+
* A page that redirects during setup never renders, so a crawler follows the
|
|
3791
|
+
* redirect and reads the destination's metadata instead. Demanding a title and
|
|
3792
|
+
* description for it reports a failure with nothing to fix.
|
|
3793
|
+
*/
|
|
3794
|
+
const REDIRECT_ONLY = /await\s+navigateTo\s*\(|definePageMeta\s*\(\s*\{[^}]*\bredirect\s*:/su;
|
|
3795
|
+
/**
|
|
3796
|
+
* Extracting the head call into a `usePageSeo`-style composable is the
|
|
3797
|
+
* documented Nuxt pattern, so a page that calls one of those wrappers has
|
|
3798
|
+
* declared its metadata just as surely as one calling useSeoMeta inline.
|
|
3799
|
+
* Collect every project composable that reaches a head call, then treat a call
|
|
3800
|
+
* to it as a metadata declaration.
|
|
3801
|
+
*/
|
|
3802
|
+
const seoComposableNames = (files) => {
|
|
3803
|
+
const names = /* @__PURE__ */ new Set();
|
|
3804
|
+
for (const file of files) {
|
|
3805
|
+
if (!COMPOSABLE_PATH.test(file.relPath) || !HEAD_CALL_PATTERN.test(file.text)) continue;
|
|
3806
|
+
for (const match of file.text.matchAll(EXPORTED_COMPOSABLE)) names.add(match[1]);
|
|
3807
|
+
}
|
|
3808
|
+
return names;
|
|
3809
|
+
};
|
|
3249
3810
|
const metadataTitleDescriptionComplete = {
|
|
3250
3811
|
id: "metadata-title-description-complete",
|
|
3251
3812
|
title: "Routable pages declare a title and description",
|
|
@@ -3260,11 +3821,15 @@ const metadataTitleDescriptionComplete = {
|
|
|
3260
3821
|
"generic-vue"
|
|
3261
3822
|
],
|
|
3262
3823
|
run: async ({ sources, result }) => {
|
|
3263
|
-
const
|
|
3824
|
+
const files = await sources();
|
|
3825
|
+
const pages = files.filter((file) => file.kind === "vue" && isPageFile(file.relPath));
|
|
3264
3826
|
if (pages.length === 0) return result.notApplicable();
|
|
3827
|
+
const wrappers = seoComposableNames(files);
|
|
3828
|
+
const wrapperCall = wrappers.size > 0 ? new RegExp(`\\b(?:${[...wrappers].join("|")})\\s*\\(`, "u") : void 0;
|
|
3265
3829
|
const findings = [];
|
|
3266
3830
|
for (const page of pages) {
|
|
3267
|
-
if (
|
|
3831
|
+
if (REDIRECT_ONLY.test(page.text)) continue;
|
|
3832
|
+
if (!(HEAD_CALL_PATTERN.test(page.text) || wrapperCall?.test(page.text) === true)) {
|
|
3268
3833
|
findings.push({
|
|
3269
3834
|
message: "Page does not declare its own metadata.",
|
|
3270
3835
|
evidence: [{ path: page.relPath }],
|
|
@@ -3560,6 +4125,72 @@ const responsiveShellPresent = {
|
|
|
3560
4125
|
}
|
|
3561
4126
|
};
|
|
3562
4127
|
//#endregion
|
|
4128
|
+
//#region src/rules/production-polish/responsive-visibility.ts
|
|
4129
|
+
/**
|
|
4130
|
+
* `hidden md:block` shows an element from the md breakpoint up. A sibling that
|
|
4131
|
+
* is `md:hidden` covers the smaller range. When only one half of the pair
|
|
4132
|
+
* exists on a surface, one viewport range renders nothing at all.
|
|
4133
|
+
*/
|
|
4134
|
+
const HIDE_AT_BREAKPOINT = /\b(?:sm|md|lg|xl|2xl):hidden\b/u;
|
|
4135
|
+
const SHOW_FROM_BREAKPOINT = /\bhidden\b[^"']*\b(?:sm|md|lg|xl|2xl):(?:block|flex|grid|inline)/u;
|
|
4136
|
+
const classOf = (element) => {
|
|
4137
|
+
const values = [];
|
|
4138
|
+
for (const prop of element.props) {
|
|
4139
|
+
const candidate = prop;
|
|
4140
|
+
if (candidate.name === "class" && typeof candidate.value?.content === "string") values.push(candidate.value.content);
|
|
4141
|
+
}
|
|
4142
|
+
return values.join(" ");
|
|
4143
|
+
};
|
|
4144
|
+
const responsiveVisibility = {
|
|
4145
|
+
id: "responsive-visibility",
|
|
4146
|
+
title: "Responsive show and hide pairs cover every viewport",
|
|
4147
|
+
description: "A surface that hides an element at a breakpoint should present the alternative at the complementary range, otherwise one viewport shows nothing where content is expected.",
|
|
4148
|
+
category: "production-polish",
|
|
4149
|
+
severity: "warning",
|
|
4150
|
+
confidence: "low",
|
|
4151
|
+
maxScore: 0,
|
|
4152
|
+
adapters: [
|
|
4153
|
+
"nuxt",
|
|
4154
|
+
"vite-vue",
|
|
4155
|
+
"generic-vue"
|
|
4156
|
+
],
|
|
4157
|
+
run: async ({ sources, result }) => {
|
|
4158
|
+
const files = await sources();
|
|
4159
|
+
const findings = [];
|
|
4160
|
+
let evaluated = 0;
|
|
4161
|
+
for (const file of files) {
|
|
4162
|
+
if (file.sfc?.templateAst === void 0 || isGeneratedUiPrimitive(file.relPath)) continue;
|
|
4163
|
+
let hides = 0;
|
|
4164
|
+
let shows = 0;
|
|
4165
|
+
let firstHideLine;
|
|
4166
|
+
walkTemplate(file.sfc.templateAst, (element) => {
|
|
4167
|
+
const classes = classOf(element);
|
|
4168
|
+
if (classes === "") return;
|
|
4169
|
+
if (SHOW_FROM_BREAKPOINT.test(classes)) {
|
|
4170
|
+
shows += 1;
|
|
4171
|
+
return;
|
|
4172
|
+
}
|
|
4173
|
+
if (HIDE_AT_BREAKPOINT.test(classes)) {
|
|
4174
|
+
hides += 1;
|
|
4175
|
+
firstHideLine ??= elementLine(element);
|
|
4176
|
+
}
|
|
4177
|
+
});
|
|
4178
|
+
if (hides === 0 && shows === 0) continue;
|
|
4179
|
+
evaluated += 1;
|
|
4180
|
+
if (hides > 0 && shows === 0) findings.push({
|
|
4181
|
+
message: "This surface hides content at a breakpoint without a counterpart shown at the complementary range.",
|
|
4182
|
+
evidence: [{
|
|
4183
|
+
path: file.relPath,
|
|
4184
|
+
line: firstHideLine ?? 1
|
|
4185
|
+
}],
|
|
4186
|
+
remediation: "Pair each breakpoint-hidden element with the alternative for the other range (for example `md:hidden` beside `hidden md:block`), or confirm the omission is deliberate."
|
|
4187
|
+
});
|
|
4188
|
+
}
|
|
4189
|
+
if (evaluated === 0) return result.notApplicable();
|
|
4190
|
+
return findings.length > 0 ? result.advisory(findings) : result.pass();
|
|
4191
|
+
}
|
|
4192
|
+
};
|
|
4193
|
+
//#endregion
|
|
3563
4194
|
//#region src/rules/production-polish/social-preview-present.ts
|
|
3564
4195
|
const OG_IMAGE_PATTERN = /(?:og:image|ogImage|twitter:image|twitterImage)/u;
|
|
3565
4196
|
const OG_TITLE_PATTERN = /(?:og:title|ogTitle|twitter:title|twitterCard|twitter:card)/u;
|
|
@@ -3588,8 +4219,10 @@ const defaultRules = [
|
|
|
3588
4219
|
focusVisibleNotSuppressed,
|
|
3589
4220
|
itemsBelongToGroups,
|
|
3590
4221
|
destructiveActionsConfirmed,
|
|
4222
|
+
typingTargetGuard,
|
|
3591
4223
|
toastProviderPresent,
|
|
3592
4224
|
toastProviderMounted,
|
|
4225
|
+
toastRuntime,
|
|
3593
4226
|
routeLoadingBoundaryPresent,
|
|
3594
4227
|
suspenseFallbackUseful,
|
|
3595
4228
|
emptyStatePresent,
|
|
@@ -3607,6 +4240,10 @@ const defaultRules = [
|
|
|
3607
4240
|
iframesHaveTitle,
|
|
3608
4241
|
interactiveElementsAreSemantic,
|
|
3609
4242
|
noNestedInteractiveControls,
|
|
4243
|
+
customControlsHaveLabels,
|
|
4244
|
+
statusMessagesAnnounced,
|
|
4245
|
+
dialogFocusTrapWorks,
|
|
4246
|
+
keyboardNavigationWorks,
|
|
3610
4247
|
formsHaveLabels,
|
|
3611
4248
|
fieldErrorsRendered,
|
|
3612
4249
|
invalidFieldsAssociatedWithErrors,
|
|
@@ -3614,6 +4251,7 @@ const defaultRules = [
|
|
|
3614
4251
|
groupedControlsHaveLegend,
|
|
3615
4252
|
personalDataAutocompletePresent,
|
|
3616
4253
|
validationWiredToForm,
|
|
4254
|
+
inputGroupComposition,
|
|
3617
4255
|
noStarterCopy,
|
|
3618
4256
|
metadataTitleDescriptionComplete,
|
|
3619
4257
|
{
|
|
@@ -3649,7 +4287,10 @@ const defaultRules = [
|
|
|
3649
4287
|
mobileOverflowAbsent,
|
|
3650
4288
|
animationsRespectReducedMotion,
|
|
3651
4289
|
pointerTargetSizePasses,
|
|
3652
|
-
buttonIconsHaveDataIcon
|
|
4290
|
+
buttonIconsHaveDataIcon,
|
|
4291
|
+
alertAnatomy,
|
|
4292
|
+
responsiveVisibility,
|
|
4293
|
+
colorContrastPasses
|
|
3653
4294
|
];
|
|
3654
4295
|
//#endregion
|
|
3655
4296
|
//#region src/scan.ts
|