@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.
- package/README.md +135 -0
- package/dist/assets/ErrorBoundary-BPz4qckm.js +524 -0
- package/dist/assets/_commonjsHelpers-Cpj98o6Y.js +1 -0
- package/dist/assets/ai-usage-log-DFkwAfmW.js +1 -0
- package/dist/assets/content-script.ts-D7yXcBUr.js +181 -0
- package/dist/assets/content-script.ts-loader-Cn8Y9Xod.js +13 -0
- package/dist/assets/crash-reporter-wxu43qbG.js +4 -0
- package/dist/assets/devtools-panel-D2fL4guz.js +1 -0
- package/dist/assets/devtools.html-DQBohI9U.js +1 -0
- package/dist/assets/diff-D4sCAdXf.js +1 -0
- package/dist/assets/forensic-log-B3iX62mE.js +129 -0
- package/dist/assets/main-CqDdt0Iq.js +6 -0
- package/dist/assets/main-DyQfCbPM.js +1 -0
- package/dist/assets/modulepreload-polyfill-B5Qt9EMX.js +1 -0
- package/dist/assets/options.html-jfjpxZBp.js +1 -0
- package/dist/assets/preload-helper-D7HrI6pR.js +1 -0
- package/dist/assets/reflow-analyzer-DNgBX8N_.js +1 -0
- package/dist/assets/service-worker.ts-DaHvU8nE.js +715 -0
- package/dist/assets/side-panel.html-DW1tssqQ.js +1 -0
- package/dist/assets/site-report-renderer-JH44v2hK.js +147 -0
- package/dist/assets/state-DnzwwNxZ.js +1 -0
- package/dist/assets/styles-DP9v_aMy.css +1 -0
- package/dist/assets/styles-kHMb1Lda.js +84 -0
- package/dist/devtools/devtools.html +11 -0
- package/dist/devtools/panel.html +20 -0
- package/dist/fonts/mona-sans-variable.woff2 +0 -0
- package/dist/icons/icon-128.png +0 -0
- package/dist/icons/icon-16.png +0 -0
- package/dist/icons/icon-32.png +0 -0
- package/dist/icons/icon-48.png +0 -0
- package/dist/manifest.json +70 -0
- package/dist/options/options.html +19 -0
- package/dist/service-worker-loader.js +1 -0
- package/dist/side-panel/App.tsx +174 -0
- package/dist/side-panel/README.md +57 -0
- package/dist/side-panel/audit-launcher.test.ts +56 -0
- package/dist/side-panel/audit-launcher.ts +65 -0
- package/dist/side-panel/format-component-id.test.ts +89 -0
- package/dist/side-panel/format-component-id.ts +40 -0
- package/dist/side-panel/github-issue.test.ts +102 -0
- package/dist/side-panel/github-issue.ts +66 -0
- package/dist/side-panel/jira-issue.ts +64 -0
- package/dist/side-panel/main.tsx +19 -0
- package/dist/side-panel/side-panel.html +21 -0
- package/dist/side-panel/store.ts +264 -0
- package/dist/side-panel/styles.css +16 -0
- package/dist/side-panel/wire-messaging.test.ts +202 -0
- package/dist/side-panel/wire-messaging.ts +285 -0
- package/package.json +39 -0
- 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
|
+
}
|