Compare commits

...

114 Commits

Author SHA1 Message Date
Ilia Mashkov cbd95350bb fix(popover): stop animating left/top so first open doesn't slide from corner
Workflow / build (pull_request) Successful in 1m18s
Workflow / e2e (pull_request) Successful in 1m15s
Workflow / publish (pull_request) Has been skipped
2026-06-02 21:38:48 +03:00
Ilia Mashkov a8a985ee6a chore: remove bits-ui dependency 2026-06-02 17:08:58 +03:00
Ilia Mashkov be073286dc refactor(typography-menu): use native Popover instead of bits-ui 2026-06-02 16:28:05 +03:00
Ilia Mashkov 7798c4bbdf refactor(combo-control): use native Popover instead of bits-ui
The native Popover always renders its content (the vertical slider), so the
slider's value label is in the DOM even when closed, and opening is driven by
the browser's declarative popovertarget invoker (not simulated by jsdom on
click). Update the tests to scope value assertions to the trigger and drive
open via showPopover(), matching Popover.svelte.test.ts.
2026-06-02 16:21:32 +03:00
Ilia Mashkov 3ae22ad515 docs(popover): add storybook stories 2026-06-02 16:16:28 +03:00
Ilia Mashkov ffa897ee54 test(popover): cover open/close state and aria wiring 2026-06-02 16:13:40 +03:00
Ilia Mashkov 93c52dd132 fix(popover): gate visibility until positioned, tighten types 2026-06-02 16:12:11 +03:00
Ilia Mashkov 9e0c8f740b feat(popover): native Popover API component with anchored positioning 2026-06-02 16:06:20 +03:00
Ilia Mashkov b1b5177e02 test: add jsdom Popover API shim 2026-06-02 16:03:54 +03:00
Ilia Mashkov ef9cd33e48 feat(popover): add pure anchored-positioning math 2026-06-02 15:59:58 +03:00
ilia f3c76df2c5 Merge pull request 'Feature/slider' (#47) from feature/slider into main
Workflow / build (push) Successful in 1m24s
Workflow / e2e (push) Successful in 1m11s
Workflow / publish (push) Successful in 15s
Reviewed-on: #47
2026-06-02 12:10:42 +00:00
Ilia Mashkov ae2d0e3c2f fix(slider): focus thumb on pointerdown for keyboard parity
Workflow / build (pull_request) Successful in 1m33s
Workflow / e2e (pull_request) Successful in 1m21s
Workflow / publish (pull_request) Has been skipped
2026-06-02 11:14:10 +03:00
Ilia Mashkov 3f5151efa0 docs(slider): update story copy for native implementation 2026-06-02 11:08:58 +03:00
Ilia Mashkov 19d9b07c55 test(slider): centralize jsdom pointer shims and add vertical drag test 2026-06-02 11:08:07 +03:00
Ilia Mashkov 1209358d40 test(slider): cover keyboard and pointer interaction 2026-06-02 11:02:52 +03:00
Ilia Mashkov d7decd7a00 fix(slider): normalize value, reactive trackEl, aria-valuetext 2026-06-02 11:01:08 +03:00
Ilia Mashkov 9d6220d2ec feat(slider): reimplement natively without bits-ui 2026-06-02 10:54:54 +03:00
Ilia Mashkov 4756682863 feat(slider): add pure value/position math helpers 2026-06-02 10:50:46 +03:00
ilia 67af3d946a Merge pull request 'chore: introduce font files caching and compressing' (#46) from chore/font-files-caching into main
Workflow / build (push) Successful in 1m19s
Workflow / e2e (push) Successful in 1m8s
Workflow / publish (push) Successful in 14s
Reviewed-on: #46
2026-06-01 07:42:51 +00:00
Ilia Mashkov c6d0270072 chore: introduce font files caching and compressing
Workflow / build (pull_request) Successful in 1m27s
Workflow / e2e (pull_request) Successful in 1m19s
Workflow / publish (pull_request) Has been skipped
2026-06-01 10:37:50 +03:00
ilia f7cd6b5081 Merge pull request 'Feature/local fonts' (#45) from feature/local-fonts into main
Workflow / build (push) Successful in 1m20s
Workflow / e2e (push) Successful in 1m7s
Workflow / publish (push) Successful in 28s
Reviewed-on: #45
2026-06-01 07:20:21 +00:00
Ilia Mashkov dda8ef6368 docs(styles): strip decorative comment banners from app.css
Workflow / build (pull_request) Successful in 1m48s
Workflow / e2e (pull_request) Successful in 1m17s
Workflow / publish (pull_request) Has been skipped
Replace ASCII-art separators (====, box-drawing rules, ---- dashes) with
plain section labels and rewrite the casual one-liners as terse, factual
comments.
2026-06-01 10:11:42 +03:00
Ilia Mashkov d77b51736a fix(styles): default body font to Inter, drop unloaded Karla
The body font-family referenced "Karla", which was never loaded, so
body text silently fell back to system-ui. Point it at the existing
--font-secondary token (Inter + system fallbacks).
2026-06-01 10:06:18 +03:00
Ilia Mashkov 1e16330097 refactor(fonts): drop Google Fonts CDN links, preload self-hosted faces
Remove the googleapis stylesheet, both google preconnects, and the two
dead fontshare preconnects from the document head. Preload the two
render-critical faces (Inter, Space Grotesk) via Vite ?url imports.
Eliminates two third-party origins and the IP leak to Google.
2026-06-01 10:06:12 +03:00
Ilia Mashkov c41016ac5d feat(fonts): self-host interface fonts as vendored latin woff2
Replace Google Fonts CDN delivery of the four UI typefaces (Inter,
Space Grotesk, Space Mono, Syne) with latin-subset woff2 vendored into
app/assets/fonts and wired via a hand-authored @font-face stylesheet.
Variable faces keep wght (Inter also opsz). Vite content-hashes the
binaries for immutable caching.
2026-06-01 10:06:07 +03:00
Ilia Mashkov aa4189f6a8 chore(app): declare *.woff2?url module type for asset imports 2026-06-01 10:05:33 +03:00
ilia eb5a8d1e5b Merge pull request 'Fixes/request deduplication' (#44) from fixes/request-deduplication into main
Workflow / build (push) Successful in 1m28s
Workflow / e2e (push) Successful in 1m14s
Workflow / publish (push) Successful in 15s
Reviewed-on: #44
2026-05-28 19:54:57 +00:00
Ilia Mashkov e698dc6e07 docs: remove GIT_WORKFLOW.md
Workflow / build (pull_request) Successful in 1m35s
Workflow / e2e (pull_request) Successful in 1m22s
Workflow / publish (pull_request) Has been skipped
2026-05-28 22:29:02 +03:00
Ilia Mashkov 5d72bb7a4c refactor(fontCatalogStore): single source of truth for query params
On initial load, two separate $effects in bindings.svelte.ts — one for
filters, one for sort — each issued its own setOptions with a different
queryKey on the first flush, producing an orphaned
`/fonts?limit=50&offset=0` request immediately followed by the real
`/fonts?limit=50&sort=popularity&offset=0`. Hardcoding the default sort
on the singleton would have papered over the symptom while leaving the
sortStore default and the catalog-store default coupled by hand.

Make bindings the sole emitter of query params:

- features/.../bindings: merge filter + sort effects into one. The effect
  reads both stores, builds the merged param object, and issues a single
  setParams. No more interleaved setOptions on mount.
- entities/.../fontCatalogStore: gate the observer with `enabled: false`
  on construction. The first setParams flips `#enabled` on and triggers
  exactly one fetch with the correct queryKey. Removes the need for a
  hardcoded default sort on the singleton.
- isEmpty is also gated on `#enabled` so the brief pre-config window
  doesn't render "no results" before bindings configures the query.
- The constructor seeds #result from observer.getCurrentResult() because
  subscribe may not fire synchronously when the observer is disabled.
2026-05-28 21:38:17 +03:00
Ilia Mashkov 7f20f36d0a fix(api): mark schema-validation errors as non-retryable
The proxy returned `{fonts: null, total: 0}` for empty results, which
fetchProxyFonts surfaced as a generic Error. fontCatalogStore wrapped it
as FontNetworkError, and TanStack retried 3× with exponential backoff —
pinning the loading skeleton for ~7s before settling on an empty list.

Schema mismatches are deterministic; retrying only delays surfacing the
contract violation.

- shared/api/queryClient: introduce NonRetryableError marker class.
  The default retry handler short-circuits when it sees this so any
  store using the shared client gets fail-fast behavior for free.
- entities/Font/lib/errors: FontResponseError extends NonRetryableError.
- entities/Font/api/proxy/proxyFonts: throw FontResponseError (was a
  bare Error). Document that ProxyFontsResponse.fonts is always an array.
- entities/Font/.../fontCatalogStore.fetchPage: preserve a
  FontResponseError raised lower in the stack instead of re-wrapping
  it as FontNetworkError.
- features/FilterAndSortFonts/api/filters: throw NonRetryableError on
  invalid filters payloads and document the array-never-null contract.
2026-05-28 21:37:23 +03:00
Ilia Mashkov c90a258f6c feat(font-list): show empty state when search yields no fonts
Adds an `empty` snippet prop to FontVirtualList and supplies it from the
sidebar FontList. Settled queries with zero results now render a centered
"No typefaces found" label instead of a blank list area.
2026-05-28 21:36:23 +03:00
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
Ilia Mashkov 05cab5f892 fix(cicd): fix yarn version, surface the real failure, bust cache
Workflow / build (push) Successful in 1m45s
Workflow / publish (push) Successful in 35s
2026-05-25 14:11:23 +03:00
Ilia Mashkov 0518c84230 fix: add .dockerignore to prevent glibc yarn cache leaking into musl build
Workflow / build (push) Successful in 1m45s
Workflow / publish (push) Failing after 9s
2026-05-25 13:57:35 +03:00
ilia 5afb9c5d5d Merge pull request 'Chore/architecture refactoring' (#42) from chore/architecture-refactoring into main
Workflow / build (push) Successful in 12s
Workflow / publish (push) Failing after 14s
Reviewed-on: #42
2026-05-25 08:43:06 +00:00
Ilia Mashkov 4126275c4d refactor(SliderArea): extract grid overlay into bg-grid utilities
Workflow / build (pull_request) Successful in 37s
Workflow / publish (pull_request) Has been skipped
The decorative dotted-grid background on the paper surface was a
6-line $derived gridStyle string applied via inline style="" plus four
extra utility classes for color and opacity. Replace with two named
utilities and let CSS handle the responsive switch.

app.css:
- New --color-grid-line CSS var (light + dark) so the grid colour and
  intensity auto-switch without consumers needing a dark: variant or an
  opacity layer.
- @utility bg-grid (20px cells) and @utility bg-grid-sm (10px cells).
  Both reference --color-grid-line, so the same markup paints correctly
  in light and dark mode.

SliderArea.svelte:
- Drop the gridStyle $derived block and the inline style= attribute.
- Overlay becomes a single line:
  <div class="absolute inset-0 pointer-events-none bg-grid-sm md:bg-grid"
       aria-hidden="true" />
  Mobile picks the tight 10px grid; the md: breakpoint flips to 20px,
  matching the prior JS-driven behaviour with no extra runtime cost.
2026-05-25 11:09:26 +03:00
Ilia Mashkov ffc28f78f5 refactor(SliderArea): bump the padding to avoid overlap with TypographyMenu 2026-05-25 11:08:04 +03:00
Ilia Mashkov 80241aa352 refactor(SliderArea): remove $derived className 2026-05-25 11:07:20 +03:00
Ilia Mashkov 37886f3aa7 refactor(TypographyMenu): use separators in one style 2026-05-25 11:01:11 +03:00
Ilia Mashkov 410a7cd37e feat(SliderArea): keyboard accessibility for the comparison slider
The slider element had role="slider" and tabindex="0" but no keyboard
handler — the focus ring appeared but the slider could not be moved.

Add a keydown handler implementing the standard ARIA slider contract:
- ArrowLeft / ArrowDown — step left by 1 percent
- ArrowRight / ArrowUp — step right by 1 percent
- Shift + arrow — coarse step (10 percent)
- PageUp / PageDown — coarse step (10 percent)
- Home — jump to 0
- End — jump to 100

Bounds and step sizes extracted as named constants (SLIDER_MIN,
SLIDER_MAX, SLIDER_STEP_FINE, SLIDER_STEP_COARSE). Position updates go
through sliderSpring.target so keyboard moves animate the same way as
pointer drags.

Also adds the missing ARIA attributes that screen readers need:
- aria-valuemin / aria-valuemax (bounds)
- aria-orientation (horizontal)
2026-05-25 10:57:54 +03:00
Ilia Mashkov b5fec3a1ba fix(SliderArea): inset paper with padding instead of scale for even gaps
scale-[0.94] shrinks proportionally — on wide viewports this produced
visibly larger horizontal gaps than vertical ones when the sidebar
opens, and it left the text engine measuring the un-scaled width
(causing the thumb-to-character morph boundary to drift).

Switch to outer-container padding (p-6 when sidebar is open on desktop)
so the paper inherits an equal pixel inset on all four sides. The
ResizeObserver picks up the new dimensions and the layout engine
re-wraps text at the actual rendered width.
2026-05-25 10:57:23 +03:00
Ilia Mashkov 8eee815e9a refactor(styles): improve light-mode contrast across surfaces and muted text
Dark mode unchanged. Targets that were reported as "barely visible" in
light theme:

Surfaces / dividers
- --color-border-subtle (light) bumped from rgb(0 0 0 / 0.05) to
  --neutral-300 (matches the Input underline variant's border color and
  yields a visible hairline on bg-surface / bg-paper).
- New bg-subtle utility (same color as border-subtle but as
  background-color) — used by Divider component and the TypographyMenu
  inline column separator. Replaces ad-hoc 'bg-black/5 dark:bg-white/10'
  and 'bg-black/10 dark:bg-white/10' bands.
- FontSearch + ComparisonView Search wrapper borders switched from
  hand-written 'border-swiss-black/5 dark:border-white/10' to
  border-subtle so they participate in the palette.

Muted text
- Button tertiary inactive text (light) bumped neutral-400 → neutral-600
  (~2.7:1 → ~7.5:1 contrast). Covers the A/B toggle and the font-list
  rows in the sidebar.
- Label/TechText muted variant (light) bumped neutral-400 → neutral-600.
  Covers the ComboControl value text.
- Link text aligned to neutral-500 / neutral-400 (subtle but visible).

No behavior changes; pure styling.
2026-05-25 10:56:51 +03:00
Ilia Mashkov 5b7ec03973 refactor: sweep call sites onto design-system utilities + bug fixes
Replace inline class clusters with the design-system utilities and
tokens established in the prior two commits. No behavior changes
intended beyond two real bug fixes.

Bug fixes:
- SampleList.svelte: 'border-border-subtle bg-background-40' was a
  silent no-op (both classes mis-spelled). Now 'border-subtle
  bg-background/40' applies as intended.
- FontList.svelte: 'h-[44px]' → 'h-11' (44px = 2.75rem = spacing-11,
  no need for arbitrary value).

Sweeps:
- TypographyMenu: popover + floating bar now use surface-popover /
  surface-floating + shadow-popover.
- FontList + FilterGroup: tertiary list buttons use the new
  Button layout="block-list-row" variant; skeleton fills use
  the skeleton-fill utility.
- Footer / BreadcrumbHeader: surface-floating absorbs the
  bg-surface/blur/border cluster. Footer bumped to z-20 with a
  comment explaining the stacking against SidebarContainer (z-40/50).
- FontSampler: surface-card + hover shadow-stamp-card token.
- SliderArea: surface-canvas, flex-center, shadow-floating-panel
  tokens (light + dark variants).
- Sidebar / Header / ButtonGroup / Layout / SidebarContainer:
  bg-surface dark:bg-dark-bg → surface-canvas (8 sites);
  SidebarContainer mobile panel uses shadow-overlay.
- Loader / Thumb: flex items-center justify-center → flex-center;
  Thumb durations → duration-fast.
- ComboControl: trigger uses surface-card-elevated when open,
  popover uses surface-card-elevated, label cluster → text-label-mono,
  flex-center for the trigger interior.
- Slider: shadow-sm → shadow-rest, duration-150 → duration-fast.
- text-secondary → text-subtle across Input, Slider, ComboControl
  (matches the rename in the styles commit).
- Link: reverted earlier surface-floating attempt — Link's original
  bg-surface/80 backdrop-blur pattern was thinner than surface-floating
  (no border, smaller blur), and the Footer was overlaying its own
  border-subtle on top, fighting the utility. Kept the original style.
2026-05-25 10:20:40 +03:00
Ilia Mashkov 15bb961ccc refactor(Button): add block-list-row layout variant + adopt design-system tokens
- New layout prop with values 'inline' (default) and 'block-list-row'.
  The block-list-row variant bakes in full-width, left-aligned content
  with trailing icon and text-sm, replacing the ~10-class override
  duplicated across FilterGroup, FontList, and similar list-row sites.
- primary variant's three hard-offset shadows now reference the
  shadow-stamp-{rest,hover,pressed} tokens; the 0.0625rem translate
  becomes translate-{x,y}-px.
- Base classes use text-label-mono and duration-normal utilities
  instead of inline 'font-primary font-bold tracking-tight uppercase'
  and 'duration-200'.
- The icon variant's background uses surface-canvas (semantic naming;
  picks up dark-mode automatically via --color-surface).
- text-secondary → text-subtle (avoids collision with the @theme
  --color-secondary token; see earlier styles commit).

New exported type: ButtonLayout.
2026-05-25 10:19:56 +03:00
Ilia Mashkov 4e7f76ecb1 feat(styles): add shadow + motion tokens, surface utilities, mode-switching color vars
Establish a real design system foundation by moving the project from
inline arbitrary-value classes to named tokens and reusable utilities.

Tokens added to @theme (auto-generate Tailwind utilities):
- Shadows: shadow-rest, shadow-stamp-{rest,hover,pressed,card},
  shadow-popover, shadow-floating-panel{,-dark}, shadow-overlay
- Motion: duration-{fast,normal,slow,slower};
  ease-{standard,out-soft,spring-overshoot}

Semantic mode-switching colors added to :root / .dark so utilities
auto-adapt without dark: variants:
- --color-border-subtle, --color-text-subtle, --color-skeleton

Utilities migrated to Tailwind v4's @utility directive with direct CSS
properties (previously @layer utilities with @apply chains, which
silently failed when chaining to other user-defined utilities):
- border-subtle, text-subtle, focus-ring
- surface-canvas, surface-card, surface-card-elevated, surface-popover,
  surface-floating
- flex-center, skeleton-fill, text-label-mono

Notes:
- text-secondary was renamed to text-subtle because --color-secondary is
  registered in @theme (a near-white shadcn surface token), which made
  Tailwind v4 auto-generate a colliding text-secondary utility that won
  over the user-defined one — every consumer effectively rendered as
  near-white text. The text-subtle name pairs cleanly with border-subtle
  and avoids any @theme collisions.
- Dead --space-* variable scale removed (was defined but never wired
  into @theme; Tailwind's default spacing scale is used everywhere).
2026-05-25 10:19:45 +03:00
Ilia Mashkov 06b6274e66 refactor: extract magic constants — wave 5 (single-site thresholds)
Long-tail cleanup: threshold and default-value literals in shared
helpers get named module-level constants.

- CharacterComparisonEngine: CHAR_PROXIMITY_RANGE_PCT (5),
  DEFAULT_RENDER_SIZE_PX (16) — kept local instead of importing
  DEFAULT_FONT_SIZE from \$entities/Font because \$shared/lib cannot
  legally upward-import from \$entities per FSD (also avoids an init
  cycle through the mocks barrel).
- typographySettingsStore: BASE_SIZE_EPSILON (0.01) — rounding-jitter
  guard for baseSize reconciliation.
- createDebouncedState: DEFAULT_DEBOUNCE_MS (300) — exported so callers
  can mirror the default.
- createVirtualizer: MEASUREMENT_EPSILON_PX (0.5) — minimum height
  delta before committing a re-measured row.
- createPerspectiveManager: PERSPECTIVE_TOGGLE_THRESHOLD (0.5) — the
  halfway point on the 0-1 spring that flips isBack/isFront.

Skipped #19 (PerspectivePlan defaults) per review — marginal gain.
2026-05-24 22:07:44 +03:00
Ilia Mashkov 0c59262a59 refactor: extract magic constants — wave 4 (UX timings + physics)
Name throttle/debounce intervals, spring presets, and layout paddings
that were inline numeric literals:

- VirtualList: VISIBLE_CHANGE_THROTTLE_MS (150), NEAR_BOTTOM_THROTTLE_MS
  (200), JUMP_THROTTLE_MS (200)
- SampleList: CHECK_POSITION_THROTTLE_MS (100)
- SliderArea: SLIDER_SPRING_CONFIG ({stiffness: 0.2, damping: 0.7}),
  SLIDER_PERSIST_DEBOUNCE_MS (100), SLIDER_PADDING_MOBILE_PX (48),
  SLIDER_PADDING_DESKTOP_PX (96)
- FontVirtualList: TOUCH_DEBOUNCE_MS (150)
- createPerspectiveManager: PERSPECTIVE_SPRING_CONFIG ({stiffness: 0.2,
  damping: 0.8})

No behavior changes — values preserved exactly.
2026-05-24 21:13:46 +03:00
Ilia Mashkov 2bb43797f0 refactor: extract magic constants — wave 3 (font lifecycle)
Promote font-loading scheduling and lifecycle tunables to named
module-level constants:

- comparisonStore: FONT_READY_FALLBACK_MS (1000ms) — UI unblock safety net
- fontLifecycleManager:
  - PURGE_INTERVAL_MS (60000) — periodic eviction sweep
  - IDLE_CALLBACK_TIMEOUT_MS (150) — requestIdleCallback timeout
  - SCHEDULE_FALLBACK_MS (16) — setTimeout fallback (~60fps)
  - YIELD_INTERVAL_MS (8) — parse-loop yield budget for non-Chromium
  - CRITICAL_FONT_WEIGHTS ([400, 700]) — data-saver allowlist
- FontEvictionPolicy: DEFAULT_FONT_TTL_MS (5 minutes)
- FontLoadQueue: FONT_LOAD_MAX_RETRIES (3)

No behavior changes — values preserved exactly. Class-private fields
that mirrored these constants are removed in favor of module scope.
2026-05-24 21:13:38 +03:00
Ilia Mashkov ccef3cf7bb refactor: extract magic constants — wave 2 (TanStack Query defaults)
Promote the duplicated query lifecycle constants in \$shared/api/queryClient.ts:

- staleTime (5 minutes) -> DEFAULT_QUERY_STALE_TIME_MS
- gcTime (10 minutes)   -> DEFAULT_QUERY_GC_TIME_MS
- retry (3)             -> QUERY_RETRY_COUNT
- retryDelay (1s base, 30s cap) -> QUERY_RETRY_BASE_DELAY_MS + QUERY_RETRY_MAX_DELAY_MS

fontCatalogStore and availableFilterStore now import the stale/gc
constants instead of re-deriving '5 * 60 * 1000' / '10 * 60 * 1000'.

fontCatalogStore.svelte.spec.ts's queryClient mock now passes through
the new named exports via importOriginal so the consumer's imports
resolve.
2026-05-24 20:33:46 +03:00
Ilia Mashkov e3b489f173 refactor: extract magic constants — wave 1 (UX, API, storage)
- Use existing MULTIPLIER_S/M/L from \$entities/Font in SliderArea instead
  of inlining the 0.5/0.75/1 literals (constants already existed but were
  duplicated at the call site).
- Centralize API base URL in \$shared/api/endpoints.ts (was duplicated
  between proxyFonts and FilterAndSortFonts filters api).
- Promote every 'glyphdiff:...' localStorage key to a named module-level
  STORAGE_KEY constant. Test files now import the source constant rather
  than redeclaring it (eliminates silent-typo divergence risk).
2026-05-24 20:30:26 +03:00
Ilia Mashkov f92577608a refactor(Font): use pretext layout() directly in row size resolver
createFontRowSizeResolver was reaching into TextLayoutEngine, which
internally called pretext's heavy layoutWithLines and then walked
per-grapheme cursors to build chars arrays. The resolver discarded all
that work and used only totalHeight.

Replace with direct prepare + layout from @chenglou/pretext — pretext
docs explicitly recommend layout() (not layoutWithLines) for the resize
hot path: pure arithmetic on cached segment widths, no canvas calls, no
string allocations.

Test spies on TextLayoutEngine.prototype.layout migrated to vi.mock-ed
pretext layout (pretext's ESM exports are frozen — vi.spyOn fails with
"Cannot redefine property").

TextLayoutEngine marked @deprecated since it has no remaining
consumers; slated for removal after a release cycle.
2026-05-24 20:12:48 +03:00
Ilia Mashkov 728380498b refactor(Font): rename fontStore and appliedFontsManager
Both names were vague or overloaded:

- fontStore / FontStore -> fontCatalogStore / FontCatalogStore
  Three font-related stores live in this slice; the new name names the
  paginated catalog specifically.

- appliedFontsManager / AppliedFontsManager -> fontLifecycleManager /
  FontLifecycleManager
  "Applied" collided with the filter-side appliedFilterStore (different
  meaning). The class actually orchestrates a load-use-evict lifecycle
  with FontBufferCache + FontEvictionPolicy + FontLoadQueue
  collaborators, so "Manager" is justified. Companion types file moved
  alongside (appliedFonts.ts -> fontLifecycle.ts).

Directories, file basenames, factory (createFontStore ->
createFontCatalogStore), and the AppliedFontsManagerDeps interface all
renamed. All consumers (ComparisonView, SampleList, FontList,
FontApplicator, FontVirtualList, FilterAndSortFonts bindings,
createFontRowSizeResolver, mocks) updated.
2026-05-24 20:00:43 +03:00
Ilia Mashkov 07d044f4d6 refactor: extract BatchFontStore into new FetchFontsByIds feature
The byId font fetch was a verb-oriented capability with a single
consumer driven by a feature need (materializing comparison picks).
That shape belongs at the feature layer, not on the entity.

Move:
- entities/Font/model/store/batchFontStore -> features/FetchFontsByIds/model/store/fontsByIdsStore
- Class BatchFontStore -> FontsByIdsStore

entities/Font retains the transport primitives (fetchFontsByIds,
seedFontCache) and the keyspace (fontKeys); the feature wraps them in
the reactive store. comparisonStore now imports FontsByIdsStore from
the new feature. The proxy API is imported via direct path so vi.spyOn
on the source module still observes the call.
2026-05-24 19:41:40 +03:00
Ilia Mashkov df59dfda02 refactor(features): rename SetupFont to AdjustTypography + reorganize
Structural:
- Merge factory + singleton from lib/settingsManager and model/state into
  one model/store/typographySettingsStore/ slice
- Drop now-empty lib/ and model/state/ directories

Semantic:
- Rename feature SetupFont -> AdjustTypography (the feature owns
  continuous typography adjustment, not one-time font setup)
- Drop "Manager" from TypographySettingsManager -> TypographySettingsStore
  (class + factory); singleton typographySettingsStore unchanged

All consumers (Character, Line, SampleList, SliderArea, FontSampler,
comparisonStore) updated. Public barrel signature changed: now exports
createTypographySettingsStore and type TypographySettingsStore.
2026-05-24 18:27:10 +03:00
Ilia Mashkov ca382fd43d refactor(features): rename GetFonts to FilterAndSortFonts
The feature does not fetch fonts — that lives in \$entities/Font's
fontStore. It owns the user's filter selections, sort preference, and
search-by-name query that drive the listing. The new name describes what
it actually does.

Directory + every \$features/GetFonts import path updated; no symbol
renames in this commit.
2026-05-24 18:16:16 +03:00
Ilia Mashkov e0d39d861f refactor(GetFonts): rename filters/filterManager to available/appliedFilterStore
The 'filters' + 'filterManager' pair didn't reveal the schema-vs-selection
split. Rename to reflect the actual roles:

- FiltersStore / filtersStore       → AvailableFilterStore / availableFilterStore
- createFilterManager / FilterManager → createAppliedFilterStore / AppliedFilterStore
- filterManager singleton            → appliedFilterStore
- mapManagerToParams                 → mapAppliedFiltersToParams

Directories and file basenames follow the new singleton names. Public
barrel signature updated; all consumers (Search, FontSearch, Filters,
FilterControls) point at the new identifiers.
2026-05-24 18:08:05 +03:00
Ilia Mashkov b6494a8cb5 test(GetFonts): cover filters and sortStore + nest each in its own dir
Export createSortStore and FiltersStore so per-test instances can be
constructed without sharing singleton state. Add unit tests covering:

- sortStore: default + custom init, display→API value mapping, set()
  idempotency, singleton shape
- filters: empty initial state, fetch population, single-call dedup,
  error path, cached-fetch reuse across observers

Group each store with its tests under its own directory to match the
filterManager layout.
2026-05-24 17:49:26 +03:00
Ilia Mashkov cc218934f4 fix(ComparisonView): update batchFontStore import path in test
Dynamic import inside the vi.mock('$entities/Font') factory was missed
when batchFontStore was relocated into its own subdirectory in 1573950.
Restores the previously-failing comparisonStore test suite (9 tests) and
clears the lingering TS error in svelte-check.
2026-05-24 16:05:59 +03:00
Ilia Mashkov 3a327e2d92 refactor(GetFonts): tighten mapManagerToParams + add coverage
Collapse the three duplicated getGroup/map/length-guard chains into a
single selectedIn helper. Drop the unnecessary `as string[]` casts —
Property<TValue extends string>.value already yields string at the call
site.

Add unit tests covering empty query, populated query, missing group,
empty group, single + multi selection, unknown group ids, and the
combined param shape.
2026-05-24 15:45:07 +03:00
Ilia Mashkov 30621c33df refactor(GetFonts): consolidate model/state into model/store
Align the slice with the project-wide convention (entities/Font,
entities/Breadcrumb, features/ChangeAppTheme all use model/store/;
CLAUDE.md spec calls for store/). Move bindings, filters, and the
filterManager subdir out of the now-removed model/state/ directory.
2026-05-24 15:33:26 +03:00
Ilia Mashkov cb8f6ffc97 refactor(GetFonts): unify filterManager factory + singleton under model/state
Merge the factory previously in lib/filterManager/ with the singleton
previously in model/state/manager.svelte.ts into a single
model/state/filterManager/ slice. The factory builds stateful runes-backed
objects, so it belongs alongside the singleton in model/, not in lib/.

lib/ now contains only the pure mapManagerToParams transform.
Public barrel signature unchanged.
2026-05-24 15:23:25 +03:00
Ilia Mashkov 33d3429060 refactor(GetFonts): consolidate filtersStore wiring into bindings
Move the filtersStore → filterManager.setGroups $effect.root out of
manager.svelte.ts into bindings.svelte.ts so all cross-store reactive
wiring for the feature lives in one place. manager.svelte.ts now only
constructs and exports the singleton.
2026-05-24 15:08:54 +03:00
Ilia Mashkov e60309af78 refactor(GetFonts): centralize filterManager/sortStore → fontStore bridge
Move the duplicated $effect blocks that mapped filterManager and sortStore
into fontStore params out of Search, FontSearch and FilterControls into a
single $effect.root in features/GetFonts/model/state/bindings.svelte.ts.

Consumers now bind to the manager/store directly; the bridge is installed
once via a side-effect import from the feature barrel.
2026-05-24 15:05:28 +03:00
Ilia Mashkov 1573950605 chore(Font): move batchFontStore to separate directory 2026-05-24 13:54:15 +03:00
ilia 773ab55f5c Merge pull request 'Fix/mobile comparison view' (#41) from fix/mobile-comparison-view into main
Workflow / build (push) Successful in 13s
Workflow / publish (push) Successful in 17s
Reviewed-on: #41
2026-05-23 18:21:47 +00:00
Ilia Mashkov 67e02e4e75 feat: tag every build with the immutable commit SHA
Workflow / build (pull_request) Successful in 38s
Workflow / publish (pull_request) Has been skipped
2026-05-23 21:20:37 +03:00
Ilia Mashkov 5ca7a433ff fix: use dvh units to prevent ComparisonView from being covered with address bar on mobile 2026-05-23 21:19:51 +03:00
ilia 3b6ea99d09 Merge pull request 'Fix/text morphing position' (#40) from fix/text-morphing-position into main
Workflow / build (push) Successful in 1m42s
Workflow / publish (push) Successful in 18s
Reviewed-on: #40
2026-05-23 17:43:07 +00:00
Ilia Mashkov f762a09c23 fix(SliderArea): temporarily replace pretext measurements with canvas
Workflow / build (pull_request) Successful in 1m53s
Workflow / publish (pull_request) Has been skipped
2026-05-23 20:07:39 +03:00
Ilia Mashkov 95ae72719e chore: move getPretextFontString into separate directory 2026-05-23 20:03:13 +03:00
ilia f3c4e72b86 Merge pull request 'Fixes/minor tweaks' (#39) from fixes/minor-tweaks into main
Workflow / build (push) Successful in 1m37s
Workflow / publish (push) Successful in 36s
Reviewed-on: #39
2026-05-23 14:11:58 +00:00
Ilia Mashkov f41c4aab9c feat: move class prop to wrapper
Workflow / build (pull_request) Successful in 3m55s
Workflow / publish (pull_request) Has been skipped
2026-05-23 17:00:29 +03:00
Ilia Mashkov d1eb83fa90 fix: wire the search to the store 2026-05-23 16:59:59 +03:00
Ilia Mashkov c01fc79a3e fix: add scrollMargin property since the IntersectionObserver has it 2026-05-05 17:04:23 +03:00
Ilia Mashkov 6bfa7ca777 chore: add .css files declaration 2026-05-05 17:03:43 +03:00
Ilia Mashkov 0d4356b8f1 chore: remove @ts-expect-error since scheduler was added in new TS release 2026-05-05 17:03:18 +03:00
Ilia Mashkov c18574d4c3 fix: remove deprecated tsconfig property 2026-05-05 17:02:25 +03:00
Ilia Mashkov 1c9a7f9fe1 chore: add .vscode to .gitignore 2026-05-05 16:49:56 +03:00
Ilia Mashkov fae6694479 chore(dprint): update markup_fmt plugin version, fix @render indentation and add couple of new rules 2026-05-05 16:49:27 +03:00
Ilia Mashkov a105c94176 chore: upgrade svelte-language-server to 0.18.0 2026-05-05 15:34:38 +03:00
Ilia Mashkov 77c2b27f8b chore: update remaining outdated packages (@chenglou/pretext 0.0.6, svelte-check 4.4.8) 2026-05-05 15:34:38 +03:00
Ilia Mashkov 1ce0d6c66f chore: upgrade tooling and ecosystem (jsdom 29, playwright 1.59.1, storybook 10.3.6) 2026-05-05 15:34:33 +03:00
Ilia Mashkov 6c20a68e19 chore: upgrade core build tooling (vite 8, svelte plugin 7, typescript 6) 2026-05-05 15:34:27 +03:00
Ilia Mashkov 3894912a22 feat(FontList): add a small gap for elements of ComparisonView sidebar font list 2026-05-05 12:05:19 +03:00
Ilia Mashkov e8d3727c6a feat: upgrade lucide icons to 1.14 2026-05-05 10:10:11 +03:00
Ilia Mashkov 5fbf090b24 fix(Footer): minor layout change 2026-05-05 10:06:30 +03:00
ilia a94e1f8b65 Merge pull request 'feat(shared): add cn utility for tailwind-aware class merging' (#38) from feature/minor-improvements into main
Workflow / build (push) Successful in 1m35s
Workflow / publish (push) Successful in 22s
Reviewed-on: #38
2026-04-23 12:11:02 +00:00
Ilia Mashkov f8ba2d7eb0 chore(Footer): move components to separate directories
Workflow / build (pull_request) Successful in 1m42s
Workflow / publish (pull_request) Has been skipped
2026-04-23 14:59:33 +03:00
Ilia Mashkov 3594033bcb feat(FooterLink): move FooterLink to the Footer widget layer, delete the one in shared/ui 2026-04-23 14:59:33 +03:00
Ilia Mashkov 2ae24912f7 feat(Footer): tweak the footer position 2026-04-23 14:59:32 +03:00
Ilia Mashkov 877719f106 feat(Link): create reusable Link ui component 2026-04-23 14:59:32 +03:00
Ilia Mashkov 4eafb96d35 feat(ComparisonView): replace window resize listener with ResiseObserver on the container to catch the container size change on sidebar open/close 2026-04-23 14:59:32 +03:00
Ilia Mashkov 652dfa5c90 feat: brand colored text selection 2026-04-23 14:59:32 +03:00
Ilia Mashkov 54087b7b2a feat: replace clsx with cn util 2026-04-23 14:59:32 +03:00
Ilia Mashkov cffebf05e3 feat(SliderArea): tweak the styles 2026-04-23 14:59:32 +03:00
Ilia Mashkov ada484e2e0 feat(FooterLink): tweak the styles 2026-04-23 14:59:32 +03:00
Ilia Mashkov dbcc1caeb0 feat(Footer): change the footer styles and layout to avoid overlapping with the TypographyMenu 2026-04-23 14:59:32 +03:00
Ilia Mashkov 2c579a3336 feat(shared): add cn utility for tailwind-aware class merging 2026-04-23 14:59:32 +03:00
Ilia Mashkov fe0d4e7daa fix: workflow
Workflow / build (push) Successful in 1m40s
Workflow / publish (push) Successful in 46s
2026-04-23 14:52:11 +03:00
Ilia Mashkov 108df323f9 test: add timeout to fail the test instead of OOM 2026-04-23 14:16:06 +03:00
Ilia Mashkov 2803bcd22c fix(createVirtualizer): add window check to resolve the ReferenceError 2026-04-23 14:16:06 +03:00
ilia 47a8487ce9 Merge pull request 'chore(SetupFont): rename controlManager to typographySettingsStore for better semantic' (#37) from feature/united-widget into main
Workflow / publish (push) Has been cancelled
Workflow / build (push) Has been cancelled
Reviewed-on: #37
2026-04-22 10:04:37 +00:00
Ilia Mashkov 1d5af5ea70 feat(Layout): add footer to layout 2026-04-22 13:01:46 +03:00
Ilia Mashkov 2221ecad4c feat(Footer): create Footer widget with project name and portfolio link 2026-04-22 13:01:16 +03:00
Ilia Mashkov cd8599d5b5 feat(Layout): add new favicon 2026-04-22 13:00:29 +03:00
Ilia Mashkov 6c91d570ec chore: remove usused code 2026-04-22 12:31:35 +03:00
Ilia Mashkov 91b80a5ada feat(ui): add FooterLink component 2026-04-22 12:31:02 +03:00
201 changed files with 6004 additions and 2917 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules
.yarn/cache
.yarn/unplugged
.yarn/install-state.gz
dist
.git
.gitea
.svelte-kit
storybook-static
+34 -3
View File
@@ -47,10 +47,37 @@ jobs:
run: yarn test:unit
- name: Run Component Tests
run: yarn test:component
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:
@@ -62,5 +89,9 @@ jobs:
- name: Build and Push Docker Image
run: |
docker build -t git.allmy.work/${{ gitea.repository }}:latest .
docker build \
-t git.allmy.work/${{ gitea.repository }}:latest \
-t git.allmy.work/${{ gitea.repository }}:${{ gitea.sha }} \
.
docker push git.allmy.work/${{ gitea.repository }}:latest
docker push git.allmy.work/${{ gitea.repository }}:${{ gitea.sha }}
+6
View File
@@ -10,6 +10,9 @@ node_modules
/build
/dist
# IDE settings
.vscode
# Git worktrees (isolated development branches)
.worktrees
@@ -47,3 +50,6 @@ storybook-static
# Tests
coverage/
.aider*
playwright-report/
blob-report/
.playwright/
+24 -1
View File
@@ -1,5 +1,28 @@
:3000 {
root * /usr/share/caddy
file_server
# Compress text responses only. woff2/png and other binaries are already
# compressed, so they're excluded — re-compressing them burns CPU for ~0%.
encode {
zstd
gzip
match {
header Content-Type text/*
header Content-Type application/javascript*
header Content-Type application/json*
header Content-Type image/svg+xml*
}
}
# Vite emits all build output under /assets/ with content-hashed filenames,
# so those bytes never change for a given URL — cache them indefinitely.
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"
# The HTML shell is the un-hashed entry point; it must revalidate so a new
# deploy is served immediately rather than from a stale cache.
header /index.html Cache-Control "no-cache"
try_files {path} /index.html
file_server
}
+4 -9
View File
@@ -1,27 +1,22 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Enable Corepack so we can use Yarn v4
RUN corepack enable && corepack prepare yarn@stable --activate
# Enable Corepack so we can use Yarn v4 (pinned to match lockfile)
RUN corepack enable && corepack prepare yarn@4.11.0 --activate
# Force Yarn to use node_modules instead of PnP
ENV YARN_NODE_LINKER=node-modules
COPY package.json yarn.lock ./
RUN yarn install --immutable
COPY . .
RUN yarn build
RUN yarn build && ls -la dist
# Production stage - Caddy
FROM caddy:2-alpine
WORKDIR /usr/share/caddy
# Copy built static files from the builder stage
COPY --from=builder /app/dist .
# Copy our local Caddyfile config
COPY Caddyfile /etc/caddy/Caddyfile
EXPOSE 3000
# Start caddy using the config file
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
-592
View File
@@ -1,592 +0,0 @@
# Git Workflow and Branching Strategy
This document outlines the git workflow, branching strategy, commit conventions, and code review guidelines for the glyphdiff.com project.
## Table of Contents
1. [Branching Strategy](#branching-strategy)
2. [Branch Naming Conventions](#branch-naming-conventions)
3. [Commit Message Conventions](#commit-message-conventions)
4. [Code Splitting and Merge Request Guidelines](#code-splitting-and-merge-request-guidelines)
5. [Branch Protection Rules](#branch-protection-rules)
6. [Git Hooks Configuration](#git-hooks-configuration)
---
## Branching Strategy
We use a Gitflow-inspired branching strategy adapted for our development workflow. This strategy provides a clear structure for feature development, bug fixes, and releases.
### Branch Types
#### 1. `main` Branch
- **Purpose**: Production-ready code only
- **Protection**: Highest level of protection
- **Rules**:
- Only merge `release/*` or `hotfix/*` branches into `main`
- No direct commits allowed
- Must pass all tests and code reviews
- Tags are created from this branch for releases (e.g., `v1.0.0`)
#### 2. `develop` Branch
- **Purpose**: Integration branch for features
- **Protection**: High level of protection
- **Rules**:
- Merge `feature/*` and `fix/*` branches into `develop`
- No direct commits allowed
- Must pass all tests before merging
- Serves as the base for `release/*` branches
#### 3. `feature/*` Branches
- **Purpose**: Develop new features
- **Naming**: `feature/feature-name` (e.g., `feature/font-catalog`, `feature/comparison-grid`)
- **Base**: Always branch from `develop`
- **Merge**: Merge back into `develop` via Merge Request (MR)
- **Rules**:
- One feature per branch
- Keep branches focused and small
- Delete after merging
#### 4. `fix/*` Branches
- **Purpose**: Fix bugs discovered during development
- **Naming**: `fix/issue-description` (e.g., `fix/font-loading-error`, `fix/responsive-layout`)
- **Base**: Branch from `develop`
- **Merge**: Merge back into `develop` via MR
- **Rules**:
- One fix per branch
- Include tests that verify the fix
- Delete after merging
#### 5. `hotfix/*` Branches
- **Purpose**: Critical fixes for production issues
- **Naming**: `hotfix/critical-fix` (e.g., `hotfix/security-patch`, `hotfix-production-crash`)
- **Base**: Branch from `main`
- **Merge**: Merge into both `main` and `develop`
- **Rules**:
- Use only for production emergencies
- Must be thoroughly tested
- Create a release tag after merging to `main`
#### 6. `release/*` Branches
- **Purpose**: Prepare for a new release
- **Naming**: `release/vX.Y.Z` (e.g., `release/v1.0.0`, `release/v1.1.0`)
- **Base**: Branch from `develop`
- **Merge**: Merge into both `main` and `develop`
- **Rules**:
- Finalize release notes
- Update version numbers
- Perform final testing
- Create release tag after merging to `main`
### Branch Workflow Diagram
```
main (production)
│ hotfix/*, release/*
develop (integration)
│ feature/*, fix/*
feature branches
```
---
## Branch Naming Conventions
### Feature Branches
- Format: `feature/feature-name`
- Examples:
- `feature/font-catalog`
- `feature/comparison-grid`
- `feature/dark-mode`
- `feature/google-fonts-integration`
### Fix Branches
- Format: `fix/issue-description`
- Examples:
- `fix/font-loading-error`
- `fix/responsive-layout`
- `fix/state-persistence`
- `fix-accessibility-contrast`
### Hotfix Branches
- Format: `hotfix/critical-fix`
- Examples:
- `hotfix/security-patch`
- `hotfix-production-crash`
- `hotfix-api-rate-limit`
### Release Branches
- Format: `release/vX.Y.Z`
- Examples:
- `release/v1.0.0`
- `release/v1.1.0`
- `release/v2.0.0`
### Naming Guidelines
- Use lowercase letters
- Use hyphens to separate words
- Be descriptive but concise
- Avoid special characters (except hyphens)
- Keep names under 50 characters
---
## Commit Message Conventions
We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification. This format enables automated changelog generation and better commit history readability.
### Format
```
<type>(<scope>): <subject>
<body>
<footer>
```
### Commit Types
| Type | Description | Examples |
|------|-------------|----------|
| `feat` | New feature | `feat(fonts): add Google Fonts integration` |
| `fix` | Bug fix | `fix(comparison): resolve font loading race condition` |
| `docs` | Documentation changes | `docs(readme): update installation instructions` |
| `style` | Code style changes (formatting, etc.) | `style(components): format with Prettier` |
| `refactor` | Code refactoring | `refactor(stores): simplify state management` |
| `test` | Adding or updating tests | `test(fonts): add unit tests for font mapper` |
| `chore` | Maintenance tasks | `chore(deps): update Tailwind CSS to v4.0` |
| `perf` | Performance improvements | `perf(catalog): implement lazy loading for fonts` |
### Scope
The scope provides context about which part of the codebase is affected. Common scopes for this project:
- `fonts` - Font-related functionality
- `comparison` - Font comparison features
- `catalog` - Font catalog pages
- `stores` - State management stores
- `components` - UI components
- `routes` - SvelteKit routes
- `services` - External API services
- `utils` - Utility functions
- `types` - TypeScript type definitions
- `ui` - UI-related changes (theme, layout, etc.)
- `config` - Configuration files
### Subject
- Use imperative mood ("add" not "added", "fix" not "fixed")
- Keep it short (50 characters or less)
- Don't end with a period
- Be specific and descriptive
### Body
- Use imperative mood
- Explain **what** and **why**, not **how**
- Wrap at 72 characters
- Include references to issues (e.g., `Closes #123`)
### Footer
- Reference breaking changes with `BREAKING CHANGE:`
- Reference issues with `Closes #123` or `Fixes #456`
- Include co-authors if needed
### Examples
#### Feature Commit
```
feat(fonts): add Google Fonts API integration
Implement Google Fonts API service to fetch and display available fonts.
This includes the fetchGoogleFonts function and font mapper utilities.
Closes #12
```
#### Bug Fix Commit
```
fix(comparison): resolve font loading race condition
The comparison grid was attempting to render fonts before they were fully
loaded. Added loading state checks to prevent this issue.
Fixes #45
```
#### Refactor Commit
```
refactor(stores): simplify state management with Svelte 5 runes
Migrated from Svelte stores to Svelte 5's $state runes for better
performance and simpler code. This change affects all stores in the
project.
BREAKING CHANGE: Store API has changed from subscribe() to direct
property access. Update all store consumers accordingly.
```
#### Documentation Commit
```
docs(git-workflow): add commit message conventions
Document the conventional commits format with examples and guidelines
for the team.
```
#### Chore Commit
```
chore(deps): update Tailwind CSS to v4.0.0
Update Tailwind CSS to the latest version and adjust configuration
files accordingly.
```
---
## Code Splitting and Merge Request Guidelines
### Merge Request Size Guidelines
- **Maximum MR size**: < 500 lines changed (additions + deletions)
- **Ideal MR size**: 100-300 lines changed
- **Files per MR**: < 10 files
### When to Split a Feature into Multiple MRs
Split a feature into multiple MRs when:
1. **The feature is large** (> 500 lines or > 10 files)
2. **Multiple concerns are involved** (e.g., UI + API + state management)
3. **Independent parts can be tested separately**
4. **The feature has logical phases** (e.g., setup → implementation → polish)
### Example: Splitting a Feature
**Feature**: Font Catalog with Filtering
**MR 1**: `feature/font-catalog-setup`
- Create basic catalog page structure
- Set up routing
- Add placeholder components
- ~150 lines
**MR 2**: `feature/font-catalog-data`
- Implement Google Fonts API integration
- Create font data fetching logic
- Add font mapper utilities
- ~200 lines
**MR 3**: `feature/font-catalog-ui`
- Build FontCard component
- Implement grid layout
- Add loading states
- ~250 lines
**MR 4**: `feature/font-catalog-filtering`
- Implement filter store
- Build FilterBar component
- Connect filters to catalog
- ~180 lines
### Merge Request Description Template
Every MR must include a comprehensive description:
```markdown
## Description
Brief description of what this MR changes and why.
## Changes Made
- [ ] Change 1
- [ ] Change 2
- [ ] Change 3
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
- [ ] Refactoring
- [ ] Performance improvement
## Testing
- [ ] Unit tests pass
- [ ] Manual testing completed
- [ ] Tested on Chrome
- [ ] Tested on Firefox
- [ ] Tested on Safari
- [ ] Tested on mobile (responsive)
## Screenshots (if applicable)
Add screenshots or GIFs showing the changes.
## Checklist
- [ ] Code follows project style guidelines
- [ ] Self-review completed
- [ ] Comments added for complex logic
- [ ] Documentation updated
- [ ] No new warnings generated
- [ ] Tests added/updated
- [ ] All tests passing
## Related Issues
Closes #123
Related to #456
```
### Code Review Checklist
Reviewers should check:
#### Functionality
- [ ] Does the code work as intended?
- [ ] Are edge cases handled?
- [ ] Is error handling appropriate?
#### Code Quality
- [ ] Is the code readable and maintainable?
- [ ] Are variable/function names descriptive?
- [ ] Is there unnecessary complexity?
- [ ] Are there code duplications?
#### Best Practices
- [ ] Does it follow project conventions?
- [ ] Are TypeScript types properly defined?
- [ ] Are Svelte best practices followed?
- [ ] Is Tailwind CSS used appropriately?
#### Testing
- [ ] Are tests included?
- [ ] Do tests cover edge cases?
- [ ] Are tests meaningful and not redundant?
#### Documentation
- [ ] Is the code self-documenting?
- [ ] Are complex functions commented?
- [ ] Is the MR description clear?
#### Performance
- [ ] Are there performance concerns?
- [ ] Is lazy loading used where appropriate?
- [ ] Are unnecessary re-renders avoided?
### Merge Request Approval Process
1. **Author**: Creates MR with complete description
2. **Reviewer**: Reviews code using the checklist above
3. **Discussion**: Address any concerns or suggestions
4. **Approval**: At least one approval required
4. **Merge**: Squash and merge into target branch
5. **Cleanup**: Delete source branch after merge
---
## Branch Protection Rules
### `main` Branch Protection
- **Require pull request reviews**: Yes
- Required approvers: 1
- Dismiss stale reviews: Yes
- **Require status checks**: Yes
- Required checks: All tests, linting
- Require branches to be up to date: Yes
- **Restrict who can push**: Only maintainers
- **Require linear history**: Yes (squash and merge)
- **Block force pushes**: Yes
### `develop` Branch Protection
- **Require pull request reviews**: Yes
- Required approvers: 1
- Dismiss stale reviews: Yes
- **Require status checks**: Yes
- Required checks: All tests, linting
- Require branches to be up to date: Yes
- **Restrict who can push**: Only developers and maintainers
- **Require linear history**: Yes (squash and merge)
- **Block force pushes**: Yes
### Implementation Notes
These rules should be configured in your Git hosting platform (GitHub, GitLab, or Bitbucket). The exact configuration steps vary by platform:
- **GitHub**: Settings → Branches → Add rule
- **GitLab**: Settings → Repository → Protected branches
- **Bitbucket**: Repository settings → Branch restrictions
---
## Git Hooks Configuration
Git hooks are automated scripts that run at specific points in the git workflow. They help maintain code quality and consistency.
### Recommended Hooks
#### 1. Pre-commit Hook
**Purpose**: Run linter and formatter before committing
**Tools**: ESLint, Prettier
**Implementation**:
```bash
#!/bin/sh
# .git/hooks/pre-commit
# Run Prettier
npm run format:check
# Run ESLint
npm run lint
# Exit with error if any check fails
if [ $? -ne 0 ]; then
echo "❌ Pre-commit checks failed. Please fix the issues before committing."
exit 1
fi
echo "✅ Pre-commit checks passed."
```
**Setup**:
```bash
# Install husky (recommended)
npm install --save-dev husky
# Initialize husky
npx husky install
# Add pre-commit hook
npx husky add .husky/pre-commit "npm run lint && npm run format:check"
```
#### 2. Commit-msg Hook
**Purpose**: Validate commit message format
**Tools**: commitlint
**Implementation**:
```bash
#!/bin/sh
# .git/hooks/commit-msg
# Validate commit message with commitlint
npx --no -- commitlint --edit $1
```
**Setup**:
```bash
# Install commitlint
npm install --save-dev @commitlint/cli @commitlint/config-conventional
# Create commitlint config
echo "module.exports = { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js
# Add commit-msg hook
npx husky add .husky/commit-msg "npx --no -- commitlint --edit \$1"
```
#### 3. Pre-push Hook
**Purpose**: Run tests before pushing
**Tools**: Vitest, SvelteKit test runner
**Implementation**:
```bash
#!/bin/sh
# .git/hooks/pre-push
# Run tests
npm run test
# Exit with error if tests fail
if [ $? -ne 0 ]; then
echo "❌ Tests failed. Please fix the failing tests before pushing."
exit 1
fi
echo "✅ All tests passed."
```
**Setup**:
```bash
# Add pre-push hook
npx husky add .husky/pre-push "npm run test"
```
### Alternative: Using Husky
[Husky](https://typicode.github.io/husky/) is a popular tool for managing git hooks. It's easier to maintain and works across different operating systems.
**Installation**:
```bash
npm install --save-dev husky
npx husky install
npm pkg set scripts.prepare="husky install"
```
**Adding hooks**:
```bash
# Pre-commit hook
npx husky add .husky/pre-commit "npm run lint && npm run format:check"
# Commit-msg hook
npx husky add .husky/commit-msg "npx --no -- commitlint --edit \$1"
# Pre-push hook
npx husky add .husky/pre-push "npm run test"
```
### Hook Scripts for This Project
Once the project is set up with SvelteKit, add these scripts to `package.json`:
```json
{
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"test:watch": "vitest",
"prepare": "husky install"
}
}
```
### Benefits of Git Hooks
1. **Consistency**: Enforce code style and formatting
2. **Quality**: Catch bugs before they're committed
3. **Efficiency**: Fail fast, fix early
4. **Automation**: Reduce manual checks
5. **Team alignment**: Ensure everyone follows the same standards
---
## Summary
This git workflow provides a structured approach to development for the glyphdiff.com project:
- **Clear branching strategy** with defined purposes for each branch type
- **Conventional commits** for readable and automated changelogs
- **Code splitting guidelines** to keep MRs focused and reviewable
- **Comprehensive review process** to maintain code quality
- **Git hooks** to automate quality checks
Following this workflow will help the team:
- Develop features in parallel without conflicts
- Maintain a clean git history
- Catch issues early in the development process
- Ensure code quality and consistency
- Streamline the release process
For questions or suggestions about this workflow, please discuss with the team or create an issue in the project repository.
+4 -5
View File
@@ -13,7 +13,7 @@
"https://plugins.dprint.dev/typescript-0.93.0.wasm",
"https://plugins.dprint.dev/json-0.19.3.wasm",
"https://plugins.dprint.dev/markdown-0.17.8.wasm",
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.27.0.wasm"
],
"typescript": {
"lineWidth": 120,
@@ -57,9 +57,8 @@
"quotes": "double",
"scriptIndent": false,
"styleIndent": false,
"vBindStyle": "short",
"vOnStyle": "short",
"formatComments": true
"formatComments": true,
"svelteAttrShorthand": true,
"svelteDirectiveShorthand": true
}
}
+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);
});
});
+32 -33
View File
@@ -27,45 +27,44 @@
"build-storybook": "storybook build"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.1.3",
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.561.0",
"@playwright/test": "^1.57.0",
"@storybook/addon-a11y": "^10.1.11",
"@storybook/addon-docs": "^10.1.11",
"@storybook/addon-svelte-csf": "^5.0.10",
"@storybook/addon-vitest": "^10.1.11",
"@storybook/svelte-vite": "^10.1.11",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.18",
"@chromatic-com/storybook": "5.1.2",
"@internationalized/date": "3.12.1",
"@lucide/svelte": "^1.14.0",
"@playwright/test": "1.59.1",
"@storybook/addon-a11y": "10.3.6",
"@storybook/addon-docs": "10.3.6",
"@storybook/addon-svelte-csf": "5.1.2",
"@storybook/addon-vitest": "10.3.6",
"@storybook/svelte-vite": "10.3.6",
"@sveltejs/vite-plugin-svelte": "7.1.0",
"@tailwindcss/vite": "4.2.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@tsconfig/svelte": "^5.0.6",
"@types/jsdom": "^27",
"@vitest/browser-playwright": "^4.0.16",
"@vitest/coverage-v8": "^4.0.16",
"bits-ui": "^2.14.4",
"@tsconfig/svelte": "5.0.8",
"@types/jsdom": "28.0.1",
"@vitest/browser-playwright": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"clsx": "^2.1.1",
"dprint": "^0.50.2",
"jsdom": "^27.4.0",
"lefthook": "^2.0.13",
"oxlint": "^1.35.0",
"playwright": "^1.57.0",
"storybook": "^10.1.11",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"svelte-language-server": "^0.17.23",
"tailwind-merge": "^3.4.0",
"dprint": "0.54.0",
"jsdom": "29.1.1",
"lefthook": "2.1.6",
"oxlint": "1.62.0",
"playwright": "1.59.1",
"storybook": "10.3.6",
"svelte": "5.55.5",
"svelte-check": "4.4.8",
"svelte-language-server": "0.18.0",
"tailwind-merge": "3.5.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.18",
"tailwindcss": "4.2.4",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.2.6",
"vitest": "^4.0.16",
"vitest-browser-svelte": "^2.0.1"
"typescript": "6.0.3",
"vite": "8.0.10",
"vitest": "4.1.5",
"vitest-browser-svelte": "2.1.1"
},
"dependencies": {
"@chenglou/pretext": "^0.0.5",
"@tanstack/svelte-query": "^6.0.14"
"@chenglou/pretext": "0.0.6",
"@tanstack/svelte-query": "6.1.28"
}
}
+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',
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
+170 -33
View File
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./fonts.css";
@variant dark (&:where(.dark, .dark *));
@@ -14,6 +15,13 @@
--swiss-black: #1a1a1a;
--swiss-white: #ffffff;
/* Semantic mode-switching colors. These are redefined inside `.dark`
so utilities that reference them auto-adapt without a `dark:` variant. */
--color-border-subtle: var(--neutral-300);
--color-text-subtle: var(--neutral-500);
--color-skeleton: var(--neutral-200);
--color-grid-line: rgb(0 0 0 / 0.03);
/* Neutral Grays */
--neutral-50: #fafafa;
--neutral-100: #f5f5f5;
@@ -80,16 +88,6 @@
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* Spacing Scale (rem-based) */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 0.75rem;
--space-lg: 1rem;
--space-xl: 1.5rem;
--space-2xl: 2rem;
--space-3xl: 3rem;
--space-4xl: 4rem;
/* Typography Scale */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
@@ -114,6 +112,12 @@
--color-surface: var(--dark-bg);
--color-paper: var(--dark-card);
/* Dark-mode overrides for the semantic mode-switching colors. */
--color-border-subtle: rgb(255 255 255 / 0.1);
--color-text-subtle: var(--neutral-400);
--color-skeleton: var(--neutral-800);
--color-grid-line: rgb(255 255 255 / 0.05);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
@@ -212,6 +216,47 @@
--text-2xs: 0.625rem;
/* Monospace label tracking — used in Loader and Footnote */
--tracking-wider-mono: 0.2em;
/* Shadow tokens */
/* Default resting shadow — equivalent to Tailwind's shadow-sm. Used on
buttons, sliders, popover triggers in non-floating state. */
--shadow-rest: 0 1px 2px 0 rgb(0 0 0 / 0.05);
/* Swiss "hard offset" stamp — rests at 2px/2px, lifts to 3px/3px on
hover, presses back to 1px/1px on active. Primary button motif. */
--shadow-stamp-rest: 0.125rem 0.125rem 0 0 rgb(0 0 0 / 0.1);
--shadow-stamp-hover: 0.1875rem 0.1875rem 0 0 rgb(0 0 0 / 0.15);
--shadow-stamp-pressed: 0.0625rem 0.0625rem 0 0 rgb(0 0 0 / 0.1);
/* Card-tier hard-offset stamp — wider, brand-tinted. Used on
interactive cards (FontSampler hover). */
--shadow-stamp-card: 5px 5px 0 0 var(--color-brand);
/* Floating popovers (typography menu, combo control list). */
--shadow-popover: 0 20px 40px -10px rgb(0 0 0 / 0.15);
/* Drop-shadow under semi-translucent floating panels like the
comparison slider's character row. */
--shadow-floating-panel: 0 25px 50px -12px rgb(0 0 0 / 0.05);
--shadow-floating-panel-dark: 0 25px 50px -12px rgb(0 0 0 / 0.2);
/* Drawer / overlay shadow — full-strength shadow-2xl. */
--shadow-overlay: 0 25px 50px -12px rgb(0 0 0 / 0.25);
/* Motion tokens */
--duration-fast: 150ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--duration-slower: 500ms;
/* Tailwind's default ease-in-out — symmetric, good for layout shifts. */
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
/* Decelerating curve — matches Tailwind's ease-out. Dominant in this codebase. */
--ease-out-soft: cubic-bezier(0, 0, 0.2, 1);
/* Spring overshoot — used in character pop animation. */
--ease-spring-overshoot: cubic-bezier(0.34, 1.56, 0.64, 1);
}
@layer base {
@@ -219,9 +264,14 @@
@apply border-border outline-ring/50;
}
::selection {
background-color: var(--color-brand);
color: var(--swiss-white);
}
body {
@apply bg-background text-foreground;
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
font-family: var(--font-secondary);
font-optical-sizing: auto;
}
@@ -272,22 +322,111 @@
}
}
@layer utilities {
/* 21× border-black/5 dark:border-white/10 → single token */
.border-subtle {
@apply border-black/5 dark:border-white/10;
}
/* Secondary text pair */
.text-secondary {
@apply text-neutral-500 dark:text-neutral-400;
}
/* Standard focus ring */
.focus-ring {
@apply focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2;
/* Design-system utilities.
Defined via `@utility` (Tailwind v4) so they integrate with the variant
system (`hover:`, `dark:`, breakpoints) and don't rely on `@apply`
chains. Colors reference the mode-switching semantic vars defined in
`:root`/`.dark` above, so most utilities need no `dark:` variant in
their definition or at call sites. */
@utility border-subtle {
border-color: var(--color-border-subtle);
}
/* Same color as border-subtle, applied via background-color — for 1px
dividers, inline separator strips, and other hairlines that aren't
element borders. */
@utility bg-subtle {
background-color: var(--color-border-subtle);
}
/* Muted text color — paired with `border-subtle` naming. The previous
name `text-secondary` collided with Tailwind v4 auto-generating a
utility from `--color-secondary` (the shadcn near-white surface token
registered in `@theme`), which made every consumer effectively
invisible (near-white text on light backgrounds). */
@utility text-subtle {
color: var(--color-text-subtle);
}
@utility focus-ring {
&:focus-visible {
outline: 2px solid transparent;
outline-offset: 2px;
box-shadow: 0 0 0 2px var(--color-background, white), 0 0 0 4px var(--color-brand);
}
}
/* Global utility - useful across your app */
/* Surface utilities */
@utility surface-canvas {
background-color: var(--color-surface);
}
@utility surface-card {
background-color: var(--color-paper);
border: 1px solid var(--color-border-subtle);
}
@utility surface-card-elevated {
background-color: var(--color-paper);
border: 1px solid var(--color-border-subtle);
box-shadow: var(--shadow-rest);
}
@utility surface-popover {
background-color: var(--color-paper);
border: 1px solid var(--color-border-subtle);
box-shadow: var(--shadow-popover);
}
@utility surface-floating {
background-color: color-mix(in srgb, var(--color-surface) 80%, transparent);
backdrop-filter: blur(12px);
border: 1px solid var(--color-border-subtle);
}
/* Shape / layout */
@utility flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@utility skeleton-fill {
background-color: color-mix(in srgb, var(--color-skeleton) 70%, transparent);
}
/* Subtle dotted-grid overlay used as a decorative background on the
comparison paper surface. Color and intensity auto-switch via
--color-grid-line. `bg-grid-sm` uses a tighter cell — typical mobile
choice; `bg-grid` is the default desktop cell. Pair with absolute /
pointer-events-none on the overlay element. */
@utility bg-grid {
background-image:
linear-gradient(var(--color-grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px);
background-size: 20px 20px;
}
@utility bg-grid-sm {
background-image:
linear-gradient(var(--color-grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px);
background-size: 10px 10px;
}
/* Typography */
@utility text-label-mono {
font-family: var(--font-primary);
font-weight: 700;
letter-spacing: -0.025em;
text-transform: uppercase;
}
/* Honor prefers-reduced-motion: collapse animation and transition timing. */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
@@ -296,12 +435,12 @@
}
}
/* Performance optimization for collapsible elements */
/* Hint the upcoming height animation on open collapsibles. */
[data-state="open"] {
will-change: height;
}
/* Smooth focus transitions - good globally */
/* Transition siblings of a focus-visible peer. */
.peer:focus-visible ~ * {
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
@@ -328,11 +467,9 @@
animation: nudge 10s ease-in-out infinite;
}
/* ============================================
SCROLLBAR STYLES
============================================ */
/* Scrollbar styling */
/* ---- Modern API: color + width (Chrome 121+, FF 64+) ---- */
/* Standard API: color + width (Chrome 121+, Firefox 64+). */
@supports (scrollbar-width: auto) {
* {
scrollbar-width: thin;
@@ -344,8 +481,8 @@
}
}
/* ---- Webkit layer: runs ON TOP in Chrome, standalone in old Safari ---- */
/* Handles things scrollbar-width can't: hiding buttons, exact sizing */
/* WebKit fallback: applies on top of the standard API in Chrome, standalone in
older Safari. Covers what scrollbar-width can't hiding buttons, exact sizing. */
@supports selector(::-webkit-scrollbar) {
::-webkit-scrollbar {
width: 6px;
@@ -353,7 +490,7 @@
}
::-webkit-scrollbar-button {
display: none; /* kills arrows */
display: none; /* hide scrollbar buttons */
}
::-webkit-scrollbar-track {
+78
View File
@@ -0,0 +1,78 @@
/*
Self-hosted interface fonts (latin subset only).
Vendored from @fontsource — see docs/interface-font-selfhost-benchmark.md.
Variable faces (Inter, Space Grotesk) keep their wght axis; Inter also keeps opsz.
url()s are resolved + content-hashed by Vite at build → immutable long-cache.
*/
/* Inter — variable wght + opsz, the body/secondary UI font (--font-secondary) */
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url('../assets/fonts/inter-latin-opsz-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-display: swap;
font-weight: 100 900;
src: url('../assets/fonts/inter-latin-opsz-italic.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Space Grotesk — variable wght, the primary/display UI font (--font-primary) */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-display: swap;
font-weight: 300 700;
src: url('../assets/fonts/space-grotesk-latin-wght-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Space Mono — static 400/700 × roman/italic (--font-mono) */
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('../assets/fonts/space-mono-latin-400-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Space Mono';
font-style: italic;
font-display: swap;
font-weight: 400;
src: url('../assets/fonts/space-mono-latin-400-italic.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url('../assets/fonts/space-mono-latin-700-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Space Mono';
font-style: italic;
font-display: swap;
font-weight: 700;
src: url('../assets/fonts/space-mono-latin-700-italic.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Syne — static 800, the logo font (--font-logo) */
@font-face {
font-family: 'Syne';
font-style: normal;
font-display: swap;
font-weight: 800;
src: url('../assets/fonts/syne-latin-800-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+7
View File
@@ -36,6 +36,13 @@ declare module '*.jpg' {
export default content;
}
declare module '*.css';
declare module '*.woff2?url' {
const content: string;
export default content;
}
/// <reference types="vite/client" />
interface ImportMetaEnv {
+26 -41
View File
@@ -3,21 +3,20 @@
Application shell with providers and page wrapper
-->
<script lang="ts">
/**
* Layout Component
*
* Root layout wrapper that provides the application shell structure. Handles favicon,
* toolbar provider initialization, and renders child routes with consistent structure.
*
* Layout structure:
* - Header area (currently empty, reserved for future use)
*
* - Footer area (currently empty, reserved for future use)
*/
import { themeManager } from '$features/ChangeAppTheme';
import GD from '$shared/assets/GD.svg';
import G from '$shared/assets/G.svg';
import { ResponsiveProvider } from '$shared/lib';
import clsx from 'clsx';
import { cn } from '$shared/lib';
import { Footer } from '$widgets/Footer';
/*
Preload the two render-critical interface faces (primary + secondary).
`?url` resolves to the content-hashed path Vite emits, so the binary is
fetched immediately rather than waiting for CSS @font-face discovery.
*/
import interWoff2 from '../assets/fonts/inter-latin-opsz-normal.woff2?url';
import spaceGroteskWoff2 from '../assets/fonts/space-grotesk-latin-wght-normal.woff2?url';
import {
type Snippet,
onDestroy,
@@ -40,38 +39,23 @@ onDestroy(() => themeManager.destroy());
</script>
<svelte:head>
<link rel="icon" href={GD} />
<link rel="icon" href={G} type="image/svg+xml" />
<link rel="preconnect" href="https://api.fontshare.com" />
<!-- Self-hosted interface fonts (see src/app/styles/fonts/fonts.css). Preload the two critical faces. -->
<link
rel="preconnect"
href="https://cdn.fontshare.com"
crossorigin="anonymous"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
rel="preload"
as="font"
type="font/woff2"
href={interWoff2}
crossorigin="anonymous"
/>
<link
rel="preload"
as="style"
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
as="font"
type="font/woff2"
href={spaceGroteskWoff2}
crossorigin="anonymous"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
media="print"
onload={(e => ((e.currentTarget as HTMLLinkElement).media = 'all'))}
/>
<noscript>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
/>
</noscript>
<title>GlyphDiff | Typography & Typefaces</title>
<meta
name="description"
@@ -82,14 +66,15 @@ onDestroy(() => themeManager.destroy());
<ResponsiveProvider>
<div
id="app-root"
class={clsx(
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg',
class={cn(
'min-h-dvh w-auto flex flex-col surface-canvas relative',
theme === 'dark' ? 'dark' : '',
)}
>
{#if fontsReady}
{@render children?.()}
{/if}
<footer></footer>
<Footer />
</div>
</ResponsiveProvider>
@@ -20,6 +20,7 @@ let mockObserverInstances: MockIntersectionObserver[] = [];
class MockIntersectionObserver implements IntersectionObserver {
root = null;
rootMargin = '';
scrollMargin = '';
thresholds: number[] = [];
readonly callbacks: Array<(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void> = [];
readonly observedElements = new Set<Element>();
@@ -43,8 +43,8 @@ function createButtonText(item: BreadcrumbItem) {
md:h-16 px-4 md:px-6 lg:px-8
flex items-center justify-between
z-40
bg-surface/90 dark:bg-dark-bg/90 backdrop-blur-md
border-b border-subtle
surface-floating bg-surface/90 dark:bg-dark-bg/90
border-x-0 border-t-0
"
>
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">
+1
View File
@@ -9,6 +9,7 @@ export {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
seedFontCache,
} from './proxy/proxyFonts';
export type {
ProxyFontsParams,
@@ -21,6 +21,7 @@ vi.mock('$shared/api/api', () => ({
import { api } from '$shared/api/api';
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import { FontResponseError } from '../../lib/errors/errors';
import {
fetchFontsByIds,
fetchProxyFontById,
@@ -86,16 +87,20 @@ describe('proxyFonts', () => {
expect(calledUrl).toContain('offset=0');
});
test('should throw on invalid response (missing fonts array)', async () => {
test('should throw FontResponseError on invalid response (missing fonts array)', async () => {
mockApiGet({ total: 0 });
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
await expect(fetchProxyFonts()).rejects.toSatisfy(
e => e instanceof FontResponseError && e.field === 'response.fonts',
);
});
test('should throw on null response data', async () => {
test('should throw FontResponseError on null response data', async () => {
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
await expect(fetchProxyFonts()).rejects.toSatisfy(
e => e instanceof FontResponseError && e.field === 'response',
);
});
});
+17 -6
View File
@@ -15,6 +15,7 @@ import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import { FontResponseError } from '../../lib/errors/errors';
import type { UnifiedFont } from '../../model/types';
/**
@@ -29,10 +30,12 @@ export function seedFontCache(fonts: UnifiedFont[]): void {
});
}
import { API_ENDPOINTS } from '$shared/api/endpoints';
/**
* Proxy API base URL
* Proxy API endpoint for font resources.
*/
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const;
const PROXY_API_URL = API_ENDPOINTS.fonts;
/**
* Proxy API parameters
@@ -94,11 +97,16 @@ export interface ProxyFontsParams extends QueryParams {
/**
* Proxy API response
*
* Includes pagination metadata alongside font data
* Includes pagination metadata alongside font data.
*
* Contract: `fonts` is always an array never `null` or omitted, even when
* `total === 0`. Returning `null` on the wire is a backend regression and
* surfaces as FontResponseError (non-retryable) on the client.
*/
export interface ProxyFontsResponse {
/**
* List of font objects returned by the proxy
* List of font objects returned by the proxy.
* Always an array; empty when no matches.
*/
fonts: UnifiedFont[];
@@ -154,8 +162,11 @@ export async function fetchProxyFonts(
const response = await api.get<ProxyFontsResponse>(url);
if (!response.data || !Array.isArray(response.data.fonts)) {
throw new Error('Proxy API returned invalid response');
if (!response.data) {
throw new FontResponseError('response', response.data);
}
if (!Array.isArray(response.data.fonts)) {
throw new FontResponseError('response.fonts', response.data.fonts);
}
return response.data;
+5 -1
View File
@@ -1,3 +1,5 @@
import { NonRetryableError } from '$shared/api/queryClient';
/**
* Thrown when the network request to the proxy API fails.
* Wraps the underlying fetch error (timeout, DNS failure, connection refused, etc.).
@@ -12,11 +14,13 @@ export class FontNetworkError extends Error {
/**
* Thrown when the proxy API returns a response with an unexpected shape.
* Extends NonRetryableError because schema mismatches are not transient
* retrying will produce the same failure and only delay surfacing the bug.
*
* @property field - The name of the field that failed validation (e.g. `'response'`, `'response.fonts'`).
* @property received - The actual value received at that field, for debugging.
*/
export class FontResponseError extends Error {
export class FontResponseError extends NonRetryableError {
readonly name = 'FontResponseError';
constructor(
+3 -3
View File
@@ -667,10 +667,10 @@ export const MOCK_STORES = {
};
},
/**
* Create a mock FontStore object
* Matches FontStore's public API for Storybook use
* Create a mock FontCatalogStore object
* Matches FontCatalogStore's public API for Storybook use
*/
fontStore: (config: {
fontCatalogStore: (config: {
/**
* Preset font list
*/
@@ -1,7 +1,19 @@
// @vitest-environment jsdom
import { TextLayoutEngine } from '$shared/lib';
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
import { clearCache } from '@chenglou/pretext';
import {
clearCache,
layout,
} from '@chenglou/pretext';
// Wrap pretext's `layout` in a spy-able mock so tests can assert call counts.
// `vi.mock` is hoisted, so the import above receives the mocked module.
vi.mock('@chenglou/pretext', async () => {
const actual = await vi.importActual<typeof import('@chenglou/pretext')>('@chenglou/pretext');
return {
...actual,
layout: vi.fn(actual.layout),
};
});
import {
beforeEach,
describe,
@@ -112,13 +124,13 @@ describe('createFontRowSizeResolver', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'loaded');
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
const layoutSpy = vi.mocked(layout);
layoutSpy.mockClear();
resolver(0);
resolver(0);
expect(layoutSpy).toHaveBeenCalledTimes(1);
layoutSpy.mockRestore();
});
it('calls layout() again when containerWidth changes (cache miss)', () => {
@@ -126,14 +138,14 @@ describe('createFontRowSizeResolver', () => {
const { resolver } = makeResolver({ getContainerWidth: () => width });
statusMap.set('inter@400', 'loaded');
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
const layoutSpy = vi.mocked(layout);
layoutSpy.mockClear();
resolver(0);
width = 100;
resolver(0);
expect(layoutSpy).toHaveBeenCalledTimes(2);
layoutSpy.mockRestore();
});
it('returns greater height when container narrows (more wrapping)', () => {
@@ -1,5 +1,8 @@
import { TextLayoutEngine } from '$shared/lib';
import { generateFontKey } from '../../model/store/appliedFontsStore/utils/generateFontKey/generateFontKey';
import {
layout,
prepare,
} from '@chenglou/pretext';
import { generateFontKey } from '../../model/store/fontLifecycleManager/utils/generateFontKey/generateFontKey';
import type {
FontLoadStatus,
UnifiedFont,
@@ -41,7 +44,7 @@ export interface FontRowSizeResolverOptions {
/**
* Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`).
*
* In production: `(key) => appliedFontsManager.statuses.get(key)`.
* In production: `(key) => fontLifecycleManager.statuses.get(key)`.
* Injected for testability avoids a module-level singleton dependency in tests.
* The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context
* for reactivity to work. This is satisfied when `itemHeight` is called by
@@ -79,14 +82,13 @@ export interface FontRowSizeResolverOptions {
* no DOM snap occurs.
*
* **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx`
* prevents redundant `TextLayoutEngine.layout()` calls. The cache is invalidated
* prevents redundant `pretext.layout()` calls. The cache is invalidated
* naturally because a change in any input produces a different cache key.
*
* @param options - Configuration and getter functions (all injected for testability).
* @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`.
*/
export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number {
const engine = new TextLayoutEngine();
// Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}`
const cache = new Map<string, number>();
@@ -108,7 +110,7 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
// generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable });
// Reading via getStatus() allows the caller to pass appliedFontsManager.statuses.get(),
// Reading via getStatus() allows the caller to pass fontLifecycleManager.statuses.get(),
// which creates a Svelte 5 reactive dependency when called inside $derived.by.
const status = options.getStatus(fontKey);
if (status !== 'loaded') {
@@ -126,7 +128,11 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
return cached;
}
const { totalHeight } = engine.layout(previewText, fontCssString, contentWidth, lineHeightPx);
// Pretext docs recommend `layout()` (not `layoutWithLines`) for the
// resize hot path — pure arithmetic on cached segment widths, no canvas
// calls, no string allocations.
const prepared = prepare(previewText, fontCssString);
const { height: totalHeight } = layout(prepared, contentWidth, lineHeightPx);
const result = totalHeight + options.chromeHeight;
cache.set(cacheKey, result);
return result;
@@ -17,13 +17,17 @@ import {
generateMockFonts,
} from '../../../lib/mocks/fonts.mock';
import type { UnifiedFont } from '../../types';
import { FontStore } from './fontStore.svelte';
import { FontCatalogStore } from './fontCatalogStore.svelte';
vi.mock('$shared/api/queryClient', () => ({
queryClient: new QueryClient({
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
}),
}));
vi.mock('$shared/api/queryClient', async importOriginal => {
const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
return {
...actual,
queryClient: new QueryClient({
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
}),
};
});
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
import { queryClient } from '$shared/api/queryClient';
@@ -44,7 +48,7 @@ const makeResponse = (
});
function makeStore(params = {}) {
return new FontStore({ limit: 10, ...params });
return new FontCatalogStore({ limit: 10, ...params });
}
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
@@ -55,7 +59,7 @@ async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Par
return store;
}
describe('FontStore', () => {
describe('FontCatalogStore', () => {
afterEach(() => {
queryClient.clear();
vi.resetAllMocks();
@@ -69,7 +73,7 @@ describe('FontStore', () => {
});
it('defaults limit to 50 when not provided', () => {
const store = new FontStore();
const store = new FontCatalogStore();
expect(store.params.limit).toBe(50);
store.destroy();
});
@@ -80,9 +84,10 @@ describe('FontStore', () => {
store.destroy();
});
it('starts with isEmpty false — initial fetch is in progress', () => {
// The observer starts fetching immediately on construction.
// isEmpty must be false so the UI shows a loader, not "no results".
it('starts with isEmpty false — observer is gated until setParams enables it', () => {
// The observer is disabled on construction (no auto-fetch) — see
// `#enabled` in the store. isEmpty must still be false so the UI
// doesn't flash "no results" before bindings configures the query.
const store = makeStore();
expect(store.isEmpty).toBe(false);
store.destroy();
@@ -390,11 +395,11 @@ describe('FontStore', () => {
});
describe('nextPage', () => {
let store: FontStore;
let store: FontCatalogStore;
beforeEach(async () => {
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
store = new FontStore({ limit: 10 });
store = new FontCatalogStore({ limit: 10 });
await store.refetch();
flushSync();
});
@@ -415,7 +420,7 @@ describe('FontStore', () => {
// Set up a store where all fonts fit in one page (hasMore = false)
queryClient.clear();
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
store = new FontStore({ limit: 10 });
store = new FontCatalogStore({ limit: 10 });
await store.refetch();
flushSync();
@@ -454,7 +459,7 @@ describe('FontStore', () => {
describe('getCachedData / setQueryData', () => {
it('getCachedData returns undefined before any fetch', () => {
queryClient.clear();
const store = new FontStore({ limit: 10 });
const store = new FontCatalogStore({ limit: 10 });
expect(store.getCachedData()).toBeUndefined();
store.destroy();
});
@@ -502,7 +507,7 @@ describe('FontStore', () => {
});
describe('filter shortcut methods', () => {
let store: FontStore;
let store: FontCatalogStore;
beforeEach(() => {
store = makeStore();
@@ -1,4 +1,8 @@
import { queryClient } from '$shared/api/queryClient';
import {
DEFAULT_QUERY_GC_TIME_MS,
DEFAULT_QUERY_STALE_TIME_MS,
queryClient,
} from '$shared/api/queryClient';
import {
type InfiniteData,
InfiniteQueryObserver,
@@ -25,8 +29,15 @@ type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
export class FontStore {
export class FontCatalogStore {
#params = $state<FontStoreParams>({ limit: 50 });
/**
* Gates the initial fetch. The observer starts disabled so the constructor
* cannot race ahead of the bindings module which is the single source of
* truth for query params. The first setParams flips this on, producing a
* single fetch with the correctly merged queryKey.
*/
#enabled = $state(false);
#result = $state<FontStoreResult>({} as FontStoreResult);
#observer: InfiniteQueryObserver<
ProxyFontsResponse,
@@ -41,6 +52,8 @@ export class FontStore {
constructor(params: FontStoreParams = {}) {
this.#params = { limit: 50, ...params };
this.#observer = new InfiniteQueryObserver(this.#qc, this.buildOptions());
// Seed result synchronously; subscribe may not fire on disabled observers.
this.#result = this.#observer.getCurrentResult();
this.#unsubscribe = this.#observer.subscribe(r => {
this.#result = r;
});
@@ -84,10 +97,13 @@ export class FontStore {
return this.#result.error ?? null;
}
/**
* True if no fonts were found for the current filter criteria
* True if no fonts were found for the current filter criteria.
* Always false until the observer has been enabled (via setParams) otherwise
* the UI would briefly render "no results" on mount before bindings configures
* the query.
*/
get isEmpty(): boolean {
return !this.isLoading && !this.isFetching && this.fonts.length === 0;
return this.#enabled && !this.isLoading && !this.isFetching && this.fonts.length === 0;
}
/**
@@ -125,10 +141,12 @@ export class FontStore {
}
/**
* Merge new parameters into existing state and trigger a refetch
* Merge new parameters into existing state and trigger a refetch.
* The first call also enables the observer (see `#enabled`).
*/
setParams(updates: Partial<FontStoreParams>) {
this.#params = { ...this.#params, ...updates };
this.#enabled = true;
this.#observer.setOptions(this.buildOptions());
}
/**
@@ -427,8 +445,9 @@ export class FontStore {
const next = lastPage.offset + lastPage.limit;
return next < lastPage.total ? { offset: next } : undefined;
},
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
enabled: this.#enabled,
staleTime: hasFilters ? 0 : DEFAULT_QUERY_STALE_TIME_MS,
gcTime: DEFAULT_QUERY_GC_TIME_MS,
};
}
@@ -437,6 +456,11 @@ export class FontStore {
try {
response = await fetchProxyFonts(params);
} catch (cause) {
// Preserve non-retryable validation errors so the query client doesn't
// burn the retry budget on a deterministic schema mismatch.
if (cause instanceof FontResponseError) {
throw cause;
}
throw new FontNetworkError(cause);
}
@@ -459,8 +483,8 @@ export class FontStore {
}
}
export function createFontStore(params: FontStoreParams = {}): FontStore {
return new FontStore(params);
export function createFontCatalogStore(params: FontStoreParams = {}): FontCatalogStore {
return new FontCatalogStore(params);
}
export const fontStore = new FontStore({ limit: 50 });
export const fontCatalogStore = new FontCatalogStore({ limit: 50 });
@@ -17,7 +17,36 @@ import { FontBufferCache } from './utils/fontBufferCache/FontBufferCache';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
interface AppliedFontsManagerDeps {
/**
* How often the periodic eviction sweep runs.
*/
const PURGE_INTERVAL_MS = 60000;
/**
* Timeout for `requestIdleCallback`. After this elapses, the callback is
* forced to run regardless of whether the browser is idle.
*/
const IDLE_CALLBACK_TIMEOUT_MS = 150;
/**
* setTimeout fallback delay when `requestIdleCallback` is unavailable.
* ~16ms one frame at 60fps.
*/
const SCHEDULE_FALLBACK_MS = 16;
/**
* How often the parse loop yields back to the main thread when the browser
* does not provide `isInputPending` (non-Chromium fallback).
*/
const YIELD_INTERVAL_MS = 8;
/**
* Font weights treated as "critical" in data-saver mode. Other weights are
* skipped to reduce network usage; variable fonts bypass this filter.
*/
const CRITICAL_FONT_WEIGHTS = [400, 700];
interface FontLifecycleManagerDeps {
cache?: FontBufferCache;
eviction?: FontEvictionPolicy;
queue?: FontLoadQueue;
@@ -46,7 +75,7 @@ interface AppliedFontsManagerDeps {
*
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
*/
export class AppliedFontsManager {
export class FontLifecycleManager {
// Injected collaborators - each handles one concern for better testability
readonly #cache: FontBufferCache;
readonly #eviction: FontEvictionPolicy;
@@ -70,22 +99,20 @@ export class AppliedFontsManager {
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
#pendingType: 'idle' | 'timeout' | null = null;
readonly #PURGE_INTERVAL = 60000;
// Reactive status map for Svelte components to track font states
statuses = new SvelteMap<string, FontLoadStatus>();
// Starts periodic cleanup timer (browser-only).
constructor(
{ cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }:
AppliedFontsManagerDeps = {},
FontLifecycleManagerDeps = {},
) {
// Inject collaborators - defaults provided for production, fakes for testing
this.#cache = cache;
this.#eviction = eviction;
this.#queue = queue;
if (typeof window !== 'undefined') {
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
this.#intervalId = setInterval(() => this.#purgeUnused(), PURGE_INTERVAL_MS);
}
}
@@ -147,11 +174,11 @@ export class AppliedFontsManager {
if (typeof requestIdleCallback !== 'undefined') {
this.#timeoutId = requestIdleCallback(
() => this.#processQueue(),
{ timeout: 150 },
{ timeout: IDLE_CALLBACK_TIMEOUT_MS },
) as unknown as ReturnType<typeof setTimeout>;
this.#pendingType = 'idle';
} else {
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
this.#timeoutId = setTimeout(() => this.#processQueue(), SCHEDULE_FALLBACK_MS);
this.#pendingType = 'timeout';
}
}
@@ -183,7 +210,7 @@ export class AppliedFontsManager {
// In data-saver mode, only load variable fonts and common weights (400, 700)
if (this.#shouldDeferNonCritical()) {
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
entries = entries.filter(([, c]) => c.isVariable || CRITICAL_FONT_WEIGHTS.includes(c.weight));
}
// Determine optimal concurrent fetches based on network speed (1-4)
@@ -198,7 +225,6 @@ export class AppliedFontsManager {
// Parse buffers one at a time with periodic yields to avoid blocking UI
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
let lastYield = performance.now();
const YIELD_INTERVAL = 8;
for (const [key, config] of entries) {
const buffer = buffers.get(key);
@@ -214,7 +240,7 @@ export class AppliedFontsManager {
// Others: yield every 8ms as fallback
const shouldYield = hasInputPending
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
: performance.now() - lastYield > YIELD_INTERVAL;
: performance.now() - lastYield > YIELD_INTERVAL_MS;
if (shouldYield) {
await yieldToMainThread();
@@ -396,4 +422,4 @@ export class AppliedFontsManager {
/**
* Singleton instance use throughout the application for unified font loading state.
*/
export const appliedFontsManager = new AppliedFontsManager();
export const fontLifecycleManager = new FontLifecycleManager();
@@ -1,8 +1,8 @@
/**
* @vitest-environment jsdom
*/
import { AppliedFontsManager } from './appliedFontsStore.svelte';
import { FontFetchError } from './errors';
import { FontLifecycleManager } from './fontLifecycleManager.svelte';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
class FakeBufferCache {
@@ -32,8 +32,8 @@ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable:
...overrides,
});
describe('AppliedFontsManager', () => {
let manager: AppliedFontsManager;
describe('FontLifecycleManager', () => {
let manager: FontLifecycleManager;
let eviction: FontEvictionPolicy;
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
@@ -55,7 +55,7 @@ describe('AppliedFontsManager', () => {
});
vi.stubGlobal('FontFace', MockFontFace);
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction });
manager = new FontLifecycleManager({ cache: new FakeBufferCache() as any, eviction });
});
afterEach(() => {
@@ -101,7 +101,7 @@ describe('AppliedFontsManager', () => {
it('skips fonts that have exhausted retries', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
// exhaust all 3 retries
for (let i = 0; i < 3; i++) {
@@ -160,7 +160,7 @@ describe('AppliedFontsManager', () => {
describe('Phase 1 — fetch', () => {
it('sets status to error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
@@ -171,7 +171,7 @@ describe('AppliedFontsManager', () => {
it('logs a console error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
@@ -189,7 +189,7 @@ describe('AppliedFontsManager', () => {
evict() {},
clear() {},
};
const abortManager = new AppliedFontsManager({ cache: abortingCache as any, eviction });
const abortManager = new FontLifecycleManager({ cache: abortingCache as any, eviction });
abortManager.touch([makeConfig('aborted')]);
await vi.advanceTimersByTimeAsync(50);
@@ -1,6 +1,11 @@
/**
* Default TTL after which an unpinned font is eligible for eviction.
*/
export const DEFAULT_FONT_TTL_MS = 5 * 60 * 1000;
interface FontEvictionPolicyOptions {
/**
* TTL in milliseconds. Defaults to 5 minutes.
* TTL in milliseconds. Defaults to {@link DEFAULT_FONT_TTL_MS}.
*/
ttl?: number;
}
@@ -17,7 +22,7 @@ export class FontEvictionPolicy {
readonly #TTL: number;
constructor({ ttl = 5 * 60 * 1000 }: FontEvictionPolicyOptions = {}) {
constructor({ ttl = DEFAULT_FONT_TTL_MS }: FontEvictionPolicyOptions = {}) {
this.#TTL = ttl;
}
@@ -1,5 +1,11 @@
import type { FontLoadRequestConfig } from '../../../../types';
/**
* Maximum number of times a single font key will be retried before it is
* considered permanently failed.
*/
export const FONT_LOAD_MAX_RETRIES = 3;
/**
* Manages the font load queue and per-font retry counts.
*
@@ -10,8 +16,6 @@ export class FontLoadQueue {
#queue = new Map<string, FontLoadRequestConfig>();
#retryCounts = new Map<string, number>();
readonly #MAX_RETRIES = 3;
/**
* Adds a font to the queue.
* @returns `true` if the key was newly enqueued, `false` if it was already present.
@@ -52,7 +56,7 @@ export class FontLoadQueue {
* Returns `true` if the font has reached or exceeded the maximum retry limit.
*/
isMaxRetriesReached(key: string): boolean {
return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES;
return (this.#retryCounts.get(key) ?? 0) >= FONT_LOAD_MAX_RETRIES;
}
/**
@@ -2,9 +2,7 @@
* Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() where available or MessageChannel fallback.
*/
export async function yieldToMainThread(): Promise<void> {
// @ts-expect-error - scheduler not in TypeScript lib yet
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
await scheduler.yield();
} else {
await new Promise<void>(resolve => {
+7 -10
View File
@@ -1,12 +1,9 @@
// Applied fonts manager
export * from './appliedFontsStore/appliedFontsStore.svelte';
// Font lifecycle manager (browser-side load + cache + eviction)
export * from './fontLifecycleManager/fontLifecycleManager.svelte';
// Batch font store
export { BatchFontStore } from './batchFontStore.svelte';
// Single FontStore
// Paginated catalog
export {
createFontStore,
FontStore,
fontStore,
} from './fontStore/fontStore.svelte';
createFontCatalogStore,
FontCatalogStore,
fontCatalogStore,
} from './fontCatalogStore/fontCatalogStore.svelte';
+1 -1
View File
@@ -23,5 +23,5 @@ export type {
FontCollectionState,
} from './store';
export * from './store/appliedFonts';
export * from './store/fontLifecycle';
export * from './typography';
@@ -39,7 +39,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: {
description: {
story:
'Font that has never been loaded by appliedFontsManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
'Font that has never been loaded by fontLifecycleManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
},
},
}}
@@ -58,7 +58,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: {
description: {
story:
'Uses Arial, a system font available in all browsers. Because appliedFontsManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
'Uses Arial, a system font available in all browsers. Because fontLifecycleManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
},
},
}}
@@ -77,7 +77,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: {
description: {
story:
'Demonstrates passing a custom weight (700). The weight is forwarded to appliedFontsManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
'Demonstrates passing a custom weight (700). The weight is forwarded to fontLifecycleManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
},
},
}}
@@ -4,12 +4,12 @@
Shows the skeleton snippet while loading; falls back to system font if no skeleton is provided.
-->
<script lang="ts">
import clsx from 'clsx';
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
import {
DEFAULT_FONT_WEIGHT,
type UnifiedFont,
appliedFontsManager,
fontLifecycleManager,
} from '../../model';
interface Props {
@@ -46,7 +46,7 @@ let {
}: Props = $props();
const status = $derived(
appliedFontsManager.getFontStatus(
fontLifecycleManager.getFontStatus(
font.id,
weight,
font.features?.isVariable,
@@ -61,7 +61,7 @@ const shouldReveal = $derived(status === 'loaded' || status === 'error');
{:else}
<div
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
class={clsx(className)}
class={cn(className)}
>
{@render children?.()}
</div>
@@ -10,7 +10,7 @@ const { Story } = defineMeta({
docs: {
description: {
component:
'Virtualized font list backed by the `fontStore` singleton. Handles font loading registration (pin/touch) for visible items and triggers infinite scroll pagination via `fontStore.nextPage()`. Because the component reads directly from the `fontStore` singleton, stories render against a live (but empty/loading) store — no font data will appear unless the API is reachable from the Storybook host.',
'Virtualized font list backed by the `fontCatalogStore` singleton. Handles font loading registration (pin/touch) for visible items and triggers infinite scroll pagination via `fontCatalogStore.nextPage()`. Because the component reads directly from the `fontCatalogStore` singleton, stories render against a live (but empty/loading) store — no font data will appear unless the API is reachable from the Storybook host.',
},
story: { inline: false },
},
@@ -33,7 +33,7 @@ import type { ComponentProps } from 'svelte';
docs: {
description: {
story:
'Skeleton state shown while `fontStore.fonts` is empty and `fontStore.isLoading` is true. In a real session the skeleton fades out once the first page loads.',
'Skeleton state shown while `fontCatalogStore.fonts` is empty and `fontCatalogStore.isLoading` is true. In a real session the skeleton fades out once the first page loads.',
},
},
}}
@@ -63,7 +63,7 @@ import type { ComponentProps } from 'svelte';
docs: {
description: {
story:
'No `skeleton` snippet provided. When `fontStore.fonts` is empty the underlying VirtualList renders its empty state directly.',
'No `skeleton` snippet provided. When `fontCatalogStore.fonts` is empty the underlying VirtualList renders its empty state directly.',
},
},
}}
@@ -86,7 +86,7 @@ import type { ComponentProps } from 'svelte';
docs: {
description: {
story:
'Demonstrates how to configure a `children` snippet for item rendering. The list will be empty because `fontStore` is not populated in Storybook, but the template shows the expected slot shape: `{ item: UnifiedFont }`.',
'Demonstrates how to configure a `children` snippet for item rendering. The list will be empty because `fontCatalogStore` is not populated in Storybook, but the template shows the expected slot shape: `{ item: UnifiedFont }`.',
},
},
}}
@@ -18,8 +18,8 @@ import { getFontUrl } from '../../lib';
import {
type FontLoadRequestConfig,
type UnifiedFont,
appliedFontsManager,
fontStore,
fontCatalogStore,
fontLifecycleManager,
} from '../../model';
interface Props extends
@@ -40,6 +40,10 @@ interface Props extends
* Skeleton snippet
*/
skeleton?: Snippet;
/**
* Empty-state snippet rendered when the query settled with zero fonts
*/
empty?: Snippet;
}
let {
@@ -47,18 +51,21 @@ let {
onVisibleItemsChange,
weight,
skeleton,
empty,
...rest
}: Props = $props();
const isLoading = $derived(
fontStore.isFetching || fontStore.isLoading,
fontCatalogStore.isFetching || fontCatalogStore.isLoading,
);
let visibleFonts = $state<UnifiedFont[]>([]);
let isCatchingUp = $state(false);
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontStore.fonts.length === 0);
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontCatalogStore.fonts.length === 0);
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
// Settled query with no matches — empty state replaces the (otherwise blank) list.
const showEmpty = $derived(!!empty && !isLoading && !isCatchingUp && fontCatalogStore.fonts.length === 0);
function handleInternalVisibleChange(items: UnifiedFont[]) {
visibleFonts = items;
@@ -68,24 +75,30 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
/**
* Handle jump scroll — batch-load all missing pages then re-enable font loading.
* Suppresses appliedFontsManager.touch() during catch-up to avoid loading
* Suppresses fontLifecycleManager.touch() during catch-up to avoid loading
* font files for thousands of intermediate fonts.
*/
async function handleJump(targetIndex: number) {
if (isCatchingUp || !fontStore.pagination.hasMore) {
if (isCatchingUp || !fontCatalogStore.pagination.hasMore) {
return;
}
isCatchingUp = true;
try {
await fontStore.fetchAllPagesTo(targetIndex);
await fontCatalogStore.fetchAllPagesTo(targetIndex);
} finally {
isCatchingUp = false;
}
}
/**
* Debounce wait before asking the font lifecycle manager to load fonts
* for the current visible window. Coalesces rapid scroll into one batch.
*/
const TOUCH_DEBOUNCE_MS = 150;
const debouncedTouch = debounce((configs: FontLoadRequestConfig[]) => {
appliedFontsManager.touch(configs);
}, 150);
fontLifecycleManager.touch(configs);
}, TOUCH_DEBOUNCE_MS);
// Re-touch whenever visible set or weight changes — fixes weight-change gap
$effect(() => {
@@ -111,11 +124,11 @@ $effect(() => {
const w = weight;
const fonts = visibleFonts;
for (const f of fonts) {
appliedFontsManager.pin(f.id, w, f.features?.isVariable);
fontLifecycleManager.pin(f.id, w, f.features?.isVariable);
}
return () => {
for (const f of fonts) {
appliedFontsManager.unpin(f.id, w, f.features?.isVariable);
fontLifecycleManager.unpin(f.id, w, f.features?.isVariable);
}
};
});
@@ -125,12 +138,12 @@ $effect(() => {
*/
function loadMore() {
if (
!fontStore.pagination.hasMore
|| fontStore.isFetching
!fontCatalogStore.pagination.hasMore
|| fontCatalogStore.isFetching
) {
return;
}
fontStore.nextPage();
fontCatalogStore.nextPage();
}
/**
@@ -140,12 +153,12 @@ function loadMore() {
* of the loaded items. Only fetches if there are more pages available.
*/
function handleNearBottom(_lastVisibleIndex: number) {
const { hasMore } = fontStore.pagination;
const { hasMore } = fontCatalogStore.pagination;
// VirtualList already checks if we're near the bottom of loaded items.
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
// during batch catch-up, which would otherwise let nextPage() race with it.
if (hasMore && !fontStore.isFetching && !isCatchingUp) {
if (hasMore && !fontCatalogStore.isFetching && !isCatchingUp) {
loadMore();
}
}
@@ -157,11 +170,15 @@ function handleNearBottom(_lastVisibleIndex: number) {
<div class="overflow-hidden h-full" transition:fade={{ duration: 300 }}>
{@render skeleton()}
</div>
{:else if showEmpty && empty}
<div class="h-full" transition:fade={{ duration: 200 }}>
{@render empty()}
</div>
{:else}
<!-- VirtualList persists during pagination - no destruction/recreation -->
<VirtualList
items={fontStore.fonts}
total={fontStore.pagination.total}
items={fontCatalogStore.fonts}
total={fontCatalogStore.pagination.total}
isLoading={isLoading || isCatchingUp}
onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom}
+6
View File
@@ -0,0 +1,6 @@
export {
createTypographySettingsStore,
type TypographySettingsStore,
typographySettingsStore,
} from './model';
export { TypographyMenu } from './ui';
@@ -0,0 +1,5 @@
export {
createTypographySettingsStore,
type TypographySettingsStore,
typographySettingsStore,
} from './store/typographySettingsStore/typographySettingsStore.svelte';
@@ -16,6 +16,7 @@ import {
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
} from '$entities/Font';
import {
type ControlDataModel,
@@ -27,6 +28,14 @@ import {
} from '$shared/lib';
import { SvelteMap } from 'svelte/reactivity';
/**
* Epsilon for detecting "significant" base-size changes when reconciling
* the multiplier-derived display value back to the underlying baseSize.
* Differences below this threshold are treated as rounding jitter and
* skipped to avoid spurious storage writes.
*/
const BASE_SIZE_EPSILON = 0.01;
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
/**
@@ -67,7 +76,7 @@ export interface TypographySettings {
* Manages multiple typography controls with persistent storage and
* responsive scaling support for font size.
*/
export class TypographySettingsManager {
export class TypographySettingsStore {
/**
* Internal map of reactive controls keyed by their identifier
*/
@@ -138,7 +147,7 @@ export class TypographySettingsManager {
const calculatedBase = currentDisplayValue / this.#multiplier;
// Only update if the difference is significant (prevents rounding jitter)
if (Math.abs(this.#baseSize - calculatedBase) > 0.01) {
if (Math.abs(this.#baseSize - calculatedBase) > BASE_SIZE_EPSILON) {
this.#baseSize = calculatedBase;
}
});
@@ -296,6 +305,16 @@ export class TypographySettingsManager {
}
}
/**
* Default factory storage key used when a caller doesn't pass one.
*/
const DEFAULT_STORAGE_KEY = 'glyphdiff:typography';
/**
* Storage key used by the app-wide singleton (scoped to comparison view).
*/
const COMPARISON_STORAGE_KEY = 'glyphdiff:comparison:typography';
/**
* Creates a typography control manager
*
@@ -303,9 +322,9 @@ export class TypographySettingsManager {
* @param storageId - Persistent storage identifier
* @returns Typography control manager instance
*/
export function createTypographySettingsManager(
export function createTypographySettingsStore(
configs: ControlModel<ControlId>[],
storageId: string = 'glyphdiff:typography',
storageId: string = DEFAULT_STORAGE_KEY,
) {
const storage = createPersistentStore<TypographySettings>(storageId, {
fontSize: DEFAULT_FONT_SIZE,
@@ -313,5 +332,13 @@ export function createTypographySettingsManager(
lineHeight: DEFAULT_LINE_HEIGHT,
letterSpacing: DEFAULT_LETTER_SPACING,
});
return new TypographySettingsManager(configs, storage);
return new TypographySettingsStore(configs, storage);
}
/**
* App-wide typography settings singleton, keyed for the comparison view.
*/
export const typographySettingsStore = createTypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
COMPARISON_STORAGE_KEY,
);
@@ -17,13 +17,13 @@ import {
} from 'vitest';
import {
type TypographySettings,
TypographySettingsManager,
} from './settingsManager.svelte';
TypographySettingsStore,
} from './typographySettingsStore.svelte';
/**
* Test Strategy for TypographySettingsManager
* Test Strategy for TypographySettingsStore
*
* This test suite validates the TypographySettingsManager state management logic.
* This test suite validates the TypographySettingsStore state management logic.
* These are unit tests for the manager logic, separate from component rendering.
*
* NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
@@ -46,7 +46,7 @@ async function flushEffects() {
await Promise.resolve();
}
describe('TypographySettingsManager - Unit Tests', () => {
describe('TypographySettingsStore - Unit Tests', () => {
let mockStorage: TypographySettings;
let mockPersistentStore: {
value: TypographySettings;
@@ -86,7 +86,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Initialization', () => {
it('creates manager with default values from storage', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -106,7 +106,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
};
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -118,7 +118,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -127,7 +127,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('returns all controls via controls getter', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -143,7 +143,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('returns individual controls via specific getters', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -161,7 +161,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('control instances have expected interface', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -180,7 +180,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Multiplier System', () => {
it('has default multiplier of 1', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -189,7 +189,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('updates multiplier when set', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -202,7 +202,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('does not update multiplier if set to same value', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -218,7 +218,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -242,7 +242,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('updates font size control display value when multiplier increases', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -263,7 +263,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Base Size Setter', () => {
it('updates baseSize when set directly', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -274,7 +274,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('updates size control value when baseSize is set', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -285,7 +285,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('applies multiplier to size control when baseSize is set', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -299,7 +299,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Rendered Size Calculation', () => {
it('calculates renderedSize as baseSize * multiplier', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -308,7 +308,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('updates renderedSize when multiplier changes', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -321,7 +321,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('updates renderedSize when baseSize changes', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -341,7 +341,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
// proxy effect behavior should be tested in E2E tests.
it('does NOT immediately update baseSize from control change (effect is async)', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -356,7 +356,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('updates baseSize via direct setter (synchronous)', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -381,7 +381,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
};
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -394,7 +394,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('syncs to storage after effect flush (async)', async () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -410,7 +410,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('syncs control changes to storage after effect flush (async)', async () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -423,7 +423,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('syncs height control changes to storage after effect flush (async)', async () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -435,7 +435,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('syncs spacing control changes to storage after effect flush (async)', async () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -449,7 +449,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Control Value Getters', () => {
it('returns current weight value', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -461,7 +461,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('returns current height value', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -473,7 +473,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('returns current spacing value', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -486,7 +486,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
it('returns default value when control is not found', () => {
// Create a manager with empty configs (no controls)
const manager = new TypographySettingsManager([], mockPersistentStore);
const manager = new TypographySettingsStore([], mockPersistentStore);
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
@@ -504,7 +504,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
};
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -537,7 +537,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
clear: clearSpy,
};
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -548,7 +548,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('respects multiplier when resetting font size control', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -566,7 +566,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Complex Scenarios', () => {
it('handles changing multiplier then modifying baseSize', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -587,7 +587,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('maintains correct renderedSize throughout changes', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -609,7 +609,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('handles multiple control changes in sequence', async () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -634,7 +634,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -646,7 +646,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('handles very small multiplier', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -659,7 +659,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('handles large base size with multiplier', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -672,7 +672,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('handles floating point precision in multiplier', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -691,7 +691,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('handles control methods (increase/decrease)', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -705,7 +705,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('handles control boundary conditions', () => {
const manager = new TypographySettingsManager(
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -0,0 +1,167 @@
<!--
Component: TypographyMenu
Floating controls bar for typography settings.
Mobile: popover with slider controls anchored to settings button.
Desktop: inline bar with combo controls.
-->
<script lang="ts">
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib';
import {
Button,
ComboControl,
ControlGroup,
Popover,
Slider,
} from '$shared/ui';
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import XIcon from '@lucide/svelte/icons/x';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { typographySettingsStore } from '../../model';
interface Props {
/**
* CSS classes
*/
class?: string;
/**
* Hidden state
* @default false
*/
hidden?: boolean;
/**
* Bindable popover open state
* @default false
*/
open?: boolean;
}
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive');
/**
* Sets the common font size multiplier based on the current responsive state.
*/
$effect(() => {
if (!responsive) {
return;
}
switch (true) {
case responsive.isMobile:
typographySettingsStore.multiplier = MULTIPLIER_S;
break;
case responsive.isTablet:
typographySettingsStore.multiplier = MULTIPLIER_M;
break;
case responsive.isDesktop:
typographySettingsStore.multiplier = MULTIPLIER_L;
break;
default:
typographySettingsStore.multiplier = MULTIPLIER_L;
}
});
</script>
{#if !hidden}
{#if responsive.isMobileOrTablet}
<div class={className}>
<Popover bind:open side="top" align="end" sideOffset={8}>
{#snippet trigger(props)}
<Button variant="primary" {...props}>
{#snippet icon()}
<Settings2Icon class="size-4" />
{/snippet}
</Button>
{/snippet}
{#snippet children({ close })}
<div
class={cn(
'w-72 p-4 rounded-none',
'surface-popover',
)}
>
<!-- Header -->
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
<div class="flex items-center gap-1.5">
<Settings2Icon size={12} class="text-swiss-red" />
<span
class="text-3xs font-mono uppercase tracking-widest font-bold text-swiss-black dark:text-neutral-200"
>
CONTROLS
</span>
</div>
<button
onclick={close}
class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label="Close controls"
>
<XIcon class="size-3.5 text-neutral-500" />
</button>
</div>
<!-- Controls -->
{#each typographySettingsStore.controls as control (control.id)}
<ControlGroup label={control.controlLabel ?? ''}>
<Slider
bind:value={control.instance.value}
min={control.instance.min}
max={control.instance.max}
step={control.instance.step}
/>
</ControlGroup>
{/each}
</div>
{/snippet}
</Popover>
</div>
{:else}
<div
class={cn('w-full md:w-auto', className)}
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
>
<div
class={cn(
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
'surface-floating bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
'shadow-popover rounded-none',
'ring-1 ring-black/5 dark:ring-white/5',
)}
>
<!-- Header: icon + label -->
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
<Settings2Icon
size={14}
class="text-swiss-red"
/>
<span
class="text-3xs md:text-2xs font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
>
GLOBAL_CONTROLS
</span>
</div>
<!-- Controls with dividers between each -->
{#each typographySettingsStore.controls as control, i (control.id)}
<div class="w-px h-4 md:h-6 bg-subtle mx-0.5 md:mx-1 shrink-0"></div>
<ComboControl
control={control.instance}
label={control.controlLabel}
increaseLabel={control.increaseLabel}
decreaseLabel={control.decreaseLabel}
controlLabel={control.controlLabel}
/>
{/each}
</div>
</div>
{/if}
{/if}
@@ -30,6 +30,8 @@
import { createPersistentStore } from '$shared/lib';
export const STORAGE_KEY = 'glyphdiff:theme';
type Theme = 'light' | 'dark';
type ThemeSource = 'system' | 'user';
@@ -56,7 +58,7 @@ class ThemeManager {
/**
* Persistent storage for user's theme preference
*/
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
#store = createPersistentStore<Theme | null>(STORAGE_KEY, null);
/**
* Bound handler for system theme change events
*/
@@ -40,8 +40,7 @@ import { ThemeManager } from './ThemeManager.svelte';
* - MediaQueryList listener management
*/
// Storage key used by ThemeManager
const STORAGE_KEY = 'glyphdiff:theme';
import { STORAGE_KEY } from './ThemeManager.svelte';
// Helper type for MediaQueryList event handler
type MediaQueryListCallback = (this: MediaQueryList, ev: MediaQueryListEvent) => void;
@@ -8,7 +8,7 @@ import {
FontApplicator,
type UnifiedFont,
} from '$entities/Font';
import { typographySettingsStore } from '$features/SetupFont/model';
import { typographySettingsStore } from '$features/AdjustTypography/model';
import {
Badge,
ContentEditable,
@@ -58,12 +58,10 @@ const stats = $derived([
class="
group relative
w-full h-full
bg-paper dark:bg-dark-card
border border-subtle
surface-card
hover:border-brand dark:hover:border-brand
hover:shadow-brand/10
hover:shadow-[5px_5px_0px_0px]
transition-all duration-200
hover:shadow-stamp-card
transition-all duration-normal
overflow-hidden
flex flex-col
min-h-60
+1
View File
@@ -0,0 +1 @@
export { FontsByIdsStore } from './model';
@@ -0,0 +1 @@
export { FontsByIdsStore } from './store/fontsByIdsStore/fontsByIdsStore.svelte';
@@ -1,14 +1,14 @@
import { fontKeys } from '$shared/api/queryKeys';
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
import {
fetchFontsByIds,
seedFontCache,
} from '../../api/proxy/proxyFonts';
} from '$entities/Font/api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '../../lib/errors/errors';
import type { UnifiedFont } from '../../model/types';
} from '$entities/Font/lib/errors/errors';
import type { UnifiedFont } from '$entities/Font/model/types';
import { fontKeys } from '$shared/api/queryKeys';
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
/**
* Internal fetcher that seeds the cache and handles error wrapping.
@@ -35,11 +35,10 @@ async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
}
/**
* Reactive store for fetching and caching batches of fonts by ID.
* Integrates with TanStack Query via BaseQueryStore and handles
* normalized cache seeding.
* Reactive store for fetching specific fonts by ID via the proxy batch endpoint.
* Wraps TanStack Query and seeds the detail cache for sibling consumers.
*/
export class BatchFontStore extends BaseQueryStore<UnifiedFont[]> {
export class FontsByIdsStore extends BaseQueryStore<UnifiedFont[]> {
constructor(initialIds: string[] = []) {
super({
queryKey: fontKeys.batch(initialIds),
@@ -1,3 +1,8 @@
import * as api from '$entities/Font/api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '$entities/Font/lib/errors/errors';
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import {
@@ -7,14 +12,9 @@ import {
it,
vi,
} from 'vitest';
import * as api from '../../api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '../../lib/errors/errors';
import { BatchFontStore } from './batchFontStore.svelte';
import { FontsByIdsStore } from './fontsByIdsStore.svelte';
describe('BatchFontStore', () => {
describe('FontsByIdsStore', () => {
beforeEach(() => {
queryClient.clear();
vi.clearAllMocks();
@@ -23,7 +23,7 @@ describe('BatchFontStore', () => {
describe('Fetch Behavior', () => {
it('should skip fetch when initialized with empty IDs', async () => {
const spy = vi.spyOn(api, 'fetchFontsByIds');
const store = new BatchFontStore([]);
const store = new FontsByIdsStore([]);
expect(spy).not.toHaveBeenCalled();
expect(store.fonts).toEqual([]);
});
@@ -31,7 +31,7 @@ describe('BatchFontStore', () => {
it('should fetch and seed cache for valid IDs', async () => {
const fonts = [{ id: 'a', name: 'A' }] as any[];
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
const store = new BatchFontStore(['a']);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
});
@@ -42,7 +42,7 @@ describe('BatchFontStore', () => {
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
);
const store = new BatchFontStore(['a']);
const store = new FontsByIdsStore(['a']);
expect(store.isLoading).toBe(true);
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
});
@@ -51,7 +51,7 @@ describe('BatchFontStore', () => {
describe('Error Handling', () => {
it('should wrap network failures in FontNetworkError', async () => {
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
const store = new BatchFontStore(['a']);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
expect(store.error).toBeInstanceOf(FontNetworkError);
});
@@ -59,7 +59,7 @@ describe('BatchFontStore', () => {
it('should handle malformed API responses with FontResponseError', async () => {
// Mocking a malformed response that the store should validate
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any);
const store = new BatchFontStore(['a']);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
expect(store.error).toBeInstanceOf(FontResponseError);
});
@@ -67,7 +67,7 @@ describe('BatchFontStore', () => {
it('should have null error in success state', async () => {
const fonts = [{ id: 'a' }] as any[];
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
const store = new BatchFontStore(['a']);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
expect(store.error).toBeNull();
});
@@ -78,7 +78,7 @@ describe('BatchFontStore', () => {
const fonts1 = [{ id: 'a' }] as any[];
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
const store = new BatchFontStore(['a']);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
spy.mockClear();
@@ -97,7 +97,7 @@ describe('BatchFontStore', () => {
.mockResolvedValueOnce(fonts1)
.mockResolvedValueOnce(fonts2);
const store = new BatchFontStore(['a']);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
store.setIds(['b']);
@@ -8,8 +8,10 @@
*/
import { api } from '$shared/api/api';
import { API_ENDPOINTS } from '$shared/api/endpoints';
import { NonRetryableError } from '$shared/api/queryClient';
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/filters' as const;
const PROXY_API_URL = API_ENDPOINTS.filters;
/**
* Filter metadata type from backend
@@ -36,7 +38,8 @@ export interface FilterMetadata {
type: 'enum' | 'string' | 'array';
/**
* Available filter options
* Available filter options.
* Always an array; empty when the group has no options.
*/
options: FilterOption[];
}
@@ -67,11 +70,16 @@ export interface FilterOption {
}
/**
* Proxy filters API response
* Proxy filters API response.
*
* Contract: `filters` (and each nested `options`) is always an array never
* `null` or omitted. Wire-level `null` here is a backend regression and
* surfaces as a non-retryable error on the client.
*/
export interface ProxyFiltersResponse {
/**
* Array of filter metadata
* Array of filter metadata.
* Always an array; empty when no filter groups are configured.
*/
filters: FilterMetadata[];
}
@@ -98,7 +106,7 @@ export async function fetchProxyFilters(): Promise<FilterMetadata[]> {
const response = await api.get<FilterMetadata[]>(PROXY_API_URL);
if (!response.data || !Array.isArray(response.data)) {
throw new Error('Proxy API returned invalid response');
throw new NonRetryableError('Proxy API returned invalid filters response');
}
return response.data;
+27
View File
@@ -0,0 +1,27 @@
export { mapAppliedFiltersToParams } from './lib';
export {
type AppliedFilterStore,
appliedFilterStore,
/**
* Filter Store
*/
availableFilterStore,
/**
* Filter Manager
*/
createAppliedFilterStore,
/**
* Sort Store
*/
SORT_MAP,
SORT_OPTIONS,
type SortApiValue,
type SortOption,
sortStore,
} from './model';
export {
FilterControls,
Filters,
} from './ui';
@@ -0,0 +1 @@
export { mapAppliedFiltersToParams } from './mapper/mapAppliedFiltersToParams';
@@ -0,0 +1,127 @@
import type { Property } from '$shared/lib';
import {
describe,
expect,
it,
} from 'vitest';
import { createAppliedFilterStore } from '../../model/store/appliedFilterStore/appliedFilterStore.svelte';
import { mapAppliedFiltersToParams } from './mapAppliedFiltersToParams';
/**
* Build a Property with explicit selection state.
*/
function prop(value: string, selected = false): Property<string> {
return { id: value, name: value, value, selected };
}
/**
* Build a filter group with a known id and a list of (value, selected) entries.
*/
function group(id: string, props: Array<[string, boolean]>) {
return {
id,
label: id,
properties: props.map(([value, selected]) => prop(value, selected)),
};
}
describe('mapAppliedFiltersToParams', () => {
describe('search query', () => {
it('omits q when query is empty', () => {
const manager = createAppliedFilterStore({ queryValue: '', groups: [] });
expect(mapAppliedFiltersToParams(manager).q).toBeUndefined();
});
it('passes the debounced query through as q', () => {
// Constructor seeds both immediate and debounced synchronously.
const manager = createAppliedFilterStore({ queryValue: 'roboto', groups: [] });
expect(mapAppliedFiltersToParams(manager).q).toBe('roboto');
});
});
describe('group selections', () => {
it('omits a group entirely when no group with that id exists', () => {
const manager = createAppliedFilterStore({ queryValue: '', groups: [] });
const params = mapAppliedFiltersToParams(manager);
expect(params.providers).toBeUndefined();
expect(params.categories).toBeUndefined();
expect(params.subsets).toBeUndefined();
});
it('omits a group when it exists but has no selections', () => {
const manager = createAppliedFilterStore({
queryValue: '',
groups: [group('providers', [['google', false], ['fontshare', false]])],
});
expect(mapAppliedFiltersToParams(manager).providers).toBeUndefined();
});
it('returns the selected values for a single group', () => {
const manager = createAppliedFilterStore({
queryValue: '',
groups: [group('providers', [['google', true], ['fontshare', false]])],
});
expect(mapAppliedFiltersToParams(manager).providers).toEqual(['google']);
});
it('returns multiple selected values in selection order', () => {
const manager = createAppliedFilterStore({
queryValue: '',
groups: [
group('categories', [
['serif', true],
['sans-serif', false],
['display', true],
['monospace', true],
]),
],
});
expect(mapAppliedFiltersToParams(manager).categories).toEqual(['serif', 'display', 'monospace']);
});
it('maps each of the three recognized group ids independently', () => {
const manager = createAppliedFilterStore({
queryValue: '',
groups: [
group('providers', [['google', true]]),
group('categories', [['serif', true], ['sans-serif', true]]),
group('subsets', [['latin', true]]),
],
});
const params = mapAppliedFiltersToParams(manager);
expect(params.providers).toEqual(['google']);
expect(params.categories).toEqual(['serif', 'sans-serif']);
expect(params.subsets).toEqual(['latin']);
});
it('ignores groups whose id does not match providers/categories/subsets', () => {
const manager = createAppliedFilterStore({
queryValue: '',
groups: [group('weights', [['400', true], ['700', true]])],
});
const params = mapAppliedFiltersToParams(manager);
expect(params.providers).toBeUndefined();
expect(params.categories).toBeUndefined();
expect(params.subsets).toBeUndefined();
});
});
describe('combined output', () => {
it('produces a complete param object when query and selections coexist', () => {
const manager = createAppliedFilterStore({
queryValue: 'inter',
groups: [
group('providers', [['google', true]]),
group('categories', [['sans-serif', true]]),
group('subsets', [['latin', false]]),
],
});
expect(mapAppliedFiltersToParams(manager)).toEqual({
q: 'inter',
providers: ['google'],
categories: ['sans-serif'],
subsets: undefined,
});
});
});
});
@@ -0,0 +1,48 @@
import type { ProxyFontsParams } from '$entities/Font/api';
import type { AppliedFilterStore } from '../../model';
/**
* Maps filter manager to proxy API parameters.
*
* Updated to support multiple filter values (arrays)
*
* @param manager - Filter manager instance with reactive state
* @returns - Partial proxy API parameters ready for API call
*
* @example
* ```ts
* // Example filter manager state:
* // {
* // queryValue: 'roboto',
* // providers: ['google', 'fontshare'],
* // categories: ['sans-serif', 'serif'],
* // subsets: ['latin']
* // }
*
* const params = mapAppliedFiltersToParams(manager);
* // Returns: {
* // providers: ['google', 'fontshare'],
* // categories: ['sans-serif', 'serif'],
* // subsets: ['latin'],
* // q: 'roboto'
* // }
* ```
*/
export function mapAppliedFiltersToParams(manager: AppliedFilterStore): Partial<ProxyFontsParams> {
/**
* Return the list of selected values for a group, or undefined when
* the group is missing or has no selection matches the API's
* "omit empty filters" contract.
*/
const selectedIn = (id: string): string[] | undefined => {
const values = manager.getGroup(id)?.instance.selectedProperties.map(p => p.value);
return values && values.length > 0 ? values : undefined;
};
return {
q: manager.debouncedQueryValue || undefined,
providers: selectedIn('providers'),
categories: selectedIn('categories'),
subsets: selectedIn('subsets'),
};
}
@@ -16,18 +16,32 @@ export {
/**
* Low-level property selection store
*/
filtersStore,
} from './state/filters.svelte';
availableFilterStore,
} from './store/availableFilterStore/availableFilterStore.svelte';
/**
* Main filter controller
*/
export {
/**
* Reactive interface returned by `createAppliedFilterStore`
*/
type AppliedFilterStore,
/**
* High-level manager for syncing search and filters
*/
filterManager,
} from './state/manager.svelte';
appliedFilterStore,
/**
* Factory for constructing a filter manager instance
*/
createAppliedFilterStore,
} from './store/appliedFilterStore/appliedFilterStore.svelte';
/**
* Side-effect import: installs the global appliedFilterStore+sortStore fontCatalogStore
* bridge on first import of this feature barrel. No exports.
*/
import './store/bindings.svelte';
/**
* Sorting logic
@@ -53,4 +67,4 @@ export {
* Reactive store for the current sort selection
*/
sortStore,
} from './store/sortStore.svelte';
} from './store/sortStore/sortStore.svelte';
@@ -1,13 +1,16 @@
/**
* Filter manager for font filtering
* Filter manager factory and singleton.
*
* Manages multiple filter groups (providers, categories, subsets)
* with debounced search input. Provides reactive state for filter
* selections and convenience methods for bulk operations.
* Owns multiple filter groups (providers, categories, subsets) plus a
* debounced search input. Provides reactive state for filter selections
* and convenience methods for bulk operations.
*
* The factory (`createAppliedFilterStore`) is exported for tests; the app
* consumes the `appliedFilterStore` singleton at the bottom of this file.
*
* @example
* ```ts
* const manager = createFilterManager({
* const manager = createAppliedFilterStore({
* queryValue: '',
* groups: [
* { id: 'providers', label: 'Provider', properties: [...] },
@@ -25,7 +28,7 @@ import { createDebouncedState } from '$shared/lib/helpers';
import type {
FilterConfig,
FilterGroupConfig,
} from '../../model';
} from '../../types/filter';
/**
* Creates a filter manager instance
@@ -36,7 +39,7 @@ import type {
* @param config - Configuration with query value and filter groups
* @returns Filter manager instance with reactive state and methods
*/
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
export function createAppliedFilterStore<TValue extends string>(config: FilterConfig<TValue>) {
const search = createDebouncedState(config.queryValue ?? '');
// Create filter instances upfront
@@ -122,4 +125,16 @@ export function createFilterManager<TValue extends string>(config: FilterConfig<
};
}
export type FilterManager = ReturnType<typeof createFilterManager>;
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
/**
* App-wide filter manager singleton.
*
* Constructed with empty groups; the availableFilterStore appliedFilterStore wiring
* lives in `./bindings.svelte` and populates groups once backend filter
* metadata arrives.
*/
export const appliedFilterStore = createAppliedFilterStore({
queryValue: '',
groups: [],
});
@@ -7,10 +7,10 @@ import {
it,
vi,
} from 'vitest';
import { createFilterManager } from './filterManager.svelte';
import { createAppliedFilterStore } from './appliedFilterStore.svelte';
/**
* Test Suite for createFilterManager Helper Function
* Test Suite for createAppliedFilterStore Helper Function
*
* This suite tests the filter manager logic including:
* - Debounced query state (immediate vs delayed)
@@ -54,9 +54,9 @@ function createTestGroups(count: number, propertiesPerGroup = 3) {
}));
}
describe('createFilterManager - Initialization', () => {
describe('createAppliedFilterStore - Initialization', () => {
it('creates manager with empty query value', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(2),
});
@@ -66,7 +66,7 @@ describe('createFilterManager - Initialization', () => {
});
it('creates manager with initial query value', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: 'search term',
groups: createTestGroups(1),
});
@@ -76,7 +76,7 @@ describe('createFilterManager - Initialization', () => {
});
it('creates manager with undefined query value (defaults to empty string)', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
groups: createTestGroups(1),
});
@@ -86,7 +86,7 @@ describe('createFilterManager - Initialization', () => {
it('creates filter groups for each config group', () => {
const groups = createTestGroups(3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -99,7 +99,7 @@ describe('createFilterManager - Initialization', () => {
it('creates filter instances for each group', () => {
const groups = createTestGroups(2, 5);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -118,7 +118,7 @@ describe('createFilterManager - Initialization', () => {
{ id: 'providers', label: 'Providers', properties: createTestProperties(2) },
{ id: 'categories', label: 'Categories', properties: createTestProperties(3) },
];
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -129,7 +129,7 @@ describe('createFilterManager - Initialization', () => {
it('handles single group', () => {
const groups = createTestGroups(1);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -139,7 +139,7 @@ describe('createFilterManager - Initialization', () => {
});
});
describe('createFilterManager - Debounced Query', () => {
describe('createAppliedFilterStore - Debounced Query', () => {
beforeEach(() => {
vi.useFakeTimers();
});
@@ -149,7 +149,7 @@ describe('createFilterManager - Debounced Query', () => {
});
it('immediate query value updates instantly', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(1),
});
@@ -161,7 +161,7 @@ describe('createFilterManager - Debounced Query', () => {
});
it('debounced query value updates after default delay (300ms)', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(1),
});
@@ -178,7 +178,7 @@ describe('createFilterManager - Debounced Query', () => {
});
it('rapid query changes reset the debounce timer', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(1),
});
@@ -200,7 +200,7 @@ describe('createFilterManager - Debounced Query', () => {
});
it('handles empty string in query', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: 'initial',
groups: createTestGroups(1),
});
@@ -213,7 +213,7 @@ describe('createFilterManager - Debounced Query', () => {
});
it('preserves initial query value until changed', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: 'initial search',
groups: createTestGroups(1),
});
@@ -228,9 +228,9 @@ describe('createFilterManager - Debounced Query', () => {
});
});
describe('createFilterManager - hasAnySelection Derived State', () => {
describe('createAppliedFilterStore - hasAnySelection Derived State', () => {
it('returns false when no filters are selected', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(3, 3),
});
@@ -240,7 +240,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
it('returns true when one filter in one group is selected', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -255,7 +255,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
it('returns true when multiple filters across groups are selected', () => {
const groups = createTestGroups(3, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -272,7 +272,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
it('returns false after deselecting all filters', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -286,7 +286,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
it('reacts to selection changes in individual groups', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -318,7 +318,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
properties: createTestProperties(3, []),
},
];
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -331,7 +331,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
{ id: 'group-0', label: 'Group 0', properties: [] },
{ id: 'group-1', label: 'Group 1', properties: [] },
];
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -340,10 +340,10 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
});
});
describe('createFilterManager - getGroup() Method', () => {
describe('createAppliedFilterStore - getGroup() Method', () => {
it('returns the correct group by ID', () => {
const groups = createTestGroups(3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -357,7 +357,7 @@ describe('createFilterManager - getGroup() Method', () => {
it('returns undefined for non-existent group ID', () => {
const groups = createTestGroups(2);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -369,7 +369,7 @@ describe('createFilterManager - getGroup() Method', () => {
it('returns group with accessible filter instance', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -385,7 +385,7 @@ describe('createFilterManager - getGroup() Method', () => {
it('returns first group when requested', () => {
const groups = createTestGroups(3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -398,7 +398,7 @@ describe('createFilterManager - getGroup() Method', () => {
it('returns last group when requested', () => {
const groups = createTestGroups(5);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -410,10 +410,10 @@ describe('createFilterManager - getGroup() Method', () => {
});
});
describe('createFilterManager - deselectAllGlobal() Method', () => {
describe('createAppliedFilterStore - deselectAllGlobal() Method', () => {
it('deselects all filters across all groups', () => {
const groups = createTestGroups(3, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -436,7 +436,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
it('handles deselecting when nothing is selected', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -453,7 +453,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
{ id: 'group-0', label: 'Group 0', properties: [] },
{ id: 'group-1', label: 'Group 1', properties: [] },
];
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -464,7 +464,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
it('can select filters after global deselect', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -482,7 +482,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
it('handles partially selected groups', () => {
const groups = createTestGroups(3, 5);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -505,7 +505,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
});
});
describe('createFilterManager - Complex Scenarios', () => {
describe('createAppliedFilterStore - Complex Scenarios', () => {
beforeEach(() => {
vi.useFakeTimers();
});
@@ -516,7 +516,7 @@ describe('createFilterManager - Complex Scenarios', () => {
it('handles query changes and filter selections together', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -553,7 +553,7 @@ describe('createFilterManager - Complex Scenarios', () => {
},
];
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -582,7 +582,7 @@ describe('createFilterManager - Complex Scenarios', () => {
it('manages multiple independent filter groups correctly', () => {
const groups = createTestGroups(4, 5);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -607,7 +607,7 @@ describe('createFilterManager - Complex Scenarios', () => {
it('handles toggle operations via getGroup', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -623,9 +623,9 @@ describe('createFilterManager - Complex Scenarios', () => {
});
});
describe('createFilterManager - Interface Compliance', () => {
describe('createAppliedFilterStore - Interface Compliance', () => {
it('exposes queryValue getter', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: 'test',
groups: createTestGroups(1),
});
@@ -636,7 +636,7 @@ describe('createFilterManager - Interface Compliance', () => {
});
it('exposes queryValue setter', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: 'test',
groups: createTestGroups(1),
});
@@ -647,7 +647,7 @@ describe('createFilterManager - Interface Compliance', () => {
});
it('exposes debouncedQueryValue getter', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: 'test',
groups: createTestGroups(1),
});
@@ -658,7 +658,7 @@ describe('createFilterManager - Interface Compliance', () => {
});
it('exposes groups getter', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(1),
});
@@ -669,7 +669,7 @@ describe('createFilterManager - Interface Compliance', () => {
});
it('exposes hasAnySelection getter', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(1),
});
@@ -680,7 +680,7 @@ describe('createFilterManager - Interface Compliance', () => {
});
it('exposes getGroup method', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(1),
});
@@ -689,7 +689,7 @@ describe('createFilterManager - Interface Compliance', () => {
});
it('exposes deselectAllGlobal method', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(1),
});
@@ -698,7 +698,7 @@ describe('createFilterManager - Interface Compliance', () => {
});
it('does not expose debouncedQueryValue setter', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(1),
});
@@ -708,7 +708,7 @@ describe('createFilterManager - Interface Compliance', () => {
});
});
describe('createFilterManager - Edge Cases', () => {
describe('createAppliedFilterStore - Edge Cases', () => {
it('handles single property groups', () => {
const groups: Array<{
id: string;
@@ -722,7 +722,7 @@ describe('createFilterManager - Edge Cases', () => {
},
];
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -749,7 +749,7 @@ describe('createFilterManager - Edge Cases', () => {
},
];
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -773,7 +773,7 @@ describe('createFilterManager - Edge Cases', () => {
},
];
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -6,18 +6,22 @@
*
* @example
* ```ts
* import { filtersStore } from '$features/GetFonts';
* import { availableFilterStore } from '$features/FilterAndSortFonts';
*
* // Access filters (reactive)
* $: filters = filtersStore.filters;
* $: isLoading = filtersStore.isLoading;
* $: error = filtersStore.error;
* $: filters = availableFilterStore.filters;
* $: isLoading = availableFilterStore.isLoading;
* $: error = availableFilterStore.error;
* ```
*/
import { fetchProxyFilters } from '$features/GetFonts/api/filters/filters';
import type { FilterMetadata } from '$features/GetFonts/api/filters/filters';
import { queryClient } from '$shared/api/queryClient';
import { fetchProxyFilters } from '$features/FilterAndSortFonts/api/filters/filters';
import type { FilterMetadata } from '$features/FilterAndSortFonts/api/filters/filters';
import {
DEFAULT_QUERY_GC_TIME_MS,
DEFAULT_QUERY_STALE_TIME_MS,
queryClient,
} from '$shared/api/queryClient';
import {
type QueryKey,
QueryObserver,
@@ -31,7 +35,7 @@ import {
* Fetches and caches filter metadata using fetchProxyFilters()
* Provides reactive access to filter data
*/
class FiltersStore {
export class AvailableFilterStore {
/**
* TanStack Query result state
*/
@@ -81,8 +85,8 @@ class FiltersStore {
return {
queryKey: this.getQueryKey(),
queryFn: () => this.fetchFn(),
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
gcTime: DEFAULT_QUERY_GC_TIME_MS,
};
}
@@ -125,4 +129,4 @@ class FiltersStore {
/**
* Singleton instance
*/
export const filtersStore = new FiltersStore();
export const availableFilterStore = new AvailableFilterStore();
@@ -0,0 +1,116 @@
import { queryClient } from '$shared/api/queryClient';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import * as filtersApi from '../../../api/filters/filters';
import type { FilterMetadata } from '../../../api/filters/filters';
import { AvailableFilterStore } from './availableFilterStore.svelte';
/**
* Build a minimal FilterMetadata fixture for tests.
*/
function metadata(id: string, optionValues: string[] = []): FilterMetadata {
return {
id,
name: id,
description: '',
type: 'enum',
options: optionValues.map(value => ({
id: value,
name: value,
value,
count: 1,
})),
} as FilterMetadata;
}
describe('AvailableFilterStore', () => {
let store: AvailableFilterStore;
beforeEach(() => {
queryClient.clear();
// TanStack defaults retry=3 with exponential backoff, which would
// make the error-path test wait >5s. Disable for deterministic timing.
queryClient.setDefaultOptions({ queries: { retry: false } });
vi.clearAllMocks();
});
afterEach(() => {
store?.destroy();
});
describe('initial state', () => {
it('starts with an empty filter list', () => {
vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]);
store = new AvailableFilterStore();
expect(store.filters).toEqual([]);
});
it('reports null error before any failure', () => {
vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]);
store = new AvailableFilterStore();
expect(store.error).toBeNull();
});
});
describe('successful fetch', () => {
it('populates filters with the fetched metadata', async () => {
const data = [
metadata('providers', ['google', 'fontshare']),
metadata('categories', ['serif', 'sans-serif']),
];
vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue(data);
store = new AvailableFilterStore();
await vi.waitFor(() => expect(store.filters).toEqual(data), { timeout: 1000 });
expect(store.isError).toBe(false);
expect(store.error).toBeNull();
});
it('calls fetchProxyFilters exactly once for the initial load', async () => {
const spy = vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]);
store = new AvailableFilterStore();
await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 });
});
});
describe('error handling', () => {
it('flips isError and exposes the error message on fetch failure', async () => {
vi.spyOn(filtersApi, 'fetchProxyFilters').mockRejectedValue(new Error('boom'));
store = new AvailableFilterStore();
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
expect(store.error).toBe('boom');
expect(store.filters).toEqual([]);
});
});
describe('caching', () => {
it('does not trigger a second fetch when another instance shares the query key', async () => {
const data = [metadata('providers', ['google'])];
const spy = vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue(data);
store = new AvailableFilterStore();
await vi.waitFor(() => expect(store.filters).toEqual(data), { timeout: 1000 });
expect(spy).toHaveBeenCalledTimes(1);
// A second observer on the same query key should reuse the cached
// result rather than triggering a new request.
const second = new AvailableFilterStore();
try {
// Give the new observer a tick to potentially refetch (it shouldn't).
await new Promise(r => setTimeout(r, 50));
expect(spy).toHaveBeenCalledTimes(1);
} finally {
second.destroy();
}
});
});
});
@@ -0,0 +1,60 @@
/**
* Bridges feature-level UI state (appliedFilterStore + sortStore) to the
* entity-level fontCatalogStore query params.
*
* Centralizing this here means consumers (Search, FontSearch,
* FilterControls, etc.) bind to the manager/store directly without
* each repeating the same mapping effect. The bridge is a singleton
* concern it tracks singleton state and writes to a singleton query
* observer, so it lives at module scope, not in any individual widget.
*/
import { fontCatalogStore } from '$entities/Font';
import { untrack } from 'svelte';
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
import { availableFilterStore } from './availableFilterStore/availableFilterStore.svelte';
import { sortStore } from './sortStore/sortStore.svelte';
$effect.root(() => {
/**
* Populate appliedFilterStore groups when backend filter metadata resolves.
* availableFilterStore is async; until it loads, appliedFilterStore has empty groups
* and the UI renders nothing for them.
*/
$effect(() => {
const dynamicFilters = availableFilterStore.filters;
if (dynamicFilters.length > 0) {
appliedFilterStore.setGroups(
dynamicFilters.map(filter => ({
id: filter.id,
label: filter.name,
properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({
id: opt.id,
name: opt.name,
value: opt.value,
selected: false,
})),
})),
);
}
});
/**
* Mirror filter selections + debounced search query + sort into fontCatalogStore params.
*
* Filters and sort are merged into one setParams call to avoid a startup race:
* two separate effects each issued setOptions with a different queryKey on the
* first flush, producing an orphaned `?limit=50&offset=0` fetch immediately
* followed by the real `?limit=50&sort=popularity&offset=0` fetch.
*
* untrack the write so fontCatalogStore's internal $state reads don't feed back
* into this effect's dependency graph.
*/
$effect(() => {
const params = mapAppliedFiltersToParams(appliedFilterStore);
const sort = sortStore.apiValue;
untrack(() => fontCatalogStore.setParams({ ...params, sort }));
});
});
@@ -17,7 +17,7 @@ export const SORT_MAP: Record<SortOption, 'name' | 'popularity' | 'lastModified'
export type SortApiValue = (typeof SORT_MAP)[SortOption];
function createSortStore(initial: SortOption = 'Popularity') {
export function createSortStore(initial: SortOption = 'Popularity') {
let current = $state<SortOption>(initial);
return {
@@ -0,0 +1,68 @@
import {
describe,
expect,
it,
} from 'vitest';
import {
SORT_MAP,
SORT_OPTIONS,
type SortOption,
createSortStore,
sortStore,
} from './sortStore.svelte';
describe('createSortStore', () => {
describe('initialization', () => {
it('defaults to Popularity when no initial value is provided', () => {
const store = createSortStore();
expect(store.value).toBe('Popularity');
});
it('accepts an explicit initial value', () => {
const store = createSortStore('Newest');
expect(store.value).toBe('Newest');
});
});
describe('apiValue mapping', () => {
it.each<[SortOption, (typeof SORT_MAP)[SortOption]]>([
['Name', 'name'],
['Popularity', 'popularity'],
['Newest', 'lastModified'],
])('maps %s to %s', (display, api) => {
const store = createSortStore(display);
expect(store.apiValue).toBe(api);
});
});
describe('set()', () => {
it('updates both value and apiValue together', () => {
const store = createSortStore('Name');
store.set('Newest');
expect(store.value).toBe('Newest');
expect(store.apiValue).toBe('lastModified');
});
it('is idempotent — setting the current value keeps state consistent', () => {
const store = createSortStore('Popularity');
store.set('Popularity');
expect(store.value).toBe('Popularity');
});
});
});
describe('sortStore singleton', () => {
it('exposes the same shape as a factory instance', () => {
expect(typeof sortStore.value).toBe('string');
expect(typeof sortStore.apiValue).toBe('string');
expect(typeof sortStore.set).toBe('function');
});
it('accepts all SORT_OPTIONS as valid set() inputs', () => {
for (const option of SORT_OPTIONS) {
sortStore.set(option);
expect(sortStore.value).toBe(option);
expect(sortStore.apiValue).toBe(SORT_MAP[option]);
}
});
});
@@ -9,7 +9,7 @@ const { Story } = defineMeta({
docs: {
description: {
component:
'Renders the full list of filter groups managed by filterManager. Each group maps to a collapsible FilterGroup with checkboxes. No props — reads directly from the filterManager singleton.',
'Renders the full list of filter groups managed by appliedFilterStore. Each group maps to a collapsible FilterGroup with checkboxes. No props — reads directly from the appliedFilterStore singleton.',
},
story: { inline: false },
},
@@ -4,10 +4,10 @@
-->
<script lang="ts">
import { FilterGroup } from '$shared/ui';
import { filterManager } from '../../model';
import { appliedFilterStore } from '../../model';
</script>
{#each filterManager.groups as group (group.id)}
{#each appliedFilterStore.groups as group (group.id)}
<FilterGroup
displayedLabel={group.label}
filter={group.instance}

Some files were not shown because too many files have changed in this diff Show More