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.
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.
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.
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.
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.
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).
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.
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.
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.
The decorative dotted-grid background on the paper surface was a
6-line $derived gridStyle string applied via inline style="" plus four
extra utility classes for color and opacity. Replace with two named
utilities and let CSS handle the responsive switch.
app.css:
- New --color-grid-line CSS var (light + dark) so the grid colour and
intensity auto-switch without consumers needing a dark: variant or an
opacity layer.
- @utility bg-grid (20px cells) and @utility bg-grid-sm (10px cells).
Both reference --color-grid-line, so the same markup paints correctly
in light and dark mode.
SliderArea.svelte:
- Drop the gridStyle $derived block and the inline style= attribute.
- Overlay becomes a single line:
<div class="absolute inset-0 pointer-events-none bg-grid-sm md:bg-grid"
aria-hidden="true" />
Mobile picks the tight 10px grid; the md: breakpoint flips to 20px,
matching the prior JS-driven behaviour with no extra runtime cost.
The slider element had role="slider" and tabindex="0" but no keyboard
handler — the focus ring appeared but the slider could not be moved.
Add a keydown handler implementing the standard ARIA slider contract:
- ArrowLeft / ArrowDown — step left by 1 percent
- ArrowRight / ArrowUp — step right by 1 percent
- Shift + arrow — coarse step (10 percent)
- PageUp / PageDown — coarse step (10 percent)
- Home — jump to 0
- End — jump to 100
Bounds and step sizes extracted as named constants (SLIDER_MIN,
SLIDER_MAX, SLIDER_STEP_FINE, SLIDER_STEP_COARSE). Position updates go
through sliderSpring.target so keyboard moves animate the same way as
pointer drags.
Also adds the missing ARIA attributes that screen readers need:
- aria-valuemin / aria-valuemax (bounds)
- aria-orientation (horizontal)
scale-[0.94] shrinks proportionally — on wide viewports this produced
visibly larger horizontal gaps than vertical ones when the sidebar
opens, and it left the text engine measuring the un-scaled width
(causing the thumb-to-character morph boundary to drift).
Switch to outer-container padding (p-6 when sidebar is open on desktop)
so the paper inherits an equal pixel inset on all four sides. The
ResizeObserver picks up the new dimensions and the layout engine
re-wraps text at the actual rendered width.
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.
- New layout prop with values 'inline' (default) and 'block-list-row'.
The block-list-row variant bakes in full-width, left-aligned content
with trailing icon and text-sm, replacing the ~10-class override
duplicated across FilterGroup, FontList, and similar list-row sites.
- primary variant's three hard-offset shadows now reference the
shadow-stamp-{rest,hover,pressed} tokens; the 0.0625rem translate
becomes translate-{x,y}-px.
- Base classes use text-label-mono and duration-normal utilities
instead of inline 'font-primary font-bold tracking-tight uppercase'
and 'duration-200'.
- The icon variant's background uses surface-canvas (semantic naming;
picks up dark-mode automatically via --color-surface).
- text-secondary → text-subtle (avoids collision with the @theme
--color-secondary token; see earlier styles commit).
New exported type: ButtonLayout.
Establish a real design system foundation by moving the project from
inline arbitrary-value classes to named tokens and reusable utilities.
Tokens added to @theme (auto-generate Tailwind utilities):
- Shadows: shadow-rest, shadow-stamp-{rest,hover,pressed,card},
shadow-popover, shadow-floating-panel{,-dark}, shadow-overlay
- Motion: duration-{fast,normal,slow,slower};
ease-{standard,out-soft,spring-overshoot}
Semantic mode-switching colors added to :root / .dark so utilities
auto-adapt without dark: variants:
- --color-border-subtle, --color-text-subtle, --color-skeleton
Utilities migrated to Tailwind v4's @utility directive with direct CSS
properties (previously @layer utilities with @apply chains, which
silently failed when chaining to other user-defined utilities):
- border-subtle, text-subtle, focus-ring
- surface-canvas, surface-card, surface-card-elevated, surface-popover,
surface-floating
- flex-center, skeleton-fill, text-label-mono
Notes:
- text-secondary was renamed to text-subtle because --color-secondary is
registered in @theme (a near-white shadcn surface token), which made
Tailwind v4 auto-generate a colliding text-secondary utility that won
over the user-defined one — every consumer effectively rendered as
near-white text. The text-subtle name pairs cleanly with border-subtle
and avoids any @theme collisions.
- Dead --space-* variable scale removed (was defined but never wired
into @theme; Tailwind's default spacing scale is used everywhere).
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.