Compare commits

..

8 Commits

Author SHA1 Message Date
ilia dec83c93d0 Merge pull request 'Feature/playwright' (#43) from feature/playwright into main
Workflow / build (push) Successful in 1m28s
Workflow / e2e (push) Successful in 1m13s
Workflow / publish (push) Successful in 15s
Reviewed-on: #43
2026-05-28 13:31:16 +00:00
Ilia Mashkov b9560336d5 ci/cd: remove artifact, add build to e2e step
Workflow / build (pull_request) Successful in 1m32s
Workflow / e2e (pull_request) Successful in 2m10s
Workflow / publish (pull_request) Has been skipped
2026-05-28 16:18:25 +03:00
Ilia Mashkov 18f1d109ab fix: add single root level
Workflow / build (pull_request) Failing after 1m33s
Workflow / e2e (pull_request) Has been skipped
Workflow / publish (pull_request) Has been skipped
2026-05-28 16:11:07 +03:00
Ilia Mashkov 24f084ae77 test: add e2e suite for core comparison flows
Workflow / build (pull_request) Failing after 3m47s
Workflow / e2e (pull_request) Has been skipped
Workflow / publish (pull_request) Has been skipped
Adds 17 new specs across six files plus the supporting POM
infrastructure:

- fixtures with auto-opened comparison + typography helpers
- TypographyMenu POM reading values from ComboControl aria-labels
- ComparisonPage extended with side selection, font picking,
  slider-value reader, FontFaceSet inspection, storage snapshot
- compare-flow: A/B selection, aria-pressed state, storage write
- preview-text: input binding + slider character rendering
- slider: keyboard ARIA contract (Arrow / Shift+Arrow / Page / Home / End)
- font-loading: FontFaceSet contains selected, excludes unrelated
- persistence: font selection + typography settings survive reload
- typography: symmetric increase/decrease for all four controls
2026-05-28 15:52:40 +03:00
Ilia Mashkov b9e21a66d3 fix(comparisonStore): preserve stored selection on cold load
The seed-defaults effect fired whenever fontA/fontB were still
undefined, including the window between constructor reading storage
and the per-id batch resolving. On a slow batch or fast catalog the
effect clobbered storage with catalog[0]/catalog[N-1], losing the
user's pick on reload.

Now bails when storage already holds IDs, and reads storage via
untrack so per-font selection writes don't re-trigger the effect.

Adds a deterministic regression test that controls catalog/batch
ordering via mockImplementation timing.
2026-05-28 14:58:18 +03:00
Ilia Mashkov 7a9422b574 test: add aria attributes to tested components 2026-05-28 14:05:14 +03:00
Ilia Mashkov f79b24272c ci/cd: add e2e tests with playwright into gitea actions workflow 2026-05-28 13:20:25 +03:00
Ilia Mashkov a9229342e6 test: base playwrignt setup for firefox and chrome 2026-05-28 12:55:10 +03:00
19 changed files with 663 additions and 19 deletions
+27 -1
View File
@@ -50,8 +50,34 @@ jobs:
timeout-minutes: 5
run: yarn test:component --reporter=verbose --logHeapUsage
e2e:
needs: build
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.59.0-jammy
steps:
- uses: actions/checkout@v4
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@stable --activate
- name: Persistent Yarn Cache
uses: actions/cache@v4
with:
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install --immutable
- name: Build Svelte SPA
run: yarn build
- name: E2E Tests
timeout-minutes: 15
run: yarn test:e2e
publish:
needs: build # Only runs if tests/lint pass
# Runs if lint, unit-, component-, e2e-tests pass
needs: [build, e2e]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' # Only deploy from main branch
steps:
+3
View File
@@ -50,3 +50,6 @@ storybook-static
# Tests
coverage/
.aider*
playwright-report/
blob-report/
.playwright/
+39
View File
@@ -0,0 +1,39 @@
import {
expect,
test,
} from './fixtures';
test.describe('compare flow', () => {
test('selects fontA and fontB onto opposite sides', async ({ comparison }) => {
await comparison.pickPair('Inter', 'Roboto');
// Each side's header region exposes the font name independently.
await expect(comparison.primaryFont).toContainText('Inter');
await expect(comparison.secondaryFont).toContainText('Roboto');
// Slider is rendered and interactive once both fonts are picked.
await expect(comparison.slider).toBeVisible();
});
test('reflects active side via aria-pressed', async ({ comparison }) => {
await comparison.selectSide('B');
expect(await comparison.activeSide()).toBe('B');
await expect(comparison.secondarySideButton).toHaveAttribute('aria-pressed', 'true');
await expect(comparison.primarySideButton).toHaveAttribute('aria-pressed', 'false');
});
test('persists selection through the comparisonStore localStorage', async ({ comparison }) => {
await comparison.pickPair('Inter', 'Roboto');
// Wait for the store debounce to flush to localStorage.
await expect.poll(async () => {
const storage = await comparison.readStorage();
return storage['glyphdiff:comparison'];
}).toMatch(/inter/i);
const storage = await comparison.readStorage();
const state = JSON.parse(storage['glyphdiff:comparison']!);
expect(state.fontAId).toBe('inter');
expect(state.fontBId).toBe('roboto');
});
});
+35
View File
@@ -0,0 +1,35 @@
import { test as base } from '@playwright/test';
import { ComparisonPage } from './pages/comparison-page';
import { TypographyMenu } from './pages/typography-menu';
type Fixtures = {
/**
* Opened ComparisonPage with the root view loaded.
*/
comparison: ComparisonPage;
/**
* Typography menu helper bound to the same page.
*/
typography: TypographyMenu;
};
/**
* Custom test that auto-opens the comparison view before each spec.
* Playwright gives each test a fresh BrowserContext by default, so
* localStorage is empty unless a test seeds it.
*/
export const test = base.extend<Fixtures>({
comparison: async ({ page }, use) => {
const view = new ComparisonPage(page);
await view.open();
await use(view);
},
// Depends on `comparison` so the root page is opened before the menu is
// consulted — TypographyMenu has no markup of its own to load.
typography: async ({ comparison, page }, use) => {
void comparison;
await use(new TypographyMenu(page));
},
});
export { expect } from '@playwright/test';
+22
View File
@@ -0,0 +1,22 @@
import {
expect,
test,
} from './fixtures';
test.describe('font loading', () => {
test('selected fonts land in the FontFaceSet with status="loaded"', async ({ comparison }) => {
await comparison.pickPair('Inter', 'Roboto');
await expect.poll(() => comparison.fontLoaded('Inter')).toBe(true);
await expect.poll(() => comparison.fontLoaded('Roboto')).toBe(true);
});
test('an unrelated font remains absent from the FontFaceSet', async ({ comparison }) => {
await comparison.pickPair('Inter', 'Roboto');
// "Audiowide" is unlikely to be on the system AND was not selected, so
// no FontFace should ever have been registered for it. This guards
// against the loader over-fetching neighbouring fonts.
await expect.poll(() => comparison.fontLoaded('Audiowide')).toBe(false);
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { Page } from '@playwright/test';
/**
* Shared base for all page objects. Subclasses extend this and expose
* domain-specific locators + actions — never raw selectors leaking into tests.
*/
export abstract class BasePage {
protected constructor(protected readonly page: Page) {}
/**
* Navigate to a path relative to baseURL.
*/
async goto(path = '/') {
await this.page.goto(path);
}
}
+144
View File
@@ -0,0 +1,144 @@
import type {
Locator,
Page,
} from '@playwright/test';
import { BasePage } from './base-page';
/**
* Page object for the root comparison view. Encapsulates locators for the
* primary controls so tests don't hardcode aria-labels or DOM structure.
*
* Selection flow: clicking a font row assigns it to whichever side
* (`A` = "Left Font" / Primary, `B` = "Right Font" / Secondary) is currently
* active in the Sidebar — there's no per-row A/B toggle.
*/
export class ComparisonPage extends BasePage {
readonly searchInput: Locator;
readonly previewInput: Locator;
readonly slider: Locator;
readonly primarySideButton: Locator;
readonly secondarySideButton: Locator;
readonly primaryFont: Locator;
readonly secondaryFont: Locator;
readonly fontList: Locator;
constructor(page: Page) {
super(page);
this.searchInput = page.getByRole('textbox', { name: 'Search typefaces' });
this.previewInput = page.getByRole('textbox', { name: 'Preview text' });
this.slider = page.getByRole('slider', { name: 'Font comparison slider' });
// ARIA-controls couples the side toggle to the font display it targets — copy-independent.
this.primarySideButton = page.locator('[aria-controls="primary-font"]');
this.secondarySideButton = page.locator('[aria-controls="secondary-font"]');
this.primaryFont = page.locator('#primary-font');
this.secondaryFont = page.locator('#secondary-font');
this.fontList = page.locator('[data-font-list]');
}
/**
* Open the root page and wait for the main controls to be interactable.
* Uses lg+ viewport for the preview input to be visible.
*/
async open() {
await this.goto('/');
await this.searchInput.waitFor({ state: 'visible' });
}
async searchFor(query: string) {
await this.searchInput.fill(query);
}
async setPreviewText(text: string) {
await this.previewInput.fill(text);
}
/**
* Switch which side the next font click will assign to.
*/
async selectSide(side: 'A' | 'B') {
const button = side === 'A' ? this.primarySideButton : this.secondarySideButton;
await button.click();
}
/**
* Read which side is currently active from `aria-pressed`.
* Falls back to A when neither button reports pressed (initial state in some flows).
*/
async activeSide(): Promise<'A' | 'B' | null> {
const [primaryPressed, secondaryPressed] = await Promise.all([
this.primarySideButton.getAttribute('aria-pressed'),
this.secondarySideButton.getAttribute('aria-pressed'),
]);
if (primaryPressed === 'true') {
return 'A';
}
if (secondaryPressed === 'true') {
return 'B';
}
return null;
}
/**
* Search for a font and click the matching list row. The row's accessible
* name is the font name itself (rendered by FontApplicator).
*/
async pickFont(name: string) {
await this.searchFor(name);
const row = this.fontList.getByRole('button', { name, exact: true });
await row.click();
}
/**
* Assign fontA to side A and fontB to side B in one call.
*/
async pickPair(fontA: string, fontB: string) {
await this.selectSide('A');
await this.pickFont(fontA);
await this.selectSide('B');
await this.pickFont(fontB);
}
/**
* Read aria-valuenow off the comparison slider.
*/
async sliderValue(): Promise<number> {
const value = await this.slider.getAttribute('aria-valuenow');
return Number(value);
}
/**
* Snapshot the glyphdiff:* localStorage entries.
*/
async readStorage(): Promise<Record<string, string | null>> {
return await this.page.evaluate(() => {
const out: Record<string, string | null> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)!;
if (key.startsWith('glyphdiff:')) {
out[key] = localStorage.getItem(key);
}
}
return out;
});
}
/**
* Whether the document.fonts FontFaceSet contains a fully-loaded face for
* the named family. Counts only faces registered via the FontFace API —
* system-installed fallbacks (which `document.fonts.check` honours) are
* excluded, so a `false` here is meaningful in negative assertions.
*/
async fontLoaded(name: string): Promise<boolean> {
return await this.page.evaluate(target => {
for (const face of document.fonts) {
// FontFace.family is wrapped in quotes only if the literal was;
// strip any surrounding quotes before comparing.
const family = face.family.replace(/^["']|["']$/g, '');
if (family === target && face.status === 'loaded') {
return true;
}
}
return false;
}, name);
}
}
+73
View File
@@ -0,0 +1,73 @@
import type {
Locator,
Page,
} from '@playwright/test';
/**
* Typography settings menu — desktop layout exposes inline ComboControls with
* increase/decrease buttons. The current value is encoded in the trigger
* button's aria-label as `${controlLabel}: ${value}` (e.g. "Size: 24").
*/
export type TypographyControl = 'size' | 'weight' | 'leading' | 'tracking';
const LABELS: Record<TypographyControl, { increase: string; decrease: string; trigger: string }> = {
size: {
increase: 'Increase Font Size',
decrease: 'Decrease Font Size',
trigger: 'Size',
},
weight: {
increase: 'Increase Font Weight',
decrease: 'Decrease Font Weight',
trigger: 'Weight',
},
leading: {
increase: 'Increase Line Height',
decrease: 'Decrease Line Height',
trigger: 'Leading',
},
tracking: {
increase: 'Increase Letter Spacing',
decrease: 'Decrease Letter Spacing',
trigger: 'Tracking',
},
};
export class TypographyMenu {
constructor(private readonly page: Page) {}
increase(control: TypographyControl): Locator {
return this.page.getByRole('button', { name: LABELS[control].increase });
}
decrease(control: TypographyControl): Locator {
return this.page.getByRole('button', { name: LABELS[control].decrease });
}
/**
* Trigger button whose aria-label encodes the current value, e.g. "Size: 24".
*/
trigger(control: TypographyControl): Locator {
return this.page.getByRole('button', { name: new RegExp(`^${LABELS[control].trigger}:\\s`) });
}
/**
* Parse the numeric value out of the trigger button's aria-label.
* Returns null if the label can't be read yet.
*/
async readValue(control: TypographyControl): Promise<number | null> {
const label = await this.trigger(control).getAttribute('aria-label');
if (!label) {
return null;
}
const match = label.match(/:\s*(-?\d+(?:\.\d+)?)/);
return match ? Number(match[1]) : null;
}
async bump(control: TypographyControl, direction: 'up' | 'down', times = 1) {
const button = direction === 'up' ? this.increase(control) : this.decrease(control);
for (let i = 0; i < times; i++) {
await button.click();
}
}
}
+41
View File
@@ -0,0 +1,41 @@
import {
expect,
test,
} from './fixtures';
test.describe('persistence', () => {
test('restores selected fonts after reload', async ({ comparison, page }) => {
await comparison.pickPair('Inter', 'Roboto');
// Confirm the store has flushed before reloading — otherwise we race
// the debounce and may reload with empty storage.
await expect.poll(async () => {
const storage = await comparison.readStorage();
return storage['glyphdiff:comparison'];
}).toMatch(/roboto/i);
await page.reload();
await comparison.searchInput.waitFor({ state: 'visible' });
await expect(comparison.primaryFont).toContainText('Inter');
await expect(comparison.secondaryFont).toContainText('Roboto');
});
test('restores typography settings after reload', async ({ comparison, typography, page }) => {
const baseline = await typography.readValue('size');
await typography.bump('size', 'up', 2);
const bumped = await typography.readValue('size');
expect(bumped).not.toBe(baseline);
await expect.poll(async () => {
const storage = await comparison.readStorage();
return storage['glyphdiff:comparison:typography'];
}).not.toBeNull();
await page.reload();
await comparison.searchInput.waitFor({ state: 'visible' });
expect(await typography.readValue('size')).toBe(bumped);
});
});
+21
View File
@@ -0,0 +1,21 @@
import {
expect,
test,
} from './fixtures';
test.describe('preview text', () => {
test('drives the slider character rendering', async ({ comparison }) => {
await comparison.pickPair('Inter', 'Roboto');
await comparison.setPreviewText('Sphinx');
// Each grapheme renders as a `.char-wrap` cell in the slider once
// both fonts are loaded. Six glyphs → six cells.
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(6);
});
test('preserves the typed value in the input', async ({ comparison }) => {
const text = 'Sphinx of black quartz';
await comparison.setPreviewText(text);
await expect(comparison.previewInput).toHaveValue(text);
});
});
+46
View File
@@ -0,0 +1,46 @@
import {
expect,
test,
} from './fixtures';
/**
* Slider position is spring-animated; aria-valuenow reflects the current
* value, not the target. All assertions use `toHaveAttribute` so Playwright
* polls until the spring settles.
*/
test.describe('comparison slider', () => {
test.beforeEach(async ({ comparison }) => {
await comparison.pickPair('Inter', 'Roboto');
await comparison.slider.focus();
});
test('keyboard navigation snaps to End and Home', async ({ comparison }) => {
await comparison.slider.press('End');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '100');
await comparison.slider.press('Home');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0');
});
test('arrow keys nudge by one, Shift+Arrow by ten', async ({ comparison }) => {
await comparison.slider.press('Home');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0');
await comparison.slider.press('ArrowRight');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '1');
await comparison.slider.press('Shift+ArrowRight');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '11');
});
test('PageUp / PageDown move by ten', async ({ comparison }) => {
await comparison.slider.press('Home');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0');
await comparison.slider.press('PageUp');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '10');
await comparison.slider.press('PageDown');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0');
});
});
+23
View File
@@ -0,0 +1,23 @@
import {
expect,
test,
} from '@playwright/test';
import { ComparisonPage } from './pages/comparison-page';
test.describe('smoke', () => {
test('loads the comparison view with its primary controls', async ({ page }) => {
const view = new ComparisonPage(page);
await view.open();
await expect(view.searchInput).toBeVisible();
await expect(view.previewInput).toBeVisible();
});
test('accepts a search query', async ({ page }) => {
const view = new ComparisonPage(page);
await view.open();
await view.searchFor('Inter');
await expect(view.searchInput).toHaveValue('Inter');
});
});
+44
View File
@@ -0,0 +1,44 @@
import {
expect,
test,
} from './fixtures';
import type { TypographyControl } from './pages/typography-menu';
/**
* Each control's trigger button advertises its current value via aria-label
* ("Size: 24"). We bump in one direction, then back, and assert the value
* tracks symmetrically.
*/
const controls: TypographyControl[] = ['size', 'weight', 'leading', 'tracking'];
test.describe('typography settings', () => {
for (const control of controls) {
test(`${control}: increase then decrease returns to baseline`, async ({ typography }) => {
const baseline = await typography.readValue(control);
expect(baseline).not.toBeNull();
await typography.bump(control, 'up');
const bumped = await typography.readValue(control);
expect(bumped).not.toBe(baseline);
expect(bumped! > baseline!).toBe(true);
await typography.bump(control, 'down');
const restored = await typography.readValue(control);
expect(restored).toBe(baseline);
});
}
test('font size step is reflected in the persisted typography state', async ({ comparison, typography }) => {
await typography.bump('size', 'up');
const expected = await typography.readValue('size');
await expect.poll(async () => {
const storage = await comparison.readStorage();
const raw = storage['glyphdiff:comparison:typography'];
if (!raw) {
return null;
}
return JSON.parse(raw).fontSize ?? null;
}).toBe(expected);
});
});
+47 -3
View File
@@ -1,10 +1,54 @@
import { defineConfig } from '@playwright/test';
import {
defineConfig,
devices,
} from '@playwright/test';
/**
* E2E config. Tests run against the production build via `vite preview` on port 4173.
* Locally: all three browser engines run in parallel.
* CI: chromium only, workers=1 — the runner has 6GB RAM and `yarn build` already
* spikes 12GB, so we keep the E2E peak bounded.
*/
const isCI = !!process.env.CI;
export default defineConfig({
testDir: 'e2e',
testMatch: /.*\.test\.ts$/,
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers: isCI ? 1 : undefined,
reporter: isCI
? [['html', { open: 'never' }], ['github']]
: [['html', { open: 'on-failure' }], ['list']],
use: {
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: isCI
? [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }]
: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
webServer: {
command: 'yarn build && yarn preview',
port: 4173,
reuseExistingServer: true,
reuseExistingServer: !isCI,
timeout: 120_000,
},
testDir: 'e2e',
});
@@ -102,7 +102,7 @@ export class ComparisonStore {
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
$effect.root(() => {
// Effect 1: Sync batch results → fontA / fontB
// Sync batch results → fontA / fontB
$effect(() => {
const fonts = this.#fontsByIdsStore.fonts;
if (fonts.length === 0) {
@@ -124,7 +124,7 @@ export class ComparisonStore {
}
});
// Effect 2: Trigger font loading whenever selection or weight changes
// Trigger font loading whenever selection or weight changes
$effect(() => {
const fa = this.#fontA;
const fb = this.#fontB;
@@ -154,24 +154,38 @@ export class ComparisonStore {
}
});
// Effect 3: Set default fonts when storage is empty
// Set default fonts when storage is empty
$effect(() => {
if (this.#fontA && this.#fontB) {
return;
}
const fonts = fontCatalogStore.fonts;
if (fonts.length >= 2) {
untrack(() => {
const id1 = fonts[0].id;
const id2 = fonts[fonts.length - 1].id;
storage.value = { fontAId: id1, fontBId: id2 };
this.#fontsByIdsStore.setIds([id1, id2]);
});
// Don't clobber a pending rehydration - only seed when storage is empty.
// Untracked: only the catalog load should drive this effect, not the
// user's storage writes that happen as a result of normal selection.
const hasStoredSelection = untrack(() => {
return storage.value.fontAId !== null || storage.value.fontBId !== null;
});
if (hasStoredSelection) {
return;
}
const fonts = fontCatalogStore.fonts;
if (fonts.length < 2) {
return;
}
untrack(() => {
const id1 = fonts[0].id;
const id2 = fonts[fonts.length - 1].id;
storage.value = { fontAId: id1, fontBId: id2 };
this.#fontsByIdsStore.setIds([id1, id2]);
});
});
// Effect 4: Pin fontA/fontB so eviction never removes on-screen fonts
// Pin fontA/fontB so eviction never removes on-screen fonts
$effect(() => {
const fa = this.#fontA;
const fb = this.#fontB;
@@ -165,6 +165,46 @@ describe('ComparisonStore', () => {
expect(mockStorage._value.fontBId).toBe(mockFontB.id);
});
});
/**
* Regression: when storage already holds the user's selection, the
* seed-defaults effect must bail out — even if it fires before the
* per-id batch returns (catalog wins the race on slow networks or
* cold reloads). Pre-fix the effect only checked fontA/fontB, both
* still undefined at this point, and clobbered storage with whatever
* the catalog had as fonts[0] / fonts[N-1].
*/
it('should not overwrite stored IDs when batch is still in flight', async () => {
const seededA = UNIFIED_FONTS.lato;
const seededB = UNIFIED_FONTS.montserrat;
mockStorage._value.fontAId = seededA.id;
mockStorage._value.fontBId = seededB.id;
// Catalog defaults differ from the stored selection — if the
// effect mis-seeds, storage will flip to roboto / open-sans.
(fontCatalogStore as any).fonts = [mockFontA, mockFontB];
// Delay the batch so the catalog-driven effect runs first.
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockImplementation(
() => new Promise(r => setTimeout(() => r([seededA, seededB]), 50)),
);
const store = new ComparisonStore();
// Let the catalog effect run; storage must be untouched.
await new Promise(r => setTimeout(r, 10));
expect(mockStorage._value.fontAId).toBe(seededA.id);
expect(mockStorage._value.fontBId).toBe(seededB.id);
// Batch resolves with the seeded selection — fontA/B must match.
await vi.waitFor(() => {
expect(store.fontA?.id).toBe(seededA.id);
expect(store.fontB?.id).toBe(seededB.id);
}, { timeout: 2000 });
expect(mockStorage._value.fontAId).toBe(seededA.id);
expect(mockStorage._value.fontBId).toBe(seededB.id);
});
});
describe('Aggregate Loading State', () => {
@@ -93,7 +93,11 @@ const fontBName = $derived(comparisonStore.fontB?.name ?? '');
<!-- Font names + slider % + theme toggle -->
<div class="flex items-center gap-3 md:gap-8 shrink-0 select-none">
<div class="hidden lg:flex items-center gap-6">
<div class="flex flex-col items-end leading-tight gap-0.5">
<div
id="primary-font"
aria-label="Primary font"
class="flex flex-col items-end leading-tight gap-0.5"
>
<TechText class="uppercase" variant="default" size="sm">
{fontAName}
</TechText>
@@ -106,7 +110,11 @@ const fontBName = $derived(comparisonStore.fontB?.name ?? '');
class="h-8 rotate-12"
/>
<div class="flex flex-col items-start leading-tight gap-0.5">
<div
id="secondary-font"
aria-label="Secondary font"
class="flex flex-col items-start leading-tight gap-0.5"
>
<TechText class="uppercase" variant="default" size="sm">
{fontBName}
</TechText>
@@ -72,6 +72,8 @@ let {
<ToggleButton
size="sm"
active={comparisonStore.side === 'A'}
aria-controls="primary-font"
aria-pressed={comparisonStore.side === 'A'}
onclick={() => comparisonStore.side = 'A'}
class="flex-1 tracking-wide font-bold uppercase"
>
@@ -82,6 +84,8 @@ let {
size="sm"
class="flex-1 tracking-wide font-bold uppercase"
active={comparisonStore.side === 'B'}
aria-controls="secondary-font"
aria-pressed={comparisonStore.side === 'B'}
onclick={() => comparisonStore.side = 'B'}
>
<span>Right Font</span>
+2 -1
View File
@@ -40,7 +40,8 @@
"src/**/*.d.ts",
"vitest.config*.ts",
"vitest.setup*.ts",
"vitest.types.d.ts"
"vitest.types.d.ts",
"playwright.config*.ts"
],
"exclude": [
"node_modules",