feat: design token system — DS_TEXT/DS_SURFACE/DS_BORDER + 237 hex migrations

Token infrastructure (src/lib/ds.ts):
- DS_TEXT: 16 semantic text color tokens (primary/secondary/muted/success/
  warning/error/info/brand/signal + dark variants successDark/warningDark/
  signalDark/infoDark for text on tinted surfaces)
- DS_BG: page/surface/subtle/muted background tokens
- DS_BORDER: default/muted/strong border tokens
- DS_SURFACE: 10 bg+border surface pairs (success/warning/error/info/indigo/
  purple/orange/blue/neutral/slate)
- DS_MATCH_TIER: score-tier surface aliases keyed by strong/moderate/weak
- DS_PRE_MARKET / DS_MARKET_SIGNAL: named aliases for futureCard tokens
- DS_SHADOW: card/panel/dialog elevation tokens
- BADGE_COLORS: 14 semantic presets for GenericBadge (confidenceHigh,
  riskMedium, preMarket, marketSignal, verified, gold, silver, bronze…)

GenericBadge (src/components/shared/GenericBadge.tsx):
- New semanticVariant prop (keyof BADGE_COLORS) — preferred over raw hex
- color prop becomes optional (fallback), type-documented as escape hatch

Priority file migrations — 237 hex literals replaced across 10 files:
  ScoreBreakdownPanel.tsx    −22  PipelineDetailPanel.tsx    −22
  FutureAvailabilityCard.tsx −26  AICompareSummary.tsx       −27
  LocationIntelligencePanel  −39  AssistantPromptSuggestions −18
  UnitStructurePanel.tsx     −25  BerichtDialog.tsx          −24
  PreMarketPanel.tsx         −24  PropertyActivityLogPanel   −10

ESLint (eslint.config.js):
- Updated rule message to reference new tokens (DS_TEXT, DS_SURFACE, etc.)
- Added 'stroke' to monitored property names
- Remains 'warn' for gradual migration; use check:tokens for CI gate

CI script (scripts/check-tokens.js + npm run check:tokens):
- Counts hex patterns in components/ + pages/
- Fails if count > THRESHOLD (ratchet: 1958 baseline, lower per sprint)
- Reports top 15 offenders for prioritizing next migration batch

