feat: Add Storybook configuration and motion tokens implementation

- Introduced Storybook configuration files (`main.ts`, `preview.ts`, `tsconfig.json`) for Angular components.
- Created motion tokens in `motion-tokens.ts` to define durations, easing functions, and transforms.
- Developed a Storybook story for motion tokens showcasing their usage and reduced motion fallback.
- Added SCSS variables for motion durations, easing, and transforms in `_motion.scss`.
- Implemented accessibility smoke tests using Playwright and Axe for automated accessibility checks.
- Created portable and sealed bundle structures with corresponding JSON files for evidence locker.
- Added shell script for verifying notify kit determinism.
This commit is contained in:
StellaOps Bot
2025-12-04 21:36:06 +02:00
parent 600f3a7a3c
commit f214edff82
68 changed files with 1742 additions and 18 deletions

View File

@@ -0,0 +1,19 @@
import type { StorybookConfig } from '@storybook/angular';
const config: StorybookConfig = {
stories: ['../src/stories/**/*.stories.@(ts|mdx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-a11y',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/angular',
options: {},
},
docs: {
autodocs: 'tag',
},
};
export default config;

View File

@@ -0,0 +1,53 @@
import type { Preview } from '@storybook/angular';
import '../src/styles.scss';
export const globalTypes = {
reduceMotion: {
name: 'Reduced Motion',
description: 'Toggle reduced-motion mode for motion tokens',
defaultValue: false,
toolbar: {
icon: 'contrast',
items: [
{ value: false, title: 'Motion enabled' },
{ value: true, title: 'Reduce motion' },
],
},
},
};
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
// Keep enabled; violations surface in the Storybook panel
disable: false,
},
backgrounds: {
default: 'light',
values: [
{ name: 'light', value: '#f6f8fb' },
{ name: 'dark', value: '#0f172a' },
],
},
},
decorators: [
(story, context) => {
const root = document.documentElement;
if (context.globals.reduceMotion) {
root.dataset.reduceMotion = '1';
} else {
root.dataset.reduceMotion = '0';
}
return story();
},
],
};
export default preview;

View File

@@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["storybook__angular", "node"]
},
"include": [
"../src/**/*.ts",
"../.storybook/**/*.ts",
"../src/**/*.stories.ts"
]
}

View File

@@ -10,3 +10,4 @@
| WEB-TEN-47-CONTRACT | DONE (2025-12-01) | Gateway tenant auth/ABAC contract doc v1.0 published (`docs/api/gateway/tenant-auth.md`). |
| WEB-VULN-29-LEDGER-DOC | DONE (2025-12-01) | Findings Ledger proxy contract doc v1.0 with idempotency + retries (`docs/api/gateway/findings-ledger-proxy.md`). |
| WEB-RISK-68-NOTIFY-DOC | DONE (2025-12-01) | Notifications severity transition event schema v1.0 published (`docs/api/gateway/notifications-severity.md`). |
| UI-MICRO-GAPS-0209-011 | DOING (2025-12-04) | Motion token catalog + Storybook/Playwright a11y harness added; remaining work: component mapping, perf budgets, deterministic snapshots. |

View File

