refactor(font): inject font-load status as a prop, decoupling UI from the store

FontApplicator and FontSampler no longer read fontLifecycleManager. They take a
`status` prop (FontLoadStatus | undefined) supplied by the composing widget;
FontList and SampleList resolve status once per visible row and pass it down.

FSD+ dependency inversion: the entity/feature UI depends on a value, not the
lifecycle store. Removes FontApplicator's value-import of the store (one step
toward an inert ./ui barrel) and drops the duplicate getFontStatus read per row
in FontList. FontSampler is now status-decoupled and trivially relocatable to
entities/Font/ui.
This commit is contained in:
Ilia Mashkov
2026-06-01 12:06:30 +03:00
parent 28a8e49915
commit fcd61be4fa
6 changed files with 52 additions and 46 deletions
@@ -10,14 +10,14 @@ const { Story } = defineMeta({
docs: { docs: {
description: { description: {
component: component:
'Loads a font and applies it to children. Shows blur/scale loading state until font is ready, then reveals with a smooth transition.', 'Applies a font to its children based on the supplied load `status`. Renders the skeleton (or system font) until status is `loaded`/`error`, then reveals the font. The status is provided by the composing widget — the component does not read the lifecycle store itself.',
}, },
story: { inline: false }, story: { inline: false },
}, },
layout: 'centered', layout: 'centered',
}, },
argTypes: { argTypes: {
weight: { control: 'number' }, status: { control: 'select', options: ['loading', 'loaded', 'error'] },
}, },
}); });
</script> </script>
@@ -39,11 +39,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: { docs: {
description: { description: {
story: story:
'Font that has never been loaded by fontLifecycleManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.', 'Status is `loading`: the font file has not resolved yet, so children render in the skeleton (or system font) fallback rather than the target font.',
}, },
}, },
}} }}
args={{ font: fontUnknown, weight: 400 }} args={{ font: fontUnknown, status: 'loading' }}
> >
{#snippet template(args: ComponentProps<typeof FontApplicator>)} {#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}> <FontApplicator {...args}>
@@ -58,11 +58,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: { docs: {
description: { description: {
story: story:
'Uses Arial, a system font available in all browsers. Because fontLifecycleManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.', 'Status is `loaded`: the component reveals the font, applying it to its children (Arial here, available in all browsers).',
}, },
}, },
}} }}
args={{ font: fontArial, weight: 400 }} args={{ font: fontArial, status: 'loaded' }}
> >
{#snippet template(args: ComponentProps<typeof FontApplicator>)} {#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}> <FontApplicator {...args}>
@@ -72,16 +72,16 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
</Story> </Story>
<Story <Story
name="Custom Weight" name="Error State"
parameters={{ parameters={{
docs: { docs: {
description: { description: {
story: story:
'Demonstrates passing a custom weight (700). The weight is forwarded to fontLifecycleManager for font resolution; visually identical to the loaded state story until the manager confirms the font.', 'Status is `error`: the font failed to load. The component still reveals (it treats `error` like `loaded` for reveal purposes) so children are not stuck behind the skeleton — they fall back to the system font.',
}, },
}, },
}} }}
args={{ font: fontArialBold, weight: 700 }} args={{ font: fontArialBold, status: 'error' }}
> >
{#snippet template(args: ComponentProps<typeof FontApplicator>)} {#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}> <FontApplicator {...args}>
@@ -6,11 +6,10 @@
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { import type {
DEFAULT_FONT_WEIGHT, FontLoadStatus,
type UnifiedFont, UnifiedFont,
fontLifecycleManager, } from '../../model/types';
} from '../../model';
interface Props { interface Props {
/** /**
@@ -18,10 +17,13 @@ interface Props {
*/ */
font: UnifiedFont; font: UnifiedFont;
/** /**
* Font weight * Current load status for this font, supplied by the composing layer.
* @default 400 * Kept out of the component so it does not depend on (and import) the
* lifecycle store — the owning widget reads the manager and passes the
* resolved status down. `undefined` means the font is not tracked yet and
* is treated as not-yet-revealed (skeleton / system-font fallback).
*/ */
weight?: number; status: FontLoadStatus | undefined;
/** /**
* CSS classes * CSS classes
*/ */
@@ -39,20 +41,12 @@ interface Props {
let { let {
font, font,
weight = DEFAULT_FONT_WEIGHT, status,
className, className,
children, children,
skeleton, skeleton,
}: Props = $props(); }: Props = $props();
const status = $derived(
fontLifecycleManager.getFontStatus(
font.id,
weight,
font.features?.isVariable,
),
);
const shouldReveal = $derived(status === 'loaded' || status === 'error'); const shouldReveal = $derived(status === 'loaded' || status === 'error');
</script> </script>
@@ -21,6 +21,11 @@ const { Story } = defineMeta({
control: 'object', control: 'object',
description: 'Font information object', description: 'Font information object',
}, },
status: {
control: 'select',
options: ['loading', 'loaded', 'error'],
description: 'Font-load status, supplied by the composing widget and forwarded to FontApplicator',
},
text: { text: {
control: 'text', control: 'text',
description: 'Editable sample text (two-way bindable)', description: 'Editable sample text (two-way bindable)',
@@ -85,6 +90,7 @@ const mockGeorgia: UnifiedFont = {
name="Default" name="Default"
args={{ args={{
font: mockArial, font: mockArial,
status: 'loaded',
text: 'The quick brown fox jumps over the lazy dog', text: 'The quick brown fox jumps over the lazy dog',
index: 0, index: 0,
}} }}
@@ -101,6 +107,7 @@ const mockGeorgia: UnifiedFont = {
name="Long Text" name="Long Text"
args={{ args={{
font: mockGeorgia, font: mockGeorgia,
status: 'loaded',
text: text:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
index: 1, index: 1,
@@ -6,6 +6,7 @@
<script lang="ts"> <script lang="ts">
import { import {
FontApplicator, FontApplicator,
type FontLoadStatus,
type UnifiedFont, type UnifiedFont,
} from '$entities/Font'; } from '$entities/Font';
import { typographySettingsStore } from '$features/AdjustTypography/model'; import { typographySettingsStore } from '$features/AdjustTypography/model';
@@ -23,6 +24,12 @@ interface Props {
* Font info * Font info
*/ */
font: UnifiedFont; font: UnifiedFont;
/**
* Current font-load status, supplied by the composing widget so this
* component (and FontApplicator) stay decoupled from the lifecycle store.
* `undefined` means not tracked yet (treated as not-yet-revealed).
*/
status: FontLoadStatus | undefined;
/** /**
* Sample text * Sample text
*/ */
@@ -34,7 +41,7 @@ interface Props {
index?: number; index?: number;
} }
let { font, text = $bindable(), index = 0 }: Props = $props(); let { font, status, text = $bindable(), index = 0 }: Props = $props();
// Adjust the property name to match your UnifiedFont type // Adjust the property name to match your UnifiedFont type
const fontType = $derived((font as any).type ?? (font as any).category ?? ''); const fontType = $derived((font as any).type ?? (font as any).category ?? '');
@@ -132,7 +139,7 @@ const stats = $derived([
<!-- ── Main content area ──────────────────────────────────────────── --> <!-- ── Main content area ──────────────────────────────────────────── -->
<div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10"> <div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10">
<FontApplicator {font} weight={typographySettingsStore.weight}> <FontApplicator {font} {status}>
<ContentEditable <ContentEditable
bind:text bind:text
fontSize={typographySettingsStore.renderedSize} fontSize={typographySettingsStore.renderedSize}
@@ -75,21 +75,6 @@ function handleSelect(font: UnifiedFont) {
comparisonStore.fontB = font; comparisonStore.fontB = font;
} }
} }
/**
* Returns true once the font file is loaded (or errored) and safe to render.
* Called inside the template — Svelte 5 tracks the $state reads inside
* fontLifecycleManager.getFontStatus(), so each row re-renders reactively
* when its file arrives.
*/
function isFontReady(font: UnifiedFont): boolean {
const status = fontLifecycleManager.getFontStatus(
font.id,
DEFAULT_FONT_WEIGHT,
font.features?.isVariable,
);
return status === 'loaded' || status === 'error';
}
</script> </script>
<div class="flex-1 min-h-0 h-full"> <div class="flex-1 min-h-0 h-full">
@@ -129,8 +114,15 @@ function isFontReady(font: UnifiedFont): boolean {
{/snippet} {/snippet}
{#snippet children({ item: font, index })} {#snippet children({ item: font, index })}
<!--
Read load status once per row. Svelte 5 tracks the $state reads inside
fontLifecycleManager.getFontStatus(), so the row re-renders reactively
when its file arrives — and the same value drives both the skeleton gate
and FontApplicator below.
-->
{@const status = fontLifecycleManager.getFontStatus(font.id, DEFAULT_FONT_WEIGHT, font.features?.isVariable)}
<div class="relative h-11 w-full"> <div class="relative h-11 w-full">
{#if !isFontReady(font)} {#if status !== 'loaded' && status !== 'error'}
<div <div
class="absolute inset-0 px-3 md:px-4 flex items-center justify-between border border-transparent" class="absolute inset-0 px-3 md:px-4 flex items-center justify-between border border-transparent"
transition:fade={{ duration: 300 }} transition:fade={{ duration: 300 }}
@@ -155,7 +147,7 @@ function isFontReady(font: UnifiedFont): boolean {
class="h-full" class="h-full"
iconPosition="right" iconPosition="right"
> >
<FontApplicator {font}> <FontApplicator {font} {status}>
{font.name} {font.name}
</FontApplicator> </FontApplicator>
@@ -113,7 +113,13 @@ const fontRowHeight = $derived.by(() =>
{skeleton} {skeleton}
> >
{#snippet children({ item: font, index })} {#snippet children({ item: font, index })}
<FontSampler bind:text {font} {index} /> <!--
Resolve load status here (the widget owns the lifecycle store) and
pass it down — FontSampler and FontApplicator stay store-decoupled.
getFontStatus reads a $state SvelteMap, so the row stays reactive.
-->
{@const status = fontLifecycleManager.getFontStatus(font.id, typographySettingsStore.weight, font.features?.isVariable)}
<FontSampler bind:text {font} {index} {status} />
{/snippet} {/snippet}
</FontVirtualList> </FontVirtualList>