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).
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.
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).
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).
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).
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.
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.
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.
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.
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.
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.
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.
Dark mode unchanged. Targets that were reported as "barely visible" in
light theme:
Surfaces / dividers
- --color-border-subtle (light) bumped from rgb(0 0 0 / 0.05) to
--neutral-300 (matches the Input underline variant's border color and
yields a visible hairline on bg-surface / bg-paper).
- New bg-subtle utility (same color as border-subtle but as
background-color) — used by Divider component and the TypographyMenu
inline column separator. Replaces ad-hoc 'bg-black/5 dark:bg-white/10'
and 'bg-black/10 dark:bg-white/10' bands.
- FontSearch + ComparisonView Search wrapper borders switched from
hand-written 'border-swiss-black/5 dark:border-white/10' to
border-subtle so they participate in the palette.
Muted text
- Button tertiary inactive text (light) bumped neutral-400 → neutral-600
(~2.7:1 → ~7.5:1 contrast). Covers the A/B toggle and the font-list
rows in the sidebar.
- Label/TechText muted variant (light) bumped neutral-400 → neutral-600.
Covers the ComboControl value text.
- Link text aligned to neutral-500 / neutral-400 (subtle but visible).
No behavior changes; pure styling.
Replace inline class clusters with the design-system utilities and
tokens established in the prior two commits. No behavior changes
intended beyond two real bug fixes.
Bug fixes:
- SampleList.svelte: 'border-border-subtle bg-background-40' was a
silent no-op (both classes mis-spelled). Now 'border-subtle
bg-background/40' applies as intended.
- FontList.svelte: 'h-[44px]' → 'h-11' (44px = 2.75rem = spacing-11,
no need for arbitrary value).
Sweeps:
- TypographyMenu: popover + floating bar now use surface-popover /
surface-floating + shadow-popover.
- FontList + FilterGroup: tertiary list buttons use the new
Button layout="block-list-row" variant; skeleton fills use
the skeleton-fill utility.
- Footer / BreadcrumbHeader: surface-floating absorbs the
bg-surface/blur/border cluster. Footer bumped to z-20 with a
comment explaining the stacking against SidebarContainer (z-40/50).
- FontSampler: surface-card + hover shadow-stamp-card token.
- SliderArea: surface-canvas, flex-center, shadow-floating-panel
tokens (light + dark variants).
- Sidebar / Header / ButtonGroup / Layout / SidebarContainer:
bg-surface dark:bg-dark-bg → surface-canvas (8 sites);
SidebarContainer mobile panel uses shadow-overlay.
- Loader / Thumb: flex items-center justify-center → flex-center;
Thumb durations → duration-fast.
- ComboControl: trigger uses surface-card-elevated when open,
popover uses surface-card-elevated, label cluster → text-label-mono,
flex-center for the trigger interior.
- Slider: shadow-sm → shadow-rest, duration-150 → duration-fast.
- text-secondary → text-subtle across Input, Slider, ComboControl
(matches the rename in the styles commit).
- Link: reverted earlier surface-floating attempt — Link's original
bg-surface/80 backdrop-blur pattern was thinner than surface-floating
(no border, smaller blur), and the Footer was overlaying its own
border-subtle on top, fighting the utility. Kept the original style.
Long-tail cleanup: threshold and default-value literals in shared
helpers get named module-level constants.
- CharacterComparisonEngine: CHAR_PROXIMITY_RANGE_PCT (5),
DEFAULT_RENDER_SIZE_PX (16) — kept local instead of importing
DEFAULT_FONT_SIZE from \$entities/Font because \$shared/lib cannot
legally upward-import from \$entities per FSD (also avoids an init
cycle through the mocks barrel).
- typographySettingsStore: BASE_SIZE_EPSILON (0.01) — rounding-jitter
guard for baseSize reconciliation.
- createDebouncedState: DEFAULT_DEBOUNCE_MS (300) — exported so callers
can mirror the default.
- createVirtualizer: MEASUREMENT_EPSILON_PX (0.5) — minimum height
delta before committing a re-measured row.
- createPerspectiveManager: PERSPECTIVE_TOGGLE_THRESHOLD (0.5) — the
halfway point on the 0-1 spring that flips isBack/isFront.
Skipped #19 (PerspectivePlan defaults) per review — marginal gain.
- Use existing MULTIPLIER_S/M/L from \$entities/Font in SliderArea instead
of inlining the 0.5/0.75/1 literals (constants already existed but were
duplicated at the call site).
- Centralize API base URL in \$shared/api/endpoints.ts (was duplicated
between proxyFonts and FilterAndSortFonts filters api).
- Promote every 'glyphdiff:...' localStorage key to a named module-level
STORAGE_KEY constant. Test files now import the source constant rather
than redeclaring it (eliminates silent-typo divergence risk).
Both names were vague or overloaded:
- fontStore / FontStore -> fontCatalogStore / FontCatalogStore
Three font-related stores live in this slice; the new name names the
paginated catalog specifically.
- appliedFontsManager / AppliedFontsManager -> fontLifecycleManager /
FontLifecycleManager
"Applied" collided with the filter-side appliedFilterStore (different
meaning). The class actually orchestrates a load-use-evict lifecycle
with FontBufferCache + FontEvictionPolicy + FontLoadQueue
collaborators, so "Manager" is justified. Companion types file moved
alongside (appliedFonts.ts -> fontLifecycle.ts).
Directories, file basenames, factory (createFontStore ->
createFontCatalogStore), and the AppliedFontsManagerDeps interface all
renamed. All consumers (ComparisonView, SampleList, FontList,
FontApplicator, FontVirtualList, FilterAndSortFonts bindings,
createFontRowSizeResolver, mocks) updated.
The byId font fetch was a verb-oriented capability with a single
consumer driven by a feature need (materializing comparison picks).
That shape belongs at the feature layer, not on the entity.
Move:
- entities/Font/model/store/batchFontStore -> features/FetchFontsByIds/model/store/fontsByIdsStore
- Class BatchFontStore -> FontsByIdsStore
entities/Font retains the transport primitives (fetchFontsByIds,
seedFontCache) and the keyspace (fontKeys); the feature wraps them in
the reactive store. comparisonStore now imports FontsByIdsStore from
the new feature. The proxy API is imported via direct path so vi.spyOn
on the source module still observes the call.
Structural:
- Merge factory + singleton from lib/settingsManager and model/state into
one model/store/typographySettingsStore/ slice
- Drop now-empty lib/ and model/state/ directories
Semantic:
- Rename feature SetupFont -> AdjustTypography (the feature owns
continuous typography adjustment, not one-time font setup)
- Drop "Manager" from TypographySettingsManager -> TypographySettingsStore
(class + factory); singleton typographySettingsStore unchanged
All consumers (Character, Line, SampleList, SliderArea, FontSampler,
comparisonStore) updated. Public barrel signature changed: now exports
createTypographySettingsStore and type TypographySettingsStore.
The feature does not fetch fonts — that lives in \$entities/Font's
fontStore. It owns the user's filter selections, sort preference, and
search-by-name query that drive the listing. The new name describes what
it actually does.
Directory + every \$features/GetFonts import path updated; no symbol
renames in this commit.
The 'filters' + 'filterManager' pair didn't reveal the schema-vs-selection
split. Rename to reflect the actual roles:
- FiltersStore / filtersStore → AvailableFilterStore / availableFilterStore
- createFilterManager / FilterManager → createAppliedFilterStore / AppliedFilterStore
- filterManager singleton → appliedFilterStore
- mapManagerToParams → mapAppliedFiltersToParams
Directories and file basenames follow the new singleton names. Public
barrel signature updated; all consumers (Search, FontSearch, Filters,
FilterControls) point at the new identifiers.
Export createSortStore and FiltersStore so per-test instances can be
constructed without sharing singleton state. Add unit tests covering:
- sortStore: default + custom init, display→API value mapping, set()
idempotency, singleton shape
- filters: empty initial state, fetch population, single-call dedup,
error path, cached-fetch reuse across observers
Group each store with its tests under its own directory to match the
filterManager layout.
Collapse the three duplicated getGroup/map/length-guard chains into a
single selectedIn helper. Drop the unnecessary `as string[]` casts —
Property<TValue extends string>.value already yields string at the call
site.
Add unit tests covering empty query, populated query, missing group,
empty group, single + multi selection, unknown group ids, and the
combined param shape.
Align the slice with the project-wide convention (entities/Font,
entities/Breadcrumb, features/ChangeAppTheme all use model/store/;
CLAUDE.md spec calls for store/). Move bindings, filters, and the
filterManager subdir out of the now-removed model/state/ directory.
Merge the factory previously in lib/filterManager/ with the singleton
previously in model/state/manager.svelte.ts into a single
model/state/filterManager/ slice. The factory builds stateful runes-backed
objects, so it belongs alongside the singleton in model/, not in lib/.
lib/ now contains only the pure mapManagerToParams transform.
Public barrel signature unchanged.
Move the filtersStore → filterManager.setGroups $effect.root out of
manager.svelte.ts into bindings.svelte.ts so all cross-store reactive
wiring for the feature lives in one place. manager.svelte.ts now only
constructs and exports the singleton.
Move the duplicated $effect blocks that mapped filterManager and sortStore
into fontStore params out of Search, FontSearch and FilterControls into a
single $effect.root in features/GetFonts/model/state/bindings.svelte.ts.
Consumers now bind to the manager/store directly; the bridge is installed
once via a side-effect import from the feature barrel.