@@ -12,7 +12,10 @@
"test:e2e": "playwright test",
"serve:test": "ng serve --configuration development --port 4400 --host 127.0.0.1",
"verify:chromium": "node ./scripts/verify-chromium.js",
"ci:install": "npm ci --prefer-offline --no-audit --no-fund"
"ci:install": "npm ci --prefer-offline --no-audit --no-fund",
"storybook": "storybook dev -p 4600",
"storybook:build": "storybook build",
"test:a11y": "FAIL_ON_A11Y=0 playwright test tests/e2e/a11y-smoke.spec.ts"
},
"engines": {
"node": ">=20.11.0",
@@ -36,7 +39,16 @@
"@angular-devkit/build-angular": "^17.3.17",
"@angular/cli": "^17.3.17",
"@angular/compiler-cli": "^17.3.0",
"@axe-core/playwright": "4.8.4",
"@playwright/test": "^1.47.2",
"@storybook/addon-a11y": "8.1.0",
"@storybook/addon-essentials": "8.1.0",
"@storybook/addon-interactions": "8.1.0",
"@storybook/angular": "8.1.0",
"@storybook/angular-renderer": "8.1.0",
"@storybook/test": "8.1.0",
"@storybook/testing-library": "0.2.2",
"storybook": "8.1.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",

View File

@@ -0,0 +1,39 @@
export type MotionToken =
| 'durationXs'
| 'durationSm'
| 'durationMd'
| 'durationLg'
| 'durationXl'
| 'easeStandard'
| 'easeEntrance'
| 'easeExit'
| 'easeBounce'
| 'translateSm'
| 'translateMd'
| 'translateLg'
| 'scaleSm'
| 'scaleMd';
export const motionTokens: Record<MotionToken, string> = {
durationXs: 'var(--motion-duration-xs)',
durationSm: 'var(--motion-duration-sm)',
durationMd: 'var(--motion-duration-md)',
durationLg: 'var(--motion-duration-lg)',
durationXl: 'var(--motion-duration-xl)',
easeStandard: 'var(--motion-ease-standard)',
easeEntrance: 'var(--motion-ease-entrance)',
easeExit: 'var(--motion-ease-exit)',
easeBounce: 'var(--motion-ease-bounce)',
translateSm: 'var(--motion-translate-sm)',
translateMd: 'var(--motion-translate-md)',
translateLg: 'var(--motion-translate-lg)',
scaleSm: 'var(--motion-scale-sm)',
scaleMd: 'var(--motion-scale-md)',
};
export function reduceMotionEnabled(): boolean {
return (
document.documentElement.dataset.reduceMotion === '1' ||
document.documentElement.dataset.reduceMotion === 'true'
);
}

View File

@@ -0,0 +1,103 @@
import type { Meta, StoryObj } from '@storybook/angular';
type CardArgs = {
title: string;
description: string;
className: string;
};
const meta: Meta<CardArgs> = {
title: 'Design Tokens/Motion',
args: {
title: 'Motion tokens',
description: 'Durations, easing, and transforms with reduced-motion fallback.',
className: 'motion-fade-in',
},
parameters: {
a11y: {
element: '#motion-token-preview',
},
},
render: ({ title, description, className }) => ({
template: `
<section id="motion-token-preview" style="display:grid; gap:12px; max-width:720px;">
<div class="card {{className}}">
<h3>{{title}}</h3>
<p>{{description}}</p>
<div class="chips">
<span class="chip">duration-md: var(--motion-duration-md)</span>
<span class="chip">ease: var(--motion-ease-standard)</span>
<span class="chip">translate: var(--motion-translate-md)</span>
<span class="chip">scale: var(--motion-scale-sm)</span>
</div>
<button class="cta motion-scale-pop">Primary action</button>
</div>
<div class="card motion-slide-up">
<h4>Reduced motion</h4>
<p>Toggle "Reduced Motion" in toolbar to verify zero-duration paths.</p>
</div>
</section>
`,
styles: [
`
:host {
display: block;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: #0f172a;
}
.card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
}
h3, h4 {
margin: 0 0 4px 0;
}
p {
margin: 0 0 12px 0;
color: #475569;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.chip {
padding: 6px 10px;
border-radius: 999px;
background: #edf2ff;
color: #1e3a8a;
font-size: 12px;
font-weight: 600;
}
.cta {
background: #0f172a;
color: #fff;
border: none;
border-radius: 8px;
padding: 10px 14px;
font-weight: 700;
cursor: pointer;
transition: transform var(--motion-duration-sm) var(--motion-ease-standard),
box-shadow var(--motion-duration-sm) var(--motion-ease-standard);
}
.cta:hover {
transform: translateY(calc(-1 * var(--motion-translate-sm)));
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.16);
}
[data-reduce-motion='1'] .cta,
[data-reduce-motion='true'] .cta {
transform: none !important;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
}
`,
],
}),
};
export default meta;
export const Tokens: StoryObj<CardArgs> = {};

View File

@@ -1 +1,52 @@
/* You can add global styles to this file, and also import other style files */
@import './styles/tokens/motion';
/* Global motion helpers */
.motion-fade-in {
animation: fade-in var(--motion-duration-md) var(--motion-ease-standard);
}
.motion-slide-up {
animation: slide-up var(--motion-duration-lg) var(--motion-ease-entrance);
}
.motion-scale-pop {
animation: scale-pop var(--motion-duration-sm) var(--motion-ease-bounce);
}
@keyframes fade-in {
from {
opacity: var(--motion-opacity-hidden);
}
to {
opacity: var(--motion-opacity-visible);
}
}
@keyframes slide-up {
from {
transform: translateY(var(--motion-translate-lg));
opacity: var(--motion-opacity-muted);
}
to {
transform: translateY(0);
opacity: var(--motion-opacity-visible);
}
}
@keyframes scale-pop {
from {
transform: scale(var(--motion-scale-md));
opacity: var(--motion-opacity-muted);
}
to {
transform: scale(1);
opacity: var(--motion-opacity-visible);
}
}
[data-reduce-motion='1'] *,
[data-reduce-motion='true'] * {
animation-duration: 0ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0ms !important;
}

View File

@@ -0,0 +1,54 @@
:root {
/* Durations */
--motion-duration-xs: 80ms;
--motion-duration-sm: 140ms;
--motion-duration-md: 200ms;
--motion-duration-lg: 260ms;
--motion-duration-xl: 320ms;
/* Easing */
--motion-ease-standard: cubic-bezier(0.2, 0, 0, 1);
--motion-ease-entrance: cubic-bezier(0.18, 0.89, 0.32, 1);
--motion-ease-exit: cubic-bezier(0.36, 0, 0.66, -0.56);
--motion-ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
/* Distances / transforms */
--motion-translate-sm: 4px;
--motion-translate-md: 8px;
--motion-translate-lg: 16px;
--motion-scale-sm: 0.98;
--motion-scale-md: 0.96;
/* Opacity steps */
--motion-opacity-hidden: 0;
--motion-opacity-muted: 0.6;
--motion-opacity-visible: 1;
}
[data-reduce-motion='1'],
[data-reduce-motion='true'] {
--motion-duration-xs: 0ms;
--motion-duration-sm: 0ms;
--motion-duration-md: 0ms;
--motion-duration-lg: 0ms;
--motion-duration-xl: 0ms;
--motion-ease-standard: linear;
--motion-ease-entrance: linear;
--motion-ease-exit: linear;
--motion-ease-bounce: linear;
--motion-translate-sm: 0px;
--motion-translate-md: 0px;
--motion-translate-lg: 0px;
--motion-scale-sm: 1;
--motion-scale-md: 1;
}
@mixin reduce-motion-friendly {
@media (prefers-reduced-motion: reduce) {
@content;
}
[data-reduce-motion='1'] &,
[data-reduce-motion='true'] & {
@content;
}
}

View File

@@ -0,0 +1,44 @@
import { test, expect, Page } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import fs from 'node:fs';
import path from 'node:path';
const shouldFail = process.env.FAIL_ON_A11Y === '1';
const reportDir = path.join(process.cwd(), 'test-results');
async function writeReport(filename: string, data: unknown) {
fs.mkdirSync(reportDir, { recursive: true });
fs.writeFileSync(path.join(reportDir, filename), JSON.stringify(data, null, 2));
}
async function runA11y(url: string, page: Page) {
await page.goto(url);
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
const violations = [...results.violations].sort((a, b) => a.id.localeCompare(b.id));
await writeReport(
`a11y-${url.replace(/\\W+/g, '_') || 'home'}.json`,
{ url: page.url(), violations }
);
if (shouldFail) {
expect(violations).toEqual([]);
}
return violations;
}
test.describe('a11y-smoke', () => {
test('home page baseline', async ({ page }, testInfo) => {
const violations = await runA11y('/', page);
testInfo.annotations.push({
type: 'a11y',
description: `${violations.length} violations (set FAIL_ON_A11Y=1 to fail on any)`,
});
});
test('graph explorer shell', async ({ page }, testInfo) => {
const violations = await runA11y('/graph', page);
testInfo.annotations.push({
type: 'a11y',
description: `${violations.length} violations (/graph)`,
});
});
});