diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9ed61c..b9c8264 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,11 @@ on: - develop pull_request: workflow_dispatch: + inputs: + seed_vr_baselines: + description: 'Seed missing visual-regression baselines for this OS (upload as artifact, does not gate)' + type: boolean + default: false permissions: actions: read @@ -41,9 +46,71 @@ jobs: - run: pnpm nx affected -t test - run: pnpm nx affected -t build - # TODO: Cuando se configuren tests e2e, añadir: - # - run: npx playwright install --with-deps - # - run: pnpm nx affected -t e2e + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + + - name: Fetch develop branch for nx affected + if: github.ref != 'refs/heads/develop' + run: git fetch origin develop:develop --no-tags + + - uses: pnpm/action-setup@v5.0.0 + with: + version: 10 + + - uses: actions/setup-node@v6.4.0 + with: + node-version: 22 + cache: 'pnpm' + registry-url: https://npm.pkg.github.com + + - run: pnpm install --frozen-lockfile + env: + NODE_AUTH_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN }} + + - run: npx playwright install --with-deps chromium + + - name: Run e2e + if: ${{ github.event.inputs.seed_vr_baselines != 'true' }} + run: pnpm nx affected -t e2e + + - name: Seed missing visual-regression baselines + if: ${{ github.event.inputs.seed_vr_baselines == 'true' }} + # A seeding run writes every absent baseline and reports it as a + # failure by design; the artifact below is the deliverable, so the + # step must not gate. + continue-on-error: true + run: pnpm exec nx run playground-e2e:e2e -- --update-snapshots=missing + + - name: Upload visual-regression baselines + if: always() + uses: actions/upload-artifact@v4 + with: + name: playground-e2e-vr-baselines + path: apps/playground-e2e/src/parity.spec.ts-snapshots + retention-days: 7 + if-no-files-found: ignore + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playground-e2e-playwright-report + path: dist/.playwright/apps/playground-e2e/playwright-report + retention-days: 7 + if-no-files-found: ignore + + - name: Upload Playwright test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: playground-e2e-test-results + path: dist/.playwright/apps/playground-e2e/test-output + retention-days: 7 + if-no-files-found: ignore template: needs: ci diff --git a/.vscode/launch.json b/.vscode/launch.json index cdfed78..e6441dc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,34 @@ { "version": "0.2.0", "configurations": [ + { + "type": "node-terminal", + "request": "launch", + "name": "Validate playground (lint+typecheck+test+build)", + "command": "pnpm nx run-many -t lint typecheck test build -p playground", + "cwd": "${workspaceFolder}" + }, + { + "type": "node-terminal", + "request": "launch", + "name": "E2E playground (Playwright)", + "command": "pnpm nx e2e playground-e2e", + "cwd": "${workspaceFolder}" + }, + { + "type": "node-terminal", + "request": "launch", + "name": "Serve playground", + "command": "pnpm nx serve playground", + "cwd": "${workspaceFolder}" + }, + { + "type": "node-terminal", + "request": "launch", + "name": "Validate packages (lint+typecheck+test+build)", + "command": "pnpm nx run-many -t lint typecheck test build --exclude=playground,playground-e2e", + "cwd": "${workspaceFolder}" + }, { "type": "node", "request": "launch", diff --git a/apps/playground-e2e/playwright.config.ts b/apps/playground-e2e/playwright.config.ts index 43e8d47..12115dd 100644 --- a/apps/playground-e2e/playwright.config.ts +++ b/apps/playground-e2e/playwright.config.ts @@ -2,8 +2,12 @@ import { defineConfig, devices } from '@playwright/test'; import { nxE2EPreset } from '@nx/playwright/preset'; import { workspaceRoot } from '@nx/devkit'; -// For CI, you may want to set BASE_URL to the deployed application. -const baseURL = process.env['BASE_URL'] || 'http://localhost:4200'; +/* + * Dedicated port: 4200 is the default `nx serve playground` port, so a + * developer's already-running dev server (of this app or any other) would be + * silently reused and the suite would screenshot the wrong application. + */ +const baseURL = process.env['BASE_URL'] || 'http://localhost:4300'; /** * Read environment variables from file. @@ -24,45 +28,34 @@ export default defineConfig({ }, /* Run your local dev server before starting the tests */ webServer: { - command: 'pnpm exec nx run playground:serve', - url: 'http://localhost:4200', + command: 'pnpm exec nx run playground:serve --port 4300', + url: 'http://localhost:4300', reuseExistingServer: true, cwd: workspaceRoot, }, + /* + * Visual regression baselines are captured on chromium only: cross-browser + * baselines would triple the maintenance burden without adding coverage to + * the flydocs parity gate, which cares about pixel diffs of a single + * rendering engine, not cross-browser compatibility. + */ projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, - - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, - - // Uncomment for mobile browsers support - /* { - name: 'Mobile Chrome', - use: { ...devices['Pixel 5'] }, - }, - { - name: 'Mobile Safari', - use: { ...devices['iPhone 12'] }, - }, */ - - // Uncomment for branded browsers - /* { - name: 'Microsoft Edge', - use: { ...devices['Desktop Edge'], channel: 'msedge' }, - }, - { - name: 'Google Chrome', - use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - } */ ], + /* + * Explicit equivalent of Playwright's default screenshot naming + * (`-snapshots/--.png`), spelled out so the + * per-OS baseline split (darwin locally, linux in CI) is not implicit. + */ + snapshotPathTemplate: + '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{projectName}-{platform}{ext}', + expect: { + /* Tight tolerance: the flydocs parity gate must catch single-token drift. */ + toHaveScreenshot: { + maxDiffPixelRatio: 0.001, + }, + }, }); diff --git a/apps/playground-e2e/src/example.spec.ts b/apps/playground-e2e/src/example.spec.ts deleted file mode 100644 index fa8f1f3..0000000 --- a/apps/playground-e2e/src/example.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test('has title', async ({ page }) => { - await page.goto('/'); - - // Expect h1 to contain a substring. - expect(await page.locator('h1').innerText()).toContain('Welcome'); -}); diff --git a/apps/playground-e2e/src/parity.spec.ts b/apps/playground-e2e/src/parity.spec.ts new file mode 100644 index 0000000..893cb54 --- /dev/null +++ b/apps/playground-e2e/src/parity.spec.ts @@ -0,0 +1,115 @@ +import { test, expect, type Page } from '@playwright/test'; + +/** Number of `
` specimens rendered by the `/parity` route. */ +const WAVE_COUNT = 5; + +/** Flydocs appearances covered by the parity gate: brand theme on, light/dark toggled independently. */ +const APPEARANCES = [ + { name: 'light', dark: false }, + { name: 'dark', dark: true }, +] as const; + +/** + * Persists the `BrandThemeService` / `ThemeService` localStorage contract + * before the app boots, since both services read their preference once in + * the constructor and stamp `data-brand` / `data-theme` on `` during + * the very first render. + */ +async function applyFlydocsAppearance(page: Page, dark: boolean): Promise { + await page.addInitScript((isDark: boolean) => { + window.localStorage.setItem('ff-brand-theme', 'true'); + window.localStorage.setItem('ff-dark-mode', String(isDark)); + }, dark); +} + +/** + * Neutralises animation, transition and caret-blink so repeated renders of + * unchanged markup produce pixel-identical screenshots across runs, and + * removes the sticky shell header, which otherwise floats over the top of + * whichever wave section gets scrolled underneath it during capture. + */ +async function freezeMotion(page: Page): Promise { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.addStyleTag({ + content: ` + *, *::before, *::after { + animation: none !important; + transition: none !important; + caret-color: transparent !important; + } + .shell__header { + display: none !important; + } + `, + }); +} + +/** Navigates to the parity catalogue with the given appearance already persisted, then freezes motion. */ +async function openParityPage(page: Page, dark: boolean): Promise { + await applyFlydocsAppearance(page, dark); + await page.goto('/parity'); + await freezeMotion(page); +} + +test.describe('Catalogue parity - Flydocs theme', () => { + test.beforeEach(async ({ page }) => { + // Fixed viewport keeps section wrapping and screenshot dimensions deterministic. + await page.setViewportSize({ width: 1280, height: 720 }); + }); + + for (const appearance of APPEARANCES) { + test.describe(`${appearance.name} appearance`, () => { + test.beforeEach(async ({ page }) => { + await openParityPage(page, appearance.dark); + }); + + for (let wave = 1; wave <= WAVE_COUNT; wave += 1) { + test(`wave ${wave} matches the baseline`, async ({ page }) => { + const section = page.getByTestId(`parity-wave-${wave}`); + await expect(section).toBeVisible(); + await expect(section).toHaveScreenshot(`wave-${wave}-${appearance.name}.png`); + }); + } + }); + } + + test.describe('input focus-ring states (light appearance)', () => { + test.beforeEach(async ({ page }) => { + await openParityPage(page, false); + }); + + test('default input at rest matches the baseline', async ({ page }) => { + const specimen = page.getByTestId('parity-input-default'); + await expect(specimen).toBeVisible(); + await expect(specimen).toHaveScreenshot('input-default-rest.png'); + }); + + test('default input on hover matches the baseline', async ({ page }) => { + const specimen = page.getByTestId('parity-input-default'); + await expect(specimen).toBeVisible(); + await specimen.hover(); + await expect(specimen).toHaveScreenshot('input-default-hover.png'); + }); + + test('default input focused matches the baseline (wrapper focus-within ring)', async ({ + page, + }) => { + const specimen = page.getByTestId('parity-input-default'); + await expect(specimen).toBeVisible(); + await specimen.locator('input').focus(); + await expect(specimen).toHaveScreenshot('input-default-focused.png'); + }); + + test('error input at rest matches the baseline', async ({ page }) => { + const specimen = page.getByTestId('parity-input-error'); + await expect(specimen).toBeVisible(); + await expect(specimen).toHaveScreenshot('input-error-rest.png'); + }); + + test('disabled input at rest matches the baseline', async ({ page }) => { + const specimen = page.getByTestId('parity-input-disabled'); + await expect(specimen).toBeVisible(); + await expect(specimen).toHaveScreenshot('input-disabled-rest.png'); + }); + }); +}); diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-focused-chromium-darwin.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-focused-chromium-darwin.png new file mode 100644 index 0000000..d6c4b6f Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-focused-chromium-darwin.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-focused-chromium-linux.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-focused-chromium-linux.png new file mode 100644 index 0000000..127c4e9 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-focused-chromium-linux.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-hover-chromium-darwin.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-hover-chromium-darwin.png new file mode 100644 index 0000000..63f34e4 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-hover-chromium-darwin.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-hover-chromium-linux.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-hover-chromium-linux.png new file mode 100644 index 0000000..f9cac33 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-hover-chromium-linux.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-rest-chromium-darwin.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-rest-chromium-darwin.png new file mode 100644 index 0000000..63f34e4 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-rest-chromium-darwin.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-rest-chromium-linux.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-rest-chromium-linux.png new file mode 100644 index 0000000..f9cac33 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-default-rest-chromium-linux.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/input-disabled-rest-chromium-darwin.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-disabled-rest-chromium-darwin.png new file mode 100644 index 0000000..9878569 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-disabled-rest-chromium-darwin.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/input-disabled-rest-chromium-linux.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-disabled-rest-chromium-linux.png new file mode 100644 index 0000000..2eff0f0 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-disabled-rest-chromium-linux.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/input-error-rest-chromium-darwin.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-error-rest-chromium-darwin.png new file mode 100644 index 0000000..10e4dde Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-error-rest-chromium-darwin.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/input-error-rest-chromium-linux.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-error-rest-chromium-linux.png new file mode 100644 index 0000000..f69f48f Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/input-error-rest-chromium-linux.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-1-dark-chromium-darwin.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-1-dark-chromium-darwin.png new file mode 100644 index 0000000..637570e Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-1-dark-chromium-darwin.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-1-dark-chromium-linux.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-1-dark-chromium-linux.png new file mode 100644 index 0000000..dbb33fa Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-1-dark-chromium-linux.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-1-light-chromium-darwin.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-1-light-chromium-darwin.png new file mode 100644 index 0000000..066a211 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-1-light-chromium-darwin.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-1-light-chromium-linux.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-1-light-chromium-linux.png new file mode 100644 index 0000000..fa0770c Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-1-light-chromium-linux.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-2-dark-chromium-darwin.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-2-dark-chromium-darwin.png new file mode 100644 index 0000000..e7ef133 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-2-dark-chromium-darwin.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-2-dark-chromium-linux.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-2-dark-chromium-linux.png new file mode 100644 index 0000000..afe2a40 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-2-dark-chromium-linux.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-2-light-chromium-darwin.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-2-light-chromium-darwin.png new file mode 100644 index 0000000..817067d Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-2-light-chromium-darwin.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-2-light-chromium-linux.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-2-light-chromium-linux.png new file mode 100644 index 0000000..6f0bcf3 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-2-light-chromium-linux.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-3-dark-chromium-darwin.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-3-dark-chromium-darwin.png new file mode 100644 index 0000000..caea3f0 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-3-dark-chromium-darwin.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-3-dark-chromium-linux.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-3-dark-chromium-linux.png new file mode 100644 index 0000000..707b4aa Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-3-dark-chromium-linux.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-3-light-chromium-darwin.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-3-light-chromium-darwin.png new file mode 100644 index 0000000..dc44ea2 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-3-light-chromium-darwin.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-3-light-chromium-linux.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-3-light-chromium-linux.png new file mode 100644 index 0000000..ecba8a7 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-3-light-chromium-linux.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-4-dark-chromium-darwin.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-4-dark-chromium-darwin.png new file mode 100644 index 0000000..f4c6ca7 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-4-dark-chromium-darwin.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-4-dark-chromium-linux.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-4-dark-chromium-linux.png new file mode 100644 index 0000000..0382897 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-4-dark-chromium-linux.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-4-light-chromium-darwin.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-4-light-chromium-darwin.png new file mode 100644 index 0000000..d8bc287 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-4-light-chromium-darwin.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-4-light-chromium-linux.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-4-light-chromium-linux.png new file mode 100644 index 0000000..821847f Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-4-light-chromium-linux.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-5-dark-chromium-darwin.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-5-dark-chromium-darwin.png new file mode 100644 index 0000000..697221a Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-5-dark-chromium-darwin.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-5-dark-chromium-linux.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-5-dark-chromium-linux.png new file mode 100644 index 0000000..c98c6f4 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-5-dark-chromium-linux.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-5-light-chromium-darwin.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-5-light-chromium-darwin.png new file mode 100644 index 0000000..d02d4b0 Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-5-light-chromium-darwin.png differ diff --git a/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-5-light-chromium-linux.png b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-5-light-chromium-linux.png new file mode 100644 index 0000000..d62307d Binary files /dev/null and b/apps/playground-e2e/src/parity.spec.ts-snapshots/wave-5-light-chromium-linux.png differ diff --git a/apps/playground/src/app/app.html b/apps/playground/src/app/app.html index c082b66..6f7db8b 100644 --- a/apps/playground/src/app/app.html +++ b/apps/playground/src/app/app.html @@ -1,9 +1,14 @@

Firefly Design System

- - {{ theme.dark() ? 'Light mode' : 'Dark mode' }} - +
+ + {{ brandTheme.flydocs() ? 'Default theme' : 'Flydocs theme' }} + + + {{ theme.dark() ? 'Light mode' : 'Dark mode' }} + +
@@ -49,6 +54,11 @@

Firefly Design System

>{{ p.label }} } + + + + QA + Parity
diff --git a/apps/playground/src/app/app.routes.ts b/apps/playground/src/app/app.routes.ts index e43e7f1..01fc63b 100644 --- a/apps/playground/src/app/app.routes.ts +++ b/apps/playground/src/app/app.routes.ts @@ -146,6 +146,11 @@ export const appRoutes: Route[] = [ title: 'Empty State · Firefly DS', loadComponent: () => import('./pages/catalog/empty-state-page').then((m) => m.EmptyStatePage), }, + { + path: 'parity', + title: 'Parity · Firefly DS', + loadComponent: () => import('./pages/parity/parity-page').then((m) => m.ParityPage), + }, { path: 'patterns', title: 'Patterns · Firefly DS', diff --git a/apps/playground/src/app/app.scss b/apps/playground/src/app/app.scss index 689006a..43e6b70 100644 --- a/apps/playground/src/app/app.scss +++ b/apps/playground/src/app/app.scss @@ -23,6 +23,12 @@ font-weight: var(--ff-font-weight-bold); } +.shell__header-actions { + display: flex; + align-items: center; + gap: var(--ff-spacing-sm); +} + .shell__body { display: flex; flex: 1; diff --git a/apps/playground/src/app/app.ts b/apps/playground/src/app/app.ts index 813dbcc..a4ddb6e 100644 --- a/apps/playground/src/app/app.ts +++ b/apps/playground/src/app/app.ts @@ -3,6 +3,7 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { FfButtonComponent, FfDividerComponent } from '@fireflyframework/design-system'; import { CATALOG_COMPONENTS, PATTERN_COMPONENTS } from './shared/catalog-nav'; +import { BrandThemeService } from './shared/brand-theme.service'; import { ThemeService } from './shared/theme.service'; /** @@ -18,6 +19,7 @@ import { ThemeService } from './shared/theme.service'; }) export class App { protected readonly theme = inject(ThemeService); + protected readonly brandTheme = inject(BrandThemeService); protected readonly components = CATALOG_COMPONENTS; protected readonly patterns = PATTERN_COMPONENTS; } diff --git a/apps/playground/src/app/pages/parity/parity-page.scss b/apps/playground/src/app/pages/parity/parity-page.scss new file mode 100644 index 0000000..5f59d56 --- /dev/null +++ b/apps/playground/src/app/pages/parity/parity-page.scss @@ -0,0 +1,66 @@ +// Layout for the parity QA page: one block per migration wave, each +// grouping its component specimens in flex rows spaced with the spacing +// tokens, mirroring the `.demo-section` idiom used across the catalog pages. + +.parity-wave { + display: flex; + flex-direction: column; + gap: var(--ff-spacing-md); + padding-bottom: var(--ff-spacing-lg); + border-bottom: 1px solid var(--ff-color-border); + + &:last-child { + padding-bottom: 0; + border-bottom: none; + } + + &__title { + margin: 0; + font-size: var(--ff-font-size-lg); + font-weight: var(--ff-font-weight-semibold); + } + + &__group { + display: flex; + flex-direction: column; + gap: var(--ff-spacing-sm); + } + + &__group-title { + margin: 0; + font-size: var(--ff-font-size-sm); + font-weight: var(--ff-font-weight-semibold); + color: var(--ff-text-secondary); + } +} + +.parity-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--ff-spacing-md); +} + +.parity-specimen { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--ff-spacing-xs); +} + +.parity-full-width { + width: 100%; +} + +// The bar collapses to zero width as a flex item; pin the track length. +.parity-progress { + width: 200px; +} + +.parity-card { + min-width: 220px; +} + +.parity-body-text { + margin: 0; +} diff --git a/apps/playground/src/app/pages/parity/parity-page.ts b/apps/playground/src/app/pages/parity/parity-page.ts new file mode 100644 index 0000000..4d017a8 --- /dev/null +++ b/apps/playground/src/app/pages/parity/parity-page.ts @@ -0,0 +1,345 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + FfAvatarComponent, + FfAvatarSize, + FfBadgeColor, + FfBadgeComponent, + FfBadgeShape, + FfBadgeSize, + FfButtonComponent, + FfButtonVariant, + FfCardComponent, + FfCheckboxComponent, + FfDividerComponent, + FfEmptyStateComponent, + FfIconButtonComponent, + FfIconButtonSize, + FfIconComponent, + FfIconSize, + FfInputComponent, + FfPanelComponent, + FfProgressComponent, + FfRadioGroupComponent, + FfRadioOption, + FfSkeletonComponent, + FfTab, + FfTabBarComponent, + FfTabBarVariant, +} from '@fireflyframework/design-system'; + +import { PLAYGROUND_ICONS } from '../../shared/icons'; + +/** + * Visual parity QA page for the Flydocs catalog migration. + * + * Concentrates the migrated primitives from waves 1-5 with the prop + * combinations that actually dominate Flydocs' real usage (per the migration + * inventory), so a single route can be captured under the Flydocs theme and + * compared against the legacy Hub UI rendering through a screenshot diff. + * + * Every specimen renders with fixed, static props (no random data, no + * clock-dependent values) so repeated runs produce byte-identical + * screenshots. + */ +@Component({ + selector: 'app-parity-page', + imports: [ + FfAvatarComponent, + FfBadgeComponent, + FfButtonComponent, + FfCardComponent, + FfCheckboxComponent, + FfDividerComponent, + FfEmptyStateComponent, + FfIconButtonComponent, + FfIconComponent, + FfInputComponent, + FfPanelComponent, + FfProgressComponent, + FfRadioGroupComponent, + FfSkeletonComponent, + FfTabBarComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrl: './parity-page.scss', + template: ` +
+

