fix(fonts): semantic typography utilities per handoff (7 roles)

Заменяет blanket override из MR !45 на типизированную type scale per
design_handoff_ordinis_mdm/README.md "Scale" section.

Семь semantic @utility в styles.css:
  text-title-xl  22/600   — modal title, section header
  text-title-lg  17/600   — page title в editor
  text-title-md  15/600   — dictionary card title
  text-body      13/400   — workhorse: body, buttons, tabs, inputs
  text-cell      12.5/500 — table cell text
  text-mono      11/500   — Mono: IDs, dates, FK refs (font-mono baked in)
  text-cap       10.5/600 — Tektur UPPERCASE caption (font/uppercase baked in)

Audit phases:
  P1: добавил 7 утилит, font/uppercase/tracking baked где надо
  P2: 43 deterministic codemods (font-mono+text-2xs/[11px] → text-mono,
      [15/17/22]px+font-semibold → text-title-*, [12.5]px → text-cell,
      [10.5]px+uppercase+tracking → text-cap)
  P3: 64 text-sm → text-body (handoff workhorse), 84 text-2xs context-aware
      (TableCell → text-cell, bare → text-cell, плюс cleanup caps в backticks
      template literals который Phase 2 пропустил), 25 text-xs (6 → text-mono
      когда с font-mono, 19 → text-cell), 8 titles text-base/lg → text-title-*
  P4: убрал --text-2xs override (no users), оставил --text-sm: 13px scoped
      к @nstart/ui passthrough (см. comment в styles.css — убирается в Stage 3.9)

Stats:
  text-body: 69 | text-cell: 99 | text-mono: 50 | text-cap: 42
  text-title-xl: 5 | text-title-lg: 5 | text-title-md: 7
  text-sm/text-2xs/text-xs в src/: 0 (только в styles.css комментариях)

