67 Commits

Author SHA1 Message Date
Ilia Mashkov 5ace4aee07 feat(Board): add CandidateCard mini switcher 2026-06-24 15:33:33 +03:00
Ilia Mashkov f3a2a6a7bd feat(Board): add keyboard and swipe focal cycling 2026-06-24 15:31:38 +03:00
Ilia Mashkov 118c588859 feat(Board): add constant-size FocalFrame 2026-06-24 15:29:52 +03:00
Ilia Mashkov 59097ca9ad feat(Board): add always-editable RoleField specimen input 2026-06-24 15:27:20 +03:00
Ilia Mashkov 738ed3b4ed feat(CompareBoard): measure focal frame height and expose slice public API 2026-06-24 15:23:35 +03:00
Ilia Mashkov 132d1327f5 feat(CompareBoard): orchestrate font preloading, pinning, and default seeding 2026-06-24 14:59:17 +03:00
Ilia Mashkov 92ea7b9dc4 feat(CompareBoard): add board store with cycling, persistence, and typography seam 2026-06-24 14:48:29 +03:00
Ilia Mashkov e55e713517 feat(CompareBoard): add board constants and storage schema 2026-06-24 13:49:24 +03:00
Ilia Mashkov f49180e83d feat(CompareBoard): add fitColumns honest column gating 2026-06-24 13:49:24 +03:00
Ilia Mashkov 2c3d88c81f feat(CompareBoard): add measureRoleHeight via Pretext line count 2026-06-24 13:49:23 +03:00
Ilia Mashkov 0e9288c295 feat(shared): add ensureCanvasFonts canvas-warm helper 2026-06-24 13:49:13 +03:00
Ilia Mashkov dbd48b287d feat(shared): add getPretextFontString formatter 2026-06-24 13:49:02 +03:00
Ilia Mashkov f29e0b0c7c feat(Pairing): add nextFocalId cycle math and expose slice API 2026-06-24 13:48:50 +03:00
Ilia Mashkov 91bb046339 feat(Pairing): add createPairing id factory 2026-06-24 13:21:30 +03:00
Ilia Mashkov f680fe01ea feat(Pairing): add comboKey natural-key derivation 2026-06-24 13:21:29 +03:00
Ilia Mashkov d37d01e6d8 feat(Pairing): add Pairing and Role types 2026-06-24 13:21:29 +03:00
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
152 changed files with 4473 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,
};
@@ -0,0 +1,20 @@
import {
describe,
expect,
it,
} from 'vitest';
import { comboKey } from './comboKey';
describe('comboKey', () => {
it('derives a key from the two font ids', () => {
expect(comboKey({ id: 'x', headerFontId: 'Inter', bodyFontId: 'Lora' })).toBe('Inter|Lora');
});
it('ignores the surrogate id (content not identity)', () => {
const a = comboKey({ id: 'a', headerFontId: 'Inter', bodyFontId: 'Lora' });
const b = comboKey({ id: 'b', headerFontId: 'Inter', bodyFontId: 'Lora' });
expect(a).toBe(b);
});
it('is order-sensitive on role', () => {
expect(comboKey({ id: 'x', headerFontId: 'Lora', bodyFontId: 'Inter' })).toBe('Lora|Inter');
});
});
@@ -0,0 +1,13 @@
import type { Pairing } from '../types';
/**
* Natural key describing a Pairing's current fonts (not its identity).
* Used for URL share-encoding and "is this combo already on the board" checks.
* Recomputed on swap; two cards may share a comboKey but never an id.
*
* @param pairing - The pairing whose fonts form the key (its `id` is ignored).
* @returns The `headerFontId|bodyFontId` key.
*/
export function comboKey(pairing: Pairing): string {
return `${pairing.headerFontId}|${pairing.bodyFontId}`;
}
@@ -0,0 +1,22 @@
import {
describe,
expect,
it,
} from 'vitest';
import { createPairing } from './createPairing';
describe('createPairing', () => {
it('builds a pairing from two font ids', () => {
const p = createPairing('Inter', 'Lora');
expect(p.headerFontId).toBe('Inter');
expect(p.bodyFontId).toBe('Lora');
});
it('generates a unique id each call (duplicates stay distinct)', () => {
const a = createPairing('Inter', 'Lora');
const b = createPairing('Inter', 'Lora');
expect(a.id).not.toBe(b.id);
});
it('accepts an explicit id for rehydration', () => {
expect(createPairing('Inter', 'Lora', 'fixed-id').id).toBe('fixed-id');
});
});
@@ -0,0 +1,15 @@
import type { Pairing } from '../types';
/**
* Creates a Pairing with a fresh surrogate id (or a supplied one when
* rehydrating from storage). The id is identity, never content — two pairings
* with the same fonts are still distinct cards.
*
* @param headerFontId - Font entity id for the header role.
* @param bodyFontId - Font entity id for the body role.
* @param id - Explicit id for rehydration; defaults to a fresh UUID.
* @returns The new Pairing.
*/
export function createPairing(headerFontId: string, bodyFontId: string, id: string = crypto.randomUUID()): Pairing {
return { id, headerFontId, bodyFontId };
}
+3
View File
@@ -0,0 +1,3 @@
export { comboKey } from './comboKey/comboKey';
export { createPairing } from './createPairing/createPairing';
export { nextFocalId } from './nextFocalId/nextFocalId';
@@ -0,0 +1,32 @@
import {
describe,
expect,
it,
} from 'vitest';
import { nextFocalId } from './nextFocalId';
const ids = ['a', 'b', 'c'];
describe('nextFocalId', () => {
it('steps forward', () => {
expect(nextFocalId(ids, 'a', 1)).toBe('b');
});
it('steps backward', () => {
expect(nextFocalId(ids, 'b', -1)).toBe('a');
});
it('wraps forward at the end', () => {
expect(nextFocalId(ids, 'c', 1)).toBe('a');
});
it('wraps backward at the start', () => {
expect(nextFocalId(ids, 'a', -1)).toBe('c');
});
it('returns the only id when list has one', () => {
expect(nextFocalId(['solo'], 'solo', 1)).toBe('solo');
});
it('returns current when focal id is absent', () => {
expect(nextFocalId(ids, 'missing', 1)).toBe('missing');
});
it('returns null for an empty list', () => {
expect(nextFocalId([], 'x', 1)).toBeNull();
});
});
@@ -0,0 +1,21 @@
/**
* The id one step from `currentId` in board order, wrapping at both ends.
*
* @param orderedIds - Pairing ids in board order.
* @param currentId - The currently focal id to step from.
* @param direction - +1 for next, -1 for previous.
* @returns The neighbouring id (wrapped), `currentId` unchanged if it isn't in
* the list, or null for an empty list.
*/
export function nextFocalId(orderedIds: string[], currentId: string, direction: 1 | -1): string | null {
if (orderedIds.length === 0) {
return null;
}
const i = orderedIds.indexOf(currentId);
if (i === -1) {
return currentId;
}
const len = orderedIds.length;
const next = (i + direction + len) % len;
return orderedIds[next];
}
@@ -0,0 +1,4 @@
export type {
Pairing,
Role,
} from './pairing';
@@ -0,0 +1,26 @@
/**
* A slot within a Pairing that a font fills.
*/
export type Role = 'header' | 'body';
/**
* The atomic unit of comparison: a header font + a body font.
* Carries a surrogate `id` (stable for the card's life, never tracks content)
* and the two font ids it pairs. Text and typography are global to the Board,
* not stored here.
*/
export interface Pairing {
/**
* Surrogate key generated at creation, stable for the card's life.
* Distinguishes duplicates with identical fonts. Focal/cycling key on this.
*/
id: string;
/**
* Font entity id filling the header role.
*/
headerFontId: string;
/**
* Font entity id filling the body role.
*/
bodyFontId: string;
}
+9
View File
@@ -0,0 +1,9 @@
export {
comboKey,
createPairing,
nextFocalId,
} from './domain';
export type {
Pairing,
Role,
} from './model/types';
@@ -0,0 +1,4 @@
export type {
Pairing,
Role,
} from './pairing';
@@ -0,0 +1,9 @@
/**
* Re-export of the Pairing identity types. The source of truth lives in
* `domain/types` so the pure domain segment can reference them without importing
* `model` (FSD+ domain isolation: ui -> model -> domain, never back).
*/
export type {
Pairing,
Role,
} from '../../domain/types';
@@ -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
+9
View File
@@ -0,0 +1,9 @@
export { fitColumns } from './lib';
export {
__resetBoard,
type BoardStore,
FRAME_ROLE_GAP,
getBoard,
MAX_COLUMNS,
type RoleTypography,
} from './model';
+8
View File
@@ -0,0 +1,8 @@
export {
combineFrameHeight,
type CombineFrameHeightInput,
fitColumns,
type FitColumnsInput,
measureRoleHeight,
type RoleHeightInput,
} from './measure';
@@ -0,0 +1,19 @@
import {
describe,
expect,
it,
} from 'vitest';
import { combineFrameHeight } from './combineFrameHeight';
describe('combineFrameHeight', () => {
it('sums header + gap + body block heights', () => {
expect(combineFrameHeight({ headerHeight: 60, bodyHeight: 200, gap: 24 })).toBe(284);
});
it('omits the gap when one block is empty (zero height)', () => {
expect(combineFrameHeight({ headerHeight: 0, bodyHeight: 200, gap: 24 })).toBe(200);
expect(combineFrameHeight({ headerHeight: 60, bodyHeight: 0, gap: 24 })).toBe(60);
});
it('is zero when both blocks are empty', () => {
expect(combineFrameHeight({ headerHeight: 0, bodyHeight: 0, gap: 24 })).toBe(0);
});
});
@@ -0,0 +1,30 @@
/**
* Inputs for combining a frame's two role blocks into one height.
*/
export interface CombineFrameHeightInput {
/**
* Measured header block height in px.
*/
headerHeight: number;
/**
* Measured body block height in px.
*/
bodyHeight: number;
/**
* Gap in px between the header and body blocks.
*/
gap: number;
}
/**
* Total focal-frame height: header block + gap + body block. The gap only
* applies when both blocks have height — an empty role (no specimen text)
* contributes neither height nor a dangling gap.
*
* @param input - The two block heights and the inter-block gap.
* @returns The combined frame height in px.
*/
export function combineFrameHeight({ headerHeight, bodyHeight, gap }: CombineFrameHeightInput): number {
const gapApplies = headerHeight > 0 && bodyHeight > 0;
return headerHeight + bodyHeight + (gapApplies ? gap : 0);
}
@@ -0,0 +1,28 @@
import {
describe,
expect,
it,
} from 'vitest';
import { fitColumns } from './fitColumns';
describe('fitColumns', () => {
it('packs as many honest columns as fit, gap-aware', () => {
// each needs 600, gap 40, available 1280 -> 1 col=600, 2 cols=1240, 3=1880
expect(fitColumns({ naturalWidth: 600, available: 1280, gap: 40, maxColumns: 3 })).toBe(2);
});
it('never exceeds maxColumns even with room', () => {
expect(fitColumns({ naturalWidth: 100, available: 5000, gap: 20, maxColumns: 3 })).toBe(3);
});
it('never returns less than 1', () => {
expect(fitColumns({ naturalWidth: 9000, available: 300, gap: 20, maxColumns: 3 })).toBe(1);
});
it('fits a column at the exact boundary (inclusive)', () => {
// 2 cols: 2*600 + 1*40 = 1240 == available -> fits
expect(fitColumns({ naturalWidth: 600, available: 1240, gap: 40, maxColumns: 3 })).toBe(2);
// one px short -> only 1
expect(fitColumns({ naturalWidth: 600, available: 1239, gap: 40, maxColumns: 3 })).toBe(1);
});
it('respects a maxColumns of 1 even with unlimited room', () => {
expect(fitColumns({ naturalWidth: 100, available: 5000, gap: 20, maxColumns: 1 })).toBe(1);
});
});
@@ -0,0 +1,41 @@
/**
* Inputs for column gating.
*/
export interface FitColumnsInput {
/**
* The widest pairing's Pretext natural (shrink-wrap) width in px.
*/
naturalWidth: number;
/**
* Total available width in px for the columns row.
*/
available: number;
/**
* Gap in px between columns.
*/
gap: number;
/**
* Hard cap on columns that still preserve an honest measure (23).
*/
maxColumns: number;
}
/**
* How many equal honest columns fit. Uses the real per-pairing required width
* (Pretext shrink-wrap) — the 4575ch rule is only a fallback bound elsewhere.
* `n` columns occupy `n*naturalWidth + (n-1)*gap`. Clamped to [1, maxColumns].
*
* @param input - Natural width, available width, gap, and column cap.
* @returns The number of columns that fit, in [1, maxColumns].
*/
export function fitColumns({ naturalWidth, available, gap, maxColumns }: FitColumnsInput): number {
let fit = 1;
for (let n = 2; n <= maxColumns; n++) {
if (n * naturalWidth + (n - 1) * gap <= available) {
fit = n;
} else {
break;
}
}
return fit;
}
@@ -0,0 +1,12 @@
export {
combineFrameHeight,
type CombineFrameHeightInput,
} from './combineFrameHeight';
export {
fitColumns,
type FitColumnsInput,
} from './fitColumns';
export {
measureRoleHeight,
type RoleHeightInput,
} from './measureFrameHeight';
@@ -0,0 +1,32 @@
import {
describe,
expect,
it,
vi,
} from 'vitest';
import { measureRoleHeight } from './measureFrameHeight';
describe('measureRoleHeight', () => {
it('multiplies pretext line count by sizePx*lineHeight', () => {
const layout = vi.fn().mockReturnValue({ lineCount: 3, height: 0 });
const prepared = {} as never;
// 3 lines * 20px * 1.5 = 90
expect(measureRoleHeight({ prepared, maxWidth: 600, sizePx: 20, lineHeight: 1.5 }, layout)).toBe(90);
});
it('passes width and pixel line-height into pretext layout', () => {
const layout = vi.fn().mockReturnValue({ lineCount: 1, height: 0 });
measureRoleHeight({ prepared: {} as never, maxWidth: 600, sizePx: 16, lineHeight: 1.25 }, layout);
expect(layout).toHaveBeenCalledWith(expect.anything(), 600, 16 * 1.25);
});
it('returns 0 when the text lays out to zero lines (empty specimen)', () => {
const layout = vi.fn().mockReturnValue({ lineCount: 0, height: 0 });
expect(measureRoleHeight({ prepared: {} as never, maxWidth: 600, sizePx: 16, lineHeight: 1.5 }, layout))
.toBe(0);
});
it('handles fractional sizes and line-heights without rounding', () => {
const layout = vi.fn().mockReturnValue({ lineCount: 2, height: 0 });
// 2 * 15.5 * 1.4 = 43.4
expect(measureRoleHeight({ prepared: {} as never, maxWidth: 320, sizePx: 15.5, lineHeight: 1.4 }, layout))
.toBeCloseTo(43.4);
});
});
@@ -0,0 +1,46 @@
import {
type PreparedText,
layout as pretextLayout,
} from '@chenglou/pretext';
/**
* Inputs for measuring one role block's rendered height.
*/
export interface RoleHeightInput {
/**
* Pretext-prepared specimen text for this role+font.
*/
prepared: PreparedText;
/**
* Available width in px (the focal frame's content width).
*/
maxWidth: number;
/**
* Resolved font-size in px.
*/
sizePx: number;
/**
* Unitless line-height multiplier.
*/
lineHeight: number;
}
/**
* Height in px of a role's text block at the given width, from Pretext's
* pure-arithmetic line count.
*
* Height is `lineCount * sizePx * lineHeight` rather than Pretext's own
* `height` so it tracks the CSS box model exactly (line-height as a multiple of
* font-size), keeping measurement and render in lockstep — the zero-shift
* invariant.
*
* @param input - Prepared text plus width and resolved type metrics.
* @param layout - Pretext layout fn; injectable for unit tests, defaults to
* `@chenglou/pretext`'s `layout`.
* @returns The block height in px.
*/
export function measureRoleHeight(input: RoleHeightInput, layout = pretextLayout): number {
const { prepared, maxWidth, sizePx, lineHeight } = input;
const { lineCount } = layout(prepared, maxWidth, sizePx * lineHeight);
return lineCount * sizePx * lineHeight;
}
@@ -0,0 +1,42 @@
/**
* localStorage key for the persisted board (pairings + focal + specimen).
*/
export const BOARD_STORAGE_KEY = 'glyphdiff:board';
/**
* Per-role typography storage key — header AdjustTypography instance.
*/
export const HEADER_TYPO_KEY = 'glyphdiff:typo:header';
/**
* Per-role typography storage key — body AdjustTypography instance.
*/
export const BODY_TYPO_KEY = 'glyphdiff:typo:body';
/**
* Schema version stamped into persisted board state (gates future
* migrations / the URL share-state codec).
*/
export const BOARD_SCHEMA_VERSION = 1;
/**
* Hard cap on side-by-side columns that still preserve an honest measure.
*/
export const MAX_COLUMNS = 3;
/**
* Vertical gap in px between the header block and the body block within a frame.
* Used by frame-height measurement so the reserved height matches the rendered
* layout exactly (zero-shift).
*/
export const FRAME_ROLE_GAP = 24;
/**
* Default shared specimen — one header line + one body paragraph (single
* language). Used to seed the board and as the share-state fallback.
*/
export const DEFAULT_SPECIMEN = {
header: 'The Art of Harmonious Type',
body:
'Good typography is invisible. It guides the eye without calling attention to itself, balancing rhythm, contrast, and proportion so the reader forgets there is a typeface at all and simply reads.',
};
+10
View File
@@ -0,0 +1,10 @@
export {
FRAME_ROLE_GAP,
MAX_COLUMNS,
} from './const/const';
export {
__resetBoard,
type BoardStore,
getBoard,
type RoleTypography,
} from './store/boardStore/boardStore.svelte';
@@ -0,0 +1,223 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
/**
* Font orchestration is exercised in e2e (needs a real browser/queryClient).
* Here we stub the entity's font stores so the board's pure logic stays testable
* off the network — only `candidateFontIds` derivation is asserted at this level.
*/
const mockLifecycle = vi.hoisted(() => ({
touch: vi.fn(),
pin: vi.fn(),
unpin: vi.fn(),
}));
/**
* Catalog stub with four fonts so the seeding effect has material to pair.
* Seeding only fires when storage is empty AND nothing has been added yet, so
* the empty/add tests (which never flush before asserting) are unaffected.
*/
const mockCatalog = vi.hoisted(() => ({
fonts: [
{ id: 'c0', name: 'C0' },
{ id: 'c1', name: 'C1' },
{ id: 'c2', name: 'C2' },
{ id: 'c3', name: 'C3' },
],
}));
/** Mutable resolved-font list the stubbed FontsByIdsStore returns; reset per test. */
const mockFonts = vi.hoisted(() => [] as { id: string; name: string }[]);
vi.mock('$entities/Font', async importOriginal => {
const actual = await importOriginal<typeof import('$entities/Font')>();
class MockFontsByIdsStore {
setIds() {}
get fonts() {
return mockFonts;
}
get isLoading() {
return false;
}
destroy() {}
}
return {
...actual,
FontsByIdsStore: MockFontsByIdsStore,
getFontLifecycleManager: () => mockLifecycle,
getFontCatalog: () => mockCatalog,
getFontUrl: () => 'https://example.com/font.woff2',
};
});
// ensureCanvasFonts needs a real browser canvas; stub it to resolve immediately.
// Spread actual so createPersistentStore/getPretextFontString stay real.
vi.mock('$shared/lib', async importOriginal => {
const actual = await importOriginal<typeof import('$shared/lib')>();
return { ...actual, ensureCanvasFonts: vi.fn(() => Promise.resolve()) };
});
// Pretext measures via canvas (degenerate in jsdom); stub for deterministic lines.
vi.mock('@chenglou/pretext', () => ({
prepareWithSegments: vi.fn(() => ({})),
layout: vi.fn(() => ({ lineCount: 2, height: 0 })),
}));
import { flushSync } from 'svelte';
import {
__resetBoard,
getBoard,
} from './boardStore.svelte';
beforeEach(() => {
localStorage.clear();
mockFonts.length = 0;
__resetBoard();
});
afterEach(() => __resetBoard());
describe('boardStore', () => {
it('starts empty with no focal', () => {
const board = getBoard();
expect(board.pairings).toEqual([]);
expect(board.focalId).toBeNull();
});
it('adds a pairing and makes the first one focal', () => {
const board = getBoard();
const p = board.addPairing('Inter', 'Lora');
expect(board.pairings).toHaveLength(1);
expect(board.focalId).toBe(p.id);
expect(board.focal).toEqual(p);
});
it('cycles focal forward with wrap', () => {
const board = getBoard();
const a = board.addPairing('Inter', 'Lora');
const b = board.addPairing('Roboto', 'Merriweather');
board.setFocal(a.id);
board.cycle(1);
expect(board.focalId).toBe(b.id);
board.cycle(1);
expect(board.focalId).toBe(a.id);
});
it('cycles focal backward with wrap', () => {
const board = getBoard();
const a = board.addPairing('Inter', 'Lora');
const b = board.addPairing('Roboto', 'Merriweather');
board.setFocal(a.id);
board.cycle(-1);
expect(board.focalId).toBe(b.id);
});
it('empties the board and clears focal when the last pairing is removed', () => {
const board = getBoard();
const a = board.addPairing('Inter', 'Lora');
board.removePairing(a.id);
expect(board.pairings).toEqual([]);
expect(board.focalId).toBeNull();
});
it('duplicates a pairing as a distinct card next to the source', () => {
const board = getBoard();
const a = board.addPairing('Inter', 'Lora');
const dup = board.duplicate(a.id);
expect(dup.id).not.toBe(a.id);
expect(dup.headerFontId).toBe('Inter');
expect(board.pairings[1].id).toBe(dup.id);
});
it('swaps one role on the focal pairing', () => {
const board = getBoard();
const a = board.addPairing('Inter', 'Lora');
board.swapFont(a.id, 'body', 'Merriweather');
expect(board.focal?.bodyFontId).toBe('Merriweather');
});
it('rewrites the shared specimen (global, not per-pairing)', () => {
const board = getBoard();
board.addPairing('Inter', 'Lora');
board.setSpecimen('header', 'New Header');
expect(board.specimen.header).toBe('New Header');
});
it('keeps a focal when the focal pairing is removed', () => {
const board = getBoard();
const a = board.addPairing('Inter', 'Lora');
const b = board.addPairing('Roboto', 'Merriweather');
board.setFocal(a.id);
board.removePairing(a.id);
expect(board.pairings).toHaveLength(1);
expect(board.focalId).toBe(b.id);
});
it('seeds curated pairings from the catalog when storage is empty', () => {
const board = getBoard();
flushSync(); // let the seed effect run
expect(board.pairings.length).toBeGreaterThan(0);
expect(board.focalId).not.toBeNull();
});
it('does not seed when storage already has pairings', () => {
// pre-seed storage so a fresh board rehydrates instead of seeding
const first = getBoard();
const p = first.addPairing('Inter', 'Lora');
__resetBoard();
const restored = getBoard();
flushSync();
expect(restored.pairings).toHaveLength(1);
expect(restored.pairings[0].id).toBe(p.id);
});
it('returns fallback 0 before warm, positive height once fonts resolve and warm', async () => {
mockFonts.push({ id: 'Inter', name: 'Inter' }, { id: 'Lora', name: 'Lora' });
const board = getBoard();
const p = board.addPairing('Inter', 'Lora');
// Cold: canvas not yet warm -> reserved fallback, never a cold measure.
expect(board.frameHeight(p.id, 600)).toBe(0);
await vi.waitFor(() => expect(board.measureReady).toBe(true));
expect(board.frameHeight(p.id, 600)).toBeGreaterThan(0);
});
it('collects every distinct candidate font id for preloading', () => {
const board = getBoard();
board.addPairing('Inter', 'Lora');
board.addPairing('Inter', 'Merriweather'); // Inter deduped
expect(new Set(board.candidateFontIds)).toEqual(new Set(['Inter', 'Lora', 'Merriweather']));
});
it('exposes default per-role typography', () => {
const board = getBoard();
expect(board.typo.header.size).toBeGreaterThan(0);
expect(board.typo.header.weight).toBeGreaterThan(0);
expect(board.typo.body.leading).toBeGreaterThan(0);
});
it('sets one role typography independently via setTypo', () => {
const board = getBoard();
board.setTypo('header', { size: 64, weight: 700, leading: 1.1, tracking: -0.02 });
expect(board.typo.header.size).toBe(64);
expect(board.typo.header.weight).toBe(700);
// body untouched
expect(board.typo.body.size).not.toBe(64);
});
it('persists and rehydrates pairings, focal, and specimen', () => {
const board = getBoard();
const a = board.addPairing('Inter', 'Lora');
board.setSpecimen('body', 'Persisted body');
__resetBoard();
const restored = getBoard();
expect(restored.pairings).toHaveLength(1);
expect(restored.pairings[0].id).toBe(a.id);
expect(restored.focalId).toBe(a.id);
expect(restored.specimen.body).toBe('Persisted body');
});
});
@@ -0,0 +1,657 @@
/**
* CompareBoard store — the board singleton.
*
* Owns the comparison board's business state: the ordered list of Pairings, the
* single focal pairing, and the board-global specimen text (header + body).
* Persists to localStorage as a compact, URL-encoding-friendly blob.
*
* Typography is NOT owned here as an AdjustTypography store (features can't
* import sibling features). Instead the board holds plain per-role typography
* values, fed in via `setTypo` by `widgets/Board` (dependency inversion).
*
* Font metadata is resolved + preloaded via the Font entity (candidate
* preloading, focal pinning). Frame heights are Pretext-measured behind a
* canvas warm-gate (the zero-shift core) and memoized for flicker-free cycling.
*/
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
type FontCatalogStore,
type FontLifecycleManager,
type FontLoadRequestConfig,
FontsByIdsStore,
type UnifiedFont,
getFontCatalog,
getFontLifecycleManager,
getFontUrl,
} from '$entities/Font';
import {
type Pairing,
type Role,
comboKey,
createPairing,
nextFocalId,
} from '$entities/Pairing';
import {
combineFrameHeight,
measureRoleHeight,
} from '$features/CompareBoard/lib/measure';
import {
BOARD_SCHEMA_VERSION,
BOARD_STORAGE_KEY,
DEFAULT_SPECIMEN,
FRAME_ROLE_GAP,
} from '$features/CompareBoard/model/const/const';
import {
createPersistentStore,
createSingleton,
ensureCanvasFonts,
getPretextFontString,
} from '$shared/lib';
import { prepareWithSegments } from '@chenglou/pretext';
import {
flushSync,
untrack,
} from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
/**
* Compact persisted board shape. Font ids are abbreviated (`h`/`b`) to keep the
* blob small and URL-encoding-friendly; specimen text is in localStorage but is
* intentionally excluded from any future URL share.
*/
interface PersistedBoard {
/**
* Schema version (gates migrations / the future URL codec).
*/
v: number;
/**
* Pairings in board order: surrogate id + the two font ids.
*/
pairings: { id: string; h: string; b: string }[];
/**
* The focal pairing's id, or null when the board is empty.
*/
focalId: string | null;
/**
* Board-global specimen text.
*/
specimen: { header: string; body: string };
}
const emptyBoard = (): PersistedBoard => ({
v: BOARD_SCHEMA_VERSION,
pairings: [],
focalId: null,
specimen: { ...DEFAULT_SPECIMEN },
});
/**
* Plain per-role typography values the board renders and measures with. Mirrors
* the four axes an `AdjustTypography` store exposes, but as a framework-free
* value shape the board owns — the inversion seam (`widgets/Board` pushes the
* concrete store's values in via `setTypo`). Not persisted here: the
* AdjustTypography stores own typography persistence.
*/
export interface RoleTypography {
/**
* Font size in px (honest, absolute — no responsive multiplier).
*/
size: number;
/**
* Numeric font weight (100900).
*/
weight: number;
/**
* Unitless line-height multiplier.
*/
leading: number;
/**
* Letter spacing in px.
*/
tracking: number;
}
const defaultRoleTypography = (): RoleTypography => ({
size: DEFAULT_FONT_SIZE,
weight: DEFAULT_FONT_WEIGHT,
leading: DEFAULT_LINE_HEIGHT,
tracking: DEFAULT_LETTER_SPACING,
});
/**
* Singleton board store. Pairings live as a reassigned `$state` array (ordered
* cycling needs index order); mutations reassign so Svelte tracks them and
* persist synchronously through the persistent store.
*/
export class BoardStore {
/**
* Ordered pairings on the board.
*/
#pairings = $state<Pairing[]>([]);
/**
* The focal pairing's id, or null when the board is empty.
*/
#focalId = $state<string | null>(null);
/**
* Board-global specimen text shared by every pairing.
*/
#specimen = $state<{ header: string; body: string }>({ ...DEFAULT_SPECIMEN });
/**
* Per-role typography, fed in by the widget from the AdjustTypography stores
* (dependency-inversion seam). Read by font-loading and frame measurement.
*/
#typo = $state<{ header: RoleTypography; body: RoleTypography }>({
header: defaultRoleTypography(),
body: defaultRoleTypography(),
});
/**
* localStorage-backed mirror of the board blob.
*/
#storage = createPersistentStore<PersistedBoard>(BOARD_STORAGE_KEY, emptyBoard());
/**
* Batch font-metadata resolver, kept in sync with `candidateFontIds`.
*/
#fontsByIds: FontsByIdsStore;
/**
* Font load/cache/eviction manager; pinned to keep on-screen fonts resident.
*/
#lifecycle: FontLifecycleManager;
/**
* Paginated font catalog — source of fonts for default seeding.
*/
#fontCatalog: FontCatalogStore;
/**
* One-shot guard: only seed a default board when storage was empty at
* construction (never re-seed after the user empties the board).
*/
#shouldSeed: boolean;
/**
* Font strings whose canvas metrics are confirmed real (warm). Reactive
* (SvelteSet) so a completed warm re-runs height readers. Gates `prepare()`
* to avoid poisoning Pretext's cache with fallback widths.
*/
#warmed = new SvelteSet<string>();
/**
* Font strings with an in-flight `ensureCanvasFonts` — dedupes warm requests.
*/
#warming = new Set<string>();
/**
* Memoized frame heights keyed by (combo, width, specimen, typography), so
* cycling back to a measured pairing is O(1) and never reflows.
*/
#heightCache = new Map<string, number>();
/**
* Last computed height per pairing — the reserved fallback returned while a
* pairing's fonts load/warm, so the frame never collapses to 0 mid-cycle.
*/
#lastHeight = new Map<string, number>();
/**
* Disposes the constructor's $effect.root. Must run on teardown.
*/
#disposeEffects: () => void;
constructor() {
const stored = this.#storage.value;
this.#pairings = stored.pairings.map(p => createPairing(p.h, p.b, p.id));
this.#focalId = stored.focalId;
this.#specimen = { ...stored.specimen };
this.#shouldSeed = stored.pairings.length === 0;
this.#lifecycle = getFontLifecycleManager();
this.#fontCatalog = getFontCatalog();
this.#fontsByIds = new FontsByIdsStore(this.candidateFontIds);
this.#disposeEffects = $effect.root(() => {
// Seed a curated default board the first time the catalog is ready and
// storage was empty — so the screen is never blank on first visit.
$effect(() => {
if (!this.#shouldSeed || this.#pairings.length > 0) {
return;
}
const fonts = this.#fontCatalog.fonts;
if (fonts.length < 2) {
return;
}
untrack(() => {
this.#shouldSeed = false;
const count = Math.min(4, Math.floor(fonts.length / 2));
for (let i = 0; i < count; i++) {
this.addPairing(fonts[i * 2].id, fonts[i * 2 + 1].id);
}
});
});
// Keep the batch query's id set in sync with the board's candidates.
$effect(() => {
this.#fontsByIds.setIds(this.candidateFontIds);
});
// Preload every candidate font at its role weight (brief §Performance).
$effect(() => {
const configs = this.#candidateConfigs();
if (configs.length > 0) {
this.#lifecycle.touch(configs);
}
});
// Pin the focal pairing's fonts so eviction never drops on-screen
// glyphs; unpin on focal/weight change via the cleanup return.
$effect(() => {
const focal = this.focal;
if (!focal) {
return;
}
const headerWeight = this.#typo.header.weight;
const bodyWeight = this.#typo.body.weight;
const header = this.fontById(focal.headerFontId);
const body = this.fontById(focal.bodyFontId);
if (header) {
this.#lifecycle.pin(header.id, headerWeight, header.features?.isVariable);
}
if (body) {
this.#lifecycle.pin(body.id, bodyWeight, body.features?.isVariable);
}
return () => {
if (header) {
this.#lifecycle.unpin(header.id, headerWeight, header.features?.isVariable);
}
if (body) {
this.#lifecycle.unpin(body.id, bodyWeight, body.features?.isVariable);
}
};
});
});
}
/**
* Builds dedup'd font-load configs for every resolvable candidate font at its
* role weight (header fonts at header weight, body fonts at body weight).
* Unresolved fonts (metadata not yet fetched) are skipped.
*/
#candidateConfigs(): FontLoadRequestConfig[] {
const configs: FontLoadRequestConfig[] = [];
const seen = new Set<string>();
const add = (fontId: string, weight: number) => {
const font = this.fontById(fontId);
if (!font) {
return;
}
const url = getFontUrl(font, weight);
if (!url || seen.has(url)) {
return;
}
seen.add(url);
configs.push({ id: font.id, name: font.name, weight, url, isVariable: font.features?.isVariable });
};
for (const pairing of this.#pairings) {
add(pairing.headerFontId, this.#typo.header.weight);
add(pairing.bodyFontId, this.#typo.body.weight);
}
return configs;
}
/**
* Writes current state back to the persistent store. The persistent store's
* own effect flushes to localStorage; `destroy()` forces that flush so
* synchronous rehydration (and test teardown) never loses a write.
*/
#persist() {
this.#storage.value = {
v: BOARD_SCHEMA_VERSION,
pairings: this.#pairings.map(p => ({ id: p.id, h: p.headerFontId, b: p.bodyFontId })),
focalId: this.#focalId,
specimen: { ...this.#specimen },
};
}
/**
* All pairings in board order (reactive).
*/
get pairings(): readonly Pairing[] {
return this.#pairings;
}
/**
* The focal pairing's id, or null when empty (reactive).
*/
get focalId(): string | null {
return this.#focalId;
}
/**
* The focal pairing, or undefined when empty (reactive).
*/
get focal(): Pairing | undefined {
return this.#pairings.find(p => p.id === this.#focalId);
}
/**
* Board-global specimen text (reactive).
*/
get specimen(): { header: string; body: string } {
return this.#specimen;
}
/**
* Per-role typography values (reactive). Fed by the widget via `setTypo`.
*/
get typo(): { header: RoleTypography; body: RoleTypography } {
return this.#typo;
}
/**
* Replaces one role's typography values. Called by `widgets/Board` whenever
* the corresponding AdjustTypography store changes (the inversion seam).
*
* @param role - Which role's typography to set.
* @param values - The new typography values for that role.
*/
setTypo(role: Role, values: RoleTypography) {
this.#typo = { ...this.#typo, [role]: { ...values } };
}
/**
* Every distinct font id referenced by any pairing (header or body). The
* preload set — kept in sync with the batch font resolver.
*/
get candidateFontIds(): string[] {
const ids = new Set<string>();
for (const pairing of this.#pairings) {
ids.add(pairing.headerFontId);
ids.add(pairing.bodyFontId);
}
return [...ids];
}
/**
* Resolves a font id to its loaded metadata, or undefined if not yet fetched.
*
* @param id - Font entity id.
* @returns The font metadata, or undefined while loading.
*/
fontById(id: string): UnifiedFont | undefined {
return this.#fontsByIds.fonts.find(f => f.id === id);
}
/**
* Resolves both fonts of a pairing for the UI.
*
* @param pairing - The pairing to resolve.
* @returns Header and body font metadata (each undefined while loading).
*/
resolvePairingFonts(pairing: Pairing): { header?: UnifiedFont; body?: UnifiedFont } {
return {
header: this.fontById(pairing.headerFontId),
body: this.fontById(pairing.bodyFontId),
};
}
/**
* The focal frame's measured height at the given content width.
*
* @param contentWidth - The frame's content width in px.
* @returns Height in px (0 when the board is empty).
*/
focalFrameHeight(contentWidth: number): number {
return this.#focalId ? this.frameHeight(this.#focalId, contentWidth) : 0;
}
/**
* Pre-measures a (typically next-up) pairing so cycling to it never reflows.
*
* @param pairingId - The pairing to measure ahead of time.
* @param contentWidth - The frame's content width in px.
* @returns Height in px (fallback while fonts load/warm).
*/
peekFrameHeight(pairingId: string, contentWidth: number): number {
return this.frameHeight(pairingId, contentWidth);
}
/**
* Measured height of a pairing's frame (header block + gap + body block) at a
* content width, via Pretext's pure line-count arithmetic. Returns the
* last-known height (or 0) until both fonts are resolved AND the canvas is
* warm — never measures cold, which would poison Pretext's width cache
* forever. Results are memoized per (combo, width, specimen, typography).
*
* @param pairingId - The pairing to measure.
* @param contentWidth - The frame's content width in px.
* @returns Height in px.
*/
frameHeight(pairingId: string, contentWidth: number): number {
const pairing = this.#pairings.find(p => p.id === pairingId);
if (!pairing) {
return 0;
}
const { header, body } = this.resolvePairingFonts(pairing);
const fallback = this.#lastHeight.get(pairingId) ?? 0;
if (!header || !body) {
return fallback;
}
const headerFont = getPretextFontString(this.#typo.header.weight, this.#typo.header.size, header.name);
const bodyFont = getPretextFontString(this.#typo.body.weight, this.#typo.body.size, body.name);
this.#ensureWarm([headerFont, bodyFont]);
// SvelteSet read is reactive: a completed warm re-runs height readers.
if (!this.#warmed.has(headerFont) || !this.#warmed.has(bodyFont)) {
return fallback;
}
const key = `${comboKey(pairing)}|${contentWidth}|${this.#specimen.header}|${this.#specimen.body}|`
+ this.#typoSignature();
const cached = this.#heightCache.get(key);
if (cached !== undefined) {
this.#lastHeight.set(pairingId, cached);
return cached;
}
const headerHeight = measureRoleHeight({
prepared: prepareWithSegments(this.#specimen.header, headerFont, {
letterSpacing: this.#typo.header.tracking,
}),
maxWidth: contentWidth,
sizePx: this.#typo.header.size,
lineHeight: this.#typo.header.leading,
});
const bodyHeight = measureRoleHeight({
prepared: prepareWithSegments(this.#specimen.body, bodyFont, {
letterSpacing: this.#typo.body.tracking,
}),
maxWidth: contentWidth,
sizePx: this.#typo.body.size,
lineHeight: this.#typo.body.leading,
});
const height = combineFrameHeight({ headerHeight, bodyHeight, gap: FRAME_ROLE_GAP });
this.#heightCache.set(key, height);
this.#lastHeight.set(pairingId, height);
return height;
}
/**
* True once the focal pairing's fonts are resolved and canvas-warm — the UI
* gates the first paint of the focal frame on this to avoid a cold-measure
* flash.
*/
get measureReady(): boolean {
const focal = this.focal;
if (!focal) {
return false;
}
const { header, body } = this.resolvePairingFonts(focal);
if (!header || !body) {
return false;
}
const headerFont = getPretextFontString(this.#typo.header.weight, this.#typo.header.size, header.name);
const bodyFont = getPretextFontString(this.#typo.body.weight, this.#typo.body.size, body.name);
return this.#warmed.has(headerFont) && this.#warmed.has(bodyFont);
}
/**
* Kicks off canvas warming for any cold font strings (dedup'd). Fire-and-
* forget: on resolution the strings join `#warmed`, re-running height readers.
*/
#ensureWarm(fontStrings: string[]) {
const cold = fontStrings.filter(s => !this.#warmed.has(s) && !this.#warming.has(s));
if (cold.length === 0) {
return;
}
cold.forEach(s => this.#warming.add(s));
void ensureCanvasFonts(cold)
.then(() => {
cold.forEach(s => {
this.#warming.delete(s);
this.#warmed.add(s);
});
})
.catch(() => {
cold.forEach(s => this.#warming.delete(s));
});
}
/**
* Stable signature of both roles' typography, for the height memo key.
*/
#typoSignature(): string {
const h = this.#typo.header;
const b = this.#typo.body;
return `${h.size},${h.weight},${h.leading},${h.tracking};${b.size},${b.weight},${b.leading},${b.tracking}`;
}
/**
* Adds a pairing to the end of the board. The first pairing added becomes
* focal.
*
* @param headerFontId - Font id for the header role.
* @param bodyFontId - Font id for the body role.
* @returns The created pairing.
*/
addPairing(headerFontId: string, bodyFontId: string): Pairing {
const pairing = createPairing(headerFontId, bodyFontId);
this.#pairings = [...this.#pairings, pairing];
if (this.#focalId === null) {
this.#focalId = pairing.id;
}
this.#persist();
return pairing;
}
/**
* Clones a pairing as a distinct card inserted directly after the source, and
* makes the clone focal so the user can immediately swap one side.
*
* @param id - Source pairing id.
* @returns The new pairing.
*/
duplicate(id: string): Pairing {
const index = this.#pairings.findIndex(p => p.id === id);
const source = this.#pairings[index];
const dup = createPairing(source.headerFontId, source.bodyFontId);
this.#pairings = [
...this.#pairings.slice(0, index + 1),
dup,
...this.#pairings.slice(index + 1),
];
this.#focalId = dup.id;
this.#persist();
return dup;
}
/**
* Removes a pairing. If the removed pairing was focal, focal moves to a
* neighbour so exactly one focal always exists on a non-empty board.
*
* @param id - Pairing id to remove.
*/
removePairing(id: string) {
let nextFocal = this.#focalId;
if (this.#focalId === id) {
// Pick a neighbour from the still-full ordered list; if the only
// candidate is the one being removed, the board becomes empty.
const candidate = nextFocalId(this.#pairings.map(p => p.id), id, 1);
nextFocal = candidate === id ? null : candidate;
}
this.#pairings = this.#pairings.filter(p => p.id !== id);
this.#focalId = nextFocal;
this.#persist();
}
/**
* Sets the focal pairing.
*
* @param id - Pairing id to focus.
*/
setFocal(id: string) {
this.#focalId = id;
this.#persist();
}
/**
* Steps focal one pairing in board order, wrapping at both ends.
*
* @param direction - +1 for next, -1 for previous.
*/
cycle(direction: 1 | -1) {
if (this.#focalId === null) {
return;
}
const next = nextFocalId(this.#pairings.map(p => p.id), this.#focalId, direction);
if (next !== null) {
this.#focalId = next;
this.#persist();
}
}
/**
* Swaps the font filling one role of a pairing.
*
* @param id - Pairing id.
* @param role - Which role to swap.
* @param fontId - New font id for that role.
*/
swapFont(id: string, role: Role, fontId: string) {
this.#pairings = this.#pairings.map(p => {
if (p.id !== id) {
return p;
}
return role === 'header' ? { ...p, headerFontId: fontId } : { ...p, bodyFontId: fontId };
});
this.#persist();
}
/**
* Rewrites the board-global specimen for a role.
*
* @param role - Which role's text to set.
* @param text - New specimen text.
*/
setSpecimen(role: Role, text: string) {
this.#specimen = { ...this.#specimen, [role]: text };
this.#persist();
}
/**
* Flushes the pending persist write, then disposes the persistent store.
* Call on teardown.
*/
destroy() {
flushSync();
this.#disposeEffects();
this.#fontsByIds.destroy();
this.#storage.destroy();
}
}
const board = createSingleton(
() => new BoardStore(),
instance => instance.destroy(),
);
export const getBoard = board.get;
// test-only reset, so specs don't share live state or persisted blobs
export const __resetBoard = board.reset;
-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
+4
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,
@@ -31,7 +33,9 @@ export {
clampNumber,
cn,
debounce,
ensureCanvasFonts,
getDecimalPlaces,
getPretextFontString,
roundToStepPrecision,
smoothScroll,
splitArray,
+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);
}
@@ -0,0 +1,71 @@
/**
* Ensures a set of fonts is usable in a `<canvas>` measurement context.
*
* `document.fonts.load()` resolves once the FontFace bytes are fetched and
* parsed, but Chrome lazily registers fonts with the canvas measurement engine
* after that `measureText` keeps returning a fallback width for some frames
* even though `document.fonts.check()` reports the font as loaded.
*
* Pretext caches measurements per font string forever, so a single fallback
* measurement during initial mount permanently poisons the cache and the
* rendered text drifts visibly from its measured box. This helper polls canvas
* measurement until each font reports a width that differs from the "unknown
* font family" fallback, guaranteeing the next `measureText` call sees the real
* glyph metrics.
*
* ponytail: deliberate copy of widgets/ComparisonView/lib's version ADR-0002
* keeps the shelved morph tool untouched, so we don't move its util. The poll
* logic is the proven fix for Pretext's fallback-width cache poisoning; copying
* it is cheaper than refactoring frozen code.
*
* @param fontStrings - Pretext/canvas font strings (`weight sizepx "family"`) to warm.
*/
import { getPretextFontString } from '../getPretextFontString/getPretextFontString';
const PROBE_TEXT = 'mmmmmmmmmm';
const MAX_WAIT_MS = 1000;
const DEFAULT_PROBE_SIZE_PX = 16;
// Family unlikely to exist in any system — gives canvas's "unknown font" fallback width.
const FALLBACK_PROBE_FAMILY = '__glyphdiff_no_such_font_42__';
export async function ensureCanvasFonts(fontStrings: string[]): Promise<void> {
await Promise.all(fontStrings.map(f => document.fonts.load(f)));
// Pretext uses OffscreenCanvas when available; DOM canvas has separate font
// registration timing, so we MUST poll using the same canvas type pretext does.
const ctx = typeof OffscreenCanvas !== 'undefined'
? new OffscreenCanvas(1, 1).getContext('2d')
: document.createElement('canvas').getContext('2d');
if (!ctx) {
return;
}
// Measure each font's "unknown font" fallback width (different per browser, per OS).
// Canvas uses this same fallback for any font family it can't resolve, so when the
// requested font finally registers, measureText will return a non-fallback width.
const fallbackWidths = new Map<string, number>();
for (const font of fontStrings) {
const sizeMatch = font.match(/(\d+(?:\.\d+)?)px/);
const sizePx = sizeMatch ? parseFloat(sizeMatch[1]) : DEFAULT_PROBE_SIZE_PX;
ctx.font = getPretextFontString(400, sizePx, FALLBACK_PROBE_FAMILY);
fallbackWidths.set(font, ctx.measureText(PROBE_TEXT).width);
}
const deadline = performance.now() + MAX_WAIT_MS;
const pending = new Set(fontStrings);
while (pending.size > 0 && performance.now() < deadline) {
for (const font of Array.from(pending)) {
ctx.font = font;
const w = ctx.measureText(PROBE_TEXT).width;
if (Math.abs(w - fallbackWidths.get(font)!) > 0.5) {
pending.delete(font);
}
}
if (pending.size === 0) {
break;
}
// Sequential by design: poll once per animation frame until fonts register.
// eslint-disable-next-line no-await-in-loop
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
}
}
@@ -0,0 +1,15 @@
import {
describe,
expect,
it,
} from 'vitest';
import { getPretextFontString } from './getPretextFontString';
describe('getPretextFontString', () => {
it('formats weight, px size and quoted family for pretext/canvas', () => {
expect(getPretextFontString(400, 48, 'Inter')).toBe('400 48px "Inter"');
});
it('preserves fractional sizes and quotes multi-word family names', () => {
expect(getPretextFontString(700, 12.5, 'PT Serif')).toBe('700 12.5px "PT Serif"');
});
});
@@ -0,0 +1,16 @@
/**
* Formats a font config into the string `@chenglou/pretext` and the Canvas 2D
* `font` property both expect: `weight sizepx "family"`.
*
* ponytail: deliberate copy of widgets/ComparisonView/lib's version ADR-0002
* keeps the shelved morph tool untouched, so we don't move its util. Three lines
* is cheaper to duplicate than to refactor frozen code.
*
* @param weight - Numeric font weight (e.g. 400).
* @param sizePx - Font size in pixels.
* @param fontName - The font family name.
* @returns A formatted font string: `weight sizepx "fontName"`.
*/
export function getPretextFontString(weight: number, sizePx: number, fontName: string): string {
return `${weight} ${sizePx}px "${fontName}"`;
}
+2
View File
@@ -17,7 +17,9 @@ export {
export { clampNumber } from './clampNumber/clampNumber';
export { cn } from './cn';
export { debounce } from './debounce/debounce';
export { ensureCanvasFonts } from './ensureCanvasFonts/ensureCanvasFonts';
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { getPretextFontString } from './getPretextFontString/getPretextFontString';
export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth';
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
export { smoothScroll } from './smoothScroll/smoothScroll';
@@ -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 };
}

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