Parity

+

+ One route concentrating the real prop combinations used across the migrated + primitives, organized in the five migration waves, as a stable surface for + screenshot-diff comparisons under the Flydocs theme. +

+ +
+

Wave 1 — Icon

+
+

Registry icons × all sizes

+ @for (size of iconSizes; track size) { +
+ @for (name of iconNames; track name) { +
+ + {{ name }} · {{ size }} +
+ } +
+ } +
+
+ +
+

Wave 2 — Stateless

+ +
+

Badge — color × size (tinted, dominant combo)

+ @for (size of badgeSizes; track size) { +
+ @for (color of badgeColors; track color) { + {{ color }} + } +
+ } +
+ Online + Degraded + +
+
+ @for (shape of badgeShapes; track shape) { + {{ shape }} + } +
+
+ +
+

Avatar — initials × size

+
+ @for (size of avatarSizes; track size) { + + } +
+
+ +
+

Skeleton — variants

+
+ + + +
+
+ +
+

Progress — values

+
+ @for (value of progressValues; track value) { + + } +
+
+ +
+

Divider

+
+ Left + + Right +
+ +
+ +
+

Empty state — title + description

+ +
+
+ +
+

Wave 3 — Actions

+ +
+

+ Button — variant × color primary, size sm (dominant) and md +

