@nebulacomponents/citable 1.16.0 → 1.18.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.
Files changed (142) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +16 -3
  3. package/dist/universal/.agents/skills/citable/SKILL.md +66 -1
  4. package/dist/universal/.agents/skills/citable/VERSION +1 -1
  5. package/dist/universal/.agents/skills/citable/manifest.json +9 -6
  6. package/dist/universal/.agents/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  7. package/dist/universal/.agents/skills/citable/schemas/finding.schema.json +69 -0
  8. package/dist/universal/.agents/skills/citable/schemas/remediation-verification.schema.json +98 -0
  9. package/dist/universal/.agents/skills/citable/schemas/sow.schema.json +92 -0
  10. package/dist/universal/.claude/agents/citable/manifest.json +2 -2
  11. package/dist/universal/.claude/skills/citable/SKILL.md +66 -1
  12. package/dist/universal/.claude/skills/citable/VERSION +1 -1
  13. package/dist/universal/.claude/skills/citable/manifest.json +9 -6
  14. package/dist/universal/.claude/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  15. package/dist/universal/.claude/skills/citable/schemas/finding.schema.json +69 -0
  16. package/dist/universal/.claude/skills/citable/schemas/remediation-verification.schema.json +98 -0
  17. package/dist/universal/.claude/skills/citable/schemas/sow.schema.json +92 -0
  18. package/dist/universal/.cursor/skills/citable/SKILL.md +66 -1
  19. package/dist/universal/.cursor/skills/citable/VERSION +1 -1
  20. package/dist/universal/.cursor/skills/citable/manifest.json +9 -6
  21. package/dist/universal/.cursor/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  22. package/dist/universal/.cursor/skills/citable/schemas/finding.schema.json +69 -0
  23. package/dist/universal/.cursor/skills/citable/schemas/remediation-verification.schema.json +98 -0
  24. package/dist/universal/.cursor/skills/citable/schemas/sow.schema.json +92 -0
  25. package/dist/universal/.gemini/skills/citable/SKILL.md +66 -1
  26. package/dist/universal/.gemini/skills/citable/VERSION +1 -1
  27. package/dist/universal/.gemini/skills/citable/manifest.json +9 -6
  28. package/dist/universal/.gemini/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  29. package/dist/universal/.gemini/skills/citable/schemas/finding.schema.json +69 -0
  30. package/dist/universal/.gemini/skills/citable/schemas/remediation-verification.schema.json +98 -0
  31. package/dist/universal/.gemini/skills/citable/schemas/sow.schema.json +92 -0
  32. package/dist/universal/.github/skills/citable/SKILL.md +66 -1
  33. package/dist/universal/.github/skills/citable/VERSION +1 -1
  34. package/dist/universal/.github/skills/citable/manifest.json +9 -6
  35. package/dist/universal/.github/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  36. package/dist/universal/.github/skills/citable/schemas/finding.schema.json +69 -0
  37. package/dist/universal/.github/skills/citable/schemas/remediation-verification.schema.json +98 -0
  38. package/dist/universal/.github/skills/citable/schemas/sow.schema.json +92 -0
  39. package/dist/universal/.kiro/skills/citable/SKILL.md +66 -1
  40. package/dist/universal/.kiro/skills/citable/VERSION +1 -1
  41. package/dist/universal/.kiro/skills/citable/manifest.json +9 -6
  42. package/dist/universal/.kiro/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  43. package/dist/universal/.kiro/skills/citable/schemas/finding.schema.json +69 -0
  44. package/dist/universal/.kiro/skills/citable/schemas/remediation-verification.schema.json +98 -0
  45. package/dist/universal/.kiro/skills/citable/schemas/sow.schema.json +92 -0
  46. package/dist/universal/.opencode/skills/citable/SKILL.md +66 -1
  47. package/dist/universal/.opencode/skills/citable/VERSION +1 -1
  48. package/dist/universal/.opencode/skills/citable/manifest.json +9 -6
  49. package/dist/universal/.opencode/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  50. package/dist/universal/.opencode/skills/citable/schemas/finding.schema.json +69 -0
  51. package/dist/universal/.opencode/skills/citable/schemas/remediation-verification.schema.json +98 -0
  52. package/dist/universal/.opencode/skills/citable/schemas/sow.schema.json +92 -0
  53. package/dist/universal/.pi/agent/skills/citable/SKILL.md +66 -1
  54. package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
  55. package/dist/universal/.pi/agent/skills/citable/manifest.json +9 -6
  56. package/dist/universal/.pi/agent/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  57. package/dist/universal/.pi/agent/skills/citable/schemas/finding.schema.json +69 -0
  58. package/dist/universal/.pi/agent/skills/citable/schemas/remediation-verification.schema.json +98 -0
  59. package/dist/universal/.pi/agent/skills/citable/schemas/sow.schema.json +92 -0
  60. package/dist/universal/.qoder/skills/citable/SKILL.md +66 -1
  61. package/dist/universal/.qoder/skills/citable/VERSION +1 -1
  62. package/dist/universal/.qoder/skills/citable/manifest.json +9 -6
  63. package/dist/universal/.qoder/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  64. package/dist/universal/.qoder/skills/citable/schemas/finding.schema.json +69 -0
  65. package/dist/universal/.qoder/skills/citable/schemas/remediation-verification.schema.json +98 -0
  66. package/dist/universal/.qoder/skills/citable/schemas/sow.schema.json +92 -0
  67. package/dist/universal/.rovodev/skills/citable/SKILL.md +66 -1
  68. package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
  69. package/dist/universal/.rovodev/skills/citable/manifest.json +9 -6
  70. package/dist/universal/.rovodev/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  71. package/dist/universal/.rovodev/skills/citable/schemas/finding.schema.json +69 -0
  72. package/dist/universal/.rovodev/skills/citable/schemas/remediation-verification.schema.json +98 -0
  73. package/dist/universal/.rovodev/skills/citable/schemas/sow.schema.json +92 -0
  74. package/dist/universal/.trae/skills/citable/SKILL.md +66 -1
  75. package/dist/universal/.trae/skills/citable/VERSION +1 -1
  76. package/dist/universal/.trae/skills/citable/manifest.json +9 -6
  77. package/dist/universal/.trae/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  78. package/dist/universal/.trae/skills/citable/schemas/finding.schema.json +69 -0
  79. package/dist/universal/.trae/skills/citable/schemas/remediation-verification.schema.json +98 -0
  80. package/dist/universal/.trae/skills/citable/schemas/sow.schema.json +92 -0
  81. package/dist/universal/.trae-cn/skills/citable/SKILL.md +66 -1
  82. package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
  83. package/dist/universal/.trae-cn/skills/citable/manifest.json +9 -6
  84. package/dist/universal/.trae-cn/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  85. package/dist/universal/.trae-cn/skills/citable/schemas/finding.schema.json +69 -0
  86. package/dist/universal/.trae-cn/skills/citable/schemas/remediation-verification.schema.json +98 -0
  87. package/dist/universal/.trae-cn/skills/citable/schemas/sow.schema.json +92 -0
  88. package/dist/universal/manifest.json +112 -76
  89. package/package.json +1 -1
  90. package/schemas/cli-output-envelope.schema.json +30 -0
  91. package/schemas/finding.schema.json +69 -0
  92. package/schemas/remediation-verification.schema.json +98 -0
  93. package/schemas/sow.schema.json +92 -0
  94. package/skill/SKILL.md +66 -1
  95. package/src/analysis/behavioral.js +126 -0
  96. package/src/analysis/croAudit.js +302 -0
  97. package/src/analysis/croRoadmap.js +121 -0
  98. package/src/analysis/eeat.js +300 -0
  99. package/src/analysis/funnelAnalysis.js +171 -0
  100. package/src/analysis/iceMatrix.js +205 -0
  101. package/src/analysis/offpage.js +228 -0
  102. package/src/analysis/readiness.js +253 -0
  103. package/src/analysis/saliency.js +119 -0
  104. package/src/analysis/schemaRecommendations.js +173 -0
  105. package/src/analysis/strategicRoadmap.js +148 -0
  106. package/src/cli/index.js +418 -9
  107. package/src/commands/answerEngineReadiness.js +48 -0
  108. package/src/commands/audit.js +2 -2
  109. package/src/commands/auditBacklinks.js +79 -0
  110. package/src/commands/ciWorkflow.js +63 -0
  111. package/src/commands/compatibility.js +205 -0
  112. package/src/commands/context.js +2 -1
  113. package/src/commands/croBacklog.js +182 -0
  114. package/src/commands/croSuite.js +156 -0
  115. package/src/commands/edgeRules.js +80 -4
  116. package/src/commands/edgeSecurity.js +192 -0
  117. package/src/commands/executive.js +6 -0
  118. package/src/commands/executiveCro.js +30 -0
  119. package/src/commands/executiveSearch.js +28 -0
  120. package/src/commands/experimentGuardrails.js +213 -0
  121. package/src/commands/goldenCorpus.js +155 -0
  122. package/src/commands/implementationKit.js +202 -0
  123. package/src/commands/inspectCro.js +67 -0
  124. package/src/commands/inspectEeat.js +53 -0
  125. package/src/commands/planExperiment.js +52 -0
  126. package/src/commands/previewCro.js +241 -0
  127. package/src/commands/prioritize.js +42 -6
  128. package/src/commands/remediate.js +461 -0
  129. package/src/commands/roadmapCmd.js +69 -0
  130. package/src/commands/schemaCmd.js +10 -2
  131. package/src/commands/sowCmd.js +67 -0
  132. package/src/commands/sweep.js +357 -0
  133. package/src/commands/verifyRemediation.js +204 -0
  134. package/src/commands/visualRegression.js +165 -0
  135. package/src/components/index.js +556 -0
  136. package/src/detectors/cro.js +48 -0
  137. package/src/detectors/framework.js +31 -1
  138. package/src/reporting/executiveCroReport.js +731 -0
  139. package/src/reporting/executiveExport.js +175 -1
  140. package/src/reporting/executiveSearchReport.js +704 -0
  141. package/src/sow/admissibilityGate.js +194 -0
  142. package/src/sow/generateSow.js +762 -0
