Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05cab5f892 | |||
| 0518c84230 | |||
| 5afb9c5d5d | |||
| 4126275c4d | |||
| ffc28f78f5 | |||
| 80241aa352 | |||
| 37886f3aa7 | |||
| 410a7cd37e | |||
| b5fec3a1ba | |||
| 8eee815e9a | |||
| 5b7ec03973 | |||
| 15bb961ccc | |||
| 4e7f76ecb1 | |||
| 06b6274e66 | |||
| 0c59262a59 | |||
| 2bb43797f0 | |||
| ccef3cf7bb | |||
| e3b489f173 | |||
| f92577608a | |||
| 728380498b | |||
| 07d044f4d6 | |||
| df59dfda02 | |||
| ca382fd43d | |||
| e0d39d861f | |||
| b6494a8cb5 | |||
| cc218934f4 | |||
| 3a327e2d92 | |||
| 30621c33df | |||
| cb8f6ffc97 | |||
| 33d3429060 | |||
| e60309af78 | |||
| 1573950605 | |||
| 773ab55f5c | |||
| 67e02e4e75 | |||
| 5ca7a433ff | |||
| 3b6ea99d09 | |||
| f762a09c23 | |||
| 95ae72719e | |||
| f3c4e72b86 | |||
| f41c4aab9c | |||
| d1eb83fa90 | |||
| c01fc79a3e | |||
| 6bfa7ca777 | |||
| 0d4356b8f1 | |||
| c18574d4c3 | |||
| 1c9a7f9fe1 | |||
| fae6694479 | |||
| a105c94176 | |||
| 77c2b27f8b | |||
| 1ce0d6c66f | |||
| 6c20a68e19 | |||
| 3894912a22 | |||
| e8d3727c6a | |||
| 5fbf090b24 | |||
| a94e1f8b65 | |||
| f8ba2d7eb0 | |||
| 3594033bcb | |||
| 2ae24912f7 | |||
| 877719f106 | |||
| 4eafb96d35 | |||
| 652dfa5c90 | |||
| 54087b7b2a | |||
| cffebf05e3 | |||
| ada484e2e0 | |||
| dbcc1caeb0 | |||
| 2c579a3336 | |||
| fe0d4e7daa | |||
| 108df323f9 | |||
| 2803bcd22c | |||
| 47a8487ce9 | |||
| 1d5af5ea70 | |||
| 2221ecad4c | |||
| cd8599d5b5 | |||
| 6c91d570ec | |||
| 91b80a5ada |
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/install-state.gz
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.svelte-kit
|
||||||
|
storybook-static
|
||||||
@@ -47,7 +47,8 @@ jobs:
|
|||||||
run: yarn test:unit
|
run: yarn test:unit
|
||||||
|
|
||||||
- name: Run Component Tests
|
- name: Run Component Tests
|
||||||
run: yarn test:component
|
timeout-minutes: 5
|
||||||
|
run: yarn test:component --reporter=verbose --logHeapUsage
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: build # Only runs if tests/lint pass
|
needs: build # Only runs if tests/lint pass
|
||||||
@@ -62,5 +63,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and Push Docker Image
|
- name: Build and Push Docker Image
|
||||||
run: |
|
run: |
|
||||||
docker build -t git.allmy.work/${{ gitea.repository }}:latest .
|
docker build \
|
||||||
|
-t git.allmy.work/${{ gitea.repository }}:latest \
|
||||||
|
-t git.allmy.work/${{ gitea.repository }}:${{ gitea.sha }} \
|
||||||
|
.
|
||||||
docker push git.allmy.work/${{ gitea.repository }}:latest
|
docker push git.allmy.work/${{ gitea.repository }}:latest
|
||||||
|
docker push git.allmy.work/${{ gitea.repository }}:${{ gitea.sha }}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ node_modules
|
|||||||
/build
|
/build
|
||||||
/dist
|
/dist
|
||||||
|
|
||||||
|
# IDE settings
|
||||||
|
.vscode
|
||||||
|
|
||||||
# Git worktrees (isolated development branches)
|
# Git worktrees (isolated development branches)
|
||||||
.worktrees
|
.worktrees
|
||||||
|
|
||||||
|
|||||||
+3
-8
@@ -1,27 +1,22 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Enable Corepack so we can use Yarn v4
|
# Enable Corepack so we can use Yarn v4 (pinned to match lockfile)
|
||||||
RUN corepack enable && corepack prepare yarn@stable --activate
|
RUN corepack enable && corepack prepare yarn@4.11.0 --activate
|
||||||
# Force Yarn to use node_modules instead of PnP
|
# Force Yarn to use node_modules instead of PnP
|
||||||
ENV YARN_NODE_LINKER=node-modules
|
ENV YARN_NODE_LINKER=node-modules
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
RUN yarn install --immutable
|
RUN yarn install --immutable
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN yarn build
|
RUN yarn build && ls -la dist
|
||||||
|
|
||||||
# Production stage - Caddy
|
# Production stage - Caddy
|
||||||
FROM caddy:2-alpine
|
FROM caddy:2-alpine
|
||||||
|
|
||||||
WORKDIR /usr/share/caddy
|
WORKDIR /usr/share/caddy
|
||||||
|
|
||||||
# Copy built static files from the builder stage
|
# Copy built static files from the builder stage
|
||||||
COPY --from=builder /app/dist .
|
COPY --from=builder /app/dist .
|
||||||
|
|
||||||
# Copy our local Caddyfile config
|
# Copy our local Caddyfile config
|
||||||
COPY Caddyfile /etc/caddy/Caddyfile
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Start caddy using the config file
|
# Start caddy using the config file
|
||||||
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||||
+4
-5
@@ -13,7 +13,7 @@
|
|||||||
"https://plugins.dprint.dev/typescript-0.93.0.wasm",
|
"https://plugins.dprint.dev/typescript-0.93.0.wasm",
|
||||||
"https://plugins.dprint.dev/json-0.19.3.wasm",
|
"https://plugins.dprint.dev/json-0.19.3.wasm",
|
||||||
"https://plugins.dprint.dev/markdown-0.17.8.wasm",
|
"https://plugins.dprint.dev/markdown-0.17.8.wasm",
|
||||||
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
|
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.27.0.wasm"
|
||||||
],
|
],
|
||||||
"typescript": {
|
"typescript": {
|
||||||
"lineWidth": 120,
|
"lineWidth": 120,
|
||||||
@@ -57,9 +57,8 @@
|
|||||||
"quotes": "double",
|
"quotes": "double",
|
||||||
"scriptIndent": false,
|
"scriptIndent": false,
|
||||||
"styleIndent": false,
|
"styleIndent": false,
|
||||||
|
"formatComments": true,
|
||||||
"vBindStyle": "short",
|
"svelteAttrShorthand": true,
|
||||||
"vOnStyle": "short",
|
"svelteDirectiveShorthand": true
|
||||||
"formatComments": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-33
@@ -27,45 +27,45 @@
|
|||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^4.1.3",
|
"@chromatic-com/storybook": "5.1.2",
|
||||||
"@internationalized/date": "^3.10.0",
|
"@internationalized/date": "3.12.1",
|
||||||
"@lucide/svelte": "^0.561.0",
|
"@lucide/svelte": "^1.14.0",
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "1.59.1",
|
||||||
"@storybook/addon-a11y": "^10.1.11",
|
"@storybook/addon-a11y": "10.3.6",
|
||||||
"@storybook/addon-docs": "^10.1.11",
|
"@storybook/addon-docs": "10.3.6",
|
||||||
"@storybook/addon-svelte-csf": "^5.0.10",
|
"@storybook/addon-svelte-csf": "5.1.2",
|
||||||
"@storybook/addon-vitest": "^10.1.11",
|
"@storybook/addon-vitest": "10.3.6",
|
||||||
"@storybook/svelte-vite": "^10.1.11",
|
"@storybook/svelte-vite": "10.3.6",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "7.1.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "4.2.4",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@tsconfig/svelte": "^5.0.6",
|
"@tsconfig/svelte": "5.0.8",
|
||||||
"@types/jsdom": "^27",
|
"@types/jsdom": "28.0.1",
|
||||||
"@vitest/browser-playwright": "^4.0.16",
|
"@vitest/browser-playwright": "4.1.5",
|
||||||
"@vitest/coverage-v8": "^4.0.16",
|
"@vitest/coverage-v8": "4.1.5",
|
||||||
"bits-ui": "^2.14.4",
|
"bits-ui": "2.18.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dprint": "^0.50.2",
|
"dprint": "0.54.0",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "29.1.1",
|
||||||
"lefthook": "^2.0.13",
|
"lefthook": "2.1.6",
|
||||||
"oxlint": "^1.35.0",
|
"oxlint": "1.62.0",
|
||||||
"playwright": "^1.57.0",
|
"playwright": "1.59.1",
|
||||||
"storybook": "^10.1.11",
|
"storybook": "10.3.6",
|
||||||
"svelte": "^5.45.6",
|
"svelte": "5.55.5",
|
||||||
"svelte-check": "^4.3.4",
|
"svelte-check": "4.4.8",
|
||||||
"svelte-language-server": "^0.17.23",
|
"svelte-language-server": "0.18.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "3.5.0",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "4.2.4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "^7.2.6",
|
"vite": "8.0.10",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "4.1.5",
|
||||||
"vitest-browser-svelte": "^2.0.1"
|
"vitest-browser-svelte": "2.1.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chenglou/pretext": "^0.0.5",
|
"@chenglou/pretext": "0.0.6",
|
||||||
"@tanstack/svelte-query": "^6.0.14"
|
"@tanstack/svelte-query": "6.1.28"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+164
-20
@@ -14,6 +14,13 @@
|
|||||||
--swiss-black: #1a1a1a;
|
--swiss-black: #1a1a1a;
|
||||||
--swiss-white: #ffffff;
|
--swiss-white: #ffffff;
|
||||||
|
|
||||||
|
/* Semantic mode-switching colors. These are redefined inside `.dark`
|
||||||
|
so utilities that reference them auto-adapt without a `dark:` variant. */
|
||||||
|
--color-border-subtle: var(--neutral-300);
|
||||||
|
--color-text-subtle: var(--neutral-500);
|
||||||
|
--color-skeleton: var(--neutral-200);
|
||||||
|
--color-grid-line: rgb(0 0 0 / 0.03);
|
||||||
|
|
||||||
/* Neutral Grays */
|
/* Neutral Grays */
|
||||||
--neutral-50: #fafafa;
|
--neutral-50: #fafafa;
|
||||||
--neutral-100: #f5f5f5;
|
--neutral-100: #f5f5f5;
|
||||||
@@ -80,16 +87,6 @@
|
|||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
|
||||||
/* Spacing Scale (rem-based) */
|
|
||||||
--space-xs: 0.25rem;
|
|
||||||
--space-sm: 0.5rem;
|
|
||||||
--space-md: 0.75rem;
|
|
||||||
--space-lg: 1rem;
|
|
||||||
--space-xl: 1.5rem;
|
|
||||||
--space-2xl: 2rem;
|
|
||||||
--space-3xl: 3rem;
|
|
||||||
--space-4xl: 4rem;
|
|
||||||
|
|
||||||
/* Typography Scale */
|
/* Typography Scale */
|
||||||
--text-xs: 0.75rem;
|
--text-xs: 0.75rem;
|
||||||
--text-sm: 0.875rem;
|
--text-sm: 0.875rem;
|
||||||
@@ -114,6 +111,12 @@
|
|||||||
--color-surface: var(--dark-bg);
|
--color-surface: var(--dark-bg);
|
||||||
--color-paper: var(--dark-card);
|
--color-paper: var(--dark-card);
|
||||||
|
|
||||||
|
/* Dark-mode overrides for the semantic mode-switching colors. */
|
||||||
|
--color-border-subtle: rgb(255 255 255 / 0.1);
|
||||||
|
--color-text-subtle: var(--neutral-400);
|
||||||
|
--color-skeleton: var(--neutral-800);
|
||||||
|
--color-grid-line: rgb(255 255 255 / 0.05);
|
||||||
|
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.145 0 0);
|
--card: oklch(0.145 0 0);
|
||||||
@@ -212,6 +215,51 @@
|
|||||||
--text-2xs: 0.625rem;
|
--text-2xs: 0.625rem;
|
||||||
/* 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
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Default resting shadow — equivalent to Tailwind's shadow-sm. Used on
|
||||||
|
buttons, sliders, popover triggers in non-floating state. */
|
||||||
|
--shadow-rest: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
|
||||||
|
/* Swiss "hard offset" stamp — rests at 2px/2px, lifts to 3px/3px on
|
||||||
|
hover, presses back to 1px/1px on active. Primary button motif. */
|
||||||
|
--shadow-stamp-rest: 0.125rem 0.125rem 0 0 rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-stamp-hover: 0.1875rem 0.1875rem 0 0 rgb(0 0 0 / 0.15);
|
||||||
|
--shadow-stamp-pressed: 0.0625rem 0.0625rem 0 0 rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
|
/* Card-tier hard-offset stamp — wider, brand-tinted. Used on
|
||||||
|
interactive cards (FontSampler hover). */
|
||||||
|
--shadow-stamp-card: 5px 5px 0 0 var(--color-brand);
|
||||||
|
|
||||||
|
/* Floating popovers (typography menu, combo control list). */
|
||||||
|
--shadow-popover: 0 20px 40px -10px rgb(0 0 0 / 0.15);
|
||||||
|
|
||||||
|
/* Drop-shadow under semi-translucent floating panels like the
|
||||||
|
comparison slider's character row. */
|
||||||
|
--shadow-floating-panel: 0 25px 50px -12px rgb(0 0 0 / 0.05);
|
||||||
|
--shadow-floating-panel-dark: 0 25px 50px -12px rgb(0 0 0 / 0.2);
|
||||||
|
|
||||||
|
/* Drawer / overlay shadow — full-strength shadow-2xl. */
|
||||||
|
--shadow-overlay: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MOTION TOKENS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
--duration-fast: 150ms;
|
||||||
|
--duration-normal: 200ms;
|
||||||
|
--duration-slow: 300ms;
|
||||||
|
--duration-slower: 500ms;
|
||||||
|
|
||||||
|
/* Tailwind's default ease-in-out — symmetric, good for layout shifts. */
|
||||||
|
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
/* Decelerating curve — matches Tailwind's ease-out. Dominant in this codebase. */
|
||||||
|
--ease-out-soft: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
/* Spring overshoot — used in character pop animation. */
|
||||||
|
--ease-spring-overshoot: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -219,6 +267,11 @@
|
|||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
color: var(--swiss-white);
|
||||||
|
}
|
||||||
|
|
||||||
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: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||||
@@ -272,19 +325,110 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
/* ============================================
|
||||||
/* 21× border-black/5 dark:border-white/10 → single token */
|
DESIGN-SYSTEM UTILITIES
|
||||||
.border-subtle {
|
============================================
|
||||||
@apply border-black/5 dark:border-white/10;
|
Defined via `@utility` (Tailwind v4) so they integrate with the variant
|
||||||
|
system (`hover:`, `dark:`, breakpoints) and don't rely on `@apply`
|
||||||
|
chains. Colors reference the mode-switching semantic vars defined in
|
||||||
|
`:root`/`.dark` above, so most utilities need no `dark:` variant in
|
||||||
|
their definition or at call sites. */
|
||||||
|
|
||||||
|
@utility border-subtle {
|
||||||
|
border-color: var(--color-border-subtle);
|
||||||
}
|
}
|
||||||
/* Secondary text pair */
|
|
||||||
.text-secondary {
|
/* Same color as border-subtle, applied via background-color — for 1px
|
||||||
@apply text-neutral-500 dark:text-neutral-400;
|
dividers, inline separator strips, and other hairlines that aren't
|
||||||
|
element borders. */
|
||||||
|
@utility bg-subtle {
|
||||||
|
background-color: var(--color-border-subtle);
|
||||||
}
|
}
|
||||||
/* Standard focus ring */
|
|
||||||
.focus-ring {
|
/* Muted text color — paired with `border-subtle` naming. The previous
|
||||||
@apply focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2;
|
name `text-secondary` collided with Tailwind v4 auto-generating a
|
||||||
|
utility from `--color-secondary` (the shadcn near-white surface token
|
||||||
|
registered in `@theme`), which made every consumer effectively
|
||||||
|
invisible (near-white text on light backgrounds). */
|
||||||
|
@utility text-subtle {
|
||||||
|
color: var(--color-text-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility focus-ring {
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-background, white), 0 0 0 4px var(--color-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Surface utilities ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@utility surface-canvas {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility surface-card {
|
||||||
|
background-color: var(--color-paper);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility surface-card-elevated {
|
||||||
|
background-color: var(--color-paper);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
box-shadow: var(--shadow-rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility surface-popover {
|
||||||
|
background-color: var(--color-paper);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
box-shadow: var(--shadow-popover);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility surface-floating {
|
||||||
|
background-color: color-mix(in srgb, var(--color-surface) 80%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shape / layout ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@utility flex-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility skeleton-fill {
|
||||||
|
background-color: color-mix(in srgb, var(--color-skeleton) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle dotted-grid overlay used as a decorative background on the
|
||||||
|
comparison paper surface. Color and intensity auto-switch via
|
||||||
|
--color-grid-line. `bg-grid-sm` uses a tighter cell — typical mobile
|
||||||
|
choice; `bg-grid` is the default desktop cell. Pair with absolute /
|
||||||
|
pointer-events-none on the overlay element. */
|
||||||
|
@utility bg-grid {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--color-grid-line) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px);
|
||||||
|
background-size: 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-grid-sm {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--color-grid-line) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px);
|
||||||
|
background-size: 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Typography ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@utility text-label-mono {
|
||||||
|
font-family: var(--font-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global utility - useful across your app */
|
/* Global utility - useful across your app */
|
||||||
|
|||||||
Vendored
+2
@@ -36,6 +36,8 @@ declare module '*.jpg' {
|
|||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module '*.css';
|
||||||
|
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
|
|||||||
@@ -3,21 +3,12 @@
|
|||||||
Application shell with providers and page wrapper
|
Application shell with providers and page wrapper
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
|
||||||
* Layout Component
|
|
||||||
*
|
|
||||||
* Root layout wrapper that provides the application shell structure. Handles favicon,
|
|
||||||
* toolbar provider initialization, and renders child routes with consistent structure.
|
|
||||||
*
|
|
||||||
* Layout structure:
|
|
||||||
* - Header area (currently empty, reserved for future use)
|
|
||||||
*
|
|
||||||
* - Footer area (currently empty, reserved for future use)
|
|
||||||
*/
|
|
||||||
import { themeManager } from '$features/ChangeAppTheme';
|
import { themeManager } from '$features/ChangeAppTheme';
|
||||||
import GD from '$shared/assets/GD.svg';
|
import G from '$shared/assets/G.svg';
|
||||||
import { ResponsiveProvider } from '$shared/lib';
|
import { ResponsiveProvider } from '$shared/lib';
|
||||||
import clsx from 'clsx';
|
import { cn } from '$shared/lib';
|
||||||
|
import { Footer } from '$widgets/Footer';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type Snippet,
|
type Snippet,
|
||||||
onDestroy,
|
onDestroy,
|
||||||
@@ -40,7 +31,7 @@ onDestroy(() => themeManager.destroy());
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={GD} />
|
<link rel="icon" href={G} type="image/svg+xml" />
|
||||||
|
|
||||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||||
<link
|
<link
|
||||||
@@ -82,14 +73,15 @@ onDestroy(() => themeManager.destroy());
|
|||||||
<ResponsiveProvider>
|
<ResponsiveProvider>
|
||||||
<div
|
<div
|
||||||
id="app-root"
|
id="app-root"
|
||||||
class={clsx(
|
class={cn(
|
||||||
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg',
|
'min-h-dvh w-auto flex flex-col surface-canvas relative',
|
||||||
theme === 'dark' ? 'dark' : '',
|
theme === 'dark' ? 'dark' : '',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{#if fontsReady}
|
{#if fontsReady}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{/if}
|
{/if}
|
||||||
<footer></footer>
|
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</ResponsiveProvider>
|
</ResponsiveProvider>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ let mockObserverInstances: MockIntersectionObserver[] = [];
|
|||||||
class MockIntersectionObserver implements IntersectionObserver {
|
class MockIntersectionObserver implements IntersectionObserver {
|
||||||
root = null;
|
root = null;
|
||||||
rootMargin = '';
|
rootMargin = '';
|
||||||
|
scrollMargin = '';
|
||||||
thresholds: number[] = [];
|
thresholds: number[] = [];
|
||||||
readonly callbacks: Array<(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void> = [];
|
readonly callbacks: Array<(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void> = [];
|
||||||
readonly observedElements = new Set<Element>();
|
readonly observedElements = new Set<Element>();
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ function createButtonText(item: BreadcrumbItem) {
|
|||||||
md:h-16 px-4 md:px-6 lg:px-8
|
md:h-16 px-4 md:px-6 lg:px-8
|
||||||
flex items-center justify-between
|
flex items-center justify-between
|
||||||
z-40
|
z-40
|
||||||
bg-surface/90 dark:bg-dark-bg/90 backdrop-blur-md
|
surface-floating bg-surface/90 dark:bg-dark-bg/90
|
||||||
border-b border-subtle
|
border-x-0 border-t-0
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">
|
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export {
|
|||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
fetchProxyFontById,
|
fetchProxyFontById,
|
||||||
fetchProxyFonts,
|
fetchProxyFonts,
|
||||||
|
seedFontCache,
|
||||||
} from './proxy/proxyFonts';
|
} from './proxy/proxyFonts';
|
||||||
export type {
|
export type {
|
||||||
ProxyFontsParams,
|
ProxyFontsParams,
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ export function seedFontCache(fonts: UnifiedFont[]): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { API_ENDPOINTS } from '$shared/api/endpoints';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy API base URL
|
* Proxy API endpoint for font resources.
|
||||||
*/
|
*/
|
||||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const;
|
const PROXY_API_URL = API_ENDPOINTS.fonts;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy API parameters
|
* Proxy API parameters
|
||||||
|
|||||||
@@ -667,10 +667,10 @@ export const MOCK_STORES = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Create a mock FontStore object
|
* Create a mock FontCatalogStore object
|
||||||
* Matches FontStore's public API for Storybook use
|
* Matches FontCatalogStore's public API for Storybook use
|
||||||
*/
|
*/
|
||||||
fontStore: (config: {
|
fontCatalogStore: (config: {
|
||||||
/**
|
/**
|
||||||
* Preset font list
|
* Preset font list
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { TextLayoutEngine } from '$shared/lib';
|
|
||||||
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
|
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
|
||||||
import { clearCache } from '@chenglou/pretext';
|
import {
|
||||||
|
clearCache,
|
||||||
|
layout,
|
||||||
|
} from '@chenglou/pretext';
|
||||||
|
|
||||||
|
// Wrap pretext's `layout` in a spy-able mock so tests can assert call counts.
|
||||||
|
// `vi.mock` is hoisted, so the import above receives the mocked module.
|
||||||
|
vi.mock('@chenglou/pretext', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@chenglou/pretext')>('@chenglou/pretext');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
layout: vi.fn(actual.layout),
|
||||||
|
};
|
||||||
|
});
|
||||||
import {
|
import {
|
||||||
beforeEach,
|
beforeEach,
|
||||||
describe,
|
describe,
|
||||||
@@ -112,13 +124,13 @@ describe('createFontRowSizeResolver', () => {
|
|||||||
const { resolver } = makeResolver();
|
const { resolver } = makeResolver();
|
||||||
statusMap.set('inter@400', 'loaded');
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
|
||||||
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
|
const layoutSpy = vi.mocked(layout);
|
||||||
|
layoutSpy.mockClear();
|
||||||
|
|
||||||
resolver(0);
|
resolver(0);
|
||||||
resolver(0);
|
resolver(0);
|
||||||
|
|
||||||
expect(layoutSpy).toHaveBeenCalledTimes(1);
|
expect(layoutSpy).toHaveBeenCalledTimes(1);
|
||||||
layoutSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls layout() again when containerWidth changes (cache miss)', () => {
|
it('calls layout() again when containerWidth changes (cache miss)', () => {
|
||||||
@@ -126,14 +138,14 @@ describe('createFontRowSizeResolver', () => {
|
|||||||
const { resolver } = makeResolver({ getContainerWidth: () => width });
|
const { resolver } = makeResolver({ getContainerWidth: () => width });
|
||||||
statusMap.set('inter@400', 'loaded');
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
|
||||||
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
|
const layoutSpy = vi.mocked(layout);
|
||||||
|
layoutSpy.mockClear();
|
||||||
|
|
||||||
resolver(0);
|
resolver(0);
|
||||||
width = 100;
|
width = 100;
|
||||||
resolver(0);
|
resolver(0);
|
||||||
|
|
||||||
expect(layoutSpy).toHaveBeenCalledTimes(2);
|
expect(layoutSpy).toHaveBeenCalledTimes(2);
|
||||||
layoutSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns greater height when container narrows (more wrapping)', () => {
|
it('returns greater height when container narrows (more wrapping)', () => {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { TextLayoutEngine } from '$shared/lib';
|
import {
|
||||||
import { generateFontKey } from '../../model/store/appliedFontsStore/utils/generateFontKey/generateFontKey';
|
layout,
|
||||||
|
prepare,
|
||||||
|
} from '@chenglou/pretext';
|
||||||
|
import { generateFontKey } from '../../model/store/fontLifecycleManager/utils/generateFontKey/generateFontKey';
|
||||||
import type {
|
import type {
|
||||||
FontLoadStatus,
|
FontLoadStatus,
|
||||||
UnifiedFont,
|
UnifiedFont,
|
||||||
@@ -41,7 +44,7 @@ export interface FontRowSizeResolverOptions {
|
|||||||
/**
|
/**
|
||||||
* Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`).
|
* Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`).
|
||||||
*
|
*
|
||||||
* In production: `(key) => appliedFontsManager.statuses.get(key)`.
|
* In production: `(key) => fontLifecycleManager.statuses.get(key)`.
|
||||||
* Injected for testability — avoids a module-level singleton dependency in tests.
|
* Injected for testability — avoids a module-level singleton dependency in tests.
|
||||||
* The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context
|
* The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context
|
||||||
* for reactivity to work. This is satisfied when `itemHeight` is called by
|
* for reactivity to work. This is satisfied when `itemHeight` is called by
|
||||||
@@ -79,14 +82,13 @@ export interface FontRowSizeResolverOptions {
|
|||||||
* no DOM snap occurs.
|
* no DOM snap occurs.
|
||||||
*
|
*
|
||||||
* **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx`
|
* **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx`
|
||||||
* prevents redundant `TextLayoutEngine.layout()` calls. The cache is invalidated
|
* prevents redundant `pretext.layout()` calls. The cache is invalidated
|
||||||
* naturally because a change in any input produces a different cache key.
|
* naturally because a change in any input produces a different cache key.
|
||||||
*
|
*
|
||||||
* @param options - Configuration and getter functions (all injected for testability).
|
* @param options - Configuration and getter functions (all injected for testability).
|
||||||
* @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`.
|
* @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`.
|
||||||
*/
|
*/
|
||||||
export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number {
|
export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number {
|
||||||
const engine = new TextLayoutEngine();
|
|
||||||
// Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}`
|
// Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}`
|
||||||
const cache = new Map<string, number>();
|
const cache = new Map<string, number>();
|
||||||
|
|
||||||
@@ -108,7 +110,7 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
|
|||||||
// generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
|
// generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
|
||||||
const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable });
|
const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable });
|
||||||
|
|
||||||
// Reading via getStatus() allows the caller to pass appliedFontsManager.statuses.get(),
|
// Reading via getStatus() allows the caller to pass fontLifecycleManager.statuses.get(),
|
||||||
// which creates a Svelte 5 reactive dependency when called inside $derived.by.
|
// which creates a Svelte 5 reactive dependency when called inside $derived.by.
|
||||||
const status = options.getStatus(fontKey);
|
const status = options.getStatus(fontKey);
|
||||||
if (status !== 'loaded') {
|
if (status !== 'loaded') {
|
||||||
@@ -126,7 +128,11 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { totalHeight } = engine.layout(previewText, fontCssString, contentWidth, lineHeightPx);
|
// Pretext docs recommend `layout()` (not `layoutWithLines`) for the
|
||||||
|
// resize hot path — pure arithmetic on cached segment widths, no canvas
|
||||||
|
// calls, no string allocations.
|
||||||
|
const prepared = prepare(previewText, fontCssString);
|
||||||
|
const { height: totalHeight } = layout(prepared, contentWidth, lineHeightPx);
|
||||||
const result = totalHeight + options.chromeHeight;
|
const result = totalHeight + options.chromeHeight;
|
||||||
cache.set(cacheKey, result);
|
cache.set(cacheKey, result);
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
+15
-11
@@ -17,13 +17,17 @@ import {
|
|||||||
generateMockFonts,
|
generateMockFonts,
|
||||||
} from '../../../lib/mocks/fonts.mock';
|
} from '../../../lib/mocks/fonts.mock';
|
||||||
import type { UnifiedFont } from '../../types';
|
import type { UnifiedFont } from '../../types';
|
||||||
import { FontStore } from './fontStore.svelte';
|
import { FontCatalogStore } from './fontCatalogStore.svelte';
|
||||||
|
|
||||||
vi.mock('$shared/api/queryClient', () => ({
|
vi.mock('$shared/api/queryClient', async importOriginal => {
|
||||||
|
const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
queryClient: new QueryClient({
|
queryClient: new QueryClient({
|
||||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
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 { queryClient } from '$shared/api/queryClient';
|
||||||
@@ -44,7 +48,7 @@ const makeResponse = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
function makeStore(params = {}) {
|
function makeStore(params = {}) {
|
||||||
return new FontStore({ limit: 10, ...params });
|
return new FontCatalogStore({ limit: 10, ...params });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
|
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
|
||||||
@@ -55,7 +59,7 @@ async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Par
|
|||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('FontStore', () => {
|
describe('FontCatalogStore', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
@@ -69,7 +73,7 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('defaults limit to 50 when not provided', () => {
|
it('defaults limit to 50 when not provided', () => {
|
||||||
const store = new FontStore();
|
const store = new FontCatalogStore();
|
||||||
expect(store.params.limit).toBe(50);
|
expect(store.params.limit).toBe(50);
|
||||||
store.destroy();
|
store.destroy();
|
||||||
});
|
});
|
||||||
@@ -390,11 +394,11 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('nextPage', () => {
|
describe('nextPage', () => {
|
||||||
let store: FontStore;
|
let store: FontCatalogStore;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
||||||
store = new FontStore({ limit: 10 });
|
store = new FontCatalogStore({ limit: 10 });
|
||||||
await store.refetch();
|
await store.refetch();
|
||||||
flushSync();
|
flushSync();
|
||||||
});
|
});
|
||||||
@@ -415,7 +419,7 @@ describe('FontStore', () => {
|
|||||||
// Set up a store where all fonts fit in one page (hasMore = false)
|
// Set up a store where all fonts fit in one page (hasMore = false)
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
|
||||||
store = new FontStore({ limit: 10 });
|
store = new FontCatalogStore({ limit: 10 });
|
||||||
await store.refetch();
|
await store.refetch();
|
||||||
flushSync();
|
flushSync();
|
||||||
|
|
||||||
@@ -454,7 +458,7 @@ describe('FontStore', () => {
|
|||||||
describe('getCachedData / setQueryData', () => {
|
describe('getCachedData / setQueryData', () => {
|
||||||
it('getCachedData returns undefined before any fetch', () => {
|
it('getCachedData returns undefined before any fetch', () => {
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
const store = new FontStore({ limit: 10 });
|
const store = new FontCatalogStore({ limit: 10 });
|
||||||
expect(store.getCachedData()).toBeUndefined();
|
expect(store.getCachedData()).toBeUndefined();
|
||||||
store.destroy();
|
store.destroy();
|
||||||
});
|
});
|
||||||
@@ -502,7 +506,7 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('filter shortcut methods', () => {
|
describe('filter shortcut methods', () => {
|
||||||
let store: FontStore;
|
let store: FontCatalogStore;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
store = makeStore();
|
store = makeStore();
|
||||||
+11
-7
@@ -1,4 +1,8 @@
|
|||||||
import { queryClient } from '$shared/api/queryClient';
|
import {
|
||||||
|
DEFAULT_QUERY_GC_TIME_MS,
|
||||||
|
DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
|
queryClient,
|
||||||
|
} from '$shared/api/queryClient';
|
||||||
import {
|
import {
|
||||||
type InfiniteData,
|
type InfiniteData,
|
||||||
InfiniteQueryObserver,
|
InfiniteQueryObserver,
|
||||||
@@ -25,7 +29,7 @@ type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
|
|||||||
|
|
||||||
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
|
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
|
||||||
|
|
||||||
export class FontStore {
|
export class FontCatalogStore {
|
||||||
#params = $state<FontStoreParams>({ limit: 50 });
|
#params = $state<FontStoreParams>({ limit: 50 });
|
||||||
#result = $state<FontStoreResult>({} as FontStoreResult);
|
#result = $state<FontStoreResult>({} as FontStoreResult);
|
||||||
#observer: InfiniteQueryObserver<
|
#observer: InfiniteQueryObserver<
|
||||||
@@ -427,8 +431,8 @@ export class FontStore {
|
|||||||
const next = lastPage.offset + lastPage.limit;
|
const next = lastPage.offset + lastPage.limit;
|
||||||
return next < lastPage.total ? { offset: next } : undefined;
|
return next < lastPage.total ? { offset: next } : undefined;
|
||||||
},
|
},
|
||||||
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
|
staleTime: hasFilters ? 0 : DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
gcTime: 10 * 60 * 1000,
|
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,8 +463,8 @@ export class FontStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFontStore(params: FontStoreParams = {}): FontStore {
|
export function createFontCatalogStore(params: FontStoreParams = {}): FontCatalogStore {
|
||||||
return new FontStore(params);
|
return new FontCatalogStore(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fontStore = new FontStore({ limit: 50 });
|
export const fontCatalogStore = new FontCatalogStore({ limit: 50 });
|
||||||
+38
-12
@@ -17,7 +17,36 @@ import { FontBufferCache } from './utils/fontBufferCache/FontBufferCache';
|
|||||||
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
||||||
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
|
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
|
||||||
|
|
||||||
interface AppliedFontsManagerDeps {
|
/**
|
||||||
|
* How often the periodic eviction sweep runs.
|
||||||
|
*/
|
||||||
|
const PURGE_INTERVAL_MS = 60000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timeout for `requestIdleCallback`. After this elapses, the callback is
|
||||||
|
* forced to run regardless of whether the browser is idle.
|
||||||
|
*/
|
||||||
|
const IDLE_CALLBACK_TIMEOUT_MS = 150;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* setTimeout fallback delay when `requestIdleCallback` is unavailable.
|
||||||
|
* ~16ms ≈ one frame at 60fps.
|
||||||
|
*/
|
||||||
|
const SCHEDULE_FALLBACK_MS = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often the parse loop yields back to the main thread when the browser
|
||||||
|
* does not provide `isInputPending` (non-Chromium fallback).
|
||||||
|
*/
|
||||||
|
const YIELD_INTERVAL_MS = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font weights treated as "critical" in data-saver mode. Other weights are
|
||||||
|
* skipped to reduce network usage; variable fonts bypass this filter.
|
||||||
|
*/
|
||||||
|
const CRITICAL_FONT_WEIGHTS = [400, 700];
|
||||||
|
|
||||||
|
interface FontLifecycleManagerDeps {
|
||||||
cache?: FontBufferCache;
|
cache?: FontBufferCache;
|
||||||
eviction?: FontEvictionPolicy;
|
eviction?: FontEvictionPolicy;
|
||||||
queue?: FontLoadQueue;
|
queue?: FontLoadQueue;
|
||||||
@@ -46,7 +75,7 @@ interface AppliedFontsManagerDeps {
|
|||||||
*
|
*
|
||||||
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
|
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
|
||||||
*/
|
*/
|
||||||
export class AppliedFontsManager {
|
export class FontLifecycleManager {
|
||||||
// Injected collaborators - each handles one concern for better testability
|
// Injected collaborators - each handles one concern for better testability
|
||||||
readonly #cache: FontBufferCache;
|
readonly #cache: FontBufferCache;
|
||||||
readonly #eviction: FontEvictionPolicy;
|
readonly #eviction: FontEvictionPolicy;
|
||||||
@@ -70,22 +99,20 @@ export class AppliedFontsManager {
|
|||||||
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
|
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
|
||||||
#pendingType: 'idle' | 'timeout' | null = null;
|
#pendingType: 'idle' | 'timeout' | null = null;
|
||||||
|
|
||||||
readonly #PURGE_INTERVAL = 60000;
|
|
||||||
|
|
||||||
// Reactive status map for Svelte components to track font states
|
// Reactive status map for Svelte components to track font states
|
||||||
statuses = new SvelteMap<string, FontLoadStatus>();
|
statuses = new SvelteMap<string, FontLoadStatus>();
|
||||||
|
|
||||||
// Starts periodic cleanup timer (browser-only).
|
// Starts periodic cleanup timer (browser-only).
|
||||||
constructor(
|
constructor(
|
||||||
{ cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }:
|
{ cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }:
|
||||||
AppliedFontsManagerDeps = {},
|
FontLifecycleManagerDeps = {},
|
||||||
) {
|
) {
|
||||||
// Inject collaborators - defaults provided for production, fakes for testing
|
// Inject collaborators - defaults provided for production, fakes for testing
|
||||||
this.#cache = cache;
|
this.#cache = cache;
|
||||||
this.#eviction = eviction;
|
this.#eviction = eviction;
|
||||||
this.#queue = queue;
|
this.#queue = queue;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
this.#intervalId = setInterval(() => this.#purgeUnused(), PURGE_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,11 +174,11 @@ export class AppliedFontsManager {
|
|||||||
if (typeof requestIdleCallback !== 'undefined') {
|
if (typeof requestIdleCallback !== 'undefined') {
|
||||||
this.#timeoutId = requestIdleCallback(
|
this.#timeoutId = requestIdleCallback(
|
||||||
() => this.#processQueue(),
|
() => this.#processQueue(),
|
||||||
{ timeout: 150 },
|
{ timeout: IDLE_CALLBACK_TIMEOUT_MS },
|
||||||
) as unknown as ReturnType<typeof setTimeout>;
|
) as unknown as ReturnType<typeof setTimeout>;
|
||||||
this.#pendingType = 'idle';
|
this.#pendingType = 'idle';
|
||||||
} else {
|
} else {
|
||||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
this.#timeoutId = setTimeout(() => this.#processQueue(), SCHEDULE_FALLBACK_MS);
|
||||||
this.#pendingType = 'timeout';
|
this.#pendingType = 'timeout';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,7 +210,7 @@ export class AppliedFontsManager {
|
|||||||
|
|
||||||
// In data-saver mode, only load variable fonts and common weights (400, 700)
|
// In data-saver mode, only load variable fonts and common weights (400, 700)
|
||||||
if (this.#shouldDeferNonCritical()) {
|
if (this.#shouldDeferNonCritical()) {
|
||||||
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
|
entries = entries.filter(([, c]) => c.isVariable || CRITICAL_FONT_WEIGHTS.includes(c.weight));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine optimal concurrent fetches based on network speed (1-4)
|
// Determine optimal concurrent fetches based on network speed (1-4)
|
||||||
@@ -198,7 +225,6 @@ export class AppliedFontsManager {
|
|||||||
// Parse buffers one at a time with periodic yields to avoid blocking UI
|
// Parse buffers one at a time with periodic yields to avoid blocking UI
|
||||||
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
||||||
let lastYield = performance.now();
|
let lastYield = performance.now();
|
||||||
const YIELD_INTERVAL = 8;
|
|
||||||
|
|
||||||
for (const [key, config] of entries) {
|
for (const [key, config] of entries) {
|
||||||
const buffer = buffers.get(key);
|
const buffer = buffers.get(key);
|
||||||
@@ -214,7 +240,7 @@ export class AppliedFontsManager {
|
|||||||
// Others: yield every 8ms as fallback
|
// Others: yield every 8ms as fallback
|
||||||
const shouldYield = hasInputPending
|
const shouldYield = hasInputPending
|
||||||
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
|
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
|
||||||
: performance.now() - lastYield > YIELD_INTERVAL;
|
: performance.now() - lastYield > YIELD_INTERVAL_MS;
|
||||||
|
|
||||||
if (shouldYield) {
|
if (shouldYield) {
|
||||||
await yieldToMainThread();
|
await yieldToMainThread();
|
||||||
@@ -396,4 +422,4 @@ export class AppliedFontsManager {
|
|||||||
/**
|
/**
|
||||||
* Singleton instance — use throughout the application for unified font loading state.
|
* Singleton instance — use throughout the application for unified font loading state.
|
||||||
*/
|
*/
|
||||||
export const appliedFontsManager = new AppliedFontsManager();
|
export const fontLifecycleManager = new FontLifecycleManager();
|
||||||
+8
-8
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @vitest-environment jsdom
|
* @vitest-environment jsdom
|
||||||
*/
|
*/
|
||||||
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
|
||||||
import { FontFetchError } from './errors';
|
import { FontFetchError } from './errors';
|
||||||
|
import { FontLifecycleManager } from './fontLifecycleManager.svelte';
|
||||||
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
||||||
|
|
||||||
class FakeBufferCache {
|
class FakeBufferCache {
|
||||||
@@ -32,8 +32,8 @@ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable:
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('AppliedFontsManager', () => {
|
describe('FontLifecycleManager', () => {
|
||||||
let manager: AppliedFontsManager;
|
let manager: FontLifecycleManager;
|
||||||
let eviction: FontEvictionPolicy;
|
let eviction: FontEvictionPolicy;
|
||||||
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
});
|
});
|
||||||
vi.stubGlobal('FontFace', MockFontFace);
|
vi.stubGlobal('FontFace', MockFontFace);
|
||||||
|
|
||||||
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction });
|
manager = new FontLifecycleManager({ cache: new FakeBufferCache() as any, eviction });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -101,7 +101,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
|
|
||||||
it('skips fonts that have exhausted retries', async () => {
|
it('skips fonts that have exhausted retries', async () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
|
||||||
|
|
||||||
// exhaust all 3 retries
|
// exhaust all 3 retries
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
@@ -160,7 +160,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
describe('Phase 1 — fetch', () => {
|
describe('Phase 1 — fetch', () => {
|
||||||
it('sets status to error on fetch failure', async () => {
|
it('sets status to error on fetch failure', async () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
|
||||||
|
|
||||||
failManager.touch([makeConfig('broken')]);
|
failManager.touch([makeConfig('broken')]);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
@@ -171,7 +171,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
|
|
||||||
it('logs a console error on fetch failure', async () => {
|
it('logs a console error on fetch failure', async () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
|
||||||
|
|
||||||
failManager.touch([makeConfig('broken')]);
|
failManager.touch([makeConfig('broken')]);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
@@ -189,7 +189,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
evict() {},
|
evict() {},
|
||||||
clear() {},
|
clear() {},
|
||||||
};
|
};
|
||||||
const abortManager = new AppliedFontsManager({ cache: abortingCache as any, eviction });
|
const abortManager = new FontLifecycleManager({ cache: abortingCache as any, eviction });
|
||||||
|
|
||||||
abortManager.touch([makeConfig('aborted')]);
|
abortManager.touch([makeConfig('aborted')]);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
+7
-2
@@ -1,6 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Default TTL after which an unpinned font is eligible for eviction.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_FONT_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
interface FontEvictionPolicyOptions {
|
interface FontEvictionPolicyOptions {
|
||||||
/**
|
/**
|
||||||
* TTL in milliseconds. Defaults to 5 minutes.
|
* TTL in milliseconds. Defaults to {@link DEFAULT_FONT_TTL_MS}.
|
||||||
*/
|
*/
|
||||||
ttl?: number;
|
ttl?: number;
|
||||||
}
|
}
|
||||||
@@ -17,7 +22,7 @@ export class FontEvictionPolicy {
|
|||||||
|
|
||||||
readonly #TTL: number;
|
readonly #TTL: number;
|
||||||
|
|
||||||
constructor({ ttl = 5 * 60 * 1000 }: FontEvictionPolicyOptions = {}) {
|
constructor({ ttl = DEFAULT_FONT_TTL_MS }: FontEvictionPolicyOptions = {}) {
|
||||||
this.#TTL = ttl;
|
this.#TTL = ttl;
|
||||||
}
|
}
|
||||||
|
|
||||||
+7
-3
@@ -1,5 +1,11 @@
|
|||||||
import type { FontLoadRequestConfig } from '../../../../types';
|
import type { FontLoadRequestConfig } from '../../../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of times a single font key will be retried before it is
|
||||||
|
* considered permanently failed.
|
||||||
|
*/
|
||||||
|
export const FONT_LOAD_MAX_RETRIES = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the font load queue and per-font retry counts.
|
* Manages the font load queue and per-font retry counts.
|
||||||
*
|
*
|
||||||
@@ -10,8 +16,6 @@ export class FontLoadQueue {
|
|||||||
#queue = new Map<string, FontLoadRequestConfig>();
|
#queue = new Map<string, FontLoadRequestConfig>();
|
||||||
#retryCounts = new Map<string, number>();
|
#retryCounts = new Map<string, number>();
|
||||||
|
|
||||||
readonly #MAX_RETRIES = 3;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a font to the queue.
|
* Adds a font to the queue.
|
||||||
* @returns `true` if the key was newly enqueued, `false` if it was already present.
|
* @returns `true` if the key was newly enqueued, `false` if it was already present.
|
||||||
@@ -52,7 +56,7 @@ export class FontLoadQueue {
|
|||||||
* Returns `true` if the font has reached or exceeded the maximum retry limit.
|
* Returns `true` if the font has reached or exceeded the maximum retry limit.
|
||||||
*/
|
*/
|
||||||
isMaxRetriesReached(key: string): boolean {
|
isMaxRetriesReached(key: string): boolean {
|
||||||
return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES;
|
return (this.#retryCounts.get(key) ?? 0) >= FONT_LOAD_MAX_RETRIES;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
-2
@@ -2,9 +2,7 @@
|
|||||||
* Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() where available or MessageChannel fallback.
|
* Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() where available or MessageChannel fallback.
|
||||||
*/
|
*/
|
||||||
export async function yieldToMainThread(): Promise<void> {
|
export async function yieldToMainThread(): Promise<void> {
|
||||||
// @ts-expect-error - scheduler not in TypeScript lib yet
|
|
||||||
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
|
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
|
||||||
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
|
|
||||||
await scheduler.yield();
|
await scheduler.yield();
|
||||||
} else {
|
} else {
|
||||||
await new Promise<void>(resolve => {
|
await new Promise<void>(resolve => {
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
// Applied fonts manager
|
// Font lifecycle manager (browser-side load + cache + eviction)
|
||||||
export * from './appliedFontsStore/appliedFontsStore.svelte';
|
export * from './fontLifecycleManager/fontLifecycleManager.svelte';
|
||||||
|
|
||||||
// Batch font store
|
// Paginated catalog
|
||||||
export { BatchFontStore } from './batchFontStore.svelte';
|
|
||||||
|
|
||||||
// Single FontStore
|
|
||||||
export {
|
export {
|
||||||
createFontStore,
|
createFontCatalogStore,
|
||||||
FontStore,
|
FontCatalogStore,
|
||||||
fontStore,
|
fontCatalogStore,
|
||||||
} from './fontStore/fontStore.svelte';
|
} from './fontCatalogStore/fontCatalogStore.svelte';
|
||||||
|
|||||||
@@ -23,5 +23,5 @@ export type {
|
|||||||
FontCollectionState,
|
FontCollectionState,
|
||||||
} from './store';
|
} from './store';
|
||||||
|
|
||||||
export * from './store/appliedFonts';
|
export * from './store/fontLifecycle';
|
||||||
export * from './typography';
|
export * from './typography';
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Font that has never been loaded by appliedFontsManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
|
'Font that has never been loaded by fontLifecycleManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -58,7 +58,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Uses Arial, a system font available in all browsers. Because appliedFontsManager 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.',
|
'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.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -77,7 +77,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Demonstrates passing a custom weight (700). The weight is forwarded to appliedFontsManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
|
'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.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
Shows the skeleton snippet while loading; falls back to system font if no skeleton is provided.
|
Shows the skeleton snippet while loading; falls back to system font if no skeleton is provided.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import clsx from 'clsx';
|
import { cn } from '$shared/lib';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
appliedFontsManager,
|
fontLifecycleManager,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -46,7 +46,7 @@ let {
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const status = $derived(
|
const status = $derived(
|
||||||
appliedFontsManager.getFontStatus(
|
fontLifecycleManager.getFontStatus(
|
||||||
font.id,
|
font.id,
|
||||||
weight,
|
weight,
|
||||||
font.features?.isVariable,
|
font.features?.isVariable,
|
||||||
@@ -61,7 +61,7 @@ const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
|||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
|
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
|
||||||
class={clsx(className)}
|
class={cn(className)}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const { Story } = defineMeta({
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
'Virtualized font list backed by the `fontStore` singleton. Handles font loading registration (pin/touch) for visible items and triggers infinite scroll pagination via `fontStore.nextPage()`. Because the component reads directly from the `fontStore` singleton, stories render against a live (but empty/loading) store — no font data will appear unless the API is reachable from the Storybook host.',
|
'Virtualized font list backed by the `fontCatalogStore` singleton. Handles font loading registration (pin/touch) for visible items and triggers infinite scroll pagination via `fontCatalogStore.nextPage()`. Because the component reads directly from the `fontCatalogStore` singleton, stories render against a live (but empty/loading) store — no font data will appear unless the API is reachable from the Storybook host.',
|
||||||
},
|
},
|
||||||
story: { inline: false },
|
story: { inline: false },
|
||||||
},
|
},
|
||||||
@@ -33,7 +33,7 @@ import type { ComponentProps } from 'svelte';
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Skeleton state shown while `fontStore.fonts` is empty and `fontStore.isLoading` is true. In a real session the skeleton fades out once the first page loads.',
|
'Skeleton state shown while `fontCatalogStore.fonts` is empty and `fontCatalogStore.isLoading` is true. In a real session the skeleton fades out once the first page loads.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -63,7 +63,7 @@ import type { ComponentProps } from 'svelte';
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'No `skeleton` snippet provided. When `fontStore.fonts` is empty the underlying VirtualList renders its empty state directly.',
|
'No `skeleton` snippet provided. When `fontCatalogStore.fonts` is empty the underlying VirtualList renders its empty state directly.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -86,7 +86,7 @@ import type { ComponentProps } from 'svelte';
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Demonstrates how to configure a `children` snippet for item rendering. The list will be empty because `fontStore` is not populated in Storybook, but the template shows the expected slot shape: `{ item: UnifiedFont }`.',
|
'Demonstrates how to configure a `children` snippet for item rendering. The list will be empty because `fontCatalogStore` is not populated in Storybook, but the template shows the expected slot shape: `{ item: UnifiedFont }`.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import { getFontUrl } from '../../lib';
|
|||||||
import {
|
import {
|
||||||
type FontLoadRequestConfig,
|
type FontLoadRequestConfig,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
appliedFontsManager,
|
fontCatalogStore,
|
||||||
fontStore,
|
fontLifecycleManager,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
interface Props extends
|
interface Props extends
|
||||||
@@ -51,13 +51,13 @@ let {
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const isLoading = $derived(
|
const isLoading = $derived(
|
||||||
fontStore.isFetching || fontStore.isLoading,
|
fontCatalogStore.isFetching || fontCatalogStore.isLoading,
|
||||||
);
|
);
|
||||||
|
|
||||||
let visibleFonts = $state<UnifiedFont[]>([]);
|
let visibleFonts = $state<UnifiedFont[]>([]);
|
||||||
let isCatchingUp = $state(false);
|
let isCatchingUp = $state(false);
|
||||||
|
|
||||||
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontStore.fonts.length === 0);
|
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontCatalogStore.fonts.length === 0);
|
||||||
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
|
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
|
||||||
|
|
||||||
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||||
@@ -68,24 +68,30 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle jump scroll — batch-load all missing pages then re-enable font loading.
|
* Handle jump scroll — batch-load all missing pages then re-enable font loading.
|
||||||
* Suppresses appliedFontsManager.touch() during catch-up to avoid loading
|
* Suppresses fontLifecycleManager.touch() during catch-up to avoid loading
|
||||||
* 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 || !fontStore.pagination.hasMore) {
|
if (isCatchingUp || !fontCatalogStore.pagination.hasMore) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isCatchingUp = true;
|
isCatchingUp = true;
|
||||||
try {
|
try {
|
||||||
await fontStore.fetchAllPagesTo(targetIndex);
|
await fontCatalogStore.fetchAllPagesTo(targetIndex);
|
||||||
} finally {
|
} finally {
|
||||||
isCatchingUp = false;
|
isCatchingUp = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce wait before asking the font lifecycle manager to load fonts
|
||||||
|
* for the current visible window. Coalesces rapid scroll into one batch.
|
||||||
|
*/
|
||||||
|
const TOUCH_DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
const debouncedTouch = debounce((configs: FontLoadRequestConfig[]) => {
|
const debouncedTouch = debounce((configs: FontLoadRequestConfig[]) => {
|
||||||
appliedFontsManager.touch(configs);
|
fontLifecycleManager.touch(configs);
|
||||||
}, 150);
|
}, TOUCH_DEBOUNCE_MS);
|
||||||
|
|
||||||
// Re-touch whenever visible set or weight changes — fixes weight-change gap
|
// Re-touch whenever visible set or weight changes — fixes weight-change gap
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -111,11 +117,11 @@ $effect(() => {
|
|||||||
const w = weight;
|
const w = weight;
|
||||||
const fonts = visibleFonts;
|
const fonts = visibleFonts;
|
||||||
for (const f of fonts) {
|
for (const f of fonts) {
|
||||||
appliedFontsManager.pin(f.id, w, f.features?.isVariable);
|
fontLifecycleManager.pin(f.id, w, f.features?.isVariable);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
for (const f of fonts) {
|
for (const f of fonts) {
|
||||||
appliedFontsManager.unpin(f.id, w, f.features?.isVariable);
|
fontLifecycleManager.unpin(f.id, w, f.features?.isVariable);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -125,12 +131,12 @@ $effect(() => {
|
|||||||
*/
|
*/
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
if (
|
if (
|
||||||
!fontStore.pagination.hasMore
|
!fontCatalogStore.pagination.hasMore
|
||||||
|| fontStore.isFetching
|
|| fontCatalogStore.isFetching
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fontStore.nextPage();
|
fontCatalogStore.nextPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,12 +146,12 @@ 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 } = fontStore.pagination;
|
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 && !fontStore.isFetching && !isCatchingUp) {
|
if (hasMore && !fontCatalogStore.isFetching && !isCatchingUp) {
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,8 +166,8 @@ function handleNearBottom(_lastVisibleIndex: number) {
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||||
<VirtualList
|
<VirtualList
|
||||||
items={fontStore.fonts}
|
items={fontCatalogStore.fonts}
|
||||||
total={fontStore.pagination.total}
|
total={fontCatalogStore.pagination.total}
|
||||||
isLoading={isLoading || isCatchingUp}
|
isLoading={isLoading || isCatchingUp}
|
||||||
onVisibleItemsChange={handleInternalVisibleChange}
|
onVisibleItemsChange={handleInternalVisibleChange}
|
||||||
onNearBottom={handleNearBottom}
|
onNearBottom={handleNearBottom}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
createTypographySettingsStore,
|
||||||
|
type TypographySettingsStore,
|
||||||
|
typographySettingsStore,
|
||||||
|
} from './model';
|
||||||
|
export { TypographyMenu } from './ui';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export {
|
||||||
|
createTypographySettingsStore,
|
||||||
|
type TypographySettingsStore,
|
||||||
|
typographySettingsStore,
|
||||||
|
} from './store/typographySettingsStore/typographySettingsStore.svelte';
|
||||||
+32
-5
@@ -16,6 +16,7 @@ import {
|
|||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
DEFAULT_LETTER_SPACING,
|
DEFAULT_LETTER_SPACING,
|
||||||
DEFAULT_LINE_HEIGHT,
|
DEFAULT_LINE_HEIGHT,
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import {
|
import {
|
||||||
type ControlDataModel,
|
type ControlDataModel,
|
||||||
@@ -27,6 +28,14 @@ import {
|
|||||||
} from '$shared/lib';
|
} from '$shared/lib';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Epsilon for detecting "significant" base-size changes when reconciling
|
||||||
|
* the multiplier-derived display value back to the underlying baseSize.
|
||||||
|
* Differences below this threshold are treated as rounding jitter and
|
||||||
|
* skipped to avoid spurious storage writes.
|
||||||
|
*/
|
||||||
|
const BASE_SIZE_EPSILON = 0.01;
|
||||||
|
|
||||||
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,7 +76,7 @@ export interface TypographySettings {
|
|||||||
* Manages multiple typography controls with persistent storage and
|
* Manages multiple typography controls with persistent storage and
|
||||||
* responsive scaling support for font size.
|
* responsive scaling support for font size.
|
||||||
*/
|
*/
|
||||||
export class TypographySettingsManager {
|
export class TypographySettingsStore {
|
||||||
/**
|
/**
|
||||||
* Internal map of reactive controls keyed by their identifier
|
* Internal map of reactive controls keyed by their identifier
|
||||||
*/
|
*/
|
||||||
@@ -138,7 +147,7 @@ export class TypographySettingsManager {
|
|||||||
const calculatedBase = currentDisplayValue / this.#multiplier;
|
const calculatedBase = currentDisplayValue / this.#multiplier;
|
||||||
|
|
||||||
// Only update if the difference is significant (prevents rounding jitter)
|
// Only update if the difference is significant (prevents rounding jitter)
|
||||||
if (Math.abs(this.#baseSize - calculatedBase) > 0.01) {
|
if (Math.abs(this.#baseSize - calculatedBase) > BASE_SIZE_EPSILON) {
|
||||||
this.#baseSize = calculatedBase;
|
this.#baseSize = calculatedBase;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -296,6 +305,16 @@ export class TypographySettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default factory storage key — used when a caller doesn't pass one.
|
||||||
|
*/
|
||||||
|
const DEFAULT_STORAGE_KEY = 'glyphdiff:typography';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage key used by the app-wide singleton (scoped to comparison view).
|
||||||
|
*/
|
||||||
|
const COMPARISON_STORAGE_KEY = 'glyphdiff:comparison:typography';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a typography control manager
|
* Creates a typography control manager
|
||||||
*
|
*
|
||||||
@@ -303,9 +322,9 @@ export class TypographySettingsManager {
|
|||||||
* @param storageId - Persistent storage identifier
|
* @param storageId - Persistent storage identifier
|
||||||
* @returns Typography control manager instance
|
* @returns Typography control manager instance
|
||||||
*/
|
*/
|
||||||
export function createTypographySettingsManager(
|
export function createTypographySettingsStore(
|
||||||
configs: ControlModel<ControlId>[],
|
configs: ControlModel<ControlId>[],
|
||||||
storageId: string = 'glyphdiff:typography',
|
storageId: string = DEFAULT_STORAGE_KEY,
|
||||||
) {
|
) {
|
||||||
const storage = createPersistentStore<TypographySettings>(storageId, {
|
const storage = createPersistentStore<TypographySettings>(storageId, {
|
||||||
fontSize: DEFAULT_FONT_SIZE,
|
fontSize: DEFAULT_FONT_SIZE,
|
||||||
@@ -313,5 +332,13 @@ export function createTypographySettingsManager(
|
|||||||
lineHeight: DEFAULT_LINE_HEIGHT,
|
lineHeight: DEFAULT_LINE_HEIGHT,
|
||||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||||
});
|
});
|
||||||
return new TypographySettingsManager(configs, storage);
|
return new TypographySettingsStore(configs, storage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-wide typography settings singleton, keyed for the comparison view.
|
||||||
|
*/
|
||||||
|
export const typographySettingsStore = createTypographySettingsStore(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
COMPARISON_STORAGE_KEY,
|
||||||
|
);
|
||||||
+45
-45
@@ -17,13 +17,13 @@ import {
|
|||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import {
|
import {
|
||||||
type TypographySettings,
|
type TypographySettings,
|
||||||
TypographySettingsManager,
|
TypographySettingsStore,
|
||||||
} from './settingsManager.svelte';
|
} from './typographySettingsStore.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test Strategy for TypographySettingsManager
|
* Test Strategy for TypographySettingsStore
|
||||||
*
|
*
|
||||||
* This test suite validates the TypographySettingsManager state management logic.
|
* This test suite validates the TypographySettingsStore state management logic.
|
||||||
* These are unit tests for the manager logic, separate from component rendering.
|
* These are unit tests for the manager logic, separate from component rendering.
|
||||||
*
|
*
|
||||||
* NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
|
* NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
|
||||||
@@ -46,7 +46,7 @@ async function flushEffects() {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('TypographySettingsManager - Unit Tests', () => {
|
describe('TypographySettingsStore - Unit Tests', () => {
|
||||||
let mockStorage: TypographySettings;
|
let mockStorage: TypographySettings;
|
||||||
let mockPersistentStore: {
|
let mockPersistentStore: {
|
||||||
value: TypographySettings;
|
value: TypographySettings;
|
||||||
@@ -86,7 +86,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Initialization', () => {
|
describe('Initialization', () => {
|
||||||
it('creates manager with default values from storage', () => {
|
it('creates manager with default values from storage', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -106,7 +106,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -118,7 +118,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
|
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -127,7 +127,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns all controls via controls getter', () => {
|
it('returns all controls via controls getter', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -143,7 +143,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns individual controls via specific getters', () => {
|
it('returns individual controls via specific getters', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -161,7 +161,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('control instances have expected interface', () => {
|
it('control instances have expected interface', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -180,7 +180,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Multiplier System', () => {
|
describe('Multiplier System', () => {
|
||||||
it('has default multiplier of 1', () => {
|
it('has default multiplier of 1', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -189,7 +189,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates multiplier when set', () => {
|
it('updates multiplier when set', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -202,7 +202,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not update multiplier if set to same value', () => {
|
it('does not update multiplier if set to same value', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -218,7 +218,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
||||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -242,7 +242,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates font size control display value when multiplier increases', () => {
|
it('updates font size control display value when multiplier increases', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -263,7 +263,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Base Size Setter', () => {
|
describe('Base Size Setter', () => {
|
||||||
it('updates baseSize when set directly', () => {
|
it('updates baseSize when set directly', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -274,7 +274,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates size control value when baseSize is set', () => {
|
it('updates size control value when baseSize is set', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -285,7 +285,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('applies multiplier to size control when baseSize is set', () => {
|
it('applies multiplier to size control when baseSize is set', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -299,7 +299,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Rendered Size Calculation', () => {
|
describe('Rendered Size Calculation', () => {
|
||||||
it('calculates renderedSize as baseSize * multiplier', () => {
|
it('calculates renderedSize as baseSize * multiplier', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -308,7 +308,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates renderedSize when multiplier changes', () => {
|
it('updates renderedSize when multiplier changes', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -321,7 +321,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates renderedSize when baseSize changes', () => {
|
it('updates renderedSize when baseSize changes', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -341,7 +341,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
// proxy effect behavior should be tested in E2E tests.
|
// proxy effect behavior should be tested in E2E tests.
|
||||||
|
|
||||||
it('does NOT immediately update baseSize from control change (effect is async)', () => {
|
it('does NOT immediately update baseSize from control change (effect is async)', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -356,7 +356,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates baseSize via direct setter (synchronous)', () => {
|
it('updates baseSize via direct setter (synchronous)', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -381,7 +381,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -394,7 +394,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('syncs to storage after effect flush (async)', async () => {
|
it('syncs to storage after effect flush (async)', async () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -410,7 +410,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('syncs control changes to storage after effect flush (async)', async () => {
|
it('syncs control changes to storage after effect flush (async)', async () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -423,7 +423,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('syncs height control changes to storage after effect flush (async)', async () => {
|
it('syncs height control changes to storage after effect flush (async)', async () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -435,7 +435,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('syncs spacing control changes to storage after effect flush (async)', async () => {
|
it('syncs spacing control changes to storage after effect flush (async)', async () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -449,7 +449,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Control Value Getters', () => {
|
describe('Control Value Getters', () => {
|
||||||
it('returns current weight value', () => {
|
it('returns current weight value', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -461,7 +461,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns current height value', () => {
|
it('returns current height value', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -473,7 +473,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns current spacing value', () => {
|
it('returns current spacing value', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -486,7 +486,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
|
|
||||||
it('returns default value when control is not found', () => {
|
it('returns default value when control is not found', () => {
|
||||||
// Create a manager with empty configs (no controls)
|
// Create a manager with empty configs (no controls)
|
||||||
const manager = new TypographySettingsManager([], mockPersistentStore);
|
const manager = new TypographySettingsStore([], mockPersistentStore);
|
||||||
|
|
||||||
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
||||||
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
||||||
@@ -504,7 +504,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -537,7 +537,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
clear: clearSpy,
|
clear: clearSpy,
|
||||||
};
|
};
|
||||||
|
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -548,7 +548,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('respects multiplier when resetting font size control', () => {
|
it('respects multiplier when resetting font size control', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -566,7 +566,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Complex Scenarios', () => {
|
describe('Complex Scenarios', () => {
|
||||||
it('handles changing multiplier then modifying baseSize', () => {
|
it('handles changing multiplier then modifying baseSize', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -587,7 +587,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('maintains correct renderedSize throughout changes', () => {
|
it('maintains correct renderedSize throughout changes', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -609,7 +609,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles multiple control changes in sequence', async () => {
|
it('handles multiple control changes in sequence', async () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -634,7 +634,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
||||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -646,7 +646,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles very small multiplier', () => {
|
it('handles very small multiplier', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -659,7 +659,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles large base size with multiplier', () => {
|
it('handles large base size with multiplier', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -672,7 +672,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles floating point precision in multiplier', () => {
|
it('handles floating point precision in multiplier', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -691,7 +691,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles control methods (increase/decrease)', () => {
|
it('handles control methods (increase/decrease)', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -705,7 +705,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles control boundary conditions', () => {
|
it('handles control boundary conditions', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<!--
|
||||||
|
Component: TypographyMenu
|
||||||
|
Floating controls bar for typography settings.
|
||||||
|
Mobile: popover with slider controls anchored to settings button.
|
||||||
|
Desktop: inline bar with combo controls.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
MULTIPLIER_L,
|
||||||
|
MULTIPLIER_M,
|
||||||
|
MULTIPLIER_S,
|
||||||
|
} from '$entities/Font';
|
||||||
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ComboControl,
|
||||||
|
ControlGroup,
|
||||||
|
Slider,
|
||||||
|
} from '$shared/ui';
|
||||||
|
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||||
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
|
import { Popover } from 'bits-ui';
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
import { typographySettingsStore } from '../../model';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* CSS classes
|
||||||
|
*/
|
||||||
|
class?: string;
|
||||||
|
/**
|
||||||
|
* Hidden state
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
hidden?: boolean;
|
||||||
|
/**
|
||||||
|
* Bindable popover open state
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
open?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the common font size multiplier based on the current responsive state.
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
if (!responsive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (true) {
|
||||||
|
case responsive.isMobile:
|
||||||
|
typographySettingsStore.multiplier = MULTIPLIER_S;
|
||||||
|
break;
|
||||||
|
case responsive.isTablet:
|
||||||
|
typographySettingsStore.multiplier = MULTIPLIER_M;
|
||||||
|
break;
|
||||||
|
case responsive.isDesktop:
|
||||||
|
typographySettingsStore.multiplier = MULTIPLIER_L;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
typographySettingsStore.multiplier = MULTIPLIER_L;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !hidden}
|
||||||
|
{#if responsive.isMobileOrTablet}
|
||||||
|
<div class={className}>
|
||||||
|
<Popover.Root bind:open>
|
||||||
|
<Popover.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button variant="primary" {...props}>
|
||||||
|
{#snippet icon()}
|
||||||
|
<Settings2Icon class="size-4" />
|
||||||
|
{/snippet}
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</Popover.Trigger>
|
||||||
|
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
side="top"
|
||||||
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
class={cn(
|
||||||
|
'z-50 w-72 p-4 rounded-none',
|
||||||
|
'surface-popover',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
'data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
'data-[side=bottom]:slide-in-from-top-2',
|
||||||
|
)}
|
||||||
|
interactOutsideBehavior="close"
|
||||||
|
escapeKeydownBehavior="close"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Settings2Icon size={12} class="text-swiss-red" />
|
||||||
|
<span
|
||||||
|
class="text-3xs font-mono uppercase tracking-widest font-bold text-swiss-black dark:text-neutral-200"
|
||||||
|
>
|
||||||
|
CONTROLS
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Popover.Close>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||||
|
aria-label="Close controls"
|
||||||
|
>
|
||||||
|
<XIcon class="size-3.5 text-neutral-500" />
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Popover.Close>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
{#each typographySettingsStore.controls as control (control.id)}
|
||||||
|
<ControlGroup label={control.controlLabel ?? ''}>
|
||||||
|
<Slider
|
||||||
|
bind:value={control.instance.value}
|
||||||
|
min={control.instance.min}
|
||||||
|
max={control.instance.max}
|
||||||
|
step={control.instance.step}
|
||||||
|
/>
|
||||||
|
</ControlGroup>
|
||||||
|
{/each}
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class={cn('w-full md:w-auto', className)}
|
||||||
|
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
|
||||||
|
'surface-floating bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
|
||||||
|
'shadow-popover rounded-none',
|
||||||
|
'ring-1 ring-black/5 dark:ring-white/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<!-- Header: icon + label -->
|
||||||
|
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
|
||||||
|
<Settings2Icon
|
||||||
|
size={14}
|
||||||
|
class="text-swiss-red"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="text-3xs md:text-2xs font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
|
||||||
|
>
|
||||||
|
GLOBAL_CONTROLS
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls with dividers between each -->
|
||||||
|
{#each typographySettingsStore.controls as control, i (control.id)}
|
||||||
|
<div class="w-px h-4 md:h-6 bg-subtle mx-0.5 md:mx-1 shrink-0"></div>
|
||||||
|
|
||||||
|
<ComboControl
|
||||||
|
control={control.instance}
|
||||||
|
label={control.controlLabel}
|
||||||
|
increaseLabel={control.increaseLabel}
|
||||||
|
decreaseLabel={control.decreaseLabel}
|
||||||
|
controlLabel={control.controlLabel}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -30,6 +30,8 @@
|
|||||||
|
|
||||||
import { createPersistentStore } from '$shared/lib';
|
import { createPersistentStore } from '$shared/lib';
|
||||||
|
|
||||||
|
export const STORAGE_KEY = 'glyphdiff:theme';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
type Theme = 'light' | 'dark';
|
||||||
type ThemeSource = 'system' | 'user';
|
type ThemeSource = 'system' | 'user';
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ class ThemeManager {
|
|||||||
/**
|
/**
|
||||||
* Persistent storage for user's theme preference
|
* Persistent storage for user's theme preference
|
||||||
*/
|
*/
|
||||||
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
|
#store = createPersistentStore<Theme | null>(STORAGE_KEY, null);
|
||||||
/**
|
/**
|
||||||
* Bound handler for system theme change events
|
* Bound handler for system theme change events
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -40,8 +40,7 @@ import { ThemeManager } from './ThemeManager.svelte';
|
|||||||
* - MediaQueryList listener management
|
* - MediaQueryList listener management
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Storage key used by ThemeManager
|
import { STORAGE_KEY } from './ThemeManager.svelte';
|
||||||
const STORAGE_KEY = 'glyphdiff:theme';
|
|
||||||
|
|
||||||
// Helper type for MediaQueryList event handler
|
// Helper type for MediaQueryList event handler
|
||||||
type MediaQueryListCallback = (this: MediaQueryList, ev: MediaQueryListEvent) => void;
|
type MediaQueryListCallback = (this: MediaQueryList, ev: MediaQueryListEvent) => void;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
FontApplicator,
|
FontApplicator,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import { typographySettingsStore } from '$features/SetupFont/model';
|
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
ContentEditable,
|
ContentEditable,
|
||||||
@@ -58,12 +58,10 @@ const stats = $derived([
|
|||||||
class="
|
class="
|
||||||
group relative
|
group relative
|
||||||
w-full h-full
|
w-full h-full
|
||||||
bg-paper dark:bg-dark-card
|
surface-card
|
||||||
border border-subtle
|
|
||||||
hover:border-brand dark:hover:border-brand
|
hover:border-brand dark:hover:border-brand
|
||||||
hover:shadow-brand/10
|
hover:shadow-stamp-card
|
||||||
hover:shadow-[5px_5px_0px_0px]
|
transition-all duration-normal
|
||||||
transition-all duration-200
|
|
||||||
overflow-hidden
|
overflow-hidden
|
||||||
flex flex-col
|
flex flex-col
|
||||||
min-h-60
|
min-h-60
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { FontsByIdsStore } from './model';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { FontsByIdsStore } from './store/fontsByIdsStore/fontsByIdsStore.svelte';
|
||||||
+8
-9
@@ -1,14 +1,14 @@
|
|||||||
import { fontKeys } from '$shared/api/queryKeys';
|
|
||||||
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
|
|
||||||
import {
|
import {
|
||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
seedFontCache,
|
seedFontCache,
|
||||||
} from '../../api/proxy/proxyFonts';
|
} from '$entities/Font/api/proxy/proxyFonts';
|
||||||
import {
|
import {
|
||||||
FontNetworkError,
|
FontNetworkError,
|
||||||
FontResponseError,
|
FontResponseError,
|
||||||
} from '../../lib/errors/errors';
|
} from '$entities/Font/lib/errors/errors';
|
||||||
import type { UnifiedFont } from '../../model/types';
|
import type { UnifiedFont } from '$entities/Font/model/types';
|
||||||
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
|
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal fetcher that seeds the cache and handles error wrapping.
|
* Internal fetcher that seeds the cache and handles error wrapping.
|
||||||
@@ -35,11 +35,10 @@ async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive store for fetching and caching batches of fonts by ID.
|
* Reactive store for fetching specific fonts by ID via the proxy batch endpoint.
|
||||||
* Integrates with TanStack Query via BaseQueryStore and handles
|
* Wraps TanStack Query and seeds the detail cache for sibling consumers.
|
||||||
* normalized cache seeding.
|
|
||||||
*/
|
*/
|
||||||
export class BatchFontStore extends BaseQueryStore<UnifiedFont[]> {
|
export class FontsByIdsStore extends BaseQueryStore<UnifiedFont[]> {
|
||||||
constructor(initialIds: string[] = []) {
|
constructor(initialIds: string[] = []) {
|
||||||
super({
|
super({
|
||||||
queryKey: fontKeys.batch(initialIds),
|
queryKey: fontKeys.batch(initialIds),
|
||||||
+15
-15
@@ -1,3 +1,8 @@
|
|||||||
|
import * as api from '$entities/Font/api/proxy/proxyFonts';
|
||||||
|
import {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from '$entities/Font/lib/errors/errors';
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
import { fontKeys } from '$shared/api/queryKeys';
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
import {
|
import {
|
||||||
@@ -7,14 +12,9 @@ import {
|
|||||||
it,
|
it,
|
||||||
vi,
|
vi,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import * as api from '../../api/proxy/proxyFonts';
|
import { FontsByIdsStore } from './fontsByIdsStore.svelte';
|
||||||
import {
|
|
||||||
FontNetworkError,
|
|
||||||
FontResponseError,
|
|
||||||
} from '../../lib/errors/errors';
|
|
||||||
import { BatchFontStore } from './batchFontStore.svelte';
|
|
||||||
|
|
||||||
describe('BatchFontStore', () => {
|
describe('FontsByIdsStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -23,7 +23,7 @@ describe('BatchFontStore', () => {
|
|||||||
describe('Fetch Behavior', () => {
|
describe('Fetch Behavior', () => {
|
||||||
it('should skip fetch when initialized with empty IDs', async () => {
|
it('should skip fetch when initialized with empty IDs', async () => {
|
||||||
const spy = vi.spyOn(api, 'fetchFontsByIds');
|
const spy = vi.spyOn(api, 'fetchFontsByIds');
|
||||||
const store = new BatchFontStore([]);
|
const store = new FontsByIdsStore([]);
|
||||||
expect(spy).not.toHaveBeenCalled();
|
expect(spy).not.toHaveBeenCalled();
|
||||||
expect(store.fonts).toEqual([]);
|
expect(store.fonts).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -31,7 +31,7 @@ describe('BatchFontStore', () => {
|
|||||||
it('should fetch and seed cache for valid IDs', async () => {
|
it('should fetch and seed cache for valid IDs', async () => {
|
||||||
const fonts = [{ id: 'a', name: 'A' }] as any[];
|
const fonts = [{ id: 'a', name: 'A' }] as any[];
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
||||||
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
|
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
|
||||||
});
|
});
|
||||||
@@ -42,7 +42,7 @@ describe('BatchFontStore', () => {
|
|||||||
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
|
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
|
||||||
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
|
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
|
||||||
);
|
);
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
expect(store.isLoading).toBe(true);
|
expect(store.isLoading).toBe(true);
|
||||||
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
|
||||||
});
|
});
|
||||||
@@ -51,7 +51,7 @@ describe('BatchFontStore', () => {
|
|||||||
describe('Error Handling', () => {
|
describe('Error Handling', () => {
|
||||||
it('should wrap network failures in FontNetworkError', async () => {
|
it('should wrap network failures in FontNetworkError', async () => {
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
|
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||||
expect(store.error).toBeInstanceOf(FontNetworkError);
|
expect(store.error).toBeInstanceOf(FontNetworkError);
|
||||||
});
|
});
|
||||||
@@ -59,7 +59,7 @@ describe('BatchFontStore', () => {
|
|||||||
it('should handle malformed API responses with FontResponseError', async () => {
|
it('should handle malformed API responses with FontResponseError', async () => {
|
||||||
// Mocking a malformed response that the store should validate
|
// Mocking a malformed response that the store should validate
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any);
|
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any);
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||||
expect(store.error).toBeInstanceOf(FontResponseError);
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
});
|
});
|
||||||
@@ -67,7 +67,7 @@ describe('BatchFontStore', () => {
|
|||||||
it('should have null error in success state', async () => {
|
it('should have null error in success state', async () => {
|
||||||
const fonts = [{ id: 'a' }] as any[];
|
const fonts = [{ id: 'a' }] as any[];
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
||||||
expect(store.error).toBeNull();
|
expect(store.error).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -78,7 +78,7 @@ describe('BatchFontStore', () => {
|
|||||||
const fonts1 = [{ id: 'a' }] as any[];
|
const fonts1 = [{ id: 'a' }] as any[];
|
||||||
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
|
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
|
||||||
|
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
||||||
|
|
||||||
spy.mockClear();
|
spy.mockClear();
|
||||||
@@ -97,7 +97,7 @@ describe('BatchFontStore', () => {
|
|||||||
.mockResolvedValueOnce(fonts1)
|
.mockResolvedValueOnce(fonts1)
|
||||||
.mockResolvedValueOnce(fonts2);
|
.mockResolvedValueOnce(fonts2);
|
||||||
|
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
||||||
|
|
||||||
store.setIds(['b']);
|
store.setIds(['b']);
|
||||||
+2
-1
@@ -8,8 +8,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '$shared/api/api';
|
import { api } from '$shared/api/api';
|
||||||
|
import { API_ENDPOINTS } from '$shared/api/endpoints';
|
||||||
|
|
||||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/filters' as const;
|
const PROXY_API_URL = API_ENDPOINTS.filters;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter metadata type from backend
|
* Filter metadata type from backend
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export { mapAppliedFiltersToParams } from './lib';
|
||||||
|
|
||||||
|
export {
|
||||||
|
type AppliedFilterStore,
|
||||||
|
appliedFilterStore,
|
||||||
|
/**
|
||||||
|
* Filter Store
|
||||||
|
*/
|
||||||
|
availableFilterStore,
|
||||||
|
/**
|
||||||
|
* Filter Manager
|
||||||
|
*/
|
||||||
|
createAppliedFilterStore,
|
||||||
|
/**
|
||||||
|
* Sort Store
|
||||||
|
*/
|
||||||
|
SORT_MAP,
|
||||||
|
SORT_OPTIONS,
|
||||||
|
type SortApiValue,
|
||||||
|
type SortOption,
|
||||||
|
sortStore,
|
||||||
|
} from './model';
|
||||||
|
|
||||||
|
export {
|
||||||
|
FilterControls,
|
||||||
|
Filters,
|
||||||
|
} from './ui';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { mapAppliedFiltersToParams } from './mapper/mapAppliedFiltersToParams';
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { Property } from '$shared/lib';
|
||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import { createAppliedFilterStore } from '../../model/store/appliedFilterStore/appliedFilterStore.svelte';
|
||||||
|
import { mapAppliedFiltersToParams } from './mapAppliedFiltersToParams';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Property with explicit selection state.
|
||||||
|
*/
|
||||||
|
function prop(value: string, selected = false): Property<string> {
|
||||||
|
return { id: value, name: value, value, selected };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a filter group with a known id and a list of (value, selected) entries.
|
||||||
|
*/
|
||||||
|
function group(id: string, props: Array<[string, boolean]>) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: id,
|
||||||
|
properties: props.map(([value, selected]) => prop(value, selected)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mapAppliedFiltersToParams', () => {
|
||||||
|
describe('search query', () => {
|
||||||
|
it('omits q when query is empty', () => {
|
||||||
|
const manager = createAppliedFilterStore({ queryValue: '', groups: [] });
|
||||||
|
expect(mapAppliedFiltersToParams(manager).q).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the debounced query through as q', () => {
|
||||||
|
// Constructor seeds both immediate and debounced synchronously.
|
||||||
|
const manager = createAppliedFilterStore({ queryValue: 'roboto', groups: [] });
|
||||||
|
expect(mapAppliedFiltersToParams(manager).q).toBe('roboto');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('group selections', () => {
|
||||||
|
it('omits a group entirely when no group with that id exists', () => {
|
||||||
|
const manager = createAppliedFilterStore({ queryValue: '', groups: [] });
|
||||||
|
const params = mapAppliedFiltersToParams(manager);
|
||||||
|
expect(params.providers).toBeUndefined();
|
||||||
|
expect(params.categories).toBeUndefined();
|
||||||
|
expect(params.subsets).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits a group when it exists but has no selections', () => {
|
||||||
|
const manager = createAppliedFilterStore({
|
||||||
|
queryValue: '',
|
||||||
|
groups: [group('providers', [['google', false], ['fontshare', false]])],
|
||||||
|
});
|
||||||
|
expect(mapAppliedFiltersToParams(manager).providers).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the selected values for a single group', () => {
|
||||||
|
const manager = createAppliedFilterStore({
|
||||||
|
queryValue: '',
|
||||||
|
groups: [group('providers', [['google', true], ['fontshare', false]])],
|
||||||
|
});
|
||||||
|
expect(mapAppliedFiltersToParams(manager).providers).toEqual(['google']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns multiple selected values in selection order', () => {
|
||||||
|
const manager = createAppliedFilterStore({
|
||||||
|
queryValue: '',
|
||||||
|
groups: [
|
||||||
|
group('categories', [
|
||||||
|
['serif', true],
|
||||||
|
['sans-serif', false],
|
||||||
|
['display', true],
|
||||||
|
['monospace', true],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(mapAppliedFiltersToParams(manager).categories).toEqual(['serif', 'display', 'monospace']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps each of the three recognized group ids independently', () => {
|
||||||
|
const manager = createAppliedFilterStore({
|
||||||
|
queryValue: '',
|
||||||
|
groups: [
|
||||||
|
group('providers', [['google', true]]),
|
||||||
|
group('categories', [['serif', true], ['sans-serif', true]]),
|
||||||
|
group('subsets', [['latin', true]]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const params = mapAppliedFiltersToParams(manager);
|
||||||
|
expect(params.providers).toEqual(['google']);
|
||||||
|
expect(params.categories).toEqual(['serif', 'sans-serif']);
|
||||||
|
expect(params.subsets).toEqual(['latin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores groups whose id does not match providers/categories/subsets', () => {
|
||||||
|
const manager = createAppliedFilterStore({
|
||||||
|
queryValue: '',
|
||||||
|
groups: [group('weights', [['400', true], ['700', true]])],
|
||||||
|
});
|
||||||
|
const params = mapAppliedFiltersToParams(manager);
|
||||||
|
expect(params.providers).toBeUndefined();
|
||||||
|
expect(params.categories).toBeUndefined();
|
||||||
|
expect(params.subsets).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('combined output', () => {
|
||||||
|
it('produces a complete param object when query and selections coexist', () => {
|
||||||
|
const manager = createAppliedFilterStore({
|
||||||
|
queryValue: 'inter',
|
||||||
|
groups: [
|
||||||
|
group('providers', [['google', true]]),
|
||||||
|
group('categories', [['sans-serif', true]]),
|
||||||
|
group('subsets', [['latin', false]]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(mapAppliedFiltersToParams(manager)).toEqual({
|
||||||
|
q: 'inter',
|
||||||
|
providers: ['google'],
|
||||||
|
categories: ['sans-serif'],
|
||||||
|
subsets: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { ProxyFontsParams } from '$entities/Font/api';
|
||||||
|
import type { AppliedFilterStore } from '../../model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps filter manager to proxy API parameters.
|
||||||
|
*
|
||||||
|
* Updated to support multiple filter values (arrays)
|
||||||
|
*
|
||||||
|
* @param manager - Filter manager instance with reactive state
|
||||||
|
* @returns - Partial proxy API parameters ready for API call
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Example filter manager state:
|
||||||
|
* // {
|
||||||
|
* // queryValue: 'roboto',
|
||||||
|
* // providers: ['google', 'fontshare'],
|
||||||
|
* // categories: ['sans-serif', 'serif'],
|
||||||
|
* // subsets: ['latin']
|
||||||
|
* // }
|
||||||
|
*
|
||||||
|
* const params = mapAppliedFiltersToParams(manager);
|
||||||
|
* // Returns: {
|
||||||
|
* // providers: ['google', 'fontshare'],
|
||||||
|
* // categories: ['sans-serif', 'serif'],
|
||||||
|
* // subsets: ['latin'],
|
||||||
|
* // q: 'roboto'
|
||||||
|
* // }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function mapAppliedFiltersToParams(manager: AppliedFilterStore): Partial<ProxyFontsParams> {
|
||||||
|
/**
|
||||||
|
* Return the list of selected values for a group, or undefined when
|
||||||
|
* the group is missing or has no selection — matches the API's
|
||||||
|
* "omit empty filters" contract.
|
||||||
|
*/
|
||||||
|
const selectedIn = (id: string): string[] | undefined => {
|
||||||
|
const values = manager.getGroup(id)?.instance.selectedProperties.map(p => p.value);
|
||||||
|
return values && values.length > 0 ? values : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
q: manager.debouncedQueryValue || undefined,
|
||||||
|
providers: selectedIn('providers'),
|
||||||
|
categories: selectedIn('categories'),
|
||||||
|
subsets: selectedIn('subsets'),
|
||||||
|
};
|
||||||
|
}
|
||||||
+19
-5
@@ -16,18 +16,32 @@ export {
|
|||||||
/**
|
/**
|
||||||
* Low-level property selection store
|
* Low-level property selection store
|
||||||
*/
|
*/
|
||||||
filtersStore,
|
availableFilterStore,
|
||||||
} from './state/filters.svelte';
|
} from './store/availableFilterStore/availableFilterStore.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main filter controller
|
* Main filter controller
|
||||||
*/
|
*/
|
||||||
export {
|
export {
|
||||||
|
/**
|
||||||
|
* Reactive interface returned by `createAppliedFilterStore`
|
||||||
|
*/
|
||||||
|
type AppliedFilterStore,
|
||||||
/**
|
/**
|
||||||
* High-level manager for syncing search and filters
|
* High-level manager for syncing search and filters
|
||||||
*/
|
*/
|
||||||
filterManager,
|
appliedFilterStore,
|
||||||
} from './state/manager.svelte';
|
/**
|
||||||
|
* Factory for constructing a filter manager instance
|
||||||
|
*/
|
||||||
|
createAppliedFilterStore,
|
||||||
|
} from './store/appliedFilterStore/appliedFilterStore.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side-effect import: installs the global appliedFilterStore+sortStore → fontCatalogStore
|
||||||
|
* bridge on first import of this feature barrel. No exports.
|
||||||
|
*/
|
||||||
|
import './store/bindings.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorting logic
|
* Sorting logic
|
||||||
@@ -53,4 +67,4 @@ export {
|
|||||||
* Reactive store for the current sort selection
|
* Reactive store for the current sort selection
|
||||||
*/
|
*/
|
||||||
sortStore,
|
sortStore,
|
||||||
} from './store/sortStore.svelte';
|
} from './store/sortStore/sortStore.svelte';
|
||||||
+23
-8
@@ -1,13 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Filter manager for font filtering
|
* Filter manager factory and singleton.
|
||||||
*
|
*
|
||||||
* Manages multiple filter groups (providers, categories, subsets)
|
* Owns multiple filter groups (providers, categories, subsets) plus a
|
||||||
* with debounced search input. Provides reactive state for filter
|
* debounced search input. Provides reactive state for filter selections
|
||||||
* selections and convenience methods for bulk operations.
|
* and convenience methods for bulk operations.
|
||||||
|
*
|
||||||
|
* The factory (`createAppliedFilterStore`) is exported for tests; the app
|
||||||
|
* consumes the `appliedFilterStore` singleton at the bottom of this file.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* const manager = createFilterManager({
|
* const manager = createAppliedFilterStore({
|
||||||
* queryValue: '',
|
* queryValue: '',
|
||||||
* groups: [
|
* groups: [
|
||||||
* { id: 'providers', label: 'Provider', properties: [...] },
|
* { id: 'providers', label: 'Provider', properties: [...] },
|
||||||
@@ -25,7 +28,7 @@ import { createDebouncedState } from '$shared/lib/helpers';
|
|||||||
import type {
|
import type {
|
||||||
FilterConfig,
|
FilterConfig,
|
||||||
FilterGroupConfig,
|
FilterGroupConfig,
|
||||||
} from '../../model';
|
} from '../../types/filter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a filter manager instance
|
* Creates a filter manager instance
|
||||||
@@ -36,7 +39,7 @@ import type {
|
|||||||
* @param config - Configuration with query value and filter groups
|
* @param config - Configuration with query value and filter groups
|
||||||
* @returns Filter manager instance with reactive state and methods
|
* @returns Filter manager instance with reactive state and methods
|
||||||
*/
|
*/
|
||||||
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
|
export function createAppliedFilterStore<TValue extends string>(config: FilterConfig<TValue>) {
|
||||||
const search = createDebouncedState(config.queryValue ?? '');
|
const search = createDebouncedState(config.queryValue ?? '');
|
||||||
|
|
||||||
// Create filter instances upfront
|
// Create filter instances upfront
|
||||||
@@ -122,4 +125,16 @@ export function createFilterManager<TValue extends string>(config: FilterConfig<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FilterManager = ReturnType<typeof createFilterManager>;
|
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-wide filter manager singleton.
|
||||||
|
*
|
||||||
|
* Constructed with empty groups; the availableFilterStore → appliedFilterStore wiring
|
||||||
|
* lives in `./bindings.svelte` and populates groups once backend filter
|
||||||
|
* metadata arrives.
|
||||||
|
*/
|
||||||
|
export const appliedFilterStore = createAppliedFilterStore({
|
||||||
|
queryValue: '',
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
+54
-54
@@ -7,10 +7,10 @@ import {
|
|||||||
it,
|
it,
|
||||||
vi,
|
vi,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { createFilterManager } from './filterManager.svelte';
|
import { createAppliedFilterStore } from './appliedFilterStore.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test Suite for createFilterManager Helper Function
|
* Test Suite for createAppliedFilterStore Helper Function
|
||||||
*
|
*
|
||||||
* This suite tests the filter manager logic including:
|
* This suite tests the filter manager logic including:
|
||||||
* - Debounced query state (immediate vs delayed)
|
* - Debounced query state (immediate vs delayed)
|
||||||
@@ -54,9 +54,9 @@ function createTestGroups(count: number, propertiesPerGroup = 3) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('createFilterManager - Initialization', () => {
|
describe('createAppliedFilterStore - Initialization', () => {
|
||||||
it('creates manager with empty query value', () => {
|
it('creates manager with empty query value', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(2),
|
groups: createTestGroups(2),
|
||||||
});
|
});
|
||||||
@@ -66,7 +66,7 @@ describe('createFilterManager - Initialization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('creates manager with initial query value', () => {
|
it('creates manager with initial query value', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: 'search term',
|
queryValue: 'search term',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -76,7 +76,7 @@ describe('createFilterManager - Initialization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('creates manager with undefined query value (defaults to empty string)', () => {
|
it('creates manager with undefined query value (defaults to empty string)', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ describe('createFilterManager - Initialization', () => {
|
|||||||
|
|
||||||
it('creates filter groups for each config group', () => {
|
it('creates filter groups for each config group', () => {
|
||||||
const groups = createTestGroups(3);
|
const groups = createTestGroups(3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -99,7 +99,7 @@ describe('createFilterManager - Initialization', () => {
|
|||||||
|
|
||||||
it('creates filter instances for each group', () => {
|
it('creates filter instances for each group', () => {
|
||||||
const groups = createTestGroups(2, 5);
|
const groups = createTestGroups(2, 5);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -118,7 +118,7 @@ describe('createFilterManager - Initialization', () => {
|
|||||||
{ id: 'providers', label: 'Providers', properties: createTestProperties(2) },
|
{ id: 'providers', label: 'Providers', properties: createTestProperties(2) },
|
||||||
{ id: 'categories', label: 'Categories', properties: createTestProperties(3) },
|
{ id: 'categories', label: 'Categories', properties: createTestProperties(3) },
|
||||||
];
|
];
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -129,7 +129,7 @@ describe('createFilterManager - Initialization', () => {
|
|||||||
|
|
||||||
it('handles single group', () => {
|
it('handles single group', () => {
|
||||||
const groups = createTestGroups(1);
|
const groups = createTestGroups(1);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -139,7 +139,7 @@ describe('createFilterManager - Initialization', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createFilterManager - Debounced Query', () => {
|
describe('createAppliedFilterStore - Debounced Query', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
@@ -149,7 +149,7 @@ describe('createFilterManager - Debounced Query', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('immediate query value updates instantly', () => {
|
it('immediate query value updates instantly', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -161,7 +161,7 @@ describe('createFilterManager - Debounced Query', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('debounced query value updates after default delay (300ms)', () => {
|
it('debounced query value updates after default delay (300ms)', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -178,7 +178,7 @@ describe('createFilterManager - Debounced Query', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rapid query changes reset the debounce timer', () => {
|
it('rapid query changes reset the debounce timer', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -200,7 +200,7 @@ describe('createFilterManager - Debounced Query', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty string in query', () => {
|
it('handles empty string in query', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: 'initial',
|
queryValue: 'initial',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -213,7 +213,7 @@ describe('createFilterManager - Debounced Query', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('preserves initial query value until changed', () => {
|
it('preserves initial query value until changed', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: 'initial search',
|
queryValue: 'initial search',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -228,9 +228,9 @@ describe('createFilterManager - Debounced Query', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createFilterManager - hasAnySelection Derived State', () => {
|
describe('createAppliedFilterStore - hasAnySelection Derived State', () => {
|
||||||
it('returns false when no filters are selected', () => {
|
it('returns false when no filters are selected', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(3, 3),
|
groups: createTestGroups(3, 3),
|
||||||
});
|
});
|
||||||
@@ -240,7 +240,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
|||||||
|
|
||||||
it('returns true when one filter in one group is selected', () => {
|
it('returns true when one filter in one group is selected', () => {
|
||||||
const groups = createTestGroups(2, 3);
|
const groups = createTestGroups(2, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -255,7 +255,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
|||||||
|
|
||||||
it('returns true when multiple filters across groups are selected', () => {
|
it('returns true when multiple filters across groups are selected', () => {
|
||||||
const groups = createTestGroups(3, 3);
|
const groups = createTestGroups(3, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -272,7 +272,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
|||||||
|
|
||||||
it('returns false after deselecting all filters', () => {
|
it('returns false after deselecting all filters', () => {
|
||||||
const groups = createTestGroups(2, 3);
|
const groups = createTestGroups(2, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -286,7 +286,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
|||||||
|
|
||||||
it('reacts to selection changes in individual groups', () => {
|
it('reacts to selection changes in individual groups', () => {
|
||||||
const groups = createTestGroups(2, 3);
|
const groups = createTestGroups(2, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -318,7 +318,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
|||||||
properties: createTestProperties(3, []),
|
properties: createTestProperties(3, []),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -331,7 +331,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
|||||||
{ id: 'group-0', label: 'Group 0', properties: [] },
|
{ id: 'group-0', label: 'Group 0', properties: [] },
|
||||||
{ id: 'group-1', label: 'Group 1', properties: [] },
|
{ id: 'group-1', label: 'Group 1', properties: [] },
|
||||||
];
|
];
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -340,10 +340,10 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createFilterManager - getGroup() Method', () => {
|
describe('createAppliedFilterStore - getGroup() Method', () => {
|
||||||
it('returns the correct group by ID', () => {
|
it('returns the correct group by ID', () => {
|
||||||
const groups = createTestGroups(3);
|
const groups = createTestGroups(3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -357,7 +357,7 @@ describe('createFilterManager - getGroup() Method', () => {
|
|||||||
|
|
||||||
it('returns undefined for non-existent group ID', () => {
|
it('returns undefined for non-existent group ID', () => {
|
||||||
const groups = createTestGroups(2);
|
const groups = createTestGroups(2);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -369,7 +369,7 @@ describe('createFilterManager - getGroup() Method', () => {
|
|||||||
|
|
||||||
it('returns group with accessible filter instance', () => {
|
it('returns group with accessible filter instance', () => {
|
||||||
const groups = createTestGroups(2, 3);
|
const groups = createTestGroups(2, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -385,7 +385,7 @@ describe('createFilterManager - getGroup() Method', () => {
|
|||||||
|
|
||||||
it('returns first group when requested', () => {
|
it('returns first group when requested', () => {
|
||||||
const groups = createTestGroups(3);
|
const groups = createTestGroups(3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -398,7 +398,7 @@ describe('createFilterManager - getGroup() Method', () => {
|
|||||||
|
|
||||||
it('returns last group when requested', () => {
|
it('returns last group when requested', () => {
|
||||||
const groups = createTestGroups(5);
|
const groups = createTestGroups(5);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -410,10 +410,10 @@ describe('createFilterManager - getGroup() Method', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createFilterManager - deselectAllGlobal() Method', () => {
|
describe('createAppliedFilterStore - deselectAllGlobal() Method', () => {
|
||||||
it('deselects all filters across all groups', () => {
|
it('deselects all filters across all groups', () => {
|
||||||
const groups = createTestGroups(3, 3);
|
const groups = createTestGroups(3, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -436,7 +436,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
|
|||||||
|
|
||||||
it('handles deselecting when nothing is selected', () => {
|
it('handles deselecting when nothing is selected', () => {
|
||||||
const groups = createTestGroups(2, 3);
|
const groups = createTestGroups(2, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -453,7 +453,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
|
|||||||
{ id: 'group-0', label: 'Group 0', properties: [] },
|
{ id: 'group-0', label: 'Group 0', properties: [] },
|
||||||
{ id: 'group-1', label: 'Group 1', properties: [] },
|
{ id: 'group-1', label: 'Group 1', properties: [] },
|
||||||
];
|
];
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -464,7 +464,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
|
|||||||
|
|
||||||
it('can select filters after global deselect', () => {
|
it('can select filters after global deselect', () => {
|
||||||
const groups = createTestGroups(2, 3);
|
const groups = createTestGroups(2, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -482,7 +482,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
|
|||||||
|
|
||||||
it('handles partially selected groups', () => {
|
it('handles partially selected groups', () => {
|
||||||
const groups = createTestGroups(3, 5);
|
const groups = createTestGroups(3, 5);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -505,7 +505,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createFilterManager - Complex Scenarios', () => {
|
describe('createAppliedFilterStore - Complex Scenarios', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
@@ -516,7 +516,7 @@ describe('createFilterManager - Complex Scenarios', () => {
|
|||||||
|
|
||||||
it('handles query changes and filter selections together', () => {
|
it('handles query changes and filter selections together', () => {
|
||||||
const groups = createTestGroups(2, 3);
|
const groups = createTestGroups(2, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -553,7 +553,7 @@ describe('createFilterManager - Complex Scenarios', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -582,7 +582,7 @@ describe('createFilterManager - Complex Scenarios', () => {
|
|||||||
|
|
||||||
it('manages multiple independent filter groups correctly', () => {
|
it('manages multiple independent filter groups correctly', () => {
|
||||||
const groups = createTestGroups(4, 5);
|
const groups = createTestGroups(4, 5);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -607,7 +607,7 @@ describe('createFilterManager - Complex Scenarios', () => {
|
|||||||
|
|
||||||
it('handles toggle operations via getGroup', () => {
|
it('handles toggle operations via getGroup', () => {
|
||||||
const groups = createTestGroups(2, 3);
|
const groups = createTestGroups(2, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -623,9 +623,9 @@ describe('createFilterManager - Complex Scenarios', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createFilterManager - Interface Compliance', () => {
|
describe('createAppliedFilterStore - Interface Compliance', () => {
|
||||||
it('exposes queryValue getter', () => {
|
it('exposes queryValue getter', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: 'test',
|
queryValue: 'test',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -636,7 +636,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exposes queryValue setter', () => {
|
it('exposes queryValue setter', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: 'test',
|
queryValue: 'test',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -647,7 +647,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exposes debouncedQueryValue getter', () => {
|
it('exposes debouncedQueryValue getter', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: 'test',
|
queryValue: 'test',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -658,7 +658,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exposes groups getter', () => {
|
it('exposes groups getter', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -669,7 +669,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exposes hasAnySelection getter', () => {
|
it('exposes hasAnySelection getter', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -680,7 +680,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exposes getGroup method', () => {
|
it('exposes getGroup method', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -689,7 +689,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exposes deselectAllGlobal method', () => {
|
it('exposes deselectAllGlobal method', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -698,7 +698,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not expose debouncedQueryValue setter', () => {
|
it('does not expose debouncedQueryValue setter', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -708,7 +708,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createFilterManager - Edge Cases', () => {
|
describe('createAppliedFilterStore - Edge Cases', () => {
|
||||||
it('handles single property groups', () => {
|
it('handles single property groups', () => {
|
||||||
const groups: Array<{
|
const groups: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -722,7 +722,7 @@ describe('createFilterManager - Edge Cases', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -749,7 +749,7 @@ describe('createFilterManager - Edge Cases', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -773,7 +773,7 @@ describe('createFilterManager - Edge Cases', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
+15
-11
@@ -6,18 +6,22 @@
|
|||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* import { filtersStore } from '$features/GetFonts';
|
* import { availableFilterStore } from '$features/FilterAndSortFonts';
|
||||||
*
|
*
|
||||||
* // Access filters (reactive)
|
* // Access filters (reactive)
|
||||||
* $: filters = filtersStore.filters;
|
* $: filters = availableFilterStore.filters;
|
||||||
* $: isLoading = filtersStore.isLoading;
|
* $: isLoading = availableFilterStore.isLoading;
|
||||||
* $: error = filtersStore.error;
|
* $: error = availableFilterStore.error;
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchProxyFilters } from '$features/GetFonts/api/filters/filters';
|
import { fetchProxyFilters } from '$features/FilterAndSortFonts/api/filters/filters';
|
||||||
import type { FilterMetadata } from '$features/GetFonts/api/filters/filters';
|
import type { FilterMetadata } from '$features/FilterAndSortFonts/api/filters/filters';
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import {
|
||||||
|
DEFAULT_QUERY_GC_TIME_MS,
|
||||||
|
DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
|
queryClient,
|
||||||
|
} from '$shared/api/queryClient';
|
||||||
import {
|
import {
|
||||||
type QueryKey,
|
type QueryKey,
|
||||||
QueryObserver,
|
QueryObserver,
|
||||||
@@ -31,7 +35,7 @@ import {
|
|||||||
* Fetches and caches filter metadata using fetchProxyFilters()
|
* Fetches and caches filter metadata using fetchProxyFilters()
|
||||||
* Provides reactive access to filter data
|
* Provides reactive access to filter data
|
||||||
*/
|
*/
|
||||||
class FiltersStore {
|
export class AvailableFilterStore {
|
||||||
/**
|
/**
|
||||||
* TanStack Query result state
|
* TanStack Query result state
|
||||||
*/
|
*/
|
||||||
@@ -81,8 +85,8 @@ class FiltersStore {
|
|||||||
return {
|
return {
|
||||||
queryKey: this.getQueryKey(),
|
queryKey: this.getQueryKey(),
|
||||||
queryFn: () => this.fetchFn(),
|
queryFn: () => this.fetchFn(),
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,4 +129,4 @@ class FiltersStore {
|
|||||||
/**
|
/**
|
||||||
* Singleton instance
|
* Singleton instance
|
||||||
*/
|
*/
|
||||||
export const filtersStore = new FiltersStore();
|
export const availableFilterStore = new AvailableFilterStore();
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import * as filtersApi from '../../../api/filters/filters';
|
||||||
|
import type { FilterMetadata } from '../../../api/filters/filters';
|
||||||
|
import { AvailableFilterStore } from './availableFilterStore.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal FilterMetadata fixture for tests.
|
||||||
|
*/
|
||||||
|
function metadata(id: string, optionValues: string[] = []): FilterMetadata {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
description: '',
|
||||||
|
type: 'enum',
|
||||||
|
options: optionValues.map(value => ({
|
||||||
|
id: value,
|
||||||
|
name: value,
|
||||||
|
value,
|
||||||
|
count: 1,
|
||||||
|
})),
|
||||||
|
} as FilterMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AvailableFilterStore', () => {
|
||||||
|
let store: AvailableFilterStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient.clear();
|
||||||
|
// TanStack defaults retry=3 with exponential backoff, which would
|
||||||
|
// make the error-path test wait >5s. Disable for deterministic timing.
|
||||||
|
queryClient.setDefaultOptions({ queries: { retry: false } });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('starts with an empty filter list', () => {
|
||||||
|
vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]);
|
||||||
|
store = new AvailableFilterStore();
|
||||||
|
expect(store.filters).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports null error before any failure', () => {
|
||||||
|
vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]);
|
||||||
|
store = new AvailableFilterStore();
|
||||||
|
expect(store.error).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('successful fetch', () => {
|
||||||
|
it('populates filters with the fetched metadata', async () => {
|
||||||
|
const data = [
|
||||||
|
metadata('providers', ['google', 'fontshare']),
|
||||||
|
metadata('categories', ['serif', 'sans-serif']),
|
||||||
|
];
|
||||||
|
vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue(data);
|
||||||
|
|
||||||
|
store = new AvailableFilterStore();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(store.filters).toEqual(data), { timeout: 1000 });
|
||||||
|
expect(store.isError).toBe(false);
|
||||||
|
expect(store.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls fetchProxyFilters exactly once for the initial load', async () => {
|
||||||
|
const spy = vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]);
|
||||||
|
store = new AvailableFilterStore();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('flips isError and exposes the error message on fetch failure', async () => {
|
||||||
|
vi.spyOn(filtersApi, 'fetchProxyFilters').mockRejectedValue(new Error('boom'));
|
||||||
|
store = new AvailableFilterStore();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||||
|
expect(store.error).toBe('boom');
|
||||||
|
expect(store.filters).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('caching', () => {
|
||||||
|
it('does not trigger a second fetch when another instance shares the query key', async () => {
|
||||||
|
const data = [metadata('providers', ['google'])];
|
||||||
|
const spy = vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue(data);
|
||||||
|
|
||||||
|
store = new AvailableFilterStore();
|
||||||
|
await vi.waitFor(() => expect(store.filters).toEqual(data), { timeout: 1000 });
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// A second observer on the same query key should reuse the cached
|
||||||
|
// result rather than triggering a new request.
|
||||||
|
const second = new AvailableFilterStore();
|
||||||
|
try {
|
||||||
|
// Give the new observer a tick to potentially refetch (it shouldn't).
|
||||||
|
await new Promise(r => setTimeout(r, 50));
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
second.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Bridges feature-level UI state (appliedFilterStore + sortStore) to the
|
||||||
|
* entity-level fontCatalogStore query params.
|
||||||
|
*
|
||||||
|
* Centralizing this here means consumers (Search, FontSearch,
|
||||||
|
* FilterControls, etc.) bind to the manager/store directly without
|
||||||
|
* each repeating the same mapping effect. The bridge is a singleton
|
||||||
|
* concern — it tracks singleton state and writes to a singleton query
|
||||||
|
* observer, so it lives at module scope, not in any individual widget.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fontCatalogStore } from '$entities/Font';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
|
||||||
|
import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
|
||||||
|
import { availableFilterStore } from './availableFilterStore/availableFilterStore.svelte';
|
||||||
|
import { sortStore } from './sortStore/sortStore.svelte';
|
||||||
|
|
||||||
|
$effect.root(() => {
|
||||||
|
/**
|
||||||
|
* Populate appliedFilterStore groups when backend filter metadata resolves.
|
||||||
|
* availableFilterStore is async; until it loads, appliedFilterStore has empty groups
|
||||||
|
* and the UI renders nothing for them.
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
const dynamicFilters = availableFilterStore.filters;
|
||||||
|
|
||||||
|
if (dynamicFilters.length > 0) {
|
||||||
|
appliedFilterStore.setGroups(
|
||||||
|
dynamicFilters.map(filter => ({
|
||||||
|
id: filter.id,
|
||||||
|
label: filter.name,
|
||||||
|
properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({
|
||||||
|
id: opt.id,
|
||||||
|
name: opt.name,
|
||||||
|
value: opt.value,
|
||||||
|
selected: false,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror filter selections + debounced search query into fontCatalogStore params.
|
||||||
|
* untrack the write so fontCatalogStore's internal $state reads don't feed back
|
||||||
|
* into this effect's dependency graph.
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
const params = mapAppliedFiltersToParams(appliedFilterStore);
|
||||||
|
untrack(() => fontCatalogStore.setParams(params));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror sort selection into fontCatalogStore.
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
const apiSort = sortStore.apiValue;
|
||||||
|
untrack(() => fontCatalogStore.setSort(apiSort));
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -17,7 +17,7 @@ export const SORT_MAP: Record<SortOption, 'name' | 'popularity' | 'lastModified'
|
|||||||
|
|
||||||
export type SortApiValue = (typeof SORT_MAP)[SortOption];
|
export type SortApiValue = (typeof SORT_MAP)[SortOption];
|
||||||
|
|
||||||
function createSortStore(initial: SortOption = 'Popularity') {
|
export function createSortStore(initial: SortOption = 'Popularity') {
|
||||||
let current = $state<SortOption>(initial);
|
let current = $state<SortOption>(initial);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
SORT_MAP,
|
||||||
|
SORT_OPTIONS,
|
||||||
|
type SortOption,
|
||||||
|
createSortStore,
|
||||||
|
sortStore,
|
||||||
|
} from './sortStore.svelte';
|
||||||
|
|
||||||
|
describe('createSortStore', () => {
|
||||||
|
describe('initialization', () => {
|
||||||
|
it('defaults to Popularity when no initial value is provided', () => {
|
||||||
|
const store = createSortStore();
|
||||||
|
expect(store.value).toBe('Popularity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an explicit initial value', () => {
|
||||||
|
const store = createSortStore('Newest');
|
||||||
|
expect(store.value).toBe('Newest');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('apiValue mapping', () => {
|
||||||
|
it.each<[SortOption, (typeof SORT_MAP)[SortOption]]>([
|
||||||
|
['Name', 'name'],
|
||||||
|
['Popularity', 'popularity'],
|
||||||
|
['Newest', 'lastModified'],
|
||||||
|
])('maps %s to %s', (display, api) => {
|
||||||
|
const store = createSortStore(display);
|
||||||
|
expect(store.apiValue).toBe(api);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('set()', () => {
|
||||||
|
it('updates both value and apiValue together', () => {
|
||||||
|
const store = createSortStore('Name');
|
||||||
|
store.set('Newest');
|
||||||
|
expect(store.value).toBe('Newest');
|
||||||
|
expect(store.apiValue).toBe('lastModified');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — setting the current value keeps state consistent', () => {
|
||||||
|
const store = createSortStore('Popularity');
|
||||||
|
store.set('Popularity');
|
||||||
|
expect(store.value).toBe('Popularity');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sortStore singleton', () => {
|
||||||
|
it('exposes the same shape as a factory instance', () => {
|
||||||
|
expect(typeof sortStore.value).toBe('string');
|
||||||
|
expect(typeof sortStore.apiValue).toBe('string');
|
||||||
|
expect(typeof sortStore.set).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts all SORT_OPTIONS as valid set() inputs', () => {
|
||||||
|
for (const option of SORT_OPTIONS) {
|
||||||
|
sortStore.set(option);
|
||||||
|
expect(sortStore.value).toBe(option);
|
||||||
|
expect(sortStore.apiValue).toBe(SORT_MAP[option]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -9,7 +9,7 @@ const { Story } = defineMeta({
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
'Renders the full list of filter groups managed by filterManager. Each group maps to a collapsible FilterGroup with checkboxes. No props — reads directly from the filterManager singleton.',
|
'Renders the full list of filter groups managed by appliedFilterStore. Each group maps to a collapsible FilterGroup with checkboxes. No props — reads directly from the appliedFilterStore singleton.',
|
||||||
},
|
},
|
||||||
story: { inline: false },
|
story: { inline: false },
|
||||||
},
|
},
|
||||||
+2
-2
@@ -4,10 +4,10 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { FilterGroup } from '$shared/ui';
|
import { FilterGroup } from '$shared/ui';
|
||||||
import { filterManager } from '../../model';
|
import { appliedFilterStore } from '../../model';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each filterManager.groups as group (group.id)}
|
{#each appliedFilterStore.groups as group (group.id)}
|
||||||
<FilterGroup
|
<FilterGroup
|
||||||
displayedLabel={group.label}
|
displayedLabel={group.label}
|
||||||
filter={group.instance}
|
filter={group.instance}
|
||||||
+9
-9
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
filterManager,
|
appliedFilterStore,
|
||||||
filtersStore,
|
availableFilterStore,
|
||||||
} from '$features/GetFonts';
|
} from '$features/FilterAndSortFonts';
|
||||||
import {
|
import {
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
@@ -11,9 +11,9 @@ import Filters from './Filters.svelte';
|
|||||||
|
|
||||||
describe('Filters', () => {
|
describe('Filters', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Clear groups and mock filtersStore to be empty so the auto-sync effect doesn't overwrite us
|
// Clear groups and mock availableFilterStore to be empty so the auto-sync effect doesn't overwrite us
|
||||||
filterManager.setGroups([]);
|
appliedFilterStore.setGroups([]);
|
||||||
vi.spyOn(filtersStore, 'filters', 'get').mockReturnValue([]);
|
vi.spyOn(availableFilterStore, 'filters', 'get').mockReturnValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -28,7 +28,7 @@ describe('Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders a label for each filter group', () => {
|
it('renders a label for each filter group', () => {
|
||||||
filterManager.setGroups([
|
appliedFilterStore.setGroups([
|
||||||
{ id: 'cat', label: 'Categories', properties: [] },
|
{ id: 'cat', label: 'Categories', properties: [] },
|
||||||
{ id: 'prov', label: 'Font Providers', properties: [] },
|
{ id: 'prov', label: 'Font Providers', properties: [] },
|
||||||
]);
|
]);
|
||||||
@@ -38,7 +38,7 @@ describe('Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders filter properties within groups', () => {
|
it('renders filter properties within groups', () => {
|
||||||
filterManager.setGroups([
|
appliedFilterStore.setGroups([
|
||||||
{
|
{
|
||||||
id: 'cat',
|
id: 'cat',
|
||||||
label: 'Category',
|
label: 'Category',
|
||||||
@@ -54,7 +54,7 @@ describe('Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders multiple groups with their properties', () => {
|
it('renders multiple groups with their properties', () => {
|
||||||
filterManager.setGroups([
|
appliedFilterStore.setGroups([
|
||||||
{
|
{
|
||||||
id: 'cat',
|
id: 'cat',
|
||||||
label: 'Category',
|
label: 'Category',
|
||||||
+1
-1
@@ -10,7 +10,7 @@ const { Story } = defineMeta({
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
'Sort options and Reset_Filters button rendered below the filter list. Reads sort state from sortStore and dispatches resets via filterManager. Requires responsive context — wrap with Providers.',
|
'Sort options and Reset_Filters button rendered below the filter list. Reads sort state from sortStore and dispatches resets via appliedFilterStore. Requires responsive context — wrap with Providers.',
|
||||||
},
|
},
|
||||||
story: { inline: false },
|
story: { inline: false },
|
||||||
},
|
},
|
||||||
+6
-15
@@ -4,19 +4,15 @@
|
|||||||
Sits below the filter list, separated by a top border.
|
Sits below the filter list, separated by a top border.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fontStore } from '$entities/Font';
|
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
import { Button } from '$shared/ui';
|
import { Button } from '$shared/ui';
|
||||||
import { Label } from '$shared/ui';
|
import { Label } from '$shared/ui';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
import clsx from 'clsx';
|
import { getContext } from 'svelte';
|
||||||
import {
|
|
||||||
getContext,
|
|
||||||
untrack,
|
|
||||||
} from 'svelte';
|
|
||||||
import {
|
import {
|
||||||
SORT_OPTIONS,
|
SORT_OPTIONS,
|
||||||
filterManager,
|
appliedFilterStore,
|
||||||
sortStore,
|
sortStore,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
@@ -31,21 +27,16 @@ const {
|
|||||||
class: className,
|
class: className,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const apiSort = sortStore.apiValue;
|
|
||||||
untrack(() => fontStore.setSort(apiSort));
|
|
||||||
});
|
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
|
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
filterManager.deselectAllGlobal();
|
appliedFilterStore.deselectAllGlobal();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={clsx(
|
class={cn(
|
||||||
'flex flex-col md:flex-row justify-between items-start md:items-center',
|
'flex flex-col md:flex-row justify-between items-start md:items-center',
|
||||||
'gap-1 md:gap-6',
|
'gap-1 md:gap-6',
|
||||||
'pt-6 mt-6 md:pt-8 md:mt-8',
|
'pt-6 mt-6 md:pt-8 md:mt-8',
|
||||||
@@ -77,7 +68,7 @@ function handleReset() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
|
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
|
||||||
onclick={handleReset}
|
onclick={handleReset}
|
||||||
class={clsx('group font-mono tracking-widest text-neutral-400', isMobileOrTabletPortrait && 'px-0')}
|
class={cn('group font-mono tracking-widest text-neutral-400', isMobileOrTabletPortrait && 'px-0')}
|
||||||
iconPosition="left"
|
iconPosition="left"
|
||||||
>
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
export {
|
|
||||||
createFilterManager,
|
|
||||||
type FilterManager,
|
|
||||||
mapManagerToParams,
|
|
||||||
} from './lib';
|
|
||||||
|
|
||||||
export { filtersStore } from './model/state/filters.svelte';
|
|
||||||
export { filterManager } from './model/state/manager.svelte';
|
|
||||||
|
|
||||||
export {
|
|
||||||
SORT_MAP,
|
|
||||||
SORT_OPTIONS,
|
|
||||||
type SortApiValue,
|
|
||||||
type SortOption,
|
|
||||||
sortStore,
|
|
||||||
} from './model/store/sortStore.svelte';
|
|
||||||
|
|
||||||
export {
|
|
||||||
FilterControls,
|
|
||||||
Filters,
|
|
||||||
} from './ui';
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export {
|
|
||||||
createFilterManager,
|
|
||||||
type FilterManager,
|
|
||||||
} from './filterManager/filterManager.svelte';
|
|
||||||
|
|
||||||
export { mapManagerToParams } from './mapper/mapManagerToParams';
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import type { ProxyFontsParams } from '$entities/Font/api';
|
|
||||||
import type { FilterManager } from '../filterManager/filterManager.svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps filter manager to proxy API parameters.
|
|
||||||
*
|
|
||||||
* Updated to support multiple filter values (arrays)
|
|
||||||
*
|
|
||||||
* @param manager - Filter manager instance with reactive state
|
|
||||||
* @returns - Partial proxy API parameters ready for API call
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* // Example filter manager state:
|
|
||||||
* // {
|
|
||||||
* // queryValue: 'roboto',
|
|
||||||
* // providers: ['google', 'fontshare'],
|
|
||||||
* // categories: ['sans-serif', 'serif'],
|
|
||||||
* // subsets: ['latin']
|
|
||||||
* // }
|
|
||||||
*
|
|
||||||
* const params = mapManagerToParams(manager);
|
|
||||||
* // Returns: {
|
|
||||||
* // providers: ['google', 'fontshare'],
|
|
||||||
* // categories: ['sans-serif', 'serif'],
|
|
||||||
* // subsets: ['latin'],
|
|
||||||
* // q: 'roboto'
|
|
||||||
* // }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function mapManagerToParams(manager: FilterManager): Partial<ProxyFontsParams> {
|
|
||||||
const providers = manager.getGroup('providers')?.instance.selectedProperties.map(p => p.value);
|
|
||||||
const categories = manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value);
|
|
||||||
const subsets = manager.getGroup('subsets')?.instance.selectedProperties.map(p => p.value);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Search query (debounced)
|
|
||||||
q: manager.debouncedQueryValue || undefined,
|
|
||||||
|
|
||||||
// NEW: Support arrays - send all selected values
|
|
||||||
providers: providers && providers.length > 0
|
|
||||||
? providers as string[]
|
|
||||||
: undefined,
|
|
||||||
|
|
||||||
categories: categories && categories.length > 0
|
|
||||||
? categories as string[]
|
|
||||||
: undefined,
|
|
||||||
|
|
||||||
subsets: subsets && subsets.length > 0
|
|
||||||
? subsets as string[]
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Filter manager singleton
|
|
||||||
*
|
|
||||||
* Creates filterManager with empty groups initially, then reactively
|
|
||||||
* populates groups when filtersStore loads data from backend.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createFilterManager } from '../../lib/filterManager/filterManager.svelte';
|
|
||||||
import { filtersStore } from './filters.svelte';
|
|
||||||
|
|
||||||
export const filterManager = createFilterManager({
|
|
||||||
queryValue: '',
|
|
||||||
groups: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reactively sync backend filter metadata into filterManager groups.
|
|
||||||
* When filtersStore.filters resolves, setGroups replaces the empty groups.
|
|
||||||
*/
|
|
||||||
$effect.root(() => {
|
|
||||||
$effect(() => {
|
|
||||||
const dynamicFilters = filtersStore.filters;
|
|
||||||
|
|
||||||
if (dynamicFilters.length > 0) {
|
|
||||||
filterManager.setGroups(
|
|
||||||
dynamicFilters.map(filter => ({
|
|
||||||
id: filter.id,
|
|
||||||
label: filter.name,
|
|
||||||
properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({
|
|
||||||
id: opt.id,
|
|
||||||
name: opt.name,
|
|
||||||
value: opt.value,
|
|
||||||
selected: false,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export {
|
|
||||||
createTypographySettingsManager,
|
|
||||||
type TypographySettingsManager,
|
|
||||||
} from './lib';
|
|
||||||
export { typographySettingsStore } from './model';
|
|
||||||
export { TypographyMenu } from './ui';
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export {
|
|
||||||
createTypographySettingsManager,
|
|
||||||
type TypographySettingsManager,
|
|
||||||
} from './settingsManager/settingsManager.svelte';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { typographySettingsStore } from './state/typographySettingsStore';
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '$entities/Font';
|
|
||||||
import { createTypographySettingsManager } from '../../lib';
|
|
||||||
|
|
||||||
export const typographySettingsStore = createTypographySettingsManager(
|
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
|
||||||
'glyphdiff:comparison:typography',
|
|
||||||
);
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: TypographyMenu
|
|
||||||
Floating controls bar for typography settings.
|
|
||||||
Mobile: popover with slider controls anchored to settings button.
|
|
||||||
Desktop: inline bar with combo controls.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
MULTIPLIER_L,
|
|
||||||
MULTIPLIER_M,
|
|
||||||
MULTIPLIER_S,
|
|
||||||
} from '$entities/Font';
|
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
ComboControl,
|
|
||||||
ControlGroup,
|
|
||||||
Slider,
|
|
||||||
} from '$shared/ui';
|
|
||||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
|
||||||
import XIcon from '@lucide/svelte/icons/x';
|
|
||||||
import { Popover } from 'bits-ui';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { getContext } from 'svelte';
|
|
||||||
import { cubicOut } from 'svelte/easing';
|
|
||||||
import { fly } from 'svelte/transition';
|
|
||||||
import { typographySettingsStore } from '../../model';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/**
|
|
||||||
* CSS classes
|
|
||||||
*/
|
|
||||||
class?: string;
|
|
||||||
/**
|
|
||||||
* Hidden state
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
hidden?: boolean;
|
|
||||||
/**
|
|
||||||
* Bindable popover open state
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
open?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
|
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the common font size multiplier based on the current responsive state.
|
|
||||||
*/
|
|
||||||
$effect(() => {
|
|
||||||
if (!responsive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch (true) {
|
|
||||||
case responsive.isMobile:
|
|
||||||
typographySettingsStore.multiplier = MULTIPLIER_S;
|
|
||||||
break;
|
|
||||||
case responsive.isTablet:
|
|
||||||
typographySettingsStore.multiplier = MULTIPLIER_M;
|
|
||||||
break;
|
|
||||||
case responsive.isDesktop:
|
|
||||||
typographySettingsStore.multiplier = MULTIPLIER_L;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
typographySettingsStore.multiplier = MULTIPLIER_L;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if !hidden}
|
|
||||||
{#if responsive.isMobileOrTablet}
|
|
||||||
<Popover.Root bind:open>
|
|
||||||
<Popover.Trigger>
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<Button class={className} variant="primary" {...props}>
|
|
||||||
{#snippet icon()}
|
|
||||||
<Settings2Icon class="size-4" />
|
|
||||||
{/snippet}
|
|
||||||
</Button>
|
|
||||||
{/snippet}
|
|
||||||
</Popover.Trigger>
|
|
||||||
|
|
||||||
<Popover.Portal>
|
|
||||||
<Popover.Content
|
|
||||||
side="top"
|
|
||||||
align="end"
|
|
||||||
sideOffset={8}
|
|
||||||
class={clsx(
|
|
||||||
'z-50 w-72',
|
|
||||||
'bg-surface dark:bg-dark-card',
|
|
||||||
'border border-subtle',
|
|
||||||
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.15)]',
|
|
||||||
'rounded-none p-4',
|
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
||||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
||||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
||||||
'data-[side=top]:slide-in-from-bottom-2',
|
|
||||||
'data-[side=bottom]:slide-in-from-top-2',
|
|
||||||
)}
|
|
||||||
interactOutsideBehavior="close"
|
|
||||||
escapeKeydownBehavior="close"
|
|
||||||
>
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<Settings2Icon size={12} class="text-swiss-red" />
|
|
||||||
<span
|
|
||||||
class="text-3xs font-mono uppercase tracking-widest font-bold text-swiss-black dark:text-neutral-200"
|
|
||||||
>
|
|
||||||
CONTROLS
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Popover.Close>
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<button
|
|
||||||
{...props}
|
|
||||||
class="inline-flex items-center justify-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
|
||||||
aria-label="Close controls"
|
|
||||||
>
|
|
||||||
<XIcon class="size-3.5 text-neutral-500" />
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
</Popover.Close>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Controls -->
|
|
||||||
{#each typographySettingsStore.controls as control (control.id)}
|
|
||||||
<ControlGroup label={control.controlLabel ?? ''}>
|
|
||||||
<Slider
|
|
||||||
bind:value={control.instance.value}
|
|
||||||
min={control.instance.min}
|
|
||||||
max={control.instance.max}
|
|
||||||
step={control.instance.step}
|
|
||||||
/>
|
|
||||||
</ControlGroup>
|
|
||||||
{/each}
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Portal>
|
|
||||||
</Popover.Root>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class={clsx('w-full md:w-auto', className)}
|
|
||||||
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class={clsx(
|
|
||||||
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
|
|
||||||
'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
|
|
||||||
'border border-subtle',
|
|
||||||
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]',
|
|
||||||
'rounded-none ring-1 ring-black/5 dark:ring-white/5',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<!-- Header: icon + label -->
|
|
||||||
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-subtle mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
|
|
||||||
<Settings2Icon
|
|
||||||
size={14}
|
|
||||||
class="text-swiss-red"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="text-3xs md:text-2xs font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
|
|
||||||
>
|
|
||||||
GLOBAL_CONTROLS
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Controls with dividers between each -->
|
|
||||||
{#each typographySettingsStore.controls as control, i (control.id)}
|
|
||||||
{#if i > 0}
|
|
||||||
<div class="w-px h-6 md:h-8 bg-black/5 dark:bg-white/10 mx-0.5 md:mx-1 shrink-0"></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<ComboControl
|
|
||||||
control={control.instance}
|
|
||||||
label={control.controlLabel}
|
|
||||||
increaseLabel={control.increaseLabel}
|
|
||||||
decreaseLabel={control.decreaseLabel}
|
|
||||||
controlLabel={control.controlLabel}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Centralized backend endpoint definitions.
|
||||||
|
*
|
||||||
|
* One source of truth for the proxy API base URL — individual resource
|
||||||
|
* modules (proxyFonts, filters) append their own paths.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const API_BASE_URL = 'https://api.glyphdiff.com/api/v1' as const;
|
||||||
|
|
||||||
|
export const API_ENDPOINTS = {
|
||||||
|
/**
|
||||||
|
* Font catalog + per-id detail + batch lookup
|
||||||
|
*/
|
||||||
|
fonts: `${API_BASE_URL}/fonts`,
|
||||||
|
/**
|
||||||
|
* Filter metadata (providers, categories, subsets)
|
||||||
|
*/
|
||||||
|
filters: `${API_BASE_URL}/filters`,
|
||||||
|
} as const;
|
||||||
@@ -1,5 +1,31 @@
|
|||||||
import { QueryClient } from '@tanstack/query-core';
|
import { QueryClient } from '@tanstack/query-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data remains fresh for this long after fetch. Stores that override
|
||||||
|
* staleness (e.g. filtered queries) can use 0 to bypass.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_QUERY_STALE_TIME_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unused cache entries are garbage collected after this long.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_QUERY_GC_TIME_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many times a failed query is retried before surfacing the error.
|
||||||
|
*/
|
||||||
|
export const QUERY_RETRY_COUNT = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base delay for exponential retry backoff.
|
||||||
|
*/
|
||||||
|
export const QUERY_RETRY_BASE_DELAY_MS = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on retry delay regardless of attempt index.
|
||||||
|
*/
|
||||||
|
export const QUERY_RETRY_MAX_DELAY_MS = 30000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TanStack Query client instance
|
* TanStack Query client instance
|
||||||
*
|
*
|
||||||
@@ -15,14 +41,8 @@ import { QueryClient } from '@tanstack/query-core';
|
|||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
/**
|
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
* Data remains fresh for 5 minutes after fetch
|
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
||||||
*/
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
/**
|
|
||||||
* Unused cache entries are removed after 10 minutes
|
|
||||||
*/
|
|
||||||
gcTime: 10 * 60 * 1000,
|
|
||||||
/**
|
/**
|
||||||
* Don't refetch when window regains focus
|
* Don't refetch when window regains focus
|
||||||
*/
|
*/
|
||||||
@@ -31,15 +51,12 @@ export const queryClient = new QueryClient({
|
|||||||
* Refetch on mount if data is stale
|
* Refetch on mount if data is stale
|
||||||
*/
|
*/
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
|
retry: QUERY_RETRY_COUNT,
|
||||||
/**
|
/**
|
||||||
* Retry failed requests up to 3 times
|
* Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
|
||||||
*/
|
*/
|
||||||
retry: 3,
|
retryDelay: attemptIndex =>
|
||||||
/**
|
Math.min(QUERY_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, QUERY_RETRY_MAX_DELAY_MS),
|
||||||
* Exponential backoff for retries
|
|
||||||
* 1s, 2s, 4s, 8s... capped at 30s
|
|
||||||
*/
|
|
||||||
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="103" height="87" viewBox="0 0 103 87" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M50.688 86.144C43.008 86.144 36.0533 85.248 29.824 83.456C23.68 81.664 18.3467 78.976 13.824 75.392C9.38667 71.808 5.97333 67.3707 3.584 62.08C1.19467 56.7893 0 50.688 0 43.776C0 36.7787 1.23733 30.592 3.712 25.216C6.272 19.7547 9.856 15.1467 14.464 11.392C19.1573 7.63733 24.704 4.82133 31.104 2.944C37.5893 0.981333 44.7573 0 52.608 0C61.9093 0 69.9307 1.32267 76.672 3.968C83.4133 6.528 88.704 10.1547 92.544 14.848C96.4693 19.5413 98.688 25.1307 99.2 31.616H82.816C81.7067 28.2027 79.872 25.2587 77.312 22.784C74.8373 20.224 71.552 18.2613 67.456 16.896C63.36 15.4453 58.4107 14.72 52.608 14.72C45.184 14.72 38.8267 15.9147 33.536 18.304C28.3307 20.6933 24.3627 24.064 21.632 28.416C18.9013 32.768 17.536 37.888 17.536 43.776C17.536 49.4933 18.7307 54.4427 21.12 58.624C23.5093 62.72 27.1787 65.8773 32.128 68.096C37.1627 70.3147 43.5627 71.424 51.328 71.424C57.3013 71.424 62.5493 70.656 67.072 69.12C71.68 67.4987 75.52 65.3653 78.592 62.72C81.664 59.9893 83.84 56.96 85.12 53.632L91.776 51.2C90.6667 62.208 86.4853 70.784 79.232 76.928C72.064 83.072 62.5493 86.144 50.688 86.144ZM87.424 84.48C87.424 81.8347 87.5947 78.8053 87.936 75.392C88.2773 71.8933 88.704 68.3947 89.216 64.896C89.728 61.312 90.1973 58.0267 90.624 55.04H52.736V44.16H102.144V84.48H87.424Z" fill="#FF3B30"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<svg width="52" height="35" viewBox="0 0 52 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M10.608 34.368C8.496 34.368 6.64 33.968 5.04 33.168C3.44 32.336 2.192 31.184 1.296 29.712C0.432 28.208 0 26.48 0 24.528V9.84C0 7.888 0.432 6.176 1.296 4.704C2.192 3.2 3.44 2.048 5.04 1.248C6.64 0.415999 8.496 0 10.608 0C12.688 0 14.528 0.415999 16.128 1.248C17.728 2.048 18.96 3.2 19.824 4.704C20.688 6.176 21.12 7.872 21.12 9.792V10.512C21.12 10.832 20.96 10.992 20.64 10.992H20.16C19.84 10.992 19.68 10.832 19.68 10.512V9.744C19.68 7.216 18.848 5.184 17.184 3.648C15.52 2.112 13.328 1.344 10.608 1.344C7.856 1.344 5.632 2.128 3.936 3.696C2.272 5.232 1.44 7.264 1.44 9.792V24.576C1.44 27.104 2.272 29.152 3.936 30.72C5.632 32.256 7.856 33.024 10.608 33.024C13.328 33.024 15.52 32.272 17.184 30.768C18.848 29.232 19.68 27.2 19.68 24.672V19.152C19.68 19.024 19.616 18.96 19.488 18.96H11.472C11.152 18.96 10.992 18.8 10.992 18.48V18.144C10.992 17.824 11.152 17.664 11.472 17.664H20.64C20.96 17.664 21.12 17.824 21.12 18.144V24.48C21.12 26.464 20.688 28.208 19.824 29.712C18.96 31.184 17.728 32.336 16.128 33.168C14.528 33.968 12.688 34.368 10.608 34.368Z" fill="white"/>
|
|
||||||
<path d="M31.2124 33.984C30.8924 33.984 30.7324 33.824 30.7324 33.504V0.863997C30.7324 0.543998 30.8924 0.383998 31.2124 0.383998H42.1084C45.0204 0.383998 47.3084 1.168 48.9724 2.736C50.6684 4.272 51.5164 6.4 51.5164 9.12V25.248C51.5164 27.968 50.6684 30.112 48.9724 31.68C47.3084 33.216 45.0204 33.984 42.1084 33.984H31.2124ZM32.1724 32.448C32.1724 32.576 32.2364 32.64 32.3644 32.64H42.2044C44.6364 32.64 46.5564 31.984 47.9644 30.672C49.3724 29.328 50.0764 27.504 50.0764 25.2V9.216C50.0764 6.88 49.3724 5.056 47.9644 3.744C46.5564 2.4 44.6364 1.728 42.2044 1.728H32.3644C32.2364 1.728 32.1724 1.792 32.1724 1.92V32.448Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
+17
-2
@@ -4,6 +4,20 @@ import {
|
|||||||
prepareWithSegments,
|
prepareWithSegments,
|
||||||
} from '@chenglou/pretext';
|
} from '@chenglou/pretext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Width of the character morph "halo" around the slider thumb, in percent
|
||||||
|
* of container width. Characters within this window get partial blending
|
||||||
|
* instead of a hard A→B flip.
|
||||||
|
*/
|
||||||
|
const CHAR_PROXIMITY_RANGE_PCT = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default render size in px when callers omit the `size` arg on `layout()`.
|
||||||
|
* Kept as a local constant to avoid pulling `$entities/Font` into
|
||||||
|
* `$shared/lib` (would create an FSD-illegal upward import cycle).
|
||||||
|
*/
|
||||||
|
const DEFAULT_RENDER_SIZE_PX = 16;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single laid-out line produced by dual-font comparison layout.
|
* A single laid-out line produced by dual-font comparison layout.
|
||||||
*
|
*
|
||||||
@@ -129,7 +143,7 @@ export class CharacterComparisonEngine {
|
|||||||
width: number,
|
width: number,
|
||||||
lineHeight: number,
|
lineHeight: number,
|
||||||
spacing: number = 0,
|
spacing: number = 0,
|
||||||
size: number = 16,
|
size: number = DEFAULT_RENDER_SIZE_PX,
|
||||||
): ComparisonResult {
|
): ComparisonResult {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return { lines: [], totalHeight: 0 };
|
return { lines: [], totalHeight: 0 };
|
||||||
@@ -260,7 +274,7 @@ export class CharacterComparisonEngine {
|
|||||||
const chars = line.chars;
|
const chars = line.chars;
|
||||||
const n = chars.length;
|
const n = chars.length;
|
||||||
const sliderX = (sliderPos / 100) * containerWidth;
|
const sliderX = (sliderPos / 100) * containerWidth;
|
||||||
const range = 5;
|
const range = CHAR_PROXIMITY_RANGE_PCT;
|
||||||
// Prefix sums of widthA (left chars will be past → use widthA).
|
// Prefix sums of widthA (left chars will be past → use widthA).
|
||||||
// Suffix sums of widthB (right chars will not be past → use widthB).
|
// Suffix sums of widthB (right chars will not be past → use widthB).
|
||||||
// This lets us compute, for each char i, what the total line width and
|
// This lets us compute, for each char i, what the total line width and
|
||||||
@@ -291,6 +305,7 @@ export class CharacterComparisonEngine {
|
|||||||
const totalRendered = chars.reduce((s, c, i) => s + (isPastArr[i] ? c.widthA : c.widthB), 0);
|
const totalRendered = chars.reduce((s, c, i) => s + (isPastArr[i] ? c.widthA : c.widthB), 0);
|
||||||
const xOffset = (containerWidth - totalRendered) / 2;
|
const xOffset = (containerWidth - totalRendered) / 2;
|
||||||
let currentX = xOffset;
|
let currentX = xOffset;
|
||||||
|
|
||||||
return chars.map((char, i) => {
|
return chars.map((char, i) => {
|
||||||
const isPast = isPastArr[i] === 1;
|
const isPast = isPastArr[i] === 1;
|
||||||
const charWidth = isPast ? char.widthA : char.widthB;
|
const charWidth = isPast ? char.widthA : char.widthB;
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ export interface LayoutResult {
|
|||||||
* **Canvas requirement:** pretext calls `document.createElement('canvas').getContext('2d')` on
|
* **Canvas requirement:** pretext calls `document.createElement('canvas').getContext('2d')` on
|
||||||
* first use and caches the context for the process lifetime. Tests must install a canvas mock
|
* first use and caches the context for the process lifetime. Tests must install a canvas mock
|
||||||
* (see `__mocks__/canvas.ts`) before the first `layout()` call.
|
* (see `__mocks__/canvas.ts`) before the first `layout()` call.
|
||||||
|
*
|
||||||
|
* @deprecated No live consumers remain — the only previous caller
|
||||||
|
* (`createFontRowSizeResolver`) now invokes pretext's `prepare` + `layout`
|
||||||
|
* directly (per pretext's "hot-path resize function" guidance). If you need
|
||||||
|
* single-font height-only measurement, use `prepare` + `layout` from
|
||||||
|
* `@chenglou/pretext` directly. If you need per-grapheme x/width data, see
|
||||||
|
* `CharacterComparisonEngine` (dual-font) or revive a slimmer wrapper.
|
||||||
|
* Slated for removal once it has been absent from `main` for a release cycle.
|
||||||
*/
|
*/
|
||||||
export class TextLayoutEngine {
|
export class TextLayoutEngine {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { debounce } from '$shared/lib/utils';
|
import { debounce } from '$shared/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default debounce delay used when no wait is provided. Picked to feel
|
||||||
|
* snappy for typing while still coalescing API-bound side effects.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates reactive state with immediate and debounced values.
|
* Creates reactive state with immediate and debounced values.
|
||||||
*
|
*
|
||||||
@@ -23,7 +29,7 @@ import { debounce } from '$shared/lib/utils';
|
|||||||
* <p>Searching: {search.debounced}</p>
|
* <p>Searching: {search.debounced}</p>
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
|
export function createDebouncedState<T>(initialValue: T, wait: number = DEFAULT_DEBOUNCE_MS) {
|
||||||
let immediate = $state(initialValue);
|
let immediate = $state(initialValue);
|
||||||
let debounced = $state(initialValue);
|
let debounced = $state(initialValue);
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,19 @@
|
|||||||
|
|
||||||
import { Spring } from 'svelte/motion';
|
import { Spring } from 'svelte/motion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring tuning for the perspective animation. Lower stiffness = slower
|
||||||
|
* easing into back/front state; higher damping = less overshoot.
|
||||||
|
*/
|
||||||
|
const PERSPECTIVE_SPRING_CONFIG = { stiffness: 0.2, damping: 0.8 } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Halfway threshold on the 0→1 spring value. Above flips `isBack`,
|
||||||
|
* below flips `isFront`. Picking 0.5 means both states flip at the
|
||||||
|
* exact midpoint of the animation.
|
||||||
|
*/
|
||||||
|
const PERSPECTIVE_TOGGLE_THRESHOLD = 0.5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration options for perspective effects
|
* Configuration options for perspective effects
|
||||||
*/
|
*/
|
||||||
@@ -93,10 +106,7 @@ export class PerspectiveManager {
|
|||||||
* Spring animation state
|
* Spring animation state
|
||||||
* Animates between 0 (front) and 1 (back) with configurable physics
|
* Animates between 0 (front) and 1 (back) with configurable physics
|
||||||
*/
|
*/
|
||||||
spring = new Spring(0, {
|
spring = new Spring(0, PERSPECTIVE_SPRING_CONFIG);
|
||||||
stiffness: 0.2,
|
|
||||||
damping: 0.8,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive state: true when in back position
|
* Reactive state: true when in back position
|
||||||
@@ -104,7 +114,7 @@ export class PerspectiveManager {
|
|||||||
* Content should appear blurred, scaled down, and less interactive
|
* Content should appear blurred, scaled down, and less interactive
|
||||||
* when this is true. Derived from spring value > 0.5.
|
* when this is true. Derived from spring value > 0.5.
|
||||||
*/
|
*/
|
||||||
isBack = $derived(this.spring.current > 0.5);
|
isBack = $derived(this.spring.current > PERSPECTIVE_TOGGLE_THRESHOLD);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive state: true when in front position
|
* Reactive state: true when in front position
|
||||||
@@ -112,7 +122,7 @@ export class PerspectiveManager {
|
|||||||
* Content should be fully visible, sharp, and interactive
|
* Content should be fully visible, sharp, and interactive
|
||||||
* when this is true. Derived from spring value < 0.5.
|
* when this is true. Derived from spring value < 0.5.
|
||||||
*/
|
*/
|
||||||
isFront = $derived(this.spring.current < 0.5);
|
isFront = $derived(this.spring.current < PERSPECTIVE_TOGGLE_THRESHOLD);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal configuration with defaults applied
|
* Internal configuration with defaults applied
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
* Used to render visible items with absolute positioning based on computed offsets.
|
* Used to render visible items with absolute positioning based on computed offsets.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum height delta (in px) required to commit a re-measured row height.
|
||||||
|
* Sub-pixel diffs are treated as measurement noise to avoid spurious re-flows.
|
||||||
|
*/
|
||||||
|
const MEASUREMENT_EPSILON_PX = 0.5;
|
||||||
|
|
||||||
export interface VirtualItem {
|
export interface VirtualItem {
|
||||||
/**
|
/**
|
||||||
* Index of the item in the data array
|
* Index of the item in the data array
|
||||||
@@ -58,7 +64,7 @@ export interface VirtualizerOptions {
|
|||||||
* when those values change, `offsets` and `totalSize` recompute instantly.
|
* when those values change, `offsets` and `totalSize` recompute instantly.
|
||||||
*
|
*
|
||||||
* For font preview rows, pass a closure that reads
|
* For font preview rows, pass a closure that reads
|
||||||
* `appliedFontsManager.statuses` so the virtualizer recalculates heights
|
* `fontLifecycleManager.statuses` so the virtualizer recalculates heights
|
||||||
* as fonts finish loading, eliminating the DOM-measurement snap on load.
|
* as fonts finish loading, eliminating the DOM-measurement snap on load.
|
||||||
*/
|
*/
|
||||||
estimateSize: (index: number) => number;
|
estimateSize: (index: number) => number;
|
||||||
@@ -258,12 +264,13 @@ export function createVirtualizer<T>(
|
|||||||
// Calculate initial offset ONCE
|
// Calculate initial offset ONCE
|
||||||
const getElementOffset = () => {
|
const getElementOffset = () => {
|
||||||
const rect = node.getBoundingClientRect();
|
const rect = node.getBoundingClientRect();
|
||||||
return rect.top + window.scrollY;
|
const scrollY = typeof window !== 'undefined' ? window.scrollY : 0;
|
||||||
|
return rect.top + scrollY;
|
||||||
};
|
};
|
||||||
|
|
||||||
let cachedOffsetTop = 0;
|
let cachedOffsetTop = 0;
|
||||||
let rafId: number | null = null;
|
let rafId: number | null = null;
|
||||||
containerHeight = window.innerHeight;
|
containerHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (rafId !== null) {
|
if (rafId !== null) {
|
||||||
@@ -380,8 +387,8 @@ export function createVirtualizer<T>(
|
|||||||
if (!isNaN(index)) {
|
if (!isNaN(index)) {
|
||||||
const oldHeight = measuredSizes[index];
|
const oldHeight = measuredSizes[index];
|
||||||
|
|
||||||
// Only update if the height difference is significant (> 0.5px)
|
// Only update if the height difference is significant
|
||||||
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
|
if (oldHeight === undefined || Math.abs(oldHeight - height) > MEASUREMENT_EPSILON_PX) {
|
||||||
measurementBuffer[index] = height;
|
measurementBuffer[index] = height;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export {
|
|||||||
export {
|
export {
|
||||||
buildQueryString,
|
buildQueryString,
|
||||||
clampNumber,
|
clampNumber,
|
||||||
|
cn,
|
||||||
debounce,
|
debounce,
|
||||||
getDecimalPlaces,
|
getDecimalPlaces,
|
||||||
roundToStepPrecision,
|
roundToStepPrecision,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
correctly via the HTML element's class attribute.
|
correctly via the HTML element's class attribute.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import clsx from 'clsx';
|
import { cn } from '$shared/lib';
|
||||||
import type {
|
import type {
|
||||||
Component,
|
Component,
|
||||||
Snippet,
|
Snippet,
|
||||||
@@ -32,7 +32,7 @@ let { icon: Icon, class: className, attrs = {} }: Props = $props();
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if Icon}
|
{#if Icon}
|
||||||
{@const __iconClass__ = clsx('size-4', className)}
|
{@const __iconClass__ = cn('size-4', className)}
|
||||||
<!-- Render icon component dynamically with class prop -->
|
<!-- Render icon component dynamically with class prop -->
|
||||||
<Icon
|
<Icon
|
||||||
class={__iconClass__}
|
class={__iconClass__}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user