@fragments-sdk/viewer 0.2.1
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/LICENSE +84 -0
- package/index.html +28 -0
- package/package.json +71 -0
- package/src/__tests__/a11y-fixes.test.ts +358 -0
- package/src/__tests__/jsx-parser.test.ts +502 -0
- package/src/__tests__/render-utils.test.ts +232 -0
- package/src/__tests__/style-utils.test.ts +404 -0
- package/src/app/index.ts +1 -0
- package/src/assets/fragments-logo.ts +4 -0
- package/src/assets/fragments_logo.png +0 -0
- package/src/components/AccessibilityPanel.tsx +1457 -0
- package/src/components/ActionCapture.tsx +172 -0
- package/src/components/ActionsPanel.tsx +332 -0
- package/src/components/AllVariantsPreview.tsx +78 -0
- package/src/components/App.tsx +604 -0
- package/src/components/BottomPanel.tsx +288 -0
- package/src/components/CodePanel.naming.test.tsx +59 -0
- package/src/components/CodePanel.tsx +118 -0
- package/src/components/CommandPalette.tsx +392 -0
- package/src/components/ComponentDocView.tsx +164 -0
- package/src/components/ComponentGraph.tsx +380 -0
- package/src/components/ComponentHeader.tsx +88 -0
- package/src/components/ContractPanel.tsx +241 -0
- package/src/components/DeviceMockup.tsx +156 -0
- package/src/components/EmptyVariantMessage.tsx +54 -0
- package/src/components/ErrorBoundary.tsx +97 -0
- package/src/components/FigmaEmbed.tsx +238 -0
- package/src/components/FragmentEditor.tsx +525 -0
- package/src/components/FragmentRenderer.tsx +61 -0
- package/src/components/HeaderSearch.tsx +24 -0
- package/src/components/HealthDashboard.tsx +441 -0
- package/src/components/HmrStatusIndicator.tsx +61 -0
- package/src/components/Icons.tsx +479 -0
- package/src/components/InteractionsPanel.tsx +757 -0
- package/src/components/IsolatedPreviewFrame.tsx +390 -0
- package/src/components/IsolatedRender.tsx +113 -0
- package/src/components/KeyboardShortcutsHelp.tsx +53 -0
- package/src/components/LandingPage.tsx +420 -0
- package/src/components/Layout.tsx +27 -0
- package/src/components/LeftSidebar.tsx +472 -0
- package/src/components/LoadErrorMessage.tsx +102 -0
- package/src/components/MultiViewportPreview.tsx +527 -0
- package/src/components/NoVariantsMessage.tsx +59 -0
- package/src/components/PanelShell.tsx +161 -0
- package/src/components/PerformancePanel.tsx +304 -0
- package/src/components/PreviewArea.tsx +254 -0
- package/src/components/PreviewAside.tsx +168 -0
- package/src/components/PreviewFrameHost.tsx +304 -0
- package/src/components/PreviewToolbar.tsx +80 -0
- package/src/components/PropsEditor.tsx +506 -0
- package/src/components/PropsTable.tsx +111 -0
- package/src/components/RelationsSection.tsx +88 -0
- package/src/components/ResizablePanel.tsx +271 -0
- package/src/components/RightSidebar.tsx +102 -0
- package/src/components/RuntimeToolsRegistrar.tsx +17 -0
- package/src/components/ScreenshotButton.tsx +90 -0
- package/src/components/ShadowPreview.tsx +204 -0
- package/src/components/Sidebar.tsx +169 -0
- package/src/components/SkeletonLoader.tsx +161 -0
- package/src/components/ThemeProvider.tsx +42 -0
- package/src/components/Toast.tsx +3 -0
- package/src/components/TokenStylePanel.tsx +699 -0
- package/src/components/TopToolbar.tsx +159 -0
- package/src/components/Untitled +1 -0
- package/src/components/UsageSection.tsx +95 -0
- package/src/components/VariantMatrix.tsx +391 -0
- package/src/components/VariantRenderer.tsx +131 -0
- package/src/components/VariantTabs.tsx +40 -0
- package/src/components/ViewerHeader.tsx +69 -0
- package/src/components/ViewerStateSync.tsx +52 -0
- package/src/components/ViewportSelector.tsx +172 -0
- package/src/components/WebMCPDevTools.tsx +503 -0
- package/src/components/WebMCPIntegration.tsx +47 -0
- package/src/components/WebMCPStatusIndicator.tsx +60 -0
- package/src/components/_future/CreatePage.tsx +835 -0
- package/src/components/viewer-utils.ts +16 -0
- package/src/composition-renderer.ts +381 -0
- package/src/constants/index.ts +1 -0
- package/src/constants/ui.ts +166 -0
- package/src/entry.tsx +335 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useA11yCache.ts +383 -0
- package/src/hooks/useA11yService.ts +364 -0
- package/src/hooks/useActions.ts +138 -0
- package/src/hooks/useAppState.ts +147 -0
- package/src/hooks/useCompiledFragments.ts +42 -0
- package/src/hooks/useFigmaIntegration.ts +132 -0
- package/src/hooks/useHmrStatus.ts +109 -0
- package/src/hooks/useKeyboardShortcuts.ts +270 -0
- package/src/hooks/usePreviewBridge.ts +347 -0
- package/src/hooks/useScrollSpy.ts +78 -0
- package/src/hooks/useShadowStyles.ts +221 -0
- package/src/hooks/useUrlState.ts +318 -0
- package/src/hooks/useViewSettings.ts +111 -0
- package/src/intelligence/healthReport.ts +505 -0
- package/src/intelligence/styleDrift.ts +340 -0
- package/src/intelligence/usageScanner.ts +309 -0
- package/src/jsx-parser.ts +486 -0
- package/src/preview-frame-entry.tsx +25 -0
- package/src/preview-frame.html +148 -0
- package/src/render-template.html +68 -0
- package/src/render-utils.ts +311 -0
- package/src/shared/ComponentDocContent.module.scss +10 -0
- package/src/shared/ComponentDocContent.module.scss.d.ts +2 -0
- package/src/shared/ComponentDocContent.tsx +274 -0
- package/src/shared/DocsHeaderBar.tsx +129 -0
- package/src/shared/DocsPageAsideHost.tsx +89 -0
- package/src/shared/DocsPageShell.tsx +124 -0
- package/src/shared/DocsSearchCommand.tsx +99 -0
- package/src/shared/DocsSidebarNav.tsx +66 -0
- package/src/shared/PropsTable.module.scss +68 -0
- package/src/shared/PropsTable.module.scss.d.ts +2 -0
- package/src/shared/PropsTable.tsx +76 -0
- package/src/shared/VariantPreviewCard.module.scss +114 -0
- package/src/shared/VariantPreviewCard.module.scss.d.ts +2 -0
- package/src/shared/VariantPreviewCard.tsx +137 -0
- package/src/shared/docs-data/index.ts +32 -0
- package/src/shared/docs-data/mcp-configs.ts +72 -0
- package/src/shared/docs-data/palettes.ts +75 -0
- package/src/shared/docs-data/setup-examples.ts +55 -0
- package/src/shared/docs-layout.scss +28 -0
- package/src/shared/docs-layout.scss.d.ts +2 -0
- package/src/shared/index.ts +34 -0
- package/src/shared/types.ts +53 -0
- package/src/style-utils.ts +414 -0
- package/src/styles/globals.css +278 -0
- package/src/types/a11y.ts +197 -0
- package/src/utils/a11y-fixes.ts +509 -0
- package/src/utils/actionExport.ts +372 -0
- package/src/utils/colorSchemes.ts +201 -0
- package/src/utils/contrast.ts +246 -0
- package/src/utils/detectRelationships.ts +256 -0
- package/src/webmcp/__tests__/analytics.test.ts +108 -0
- package/src/webmcp/analytics.ts +165 -0
- package/src/webmcp/index.ts +3 -0
- package/src/webmcp/posthog-bridge.ts +39 -0
- package/src/webmcp/runtime-tools.ts +152 -0
- package/src/webmcp/scan-utils.ts +135 -0
- package/src/webmcp/use-tool-analytics.ts +69 -0
- package/src/webmcp/viewer-state.ts +45 -0
- package/tsconfig.json +20 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Functional Source License, Version 1.1, MIT Future License
|
|
2
|
+
|
|
3
|
+
Licensor: Conan McNicholl
|
|
4
|
+
Software: Fragments SDK (@fragments-sdk/cli, @fragments-sdk/mcp, @fragments-sdk/context)
|
|
5
|
+
|
|
6
|
+
IMPORTANT: The @fragments-sdk/ui package is licensed separately under the MIT License.
|
|
7
|
+
See libs/ui/LICENSE for details.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Terms and Conditions
|
|
12
|
+
|
|
13
|
+
### Licensor ("We")
|
|
14
|
+
|
|
15
|
+
The individual or entity listed above.
|
|
16
|
+
|
|
17
|
+
### The Software
|
|
18
|
+
|
|
19
|
+
The software identified above, including all source code, object code,
|
|
20
|
+
documentation, and other files provided by the Licensor.
|
|
21
|
+
|
|
22
|
+
### Grant of Rights
|
|
23
|
+
|
|
24
|
+
Subject to the conditions below, the Licensor grants you a non-exclusive,
|
|
25
|
+
worldwide, royalty-free license to use, copy, modify, create derivative works,
|
|
26
|
+
and redistribute the Software, in each case subject to the limitations below.
|
|
27
|
+
|
|
28
|
+
### Limitation — Competing Use
|
|
29
|
+
|
|
30
|
+
You may not use the Software in, or to provide, a Commercial Product or Service
|
|
31
|
+
that competes with the Software or with any product or service that the Licensor
|
|
32
|
+
provides using the Software. A "Commercial Product or Service" is any product or
|
|
33
|
+
service offered to third parties for a fee or other consideration.
|
|
34
|
+
|
|
35
|
+
For clarity, the following uses are always permitted regardless of this limitation:
|
|
36
|
+
|
|
37
|
+
- Using the Software for your own internal business purposes
|
|
38
|
+
- Using the Software to build and deploy your own applications
|
|
39
|
+
- Using the Software for personal, educational, or evaluation purposes
|
|
40
|
+
- Providing professional services (consulting, integration) to your clients
|
|
41
|
+
that involve configuring or extending the Software
|
|
42
|
+
|
|
43
|
+
The following are examples of Competing Use that are NOT permitted:
|
|
44
|
+
|
|
45
|
+
- Offering a hosted developer tools service that repackages or exposes
|
|
46
|
+
the functionality of the Software
|
|
47
|
+
- Selling or distributing a product that is a substitute for any product
|
|
48
|
+
or service offered by the Licensor
|
|
49
|
+
- Building and offering an MCP server, CLI tool, or code intelligence
|
|
50
|
+
platform that is substantially derived from the Software
|
|
51
|
+
|
|
52
|
+
### Change Date and License
|
|
53
|
+
|
|
54
|
+
On the second anniversary of each version's release date, the Licensor grants
|
|
55
|
+
you the rights under the terms of the MIT License for that version.
|
|
56
|
+
The "MIT License" means the license identified by SPDX as "MIT" and published
|
|
57
|
+
at https://opensource.org/licenses/MIT.
|
|
58
|
+
|
|
59
|
+
### No Warranties
|
|
60
|
+
|
|
61
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
62
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
63
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
64
|
+
|
|
65
|
+
### Limitation of Liability
|
|
66
|
+
|
|
67
|
+
IN NO EVENT SHALL THE LICENSOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
68
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
69
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
70
|
+
THE SOFTWARE.
|
|
71
|
+
|
|
72
|
+
### General
|
|
73
|
+
|
|
74
|
+
If any provision of this License is held to be unenforceable, that provision
|
|
75
|
+
shall be reformed only to the extent necessary to make it enforceable, and the
|
|
76
|
+
remaining provisions shall continue in full force and effect.
|
|
77
|
+
|
|
78
|
+
This License does not grant permission to use the trade names, trademarks,
|
|
79
|
+
service marks, or product names of the Licensor, except as required for
|
|
80
|
+
reasonable and customary use in describing the origin of the Software.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
For more information about the Functional Source License, see https://fsl.software/
|
package/index.html
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
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>Fragments</title>
|
|
7
|
+
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
10
|
+
<link
|
|
11
|
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
|
12
|
+
rel="stylesheet"
|
|
13
|
+
/>
|
|
14
|
+
<script>
|
|
15
|
+
// Sync theme with system preference before render to avoid flash
|
|
16
|
+
(function () {
|
|
17
|
+
const stored = localStorage.getItem('fragments-theme');
|
|
18
|
+
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
19
|
+
document.documentElement.classList.add('dark');
|
|
20
|
+
}
|
|
21
|
+
})();
|
|
22
|
+
</script>
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
<div id="root"></div>
|
|
26
|
+
<script type="module" src="/src/entry.tsx"></script>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fragments-sdk/viewer",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"license": "FSL-1.1-MIT",
|
|
5
|
+
"description": "Viewer shell — Storybook-like React UI for previewing Fragments components, shared doc layouts, and devtools panels",
|
|
6
|
+
"author": "Conan McNicholl",
|
|
7
|
+
"homepage": "https://usefragments.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/ConanMcN/fragments"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
"./shared": "./src/shared/index.ts",
|
|
15
|
+
"./app": "./src/app/index.ts",
|
|
16
|
+
"./docs-data": "./src/shared/docs-data/index.ts",
|
|
17
|
+
"./docs-layout.scss": "./src/shared/docs-layout.scss"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"fragments",
|
|
21
|
+
"viewer",
|
|
22
|
+
"design-system",
|
|
23
|
+
"component-preview",
|
|
24
|
+
"storybook-alternative"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src",
|
|
31
|
+
"index.html",
|
|
32
|
+
"package.json",
|
|
33
|
+
"tsconfig.json"
|
|
34
|
+
],
|
|
35
|
+
"sideEffects": [
|
|
36
|
+
"*.scss",
|
|
37
|
+
"*.css"
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@phosphor-icons/react": "^2.1.10",
|
|
41
|
+
"@hookform/resolvers": "^5.2.2",
|
|
42
|
+
"@monaco-editor/react": "^4.7.0",
|
|
43
|
+
"@tanstack/react-virtual": "^3.13.18",
|
|
44
|
+
"axe-core": "^4.11.1",
|
|
45
|
+
"html2canvas": "^1.4.1",
|
|
46
|
+
"monaco-editor": "^0.55.1",
|
|
47
|
+
"react-colorful": "^5.6.1",
|
|
48
|
+
"react-hook-form": "^7.71.0",
|
|
49
|
+
"react-live": "^4.1.6",
|
|
50
|
+
"shiki": "^3.21.0",
|
|
51
|
+
"@fragments-sdk/core": "0.2.0",
|
|
52
|
+
"@fragments-sdk/ui": "0.14.0"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"react": ">=18",
|
|
56
|
+
"react-dom": ">=18"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/react": "^19.0.0",
|
|
60
|
+
"@types/react-dom": "^19.0.0",
|
|
61
|
+
"vite": "^6.0.0",
|
|
62
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
63
|
+
"sass": "^1.83.0",
|
|
64
|
+
"typescript": "^5.7.2"
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"dev": "vite",
|
|
68
|
+
"typecheck": "tsc --noEmit",
|
|
69
|
+
"clean": "rm -rf dist"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getStaticFix,
|
|
4
|
+
generateElementFix,
|
|
5
|
+
getImpactDescription,
|
|
6
|
+
getImpactColorClass,
|
|
7
|
+
extractWcagTags,
|
|
8
|
+
parseWcagTag,
|
|
9
|
+
} from '../utils/a11y-fixes.js';
|
|
10
|
+
import type { SerializedNode } from '../types/a11y.js';
|
|
11
|
+
|
|
12
|
+
function makeNode(overrides?: Partial<SerializedNode>): SerializedNode {
|
|
13
|
+
return {
|
|
14
|
+
html: '<div>test</div>',
|
|
15
|
+
target: ['div'],
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeContrastNode(
|
|
21
|
+
fg: string,
|
|
22
|
+
bg: string,
|
|
23
|
+
ratio: number,
|
|
24
|
+
required: number
|
|
25
|
+
): SerializedNode {
|
|
26
|
+
return {
|
|
27
|
+
html: '<span style="color: ' + fg + '; background: ' + bg + '">text</span>',
|
|
28
|
+
target: ['span'],
|
|
29
|
+
any: [
|
|
30
|
+
{
|
|
31
|
+
id: 'color-contrast',
|
|
32
|
+
data: {
|
|
33
|
+
fgColor: fg,
|
|
34
|
+
bgColor: bg,
|
|
35
|
+
contrastRatio: ratio,
|
|
36
|
+
expectedContrastRatio: required,
|
|
37
|
+
fontSize: '16px',
|
|
38
|
+
fontWeight: 'normal',
|
|
39
|
+
},
|
|
40
|
+
impact: 'serious',
|
|
41
|
+
message: 'Element has insufficient color contrast',
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('getStaticFix', () => {
|
|
48
|
+
const knownRules = [
|
|
49
|
+
'color-contrast',
|
|
50
|
+
'image-alt',
|
|
51
|
+
'button-name',
|
|
52
|
+
'link-name',
|
|
53
|
+
'label',
|
|
54
|
+
'aria-valid-attr',
|
|
55
|
+
'aria-valid-attr-value',
|
|
56
|
+
'aria-required-attr',
|
|
57
|
+
'heading-order',
|
|
58
|
+
'tabindex',
|
|
59
|
+
'duplicate-id',
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
for (const ruleId of knownRules) {
|
|
63
|
+
it(`returns fix for "${ruleId}"`, () => {
|
|
64
|
+
const fix = getStaticFix(ruleId);
|
|
65
|
+
expect(fix).not.toBeNull();
|
|
66
|
+
expect(fix!.whyItMatters).toBeTruthy();
|
|
67
|
+
expect(fix!.howToFix).toBeTruthy();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
it('returns null for unknown rule', () => {
|
|
72
|
+
expect(getStaticFix('unknown-rule')).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('color-contrast has wcagCriterion 1.4.3 AA', () => {
|
|
76
|
+
const fix = getStaticFix('color-contrast');
|
|
77
|
+
expect(fix!.wcagCriterion).toBeDefined();
|
|
78
|
+
expect(fix!.wcagCriterion!.id).toBe('1.4.3');
|
|
79
|
+
expect(fix!.wcagCriterion!.level).toBe('AA');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('generateElementFix — color-contrast', () => {
|
|
84
|
+
it('valid colors → autoFixable: true with suggested hex', () => {
|
|
85
|
+
const node = makeContrastNode('#888888', '#ffffff', 3.54, 4.5);
|
|
86
|
+
const fix = generateElementFix('color-contrast', node);
|
|
87
|
+
expect(fix).not.toBeNull();
|
|
88
|
+
expect(fix!.autoFixable).toBe(true);
|
|
89
|
+
expect(fix!.explanation).toMatch(/#[0-9a-f]{6}/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('includes ratio in explanation', () => {
|
|
93
|
+
const node = makeContrastNode('#888888', '#ffffff', 3.54, 4.5);
|
|
94
|
+
const fix = generateElementFix('color-contrast', node);
|
|
95
|
+
expect(fix!.explanation).toContain('3.54');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('includes required ratio in explanation', () => {
|
|
99
|
+
const node = makeContrastNode('#888888', '#ffffff', 3.54, 4.5);
|
|
100
|
+
const fix = generateElementFix('color-contrast', node);
|
|
101
|
+
expect(fix!.explanation).toContain('4.5');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('missing fg color → null', () => {
|
|
105
|
+
const node = makeNode({
|
|
106
|
+
any: [{
|
|
107
|
+
id: 'color-contrast',
|
|
108
|
+
data: { bgColor: '#ffffff' },
|
|
109
|
+
impact: 'serious',
|
|
110
|
+
message: 'Insufficient contrast',
|
|
111
|
+
}],
|
|
112
|
+
});
|
|
113
|
+
const fix = generateElementFix('color-contrast', node);
|
|
114
|
+
expect(fix).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('missing bg color → null', () => {
|
|
118
|
+
const node = makeNode({
|
|
119
|
+
any: [{
|
|
120
|
+
id: 'color-contrast',
|
|
121
|
+
data: { fgColor: '#888888' },
|
|
122
|
+
impact: 'serious',
|
|
123
|
+
message: 'Insufficient contrast',
|
|
124
|
+
}],
|
|
125
|
+
});
|
|
126
|
+
const fix = generateElementFix('color-contrast', node);
|
|
127
|
+
expect(fix).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('CSS variable colors → autoFixable: false', () => {
|
|
131
|
+
const node = makeNode({
|
|
132
|
+
html: '<span>text</span>',
|
|
133
|
+
any: [{
|
|
134
|
+
id: 'color-contrast',
|
|
135
|
+
data: {
|
|
136
|
+
fgColor: 'var(--text-color)',
|
|
137
|
+
bgColor: '#ffffff',
|
|
138
|
+
contrastRatio: 3.0,
|
|
139
|
+
expectedContrastRatio: 4.5,
|
|
140
|
+
},
|
|
141
|
+
impact: 'serious',
|
|
142
|
+
message: 'Insufficient contrast',
|
|
143
|
+
}],
|
|
144
|
+
});
|
|
145
|
+
const fix = generateElementFix('color-contrast', node);
|
|
146
|
+
expect(fix).not.toBeNull();
|
|
147
|
+
expect(fix!.autoFixable).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('no any data → null', () => {
|
|
151
|
+
const node = makeNode({ html: '<span>text</span>' });
|
|
152
|
+
const fix = generateElementFix('color-contrast', node);
|
|
153
|
+
expect(fix).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('generateElementFix — image-alt', () => {
|
|
158
|
+
it('missing alt → adds alt attribute', () => {
|
|
159
|
+
const node = makeNode({ html: '<img src="hero.jpg">' });
|
|
160
|
+
const fix = generateElementFix('image-alt', node);
|
|
161
|
+
expect(fix).not.toBeNull();
|
|
162
|
+
expect(fix!.fixedHtml).toContain('alt=');
|
|
163
|
+
expect(fix!.autoFixable).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('empty alt → replaces with descriptive text', () => {
|
|
167
|
+
const node = makeNode({ html: '<img src="photo.jpg" alt="">' });
|
|
168
|
+
const fix = generateElementFix('image-alt', node);
|
|
169
|
+
expect(fix).not.toBeNull();
|
|
170
|
+
expect(fix!.fixedHtml).toContain('alt="photo"');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('derives alt text from filename', () => {
|
|
174
|
+
const node = makeNode({ html: '<img src="/images/user-profile.png">' });
|
|
175
|
+
const fix = generateElementFix('image-alt', node);
|
|
176
|
+
expect(fix!.fixedHtml).toContain('user profile');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('handles camelCase filenames', () => {
|
|
180
|
+
const node = makeNode({ html: '<img src="teamPhoto.jpg">' });
|
|
181
|
+
const fix = generateElementFix('image-alt', node);
|
|
182
|
+
expect(fix!.fixedHtml).toContain('team photo');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('autoFixable is true', () => {
|
|
186
|
+
const node = makeNode({ html: '<img src="test.jpg">' });
|
|
187
|
+
const fix = generateElementFix('image-alt', node);
|
|
188
|
+
expect(fix!.autoFixable).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('generateElementFix — button-name', () => {
|
|
193
|
+
it('SVG-only button → aria-label suggestion', () => {
|
|
194
|
+
const node = makeNode({
|
|
195
|
+
html: '<button><svg xmlns="http://www.w3.org/2000/svg"><path d="M10 10"/></svg></button>',
|
|
196
|
+
});
|
|
197
|
+
const fix = generateElementFix('button-name', node);
|
|
198
|
+
expect(fix).not.toBeNull();
|
|
199
|
+
expect(fix!.fixedHtml).toContain('aria-label');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('empty button → text suggestion', () => {
|
|
203
|
+
const node = makeNode({ html: '<button></button>' });
|
|
204
|
+
const fix = generateElementFix('button-name', node);
|
|
205
|
+
expect(fix).not.toBeNull();
|
|
206
|
+
expect(fix!.fixedHtml).toContain('[Button text]');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('autoFixable is false (requires human decision)', () => {
|
|
210
|
+
const node = makeNode({ html: '<button></button>' });
|
|
211
|
+
const fix = generateElementFix('button-name', node);
|
|
212
|
+
expect(fix!.autoFixable).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('generateElementFix — link-name', () => {
|
|
217
|
+
it('SVG-only link → aria-label suggestion', () => {
|
|
218
|
+
const node = makeNode({
|
|
219
|
+
html: '<a href="/"><svg xmlns="http://www.w3.org/2000/svg"><path d="M10 10"/></svg></a>',
|
|
220
|
+
});
|
|
221
|
+
const fix = generateElementFix('link-name', node);
|
|
222
|
+
expect(fix).not.toBeNull();
|
|
223
|
+
expect(fix!.fixedHtml).toContain('aria-label');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('empty link → text suggestion', () => {
|
|
227
|
+
const node = makeNode({ html: '<a href="/about"></a>' });
|
|
228
|
+
const fix = generateElementFix('link-name', node);
|
|
229
|
+
expect(fix).not.toBeNull();
|
|
230
|
+
expect(fix!.fixedHtml).toContain('[Link text]');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('text link → explanation only (no fixedHtml change needed)', () => {
|
|
234
|
+
const node = makeNode({ html: '<a href="/about">Click here</a>' });
|
|
235
|
+
const fix = generateElementFix('link-name', node);
|
|
236
|
+
expect(fix).not.toBeNull();
|
|
237
|
+
expect(fix!.explanation).toContain('descriptive');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('autoFixable is false', () => {
|
|
241
|
+
const node = makeNode({ html: '<a href="/"></a>' });
|
|
242
|
+
const fix = generateElementFix('link-name', node);
|
|
243
|
+
expect(fix!.autoFixable).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('generateElementFix — label', () => {
|
|
248
|
+
it('input with id → label suggestion', () => {
|
|
249
|
+
const node = makeNode({ html: '<input type="email" id="email-input">' });
|
|
250
|
+
const fix = generateElementFix('label', node);
|
|
251
|
+
expect(fix).not.toBeNull();
|
|
252
|
+
expect(fix!.explanation).toContain('email-input');
|
|
253
|
+
expect(fix!.explanation).toContain('<label');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('input without id → adds id', () => {
|
|
257
|
+
const node = makeNode({ html: '<input type="text">' });
|
|
258
|
+
const fix = generateElementFix('label', node);
|
|
259
|
+
expect(fix).not.toBeNull();
|
|
260
|
+
expect(fix!.fixedHtml).toContain('id=');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('autoFixable is false', () => {
|
|
264
|
+
const node = makeNode({ html: '<input type="text">' });
|
|
265
|
+
const fix = generateElementFix('label', node);
|
|
266
|
+
expect(fix!.autoFixable).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('generateElementFix — unknown rule', () => {
|
|
271
|
+
it('heading-order → null (no generator registered)', () => {
|
|
272
|
+
const node = makeNode({ html: '<h4>Section</h4>' });
|
|
273
|
+
const fix = generateElementFix('heading-order', node);
|
|
274
|
+
expect(fix).toBeNull();
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('getImpactDescription', () => {
|
|
279
|
+
it('critical → correct description', () => {
|
|
280
|
+
expect(getImpactDescription('critical')).toContain('completely block');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('serious → correct description', () => {
|
|
284
|
+
expect(getImpactDescription('serious')).toContain('very difficult');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('moderate → correct description', () => {
|
|
288
|
+
expect(getImpactDescription('moderate')).toContain('some difficulty');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('minor → correct description', () => {
|
|
292
|
+
expect(getImpactDescription('minor')).toContain('slight inconvenience');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('null → fallback description', () => {
|
|
296
|
+
expect(getImpactDescription(null)).toContain('not determined');
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('getImpactColorClass', () => {
|
|
301
|
+
it('critical → error variant with danger color', () => {
|
|
302
|
+
const result = getImpactColorClass('critical');
|
|
303
|
+
expect(result.variant).toBe('error');
|
|
304
|
+
expect(result.color).toContain('danger');
|
|
305
|
+
expect(result.bg).toContain('danger');
|
|
306
|
+
expect(result.borderLeft).toContain('danger');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('serious → error variant with danger color', () => {
|
|
310
|
+
const result = getImpactColorClass('serious');
|
|
311
|
+
expect(result.variant).toBe('error');
|
|
312
|
+
expect(result.color).toContain('danger');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('moderate → warning variant', () => {
|
|
316
|
+
const result = getImpactColorClass('moderate');
|
|
317
|
+
expect(result.variant).toBe('warning');
|
|
318
|
+
expect(result.color).toContain('warning');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('minor/null → info variant', () => {
|
|
322
|
+
const minorResult = getImpactColorClass('minor');
|
|
323
|
+
expect(minorResult.variant).toBe('info');
|
|
324
|
+
expect(minorResult.color).toContain('info');
|
|
325
|
+
const nullResult = getImpactColorClass(null);
|
|
326
|
+
expect(nullResult.variant).toBe('info');
|
|
327
|
+
expect(nullResult.color).toContain('info');
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe('extractWcagTags', () => {
|
|
332
|
+
it('filters wcag-prefixed tags only', () => {
|
|
333
|
+
const tags = ['wcag2a', 'wcag143', 'cat.color', 'best-practice'];
|
|
334
|
+
const result = extractWcagTags(tags);
|
|
335
|
+
expect(result).toEqual(['wcag2a', 'wcag143']);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('returns empty when no wcag tags', () => {
|
|
339
|
+
const tags = ['best-practice', 'cat.aria'];
|
|
340
|
+
const result = extractWcagTags(tags);
|
|
341
|
+
expect(result).toEqual([]);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe('parseWcagTag', () => {
|
|
346
|
+
it('wcag143 → 1.4.3', () => {
|
|
347
|
+
expect(parseWcagTag('wcag143')).toBe('1.4.3');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('wcag111 → 1.1.1', () => {
|
|
351
|
+
expect(parseWcagTag('wcag111')).toBe('1.1.1');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('non-matching → null', () => {
|
|
355
|
+
expect(parseWcagTag('wcag2a')).toBeNull();
|
|
356
|
+
expect(parseWcagTag('best-practice')).toBeNull();
|
|
357
|
+
});
|
|
358
|
+
});
|