Compare commits
91 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 | |||
| b3bc40b76c | |||
| 839460726e | |||
| 6877807aaf | |||
| 3dca11fea8 | |||
| 0b675635b3 | |||
| 9780ff9358 | |||
| 1ad015aed6 | |||
| 10603d18bf | |||
| 39d1ce4c37 | |||
| fcd61be4fa | |||
| 28a8e49915 | |||
| 43e8507144 | |||
| 67af3d946a | |||
| c6d0270072 | |||
| a677dc6b0b | |||
| f7cd6b5081 | |||
| dda8ef6368 | |||
| d77b51736a | |||
| 1e16330097 | |||
| c41016ac5d | |||
| aa4189f6a8 | |||
| 17c022470e | |||
| a9f3b990ab | |||
| 36673597f7 |
+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,5 +1,28 @@
|
|||||||
:3000 {
|
:3000 {
|
||||||
root * /usr/share/caddy
|
root * /usr/share/caddy
|
||||||
file_server
|
|
||||||
|
# Compress text responses only. woff2/png and other binaries are already
|
||||||
|
# compressed, so they're excluded — re-compressing them burns CPU for ~0%.
|
||||||
|
encode {
|
||||||
|
zstd
|
||||||
|
gzip
|
||||||
|
match {
|
||||||
|
header Content-Type text/*
|
||||||
|
header Content-Type application/javascript*
|
||||||
|
header Content-Type application/json*
|
||||||
|
header Content-Type image/svg+xml*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vite emits all build output under /assets/ with content-hashed filenames,
|
||||||
|
# so those bytes never change for a given URL — cache them indefinitely.
|
||||||
|
@assets path /assets/*
|
||||||
|
header @assets Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
# The HTML shell is the un-hashed entry point; it must revalidate so a new
|
||||||
|
# deploy is served immediately rather than from a stale cache.
|
||||||
|
header /index.html Cache-Control "no-cache"
|
||||||
|
|
||||||
try_files {path} /index.html
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-1
@@ -4,6 +4,10 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"packageManager": "yarn@4.11.0",
|
"packageManager": "yarn@4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"sideEffects": [
|
||||||
|
"*.css",
|
||||||
|
"**/router.ts"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -44,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",
|
||||||
|
|||||||
+9
-4
@@ -16,12 +16,17 @@
|
|||||||
*/
|
*/
|
||||||
import '$routes/router';
|
import '$routes/router';
|
||||||
import { Router } from 'sv-router';
|
import { Router } from 'sv-router';
|
||||||
import { QueryProvider } from './providers';
|
import {
|
||||||
|
AppBindingsProvider,
|
||||||
|
QueryProvider,
|
||||||
|
} from './providers';
|
||||||
import Layout from './ui/Layout.svelte';
|
import Layout from './ui/Layout.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<Layout>
|
<AppBindingsProvider>
|
||||||
<Router />
|
<Layout>
|
||||||
</Layout>
|
<Router />
|
||||||
|
</Layout>
|
||||||
|
</AppBindingsProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,24 @@
|
|||||||
|
<!--
|
||||||
|
Component: AppBindings
|
||||||
|
Provider that starts app-wide store bindings (filters → sort → font catalog)
|
||||||
|
for its subtree. Mount-scoped so the bindings' lifetime tracks the app tree.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { startFilterBindings } from '$features/FilterAndSortFonts';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Content snippet
|
||||||
|
*/
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props();
|
||||||
|
|
||||||
|
// startFilterBindings returns its $effect.root cleanup; onMount runs it on unmount.
|
||||||
|
onMount(() => startFilterBindings());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children?.()}
|
||||||
@@ -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}>
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export { default as AppBindingsProvider } from './AppBindings.svelte';
|
||||||
export { default as QueryProvider } from './QueryProvider.svelte';
|
export { default as QueryProvider } from './QueryProvider.svelte';
|
||||||
|
|||||||
+16
-23
@@ -1,5 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
@import "./fonts.css";
|
||||||
|
|
||||||
@variant dark (&:where(.dark, .dark *));
|
@variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@@ -216,9 +217,7 @@
|
|||||||
/* Monospace label tracking — used in Loader and Footnote */
|
/* Monospace label tracking — used in Loader and Footnote */
|
||||||
--tracking-wider-mono: 0.2em;
|
--tracking-wider-mono: 0.2em;
|
||||||
|
|
||||||
/* ============================================
|
/* Shadow tokens */
|
||||||
SHADOW TOKENS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Default resting shadow — equivalent to Tailwind's shadow-sm. Used on
|
/* Default resting shadow — equivalent to Tailwind's shadow-sm. Used on
|
||||||
buttons, sliders, popover triggers in non-floating state. */
|
buttons, sliders, popover triggers in non-floating state. */
|
||||||
@@ -245,9 +244,7 @@
|
|||||||
/* Drawer / overlay shadow — full-strength shadow-2xl. */
|
/* Drawer / overlay shadow — full-strength shadow-2xl. */
|
||||||
--shadow-overlay: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
--shadow-overlay: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||||
|
|
||||||
/* ============================================
|
/* Motion tokens */
|
||||||
MOTION TOKENS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
--duration-fast: 150ms;
|
--duration-fast: 150ms;
|
||||||
--duration-normal: 200ms;
|
--duration-normal: 200ms;
|
||||||
@@ -274,7 +271,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
font-family: var(--font-secondary);
|
||||||
font-optical-sizing: auto;
|
font-optical-sizing: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,9 +322,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* Design-system utilities.
|
||||||
DESIGN-SYSTEM UTILITIES
|
|
||||||
============================================
|
|
||||||
Defined via `@utility` (Tailwind v4) so they integrate with the variant
|
Defined via `@utility` (Tailwind v4) so they integrate with the variant
|
||||||
system (`hover:`, `dark:`, breakpoints) and don't rely on `@apply`
|
system (`hover:`, `dark:`, breakpoints) and don't rely on `@apply`
|
||||||
chains. Colors reference the mode-switching semantic vars defined in
|
chains. Colors reference the mode-switching semantic vars defined in
|
||||||
@@ -362,7 +357,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Surface utilities ────────────────────────────────────────── */
|
/* Surface utilities */
|
||||||
|
|
||||||
@utility surface-canvas {
|
@utility surface-canvas {
|
||||||
background-color: var(--color-surface);
|
background-color: var(--color-surface);
|
||||||
@@ -391,7 +386,7 @@
|
|||||||
border: 1px solid var(--color-border-subtle);
|
border: 1px solid var(--color-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Shape / layout ───────────────────────────────────────────── */
|
/* Shape / layout */
|
||||||
|
|
||||||
@utility flex-center {
|
@utility flex-center {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -422,7 +417,7 @@
|
|||||||
background-size: 10px 10px;
|
background-size: 10px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Typography ───────────────────────────────────────────────── */
|
/* Typography */
|
||||||
|
|
||||||
@utility text-label-mono {
|
@utility text-label-mono {
|
||||||
font-family: var(--font-primary);
|
font-family: var(--font-primary);
|
||||||
@@ -431,7 +426,7 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global utility - useful across your app */
|
/* Honor prefers-reduced-motion: collapse animation and transition timing. */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* {
|
* {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
@@ -440,12 +435,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Performance optimization for collapsible elements */
|
/* Hint the upcoming height animation on open collapsibles. */
|
||||||
[data-state="open"] {
|
[data-state="open"] {
|
||||||
will-change: height;
|
will-change: height;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth focus transitions - good globally */
|
/* Transition siblings of a focus-visible peer. */
|
||||||
.peer:focus-visible ~ * {
|
.peer:focus-visible ~ * {
|
||||||
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
@@ -472,11 +467,9 @@
|
|||||||
animation: nudge 10s ease-in-out infinite;
|
animation: nudge 10s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* Scrollbar styling */
|
||||||
SCROLLBAR STYLES
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* ---- Modern API: color + width (Chrome 121+, FF 64+) ---- */
|
/* Standard API: color + width (Chrome 121+, Firefox 64+). */
|
||||||
@supports (scrollbar-width: auto) {
|
@supports (scrollbar-width: auto) {
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@@ -488,8 +481,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Webkit layer: runs ON TOP in Chrome, standalone in old Safari ---- */
|
/* WebKit fallback: applies on top of the standard API in Chrome, standalone in
|
||||||
/* Handles things scrollbar-width can't: hiding buttons, exact sizing */
|
older Safari. Covers what scrollbar-width can't — hiding buttons, exact sizing. */
|
||||||
@supports selector(::-webkit-scrollbar) {
|
@supports selector(::-webkit-scrollbar) {
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
@@ -497,7 +490,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-button {
|
::-webkit-scrollbar-button {
|
||||||
display: none; /* kills arrows */
|
display: none; /* hide scrollbar buttons */
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
Self-hosted interface fonts (latin subset only).
|
||||||
|
Vendored from @fontsource — see docs/interface-font-selfhost-benchmark.md.
|
||||||
|
Variable faces (Inter, Space Grotesk) keep their wght axis; Inter also keeps opsz.
|
||||||
|
url()s are resolved + content-hashed by Vite at build → immutable long-cache.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Inter — variable wght + opsz, the body/secondary UI font (--font-secondary) */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 100 900;
|
||||||
|
src: url('../assets/fonts/inter-latin-opsz-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 100 900;
|
||||||
|
src: url('../assets/fonts/inter-latin-opsz-italic.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Space Grotesk — variable wght, the primary/display UI font (--font-primary) */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 300 700;
|
||||||
|
src: url('../assets/fonts/space-grotesk-latin-wght-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Space Mono — static 400/700 × roman/italic (--font-mono) */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('../assets/fonts/space-mono-latin-400-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('../assets/fonts/space-mono-latin-400-italic.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url('../assets/fonts/space-mono-latin-700-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url('../assets/fonts/space-mono-latin-700-italic.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Syne — static 800, the logo font (--font-logo) */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Syne';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 800;
|
||||||
|
src: url('../assets/fonts/syne-latin-800-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
Vendored
+5
@@ -38,6 +38,11 @@ declare module '*.jpg' {
|
|||||||
|
|
||||||
declare module '*.css';
|
declare module '*.css';
|
||||||
|
|
||||||
|
declare module '*.woff2?url' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
|
|||||||
+20
-25
@@ -3,12 +3,20 @@
|
|||||||
Application shell with providers and page wrapper
|
Application shell with providers and page wrapper
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { themeManager } from '$features/ChangeAppTheme';
|
import { getThemeManager } from '$features/ChangeAppTheme';
|
||||||
import G from '$shared/assets/G.svg';
|
import G from '$shared/assets/G.svg';
|
||||||
import { ResponsiveProvider } from '$shared/lib';
|
import { ResponsiveProvider } from '$shared/lib';
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
import { Footer } from '$widgets/Footer';
|
import { Footer } from '$widgets/Footer';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Preload the two render-critical interface faces (primary + secondary).
|
||||||
|
`?url` resolves to the content-hashed path Vite emits, so the binary is
|
||||||
|
fetched immediately rather than waiting for CSS @font-face discovery.
|
||||||
|
*/
|
||||||
|
import interWoff2 from '../assets/fonts/inter-latin-opsz-normal.woff2?url';
|
||||||
|
import spaceGroteskWoff2 from '../assets/fonts/space-grotesk-latin-wght-normal.woff2?url';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type Snippet,
|
type Snippet,
|
||||||
onDestroy,
|
onDestroy,
|
||||||
@@ -24,6 +32,8 @@ interface Props {
|
|||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
let fontsReady = $state(true);
|
let fontsReady = $state(true);
|
||||||
|
|
||||||
|
const themeManager = getThemeManager();
|
||||||
const theme = $derived(themeManager.value);
|
const theme = $derived(themeManager.value);
|
||||||
|
|
||||||
onMount(() => themeManager.init());
|
onMount(() => themeManager.init());
|
||||||
@@ -33,36 +43,21 @@ onDestroy(() => themeManager.destroy());
|
|||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={G} type="image/svg+xml" />
|
<link rel="icon" href={G} type="image/svg+xml" />
|
||||||
|
|
||||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
<!-- Self-hosted interface fonts (see src/app/styles/fonts/fonts.css). Preload the two critical faces. -->
|
||||||
<link
|
<link
|
||||||
rel="preconnect"
|
rel="preload"
|
||||||
href="https://cdn.fontshare.com"
|
as="font"
|
||||||
crossorigin="anonymous"
|
type="font/woff2"
|
||||||
/>
|
href={interWoff2}
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link
|
|
||||||
rel="preconnect"
|
|
||||||
href="https://fonts.gstatic.com"
|
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
/>
|
/>
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
as="style"
|
as="font"
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
|
type="font/woff2"
|
||||||
|
href={spaceGroteskWoff2}
|
||||||
|
crossorigin="anonymous"
|
||||||
/>
|
/>
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
|
|
||||||
media="print"
|
|
||||||
onload={(e => ((e.currentTarget as HTMLLinkElement).media = 'all'))}
|
|
||||||
/>
|
|
||||||
<noscript>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
|
|
||||||
/>
|
|
||||||
</noscript>
|
|
||||||
<title>GlyphDiff | Typography & Typefaces</title>
|
<title>GlyphDiff | Typography & Typefaces</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './store/scrollBreadcrumbsStore.svelte';
|
|
||||||
export * from './types/types.ts';
|
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -1,8 +1,93 @@
|
|||||||
export * from './api';
|
export {
|
||||||
export * from './domain';
|
computeLineRenderModel,
|
||||||
export * from './lib';
|
DualFontLayout,
|
||||||
export * from './model';
|
findSplitIndex,
|
||||||
export * from './ui';
|
windowSizeForLine,
|
||||||
|
} from './domain';
|
||||||
|
export type {
|
||||||
|
ComparisonLine,
|
||||||
|
ComparisonResult,
|
||||||
|
LineRenderModel,
|
||||||
|
} from './domain';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createFontRowSizeResolver,
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
getFontUrl,
|
||||||
|
} from './lib';
|
||||||
|
export type { FontRowSizeResolverOptions } from './lib';
|
||||||
|
|
||||||
|
export {
|
||||||
|
FontApplicator,
|
||||||
|
FontSampler,
|
||||||
|
FontVirtualList,
|
||||||
|
} from './ui';
|
||||||
|
|
||||||
|
// Pure model surface (types + constants).
|
||||||
|
export {
|
||||||
|
DEFAULT_FONT_SIZE,
|
||||||
|
DEFAULT_FONT_WEIGHT,
|
||||||
|
DEFAULT_LETTER_SPACING,
|
||||||
|
DEFAULT_LINE_HEIGHT,
|
||||||
|
FONT_SIZE_STEP,
|
||||||
|
FONT_WEIGHT_STEP,
|
||||||
|
LETTER_SPACING_STEP,
|
||||||
|
LINE_HEIGHT_STEP,
|
||||||
|
MAX_FONT_SIZE,
|
||||||
|
MAX_FONT_WEIGHT,
|
||||||
|
MAX_LETTER_SPACING,
|
||||||
|
MAX_LINE_HEIGHT,
|
||||||
|
MIN_FONT_SIZE,
|
||||||
|
MIN_FONT_WEIGHT,
|
||||||
|
MIN_LETTER_SPACING,
|
||||||
|
MIN_LINE_HEIGHT,
|
||||||
|
VIRTUAL_INDEX_NOT_LOADED,
|
||||||
|
} from './model/const/const';
|
||||||
|
export type {
|
||||||
|
FilterGroup,
|
||||||
|
FilterType,
|
||||||
|
FontCategory,
|
||||||
|
FontCollectionFilters,
|
||||||
|
FontCollectionSort,
|
||||||
|
FontCollectionState,
|
||||||
|
FontFeatures,
|
||||||
|
FontFilters,
|
||||||
|
FontLoadRequestConfig,
|
||||||
|
FontLoadStatus,
|
||||||
|
FontMetadata,
|
||||||
|
FontProvider,
|
||||||
|
FontStyleUrls,
|
||||||
|
FontSubset,
|
||||||
|
FontVariant,
|
||||||
|
FontWeight,
|
||||||
|
FontWeightItalic,
|
||||||
|
UnifiedFont,
|
||||||
|
UnifiedFontVariant,
|
||||||
|
} from './model/types';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Stores are exposed as lazy accessors / classes (not eager singletons): the
|
||||||
|
* entity's public API is complete, so consumers go through this barrel instead
|
||||||
|
* of deep-importing `./model` (FSD public-API boundary). Construction happens on
|
||||||
|
* first call, so this is inert at import. The slice root already transitively
|
||||||
|
* loads `@tanstack/query-core` via `./ui` (FontVirtualList), so surfacing the
|
||||||
|
* stores here adds no new eager cost.
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
FontLifecycleManager,
|
||||||
|
FontsByIdsStore,
|
||||||
|
getFontCatalog,
|
||||||
|
getFontLifecycleManager,
|
||||||
|
} from './model';
|
||||||
|
export type { FontCatalogStore } from './model';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* `./api` (proxy clients: `fetchProxyFonts`, `seedFontCache`, …) is intentionally
|
||||||
|
* NOT re-exported here — those are not part of the entity's consumed surface and
|
||||||
|
* importing them eagerly constructs the TanStack `queryClient`. Import via the
|
||||||
|
* segment: `import { fetchProxyFonts } from '$entities/Font/api'`.
|
||||||
|
*/
|
||||||
|
|
||||||
// `./testing` is intentionally not re-exported: fixtures must not leak into the
|
// `./testing` is intentionally not re-exported: fixtures must not leak into the
|
||||||
// production public API. Import them via `$entities/Font/testing`.
|
// production public API. Import them via `$entities/Font/testing`.
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import type { UnifiedFont } from '../../model/types';
|
||||||
|
import { createFontLoadRequestContfig } from './createFontLoadRequestContfig';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal UnifiedFont mock — override only the fields a case exercises.
|
||||||
|
*/
|
||||||
|
function createMockFont(overrides: Partial<UnifiedFont> = {}): UnifiedFont {
|
||||||
|
const baseFont: UnifiedFont = {
|
||||||
|
id: 'test-font',
|
||||||
|
name: 'Test Font',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin'],
|
||||||
|
variants: [],
|
||||||
|
styles: {},
|
||||||
|
metadata: {
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
isVariable: false,
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...baseFont, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createFontLoadRequestContfig', () => {
|
||||||
|
it('builds a single-element config when a URL resolves', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
id: 'roboto',
|
||||||
|
name: 'Roboto',
|
||||||
|
styles: { variants: { '400': 'https://example.com/roboto-400.woff2' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = createFontLoadRequestContfig(font, 400);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
id: 'roboto',
|
||||||
|
name: 'Roboto',
|
||||||
|
weight: 400,
|
||||||
|
url: 'https://example.com/roboto-400.woff2',
|
||||||
|
isVariable: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when no URL resolves (flatMap drops the font)', () => {
|
||||||
|
const font = createMockFont({ styles: {} });
|
||||||
|
|
||||||
|
expect(createFontLoadRequestContfig(font, 400)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards isVariable from font features', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
features: { isVariable: true, tags: [] },
|
||||||
|
styles: { variants: { '700': 'https://example.com/inter-vf.woff2' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [config] = createFontLoadRequestContfig(font, 700);
|
||||||
|
|
||||||
|
expect(config.isVariable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets isVariable to undefined when features is absent', () => {
|
||||||
|
// features is non-optional on UnifiedFont, but upstream data can be partial —
|
||||||
|
// the optional chain must not throw, and isVariable stays undefined.
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: { variants: { '400': 'https://example.com/font.woff2' } },
|
||||||
|
});
|
||||||
|
// @ts-expect-error — deliberately drop the guaranteed field to exercise the optional chain
|
||||||
|
font.features = undefined;
|
||||||
|
|
||||||
|
const [config] = createFontLoadRequestContfig(font, 400);
|
||||||
|
|
||||||
|
expect(config.isVariable).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the resolved fallback URL, not just exact matches', () => {
|
||||||
|
// getFontUrl falls back to styles.regular when the exact weight is missing;
|
||||||
|
// the config must carry whatever URL actually resolved.
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: { regular: 'https://example.com/font-regular.woff2' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [config] = createFontLoadRequestContfig(font, 900);
|
||||||
|
|
||||||
|
expect(config.url).toBe('https://example.com/font-regular.woff2');
|
||||||
|
expect(config.weight).toBe(900);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the requested weight even when the URL is a shared fallback', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: { variants: { '400': 'https://example.com/shared.woff2' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createFontLoadRequestContfig(font, 700)[0].weight).toBe(700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates the invalid-weight error from getFontUrl', () => {
|
||||||
|
const font = createMockFont();
|
||||||
|
|
||||||
|
expect(() => createFontLoadRequestContfig(font, 450)).toThrow('Invalid weight: 450');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type {
|
||||||
|
FontLoadRequestConfig,
|
||||||
|
UnifiedFont,
|
||||||
|
} from '../../model';
|
||||||
|
import { getFontUrl } from '../getFontUrl/getFontUrl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the font-lifecycle load request for a single font at a given weight.
|
||||||
|
*
|
||||||
|
* Returns a 0-or-1 element array rather than `FontLoadRequestConfig | undefined`
|
||||||
|
* so call sites can `flatMap` over a font list — resolve the URL and drop fonts
|
||||||
|
* that have none in a single pass, with no separate filter step. An empty array
|
||||||
|
* means the font has no loadable asset for this weight (or its fallbacks) and is
|
||||||
|
* silently skipped.
|
||||||
|
*
|
||||||
|
* `isVariable` is forwarded from the font's features so the lifecycle manager can
|
||||||
|
* dedupe variable fonts per ID (they load once regardless of weight) while still
|
||||||
|
* loading static fonts per weight.
|
||||||
|
*
|
||||||
|
* @param font - Unified font to load
|
||||||
|
* @param weight - Numeric weight (100-900)
|
||||||
|
* @returns Single-element config array, or `[]` when no URL resolves
|
||||||
|
* @throws Error when weight is outside the valid 100-900 range (propagated from `getFontUrl`)
|
||||||
|
*/
|
||||||
|
export function createFontLoadRequestContfig(font: UnifiedFont, weight: number): FontLoadRequestConfig[] {
|
||||||
|
const url = getFontUrl(font, weight);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ id: font.id, name: font.name, weight, url, isVariable: font.features?.isVariable }];
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NonRetryableError } from '$shared/api/queryClient';
|
import { NonRetryableError } from '$shared/api/nonRetryableError';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thrown when the network request to the proxy API fails.
|
* Thrown when the network request to the proxy API fails.
|
||||||
|
|||||||
@@ -1,3 +1,51 @@
|
|||||||
export * from './const/const';
|
export {
|
||||||
export * from './store';
|
DEFAULT_FONT_SIZE,
|
||||||
export * from './types';
|
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';
|
||||||
|
|
||||||
|
// Stores (lazy accessors + classes)
|
||||||
|
export {
|
||||||
|
__resetFontLifecycleManager,
|
||||||
|
FontLifecycleManager,
|
||||||
|
FontsByIdsStore,
|
||||||
|
getFontCatalog,
|
||||||
|
getFontLifecycleManager,
|
||||||
|
} from './store';
|
||||||
|
export type { FontCatalogStore } from './store';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
FilterGroup,
|
||||||
|
FilterType,
|
||||||
|
FontCategory,
|
||||||
|
FontCollectionFilters,
|
||||||
|
FontCollectionSort,
|
||||||
|
FontCollectionState,
|
||||||
|
FontFeatures,
|
||||||
|
FontFilters,
|
||||||
|
FontLoadRequestConfig,
|
||||||
|
FontLoadStatus,
|
||||||
|
FontMetadata,
|
||||||
|
FontProvider,
|
||||||
|
FontStyleUrls,
|
||||||
|
FontSubset,
|
||||||
|
FontVariant,
|
||||||
|
FontWeight,
|
||||||
|
FontWeightItalic,
|
||||||
|
UnifiedFont,
|
||||||
|
UnifiedFontVariant,
|
||||||
|
} from './types';
|
||||||
|
|||||||
@@ -27,18 +27,21 @@ vi.mock('$shared/api/queryClient', async importOriginal => {
|
|||||||
*/
|
*/
|
||||||
const { QueryClient } = await import('@tanstack/query-core');
|
const { 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,8 +484,12 @@ export class FontCatalogStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFontCatalogStore(params: FontStoreParams = {}): FontCatalogStore {
|
const catalog = createSingleton(
|
||||||
return new FontCatalogStore(params);
|
() => new FontCatalogStore({ limit: 50 }),
|
||||||
}
|
instance => instance.destroy(),
|
||||||
|
);
|
||||||
|
|
||||||
export const fontCatalogStore = new FontCatalogStore({ limit: 50 });
|
export const getFontCatalog = catalog.get;
|
||||||
|
|
||||||
|
// test-only reset, so specs don't share a live observer
|
||||||
|
export const __resetFontCatalog = catalog.reset;
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -420,6 +421,15 @@ export class FontLifecycleManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Singleton instance — use throughout the application for unified font loading state.
|
* App-wide font lifecycle manager, created on first access. Lazy so its
|
||||||
|
* AbortController / FontFace bookkeeping isn't set up at module load.
|
||||||
*/
|
*/
|
||||||
export const fontLifecycleManager = new FontLifecycleManager();
|
const fontLifecycleManager = createSingleton(
|
||||||
|
() => new FontLifecycleManager(),
|
||||||
|
instance => instance.destroy(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getFontLifecycleManager = fontLifecycleManager.get;
|
||||||
|
|
||||||
|
// test-only reset, so specs don't share loaded-font/eviction state
|
||||||
|
export const __resetFontLifecycleManager = fontLifecycleManager.reset;
|
||||||
|
|||||||
@@ -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,12 +1,13 @@
|
|||||||
// 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 {
|
export { getFontCatalog } from './fontCatalogStore/fontCatalogStore.svelte';
|
||||||
createFontCatalogStore,
|
export type { FontCatalogStore } from './fontCatalogStore/fontCatalogStore.svelte';
|
||||||
FontCatalogStore,
|
|
||||||
fontCatalogStore,
|
|
||||||
} from './fontCatalogStore/fontCatalogStore.svelte';
|
|
||||||
|
|
||||||
// Batch fetch by IDs (detail-cache seeding)
|
// Batch fetch by IDs (detail-cache seeding)
|
||||||
export { FontsByIdsStore } from './fontsByIdsStore/fontsByIdsStore.svelte';
|
export { FontsByIdsStore } from './fontsByIdsStore/fontsByIdsStore.svelte';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ const { Story } = defineMeta({
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
'Loads a font and applies it to children. Shows blur/scale loading state until font is ready, then reveals with a smooth transition.',
|
'Applies a font to its children based on the supplied load `status`. Renders the skeleton (or system font) until status is `loaded`/`error`, then reveals the font. The status is provided by the composing widget — the component does not read the lifecycle store itself.',
|
||||||
},
|
},
|
||||||
story: { inline: false },
|
story: { inline: false },
|
||||||
},
|
},
|
||||||
layout: 'centered',
|
layout: 'centered',
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
weight: { control: 'number' },
|
status: { control: 'select', options: ['loading', 'loaded', 'error'] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -39,11 +39,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Font that has never been loaded by fontLifecycleManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
|
'Status is `loading`: the font file has not resolved yet, so children render in the skeleton (or system font) fallback rather than the target font.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
args={{ font: fontUnknown, weight: 400 }}
|
args={{ font: fontUnknown, status: 'loading' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||||
<FontApplicator {...args}>
|
<FontApplicator {...args}>
|
||||||
@@ -58,11 +58,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Uses Arial, a system font available in all browsers. Because fontLifecycleManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
|
'Status is `loaded`: the component reveals the font, applying it to its children (Arial here, available in all browsers).',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
args={{ font: fontArial, weight: 400 }}
|
args={{ font: fontArial, status: 'loaded' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||||
<FontApplicator {...args}>
|
<FontApplicator {...args}>
|
||||||
@@ -72,16 +72,16 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
|||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
name="Custom Weight"
|
name="Error State"
|
||||||
parameters={{
|
parameters={{
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Demonstrates passing a custom weight (700). The weight is forwarded to fontLifecycleManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
|
'Status is `error`: the font failed to load. The component still reveals (it treats `error` like `loaded` for reveal purposes) so children are not stuck behind the skeleton — they fall back to the system font.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
args={{ font: fontArialBold, weight: 700 }}
|
args={{ font: fontArialBold, status: 'error' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||||
<FontApplicator {...args}>
|
<FontApplicator {...args}>
|
||||||
|
|||||||
@@ -6,11 +6,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import {
|
import type {
|
||||||
DEFAULT_FONT_WEIGHT,
|
FontLoadStatus,
|
||||||
type UnifiedFont,
|
UnifiedFont,
|
||||||
fontLifecycleManager,
|
} from '../../model/types';
|
||||||
} from '../../model';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -18,10 +17,13 @@ interface Props {
|
|||||||
*/
|
*/
|
||||||
font: UnifiedFont;
|
font: UnifiedFont;
|
||||||
/**
|
/**
|
||||||
* Font weight
|
* Current load status for this font, supplied by the composing layer.
|
||||||
* @default 400
|
* Kept out of the component so it does not depend on (and import) the
|
||||||
|
* lifecycle store — the owning widget reads the manager and passes the
|
||||||
|
* resolved status down. `undefined` means the font is not tracked yet and
|
||||||
|
* is treated as not-yet-revealed (skeleton / system-font fallback).
|
||||||
*/
|
*/
|
||||||
weight?: number;
|
status: FontLoadStatus | undefined;
|
||||||
/**
|
/**
|
||||||
* CSS classes
|
* CSS classes
|
||||||
*/
|
*/
|
||||||
@@ -39,20 +41,12 @@ interface Props {
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
font,
|
font,
|
||||||
weight = DEFAULT_FONT_WEIGHT,
|
status,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
skeleton,
|
skeleton,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const status = $derived(
|
|
||||||
fontLifecycleManager.getFontStatus(
|
|
||||||
font.id,
|
|
||||||
weight,
|
|
||||||
font.features?.isVariable,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+19
-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: {
|
||||||
@@ -21,6 +21,11 @@ const { Story } = defineMeta({
|
|||||||
control: 'object',
|
control: 'object',
|
||||||
description: 'Font information object',
|
description: 'Font information object',
|
||||||
},
|
},
|
||||||
|
status: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['loading', 'loaded', 'error'],
|
||||||
|
description: 'Font-load status, supplied by the composing widget and forwarded to FontApplicator',
|
||||||
|
},
|
||||||
text: {
|
text: {
|
||||||
control: 'text',
|
control: 'text',
|
||||||
description: 'Editable sample text (two-way bindable)',
|
description: 'Editable sample text (two-way bindable)',
|
||||||
@@ -34,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 = {
|
||||||
@@ -79,14 +84,24 @@ 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
|
||||||
name="Default"
|
name="Default"
|
||||||
args={{
|
args={{
|
||||||
font: mockArial,
|
font: mockArial,
|
||||||
|
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>)}
|
||||||
@@ -101,9 +116,11 @@ const mockGeorgia: UnifiedFont = {
|
|||||||
name="Long Text"
|
name="Long Text"
|
||||||
args={{
|
args={{
|
||||||
font: mockGeorgia,
|
font: mockGeorgia,
|
||||||
|
status: 'loaded',
|
||||||
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>)}
|
||||||
+54
-24
@@ -4,11 +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 UnifiedFont,
|
|
||||||
} from '$entities/Font';
|
|
||||||
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
ContentEditable,
|
ContentEditable,
|
||||||
@@ -17,12 +12,47 @@ 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 {
|
||||||
/**
|
/**
|
||||||
* Font info
|
* Font info
|
||||||
*/
|
*/
|
||||||
font: UnifiedFont;
|
font: UnifiedFont;
|
||||||
|
/**
|
||||||
|
* Current font-load status, supplied by the composing widget so this
|
||||||
|
* component (and FontApplicator) stay decoupled from the lifecycle store.
|
||||||
|
* `undefined` means not tracked yet (treated as not-yet-revealed).
|
||||||
|
*/
|
||||||
|
status: FontLoadStatus | undefined;
|
||||||
/**
|
/**
|
||||||
* Sample text
|
* Sample text
|
||||||
*/
|
*/
|
||||||
@@ -32,12 +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, text = $bindable(), index = 0 }: Props = $props();
|
let { font, status, text = $bindable(), index = 0, typography }: Props = $props();
|
||||||
|
|
||||||
// Adjust the property name to match your UnifiedFont type
|
|
||||||
const fontType = $derived((font as any).type ?? (font as any).category ?? '');
|
|
||||||
|
|
||||||
// Extract provider badge with fallback
|
// Extract provider badge with fallback
|
||||||
const providerBadge = $derived(
|
const providerBadge = $derived(
|
||||||
@@ -46,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>
|
||||||
|
|
||||||
@@ -67,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
|
||||||
@@ -91,9 +123,9 @@ const stats = $derived([
|
|||||||
{font.name}
|
{font.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{#if fontType}
|
{#if font?.category}
|
||||||
<Badge size="xs" variant="default" nowrap>
|
<Badge size="xs" variant="default" nowrap>
|
||||||
{fontType}
|
{font?.category}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -130,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} weight={typographySettingsStore.weight}>
|
<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' : ''}">
|
||||||
@@ -154,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
|
||||||
@@ -5,21 +5,18 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { debounce } from '$shared/lib/utils';
|
import { debounce } from '$shared/lib/utils';
|
||||||
import {
|
import { VirtualList } from '$shared/ui';
|
||||||
Skeleton,
|
|
||||||
VirtualList,
|
|
||||||
} from '$shared/ui';
|
|
||||||
import type {
|
import type {
|
||||||
ComponentProps,
|
ComponentProps,
|
||||||
Snippet,
|
Snippet,
|
||||||
} from 'svelte';
|
} from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { getFontUrl } from '../../lib';
|
import { createFontLoadRequestContfig } from '../../lib/createFontLoadRequestContfig/createFontLoadRequestContfig';
|
||||||
import {
|
import {
|
||||||
type FontLoadRequestConfig,
|
type FontLoadRequestConfig,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
fontCatalogStore,
|
getFontCatalog,
|
||||||
fontLifecycleManager,
|
getFontLifecycleManager,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
interface Props extends
|
interface Props extends
|
||||||
@@ -55,17 +52,28 @@ let {
|
|||||||
...rest
|
...rest
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const isLoading = $derived(
|
const fontCatalog = getFontCatalog();
|
||||||
fontCatalogStore.isFetching || fontCatalogStore.isLoading,
|
const fontLifecycleManager = getFontLifecycleManager();
|
||||||
);
|
|
||||||
|
const isLoading = $derived<boolean>(fontCatalog?.isLoading);
|
||||||
|
const isFetching = $derived<boolean>(fontCatalog.isFetching);
|
||||||
|
const hasMore = $derived<boolean>(fontCatalog?.pagination?.hasMore);
|
||||||
|
const fonts = $derived<UnifiedFont[]>(fontCatalog.fonts);
|
||||||
|
const total = $derived<number>(fontCatalog?.pagination.total);
|
||||||
|
|
||||||
let visibleFonts = $state<UnifiedFont[]>([]);
|
let visibleFonts = $state<UnifiedFont[]>([]);
|
||||||
let isCatchingUp = $state(false);
|
let isCatchingUp = $state<boolean>(false);
|
||||||
|
|
||||||
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontCatalogStore.fonts.length === 0);
|
const showInitialSkeleton = $derived.by(() => (
|
||||||
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
|
!!skeleton && (isLoading || isFetching) && fontCatalog.fonts.length === 0
|
||||||
|
));
|
||||||
|
const showCatchupSkeleton = $derived.by(() => (
|
||||||
|
!!skeleton && isCatchingUp
|
||||||
|
));
|
||||||
// Settled query with no matches — empty state replaces the (otherwise blank) list.
|
// Settled query with no matches — empty state replaces the (otherwise blank) list.
|
||||||
const showEmpty = $derived(!!empty && !isLoading && !isCatchingUp && fontCatalogStore.fonts.length === 0);
|
const showEmpty = $derived.by(() => (
|
||||||
|
!!empty && !(isLoading || isFetching) && !isCatchingUp && fontCatalog.fonts.length === 0
|
||||||
|
));
|
||||||
|
|
||||||
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||||
visibleFonts = items;
|
visibleFonts = items;
|
||||||
@@ -79,12 +87,12 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
|
|||||||
* font files for thousands of intermediate fonts.
|
* font files for thousands of intermediate fonts.
|
||||||
*/
|
*/
|
||||||
async function handleJump(targetIndex: number) {
|
async function handleJump(targetIndex: number) {
|
||||||
if (isCatchingUp || !fontCatalogStore.pagination.hasMore) {
|
if (isCatchingUp || !hasMore) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isCatchingUp = true;
|
isCatchingUp = true;
|
||||||
try {
|
try {
|
||||||
await fontCatalogStore.fetchAllPagesTo(targetIndex);
|
await fontCatalog.fetchAllPagesTo(targetIndex);
|
||||||
} finally {
|
} finally {
|
||||||
isCatchingUp = false;
|
isCatchingUp = false;
|
||||||
}
|
}
|
||||||
@@ -105,13 +113,7 @@ $effect(() => {
|
|||||||
if (isCatchingUp) {
|
if (isCatchingUp) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
|
const configs = visibleFonts.flatMap(item => createFontLoadRequestContfig(item, weight));
|
||||||
const url = getFontUrl(item, weight);
|
|
||||||
if (!url) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
|
|
||||||
});
|
|
||||||
if (configs.length > 0) {
|
if (configs.length > 0) {
|
||||||
debouncedTouch(configs);
|
debouncedTouch(configs);
|
||||||
}
|
}
|
||||||
@@ -137,13 +139,11 @@ $effect(() => {
|
|||||||
* Load more fonts by moving to the next page
|
* Load more fonts by moving to the next page
|
||||||
*/
|
*/
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
if (
|
if (!hasMore || isFetching) {
|
||||||
!fontCatalogStore.pagination.hasMore
|
|
||||||
|| fontCatalogStore.isFetching
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fontCatalogStore.nextPage();
|
|
||||||
|
fontCatalog.nextPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,12 +153,10 @@ function loadMore() {
|
|||||||
* of the loaded items. Only fetches if there are more pages available.
|
* of the loaded items. Only fetches if there are more pages available.
|
||||||
*/
|
*/
|
||||||
function handleNearBottom(_lastVisibleIndex: number) {
|
function handleNearBottom(_lastVisibleIndex: number) {
|
||||||
const { hasMore } = fontCatalogStore.pagination;
|
|
||||||
|
|
||||||
// VirtualList already checks if we're near the bottom of loaded items.
|
// VirtualList already checks if we're near the bottom of loaded items.
|
||||||
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
|
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
|
||||||
// during batch catch-up, which would otherwise let nextPage() race with it.
|
// during batch catch-up, which would otherwise let nextPage() race with it.
|
||||||
if (hasMore && !fontCatalogStore.isFetching && !isCatchingUp) {
|
if (hasMore && !isFetching && !isCatchingUp) {
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,9 +175,9 @@ function handleNearBottom(_lastVisibleIndex: number) {
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||||
<VirtualList
|
<VirtualList
|
||||||
items={fontCatalogStore.fonts}
|
items={fonts}
|
||||||
total={fontCatalogStore.pagination.total}
|
{total}
|
||||||
isLoading={isLoading || isCatchingUp}
|
isLoading={isLoading || isFetching || isCatchingUp}
|
||||||
onVisibleItemsChange={handleInternalVisibleChange}
|
onVisibleItemsChange={handleInternalVisibleChange}
|
||||||
onNearBottom={handleNearBottom}
|
onNearBottom={handleNearBottom}
|
||||||
onJump={handleJump}
|
onJump={handleJump}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
export {
|
export {
|
||||||
createTypographySettingsStore,
|
createTypographySettingsStore,
|
||||||
|
getTypographySettingsStore,
|
||||||
MULTIPLIER_L,
|
MULTIPLIER_L,
|
||||||
MULTIPLIER_M,
|
MULTIPLIER_M,
|
||||||
MULTIPLIER_S,
|
MULTIPLIER_S,
|
||||||
type TypographySettingsStore,
|
type TypographySettingsStore,
|
||||||
typographySettingsStore,
|
|
||||||
} from './model';
|
} from './model';
|
||||||
export { TypographyMenu } from './ui';
|
export { TypographyMenu } from './ui';
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ export {
|
|||||||
} from './const/const';
|
} from './const/const';
|
||||||
export {
|
export {
|
||||||
createTypographySettingsStore,
|
createTypographySettingsStore,
|
||||||
|
getTypographySettingsStore,
|
||||||
type TypographySettingsStore,
|
type TypographySettingsStore,
|
||||||
typographySettingsStore,
|
|
||||||
} from './store/typographySettingsStore/typographySettingsStore.svelte';
|
} from './store/typographySettingsStore/typographySettingsStore.svelte';
|
||||||
|
|||||||
+33
-9
@@ -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';
|
||||||
@@ -94,6 +98,12 @@ export class TypographySettingsStore {
|
|||||||
* The underlying font size before responsive scaling is applied
|
* The underlying font size before responsive scaling is applied
|
||||||
*/
|
*/
|
||||||
#baseSize = $state(DEFAULT_FONT_SIZE);
|
#baseSize = $state(DEFAULT_FONT_SIZE);
|
||||||
|
/**
|
||||||
|
* Disposes the $effect.root that backs the storage-sync effects.
|
||||||
|
* $effect.root lives outside component lifecycle, so callers must invoke
|
||||||
|
* destroy() to avoid leaking the subscriptions.
|
||||||
|
*/
|
||||||
|
#disposeEffects: () => void;
|
||||||
|
|
||||||
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
||||||
this.#storage = storage;
|
this.#storage = storage;
|
||||||
@@ -117,7 +127,7 @@ export class TypographySettingsStore {
|
|||||||
|
|
||||||
// The Sync Effect (UI -> Storage)
|
// The Sync Effect (UI -> Storage)
|
||||||
// We access .value explicitly to ensure Svelte 5 tracks the dependency
|
// We access .value explicitly to ensure Svelte 5 tracks the dependency
|
||||||
$effect.root(() => {
|
this.#disposeEffects = $effect.root(() => {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// EXPLICIT DEPENDENCIES: Accessing these triggers the effect
|
// EXPLICIT DEPENDENCIES: Accessing these triggers the effect
|
||||||
const fontSize = this.#baseSize;
|
const fontSize = this.#baseSize;
|
||||||
@@ -155,6 +165,14 @@ export class TypographySettingsStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tears down the storage-sync effects. Call on unmount / store disposal.
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.#disposeEffects();
|
||||||
|
this.#storage.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets initial value for a control from storage or defaults
|
* Gets initial value for a control from storage or defaults
|
||||||
*/
|
*/
|
||||||
@@ -289,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;
|
||||||
}
|
}
|
||||||
@@ -336,10 +351,19 @@ export function createTypographySettingsStore(
|
|||||||
return new TypographySettingsStore(configs, storage);
|
return new TypographySettingsStore(configs, storage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TypographySettingsStoreInstance = ReturnType<typeof createTypographySettingsStore>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide typography settings singleton, 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
|
||||||
|
* at module load.
|
||||||
*/
|
*/
|
||||||
export const typographySettingsStore = createTypographySettingsStore(
|
const typographySettingsStore = createSingleton(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
() => createTypographySettingsStore(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, COMPARISON_STORAGE_KEY),
|
||||||
COMPARISON_STORAGE_KEY,
|
instance => instance.destroy(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getTypographySettingsStore = typographySettingsStore.get;
|
||||||
|
|
||||||
|
// test-only reset, so specs don't share persisted typography state or leak effects
|
||||||
|
export const __resetTypographySettingsStore = typographySettingsStore.reset;
|
||||||
|
|||||||
+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';
|
||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
MULTIPLIER_L,
|
MULTIPLIER_L,
|
||||||
MULTIPLIER_M,
|
MULTIPLIER_M,
|
||||||
MULTIPLIER_S,
|
MULTIPLIER_S,
|
||||||
typographySettingsStore,
|
getTypographySettingsStore,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -46,6 +46,7 @@ interface Props {
|
|||||||
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
|
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
const typographySettingsStore = getTypographySettingsStore();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the common font size multiplier based on the current responsive state.
|
* Sets the common font size multiplier based on the current responsive state.
|
||||||
@@ -73,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">
|
||||||
@@ -111,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 -->
|
||||||
@@ -135,9 +120,9 @@ $effect(() => {
|
|||||||
/>
|
/>
|
||||||
</ControlGroup>
|
</ControlGroup>
|
||||||
{/each}
|
{/each}
|
||||||
</Popover.Content>
|
</div>
|
||||||
</Popover.Portal>
|
{/snippet}
|
||||||
</Popover.Root>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* @example
|
* @example
|
||||||
* ```svelte
|
* ```svelte
|
||||||
* <script lang="ts">
|
* <script lang="ts">
|
||||||
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
* import { scrollBreadcrumbsStore } from '$features/Breadcrumb';
|
||||||
* import { onMount } from 'svelte';
|
* import { onMount } from 'svelte';
|
||||||
*
|
*
|
||||||
* onMount(() => {
|
* onMount(() => {
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
getScrollBreadcrumbsStore,
|
||||||
type NavigationAction,
|
type NavigationAction,
|
||||||
scrollBreadcrumbsStore,
|
|
||||||
} from './model';
|
} from './model';
|
||||||
export {
|
export {
|
||||||
BreadcrumbHeader,
|
BreadcrumbHeader,
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
__resetScrollBreadcrumbsStore,
|
||||||
|
createScrollBreadcrumbsStore,
|
||||||
|
getScrollBreadcrumbsStore,
|
||||||
|
} from './store/scrollBreadcrumbsStore.svelte';
|
||||||
|
export type { BreadcrumbItem } from './store/scrollBreadcrumbsStore.svelte';
|
||||||
|
export type { NavigationAction } from './types/types.ts';
|
||||||
+20
-3
@@ -1,3 +1,5 @@
|
|||||||
|
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scroll-based breadcrumb tracking store
|
* Scroll-based breadcrumb tracking store
|
||||||
*
|
*
|
||||||
@@ -15,7 +17,7 @@
|
|||||||
* @example
|
* @example
|
||||||
* ```svelte
|
* ```svelte
|
||||||
* <script lang="ts">
|
* <script lang="ts">
|
||||||
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
* import { scrollBreadcrumbsStore } from '$features/Breadcrumb';
|
||||||
*
|
*
|
||||||
* onMount(() => {
|
* onMount(() => {
|
||||||
* scrollBreadcrumbsStore.add({
|
* scrollBreadcrumbsStore.add({
|
||||||
@@ -167,6 +169,13 @@ class ScrollBreadcrumbsStore {
|
|||||||
this.#detachScrollListener();
|
this.#detachScrollListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tears down the observer and scroll listener. Call on store disposal.
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.#disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All tracked items sorted by index
|
* All tracked items sorted by index
|
||||||
*/
|
*/
|
||||||
@@ -273,6 +282,14 @@ export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Singleton scroll breadcrumbs store instance
|
* App-wide scroll breadcrumbs store, created on first access.
|
||||||
*/
|
*/
|
||||||
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();
|
const scrollBreadcrumbsStore = createSingleton(
|
||||||
|
() => createScrollBreadcrumbsStore(),
|
||||||
|
instance => instance.destroy(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getScrollBreadcrumbsStore = scrollBreadcrumbsStore.get;
|
||||||
|
|
||||||
|
// test-only reset, so specs don't share observer/scroll state
|
||||||
|
export const __resetScrollBreadcrumbsStore = scrollBreadcrumbsStore.reset;
|
||||||
+2
-3
@@ -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);
|
||||||
+2
-1
@@ -14,9 +14,10 @@ import { cubicOut } from 'svelte/easing';
|
|||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import {
|
import {
|
||||||
type BreadcrumbItem,
|
type BreadcrumbItem,
|
||||||
scrollBreadcrumbsStore,
|
getScrollBreadcrumbsStore,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
|
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
|
||||||
const breadcrumbs = $derived(scrollBreadcrumbsStore.scrolledPastItems);
|
const breadcrumbs = $derived(scrollBreadcrumbsStore.scrolledPastItems);
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
+9
-3
@@ -1,18 +1,24 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { scrollBreadcrumbsStore } from '../../model';
|
import { getScrollBreadcrumbsStore } from '../../model';
|
||||||
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
|
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
|
||||||
|
|
||||||
|
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
{ index: 100, title: 'Introduction' },
|
{ index: 100, title: 'Introduction' },
|
||||||
{ index: 101, title: 'Typography' },
|
{ index: 101, title: 'Typography' },
|
||||||
{ 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);
|
||||||
+3
-1
@@ -6,9 +6,11 @@
|
|||||||
import { type Snippet } from 'svelte';
|
import { type Snippet } from 'svelte';
|
||||||
import {
|
import {
|
||||||
type NavigationAction,
|
type NavigationAction,
|
||||||
scrollBreadcrumbsStore,
|
getScrollBreadcrumbsStore,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
|
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Navigation index
|
* Navigation index
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './model';
|
export { getThemeManager } from './model';
|
||||||
export * from './ui';
|
export { ThemeSwitch } from './ui';
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { themeManager } from './store/ThemeManager/ThemeManager.svelte';
|
export { getThemeManager } from './store/ThemeManager/ThemeManager.svelte';
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -195,14 +199,20 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Singleton theme manager instance
|
* App-wide theme manager, created on first access.
|
||||||
*
|
*
|
||||||
* Use throughout the app for consistent theme state.
|
* Lazy so its persistent-store subscription isn't set up at module load.
|
||||||
|
* Call init() on mount and destroy() on unmount (see Layout).
|
||||||
*/
|
*/
|
||||||
export const themeManager = new ThemeManager();
|
const themeManager = createSingleton(() => new ThemeManager(), instance => instance.destroy());
|
||||||
|
|
||||||
|
export const getThemeManager = themeManager.get;
|
||||||
|
|
||||||
|
// test-only reset, so specs don't share persisted theme state
|
||||||
|
export const __resetThemeManager = themeManager.reset;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ThemeManager class exported for testing purposes
|
* ThemeManager class exported for testing purposes
|
||||||
* Use the singleton `themeManager` in application code.
|
* Use the `getThemeManager()` accessor in application code.
|
||||||
*/
|
*/
|
||||||
export { ThemeManager };
|
export { ThemeManager };
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ const { Story } = defineMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { themeManager } from '$features/ChangeAppTheme';
|
import { getThemeManager } from '$features/ChangeAppTheme';
|
||||||
|
|
||||||
|
const themeManager = getThemeManager();
|
||||||
// Current theme state for display
|
// Current theme state for display
|
||||||
const currentTheme = $derived(themeManager.value);
|
const currentTheme = $derived(themeManager.value);
|
||||||
const themeSource = $derived(themeManager.source);
|
const themeSource = $derived(themeManager.source);
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import { IconButton } from '$shared/ui';
|
|||||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||||
import SunIcon from '@lucide/svelte/icons/sun';
|
import SunIcon from '@lucide/svelte/icons/sun';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { themeManager } from '../../model';
|
import { getThemeManager } from '../../model';
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
|
const themeManager = getThemeManager();
|
||||||
const theme = $derived(themeManager.value);
|
const theme = $derived(themeManager.value);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,25 @@ import {
|
|||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
} from '@testing-library/svelte';
|
} from '@testing-library/svelte';
|
||||||
import { themeManager } from '../../model';
|
import { afterEach } from 'vitest';
|
||||||
|
import { getThemeManager } from '../../model';
|
||||||
|
import { __resetThemeManager } from '../../model/store/ThemeManager/ThemeManager.svelte';
|
||||||
import ThemeSwitch from './ThemeSwitch.svelte';
|
import ThemeSwitch from './ThemeSwitch.svelte';
|
||||||
|
|
||||||
const context = new Map([['responsive', { isMobile: false }]]);
|
const context = new Map([['responsive', { isMobile: false }]]);
|
||||||
|
|
||||||
describe('ThemeSwitch', () => {
|
describe('ThemeSwitch', () => {
|
||||||
|
let themeManager: ReturnType<typeof getThemeManager>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
themeManager = getThemeManager();
|
||||||
themeManager.setTheme('light');
|
themeManager.setTheme('light');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
__resetThemeManager();
|
||||||
|
});
|
||||||
|
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it('renders an icon button', () => {
|
it('renders an icon button', () => {
|
||||||
render(ThemeSwitch, { context });
|
render(ThemeSwitch, { context });
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { api } from '$shared/api/api';
|
import { api } from '$shared/api/api';
|
||||||
import { API_ENDPOINTS } from '$shared/api/endpoints';
|
import { API_ENDPOINTS } from '$shared/api/endpoints';
|
||||||
import { NonRetryableError } from '$shared/api/queryClient';
|
import { NonRetryableError } from '$shared/api/nonRetryableError';
|
||||||
|
|
||||||
const PROXY_API_URL = API_ENDPOINTS.filters;
|
const PROXY_API_URL = API_ENDPOINTS.filters;
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
export * from './filters/filters';
|
export { fetchProxyFilters } from './filters/filters';
|
||||||
|
export type {
|
||||||
|
FilterMetadata,
|
||||||
|
FilterOption,
|
||||||
|
ProxyFiltersResponse,
|
||||||
|
} from './filters/filters';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user