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:
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user