@@ -0,0 +1,556 @@
1
+ /**
2
+ * Nebula Components Design System Library (@nebulacomponents/core)
3
+ *
4
+ * Production-ready, accessible (WCAG 2.2 AA), conversion-optimized components
5
+ * designed to remediate CRO and component linting defects deterministically.
6
+ */
7
+
8
+ export const COMPONENTS = {
9
+ "hero-cta": {
10
+ name: "NebulaHeroCTA",
11
+ id: "hero-cta",
12
+ description: "High-contrast primary hero conversion block with strict visual hierarchy and single dominant action (remediates CRO-010, CRO-015, CRO-016).",
13
+ detectors: ["CRO-010", "CRO-015", "CRO-016", "COMP-001", "COMP-003"],
14
+ templates: {
15
+ react: `import React from "react";
16
+
17
+ export function NebulaHeroCTA({
18
+ primaryText = "Get Started Free",
19
+ primaryHref = "/signup",
20
+ secondaryText = "View Live Demo",
21
+ secondaryHref = "#demo",
22
+ onPrimaryClick,
23
+ onSecondaryClick,
24
+ className = "",
25
+ }) {
26
+ return (
27
+ <div className={\`flex flex-col sm:flex-row items-center gap-4 my-6 \${className}\`}>
28
+ <a
29
+ href={primaryHref}
30
+ onClick={onPrimaryClick}
31
+ className="inline-flex items-center justify-center min-h-[48px] min-w-[160px] px-8 py-3.5 text-base font-semibold text-white bg-blue-600 hover:bg-blue-700 active:bg-blue-800 rounded-lg shadow-md transition-colors focus:outline-none focus:ring-4 focus:ring-blue-300 touch-manipulation"
32
+ >
33
+ {primaryText}
34
+ </a>
35
+ {secondaryText && (
36
+ <a
37
+ href={secondaryHref}
38
+ onClick={onSecondaryClick}
39
+ className="inline-flex items-center justify-center min-h-[48px] px-6 py-3 text-sm font-medium text-slate-700 hover:text-slate-900 hover:underline transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 touch-manipulation"
40
+ >
41
+ {secondaryText}
42
+ </a>
43
+ )}
44
+ </div>
45
+ );
46
+ }`,
47
+ vue: `<template>
48
+ <div :class="['flex flex-col sm:flex-row items-center gap-4 my-6', className]">
49
+ <a
50
+ :href="primaryHref"
51
+ @click="$emit('primary-click', $event)"
52
+ class="inline-flex items-center justify-center min-h-[48px] min-w-[160px] px-8 py-3.5 text-base font-semibold text-white bg-blue-600 hover:bg-blue-700 active:bg-blue-800 rounded-lg shadow-md transition-colors focus:outline-none focus:ring-4 focus:ring-blue-300 touch-manipulation"
53
+ >
54
+ {{ primaryText }}
55
+ </a>
56
+ <a
57
+ v-if="secondaryText"
58
+ :href="secondaryHref"
59
+ @click="$emit('secondary-click', $event)"
60
+ class="inline-flex items-center justify-center min-h-[48px] px-6 py-3 text-sm font-medium text-slate-700 hover:text-slate-900 hover:underline transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 touch-manipulation"
61
+ >
62
+ {{ secondaryText }}
63
+ </a>
64
+ </div>
65
+ </template>
66
+
67
+ <script setup>
68
+ defineProps({
69
+ primaryText: { type: String, default: "Get Started Free" },
70
+ primaryHref: { type: String, default: "/signup" },
71
+ secondaryText: { type: String, default: "View Live Demo" },
72
+ secondaryHref: { type: String, default: "#demo" },
73
+ className: { type: String, default: "" },
74
+ });
75
+ defineEmits(["primary-click", "secondary-click"]);
76
+ </script>`,
77
+ html: `<!-- NebulaHeroCTA Component (CRO-010, CRO-015, CRO-016 compliant) -->
78
+ <div class="flex flex-col sm:flex-row items-center gap-4 my-6">
79
+ <a
80
+ href="/signup"
81
+ class="inline-flex items-center justify-center min-h-[48px] min-w-[160px] px-8 py-3.5 text-base font-semibold text-white bg-blue-600 hover:bg-blue-700 active:bg-blue-800 rounded-lg shadow-md transition-colors focus:outline-none focus:ring-4 focus:ring-blue-300 touch-manipulation"
82
+ >
83
+ Get Started Free
84
+ </a>
85
+ <a
86
+ href="#demo"
87
+ class="inline-flex items-center justify-center min-h-[48px] px-6 py-3 text-sm font-medium text-slate-700 hover:text-slate-900 hover:underline transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 touch-manipulation"
88
+ >
89
+ View Live Demo
90
+ </a>
91
+ </div>`
92
+ }
93
+ },
94
+
95
+ "frictionless-form": {
96
+ name: "NebulaFrictionlessForm",
97
+ id: "frictionless-form",
98
+ description: "Accessible conversion form with persistent floating labels, W3C autocomplete tokens, and device-native virtual keyboard mappings (remediates CRO-007, CRO-008, CRO-009, COMP-005).",
99
+ detectors: ["CRO-007", "CRO-008", "CRO-009", "COMP-005"],
100
+ templates: {
101
+ react: `import React, { useState } from "react";
102
+
103
+ export function NebulaFrictionlessForm({
104
+ onSubmit,
105
+ submitText = "Complete Order",
106
+ className = "",
107
+ }) {
108
+ const [values, setValues] = useState({ name: "", email: "", tel: "" });
109
+ const [errors, setErrors] = useState({});
110
+
111
+ const validateField = (field, value) => {
112
+ if (!value.trim()) return "This field is required";
113
+ if (field === "email" && !/^\\S+@\\S+\\.\\S+$/.test(value)) return "Enter a valid email address";
114
+ return null;
115
+ };
116
+
117
+ const handleBlur = (field) => {
118
+ const error = validateField(field, values[field]);
119
+ setErrors((prev) => ({ ...prev, [field]: error }));
120
+ };
121
+
122
+ return (
123
+ <form
124
+ onSubmit={(e) => {
125
+ e.preventDefault();
126
+ onSubmit?.(values);
127
+ }}
128
+ className={\`space-y-4 max-w-md w-full \${className}\`}
129
+ noValidate
130
+ >
131
+ <div className="relative">
132
+ <input
133
+ id="nebula-name"
134
+ name="name"
135
+ type="text"
136
+ autoComplete="name"
137
+ value={values.name}
138
+ onChange={(e) => setValues({ ...values, name: e.target.value })}
139
+ onBlur={() => handleBlur("name")}
140
+ placeholder=" "
141
+ aria-invalid={!!errors.name}
142
+ aria-describedby={errors.name ? "nebula-name-error" : undefined}
143
+ className="peer block w-full px-4 pt-6 pb-2 min-h-[48px] text-base text-slate-900 bg-white border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
144
+ />
145
+ <label
146
+ htmlFor="nebula-name"
147
+ className="absolute text-sm text-slate-500 duration-150 transform -translate-y-3 scale-75 top-4 z-10 origin-[0] left-4 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-3 peer-focus:text-blue-600"
148
+ >
149
+ Full Name
150
+ </label>
151
+ {errors.name && <p id="nebula-name-error" className="mt-1 text-xs text-red-600">{errors.name}</p>}
152
+ </div>
153
+
154
+ <div className="relative">
155
+ <input
156
+ id="nebula-email"
157
+ name="email"
158
+ type="email"
159
+ inputMode="email"
160
+ autoComplete="email"
161
+ value={values.email}
162
+ onChange={(e) => setValues({ ...values, email: e.target.value })}
163
+ onBlur={() => handleBlur("email")}
164
+ placeholder=" "
165
+ aria-invalid={!!errors.email}
166
+ aria-describedby={errors.email ? "nebula-email-error" : undefined}
167
+ className="peer block w-full px-4 pt-6 pb-2 min-h-[48px] text-base text-slate-900 bg-white border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
168
+ />
169
+ <label
170
+ htmlFor="nebula-email"
171
+ className="absolute text-sm text-slate-500 duration-150 transform -translate-y-3 scale-75 top-4 z-10 origin-[0] left-4 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-3 peer-focus:text-blue-600"
172
+ >
173
+ Email Address
174
+ </label>
175
+ {errors.email && <p id="nebula-email-error" className="mt-1 text-xs text-red-600">{errors.email}</p>}
176
+ </div>
177
+
178
+ <button
179
+ type="submit"
180
+ className="w-full min-h-[48px] px-6 py-3 text-base font-semibold text-white bg-blue-600 hover:bg-blue-700 active:bg-blue-800 rounded-lg shadow-md transition-colors focus:outline-none focus:ring-4 focus:ring-blue-300 touch-manipulation"
181
+ >
182
+ {submitText}
183
+ </button>
184
+ </form>
185
+ );
186
+ }`,
187
+ vue: `<template>
188
+ <form @submit.prevent="handleSubmit" class="space-y-4 max-w-md w-full" no-validate>
189
+ <div class="relative">
190
+ <input
191
+ id="nebula-name"
192
+ name="name"
193
+ type="text"
194
+ autocomplete="name"
195
+ v-model="values.name"
196
+ @blur="handleBlur('name')"
197
+ placeholder=" "
198
+ :aria-invalid="!!errors.name"
199
+ class="peer block w-full px-4 pt-6 pb-2 min-h-[48px] text-base text-slate-900 bg-white border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
200
+ />
201
+ <label
202
+ for="nebula-name"
203
+ class="absolute text-sm text-slate-500 duration-150 transform -translate-y-3 scale-75 top-4 z-10 origin-[0] left-4 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-3 peer-focus:text-blue-600"
204
+ >
205
+ Full Name
206
+ </label>
207
+ <p v-if="errors.name" class="mt-1 text-xs text-red-600">{{ errors.name }}</p>
208
+ </div>
209
+
210
+ <button
211
+ type="submit"
212
+ class="w-full min-h-[48px] px-6 py-3 text-base font-semibold text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-md transition-colors focus:outline-none focus:ring-4 focus:ring-blue-300 touch-manipulation"
213
+ >
214
+ {{ submitText }}
215
+ </button>
216
+ </form>
217
+ </template>
218
+
219
+ <script setup>
220
+ import { reactive } from "vue";
221
+ defineProps({ submitText: { type: String, default: "Complete Order" } });
222
+ const emit = defineEmits(["submit"]);
223
+ const values = reactive({ name: "", email: "" });
224
+ const errors = reactive({});
225
+
226
+ const handleBlur = (field) => {
227
+ if (!values[field]?.trim()) errors[field] = "This field is required";
228
+ else delete errors[field];
229
+ };
230
+ const handleSubmit = () => emit("submit", { ...values });
231
+ </script>`,
232
+ html: `<!-- NebulaFrictionlessForm Component (CRO-007, CRO-008, CRO-009 compliant) -->
233
+ <form class="space-y-4 max-w-md w-full" method="POST" action="/submit">
234
+ <div class="relative">
235
+ <input
236
+ id="nebula-name"
237
+ name="name"
238
+ type="text"
239
+ autocomplete="name"
240
+ placeholder=" "
241
+ required
242
+ class="peer block w-full px-4 pt-6 pb-2 min-h-[48px] text-base text-slate-900 bg-white border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
243
+ />
244
+ <label
245
+ for="nebula-name"
246
+ class="absolute text-sm text-slate-500 duration-150 transform -translate-y-3 scale-75 top-4 z-10 origin-[0] left-4 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-3 peer-focus:text-blue-600"
247
+ >
248
+ Full Name
249
+ </label>
250
+ </div>
251
+ <button
252
+ type="submit"
253
+ class="w-full min-h-[48px] px-6 py-3 text-base font-semibold text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-md transition-colors focus:outline-none focus:ring-4 focus:ring-blue-300 touch-manipulation"
254
+ >
255
+ Complete Order
256
+ </button>
257
+ </form>`
258
+ }
259
+ },
260
+
261
+ "sticky-dock": {
262
+ name: "NebulaStickyMobileDock",
263
+ id: "sticky-dock",
264
+ description: "Fixed-bottom mobile thumb-zone conversion dock with scroll-aware disclosure (remediates CRO-011, CRO-015).",
265
+ detectors: ["CRO-011", "CRO-015"],
266
+ templates: {
267
+ react: `import React, { useState, useEffect } from "react";
268
+
269
+ export function NebulaStickyMobileDock({
270
+ ctaText = "Start Free Trial",
271
+ ctaHref = "/signup",
272
+ triggerSelector = "#hero-cta",
273
+ onCtaClick,
274
+ }) {
275
+ const [visible, setVisible] = useState(false);
276
+
277
+ useEffect(() => {
278
+ const target = triggerSelector ? document.querySelector(triggerSelector) : null;
279
+ const handleScroll = () => {
280
+ if (target) {
281
+ const rect = target.getBoundingClientRect();
282
+ setVisible(rect.bottom < 0);
283
+ } else {
284
+ setVisible(window.scrollY > 400);
285
+ }
286
+ };
287
+ window.addEventListener("scroll", handleScroll, { passive: true });
288
+ handleScroll();
289
+ return () => window.removeEventListener("scroll", handleScroll);
290
+ }, [triggerSelector]);
291
+
292
+ if (!visible) return null;
293
+
294
+ return (
295
+ <aside
296
+ role="complementary"
297
+ aria-label="Quick action"
298
+ className="sm:hidden fixed bottom-0 left-0 right-0 z-50 p-3 bg-white/95 backdrop-blur-md border-t border-slate-200 shadow-lg"
299
+ >
300
+ <div className="flex items-center gap-3 max-w-md mx-auto">
301
+ <a
302
+ href={ctaHref}
303
+ onClick={onCtaClick}
304
+ className="flex-1 inline-flex items-center justify-center min-h-[48px] px-6 py-3 text-base font-semibold text-white bg-blue-600 hover:bg-blue-700 active:bg-blue-800 rounded-lg shadow-md transition-colors focus:outline-none focus:ring-4 focus:ring-blue-300 touch-manipulation"
305
+ >
306
+ {ctaText}
307
+ </a>
308
+ </div>
309
+ </aside>
310
+ );
311
+ }`,
312
+ vue: `<template>
313
+ <aside
314
+ v-if="visible"
315
+ role="complementary"
316
+ aria-label="Quick action"
317
+ class="sm:hidden fixed bottom-0 left-0 right-0 z-50 p-3 bg-white/95 backdrop-blur-md border-t border-slate-200 shadow-lg"
318
+ >
319
+ <div class="flex items-center gap-3 max-w-md mx-auto">
320
+ <a
321
+ :href="ctaHref"
322
+ @click="$emit('cta-click', $event)"
323
+ class="flex-1 inline-flex items-center justify-center min-h-[48px] px-6 py-3 text-base font-semibold text-white bg-blue-600 hover:bg-blue-700 active:bg-blue-800 rounded-lg shadow-md transition-colors focus:outline-none focus:ring-4 focus:ring-blue-300 touch-manipulation"
324
+ >
325
+ {{ ctaText }}
326
+ </a>
327
+ </div>
328
+ </aside>
329
+ </template>
330
+
331
+ <script setup>
332
+ import { ref, onMounted, onUnmounted } from "vue";
333
+ const props = defineProps({
334
+ ctaText: { type: String, default: "Start Free Trial" },
335
+ ctaHref: { type: String, default: "/signup" },
336
+ triggerSelector: { type: String, default: "#hero-cta" },
337
+ });
338
+ defineEmits(["cta-click"]);
339
+ const visible = ref(false);
340
+
341
+ const handleScroll = () => {
342
+ const target = props.triggerSelector ? document.querySelector(props.triggerSelector) : null;
343
+ visible.value = target ? target.getBoundingClientRect().bottom < 0 : window.scrollY > 400;
344
+ };
345
+ onMounted(() => {
346
+ window.addEventListener("scroll", handleScroll, { passive: true });
347
+ handleScroll();
348
+ });
349
+ onUnmounted(() => window.removeEventListener("scroll", handleScroll));
350
+ </script>`,
351
+ html: `<!-- NebulaStickyMobileDock Component (CRO-011, CRO-015 compliant) -->
352
+ <aside
353
+ id="nebula-sticky-dock"
354
+ role="complementary"
355
+ aria-label="Quick conversion action"
356
+ class="sm:hidden fixed bottom-0 left-0 right-0 z-50 p-3 bg-white/95 backdrop-blur-md border-t border-slate-200 shadow-lg"
357
+ >
358
+ <div class="flex items-center gap-3 max-w-md mx-auto">
359
+ <a
360
+ href="/signup"
361
+ class="flex-1 inline-flex items-center justify-center min-h-[48px] px-6 py-3 text-base font-semibold text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-md transition-colors focus:outline-none focus:ring-4 focus:ring-blue-300 touch-manipulation"
362
+ >
363
+ Start Free Trial
364
+ </a>
365
+ </div>
366
+ </aside>`
367
+ }
368
+ },
369
+
370
+ "scent-beacon": {
371
+ name: "NebulaScentBeacon",
372
+ id: "scent-beacon",
373
+ description: "AI Search referral scent alignment component highlighting cited claims and docking adjacent conversion triggers (remediates CRO-019). Referral fragments and referrer headers are user-controlled, untrusted input: they are used only to DETECT the AI-referral state against an allowlisted engine list; their raw values are never rendered, stored, or transmitted.",
374
+ detectors: ["CRO-019"],
375
+ safety: {
376
+ untrusted_inputs: ["document.referrer", "window.location.hash (including #:~:text= fragments)"],
377
+ boundary: "Engine identity is matched against a fixed allowlist (perplexity, searchgpt, openai, gemini) and only a static label is rendered. Referral content is never reflected into the DOM, never persisted, never transmitted, and never logged. Any dynamic claim text must come from developer-controlled props bound to verified registry claims, not from URL fragments.",
378
+ csp: "No eval, no inline event handlers in the React/Vue templates; safe under strict CSP.",
379
+ },
380
+ templates: {
381
+ react: `import React, { useEffect, useState } from "react";
382
+
383
+ // Untrusted-input boundary: document.referrer and location.hash are
384
+ // user-controlled. They are ONLY matched against this fixed allowlist to
385
+ // detect the referral state. Raw values are never rendered, stored, or sent.
386
+ const ALLOWED_ENGINES = [
387
+ { match: "perplexity", label: "Perplexity" },
388
+ { match: "searchgpt", label: "SearchGPT" },
389
+ { match: "openai", label: "SearchGPT" },
390
+ { match: "gemini", label: "Google Gemini" },
391
+ ];
392
+
393
+ export function NebulaScentBeacon({
394
+ claimText,
395
+ ctaText = "Explore This Feature",
396
+ ctaHref = "#demo",
397
+ }) {
398
+ const [active, setActive] = useState(false);
399
+ const [sourceEngine, setSourceEngine] = useState("");
400
+
401
+ useEffect(() => {
402
+ const ref = document.referrer.toLowerCase();
403
+ const hash = window.location.hash;
404
+ const isAiReferral = ALLOWED_ENGINES.some((e) => ref.includes(e.match)) || hash.includes(":~:text=");
405
+ if (isAiReferral) {
406
+ setActive(true);
407
+ const engine = ALLOWED_ENGINES.find((e) => ref.includes(e.match));
408
+ setSourceEngine(engine ? engine.label : "AI Search");
409
+ }
410
+ }, []);
411
+
412
+ if (!active) return null;
413
+
414
+ return (
415
+ <div
416
+ role="region"
417
+ aria-label="Corroborated AI citation context"
418
+ className="my-4 p-4 bg-amber-50 border-l-4 border-amber-500 rounded-r-lg shadow-sm"
419
+ >
420
+ <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
421
+ <div className="text-sm text-amber-950">
422
+ <span className="font-semibold">{sourceEngine} Citation Corroboration:</span>{" "}
423
+ {claimText || "This page corroborates the verified claim cited in your search."}
424
+ </div>
425
+ <a
426
+ href={ctaHref}
427
+ className="inline-flex items-center justify-center min-h-[44px] px-4 py-2 text-xs font-semibold text-white bg-amber-600 hover:bg-amber-700 rounded-md transition-colors touch-manipulation focus:outline-none focus:ring-2 focus:ring-amber-400"
428
+ >
429
+ {ctaText}
430
+ </a>
431
+ </div>
432
+ </div>
433
+ );
434
+ }`,
435
+ vue: `<template>
436
+ <div
437
+ v-if="active"
438
+ role="region"
439
+ aria-label="Corroborated AI citation context"
440
+ class="my-4 p-4 bg-amber-50 border-l-4 border-amber-500 rounded-r-lg shadow-sm"
441
+ >
442
+ <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
443
+ <div class="text-sm text-amber-950">
444
+ <span class="font-semibold">{{ sourceEngine }} Citation Corroboration:</span>
445
+ {{ claimText || "This page corroborates the verified claim cited in your search." }}
446
+ </div>
447
+ <a
448
+ :href="ctaHref"
449
+ class="inline-flex items-center justify-center min-h-[44px] px-4 py-2 text-xs font-semibold text-white bg-amber-600 hover:bg-amber-700 rounded-md transition-colors touch-manipulation"
450
+ >
451
+ {{ ctaText }}
452
+ </a>
453
+ </div>
454
+ </div>
455
+ </template>
456
+
457
+ <script setup>
458
+ // Untrusted-input boundary: document.referrer and location.hash are
459
+ // user-controlled. Only the allowlisted labels below are ever rendered.
460
+ const ALLOWED_ENGINES = [
461
+ { match: "perplexity", label: "Perplexity" },
462
+ { match: "searchgpt", label: "SearchGPT" },
463
+ { match: "openai", label: "SearchGPT" },
464
+ { match: "gemini", label: "Google Gemini" },
465
+ ];
466
+ defineProps({
467
+ claimText: { type: String, default: "" },
468
+ ctaText: { type: String, default: "Explore This Feature" },
469
+ ctaHref: { type: String, default: "#demo" },
470
+ });
471
+ const active = ref(false);
472
+ const sourceEngine = ref("AI Search");
473
+
474
+ onMounted(() => {
475
+ const refUrl = document.referrer.toLowerCase();
476
+ const hash = window.location.hash;
477
+ const engine = ALLOWED_ENGINES.find((e) => refUrl.includes(e.match));
478
+ if (engine || hash.includes(":~:text=")) {
479
+ active.value = true;
480
+ if (engine) sourceEngine.value = engine.label;
481
+ }
482
+ });
483
+ </script>`,
484
+ html: `<!-- NebulaScentBeacon Component (CRO-019 compliant) -->
485
+ <div
486
+ id="nebula-scent-beacon"
487
+ role="region"
488
+ aria-label="Corroborated AI citation context"
489
+ class="my-4 p-4 bg-amber-50 border-l-4 border-amber-500 rounded-r-lg shadow-sm"
490
+ >
491
+ <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
492
+ <div class="text-sm text-amber-950">
493
+ <span class="font-semibold">AI Search Corroboration:</span>
494
+ Verified claim matching your search inquiry.
495
+ </div>
496
+ <a
497
+ href="#demo"
498
+ class="inline-flex items-center justify-center min-h-[44px] px-4 py-2 text-xs font-semibold text-white bg-amber-600 hover:bg-amber-700 rounded-md transition-colors touch-manipulation"
499
+ >
500
+ Explore Feature
501
+ </a>
502
+ </div>
503
+ </div>`
504
+ }
505
+ },
506
+
507
+ "touch-target": {
508
+ name: "NebulaTouchTarget",
509
+ id: "touch-target",
510
+ description: "Accessible hit-area wrapper ensuring 48x48px touch boundaries without altering visual element layout (remediates CRO-015, COMP-001).",
511
+ detectors: ["CRO-015", "COMP-001"],
512
+ templates: {
513
+ react: `import React from "react";
514
+
515
+ export function NebulaTouchTarget({ children, className = "" }) {
516
+ return (
517
+ <span className={\`relative inline-flex items-center justify-center \${className}\`}>
518
+ {children}
519
+ <span className="absolute inset-0 min-h-[48px] min-w-[48px] -m-1 pointer-events-none" aria-hidden="true" />
520
+ </span>
521
+ );
522
+ }`,
523
+ vue: `<template>
524
+ <span :class="['relative inline-flex items-center justify-center', className]">
525
+ <slot />
526
+ <span class="absolute inset-0 min-h-[48px] min-w-[48px] -m-1 pointer-events-none" aria-hidden="true" />
527
+ </span>
528
+ </template>
529
+
530
+ <script setup>
531
+ defineProps({ className: { type: String, default: "" } });
532
+ </script>`,
533
+ html: `<!-- NebulaTouchTarget Wrapper (CRO-015, COMP-001 compliant) -->
534
+ <span class="relative inline-flex items-center justify-center">
535
+ <button class="px-3 py-1.5 text-sm font-medium text-slate-700">Action</button>
536
+ <span class="absolute inset-0 min-h-[48px] min-w-[48px] -m-1 pointer-events-none" aria-hidden="true"></span>
537
+ </span>`
538
+ }
539
+ }
540
+ };
541
+
542
+ export function getComponent(id, format = "react") {
543
+ const comp = COMPONENTS[id];
544
+ if (!comp) return null;
545
+ const template = comp.templates[format] || comp.templates.react;
546
+ return { ...comp, code: template, format };
547
+ }
548
+
549
+ export function listComponents() {
550
+ return Object.values(COMPONENTS).map((c) => ({
551
+ id: c.id,
552
+ name: c.name,
553
+ description: c.description,
554
+ detectors: c.detectors,
555
+ }));
556
+ }
@@ -774,4 +774,52 @@ D.push(defineDetector({
774
774
  },
775
775
  }));