Поведение всех 277 typography usages теперь явно соответствует handoff —
каждое место осознанно выбрано под роль, не плажирующий override.
This commit is contained in:
Zimin A.N.
2026-05-11 14:31:35 +03:00
parent e62633d903
commit b94912789f
46 changed files with 296 additions and 243 deletions
@@ -506,7 +506,7 @@ function DictionaryDetail() {
)}
<PageHeader
breadcrumb={
<div className="flex items-center gap-3 text-sm">
<div className="flex items-center gap-3 text-body">
<Link to="/dictionaries" className="text-ink-2 hover:text-accent">
{t('nav.dictionaries')}
</Link>
@@ -630,8 +630,8 @@ function DictionaryDetail() {
)}
{aoi && (
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-accent/30 bg-accent/4 text-sm">
<span className="font-mono text-xs text-ink">
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-accent/30 bg-accent/4 text-body">
<span className=" text-mono text-ink">
{aoi.kind === 'bbox'
? t('aoi.activeBbox', { value: aoi.bboxCsv })
: t('aoi.activePolygon', {
@@ -640,7 +640,7 @@ function DictionaryDetail() {
</span>
<button
type="button"
className="text-xs text-accent hover:underline"
className="text-cell text-accent hover:underline"
onClick={handleAoiClear}
>
{t('aoi.clear')}
@@ -667,16 +667,16 @@ function DictionaryDetail() {
{/* Active state — ambient banner если ?at= задан, даже если picker закрыт. */}
{timeTravelAt && !timeTravelOpen && (
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-warn bg-warn-bg text-sm">
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-warn bg-warn-bg text-body">
<ClockCounterClockwiseIcon weight="bold" size={16} className="text-warn shrink-0" />
<span className="font-mono text-xs text-ink">
<span className=" text-mono text-ink">
{t('timeTravel.viewing', {
date: new Date(timeTravelAt).toLocaleString(),
})}
</span>
<button
type="button"
className="text-xs text-accent hover:underline ml-auto"
className="text-cell text-accent hover:underline ml-auto"
onClick={() => setTimeTravelAt(undefined)}
>
{t('timeTravel.clear')}
@@ -717,7 +717,7 @@ function DictionaryDetail() {
onClick={() => toggleScope(s)}
aria-pressed={active}
className={[
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border text-2xs uppercase tracking-[0.10em] font-display transition-colors',
'text-cap inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border transition-colors',
active
? 'border-accent bg-accent/8 text-accent'
: 'border-line bg-white text-ink-2 hover:border-carbon/40',
@@ -733,7 +733,7 @@ function DictionaryDetail() {
})}
</div>
{filtersActive && (
<div className="flex items-center gap-2 text-xs text-ink-2">
<div className="flex items-center gap-2 text-cell text-ink-2">
<span>
{t('dict.filter.matched', { count: filteredCount, total: totalRecords })}
</span>
@@ -800,7 +800,7 @@ function DictionaryDetail() {
)}
<TableCell>
<div className="flex items-center gap-1.5">
<span className="font-mono text-2xs">{r.businessKey}</span>
<span className="text-mono">{r.businessKey}</span>
{pendingByKey.has(r.businessKey) ? (
<span title={t('dict.pendingReview.tooltip')}>
<Badge variant="warning">{t('dict.pendingReview.label')}</Badge>
@@ -819,7 +819,7 @@ function DictionaryDetail() {
if (v == null || v === '') {
return (
<TableCell key={c.key}>
<span className="text-2xs text-mute/70"></span>
<span className="text-cell text-mute/70"></span>
</TableCell>
)
}
@@ -828,7 +828,7 @@ function DictionaryDetail() {
<TableCell key={c.key}>
<span
className={[
'inline-block px-1.5 py-0.5 rounded-sm font-mono text-2xs',
'inline-block px-1.5 py-0.5 rounded-sm font-mono text-cell',
c.isFk
? 'bg-accent/8 text-accent'
: 'bg-carbon/8 text-ink',
@@ -843,7 +843,7 @@ function DictionaryDetail() {
<Badge variant="info">{r.dataScope}</Badge>
</TableCell>
<TableCell>
<span className="text-2xs text-mute">
<span className="text-cell text-mute">
{new Date(r.validFrom).toLocaleDateString()}
</span>
</TableCell>
@@ -970,7 +970,7 @@ function DictionaryDetail() {
>
{edit.kind === 'close-confirm' && (
<div className="space-y-4">
<p className="text-sm text-ink">
<p className="text-body text-ink">
{t('dict.confirmClose.body', { key: edit.record.businessKey })}
</p>
<TextInput
@@ -1093,13 +1093,13 @@ function BulkSelectionToolbar({
].join(' ')}
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium tabular-nums">
<span className="text-body font-medium tabular-nums">
{t('dict.bulk.selectedCount', { count, total: totalVisible })}
</span>
<button
type="button"
onClick={onClear}
className="inline-flex items-center gap-1 text-2xs underline-offset-2 hover:underline opacity-80 hover:opacity-100"
className="inline-flex items-center gap-1 text-cell underline-offset-2 hover:underline opacity-80 hover:opacity-100"
>
<XIcon size={12} weight="bold" />
{t('dict.bulk.clearSelection')}
@@ -1150,10 +1150,10 @@ function RecordsPagination({
const to = Math.min((page + 1) * pageSize, totalCount)
return (
<div className="flex items-center justify-between flex-wrap gap-3 px-2">
<span className="text-2xs text-ink-2 tabular-nums">
<span className="text-cell text-ink-2 tabular-nums">
{t('dict.page.range', { from, to, total: totalCount })}
</span>
<div className="flex items-center gap-3 text-sm">
<div className="flex items-center gap-3 text-body">
<Button
type="button"
variant="secondary"
@@ -1163,7 +1163,7 @@ function RecordsPagination({
>
{t('dict.page.prev')}
</Button>
<span className="tabular-nums text-2xs text-ink-2">
<span className="tabular-nums text-cell text-ink-2">
{t('dict.page.of', { cur: page + 1, total: totalPages })}
</span>
<Button
@@ -1209,19 +1209,19 @@ function BulkCloseDialog({
<div className="space-y-4">
<div className="space-y-2">
{result.closed.length > 0 && (
<div className="flex items-center gap-2 text-sm text-soyuz-green">
<div className="flex items-center gap-2 text-body text-soyuz-green">
<CheckCircleIcon size={18} weight="fill" />
<span>{t('dict.bulk.resultClosed', { count: result.closed.length })}</span>
</div>
)}
{result.skipped.length > 0 && (
<div className="flex items-center gap-2 text-sm text-saturn">
<div className="flex items-center gap-2 text-body text-saturn">
<WarningIcon size={18} weight="fill" />
<span>{t('dict.bulk.resultSkipped', { count: result.skipped.length })}</span>
</div>
)}
{result.errors.length > 0 && (
<div className="flex items-center gap-2 text-sm text-mars">
<div className="flex items-center gap-2 text-body text-mars">
<XCircleIcon size={18} weight="fill" />
<span>{t('dict.bulk.resultErrors', { count: result.errors.length })}</span>
</div>
@@ -1230,7 +1230,7 @@ function BulkCloseDialog({
{(result.skipped.length > 0 || result.errors.length > 0) && (
<div className="border border-line rounded p-3 max-h-48 overflow-auto bg-line/30">
<ul className="text-2xs font-mono space-y-1">
<ul className="text-mono space-y-1">
{result.skipped.map((s) => (
<li key={`s-${s.businessKey}`} className="text-saturn">
· {s.businessKey} {s.reason}
@@ -1256,7 +1256,7 @@ function BulkCloseDialog({
return (
<div className="space-y-4">
<p className="text-sm text-ink">{t('dict.bulk.confirmBody', { count })}</p>
<p className="text-body text-ink">{t('dict.bulk.confirmBody', { count })}</p>
<TextInput
label={t('dict.confirmClose.reason')}
placeholder={t('dict.confirmClose.reasonPlaceholder')}
@@ -1361,7 +1361,7 @@ function EditorTabBar({
onClick={() => onChange(tab.id)}
aria-pressed={isActive}
className={[
'px-3.5 h-10 text-sm transition-colors -mb-px border-b-2',
'px-3.5 h-10 text-body transition-colors -mb-px border-b-2',
isActive
? 'border-accent text-ink font-semibold'
: 'border-transparent text-ink-2 hover:text-ink',
@@ -1382,14 +1382,14 @@ function FieldsTabContent({ detail }: { detail: { schemaJson: import('@/api/clie
const entries = Object.entries(props)
if (entries.length === 0) {
return (
<div className="text-sm text-mute py-8 text-center">
<div className="text-body text-mute py-8 text-center">
Schema без properties.
</div>
)
}
return (
<div className="rounded-lg border border-line overflow-hidden">
<table className="w-full text-sm">
<table className="w-full text-body">
<thead className="text-cap bg-surface-2 font-display text-mute">
<tr>
<th className="text-left px-3 py-2">name</th>
@@ -1404,15 +1404,15 @@ function FieldsTabContent({ detail }: { detail: { schemaJson: import('@/api/clie
<tbody>
{entries.map(([key, prop]) => (
<tr key={key} className="border-t border-line-2 hover:bg-surface-2/50">
<td className="px-3 py-1.5 font-mono text-[12.5px]">{key}</td>
<td className="text-cell px-3 py-1.5 font-mono">{key}</td>
<td className="px-3 py-1.5 text-ink-2">{prop.type ?? '—'}</td>
<td className="px-3 py-1.5">{required.has(key) ? '✓' : ''}</td>
<td className="px-3 py-1.5">{prop['x-unique'] ? '✓' : ''}</td>
<td className="px-3 py-1.5">{prop['x-localized'] ? '✓' : ''}</td>
<td className="px-3 py-1.5 font-mono text-[11px] text-accent">
<td className="text-mono px-3 py-1.5 text-accent">
{prop['x-references'] ?? ''}
</td>
<td className="px-3 py-1.5 text-ink-2 text-xs max-w-[300px] truncate">
<td className="px-3 py-1.5 text-ink-2 text-cell max-w-[300px] truncate">
{prop.description ?? ''}
</td>
</tr>
@@ -1437,13 +1437,13 @@ function JsonTabContent({ detail }: { detail: { schemaJson: import('@/api/client
function EventsTabContent({ dictName }: { dictName: string }) {
return (
<div className="rounded-lg border border-line bg-surface p-6 text-center space-y-3">
<p className="text-sm text-ink-2">
<p className="text-body text-ink-2">
События для справочника <span className="font-mono">{dictName}</span> в общем аудит-логе.
</p>
<Link
to="/audit"
search={{ dict: dictName }}
className="inline-flex items-center gap-1 text-accent hover:underline text-sm"
className="inline-flex items-center gap-1 text-accent hover:underline text-body"
>
Открыть /audit с фильтром
</Link>
@@ -1454,7 +1454,7 @@ function EventsTabContent({ dictName }: { dictName: string }) {
/** History tab — placeholder (Stage 3.x followup, требует backend changelog endpoint). */
function HistoryTabContent() {
return (
<div className="rounded-lg border border-dashed border-line bg-surface-2/30 p-8 text-center text-sm text-mute">
<div className="rounded-lg border border-dashed border-line bg-surface-2/30 p-8 text-center text-body text-mute">
Timeline изменений справочника скоро. Требует backend endpoint для schema versions.
</div>
)