Results: ESLint targeted sx-prop violations: 1752 → 1030 (−41%)
0 TypeScript errors, 154 tests green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 14:05:52 +02:00
parent e62391af66
commit 7934da7669
16 changed files with 445 additions and 243 deletions
@@ -4,7 +4,7 @@ import { useNavigate } from 'react-router'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { floorLabel, SOURCE_META, ASSET_TYPE_LABELS, getOpportunityHeadline } from './futureAvailabilityCardHelpers'
import { SignalQualityDots } from './SignalQualityDots'
import { DS_COLORS } from '../../lib/ds'
import { DS_COLORS, DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
import type { MatchCardViewModel } from './MatchCardViewModel'
// ── Future Availability Card ──────────────────────────────────────────────────
@@ -44,7 +44,7 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
{/* Asset type · location + badge */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.8 }}>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.8 }}>
{assetLabel.toUpperCase()} · {vm.locationLabel?.split(',')[0]?.toUpperCase() ?? ''}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: badgeBg, color: badgeColor, px: 0.875, py: 0.25, borderRadius: 1, flexShrink: 0 }}>
@@ -56,19 +56,19 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
</Box>
{/* Opportunity headline */}
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3, color: '#1e293b', mb: 0.75 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary, mb: 0.75 }}>
{headline}
</Typography>
{/* Key facts */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', mb: 0.875 }}>
{vm.signalAreaSqmEstimate ? (
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#1e293b' }}>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: DS_TEXT.primary }}>
{isControlled ? '' : '~'}{vm.signalAreaSqmEstimate.toLocaleString('de-CH')} m²
</Typography>
) : null}
{vm.availabilityLabel && (
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary }}>
{vm.availabilityLabel}
</Typography>
)}
@@ -76,20 +76,20 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
{/* Source attribution */}
{isControlled ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 0.875, py: 0.3, width: 'fit-content' }}>
<ShieldCheck size={10} color="#1a7a4a" />
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, color: '#1a7a4a' }}>Direkte Verwaltungsquelle</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`, borderRadius: 1, px: 0.875, py: 0.3, width: 'fit-content' }}>
<ShieldCheck size={10} color={DS_TEXT.success} />
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, color: DS_TEXT.success }}>Direkte Verwaltungsquelle</Typography>
</Box>
) : sourceMeta ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, width: 'fit-content' }}>
<Box sx={{ color: '#64748b', display: 'flex' }}>{sourceMeta.icon}</Box>
<Typography sx={{ fontSize: '0.65rem', color: '#64748b', fontWeight: 500 }}>{sourceMeta.label}</Typography>
<Box sx={{ color: DS_TEXT.muted, display: 'flex' }}>{sourceMeta.icon}</Box>
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted, fontWeight: 500 }}>{sourceMeta.label}</Typography>
</Box>
) : null}
</Box>
{/* ── Score + signal quality strip ────────────────────────────────────── */}
<Box sx={{ px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1.5, borderBottom: '1px solid #f1f5f9', bgcolor: 'white' }}>
<Box sx={{ px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1.5, borderBottom: `1px solid ${DS_BORDER.muted}`, bgcolor: 'white' }}>
<Box sx={{
background: theme.gradient, borderRadius: '8px',
px: 1.25, py: 0.4, border: `1px solid ${theme.border}`,
@@ -101,8 +101,8 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
</Box>
<SignalQualityDots quality={vm.signalQuality} />
{!isControlled && vm.signalProbability !== undefined && (
<Box sx={{ ml: 'auto', bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.75, py: 0.2 }}>
<Typography sx={{ fontSize: '0.63rem', color: '#64748b', fontWeight: 600 }}>
<Box sx={{ ml: 'auto', bgcolor: DS_SURFACE.neutral.bg, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, px: 0.75, py: 0.2 }}>
<Typography sx={{ fontSize: '0.63rem', color: DS_TEXT.muted, fontWeight: 600 }}>
{Math.round(vm.signalProbability * 100)}% Signalw.
</Typography>
</Box>
@@ -116,10 +116,10 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
{!isControlled && (
<Box sx={{
display: 'flex', alignItems: 'flex-start', gap: 0.6, mb: 1.25,
bgcolor: '#fefce8', border: '1px solid #fde68a', borderRadius: 1, px: 1, py: 0.625,
bgcolor: DS_SURFACE.warning.bg, border: `1px solid ${DS_SURFACE.warning.border}`, borderRadius: 1, px: 1, py: 0.625,
}}>
<AlertCircle size={11} color="#92400e" style={{ marginTop: 2, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.67rem', color: '#78350f', lineHeight: 1.45 }}>
<AlertCircle size={11} color={DS_COLORS.heat.VERY_HOT.text} style={{ marginTop: 2, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.67rem', color: DS_COLORS.heat.VERY_HOT.text, lineHeight: 1.45 }}>
Probabilistischer Marktindikator kein bestätigtes Objekt. Dient als strategischer Frühindikator.
</Typography>
</Box>
@@ -130,8 +130,8 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, mb: 1.25 }}>
{(vm.signalConfirmedFacts ?? []).slice(0, 3).map((fact, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.6 }}>
<CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.72rem', color: '#1a7a4a', fontWeight: 500 }}>{fact}</Typography>
<CheckCircle2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.success, fontWeight: 500 }}>{fact}</Typography>
</Box>
))}
</Box>
@@ -139,19 +139,19 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
{/* PRE-MARKET VERIFIED: specific unit info */}
{isControlled && vm.preMarketUnit && (
<Box sx={{ bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 1.25, py: 0.875, mb: 1.25 }}>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#1a7a4a', textTransform: 'uppercase', letterSpacing: 0.5, mb: 0.4 }}>
<Box sx={{ bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`, borderRadius: 1, px: 1.25, py: 0.875, mb: 1.25 }}>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: DS_TEXT.success, textTransform: 'uppercase', letterSpacing: 0.5, mb: 0.4 }}>
Freigegebene Einheit
</Typography>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: '0.72rem', color: '#1e293b', fontWeight: 600 }}>
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.primary, fontWeight: 600 }}>
{floorLabel(vm.preMarketUnit.floorLevel)}{vm.preMarketUnit.unitLabel ? ` · ${vm.preMarketUnit.unitLabel}` : ''}
</Typography>
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary }}>
{vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
</Typography>
{vm.preMarketUnit.schattenmarktRelease?.availableFrom && (
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary }}>
ab {new Date(vm.preMarketUnit.schattenmarktRelease.availableFrom).toLocaleDateString('de-CH', { month: 'short', year: 'numeric' })}
</Typography>
)}
@@ -162,14 +162,14 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
{/* MARKET SIGNAL: market indicators */}
{!isControlled && (vm.signalMarketIndicators?.length ?? 0) > 0 && (
<Box sx={{ mb: 1.25 }}>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.5 }}>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.5 }}>
Erkannte Marktindikatoren
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35 }}>
{(vm.signalMarketIndicators ?? []).slice(0, 3).map((ind, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.6 }}>
<Box sx={{ width: 4, height: 4, borderRadius: '50%', bgcolor: DS_COLORS.futureCard.signal.indicator, mt: '5px', flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.72rem', color: '#475569', lineHeight: 1.3 }}>{ind}</Typography>
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary, lineHeight: 1.3 }}>{ind}</Typography>
</Box>
))}
</Box>
@@ -180,16 +180,16 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
{vm.reasons.length > 0 && (
<>
<Divider sx={{ mb: 1 }} />
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.6 }}>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.6 }}>
Warum relevant für Ihre Suche?
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mb: 1.25 }}>
{vm.reasons.slice(0, 3).map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.6 }}>
<CheckCircle2 size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
<CheckCircle2 size={12} color={DS_TEXT.success} style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>{r.label}</Typography>
<Typography sx={{ fontSize: '0.69rem', color: '#64748b', lineHeight: 1.3 }}>{r.explanation}</Typography>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: DS_TEXT.primary, lineHeight: 1.3 }}>{r.label}</Typography>
<Typography sx={{ fontSize: '0.69rem', color: DS_TEXT.muted, lineHeight: 1.3 }}>{r.explanation}</Typography>
</Box>
</Box>
))}
@@ -236,7 +236,7 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
{/* Disclaimer footnote */}
{vm.disclaimer && (
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1, fontSize: '0.63rem', lineHeight: 1.4, borderTop: '1px solid #f1f5f9', pt: 0.75 }}>
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1, fontSize: '0.63rem', lineHeight: 1.4, borderTop: `1px solid ${DS_BORDER.muted}`, pt: 0.75 }}>
{vm.disclaimer}
</Typography>
)}