refactor(entities/Font): relocate FontSampler from DisplayFont, invert typography

DisplayFont was not a feature (FSD+ A-6): the whole slice was one
presentational component that renders a Font styled by typography, with no
model/domain/action. To get typography it reached sideways into a sibling
feature (`$features/AdjustTypography/model`) — a feature->feature edge
(C-1), the symptom of the mislayering, not the disease.

Fix by inversion, mirroring the existing `status` prop pattern:

- move FontSampler into entities/Font/ui (it now uses only entity siblings
  + $shared/ui)
- it accepts a `typography` prop typed to a minimal contract defined in the
  component; the AdjustTypography store satisfies it structurally, so the
  entity has no dependency on the feature
- SampleList (owns both) injects its typographySettingsStore as the prop
- delete the DisplayFont slice; export FontSampler from the Font barrel;
  relocate the story (now passes a mock typography)

Resolves A-6, A-7, and the FontSampler half of C-1. Verified: 0 type
errors, 0 lint (boundary rule satisfied), 905 unit + 213 component tests,
production build OK.
This commit is contained in:
Ilia Mashkov
2026-06-03 08:34:49 +03:00
parent 028853aff5
commit 09869aed00
7 changed files with 61 additions and 25 deletions
+1
View File
@@ -19,6 +19,7 @@ export type { FontRowSizeResolverOptions } from './lib';
export {
FontApplicator,
FontSampler,
FontVirtualList,
} from './ui';
@@ -0,0 +1,133 @@
<script module>
import Providers from '$shared/lib/storybook/Providers.svelte';
import { defineMeta } from '@storybook/addon-svelte-csf';
import FontSampler from './FontSampler.svelte';
const { Story } = defineMeta({
title: 'Entities/Font/FontSampler',
component: FontSampler,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Displays a sample text with a given font in a contenteditable element. Visual design matches FontCard: sharp corners, brand hover accent, header stats showing font properties (size, weight, line height, letter spacing). Staggered entrance animation based on index.',
},
story: { inline: false },
},
},
argTypes: {
font: {
control: '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: {
control: 'text',
description: 'Editable sample text (two-way bindable)',
},
index: {
control: { type: 'number', min: 0 },
description: 'Position index — drives the staggered entrance delay',
},
},
});
</script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
import type { UnifiedFont } from '../../model/types';
// Mock fonts for testing
const mockArial: UnifiedFont = {
id: 'arial',
name: 'Arial',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: ['400', '700'],
styles: {
regular: '',
bold: '',
},
metadata: {
cachedAt: Date.now(),
version: '1.0',
popularity: 1,
},
features: {
isVariable: false,
},
};
const mockGeorgia: UnifiedFont = {
id: 'georgia',
name: 'Georgia',
provider: 'google',
category: 'serif',
subsets: ['latin'],
variants: ['400', '700'],
styles: {
regular: '',
bold: '',
},
metadata: {
cachedAt: Date.now(),
version: '1.0',
popularity: 2,
},
features: {
isVariable: false,
},
};
// Stand-in for the AdjustTypography store the composing widget injects.
const mockTypography = {
renderedSize: 48,
weight: 400,
height: 1.5,
spacing: 0,
};
</script>
<Story
name="Default"
args={{
font: mockArial,
status: 'loaded',
text: 'The quick brown fox jumps over the lazy dog',
index: 0,
typography: mockTypography,
}}
>
{#snippet template(args: ComponentProps<typeof FontSampler>)}
<Providers>
<div class="max-w-2xl mx-auto">
<FontSampler {...args} />
</div>
</Providers>
{/snippet}
</Story>
<Story
name="Long Text"
args={{
font: mockGeorgia,
status: 'loaded',
text:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
index: 1,
typography: mockTypography,
}}
>
{#snippet template(args: ComponentProps<typeof FontSampler>)}
<Providers>
<div class="max-w-2xl mx-auto">
<FontSampler {...args} />
</div>
</Providers>
{/snippet}
</Story>
@@ -0,0 +1,201 @@
<!--
Component: FontSampler
Displays a sample text with a given font in a contenteditable element.
Visual design matches FontCard: sharp corners, red hover accent, header stats.
-->
<script lang="ts">
import {
Badge,
ContentEditable,
Divider,
Footnote,
Stat,
} from '$shared/ui';
import { fly } from 'svelte/transition';
import type {
FontLoadStatus,
UnifiedFont,
} from '../../model/types';
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
/**
* Minimal typography contract this view renders with. The AdjustTypography
* store satisfies it structurally; defining it here keeps the entity decoupled
* from that feature (no entity -> feature import).
*/
interface FontSampleTypography {
/**
* Rendered font size in px
*/
renderedSize: number;
/**
* Numeric font weight
*/
weight: number;
/**
* Line-height multiplier
*/
height: number;
/**
* Letter spacing
*/
spacing: number;
}
interface Props {
/**
* Font info
*/
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
*/
text: string;
/**
* Position index
* @default 0
*/
index?: number;
/**
* Typography settings to render the sample with. Injected by the composing
* widget (which owns the AdjustTypography store) so this entity view stays
* decoupled from that feature — the same inversion as `status`.
*/
typography: FontSampleTypography;
}
let { font, status, text = $bindable(), index = 0, typography }: Props = $props();
// Extract provider badge with fallback
const providerBadge = $derived(
font.providerBadge
?? (font.provider === 'google' ? 'Google Fonts' : 'Fontshare'),
);
const stats = $derived([
{ label: 'SZ', value: `${typography.renderedSize}PX` },
{ label: 'WGT', value: `${typography.weight}` },
{ label: 'LH', value: typography.height.toFixed(2) },
{ label: 'LTR', value: `${typography.spacing}` },
]);
</script>
<div
in:fly={{ y: 20, duration: 400, delay: index * 50 }}
class="
group relative
w-full h-full
surface-card
hover:border-brand dark:hover:border-brand
hover:shadow-stamp-card
transition-all duration-normal
overflow-hidden
flex flex-col
min-h-60
rounded-none
"
style:font-weight={typography.weight}
>
<!-- ── Header bar ─────────────────────────────────────────────────── -->
<div
class="
flex items-center justify-between
px-4 sm:px-5 md:px-6 py-3 sm:py-4
border-b border-subtle
bg-paper dark:bg-dark-card
"
>
<!-- Left: index · name · type badge · provider badge -->
<div class="flex items-center gap-2 sm:gap-4 min-w-0 shrink-0">
<span class="font-mono text-2xs tracking-widest text-neutral-400 uppercase leading-none shrink-0">
{String(index + 1).padStart(2, '0')}
</span>
<Divider orientation="vertical" class="h-3 shrink-0" />
<span
class="font-primary font-bold text-sm text-swiss-black dark:text-neutral-200 leading-none tracking-tight uppercase truncate"
>
{font.name}
</span>
{#if font?.category}
<Badge size="xs" variant="default" nowrap>
{font?.category}
</Badge>
{/if}
<!-- Provider badge -->
{#if providerBadge}
<Badge size="xs" variant="default" nowrap data-provider={font.provider}>
{providerBadge}
</Badge>
{/if}
</div>
<!-- Right: stats, hidden on mobile, fade in on group hover -->
<div
class="
flex-1 min-w-0
hidden md:block @container
opacity-50 group-hover:opacity-100
transition-opacity duration-200 ml-4
"
>
<!-- Switches: narrow → 2×2, wide enough → 1 row -->
<div
class="
max-w-64 ml-auto
grid grid-cols-2 gap-x-3 gap-y-2
@[160px]:grid-cols-4 @[160px]:gap-y-0
items-center
"
>
{#each stats as stat}
<Stat label={stat.label} value={stat.value} />
{/each}
</div>
</div>
</div>
<!-- ── Main content area ──────────────────────────────────────────── -->
<div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10">
<FontApplicator {font} {status}>
<ContentEditable
bind:text
fontSize={typography.renderedSize}
lineHeight={typography.height}
letterSpacing={typography.spacing}
/>
</FontApplicator>
</div>
<!-- ── Mobile stats footer (md:hidden — header stats take over above) -->
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-subtle flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
{#each stats as stat, i}
<Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
{stat.label}:{stat.value}
</Footnote>
{#if i < stats.length - 1}
<div class="w-px h-2 sm:h-2.5 self-center bg-black/10 dark:bg-white/10 hidden sm:block"></div>
{/if}
{/each}
</div>
<!-- ── Red hover line ─────────────────────────────────────────────── -->
<div
class="
absolute bottom-0 left-0 right-0
w-full h-0.5 bg-brand
scale-x-0 group-hover:scale-x-100
transition-transform cubic-bezier(0.25, 0.1, 0.25, 1) origin-left duration-400
z-10
"
>
</div>
</div>
+2
View File
@@ -1,7 +1,9 @@
import FontApplicator from './FontApplicator/FontApplicator.svelte';
import FontSampler from './FontSampler/FontSampler.svelte';
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
export {
FontApplicator,
FontSampler,
FontVirtualList,
};