@wcag-checkr/ci 1.0.0-rc.13

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 (50) hide show
  1. package/README.md +135 -0
  2. package/dist/assets/ErrorBoundary-BPz4qckm.js +524 -0
  3. package/dist/assets/_commonjsHelpers-Cpj98o6Y.js +1 -0
  4. package/dist/assets/ai-usage-log-DFkwAfmW.js +1 -0
  5. package/dist/assets/content-script.ts-D7yXcBUr.js +181 -0
  6. package/dist/assets/content-script.ts-loader-Cn8Y9Xod.js +13 -0
  7. package/dist/assets/crash-reporter-wxu43qbG.js +4 -0
  8. package/dist/assets/devtools-panel-D2fL4guz.js +1 -0
  9. package/dist/assets/devtools.html-DQBohI9U.js +1 -0
  10. package/dist/assets/diff-D4sCAdXf.js +1 -0
  11. package/dist/assets/forensic-log-B3iX62mE.js +129 -0
  12. package/dist/assets/main-CqDdt0Iq.js +6 -0
  13. package/dist/assets/main-DyQfCbPM.js +1 -0
  14. package/dist/assets/modulepreload-polyfill-B5Qt9EMX.js +1 -0
  15. package/dist/assets/options.html-jfjpxZBp.js +1 -0
  16. package/dist/assets/preload-helper-D7HrI6pR.js +1 -0
  17. package/dist/assets/reflow-analyzer-DNgBX8N_.js +1 -0
  18. package/dist/assets/service-worker.ts-DaHvU8nE.js +715 -0
  19. package/dist/assets/side-panel.html-DW1tssqQ.js +1 -0
  20. package/dist/assets/site-report-renderer-JH44v2hK.js +147 -0
  21. package/dist/assets/state-DnzwwNxZ.js +1 -0
  22. package/dist/assets/styles-DP9v_aMy.css +1 -0
  23. package/dist/assets/styles-kHMb1Lda.js +84 -0
  24. package/dist/devtools/devtools.html +11 -0
  25. package/dist/devtools/panel.html +20 -0
  26. package/dist/fonts/mona-sans-variable.woff2 +0 -0
  27. package/dist/icons/icon-128.png +0 -0
  28. package/dist/icons/icon-16.png +0 -0
  29. package/dist/icons/icon-32.png +0 -0
  30. package/dist/icons/icon-48.png +0 -0
  31. package/dist/manifest.json +70 -0
  32. package/dist/options/options.html +19 -0
  33. package/dist/service-worker-loader.js +1 -0
  34. package/dist/side-panel/App.tsx +174 -0
  35. package/dist/side-panel/README.md +57 -0
  36. package/dist/side-panel/audit-launcher.test.ts +56 -0
  37. package/dist/side-panel/audit-launcher.ts +65 -0
  38. package/dist/side-panel/format-component-id.test.ts +89 -0
  39. package/dist/side-panel/format-component-id.ts +40 -0
  40. package/dist/side-panel/github-issue.test.ts +102 -0
  41. package/dist/side-panel/github-issue.ts +66 -0
  42. package/dist/side-panel/jira-issue.ts +64 -0
  43. package/dist/side-panel/main.tsx +19 -0
  44. package/dist/side-panel/side-panel.html +21 -0
  45. package/dist/side-panel/store.ts +264 -0
  46. package/dist/side-panel/styles.css +16 -0
  47. package/dist/side-panel/wire-messaging.test.ts +202 -0
  48. package/dist/side-panel/wire-messaging.ts +285 -0
  49. package/package.json +39 -0
  50. package/wcagcheckr-ci.mjs +559 -0
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildGithubIssueUrl } from './github-issue';
3
+ import { DEFAULT_BREAKPOINT_PRESETS } from '../types/state';
4
+ import type { Violation } from '../types/audit';
5
+
6
+ const desktop = DEFAULT_BREAKPOINT_PRESETS.find((p) => p.id === 'desktop')!;
7
+
8
+ function v(overrides: Partial<Violation> = {}): Violation {
9
+ return {
10
+ ruleId: 'color-contrast',
11
+ wcagCriterion: 'wcag143',
12
+ wcagLevel: 'AA',
13
+ impact: 'serious',
14
+ description: 'Insufficient contrast',
15
+ helpUrl: 'https://example.com/r',
16
+ target: {
17
+ selector: '#btn',
18
+ outerHTML: '<button id="btn">Go</button>',
19
+ failureSummary: '',
20
+ tagName: 'BUTTON',
21
+ role: null,
22
+ accessibleName: 'Go',
23
+ textSnippet: 'Go',
24
+ attrId: 'btn',
25
+ attrTestid: null,
26
+ },
27
+ componentId: '::cmp::btn',
28
+ currentState: {
29
+ pseudoState: 'default',
30
+ ariaVariation: null,
31
+ theme: 'light',
32
+ direction: 'ltr',
33
+ breakpoint: desktop,
34
+ },
35
+ axeVersion: '4.11.4',
36
+ detectedAt: '2026-05-04T00:00:00Z',
37
+ matchKey: 'mk-1',
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ describe('buildGithubIssueUrl', () => {
43
+ it('produces a /issues/new URL pointing at the configured repo', () => {
44
+ const r = buildGithubIssueUrl('https://github.com/foo/bar', '::cmp::x', [v()]);
45
+ expect(r.url.startsWith('https://github.com/foo/bar/issues/new?')).toBe(true);
46
+ });
47
+
48
+ it('strips trailing slash from repo URL', () => {
49
+ const r = buildGithubIssueUrl('https://github.com/foo/bar/', '::x', [v()]);
50
+ expect(r.url.startsWith('https://github.com/foo/bar/issues/new?')).toBe(true);
51
+ });
52
+
53
+ it('puts violation count in the title', () => {
54
+ const r = buildGithubIssueUrl('https://github.com/foo/bar', '::x', [
55
+ v({ matchKey: 'm1' }),
56
+ v({ matchKey: 'm2', ruleId: 'image-alt' }),
57
+ ]);
58
+ expect(r.title).toMatch(/2 new violations/);
59
+ });
60
+
61
+ it('singular vs plural in title', () => {
62
+ const r = buildGithubIssueUrl('https://github.com/foo/bar', '::x', [v()]);
63
+ expect(r.title).toMatch(/1 new violation in/);
64
+ expect(r.title).not.toMatch(/violations/);
65
+ });
66
+
67
+ it('dedupes violations sharing the same logical key (rule + selector)', () => {
68
+ const a = v({ matchKey: 'm1', currentState: { ...v().currentState, pseudoState: 'hover' } });
69
+ const b = v({ matchKey: 'm2', currentState: { ...v().currentState, pseudoState: 'focus' } });
70
+ const r = buildGithubIssueUrl('https://github.com/foo/bar', '::x', [a, b]);
71
+ // Title says 1 violation (deduped), body lists both states.
72
+ expect(r.title).toMatch(/1 new violation/);
73
+ expect(r.body).toMatch(/:hover/);
74
+ expect(r.body).toMatch(/:focus/);
75
+ });
76
+
77
+ it('includes selector, helpUrl, and outerHTML in the body', () => {
78
+ const r = buildGithubIssueUrl('https://github.com/foo/bar', '::x', [
79
+ v({
80
+ target: {
81
+ ...v().target,
82
+ selector: '.my-thing',
83
+ outerHTML: '<button class="my-thing">x</button>',
84
+ },
85
+ helpUrl: 'https://example.com/help-link',
86
+ }),
87
+ ]);
88
+ expect(r.body).toContain('.my-thing');
89
+ expect(r.body).toContain('https://example.com/help-link');
90
+ expect(r.body).toContain('<button class="my-thing">x</button>');
91
+ });
92
+
93
+ it('truncates the URL when it gets absurdly long', () => {
94
+ const huge = 'x'.repeat(20000);
95
+ const r = buildGithubIssueUrl('https://github.com/foo/bar', '::x', [
96
+ v({
97
+ target: { ...v().target, outerHTML: huge },
98
+ }),
99
+ ]);
100
+ expect(r.url.length).toBeLessThanOrEqual(7800);
101
+ });
102
+ });
@@ -0,0 +1,66 @@
1
+ // Builds a pre-filled GitHub new-issue URL from a set of NEW violations.
2
+ // Pure function — extracted from DeltaView so the URL/body shape is testable.
3
+
4
+ import type { Violation } from '../types/audit';
5
+
6
+ export type IssueBuild = { url: string; title: string; body: string };
7
+
8
+ const URL_LIMIT = 7800;
9
+
10
+ export function buildGithubIssueUrl(
11
+ repoUrl: string,
12
+ componentId: string | null,
13
+ newViolations: Violation[]
14
+ ): IssueBuild {
15
+ const cleaned = repoUrl.replace(/\/$/, '');
16
+
17
+ // Dedup violations by logical key (rule + selector); aggregate states.
18
+ const groups: Array<Violation & { _states: string[] }> = [];
19
+ for (const v of newViolations) {
20
+ const key = `${v.ruleId}::${v.target.selector}`;
21
+ const stateLabel = `:${v.currentState.pseudoState} · ${v.currentState.theme} · ${v.currentState.direction}`;
22
+ const existing = groups.find((g) => `${g.ruleId}::${g.target.selector}` === key);
23
+ if (existing) {
24
+ if (!existing._states.includes(stateLabel)) existing._states.push(stateLabel);
25
+ continue;
26
+ }
27
+ groups.push({ ...v, _states: [stateLabel] });
28
+ }
29
+
30
+ const title = `a11y: ${groups.length} new violation${groups.length === 1 ? '' : 's'} in ${componentId ?? 'audited component'}`;
31
+
32
+ const lines: string[] = [];
33
+ lines.push(`**Component:** \`${componentId ?? 'unknown'}\``);
34
+ lines.push('');
35
+ lines.push(
36
+ `Detected by WCAG Component Auditor as **new** vs the saved baseline — these are violations introduced since the last accepted baseline.`
37
+ );
38
+ lines.push('');
39
+ lines.push('---');
40
+ lines.push('');
41
+ for (const v of groups) {
42
+ lines.push(`### \`${v.ruleId}\` — ${v.impact}`);
43
+ lines.push('');
44
+ lines.push(v.description);
45
+ lines.push('');
46
+ lines.push(`- **WCAG:** ${v.wcagCriterion} (${v.wcagLevel})`);
47
+ lines.push(`- **Selector:** \`${v.target.selector}\``);
48
+ lines.push(`- **Found in state(s):** ${v._states.join(', ')}`);
49
+ if (v.helpUrl) lines.push(`- **More info:** ${v.helpUrl}`);
50
+ lines.push('');
51
+ lines.push('```html');
52
+ lines.push(v.target.outerHTML);
53
+ lines.push('```');
54
+ lines.push('');
55
+ }
56
+ lines.push('---');
57
+ lines.push('');
58
+ lines.push('_Filed via WCAG Component Auditor (delta-only — inherited debt not included)._');
59
+
60
+ const body = lines.join('\n');
61
+ const params = new URLSearchParams({ title, body });
62
+ let url = `${cleaned}/issues/new?${params.toString()}`;
63
+ if (url.length > URL_LIMIT) url = url.slice(0, URL_LIMIT);
64
+
65
+ return { url, title, body };
66
+ }
@@ -0,0 +1,64 @@
1
+ // Builds a pre-filled Jira create-issue URL from a set of NEW violations.
2
+ // Mirrors the GitHub flow but targets Jira Cloud's CreateIssue URL params.
3
+ // Description field is plain text; we keep formatting minimal so it reads
4
+ // cleanly in Jira's default text view (markdown won't render natively).
5
+
6
+ import type { Violation } from '../types/audit';
7
+
8
+ export type JiraIssueBuild = { url: string; summary: string; description: string };
9
+
10
+ const URL_LIMIT = 7800;
11
+
12
+ export function buildJiraIssueUrl(
13
+ instanceUrl: string,
14
+ componentId: string | null,
15
+ newViolations: Violation[]
16
+ ): JiraIssueBuild {
17
+ const cleaned = instanceUrl.replace(/\/$/, '');
18
+
19
+ // Dedup violations by logical key (rule + selector); aggregate states.
20
+ const groups: Array<Violation & { _states: string[] }> = [];
21
+ for (const v of newViolations) {
22
+ const key = `${v.ruleId}::${v.target.selector}`;
23
+ const stateLabel = `:${v.currentState.pseudoState} · ${v.currentState.theme} · ${v.currentState.direction}`;
24
+ const existing = groups.find((g) => `${g.ruleId}::${g.target.selector}` === key);
25
+ if (existing) {
26
+ if (!existing._states.includes(stateLabel)) existing._states.push(stateLabel);
27
+ continue;
28
+ }
29
+ groups.push({ ...v, _states: [stateLabel] });
30
+ }
31
+
32
+ const summary = `a11y: ${groups.length} new violation${groups.length === 1 ? '' : 's'} in ${componentId ?? 'audited component'}`;
33
+
34
+ const lines: string[] = [];
35
+ lines.push(`Component: ${componentId ?? 'unknown'}`);
36
+ lines.push('');
37
+ lines.push(
38
+ `Detected by WCAG Component Auditor as NEW vs the saved baseline — these are violations introduced since the last accepted baseline.`
39
+ );
40
+ lines.push('');
41
+ lines.push('---');
42
+ lines.push('');
43
+ for (const v of groups) {
44
+ lines.push(`Rule: ${v.ruleId} (${v.impact})`);
45
+ lines.push(`WCAG: ${v.wcagCriterion} ${v.wcagLevel}`);
46
+ lines.push(`Description: ${v.description}`);
47
+ lines.push(`Selector: ${v.target.selector}`);
48
+ lines.push(`Found in state(s): ${v._states.join(', ')}`);
49
+ if (v.helpUrl) lines.push(`More info: ${v.helpUrl}`);
50
+ lines.push('');
51
+ lines.push(`HTML: ${v.target.outerHTML}`);
52
+ lines.push('');
53
+ lines.push('---');
54
+ lines.push('');
55
+ }
56
+ lines.push('Filed via WCAG Component Auditor (delta-only — inherited debt not included).');
57
+
58
+ const description = lines.join('\n');
59
+ const params = new URLSearchParams({ summary, description });
60
+ let url = `${cleaned}/secure/CreateIssue!default.jspa?${params.toString()}`;
61
+ if (url.length > URL_LIMIT) url = url.slice(0, URL_LIMIT);
62
+
63
+ return { url, summary, description };
64
+ }
@@ -0,0 +1,19 @@
1
+ import React from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { App } from './App';
4
+ import { ErrorBoundary } from './components/ErrorBoundary';
5
+ import { installCrashReporter } from '../modules/crash-reporter';
6
+ import './styles.css';
7
+
8
+ installCrashReporter('side-panel');
9
+
10
+ const root = document.getElementById('root');
11
+ if (!root) throw new Error('side-panel: #root not found');
12
+
13
+ createRoot(root).render(
14
+ <React.StrictMode>
15
+ <ErrorBoundary>
16
+ <App />
17
+ </ErrorBoundary>
18
+ </React.StrictMode>
19
+ );
@@ -0,0 +1,21 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>WCAG Component Auditor</title>
7
+ <script type="module" crossorigin src="/assets/modulepreload-polyfill-B5Qt9EMX.js"></script>
8
+ <script type="module" crossorigin src="/assets/_commonjsHelpers-Cpj98o6Y.js"></script>
9
+ <script type="module" crossorigin src="/assets/crash-reporter-wxu43qbG.js"></script>
10
+ <script type="module" crossorigin src="/assets/state-DnzwwNxZ.js"></script>
11
+ <script type="module" crossorigin src="/assets/styles-kHMb1Lda.js"></script>
12
+ <script type="module" crossorigin src="/assets/preload-helper-D7HrI6pR.js"></script>
13
+ <script type="module" crossorigin src="/assets/forensic-log-B3iX62mE.js"></script>
14
+ <script type="module" crossorigin src="/assets/ErrorBoundary-BPz4qckm.js"></script>
15
+ <script type="module" crossorigin src="/assets/main-DyQfCbPM.js"></script>
16
+ <link rel="stylesheet" crossorigin href="/assets/styles-DP9v_aMy.css">
17
+ </head>
18
+ <body class="m-0 p-0">
19
+ <div id="root"></div>
20
+ </body>
21
+ </html>
@@ -0,0 +1,264 @@
1
+ // Zustand store for side panel state.
2
+ // Spec: phase-1-specs.md §9.
3
+
4
+ import { create } from 'zustand';
5
+ import type { AuditResult, DeltaResult } from '../types/audit';
6
+ import type { StateConfig } from '../types/state';
7
+ import type { LicenseTier } from '../shared/messages';
8
+ import type { InAppMessage } from '../modules/messages-client';
9
+ import type { SiteCrawlReport } from '../shared/site-aggregator';
10
+
11
+ export type AuditStatus = 'idle' | 'running' | 'complete' | 'failed' | 'interrupted';
12
+ export type View = 'matrix' | 'delta' | 'activity' | 'guided' | 'flows' | 'scorecard' | 'crawl' | 'forensic' | 'compliance';
13
+ /** Two distinct personas: site owners (mom-and-pop, no code) vs developers
14
+ * (full feature surface, CLI-friendly outputs, baselines, exports). */
15
+ export type UserMode = 'owner' | 'dev';
16
+
17
+ type Store = {
18
+ // audit
19
+ status: AuditStatus;
20
+ progress: { current: number; total: number; currentState?: StateConfig } | null;
21
+ results: AuditResult[];
22
+ delta: DeltaResult | null;
23
+ componentId: string | null;
24
+ errorMessage: string | null;
25
+ /** True only when results came from a scan that completed during this side-panel
26
+ * session. False when results were rehydrated from `loadLastAudit` — in that case
27
+ * the score UI should mark the grade as "stale, re-scan to refresh" rather than
28
+ * imply it reflects the page's current state. */
29
+ freshThisSession: boolean;
30
+
31
+ // baselines
32
+ baselineList: Array<{
33
+ componentId: string;
34
+ violationCount: number;
35
+ lastUpdated: string;
36
+ seenOnUrlsCount?: number;
37
+ metrics?: import('../shared/messages').BaselineSummaryMetrics;
38
+ }>;
39
+
40
+ // license
41
+ tier: LicenseTier;
42
+ /** Trial-tier countdown. null = unknown / not on trial. */
43
+ trialDaysRemaining: number | null;
44
+ /** Team-tier seat consumption. null = server hasn't emitted seat info. */
45
+ seatsUsed: number | null;
46
+ seatsTotal: number | null;
47
+ /** Server's actual plan code for the active license — `solo`, `solo-yearly`,
48
+ * `solo-single-month`, `team`, `team-15`. Lets the UpgradeCard tell apart
49
+ * the recurring SKUs from the one-time single-month pass even though they
50
+ * all map to tier='solo' or tier='team' for capability gating. */
51
+ planCode: string | null;
52
+ /** Days until the active one-time license (e.g. solo-single-month) expires.
53
+ * null for recurring subscriptions. */
54
+ licenseDaysRemaining: number | null;
55
+ /** True when Stripe reported the most recent invoice payment as failed.
56
+ * License stays valid during the grace period but UI surfaces the
57
+ * failure prominently so the user updates their card before access cuts. */
58
+ pastDue: boolean;
59
+
60
+ // in-app messages
61
+ messages: InAppMessage[];
62
+ unreadMessageCount: number;
63
+ criticalUnacked: boolean;
64
+
65
+ // ui
66
+ view: View;
67
+ /** Persona-driven UI mode. `null` = first-launch (wizard not yet answered). */
68
+ userMode: UserMode | null;
69
+
70
+ // site crawl
71
+ siteCrawlStatus: 'idle' | 'running' | 'complete' | 'failed';
72
+ siteCrawlProgress: { current: number; total: number; url: string; lastViolations?: number } | null;
73
+ siteCrawlReport: SiteCrawlReport | null;
74
+ siteCrawlError: string | null;
75
+
76
+ // highlight pin: which violation row is currently showing its overlay on the page,
77
+ // and whether its selector resolved (false → render "not in current state" hint).
78
+ pinnedMatchKey: string | null;
79
+ pinnedFound: boolean;
80
+
81
+ // AI augmentation failure surface — shown as a dismissible banner above the
82
+ // results area when an audit's AI portion errored despite a configured key.
83
+ // Cleared whenever a new audit starts or completes cleanly.
84
+ aiFailure: {
85
+ severity: 'total' | 'partial';
86
+ reason: string;
87
+ checksAttempted: number;
88
+ checksSucceeded: number;
89
+ checksErrored: number;
90
+ errorDetails: string[];
91
+ } | null;
92
+
93
+ // AI augmentation in-flight progress. Drives the post-matrix progress line:
94
+ // matrix loop owns 1..N states, then AI augmentation takes over and the bar
95
+ // shows e.g. "AI: 3/8 — Verifying alt text…". Cleared when AUDIT_COMPLETE
96
+ // fires or a new scan starts.
97
+ aiProgress: {
98
+ currentCheckLabel: string;
99
+ current: number;
100
+ total: number;
101
+ } | null;
102
+
103
+ // actions
104
+ setStatus: (s: AuditStatus) => void;
105
+ /** Begin a new scan: flips status to 'running' AND clears prior results,
106
+ * delta, error, and progress so the panel doesn't show stale data from the
107
+ * previous site under the new "Scanning…" indicator. Use this from every
108
+ * START_AUDIT dispatch site instead of setStatus('running') alone. */
109
+ startNewScan: () => void;
110
+ setProgress: (p: Store['progress']) => void;
111
+ setResults: (results: AuditResult[], delta: DeltaResult | null, componentId: string) => void;
112
+ setDelta: (delta: DeltaResult | null) => void;
113
+ setError: (message: string) => void;
114
+ setBaselineList: (list: Store['baselineList']) => void;
115
+ /** Wipe the current audit (results, delta, componentId, error, progress).
116
+ * Used by the user-triggered Clear button and by auto-clear when the user
117
+ * navigates the audit target to a different URL. Baselines + forensic
118
+ * history are NOT touched — those are persistent records. */
119
+ clearResults: () => void;
120
+ setTier: (
121
+ tier: LicenseTier,
122
+ extras?: {
123
+ trialDaysRemaining?: number;
124
+ seatsUsed?: number;
125
+ seatsTotal?: number;
126
+ planCode?: string | null;
127
+ licenseDaysRemaining?: number;
128
+ pastDue?: boolean;
129
+ }
130
+ ) => void;
131
+ setView: (v: View) => void;
132
+ setUserMode: (m: UserMode | null) => void;
133
+ setSiteCrawlStatus: (s: Store['siteCrawlStatus']) => void;
134
+ setSiteCrawlProgress: (p: Store['siteCrawlProgress']) => void;
135
+ setSiteCrawlReport: (r: SiteCrawlReport | null) => void;
136
+ setSiteCrawlError: (e: string | null) => void;
137
+ setPinned: (matchKey: string | null, found?: boolean) => void;
138
+ setAiFailure: (failure: Store['aiFailure']) => void;
139
+ clearAiFailure: () => void;
140
+ setAiProgress: (progress: Store['aiProgress']) => void;
141
+ /** Replace the in-app messages list + counters. Called by wire-messaging
142
+ * after a refresh from the server. */
143
+ setMessages: (messages: InAppMessage[], unreadCount: number, criticalUnacked: boolean) => void;
144
+ /** Mark a message as locally seen/dismissed/acknowledged so the UI updates
145
+ * immediately without waiting for the next server refresh. */
146
+ applyMessageAck: (messageId: number, action: 'seen' | 'dismissed' | 'acknowledged' | 'clicked') => void;
147
+ };
148
+
149
+ export const useStore = create<Store>((set) => ({
150
+ status: 'idle',
151
+ progress: null,
152
+ results: [],
153
+ delta: null,
154
+ componentId: null,
155
+ errorMessage: null,
156
+ freshThisSession: false,
157
+ baselineList: [],
158
+ tier: 'trial',
159
+ trialDaysRemaining: null,
160
+ seatsUsed: null,
161
+ seatsTotal: null,
162
+ planCode: null,
163
+ licenseDaysRemaining: null,
164
+ pastDue: false,
165
+ messages: [],
166
+ unreadMessageCount: 0,
167
+ criticalUnacked: false,
168
+ view: 'matrix',
169
+ userMode: null,
170
+ siteCrawlStatus: 'idle',
171
+ siteCrawlProgress: null,
172
+ siteCrawlReport: null,
173
+ siteCrawlError: null,
174
+ pinnedMatchKey: null,
175
+ pinnedFound: true,
176
+ aiFailure: null,
177
+ aiProgress: null,
178
+
179
+ setStatus: (status) => set({ status }),
180
+ startNewScan: () =>
181
+ set({
182
+ status: 'running',
183
+ results: [],
184
+ delta: null,
185
+ errorMessage: null,
186
+ progress: null,
187
+ componentId: null,
188
+ pinnedMatchKey: null,
189
+ pinnedFound: true,
190
+ aiFailure: null,
191
+ aiProgress: null,
192
+ // freshThisSession stays false until setResults fires.
193
+ }),
194
+ setProgress: (progress) => set({ progress }),
195
+ setResults: (results, delta, componentId) =>
196
+ set({
197
+ results,
198
+ delta,
199
+ componentId,
200
+ status: 'complete',
201
+ errorMessage: null,
202
+ freshThisSession: true,
203
+ aiProgress: null,
204
+ // New audit invalidates any prior pin.
205
+ pinnedMatchKey: null,
206
+ pinnedFound: true,
207
+ }),
208
+ setDelta: (delta) => set({ delta }),
209
+ setError: (errorMessage) => set({ errorMessage, status: 'failed' }),
210
+ setBaselineList: (baselineList) => set({ baselineList }),
211
+ clearResults: () =>
212
+ set({
213
+ results: [],
214
+ delta: null,
215
+ componentId: null,
216
+ errorMessage: null,
217
+ progress: null,
218
+ status: 'idle',
219
+ freshThisSession: false,
220
+ pinnedMatchKey: null,
221
+ pinnedFound: true,
222
+ aiFailure: null,
223
+ aiProgress: null,
224
+ }),
225
+ setTier: (tier, extras) =>
226
+ set({
227
+ tier,
228
+ trialDaysRemaining: extras?.trialDaysRemaining ?? null,
229
+ seatsUsed: extras?.seatsUsed ?? null,
230
+ seatsTotal: extras?.seatsTotal ?? null,
231
+ planCode: extras?.planCode ?? null,
232
+ licenseDaysRemaining: extras?.licenseDaysRemaining ?? null,
233
+ pastDue: extras?.pastDue ?? false,
234
+ }),
235
+ setView: (view) => set({ view }),
236
+ setUserMode: (userMode) => set({ userMode }),
237
+ setSiteCrawlStatus: (siteCrawlStatus) => set({ siteCrawlStatus }),
238
+ setSiteCrawlProgress: (siteCrawlProgress) => set({ siteCrawlProgress }),
239
+ setSiteCrawlReport: (siteCrawlReport) => set({ siteCrawlReport }),
240
+ setSiteCrawlError: (siteCrawlError) => set({ siteCrawlError }),
241
+ setPinned: (matchKey, found = true) => set({ pinnedMatchKey: matchKey, pinnedFound: found }),
242
+ setAiFailure: (aiFailure) => set({ aiFailure }),
243
+ clearAiFailure: () => set({ aiFailure: null }),
244
+ setAiProgress: (aiProgress) => set({ aiProgress }),
245
+ setMessages: (messages, unreadMessageCount, criticalUnacked) =>
246
+ set({ messages, unreadMessageCount, criticalUnacked }),
247
+ applyMessageAck: (messageId, action) =>
248
+ set((s) => {
249
+ const next = s.messages
250
+ .map((m) => {
251
+ if (m.id !== messageId) return m;
252
+ if (action === 'seen') return { ...m, seen: true };
253
+ if (action === 'dismissed') return { ...m, dismissed: true };
254
+ if (action === 'acknowledged') return { ...m, acknowledged: true };
255
+ if (action === 'clicked') return { ...m, clicked: true };
256
+ return m;
257
+ })
258
+ // Drop fully-handled non-critical messages (mirrors server visibility).
259
+ .filter((m) => (m.severity === 'critical' ? !m.acknowledged : !m.dismissed));
260
+ const unread = next.filter((m) => !m.seen).length;
261
+ const critUnacked = next.some((m) => m.severity === 'critical' && !m.acknowledged);
262
+ return { messages: next, unreadMessageCount: unread, criticalUnacked: critUnacked };
263
+ }),
264
+ }));
@@ -0,0 +1,16 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ html, body, #root {
6
+ height: 100%;
7
+ }
8
+
9
+ /* Tailwind's preflight resets the UA focus ring. Restore a high-contrast
10
+ ring for keyboard users (only :focus-visible — pointer focus stays clean).
11
+ Brand-700 #3730a3 over white = 9.6:1 contrast; same ring shows fine on
12
+ bg-brand-500 buttons too. */
13
+ :focus-visible {
14
+ outline: 2px solid #3730a3;
15
+ outline-offset: 2px;
16
+ }