Compare commits

117 Commits

Author SHA1 Message Date
ilia c78b8e032e Merge pull request 'Feature/adaptive crossfade window' (#50) from feature/adaptive-crossfade-window into main
Workflow / build (push) Successful in 1m15s
Workflow / e2e (push) Successful in 1m4s
Workflow / publish (push) Successful in 14s
Reviewed-on: #50
2026-06-06 06:05:08 +00:00
Ilia Mashkov 11d5ba0e63 refactor(ComparisonView): extract strut-height and settled-text from Line
Workflow / build (pull_request) Successful in 1m27s
Workflow / e2e (pull_request) Successful in 1m15s
Workflow / publish (pull_request) Has been skipped
Pull the baseline-strut height math into a documented computeStrutHeight
util (named constants for the empirical 0.34 / 1.1 factors, with a unit
test) and the per-side native text runs into a SettledText component.
Strut statics move to Tailwind classes with only the height as style:height;
drop the now-redundant style-string $derived bindings.
2026-06-06 08:59:21 +03:00
Ilia Mashkov 99e9a1fb2c docs(Font): record short-line crossfade pop tradeoff on WINDOW_MIN 2026-06-03 16:13:16 +03:00
Ilia Mashkov 5084df3914 test(e2e): document single-line ASCII constraint on preview sample 2026-06-03 16:10:31 +03:00
Ilia Mashkov a2ec025a65 test(e2e): assert crossfade window against the sizing rule 2026-06-03 16:07:48 +03:00
Ilia Mashkov 8dbea97a33 docs(ComparisonView): note per-line window sizing in Line header 2026-06-03 16:06:35 +03:00
Ilia Mashkov 744cdc9d19 refactor(ComparisonView): size crossfade window per line 2026-06-03 16:03:51 +03:00
Ilia Mashkov 600b905e01 feat(Font): add windowSizeForLine crossfade-window policy 2026-06-03 16:00:29 +03:00
ilia 4ad0fe4cfa Merge pull request 'Refactor/reacrhitecture to fsd+' (#49) from refactor/reacrhitecture-to-fsd+ into main
Workflow / build (push) Successful in 1m6s
Workflow / e2e (push) Successful in 58s
Workflow / publish (push) Successful in 24s
Reviewed-on: #49
2026-06-03 09:55:46 +00:00
Ilia Mashkov eafe89b313 test: change old test to work with new grapheme split mechanism
Workflow / build (pull_request) Successful in 1m16s
Workflow / e2e (pull_request) Successful in 1m11s
Workflow / publish (pull_request) Has been skipped
2026-06-03 12:50:03 +03:00
Ilia Mashkov 724b00d3d5 test(layoutStore): silence expected warn in invalid-JSON case
The "defaults to list mode when localStorage has invalid data" test feeds
invalid JSON on purpose; createPersistentStore logs and swallows the parse
error, so its warning (with stack) was polluting CI output. Spy on
console.warn to silence it and assert it fired, matching the equivalent
test in createPersistentStore.test.ts.
2026-06-03 11:45:13 +03:00
Ilia Mashkov c09ca93f4e perf(test): stop typographySettings pulling @tanstack for four constants
Workflow / build (pull_request) Successful in 1m15s
Workflow / e2e (pull_request) Failing after 1m33s
Workflow / publish (pull_request) Has been skipped
typographySettingsStore (and its spec) imported DEFAULT_FONT_* from the
$entities/Font root barrel, which re-exports FontVirtualList -> stores ->
@tanstack/query-core. Under Vitest (no tree-shaking) that loaded the entire
UI + TanStack graph just to read four constants.

Import them from the pure $entities/Font/model/const/const module instead.
Deep path is deliberate: it pulls only the constants, not the entity store
graph. Mitigates the test-side cost of audit D-1 (root barrel not yet
inert); the structural fix (inject stores into FontVirtualList) stays
parked.
2026-06-03 10:52:29 +03:00
Ilia Mashkov 99ab7e9e08 refactor(shared/ui): stop deep-importing sibling config/types (C-3)
Badge and TechText reached into $shared/ui/Label/config, and SearchBar into
$shared/ui/Input/types — bypassing the siblings public surface.

- Label/config.ts was explicitly shared text-styling config, not Labels
  internals; relocate it to shared/ui/labelConfig.ts (a neutral peer module)
  and point Label/Badge/TechText at it relatively.
- inputIconSize is a consumer-facing map; export it from Input/index.ts so
  SearchBar imports it through the barrel alongside Input.
2026-06-03 10:36:15 +03:00
Ilia Mashkov ec488cf1ce docs(ComparisonView): document the injected store fields
#fontCatalog / #typography / #lifecycle lacked the per-field doc the rest
of the class uses.
2026-06-03 10:26:00 +03:00
Ilia Mashkov fe07c60dd4 refactor: adopt createSingleton across the remaining stores
Replace the hand-rolled let _x / getX / __resetX boilerplate with the
createSingleton helper in all nine remaining singleton stores. Exposed
accessor names (getX, __resetX) are unchanged, so consumers and specs are
unaffected. Teardown wired to each stores destroy() where it has one
(fontCatalog, fontLifecycle, typography, availableFilter, theme, layout,
scrollBreadcrumbs); sort and appliedFilter have no teardown. Also merges
layoutStores duplicate $shared/lib imports.
2026-06-03 10:22:41 +03:00
Ilia Mashkov 0aae710e35 fix: dispose persistent-store effects; close comparisonStore effect leak
Thread the new createPersistentStore.destroy() through its owners so the
save effect.root is actually torn down: LayoutManager gains destroy();
ThemeManager and typographySettings dispose their #store in their existing
destroy().

comparisonStore had its own leak — a constructor $effect.root whose disposer
was discarded, and a module-level persistent storage created at import. Move
storage into the instance (#storage, created lazily per instance), capture
the effect.root disposer, add destroy(), and adopt createSingleton so reset
runs resetAll() + destroy(). Re-export createSingleton from the $shared/lib
barrel; give the test storage mock a destroy().
2026-06-03 10:16:47 +03:00
Ilia Mashkov ded9606c30 fix(shared): give createPersistentStore a destroy() to dispose its effect.root
The store created an $effect.root for the save-on-change sync but returned no
disposer, so the effect leaked for the life of the process — contradicting
the rule that $effect.root owners must expose destroy(). Capture and expose
the disposer.

- add destroy() to the returned store; covered by tests (flushSync proves the
  save effect runs before destroy and stops after)
- trim the bloated header (two near-duplicate @example blocks) to one concise
  JSDoc — no fluff
- update typographySettings test mocks to satisfy the now-required destroy()

Consumers (LayoutManager, ThemeManager, typographySettings, comparisonStore)
do not yet call it — threading + the createSingleton migration follow.
2026-06-03 10:00:21 +03:00
Ilia Mashkov f0736f4d35 feat(shared): add createSingleton lazy-singleton accessor helper
Standardizes the getX() / __resetX() pattern hand-rolled identically across
every store: lazy construction on first get(), memoized thereafter, and a
reset() that runs an optional teardown (e.g. destroy()) and clears so the
next get() rebuilds. Lazy by construction, so owning modules stay inert at
import. Covered by unit tests (laziness, memoization, rebuild-after-reset,
teardown-once-with-live-instance, reset-before-get no-op, falsy-value
caching).

Not yet adopted by the stores — that migration is a separate step.
2026-06-03 09:39:51 +03:00
Ilia Mashkov 5eb458eabb refactor(ComparisonView): import typography store via root barrel
comparisonStore reached getTypographySettingsStore through
$features/AdjustTypography/model (deep, past the public API) while the
catalog/lifecycle accessors already go through the $entities/Font root
barrel. Re-path to $features/AdjustTypography so all three composed-store
imports go through public APIs uniformly. Direction was always legal
(widget -> feature, downward); this only closes the deep-import
inconsistency.

Consolidate the spec mock onto the root module accordingly and drop the
now-dead /model mock.
2026-06-03 08:57:04 +03:00
Ilia Mashkov a428eac309 docs: remove decorative separator comments
Strip box-drawing (──) section dividers and ===/banner headers — visual
noise with no information. Where a divider label carried a non-obvious
why (VirtualList owns scrolling; mobile footer is md:hidden because header
stats take over) it is kept as a plain one-line comment; pure restatements
of the markup (Header bar, Red hover line, Bottom: fixed controls) are
dropped. Single comment style, no fluff.
2026-06-03 08:45:36 +03:00
Ilia Mashkov 09869aed00 refactor(entities/Font): relocate FontSampler from DisplayFont, invert typography
DisplayFont was not a feature (FSD+ A-6): the whole slice was one
presentational component that renders a Font styled by typography, with no
model/domain/action. To get typography it reached sideways into a sibling
feature (`$features/AdjustTypography/model`) — a feature->feature edge
(C-1), the symptom of the mislayering, not the disease.

Fix by inversion, mirroring the existing `status` prop pattern:

- move FontSampler into entities/Font/ui (it now uses only entity siblings
  + $shared/ui)
- it accepts a `typography` prop typed to a minimal contract defined in the
  component; the AdjustTypography store satisfies it structurally, so the
  entity has no dependency on the feature
- SampleList (owns both) injects its typographySettingsStore as the prop
- delete the DisplayFont slice; export FontSampler from the Font barrel;
  relocate the story (now passes a mock typography)

Resolves A-6, A-7, and the FontSampler half of C-1. Verified: 0 type
errors, 0 lint (boundary rule satisfied), 905 unit + 213 component tests,
production build OK.
2026-06-03 08:34:49 +03:00
Ilia Mashkov 028853aff5 chore: drop stale bindings.svelte.ts from sideEffects allowlist
bindings.svelte.ts no longer has a top-level side effect: the $effect.root
bridge was moved into startFilterBindings(), wired explicitly by the
app-layer AppBindings provider (onMount). Nothing imports it
side-effect-only anymore, so the allowlist entry falsely marked a now-pure
module as impure. Stores and queryClient are lazy getX() accessors, so they
correctly need no entry either.

Allowlist is now just *.css (style injection) and **/router.ts
(createRouter at eval). Verified: production build succeeds and
startFilterBindings is retained as a used export.
2026-06-02 23:34:08 +03:00
Ilia Mashkov 1c6427c586 chore: drop vestigial $lib alias
$lib pointed at src/lib/, which does not exist, and nothing imported it.
Removed the dead alias from all five declaration sites (tsconfig plus the
vite and three vitest configs). A stray $lib import now fails fast as an
unknown alias instead of resolving to a missing path.
2026-06-02 23:17:27 +03:00
Ilia Mashkov 60e115309c refactor(shared/api): lazify queryClient to remove eager-construction footgun
queryClient.ts constructed the TanStack client at module eval but was not
in the sideEffects allowlist, so Rollup treated the module as pure — safe
only while a value-importer keeps it alive (D-3).

- replace the eager `queryClient` singleton with a memoized getQueryClient()
  factory; construction is deferred to first call
- the module is now genuinely side-effect-free, so no sideEffects exception
  is needed and construction can never be legally tree-shaken away
- route all consumers through getQueryClient() (QueryProvider as first
  caller; stores via class fields/observers; tests via a local alias; the
  fontCatalogStore spec mock now overrides getQueryClient)
2026-06-02 23:13:03 +03:00
Ilia Mashkov b390efdabe refactor(entities/Font): named public API and expose stores via root barrel
Stores were only reachable by deep-importing $entities/Font/model, so
consumers reached past the slice public API (FSD anti-pattern D, B-3/D-2).

- convert the Font barrels (root, model, model/store, model/types) to
  explicit named exports with export/export type split (B-1)
- re-export the lazy store accessors/classes from the root barrel so the
  entity public API is complete and inert at import (construction stays
  lazy; the root already loads tanstack via ./ui)
- repoint all consumers (SampleList, SampleListSection, FontList,
  comparisonStore, bindings) from $entities/Font/model to $entities/Font
2026-06-02 23:02:30 +03:00
Ilia Mashkov 771bda745c refactor: replace export* barrels with explicit named exports
Wildcard re-exports obscure each slice public surface and weaken
tree-shaking. Convert to explicit named re-exports with export/export
type split (B-1) for ComparisonView, ChangeAppTheme, Breadcrumb/model,
and FilterAndSortFonts/api barrels.
2026-06-02 23:02:18 +03:00
Ilia Mashkov c6c8497906 fix: break import cycles
import/no-cycle (now active) flagged 17 cycles across 12 files:

- shared/ui self-barrel cycles (Logo/Stat/StatGroup/ComboControl/
  FilterGroup/SectionHeader): import siblings relatively instead of
  through the $shared/ui barrel that re-exports them
- shared/lib/utils: roundToStepPrecision imports getDecimalPlaces
  relatively instead of via the utils barrel
- routes: lazy-load Redirect in the router so it no longer statically
  imports a component that imports navigate back from it
2026-06-02 23:02:07 +03:00
Ilia Mashkov f3a10e38df refactor: clear remaining lint errors (comma operator, bind:this ref)
- splitArray: replace the comma-operator reduce body with an explicit
  block + return (no-sequences); behaviour unchanged
- BreadcrumbHeaderSeeded: declare the bind:this ref with $state() so it
  is not flagged as never-assigned (oxlint cannot see template bindings),
  matching the rest of the codebase; guard the onMount use
2026-06-02 23:01:59 +03:00
Ilia Mashkov 9788f07dec refactor: remove unused vars and dead code
Cleanup surfaced once the oxlint config actually loads (no-unused-vars).

- drop dead locals/imports/params (cachedOffsetTop, elasticOut, key,
  unused type imports, unused test imports; _-prefix unused mock params)
- createVirtualizer: keep the _version read (reactive subscription inside
  $derived.by) but bind it to _v so it is not flagged
- scrollBreadcrumbsStore.test: keep the removeEventListener mock side
  effect, drop the unread spy binding
2026-06-02 23:01:48 +03:00
Ilia Mashkov deefb51b57 chore(lint): repair oxlint config and enforce FSD boundaries
oxlint was never loading its config: the file was named oxlint.json but
oxlint only auto-discovers .oxlintrc.json/.jsonc, and the `ignore` field
was invalid (should be `ignorePatterns`). So import/no-cycle and every
other rule silently never ran.

- rename oxlint.json -> .oxlintrc.json, fix ignore -> ignorePatterns
- turn off the restriction/style category grab-bags (opt-in, partly
  contradictory); enable wanted rules individually
- add overrides enforcing FSD layer direction and the interior
  ui -> model -> domain law via no-restricted-imports (oxlint has no
  zone rule); import/no-cycle resolves $-aliases via tsconfig discovery
2026-06-02 23:01:33 +03:00
Ilia Mashkov 431fb41a7f chore: merged with main, conflict resolved 2026-06-02 21:52:33 +03:00
ilia db6384110e Merge pull request 'Feature/popover' (#48) from feature/popover into main
Workflow / build (push) Failing after 3m11s
Workflow / e2e (push) Has been skipped
Workflow / publish (push) Has been skipped
Reviewed-on: #48
2026-06-02 18:47:17 +00:00
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 Mashkov 7ddf232e3a refactor(sample-list): replace layoutManager singleton with lazy accessor
Convert the eager layoutManager singleton to getLayoutManager() (+ __resetLayoutManager
for tests), so its persisted layout preference is read on first access rather than at
module load. Update the model barrels and consumers (LayoutSwitch, SampleListSection,
SampleList) with $derived reads; the LayoutSwitch test resolves via the accessor.
2026-06-02 09:09:20 +03:00
Ilia Mashkov b3bc40b76c refactor(font): replace fontLifecycleManager singleton with lazy accessor
Convert the eager fontLifecycleManager singleton to getFontLifecycleManager()
(+ __resetFontLifecycleManager for tests), so its AbortController/FontFace
bookkeeping is set up on first use rather than at module load. Update consumers
(FontVirtualList, FontList, SampleList) and resolve it once as a field in
comparisonStore; the comparisonStore mock now exposes getFontLifecycleManager.
2026-06-01 18:49:47 +03:00
Ilia Mashkov 839460726e refactor(breadcrumb): replace scrollBreadcrumbsStore singleton with lazy accessor
Convert the eager scrollBreadcrumbsStore singleton to getScrollBreadcrumbsStore()
(+ __resetScrollBreadcrumbsStore for tests) and add a public destroy() that
disconnects the IntersectionObserver and scroll listener. Update the feature
barrel and consumers (BreadcrumbHeader, BreadcrumbHeaderSeeded, NavigationWrapper).
2026-06-01 18:46:10 +03:00
Ilia Mashkov 6877807aaf refactor(typography): lazy getTypographySettingsStore + fix effect leak
Convert the eager typographySettingsStore singleton to getTypographySettingsStore()
(+ __resetTypographySettingsStore for tests) and update barrels and consumers
(TypographyMenu, FontSampler, SampleList, ComparisonView Line/SliderArea,
comparisonStore + its mock).

Fix a latent leak while here: the store ran $effect.root and discarded the
returned cleanup, so its storage-sync effects outlived every instance. Capture
the disposer and expose destroy(), which __reset now calls.
2026-06-01 18:44:17 +03:00
Ilia Mashkov 3dca11fea8 refactor(theme): replace themeManager singleton with lazy getThemeManager
Convert the eager themeManager singleton to a getThemeManager() lazy accessor
(+ __resetThemeManager for tests), so the persistent-store subscription is set
up on first access rather than at module load. Update the barrel and consumers
(Layout init/destroy, ThemeSwitch, story, test).
2026-06-01 18:39:17 +03:00
Ilia Mashkov 0b675635b3 refactor(filters): replace filter/sort store singletons with lazy accessors
Convert appliedFilterStore, availableFilterStore and sortStore from eager
module-level singletons to getAppliedFilterStore/getAvailableFilterStore/
getSortStore lazy accessors (+ __reset* helpers for tests), so the
availableFilterStore QueryObserver is built on first use rather than at import.

Update barrels, the startFilterBindings bridge, and all consumers. Reactive
reads in components are wrapped in $derived; two-way bind:value targets resolve
the accessor once and bind directly (a $derived is read-only).
2026-06-01 18:37:18 +03:00
Ilia Mashkov 9780ff9358 refactor(filters): mount-scope store bindings and fix effect-update loop
Replace the side-effect-on-import $effect.root in bindings with an explicit
startFilterBindings() started from an AppBindings provider in onMount, so the
filters/sort -> font-catalog bridge has a lifecycle tied to the app tree and a
returned cleanup. bindings now consumes getFontCatalog().

Fix the effect-update loop this surfaced: setGroups populated the reactive
groups array in place via `groups.length = 0; groups.push(...)`. push reads the
array's length signal, so the populating effect both read and wrote
groups.length each run and re-triggered itself forever
(effect_update_depth_exceeded). setGroups now reassigns the array (groups is
`let`), which does not read length.

Extract mapFilterMetadataToGroups to own the metadata -> group-config mapping,
including sorting a copy of options (the source is TanStack-cached data; an
in-place sort corrupts the cache and writes into the effect's read dependency).
2026-06-01 17:25:26 +03:00
Ilia Mashkov 1ad015aed6 refactor(comparison): replace comparisonStore singleton with lazy getComparisonStore
Mirror the font-catalog change in ComparisonView: expose getComparisonStore()
(plus __resetComparisonStore for tests) instead of an eager comparisonStore
singleton, and consume getFontCatalog() internally. Update the model barrel and
all UI consumers (Sidebar, FontList, Header, Line, SliderArea); Character no
longer needs the store and reads everything from props.

Update both specs to the accessor: comparisonStore.test mocks getFontCatalog
with a writable stub (the real store's fonts is getter-only) and resets the
catalog between cases; Sidebar.svelte.test resolves the store via the accessor.

Also document Character's props.
2026-06-01 17:25:05 +03:00
Ilia Mashkov 10603d18bf refactor(font): replace fontCatalogStore singleton with lazy getFontCatalog
Swap the eagerly-constructed fontCatalogStore singleton for a lazy
getFontCatalog() accessor (plus __resetFontCatalog for tests), so the
InfiniteQueryObserver is created on first use rather than at module load.
Update the model barrels and all consumers (FontVirtualList, SampleList,
SampleListSection) to the accessor.

Also extract createFontLoadRequestConfig from FontVirtualList: it resolves a
font's URL for a weight and returns a 0-or-1 element array, letting callers
flatMap over a list to build load requests and drop unresolvable fonts in one
pass.
2026-06-01 17:24:55 +03:00
Ilia Mashkov 39d1ce4c37 refactor(FontSampler): remove $derived, since props are already reactive 2026-06-01 16:49:59 +03:00
Ilia Mashkov fcd61be4fa refactor(font): inject font-load status as a prop, decoupling UI from the store
FontApplicator and FontSampler no longer read fontLifecycleManager. They take a
`status` prop (FontLoadStatus | undefined) supplied by the composing widget;
FontList and SampleList resolve status once per visible row and pass it down.

FSD+ dependency inversion: the entity/feature UI depends on a value, not the
lifecycle store. Removes FontApplicator's value-import of the store (one step
toward an inert ./ui barrel) and drops the duplicate getFontStatus read per row
in FontList. FontSampler is now status-decoupled and trivially relocatable to
entities/Font/ui.
2026-06-01 12:06:30 +03:00
Ilia Mashkov 28a8e49915 refactor(font): keep @tanstack out of the entity public API barrel
Extract NonRetryableError into its own tanstack-free module and drop the
./api re-export from the Font slice barrel. Importing $entities/Font no
longer transitively loads @tanstack/query-core or constructs the QueryClient
singleton via the ./api and ./lib (errors) chains — light consumers (domain,
types, consts) and unit specs stop paying for TanStack.

Note: ./ui still pulls the stores; addressed separately.
2026-06-01 11:49:53 +03:00
Ilia Mashkov 43e8507144 Merge branch 'main' into refactor/reacrhitecture-to-fsd+ 2026-06-01 10:43:29 +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 Mashkov a677dc6b0b Merge branch 'main' into refactor/reacrhitecture-to-fsd+ 2026-06-01 10:21:42 +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 Mashkov 17c022470e refactor(font): expose stores via model segment, not the top barrel
Re-exporting the store singletons (fontCatalogStore, fontLifecycleManager,
FontsByIdsStore) through entities/Font/index.ts meant every consumer of the
barrel eager-instantiated stores and pulled @tanstack/query-core — in dev,
test, and as retained code. Drop the store re-export from the top barrel;
keep the pure surface (types, constants, domain, lib, ui) there for
convenience. Consumers that need stores import $entities/Font/model.
Aligns with the BaseQueryStore carve-out: barrel by default, segment path
when it would drag a heavy or side-effectful dependency.
2026-05-31 20:06:33 +03:00
Ilia Mashkov a9f3b990ab chore: declare sideEffects allowlist for tree-shaking
Without the field, Rollup treats every module as potentially side-effectful
and cannot drop unused re-exports pulled through barrels. Audited all
import-time side effects: only CSS and the two bare side-effect imports
(router.ts, bindings.svelte.ts) must be preserved; module-level store
singletons ride their export usage and need no listing. Trims the bundle
~8 KB raw / ~2.4 KB gzip.
2026-05-31 20:06:22 +03:00
Ilia Mashkov 36673597f7 refactor(breadcrumb): relocate Breadcrumb slice from entities to features
Breadcrumb is not a business aggregate — it is a scroll-tracking navigation
capability (NavigationWrapper registers page sections into a store), so it
belongs in the features layer, not entities. Move the whole slice and
repoint its three widget consumers. entities/ now holds only Font, a true
aggregate.
2026-05-31 19:30:56 +03:00
Ilia Mashkov 42bcc915c7 chore(lint): enable import/no-cycle
Guards against circular-dependency regressions in barrels. Requires the
import plugin, which is not enabled by default.
2026-05-31 19:16:44 +03:00
Ilia Mashkov c72b51b1c7 refactor(shared): keep BaseQueryStore out of the lib barrels
BaseQueryStore pulls @tanstack/query-core. Re-exporting it through the
broad $shared/lib and $shared/lib/helpers barrels made every consumer of
those barrels eager-load TanStack at module-eval time (no tree-shaking in
vitest/vite-node), which is what surfaced the queryClient mock init-order
failure. Its single consumer now imports it by path.
2026-05-31 19:16:44 +03:00
Ilia Mashkov 6888f67f14 test(Font): make queryClient mock factory self-contained
The vi.mock factory referenced the top-level QueryClient import. Once
BaseQueryStore landed in the $shared/lib barrel, importing that barrel
for createFilter eagerly pulled @tanstack/query-core into the init
cycle, so the hoisted factory hit the import before initialization
(Cannot access '__vi_import_2__' before initialization). Import
QueryClient inside the factory to keep it independent of module order.
2026-05-31 17:51:34 +03:00
Ilia Mashkov a651d3d16f refactor(font): absorb FetchFontsByIds store into Font entity
FetchFontsByIds was data-access for the Font aggregate wearing a feature
costume: a single FontsByIdsStore (BaseQueryStore over Font's proxy API),
no UI, consumed only by comparisonStore. Move it beside fontCatalogStore
in the Font entity, collapse its deep $entities/Font imports to relative
paths, expose via the Font public API, and delete the feature slice.
2026-05-31 17:49:59 +03:00
Ilia Mashkov 4d8dcf52e0 refactor: move BaseQueryStore into separate directory, adjust exports/imports 2026-05-31 17:33:06 +03:00
Ilia Mashkov 907145c655 refactor(shared): drop createTypographyControl from shared/lib 2026-05-31 17:08:55 +03:00
Ilia Mashkov e49148008b refactor(Font): remove typography-control config, keep render defaults 2026-05-31 17:08:48 +03:00
Ilia Mashkov c613d4cf88 refactor(AdjustTypography): consume typography-control module from its new home 2026-05-31 17:04:51 +03:00
Ilia Mashkov 7834c7cbf2 refactor(AdjustTypography): add typography-control module (factory, types, constants) 2026-05-31 16:52:37 +03:00
Ilia Mashkov 4640d6e521 test(shared): test ComboControl against NumericControl mock, not the factory 2026-05-31 16:50:55 +03:00
Ilia Mashkov 8adf5cd7b3 refactor(shared): ComboControl owns its NumericControl + ControlLabels contract 2026-05-31 16:45:04 +03:00
Ilia Mashkov b8edeff86f refactor(font): move test fixtures into a dedicated testing segment
Mock data lived in lib/ and was re-exported through the slice's
production index.ts, advertising fixtures as part of the public API.
Move it to a testing/ segment and drop it from index.ts; consumers now
import explicitly via the $entities/Font/testing subpath.

Repoints the four mock consumers (FontApplicator story, sizeResolver
and fontCatalogStore specs, comparisonStore test) to the new subpath.
2026-05-31 14:08:25 +03:00
Ilia Mashkov 7d66b0bc92 refactor(font): extract glyph-comparison logic into a domain segment
Move DualFontLayout and computeLineRenderModel from lib/ to a new
domain/ segment. This is the pure glyph-comparison algorithm — no
framework, no UI, no shared/model dependencies — so it belongs in
domain per the FSD+ ui -> model -> domain interior rule, shielded from
UI-kit and API churn.

Public API is unchanged: the slice index re-exports domain/, so
$entities/Font consumers (ComparisonView SliderArea, Line) are
unaffected.
2026-05-31 14:08:09 +03:00
Ilia Mashkov ecdb2f1b7f refactor(shared): remove deprecated TextLayoutEngine and its re-exports 2026-05-31 13:36:39 +03:00
Ilia Mashkov 6a07b89773 refactor(font): remove CharacterComparisonEngine, superseded by DualFontLayout 2026-05-31 13:36:39 +03:00
Ilia Mashkov 02aa27dc48 refactor(comparison): drop no-op displayChar mapping
The space→non-breaking-space swap guarded against a lone space collapsing
to zero width, but the line container now uses white-space: pre (inherited
by Character), so spaces keep their width. The derived had been reduced to
a space→space identity; render `char` directly.
2026-05-31 13:25:33 +03:00
Ilia Mashkov 4652857512 fix(comparison): lay out lines to the content box, not a padding guess
availableWidth was containerWidth minus a flat 48/96px constant, but the
slider track's gutters are responsive CSS padding (px-4/8/12/lg:px-24,
per side). At lg the real padding is 192px while only 96 was subtracted,
so lines were broken ~96px too wide and overflowed the container.

Measure the container's content box (ResizeObserver contentBoxSize) and
use it directly as availableWidth; keep the border box for the slider and
split math. The content box already excludes the gutters, so it tracks the
breakpoints with no constant to maintain.
2026-05-31 13:25:13 +03:00
Ilia Mashkov d5f0814efc fix(comparison): stabilize line rendering, cut per-tick re-renders
Extract findSplitIndex; computeLineRenderModel now takes the split index
as a primitive. Line derives its model from `split`, so the $derived
short-circuits on value equality and skips recomputation on spring ticks
that don't move the split (previously every tick rebuilt the model and
re-rendered the line).

Lay the three regions out as inline boxes on a shared baseline. fontA and
fontB now align on the typographic baseline despite differing metrics,
and an always-present overflow:hidden strut pins the line-box baseline so
the line no longer jumps when a bulk run mounts/unmounts or the last
window char morphs to a font of different ascent.
2026-05-31 13:24:14 +03:00
Ilia Mashkov 6153769317 refactor(comparison): switch to 3-section render model via DualFontLayout
Rewrite Line.svelte to render leftText / windowChars / rightText regions
from a LineRenderModel. Bulk regions render as native shaped text runs so
the browser applies kerning and ligatures; per-char DOM is reserved for
the N-char crossfade window straddling the slider.

Slim Character.svelte: drop the unused proximity prop and the redundant
font-size/font-weight/letter-spacing styles now inherited from the line
container.

Switch SliderArea.svelte to instantiate DualFontLayout and derive each
line's render model via computeLineRenderModel(line, sliderPos,
containerWidth, WINDOW_SIZE).
2026-05-30 22:29:43 +03:00
Ilia Mashkov 3e568685b3 refactor(font): drop unnecessary PretextInternals shim, use pretext type directly 2026-05-30 22:27:44 +03:00
Ilia Mashkov 581ffb5887 refactor(font): re-export dualFontLayout from Font entity public API 2026-05-30 22:13:26 +03:00
Ilia Mashkov 2ece4c5559 test(font): port DualFontLayout tests, drop per-char-state cases 2026-05-30 22:12:29 +03:00
Ilia Mashkov 1fa099bef5 refactor(font): port engine to DualFontLayout with typed pretext internals 2026-05-30 22:12:00 +03:00
Ilia Mashkov 50238e12c3 refactor(findSplitIndex): remove one for cycle 2026-05-30 22:10:17 +03:00
Ilia Mashkov f13dfe1caf chore: add jsdoc comment 2026-05-30 22:06:47 +03:00
Ilia Mashkov f4edb67acb test(font): window centering, clamping, and key stability 2026-05-30 21:50:34 +03:00
Ilia Mashkov ccf51c645e feat(font): compute split index and 3-region slice 2026-05-30 21:50:02 +03:00
Ilia Mashkov efbc464b14 test(font): computeLineRenderModel handles empty line 2026-05-30 21:49:25 +03:00
Ilia Mashkov c5092a488b refactor(font): scaffold dualFontLayout module with shared types 2026-05-30 21:48:56 +03:00
Ilia Mashkov ddadac8686 reafactor: move CharacterComparisonEngine into Font entity 2026-05-30 18:48:53 +03:00
Ilia Mashkov f6911fbcca feature: add sv-router, page structure and redirect to home from any other page 2026-05-30 18:31:56 +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
206 changed files with 5179 additions and 2876 deletions
+4 -27
View File
@@ -50,14 +50,6 @@ jobs:
timeout-minutes: 5 timeout-minutes: 5
run: yarn test:component --reporter=verbose --logHeapUsage run: yarn test:component --reporter=verbose --logHeapUsage
- name: Upload Build Artifacts
uses: actions/upload-artifact@v4
with:
name: svelte-static-dist
path: |
dist/
package.json
e2e: e2e:
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -65,42 +57,27 @@ jobs:
image: mcr.microsoft.com/playwright:v1.59.0-jammy image: mcr.microsoft.com/playwright:v1.59.0-jammy
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Enable Corepack - name: Enable Corepack
run: | run: |
corepack enable corepack enable
corepack prepare yarn@stable --activate corepack prepare yarn@stable --activate
- name: Persistent Yarn Cache - name: Persistent Yarn Cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: .yarn/cache path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn- restore-keys: ${{ runner.os }}-yarn-
- name: Install dependencies - name: Install dependencies
run: yarn install --immutable run: yarn install --immutable
- name: Build Svelte SPA
# Pull down the compiled dist folder so Vite can preview it run: yarn build
- name: Download Build Artifacts
uses: actions/download-artifact@v4
with:
name: svelte-static-dist
- name: E2E Tests - name: E2E Tests
timeout-minutes: 15 timeout-minutes: 15
run: yarn test:e2e run: yarn test:e2e
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
publish: publish:
needs: build # Only runs if tests/lint pass # Runs if lint, unit-, component-, e2e-tests pass
needs: [build, e2e]
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' # Only deploy from main branch if: github.ref == 'refs/heads/main' # Only deploy from main branch
steps: steps:
+195
View File
@@ -0,0 +1,195 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["import"],
"categories": {
"correctness": "error",
"suspicious": "warn",
"perf": "warn",
// style/restriction off: opt-in, contradictory grab-bags. Wanted rules enabled individually below.
"style": "off",
"restriction": "off"
},
"env": {
"browser": true,
"es2021": true
},
"ignorePatterns": [
"node_modules",
"dist",
"build",
".svelte-kit",
".vercel",
"*.config.js",
"*.config.ts"
],
"rules": {
"no-console": "warn",
"no-debugger": "error",
"no-alert": "warn",
// no-cycle resolves $-aliases via tsconfig auto-discovery (no resolver config in oxlint)
"import/no-cycle": "error",
"import/no-duplicates": "warn",
"import/no-unassigned-import": "off", // CSS/side-effect imports are intentional
"no-sequences": "error",
"no-underscore-dangle": "off",
"no-shadow": "warn",
"no-implicit-coercion": "warn",
"no-await-in-loop": "warn",
"no-return-assign": "warn",
"no-new": "warn",
"no-unneeded-ternary": "warn"
},
// FSD boundaries. oxlint has no zone rule, so layer/segment direction is enforced
// with no-restricted-imports patterns scoped per glob. Layer order (high->low):
// app(exempt top shell) > routes > widgets > features > entities > shared.
// A layer bans imports from itself (cross-slice via alias) and every layer above.
// Overrides are LAST-WINS, not merged: a file matching two overrides keeps only the
// last rule config. So the domain override (below) is a self-contained superset, and
// the test/story override (last) fully disables boundary checks for those files.
"overrides": [
// shared = lowest layer: imports nothing above it
{
"files": ["src/shared/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": [
"$app",
"$app/*",
"$routes",
"$routes/*",
"$widgets",
"$widgets/*",
"$features",
"$features/*",
"$entities",
"$entities/*"
],
"message": "FSD layer violation: `shared` is the lowest layer and may not import from any layer above it."
}
]
}]
}
},
// entities: import shared only; no other entity via alias; interior ui<-only-ui
{
"files": ["src/entities/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": ["$app", "$app/*", "$routes", "$routes/*", "$widgets", "$widgets/*", "$features", "$features/*"],
"message": "FSD layer violation: `entities` may only import from `shared`."
},
{
"group": ["$entities", "$entities/*"],
"message": "FSD cross-slice violation: do not import another entity via its alias. Use relative imports inside your own slice; invert the dependency through a higher layer for cross-slice needs."
},
{
"group": ["../ui", "../ui/*", "../../ui/*"],
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
}
]
}]
}
},
// features: import entities/shared only; no other feature via alias
{
"files": ["src/features/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": ["$app", "$app/*", "$routes", "$routes/*", "$widgets", "$widgets/*"],
"message": "FSD layer violation: `features` may only import from `entities` and `shared`."
},
{
"group": ["$features", "$features/*"],
"message": "FSD cross-slice violation: do not import another feature via its alias. Invert the dependency through a higher layer (widget/route)."
},
{
"group": ["../ui", "../ui/*", "../../ui/*"],
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
}
]
}]
}
},
// widgets: import features/entities/shared only; no other widget via alias
{
"files": ["src/widgets/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": ["$app", "$app/*", "$routes", "$routes/*"],
"message": "FSD layer violation: `widgets` may only import from `features`, `entities`, and `shared`."
},
{
"group": ["$widgets", "$widgets/*"],
"message": "FSD cross-slice violation: do not import another widget via its alias. Invert the dependency through the route layer."
},
{
"group": ["../ui", "../ui/*", "../../ui/*"],
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
}
]
}]
}
},
// routes: top of the FSD list, imports any layer below; only app is above it
{
"files": ["src/routes/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{ "group": ["$app", "$app/*"], "message": "FSD layer violation: `routes` may not import from `app`." }
]
}]
}
},
// domain (FSD+): pure logic. Imports NO layer (not even shared) and no sibling
// model/ui segment. Superset: wins over the layer override above for these files.
{
"files": ["src/**/domain/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": [
"$app",
"$app/*",
"$routes",
"$routes/*",
"$widgets",
"$widgets/*",
"$features",
"$features/*",
"$entities",
"$entities/*",
"$shared",
"$shared/*"
],
"message": "FSD+ domain isolation: `domain` is pure business logic and may not import any layer (including `shared`). Allowed: relative imports within `domain` and framework-agnostic npm packages."
},
{
"group": ["../model", "../model/*", "../../model/*", "../ui", "../ui/*", "../../ui/*"],
"message": "FSD+ domain isolation: `domain` may not import sibling `model` or `ui` segments. Dependency flows ui -> model -> domain, never back."
}
]
}]
}
},
// tests/stories/fixtures legitimately cross-import (e.g. $entities/Font/testing).
// Must be LAST so last-wins disables boundary checks for them.
{
"files": ["**/*.test.ts", "**/*.spec.ts", "**/*.stories.svelte", "src/**/testing/**"],
"rules": {
"no-restricted-imports": "off"
}
}
]
}
+24 -1
View File
@@ -1,5 +1,28 @@
:3000 { :3000 {
root * /usr/share/caddy 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 try_files {path} /index.html
file_server
} }
-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.
+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);
});
});
+108
View File
@@ -7,19 +7,37 @@ import { BasePage } from './base-page';
/** /**
* Page object for the root comparison view. Encapsulates locators for the * Page object for the root comparison view. Encapsulates locators for the
* primary controls so tests don't hardcode aria-labels or DOM structure. * 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 { export class ComparisonPage extends BasePage {
readonly searchInput: Locator; readonly searchInput: Locator;
readonly previewInput: 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) { constructor(page: Page) {
super(page); super(page);
this.searchInput = page.getByRole('textbox', { name: 'Search typefaces' }); this.searchInput = page.getByRole('textbox', { name: 'Search typefaces' });
this.previewInput = page.getByRole('textbox', { name: 'Preview text' }); 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. * 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() { async open() {
await this.goto('/'); await this.goto('/');
@@ -33,4 +51,94 @@ export class ComparisonPage extends BasePage {
async setPreviewText(text: string) { async setPreviewText(text: string) {
await this.previewInput.fill(text); 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);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { windowSizeForLine } from '../src/entities/Font/domain/windowSizeForLine/windowSizeForLine';
import {
expect,
test,
} from './fixtures';
test.describe('preview text', () => {
test('drives the slider character rendering', async ({ comparison }) => {
/**
* Must stay a single unwrapped line of ASCII: the assertion feeds
* `text.length` (UTF-16 code units) to `windowSizeForLine`, but the
* renderer feeds it the line's grapheme count. They match only for
* plain ASCII — emoji/combining marks (length > graphemes) or wrapping
* (one input string splitting into several lines) silently desync them.
*/
const text = 'Sphinx';
await comparison.pickPair('Inter', 'Roboto');
await comparison.setPreviewText(text);
// Window chars render as `.char-wrap` cells for crossfade. The window
// size is a pure function of the line's grapheme count — assert against
// the rule, not a hardcoded constant, so tuning the policy can't silently
// break this. "Sphinx" is one unwrapped line of 6 graphemes.
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(windowSizeForLine(text.length));
});
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');
});
});
+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);
});
});
-27
View File
@@ -1,27 +0,0 @@
{
"categories": {
"correctness": "error",
"suspicious": "warn",
"perf": "warn",
"style": "warn",
"restriction": "error"
},
"env": {
"browser": true,
"es2021": true
},
"ignore": [
"node_modules",
"dist",
"build",
".svelte-kit",
".vercel",
"*.config.js",
"*.config.ts"
],
"rules": {
"no-console": "off",
"no-debugger": "error",
"no-alert": "warn"
}
}
+6 -2
View File
@@ -4,6 +4,10 @@
"version": "0.0.1", "version": "0.0.1",
"packageManager": "yarn@4.11.0", "packageManager": "yarn@4.11.0",
"type": "module", "type": "module",
"sideEffects": [
"*.css",
"**/router.ts"
],
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
@@ -44,7 +48,6 @@
"@types/jsdom": "28.0.1", "@types/jsdom": "28.0.1",
"@vitest/browser-playwright": "4.1.5", "@vitest/browser-playwright": "4.1.5",
"@vitest/coverage-v8": "4.1.5", "@vitest/coverage-v8": "4.1.5",
"bits-ui": "2.18.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dprint": "0.54.0", "dprint": "0.54.0",
"jsdom": "29.1.1", "jsdom": "29.1.1",
@@ -66,6 +69,7 @@
}, },
"dependencies": { "dependencies": {
"@chenglou/pretext": "0.0.6", "@chenglou/pretext": "0.0.6",
"@tanstack/svelte-query": "6.1.28" "@tanstack/svelte-query": "6.1.28",
"sv-router": "^0.16.3"
} }
} }
+13 -7
View File
@@ -6,21 +6,27 @@
/** /**
* App Component * App Component
* *
* Application entry point component. Wraps the main page route within the shared * Application entry point component. Wraps the active route within the shared
* layout shell. This is the root component mounted by the application. * layout shell. This is the root component mounted by the application.
* *
* Structure: * Structure:
* - QueryProvider provides TanStack Query client for data fetching * - QueryProvider provides TanStack Query client for data fetching
* - Layout provides sidebar, header/footer, and page container * - Layout provides sidebar, header/footer, and page container
* - Page renders the current route content * - Router renders the matched route component
*/ */
import Page from '$routes/Page.svelte'; import '$routes/router';
import { QueryProvider } from './providers'; import { Router } from 'sv-router';
import {
AppBindingsProvider,
QueryProvider,
} from './providers';
import Layout from './ui/Layout.svelte'; import Layout from './ui/Layout.svelte';
</script> </script>
<QueryProvider> <QueryProvider>
<Layout> <AppBindingsProvider>
<Page /> <Layout>
</Layout> <Router />
</Layout>
</AppBindingsProvider>
</QueryProvider> </QueryProvider>
Binary file not shown.
Binary file not shown.
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
<!--
Component: AppBindings
Provider that starts app-wide store bindings (filters → sort → font catalog)
for its subtree. Mount-scoped so the bindings' lifetime tracks the app tree.
-->
<script lang="ts">
import { startFilterBindings } from '$features/FilterAndSortFonts';
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
/**
* Content snippet
*/
children?: Snippet;
}
let { children }: Props = $props();
// startFilterBindings returns its $effect.root cleanup; onMount runs it on unmount.
onMount(() => startFilterBindings());
</script>
{@render children?.()}
+4 -1
View File
@@ -6,7 +6,7 @@
descendants of this provider. descendants of this provider.
--> -->
<script lang="ts"> <script lang="ts">
import { queryClient } from '$shared/api/queryClient'; import { getQueryClient } from '$shared/api/queryClient';
import { QueryClientProvider } from '@tanstack/svelte-query'; import { QueryClientProvider } from '@tanstack/svelte-query';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -18,6 +18,9 @@ interface Props {
} }
let { children }: Props = $props(); let { children }: Props = $props();
// First call to the lazy singleton — constructs the shared client for the app.
const queryClient = getQueryClient();
</script> </script>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
+1
View File
@@ -1 +1,2 @@
export { default as AppBindingsProvider } from './AppBindings.svelte';
export { default as QueryProvider } from './QueryProvider.svelte'; export { default as QueryProvider } from './QueryProvider.svelte';
+16 -23
View File
@@ -1,5 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "./fonts.css";
@variant dark (&:where(.dark, .dark *)); @variant dark (&:where(.dark, .dark *));
@@ -216,9 +217,7 @@
/* Monospace label tracking — used in Loader and Footnote */ /* Monospace label tracking — used in Loader and Footnote */
--tracking-wider-mono: 0.2em; --tracking-wider-mono: 0.2em;
/* ============================================ /* Shadow tokens */
SHADOW TOKENS
============================================ */
/* Default resting shadow — equivalent to Tailwind's shadow-sm. Used on /* Default resting shadow — equivalent to Tailwind's shadow-sm. Used on
buttons, sliders, popover triggers in non-floating state. */ buttons, sliders, popover triggers in non-floating state. */
@@ -245,9 +244,7 @@
/* Drawer / overlay shadow — full-strength shadow-2xl. */ /* Drawer / overlay shadow — full-strength shadow-2xl. */
--shadow-overlay: 0 25px 50px -12px rgb(0 0 0 / 0.25); --shadow-overlay: 0 25px 50px -12px rgb(0 0 0 / 0.25);
/* ============================================ /* Motion tokens */
MOTION TOKENS
============================================ */
--duration-fast: 150ms; --duration-fast: 150ms;
--duration-normal: 200ms; --duration-normal: 200ms;
@@ -274,7 +271,7 @@
body { body {
@apply bg-background text-foreground; @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; font-optical-sizing: auto;
} }
@@ -325,9 +322,7 @@
} }
} }
/* ============================================ /* Design-system utilities.
DESIGN-SYSTEM UTILITIES
============================================
Defined via `@utility` (Tailwind v4) so they integrate with the variant Defined via `@utility` (Tailwind v4) so they integrate with the variant
system (`hover:`, `dark:`, breakpoints) and don't rely on `@apply` system (`hover:`, `dark:`, breakpoints) and don't rely on `@apply`
chains. Colors reference the mode-switching semantic vars defined in chains. Colors reference the mode-switching semantic vars defined in
@@ -362,7 +357,7 @@
} }
} }
/* ── Surface utilities ────────────────────────────────────────── */ /* Surface utilities */
@utility surface-canvas { @utility surface-canvas {
background-color: var(--color-surface); background-color: var(--color-surface);
@@ -391,7 +386,7 @@
border: 1px solid var(--color-border-subtle); border: 1px solid var(--color-border-subtle);
} }
/* ── Shape / layout ───────────────────────────────────────────── */ /* Shape / layout */
@utility flex-center { @utility flex-center {
display: flex; display: flex;
@@ -422,7 +417,7 @@
background-size: 10px 10px; background-size: 10px 10px;
} }
/* ── Typography ───────────────────────────────────────────────── */ /* Typography */
@utility text-label-mono { @utility text-label-mono {
font-family: var(--font-primary); font-family: var(--font-primary);
@@ -431,7 +426,7 @@
text-transform: uppercase; text-transform: uppercase;
} }
/* Global utility - useful across your app */ /* Honor prefers-reduced-motion: collapse animation and transition timing. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { * {
animation-duration: 0.01ms !important; animation-duration: 0.01ms !important;
@@ -440,12 +435,12 @@
} }
} }
/* Performance optimization for collapsible elements */ /* Hint the upcoming height animation on open collapsibles. */
[data-state="open"] { [data-state="open"] {
will-change: height; will-change: height;
} }
/* Smooth focus transitions - good globally */ /* Transition siblings of a focus-visible peer. */
.peer:focus-visible ~ * { .peer:focus-visible ~ * {
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
} }
@@ -472,11 +467,9 @@
animation: nudge 10s ease-in-out infinite; animation: nudge 10s ease-in-out infinite;
} }
/* ============================================ /* Scrollbar styling */
SCROLLBAR STYLES
============================================ */
/* ---- Modern API: color + width (Chrome 121+, FF 64+) ---- */ /* Standard API: color + width (Chrome 121+, Firefox 64+). */
@supports (scrollbar-width: auto) { @supports (scrollbar-width: auto) {
* { * {
scrollbar-width: thin; scrollbar-width: thin;
@@ -488,8 +481,8 @@
} }
} }
/* ---- Webkit layer: runs ON TOP in Chrome, standalone in old Safari ---- */ /* WebKit fallback: applies on top of the standard API in Chrome, standalone in
/* Handles things scrollbar-width can't: hiding buttons, exact sizing */ older Safari. Covers what scrollbar-width can't hiding buttons, exact sizing. */
@supports selector(::-webkit-scrollbar) { @supports selector(::-webkit-scrollbar) {
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
@@ -497,7 +490,7 @@
} }
::-webkit-scrollbar-button { ::-webkit-scrollbar-button {
display: none; /* kills arrows */ display: none; /* hide scrollbar buttons */
} }
::-webkit-scrollbar-track { ::-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;
}
+5
View File
@@ -38,6 +38,11 @@ declare module '*.jpg' {
declare module '*.css'; declare module '*.css';
declare module '*.woff2?url' {
const content: string;
export default content;
}
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
+20 -25
View File
@@ -3,12 +3,20 @@
Application shell with providers and page wrapper Application shell with providers and page wrapper
--> -->
<script lang="ts"> <script lang="ts">
import { themeManager } from '$features/ChangeAppTheme'; import { getThemeManager } from '$features/ChangeAppTheme';
import G from '$shared/assets/G.svg'; import G from '$shared/assets/G.svg';
import { ResponsiveProvider } from '$shared/lib'; import { ResponsiveProvider } from '$shared/lib';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
import { Footer } from '$widgets/Footer'; 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 { import {
type Snippet, type Snippet,
onDestroy, onDestroy,
@@ -24,6 +32,8 @@ interface Props {
let { children }: Props = $props(); let { children }: Props = $props();
let fontsReady = $state(true); let fontsReady = $state(true);
const themeManager = getThemeManager();
const theme = $derived(themeManager.value); const theme = $derived(themeManager.value);
onMount(() => themeManager.init()); onMount(() => themeManager.init());
@@ -33,36 +43,21 @@ onDestroy(() => themeManager.destroy());
<svelte:head> <svelte:head>
<link rel="icon" href={G} type="image/svg+xml" /> <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 <link
rel="preconnect" rel="preload"
href="https://cdn.fontshare.com" as="font"
crossorigin="anonymous" type="font/woff2"
/> href={interWoff2}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossorigin="anonymous" crossorigin="anonymous"
/> />
<link <link
rel="preload" rel="preload"
as="style" as="font"
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" 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> <title>GlyphDiff | Typography & Typefaces</title>
<meta <meta
name="description" name="description"
-2
View File
@@ -1,2 +0,0 @@
export * from './store/scrollBreadcrumbsStore.svelte';
export * from './types/types.ts';
+12 -5
View File
@@ -19,8 +19,11 @@ vi.mock('$shared/api/api', () => ({
})); }));
import { api } from '$shared/api/api'; import { api } from '$shared/api/api';
import { queryClient } from '$shared/api/queryClient'; import { getQueryClient } from '$shared/api/queryClient';
const queryClient = getQueryClient();
import { fontKeys } from '$shared/api/queryKeys'; import { fontKeys } from '$shared/api/queryKeys';
import { FontResponseError } from '../../lib/errors/errors';
import { import {
fetchFontsByIds, fetchFontsByIds,
fetchProxyFontById, fetchProxyFontById,
@@ -86,16 +89,20 @@ describe('proxyFonts', () => {
expect(calledUrl).toContain('offset=0'); 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 }); 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 }); 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',
);
}); });
}); });
+15 -6
View File
@@ -11,10 +11,11 @@
*/ */
import { api } from '$shared/api/api'; import { api } from '$shared/api/api';
import { queryClient } from '$shared/api/queryClient'; import { getQueryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys'; import { fontKeys } from '$shared/api/queryKeys';
import { buildQueryString } from '$shared/lib/utils'; import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils'; import type { QueryParams } from '$shared/lib/utils';
import { FontResponseError } from '../../lib/errors/errors';
import type { UnifiedFont } from '../../model/types'; import type { UnifiedFont } from '../../model/types';
/** /**
@@ -25,7 +26,7 @@ import type { UnifiedFont } from '../../model/types';
*/ */
export function seedFontCache(fonts: UnifiedFont[]): void { export function seedFontCache(fonts: UnifiedFont[]): void {
fonts.forEach(font => { fonts.forEach(font => {
queryClient.setQueryData(fontKeys.detail(font.id), font); getQueryClient().setQueryData(fontKeys.detail(font.id), font);
}); });
} }
@@ -96,11 +97,16 @@ export interface ProxyFontsParams extends QueryParams {
/** /**
* Proxy API response * 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 { 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[]; fonts: UnifiedFont[];
@@ -156,8 +162,11 @@ export async function fetchProxyFonts(
const response = await api.get<ProxyFontsResponse>(url); const response = await api.get<ProxyFontsResponse>(url);
if (!response.data || !Array.isArray(response.data.fonts)) { if (!response.data) {
throw new Error('Proxy API returned invalid response'); throw new FontResponseError('response', response.data);
}
if (!Array.isArray(response.data.fonts)) {
throw new FontResponseError('response.fonts', response.data.fonts);
} }
return response.data; return response.data;
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
import { clearCache } from '@chenglou/pretext';
import {
beforeEach,
describe,
expect,
it,
} from 'vitest';
import { DualFontLayout } from './DualFontLayout';
// FontA: 10px per character. FontB: 15px per character.
// The mock dispatches on whether the font string contains 'FontA' or 'FontB'.
const FONT_A_WIDTH = 10;
const FONT_B_WIDTH = 15;
function fontWidthFactory(font: string, text: string): number {
const perChar = font.includes('FontA') ? FONT_A_WIDTH : FONT_B_WIDTH;
return text.length * perChar;
}
describe('DualFontLayout', () => {
let layout: DualFontLayout;
beforeEach(() => {
installCanvasMock(fontWidthFactory);
clearCache();
layout = new DualFontLayout();
});
it('returns empty result for empty string', () => {
const result = layout.layout('', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
expect(result.lines).toHaveLength(0);
expect(result.totalHeight).toBe(0);
});
it('uses worst-case width across both fonts to determine line breaks', () => {
// 'AB CD' — two 2-char words separated by a space.
// FontA: 'AB'=20px, 'CD'=20px. Both fit in 25px? No: 'AB CD' = 50px total.
// FontB: 'AB'=30px, 'CD'=30px. Width 35px forces wrap after 'AB '.
// Unified must use FontB widths — so it must wrap at the same place FontB wraps.
const result = layout.layout('AB CD', '400 16px "FontA"', '400 16px "FontB"', 35, 20);
expect(result.lines.length).toBeGreaterThan(1);
// First line text must not include both words.
expect(result.lines[0].text).not.toContain('CD');
});
it('provides xA and xB offsets for both fonts on a single line', () => {
// 'ABC' fits in 500px for both fonts.
// FontA: A@0(w=10), B@10(w=10), C@20(w=10)
// FontB: A@0(w=15), B@15(w=15), C@30(w=15)
const result = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const chars = result.lines[0].chars;
expect(chars).toHaveLength(3);
expect(chars[0].xA).toBe(0);
expect(chars[0].widthA).toBe(FONT_A_WIDTH);
expect(chars[0].xB).toBe(0);
expect(chars[0].widthB).toBe(FONT_B_WIDTH);
expect(chars[1].xA).toBe(FONT_A_WIDTH); // 10
expect(chars[1].widthA).toBe(FONT_A_WIDTH);
expect(chars[1].xB).toBe(FONT_B_WIDTH); // 15
expect(chars[1].widthB).toBe(FONT_B_WIDTH);
expect(chars[2].xA).toBe(FONT_A_WIDTH * 2); // 20
expect(chars[2].xB).toBe(FONT_B_WIDTH * 2); // 30
});
it('returns cached result when called again with same arguments', () => {
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
expect(r2).toBe(r1); // strict reference equality — same object
});
it('re-computes when text changes', () => {
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = layout.layout('DEF', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
expect(r2).not.toBe(r1);
expect(r2.lines[0].text).not.toBe(r1.lines[0].text);
});
it('re-computes when width changes', () => {
const r1 = layout.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = layout.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 60, 20);
expect(r2).not.toBe(r1);
});
it('re-computes when fontA changes', () => {
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = layout.layout('ABC', '400 24px "FontA"', '400 16px "FontB"', 500, 20);
expect(r2).not.toBe(r1);
});
});
@@ -0,0 +1,278 @@
import {
type PreparedTextWithSegments,
layoutWithLines,
prepareWithSegments,
} from '@chenglou/pretext';
/**
* Default render size in px when callers omit the `size` arg on `layout()`.
*/
const DEFAULT_RENDER_SIZE_PX = 16;
/**
* Per-grapheme data computed during dual-font layout. Internal to the engine;
* consumed by computeLineRenderModel to derive the per-frame render model.
*/
export interface ComparisonChar {
/**
* Grapheme cluster (may be >1 code unit for emoji, combining marks).
*/
char: string;
/**
* X offset from line start in fontA, pixels.
*/
xA: number;
/**
* Advance width of this grapheme in fontA, pixels.
*/
widthA: number;
/**
* X offset from line start in fontB, pixels.
*/
xB: number;
/**
* Advance width of this grapheme in fontB, pixels.
*/
widthB: number;
}
/**
* A single laid-out line. `chars` carries the per-grapheme data needed by
* computeLineRenderModel. Consumers should not iterate it directly.
*/
export interface ComparisonLine {
/**
* Full text of this line as returned by pretext.
*/
text: string;
/**
* Rendered width in pixels maximum across fontA and fontB.
*/
width: number;
/**
* Per-grapheme metadata for both fonts.
*/
chars: ComparisonChar[];
}
/**
* Aggregated output of a dual-font layout pass.
*/
export interface ComparisonResult {
/**
* Per-line grapheme data. Empty when input text is empty.
*/
lines: ComparisonLine[];
/**
* Total height in pixels.
*/
totalHeight: number;
}
/**
* Dual-font text layout engine backed by `@chenglou/pretext`.
*
* Computes identical line breaks for two fonts simultaneously by constructing a
* "unified" prepared-text object whose per-glyph widths are the worst-case maximum
* of font A and font B. This guarantees that both fonts wrap at exactly the same
* positions, making side-by-side or slider comparison visually coherent.
*
* Relies on pretext's published structural fields on `PreparedTextWithSegments`
* (`widths`, `breakableFitAdvances`, `lineEndFitAdvances`, `lineEndPaintAdvances`)
* which are exposed via the `PreparedCore` intersection in `@chenglou/pretext@0.0.6`.
*
* **Two-level caching strategy**
* 1. Font-change cache (`#preparedA`, `#preparedB`, `#unifiedPrepared`): rebuilt only
* when `text`, `fontA`, or `fontB` changes. `prepareWithSegments` is expensive
* (canvas measurement), so this avoids re-measuring during slider interaction.
* 2. Layout cache (`#lastResult`): rebuilt when `width` or `lineHeight` changes but
* the fonts have not changed. Line-breaking is cheap relative to measurement, but
* still worth skipping on every render tick.
*
* Per-frame slider state derivation lives in `computeLineRenderModel`, not on the
* class. This class is pure layout + caching; it holds no reactive state.
*/
export class DualFontLayout {
#segmenter: Intl.Segmenter;
// Cached prepared data
#preparedA: PreparedTextWithSegments | null = null;
#preparedB: PreparedTextWithSegments | null = null;
#unifiedPrepared: PreparedTextWithSegments | null = null;
#lastText = '';
#lastFontA = '';
#lastFontB = '';
#lastSpacing = 0;
#lastSize = 0;
// Cached layout results
#lastWidth = -1;
#lastLineHeight = -1;
#lastResult: ComparisonResult | null = null;
constructor(locale?: string) {
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
}
/**
* Lay out `text` using both fonts within `width` pixels.
*
* Line breaks are determined by the worst-case (maximum) glyph widths across
* both fonts, so both fonts always wrap at identical positions.
*
* @param text Raw text to lay out.
* @param fontA CSS font string for the first font: `"weight sizepx \"family\""`.
* @param fontB CSS font string for the second font: `"weight sizepx \"family\""`.
* @param width Available line width in pixels.
* @param lineHeight Line height in pixels (passed directly to pretext).
* @param spacing Letter spacing in em (from typography settings).
* @param size Current font size in pixels (used to convert spacing em to px).
* @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty.
*/
layout(
text: string,
fontA: string,
fontB: string,
width: number,
lineHeight: number,
spacing: number = 0,
size: number = DEFAULT_RENDER_SIZE_PX,
): ComparisonResult {
if (!text) {
return { lines: [], totalHeight: 0 };
}
const spacingPx = spacing * size;
const isFontChange = text !== this.#lastText
|| fontA !== this.#lastFontA
|| fontB !== this.#lastFontB
|| spacing !== this.#lastSpacing
|| size !== this.#lastSize;
const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight;
if (!isFontChange && !isLayoutChange && this.#lastResult) {
return this.#lastResult;
}
// 1. Prepare (or use cache)
if (isFontChange) {
this.#preparedA = prepareWithSegments(text, fontA);
this.#preparedB = prepareWithSegments(text, fontB);
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB, spacingPx);
this.#lastText = text;
this.#lastFontA = fontA;
this.#lastFontB = fontB;
this.#lastSpacing = spacing;
this.#lastSize = size;
}
if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) {
return { lines: [], totalHeight: 0 };
}
const { lines, height } = layoutWithLines(this.#unifiedPrepared, width, lineHeight);
// 3. Map results back to both fonts
const preparedA = this.#preparedA;
const preparedB = this.#preparedB;
const resultLines: ComparisonLine[] = lines.map(line => {
const chars: ComparisonChar[] = [];
let currentXA = 0;
let currentXB = 0;
const start = line.start;
const end = line.end;
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
const segmentText = preparedA.segments[sIdx];
if (segmentText === undefined) {
continue;
}
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
const advA = preparedA.breakableFitAdvances[sIdx];
const advB = preparedB.breakableFitAdvances[sIdx];
const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0;
const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length;
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
const char = graphemes[gIdx];
let wA = advA != null ? advA[gIdx]! : preparedA.widths[sIdx]!;
let wB = advB != null ? advB[gIdx]! : preparedB.widths[sIdx]!;
// Apply letter spacing (tracking) to the width of each character
wA += spacingPx;
wB += spacingPx;
chars.push({
char,
xA: currentXA,
widthA: wA,
xB: currentXB,
widthB: wB,
});
currentXA += wA;
currentXB += wB;
}
}
return {
text: line.text,
width: line.width,
chars,
};
});
this.#lastWidth = width;
this.#lastLineHeight = lineHeight;
this.#lastResult = {
lines: resultLines,
totalHeight: height,
};
return this.#lastResult;
}
/**
* Merge two prepared texts into a worst-case unified version so both fonts
* wrap at identical positions. Per-segment widths are the elementwise max
* across both fonts, with `spacingPx` added to model letter-spacing.
*/
#createUnifiedPrepared(
a: PreparedTextWithSegments,
b: PreparedTextWithSegments,
spacingPx: number = 0,
): PreparedTextWithSegments {
const unified: PreparedTextWithSegments = { ...a };
unified.widths = a.widths.map((w, i) => Math.max(w, b.widths[i]) + spacingPx);
unified.lineEndFitAdvances = a.lineEndFitAdvances.map((w, i) =>
Math.max(w, b.lineEndFitAdvances[i]) + spacingPx
);
unified.lineEndPaintAdvances = a.lineEndPaintAdvances.map((w, i) =>
Math.max(w, b.lineEndPaintAdvances[i]) + spacingPx
);
unified.breakableFitAdvances = a.breakableFitAdvances.map((advA, i) => {
const advB = b.breakableFitAdvances[i];
if (!advA && !advB) {
return null;
}
if (!advA) {
return advB!.map(w => w + spacingPx);
}
if (!advB) {
return advA.map(w => w + spacingPx);
}
return advA.map((w, j) => Math.max(w, advB[j]) + spacingPx);
});
return unified;
}
}
@@ -0,0 +1,220 @@
import {
describe,
expect,
it,
} from 'vitest';
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
import {
type LineRenderModel,
computeLineRenderModel,
findSplitIndex,
} from './computeLineRenderModel';
/**
* Build a ComparisonLine fixture with given per-char widths. xA/xB are
* cumulative prefix sums of widthA/widthB respectively.
*/
function makeLine(
chars: { char: string; widthA: number; widthB: number }[],
): ComparisonLine {
let xA = 0;
let xB = 0;
const out: ComparisonLine = {
text: chars.map(c => c.char).join(''),
width: chars.reduce((s, c) => s + Math.max(c.widthA, c.widthB), 0),
chars: chars.map(c => {
const entry = {
char: c.char,
xA,
xB,
widthA: c.widthA,
widthB: c.widthB,
};
xA += c.widthA;
xB += c.widthB;
return entry;
}),
};
return out;
}
/**
* Test helper: compute split + render model in one step, matching the
* SliderArea call site shape.
*/
function compute(
line: ComparisonLine,
sliderPos: number,
containerWidth: number,
windowSize: number,
): LineRenderModel {
const split = findSplitIndex(line, sliderPos, containerWidth);
return computeLineRenderModel(line, split, windowSize);
}
describe('computeLineRenderModel', () => {
it('returns empty model for an empty line', () => {
const line = makeLine([]);
const model = compute(line, 50, 500, 5);
expect(model.leftText).toBe('');
expect(model.windowChars).toEqual([]);
expect(model.rightText).toBe('');
});
it('places entire line in rightText when slider is at 0', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
const model = compute(line, 0, 500, 0);
expect(model.leftText).toBe('');
expect(model.windowChars).toEqual([]);
expect(model.rightText).toBe('ABC');
});
it('places entire line in leftText when slider is at 100', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
const model = compute(line, 100, 500, 0);
expect(model.leftText).toBe('ABC');
expect(model.windowChars).toEqual([]);
expect(model.rightText).toBe('');
});
it('splits line correctly with slider mid-line (window=0)', () => {
// Equal widths → line is centered. Container=300, total=30 → xOffset=135.
// Char thresholds (per the threshold formula in the design):
// threshold[i] = xOffset + prefA[i] + widthA[i]/2
// i=0: 135 + 0 + 5 = 140 → 140/300 = 46.67%
// i=1: 135 + 10 + 5 = 150 → 150/300 = 50.00%
// i=2: 135 + 20 + 5 = 160 → 160/300 = 53.33%
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
// Slider just past B's threshold (50%) but not C's (53.33%).
const model = compute(line, 51, 300, 0);
expect(model.leftText).toBe('AB');
expect(model.rightText).toBe('C');
});
it('centers window of size 3 on the split index', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
// Slider past A and B (~thresholds 43.33%, 46.67%); not past C (50%).
// split = 2 → halfWindow = 1 → windowStart = 1, windowEnd = 4
const model = compute(line, 48, 300, 3);
expect(model.leftText).toBe('A');
expect(model.windowChars.map(w => w.char)).toEqual(['B', 'C', 'D']);
expect(model.rightText).toBe('E');
});
it('clamps window at line start when slider is near 0', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
const model = compute(line, 0, 300, 3);
expect(model.leftText).toBe('');
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B', 'C']);
expect(model.rightText).toBe('DE');
});
it('clamps window at line end when slider is near 100', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
const model = compute(line, 100, 300, 3);
expect(model.leftText).toBe('AB');
expect(model.windowChars.map(w => w.char)).toEqual(['C', 'D', 'E']);
expect(model.rightText).toBe('');
});
it('treats whole line as window when line is shorter than windowSize', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
]);
const model = compute(line, 50, 300, 5);
expect(model.leftText).toBe('');
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B']);
expect(model.rightText).toBe('');
});
it('produces stable keys across slider movement within the same line', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
const a = compute(line, 40, 300, 3);
const b = compute(line, 60, 300, 3);
// Chars that appear in both windows must carry identical keys.
for (const charA of a.windowChars) {
const charB = b.windowChars.find(w => w.char === charA.char);
if (charB !== undefined) {
expect(charB.key).toBe(charA.key);
}
}
});
it('marks isPast=true for chars before the split and false for chars after', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
// split = 2 → A,B past; C,D,E not
const model = compute(line, 48, 300, 5);
const expected = new Map([['A', true], ['B', true], ['C', false], ['D', false], ['E', false]]);
for (const wc of model.windowChars) {
expect(wc.isPast).toBe(expected.get(wc.char));
}
});
});
describe('findSplitIndex', () => {
it('returns 0 for empty line', () => {
const line = makeLine([]);
expect(findSplitIndex(line, 50, 500)).toBe(0);
});
it('returns 0 when slider is before all char thresholds', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
expect(findSplitIndex(line, 0, 300)).toBe(0);
});
it('returns chars.length when slider is past all char thresholds', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
expect(findSplitIndex(line, 100, 300)).toBe(3);
});
});
@@ -0,0 +1,133 @@
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
/**
* Per-line render slice consumed by Line.svelte. The window is centered on the
* slider's split index and clamps at line boundaries.
*/
export interface LineRenderModel {
/**
* Chars before the window joined into a single string, rendered as one fontA text run.
*/
leftText: string;
/**
* Window chars each rendered as its own Character element with crossfade slots.
*/
windowChars: Array<{
/**
* Stable key for Svelte keyed each survives slider movement within the same line.
*/
key: string;
/**
* Grapheme cluster to render.
*/
char: string;
/**
* True once the slider has crossed this char's threshold.
*/
isPast: boolean;
}>;
/**
* Chars after the window joined into a single string, rendered as one fontB text run.
*/
rightText: string;
}
/**
* Returns the count of chars whose flip threshold the slider has crossed.
*
* Exposed as a separate step so consumers can pass the resulting primitive
* `split` across component boundaries: when split is unchanged tick-to-tick,
* downstream `$derived` reads of `computeLineRenderModel(line, split, ...)`
* short-circuit on value equality and skip re-rendering.
*
* For each candidate split `i`, the line's hypothetical width at that moment is
* `prefA[i] + widthA[i] + sufB[i+1]` (past chars in fontA, char `i` flipping, future
* chars in fontB). The threshold is the x of char `i`'s center in the centered line.
* Thresholds are monotonically non-decreasing in `i`, so the scan short-circuits on
* the first miss.
*/
export function findSplitIndex(
line: ComparisonLine,
sliderPos: number,
containerWidth: number,
): number {
const chars = line.chars;
const n = chars.length;
if (n === 0) {
return 0;
}
const sliderX = (sliderPos / 100) * containerWidth;
const prefA = new Float64Array(n + 1);
const sufB = new Float64Array(n + 1);
for (let i = 0, j = n - 1; i < n; i++, j--) {
prefA[i + 1] = prefA[i] + chars[i].widthA;
sufB[j] = sufB[j + 1] + chars[j].widthB;
}
let split = 0;
for (let i = 0; i < n; i++) {
const totalWidth = prefA[i] + chars[i].widthA + sufB[i + 1];
const xOffset = (containerWidth - totalWidth) / 2;
const threshold = xOffset + prefA[i] + chars[i].widthA / 2;
if (sliderX > threshold) {
split = i + 1;
} else {
break;
}
}
return split;
}
/**
* Slices a laid-out line into three regions around a precomputed split index:
* a fontA bulk run, an N-char crossfade window, and a fontB bulk run.
*
* Pure and allocation-bounded: two strings plus a `windowSize`-length array per call.
* Takes `split` as a primitive so callers can feed it into a `$derived` and
* skip re-evaluation on ticks where the split index is unchanged.
*
* @param line Line from `DualFontLayout.layout()`. Empty `chars` yields an empty model.
* @param split Count of chars the slider has passed, in `[0, line.chars.length]`.
* @param windowSize Number of chars in the crossfade window. Clamped to `[0, line.chars.length]`.
* At line edges the window is shifted (not shrunk) to keep its size.
*/
export function computeLineRenderModel(
line: ComparisonLine,
split: number,
windowSize: number,
): LineRenderModel {
const chars = line.chars;
const n = chars.length;
if (n === 0) {
return { leftText: '', windowChars: [], rightText: '' };
}
const halfWindow = Math.floor(Math.max(0, windowSize) / 2);
let windowStart = clamp(split - halfWindow, 0, n);
let windowEnd = clamp(windowStart + Math.max(0, windowSize), 0, n);
windowStart = Math.max(0, windowEnd - Math.max(0, windowSize));
const leftText = chars.slice(0, windowStart).map(c => c.char).join('');
const rightText = chars.slice(windowEnd).map(c => c.char).join('');
const windowChars = chars.slice(windowStart, windowEnd).map((c, idx) => ({
key: `${windowStart + idx}-${c.char}`,
char: c.char,
isPast: (windowStart + idx) < split,
}));
return { leftText, windowChars, rightText };
}
/**
* Clamps `value` into the inclusive range `[lo, hi]`. Assumes `lo <= hi`.
*/
function clamp(value: number, lo: number, hi: number): number {
if (value < lo) {
return lo;
}
if (value > hi) {
return hi;
}
return value;
}
+11
View File
@@ -0,0 +1,11 @@
export {
type ComparisonLine,
type ComparisonResult,
DualFontLayout,
} from './DualFontLayout/DualFontLayout';
export {
computeLineRenderModel,
findSplitIndex,
type LineRenderModel,
} from './computeLineRenderModel/computeLineRenderModel';
export { windowSizeForLine } from './windowSizeForLine/windowSizeForLine';
@@ -0,0 +1,38 @@
import {
describe,
expect,
it,
} from 'vitest';
import { windowSizeForLine } from './windowSizeForLine';
describe('windowSizeForLine', () => {
it('returns 0 for an empty or non-positive line', () => {
expect(windowSizeForLine(0)).toBe(0);
expect(windowSizeForLine(-3)).toBe(0);
});
it('floors non-empty short lines at the minimum window of 1', () => {
expect(windowSizeForLine(1)).toBe(1);
expect(windowSizeForLine(2)).toBe(1);
expect(windowSizeForLine(3)).toBe(1);
});
it('scales with round(n / 3) in the mid range', () => {
expect(windowSizeForLine(6)).toBe(2);
expect(windowSizeForLine(12)).toBe(4);
});
it('caps at the maximum window of 5', () => {
expect(windowSizeForLine(15)).toBe(5);
expect(windowSizeForLine(16)).toBe(5);
expect(windowSizeForLine(100)).toBe(5);
});
it('rounds to nearest at fractional boundaries', () => {
// round(4/3)=1, round(5/3)=2, round(13/3)=4, round(14/3)=5
expect(windowSizeForLine(4)).toBe(1);
expect(windowSizeForLine(5)).toBe(2);
expect(windowSizeForLine(13)).toBe(4);
expect(windowSizeForLine(14)).toBe(5);
});
});
@@ -0,0 +1,39 @@
/**
* Crossfade-window sizing policy for the dual-font slider.
*
* The slider renders a band of per-char `Character` cells that opacity-crossfade
* between the two fonts; everything outside the band is committed native bulk
* text. A fixed band looked wrong on short lines a 6-grapheme line left almost
* no bulk, so nearly the whole line shimmered as per-char DOM. The band size
* therefore scales with the line's grapheme count and caps so long lines don't
* pay for an oversized per-char DOM band.
*/
/**
* Fraction of a line's graphemes that sit in the crossfade band.
*/
const WINDOW_RATIO = 1 / 3;
/**
* Smallest band for a non-empty line guarantees at least one crossfading char.
*
* Accepted tradeoff: short lines now get a band of 12, so a fast slider drag
* can unmount a char before its ~100ms opacity crossfade finishes, a slight pop.
* Worth it for the "bulk committed, small band shimmering" look on short lines;
* raising this trades that pop back for less committed bulk.
*/
const WINDOW_MIN = 1;
/**
* Largest band regardless of line length bounds per-char DOM cost.
*/
const WINDOW_MAX = 5;
/**
* Crossfade window size, in graphemes, for a line of `n` graphemes.
* `clamp(round(n / 3), 1, 5)`; an empty/non-positive line gets no window.
*/
export function windowSizeForLine(n: number): number {
if (n <= 0) {
return 0;
}
return Math.min(WINDOW_MAX, Math.max(WINDOW_MIN, Math.round(n * WINDOW_RATIO)));
}
+93 -4
View File
@@ -1,4 +1,93 @@
export * from './api'; export {
export * from './lib'; computeLineRenderModel,
export * from './model'; DualFontLayout,
export * from './ui'; findSplitIndex,
windowSizeForLine,
} from './domain';
export type {
ComparisonLine,
ComparisonResult,
LineRenderModel,
} from './domain';
export {
createFontRowSizeResolver,
FontNetworkError,
FontResponseError,
getFontUrl,
} from './lib';
export type { FontRowSizeResolverOptions } from './lib';
export {
FontApplicator,
FontSampler,
FontVirtualList,
} from './ui';
// Pure model surface (types + constants).
export {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LETTER_SPACING_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LETTER_SPACING,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LETTER_SPACING,
MIN_LINE_HEIGHT,
VIRTUAL_INDEX_NOT_LOADED,
} from './model/const/const';
export type {
FilterGroup,
FilterType,
FontCategory,
FontCollectionFilters,
FontCollectionSort,
FontCollectionState,
FontFeatures,
FontFilters,
FontLoadRequestConfig,
FontLoadStatus,
FontMetadata,
FontProvider,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
UnifiedFont,
UnifiedFontVariant,
} from './model/types';
/*
* Stores are exposed as lazy accessors / classes (not eager singletons): the
* entity's public API is complete, so consumers go through this barrel instead
* of deep-importing `./model` (FSD public-API boundary). Construction happens on
* first call, so this is inert at import. The slice root already transitively
* loads `@tanstack/query-core` via `./ui` (FontVirtualList), so surfacing the
* stores here adds no new eager cost.
*/
export {
FontLifecycleManager,
FontsByIdsStore,
getFontCatalog,
getFontLifecycleManager,
} from './model';
export type { FontCatalogStore } from './model';
/*
* `./api` (proxy clients: `fetchProxyFonts`, `seedFontCache`, ) is intentionally
* NOT re-exported here those are not part of the entity's consumed surface and
* importing them eagerly constructs the TanStack `queryClient`. Import via the
* segment: `import { fetchProxyFonts } from '$entities/Font/api'`.
*/
// `./testing` is intentionally not re-exported: fixtures must not leak into the
// production public API. Import them via `$entities/Font/testing`.
@@ -0,0 +1,111 @@
import {
describe,
expect,
it,
} from 'vitest';
import type { UnifiedFont } from '../../model/types';
import { createFontLoadRequestContfig } from './createFontLoadRequestContfig';
/**
* Minimal UnifiedFont mock override only the fields a case exercises.
*/
function createMockFont(overrides: Partial<UnifiedFont> = {}): UnifiedFont {
const baseFont: UnifiedFont = {
id: 'test-font',
name: 'Test Font',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: [],
styles: {},
metadata: {
cachedAt: Date.now(),
},
features: {
isVariable: false,
tags: [],
},
};
return { ...baseFont, ...overrides };
}
describe('createFontLoadRequestContfig', () => {
it('builds a single-element config when a URL resolves', () => {
const font = createMockFont({
id: 'roboto',
name: 'Roboto',
styles: { variants: { '400': 'https://example.com/roboto-400.woff2' } },
});
const result = createFontLoadRequestContfig(font, 400);
expect(result).toEqual([
{
id: 'roboto',
name: 'Roboto',
weight: 400,
url: 'https://example.com/roboto-400.woff2',
isVariable: false,
},
]);
});
it('returns an empty array when no URL resolves (flatMap drops the font)', () => {
const font = createMockFont({ styles: {} });
expect(createFontLoadRequestContfig(font, 400)).toEqual([]);
});
it('forwards isVariable from font features', () => {
const font = createMockFont({
features: { isVariable: true, tags: [] },
styles: { variants: { '700': 'https://example.com/inter-vf.woff2' } },
});
const [config] = createFontLoadRequestContfig(font, 700);
expect(config.isVariable).toBe(true);
});
it('sets isVariable to undefined when features is absent', () => {
// features is non-optional on UnifiedFont, but upstream data can be partial —
// the optional chain must not throw, and isVariable stays undefined.
const font = createMockFont({
styles: { variants: { '400': 'https://example.com/font.woff2' } },
});
// @ts-expect-error — deliberately drop the guaranteed field to exercise the optional chain
font.features = undefined;
const [config] = createFontLoadRequestContfig(font, 400);
expect(config.isVariable).toBeUndefined();
});
it('uses the resolved fallback URL, not just exact matches', () => {
// getFontUrl falls back to styles.regular when the exact weight is missing;
// the config must carry whatever URL actually resolved.
const font = createMockFont({
styles: { regular: 'https://example.com/font-regular.woff2' },
});
const [config] = createFontLoadRequestContfig(font, 900);
expect(config.url).toBe('https://example.com/font-regular.woff2');
expect(config.weight).toBe(900);
});
it('carries the requested weight even when the URL is a shared fallback', () => {
const font = createMockFont({
styles: { variants: { '400': 'https://example.com/shared.woff2' } },
});
expect(createFontLoadRequestContfig(font, 700)[0].weight).toBe(700);
});
it('propagates the invalid-weight error from getFontUrl', () => {
const font = createMockFont();
expect(() => createFontLoadRequestContfig(font, 450)).toThrow('Invalid weight: 450');
});
});
@@ -0,0 +1,33 @@
import type {
FontLoadRequestConfig,
UnifiedFont,
} from '../../model';
import { getFontUrl } from '../getFontUrl/getFontUrl';
/**
* Build the font-lifecycle load request for a single font at a given weight.
*
* Returns a 0-or-1 element array rather than `FontLoadRequestConfig | undefined`
* so call sites can `flatMap` over a font list resolve the URL and drop fonts
* that have none in a single pass, with no separate filter step. An empty array
* means the font has no loadable asset for this weight (or its fallbacks) and is
* silently skipped.
*
* `isVariable` is forwarded from the font's features so the lifecycle manager can
* dedupe variable fonts per ID (they load once regardless of weight) while still
* loading static fonts per weight.
*
* @param font - Unified font to load
* @param weight - Numeric weight (100-900)
* @returns Single-element config array, or `[]` when no URL resolves
* @throws Error when weight is outside the valid 100-900 range (propagated from `getFontUrl`)
*/
export function createFontLoadRequestContfig(font: UnifiedFont, weight: number): FontLoadRequestConfig[] {
const url = getFontUrl(font, weight);
if (!url) {
return [];
}
return [{ id: font.id, name: font.name, weight, url, isVariable: font.features?.isVariable }];
}
+5 -1
View File
@@ -1,3 +1,5 @@
import { NonRetryableError } from '$shared/api/nonRetryableError';
/** /**
* Thrown when the network request to the proxy API fails. * Thrown when the network request to the proxy API fails.
* Wraps the underlying fetch error (timeout, DNS failure, connection refused, etc.). * 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. * 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 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. * @property received - The actual value received at that field, for debugging.
*/ */
export class FontResponseError extends Error { export class FontResponseError extends NonRetryableError {
readonly name = 'FontResponseError'; readonly name = 'FontResponseError';
constructor( constructor(
-44
View File
@@ -1,49 +1,5 @@
export { getFontUrl } from './getFontUrl/getFontUrl'; export { getFontUrl } from './getFontUrl/getFontUrl';
// Mock data helpers for Storybook and testing
export {
createCategoriesFilter,
createErrorState,
createGenericFilter,
createLoadingState,
createMockComparisonStore,
// Filter mocks
createMockFilter,
createMockFontApiResponse,
createMockFontStoreState,
// Store mocks
createMockQueryState,
createMockReactiveState,
createMockStore,
createProvidersFilter,
createSubsetsFilter,
createSuccessState,
generateMixedCategoryFonts,
generateMockFonts,
generatePaginatedFonts,
generateSequentialFilter,
GENERIC_FILTERS,
getAllMockFonts,
getFontsByCategory,
getFontsByProvider,
MOCK_FILTERS,
MOCK_FILTERS_ALL_SELECTED,
MOCK_FILTERS_EMPTY,
MOCK_FILTERS_SELECTED,
MOCK_FONT_STORE_STATES,
MOCK_STORES,
type MockFilterOptions,
type MockFilters,
type MockFontStoreState,
// Font mocks
// Types
type MockQueryObserverResult,
type MockQueryState,
mockUnifiedFont,
type MockUnifiedFontOptions,
UNIFIED_FONTS,
} from './mocks';
export { export {
FontNetworkError, FontNetworkError,
FontResponseError, FontResponseError,
@@ -14,6 +14,7 @@ vi.mock('@chenglou/pretext', async () => {
layout: vi.fn(actual.layout), layout: vi.fn(actual.layout),
}; };
}); });
import { mockUnifiedFont } from '$entities/Font/testing';
import { import {
beforeEach, beforeEach,
describe, describe,
@@ -22,7 +23,6 @@ import {
vi, vi,
} from 'vitest'; } from 'vitest';
import type { FontLoadStatus } from '../../model/types'; import type { FontLoadStatus } from '../../model/types';
import { mockUnifiedFont } from '../mocks';
import { createFontRowSizeResolver } from './createFontRowSizeResolver'; import { createFontRowSizeResolver } from './createFontRowSizeResolver';
// Fixed-width canvas mock: every character is 10px wide regardless of font. // Fixed-width canvas mock: every character is 10px wide regardless of font.
-57
View File
@@ -1,6 +1,3 @@
import type { ControlModel } from '$shared/lib';
import type { ControlId } from '../types/typography';
/** /**
* Font size constants * Font size constants
*/ */
@@ -33,60 +30,6 @@ export const MIN_LETTER_SPACING = -0.1;
export const MAX_LETTER_SPACING = 0.5; export const MAX_LETTER_SPACING = 0.5;
export const LETTER_SPACING_STEP = 0.01; export const LETTER_SPACING_STEP = 0.01;
export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
{
id: 'font_size',
value: DEFAULT_FONT_SIZE,
max: MAX_FONT_SIZE,
min: MIN_FONT_SIZE,
step: FONT_SIZE_STEP,
increaseLabel: 'Increase Font Size',
decreaseLabel: 'Decrease Font Size',
controlLabel: 'Size',
},
{
id: 'font_weight',
value: DEFAULT_FONT_WEIGHT,
max: MAX_FONT_WEIGHT,
min: MIN_FONT_WEIGHT,
step: FONT_WEIGHT_STEP,
increaseLabel: 'Increase Font Weight',
decreaseLabel: 'Decrease Font Weight',
controlLabel: 'Weight',
},
{
id: 'line_height',
value: DEFAULT_LINE_HEIGHT,
max: MAX_LINE_HEIGHT,
min: MIN_LINE_HEIGHT,
step: LINE_HEIGHT_STEP,
increaseLabel: 'Increase Line Height',
decreaseLabel: 'Decrease Line Height',
controlLabel: 'Leading',
},
{
id: 'letter_spacing',
value: DEFAULT_LETTER_SPACING,
max: MAX_LETTER_SPACING,
min: MIN_LETTER_SPACING,
step: LETTER_SPACING_STEP,
increaseLabel: 'Increase Letter Spacing',
decreaseLabel: 'Decrease Letter Spacing',
controlLabel: 'Tracking',
},
];
/**
* Font size multipliers
*/
export const MULTIPLIER_S = 0.5;
export const MULTIPLIER_M = 0.75;
export const MULTIPLIER_L = 1;
/** /**
* Index value for items not yet loaded in a virtualized list. * Index value for items not yet loaded in a virtualized list.
* Treated as being at the very bottom of the infinite scroll. * Treated as being at the very bottom of the infinite scroll.
+51 -3
View File
@@ -1,3 +1,51 @@
export * from './const/const'; export {
export * from './store'; DEFAULT_FONT_SIZE,
export * from './types'; DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LETTER_SPACING_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LETTER_SPACING,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LETTER_SPACING,
MIN_LINE_HEIGHT,
VIRTUAL_INDEX_NOT_LOADED,
} from './const/const';
// Stores (lazy accessors + classes)
export {
__resetFontLifecycleManager,
FontLifecycleManager,
FontsByIdsStore,
getFontCatalog,
getFontLifecycleManager,
} from './store';
export type { FontCatalogStore } from './store';
export type {
FilterGroup,
FilterType,
FontCategory,
FontCollectionFilters,
FontCollectionSort,
FontCollectionState,
FontFeatures,
FontFilters,
FontLoadRequestConfig,
FontLoadStatus,
FontMetadata,
FontProvider,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
UnifiedFont,
UnifiedFontVariant,
} from './types';
@@ -1,4 +1,7 @@
import { QueryClient } from '@tanstack/query-core'; import {
generateMixedCategoryFonts,
generateMockFonts,
} from '$entities/Font/testing';
import { flushSync } from 'svelte'; import { flushSync } from 'svelte';
import { import {
afterEach, afterEach,
@@ -12,27 +15,33 @@ import {
FontNetworkError, FontNetworkError,
FontResponseError, FontResponseError,
} from '../../../lib/errors/errors'; } from '../../../lib/errors/errors';
import {
generateMixedCategoryFonts,
generateMockFonts,
} from '../../../lib/mocks/fonts.mock';
import type { UnifiedFont } from '../../types'; import type { UnifiedFont } from '../../types';
import { FontCatalogStore } from './fontCatalogStore.svelte'; import { FontCatalogStore } from './fontCatalogStore.svelte';
vi.mock('$shared/api/queryClient', async importOriginal => { vi.mock('$shared/api/queryClient', async importOriginal => {
/**
* Import QueryClient inside the factory rather than referencing the top-level binding.
* A hoisted vi.mock factory that touches a module-level import can hit that import
* before it is initialized (ReferenceError) when the import sits in a circular/eager
* barrel chain which it now does via $shared/lib BaseQueryStore query-core.
*/
const { QueryClient } = await import('@tanstack/query-core');
const actual = await importOriginal<typeof import('$shared/api/queryClient')>(); const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
const mockClient = new QueryClient({
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
});
return { return {
...actual, ...actual,
queryClient: new QueryClient({ getQueryClient: () => mockClient,
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
}),
}; };
}); });
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() })); vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
import { queryClient } from '$shared/api/queryClient'; import { getQueryClient } from '$shared/api/queryClient';
import { fetchProxyFonts } from '../../../api'; import { fetchProxyFonts } from '../../../api';
const queryClient = getQueryClient();
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>; const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number }; type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
@@ -84,9 +93,10 @@ describe('FontCatalogStore', () => {
store.destroy(); store.destroy();
}); });
it('starts with isEmpty false — initial fetch is in progress', () => { it('starts with isEmpty false — observer is gated until setParams enables it', () => {
// The observer starts fetching immediately on construction. // The observer is disabled on construction (no auto-fetch) — see
// isEmpty must be false so the UI shows a loader, not "no results". // `#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(); const store = makeStore();
expect(store.isEmpty).toBe(false); expect(store.isEmpty).toBe(false);
store.destroy(); store.destroy();
@@ -1,8 +1,9 @@
import { import {
DEFAULT_QUERY_GC_TIME_MS, DEFAULT_QUERY_GC_TIME_MS,
DEFAULT_QUERY_STALE_TIME_MS, DEFAULT_QUERY_STALE_TIME_MS,
queryClient, getQueryClient,
} from '$shared/api/queryClient'; } from '$shared/api/queryClient';
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
import { import {
type InfiniteData, type InfiniteData,
InfiniteQueryObserver, InfiniteQueryObserver,
@@ -31,6 +32,13 @@ type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsRespon
export class FontCatalogStore { export class FontCatalogStore {
#params = $state<FontStoreParams>({ limit: 50 }); #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); #result = $state<FontStoreResult>({} as FontStoreResult);
#observer: InfiniteQueryObserver< #observer: InfiniteQueryObserver<
ProxyFontsResponse, ProxyFontsResponse,
@@ -39,12 +47,14 @@ export class FontCatalogStore {
readonly unknown[], readonly unknown[],
PageParam PageParam
>; >;
#qc = queryClient; #qc = getQueryClient();
#unsubscribe: () => void; #unsubscribe: () => void;
constructor(params: FontStoreParams = {}) { constructor(params: FontStoreParams = {}) {
this.#params = { limit: 50, ...params }; this.#params = { limit: 50, ...params };
this.#observer = new InfiniteQueryObserver(this.#qc, this.buildOptions()); 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.#unsubscribe = this.#observer.subscribe(r => {
this.#result = r; this.#result = r;
}); });
@@ -88,10 +98,13 @@ export class FontCatalogStore {
return this.#result.error ?? null; 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 { get isEmpty(): boolean {
return !this.isLoading && !this.isFetching && this.fonts.length === 0; return this.#enabled && !this.isLoading && !this.isFetching && this.fonts.length === 0;
} }
/** /**
@@ -129,10 +142,12 @@ export class FontCatalogStore {
} }
/** /**
* 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>) { setParams(updates: Partial<FontStoreParams>) {
this.#params = { ...this.#params, ...updates }; this.#params = { ...this.#params, ...updates };
this.#enabled = true;
this.#observer.setOptions(this.buildOptions()); this.#observer.setOptions(this.buildOptions());
} }
/** /**
@@ -431,6 +446,7 @@ export class FontCatalogStore {
const next = lastPage.offset + lastPage.limit; const next = lastPage.offset + lastPage.limit;
return next < lastPage.total ? { offset: next } : undefined; return next < lastPage.total ? { offset: next } : undefined;
}, },
enabled: this.#enabled,
staleTime: hasFilters ? 0 : DEFAULT_QUERY_STALE_TIME_MS, staleTime: hasFilters ? 0 : DEFAULT_QUERY_STALE_TIME_MS,
gcTime: DEFAULT_QUERY_GC_TIME_MS, gcTime: DEFAULT_QUERY_GC_TIME_MS,
}; };
@@ -441,6 +457,11 @@ export class FontCatalogStore {
try { try {
response = await fetchProxyFonts(params); response = await fetchProxyFonts(params);
} catch (cause) { } 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); throw new FontNetworkError(cause);
} }
@@ -463,8 +484,12 @@ export class FontCatalogStore {
} }
} }
export function createFontCatalogStore(params: FontStoreParams = {}): FontCatalogStore { const catalog = createSingleton(
return new FontCatalogStore(params); () => new FontCatalogStore({ limit: 50 }),
} instance => instance.destroy(),
);
export const fontCatalogStore = new FontCatalogStore({ limit: 50 }); export const getFontCatalog = catalog.get;
// test-only reset, so specs don't share a live observer
export const __resetFontCatalog = catalog.reset;
@@ -1,3 +1,4 @@
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import { import {
type FontLoadRequestConfig, type FontLoadRequestConfig,
@@ -420,6 +421,15 @@ export class FontLifecycleManager {
} }
/** /**
* Singleton instance use throughout the application for unified font loading state. * App-wide font lifecycle manager, created on first access. Lazy so its
* AbortController / FontFace bookkeeping isn't set up at module load.
*/ */
export const fontLifecycleManager = new FontLifecycleManager(); const fontLifecycleManager = createSingleton(
() => new FontLifecycleManager(),
instance => instance.destroy(),
);
export const getFontLifecycleManager = fontLifecycleManager.get;
// test-only reset, so specs don't share loaded-font/eviction state
export const __resetFontLifecycleManager = fontLifecycleManager.reset;
@@ -71,7 +71,7 @@ describe('loadFont', () => {
it('throws FontParseError when font.load() rejects', async () => { it('throws FontParseError when font.load() rejects', async () => {
const loadError = new Error('parse failed'); const loadError = new Error('parse failed');
const MockFontFace = vi.fn( const MockFontFace = vi.fn(
function(this: any, name: string, buffer: BufferSource, options: FontFaceDescriptors) { function(this: any, _name: string, _buffer: BufferSource, _options: FontFaceDescriptors) {
this.load = vi.fn().mockRejectedValue(loadError); this.load = vi.fn().mockRejectedValue(loadError);
}, },
); );
@@ -1,14 +1,14 @@
import { fontKeys } from '$shared/api/queryKeys';
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore/BaseQueryStore.svelte';
import { import {
fetchFontsByIds, fetchFontsByIds,
seedFontCache, seedFontCache,
} from '$entities/Font/api/proxy/proxyFonts'; } from '../../../api/proxy/proxyFonts';
import { import {
FontNetworkError, FontNetworkError,
FontResponseError, FontResponseError,
} from '$entities/Font/lib/errors/errors'; } from '../../../lib/errors/errors';
import type { UnifiedFont } from '$entities/Font/model/types'; import type { UnifiedFont } from '../../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. * Internal fetcher that seeds the cache and handles error wrapping.
@@ -1,9 +1,6 @@
import * as api from '$entities/Font/api/proxy/proxyFonts'; import { getQueryClient } from '$shared/api/queryClient';
import {
FontNetworkError, const queryClient = getQueryClient();
FontResponseError,
} from '$entities/Font/lib/errors/errors';
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys'; import { fontKeys } from '$shared/api/queryKeys';
import { import {
beforeEach, beforeEach,
@@ -12,6 +9,11 @@ import {
it, it,
vi, vi,
} from 'vitest'; } from 'vitest';
import * as api from '../../../api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '../../../lib/errors/errors';
import { FontsByIdsStore } from './fontsByIdsStore.svelte'; import { FontsByIdsStore } from './fontsByIdsStore.svelte';
describe('FontsByIdsStore', () => { describe('FontsByIdsStore', () => {
+10 -6
View File
@@ -1,9 +1,13 @@
// Font lifecycle manager (browser-side load + cache + eviction) // Font lifecycle manager (browser-side load + cache + eviction)
export * from './fontLifecycleManager/fontLifecycleManager.svelte'; export {
__resetFontLifecycleManager,
FontLifecycleManager,
getFontLifecycleManager,
} from './fontLifecycleManager/fontLifecycleManager.svelte';
// Paginated catalog // Paginated catalog
export { export { getFontCatalog } from './fontCatalogStore/fontCatalogStore.svelte';
createFontCatalogStore, export type { FontCatalogStore } from './fontCatalogStore/fontCatalogStore.svelte';
FontCatalogStore,
fontCatalogStore, // Batch fetch by IDs (detail-cache seeding)
} from './fontCatalogStore/fontCatalogStore.svelte'; export { FontsByIdsStore } from './fontsByIdsStore/fontsByIdsStore.svelte';
+4 -2
View File
@@ -23,5 +23,7 @@ export type {
FontCollectionState, FontCollectionState,
} from './store'; } from './store';
export * from './store/fontLifecycle'; export type {
export * from './typography'; FontLoadRequestConfig,
FontLoadStatus,
} from './store/fontLifecycle';
@@ -1 +0,0 @@
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
@@ -1,9 +1,5 @@
/** /**
* ============================================================================ * Mock font data: factory functions and preset fixtures.
* MOCK FONT DATA
* ============================================================================
*
* Factory functions and preset mock data for fonts.
* Used in Storybook stories, tests, and development. * Used in Storybook stories, tests, and development.
* *
* ## Usage * ## Usage
@@ -16,7 +12,7 @@
* GOOGLE_FONTS, * GOOGLE_FONTS,
* FONTHARE_FONTS, * FONTHARE_FONTS,
* UNIFIED_FONTS, * UNIFIED_FONTS,
* } from '$entities/Font/lib/mocks'; * } from '$entities/Font/testing';
* *
* // Create a mock Google Font * // Create a mock Google Font
* const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' }); * const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' });
@@ -28,7 +24,7 @@
* const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' }); * const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' });
* *
* // Use preset fonts * // Use preset fonts
* import { UNIFIED_FONTS } from '$entities/Font/lib/mocks'; * import { UNIFIED_FONTS } from '$entities/Font/testing';
* ``` * ```
*/ */
@@ -1,8 +1,5 @@
/** /**
* ============================================================================ * Mock data helpers (main export).
* MOCK DATA HELPERS - MAIN EXPORT
* ============================================================================
*
* Comprehensive mock data for Storybook stories, tests, and development. * Comprehensive mock data for Storybook stories, tests, and development.
* *
* ## Quick Start * ## Quick Start
@@ -13,7 +10,7 @@
* UNIFIED_FONTS, * UNIFIED_FONTS,
* MOCK_FILTERS, * MOCK_FILTERS,
* createMockFontStoreState, * createMockFontStoreState,
* } from '$entities/Font/lib/mocks'; * } from '$entities/Font/testing';
* *
* // Use in stories * // Use in stories
* const font = mockUnifiedFont({ name: 'My Font', category: 'serif' }); * const font = mockUnifiedFont({ name: 'My Font', category: 'serif' });
@@ -8,7 +8,7 @@
* import { * import {
* createMockQueryState, * createMockQueryState,
* MOCK_STORES, * MOCK_STORES,
* } from '$entities/Font/lib/mocks'; * } from '$entities/Font/testing';
* *
* // Create a mock query state * // Create a mock query state
* const loadingState = createMockQueryState({ status: 'pending' }); * const loadingState = createMockQueryState({ status: 'pending' });
@@ -21,11 +21,7 @@
*/ */
import type { UnifiedFont } from '$entities/Font/model/types'; import type { UnifiedFont } from '$entities/Font/model/types';
import type { import type { QueryStatus } from '@tanstack/svelte-query';
QueryKey,
QueryObserverResult,
QueryStatus,
} from '@tanstack/svelte-query';
import { import {
UNIFIED_FONTS, UNIFIED_FONTS,
generateMockFonts, generateMockFonts,
@@ -10,20 +10,20 @@ const { Story } = defineMeta({
docs: { docs: {
description: { description: {
component: component:
'Loads a font and applies it to children. Shows blur/scale loading state until font is ready, then reveals with a smooth transition.', 'Applies a font to its children based on the supplied load `status`. Renders the skeleton (or system font) until status is `loaded`/`error`, then reveals the font. The status is provided by the composing widget — the component does not read the lifecycle store itself.',
}, },
story: { inline: false }, story: { inline: false },
}, },
layout: 'centered', layout: 'centered',
}, },
argTypes: { argTypes: {
weight: { control: 'number' }, status: { control: 'select', options: ['loading', 'loaded', 'error'] },
}, },
}); });
</script> </script>
<script lang="ts"> <script lang="ts">
import { mockUnifiedFont } from '$entities/Font/lib/mocks'; import { mockUnifiedFont } from '$entities/Font/testing';
import type { ComponentProps } from 'svelte'; import type { ComponentProps } from 'svelte';
const fontUnknown = mockUnifiedFont({ id: 'nonexistent-font-xk92z', name: 'Nonexistent Font Xk92z' }); const fontUnknown = mockUnifiedFont({ id: 'nonexistent-font-xk92z', name: 'Nonexistent Font Xk92z' });
@@ -39,11 +39,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: { docs: {
description: { description: {
story: story:
'Font that has never been loaded by fontLifecycleManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.', 'Status is `loading`: the font file has not resolved yet, so children render in the skeleton (or system font) fallback rather than the target font.',
}, },
}, },
}} }}
args={{ font: fontUnknown, weight: 400 }} args={{ font: fontUnknown, status: 'loading' }}
> >
{#snippet template(args: ComponentProps<typeof FontApplicator>)} {#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}> <FontApplicator {...args}>
@@ -58,11 +58,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: { docs: {
description: { description: {
story: story:
'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.', 'Status is `loaded`: the component reveals the font, applying it to its children (Arial here, available in all browsers).',
}, },
}, },
}} }}
args={{ font: fontArial, weight: 400 }} args={{ font: fontArial, status: 'loaded' }}
> >
{#snippet template(args: ComponentProps<typeof FontApplicator>)} {#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}> <FontApplicator {...args}>
@@ -72,16 +72,16 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
</Story> </Story>
<Story <Story
name="Custom Weight" name="Error State"
parameters={{ parameters={{
docs: { docs: {
description: { description: {
story: story:
'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.', 'Status is `error`: the font failed to load. The component still reveals (it treats `error` like `loaded` for reveal purposes) so children are not stuck behind the skeleton — they fall back to the system font.',
}, },
}, },
}} }}
args={{ font: fontArialBold, weight: 700 }} args={{ font: fontArialBold, status: 'error' }}
> >
{#snippet template(args: ComponentProps<typeof FontApplicator>)} {#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}> <FontApplicator {...args}>
@@ -6,11 +6,10 @@
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { import type {
DEFAULT_FONT_WEIGHT, FontLoadStatus,
type UnifiedFont, UnifiedFont,
fontLifecycleManager, } from '../../model/types';
} from '../../model';
interface Props { interface Props {
/** /**
@@ -18,10 +17,13 @@ interface Props {
*/ */
font: UnifiedFont; font: UnifiedFont;
/** /**
* Font weight * Current load status for this font, supplied by the composing layer.
* @default 400 * Kept out of the component so it does not depend on (and import) the
* lifecycle store — the owning widget reads the manager and passes the
* resolved status down. `undefined` means the font is not tracked yet and
* is treated as not-yet-revealed (skeleton / system-font fallback).
*/ */
weight?: number; status: FontLoadStatus | undefined;
/** /**
* CSS classes * CSS classes
*/ */
@@ -39,20 +41,12 @@ interface Props {
let { let {
font, font,
weight = DEFAULT_FONT_WEIGHT, status,
className, className,
children, children,
skeleton, skeleton,
}: Props = $props(); }: Props = $props();
const status = $derived(
fontLifecycleManager.getFontStatus(
font.id,
weight,
font.features?.isVariable,
),
);
const shouldReveal = $derived(status === 'loaded' || status === 'error'); const shouldReveal = $derived(status === 'loaded' || status === 'error');
</script> </script>
@@ -4,7 +4,7 @@ import { defineMeta } from '@storybook/addon-svelte-csf';
import FontSampler from './FontSampler.svelte'; import FontSampler from './FontSampler.svelte';
const { Story } = defineMeta({ const { Story } = defineMeta({
title: 'Features/FontSampler', title: 'Entities/Font/FontSampler',
component: FontSampler, component: FontSampler,
tags: ['autodocs'], tags: ['autodocs'],
parameters: { parameters: {
@@ -21,6 +21,11 @@ const { Story } = defineMeta({
control: 'object', control: 'object',
description: 'Font information object', description: 'Font information object',
}, },
status: {
control: 'select',
options: ['loading', 'loaded', 'error'],
description: 'Font-load status, supplied by the composing widget and forwarded to FontApplicator',
},
text: { text: {
control: 'text', control: 'text',
description: 'Editable sample text (two-way bindable)', description: 'Editable sample text (two-way bindable)',
@@ -34,8 +39,8 @@ const { Story } = defineMeta({
</script> </script>
<script lang="ts"> <script lang="ts">
import type { UnifiedFont } from '$entities/Font';
import type { ComponentProps } from 'svelte'; import type { ComponentProps } from 'svelte';
import type { UnifiedFont } from '../../model/types';
// Mock fonts for testing // Mock fonts for testing
const mockArial: UnifiedFont = { const mockArial: UnifiedFont = {
@@ -79,14 +84,24 @@ const mockGeorgia: UnifiedFont = {
isVariable: false, isVariable: false,
}, },
}; };
// Stand-in for the AdjustTypography store the composing widget injects.
const mockTypography = {
renderedSize: 48,
weight: 400,
height: 1.5,
spacing: 0,
};
</script> </script>
<Story <Story
name="Default" name="Default"
args={{ args={{
font: mockArial, font: mockArial,
status: 'loaded',
text: 'The quick brown fox jumps over the lazy dog', text: 'The quick brown fox jumps over the lazy dog',
index: 0, index: 0,
typography: mockTypography,
}} }}
> >
{#snippet template(args: ComponentProps<typeof FontSampler>)} {#snippet template(args: ComponentProps<typeof FontSampler>)}
@@ -101,9 +116,11 @@ const mockGeorgia: UnifiedFont = {
name="Long Text" name="Long Text"
args={{ args={{
font: mockGeorgia, font: mockGeorgia,
status: 'loaded',
text: text:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
index: 1, index: 1,
typography: mockTypography,
}} }}
> >
{#snippet template(args: ComponentProps<typeof FontSampler>)} {#snippet template(args: ComponentProps<typeof FontSampler>)}
@@ -4,11 +4,6 @@
Visual design matches FontCard: sharp corners, red hover accent, header stats. Visual design matches FontCard: sharp corners, red hover accent, header stats.
--> -->
<script lang="ts"> <script lang="ts">
import {
FontApplicator,
type UnifiedFont,
} from '$entities/Font';
import { typographySettingsStore } from '$features/AdjustTypography/model';
import { import {
Badge, Badge,
ContentEditable, ContentEditable,
@@ -17,12 +12,47 @@ import {
Stat, Stat,
} from '$shared/ui'; } from '$shared/ui';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import type {
FontLoadStatus,
UnifiedFont,
} from '../../model/types';
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
/**
* Minimal typography contract this view renders with. The AdjustTypography
* store satisfies it structurally; defining it here keeps the entity decoupled
* from that feature (no entity -> feature import).
*/
interface FontSampleTypography {
/**
* Rendered font size in px
*/
renderedSize: number;
/**
* Numeric font weight
*/
weight: number;
/**
* Line-height multiplier
*/
height: number;
/**
* Letter spacing
*/
spacing: number;
}
interface Props { interface Props {
/** /**
* Font info * Font info
*/ */
font: UnifiedFont; font: UnifiedFont;
/**
* Current font-load status, supplied by the composing widget so this
* component (and FontApplicator) stay decoupled from the lifecycle store.
* `undefined` means not tracked yet (treated as not-yet-revealed).
*/
status: FontLoadStatus | undefined;
/** /**
* Sample text * Sample text
*/ */
@@ -32,12 +62,15 @@ interface Props {
* @default 0 * @default 0
*/ */
index?: number; index?: number;
/**
* Typography settings to render the sample with. Injected by the composing
* widget (which owns the AdjustTypography store) so this entity view stays
* decoupled from that feature — the same inversion as `status`.
*/
typography: FontSampleTypography;
} }
let { font, text = $bindable(), index = 0 }: Props = $props(); let { font, status, text = $bindable(), index = 0, typography }: Props = $props();
// Adjust the property name to match your UnifiedFont type
const fontType = $derived((font as any).type ?? (font as any).category ?? '');
// Extract provider badge with fallback // Extract provider badge with fallback
const providerBadge = $derived( const providerBadge = $derived(
@@ -46,10 +79,10 @@ const providerBadge = $derived(
); );
const stats = $derived([ const stats = $derived([
{ label: 'SZ', value: `${typographySettingsStore.renderedSize}PX` }, { label: 'SZ', value: `${typography.renderedSize}PX` },
{ label: 'WGT', value: `${typographySettingsStore.weight}` }, { label: 'WGT', value: `${typography.weight}` },
{ label: 'LH', value: typographySettingsStore.height?.toFixed(2) }, { label: 'LH', value: typography.height.toFixed(2) },
{ label: 'LTR', value: `${typographySettingsStore.spacing}` }, { label: 'LTR', value: `${typography.spacing}` },
]); ]);
</script> </script>
@@ -67,9 +100,8 @@ const stats = $derived([
min-h-60 min-h-60
rounded-none rounded-none
" "
style:font-weight={typographySettingsStore.weight} style:font-weight={typography.weight}
> >
<!-- ── Header bar ─────────────────────────────────────────────────── -->
<div <div
class=" class="
flex items-center justify-between flex items-center justify-between
@@ -91,9 +123,9 @@ const stats = $derived([
{font.name} {font.name}
</span> </span>
{#if fontType} {#if font?.category}
<Badge size="xs" variant="default" nowrap> <Badge size="xs" variant="default" nowrap>
{fontType} {font?.category}
</Badge> </Badge>
{/if} {/if}
@@ -130,19 +162,18 @@ const stats = $derived([
</div> </div>
</div> </div>
<!-- ── Main content area ──────────────────────────────────────────── -->
<div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10"> <div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10">
<FontApplicator {font} weight={typographySettingsStore.weight}> <FontApplicator {font} {status}>
<ContentEditable <ContentEditable
bind:text bind:text
fontSize={typographySettingsStore.renderedSize} fontSize={typography.renderedSize}
lineHeight={typographySettingsStore.height} lineHeight={typography.height}
letterSpacing={typographySettingsStore.spacing} letterSpacing={typography.spacing}
/> />
</FontApplicator> </FontApplicator>
</div> </div>
<!-- ── Mobile stats footer (md:hidden header stats take over above) --> <!-- Mobile stats footer; md:hidden because the header stats take over above -->
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-subtle flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto"> <div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-subtle flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
{#each stats as stat, i} {#each stats as stat, i}
<Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}"> <Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
@@ -154,7 +185,6 @@ const stats = $derived([
{/each} {/each}
</div> </div>
<!-- ── Red hover line ─────────────────────────────────────────────── -->
<div <div
class=" class="
absolute bottom-0 left-0 right-0 absolute bottom-0 left-0 right-0
@@ -5,21 +5,18 @@
--> -->
<script lang="ts"> <script lang="ts">
import { debounce } from '$shared/lib/utils'; import { debounce } from '$shared/lib/utils';
import { import { VirtualList } from '$shared/ui';
Skeleton,
VirtualList,
} from '$shared/ui';
import type { import type {
ComponentProps, ComponentProps,
Snippet, Snippet,
} from 'svelte'; } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { getFontUrl } from '../../lib'; import { createFontLoadRequestContfig } from '../../lib/createFontLoadRequestContfig/createFontLoadRequestContfig';
import { import {
type FontLoadRequestConfig, type FontLoadRequestConfig,
type UnifiedFont, type UnifiedFont,
fontCatalogStore, getFontCatalog,
fontLifecycleManager, getFontLifecycleManager,
} from '../../model'; } from '../../model';
interface Props extends interface Props extends
@@ -40,6 +37,10 @@ interface Props extends
* Skeleton snippet * Skeleton snippet
*/ */
skeleton?: Snippet; skeleton?: Snippet;
/**
* Empty-state snippet rendered when the query settled with zero fonts
*/
empty?: Snippet;
} }
let { let {
@@ -47,18 +48,32 @@ let {
onVisibleItemsChange, onVisibleItemsChange,
weight, weight,
skeleton, skeleton,
empty,
...rest ...rest
}: Props = $props(); }: Props = $props();
const isLoading = $derived( const fontCatalog = getFontCatalog();
fontCatalogStore.isFetching || fontCatalogStore.isLoading, const fontLifecycleManager = getFontLifecycleManager();
);
const isLoading = $derived<boolean>(fontCatalog?.isLoading);
const isFetching = $derived<boolean>(fontCatalog.isFetching);
const hasMore = $derived<boolean>(fontCatalog?.pagination?.hasMore);
const fonts = $derived<UnifiedFont[]>(fontCatalog.fonts);
const total = $derived<number>(fontCatalog?.pagination.total);
let visibleFonts = $state<UnifiedFont[]>([]); let visibleFonts = $state<UnifiedFont[]>([]);
let isCatchingUp = $state(false); let isCatchingUp = $state<boolean>(false);
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontCatalogStore.fonts.length === 0); const showInitialSkeleton = $derived.by(() => (
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp); !!skeleton && (isLoading || isFetching) && fontCatalog.fonts.length === 0
));
const showCatchupSkeleton = $derived.by(() => (
!!skeleton && isCatchingUp
));
// Settled query with no matches — empty state replaces the (otherwise blank) list.
const showEmpty = $derived.by(() => (
!!empty && !(isLoading || isFetching) && !isCatchingUp && fontCatalog.fonts.length === 0
));
function handleInternalVisibleChange(items: UnifiedFont[]) { function handleInternalVisibleChange(items: UnifiedFont[]) {
visibleFonts = items; visibleFonts = items;
@@ -72,12 +87,12 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
* font files for thousands of intermediate fonts. * font files for thousands of intermediate fonts.
*/ */
async function handleJump(targetIndex: number) { async function handleJump(targetIndex: number) {
if (isCatchingUp || !fontCatalogStore.pagination.hasMore) { if (isCatchingUp || !hasMore) {
return; return;
} }
isCatchingUp = true; isCatchingUp = true;
try { try {
await fontCatalogStore.fetchAllPagesTo(targetIndex); await fontCatalog.fetchAllPagesTo(targetIndex);
} finally { } finally {
isCatchingUp = false; isCatchingUp = false;
} }
@@ -98,13 +113,7 @@ $effect(() => {
if (isCatchingUp) { if (isCatchingUp) {
return; return;
} }
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => { const configs = visibleFonts.flatMap(item => createFontLoadRequestContfig(item, weight));
const url = getFontUrl(item, weight);
if (!url) {
return [];
}
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
});
if (configs.length > 0) { if (configs.length > 0) {
debouncedTouch(configs); debouncedTouch(configs);
} }
@@ -130,13 +139,11 @@ $effect(() => {
* Load more fonts by moving to the next page * Load more fonts by moving to the next page
*/ */
function loadMore() { function loadMore() {
if ( if (!hasMore || isFetching) {
!fontCatalogStore.pagination.hasMore
|| fontCatalogStore.isFetching
) {
return; return;
} }
fontCatalogStore.nextPage();
fontCatalog.nextPage();
} }
/** /**
@@ -146,12 +153,10 @@ function loadMore() {
* of the loaded items. Only fetches if there are more pages available. * of the loaded items. Only fetches if there are more pages available.
*/ */
function handleNearBottom(_lastVisibleIndex: number) { function handleNearBottom(_lastVisibleIndex: number) {
const { hasMore } = fontCatalogStore.pagination;
// VirtualList already checks if we're near the bottom of loaded items. // VirtualList already checks if we're near the bottom of loaded items.
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false // Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
// during batch catch-up, which would otherwise let nextPage() race with it. // during batch catch-up, which would otherwise let nextPage() race with it.
if (hasMore && !fontCatalogStore.isFetching && !isCatchingUp) { if (hasMore && !isFetching && !isCatchingUp) {
loadMore(); loadMore();
} }
} }
@@ -163,12 +168,16 @@ function handleNearBottom(_lastVisibleIndex: number) {
<div class="overflow-hidden h-full" transition:fade={{ duration: 300 }}> <div class="overflow-hidden h-full" transition:fade={{ duration: 300 }}>
{@render skeleton()} {@render skeleton()}
</div> </div>
{:else if showEmpty && empty}
<div class="h-full" transition:fade={{ duration: 200 }}>
{@render empty()}
</div>
{:else} {:else}
<!-- VirtualList persists during pagination - no destruction/recreation --> <!-- VirtualList persists during pagination - no destruction/recreation -->
<VirtualList <VirtualList
items={fontCatalogStore.fonts} items={fonts}
total={fontCatalogStore.pagination.total} {total}
isLoading={isLoading || isCatchingUp} isLoading={isLoading || isFetching || isCatchingUp}
onVisibleItemsChange={handleInternalVisibleChange} onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom} onNearBottom={handleNearBottom}
onJump={handleJump} onJump={handleJump}
+2
View File
@@ -1,7 +1,9 @@
import FontApplicator from './FontApplicator/FontApplicator.svelte'; import FontApplicator from './FontApplicator/FontApplicator.svelte';
import FontSampler from './FontSampler/FontSampler.svelte';
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte'; import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
export { export {
FontApplicator, FontApplicator,
FontSampler,
FontVirtualList, FontVirtualList,
}; };
+4 -1
View File
@@ -1,6 +1,9 @@
export { export {
createTypographySettingsStore, createTypographySettingsStore,
getTypographySettingsStore,
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
type TypographySettingsStore, type TypographySettingsStore,
typographySettingsStore,
} from './model'; } from './model';
export { TypographyMenu } from './ui'; export { TypographyMenu } from './ui';
@@ -0,0 +1,76 @@
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LETTER_SPACING_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LETTER_SPACING,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LETTER_SPACING,
MIN_LINE_HEIGHT,
} from '$entities/Font';
import type {
ControlId,
ControlModel,
} from '../types/typography';
/**
* Responsive font-size scaling factors applied by typographySettingsStore.
*/
export const MULTIPLIER_S = 0.5;
export const MULTIPLIER_M = 0.75;
export const MULTIPLIER_L = 1;
/**
* Default control definitions seeding the typography settings store.
* Composed from the font-render ranges/defaults owned by the Font entity.
*/
export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
{
id: 'font_size',
value: DEFAULT_FONT_SIZE,
max: MAX_FONT_SIZE,
min: MIN_FONT_SIZE,
step: FONT_SIZE_STEP,
increaseLabel: 'Increase Font Size',
decreaseLabel: 'Decrease Font Size',
controlLabel: 'Size',
},
{
id: 'font_weight',
value: DEFAULT_FONT_WEIGHT,
max: MAX_FONT_WEIGHT,
min: MIN_FONT_WEIGHT,
step: FONT_WEIGHT_STEP,
increaseLabel: 'Increase Font Weight',
decreaseLabel: 'Decrease Font Weight',
controlLabel: 'Weight',
},
{
id: 'line_height',
value: DEFAULT_LINE_HEIGHT,
max: MAX_LINE_HEIGHT,
min: MIN_LINE_HEIGHT,
step: LINE_HEIGHT_STEP,
increaseLabel: 'Increase Line Height',
decreaseLabel: 'Decrease Line Height',
controlLabel: 'Leading',
},
{
id: 'letter_spacing',
value: DEFAULT_LETTER_SPACING,
max: MAX_LETTER_SPACING,
min: MIN_LETTER_SPACING,
step: LETTER_SPACING_STEP,
increaseLabel: 'Increase Letter Spacing',
decreaseLabel: 'Decrease Letter Spacing',
controlLabel: 'Tracking',
},
];
+6 -1
View File
@@ -1,5 +1,10 @@
export {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from './const/const';
export { export {
createTypographySettingsStore, createTypographySettingsStore,
getTypographySettingsStore,
type TypographySettingsStore, type TypographySettingsStore,
typographySettingsStore,
} from './store/typographySettingsStore/typographySettingsStore.svelte'; } from './store/typographySettingsStore/typographySettingsStore.svelte';
@@ -11,22 +11,27 @@
*/ */
import { import {
type ControlId,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING, DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT, DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, // Deep path (not the root barrel) on purpose: pulls only these pure
} from '$entities/Font'; // constants, not the entity's UI/store graph (+ @tanstack) — keeps this
// feature store and its spec light at import. See audit D-1.
} from '$entities/Font/model/const/const';
import { import {
type ControlDataModel,
type ControlModel,
type PersistentStore, type PersistentStore,
type TypographyControl,
createPersistentStore, createPersistentStore,
createTypographyControl, createSingleton,
} from '$shared/lib'; } from '$shared/lib';
import type { NumericControl } from '$shared/ui';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../../const/const';
import type {
ControlId,
ControlModel,
} from '../../types/typography';
import { createTypographyControl } from '../../typographyControl/createTypographyControl.svelte';
/** /**
* Epsilon for detecting "significant" base-size changes when reconciling * Epsilon for detecting "significant" base-size changes when reconciling
@@ -36,7 +41,7 @@ import { SvelteMap } from 'svelte/reactivity';
*/ */
const BASE_SIZE_EPSILON = 0.01; const BASE_SIZE_EPSILON = 0.01;
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>; type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, 'value' | 'min' | 'max' | 'step'>;
/** /**
* A control with its associated instance * A control with its associated instance
@@ -45,7 +50,7 @@ export interface Control extends ControlOnlyFields<ControlId> {
/** /**
* The reactive typography control instance * The reactive typography control instance
*/ */
instance: TypographyControl; instance: NumericControl;
} }
/** /**
@@ -93,6 +98,12 @@ export class TypographySettingsStore {
* The underlying font size before responsive scaling is applied * The underlying font size before responsive scaling is applied
*/ */
#baseSize = $state(DEFAULT_FONT_SIZE); #baseSize = $state(DEFAULT_FONT_SIZE);
/**
* Disposes the $effect.root that backs the storage-sync effects.
* $effect.root lives outside component lifecycle, so callers must invoke
* destroy() to avoid leaking the subscriptions.
*/
#disposeEffects: () => void;
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) { constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
this.#storage = storage; this.#storage = storage;
@@ -116,7 +127,7 @@ export class TypographySettingsStore {
// The Sync Effect (UI -> Storage) // The Sync Effect (UI -> Storage)
// We access .value explicitly to ensure Svelte 5 tracks the dependency // We access .value explicitly to ensure Svelte 5 tracks the dependency
$effect.root(() => { this.#disposeEffects = $effect.root(() => {
$effect(() => { $effect(() => {
// EXPLICIT DEPENDENCIES: Accessing these triggers the effect // EXPLICIT DEPENDENCIES: Accessing these triggers the effect
const fontSize = this.#baseSize; const fontSize = this.#baseSize;
@@ -154,6 +165,14 @@ export class TypographySettingsStore {
}); });
} }
/**
* Tears down the storage-sync effects. Call on unmount / store disposal.
*/
destroy(): void {
this.#disposeEffects();
this.#storage.destroy();
}
/** /**
* Gets initial value for a control from storage or defaults * Gets initial value for a control from storage or defaults
*/ */
@@ -288,9 +307,6 @@ export class TypographySettingsStore {
if (c.id === 'font_size') { if (c.id === 'font_size') {
c.instance.value = defaults.fontSize * this.#multiplier; c.instance.value = defaults.fontSize * this.#multiplier;
} else { } else {
// Map storage key to control id
const key = c.id.replace('_', '') as keyof TypographySettings;
// Simplified for brevity, you'd map these properly:
if (c.id === 'font_weight') { if (c.id === 'font_weight') {
c.instance.value = defaults.fontWeight; c.instance.value = defaults.fontWeight;
} }
@@ -335,10 +351,19 @@ export function createTypographySettingsStore(
return new TypographySettingsStore(configs, storage); return new TypographySettingsStore(configs, storage);
} }
export type TypographySettingsStoreInstance = ReturnType<typeof createTypographySettingsStore>;
/** /**
* App-wide typography settings singleton, keyed for the comparison view. * App-wide typography settings store, keyed for the comparison view.
* Created on first access so its persistent-store sync effects aren't set up
* at module load.
*/ */
export const typographySettingsStore = createTypographySettingsStore( const typographySettingsStore = createSingleton(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, () => createTypographySettingsStore(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, COMPARISON_STORAGE_KEY),
COMPARISON_STORAGE_KEY, instance => instance.destroy(),
); );
export const getTypographySettingsStore = typographySettingsStore.get;
// test-only reset, so specs don't share persisted typography state or leak effects
export const __resetTypographySettingsStore = typographySettingsStore.reset;
@@ -6,8 +6,7 @@ import {
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING, DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT, DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, } from '$entities/Font/model/const/const';
} from '$entities/Font';
import { import {
beforeEach, beforeEach,
describe, describe,
@@ -15,6 +14,7 @@ import {
it, it,
vi, vi,
} from 'vitest'; } from 'vitest';
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../../const/const';
import { import {
type TypographySettings, type TypographySettings,
TypographySettingsStore, TypographySettingsStore,
@@ -51,6 +51,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
let mockPersistentStore: { let mockPersistentStore: {
value: TypographySettings; value: TypographySettings;
clear: () => void; clear: () => void;
destroy: () => void;
}; };
const createMockPersistentStore = (initialValue: TypographySettings) => { const createMockPersistentStore = (initialValue: TypographySettings) => {
@@ -70,6 +71,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
letterSpacing: DEFAULT_LETTER_SPACING, letterSpacing: DEFAULT_LETTER_SPACING,
}; };
}, },
destroy() {},
}; };
}; };
@@ -535,6 +537,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
mockStorage = v; mockStorage = v;
}, },
clear: clearSpy, clear: clearSpy,
destroy() {},
}; };
const manager = new TypographySettingsStore( const manager = new TypographySettingsStore(
@@ -0,0 +1,27 @@
import type {
ControlLabels,
NumericControl,
} from '$shared/ui';
/**
* Identifiers for the adjustable typography axes
*/
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
/**
* Static configuration for one typography control.
*
* Derived from the SSOT contract types declares no fields of its own beyond
* the domain `id`. Bounds come from NumericControl, labels from ControlLabels.
*
* @template T - Control identifier type
*/
export type ControlModel<T extends string = string> =
& Pick<NumericControl, 'value' | 'min' | 'max' | 'step'>
& ControlLabels
& {
/**
* Unique identifier for the control
*/
id: T;
};
@@ -0,0 +1,67 @@
/**
* Bounded numeric control for typography settings.
*
* Produces a reactive control that clamps to [min, max] and rounds to step.
* Implements the NumericControl contract that ComboControl renders.
*/
import {
clampNumber,
roundToStepPrecision,
} from '$shared/lib/utils';
import type { NumericControl } from '$shared/ui';
/**
* Bounds + initial value seed for a control
*/
type ControlSeed = Pick<NumericControl, 'value' | 'min' | 'max' | 'step'>;
/**
* Create a reactive bounded numeric control.
*
* @param initialState - Initial value and bounds
* @returns A NumericControl whose value is always clamped and step-rounded
*/
export function createTypographyControl(initialState: ControlSeed): NumericControl {
let value = $state(initialState.value);
let max = $state(initialState.max);
let min = $state(initialState.min);
let step = $state(initialState.step);
const { isAtMax, isAtMin } = $derived({
isAtMax: value >= max,
isAtMin: value <= min,
});
return {
get value() {
return value;
},
set value(newValue) {
const rounded = roundToStepPrecision(clampNumber(newValue, min, max), step);
if (value !== rounded) {
value = rounded;
}
},
get max() {
return max;
},
get min() {
return min;
},
get step() {
return step;
},
get isAtMax() {
return isAtMax;
},
get isAtMin() {
return isAtMin;
},
increase() {
value = roundToStepPrecision(clampNumber(value + step, min, max), step);
},
decrease() {
value = roundToStepPrecision(clampNumber(value - step, min, max), step);
},
};
}
@@ -1,12 +1,10 @@
import { import type { NumericControl } from '$shared/ui';
type TypographyControl,
createTypographyControl,
} from '$shared/lib';
import { import {
describe, describe,
expect, expect,
it, it,
} from 'vitest'; } from 'vitest';
import { createTypographyControl } from './createTypographyControl.svelte';
/** /**
* Test Strategy for createTypographyControl Helper * Test Strategy for createTypographyControl Helper
@@ -34,7 +32,7 @@ describe('createTypographyControl - Unit Tests', () => {
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
}): TypographyControl { }): NumericControl {
return createTypographyControl({ return createTypographyControl({
value: initialValue, value: initialValue,
min: options?.min ?? 0, min: options?.min ?? 0,
@@ -5,26 +5,26 @@
Desktop: inline bar with combo controls. Desktop: inline bar with combo controls.
--> -->
<script lang="ts"> <script lang="ts">
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
import { import {
Button, Button,
ComboControl, ComboControl,
ControlGroup, ControlGroup,
Popover,
Slider, Slider,
} from '$shared/ui'; } from '$shared/ui';
import Settings2Icon from '@lucide/svelte/icons/settings-2'; import Settings2Icon from '@lucide/svelte/icons/settings-2';
import XIcon from '@lucide/svelte/icons/x'; import XIcon from '@lucide/svelte/icons/x';
import { Popover } from 'bits-ui';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { typographySettingsStore } from '../../model'; import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
getTypographySettingsStore,
} from '../../model';
interface Props { interface Props {
/** /**
@@ -46,6 +46,7 @@ interface Props {
let { class: className, hidden = false, open = $bindable(false) }: Props = $props(); let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive'); const responsive = getContext<ResponsiveManager>('responsive');
const typographySettingsStore = getTypographySettingsStore();
/** /**
* Sets the common font size multiplier based on the current responsive state. * Sets the common font size multiplier based on the current responsive state.
@@ -73,33 +74,21 @@ $effect(() => {
{#if !hidden} {#if !hidden}
{#if responsive.isMobileOrTablet} {#if responsive.isMobileOrTablet}
<div class={className}> <div class={className}>
<Popover.Root bind:open> <Popover bind:open side="top" align="end" sideOffset={8}>
<Popover.Trigger> {#snippet trigger(props)}
{#snippet child({ props })} <Button variant="primary" {...props}>
<Button variant="primary" {...props}> {#snippet icon()}
{#snippet icon()} <Settings2Icon class="size-4" />
<Settings2Icon class="size-4" /> {/snippet}
{/snippet} </Button>
</Button> {/snippet}
{/snippet}
</Popover.Trigger>
<Popover.Portal> {#snippet children({ close })}
<Popover.Content <div
side="top"
align="end"
sideOffset={8}
class={cn( class={cn(
'z-50 w-72 p-4 rounded-none', 'w-72 p-4 rounded-none',
'surface-popover', 'surface-popover',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=top]:slide-in-from-bottom-2',
'data-[side=bottom]:slide-in-from-top-2',
)} )}
interactOutsideBehavior="close"
escapeKeydownBehavior="close"
> >
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle"> <div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
@@ -111,17 +100,13 @@ $effect(() => {
CONTROLS CONTROLS
</span> </span>
</div> </div>
<Popover.Close> <button
{#snippet child({ props })} onclick={close}
<button class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
{...props} aria-label="Close controls"
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>
<XIcon class="size-3.5 text-neutral-500" />
</button>
{/snippet}
</Popover.Close>
</div> </div>
<!-- Controls --> <!-- Controls -->
@@ -135,9 +120,9 @@ $effect(() => {
/> />
</ControlGroup> </ControlGroup>
{/each} {/each}
</Popover.Content> </div>
</Popover.Portal> {/snippet}
</Popover.Root> </Popover>
</div> </div>
{:else} {:else}
<div <div
@@ -8,7 +8,7 @@
* @example * @example
* ```svelte * ```svelte
* <script lang="ts"> * <script lang="ts">
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb'; * import { scrollBreadcrumbsStore } from '$features/Breadcrumb';
* import { onMount } from 'svelte'; * import { onMount } from 'svelte';
* *
* onMount(() => { * onMount(() => {
@@ -26,8 +26,8 @@
*/ */
export { export {
getScrollBreadcrumbsStore,
type NavigationAction, type NavigationAction,
scrollBreadcrumbsStore,
} from './model'; } from './model';
export { export {
BreadcrumbHeader, BreadcrumbHeader,
+7
View File
@@ -0,0 +1,7 @@
export {
__resetScrollBreadcrumbsStore,
createScrollBreadcrumbsStore,
getScrollBreadcrumbsStore,
} from './store/scrollBreadcrumbsStore.svelte';
export type { BreadcrumbItem } from './store/scrollBreadcrumbsStore.svelte';
export type { NavigationAction } from './types/types.ts';
@@ -1,3 +1,5 @@
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
/** /**
* Scroll-based breadcrumb tracking store * Scroll-based breadcrumb tracking store
* *
@@ -15,7 +17,7 @@
* @example * @example
* ```svelte * ```svelte
* <script lang="ts"> * <script lang="ts">
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb'; * import { scrollBreadcrumbsStore } from '$features/Breadcrumb';
* *
* onMount(() => { * onMount(() => {
* scrollBreadcrumbsStore.add({ * scrollBreadcrumbsStore.add({
@@ -167,6 +169,13 @@ class ScrollBreadcrumbsStore {
this.#detachScrollListener(); this.#detachScrollListener();
} }
/**
* Tears down the observer and scroll listener. Call on store disposal.
*/
destroy(): void {
this.#disconnect();
}
/** /**
* All tracked items sorted by index * All tracked items sorted by index
*/ */
@@ -273,6 +282,14 @@ export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
} }
/** /**
* Singleton scroll breadcrumbs store instance * App-wide scroll breadcrumbs store, created on first access.
*/ */
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore(); const scrollBreadcrumbsStore = createSingleton(
() => createScrollBreadcrumbsStore(),
instance => instance.destroy(),
);
export const getScrollBreadcrumbsStore = scrollBreadcrumbsStore.get;
// test-only reset, so specs don't share observer/scroll state
export const __resetScrollBreadcrumbsStore = scrollBreadcrumbsStore.reset;
@@ -70,7 +70,6 @@ class MockIntersectionObserver implements IntersectionObserver {
describe('ScrollBreadcrumbsStore', () => { describe('ScrollBreadcrumbsStore', () => {
let scrollListeners: Array<() => void> = []; let scrollListeners: Array<() => void> = [];
let addEventListenerSpy: ReturnType<typeof vi.spyOn>; let addEventListenerSpy: ReturnType<typeof vi.spyOn>;
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>;
let scrollToSpy: ReturnType<typeof vi.spyOn>; let scrollToSpy: ReturnType<typeof vi.spyOn>;
// Helper to create mock elements // Helper to create mock elements
@@ -111,7 +110,7 @@ describe('ScrollBreadcrumbsStore', () => {
// Track scroll event listeners // Track scroll event listeners
addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation( addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(
(event: string, listener: EventListenerOrEventListenerObject, options?: any) => { (event: string, listener: EventListenerOrEventListenerObject, _options?: any) => {
if (event === 'scroll') { if (event === 'scroll') {
scrollListeners.push(listener as () => void); scrollListeners.push(listener as () => void);
} }
@@ -119,7 +118,7 @@ describe('ScrollBreadcrumbsStore', () => {
}, },
); );
removeEventListenerSpy = vi.spyOn(window, 'removeEventListener').mockImplementation( vi.spyOn(window, 'removeEventListener').mockImplementation(
(event: string, listener: EventListenerOrEventListenerObject) => { (event: string, listener: EventListenerOrEventListenerObject) => {
if (event === 'scroll') { if (event === 'scroll') {
const index = scrollListeners.indexOf(listener as () => void); const index = scrollListeners.indexOf(listener as () => void);
@@ -14,9 +14,10 @@ import { cubicOut } from 'svelte/easing';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { import {
type BreadcrumbItem, type BreadcrumbItem,
scrollBreadcrumbsStore, getScrollBreadcrumbsStore,
} from '../../model'; } from '../../model';
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
const breadcrumbs = $derived(scrollBreadcrumbsStore.scrolledPastItems); const breadcrumbs = $derived(scrollBreadcrumbsStore.scrolledPastItems);
const responsive = getContext<ResponsiveManager>('responsive'); const responsive = getContext<ResponsiveManager>('responsive');
@@ -1,18 +1,24 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { scrollBreadcrumbsStore } from '../../model'; import { getScrollBreadcrumbsStore } from '../../model';
import BreadcrumbHeader from './BreadcrumbHeader.svelte'; import BreadcrumbHeader from './BreadcrumbHeader.svelte';
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
const sections = [ const sections = [
{ index: 100, title: 'Introduction' }, { index: 100, title: 'Introduction' },
{ index: 101, title: 'Typography' }, { index: 101, title: 'Typography' },
{ index: 102, title: 'Spacing' }, { index: 102, title: 'Spacing' },
]; ];
/** @type {HTMLDivElement} */ /** @type {HTMLDivElement | undefined} */
let container; let container = $state();
onMount(() => { onMount(() => {
if (!container) {
return;
}
for (const section of sections) { for (const section of sections) {
const el = /** @type {HTMLElement} */ (container.querySelector(`[data-story-index="${section.index}"]`)); const el = /** @type {HTMLElement} */ (container.querySelector(`[data-story-index="${section.index}"]`));
scrollBreadcrumbsStore.add({ index: section.index, title: section.title, element: el }, 96); scrollBreadcrumbsStore.add({ index: section.index, title: section.title, element: el }, 96);
@@ -6,9 +6,11 @@
import { type Snippet } from 'svelte'; import { type Snippet } from 'svelte';
import { import {
type NavigationAction, type NavigationAction,
scrollBreadcrumbsStore, getScrollBreadcrumbsStore,
} from '../../model'; } from '../../model';
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
interface Props { interface Props {
/** /**
* Navigation index * Navigation index
+2 -2
View File
@@ -1,2 +1,2 @@
export * from './model'; export { getThemeManager } from './model';
export * from './ui'; export { ThemeSwitch } from './ui';
+1 -1
View File
@@ -1 +1 @@
export { themeManager } from './store/ThemeManager/ThemeManager.svelte'; export { getThemeManager } from './store/ThemeManager/ThemeManager.svelte';
@@ -28,7 +28,10 @@
* ``` * ```
*/ */
import { createPersistentStore } from '$shared/lib'; import {
createPersistentStore,
createSingleton,
} from '$shared/lib';
export const STORAGE_KEY = 'glyphdiff:theme'; export const STORAGE_KEY = 'glyphdiff:theme';
@@ -125,6 +128,7 @@ class ThemeManager {
destroy(): void { destroy(): void {
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler); this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
this.#mediaQuery = null; this.#mediaQuery = null;
this.#store.destroy();
} }
/** /**
@@ -195,14 +199,20 @@ class ThemeManager {
} }
/** /**
* Singleton theme manager instance * App-wide theme manager, created on first access.
* *
* Use throughout the app for consistent theme state. * Lazy so its persistent-store subscription isn't set up at module load.
* Call init() on mount and destroy() on unmount (see Layout).
*/ */
export const themeManager = new ThemeManager(); const themeManager = createSingleton(() => new ThemeManager(), instance => instance.destroy());
export const getThemeManager = themeManager.get;
// test-only reset, so specs don't share persisted theme state
export const __resetThemeManager = themeManager.reset;
/** /**
* ThemeManager class exported for testing purposes * ThemeManager class exported for testing purposes
* Use the singleton `themeManager` in application code. * Use the `getThemeManager()` accessor in application code.
*/ */
export { ThemeManager }; export { ThemeManager };
@@ -22,8 +22,9 @@ const { Story } = defineMeta({
</script> </script>
<script lang="ts"> <script lang="ts">
import { themeManager } from '$features/ChangeAppTheme'; import { getThemeManager } from '$features/ChangeAppTheme';
const themeManager = getThemeManager();
// Current theme state for display // Current theme state for display
const currentTheme = $derived(themeManager.value); const currentTheme = $derived(themeManager.value);
const themeSource = $derived(themeManager.source); const themeSource = $derived(themeManager.source);
@@ -8,10 +8,11 @@ import { IconButton } from '$shared/ui';
import MoonIcon from '@lucide/svelte/icons/moon'; import MoonIcon from '@lucide/svelte/icons/moon';
import SunIcon from '@lucide/svelte/icons/sun'; import SunIcon from '@lucide/svelte/icons/sun';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { themeManager } from '../../model'; import { getThemeManager } from '../../model';
const responsive = getContext<ResponsiveManager>('responsive'); const responsive = getContext<ResponsiveManager>('responsive');
const themeManager = getThemeManager();
const theme = $derived(themeManager.value); const theme = $derived(themeManager.value);
</script> </script>
@@ -3,16 +3,25 @@ import {
render, render,
screen, screen,
} from '@testing-library/svelte'; } from '@testing-library/svelte';
import { themeManager } from '../../model'; import { afterEach } from 'vitest';
import { getThemeManager } from '../../model';
import { __resetThemeManager } from '../../model/store/ThemeManager/ThemeManager.svelte';
import ThemeSwitch from './ThemeSwitch.svelte'; import ThemeSwitch from './ThemeSwitch.svelte';
const context = new Map([['responsive', { isMobile: false }]]); const context = new Map([['responsive', { isMobile: false }]]);
describe('ThemeSwitch', () => { describe('ThemeSwitch', () => {
let themeManager: ReturnType<typeof getThemeManager>;
beforeEach(() => { beforeEach(() => {
themeManager = getThemeManager();
themeManager.setTheme('light'); themeManager.setTheme('light');
}); });
afterEach(() => {
__resetThemeManager();
});
describe('Rendering', () => { describe('Rendering', () => {
it('renders an icon button', () => { it('renders an icon button', () => {
render(ThemeSwitch, { context }); render(ThemeSwitch, { context });
-1
View File
@@ -1 +0,0 @@
export { FontSampler } from './ui';
-3
View File
@@ -1,3 +0,0 @@
import FontSampler from './FontSampler/FontSampler.svelte';
export { FontSampler };
-1
View File
@@ -1 +0,0 @@
export { FontsByIdsStore } from './model';
@@ -1 +0,0 @@
export { FontsByIdsStore } from './store/fontsByIdsStore/fontsByIdsStore.svelte';
@@ -9,6 +9,7 @@
import { api } from '$shared/api/api'; import { api } from '$shared/api/api';
import { API_ENDPOINTS } from '$shared/api/endpoints'; import { API_ENDPOINTS } from '$shared/api/endpoints';
import { NonRetryableError } from '$shared/api/nonRetryableError';
const PROXY_API_URL = API_ENDPOINTS.filters; const PROXY_API_URL = API_ENDPOINTS.filters;
@@ -37,7 +38,8 @@ export interface FilterMetadata {
type: 'enum' | 'string' | 'array'; type: 'enum' | 'string' | 'array';
/** /**
* Available filter options * Available filter options.
* Always an array; empty when the group has no options.
*/ */
options: FilterOption[]; options: FilterOption[];
} }
@@ -68,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 { export interface ProxyFiltersResponse {
/** /**
* Array of filter metadata * Array of filter metadata.
* Always an array; empty when no filter groups are configured.
*/ */
filters: FilterMetadata[]; filters: FilterMetadata[];
} }
@@ -99,7 +106,7 @@ export async function fetchProxyFilters(): Promise<FilterMetadata[]> {
const response = await api.get<FilterMetadata[]>(PROXY_API_URL); const response = await api.get<FilterMetadata[]>(PROXY_API_URL);
if (!response.data || !Array.isArray(response.data)) { 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; return response.data;

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