776
776
 
777
+ D.push(defineDetector({
778
+ id: 'CRO-021', name: 'High-intent conversion step lacks express payment wallet options', namespace: 'CRO',
779
+ version: 2,
780
+ description: 'A high-intent conversion step (checkout, pricing, cart, subscription) contains traditional payment forms or checkout actions but provides no express payment wallet buttons (Apple Pay, Google Pay, PayPal Express). Payment wallets and WebAuthn passkey authentication are distinct capabilities: this detector reports payment-wallet readiness only; passkey/WebAuthn authentication readiness is reported separately by inspect cro as an informational index.',
781
+ discipline: ['seo', 'aeo'], severity: 'medium', deterministic: true, requires: ['site'],
782
+ impact: { conversion: 'high' },
783
+ applicable_requirement: 'CRO §21 express checkout ergonomics; mobile checkout friction reduction (payment wallets; authentication readiness tracked separately)',
784
+ remediation: 'Add express payment wallet triggers (Apple Pay, Google Pay, PayPal Express) on high-intent conversion steps. Passkey/WebAuthn authentication is an account-authentication capability and does not substitute for a payment wallet.',
785
+ verification: 'Confirm high-intent conversion pages provide at least one express payment wallet mechanism.',
786
+ check(ctx) {
787
+ const hits = [];
788
+ for (const p of indexTargets(ctx)) {
789
+ const reg = registryPageFor(ctx, p);
790
+ const pageType = (reg?.page_type || '').toLowerCase();
791
+ const urlLower = (p.url || '').toLowerCase();
792
+ const isCheckout = ['checkout', 'cart'].includes(pageType) ||
793
+ urlLower.includes('/checkout') || urlLower.includes('/cart') ||
794
+ (p.forms || []).some((f) => f.inputs && f.inputs.some((inp) => /card|cvv|exp|payment/i.test(inp.name || inp.type || '')));
795
+ if (!isCheckout) continue;
796
+
797
+ const ctas = p.ctas || [];
798
+ const forms = p.forms || [];
799
+ const hasCheckoutAction = ctas.some((c) => /checkout|pay|purchase|subscribe|order/i.test(c.text || '')) || forms.length > 0;
800
+ if (!hasCheckoutAction) continue;
801
+
802
+ const walletSignals = ctas.filter((c) => /apple\s*pay|google\s*pay|paypal|one-click/i.test(c.text || ''));
803
+ const hasExpressPayment = walletSignals.length > 0 ||
804
+ (p.trustBadges || []).some((t) => /apple\s*pay|google\s*pay|paypal/i.test(t.signal || '')) ||
805
+ (p.rawHtml && /data-express-payment|apple-pay|google-pay|paypal-button/i.test(p.rawHtml));
806
+
807
+ if (!hasExpressPayment) {
808
+ hits.push({
809
+ subject: pageSubject(p),
810
+ summary: `High-intent conversion page "${p.url}" lacks express payment wallet options (Apple Pay, Google Pay, PayPal Express)`,
811
+ evidence: [
812
+ `page type: "${pageType || 'inferred checkout'}"`,
813
+ `detected CTAs: ${ctas.map((c) => c.text).join(', ') || 'none'}`,
814
+ 'payment wallets (Apple Pay, Google Pay, PayPal Express) are distinct from WebAuthn passkey authentication; mobile visitors abandon multi-step checkout forms without express wallet options',
815
+ ],
816
+ captured: { has_express_payment_wallet: false, ctaCount: ctas.length },
817
+ expected: '>= 1 express payment wallet trigger (Apple Pay, Google Pay, PayPal Express)',
818
+ });
819
+ }
820
+ }
821
+ return hits;
822
+ },
823
+ }));
824
+
777
825
  export default D;
