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