@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,11 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>wcagcheckr — DevTools</title>
6
+ <script type="module" crossorigin src="/assets/devtools.html-DQBohI9U.js"></script>
7
+ <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
8
+ </head>
9
+ <body>
10
+ </body>
11
+ </html>
@@ -0,0 +1,20 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>wcagcheckr</title>
6
+ <script type="module" crossorigin src="/assets/devtools-panel-D2fL4guz.js"></script>
7
+ <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
8
+ <link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-Cpj98o6Y.js">
9
+ <link rel="modulepreload" crossorigin href="/assets/crash-reporter-wxu43qbG.js">
10
+ <link rel="modulepreload" crossorigin href="/assets/state-DnzwwNxZ.js">
11
+ <link rel="modulepreload" crossorigin href="/assets/styles-kHMb1Lda.js">
12
+ <link rel="modulepreload" crossorigin href="/assets/preload-helper-D7HrI6pR.js">
13
+ <link rel="modulepreload" crossorigin href="/assets/forensic-log-B3iX62mE.js">
14
+ <link rel="modulepreload" crossorigin href="/assets/ErrorBoundary-BPz4qckm.js">
15
+ <link rel="stylesheet" crossorigin href="/assets/styles-DP9v_aMy.css">
16
+ </head>
17
+ <body class="bg-slate-50">
18
+ <div id="root"></div>
19
+ </body>
20
+ </html>
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,70 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "wcagcheckr",
4
+ "description": "Audit components across hover, focus, dark mode, forced colors, RTL — every state your users actually encounter. Per-component baselines surface only NEW violations.",
5
+ "version": "1.0.0.13",
6
+ "version_name": "1.0.0-rc.13",
7
+ "author": "Locustware",
8
+ "homepage_url": "https://wcagcheckr.com",
9
+ "icons": {
10
+ "16": "icons/icon-16.png",
11
+ "32": "icons/icon-32.png",
12
+ "48": "icons/icon-48.png",
13
+ "128": "icons/icon-128.png"
14
+ },
15
+ "permissions": [
16
+ "debugger",
17
+ "storage",
18
+ "sidePanel",
19
+ "scripting",
20
+ "tabs",
21
+ "alarms"
22
+ ],
23
+ "host_permissions": [
24
+ "<all_urls>"
25
+ ],
26
+ "background": {
27
+ "service_worker": "service-worker-loader.js",
28
+ "type": "module"
29
+ },
30
+ "side_panel": {
31
+ "default_path": "side-panel/side-panel.html"
32
+ },
33
+ "options_page": "options/options.html",
34
+ "devtools_page": "devtools/devtools.html",
35
+ "content_scripts": [
36
+ {
37
+ "js": [
38
+ "assets/content-script.ts-loader-Cn8Y9Xod.js"
39
+ ],
40
+ "matches": [
41
+ "<all_urls>"
42
+ ],
43
+ "all_frames": true,
44
+ "run_at": "document_idle"
45
+ }
46
+ ],
47
+ "action": {
48
+ "default_title": "wcagcheckr"
49
+ },
50
+ "web_accessible_resources": [
51
+ {
52
+ "matches": [
53
+ "<all_urls>"
54
+ ],
55
+ "resources": [
56
+ "side-panel/side-panel.html",
57
+ "side-panel/*",
58
+ "assets/*",
59
+ "fonts/*",
60
+ "assets/preload-helper-D7HrI6pR.js",
61
+ "assets/crash-reporter-wxu43qbG.js",
62
+ "assets/diff-D4sCAdXf.js",
63
+ "assets/_commonjsHelpers-Cpj98o6Y.js",
64
+ "assets/reflow-analyzer-DNgBX8N_.js",
65
+ "assets/content-script.ts-D7yXcBUr.js"
66
+ ],
67
+ "use_dynamic_url": false
68
+ }
69
+ ]
70
+ }
@@ -0,0 +1,19 @@
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>wcagcheckr — Settings</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/ai-usage-log-DFkwAfmW.js"></script>
13
+ <script type="module" crossorigin src="/assets/main-CqDdt0Iq.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/styles-DP9v_aMy.css">
15
+ </head>
16
+ <body class="m-0 p-0 bg-slate-50">
17
+ <div id="root"></div>
18
+ </body>
19
+ </html>
@@ -0,0 +1 @@
1
+ import './assets/service-worker.ts-DaHvU8nE.js';
@@ -0,0 +1,174 @@
1
+ import { useEffect } from 'react';
2
+ import { useStore } from './store';
3
+ import {
4
+ wireMessaging,
5
+ refreshBaselineList,
6
+ refreshLicenseTier,
7
+ refreshUserMode,
8
+ startKeepalive,
9
+ loadLastAudit,
10
+ } from './wire-messaging';
11
+ import { primeAuditLauncher } from './audit-launcher';
12
+ import { sendToTab } from '../shared/messaging';
13
+ import { getAuditTargetTabId } from '../shared/active-tab';
14
+ import { Header } from './components/Header';
15
+ import { AuditControls } from './components/AuditControls';
16
+ import { VisualizerTools } from './components/VisualizerTools';
17
+ import { ProgressBar } from './components/ProgressBar';
18
+ import { MatrixView } from './views/MatrixView';
19
+ import { DeltaView } from './views/DeltaView';
20
+ import { ActivityView } from './views/ActivityView';
21
+ import { GuidedView } from './views/GuidedView';
22
+ import { FlowsView } from './views/FlowsView';
23
+ import { ScorecardView } from './views/ScorecardView';
24
+ import { SiteCrawlPanel } from './components/SiteCrawlPanel';
25
+ import { OnboardingModal } from './components/OnboardingModal';
26
+ import { ErrorBanner } from './components/ErrorBanner';
27
+ import { AiFailureBanner } from './components/AiFailureBanner';
28
+ import { DebuggerBusyModal } from './components/DebuggerBusyModal';
29
+ import { ResumeAuditBanner } from './components/ResumeAuditBanner';
30
+ import { StorybookHint } from './components/StorybookHint';
31
+ import { AuditFooter } from './components/AuditFooter';
32
+ import { ComplianceDisclaimer } from './components/ComplianceDisclaimer';
33
+ import { Announcer } from '../shared/announcer';
34
+ import { UserModeWizard } from './components/UserModeWizard';
35
+ import { OwnerView } from './views/OwnerView';
36
+ import { ForensicLogView } from './views/ForensicLogView';
37
+ import { WcagEmView } from './views/WcagEmView';
38
+ import { UpgradeCard } from './components/UpgradeCard';
39
+ import { CriticalBanner } from './components/MessagesBell';
40
+
41
+ export function App() {
42
+ const view = useStore((s) => s.view);
43
+ const userMode = useStore((s) => s.userMode);
44
+
45
+ useEffect(() => {
46
+ const port = startKeepalive();
47
+ // Detect runtime context: iframe-overlay vs chrome.sidePanel.
48
+ // ?context=overlay → loaded by modules/sidebar-handle.ts inside the page
49
+ // no query param → loaded by chrome.sidePanel (the fallback during audits)
50
+ // Only the chrome.sidePanel instance opens the tracker port — its disconnect
51
+ // is the SW's signal that the fallback UI just closed, time to restore the
52
+ // in-page overlay.
53
+ const isOverlayContext =
54
+ new URLSearchParams(window.location.search).get('context') === 'overlay';
55
+ const trackerPort = isOverlayContext
56
+ ? null
57
+ : chrome.runtime.connect({ name: 'sidepanel-tracker' });
58
+ const off = wireMessaging();
59
+ // Defensive .catch — these are bootstrapping side effects; failures should not
60
+ // surface as uncaught promise rejections in the console.
61
+ // First load: bypass the 24h license cache so the UpgradeCard reads
62
+ // any newly-added server fields (seats counts, cancel-at-period-end,
63
+ // single-month plan code, etc.). Subsequent LICENSE_CHANGED_EVENT
64
+ // refreshes use the cache normally — the SW already invalidates it
65
+ // on every LICENSE_SET_REQUEST.
66
+ refreshLicenseTier({ forceRefresh: true }).catch(() => {});
67
+ refreshBaselineList().catch(() => {});
68
+ refreshUserMode().catch(() => {});
69
+ // Pre-cache windowId so audit-start click handlers can open
70
+ // chrome.sidePanel synchronously (preserves user-gesture context).
71
+ primeAuditLauncher().catch(() => {});
72
+ // Load any persisted audit. Results stay until the user clicks Clear —
73
+ // switching tabs or navigating the audited page no longer wipes them.
74
+ // The Header shows the audited domain so the user always knows which
75
+ // page the displayed results belong to.
76
+ loadLastAudit().catch(() => {});
77
+
78
+ // Esc inside the side panel clears the highlight pin. The page's own keydown
79
+ // handler (in element-highlighter) only fires when the page has focus.
80
+ const onEsc = (e: KeyboardEvent) => {
81
+ if (e.key !== 'Escape') return;
82
+ const { pinnedMatchKey, setPinned, results } = useStore.getState();
83
+ if (!pinnedMatchKey) return;
84
+ setPinned(null);
85
+ void getAuditTargetTabId().then((tabId) => {
86
+ if (!tabId) return;
87
+ sendToTab(
88
+ tabId,
89
+ { type: 'HIGHLIGHT_CLEAR_REQUEST', tabId },
90
+ results[0]?.frameId
91
+ ).catch(() => {});
92
+ });
93
+ };
94
+ window.addEventListener('keydown', onEsc);
95
+
96
+ return () => {
97
+ off();
98
+ port.disconnect();
99
+ trackerPort?.disconnect();
100
+ window.removeEventListener('keydown', onEsc);
101
+ };
102
+ }, []);
103
+
104
+ // First launch — show the persona picker. Once chosen it persists in
105
+ // chrome.storage.local; subsequent loads bypass the wizard.
106
+ if (userMode === null) {
107
+ return (
108
+ <>
109
+ <UserModeWizard />
110
+ <Announcer />
111
+ </>
112
+ );
113
+ }
114
+
115
+ // Owner mode — simplified surface. No view tabs, no visualizers, no IGT.
116
+ // OwnerView holds its own header + footer for the simplified flow.
117
+ if (userMode === 'owner') {
118
+ return (
119
+ <>
120
+ <OwnerView />
121
+ <DebuggerBusyModal />
122
+ <Announcer />
123
+ </>
124
+ );
125
+ }
126
+
127
+ // Dev mode — full feature surface (everything that existed before).
128
+ return (
129
+ <div className="flex flex-col h-screen bg-slate-50 text-slate-900 text-sm">
130
+ {/* Skip link — visually hidden by default; revealed on focus so keyboard
131
+ users can jump past the header/UpgradeCard chrome straight to the
132
+ audit results. Required for WCAG 2.4.1 (Bypass Blocks) when there
133
+ are many tab stops before the main content. */}
134
+ <a
135
+ href="#main-content"
136
+ className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:bg-white focus:text-slate-900 focus:px-3 focus:py-1.5 focus:rounded focus:shadow focus:outline focus:outline-2 focus:outline-brand-500"
137
+ >
138
+ Skip to main content
139
+ </a>
140
+ <UpgradeCard />
141
+ <CriticalBanner />
142
+ <Header />
143
+ <main
144
+ id="main-content"
145
+ className="flex-1 flex flex-col overflow-hidden"
146
+ aria-label="wcagcheckr"
147
+ >
148
+ <AuditControls />
149
+ <VisualizerTools />
150
+ <StorybookHint />
151
+ <ResumeAuditBanner />
152
+ <ProgressBar />
153
+ <ErrorBanner />
154
+ <AiFailureBanner />
155
+ <div className="flex-1 overflow-y-auto" role="region" aria-label={`${view} view`}>
156
+ {view === 'matrix' && <MatrixView />}
157
+ {view === 'delta' && <DeltaView />}
158
+ {view === 'activity' && <ActivityView />}
159
+ {view === 'guided' && <GuidedView />}
160
+ {view === 'flows' && <FlowsView />}
161
+ {view === 'scorecard' && <ScorecardView />}
162
+ {view === 'crawl' && <SiteCrawlPanel />}
163
+ {view === 'forensic' && <ForensicLogView />}
164
+ {view === 'compliance' && <WcagEmView />}
165
+ </div>
166
+ </main>
167
+ <ComplianceDisclaimer />
168
+ <AuditFooter />
169
+ <OnboardingModal />
170
+ <DebuggerBusyModal />
171
+ <Announcer />
172
+ </div>
173
+ );
174
+ }
@@ -0,0 +1,57 @@
1
+ # side-panel/
2
+
3
+ The user-facing UI surface. Spec: `phase-1-specs.md` §9 (Zustand) + §11 (panel structure).
4
+
5
+ ## Component map
6
+
7
+ ```
8
+ App.tsx
9
+ ├── Header (title + version + tier badge + Upgrade/License/Settings/Support buttons + view tabs)
10
+ ├── AuditControls (mode select, Pick/Audit-all button, Stop button, upgrade-modal trigger)
11
+ ├── StorybookHint (banner: shown if active tab is a Storybook page on first open)
12
+ ├── ResumeAuditBanner (banner: shown if a previous audit was interrupted)
13
+ ├── ProgressBar (shown only while status === 'running')
14
+ ├── ErrorBanner (shown only while status === 'failed')
15
+ ├── <view> (matrix | delta | scorecard, picked by store.view)
16
+ ├── AuditFooter (always-visible: tracked count + last-audit relative time)
17
+ ├── OnboardingModal (first-run, explains chrome.debugger banner)
18
+ └── DebuggerBusyModal (DEBUGGER_BUSY events open this)
19
+ ```
20
+
21
+ ## State
22
+
23
+ `store.ts` defines a single Zustand store. Slices: `auditState`, `progress`, `results`, `delta`, `componentId`, `errorMessage`, `baselineList`, `tier`, `view`. Setters are flat methods on the store.
24
+
25
+ `wire-messaging.ts` subscribes the store to messaging events:
26
+ - `AUDIT_PROGRESS_EVENT` → `setProgress` + `setStatus('running')`
27
+ - `AUDIT_COMPLETE_EVENT` → `setResults` + persist to `chrome.storage.local` for next-open hydrate
28
+ - `AUDIT_FAILED_EVENT` → `setError`
29
+ - `LICENSE_CHANGED_EVENT` → re-fetch tier
30
+ - `SCORECARD_UPDATED_EVENT` → re-fetch baseline list
31
+
32
+ `loadLastAudit()` runs on App mount to restore previous results.
33
+
34
+ ## ErrorBoundary
35
+
36
+ Wraps the App root in `main.tsx`. Catches React render errors so a single broken view doesn't blank the whole panel. Shows a Reload button that calls `location.reload()`.
37
+
38
+ ## Keepalive
39
+
40
+ `startKeepalive()` opens a `chrome.runtime.connect({ name: 'audit-keepalive' })` port on App mount. While this port is open, Chrome won't suspend the service worker — keeps in-flight audits alive.
41
+
42
+ ## Reusable bits
43
+
44
+ - `IconButton` — consistent text-button styling for icon-only triggers (settings ⚙, support ?). Required `ariaLabel`.
45
+ - `Modal` — Radix Dialog wrapper with consistent overlay + sizing.
46
+ - `ViolationCard` — used by both Matrix and Delta views.
47
+ - `UpgradePrompt` — used by Header upgrade modal + AuditControls upgrade modal.
48
+ - `format-component-id.ts` — pretty-display of canonical IDs (e.g., `::story:atoms-button--primary` → "Atoms Button / Primary").
49
+
50
+ ## Tier gating in the UI
51
+
52
+ Performed locally against `TIER_RULES` from `shared/tier-rules` once `tier` is in the store. No round-trip per gate check.
53
+
54
+ - `AuditControls` blocks "All stories" mode when `!isFeatureAllowed(tier, 'storybookAutoIterate')`
55
+ - `DeltaView` blocks "Accept as baseline" when free + already has 1 baseline; blocks Export when below paid tier
56
+ - `ScorecardView` shows a banner when free user is at the baseline limit
57
+ - `Header` shows the Upgrade CTA when tier is `trial` or `free`
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { openFallbackPanel, primeAuditLauncher } from './audit-launcher';
3
+
4
+ // chrome.sidePanel.open requires a live user-gesture and silently no-ops
5
+ // without one. The contract this test guards: openFallbackPanel runs
6
+ // SYNCHRONOUSLY relative to its caller — it does not await — so when
7
+ // invoked from a click handler the user-gesture is still alive at call
8
+ // time. Any future refactor that awaits inside this function silently
9
+ // breaks the bug fix it backs (Load state on narrow-breakpoint violations
10
+ // must open chrome.sidePanel as fallback before the viewport shrinks the
11
+ // overlay out of existence).
12
+
13
+ describe('openFallbackPanel', () => {
14
+ const openSpy = vi.fn();
15
+ beforeEach(() => {
16
+ openSpy.mockReset();
17
+ (globalThis as unknown as { chrome: unknown }).chrome = {
18
+ sidePanel: { open: openSpy.mockResolvedValue(undefined) },
19
+ windows: { getCurrent: vi.fn().mockResolvedValue({ id: 42 }) },
20
+ };
21
+ });
22
+
23
+ it('does nothing when not primed (no cached windowId)', () => {
24
+ // primeAuditLauncher hasn't run yet → no cached windowId → fallback
25
+ // open should silently no-op rather than throw.
26
+ openFallbackPanel();
27
+ expect(openSpy).not.toHaveBeenCalled();
28
+ });
29
+
30
+ it('opens chrome.sidePanel with the cached windowId after priming', async () => {
31
+ await primeAuditLauncher();
32
+ openFallbackPanel();
33
+ expect(openSpy).toHaveBeenCalledOnce();
34
+ expect(openSpy).toHaveBeenCalledWith({ windowId: 42 });
35
+ });
36
+
37
+ it('does not throw when chrome.sidePanel is missing (ui-preview / test contexts)', async () => {
38
+ (globalThis as unknown as { chrome: unknown }).chrome = {
39
+ windows: { getCurrent: vi.fn().mockResolvedValue({ id: 7 }) },
40
+ };
41
+ await primeAuditLauncher();
42
+ expect(() => openFallbackPanel()).not.toThrow();
43
+ });
44
+
45
+ it('the call is synchronous — returns before chrome.sidePanel.open resolves', async () => {
46
+ // Promise that never resolves, simulating Chrome holding the open()
47
+ // promise pending. If openFallbackPanel awaited it, the caller would
48
+ // hang forever — the test would time out.
49
+ const neverResolves = new Promise<void>(() => {});
50
+ openSpy.mockReturnValue(neverResolves);
51
+ await primeAuditLauncher();
52
+ // No await — must return immediately even though open() never settles.
53
+ openFallbackPanel();
54
+ expect(openSpy).toHaveBeenCalled();
55
+ });
56
+ });
@@ -0,0 +1,65 @@
1
+ // Audit launcher — wraps every START_AUDIT / START_SITE_CRAWL dispatch with
2
+ // an opening of chrome.sidePanel as the fallback UI.
3
+ //
4
+ // Why this exists: chrome.debugger viewport emulation during an audit affects
5
+ // everything in the audited tab — including our in-page sidebar overlay
6
+ // (which then gets squished into the emulated viewport and visually trapped).
7
+ // The in-page overlay therefore HIDES during audits (service-worker broadcasts
8
+ // SIDEBAR_HIDE_REQUEST). chrome.sidePanel is the only UI surface that renders
9
+ // OUTSIDE the tab's render tree, so we open it as the fallback so users still
10
+ // see progress + stop controls.
11
+ //
12
+ // Why this can't live in the SW: chrome.sidePanel.open() requires a user-
13
+ // gesture context. User gestures DO propagate through chrome.runtime.send-
14
+ // Message into the SW, but Chrome consumes the gesture as soon as the SW
15
+ // awaits anything (chrome.tabs.query, etc.). Calling chrome.sidePanel.open
16
+ // synchronously from the original click handler — before any await — is the
17
+ // only reliable path. To do that we need the windowId synchronously, so we
18
+ // pre-cache it on side-panel mount.
19
+
20
+ import { send } from '../shared/messaging';
21
+ import type { StartAuditAction, StartSiteCrawl } from '../shared/messages';
22
+
23
+ let cachedWindowId: number | null = null;
24
+
25
+ /** Pre-fetch the side-panel's window id so audit launches can open
26
+ * chrome.sidePanel synchronously without burning user-gesture context on
27
+ * an await. Call once on side-panel mount. Idempotent. */
28
+ export async function primeAuditLauncher(): Promise<void> {
29
+ if (cachedWindowId !== null) return;
30
+ try {
31
+ const w = await chrome.windows.getCurrent();
32
+ if (typeof w.id === 'number') cachedWindowId = w.id;
33
+ } catch {
34
+ // chrome.windows is unavailable in some contexts (ui-preview shim) —
35
+ // launcher gracefully degrades; user just loses the fallback panel.
36
+ }
37
+ }
38
+
39
+ /** Open chrome.sidePanel synchronously while the click-handler user-gesture
40
+ * is still live. Returns immediately; the open() promise resolves later but
41
+ * the gesture is consumed at call time, which is what matters.
42
+ *
43
+ * Exported so call sites OTHER than audit-launch (e.g. ViolationCard's
44
+ * Load state link, which drives the page through a narrow-breakpoint
45
+ * state) can pre-open the fallback before issuing their async work. */
46
+ export function openFallbackPanel(): void {
47
+ if (cachedWindowId === null) return;
48
+ // Guard chrome.sidePanel access — the API isn't present in non-extension
49
+ // contexts (tests, ui-preview).
50
+ const api = chrome.sidePanel;
51
+ if (!api || typeof api.open !== 'function') return;
52
+ api.open({ windowId: cachedWindowId }).catch(() => {
53
+ // best-effort — failures here just mean the user won't see the fallback
54
+ // panel, which is acceptable. The audit still runs.
55
+ });
56
+ }
57
+
58
+ /** Drop-in replacement for `send(startAuditMessage)` at audit-start sites.
59
+ * Opens chrome.sidePanel first (preserving user-gesture), then dispatches
60
+ * the audit start to the service worker. Use for both START_AUDIT and
61
+ * START_SITE_CRAWL — both run the state-matrix audit pipeline. */
62
+ export function launchAudit(msg: StartAuditAction | StartSiteCrawl): void {
63
+ openFallbackPanel();
64
+ void send(msg);
65
+ }
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { displayComponentId } from './format-component-id';
3
+
4
+ describe('displayComponentId', () => {
5
+ it('formats Storybook story id', () => {
6
+ const r = displayComponentId('::story:atoms-button--primary');
7
+ expect(r.primary).toBe('Atoms Button / Primary');
8
+ expect(r.secondary).toBe('storybook');
9
+ });
10
+
11
+ it('formats testid with URL scope', () => {
12
+ const r = displayComponentId('https://example.com/login::testid:email-input');
13
+ expect(r.primary).toBe('email-input');
14
+ expect(r.secondary).toBe('https://example.com/login');
15
+ });
16
+
17
+ it('formats explicit data-componentid', () => {
18
+ const r = displayComponentId('https://example.com::explicit:my-card');
19
+ expect(r.primary).toBe('my-card');
20
+ expect(r.secondary).toBe('https://example.com');
21
+ });
22
+
23
+ it('formats custom element', () => {
24
+ const r = displayComponentId('https://example.com::customelement:my-button');
25
+ expect(r.primary).toBe('my-button');
26
+ expect(r.secondary).toBe('https://example.com');
27
+ });
28
+
29
+ it('formats aria id with role:name', () => {
30
+ const r = displayComponentId('https://example.com::aria:button:Submit form');
31
+ // value contains a colon; we keep everything after the first colon as the value
32
+ expect(r.primary).toBe('button:Submit form');
33
+ expect(r.secondary).toBe('https://example.com');
34
+ });
35
+
36
+ it('falls back gracefully when no separator', () => {
37
+ const r = displayComponentId('weird-id');
38
+ expect(r.primary).toBe('weird-id');
39
+ });
40
+
41
+ it('handles aria value with multiple colons (kept verbatim after first colon)', () => {
42
+ const r = displayComponentId('https://example.com::aria:button:Foo: bar: baz');
43
+ // Strategy is "aria"; value is "button:Foo: bar: baz" (preserves colons after the first).
44
+ expect(r.primary).toBe('button:Foo: bar: baz');
45
+ expect(r.secondary).toBe('https://example.com');
46
+ });
47
+
48
+ it('handles unicode story names', () => {
49
+ const r = displayComponentId('::story:atoms-bütton--primary');
50
+ expect(r.primary).toBe('Atoms Bütton / Primary');
51
+ });
52
+
53
+ it('handles emoji in story names', () => {
54
+ const r = displayComponentId('::story:atoms-button--🚀-primary');
55
+ expect(r.primary).toContain('🚀');
56
+ });
57
+
58
+ it('handles very long story id without crashing', () => {
59
+ const longSegment = 'a'.repeat(200);
60
+ const r = displayComponentId(`::story:${longSegment}--variant`);
61
+ expect(r.primary.length).toBeGreaterThan(0);
62
+ expect(r.primary).toContain('Variant');
63
+ });
64
+
65
+ it('handles single-segment story id without "--" separator', () => {
66
+ const r = displayComponentId('::story:single-segment-only');
67
+ // No "--" separator → entire value is one segment, gets capitalized.
68
+ expect(r.primary).toBe('Single Segment Only');
69
+ });
70
+
71
+ it('handles testid with special characters', () => {
72
+ const r = displayComponentId('https://example.com::testid:btn-#$@-special');
73
+ expect(r.primary).toBe('btn-#$@-special');
74
+ expect(r.secondary).toBe('https://example.com');
75
+ });
76
+
77
+ it('handles empty url scope (storybook-style separator with no URL)', () => {
78
+ const r = displayComponentId('::testid:button-x');
79
+ expect(r.primary).toBe('button-x');
80
+ // Empty URL scope falls back to strategy as the secondary label.
81
+ expect(r.secondary).toBe('testid');
82
+ });
83
+
84
+ it('handles trailing :: with empty rest', () => {
85
+ const r = displayComponentId('https://example.com::');
86
+ // No strategy:value content after the separator; should not crash.
87
+ expect(r.primary).toBe('');
88
+ });
89
+ });
@@ -0,0 +1,40 @@
1
+ // Pretty-print a canonical componentId for display in the UI.
2
+ // Spec: phase-1-specs.md §2 (componentId format).
3
+
4
+ export type ComponentIdDisplay = {
5
+ primary: string; // Headline label
6
+ secondary?: string; // Smaller subtext (URL or strategy)
7
+ };
8
+
9
+ export function displayComponentId(id: string): ComponentIdDisplay {
10
+ // Format: {urlScope}::{strategy}:{value} (urlScope may be empty for storybook)
11
+ const sepIdx = id.indexOf('::');
12
+ if (sepIdx === -1) {
13
+ return { primary: id };
14
+ }
15
+ const url = id.slice(0, sepIdx);
16
+ const rest = id.slice(sepIdx + 2);
17
+
18
+ // rest is "{strategy}:{value}". Strategy is the first segment up to the FIRST colon.
19
+ const colonIdx = rest.indexOf(':');
20
+ if (colonIdx === -1) {
21
+ return { primary: rest };
22
+ }
23
+ const strategy = rest.slice(0, colonIdx);
24
+ const value = rest.slice(colonIdx + 1);
25
+
26
+ if (strategy === 'story') {
27
+ // storyId like "atoms-button--primary" → "Atoms / Button / Primary"
28
+ const niceStory = value
29
+ .split('--')
30
+ .map((seg) => seg.split('-').map(cap).join(' '))
31
+ .join(' / ');
32
+ return { primary: niceStory, secondary: 'storybook' };
33
+ }
34
+
35
+ return { primary: value, secondary: url || strategy };
36
+ }
37
+
38
+ function cap(word: string): string {
39
+ return word.length === 0 ? word : word[0]!.toUpperCase() + word.slice(1);
40
+ }