Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ace4aee07 | |||
| f3a2a6a7bd | |||
| 118c588859 | |||
| 59097ca9ad | |||
| 738ed3b4ed | |||
| 132d1327f5 | |||
| 92ea7b9dc4 | |||
| e55e713517 | |||
| f49180e83d | |||
| 2c3d88c81f | |||
| 0e9288c295 | |||
| dbd48b287d | |||
| f29e0b0c7c | |||
| 91bb046339 | |||
| f680fe01ea | |||
| d37d01e6d8 | |||
| c78b8e032e | |||
| 11d5ba0e63 | |||
| 99e9a1fb2c | |||
| 5084df3914 | |||
| a2ec025a65 | |||
| 8dbea97a33 | |||
| 744cdc9d19 | |||
| 600b905e01 | |||
| 4ad0fe4cfa | |||
| eafe89b313 | |||
| 724b00d3d5 | |||
| c09ca93f4e | |||
| 99ab7e9e08 | |||
| ec488cf1ce | |||
| fe07c60dd4 | |||
| 0aae710e35 | |||
| ded9606c30 | |||
| f0736f4d35 | |||
| 5eb458eabb | |||
| a428eac309 | |||
| 09869aed00 | |||
| 028853aff5 | |||
| 1c6427c586 | |||
| 60e115309c | |||
| b390efdabe | |||
| 771bda745c | |||
| c6c8497906 | |||
| f3a10e38df | |||
| 9788f07dec | |||
| deefb51b57 | |||
| 431fb41a7f | |||
| db6384110e | |||
| cbd95350bb | |||
| a8a985ee6a | |||
| be073286dc | |||
| 7798c4bbdf | |||
| 3ae22ad515 | |||
| ffa897ee54 | |||
| 93c52dd132 | |||
| 9e0c8f740b | |||
| b1b5177e02 | |||
| ef9cd33e48 | |||
| f3c76df2c5 | |||
| ae2d0e3c2f | |||
| 3f5151efa0 | |||
| 19d9b07c55 | |||
| 1209358d40 | |||
| d7decd7a00 | |||
| 9d6220d2ec | |||
| 4756682863 | |||
| 7ddf232e3a |
+195
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { windowSizeForLine } from '../src/entities/Font/domain/windowSizeForLine/windowSizeForLine';
|
||||||
import {
|
import {
|
||||||
expect,
|
expect,
|
||||||
test,
|
test,
|
||||||
@@ -5,12 +6,22 @@ import {
|
|||||||
|
|
||||||
test.describe('preview text', () => {
|
test.describe('preview text', () => {
|
||||||
test('drives the slider character rendering', async ({ comparison }) => {
|
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.pickPair('Inter', 'Roboto');
|
||||||
await comparison.setPreviewText('Sphinx');
|
await comparison.setPreviewText(text);
|
||||||
|
|
||||||
// Each grapheme renders as a `.char-wrap` cell in the slider once
|
// Window chars render as `.char-wrap` cells for crossfade. The window
|
||||||
// both fonts are loaded. Six glyphs → six cells.
|
// size is a pure function of the line's grapheme count — assert against
|
||||||
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(6);
|
// 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 }) => {
|
test('preserves the typed value in the input', async ({ comparison }) => {
|
||||||
|
|||||||
-29
@@ -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
@@ -6,8 +6,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
"*.css",
|
"*.css",
|
||||||
"**/router.ts",
|
"**/router.ts"
|
||||||
"**/bindings.svelte.ts"
|
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -49,7 +48,6 @@
|
|||||||
"@types/jsdom": "28.0.1",
|
"@types/jsdom": "28.0.1",
|
||||||
"@vitest/browser-playwright": "4.1.5",
|
"@vitest/browser-playwright": "4.1.5",
|
||||||
"@vitest/coverage-v8": "4.1.5",
|
"@vitest/coverage-v8": "4.1.5",
|
||||||
"bits-ui": "2.18.1",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dprint": "0.54.0",
|
"dprint": "0.54.0",
|
||||||
"jsdom": "29.1.1",
|
"jsdom": "29.1.1",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
descendants of this provider.
|
descendants of this provider.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
@@ -18,6 +18,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
|
|
||||||
|
// First call to the lazy singleton — constructs the shared client for the app.
|
||||||
|
const queryClient = getQueryClient();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ vi.mock('$shared/api/api', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { api } from '$shared/api/api';
|
import { api } from '$shared/api/api';
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
import { fontKeys } from '$shared/api/queryKeys';
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
import { FontResponseError } from '../../lib/errors/errors';
|
import { FontResponseError } from '../../lib/errors/errors';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '$shared/api/api';
|
import { api } from '$shared/api/api';
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
import { fontKeys } from '$shared/api/queryKeys';
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
import { buildQueryString } from '$shared/lib/utils';
|
import { buildQueryString } from '$shared/lib/utils';
|
||||||
import type { QueryParams } from '$shared/lib/utils';
|
import type { QueryParams } from '$shared/lib/utils';
|
||||||
@@ -26,7 +26,7 @@ import type { UnifiedFont } from '../../model/types';
|
|||||||
*/
|
*/
|
||||||
export function seedFontCache(fonts: UnifiedFont[]): void {
|
export function seedFontCache(fonts: UnifiedFont[]): void {
|
||||||
fonts.forEach(font => {
|
fonts.forEach(font => {
|
||||||
queryClient.setQueryData(fontKeys.detail(font.id), font);
|
getQueryClient().setQueryData(fontKeys.detail(font.id), font);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export {
|
|||||||
findSplitIndex,
|
findSplitIndex,
|
||||||
type LineRenderModel,
|
type LineRenderModel,
|
||||||
} from './computeLineRenderModel/computeLineRenderModel';
|
} 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 1–2, 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
@@ -1,29 +1,92 @@
|
|||||||
export * from './domain';
|
export {
|
||||||
export * from './lib';
|
computeLineRenderModel,
|
||||||
export * from './ui';
|
DualFontLayout,
|
||||||
|
findSplitIndex,
|
||||||
|
windowSizeForLine,
|
||||||
|
} from './domain';
|
||||||
|
export type {
|
||||||
|
ComparisonLine,
|
||||||
|
ComparisonResult,
|
||||||
|
LineRenderModel,
|
||||||
|
} from './domain';
|
||||||
|
|
||||||
// Pure model surface (types + constants) is part of the convenient top-level
|
export {
|
||||||
// API. Stateful stores are deliberately excluded — see below.
|
createFontRowSizeResolver,
|
||||||
export * from './model/const/const';
|
FontNetworkError,
|
||||||
export * from './model/types';
|
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
|
* `./api` (proxy clients: `fetchProxyFonts`, `seedFontCache`, …) is intentionally
|
||||||
* NOT re-exported here. Those clients import `$shared/api/queryClient`, whose
|
* NOT re-exported here — those are not part of the entity's consumed surface and
|
||||||
* module eval runs `new QueryClient()` and loads `@tanstack/query-core`. Funneling
|
* importing them eagerly constructs the TanStack `queryClient`. Import via the
|
||||||
* them through this barrel made every consumer of `$entities/Font` — including
|
* segment: `import { fetchProxyFonts } from '$entities/Font/api'`.
|
||||||
* 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';
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// `./testing` is intentionally not re-exported: fixtures must not leak into the
|
// `./testing` is intentionally not re-exported: fixtures must not leak into the
|
||||||
|
|||||||
@@ -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 type {
|
||||||
export * from './types';
|
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 { QueryClient } = await import('@tanstack/query-core');
|
||||||
const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
|
const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
|
||||||
|
const mockClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
queryClient: new QueryClient({
|
getQueryClient: () => mockClient,
|
||||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
||||||
|
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
import { fetchProxyFonts } from '../../../api';
|
import { fetchProxyFonts } from '../../../api';
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
|
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_QUERY_GC_TIME_MS,
|
DEFAULT_QUERY_GC_TIME_MS,
|
||||||
DEFAULT_QUERY_STALE_TIME_MS,
|
DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
queryClient,
|
getQueryClient,
|
||||||
} from '$shared/api/queryClient';
|
} from '$shared/api/queryClient';
|
||||||
|
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||||
import {
|
import {
|
||||||
type InfiniteData,
|
type InfiniteData,
|
||||||
InfiniteQueryObserver,
|
InfiniteQueryObserver,
|
||||||
@@ -46,7 +47,7 @@ export class FontCatalogStore {
|
|||||||
readonly unknown[],
|
readonly unknown[],
|
||||||
PageParam
|
PageParam
|
||||||
>;
|
>;
|
||||||
#qc = queryClient;
|
#qc = getQueryClient();
|
||||||
#unsubscribe: () => void;
|
#unsubscribe: () => void;
|
||||||
|
|
||||||
constructor(params: FontStoreParams = {}) {
|
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 {
|
export const getFontCatalog = catalog.get;
|
||||||
return (_catalog ??= new FontCatalogStore({ limit: 50 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// test-only reset, so specs don't share a live observer
|
// test-only reset, so specs don't share a live observer
|
||||||
export function __resetFontCatalog() {
|
export const __resetFontCatalog = catalog.reset;
|
||||||
_catalog?.destroy();
|
|
||||||
_catalog = undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import {
|
import {
|
||||||
type FontLoadRequestConfig,
|
type FontLoadRequestConfig,
|
||||||
@@ -419,18 +420,16 @@ export class FontLifecycleManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _fontLifecycleManager: FontLifecycleManager | undefined;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide font lifecycle manager, created on first access. Lazy so its
|
* App-wide font lifecycle manager, created on first access. Lazy so its
|
||||||
* AbortController / FontFace bookkeeping isn't set up at module load.
|
* AbortController / FontFace bookkeeping isn't set up at module load.
|
||||||
*/
|
*/
|
||||||
export function getFontLifecycleManager(): FontLifecycleManager {
|
const fontLifecycleManager = createSingleton(
|
||||||
return (_fontLifecycleManager ??= new FontLifecycleManager());
|
() => new FontLifecycleManager(),
|
||||||
}
|
instance => instance.destroy(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getFontLifecycleManager = fontLifecycleManager.get;
|
||||||
|
|
||||||
// test-only reset, so specs don't share loaded-font/eviction state
|
// test-only reset, so specs don't share loaded-font/eviction state
|
||||||
export function __resetFontLifecycleManager() {
|
export const __resetFontLifecycleManager = fontLifecycleManager.reset;
|
||||||
_fontLifecycleManager?.destroy();
|
|
||||||
_fontLifecycleManager = undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ describe('loadFont', () => {
|
|||||||
it('throws FontParseError when font.load() rejects', async () => {
|
it('throws FontParseError when font.load() rejects', async () => {
|
||||||
const loadError = new Error('parse failed');
|
const loadError = new Error('parse failed');
|
||||||
const MockFontFace = vi.fn(
|
const MockFontFace = vi.fn(
|
||||||
function(this: any, name: string, buffer: BufferSource, options: FontFaceDescriptors) {
|
function(this: any, _name: string, _buffer: BufferSource, _options: FontFaceDescriptors) {
|
||||||
this.load = vi.fn().mockRejectedValue(loadError);
|
this.load = vi.fn().mockRejectedValue(loadError);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,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 { fontKeys } from '$shared/api/queryKeys';
|
||||||
import {
|
import {
|
||||||
beforeEach,
|
beforeEach,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
// Font lifecycle manager (browser-side load + cache + eviction)
|
// Font lifecycle manager (browser-side load + cache + eviction)
|
||||||
export * from './fontLifecycleManager/fontLifecycleManager.svelte';
|
export {
|
||||||
|
__resetFontLifecycleManager,
|
||||||
|
FontLifecycleManager,
|
||||||
|
getFontLifecycleManager,
|
||||||
|
} from './fontLifecycleManager/fontLifecycleManager.svelte';
|
||||||
|
|
||||||
// Paginated catalog
|
// Paginated catalog
|
||||||
export { getFontCatalog } from './fontCatalogStore/fontCatalogStore.svelte';
|
export { getFontCatalog } from './fontCatalogStore/fontCatalogStore.svelte';
|
||||||
|
|
||||||
export type { FontCatalogStore } from './fontCatalogStore/fontCatalogStore.svelte';
|
export type { FontCatalogStore } from './fontCatalogStore/fontCatalogStore.svelte';
|
||||||
|
|
||||||
// Batch fetch by IDs (detail-cache seeding)
|
// Batch fetch by IDs (detail-cache seeding)
|
||||||
|
|||||||
@@ -23,4 +23,7 @@ export type {
|
|||||||
FontCollectionState,
|
FontCollectionState,
|
||||||
} from './store';
|
} from './store';
|
||||||
|
|
||||||
export * from './store/fontLifecycle';
|
export type {
|
||||||
|
FontLoadRequestConfig,
|
||||||
|
FontLoadStatus,
|
||||||
|
} from './store/fontLifecycle';
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* ============================================================================
|
* Mock font data: factory functions and preset fixtures.
|
||||||
* MOCK FONT DATA
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Factory functions and preset mock data for fonts.
|
|
||||||
* Used in Storybook stories, tests, and development.
|
* Used in Storybook stories, tests, and development.
|
||||||
*
|
*
|
||||||
* ## Usage
|
* ## Usage
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* ============================================================================
|
* Mock data helpers (main export).
|
||||||
* MOCK DATA HELPERS - MAIN EXPORT
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Comprehensive mock data for Storybook stories, tests, and development.
|
* Comprehensive mock data for Storybook stories, tests, and development.
|
||||||
*
|
*
|
||||||
* ## Quick Start
|
* ## Quick Start
|
||||||
|
|||||||
@@ -21,11 +21,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { UnifiedFont } from '$entities/Font/model/types';
|
import type { UnifiedFont } from '$entities/Font/model/types';
|
||||||
import type {
|
import type { QueryStatus } from '@tanstack/svelte-query';
|
||||||
QueryKey,
|
|
||||||
QueryObserverResult,
|
|
||||||
QueryStatus,
|
|
||||||
} from '@tanstack/svelte-query';
|
|
||||||
import {
|
import {
|
||||||
UNIFIED_FONTS,
|
UNIFIED_FONTS,
|
||||||
generateMockFonts,
|
generateMockFonts,
|
||||||
|
|||||||
+12
-2
@@ -4,7 +4,7 @@ import { defineMeta } from '@storybook/addon-svelte-csf';
|
|||||||
import FontSampler from './FontSampler.svelte';
|
import FontSampler from './FontSampler.svelte';
|
||||||
|
|
||||||
const { Story } = defineMeta({
|
const { Story } = defineMeta({
|
||||||
title: 'Features/FontSampler',
|
title: 'Entities/Font/FontSampler',
|
||||||
component: FontSampler,
|
component: FontSampler,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -39,8 +39,8 @@ const { Story } = defineMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { UnifiedFont } from '$entities/Font';
|
|
||||||
import type { ComponentProps } from 'svelte';
|
import type { ComponentProps } from 'svelte';
|
||||||
|
import type { UnifiedFont } from '../../model/types';
|
||||||
|
|
||||||
// Mock fonts for testing
|
// Mock fonts for testing
|
||||||
const mockArial: UnifiedFont = {
|
const mockArial: UnifiedFont = {
|
||||||
@@ -84,6 +84,14 @@ const mockGeorgia: UnifiedFont = {
|
|||||||
isVariable: false,
|
isVariable: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Stand-in for the AdjustTypography store the composing widget injects.
|
||||||
|
const mockTypography = {
|
||||||
|
renderedSize: 48,
|
||||||
|
weight: 400,
|
||||||
|
height: 1.5,
|
||||||
|
spacing: 0,
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
@@ -93,6 +101,7 @@ const mockGeorgia: UnifiedFont = {
|
|||||||
status: 'loaded',
|
status: 'loaded',
|
||||||
text: 'The quick brown fox jumps over the lazy dog',
|
text: 'The quick brown fox jumps over the lazy dog',
|
||||||
index: 0,
|
index: 0,
|
||||||
|
typography: mockTypography,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||||
@@ -111,6 +120,7 @@ const mockGeorgia: UnifiedFont = {
|
|||||||
text:
|
text:
|
||||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
|
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
|
||||||
index: 1,
|
index: 1,
|
||||||
|
typography: mockTypography,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||||
+45
-21
@@ -4,12 +4,6 @@
|
|||||||
Visual design matches FontCard: sharp corners, red hover accent, header stats.
|
Visual design matches FontCard: sharp corners, red hover accent, header stats.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
|
||||||
FontApplicator,
|
|
||||||
type FontLoadStatus,
|
|
||||||
type UnifiedFont,
|
|
||||||
} from '$entities/Font';
|
|
||||||
import { getTypographySettingsStore } from '$features/AdjustTypography/model';
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
ContentEditable,
|
ContentEditable,
|
||||||
@@ -18,6 +12,35 @@ import {
|
|||||||
Stat,
|
Stat,
|
||||||
} from '$shared/ui';
|
} from '$shared/ui';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
|
import type {
|
||||||
|
FontLoadStatus,
|
||||||
|
UnifiedFont,
|
||||||
|
} from '../../model/types';
|
||||||
|
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal typography contract this view renders with. The AdjustTypography
|
||||||
|
* store satisfies it structurally; defining it here keeps the entity decoupled
|
||||||
|
* from that feature (no entity -> feature import).
|
||||||
|
*/
|
||||||
|
interface FontSampleTypography {
|
||||||
|
/**
|
||||||
|
* Rendered font size in px
|
||||||
|
*/
|
||||||
|
renderedSize: number;
|
||||||
|
/**
|
||||||
|
* Numeric font weight
|
||||||
|
*/
|
||||||
|
weight: number;
|
||||||
|
/**
|
||||||
|
* Line-height multiplier
|
||||||
|
*/
|
||||||
|
height: number;
|
||||||
|
/**
|
||||||
|
* Letter spacing
|
||||||
|
*/
|
||||||
|
spacing: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -39,11 +62,15 @@ interface Props {
|
|||||||
* @default 0
|
* @default 0
|
||||||
*/
|
*/
|
||||||
index?: number;
|
index?: number;
|
||||||
|
/**
|
||||||
|
* Typography settings to render the sample with. Injected by the composing
|
||||||
|
* widget (which owns the AdjustTypography store) so this entity view stays
|
||||||
|
* decoupled from that feature — the same inversion as `status`.
|
||||||
|
*/
|
||||||
|
typography: FontSampleTypography;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { font, status, text = $bindable(), index = 0 }: Props = $props();
|
let { font, status, text = $bindable(), index = 0, typography }: Props = $props();
|
||||||
|
|
||||||
const typographySettingsStore = getTypographySettingsStore();
|
|
||||||
|
|
||||||
// Extract provider badge with fallback
|
// Extract provider badge with fallback
|
||||||
const providerBadge = $derived(
|
const providerBadge = $derived(
|
||||||
@@ -52,10 +79,10 @@ const providerBadge = $derived(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const stats = $derived([
|
const stats = $derived([
|
||||||
{ label: 'SZ', value: `${typographySettingsStore.renderedSize}PX` },
|
{ label: 'SZ', value: `${typography.renderedSize}PX` },
|
||||||
{ label: 'WGT', value: `${typographySettingsStore.weight}` },
|
{ label: 'WGT', value: `${typography.weight}` },
|
||||||
{ label: 'LH', value: typographySettingsStore.height?.toFixed(2) },
|
{ label: 'LH', value: typography.height.toFixed(2) },
|
||||||
{ label: 'LTR', value: `${typographySettingsStore.spacing}` },
|
{ label: 'LTR', value: `${typography.spacing}` },
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -73,9 +100,8 @@ const stats = $derived([
|
|||||||
min-h-60
|
min-h-60
|
||||||
rounded-none
|
rounded-none
|
||||||
"
|
"
|
||||||
style:font-weight={typographySettingsStore.weight}
|
style:font-weight={typography.weight}
|
||||||
>
|
>
|
||||||
<!-- ── Header bar ─────────────────────────────────────────────────── -->
|
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
flex items-center justify-between
|
flex items-center justify-between
|
||||||
@@ -136,19 +162,18 @@ const stats = $derived([
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Main content area ──────────────────────────────────────────── -->
|
|
||||||
<div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10">
|
<div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10">
|
||||||
<FontApplicator {font} {status}>
|
<FontApplicator {font} {status}>
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
bind:text
|
bind:text
|
||||||
fontSize={typographySettingsStore.renderedSize}
|
fontSize={typography.renderedSize}
|
||||||
lineHeight={typographySettingsStore.height}
|
lineHeight={typography.height}
|
||||||
letterSpacing={typographySettingsStore.spacing}
|
letterSpacing={typography.spacing}
|
||||||
/>
|
/>
|
||||||
</FontApplicator>
|
</FontApplicator>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Mobile stats footer (md:hidden — header stats take over above) -->
|
<!-- Mobile stats footer; md:hidden because the header stats take over above -->
|
||||||
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-subtle flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
|
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-subtle flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
|
||||||
{#each stats as stat, i}
|
{#each stats as stat, i}
|
||||||
<Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
<Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
||||||
@@ -160,7 +185,6 @@ const stats = $derived([
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Red hover line ─────────────────────────────────────────────── -->
|
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
absolute bottom-0 left-0 right-0
|
absolute bottom-0 left-0 right-0
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
||||||
|
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||||
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
FontApplicator,
|
FontApplicator,
|
||||||
|
FontSampler,
|
||||||
FontVirtualList,
|
FontVirtualList,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
+13
-16
@@ -15,10 +15,14 @@ import {
|
|||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
DEFAULT_LETTER_SPACING,
|
DEFAULT_LETTER_SPACING,
|
||||||
DEFAULT_LINE_HEIGHT,
|
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 {
|
import {
|
||||||
type PersistentStore,
|
type PersistentStore,
|
||||||
createPersistentStore,
|
createPersistentStore,
|
||||||
|
createSingleton,
|
||||||
} from '$shared/lib';
|
} from '$shared/lib';
|
||||||
import type { NumericControl } from '$shared/ui';
|
import type { NumericControl } from '$shared/ui';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
@@ -166,6 +170,7 @@ export class TypographySettingsStore {
|
|||||||
*/
|
*/
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.#disposeEffects();
|
this.#disposeEffects();
|
||||||
|
this.#storage.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -302,9 +307,6 @@ export class TypographySettingsStore {
|
|||||||
if (c.id === 'font_size') {
|
if (c.id === 'font_size') {
|
||||||
c.instance.value = defaults.fontSize * this.#multiplier;
|
c.instance.value = defaults.fontSize * this.#multiplier;
|
||||||
} else {
|
} else {
|
||||||
// Map storage key to control id
|
|
||||||
const key = c.id.replace('_', '') as keyof TypographySettings;
|
|
||||||
// Simplified for brevity, you'd map these properly:
|
|
||||||
if (c.id === 'font_weight') {
|
if (c.id === 'font_weight') {
|
||||||
c.instance.value = defaults.fontWeight;
|
c.instance.value = defaults.fontWeight;
|
||||||
}
|
}
|
||||||
@@ -351,22 +353,17 @@ export function createTypographySettingsStore(
|
|||||||
|
|
||||||
export type TypographySettingsStoreInstance = ReturnType<typeof createTypographySettingsStore>;
|
export type TypographySettingsStoreInstance = ReturnType<typeof createTypographySettingsStore>;
|
||||||
|
|
||||||
let _typographySettingsStore: TypographySettingsStoreInstance | undefined;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide typography settings store, keyed for the comparison view.
|
* App-wide typography settings store, keyed for the comparison view.
|
||||||
* Created on first access so its persistent-store sync effects aren't set up
|
* Created on first access so its persistent-store sync effects aren't set up
|
||||||
* at module load.
|
* at module load.
|
||||||
*/
|
*/
|
||||||
export function getTypographySettingsStore(): TypographySettingsStoreInstance {
|
const typographySettingsStore = createSingleton(
|
||||||
return (_typographySettingsStore ??= createTypographySettingsStore(
|
() => createTypographySettingsStore(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, COMPARISON_STORAGE_KEY),
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
instance => instance.destroy(),
|
||||||
COMPARISON_STORAGE_KEY,
|
);
|
||||||
));
|
|
||||||
}
|
export const getTypographySettingsStore = typographySettingsStore.get;
|
||||||
|
|
||||||
// test-only reset, so specs don't share persisted typography state or leak effects
|
// test-only reset, so specs don't share persisted typography state or leak effects
|
||||||
export function __resetTypographySettingsStore() {
|
export const __resetTypographySettingsStore = typographySettingsStore.reset;
|
||||||
_typographySettingsStore?.destroy();
|
|
||||||
_typographySettingsStore = undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
+4
-1
@@ -6,7 +6,7 @@ import {
|
|||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
DEFAULT_LETTER_SPACING,
|
DEFAULT_LETTER_SPACING,
|
||||||
DEFAULT_LINE_HEIGHT,
|
DEFAULT_LINE_HEIGHT,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font/model/const/const';
|
||||||
import {
|
import {
|
||||||
beforeEach,
|
beforeEach,
|
||||||
describe,
|
describe,
|
||||||
@@ -51,6 +51,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
|||||||
let mockPersistentStore: {
|
let mockPersistentStore: {
|
||||||
value: TypographySettings;
|
value: TypographySettings;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
|
destroy: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createMockPersistentStore = (initialValue: TypographySettings) => {
|
const createMockPersistentStore = (initialValue: TypographySettings) => {
|
||||||
@@ -70,6 +71,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
|||||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
destroy() {},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -535,6 +537,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
|||||||
mockStorage = v;
|
mockStorage = v;
|
||||||
},
|
},
|
||||||
clear: clearSpy,
|
clear: clearSpy,
|
||||||
|
destroy() {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const manager = new TypographySettingsStore(
|
const manager = new TypographySettingsStore(
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
ComboControl,
|
ComboControl,
|
||||||
ControlGroup,
|
ControlGroup,
|
||||||
|
Popover,
|
||||||
Slider,
|
Slider,
|
||||||
} from '$shared/ui';
|
} from '$shared/ui';
|
||||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||||
import XIcon from '@lucide/svelte/icons/x';
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
import { Popover } from 'bits-ui';
|
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
@@ -74,33 +74,21 @@ $effect(() => {
|
|||||||
{#if !hidden}
|
{#if !hidden}
|
||||||
{#if responsive.isMobileOrTablet}
|
{#if responsive.isMobileOrTablet}
|
||||||
<div class={className}>
|
<div class={className}>
|
||||||
<Popover.Root bind:open>
|
<Popover bind:open side="top" align="end" sideOffset={8}>
|
||||||
<Popover.Trigger>
|
{#snippet trigger(props)}
|
||||||
{#snippet child({ props })}
|
<Button variant="primary" {...props}>
|
||||||
<Button variant="primary" {...props}>
|
{#snippet icon()}
|
||||||
{#snippet icon()}
|
<Settings2Icon class="size-4" />
|
||||||
<Settings2Icon class="size-4" />
|
{/snippet}
|
||||||
{/snippet}
|
</Button>
|
||||||
</Button>
|
{/snippet}
|
||||||
{/snippet}
|
|
||||||
</Popover.Trigger>
|
|
||||||
|
|
||||||
<Popover.Portal>
|
{#snippet children({ close })}
|
||||||
<Popover.Content
|
<div
|
||||||
side="top"
|
|
||||||
align="end"
|
|
||||||
sideOffset={8}
|
|
||||||
class={cn(
|
class={cn(
|
||||||
'z-50 w-72 p-4 rounded-none',
|
'w-72 p-4 rounded-none',
|
||||||
'surface-popover',
|
'surface-popover',
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
||||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
||||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
||||||
'data-[side=top]:slide-in-from-bottom-2',
|
|
||||||
'data-[side=bottom]:slide-in-from-top-2',
|
|
||||||
)}
|
)}
|
||||||
interactOutsideBehavior="close"
|
|
||||||
escapeKeydownBehavior="close"
|
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
|
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
|
||||||
@@ -112,17 +100,13 @@ $effect(() => {
|
|||||||
CONTROLS
|
CONTROLS
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Popover.Close>
|
<button
|
||||||
{#snippet child({ props })}
|
onclick={close}
|
||||||
<button
|
class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||||
{...props}
|
aria-label="Close controls"
|
||||||
class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
>
|
||||||
aria-label="Close controls"
|
<XIcon class="size-3.5 text-neutral-500" />
|
||||||
>
|
</button>
|
||||||
<XIcon class="size-3.5 text-neutral-500" />
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
</Popover.Close>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls -->
|
<!-- Controls -->
|
||||||
@@ -136,9 +120,9 @@ $effect(() => {
|
|||||||
/>
|
/>
|
||||||
</ControlGroup>
|
</ControlGroup>
|
||||||
{/each}
|
{/each}
|
||||||
</Popover.Content>
|
</div>
|
||||||
</Popover.Portal>
|
{/snippet}
|
||||||
</Popover.Root>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,2 +1,7 @@
|
|||||||
export * from './store/scrollBreadcrumbsStore.svelte';
|
export {
|
||||||
export * from './types/types.ts';
|
__resetScrollBreadcrumbsStore,
|
||||||
|
createScrollBreadcrumbsStore,
|
||||||
|
getScrollBreadcrumbsStore,
|
||||||
|
} from './store/scrollBreadcrumbsStore.svelte';
|
||||||
|
export type { BreadcrumbItem } from './store/scrollBreadcrumbsStore.svelte';
|
||||||
|
export type { NavigationAction } from './types/types.ts';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scroll-based breadcrumb tracking store
|
* Scroll-based breadcrumb tracking store
|
||||||
*
|
*
|
||||||
@@ -279,17 +281,15 @@ export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
|
|||||||
return new ScrollBreadcrumbsStore();
|
return new ScrollBreadcrumbsStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
let _scrollBreadcrumbsStore: ScrollBreadcrumbsStore | undefined;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide scroll breadcrumbs store, created on first access.
|
* App-wide scroll breadcrumbs store, created on first access.
|
||||||
*/
|
*/
|
||||||
export function getScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
|
const scrollBreadcrumbsStore = createSingleton(
|
||||||
return (_scrollBreadcrumbsStore ??= createScrollBreadcrumbsStore());
|
() => createScrollBreadcrumbsStore(),
|
||||||
}
|
instance => instance.destroy(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getScrollBreadcrumbsStore = scrollBreadcrumbsStore.get;
|
||||||
|
|
||||||
// test-only reset, so specs don't share observer/scroll state
|
// test-only reset, so specs don't share observer/scroll state
|
||||||
export function __resetScrollBreadcrumbsStore() {
|
export const __resetScrollBreadcrumbsStore = scrollBreadcrumbsStore.reset;
|
||||||
_scrollBreadcrumbsStore?.destroy();
|
|
||||||
_scrollBreadcrumbsStore = undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ class MockIntersectionObserver implements IntersectionObserver {
|
|||||||
describe('ScrollBreadcrumbsStore', () => {
|
describe('ScrollBreadcrumbsStore', () => {
|
||||||
let scrollListeners: Array<() => void> = [];
|
let scrollListeners: Array<() => void> = [];
|
||||||
let addEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
let addEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
||||||
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
let scrollToSpy: ReturnType<typeof vi.spyOn>;
|
let scrollToSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
// Helper to create mock elements
|
// Helper to create mock elements
|
||||||
@@ -111,7 +110,7 @@ describe('ScrollBreadcrumbsStore', () => {
|
|||||||
|
|
||||||
// Track scroll event listeners
|
// Track scroll event listeners
|
||||||
addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(
|
addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(
|
||||||
(event: string, listener: EventListenerOrEventListenerObject, options?: any) => {
|
(event: string, listener: EventListenerOrEventListenerObject, _options?: any) => {
|
||||||
if (event === 'scroll') {
|
if (event === 'scroll') {
|
||||||
scrollListeners.push(listener as () => void);
|
scrollListeners.push(listener as () => void);
|
||||||
}
|
}
|
||||||
@@ -119,7 +118,7 @@ describe('ScrollBreadcrumbsStore', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
removeEventListenerSpy = vi.spyOn(window, 'removeEventListener').mockImplementation(
|
vi.spyOn(window, 'removeEventListener').mockImplementation(
|
||||||
(event: string, listener: EventListenerOrEventListenerObject) => {
|
(event: string, listener: EventListenerOrEventListenerObject) => {
|
||||||
if (event === 'scroll') {
|
if (event === 'scroll') {
|
||||||
const index = scrollListeners.indexOf(listener as () => void);
|
const index = scrollListeners.indexOf(listener as () => void);
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ const sections = [
|
|||||||
{ index: 102, title: 'Spacing' },
|
{ index: 102, title: 'Spacing' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @type {HTMLDivElement} */
|
/** @type {HTMLDivElement | undefined} */
|
||||||
let container;
|
let container = $state();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
const el = /** @type {HTMLElement} */ (container.querySelector(`[data-story-index="${section.index}"]`));
|
const el = /** @type {HTMLElement} */ (container.querySelector(`[data-story-index="${section.index}"]`));
|
||||||
scrollBreadcrumbsStore.add({ index: section.index, title: section.title, element: el }, 96);
|
scrollBreadcrumbsStore.add({ index: section.index, title: section.title, element: el }, 96);
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './model';
|
export { getThemeManager } from './model';
|
||||||
export * from './ui';
|
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';
|
export const STORAGE_KEY = 'glyphdiff:theme';
|
||||||
|
|
||||||
@@ -125,6 +128,7 @@ class ThemeManager {
|
|||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
|
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
|
||||||
this.#mediaQuery = null;
|
this.#mediaQuery = null;
|
||||||
|
this.#store.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,23 +198,18 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _themeManager: ThemeManager | undefined;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide theme manager, created on first access.
|
* App-wide theme manager, created on first access.
|
||||||
*
|
*
|
||||||
* Lazy so its persistent-store subscription isn't set up at module load.
|
* Lazy so its persistent-store subscription isn't set up at module load.
|
||||||
* Call init() on mount and destroy() on unmount (see Layout).
|
* Call init() on mount and destroy() on unmount (see Layout).
|
||||||
*/
|
*/
|
||||||
export function getThemeManager(): ThemeManager {
|
const themeManager = createSingleton(() => new ThemeManager(), instance => instance.destroy());
|
||||||
return (_themeManager ??= new ThemeManager());
|
|
||||||
}
|
export const getThemeManager = themeManager.get;
|
||||||
|
|
||||||
// test-only reset, so specs don't share persisted theme state
|
// test-only reset, so specs don't share persisted theme state
|
||||||
export function __resetThemeManager() {
|
export const __resetThemeManager = themeManager.reset;
|
||||||
_themeManager?.destroy();
|
|
||||||
_themeManager = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ThemeManager class exported for testing purposes
|
* ThemeManager class exported for testing purposes
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export { fitColumns } from './lib';
|
||||||
|
export {
|
||||||
|
__resetBoard,
|
||||||
|
type BoardStore,
|
||||||
|
FRAME_ROLE_GAP,
|
||||||
|
getBoard,
|
||||||
|
MAX_COLUMNS,
|
||||||
|
type RoleTypography,
|
||||||
|
} from './model';
|
||||||
@@ -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 (2–3).
|
||||||
|
*/
|
||||||
|
maxColumns: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many equal honest columns fit. Uses the real per-pairing required width
|
||||||
|
* (Pretext shrink-wrap) — the 45–75ch 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.',
|
||||||
|
};
|
||||||
@@ -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 (100–900).
|
||||||
|
*/
|
||||||
|
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 +0,0 @@
|
|||||||
export { FontSampler } from './ui';
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import FontSampler from './FontSampler/FontSampler.svelte';
|
|
||||||
|
|
||||||
export { FontSampler };
|
|
||||||
@@ -1 +1,6 @@
|
|||||||
export * from './filters/filters';
|
export { fetchProxyFilters } from './filters/filters';
|
||||||
|
export type {
|
||||||
|
FilterMetadata,
|
||||||
|
FilterOption,
|
||||||
|
ProxyFiltersResponse,
|
||||||
|
} from './filters/filters';
|
||||||
|
|||||||
+11
-10
@@ -23,7 +23,10 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createFilter } from '$shared/lib';
|
import {
|
||||||
|
createFilter,
|
||||||
|
createSingleton,
|
||||||
|
} from '$shared/lib';
|
||||||
import { createDebouncedState } from '$shared/lib/helpers';
|
import { createDebouncedState } from '$shared/lib/helpers';
|
||||||
import type {
|
import type {
|
||||||
FilterConfig,
|
FilterConfig,
|
||||||
@@ -129,8 +132,6 @@ export function createAppliedFilterStore<TValue extends string>(config: FilterCo
|
|||||||
|
|
||||||
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
|
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
|
||||||
|
|
||||||
let _appliedFilterStore: AppliedFilterStore | undefined;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide filter manager, created on first access.
|
* 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
|
* lives in `./bindings.svelte` and populates groups once backend filter
|
||||||
* metadata arrives.
|
* metadata arrives.
|
||||||
*/
|
*/
|
||||||
export function getAppliedFilterStore(): AppliedFilterStore {
|
const appliedFilterStore = createSingleton(() =>
|
||||||
return (_appliedFilterStore ??= createAppliedFilterStore<string>({
|
createAppliedFilterStore<string>({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: [],
|
groups: [],
|
||||||
}));
|
})
|
||||||
}
|
);
|
||||||
|
|
||||||
|
export const getAppliedFilterStore = appliedFilterStore.get;
|
||||||
|
|
||||||
// test-only reset, so specs don't share filter/selection state
|
// test-only reset, so specs don't share filter/selection state
|
||||||
export function __resetAppliedFilterStore() {
|
export const __resetAppliedFilterStore = appliedFilterStore.reset;
|
||||||
_appliedFilterStore = undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
-6
@@ -29,12 +29,6 @@ import { createAppliedFilterStore } from './appliedFilterStore.svelte';
|
|||||||
* testing Svelte 5 reactive code in Node.js.
|
* 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
|
// Helper to create test properties
|
||||||
function createTestProperties(count: number, selectedIndices: number[] = []): Property<string>[] {
|
function createTestProperties(count: number, selectedIndices: number[] = []): Property<string>[] {
|
||||||
return Array.from({ length: count }, (_, i) => ({
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
|
|||||||
+10
-11
@@ -20,8 +20,9 @@ import type { FilterMetadata } from '$features/FilterAndSortFonts/api/filters/fi
|
|||||||
import {
|
import {
|
||||||
DEFAULT_QUERY_GC_TIME_MS,
|
DEFAULT_QUERY_GC_TIME_MS,
|
||||||
DEFAULT_QUERY_STALE_TIME_MS,
|
DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
queryClient,
|
getQueryClient,
|
||||||
} from '$shared/api/queryClient';
|
} from '$shared/api/queryClient';
|
||||||
|
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||||
import {
|
import {
|
||||||
type QueryKey,
|
type QueryKey,
|
||||||
QueryObserver,
|
QueryObserver,
|
||||||
@@ -49,7 +50,7 @@ export class AvailableFilterStore {
|
|||||||
/**
|
/**
|
||||||
* Shared query client
|
* Shared query client
|
||||||
*/
|
*/
|
||||||
protected qc = queryClient;
|
protected qc = getQueryClient();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new filters store
|
* 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
|
* App-wide filter-metadata store, created on first access. Lazy so the
|
||||||
* QueryObserver isn't constructed at module load.
|
* QueryObserver isn't constructed at module load.
|
||||||
*/
|
*/
|
||||||
export function getAvailableFilterStore(): AvailableFilterStore {
|
const availableFilterStore = createSingleton(
|
||||||
return (_availableFilterStore ??= new AvailableFilterStore());
|
() => new AvailableFilterStore(),
|
||||||
}
|
instance => instance.destroy(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getAvailableFilterStore = availableFilterStore.get;
|
||||||
|
|
||||||
// test-only reset, so specs don't share a live observer
|
// test-only reset, so specs don't share a live observer
|
||||||
export function __resetAvailableFilterStore() {
|
export const __resetAvailableFilterStore = availableFilterStore.reset;
|
||||||
_availableFilterStore?.destroy();
|
|
||||||
_availableFilterStore = undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
import {
|
import {
|
||||||
afterEach,
|
afterEach,
|
||||||
beforeEach,
|
beforeEach,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* observer, so it lives at module scope, not in any individual widget.
|
* 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 { untrack } from 'svelte';
|
||||||
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
|
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
|
||||||
import { mapFilterMetadataToGroups } from '../../lib/mapper/mapFilterMetadataToGroups';
|
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.
|
* 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>;
|
export type SortStore = ReturnType<typeof createSortStore>;
|
||||||
|
|
||||||
let _sortStore: SortStore | undefined;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide sort store, created on first access.
|
* App-wide sort store, created on first access.
|
||||||
*/
|
*/
|
||||||
export function getSortStore(): SortStore {
|
const sortStore = createSingleton(() => createSortStore());
|
||||||
return (_sortStore ??= createSortStore());
|
|
||||||
}
|
export const getSortStore = sortStore.get;
|
||||||
|
|
||||||
// test-only reset, so specs don't share selection state
|
// test-only reset, so specs don't share selection state
|
||||||
export function __resetSortStore() {
|
export const __resetSortStore = sortStore.reset;
|
||||||
_sortStore = undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createRouter } from 'sv-router';
|
import { createRouter } from 'sv-router';
|
||||||
import Home from './Home.svelte';
|
import Home from './Home.svelte';
|
||||||
import Redirect from './Redirect.svelte';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-page router for glyphdiff.
|
* Single-page router for glyphdiff.
|
||||||
@@ -18,6 +17,8 @@ export const {
|
|||||||
'/': Home,
|
'/': Home,
|
||||||
/**
|
/**
|
||||||
* Any unmatched path redirects to home until additional routes exist.
|
* 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'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,11 +27,16 @@ export const QUERY_RETRY_BASE_DELAY_MS = 1000;
|
|||||||
*/
|
*/
|
||||||
export const QUERY_RETRY_MAX_DELAY_MS = 30000;
|
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.
|
* Construction is deferred to the first call so importing this module is inert:
|
||||||
* Used by all font stores for data fetching and caching.
|
* 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:
|
* Cache behavior:
|
||||||
* - Data stays fresh for 5 minutes (staleTime)
|
* - Data stays fresh for 5 minutes (staleTime)
|
||||||
@@ -39,30 +44,32 @@ export const QUERY_RETRY_MAX_DELAY_MS = 30000;
|
|||||||
* - No refetch on window focus (reduces unnecessary network requests)
|
* - No refetch on window focus (reduces unnecessary network requests)
|
||||||
* - 3 retries with exponential backoff on failure
|
* - 3 retries with exponential backoff on failure
|
||||||
*/
|
*/
|
||||||
export const queryClient = new QueryClient({
|
export function getQueryClient(): QueryClient {
|
||||||
defaultOptions: {
|
return (queryClientInstance ??= new QueryClient({
|
||||||
queries: {
|
defaultOptions: {
|
||||||
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
|
queries: {
|
||||||
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
/**
|
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
||||||
* Don't refetch when window regains focus
|
/**
|
||||||
*/
|
* Don't refetch when window regains focus
|
||||||
refetchOnWindowFocus: false,
|
*/
|
||||||
/**
|
refetchOnWindowFocus: false,
|
||||||
* Refetch on mount if data is stale
|
/**
|
||||||
*/
|
* Refetch on mount if data is stale
|
||||||
refetchOnMount: true,
|
*/
|
||||||
retry: (failureCount, error) => {
|
refetchOnMount: true,
|
||||||
if (error instanceof NonRetryableError) {
|
retry: (failureCount, error) => {
|
||||||
return false;
|
if (error instanceof NonRetryableError) {
|
||||||
}
|
return false;
|
||||||
return failureCount < QUERY_RETRY_COUNT;
|
}
|
||||||
|
return failureCount < QUERY_RETRY_COUNT;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
|
||||||
|
*/
|
||||||
|
retryDelay: attemptIndex =>
|
||||||
|
Math.min(QUERY_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, QUERY_RETRY_MAX_DELAY_MS),
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
|
|
||||||
*/
|
|
||||||
retryDelay: attemptIndex =>
|
|
||||||
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 {
|
import {
|
||||||
QueryObserver,
|
QueryObserver,
|
||||||
type QueryObserverOptions,
|
type QueryObserverOptions,
|
||||||
@@ -20,7 +20,7 @@ export abstract class BaseQueryStore<TData, TError = Error> {
|
|||||||
#unsubscribe: () => void;
|
#unsubscribe: () => void;
|
||||||
|
|
||||||
constructor(options: QueryObserverOptions<TData, TError, TData, any, any>) {
|
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.#unsubscribe = this.#observer.subscribe(result => {
|
||||||
this.#result = result;
|
this.#result = result;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
import {
|
import {
|
||||||
beforeEach,
|
beforeEach,
|
||||||
describe,
|
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.
|
* Owners that create this outside a component must call destroy() to dispose
|
||||||
* Values persist across browser sessions and are restored on page load.
|
* the save effect.
|
||||||
*
|
*
|
||||||
* Handles edge cases:
|
* @param key - localStorage key
|
||||||
* - SSR safety (no localStorage on server)
|
* @param defaultValue - value used when nothing is stored
|
||||||
* - 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
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export function createPersistentStore<T>(key: string, defaultValue: T) {
|
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 => {
|
const loadFromStorage = (): T => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
@@ -76,9 +25,13 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
|
|||||||
|
|
||||||
let value = $state<T>(loadFromStorage());
|
let value = $state<T>(loadFromStorage());
|
||||||
|
|
||||||
// Sync to storage whenever value changes
|
/**
|
||||||
// Wrapped in $effect.root to prevent memory leaks
|
* Sync to storage whenever value changes. The effect lives in an
|
||||||
$effect.root(() => {
|
* $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(() => {
|
$effect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -113,6 +66,15 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
|
|||||||
}
|
}
|
||||||
value = defaultValue;
|
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
|
* @vitest-environment jsdom
|
||||||
*/
|
*/
|
||||||
|
import { flushSync } from 'svelte';
|
||||||
import {
|
import {
|
||||||
afterEach,
|
afterEach,
|
||||||
beforeEach,
|
beforeEach,
|
||||||
@@ -376,4 +377,39 @@ describe('createPersistentStore', () => {
|
|||||||
expect(store.value[0].name).toBe('First');
|
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 offsets = $derived.by(() => {
|
||||||
const count = options.count;
|
const count = options.count;
|
||||||
// Implicit dependency on version signal
|
// Implicit dependency on version signal
|
||||||
const v = _version;
|
const _v = _version;
|
||||||
const result = new Float64Array(count);
|
const result = new Float64Array(count);
|
||||||
let accumulated = 0;
|
let accumulated = 0;
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -180,7 +180,7 @@ export function createVirtualizer<T>(
|
|||||||
// this derivation when the items array is replaced!
|
// this derivation when the items array is replaced!
|
||||||
const { count, data } = options;
|
const { count, data } = options;
|
||||||
// Implicit dependency
|
// Implicit dependency
|
||||||
const v = _version;
|
const _v = _version;
|
||||||
if (count === 0 || containerHeight === 0 || !data) {
|
if (count === 0 || containerHeight === 0 || !data) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -268,7 +268,6 @@ export function createVirtualizer<T>(
|
|||||||
return rect.top + scrollY;
|
return rect.top + scrollY;
|
||||||
};
|
};
|
||||||
|
|
||||||
let cachedOffsetTop = 0;
|
|
||||||
let rafId: number | null = null;
|
let rafId: number | null = null;
|
||||||
containerHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
|
containerHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
|
||||||
|
|
||||||
@@ -292,14 +291,12 @@ export function createVirtualizer<T>(
|
|||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
containerHeight = window.innerHeight;
|
containerHeight = window.innerHeight;
|
||||||
elementOffsetTop = getElementOffset();
|
elementOffsetTop = getElementOffset();
|
||||||
cachedOffsetTop = elementOffsetTop;
|
|
||||||
handleScroll();
|
handleScroll();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial setup
|
// Initial setup
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
elementOffsetTop = getElementOffset();
|
elementOffsetTop = getElementOffset();
|
||||||
cachedOffsetTop = elementOffsetTop;
|
|
||||||
handleScroll();
|
handleScroll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,20 @@ export {
|
|||||||
type PerspectiveManager,
|
type PerspectiveManager,
|
||||||
} from './createPerspectiveManager/createPerspectiveManager.svelte';
|
} 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.
|
* BaseQueryStore is intentionally NOT re-exported here.
|
||||||
* It pulls @tanstack/query-core, so routing it through this leaf barrel would
|
* It pulls @tanstack/query-core, so routing it through this leaf barrel would
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export {
|
|||||||
createPersistentStore,
|
createPersistentStore,
|
||||||
createPerspectiveManager,
|
createPerspectiveManager,
|
||||||
createResponsiveManager,
|
createResponsiveManager,
|
||||||
|
createSingleton,
|
||||||
createVirtualizer,
|
createVirtualizer,
|
||||||
type Entity,
|
type Entity,
|
||||||
type EntityStore,
|
type EntityStore,
|
||||||
@@ -21,6 +22,7 @@ export {
|
|||||||
type Property,
|
type Property,
|
||||||
type ResponsiveManager,
|
type ResponsiveManager,
|
||||||
responsiveManager,
|
responsiveManager,
|
||||||
|
type Singleton,
|
||||||
type VirtualItem,
|
type VirtualItem,
|
||||||
type Virtualizer,
|
type Virtualizer,
|
||||||
type VirtualizerOptions,
|
type VirtualizerOptions,
|
||||||
@@ -31,7 +33,9 @@ export {
|
|||||||
clampNumber,
|
clampNumber,
|
||||||
cn,
|
cn,
|
||||||
debounce,
|
debounce,
|
||||||
|
ensureCanvasFonts,
|
||||||
getDecimalPlaces,
|
getDecimalPlaces,
|
||||||
|
getPretextFontString,
|
||||||
roundToStepPrecision,
|
roundToStepPrecision,
|
||||||
smoothScroll,
|
smoothScroll,
|
||||||
splitArray,
|
splitArray,
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* ============================================================================
|
* Storybook helpers: components and utilities for stories.
|
||||||
* STORYBOOK HELPERS
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Helper components and utilities for Storybook stories.
|
|
||||||
*
|
*
|
||||||
* ## Usage
|
* ## Usage
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ import type {
|
|||||||
TransitionConfig,
|
TransitionConfig,
|
||||||
} from 'svelte/transition';
|
} 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) {
|
function gentleSpring(t: number) {
|
||||||
return 1 - Math.pow(1 - t, 3) * Math.cos(t * Math.PI * 2);
|
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}"`;
|
||||||
|
}
|
||||||
@@ -17,7 +17,9 @@ export {
|
|||||||
export { clampNumber } from './clampNumber/clampNumber';
|
export { clampNumber } from './clampNumber/clampNumber';
|
||||||
export { cn } from './cn';
|
export { cn } from './cn';
|
||||||
export { debounce } from './debounce/debounce';
|
export { debounce } from './debounce/debounce';
|
||||||
|
export { ensureCanvasFonts } from './ensureCanvasFonts/ensureCanvasFonts';
|
||||||
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
||||||
|
export { getPretextFontString } from './getPretextFontString/getPretextFontString';
|
||||||
export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth';
|
export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth';
|
||||||
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
|
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
|
||||||
export { smoothScroll } from './smoothScroll/smoothScroll';
|
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
|
* 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) {
|
export function splitArray<T>(array: T[], callback: (item: T) => boolean) {
|
||||||
return array.reduce<[T[], T[]]>(
|
return array.reduce<[T[], T[]]>(
|
||||||
([pass, fail], item) => (
|
([pass, fail], item) => {
|
||||||
callback(item) ? pass.push(item) : fail.push(item), [pass, fail]
|
if (callback(item)) {
|
||||||
),
|
pass.push(item);
|
||||||
|
} else {
|
||||||
|
fail.push(item);
|
||||||
|
}
|
||||||
|
return [pass, fail];
|
||||||
|
},
|
||||||
[[], []],
|
[[], []],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import {
|
import {
|
||||||
type LabelSize,
|
type LabelSize,
|
||||||
labelSizeConfig,
|
labelSizeConfig,
|
||||||
} from '$shared/ui/Label/config';
|
} from '../labelConfig';
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
|
||||||
|
|
||||||
type BadgeVariant = 'default' | 'accent' | 'success' | 'warning' | 'info';
|
type BadgeVariant = 'default' | 'accent' | 'success' | 'warning' | 'info';
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
import { Slider } from '$shared/ui';
|
|
||||||
import { Button } from '$shared/ui/Button';
|
|
||||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
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 TechText from '../TechText/TechText.svelte';
|
||||||
import type {
|
import type {
|
||||||
ControlLabels,
|
ControlLabels,
|
||||||
@@ -84,9 +84,11 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
|||||||
{formattedValue()}
|
{formattedValue()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── FULL MODE ──────────────────────────────────────────────────────────────── -->
|
|
||||||
{:else}
|
{:else}
|
||||||
|
<!--
|
||||||
|
FULL MODE
|
||||||
|
+/- buttons flanking a slider popover.
|
||||||
|
-->
|
||||||
<div class={cn('flex items-center px-1 relative', className)}>
|
<div class={cn('flex items-center px-1 relative', className)}>
|
||||||
<!-- Decrease button -->
|
<!-- Decrease button -->
|
||||||
<Button
|
<Button
|
||||||
@@ -103,59 +105,55 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
|||||||
|
|
||||||
<!-- Trigger -->
|
<!-- Trigger -->
|
||||||
<div class="relative mx-1">
|
<div class="relative mx-1">
|
||||||
<Popover.Root bind:open>
|
<Popover bind:open side="top" align="center">
|
||||||
<Popover.Trigger>
|
{#snippet trigger(props)}
|
||||||
{#snippet child({ props })}
|
<button
|
||||||
<button
|
{...props}
|
||||||
{...props}
|
class={cn(
|
||||||
class={cn(
|
'flex flex-col flex-center w-14 py-1',
|
||||||
'flex flex-col flex-center w-14 py-1',
|
'select-none rounded-none transition-all duration-fast',
|
||||||
'select-none rounded-none transition-all duration-fast',
|
'border border-transparent',
|
||||||
'border border-transparent',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
|
open
|
||||||
open
|
? 'surface-card-elevated'
|
||||||
? 'surface-card-elevated'
|
: 'hover:bg-paper/50 dark:hover:bg-dark-card/50',
|
||||||
: 'hover:bg-paper/50 dark:hover:bg-dark-card/50',
|
)}
|
||||||
)}
|
aria-label={controlLabel ? `${controlLabel}: ${formattedValue()}` : undefined}
|
||||||
aria-label={controlLabel ? `${controlLabel}: ${formattedValue()}` : undefined}
|
>
|
||||||
>
|
<!-- Label row -->
|
||||||
<!-- Label row -->
|
{#if displayLabel}
|
||||||
{#if displayLabel}
|
<span
|
||||||
<span
|
class="
|
||||||
class="
|
text-3xs text-label-mono
|
||||||
text-3xs text-label-mono
|
text-neutral-900 dark:text-neutral-100
|
||||||
text-neutral-900 dark:text-neutral-100
|
mb-0.5 leading-none
|
||||||
mb-0.5 leading-none
|
"
|
||||||
"
|
>
|
||||||
>
|
{displayLabel}
|
||||||
{displayLabel}
|
</span>
|
||||||
</span>
|
{/if}
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Value row -->
|
<!-- Value row -->
|
||||||
<TechText variant="muted" size="md">
|
<TechText variant="muted" size="md">
|
||||||
{formattedValue()}
|
{formattedValue()}
|
||||||
</TechText>
|
</TechText>
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Popover.Trigger>
|
|
||||||
|
|
||||||
<!-- Vertical slider popover -->
|
<!-- Vertical slider popover -->
|
||||||
<Popover.Content
|
{#snippet children()}
|
||||||
class="w-auto py-4 px-3 h-64 flex-center rounded-none surface-card-elevated"
|
<div class="w-auto py-4 px-3 h-64 flex-center rounded-none surface-card-elevated">
|
||||||
align="center"
|
<Slider
|
||||||
side="top"
|
class="h-full"
|
||||||
>
|
bind:value={control.value}
|
||||||
<Slider
|
min={control.min}
|
||||||
class="h-full"
|
max={control.max}
|
||||||
bind:value={control.value}
|
step={control.step}
|
||||||
min={control.min}
|
orientation="vertical"
|
||||||
max={control.max}
|
/>
|
||||||
step={control.step}
|
</div>
|
||||||
orientation="vertical"
|
{/snippet}
|
||||||
/>
|
</Popover>
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Increase button -->
|
<!-- Increase button -->
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
|
within,
|
||||||
} from '@testing-library/svelte';
|
} from '@testing-library/svelte';
|
||||||
import ComboControl from './ComboControl.svelte';
|
import ComboControl from './ComboControl.svelte';
|
||||||
import { createNumericControlMock } from './testing/createNumericControlMock.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('ComboControl', () => {
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it('renders decrease and increase buttons', () => {
|
it('renders decrease and increase buttons', () => {
|
||||||
@@ -26,17 +37,17 @@ describe('ComboControl', () => {
|
|||||||
|
|
||||||
it('renders the current integer value', () => {
|
it('renders the current integer value', () => {
|
||||||
render(ComboControl, { control: makeControl(42) });
|
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', () => {
|
it('formats decimal value to 1 decimal place when step >= 0.1', () => {
|
||||||
render(ComboControl, { control: makeControl(1.5, { 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', () => {
|
it('formats decimal value to 2 decimal places when step < 0.1', () => {
|
||||||
render(ComboControl, { control: makeControl(1.55, { step: 0.01 }) });
|
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', () => {
|
it('renders label when label prop is provided', () => {
|
||||||
@@ -106,16 +117,32 @@ describe('ComboControl', () => {
|
|||||||
const control = makeControl(50);
|
const control = makeControl(50);
|
||||||
render(ComboControl, { control });
|
render(ComboControl, { control });
|
||||||
await fireEvent.click(screen.getByLabelText('Increase'));
|
await fireEvent.click(screen.getByLabelText('Increase'));
|
||||||
await waitFor(() => expect(screen.getByText('51')).toBeInTheDocument());
|
await waitFor(() => expect(within(getTrigger()).getByText('51')).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Popover', () => {
|
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' });
|
render(ComboControl, { control: makeControl(50), controlLabel: 'Size control' });
|
||||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
|
||||||
await fireEvent.click(screen.getByText('Size control'));
|
const trigger = getTrigger();
|
||||||
await waitFor(() => expect(screen.getByRole('slider')).toBeInTheDocument());
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Filter } from '$shared/lib';
|
import type { Filter } from '$shared/lib';
|
||||||
import { cn } 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 ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||||
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
|
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
@@ -14,6 +12,8 @@ import {
|
|||||||
draw,
|
draw,
|
||||||
fly,
|
fly,
|
||||||
} from 'svelte/transition';
|
} from 'svelte/transition';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
import Label from '../Label/Label.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { default as Input } from './Input.svelte';
|
export { default as Input } from './Input.svelte';
|
||||||
|
export { inputIconSize } from './types';
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
type LabelVariant,
|
type LabelVariant,
|
||||||
labelSizeConfig,
|
labelSizeConfig,
|
||||||
labelVariantConfig,
|
labelVariantConfig,
|
||||||
} from './config';
|
} from '../labelConfig';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
import { Badge } from '$shared/ui';
|
import Badge from '../Badge/Badge.svelte';
|
||||||
|
|
||||||
interface Props {
|
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>
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
Reference in New Issue
Block a user