@@ -1,4 +1,8 @@
1
1
  import { sha256 } from '../shared/io.js';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { readJson } from '../shared/io.js';
4
+
5
+ const TOOL_VERSION = readJson(new URL('../../package.json', import.meta.url)).version;
2
6
 
3
7
  const REQUIRED = ['id', 'name', 'namespace', 'description', 'discipline', 'severity', 'deterministic', 'remediation', 'verification', 'check'];
4
8
  // RENDER (browser-rendered truth / source-render divergence) is reserved for the
@@ -7,8 +11,10 @@ const NAMESPACES = ['TECH', 'CRAWL', 'ARCH', 'PAGE', 'ANS', 'ENTITY', 'CLAIM', '
7
11
 
8
12
  /**
9
13
  * Detector definition contract. `check(ctx)` returns raw hits:
10
- * { subject: {type, identifier, url?, source_file?, source_location?}, summary, evidence: [..],
14
+ * { subject: {type, identifier, url?, source_file?, source_location?, rendered_selector?}, summary, evidence: [..],
11
15
  * captured?, expected?, severity?, confidence? }
16
+ * Bump `version` whenever detector logic, thresholds, or output semantics
17
+ * change; findings record it in provenance.detector_version.
12
18
  */
13
19
  export function defineDetector(def) {
14
20
  for (const k of REQUIRED) {
@@ -17,6 +23,7 @@ export function defineDetector(def) {
17
23
  if (!NAMESPACES.includes(def.namespace)) throw new Error(`detector ${def.id}: unknown namespace ${def.namespace}`);
18
24
  if (!def.id.startsWith(def.namespace + '-')) throw new Error(`detector id ${def.id} must be prefixed with namespace`);
19
25
  return {
26
+ version: 1,
20
27
  confidence_model: def.deterministic ? 'binary condition; confirmed when observed' : 'heuristic; confidence reported per finding',
21
28
  confidence: def.deterministic ? 'confirmed' : 'medium',
22
29
  finding_type: def.deterministic ? 'deterministic_observation' : 'probabilistic_inference',
@@ -28,6 +35,14 @@ export function defineDetector(def) {
28
35
  };
29
36
  }
30
37
 
38
+ function evidenceSourceFor(detector, ctx) {
39
+ if (ctx.observations?.length && detector.requires?.includes('observations')) return 'controlled_observation';
40
+ if (detector.requires?.includes('registries') && detector.requires?.includes('site')) return 'registry+dom_parse';
41
+ if (detector.requires?.includes('registries')) return 'registry';
42
+ if (detector.requires?.includes('site')) return 'dom_parse';
43
+ return 'configuration';
44
+ }
45
+
31
46
  /** Run detectors over a context; returns { findings, detectorsRun, detectorsSkipped, errors }. */
32
47
  export function runDetectors(detectors, ctx) {
33
48
  const findings = [];
@@ -53,6 +68,7 @@ export function runDetectors(detectors, ctx) {
53
68
  findings.push({
54
69
  finding_id: `F-${sha256(idSeed).slice(0, 12)}`,
55
70
  detector_id: d.id,
71
+ detector_name: d.name,
56
72
  run_id: ctx.runId ?? 'adhoc',
57
73
  timestamp: ts,
58
74
  discipline: d.discipline,
@@ -88,6 +104,20 @@ export function runDetectors(detectors, ctx) {
88
104
  expected_result: 'detector no longer reports this subject',
89
105
  detector_to_rerun: d.id,
90
106
  },
107
+ provenance: {
108
+ detector_version: d.version ?? 1,
109
+ tool_version: TOOL_VERSION,
110
+ source_url: hit.subject?.url ?? hit.subject?.identifier ?? null,
111
+ source_file: hit.subject?.source_file ?? null,
112
+ evidence_selector: hit.subject?.rendered_selector ?? hit.subject?.source_location ?? null,
113
+ evidence_source: evidenceSourceFor(d, ctx),
114
+ viewport: ctx.viewport ?? null,
115
+ viewport_note: ctx.viewport ? null : 'viewport not captured for this collection method; static source/built-output check',
116
+ methodology: d.deterministic
117
+ ? 'deterministic condition check over captured evidence'
118
+ : 'heuristic judgment; human semantic review required before acting',
119
+ revalidation_required: d.deterministic ? 'on_next_audit' : 'human_review_before_action',
120
+ },
91
121
  status: { state: 'open', first_seen: ts, last_seen: ts, resolved_at: null },
92
122
  });
93
123
  }