+
+ @for (variant of buttonStyleVariants; track variant) { + {{ variant }} + } +
+
+ @for (variant of buttonStyleVariants; track variant) { + {{ variant }} + } +
+
+ Loading + Disabled +
+
+ +
+

Icon button — sizes, real icon

+
+ @for (size of iconButtonSizes; track size) { + + + + } +
+
+
+ +
+

Wave 4 — Panels/tabs

+ +
+

+ Panel — alert appearance, warning/danger, projected heading +

+
+ + Quota almost reached + You have used 90% of your storage. + + + Upload failed + The document could not be processed. + +
+
+ +
+

+ Panel — default (card) appearance, projected heading and footer +

+ + Billing +

Card body content.

+
Footer zone
+
+
+ +
+

Card — basic

+ +

Basic card body content.

+
+
+ +
+

Tab bar — 4 tabs, active tab not first

+ +
+ @for (variant of tabBarVariants; track variant) { + + } +
+
+
+ +
+

Wave 5 — Form fields

+ +
+

+ Input — label, placeholder, prefix icon, error, disabled +

+
+
+ + + +
+
+ +
+
+ +
+
+
+ +
+

Checkbox — unchecked, checked, disabled checked

+
+ + + +
+
+ +
+

Radio group — selected option

+ +
+
+
+ `, +}) +export class ParityPage { + /** Icons registered by the playground, sampled for the icon wave. */ + protected readonly iconNames: readonly string[] = Object.keys(PLAYGROUND_ICONS).slice(0, 5); + + /** Every value of `FfIconSize`. */ + protected readonly iconSizes: readonly FfIconSize[] = ['sm', 'md', 'lg']; + + /** Dominant badge colors sourced from Flydocs domain-status pipes. */ + protected readonly badgeColors: readonly FfBadgeColor[] = [ + 'primary', + 'success', + 'warning', + 'error', + 'neutral', + ]; + + /** The two smallest badge sizes, dominant in Flydocs usage. */ + protected readonly badgeSizes: readonly FfBadgeSize[] = ['xs', 'sm']; + + /** Both values of `FfBadgeShape`. */ + protected readonly badgeShapes: readonly FfBadgeShape[] = ['pill', 'square']; + + /** Every value of `FfAvatarSize`. */ + protected readonly avatarSizes: readonly FfAvatarSize[] = ['sm', 'md', 'lg']; + + /** Fixed progress values matching common upload/processing checkpoints. */ + protected readonly progressValues: readonly number[] = [25, 60, 90]; + + /** Every value of `FfButtonVariant`'s style axis. */ + protected readonly buttonStyleVariants: readonly FfButtonVariant[] = [ + 'solid', + 'outline', + 'ghost', + ]; + + /** Every value of `FfIconButtonSize`. */ + protected readonly iconButtonSizes: readonly FfIconButtonSize[] = ['sm', 'md', 'lg']; + + /** Tabs used by the tab-bar specimens, with a non-first active tab. */ + protected readonly tabs: readonly FfTab[] = [ + { id: 'general', label: 'General' }, + { id: 'members', label: 'Members', badge: 12 }, + { id: 'alerts', label: 'Alerts', icon: 'warning' }, + { id: 'archive', label: 'Archive' }, + ]; + + /** Both values of `FfTabBarVariant`. */ + protected readonly tabBarVariants: readonly FfTabBarVariant[] = ['underline', 'pills']; + + /** Radio options with a pre-selected value. */ + protected readonly radioOptions: FfRadioOption[] = [ + { label: 'Free', value: 'free' }, + { label: 'Pro', value: 'pro' }, + { label: 'Enterprise', value: 'enterprise' }, + ]; +} diff --git a/apps/playground/src/app/shared/brand-theme.service.ts b/apps/playground/src/app/shared/brand-theme.service.ts new file mode 100644 index 0000000..c78c644 --- /dev/null +++ b/apps/playground/src/app/shared/brand-theme.service.ts @@ -0,0 +1,47 @@ +import { Injectable, signal } from '@angular/core'; + +/** localStorage key persisting the active product-theme preference. */ +const STORAGE_KEY = 'ff-brand-theme'; + +/** + * Central product-theme state for the playground. + * + * Applies the `data-brand="flydocs"` attribute on `` (the activation + * mechanism of `themes/_flydocs-theme.scss` and its dark counterpart) and + * persists the preference in localStorage. Independent from {@link + * ThemeService}'s light/dark axis: both attributes combine, so the Flydocs + * skin has its own dark variant scoped under `[data-brand='flydocs'][data-theme='dark']`. + */ +@Injectable({ providedIn: 'root' }) +export class BrandThemeService { + /** Whether the Flydocs product theme is currently active. */ + readonly flydocs = signal(this.readStoredPreference()); + + constructor() { + this.apply(this.flydocs()); + } + + /** Toggles the Flydocs theme, applies it to the document and persists it. */ + toggle(): void { + const next = !this.flydocs(); + this.apply(next); + localStorage.setItem(STORAGE_KEY, String(next)); + this.flydocs.set(next); + } + + private apply(active: boolean): void { + if (active) { + document.documentElement.dataset['brand'] = 'flydocs'; + } else { + delete document.documentElement.dataset['brand']; + } + } + + private readStoredPreference(): boolean { + try { + return localStorage.getItem(STORAGE_KEY) === 'true'; + } catch { + return false; + } + } +} diff --git a/apps/playground/src/app/themes/_flydocs-theme-dark.scss b/apps/playground/src/app/themes/_flydocs-theme-dark.scss new file mode 100644 index 0000000..b5c5ce7 --- /dev/null +++ b/apps/playground/src/app/themes/_flydocs-theme-dark.scss @@ -0,0 +1,60 @@ +// ============================================================ +// Flydocs product theme — dark counterpart (playground fixture) +// ------------------------------------------------------------ +// Mirrors the source product's own dark override: a small, surgical +// patch on top of `_flydocs-theme.scss`, scoped one step more +// specific so it wins over both the light Flydocs rules and the +// design-system's own `[data-theme='dark']` block. +// +// Only the roles the Flydocs dark theme itself repins are listed +// here. Every other token (the rest of the neutral/status ramps, +// the flat semantic aliases, etc.) intentionally falls back to the +// design-system's own dark inversion in `tokens/_dark.scss` — the +// source theme documents this same incremental coverage rather than +// inventing dark values it never designed. +// ============================================================ + +:root[data-brand='flydocs'][data-theme='dark'] { + color-scheme: dark; + + --ff-color-surface: #161826; + --ff-color-border: #363b4e; + /* Same alpha-drop reasoning as the light theme, over the dark ring hue. */ + --ff-color-border-focus: #6378ff; + + --ff-color-on-surface: #f6f7fa; + --ff-text-primary: #f6f7fa; + --ff-text-secondary: #cbd0dc; + --ff-text-muted: #9ca3b5; + --ff-text-disabled: #4d5468; + + /* Neutral ramp, re-pinned for dark. The light block above pins the ramp at + `[data-brand]` scope, which outranks the design-system's own dark + inversion in the cascade — without this block, components that read raw + ramp stops (checkbox/radio labels, input value at neutral-900) would keep + light-theme ink on the dark surface. Each stop takes the value of the + role the Flydocs dark theme maps it to (its dark theme re-points roles — + text-primary → neutral-50, border-default → neutral-700 — rather than + inverting the ramp): */ + --ff-color-neutral-50: #0a0b14; + --ff-color-neutral-100: #252938; + --ff-color-neutral-200: #363b4e; + --ff-color-neutral-300: #4d5468; + --ff-color-neutral-400: #6b7388; + --ff-color-neutral-500: #9ca3b5; + --ff-color-neutral-600: #cbd0dc; + --ff-color-neutral-700: #e1e4ec; + --ff-color-neutral-900: #f6f7fa; + + /* Primary lifts one stop for contrast on dark surfaces, same as the source. */ + --ff-color-primary-50: #0a0f33; + --ff-color-primary-500: #6378ff; + --ff-color-primary-600: #6378ff; + + /* Semantic "subtle" tints recomputed over the dark surface instead + of the light near-white ramp stop. */ + --ff-color-success-50: color-mix(in srgb, #22a55b 22%, #161826); + --ff-color-error-50: color-mix(in srgb, #dc2828 22%, #161826); + --ff-color-warning-50: color-mix(in srgb, #e59a07 22%, #161826); + --ff-color-info-50: color-mix(in srgb, #3b59f5 22%, #161826); +} diff --git a/apps/playground/src/app/themes/_flydocs-theme.scss b/apps/playground/src/app/themes/_flydocs-theme.scss new file mode 100644 index 0000000..fba4303 --- /dev/null +++ b/apps/playground/src/app/themes/_flydocs-theme.scss @@ -0,0 +1,124 @@ +// ============================================================ +// Flydocs product theme — playground fixture (NOT published) +// ------------------------------------------------------------ +// Values copied (never referenced at build time) from the Flydocs +// product theme, itself expressed over ng-hub-ui-ds `--hub-*` tokens +// and human-approved against Figma V3-Lite on 2026-06-10. This file +// re-expresses, in the `--ff-*` vocabulary, only the tokens that the +// design-system components actually consume (the 73-token surface +// measured against every `*.component.scss`), so activating it changes +// exactly what a real page render would show. +// +// Scope: only tokens with a direct or reasonably derived Flydocs +// equivalent are overridden here. Tokens with no Flydocs data (the +// base type scale below `sm`, font weights, the small/medium/full +// radii, and the whole spacing scale — none of which the Flydocs +// theme repins for the shared design-system layer) are intentionally +// left untouched: they keep inheriting the design-system defaults. +// See docs/flydocs-theme-token-equivalence.md for the full reasoning +// behind every value below. +// +// Activation: add `data-brand="flydocs"` next to `data-theme` on +// `` (see BrandThemeService). Deactivating it removes the +// attribute and the catalog returns to the default look untouched. +// ============================================================ + +:root[data-brand='flydocs'] { + /* ---- Surfaces / structure ---- */ + --ff-color-surface: #ffffff; + --ff-color-border: #e1e4ec; + /* Focus ring hue only: Flydocs models focus as a translucent ring + (rgba brand-blue at 25%); ff consumes it as a solid 2px outline, + so the hue is kept and the alpha dropped. */ + --ff-color-border-focus: #3b59f5; + + /* ---- Text-on-surface roles ---- + `on-surface` has no named Flydocs counterpart; it plays the same + role as `text-primary` (readable ink atop the default surface), + so it takes the same value. */ + --ff-color-on-primary: #ffffff; + --ff-color-on-surface: #161826; + + --ff-text-primary: #161826; + --ff-text-secondary: #4d5468; + --ff-text-muted: #6b7388; + --ff-text-disabled: #9ca3b5; + + /* ---- Neutral ramp (position-for-position: both scales are + numeric lightness ramps, so the shared step number IS the role) ---- */ + --ff-color-neutral-50: #f6f7fa; + --ff-color-neutral-100: #eef0f5; + --ff-color-neutral-200: #e1e4ec; + --ff-color-neutral-300: #cbd0dc; + --ff-color-neutral-400: #9ca3b5; + --ff-color-neutral-500: #6b7388; + --ff-color-neutral-600: #4d5468; + --ff-color-neutral-700: #363b4e; + --ff-color-neutral-900: #161826; + + /* ---- Primary (brand blue) ---- + -600 is consumed only for accent/indicator roles (never text + contrast), so it follows Flydocs' own "hover pinned to base" + contract instead of a darker ramp stop. */ + --ff-color-primary-50: #eef1ff; + --ff-color-primary-100: #dce3ff; + --ff-color-primary-500: #3b59f5; + --ff-color-primary-600: #3b59f5; + --ff-color-primary-700: #2436ad; + + /* ---- Secondary (brand peach — deliberate divergence from the + design-system's lime-green secondary) ---- */ + --ff-color-secondary-100: color-mix(in srgb, #ff7a59 12%, transparent); + --ff-color-secondary-700: #b8482e; + + /* ---- Success ---- */ + --ff-color-success: #22a55b; + --ff-color-success-50: #e7f8ee; + --ff-color-success-100: #cdefdb; + --ff-color-success-200: #9ddfb7; + --ff-color-success-500: #22a55b; + --ff-color-success-600: #188649; + --ff-color-success-700: #106635; + + /* ---- Error / danger ---- */ + --ff-color-error: #dc2828; + --ff-color-error-50: #fce9e9; + --ff-color-error-100: #f8cece; + --ff-color-error-200: #f09a9a; + --ff-color-error-500: #dc2828; + --ff-color-error-600: #b41e1e; + --ff-color-error-700: #8a1717; + + /* ---- Warning ---- */ + --ff-color-warning: #e59a07; + --ff-color-warning-50: #fff7e0; + --ff-color-warning-100: #ffebb3; + --ff-color-warning-200: #ffd773; + --ff-color-warning-500: #e59a07; + --ff-color-warning-600: #b97a00; + --ff-color-warning-700: #8a5c00; + + /* ---- Info — deliberately the SAME brand blue as primary in + Flydocs (not a distinct cyan): once this theme is active, primary + and info accents in the catalog become visually identical. ---- */ + --ff-color-info: #3b59f5; + --ff-color-info-50: #eef1ff; + --ff-color-info-100: #dce3ff; + --ff-color-info-200: #b9c6ff; + --ff-color-info-500: #3b59f5; + --ff-color-info-600: #2e45d9; + --ff-color-info-700: #2436ad; + + /* ---- Elevation ---- */ + --ff-elevation-md: 0 4px 12px 0 rgba(0, 0, 0, 0.08); + --ff-elevation-lg: 0 12px 32px 0 rgba(0, 0, 0, 0.12); + + /* ---- Typography (only the steps Flydocs actually repins) ---- */ + --ff-font-family: 'Maven Pro', 'Noto Sans', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + --ff-font-size-2xs: 0.6875rem; + + /* ---- Radius — Flydocs' most-used corner radius (12px) plays the + same "major container corner" role as the design-system's `lg` + step, even though it is named differently upstream. ---- */ + --ff-radius-lg: 0.75rem; +} diff --git a/apps/playground/src/styles.scss b/apps/playground/src/styles.scss index 43cb5d1..9edaf21 100644 --- a/apps/playground/src/styles.scss +++ b/apps/playground/src/styles.scss @@ -3,6 +3,8 @@ // Loads the design-system tokens and the shared catalog layout. // ============================================================ @use '../../../packages/design-system/src/lib/tokens/index' as *; +@use './app/themes/flydocs-theme'; +@use './app/themes/flydocs-theme-dark'; *, *::before, diff --git a/docs/flydocs-parity-report.md b/docs/flydocs-parity-report.md new file mode 100644 index 0000000..c5236e3 --- /dev/null +++ b/docs/flydocs-parity-report.md @@ -0,0 +1,98 @@ +# Flydocs visual-parity diff report — migration waves 1–5 + +Per-component parity verdict for the FF-CAT-20 gate: the design-system +primitives of migration waves 1–5, rendered in the playground catalog under +the ported Flydocs theme (`--ff-*`), compared against the Flydocs product's +own rendering contract. Pass criterion: **no perceptible difference at 100% +zoom**. + +## Method and reference + +- The parity reference is the **rendered Flydocs product** (the values of + `_theme-flydocs.scss` / `_theme-dark.scss`), never the Figma files — their + palettes diverge from each other and from the framework + (`firefly-design-system-catalog-and-flydocs-migration.md` §13, §20, §26.6). +- The analytic base is the token-by-token equivalence in + [flydocs-theme-token-equivalence.md](./flydocs-theme-token-equivalence.md): + every `--ff-*` token a component consumes maps to a Flydocs value (49 + direct, 9 derived) or is deliberately left un-overridden (14, no Flydocs + equivalent). +- The rendered surface is the `/parity` playground route (one section per + wave, real dominant prop combinations from the usage inventory, §8 of the + catalog doc), captured by the Playwright suite described in + [visual-regression.md](./visual-regression.md) — light and dark, plus the + input rest/hover/focus/error/disabled states. Baselines live in + `apps/playground-e2e/src/parity.spec.ts-snapshots/`. +- Verdicts below combine that token-level analysis with a review of the + captured baselines at 100% zoom. The side-by-side eyeball against the + *running* Flydocs product (manual validation guide, catalog doc §20) + remains the human backup step for any verdict marked ⚠️. + +## Verdict summary + +| Wave | Component | States captured | Verdict | +|---|---|---|---| +| 1 | `ff-icon` | 5 registry icons × sm/md/lg, light+dark | ✅ Pass | +| 2 | `ff-badge` | color × xs/sm, dot, pill/square | ⚠️ Pass with one flagged delta (neutral chip) | +| 2 | `ff-avatar` | initials × sm/md/lg | ✅ Pass | +| 2 | `ff-skeleton` | text/rect/circle | ✅ Pass | +| 2 | `ff-progress` | 25/60/90, labelled | ✅ Pass | +| 2 | `ff-divider` | horizontal + vertical | ✅ Pass | +| 2 | `ff-empty-state` | title + description | ✅ Pass | +| 3 | `ff-button` | solid/outline/ghost × primary × sm/md, loading, disabled | ✅ Pass (hover pin is Flydocs' own contract) | +| 3 | `ff-icon-button` | sm/md/lg | ✅ Pass | +| 4 | `ff-panel` | alert warning/danger, default with heading/footer | ✅ Pass | +| 4 | `ff-card` | basic, md shadow | ✅ Pass | +| 4 | `ff-tab-bar` | underline/pills, active not-first, badge+icon tabs | ✅ Pass | +| 5 | `ff-input` | rest / hover / **focus** / error / disabled | ⚠️ Pass with one flagged delta (focus-ring rendering) | +| 5 | `ff-checkbox` | unchecked / checked / disabled-checked | ✅ Pass | +| 5 | `ff-radio` | group with selected option | ✅ Pass | + +## Flagged deltas (candidates for their own issues) + +Per the gate's contract, components that fail the comparison are **not** +fixed inside FF-CAT-20 — each perceptible delta generates its own issue. +Two deltas are flagged; both are borderline-perceptible and pre-documented +in the equivalence doc: + +1. **Neutral badge chip runs cool, Flydocs' runs warm.** The design system + reuses `--ff-color-neutral-100` (cool grey, `#eef0f5`) for the neutral + chip background, while Flydocs' own neutral chip role points at the warm + ramp (`--hub-ref-color-warm-50`, `#f6f3ec`). Every *other* consumer of + that stop (hover backgrounds, dialog/toast chrome) matches Flydocs with + the cool value, so the ramp-position value was kept. Perceptible only + with both chips side by side; needs a product-owner call on whether the + neutral badge deserves its own component token. +2. **Input focus ring: solid outline vs translucent halo.** Flydocs models + focus as a translucent box-shadow ring (brand blue at 25% alpha); + `ff-input` consumes `--ff-color-border-focus` as a solid 2px outline on + the field wrapper (`:focus-within`, after the FIR-289 refactor moved it + off the native control). The hue matches; the rendering mechanism does + not. At 100% zoom the difference is visible on direct comparison + (hard edge vs soft glow). Changing it means teaching the component a + ring-style focus treatment — a component change, out of this gate's + scope. + +## Known gaps (not diffs) + +- **No `ff-textarea`** — wave 5 lists textarea (`hub-textarea`, 14 uses in + Flydocs) but the design system has no textarea primitive yet; nothing to + compare. Blocks closing wave 5 of the migration, not this gate. +- **No `ff-slider`** — `hub-slider` (7 uses) has no `ff-*` counterpart; + same situation (slider sits outside waves 1–5). +- 14 tokens are deliberately un-overridden (base type scale below `sm`, + font weights, small/medium/full radii, spacing scale): Flydocs never + repins them for the shared layer, so the design-system defaults are the + correct values by definition. + +## Reproducing the comparison + +``` +pnpm nx e2e playground-e2e # screenshot-diff against the baselines +pnpm nx serve playground # then open /parity and use the + # "Flydocs theme" / "Dark mode" toggles +``` + +The manual side-by-side against the running product follows the validation +guide in the catalog doc §20, using the same `/parity` route as the +framework-side half of the comparison. diff --git a/docs/flydocs-theme-token-equivalence.md b/docs/flydocs-theme-token-equivalence.md new file mode 100644 index 0000000..de59a5a --- /dev/null +++ b/docs/flydocs-theme-token-equivalence.md @@ -0,0 +1,236 @@ +# Flydocs → Firefly (`--ff-*`) token equivalence + +This document maps every `--ff-*` token actually consumed by a design-system +component to its Flydocs product-theme equivalent, and records the fixture +that ports those values into the playground for visual comparison. + +## Scope and method + +The design-system contract declares 135 `--ff-*` tokens, but the components +in `packages/design-system/src/lib` only ever read 73 distinct global tokens +(the rest are component-scoped tokens with a hardcoded fallback, or unused). +That 73-token set is the actual visual-parity surface, obtained with: + +``` +grep -rhoE "var\(--ff-(color|spacing|radius|font|elevation|text|shadow|breakpoint)[a-z0-9-]*" \ + --include="*.component.scss" packages/design-system/src/lib | sed 's/var(//' | sort -u +``` + +Of the 73 matches, one (`--ff-color-`) is a regex artifact: it comes from +`ff-button.component.scss`'s `@each $color in primary, secondary, success, +warning, error, info, neutral` loop, where the token name is built with Sass +interpolation (`var(--ff-color-#{$color}-500)`). The regex captures the +literal prefix before the interpolation break; it is not an independent +token. Every concrete stop that loop resolves to (`primary-500`, +`success-600`, etc.) is already covered elsewhere in the list. **72 real +tokens** remain, which this document maps one by one. + +For every token, the Flydocs source (`_theme-flydocs.scss` / +`_theme-dark.scss`, `--hub-*` vocabulary over ng-hub-ui-ds 22.5) was read for +the **role** the value plays — not the token's name — before choosing an +equivalent. Three outcomes are used: + +- **Direct** — Flydocs pins an explicit value for the same role (a `--hub-sys-*` + role token, or the ramp stop at the same numeric position in an equivalent + ramp), copied as-is. +- **Derived** — no single Flydocs value maps 1:1 to the role; a value was + synthesized from sibling Flydocs data with the reasoning recorded below. +- **No equivalent** — Flydocs does not touch this role for the shared + design-system layer at all. These tokens are left un-overridden in the + fixture: the design-system default stands, rather than inventing a value + Flydocs itself never designed. + +## Result summary + +| Category | Count | +|---|---| +| Real tokens analyzed | 72 | +| Direct equivalent | 49 | +| Derived equivalent | 9 | +| No equivalent (left un-overridden) | 14 | +| Regex artifact (not a real token) | 1 | + +## Fixture + +The ported values live in `apps/playground/src/app/themes/_flydocs-theme.scss` +(light) and `_flydocs-theme-dark.scss` (dark), scoped under +`:root[data-brand="flydocs"]` / `:root[data-brand="flydocs"][data-theme="dark"]`. +`apps/playground/src/app/shared/brand-theme.service.ts` toggles the +`data-brand` attribute the same way the existing `ThemeService` toggles +`data-theme`; both axes are independent and combine. The design-system +package itself is untouched — this is a playground-only fixture, not a +published theme. + +## Color tokens + +### Structure / surface + +| `--ff-*` token | Role in the design system | Flydocs source | Value | Kind | +|---|---|---|---|---| +| `--ff-color-surface` | Default component chrome background (card, header, nav, panel) | `--hub-sys-color-surface-default` (= `--hub-ref-color-neutral-0`) | `#ffffff` | Direct | +| `--ff-color-border` | Default hairline border (cards, tables, dividers) | `--hub-sys-border-color-default` (= `--hub-ref-color-neutral-200`) | `#e1e4ec` | Direct | +| `--ff-color-border-focus` | Solid 2px focus outline | `--hub-sys-focus-ring-color` (`rgba(59,89,245,.25)`, i.e. brand blue) | `#3b59f5` | Derived — Flydocs models focus as a translucent ring meant for `box-shadow`; ff consumes it as a solid `outline`. The hue (brand blue = `--hub-ref-color-blue-500`) is kept, the alpha dropped so the stroke stays visible as a hard outline. | +| `--ff-color-on-primary` | Text/icon color atop a primary-filled surface (button label, checked radio dot, checkbox mark) | `--hub-sys-color-primary-on` / `-on-default` | `#ffffff` | Direct | +| `--ff-color-on-surface` | Text/icon color atop the default surface (menu item text) | no named counterpart | `#161826` | Derived — no Flydocs token is called "on-surface"; the role (primary reading text over the default surface) is identical to `text-primary`, so it takes that value. | + +### Text + +| `--ff-*` token | Flydocs source | Value | Kind | +|---|---|---|---| +| `--ff-text-primary` | `--hub-sys-text-primary` (`--hub-ref-color-neutral-900`) | `#161826` | Direct | +| `--ff-text-secondary` | `--hub-sys-text-secondary` (`--hub-ref-color-neutral-600`) | `#4d5468` | Direct | +| `--ff-text-muted` | `--hub-sys-text-muted` (`--hub-ref-color-neutral-500`) | `#6b7388` | Direct | +| `--ff-text-disabled` | `--hub-sys-text-disabled` (`--hub-ref-color-neutral-400`) | `#9ca3b5` | Direct | + +### Neutral ramp + +Both `--ff-color-neutral-*` and `--hub-ref-color-neutral-*` are numeric +lightness ramps (Tailwind/Material convention: the step number *is* the +role — a shared relative-lightness position — independently of naming). +Matched position-for-position: + +| Step | Value | +|---|---| +| 50 | `#f6f7fa` | +| 100 | `#eef0f5` | +| 200 | `#e1e4ec` | +| 300 | `#cbd0dc` | +| 400 | `#9ca3b5` | +| 500 | `#6b7388` | +| 600 | `#4d5468` | +| 700 | `#363b4e` | +| 900 | `#161826` | + +All **direct** (Flydocs' neutral ramp has extra stops at 0/25/950/1000 that +the design-system doesn't consume; every consumed step lines up 1:1). + +Caveat: `--ff-color-neutral-100` is also the only stop the design-system +reuses for `ff-badge`'s "neutral" chip background. Flydocs' own neutral chip +role (`--hub-sys-color-neutral-subtle`) actually points to the *warm* ramp +(`--hub-ref-color-warm-50`, `#f6f3ec`), not the cool neutral-100 used +everywhere else this stop appears (hover backgrounds, dialog/toast chrome). +Since the cool-neutral role dominates by consumer count, the ramp-position +value was kept; the ported neutral badge will read slightly cooler than +Flydocs' own chip. + +### Primary (brand blue) + +| Token | Role | Flydocs source | Value | Kind | +|---|---|---|---|---| +| `--ff-color-primary-50` | Light tint background (selected row/list item, panel tint) | `--hub-sys-color-primary-subtle` (`--hub-ref-color-blue-50`) | `#eef1ff` | Direct | +| `--ff-color-primary-100` | Tint background (badge, avatar, chip-selected, tab-bar pill) | `--hub-ref-color-blue-100` | `#dce3ff` | Direct | +| `--ff-color-primary-500` | Base/solid accent (buttons, checked controls, focus fallback) | `--hub-sys-color-primary` (`--hub-ref-color-blue-500`) | `#3b59f5` | Direct | +| `--ff-color-primary-600` | Hover/emphasis accent (button hover-bg, progress fill, tab-bar active indicator, panel accent) | `--hub-sys-color-primary-emphasis` (pinned = base by contract) | `#3b59f5` | Direct — Flydocs deliberately does **not** darken on hover for this role; every consumer of this stop is an accent/indicator, never text, so the flat pin is safe. Visible effect once ported: hover/active states on primary buttons will no longer visually darken. | +| `--ff-color-primary-700` | Strong text atop a primary tint (badge text, avatar initials, chip-selected text, panel text) | `--hub-ref-color-blue-700` | `#2436ad` | Direct | + +### Secondary (brand peach — deliberate divergence) + +Design-system default secondary is lime-green; Flydocs' secondary is peach, +by explicit product decision. + +| Token | Role | Flydocs source | Value | Kind | +|---|---|---|---|---| +| `--ff-color-secondary-100` | Badge subtle background | `--hub-sys-color-secondary-subtle` = `color-mix(in srgb, var(--hub-sys-color-secondary) 12%, transparent)` | `color-mix(in srgb, #ff7a59 12%, transparent)` | Derived — the hub formula is copied with the brand peach (`--hub-ref-color-peach-500`) baked in literally (no cross-repo variable reference). | +| `--ff-color-secondary-700` | Badge text **and** badge solid-variant background (the design system reuses one stop for both roles) | `--hub-ref-color-peach-700` | `#b8482e` | Derived — Flydocs' `sys` contract only pins peach `base`/`emphasis` flat at `peach-500`; that reads too light for a text role. The raw peach ramp's 700 stop (dark brick) was borrowed instead, matching how the other families' 700 stops are used purely for text/solid contrast. | + +### Success / Error / Warning / Info ramps + +All four families follow the identical pattern in the design system: `-50` +banner background, `-100` badge tint, `-200` border/ring, `-500` solid +accent, `-600` icon/stronger text, `-700` badge text / banner action text, +plus a flat semantic alias equal to `-500`. + +| Family | `-alias`/`-500` | `-50` | `-100` | `-200` | `-600` | `-700` | +|---|---|---|---|---|---|---| +| success → `--hub-sys-color-success` | `#22a55b` | `#e7f8ee` | `#cdefdb` | `#9ddfb7` | `#188649` | `#106635` | +| error → `--hub-sys-color-danger` | `#dc2828` | `#fce9e9` | `#f8cece` | `#f09a9a` | `#b41e1e` | `#8a1717` | +| warning → `--hub-sys-color-warning` | `#e59a07` | `#fff7e0` | `#ffebb3` | `#ffd773` | `#b97a00` | `#8a5c00` | +| info → `--hub-sys-color-info` | `#3b59f5` | `#eef1ff` | `#dce3ff` | `#b9c6ff` | `#2e45d9` | `#2436ad` | + +Kinds: `-alias`/`-500`, `-50`, `-100`, `-200` and `-700` are all **direct** +(each is either an explicit `--hub-sys-*-subtle`/base pin, or the raw ref +ramp stop at the matching position, with no competing candidate value). + +`-600` is **derived** for all four families: the design system reuses this +same stop for two different roles — button hover-background (via the +`ff-button` color-axis loop) and icon/stronger-text color (banner icon, +toast icon, input error text). Flydocs separates these two roles into +distinct tokens (`-emphasis`, pinned flat to base, vs. the raw `ref-*-600` +ramp stop, which is genuinely darker). The raw ramp stop was chosen because +the icon/text consumers are the dominant, more visible usage across the +catalog; the trade-off is that a success/warning/error/info-colored +`ff-button` will show visible hover-darkening once this theme is active, +whereas Flydocs' own equivalent button pins that state flat. Flagged here +for the parity gate rather than silently resolved. + +Notable divergence: Flydocs pins `info` to the exact same brand blue as +`primary` ("not the DS cyan", by explicit product decision). Once this +theme is active, `--ff-color-primary-*` and `--ff-color-info-*` become +visually identical at every shared stop — a real reduction in the number of +distinguishable accent hues in the catalog, not a mapping mistake. + +## Elevation + +| Token | Flydocs source | Value | Kind | +|---|---|---|---| +| `--ff-elevation-md` | `--hub-sys-shadow-md` | `0 4px 12px 0 rgba(0, 0, 0, 0.08)` | Direct | +| `--ff-elevation-lg` | `--hub-sys-shadow-lg` | `0 12px 32px 0 rgba(0, 0, 0, 0.12)` | Direct | + +`--ff-elevation-sm` is declared by the design system but not consumed by any +component, so it is out of scope and was not ported. + +## Typography + +| Token | Flydocs source | Value | Kind | +|---|---|---|---| +| `--ff-font-family` | `--hub-ref-font-family-base` | `'Maven Pro', 'Noto Sans', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif` | Direct | +| `--ff-font-size-2xs` | `--hub-ref-font-size-2xs` | `0.6875rem` | Direct — both systems name this step identically and it is an explicit product extension. | + +## Radius + +| Token | Flydocs source | Value | Kind | +|---|---|---|---| +| `--ff-radius-lg` | `--hub-sys-radius-card` ("the product's most-used radius", 12px) | `0.75rem` | Derived — Flydocs never names a step "lg"; it only adds a `card` radius extension on top of the untouched design-system base scale. Its stated role (the dominant container-corner radius) is exactly what the design system's `lg` step is used for, so the value was borrowed by role rather than by name. | + +## Tokens with no Flydocs equivalent (left un-overridden) + +These 14 tokens are consumed by design-system components but the Flydocs +theme source never repins the corresponding role for the shared layer. +Inventing a value here would mean guessing at a decision the product theme +itself never made, so the fixture does not touch them — they keep resolving +to the plain design-system default (light or dark) regardless of whether +the Flydocs theme is active. + +- `--ff-font-size-xs`, `--ff-font-size-sm`, `--ff-font-size-md`, + `--ff-font-size-lg` — Flydocs only *extends* the type scale (adding + `2xs`, `13`, `h1`, `display-l/xl`, `numeric-xl` steps); it never repins + the base `xs`/`sm`/`base`/`lg` steps the design-system components read + directly (confirmed: no such override exists in `_theme-flydocs.scss`). + The one exception, `body { font-size: var(--hub-ref-font-size-sm); }`, + reuses whatever the *ng-hub-ui-ds default* already is — it is not a + repin, so there is no new value to port. +- `--ff-font-weight-medium`, `--ff-font-weight-semibold` — no font-weight + override exists anywhere in the source theme. +- `--ff-radius-md`, `--ff-radius-sm` — Flydocs' radius section is + exclusively *extensions* above 12px (`card`/`xl2`/`xxl2`); nothing below + that is touched. +- `--ff-radius-full` — a pill/circular radius is an unbranded geometric + convention (any value ≥ half the element's height renders identically); + Flydocs' source confirms this by never declaring one. +- `--ff-spacing-xs`, `--ff-spacing-sm`, `--ff-spacing-md`, `--ff-spacing-lg`, + `--ff-spacing-xl` — Flydocs' spacing section is entirely product + **extensions** for its own bespoke, 12px-rhythm components (new + px-suffixed steps: `2px`…`80px`). It never overrides the shared + Bootstrap-style spacing scale the design-system components actually + consume. This is a real finding for the parity gate: spacing is not + part of the visual-identity surface Flydocs customized at the + shared-component level. + +## Artifact + +`--ff-color-` (matched once by the discovery grep) is not an independent +token: it is the literal prefix Sass leaves before an interpolation break +in `ff-button.component.scss`'s color-axis loop +(`var(--ff-color-#{$color}-500)`, etc.). Every concrete value that +interpolation can produce (`primary-500`, `success-600`, `neutral-100`, …) +is already mapped above. diff --git a/docs/visual-regression.md b/docs/visual-regression.md new file mode 100644 index 0000000..f487923 --- /dev/null +++ b/docs/visual-regression.md @@ -0,0 +1,97 @@ +# Catalogue visual regression (Flydocs parity gate) + +`apps/playground-e2e/src/parity.spec.ts` screenshots the `/parity` route of +the playground (5 `data-testid="parity-wave-N"` sections, plus the +`ff-input` focus-ring specimens in wave 5) with the Flydocs product theme +applied, and compares them against committed baselines with Playwright's +`toHaveScreenshot`. This is the gate that catches unintended visual drift +in the catalogue components once the Flydocs skin is applied. + +## Scope: chromium only + +Screenshots run on the `chromium` project only. Firefox/webkit are removed +from `apps/playground-e2e/playwright.config.ts`. The gate is a pixel diff of +one rendering engine against itself over time, not a cross-browser +compatibility check; multi-browser baselines would triple the number of PNGs +to maintain for no additional signal. + +## How appearances are driven + +`BrandThemeService` / `ThemeService` read `localStorage['ff-brand-theme']` +and `localStorage['ff-dark-mode']` once, in their constructor, and stamp +`data-brand="flydocs"` / `data-theme="dark"` on `` on first render. +The spec sets both keys via `page.addInitScript` **before** navigation so +the app boots directly into the target appearance — no runtime toggle, no +flash of the wrong theme to settle before the screenshot. + +## Determinism + +Each test: + +- fixes the viewport to `1280x720`; +- calls `page.emulateMedia({ reducedMotion: 'reduce' })`; +- injects a global stylesheet that disables `animation`, `transition` and + caret blinking (`caret-color: transparent`) on every element; +- waits for the target locator to be visible before calling + `toHaveScreenshot`. + +`maxDiffPixelRatio: 0.001` is configured once, globally, under +`expect.toHaveScreenshot` in `playwright.config.ts` — no per-test tolerance +overrides. + +## Running locally + +``` +pnpm exec nx run playground-e2e:e2e +``` + +This starts (or reuses) the playground dev server and runs the full parity +matrix against the baselines committed under +`apps/playground-e2e/src/parity.spec.ts-snapshots/`. + +## Updating baselines + +After an intentional visual change to the catalogue or the Flydocs theme, +regenerate the affected baselines and review the diff before committing: + +``` +pnpm exec nx run playground-e2e:e2e -- --update-snapshots +``` + +Scope the update to a single test when possible (`--grep`) to avoid +regenerating unrelated baselines. Baseline filenames already encode the +platform (see below), so running this on macOS only refreshes the +`-darwin` set — the `-linux` set used by CI must be bootstrapped/updated +separately (see next section). + +## CI: linux baseline bootstrap + +Playwright screenshots are only stable when compared to a baseline +generated on the same OS. `apps/playground-e2e/playwright.config.ts` sets +`snapshotPathTemplate` to include `{platform}` explicitly, so local (darwin) +and CI (linux) baselines live side by side as separate files and never +overwrite each other. + +The installed `@playwright/test` (`1.59.1`) supports +`--update-snapshots=missing`, which writes a baseline only when none exists +yet and leaves existing ones untouched. A missing baseline still fails the +run (Playwright writes the file and reports the test as failed), so until +the linux set is committed the regular `e2e` job cannot pass — bootstrap it +right after the branch lands: + +1. In GitHub → Actions → CI → *Run workflow*, launch a manual run with the + **`seed_vr_baselines`** input checked. The `e2e` job then runs the suite + with `--update-snapshots=missing` in a non-gating step and always uploads + `apps/playground-e2e/src/parity.spec.ts-snapshots/` as the + `playground-e2e-vr-baselines` artifact. +2. Download the artifact, inspect the new `-linux.png` files at 100% zoom, + and commit them alongside the `-darwin.png` set already produced locally. +3. Nothing to revert: regular pushes/PRs never take the seeding branch of + the job. + +The same seeding run also refreshes the artifact after an intentional +visual change when regenerating locally is not possible; existing linux +baselines are never overwritten by it (`missing` mode), so a deliberate +refresh of *changed* baselines still needs the artifact from a red regular +run (the `playground-e2e-test-results` artifact contains the actual/diff +images).