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/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$1 = (text, index) => text.slice(0, index).split("\n").length;
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$1(style.text, match.index)
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 pages = (await sources()).filter((file) => file.kind === "vue" && isPageFile(file.relPath));
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 (!HEAD_CALL_PATTERN.test(page.text)) {
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