Compare commits

51 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
113 changed files with 2459 additions and 743 deletions
+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"
}
}
]
}
+15 -4
View File
@@ -1,3 +1,4 @@
import { windowSizeForLine } from '../src/entities/Font/domain/windowSizeForLine/windowSizeForLine';
import {
expect,
test,
@@ -5,12 +6,22 @@ import {
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('Sphinx');
await comparison.setPreviewText(text);
// Each grapheme renders as a `.char-wrap` cell in the slider once
// both fonts are loaded. Six glyphs → six cells.
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(6);
// 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 }) => {
-29
View File
@@ -1,29 +0,0 @@
{
"plugins": ["import"],
"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",
"import/no-cycle": "error"
}
}
+1 -3
View File
@@ -6,8 +6,7 @@
"type": "module",
"sideEffects": [
"*.css",
"**/router.ts",
"**/bindings.svelte.ts"
"**/router.ts"
],
"scripts": {
"dev": "vite",
@@ -49,7 +48,6 @@
"@types/jsdom": "28.0.1",
"@vitest/browser-playwright": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"bits-ui": "2.18.1",
"clsx": "^2.1.1",
"dprint": "0.54.0",
"jsdom": "29.1.1",
+4 -1
View File
@@ -6,7 +6,7 @@
descendants of this provider.
-->
<script lang="ts">
import { queryClient } from '$shared/api/queryClient';
import { getQueryClient } from '$shared/api/queryClient';
import { QueryClientProvider } from '@tanstack/svelte-query';
import type { Snippet } from 'svelte';
@@ -18,6 +18,9 @@ interface Props {
}
let { children }: Props = $props();
// First call to the lazy singleton — constructs the shared client for the app.
const queryClient = getQueryClient();
</script>
<QueryClientProvider client={queryClient}>
@@ -19,7 +19,9 @@ vi.mock('$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 { FontResponseError } from '../../lib/errors/errors';
import {
+2 -2
View File
@@ -11,7 +11,7 @@
*/
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 { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
@@ -26,7 +26,7 @@ import type { UnifiedFont } from '../../model/types';
*/
export function seedFontCache(fonts: UnifiedFont[]): void {
fonts.forEach(font => {
queryClient.setQueryData(fontKeys.detail(font.id), font);
getQueryClient().setQueryData(fontKeys.detail(font.id), font);
});
}
+1
View File
@@ -8,3 +8,4 @@ export {
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)));
}
+85 -22
View File
@@ -1,29 +1,92 @@
export * from './domain';
export * from './lib';
export * from './ui';
export {
computeLineRenderModel,
DualFontLayout,
findSplitIndex,
windowSizeForLine,
} from './domain';
export type {
ComparisonLine,
ComparisonResult,
LineRenderModel,
} from './domain';
// Pure model surface (types + constants) is part of the convenient top-level
// API. Stateful stores are deliberately excluded — see below.
export * from './model/const/const';
export * from './model/types';
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 clients import `$shared/api/queryClient`, whose
* module eval runs `new QueryClient()` and loads `@tanstack/query-core`. Funneling
* them through this barrel made every consumer of `$entities/Font` — including
* pure-domain and type-only importers — eager-load TanStack and construct the
* client (notably in unit specs). Import API clients via the segment:
* import { fetchProxyFonts } from '$entities/Font/api';
*/
/*
* Stores (`fontCatalogStore`, `fontLifecycleManager`, `FontsByIdsStore`) are
* intentionally NOT re-exported here. They instantiate module-level singletons
* and pull `@tanstack/query-core`, so funneling them through this barrel would
* make every consumer of `$entities/Font` eager-instantiate stores (and break
* tree-shaking / test init-order). Import them via the model segment:
* import { fontCatalogStore } from '$entities/Font/model';
* 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
+49 -4
View File
@@ -1,6 +1,51 @@
export * from './const/const';
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 './const/const';
export { getFontCatalog } from './store';
// Stores (lazy accessors + classes)
export {
__resetFontLifecycleManager,
FontLifecycleManager,
FontsByIdsStore,
getFontCatalog,
getFontLifecycleManager,
} from './store';
export type { FontCatalogStore } from './store';
export * from './store';
export * from './types';
export type {
FilterGroup,
FilterType,
FontCategory,
FontCollectionFilters,
FontCollectionSort,
FontCollectionState,
FontFeatures,
FontFilters,
FontLoadRequestConfig,
FontLoadStatus,
FontMetadata,
FontProvider,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
UnifiedFont,
UnifiedFontVariant,
} from './types';
@@ -27,18 +27,21 @@ vi.mock('$shared/api/queryClient', async importOriginal => {
*/
const { QueryClient } = await import('@tanstack/query-core');
const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
const mockClient = new QueryClient({
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
});
return {
...actual,
queryClient: new QueryClient({
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
}),
getQueryClient: () => mockClient,
};
});
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
import { queryClient } from '$shared/api/queryClient';
import { getQueryClient } from '$shared/api/queryClient';
import { fetchProxyFonts } from '../../../api';
const queryClient = getQueryClient();
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
@@ -1,8 +1,9 @@
import {
DEFAULT_QUERY_GC_TIME_MS,
DEFAULT_QUERY_STALE_TIME_MS,
queryClient,
getQueryClient,
} from '$shared/api/queryClient';
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
import {
type InfiniteData,
InfiniteQueryObserver,
@@ -46,7 +47,7 @@ export class FontCatalogStore {
readonly unknown[],
PageParam
>;
#qc = queryClient;
#qc = getQueryClient();
#unsubscribe: () => void;
constructor(params: FontStoreParams = {}) {
@@ -483,14 +484,12 @@ export class FontCatalogStore {
}
}
let _catalog: FontCatalogStore | undefined;
const catalog = createSingleton(
() => new FontCatalogStore({ limit: 50 }),
instance => instance.destroy(),
);
export function getFontCatalog(): FontCatalogStore {
return (_catalog ??= new FontCatalogStore({ limit: 50 }));
}
export const getFontCatalog = catalog.get;
// test-only reset, so specs don't share a live observer
export function __resetFontCatalog() {
_catalog?.destroy();
_catalog = undefined;
}
export const __resetFontCatalog = catalog.reset;
@@ -1,3 +1,4 @@
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
import { SvelteMap } from 'svelte/reactivity';
import {
type FontLoadRequestConfig,
@@ -419,18 +420,16 @@ export class FontLifecycleManager {
}
}
let _fontLifecycleManager: FontLifecycleManager | undefined;
/**
* App-wide font lifecycle manager, created on first access. Lazy so its
* AbortController / FontFace bookkeeping isn't set up at module load.
*/
export function getFontLifecycleManager(): FontLifecycleManager {
return (_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 function __resetFontLifecycleManager() {
_fontLifecycleManager?.destroy();
_fontLifecycleManager = undefined;
}
export const __resetFontLifecycleManager = fontLifecycleManager.reset;
@@ -71,7 +71,7 @@ describe('loadFont', () => {
it('throws FontParseError when font.load() rejects', async () => {
const loadError = new Error('parse failed');
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);
},
);
@@ -1,4 +1,6 @@
import { queryClient } from '$shared/api/queryClient';
import { getQueryClient } from '$shared/api/queryClient';
const queryClient = getQueryClient();
import { fontKeys } from '$shared/api/queryKeys';
import {
beforeEach,
+5 -2
View File
@@ -1,9 +1,12 @@
// Font lifecycle manager (browser-side load + cache + eviction)
export * from './fontLifecycleManager/fontLifecycleManager.svelte';
export {
__resetFontLifecycleManager,
FontLifecycleManager,
getFontLifecycleManager,
} from './fontLifecycleManager/fontLifecycleManager.svelte';
// Paginated catalog
export { getFontCatalog } from './fontCatalogStore/fontCatalogStore.svelte';
export type { FontCatalogStore } from './fontCatalogStore/fontCatalogStore.svelte';
// Batch fetch by IDs (detail-cache seeding)
+4 -1
View File
@@ -23,4 +23,7 @@ export type {
FontCollectionState,
} from './store';
export * from './store/fontLifecycle';
export type {
FontLoadRequestConfig,
FontLoadStatus,
} from './store/fontLifecycle';
+1 -5
View File
@@ -1,9 +1,5 @@
/**
* ============================================================================
* MOCK FONT DATA
* ============================================================================
*
* Factory functions and preset mock data for fonts.
* Mock font data: factory functions and preset fixtures.
* Used in Storybook stories, tests, and development.
*
* ## Usage
+1 -4
View File
@@ -1,8 +1,5 @@
/**
* ============================================================================
* MOCK DATA HELPERS - MAIN EXPORT
* ============================================================================
*
* Mock data helpers (main export).
* Comprehensive mock data for Storybook stories, tests, and development.
*
* ## Quick Start
+1 -5
View File
@@ -21,11 +21,7 @@
*/
import type { UnifiedFont } from '$entities/Font/model/types';
import type {
QueryKey,
QueryObserverResult,
QueryStatus,
} from '@tanstack/svelte-query';
import type { QueryStatus } from '@tanstack/svelte-query';
import {
UNIFIED_FONTS,
generateMockFonts,
@@ -4,7 +4,7 @@ import { defineMeta } from '@storybook/addon-svelte-csf';
import FontSampler from './FontSampler.svelte';
const { Story } = defineMeta({
title: 'Features/FontSampler',
title: 'Entities/Font/FontSampler',
component: FontSampler,
tags: ['autodocs'],
parameters: {
@@ -39,8 +39,8 @@ const { Story } = defineMeta({
</script>
<script lang="ts">
import type { UnifiedFont } from '$entities/Font';
import type { ComponentProps } from 'svelte';
import type { UnifiedFont } from '../../model/types';
// Mock fonts for testing
const mockArial: UnifiedFont = {
@@ -84,6 +84,14 @@ const mockGeorgia: UnifiedFont = {
isVariable: false,
},
};
// Stand-in for the AdjustTypography store the composing widget injects.
const mockTypography = {
renderedSize: 48,
weight: 400,
height: 1.5,
spacing: 0,
};
</script>
<Story
@@ -93,6 +101,7 @@ const mockGeorgia: UnifiedFont = {
status: 'loaded',
text: 'The quick brown fox jumps over the lazy dog',
index: 0,
typography: mockTypography,
}}
>
{#snippet template(args: ComponentProps<typeof FontSampler>)}
@@ -111,6 +120,7 @@ const mockGeorgia: UnifiedFont = {
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.',
index: 1,
typography: mockTypography,
}}
>
{#snippet template(args: ComponentProps<typeof FontSampler>)}
@@ -4,12 +4,6 @@
Visual design matches FontCard: sharp corners, red hover accent, header stats.
-->
<script lang="ts">
import {
FontApplicator,
type FontLoadStatus,
type UnifiedFont,
} from '$entities/Font';
import { getTypographySettingsStore } from '$features/AdjustTypography/model';
import {
Badge,
ContentEditable,
@@ -18,6 +12,35 @@ import {
Stat,
} from '$shared/ui';
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 {
/**
@@ -39,11 +62,15 @@ interface Props {
* @default 0
*/
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, status, text = $bindable(), index = 0 }: Props = $props();
const typographySettingsStore = getTypographySettingsStore();
let { font, status, text = $bindable(), index = 0, typography }: Props = $props();
// Extract provider badge with fallback
const providerBadge = $derived(
@@ -52,10 +79,10 @@ const providerBadge = $derived(
);
const stats = $derived([
{ label: 'SZ', value: `${typographySettingsStore.renderedSize}PX` },
{ label: 'WGT', value: `${typographySettingsStore.weight}` },
{ label: 'LH', value: typographySettingsStore.height?.toFixed(2) },
{ label: 'LTR', value: `${typographySettingsStore.spacing}` },
{ label: 'SZ', value: `${typography.renderedSize}PX` },
{ label: 'WGT', value: `${typography.weight}` },
{ label: 'LH', value: typography.height.toFixed(2) },
{ label: 'LTR', value: `${typography.spacing}` },
]);
</script>
@@ -73,9 +100,8 @@ const stats = $derived([
min-h-60
rounded-none
"
style:font-weight={typographySettingsStore.weight}
style:font-weight={typography.weight}
>
<!-- ── Header bar ─────────────────────────────────────────────────── -->
<div
class="
flex items-center justify-between
@@ -136,19 +162,18 @@ const stats = $derived([
</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">
<FontApplicator {font} {status}>
<ContentEditable
bind:text
fontSize={typographySettingsStore.renderedSize}
lineHeight={typographySettingsStore.height}
letterSpacing={typographySettingsStore.spacing}
fontSize={typography.renderedSize}
lineHeight={typography.height}
letterSpacing={typography.spacing}
/>
</FontApplicator>
</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">
{#each stats as stat, i}
<Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
@@ -160,7 +185,6 @@ const stats = $derived([
{/each}
</div>
<!-- ── Red hover line ─────────────────────────────────────────────── -->
<div
class="
absolute bottom-0 left-0 right-0
+2
View File
@@ -1,7 +1,9 @@
import FontApplicator from './FontApplicator/FontApplicator.svelte';
import FontSampler from './FontSampler/FontSampler.svelte';
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
export {
FontApplicator,
FontSampler,
FontVirtualList,
};
@@ -15,10 +15,14 @@ import {
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
} from '$entities/Font';
// Deep path (not the root barrel) on purpose: pulls only these pure
// 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 {
type PersistentStore,
createPersistentStore,
createSingleton,
} from '$shared/lib';
import type { NumericControl } from '$shared/ui';
import { SvelteMap } from 'svelte/reactivity';
@@ -166,6 +170,7 @@ export class TypographySettingsStore {
*/
destroy(): void {
this.#disposeEffects();
this.#storage.destroy();
}
/**
@@ -302,9 +307,6 @@ export class TypographySettingsStore {
if (c.id === 'font_size') {
c.instance.value = defaults.fontSize * this.#multiplier;
} 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') {
c.instance.value = defaults.fontWeight;
}
@@ -351,22 +353,17 @@ export function createTypographySettingsStore(
export type TypographySettingsStoreInstance = ReturnType<typeof createTypographySettingsStore>;
let _typographySettingsStore: TypographySettingsStoreInstance | undefined;
/**
* 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 function getTypographySettingsStore(): TypographySettingsStoreInstance {
return (_typographySettingsStore ??= createTypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
COMPARISON_STORAGE_KEY,
));
}
const typographySettingsStore = createSingleton(
() => createTypographySettingsStore(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 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 function __resetTypographySettingsStore() {
_typographySettingsStore?.destroy();
_typographySettingsStore = undefined;
}
export const __resetTypographySettingsStore = typographySettingsStore.reset;
@@ -6,7 +6,7 @@ import {
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
} from '$entities/Font';
} from '$entities/Font/model/const/const';
import {
beforeEach,
describe,
@@ -51,6 +51,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
let mockPersistentStore: {
value: TypographySettings;
clear: () => void;
destroy: () => void;
};
const createMockPersistentStore = (initialValue: TypographySettings) => {
@@ -70,6 +71,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
letterSpacing: DEFAULT_LETTER_SPACING,
};
},
destroy() {},
};
};
@@ -535,6 +537,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
mockStorage = v;
},
clear: clearSpy,
destroy() {},
};
const manager = new TypographySettingsStore(
@@ -11,11 +11,11 @@ import {
Button,
ComboControl,
ControlGroup,
Popover,
Slider,
} from '$shared/ui';
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import XIcon from '@lucide/svelte/icons/x';
import { Popover } from 'bits-ui';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
@@ -74,33 +74,21 @@ $effect(() => {
{#if !hidden}
{#if responsive.isMobileOrTablet}
<div class={className}>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Popover bind:open side="top" align="end" sideOffset={8}>
{#snippet trigger(props)}
<Button variant="primary" {...props}>
{#snippet icon()}
<Settings2Icon class="size-4" />
{/snippet}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side="top"
align="end"
sideOffset={8}
{#snippet children({ close })}
<div
class={cn(
'z-50 w-72 p-4 rounded-none',
'w-72 p-4 rounded-none',
'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 -->
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
@@ -112,17 +100,13 @@ $effect(() => {
CONTROLS
</span>
</div>
<Popover.Close>
{#snippet child({ props })}
<button
{...props}
onclick={close}
class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label="Close controls"
>
<XIcon class="size-3.5 text-neutral-500" />
</button>
{/snippet}
</Popover.Close>
</div>
<!-- Controls -->
@@ -136,9 +120,9 @@ $effect(() => {
/>
</ControlGroup>
{/each}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
{/snippet}
</Popover>
</div>
{:else}
<div
+7 -2
View File
@@ -1,2 +1,7 @@
export * from './store/scrollBreadcrumbsStore.svelte';
export * from './types/types.ts';
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
*
@@ -279,17 +281,15 @@ export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
return new ScrollBreadcrumbsStore();
}
let _scrollBreadcrumbsStore: ScrollBreadcrumbsStore | undefined;
/**
* App-wide scroll breadcrumbs store, created on first access.
*/
export function getScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
return (_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 function __resetScrollBreadcrumbsStore() {
_scrollBreadcrumbsStore?.destroy();
_scrollBreadcrumbsStore = undefined;
}
export const __resetScrollBreadcrumbsStore = scrollBreadcrumbsStore.reset;
@@ -70,7 +70,6 @@ class MockIntersectionObserver implements IntersectionObserver {
describe('ScrollBreadcrumbsStore', () => {
let scrollListeners: Array<() => void> = [];
let addEventListenerSpy: ReturnType<typeof vi.spyOn>;
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>;
let scrollToSpy: ReturnType<typeof vi.spyOn>;
// Helper to create mock elements
@@ -111,7 +110,7 @@ describe('ScrollBreadcrumbsStore', () => {
// Track scroll event listeners
addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(
(event: string, listener: EventListenerOrEventListenerObject, options?: any) => {
(event: string, listener: EventListenerOrEventListenerObject, _options?: any) => {
if (event === 'scroll') {
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) => {
if (event === 'scroll') {
const index = scrollListeners.indexOf(listener as () => void);
@@ -11,10 +11,14 @@ const sections = [
{ index: 102, title: 'Spacing' },
];
/** @type {HTMLDivElement} */
let container;
/** @type {HTMLDivElement | undefined} */
let container = $state();
onMount(() => {
if (!container) {
return;
}
for (const section of sections) {
const el = /** @type {HTMLElement} */ (container.querySelector(`[data-story-index="${section.index}"]`));
scrollBreadcrumbsStore.add({ index: section.index, title: section.title, element: el }, 96);
+2 -2
View File
@@ -1,2 +1,2 @@
export * from './model';
export * from './ui';
export { getThemeManager } from './model';
export { ThemeSwitch } from './ui';
@@ -28,7 +28,10 @@
* ```
*/
import { createPersistentStore } from '$shared/lib';
import {
createPersistentStore,
createSingleton,
} from '$shared/lib';
export const STORAGE_KEY = 'glyphdiff:theme';
@@ -125,6 +128,7 @@ class ThemeManager {
destroy(): void {
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
this.#mediaQuery = null;
this.#store.destroy();
}
/**
@@ -194,23 +198,18 @@ class ThemeManager {
}
}
let _themeManager: ThemeManager | undefined;
/**
* App-wide theme manager, created on first access.
*
* Lazy so its persistent-store subscription isn't set up at module load.
* Call init() on mount and destroy() on unmount (see Layout).
*/
export function getThemeManager(): ThemeManager {
return (_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 function __resetThemeManager() {
_themeManager?.destroy();
_themeManager = undefined;
}
export const __resetThemeManager = themeManager.reset;
/**
* ThemeManager class exported for testing purposes
-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 };
+6 -1
View File
@@ -1 +1,6 @@
export * from './filters/filters';
export { fetchProxyFilters } from './filters/filters';
export type {
FilterMetadata,
FilterOption,
ProxyFiltersResponse,
} from './filters/filters';
@@ -23,7 +23,10 @@
* ```
*/
import { createFilter } from '$shared/lib';
import {
createFilter,
createSingleton,
} from '$shared/lib';
import { createDebouncedState } from '$shared/lib/helpers';
import type {
FilterConfig,
@@ -129,8 +132,6 @@ export function createAppliedFilterStore<TValue extends string>(config: FilterCo
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
let _appliedFilterStore: AppliedFilterStore | undefined;
/**
* App-wide filter manager, created on first access.
*
@@ -138,14 +139,14 @@ let _appliedFilterStore: AppliedFilterStore | undefined;
* lives in `./bindings.svelte` and populates groups once backend filter
* metadata arrives.
*/
export function getAppliedFilterStore(): AppliedFilterStore {
return (_appliedFilterStore ??= createAppliedFilterStore<string>({
const appliedFilterStore = createSingleton(() =>
createAppliedFilterStore<string>({
queryValue: '',
groups: [],
}));
}
})
);
export const getAppliedFilterStore = appliedFilterStore.get;
// test-only reset, so specs don't share filter/selection state
export function __resetAppliedFilterStore() {
_appliedFilterStore = undefined;
}
export const __resetAppliedFilterStore = appliedFilterStore.reset;
@@ -29,12 +29,6 @@ import { createAppliedFilterStore } from './appliedFilterStore.svelte';
* testing Svelte 5 reactive code in Node.js.
*/
// Helper to flush Svelte effects (they run in microtasks)
async function flushEffects() {
await Promise.resolve();
await Promise.resolve();
}
// Helper to create test properties
function createTestProperties(count: number, selectedIndices: number[] = []): Property<string>[] {
return Array.from({ length: count }, (_, i) => ({
@@ -20,8 +20,9 @@ import type { FilterMetadata } from '$features/FilterAndSortFonts/api/filters/fi
import {
DEFAULT_QUERY_GC_TIME_MS,
DEFAULT_QUERY_STALE_TIME_MS,
queryClient,
getQueryClient,
} from '$shared/api/queryClient';
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
import {
type QueryKey,
QueryObserver,
@@ -49,7 +50,7 @@ export class AvailableFilterStore {
/**
* Shared query client
*/
protected qc = queryClient;
protected qc = getQueryClient();
/**
* Creates a new filters store
@@ -126,18 +127,16 @@ export class AvailableFilterStore {
}
}
let _availableFilterStore: AvailableFilterStore | undefined;
/**
* App-wide filter-metadata store, created on first access. Lazy so the
* QueryObserver isn't constructed at module load.
*/
export function getAvailableFilterStore(): AvailableFilterStore {
return (_availableFilterStore ??= new AvailableFilterStore());
}
const availableFilterStore = createSingleton(
() => new AvailableFilterStore(),
instance => instance.destroy(),
);
export const getAvailableFilterStore = availableFilterStore.get;
// test-only reset, so specs don't share a live observer
export function __resetAvailableFilterStore() {
_availableFilterStore?.destroy();
_availableFilterStore = undefined;
}
export const __resetAvailableFilterStore = availableFilterStore.reset;
@@ -1,4 +1,6 @@
import { queryClient } from '$shared/api/queryClient';
import { getQueryClient } from '$shared/api/queryClient';
const queryClient = getQueryClient();
import {
afterEach,
beforeEach,
@@ -9,7 +9,7 @@
* observer, so it lives at module scope, not in any individual widget.
*/
import { getFontCatalog } from '$entities/Font/model';
import { getFontCatalog } from '$entities/Font';
import { untrack } from 'svelte';
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
import { mapFilterMetadataToGroups } from '../../lib/mapper/mapFilterMetadataToGroups';
@@ -1,3 +1,5 @@
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
/**
* Sort store manages the current sort option for font listings.
*
@@ -46,16 +48,12 @@ export function createSortStore(initial: SortOption = 'Popularity') {
export type SortStore = ReturnType<typeof createSortStore>;
let _sortStore: SortStore | undefined;
/**
* App-wide sort store, created on first access.
*/
export function getSortStore(): SortStore {
return (_sortStore ??= createSortStore());
}
const sortStore = createSingleton(() => createSortStore());
export const getSortStore = sortStore.get;
// test-only reset, so specs don't share selection state
export function __resetSortStore() {
_sortStore = undefined;
}
export const __resetSortStore = sortStore.reset;
+3 -2
View File
@@ -1,6 +1,5 @@
import { createRouter } from 'sv-router';
import Home from './Home.svelte';
import Redirect from './Redirect.svelte';
/**
* Single-page router for glyphdiff.
@@ -18,6 +17,8 @@ export const {
'/': Home,
/**
* Any unmatched path redirects to home until additional routes exist.
* Lazy-loaded so `router` doesn't statically import `Redirect`, which
* imports `navigate` from here breaks the import cycle.
*/
'*notfound': Redirect,
'*notfound': () => import('./Redirect.svelte'),
});
+12 -5
View File
@@ -27,11 +27,16 @@ export const QUERY_RETRY_BASE_DELAY_MS = 1000;
*/
export const QUERY_RETRY_MAX_DELAY_MS = 30000;
let queryClientInstance: QueryClient | undefined;
/**
* TanStack Query client instance
* Shared TanStack Query client (lazy singleton).
*
* Configured for optimal caching and refetching behavior.
* Used by all font stores for data fetching and caching.
* Construction is deferred to the first call so importing this module is inert:
* module eval runs no `new QueryClient()`, so the module is genuinely
* side-effect-free and needs no `sideEffects` allowlist exception. The
* app-layer `QueryProvider` is the first caller; every store reuses the same
* instance. Matches the lazy-accessor pattern used by the font stores.
*
* Cache behavior:
* - Data stays fresh for 5 minutes (staleTime)
@@ -39,7 +44,8 @@ export const QUERY_RETRY_MAX_DELAY_MS = 30000;
* - No refetch on window focus (reduces unnecessary network requests)
* - 3 retries with exponential backoff on failure
*/
export const queryClient = new QueryClient({
export function getQueryClient(): QueryClient {
return (queryClientInstance ??= new QueryClient({
defaultOptions: {
queries: {
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
@@ -65,4 +71,5 @@ export const queryClient = new QueryClient({
Math.min(QUERY_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, QUERY_RETRY_MAX_DELAY_MS),
},
},
});
}));
}
@@ -1,4 +1,4 @@
import { queryClient } from '$shared/api/queryClient';
import { getQueryClient } from '$shared/api/queryClient';
import {
QueryObserver,
type QueryObserverOptions,
@@ -20,7 +20,7 @@ export abstract class BaseQueryStore<TData, TError = Error> {
#unsubscribe: () => void;
constructor(options: QueryObserverOptions<TData, TError, TData, any, any>) {
this.#observer = new QueryObserver(queryClient, options);
this.#observer = new QueryObserver(getQueryClient(), options);
this.#unsubscribe = this.#observer.subscribe(result => {
this.#result = result;
});
@@ -1,4 +1,6 @@
import { queryClient } from '$shared/api/queryClient';
import { getQueryClient } from '$shared/api/queryClient';
const queryClient = getQueryClient();
import {
beforeEach,
describe,
@@ -1,66 +1,15 @@
/**
* Persistent localStorage-backed reactive state
* Reactive localStorage-backed state. Loads on init, saves on change via an
* $effect.root. Falls back to the default on SSR (no localStorage) and on JSON
* parse errors; swallows quota/write errors with a warning.
*
* Creates reactive state that automatically syncs with localStorage.
* Values persist across browser sessions and are restored on page load.
* Owners that create this outside a component must call destroy() to dispose
* the save effect.
*
* Handles edge cases:
* - SSR safety (no localStorage on server)
* - JSON parse errors (falls back to default)
* - Storage quota errors (logs warning, doesn't crash)
*
* @example
* ```ts
* // Store user preferences
* const preferences = createPersistentStore('user-prefs', {
* theme: 'dark',
* fontSize: 16,
* sidebarOpen: true
* });
*
* // Access reactive state
* $: currentTheme = preferences.value.theme;
*
* // Update (auto-saves to localStorage)
* preferences.value.theme = 'light';
*
* // Clear stored value
* preferences.clear();
* ```
*/
/**
* Creates a reactive store backed by localStorage
*
* The value is loaded from localStorage on initialization and automatically
* saved whenever it changes. Uses Svelte 5's $effect for reactive sync.
*
* @param key - localStorage key for storing the value
* @param defaultValue - Default value if no stored value exists
* @returns Persistent store with getter/setter and clear method
*
* @example
* ```ts
* // Simple value
* const counter = createPersistentStore('counter', 0);
* counter.value++;
*
* // Complex object
* interface Settings {
* theme: 'light' | 'dark';
* fontSize: number;
* }
* const settings = createPersistentStore<Settings>('app-settings', {
* theme: 'light',
* fontSize: 16
* });
* ```
* @param key - localStorage key
* @param defaultValue - value used when nothing is stored
*/
export function createPersistentStore<T>(key: string, defaultValue: T) {
/**
* Load value from localStorage or return default
* Safely handles missing keys, parse errors, and SSR
*/
const loadFromStorage = (): T => {
if (typeof window === 'undefined') {
return defaultValue;
@@ -76,9 +25,13 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
let value = $state<T>(loadFromStorage());
// Sync to storage whenever value changes
// Wrapped in $effect.root to prevent memory leaks
$effect.root(() => {
/**
* Sync to storage whenever value changes. The effect lives in an
* $effect.root so it outlives any component; the returned disposer is kept
* and run by destroy(), because an $effect.root with no disposer leaks for
* the life of the process.
*/
const dispose = $effect.root(() => {
$effect(() => {
if (typeof window === 'undefined') {
return;
@@ -113,6 +66,15 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
}
value = defaultValue;
},
/**
* Dispose the storage-sync effect. Owners that create a store outside a
* component (e.g. a singleton store class) must call this to avoid
* leaking the underlying $effect.root.
*/
destroy() {
dispose();
},
};
}
@@ -1,6 +1,7 @@
/**
* @vitest-environment jsdom
*/
import { flushSync } from 'svelte';
import {
afterEach,
beforeEach,
@@ -376,4 +377,39 @@ describe('createPersistentStore', () => {
expect(store.value[0].name).toBe('First');
});
});
describe('Lifecycle', () => {
it('persists value changes via the sync effect', () => {
const store = createPersistentStore(testKey, 'a');
const spy = vi.spyOn(mockLocalStorage, 'setItem');
store.value = 'b';
flushSync();
expect(spy).toHaveBeenCalledWith(testKey, JSON.stringify('b'));
});
it('stops persisting after destroy()', () => {
const store = createPersistentStore(testKey, 'a');
flushSync();
store.destroy();
const spy = vi.spyOn(mockLocalStorage, 'setItem');
store.value = 'c';
flushSync();
expect(spy).not.toHaveBeenCalled();
// reading still works after disposal
expect(store.value).toBe('c');
});
it('destroy() is safe to call repeatedly', () => {
const store = createPersistentStore(testKey, 'a');
expect(() => {
store.destroy();
store.destroy();
}).not.toThrow();
});
});
});
@@ -0,0 +1,86 @@
import {
describe,
expect,
it,
vi,
} from 'vitest';
import { createSingleton } from './createSingleton';
describe('createSingleton', () => {
it('does not call the factory until the first get (lazy)', () => {
const factory = vi.fn(() => ({ id: 1 }));
createSingleton(factory);
expect(factory).not.toHaveBeenCalled();
});
it('constructs on first get and memoizes the instance', () => {
const factory = vi.fn(() => ({ id: 1 }));
const singleton = createSingleton(factory);
const a = singleton.get();
const b = singleton.get();
expect(factory).toHaveBeenCalledTimes(1);
expect(a).toBe(b);
});
it('rebuilds a fresh instance after reset', () => {
let count = 0;
const singleton = createSingleton(() => ({ id: ++count }));
const first = singleton.get();
singleton.reset();
const second = singleton.get();
expect(first).not.toBe(second);
expect(second.id).toBe(2);
});
it('runs teardown once, with the live instance, on reset', () => {
const teardown = vi.fn();
const singleton = createSingleton(() => ({ id: 1 }), teardown);
const instance = singleton.get();
singleton.reset();
expect(teardown).toHaveBeenCalledTimes(1);
expect(teardown).toHaveBeenCalledWith(instance);
});
it('treats reset before any get as a no-op (no teardown, no throw)', () => {
const teardown = vi.fn();
const singleton = createSingleton(() => ({ id: 1 }), teardown);
expect(() => singleton.reset()).not.toThrow();
expect(teardown).not.toHaveBeenCalled();
});
it('does not run teardown again on a second consecutive reset', () => {
const teardown = vi.fn();
const singleton = createSingleton(() => ({ id: 1 }), teardown);
singleton.get();
singleton.reset();
singleton.reset();
expect(teardown).toHaveBeenCalledTimes(1);
});
it('works without a teardown', () => {
const singleton = createSingleton(() => ({ id: 1 }));
singleton.get();
expect(() => singleton.reset()).not.toThrow();
expect(singleton.get().id).toBe(1);
});
it('caches a falsy instance value without re-running the factory', () => {
const factory = vi.fn(() => undefined);
const singleton = createSingleton<undefined>(factory);
singleton.get();
singleton.get();
expect(factory).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,57 @@
/**
* A lazily-constructed singleton accessor pair.
*/
export interface Singleton<T> {
/**
* Returns the instance, constructing it on the first call and reusing it
* thereafter.
*/
get: () => T;
/**
* Tears down the current instance (if built) and clears it, so the next
* `get()` rebuilds. Used by specs to avoid shared state between tests.
*/
reset: () => void;
}
/**
* Standardizes the lazy `getX()` / `__resetX()` singleton pattern used by the
* app's stores.
*
* The instance is built on the first `get()` and reused afterwards; `reset()`
* runs the optional teardown against the live instance and clears it. Building
* lazily keeps the owning module inert at import construction happens only on
* first access, never at module eval.
*
* @param factory - Builds the instance on first access.
* @param teardown - Optional cleanup run against the live instance on reset
* (e.g. disposing an `$effect.root` via the instance's `destroy()`).
*
* @example
* ```ts
* const catalog = createSingleton(() => new FontCatalogStore({ limit: 50 }), c => c.destroy());
* export const getFontCatalog = catalog.get;
* export const __resetFontCatalog = catalog.reset;
* ```
*/
export function createSingleton<T>(factory: () => T, teardown?: (instance: T) => void): Singleton<T> {
let instance: T | undefined;
let initialized = false;
return {
get: () => {
if (!initialized) {
instance = factory();
initialized = true;
}
return instance as T;
},
reset: () => {
if (initialized) {
teardown?.(instance as T);
}
instance = undefined;
initialized = false;
},
};
}
@@ -156,7 +156,7 @@ export function createVirtualizer<T>(
const offsets = $derived.by(() => {
const count = options.count;
// Implicit dependency on version signal
const v = _version;
const _v = _version;
const result = new Float64Array(count);
let accumulated = 0;
for (let i = 0; i < count; i++) {
@@ -180,7 +180,7 @@ export function createVirtualizer<T>(
// this derivation when the items array is replaced!
const { count, data } = options;
// Implicit dependency
const v = _version;
const _v = _version;
if (count === 0 || containerHeight === 0 || !data) {
return [];
}
@@ -268,7 +268,6 @@ export function createVirtualizer<T>(
return rect.top + scrollY;
};
let cachedOffsetTop = 0;
let rafId: number | null = null;
containerHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
@@ -292,14 +291,12 @@ export function createVirtualizer<T>(
const handleResize = () => {
containerHeight = window.innerHeight;
elementOffsetTop = getElementOffset();
cachedOffsetTop = elementOffsetTop;
handleScroll();
};
// Initial setup
requestAnimationFrame(() => {
elementOffsetTop = getElementOffset();
cachedOffsetTop = elementOffsetTop;
handleScroll();
});
+14
View File
@@ -137,6 +137,20 @@ export {
type PerspectiveManager,
} from './createPerspectiveManager/createPerspectiveManager.svelte';
/**
* Lazy singletons
*/
export {
/**
* Lazy `getX()` / `__resetX()` singleton accessor factory
*/
createSingleton,
/**
* Singleton accessor pair type
*/
type Singleton,
} from './createSingleton/createSingleton';
/*
* BaseQueryStore is intentionally NOT re-exported here.
* It pulls @tanstack/query-core, so routing it through this leaf barrel would
+2
View File
@@ -11,6 +11,7 @@ export {
createPersistentStore,
createPerspectiveManager,
createResponsiveManager,
createSingleton,
createVirtualizer,
type Entity,
type EntityStore,
@@ -21,6 +22,7 @@ export {
type Property,
type ResponsiveManager,
responsiveManager,
type Singleton,
type VirtualItem,
type Virtualizer,
type VirtualizerOptions,
+1 -5
View File
@@ -1,9 +1,5 @@
/**
* ============================================================================
* STORYBOOK HELPERS
* ============================================================================
*
* Helper components and utilities for Storybook stories.
* Storybook helpers: components and utilities for stories.
*
* ## Usage
*
@@ -3,10 +3,6 @@ import type {
TransitionConfig,
} from 'svelte/transition';
function elasticOut(t: number) {
return Math.pow(2, -10 * t) * Math.sin((t - 0.075) * (2 * Math.PI) / 0.3) + 1;
}
function gentleSpring(t: number) {
return 1 - Math.pow(1 - t, 3) * Math.cos(t * Math.PI * 2);
}
@@ -1,4 +1,4 @@
import { getDecimalPlaces } from '$shared/lib/utils';
import { getDecimalPlaces } from '../getDecimalPlaces/getDecimalPlaces';
/**
* Rounds a value to match the precision of a given step
@@ -24,9 +24,14 @@
*/
export function splitArray<T>(array: T[], callback: (item: T) => boolean) {
return array.reduce<[T[], T[]]>(
([pass, fail], item) => (
callback(item) ? pass.push(item) : fail.push(item), [pass, fail]
),
([pass, fail], item) => {
if (callback(item)) {
pass.push(item);
} else {
fail.push(item);
}
return [pass, fail];
},
[[], []],
);
}
+3 -3
View File
@@ -4,12 +4,12 @@
-->
<script lang="ts">
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import {
type LabelSize,
labelSizeConfig,
} from '$shared/ui/Label/config';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
} from '../labelConfig';
type BadgeVariant = 'default' | 'accent' | 'success' | 'warning' | 'info';
+14 -16
View File
@@ -5,11 +5,11 @@
-->
<script lang="ts">
import { cn } from '$shared/lib';
import { Slider } from '$shared/ui';
import { Button } from '$shared/ui/Button';
import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus';
import { Popover } from 'bits-ui';
import { Button } from '../Button';
import Popover from '../Popover/Popover.svelte';
import Slider from '../Slider/Slider.svelte';
import TechText from '../TechText/TechText.svelte';
import type {
ControlLabels,
@@ -84,9 +84,11 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
{formattedValue()}
</span>
</div>
<!-- ── FULL MODE ──────────────────────────────────────────────────────────────── -->
{:else}
<!--
FULL MODE
+/- buttons flanking a slider popover.
-->
<div class={cn('flex items-center px-1 relative', className)}>
<!-- Decrease button -->
<Button
@@ -103,9 +105,8 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
<!-- Trigger -->
<div class="relative mx-1">
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Popover bind:open side="top" align="center">
{#snippet trigger(props)}
<button
{...props}
class={cn(
@@ -138,14 +139,10 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
</TechText>
</button>
{/snippet}
</Popover.Trigger>
<!-- Vertical slider popover -->
<Popover.Content
class="w-auto py-4 px-3 h-64 flex-center rounded-none surface-card-elevated"
align="center"
side="top"
>
{#snippet children()}
<div class="w-auto py-4 px-3 h-64 flex-center rounded-none surface-card-elevated">
<Slider
class="h-full"
bind:value={control.value}
@@ -154,8 +151,9 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
step={control.step}
orientation="vertical"
/>
</Popover.Content>
</Popover.Root>
</div>
{/snippet}
</Popover>
</div>
<!-- Increase button -->
@@ -3,6 +3,7 @@ import {
render,
screen,
waitFor,
within,
} from '@testing-library/svelte';
import ComboControl from './ComboControl.svelte';
import { createNumericControlMock } from './testing/createNumericControlMock.svelte';
@@ -16,6 +17,16 @@ function makeControl(value: number, opts: { min?: number; max?: number; step?: n
});
}
/**
* The trigger is the button wired to the popover (has popovertarget). The native
* Popover always renders its content (the vertical slider, which also displays the
* value) in the DOM, so value assertions must be scoped to the trigger to avoid
* matching the slider's own value label.
*/
function getTrigger(): HTMLElement {
return document.querySelector('button[popovertarget]') as HTMLElement;
}
describe('ComboControl', () => {
describe('Rendering', () => {
it('renders decrease and increase buttons', () => {
@@ -26,17 +37,17 @@ describe('ComboControl', () => {
it('renders the current integer value', () => {
render(ComboControl, { control: makeControl(42) });
expect(screen.getByText('42')).toBeInTheDocument();
expect(within(getTrigger()).getByText('42')).toBeInTheDocument();
});
it('formats decimal value to 1 decimal place when step >= 0.1', () => {
render(ComboControl, { control: makeControl(1.5, { step: 0.1 }) });
expect(screen.getByText('1.5')).toBeInTheDocument();
expect(within(getTrigger()).getByText('1.5')).toBeInTheDocument();
});
it('formats decimal value to 2 decimal places when step < 0.1', () => {
render(ComboControl, { control: makeControl(1.55, { step: 0.01 }) });
expect(screen.getByText('1.55')).toBeInTheDocument();
expect(within(getTrigger()).getByText('1.55')).toBeInTheDocument();
});
it('renders label when label prop is provided', () => {
@@ -106,16 +117,32 @@ describe('ComboControl', () => {
const control = makeControl(50);
render(ComboControl, { control });
await fireEvent.click(screen.getByLabelText('Increase'));
await waitFor(() => expect(screen.getByText('51')).toBeInTheDocument());
await waitFor(() => expect(within(getTrigger()).getByText('51')).toBeInTheDocument());
});
});
describe('Popover', () => {
it('opens popover with vertical slider on trigger click', async () => {
/**
* The native Popover always renders its content; opening is driven by the
* browser's declarative popovertarget invoker, which jsdom does not simulate
* on click (mirrors Popover.svelte.test.ts). So assert the wired-but-closed
* state, then drive the open through the API the browser would call.
*/
it('exposes a popover trigger with the vertical slider as its content', async () => {
render(ComboControl, { control: makeControl(50), controlLabel: 'Size control' });
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
await fireEvent.click(screen.getByText('Size control'));
await waitFor(() => expect(screen.getByRole('slider')).toBeInTheDocument());
const trigger = getTrigger();
expect(trigger).toHaveAttribute('aria-expanded', 'false');
const content = document.getElementById(trigger.getAttribute('popovertarget')!) as HTMLElement;
expect(content).toHaveAttribute('data-state', 'closed');
// The vertical slider lives inside the popover content. While closed the
// content is visibility:hidden, so query including hidden elements.
expect(within(content).getByRole('slider', { hidden: true })).toBeInTheDocument();
content.showPopover();
await waitFor(() => expect(content).toHaveAttribute('data-state', 'open'));
expect(trigger).toHaveAttribute('aria-expanded', 'true');
});
});
+2 -2
View File
@@ -5,8 +5,6 @@
<script lang="ts">
import type { Filter } from '$shared/lib';
import { cn } from '$shared/lib';
import { Button } from '$shared/ui';
import { Label } from '$shared/ui';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import { cubicOut } from 'svelte/easing';
@@ -14,6 +12,8 @@ import {
draw,
fly,
} from 'svelte/transition';
import { Button } from '../Button';
import Label from '../Label/Label.svelte';
interface Props {
/**
+1
View File
@@ -1 +1,2 @@
export { default as Input } from './Input.svelte';
export { inputIconSize } from './types';
+1 -1
View File
@@ -11,7 +11,7 @@ import {
type LabelVariant,
labelSizeConfig,
labelVariantConfig,
} from './config';
} from '../labelConfig';
interface Props {
/**
+1 -1
View File
@@ -4,7 +4,7 @@
-->
<script lang="ts">
import { cn } from '$shared/lib';
import { Badge } from '$shared/ui';
import Badge from '../Badge/Badge.svelte';
interface Props {
/**
@@ -0,0 +1,117 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Popover from './Popover.svelte';
const { Story } = defineMeta({
title: 'Shared/Popover',
component: Popover,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Anchored popover on the native Popover API (top-layer, light-dismiss, ESC, focus return). Hand-rolled side/align/offset positioning with flip + shift.',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
argTypes: {
side: {
control: 'select',
options: ['top', 'bottom', 'left', 'right'],
description: 'Preferred side',
},
align: {
control: 'select',
options: ['start', 'center', 'end'],
description: 'Cross-axis alignment',
},
sideOffset: {
control: 'number',
description: 'Gap between trigger and content (px)',
},
},
});
</script>
<script lang="ts">
import { Slider } from '$shared/ui';
let open = $state(false);
let value = $state(50);
</script>
<Story name="Bottom">
{#snippet template()}
<div class="p-32 flex-center min-h-screen">
<Popover bind:open side="bottom" align="center" sideOffset={8}>
{#snippet trigger(props)}
<button {...props} class="surface-card-elevated px-4 py-2">Open popover</button>
{/snippet}
{#snippet children()}
<div class="surface-popover p-4 w-56">Popover content</div>
{/snippet}
</Popover>
</div>
{/snippet}
</Story>
<Story name="Top">
{#snippet template()}
<div class="p-32 flex-center min-h-screen">
<Popover bind:open side="top" align="center" sideOffset={8}>
{#snippet trigger(props)}
<button {...props} class="surface-card-elevated px-4 py-2">Open popover</button>
{/snippet}
{#snippet children()}
<div class="surface-popover p-4 w-56">Popover content</div>
{/snippet}
</Popover>
</div>
{/snippet}
</Story>
<!--
Mirrors TypographyMenu: top/end placement with a programmatic Close button
wired to the `close()` param of the children snippet.
-->
<Story name="AlignedEnd">
{#snippet template()}
<div class="p-32 flex-center min-h-screen">
<Popover bind:open side="top" align="end" sideOffset={8}>
{#snippet trigger(props)}
<button {...props} class="surface-card-elevated px-4 py-2">Open menu</button>
{/snippet}
{#snippet children({ close })}
<div class="surface-popover p-4 w-72">
<h3 class="text-sm font-medium mb-3">Menu header</h3>
<p class="text-sm text-muted-foreground mb-4">
Aligned to the trigger's end edge.
</p>
<button class="surface-card-elevated px-3 py-1.5 text-sm" onclick={close}>
Close
</button>
</div>
{/snippet}
</Popover>
</div>
{/snippet}
</Story>
<!-- Mirrors ComboControl: a vertical Slider lives inside the popover content. -->
<Story name="WithSlider">
{#snippet template()}
<div class="p-32 flex-center min-h-screen">
<Popover bind:open side="top" align="center" sideOffset={8}>
{#snippet trigger(props)}
<button {...props} class="surface-card-elevated px-4 py-2">Adjust value</button>
{/snippet}
{#snippet children()}
<div class="surface-card-elevated p-3 h-64 flex-center">
<Slider orientation="vertical" min={0} max={100} bind:value />
</div>
{/snippet}
</Popover>
</div>
{/snippet}
</Story>
+225
View File
@@ -0,0 +1,225 @@
<!--
Component: Popover
Anchored popover on the native Popover API (top-layer, light-dismiss, ESC,
focus return handled by the browser). Placement is computed by the pure
`popover-position` module and applied as fixed coordinates; it repositions
on scroll/resize/content-resize. `open` is two-way bindable. The trigger is
consumer-rendered via the `trigger` snippet, which spreads a props object
(an attachment captures the trigger element; `popovertarget` wires the
native invoker). `children` receives `close()` to dismiss programmatically.
-->
<script lang="ts">
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
import { createAttachmentKey } from 'svelte/attachments';
import {
type Align,
type Side,
computePosition,
} from './popover-position';
interface Props {
/**
* Open state (two-way bindable)
* @default false
*/
open?: boolean;
/**
* Preferred side
* @default 'bottom'
*/
side?: Side;
/**
* Cross-axis alignment
* @default 'center'
*/
align?: Align;
/**
* Gap between trigger and content (px)
* @default 0
*/
sideOffset?: number;
/**
* CSS classes applied to the content element
*/
class?: string;
/**
* ARIA role for the content
* @default 'dialog'
*/
role?: 'dialog' | 'menu' | 'listbox';
/**
* Trigger snippet — spread the provided props onto your trigger element
*/
trigger: Snippet<[Record<string, unknown>]>;
/**
* Content snippet — receives `close()` for programmatic dismissal
*/
children: Snippet<[{ close: () => void }]>;
}
let {
open = $bindable(false),
side = 'bottom',
align = 'center',
sideOffset = 0,
class: className,
role = 'dialog',
trigger,
children,
}: Props = $props();
const uid = $props.id();
const contentId = `popover-${uid}`;
let triggerEl: HTMLElement | undefined = $state();
let contentEl: HTMLElement | undefined = $state();
/**
* Side actually used after flip. Seeded from the `side` prop; the authoritative
* value is written by updatePosition() on every open, so the seed only matters
* for the closed state (hence the intentional state_referenced_locally warning).
*/
let resolvedSide = $state(side);
/**
* True once updatePosition has applied coordinates for the current open.
* Gates visibility so the content never paints at its pre-positioned (0,0)
* top-layer default before the first measurement.
*/
let positioned = $state(false);
/**
* Resolved fixed-position coordinates. Applied through the reactive `style`
* attribute (not imperatively) so they can't be wiped when the attribute
* re-renders — mixing the two caused a one-frame top-left flash.
*/
let x = $state(0);
let y = $state(0);
/**
* Actual DOM open state, driven by the `toggle` event. Source of truth for
* whether the browser currently shows the popover; `open` is the public binding.
*/
let shown = $state(false);
/**
* Stable attachment that captures the consumer's trigger element for measuring.
* Created once so spreading reactive `triggerProps` doesn't re-run it.
*/
const attachKey = createAttachmentKey();
const attachTrigger = (node: HTMLElement) => {
triggerEl = node;
return () => {
if (triggerEl === node) {
triggerEl = undefined;
}
};
};
const triggerProps = $derived({
popovertarget: contentId,
'aria-haspopup': role,
'aria-expanded': open,
'aria-controls': contentId,
[attachKey]: attachTrigger,
});
/**
* Recompute and apply the fixed-position coordinates.
*/
function updatePosition(): void {
if (!triggerEl || !contentEl) {
return;
}
const result = computePosition({
triggerRect: triggerEl.getBoundingClientRect(),
contentRect: { width: contentEl.offsetWidth, height: contentEl.offsetHeight },
viewport: { width: window.innerWidth, height: window.innerHeight },
side,
align,
sideOffset,
});
resolvedSide = result.side;
x = result.x;
y = result.y;
positioned = true;
}
/**
* Mirror the `toggle` event into our state.
*/
function onToggle(event: ToggleEvent): void {
shown = event.newState === 'open';
open = shown;
if (!shown) {
positioned = false;
}
}
/**
* Programmatic dismiss for the content snippet.
*/
function close(): void {
open = false;
}
// state -> browser: open the popover when `open` flips true and it isn't shown,
// and close it when `open` flips false while shown. `shown` (from toggle) breaks
// the loop so we never call show/hide redundantly.
$effect(() => {
const el = contentEl;
if (!el) {
return;
}
if (open && !shown) {
el.showPopover();
} else if (!open && shown) {
el.hidePopover();
}
});
// Position while shown; reposition on scroll/resize/content-resize; auto-clean.
$effect(() => {
if (!shown || !contentEl || !triggerEl) {
return;
}
updatePosition();
const observer = new ResizeObserver(() => updatePosition());
observer.observe(contentEl);
const onScroll = () => updatePosition();
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onScroll);
return () => {
observer.disconnect();
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onScroll);
};
});
</script>
{@render trigger(triggerProps)}
<!--
inset:auto + margin:0 neutralize the UA popover stylesheet (which sets
inset:0; margin:auto to center it) so the JS-applied left/top win.
visibility is hidden until updatePosition runs (see `positioned`).
-->
<div
bind:this={contentEl}
id={contentId}
popover="auto"
{role}
data-side={resolvedSide}
data-state={shown ? 'open' : 'closed'}
ontoggle={onToggle}
style={`position: fixed; inset: auto; left: ${x}px; top: ${y}px; margin: 0;${positioned ? '' : ' visibility: hidden;'}`}
class={cn(
'opacity-0 scale-95 transition-discrete transition-[opacity,transform] duration-fast',
'starting:opacity-0 starting:scale-95',
'[&:popover-open]:opacity-100 [&:popover-open]:scale-100',
'data-[side=top]:origin-bottom data-[side=bottom]:origin-top',
className,
)}
>
{@render children({ close })}
</div>
@@ -0,0 +1,49 @@
import {
fireEvent,
render,
screen,
} from '@testing-library/svelte';
import Harness from './PopoverHarness.svelte';
/**
* Resolve the popover content element (the [popover] ancestor of the test content).
*/
function getContent(): HTMLElement {
return screen.getByTestId('content').closest('[popover]') as HTMLElement;
}
describe('Popover', () => {
it('renders the trigger with aria wiring, closed by default', () => {
render(Harness);
const trigger = screen.getByRole('button', { name: 'Open' });
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog');
expect(trigger).toHaveAttribute('popovertarget');
expect(getContent()).toHaveAttribute('data-state', 'closed');
});
it('opens via the popover toggle and syncs aria-expanded + data-state', async () => {
render(Harness);
const trigger = screen.getByRole('button', { name: 'Open' });
// jsdom does not auto-invoke popovertarget; call the API the browser would.
getContent().showPopover();
await Promise.resolve();
expect(getContent()).toHaveAttribute('data-state', 'open');
expect(trigger).toHaveAttribute('aria-expanded', 'true');
});
it('opens when the parent sets open=true (state -> browser)', async () => {
render(Harness, { open: true });
await Promise.resolve();
expect(getContent()).toHaveAttribute('data-state', 'open');
});
it('close() hides the popover and resets aria-expanded', async () => {
render(Harness, { open: true });
await Promise.resolve();
const trigger = screen.getByRole('button', { name: 'Open' });
await fireEvent.click(screen.getByTestId('close'));
expect(getContent()).toHaveAttribute('data-state', 'closed');
expect(trigger).toHaveAttribute('aria-expanded', 'false');
});
});
@@ -0,0 +1,21 @@
<!--
Component: PopoverHarness
Test-only fixture: renders Popover with a button trigger and simple content
exposing the close() callback.
-->
<script lang="ts">
import Popover from './Popover.svelte';
let { open = $bindable(false) }: { open?: boolean } = $props();
</script>
<Popover bind:open>
{#snippet trigger(props)}
<button {...props}>Open</button>
{/snippet}
{#snippet children({ close })}
<div data-testid="content">
<button onclick={close} data-testid="close">Close</button>
</div>
{/snippet}
</Popover>
@@ -0,0 +1,119 @@
import {
type Align,
type Side,
computePosition,
} from './popover-position';
/**
* Build a DOMRect-like object (jsdom/node has no layout).
*/
function rect(x: number, y: number, width: number, height: number): DOMRect {
return {
x,
y,
width,
height,
top: y,
left: x,
right: x + width,
bottom: y + height,
toJSON: () => ({}),
} as DOMRect;
}
const viewport = { width: 1000, height: 800 };
const content = { width: 200, height: 100 };
function compute(side: Side, align: Align, sideOffset = 0, trigger = rect(400, 400, 100, 40)) {
return computePosition({ triggerRect: trigger, contentRect: content, viewport, side, align, sideOffset });
}
describe('computePosition', () => {
it('places below the trigger for side="bottom"', () => {
const r = compute('bottom', 'center');
expect(r.side).toBe('bottom');
expect(r.y).toBe(440); // trigger.bottom (400+40)
});
it('places above the trigger for side="top"', () => {
const r = compute('top', 'center');
expect(r.side).toBe('top');
expect(r.y).toBe(300); // trigger.top (400) - content.height (100)
});
it('applies sideOffset on the main axis', () => {
const r = compute('bottom', 'center', 8);
expect(r.y).toBe(448);
});
it('aligns center on the cross axis (vertical side)', () => {
const r = compute('bottom', 'center');
// trigger center x = 450; content half = 100 -> 350
expect(r.x).toBe(350);
});
it('aligns start and end on the cross axis (vertical side)', () => {
expect(compute('bottom', 'start').x).toBe(400); // trigger.left
expect(compute('bottom', 'end').x).toBe(300); // trigger.right(500) - content.width(200)
});
it('places left/right with vertical cross-axis alignment', () => {
const right = compute('right', 'start');
expect(right.side).toBe('right');
expect(right.x).toBe(500); // trigger.right
expect(right.y).toBe(400); // trigger.top (align start)
const left = compute('left', 'center');
expect(left.side).toBe('left');
expect(left.x).toBe(200); // trigger.left(400) - content.width(200)
});
it('flips top->bottom when there is no room above', () => {
const nearTop = rect(400, 20, 100, 40); // only 20px above, content needs 100
const r = computePosition({
triggerRect: nearTop,
contentRect: content,
viewport,
side: 'top',
align: 'center',
sideOffset: 0,
});
expect(r.side).toBe('bottom');
expect(r.y).toBe(60); // nearTop.bottom
});
it('does NOT flip when neither side fits (keeps requested side)', () => {
const tall = { width: 200, height: 700 };
const r = computePosition({
triggerRect: rect(400, 400, 100, 40),
contentRect: tall,
viewport,
side: 'top',
align: 'center',
sideOffset: 0,
});
expect(r.side).toBe('top');
});
it('shifts on the cross axis to stay within the viewport', () => {
const nearRight = rect(950, 400, 40, 40); // center x ~970, content 200 would overflow right
const r = computePosition({
triggerRect: nearRight,
contentRect: content,
viewport,
side: 'bottom',
align: 'center',
sideOffset: 0,
});
expect(r.x).toBe(800); // clamped to viewport.width(1000) - content.width(200)
const nearLeft = rect(10, 400, 40, 40);
const r2 = computePosition({
triggerRect: nearLeft,
contentRect: content,
viewport,
side: 'bottom',
align: 'center',
sideOffset: 0,
});
expect(r2.x).toBe(0); // clamped to 0
});
});
+149
View File
@@ -0,0 +1,149 @@
import { clampNumber } from '$shared/lib/utils';
/**
* Side of the trigger the content prefers to open toward.
*/
export type Side = 'top' | 'bottom' | 'left' | 'right';
/**
* Cross-axis alignment of the content relative to the trigger.
*/
export type Align = 'start' | 'center' | 'end';
/**
* Inputs for a single placement computation. All geometry is injected
* (no DOM reads) so the function stays pure and unit-testable.
*/
type ComputeArgs = {
/**
* Trigger bounding rect (viewport coordinates).
*/
triggerRect: DOMRect;
/**
* Measured content size.
*/
contentRect: { width: number; height: number };
/**
* Viewport size.
*/
viewport: { width: number; height: number };
/**
* Preferred side.
*/
side: Side;
/**
* Cross-axis alignment.
*/
align: Align;
/**
* Gap between trigger and content on the main axis.
*/
sideOffset: number;
};
/**
* Resolved placement: fixed-position coordinates plus the side actually used
* (may differ from the requested side after a flip).
*/
type ComputeResult = { x: number; y: number; side: Side };
const OPPOSITE: Record<Side, Side> = {
top: 'bottom',
bottom: 'top',
left: 'right',
right: 'left',
};
/**
* True for sides whose main axis is vertical (content sits above/below).
*/
function isVertical(side: Side): boolean {
return side === 'top' || side === 'bottom';
}
/**
* Main-axis coordinate (top for vertical sides, left for horizontal sides).
*/
function mainAxisCoord(side: Side, t: DOMRect, c: { width: number; height: number }, offset: number): number {
switch (side) {
case 'top':
return t.top - c.height - offset;
case 'bottom':
return t.bottom + offset;
case 'left':
return t.left - c.width - offset;
case 'right':
return t.right + offset;
}
}
/**
* Whether the content fits on the given side within the viewport.
*/
function fitsOnSide(
side: Side,
t: DOMRect,
c: { width: number; height: number },
v: { width: number; height: number },
offset: number,
): boolean {
const coord = mainAxisCoord(side, t, c, offset);
switch (side) {
case 'top':
return coord >= 0;
case 'left':
return coord >= 0;
case 'bottom':
return coord + c.height <= v.height;
case 'right':
return coord + c.width <= v.width;
}
}
/**
* Cross-axis coordinate for the requested alignment.
*/
function crossAxisCoord(side: Side, align: Align, t: DOMRect, c: { width: number; height: number }): number {
if (isVertical(side)) {
if (align === 'start') {
return t.left;
}
if (align === 'end') {
return t.right - c.width;
}
return t.left + t.width / 2 - c.width / 2;
}
if (align === 'start') {
return t.top;
}
if (align === 'end') {
return t.bottom - c.height;
}
return t.top + t.height / 2 - c.height / 2;
}
/**
* Compute an anchored placement with flip (to the opposite side when the
* preferred side doesn't fit but the opposite does) and shift (clamp the
* cross axis so the content stays within the viewport).
*/
export function computePosition(args: ComputeArgs): ComputeResult {
const { triggerRect: t, contentRect: c, viewport: v, align, sideOffset } = args;
let side = args.side;
if (!fitsOnSide(side, t, c, v, sideOffset) && fitsOnSide(OPPOSITE[side], t, c, v, sideOffset)) {
side = OPPOSITE[side];
}
let x: number;
let y: number;
if (isVertical(side)) {
y = mainAxisCoord(side, t, c, sideOffset);
x = clampNumber(crossAxisCoord(side, align, t, c), 0, Math.max(0, v.width - c.width));
} else {
x = mainAxisCoord(side, t, c, sideOffset);
y = clampNumber(crossAxisCoord(side, align, t, c), 0, Math.max(0, v.height - c.height));
}
return { x, y, side };
}
+4 -2
View File
@@ -7,8 +7,10 @@
They cannot pass leftIcon — it's owned by this wrapper.
-->
<script lang="ts">
import { Input } from '$shared/ui/Input';
import { inputIconSize } from '$shared/ui/Input/types';
import {
Input,
inputIconSize,
} from '$shared/ui/Input';
import SearchIcon from '@lucide/svelte/icons/search';
import type { ComponentProps } from 'svelte';
@@ -4,7 +4,7 @@
-->
<script lang="ts">
import { cn } from '$shared/lib';
import { Label } from '$shared/ui';
import Label from '../../Label/Label.svelte';
interface Props {
/**
@@ -43,7 +43,7 @@ function close() {
{#if responsive.isMobile}
<!--
── MOBILE: fixed overlay ─────────────────────────────────────────────
MOBILE: fixed overlay.
Only rendered when open. Both backdrop and panel use Svelte transitions
so they animate in and out independently.
-->
@@ -70,7 +70,7 @@ function close() {
{/if}
{:else}
<!--
── DESKTOP: collapsible column ───────────────────────────────────────
DESKTOP: collapsible column.
Always in the DOM — width transitions between 320px and 0.
overflow-hidden clips the w-80 inner div during the collapse.
+1 -3
View File
@@ -10,7 +10,7 @@ const { Story } = defineMeta({
docs: {
description: {
component:
'Styled bits-ui slider component with red accent (#ff3b30). Thumb is a 45° rotated square with hover/active scale animations.',
'Single-value slider (native, no bits-ui) with brand accent. Diamond thumb (45° rotated square) with hover/active scale. Supports pointer drag, click-to-seek, touch, and keyboard (arrows, Home/End, PageUp/Down).',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
@@ -39,8 +39,6 @@ const { Story } = defineMeta({
<script lang="ts">
import type { ComponentProps } from 'svelte';
let value = $state(50);
let valueLow = $state(25);
let valueHigh = $state(75);
</script>
<Story
+179 -42
View File
@@ -1,13 +1,16 @@
<!--
Component: Slider
Single-value slider using bits-ui Slider primitive.
Single-value slider built on a native role="slider" element (no bits-ui).
Supports pointer drag, click-to-seek, touch, and full keyboard nav.
Swiss design: 1px track, diamond thumb (rotate-45), brand accent.
-->
<script lang="ts">
import {
type Orientation,
Slider,
} from 'bits-ui';
pointerToValue,
snapToStep,
} from './slider-math';
type Orientation = 'horizontal' | 'vertical';
interface Props {
/**
@@ -67,8 +70,122 @@ let {
class: className,
}: Props = $props();
/**
* PageUp/PageDown move by this multiple of `step`.
*/
const LARGE_STEP_MULTIPLIER = 10;
const isVertical = $derived(orientation === 'vertical');
/**
* Thumb/range offset as a clamped percentage of the track.
*/
const percent = $derived.by(() => {
if (max <= min) {
return 0;
}
return Math.min(Math.max(((value - min) / (max - min)) * 100, 0), 100);
});
let trackEl: HTMLElement | undefined = $state();
let thumbEl: HTMLElement | undefined = $state();
let dragging = $state(false);
/**
* Apply a candidate value: snap, clamp, store, and notify only on change.
*/
function commit(raw: number): void {
const next = snapToStep(raw, { min, max, step });
if (next !== value) {
value = next;
onValueChange?.(next);
}
}
/**
* Keep an externally-supplied value normalized to the step grid and range.
* Mirrors the bits-ui primitive's behavior so out-of-range or off-grid
* props don't desync the thumb position from aria-valuenow / the label.
* Converges in one pass: once snapped, the value equals its own snap.
*/
$effect(() => {
const normalized = snapToStep(value, { min, max, step });
if (normalized !== value) {
value = normalized;
onValueChange?.(normalized);
}
});
/**
* Resolve a pointer event to a value using the live track rect.
*/
function seek(event: PointerEvent): void {
if (!trackEl) {
return;
}
const rect = trackEl.getBoundingClientRect();
commit(pointerToValue(event, rect, { min, max, step, orientation }));
}
function handlePointerDown(event: PointerEvent): void {
if (disabled) {
return;
}
dragging = true;
thumbEl?.focus();
(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
seek(event);
}
function handlePointerMove(event: PointerEvent): void {
if (!dragging || disabled) {
return;
}
seek(event);
}
function handlePointerUp(event: PointerEvent): void {
if (!dragging) {
return;
}
dragging = false;
(event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId);
}
function handleKeyDown(event: KeyboardEvent): void {
if (disabled) {
return;
}
const large = step * LARGE_STEP_MULTIPLIER;
let next: number | undefined;
switch (event.key) {
case 'ArrowRight':
case 'ArrowUp':
next = value + step;
break;
case 'ArrowLeft':
case 'ArrowDown':
next = value - step;
break;
case 'PageUp':
next = value + large;
break;
case 'PageDown':
next = value - large;
break;
case 'Home':
next = min;
break;
case 'End':
next = max;
break;
default:
return;
}
event.preventDefault();
commit(next);
}
const labelClasses = `font-mono text-2xs tabular-nums shrink-0
text-subtle
group-hover:text-neutral-700 dark:group-hover:text-neutral-300
@@ -91,22 +208,21 @@ const thumbClasses = `block w-2.5 h-2.5 bg-brand
{format(value)}
</span>
<Slider.Root
type="single"
orientation="vertical"
bind:value
{min}
{max}
{step}
{disabled}
onValueChange={(v => onValueChange?.(v))}
<div
bind:this={trackEl}
role="presentation"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={handlePointerUp}
class="
relative flex flex-col items-center select-none touch-none
w-5 h-full grow cursor-row-resize
disabled:opacity-50 disabled:cursor-not-allowed
"
class:opacity-50={disabled}
class:cursor-not-allowed={disabled}
>
{#snippet children({ thumbItems })}
<span
class="
bg-neutral-200 dark:bg-neutral-800
@@ -115,37 +231,47 @@ const thumbClasses = `block w-2.5 h-2.5 bg-brand
transition-colors
"
>
<Slider.Range class="absolute bg-brand w-full" />
<span
class="absolute bottom-0 left-0 bg-brand w-full"
style="height: {percent}%"
></span>
</span>
{#each thumbItems as thumb (thumb)}
<Slider.Thumb
index={thumb.index}
class={thumbClasses}
<span
role="slider"
bind:this={thumbEl}
tabindex={disabled ? -1 : 0}
aria-label="Value"
/>
{/each}
{/snippet}
</Slider.Root>
aria-orientation="vertical"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={String(format(value))}
aria-disabled={disabled ? 'true' : undefined}
data-active={dragging ? '' : undefined}
onkeydown={handleKeyDown}
class="{thumbClasses} absolute left-1/2 -translate-x-1/2 translate-y-1/2"
style="bottom: {percent}%"
></span>
</div>
</div>
{:else}
<div class="flex items-center gap-4 group w-full {className ?? ''}">
<Slider.Root
type="single"
orientation="horizontal"
bind:value
{min}
{max}
{step}
{disabled}
onValueChange={(v => onValueChange?.(v))}
<div
bind:this={trackEl}
role="presentation"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={handlePointerUp}
class="
relative flex items-center select-none touch-none
w-full h-5 cursor-col-resize
disabled:opacity-50 disabled:cursor-not-allowed
"
class:opacity-50={disabled}
class:cursor-not-allowed={disabled}
>
{#snippet children({ thumbItems })}
<span
class="
bg-neutral-200 dark:bg-neutral-800
@@ -154,18 +280,29 @@ const thumbClasses = `block w-2.5 h-2.5 bg-brand
transition-colors
"
>
<Slider.Range class="absolute bg-brand h-full" />
<span
class="absolute top-0 left-0 bg-brand h-full"
style="width: {percent}%"
></span>
</span>
{#each thumbItems as thumb (thumb)}
<Slider.Thumb
index={thumb.index}
class={thumbClasses}
<span
role="slider"
bind:this={thumbEl}
tabindex={disabled ? -1 : 0}
aria-label="Value"
/>
{/each}
{/snippet}
</Slider.Root>
aria-orientation="horizontal"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={String(format(value))}
aria-disabled={disabled ? 'true' : undefined}
data-active={dragging ? '' : undefined}
onkeydown={handleKeyDown}
class="{thumbClasses} absolute top-1/2 -translate-y-1/2 -translate-x-1/2"
style="left: {percent}%"
></span>
</div>
<!-- Label: right of slider -->
<span class="{labelClasses} w-12 text-right">
+107
View File
@@ -1,4 +1,5 @@
import {
fireEvent,
render,
screen,
} from '@testing-library/svelte';
@@ -60,3 +61,109 @@ describe('Slider', () => {
});
});
});
describe('Keyboard', () => {
it('increments by step on ArrowRight / ArrowUp', async () => {
const onValueChange = vi.fn();
render(Slider, { value: 50, step: 5, onValueChange });
const thumb = screen.getByRole('slider');
await fireEvent.keyDown(thumb, { key: 'ArrowRight' });
expect(thumb).toHaveAttribute('aria-valuenow', '55');
expect(onValueChange).toHaveBeenCalledWith(55);
});
it('decrements by step on ArrowLeft / ArrowDown', async () => {
render(Slider, { value: 50, step: 5 });
const thumb = screen.getByRole('slider');
await fireEvent.keyDown(thumb, { key: 'ArrowDown' });
expect(thumb).toHaveAttribute('aria-valuenow', '45');
});
it('jumps to min on Home and max on End', async () => {
render(Slider, { value: 50, min: 10, max: 90 });
const thumb = screen.getByRole('slider');
await fireEvent.keyDown(thumb, { key: 'Home' });
expect(thumb).toHaveAttribute('aria-valuenow', '10');
await fireEvent.keyDown(thumb, { key: 'End' });
expect(thumb).toHaveAttribute('aria-valuenow', '90');
});
it('moves by step*10 on PageUp / PageDown', async () => {
render(Slider, { value: 50, step: 2 });
const thumb = screen.getByRole('slider');
await fireEvent.keyDown(thumb, { key: 'PageUp' });
expect(thumb).toHaveAttribute('aria-valuenow', '70');
await fireEvent.keyDown(thumb, { key: 'PageDown' });
expect(thumb).toHaveAttribute('aria-valuenow', '50');
});
it('clamps at the bounds', async () => {
render(Slider, { value: 98, max: 100, step: 5 });
const thumb = screen.getByRole('slider');
await fireEvent.keyDown(thumb, { key: 'End' });
expect(thumb).toHaveAttribute('aria-valuenow', '100');
});
it('does nothing when disabled', async () => {
const onValueChange = vi.fn();
render(Slider, { value: 50, disabled: true, onValueChange });
const thumb = screen.getByRole('slider');
await fireEvent.keyDown(thumb, { key: 'ArrowRight' });
expect(thumb).toHaveAttribute('aria-valuenow', '50');
expect(onValueChange).not.toHaveBeenCalled();
});
});
describe('Pointer', () => {
/**
* Force a deterministic track rect since jsdom has no layout.
*/
function mockTrackRect(container: HTMLElement) {
const track = container.querySelector('[role="presentation"]') as HTMLElement;
track.getBoundingClientRect = () =>
({ left: 0, right: 200, top: 0, bottom: 20, width: 200, height: 20 }) as DOMRect;
return track;
}
it('seeks to the clicked position (click-to-seek)', async () => {
const onValueChange = vi.fn();
const { container } = render(Slider, { value: 0, min: 0, max: 100, onValueChange });
const track = mockTrackRect(container);
await fireEvent.pointerDown(track, { clientX: 100, clientY: 10, pointerId: 1 });
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '50');
expect(onValueChange).toHaveBeenCalledWith(50);
});
it('updates while dragging after pointerdown', async () => {
const { container } = render(Slider, { value: 0, min: 0, max: 100 });
const track = mockTrackRect(container);
await fireEvent.pointerDown(track, { clientX: 50, clientY: 10, pointerId: 1 });
await fireEvent.pointerMove(track, { clientX: 150, clientY: 10, pointerId: 1 });
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '75');
});
it('ignores pointer when disabled', async () => {
const { container } = render(Slider, { value: 0, disabled: true });
const track = mockTrackRect(container);
await fireEvent.pointerDown(track, { clientX: 100, clientY: 10, pointerId: 1 });
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0');
});
it('focuses the thumb on pointerdown so arrow keys work immediately', async () => {
const { container } = render(Slider, { value: 0, min: 0, max: 100 });
const track = container.querySelector('[role="presentation"]') as HTMLElement;
track.getBoundingClientRect = () =>
({ left: 0, right: 200, top: 0, bottom: 20, width: 200, height: 20 }) as DOMRect;
await fireEvent.pointerDown(track, { clientX: 100, clientY: 10, pointerId: 1 });
expect(screen.getByRole('slider')).toBe(document.activeElement);
});
it('maps a vertical drag with the inverted axis (bottom→min, top→max)', async () => {
const { container } = render(Slider, { value: 0, min: 0, max: 100, orientation: 'vertical' });
const track = container.querySelector('[role="presentation"]') as HTMLElement;
track.getBoundingClientRect = () =>
({ left: 0, right: 20, top: 0, bottom: 200, width: 20, height: 200 }) as DOMRect;
await fireEvent.pointerDown(track, { clientX: 10, clientY: 50, pointerId: 1 });
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '75');
});
});
+55
View File
@@ -0,0 +1,55 @@
import {
pointerToValue,
snapToStep,
} from './slider-math';
describe('snapToStep', () => {
it('snaps a raw value to the nearest step on the grid', () => {
expect(snapToStep(53, { min: 0, max: 100, step: 10 })).toBe(50);
expect(snapToStep(56, { min: 0, max: 100, step: 10 })).toBe(60);
});
it('clamps below min and above max', () => {
expect(snapToStep(-20, { min: 0, max: 100, step: 1 })).toBe(0);
expect(snapToStep(200, { min: 0, max: 100, step: 1 })).toBe(100);
});
it('respects a non-zero min when snapping', () => {
expect(snapToStep(13, { min: 10, max: 90, step: 5 })).toBe(15);
});
it('preserves fractional step precision', () => {
expect(snapToStep(1.34, { min: 0, max: 2, step: 0.05 })).toBe(1.35);
expect(snapToStep(0.31, { min: 0, max: 1, step: 0.1 })).toBe(0.3);
});
});
describe('pointerToValue', () => {
const rect = { left: 100, right: 300, top: 50, bottom: 250, width: 200, height: 200 } as DOMRect;
it('maps horizontal pointer position left→min, right→max', () => {
const opts = { min: 0, max: 100, step: 1, orientation: 'horizontal' as const };
expect(pointerToValue({ clientX: 100, clientY: 0 }, rect, opts)).toBe(0);
expect(pointerToValue({ clientX: 200, clientY: 0 }, rect, opts)).toBe(50);
expect(pointerToValue({ clientX: 300, clientY: 0 }, rect, opts)).toBe(100);
});
it('inverts vertical: bottom→min, top→max', () => {
const opts = { min: 0, max: 100, step: 1, orientation: 'vertical' as const };
expect(pointerToValue({ clientX: 0, clientY: 250 }, rect, opts)).toBe(0);
expect(pointerToValue({ clientX: 0, clientY: 150 }, rect, opts)).toBe(50);
expect(pointerToValue({ clientX: 0, clientY: 50 }, rect, opts)).toBe(100);
});
it('clamps when pointer is outside the track', () => {
const opts = { min: 0, max: 100, step: 1, orientation: 'horizontal' as const };
expect(pointerToValue({ clientX: 0, clientY: 0 }, rect, opts)).toBe(0);
expect(pointerToValue({ clientX: 9999, clientY: 0 }, rect, opts)).toBe(100);
});
it('returns min for a zero-size track without NaN', () => {
const zero = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 } as DOMRect;
const opts = { min: 5, max: 95, step: 1, orientation: 'horizontal' as const };
expect(pointerToValue({ clientX: 0, clientY: 0 }, zero, opts)).toBe(5);
});
});
+59
View File
@@ -0,0 +1,59 @@
import {
clampNumber,
roundToStepPrecision,
} from '$shared/lib/utils';
/**
* Geometry/range options shared by the math helpers.
*/
type SliderMathOpts = {
/**
* Minimum value (inclusive)
*/
min: number;
/**
* Maximum value (inclusive)
*/
max: number;
/**
* Step increment
*/
step: number;
};
/**
* Snap a raw value onto the step grid, then clamp to [min, max].
*
* Snapping is anchored to `min` so non-zero ranges land on valid stops.
* `roundToStepPrecision` removes IEEE-754 drift from fractional steps.
*/
export function snapToStep(raw: number, { min, max, step }: SliderMathOpts): number {
if (step <= 0) {
return clampNumber(raw, min, max);
}
const snapped = min + Math.round((raw - min) / step) * step;
return clampNumber(roundToStepPrecision(snapped, step), min, max);
}
/**
* Convert a pointer coordinate into a slider value.
*
* Horizontal maps leftmin, rightmax. Vertical is inverted so that
* upmax, matching natural slider expectations. The DOMRect is passed in
* to keep this pure and unit-testable without layout.
*/
export function pointerToValue(
point: { clientX: number; clientY: number },
rect: DOMRect,
opts: SliderMathOpts & { orientation: 'horizontal' | 'vertical' },
): number {
const { min, max, orientation } = opts;
const size = orientation === 'vertical' ? rect.height : rect.width;
if (size <= 0) {
return snapToStep(min, opts);
}
const ratio = orientation === 'vertical'
? (rect.bottom - point.clientY) / size
: (point.clientX - rect.left) / size;
return snapToStep(min + clampNumber(ratio, 0, 1) * (max - min), opts);
}
+1 -1
View File
@@ -4,8 +4,8 @@
-->
<script lang="ts">
import { cn } from '$shared/lib';
import { Label } from '$shared/ui';
import type { ComponentProps } from 'svelte';
import Label from '../Label/Label.svelte';
interface Props extends Pick<ComponentProps<typeof Label>, 'variant'> {
/**
+1 -1
View File
@@ -4,8 +4,8 @@
-->
<script lang="ts">
import { cn } from '$shared/lib';
import { Stat } from '$shared/ui';
import type { ComponentProps } from 'svelte';
import Stat from './Stat.svelte';
interface StatItem extends Partial<Pick<ComponentProps<typeof Stat>, 'variant'>> {
label: string;
+2 -2
View File
@@ -4,13 +4,13 @@
-->
<script lang="ts">
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
import {
type LabelSize,
type LabelVariant,
labelSizeConfig,
labelVariantConfig,
} from '$shared/ui/Label/config';
import type { Snippet } from 'svelte';
} from '../labelConfig';
interface Props {
/**
+6
View File
@@ -104,6 +104,12 @@ export {
*/
default as PerspectivePlan,
} from './PerspectivePlan/PerspectivePlan.svelte';
export {
/**
* Anchored popover on the native Popover API
*/
default as Popover,
} from './Popover/Popover.svelte';
export {
/**
* Specialized input with search icon and clear state
+2 -1
View File
@@ -1,2 +1,3 @@
export * from './model';
export { getComparisonStore } from './model';
export type { Side } from './model';
export { ComparisonView } from './ui';
+8 -3
View File
@@ -1,3 +1,8 @@
export * from './utils/dotTransition';
export * from './utils/ensureCanvasFonts/ensureCanvasFonts';
export * from './utils/getPretextFontString/getPretextFontString';
export { computeStrutHeight } from './utils/computeStrutHeight/computeStrutHeight';
export {
createDotCrossfade,
getDotTransitionParams,
} from './utils/dotTransition';
export type { DotTransitionParams } from './utils/dotTransition';
export { ensureCanvasFonts } from './utils/ensureCanvasFonts/ensureCanvasFonts';
export { getPretextFontString } from './utils/getPretextFontString/getPretextFontString';
@@ -0,0 +1,28 @@
import {
describe,
expect,
it,
} from 'vitest';
import { computeStrutHeight } from './computeStrutHeight';
describe('computeStrutHeight', () => {
it('uses the centering height when the line-height is generous', () => {
// centering = 40/2 + 16*0.34 = 25.44; floor = 16*1.1 = 17.6 → centering wins.
expect(computeStrutHeight(40, 16)).toBeCloseTo(25.44, 5);
});
it('falls back to the ascent floor when the line-height is tight', () => {
// centering = 16/2 + 16*0.34 = 13.44; floor = 16*1.1 = 17.6 → floor wins.
expect(computeStrutHeight(16, 16)).toBeCloseTo(17.6, 5);
});
it('treats the floor and centering height as equal at the crossover line-height', () => {
// centering == floor when lineHeight = 1.52 * fontSize → 24.32 for 16px.
expect(computeStrutHeight(24.32, 16)).toBeCloseTo(17.6, 5);
});
it('scales with font size', () => {
// centering = 60/2 + 32*0.34 = 40.88; floor = 32*1.1 = 35.2 → centering wins.
expect(computeStrutHeight(60, 32)).toBeCloseTo(40.88, 5);
});
});
@@ -0,0 +1,45 @@
/**
* Fraction of the font size added to half the line height to drop the strut's
* baseline from the line box's vertical middle down to the text's optical
* center. Empirical: ~0.34em approximates a Latin font's midline-to-baseline
* offset, so glyphs sit centered rather than riding high in the line.
*/
const BASELINE_OFFSET_RATIO = 0.34;
/**
* Minimum strut height as a multiple of the font size. Floors the strut above
* the fonts' ascent (~1em) so that at tight line-heights it stays the tallest
* inline box and keeps ownership of the line baseline. Empirical: 1.1 clears the
* tallest ascenders in the catalog's Latin fonts.
*/
const MIN_HEIGHT_RATIO = 1.1;
/**
* Pixel height for a slider line's invisible baseline strut.
*
* The slider renders each line with a zero-width strut span whose box is
* deliberately the tallest inline box on the line. The browser pins a line box's
* baseline to its tallest inline box; fixing the strut's height independent of
* which bulk runs or window chars are currently mounted keeps the baseline (and
* every glyph) from jumping as the slider sweeps runs in and out. With
* `overflow: hidden` the strut's baseline sits at its bottom edge, so this height
* also sets the text's vertical position within the line box.
*
* The result is `max(centeringHeight, ascentFloor)`:
* - `centeringHeight = lineHeightPx / 2 + fontSizePx * BASELINE_OFFSET_RATIO`
* centers the text half the line box places the strut's bottom edge at the
* vertical middle, and the offset term nudges the baseline down to the glyphs'
* optical center.
* - `ascentFloor = fontSizePx * MIN_HEIGHT_RATIO` keeps the strut taller than the
* fonts' ascent when the line-height is tight (where `centeringHeight` would
* shrink below a real glyph box and let another box steal the baseline).
*
* @param lineHeightPx Line height in pixels (typography line-height × font size).
* @param fontSizePx Rendered font size in pixels.
* @returns Strut height in pixels.
*/
export function computeStrutHeight(lineHeightPx: number, fontSizePx: number): number {
const centeringHeight = lineHeightPx / 2 + fontSizePx * BASELINE_OFFSET_RATIO;
const ascentFloor = fontSizePx * MIN_HEIGHT_RATIO;
return Math.max(centeringHeight, ascentFloor);
}
@@ -1,4 +1,3 @@
import { VIRTUAL_INDEX_NOT_LOADED } from '$entities/Font';
import { cubicOut } from 'svelte/easing';
import {
type CrossfadeParams,
@@ -14,22 +14,23 @@
*/
import {
type FontCatalogStore,
type FontLifecycleManager,
type FontLoadRequestConfig,
FontsByIdsStore,
type UnifiedFont,
getFontCatalog,
getFontLifecycleManager,
getFontUrl,
} from '$entities/Font';
import {
type FontCatalogStore,
type FontLifecycleManager,
FontsByIdsStore,
getFontCatalog,
getFontLifecycleManager,
} from '$entities/Font/model';
import {
type TypographySettingsStore,
getTypographySettingsStore,
} from '$features/AdjustTypography/model';
import { createPersistentStore } from '$shared/lib';
} from '$features/AdjustTypography';
import {
createPersistentStore,
createSingleton,
} from '$shared/lib';
import { untrack } from 'svelte';
import { getPretextFontString } from '../../lib';
@@ -58,12 +59,6 @@ const STORAGE_KEY = 'glyphdiff:comparison';
*/
const FONT_READY_FALLBACK_MS = 1000;
// Persistent storage for selected comparison fonts
const storage = createPersistentStore<ComparisonState>(STORAGE_KEY, {
fontAId: null,
fontBId: null,
});
/**
* Store for managing font comparison state.
*
@@ -102,22 +97,39 @@ export class ComparisonStore {
* TanStack Query-backed store for efficient batch font retrieval
*/
#fontsByIdsStore: FontsByIdsStore;
/**
* Paginated font catalog source of fonts for default seeding.
*/
#fontCatalog: FontCatalogStore;
/**
* Typography settings applied to the rendered comparison.
*/
#typography: TypographySettingsStore;
/**
* Font load/cache/eviction manager; pinned to keep compared fonts resident.
*/
#lifecycle: FontLifecycleManager;
/**
* Per-instance persistent storage for the selected comparison fonts.
*/
#storage = createPersistentStore<ComparisonState>(STORAGE_KEY, {
fontAId: null,
fontBId: null,
});
/**
* Disposes the constructor's $effect.root. Must be run on teardown.
*/
#disposeEffects: () => void;
constructor() {
// Synchronously seed the batch store with any IDs already in storage
const { fontAId, fontBId } = storage.value;
const { fontAId, fontBId } = this.#storage.value;
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
this.#fontCatalog = getFontCatalog();
this.#typography = getTypographySettingsStore();
this.#lifecycle = getFontLifecycleManager();
$effect.root(() => {
this.#disposeEffects = $effect.root(() => {
// Sync batch results → fontA / fontB
$effect(() => {
const fonts = this.#fontsByIdsStore.fonts;
@@ -125,7 +137,7 @@ export class ComparisonStore {
return;
}
const { fontAId: aId, fontBId: bId } = storage.value;
const { fontAId: aId, fontBId: bId } = this.#storage.value;
if (aId) {
const fa = fonts.find(f => f.id === aId);
if (fa) {
@@ -180,7 +192,7 @@ export class ComparisonStore {
// Untracked: only the catalog load should drive this effect, not the
// user's storage writes that happen as a result of normal selection.
const hasStoredSelection = untrack(() => {
return storage.value.fontAId !== null || storage.value.fontBId !== null;
return this.#storage.value.fontAId !== null || this.#storage.value.fontBId !== null;
});
if (hasStoredSelection) {
@@ -196,7 +208,7 @@ export class ComparisonStore {
untrack(() => {
const id1 = fonts[0].id;
const id2 = fonts[fonts.length - 1].id;
storage.value = { fontAId: id1, fontBId: id2 };
this.#storage.value = { fontAId: id1, fontBId: id2 };
this.#fontsByIdsStore.setIds([id1, id2]);
});
});
@@ -281,7 +293,7 @@ export class ComparisonStore {
* Updates persistent storage with the current font selection.
*/
private updateStorage() {
storage.value = {
this.#storage.value = {
fontAId: this.#fontA?.id ?? null,
fontBId: this.#fontB?.id ?? null,
};
@@ -365,19 +377,28 @@ export class ComparisonStore {
this.#fontA = undefined;
this.#fontB = undefined;
this.#fontsByIdsStore.setIds([]);
storage.clear();
this.#storage.clear();
this.#typography.reset();
}
/**
* Disposes reactive effects and the persistent store. Call on teardown.
*/
destroy() {
this.#disposeEffects();
this.#storage.destroy();
}
}
let _comparisonStore: ComparisonStore | undefined;
const comparisonStore = createSingleton(
() => new ComparisonStore(),
instance => {
instance.resetAll();
instance.destroy();
},
);
export function getComparisonStore(): ComparisonStore {
return (_comparisonStore ??= new ComparisonStore());
}
export const getComparisonStore = comparisonStore.get;
// test-only reset, so specs don't share a live observer
export function __resetComparisonStore() {
_comparisonStore?.resetAll();
_comparisonStore = undefined;
}
// test-only reset, so specs don't share a live observer or persisted state
export const __resetComparisonStore = comparisonStore.reset;
@@ -11,7 +11,9 @@
import type { UnifiedFont } from '$entities/Font';
import { UNIFIED_FONTS } from '$entities/Font/testing';
import { queryClient } from '$shared/api/queryClient';
import { getQueryClient } from '$shared/api/queryClient';
const queryClient = getQueryClient();
import {
beforeEach,
describe,
@@ -44,6 +46,8 @@ const mockStorage = vi.hoisted(() => {
configurable: true,
});
storage.destroy = vi.fn();
return storage;
});
@@ -82,6 +86,12 @@ vi.mock('$entities/Font/model', async importOriginal => {
};
});
const mockTypography = vi.hoisted(() => ({
weight: 400,
renderedSize: 48,
reset: vi.fn(),
}));
vi.mock('$features/AdjustTypography', () => ({
DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [],
createTypographyControlManager: vi.fn(() => ({
@@ -89,15 +99,6 @@ vi.mock('$features/AdjustTypography', () => ({
renderedSize: 48,
reset: vi.fn(),
})),
}));
const mockTypography = vi.hoisted(() => ({
weight: 400,
renderedSize: 48,
reset: vi.fn(),
}));
vi.mock('$features/AdjustTypography/model', () => ({
getTypographySettingsStore: () => mockTypography,
}));
@@ -9,11 +9,9 @@ import {
FontVirtualList,
type UnifiedFont,
VIRTUAL_INDEX_NOT_LOADED,
} from '$entities/Font';
import {
getFontCatalog,
getFontLifecycleManager,
} from '$entities/Font/model';
} from '$entities/Font';
import { getSkeletonWidth } from '$shared/lib/utils';
import {
Button,
+18 -39
View File
@@ -1,7 +1,8 @@
<!--
Component: Line
Renders one laid-out line as three regions: a fontA bulk run (past the
slider), an N-char crossfade window straddling it, and a fontB bulk run (not
slider), a crossfade window straddling it (its size derived per line from
the line's grapheme count via `windowSizeForLine`), and a fontB bulk run (not
yet past). Bulk runs are native shaped text (kerning, ligatures); only the
window uses per-char DOM. `split` is a primitive so the render-model
`$derived` skips recomputation on ticks that leave it unchanged.
@@ -10,10 +11,13 @@
import {
type ComparisonLine,
computeLineRenderModel,
windowSizeForLine,
} from '$entities/Font';
import { getTypographySettingsStore } from '$features/AdjustTypography';
import { computeStrutHeight } from '../../lib';
import { getComparisonStore } from '../../model';
import Character from '../Character/Character.svelte';
import SettledText from '../SettledText/SettledText.svelte';
interface Props {
/**
@@ -24,16 +28,14 @@ interface Props {
* Count of chars the slider has passed, from `findSplitIndex`.
*/
split: number;
/**
* Number of chars in the crossfade window around the split.
*/
windowSize: number;
}
let { line, split, windowSize }: Props = $props();
let { line, split }: Props = $props();
const comparisonStore = getComparisonStore();
const windowSize = $derived(windowSizeForLine(line.chars.length));
const model = $derived(computeLineRenderModel(line, split, windowSize));
const typography = getTypographySettingsStore();
@@ -44,36 +46,9 @@ const fontSizePx = $derived(typography.renderedSize);
const lineHeightPx = $derived(typography.height * typography.renderedSize);
const letterSpacingPx = $derived(typography.spacing * typography.renderedSize);
/**
* Class and style are single short bindings so the formatter keeps
* `<span ...>{text}</span>` on one line. A wrapped text expression would leak
* its indentation into the span content under `white-space: pre`.
*/
const BULK_LEFT_CLASS =
'inline-block align-baseline leading-none text-swiss-black/75 dark:text-brand/75 transition-colors duration-300';
const BULK_RIGHT_CLASS =
'inline-block align-baseline leading-none text-neutral-950 dark:text-white transition-colors duration-300';
const leftStyle = $derived(`font-family:${fontA?.name ?? ''};font-size:${fontSizePx}px`);
const rightStyle = $derived(`font-family:${fontB?.name ?? ''};font-size:${fontSizePx}px`);
/**
* Stops the whole line from jumping up or down as the slider moves. The browser
* pins a line box's baseline to its tallest inline box, so without a fixed
* reference the baseline (and every glyph) shifts the moment a bulk run appears
* or disappears, or the last window char morphs to a font with a taller ascent.
* This invisible strut is always the tallest box — `overflow: hidden` puts its
* baseline at its bottom edge — so it owns the line baseline and holds it still.
* Its height also sets the text's vertical position (the container is block, so
* nothing else centers it).
*
* Height factors are empirical: the first term centers the text, the `* 1.1`
* floor keeps the strut above the fonts' ascent at tight line-heights.
*/
const strutHeightPx = $derived(Math.max(lineHeightPx / 2 + fontSizePx * 0.34, fontSizePx * 1.1));
const strutStyle = $derived(
`display:inline-block;width:0;overflow:hidden;vertical-align:baseline;height:${strutHeightPx}px`,
);
// Invisible strut that pins the line baseline so glyphs don't jump as the
// slider moves; `computeStrutHeight` explains the why and the formula.
const strutHeightPx = $derived(computeStrutHeight(lineHeightPx, fontSizePx));
</script>
<!--
@@ -91,15 +66,19 @@ const strutStyle = $derived(
style:letter-spacing="{letterSpacingPx}px"
style:font-weight={typography.weight}
>
<span style={strutStyle} aria-hidden="true"></span>
<span
class="inline-block w-0 overflow-hidden align-baseline"
style:height="{strutHeightPx}px"
aria-hidden="true"
></span>
{#if model.leftText}
<span class={BULK_LEFT_CLASS} style={leftStyle}>{model.leftText}</span>
<SettledText text={model.leftText} fontFamily={fontA.name} fontSize={fontSizePx} side="left" />
{/if}
{#each model.windowChars as wc (wc.key)}
<Character char={wc.char} {fontA} {fontB} isPast={wc.isPast} fontSize={fontSizePx} />
{/each}
{#if model.rightText}
<span class={BULK_RIGHT_CLASS} style={rightStyle}>{model.rightText}</span>
<SettledText text={model.rightText} fontFamily={fontB.name} fontSize={fontSizePx} side="right" />
{/if}
</div>
{/if}
@@ -0,0 +1,37 @@
<!--
Component: SettledText
One side's text settled in a single font — left is fontA the slider has
passed, right is fontB not yet reached. A native shaped run (kerning,
ligatures); the crossfading middle uses per-char Character cells instead.
-->
<script lang="ts">
interface Props {
/**
* Run text.
*/
text: string;
/**
* CSS font-family name.
*/
fontFamily: string;
/**
* Font size in px.
*/
fontSize: number;
/**
* Window side — selects the color treatment.
*/
side: 'left' | 'right';
}
let { text, fontFamily, fontSize, side }: Props = $props();
// Left (fontA, passed) is dimmed; right (fontB, pending) is full-strength.
const SIDE_CLASS: Record<Props['side'], string> = {
left:
'inline-block align-baseline leading-none text-swiss-black/75 dark:text-brand/75 transition-colors duration-300',
right: 'inline-block align-baseline leading-none text-neutral-950 dark:text-white transition-colors duration-300',
};
</script>
<span class={SIDE_CLASS[side]} style:font-family="'{fontFamily}'" style:font-size="{fontSize}px">{text}</span>
@@ -52,7 +52,6 @@ const side = $derived<Side>(comparisonStore.side);
className,
)}
>
<!-- ── Header: title + A/B toggle ────────────────────────────────── -->
<div
class="
p-6 shrink-0
@@ -96,14 +95,13 @@ const side = $derived<Side>(comparisonStore.side);
</ButtonGroup>
</div>
<!-- ── Main: content area (no scroll - VirtualList handles scrolling) ─────────────────────────────── -->
<!-- No scroll here; VirtualList handles scrolling -->
<div class="flex-1 min-h-0 surface-canvas">
{#if main}
{@render main()}
{/if}
</div>
<!-- ── Bottom: fixed controls ─────────────────────────────────────── -->
{#if controls}
<div
class="
@@ -107,12 +107,6 @@ const layout = new DualFontLayout();
let layoutResult = $state<ComparisonResult>({ lines: [], totalHeight: 0 });
/**
* N-window size for the per-char crossfade zone around the slider split.
* Tuned so chars complete their 100ms opacity crossfade before exiting the window.
*/
const WINDOW_SIZE = 5;
// Track container width changes (window resize, sidebar toggle, etc.)
$effect(() => {
if (!container) {
@@ -344,7 +338,7 @@ $effect(() => {
>
{#each layoutResult.lines as line, lineIdx (lineIdx)}
{@const split = findSplitIndex(line, sliderPos, containerWidth)}
<Line {line} {split} windowSize={WINDOW_SIZE} />
<Line {line} {split} />
{/each}
</div>
@@ -2,12 +2,8 @@ import {
render,
screen,
} from '@testing-library/svelte';
import { setContext } from 'svelte';
import Footer from './Footer.svelte';
// Mock component to provide context
import ContextWrapper from '$shared/lib/providers/ResponsiveProvider/ResponsiveProvider.svelte';
describe('Footer', () => {
const currentYear = new Date().getFullYear();
@@ -18,7 +14,7 @@ describe('Footer', () => {
isDesktopLarge: false,
};
const { container } = render(Footer, {
render(Footer, {
context: new Map([['responsive', mockResponsive]]),
});
+1 -1
View File
@@ -1,2 +1,2 @@
export { layoutManager } from './stores';
export { getLayoutManager } from './stores';
export type { LayoutMode } from './stores';
+1 -1
View File
@@ -1,2 +1,2 @@
export { layoutManager } from './layoutStore/layoutStore.svelte';
export { getLayoutManager } from './layoutStore/layoutStore.svelte';
export type { LayoutMode } from './layoutStore/layoutStore.svelte';
@@ -16,8 +16,11 @@
* - Desktop Large (>= 1280px): 4 columns
*/
import { createPersistentStore } from '$shared/lib';
import { responsiveManager } from '$shared/lib';
import {
createPersistentStore,
createSingleton,
responsiveManager,
} from '$shared/lib';
export type LayoutMode = 'list' | 'grid';
@@ -144,12 +147,25 @@ class LayoutManager {
this.#mode = DEFAULT_CONFIG.mode;
this.#store.clear();
}
/**
* Dispose the persistent store's save effect. Call on store disposal.
*/
destroy(): void {
this.#store.destroy();
}
}
/**
* Singleton layout manager instance
* App-wide layout manager, created on first access. Lazy so its persisted
* layout preference isn't read at module load.
*/
export const layoutManager = new LayoutManager();
const layoutManager = createSingleton(() => new LayoutManager(), instance => instance.destroy());
export const getLayoutManager = layoutManager.get;
// test-only reset, so specs don't share persisted layout state
export const __resetLayoutManager = layoutManager.reset;
// Export class for testing purposes
export { LayoutManager };

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