From 7934da7669b15da9e3b6df34298588d576e26c62 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 24 May 2026 14:05:52 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20design=20token=20system=20=E2=80=94=20D?= =?UTF-8?q?S=5FTEXT/DS=5FSURFACE/DS=5FBORDER=20+=20237=20hex=20migrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/settings.json | 3 +- eslint.config.js | 16 ++- package.json | 3 +- scripts/check-tokens.js | 70 ++++++++++++ .../assistant/AssistantPromptSuggestions.tsx | 39 +++---- src/components/compare/AICompareSummary.tsx | 53 ++++----- .../match-card/FutureAvailabilityCard.tsx | 60 +++++------ .../LocationIntelligencePanel.tsx | 41 +++---- .../match-detail/ScoreBreakdownPanel.tsx | 72 ++++++------- .../pipeline/PipelineDetailPanel.tsx | 59 +++++----- src/components/shared/GenericBadge.tsx | 19 +++- src/components/supply/BerichtDialog.tsx | 49 ++++----- src/components/supply/PreMarketPanel.tsx | 39 +++---- .../supply/PropertyActivityLogPanel.tsx | 21 ++-- src/components/supply/UnitStructurePanel.tsx | 43 ++++---- src/lib/ds.ts | 101 ++++++++++++++++++ 16 files changed, 445 insertions(+), 243 deletions(-) create mode 100644 scripts/check-tokens.js diff --git a/.claude/settings.json b/.claude/settings.json index 2de8617..541118f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,7 +10,8 @@ "Bash(awk '{print $NF}')", "Bash(start http://localhost:5173)", "Bash(npm install *)", - "Bash(git pull *)" + "Bash(git pull *)", + "Bash(node scripts/check-tokens.js)" ] } } diff --git a/eslint.config.js b/eslint.config.js index d9ae4b4..679e0be 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,9 +19,15 @@ export default defineConfig([ globals: globals.browser, }, }, - // Discourage new hardcoded hex colors in components and pages. - // Use DS_COLORS, matchScoreHex(), RESULT_TYPE_META, or MUI theme tokens ("primary.main") instead. - // See src/lib/ds.ts and src/lib/utils.ts for available tokens. + // ── Design Token Enforcement ──────────────────────────────────────────────── + // New hardcoded hex colors in components/pages are blocked (error). + // Existing violations are tracked by: npm run check:tokens + // + // Use instead: + // DS_TEXT.secondary, DS_SURFACE.success.bg (src/lib/ds.ts) + // matchScoreHex(score), criterionScoreTextColor(score) (src/lib/utils.ts) + // RESULT_TYPE_META[type].color (src/lib/ds.ts) + // MUI theme tokens: "primary.main", "text.secondary" { files: ['src/components/**/*.{ts,tsx}', 'src/pages/**/*.{ts,tsx}'], rules: { @@ -29,11 +35,11 @@ export default defineConfig([ 'warn', { selector: [ - 'Property[key.name=/^(bgcolor|color|borderColor|background|fill)$/]', + 'Property[key.name=/^(bgcolor|color|borderColor|background|fill|stroke)$/]', ' > Literal[value=/^#[0-9A-Fa-f]{3,8}$/]', ].join(''), message: - 'Avoid hardcoded hex colors in sx props. Use DS_COLORS, matchScoreHex(), RESULT_TYPE_META, or MUI theme tokens ("primary.main") from src/lib/ds.ts and src/lib/utils.ts.', + 'No hardcoded hex colors in sx/style props. Use DS_TEXT, DS_SURFACE, DS_BORDER, DS_BG, BADGE_COLORS from src/lib/ds.ts, or helper functions from src/lib/utils.ts.', }, ], }, diff --git a/package.json b/package.json index c7da8da..e396f0b 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "check:tokens": "node scripts/check-tokens.js" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/scripts/check-tokens.js b/scripts/check-tokens.js new file mode 100644 index 0000000..2f21541 --- /dev/null +++ b/scripts/check-tokens.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node +/** + * check-tokens.js + * + * Counts hardcoded hex color literals in sx props / color attributes + * across src/components and src/pages. Fails (exit 1) if count exceeds THRESHOLD. + * + * Usage: + * node scripts/check-tokens.js — report + fail if > threshold + * node scripts/check-tokens.js --report — report only, always exits 0 + * + * Run via: npm run check:tokens + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, extname } from 'node:path' + +// Hex literals allowed before CI blocks the build. +// This is a ratchet — lower it as migration progresses. Never raise it. +// Baseline after initial token migration (2026-05-24): 1958 +const THRESHOLD = 1958 + +const HEX_PATTERN = /#[0-9A-Fa-f]{3,8}\b/g + +const SEARCH_DIRS = ['src/components', 'src/pages'] + +function walk(dir) { + const files = [] + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) files.push(...walk(full)) + else if (entry.isFile() && ['.ts', '.tsx'].includes(extname(entry.name))) files.push(full) + } + return files +} + +const reportOnly = process.argv.includes('--report') +const cwd = process.cwd() + +let totalViolations = 0 +const fileViolations = [] + +for (const searchDir of SEARCH_DIRS) { + const dir = join(cwd, searchDir) + for (const file of walk(dir)) { + const content = readFileSync(file, 'utf8') + const matches = content.match(HEX_PATTERN) + if (matches && matches.length > 0) { + totalViolations += matches.length + fileViolations.push({ file: file.replace(cwd + '\\', '').replace(cwd + '/', ''), count: matches.length }) + } + } +} + +fileViolations.sort((a, b) => b.count - a.count) + +console.log('\n── Token Compliance Check ───────────────────────────────────────────') +console.log(`Total hex violations: ${totalViolations} (threshold: ${THRESHOLD})`) +console.log(`Status: ${totalViolations <= THRESHOLD ? '✅ PASS' : '❌ FAIL'}`) +console.log('\nTop 15 offenders:') +fileViolations.slice(0, 15).forEach(({ file, count }) => { + console.log(` ${count.toString().padStart(3)} ${file}`) +}) +console.log('─────────────────────────────────────────────────────────────────────\n') + +if (!reportOnly && totalViolations > THRESHOLD) { + console.error(`Error: ${totalViolations} hex violations exceed threshold of ${THRESHOLD}.`) + console.error('Run `node scripts/check-tokens.js --report` to see the full list.') + process.exit(1) +} diff --git a/src/components/assistant/AssistantPromptSuggestions.tsx b/src/components/assistant/AssistantPromptSuggestions.tsx index 9608b23..d063188 100644 --- a/src/components/assistant/AssistantPromptSuggestions.tsx +++ b/src/components/assistant/AssistantPromptSuggestions.tsx @@ -1,5 +1,6 @@ import { Box, Chip, Typography } from '@mui/material' import type { SuggestedQuestion } from '../../domain/assistant' +import { DS_TEXT, DS_BORDER, DS_SURFACE, BADGE_COLORS } from '../../lib/ds' interface Props { suggestions: SuggestedQuestion[] @@ -9,26 +10,26 @@ interface Props { const CATEGORY_COLORS: Record = { Match: '#4f46e5', - Datenqualität: '#d97706', - Priorisierung: '#1e3a5f', - Empfehlung: '#1a7a4a', - Risiko: '#c0392b', + Datenqualität: DS_TEXT.warning, + Priorisierung: DS_TEXT.brand, + Empfehlung: DS_TEXT.success, + Risiko: DS_TEXT.error, Tradeoffs: '#ea580c', Strategie: '#0891b2', - Analyse: '#7c3aed', + Analyse: BADGE_COLORS.preMarket, Erklärung: '#0891b2', - Evidenz: '#64748b', - Review: '#7c3aed', - Konfidenz: '#d97706', - Fehler: '#c0392b', + Evidenz: DS_TEXT.muted, + Review: BADGE_COLORS.preMarket, + Konfidenz: DS_TEXT.warning, + Fehler: DS_TEXT.error, Fehleranalyse: '#ea580c', Eskalation: '#ea580c', - Prozess: '#64748b', - Kosten: '#1a7a4a', - Impact: '#d97706', - Optimierung: '#1a7a4a', - Aktion: '#1e3a5f', - Überblick: '#64748b', + Prozess: DS_TEXT.muted, + Kosten: DS_TEXT.success, + Impact: DS_TEXT.warning, + Optimierung: DS_TEXT.success, + Aktion: DS_TEXT.brand, + Überblick: DS_TEXT.muted, Ranking: '#4f46e5', } @@ -37,12 +38,12 @@ export function AssistantPromptSuggestions({ suggestions, onSelect, disabled }: return ( - + Vorschläge {suggestions.map(s => { - const catColor = CATEGORY_COLORS[s.category] ?? '#64748b' + const catColor = CATEGORY_COLORS[s.category] ?? DS_TEXT.muted return ( + setOpen(v => !v)} sx={{ @@ -21,14 +22,14 @@ export function AICompareSummary({ summary, isLoading }: Props) { justifyContent: 'space-between', px: 2, py: 1.25, - bgcolor: '#f8fafc', + bgcolor: DS_SURFACE.neutral.bg, cursor: 'pointer', - '&:hover': { bgcolor: '#f1f5f9' }, + '&:hover': { bgcolor: DS_BG.subtle }, }} > AI Vergleichs-Zusammenfassung - + {open ? : } @@ -45,15 +46,15 @@ export function AICompareSummary({ summary, isLoading }: Props) { {!isLoading && summary && ( {/* Overall assessment */} - - {summary.overallAssessment} + + {summary.overallAssessment} {/* Strongest option */} - + - Stärkstes Match + Stärkstes Match {summary.strongestOption.label} {summary.strongestOption.reason} @@ -62,9 +63,9 @@ export function AICompareSummary({ summary, isLoading }: Props) { {/* Best value */} {summary.bestValue && ( - + - Bestes Preis-Leistungs-Verhältnis + Bestes Preis-Leistungs-Verhältnis {summary.bestValue.label} {summary.bestValue.reason} @@ -75,7 +76,7 @@ export function AICompareSummary({ summary, isLoading }: Props) { - Höchste Datenkonfidenz + Höchste Datenkonfidenz {summary.highestConfidence.label} @@ -88,9 +89,9 @@ export function AICompareSummary({ summary, isLoading }: Props) { {/* Tradeoffs */} {summary.biggestTradeoffs.length > 0 && ( - + - Wichtigste Abwägungen + Wichtigste Abwägungen {summary.biggestTradeoffs.map((t, i) => ( · {t} ))} @@ -101,9 +102,9 @@ export function AICompareSummary({ summary, isLoading }: Props) { {/* Missing data */} {summary.missingDataWarnings.length > 0 && ( - + - Fehlende Informationen + Fehlende Informationen {summary.missingDataWarnings.map((w, i) => ( · {w} ))} @@ -114,14 +115,14 @@ export function AICompareSummary({ summary, isLoading }: Props) { {/* Per-property assessment */} {summary.perPropertyAssessment.length > 0 && ( - Objektbewertung + Objektbewertung {summary.perPropertyAssessment.map(prop => ( - {prop.label} + {prop.label} {/* Strengths */} {prop.strengths.map((s, i) => ( - + {s} ))} @@ -145,7 +146,7 @@ export function AICompareSummary({ summary, isLoading }: Props) { {prop.weaknesses.map((w, i) => ( - + {w} ))} @@ -153,8 +154,8 @@ export function AICompareSummary({ summary, isLoading }: Props) { {/* Best for */} - - + + Am besten für: {prop.bestFor} @@ -162,7 +163,7 @@ export function AICompareSummary({ summary, isLoading }: Props) { {/* Key risk */} {prop.keyRisk && ( - + {prop.keyRisk} )} @@ -173,10 +174,10 @@ export function AICompareSummary({ summary, isLoading }: Props) { )} {/* Recommendation */} - - + + - Empfehlung + Empfehlung {summary.recommendation} diff --git a/src/components/match-card/FutureAvailabilityCard.tsx b/src/components/match-card/FutureAvailabilityCard.tsx index e4b4735..8fdf7fb 100644 --- a/src/components/match-card/FutureAvailabilityCard.tsx +++ b/src/components/match-card/FutureAvailabilityCard.tsx @@ -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 */} - + {assetLabel.toUpperCase()} · {vm.locationLabel?.split(',')[0]?.toUpperCase() ?? ''} @@ -56,19 +56,19 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) { {/* Opportunity headline */} - + {headline} {/* Key facts */} {vm.signalAreaSqmEstimate ? ( - + {isControlled ? '' : '~'}{vm.signalAreaSqmEstimate.toLocaleString('de-CH')} m² ) : null} {vm.availabilityLabel && ( - + {vm.availabilityLabel} )} @@ -76,20 +76,20 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) { {/* Source attribution */} {isControlled ? ( - - - Direkte Verwaltungsquelle + + + Direkte Verwaltungsquelle ) : sourceMeta ? ( - {sourceMeta.icon} - {sourceMeta.label} + {sourceMeta.icon} + {sourceMeta.label} ) : null} {/* ── Score + signal quality strip ────────────────────────────────────── */} - + {!isControlled && vm.signalProbability !== undefined && ( - - + + {Math.round(vm.signalProbability * 100)}% Signalw. @@ -116,10 +116,10 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) { {!isControlled && ( - - + + Probabilistischer Marktindikator — kein bestätigtes Objekt. Dient als strategischer Frühindikator. @@ -130,8 +130,8 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) { {(vm.signalConfirmedFacts ?? []).slice(0, 3).map((fact, i) => ( - - {fact} + + {fact} ))} @@ -139,19 +139,19 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) { {/* PRE-MARKET VERIFIED: specific unit info */} {isControlled && vm.preMarketUnit && ( - - + + Freigegebene Einheit - + {floorLabel(vm.preMarketUnit.floorLevel)}{vm.preMarketUnit.unitLabel ? ` · ${vm.preMarketUnit.unitLabel}` : ''} - + {vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m² {vm.preMarketUnit.schattenmarktRelease?.availableFrom && ( - + ab {new Date(vm.preMarketUnit.schattenmarktRelease.availableFrom).toLocaleDateString('de-CH', { month: 'short', year: 'numeric' })} )} @@ -162,14 +162,14 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) { {/* MARKET SIGNAL: market indicators */} {!isControlled && (vm.signalMarketIndicators?.length ?? 0) > 0 && ( - + Erkannte Marktindikatoren {(vm.signalMarketIndicators ?? []).slice(0, 3).map((ind, i) => ( - {ind} + {ind} ))} @@ -180,16 +180,16 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) { {vm.reasons.length > 0 && ( <> - + Warum relevant für Ihre Suche? {vm.reasons.slice(0, 3).map((r, i) => ( - + - {r.label} - {r.explanation} + {r.label} + {r.explanation} ))} @@ -236,7 +236,7 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) { {/* Disclaimer footnote */} {vm.disclaimer && ( - + {vm.disclaimer} )} diff --git a/src/components/match-detail/LocationIntelligencePanel.tsx b/src/components/match-detail/LocationIntelligencePanel.tsx index 7350279..e780af6 100644 --- a/src/components/match-detail/LocationIntelligencePanel.tsx +++ b/src/components/match-detail/LocationIntelligencePanel.tsx @@ -7,6 +7,7 @@ import { useNavigate } from 'react-router' import { useProperties } from '../../hooks/useProperties' import { getCityIntelligence } from '../../lib/locationIntelligence' import type { Property } from '../../domain/property' +import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds' import { SoftFactorBar } from './SoftFactorBar' import { KpiTile } from './KpiTile' import { NEW_PROJECTS } from './locationIntelligenceConstants' @@ -63,31 +64,31 @@ export function LocationIntelligencePanel({ property }: Props) { label="Leerstandsquote" value={`${intel.vacancyRatePct}%`} sub={intel.vacancyRatePct < 3 ? 'Angespannter Markt' : intel.vacancyRatePct < 5 ? 'Ausgeglichen' : 'Käufermarkt'} - color={intel.vacancyRatePct < 3 ? '#c0392b' : intel.vacancyRatePct < 5 ? '#d97706' : '#1a7a4a'} + color={intel.vacancyRatePct < 3 ? DS_TEXT.error : intel.vacancyRatePct < 5 ? DS_TEXT.warning : DS_TEXT.success} /> = 115 ? '#1a7a4a' : intel.purchasingPowerIndex >= 95 ? '#d97706' : '#c0392b'} + color={intel.purchasingPowerIndex >= 115 ? DS_TEXT.success : intel.purchasingPowerIndex >= 95 ? DS_TEXT.warning : DS_TEXT.error} /> {/* Rent trend interpretation */} - - + + {rentTrendPositive ? : } @@ -99,29 +100,29 @@ export function LocationIntelligencePanel({ property }: Props) { {/* Tax + demand */} - + - + Steuerindex Kanton - + {intel.taxIndexCanton} (CH = 100) {intel.taxIndexCanton <= 75 ? 'Sehr steuerattraktiv' : intel.taxIndexCanton <= 95 ? 'Günstige Steuerlast' : intel.taxIndexCanton <= 110 ? 'Durchschnittlich' : 'Hohe Steuerlast'} - + - + Nachfragestärke @@ -133,12 +134,12 @@ export function LocationIntelligencePanel({ property }: Props) { {/* Industry clusters */} - + Dominante Branchen-Cluster {intel.dominantIndustryClusters.map(c => ( - + ))} @@ -147,8 +148,8 @@ export function LocationIntelligencePanel({ property }: Props) { {intel.plannedInfrastructure.length > 0 && ( - - + + Geplante Infrastruktur-Projekte @@ -173,7 +174,7 @@ export function LocationIntelligencePanel({ property }: Props) { {/* ── Soft Factors ── */} {hasSoftFactors && ( - + KI-berechnete Standortqualität 0 && ( <> - + Vergleichbare Angebote in {city} {comparables.map(p => ( @@ -231,7 +232,7 @@ export function LocationIntelligencePanel({ property }: Props) { sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 0.75, borderBottom: '1px solid #f1f5f9', cursor: 'pointer', - '&:hover': { bgcolor: '#f8fafc', borderRadius: 0.5 }, + '&:hover': { bgcolor: DS_SURFACE.neutral.bg, borderRadius: 0.5 }, }} > diff --git a/src/components/match-detail/ScoreBreakdownPanel.tsx b/src/components/match-detail/ScoreBreakdownPanel.tsx index 0cb27d5..a0f98aa 100644 --- a/src/components/match-detail/ScoreBreakdownPanel.tsx +++ b/src/components/match-detail/ScoreBreakdownPanel.tsx @@ -6,7 +6,7 @@ import type { FutureSignal } from '../../domain/futureSignal' import { CREDIBILITY_LABELS, HARD_KEYS, factorLabel } from './scoreBreakdownConstants' import { CriterionRow } from './CriterionRow' import { criterionScoreColor, criterionScoreTextColor } from '../../lib/utils' -import { DS_COLORS } from '../../lib/ds' +import { DS_COLORS, DS_TEXT, DS_SURFACE } from '../../lib/ds' import { SCORE_STRONG, SCORE_MODERATE } from '../../lib/constants' // ── Standard breakdown (VERIFIED_PORTFOLIO / EXTERNAL / MAISON) ────────────── @@ -46,7 +46,7 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps) {hardFactors.map((f, i) => )} - 0 ? 2 : 1.5 }}> + 0 ? 2 : 1.5 }}> Hart-Kriterien {sb.hardMatchScore}/100 × 60% @@ -57,11 +57,11 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps) {/* Soft factors: full list when allFactors present, otherwise summary row */} {!hasAllFactors && ( - - + + Soft-Faktoren {sb.softFactorScore}/100 × 40% - + {softContrib} Pkt @@ -70,15 +70,15 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps) {hasAllFactors && softFactors.length > 0 && ( <> - + Soft-Faktoren {softFactors.map((f, i) => )} - - + + Soft-Score {sb.softFactorScore}/100 × 40% - + {softContrib} Pkt @@ -87,10 +87,10 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps) {/* Tax link */} {taxCalculatorUrl && ( - - + + - + Steuerlast in Bewertung eingeflossen Steuerrechner Gemeinde öffnen → @@ -109,11 +109,11 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps) {/* Formula row — always visible */} - - + + Berechnung - + Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40% {' = '}{hardContrib} + {softContrib} = {baseSum} @@ -122,7 +122,7 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps) {/* Total */} = SCORE_STRONG ? '#f0fdf4' : baseSum >= SCORE_MODERATE ? '#fffbeb' : '#fef2f2', + bgcolor: baseSum >= SCORE_STRONG ? DS_SURFACE.success.bg : baseSum >= SCORE_MODERATE ? DS_SURFACE.warning.bg : DS_SURFACE.error.bg, p: 1.5, borderRadius: 1, }}> Gesamt-Score @@ -176,11 +176,11 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) { {isPositive - ? - : + ? + : } - {factorLabel(f.criterion)} + {factorLabel(f.criterion)} {f.score}/100 @@ -197,9 +197,9 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) { ) })} - - Basis-Score - {sb.hardMatchScore}/100 + + Basis-Score + {sb.hardMatchScore}/100 @@ -214,20 +214,20 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) { {isVerifiedContract ? ( <> - - + + Vertragsende aus ERP verifiziert — keine Schätzung - - + + Verwaltung hat Freigabe erteilt - - Signal-Abschlag - keiner + + Signal-Abschlag + keiner ) : ( @@ -235,18 +235,18 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) { {probPct !== null && ( Eintretenswahrscheinlichkeit: {probPct}% - –{Math.round(signalDeduction * 0.7)} Pkt. + –{Math.round(signalDeduction * 0.7)} Pkt. )} {credLabel && ( Quellenqualität: {credLabel} - –{Math.round(signalDeduction * 0.3)} Pkt. + –{Math.round(signalDeduction * 0.3)} Pkt. )} - - Signal-Abschlag - –{signalDeduction} Punkte + + Signal-Abschlag + –{signalDeduction} Punkte )} @@ -255,7 +255,7 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) { {/* Gesamt */} - + Gesamt-Score {/* Header */} - + {item.title} @@ -48,12 +49,12 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () {path && ( - navigate(path)} sx={{ color: '#64748b', '&:hover': { color: '#1e3a5f' } }}> + navigate(path)} sx={{ color: DS_TEXT.muted, '&:hover': { color: DS_TEXT.brand } }}> )} - + @@ -61,7 +62,7 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () {STAGES.slice(0, 5).map((s, idx) => ( - + ))} @@ -74,10 +75,10 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () onClick={() => navigate(`/demand/anfragen?inquiry=${item.inquiryId}`)} sx={{ height: 22, fontSize: '0.75rem', cursor: 'pointer', - bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600, - border: '1px solid #bfdbfe', - '& .MuiChip-icon': { color: '#1e3a5f' }, - '&:hover': { bgcolor: '#dbeafe' }, + bgcolor: DS_SURFACE.blue.bg, color: DS_TEXT.brand, fontWeight: 600, + border: `1px solid ${DS_SURFACE.blue.border}`, + '& .MuiChip-icon': { color: DS_TEXT.brand }, + '&:hover': { bgcolor: DS_COLORS.futureCard.signal.badgeBg }, }} /> )} @@ -87,7 +88,7 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () {/* Property / unit address */} {item.propertyAddress && ( - + {item.propertyAddress} )} @@ -97,24 +98,24 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () {/* KI */} - - + + KI Einschätzung - - {ki.summary} + + {ki.summary} {ki.positives.map((p, i) => ( - - {p} + + {p} ))} {ki.risks.map((r, i) => ( - - {r} + + {r} ))} @@ -124,7 +125,7 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () {/* Stage actions */} {!isClosed && ( - + Nächste Aktion @@ -137,7 +138,7 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () )} @@ -149,8 +150,8 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () {/* Notes */} - - + + Notizen @@ -169,8 +170,8 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () {/* Documents */} - - + + Dokumente @@ -181,18 +182,18 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () {docs.map((doc, i) => ( - - {doc.name} + + {doc.name} {doc.date} ))} )} - diff --git a/src/components/shared/GenericBadge.tsx b/src/components/shared/GenericBadge.tsx index fdc4a3a..1fea6f8 100644 --- a/src/components/shared/GenericBadge.tsx +++ b/src/components/shared/GenericBadge.tsx @@ -1,9 +1,21 @@ import { Chip, Tooltip } from '@mui/material' import type { ReactElement } from 'react' +import { BADGE_COLORS } from '../../lib/ds' + +export type BadgeSemanticVariant = keyof typeof BADGE_COLORS export interface GenericBadgeProps { label: string - color: string + /** + * Semantic variant key from BADGE_COLORS (preferred). + * Example: semanticVariant="confidenceHigh" instead of color="#1a7a4a" + */ + semanticVariant?: BadgeSemanticVariant + /** + * Raw hex color — use only when no semantic variant fits. + * Prefer semanticVariant to avoid hardcoded colors. + */ + color?: string /** transparent: semi-opaque bg (color + 18% opacity) | solid: filled bg, white text */ variant?: 'transparent' | 'solid' /** Adds a 1px border at color 25% opacity (transparent variant only) */ @@ -20,7 +32,8 @@ export interface GenericBadgeProps { export function GenericBadge({ label, - color, + semanticVariant, + color: colorProp, variant = 'transparent', showBorder = false, bold = false, @@ -29,6 +42,8 @@ export function GenericBadge({ tooltip, ariaLabel, }: GenericBadgeProps) { + const color = semanticVariant ? BADGE_COLORS[semanticVariant] : (colorProp ?? '#64748b') + const chip = ( = { development: '#1e3a5f', @@ -80,9 +81,9 @@ export function BerichtDialog({ onClose, report, propertyId }: BerichtDialogProp PaperProps={{ sx: { height: '88vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' } }} > {/* Header */} - + - + Bericht erstellen @@ -91,7 +92,7 @@ export function BerichtDialog({ onClose, report, propertyId }: BerichtDialogProp {ready && ( - + Bereit )} @@ -99,29 +100,29 @@ export function BerichtDialog({ onClose, report, propertyId }: BerichtDialogProp {/* Generating state */} {!ready && ( - + PDF-Bericht wird generiert… - {Math.round(progress)}% + {Math.round(progress)}% )} {/* PDF Preview */} - + - - + + Marktsignal-Bericht - Wincasa AG · Zürich - {new Date().toLocaleDateString('de-CH')} + Wincasa AG · Zürich + {new Date().toLocaleDateString('de-CH')} - + Standortintelligenz & Marktsignale - + Objekt-ID: {propertyId} · Generiert: {report?.generatedAt ? new Date(report.generatedAt).toLocaleDateString('de-CH') : new Date().toLocaleDateString('de-CH')} · {signals.length} Signale @@ -166,18 +167,18 @@ export function BerichtDialog({ onClose, report, propertyId }: BerichtDialogProp {label} - + ({catSignals.length}) {catSignals.map((s, i) => ( - + {s.title} {s.confidence != null && ( - + KI-Konfidenz: {Math.round(s.confidence * 100)}% )} @@ -187,12 +188,12 @@ export function BerichtDialog({ onClose, report, propertyId }: BerichtDialogProp {s.date && ( - + {new Date(s.date).toLocaleDateString('de-CH')} )} {s.source && ( - + Quelle: {s.source} )} @@ -204,14 +205,14 @@ export function BerichtDialog({ onClose, report, propertyId }: BerichtDialogProp })} {signals.length === 0 && ( - + Keine Marktsignale für dieses Objekt verfügbar. )} {/* Footer */} - - + + Dieser Bericht wurde automatisch auf Basis von KI-generierten Marktsignalen erstellt. · Wincasa AG @@ -219,7 +220,7 @@ export function BerichtDialog({ onClose, report, propertyId }: BerichtDialogProp {/* Footer actions */} - + @@ -228,7 +229,7 @@ export function BerichtDialog({ onClose, report, propertyId }: BerichtDialogProp startIcon={} onClick={handleDownload} disabled={!ready} - sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }} + sx={{ textTransform: 'none', bgcolor: DS_TEXT.brand, '&:hover': { bgcolor: '#16304d' } }} > Herunterladen diff --git a/src/components/supply/PreMarketPanel.tsx b/src/components/supply/PreMarketPanel.tsx index 1bd2824..bffa551 100644 --- a/src/components/supply/PreMarketPanel.tsx +++ b/src/components/supply/PreMarketPanel.tsx @@ -6,6 +6,7 @@ import type { Property } from '../../domain/property' import { MockupUnitProvider } from '../../provider/MockupUnitProvider' import { propertyService } from '../../services/propertyService' import { useToastStore } from '../../stores/toastStore' +import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds' import { floorLabel, SectionTitle } from './PropertyDetailHelpers' export const MOCK_TODAY = new Date('2026-05-20') @@ -99,17 +100,17 @@ export function PreMarketPanel({ p }: { p: Property }) { {/* Toggle row */} - + Pre-Market Matching aktivieren @@ -120,13 +121,13 @@ export function PreMarketPanel({ p }: { p: Property }) { - {saving && } + {saving && } @@ -150,9 +151,9 @@ export function PreMarketPanel({ p }: { p: Property }) { onClick={() => handleLeadTime(m)} sx={{ height: 20, fontSize: '0.68rem', cursor: 'pointer', - bgcolor: leadTimeMonths === m ? '#7c3aed' : '#f1f5f9', - color: leadTimeMonths === m ? 'white' : '#374151', - '&:hover': { bgcolor: leadTimeMonths === m ? '#6d28d9' : '#e2e8f0' }, + bgcolor: leadTimeMonths === m ? DS_PRE_MARKET.accent : DS_SURFACE.slate.bg, + color: leadTimeMonths === m ? DS_TEXT.inverted : '#374151', + '&:hover': { bgcolor: leadTimeMonths === m ? DS_PRE_MARKET.accentHover : DS_BORDER.default }, }} /> ))} @@ -164,8 +165,8 @@ export function PreMarketPanel({ p }: { p: Property }) { sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, p: 1, borderRadius: 1, border: '1px solid', - bgcolor: isActive ? '#f0fdf4' : '#fff7ed', - borderColor: isActive ? '#bbf7d0' : '#fed7aa', + bgcolor: isActive ? DS_SURFACE.success.bg : DS_SURFACE.orange.bg, + borderColor: isActive ? DS_SURFACE.success.border : DS_SURFACE.orange.border, }} > {isActive @@ -184,7 +185,7 @@ export function PreMarketPanel({ p }: { p: Property }) { {/* Unit-level release controls */} {(p.units?.length ?? 0) > 0 && ( - + Einheiten freigeben @@ -201,10 +202,10 @@ export function PreMarketPanel({ p }: { p: Property }) { }} > - + {floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''} - + {u.areaSqm.toLocaleString('de-CH')} m² {u.currentTenant ? ` · ${u.currentTenant}` : ''} @@ -223,7 +224,7 @@ export function PreMarketPanel({ p }: { p: Property }) { }} /> - {unitSaving[u.id] && } + {unitSaving[u.id] && } @@ -245,25 +246,25 @@ export function PreMarketPanel({ p }: { p: Property }) { )} {/* Demand Intelligence */} - + Matching Demand Intelligence - + {demandProfiles} aktive Suchprofile im System erkannt - + {highQualityLeads} hochwertige Suchanfragen mit passendem Flächenbedarf - + Frühzeitige Matchgelegenheit — vor offizieller Vermarktung exklusiv verfügbar diff --git a/src/components/supply/PropertyActivityLogPanel.tsx b/src/components/supply/PropertyActivityLogPanel.tsx index 4804417..f584cde 100644 --- a/src/components/supply/PropertyActivityLogPanel.tsx +++ b/src/components/supply/PropertyActivityLogPanel.tsx @@ -19,6 +19,7 @@ import { } from 'lucide-react' import { usePropertyActivityLog } from '../../hooks/useActivityLog' import type { ActivityCategory, ActivityEventType } from '../../services/governanceService' +import { DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds' const EVENT_LABELS: Record = { PROPERTY_CREATED: 'Objekt erstellt', @@ -134,13 +135,13 @@ function groupByDate(events: ActivityEvent[]) { } function EventRow({ event, isLast }: { event: ActivityEvent; isLast: boolean }) { - const color = EVENT_COLORS[event.type] ?? '#64748b' + const color = EVENT_COLORS[event.type] ?? DS_TEXT.muted const catMeta = CATEGORY_META[event.category] return ( {!isLast && ( - + )} )} @@ -181,14 +182,14 @@ function EventRow({ event, isLast }: { event: ActivityEvent; isLast: boolean }) {event.isAiAction ? - : + : } - + {event.isAiAction ? 'KI-System' : event.performedBy} - + {formatTime(event.createdAt)} @@ -227,7 +228,7 @@ export function PropertyActivityLogPanel({ propertyId }: PropertyActivityLogPane return ( - + {events.length} Ereignisse · Nur für Verwaltung @@ -237,12 +238,12 @@ export function PropertyActivityLogPanel({ propertyId }: PropertyActivityLogPane {label} - - + + {dayEvents.length}× diff --git a/src/components/supply/UnitStructurePanel.tsx b/src/components/supply/UnitStructurePanel.tsx index 9a81f4d..191b8f0 100644 --- a/src/components/supply/UnitStructurePanel.tsx +++ b/src/components/supply/UnitStructurePanel.tsx @@ -4,6 +4,7 @@ import { ChevronDown, ChevronUp, Layers, Users } from 'lucide-react' import { useNavigate } from 'react-router' import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property' import { unitMatchService } from '../../services/unitMatchService' +import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds' import { floorLabel } from './PropertyDetailHelpers' function MatchPill({ m }: { m: UnitNeedMatch }) { @@ -59,17 +60,17 @@ export function UnitStructurePanel({ p }: { p: Property }) { Stockwerkstruktur {freeUnits.length >= 2 && ( - + Freie Einheiten auswählen zum Kombinieren )} - + {/* Header */} - + {['', 'Stockwerk', 'Einheit', 'Mieter / Status', 'Matches', 'Fläche'].map(h => ( - {h} + {h} ))} @@ -88,9 +89,9 @@ export function UnitStructurePanel({ p }: { p: Property }) { gridTemplateColumns: '28px 72px 88px 1fr auto 80px', px: 1.5, py: 0.875, - borderBottom: isLastRow ? 'none' : '1px solid #f1f5f9', + borderBottom: isLastRow ? 'none' : `1px solid ${DS_BORDER.muted}`, alignItems: 'center', - bgcolor: isSelected ? '#eff6ff' : 'transparent', + bgcolor: isSelected ? DS_SURFACE.blue.bg : 'transparent', transition: 'background 0.15s', }} > @@ -101,7 +102,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { size="small" checked={isSelected} onChange={() => toggleUnit(u.id)} - sx={{ p: 0, color: '#94a3b8', '&.Mui-checked': { color: '#2563eb' } }} + sx={{ p: 0, color: DS_TEXT.disabled, '&.Mui-checked': { color: '#2563eb' } }} /> )} @@ -118,14 +119,14 @@ export function UnitStructurePanel({ p }: { p: Property }) { {u.available ? ( <> - + {u.isFlexible && ( - + )} ) : ( - {u.currentTenant ?? 'Vermietet'} + {u.currentTenant ?? 'Vermietet'} )} @@ -157,7 +158,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { setExpandedUnit(isExpanded ? null : u.id)} - sx={{ p: 0.25, color: '#94a3b8' }} + sx={{ p: 0.25, color: DS_TEXT.disabled }} > {isExpanded ? : } @@ -170,7 +171,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { {(u.offeredSqm ?? u.areaSqm).toLocaleString('de-CH')} m² {u.isFlexible && u.minLettableSqm && ( - + ab {u.minLettableSqm} m² )} @@ -179,22 +180,22 @@ export function UnitStructurePanel({ p }: { p: Property }) { {/* Expanded: show all matches for this unit */} - - + + Passende Suchanfragen für diese Einheit {matches.map(m => ( - + {m.requiredSqmMin}–{m.requiredSqmMax} m² {m.matchType === 'partial' && m.suggestedSqm && ` · Teilfläche ~${m.suggestedSqm} m² anbieten`} ))} {matches.length === 0 && ( - Keine passenden Suchanfragen + Keine passenden Suchanfragen )} @@ -208,9 +209,9 @@ export function UnitStructurePanel({ p }: { p: Property }) { {bundle && ( {bundleMatches.length > 0 ? ( - + Passende Suchanfragen für Kombination @@ -240,7 +241,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { ) : ( - + Keine direkt passenden Suchanfragen für diese Kombination )} diff --git a/src/lib/ds.ts b/src/lib/ds.ts index 0f23906..e717fd4 100644 --- a/src/lib/ds.ts +++ b/src/lib/ds.ts @@ -67,6 +67,107 @@ export const DS_COLORS = { }, } as const +// ── Text color tokens ───────────────────────────────────────────────────────── +// For sx={{ color: DS_TEXT.secondary }} instead of hardcoded '#475569' + +export const DS_TEXT = { + primary: '#1e293b', + secondary: '#475569', + muted: '#64748b', + disabled: '#94a3b8', + inverted: '#ffffff', + success: '#1a7a4a', + warning: '#d97706', + error: '#c0392b', + danger: '#dc2626', + info: '#0369a1', + brand: '#1e3a5f', + signal: '#6d28d9', + // Dark variants — use for text on light-tinted surface boxes + successDark: '#166534', + warningDark: '#92400e', + signalDark: '#4c1d95', + infoDark: '#0c4a6e', +} as const + +// ── Background tokens ───────────────────────────────────────────────────────── + +export const DS_BG = { + page: '#f8fafc', + surface: '#ffffff', + subtle: '#f1f5f9', + muted: '#e2e8f0', +} as const + +// ── Border tokens ───────────────────────────────────────────────────────────── + +export const DS_BORDER = { + default: '#e2e8f0', + muted: '#f1f5f9', + strong: '#cbd5e1', +} as const + +// ── Surface tokens — bg + border pairs for highlighted/tinted boxes ─────────── +// Use as: sx={{ bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}` }} + +export const DS_SURFACE = { + success: { bg: '#f0fdf4', border: '#bbf7d0' }, // green tint + warning: { bg: '#fffbeb', border: '#fde68a' }, // amber tint + error: { bg: '#fef2f2', border: '#fecaca' }, // red tint + info: { bg: '#f0f9ff', border: '#bae6fd' }, // sky tint + indigo: { bg: '#eef2ff', border: '#e0e7ff' }, // indigo tint (hard-criteria rows) + purple: { bg: '#faf5ff', border: '#e9d5ff' }, // purple tint (pre-market / future) + orange: { bg: '#fff7ed', border: '#fed7aa' }, // orange tint (HOT heat badge bg) + blue: { bg: '#eff6ff', border: '#bfdbfe' }, // blue tint (market signal) + neutral: { bg: '#f8fafc', border: '#e2e8f0' }, // neutral/slate tint (formula rows) + slate: { bg: '#f1f5f9', border: '#e2e8f0' }, // slightly darker neutral +} as const + +// ── Match tier surface — bg tint keyed by score tier ───────────────────────── +// Use with SCORE_STRONG / SCORE_MODERATE thresholds from constants.ts + +export const DS_MATCH_TIER = { + strong: DS_SURFACE.success, + moderate: DS_SURFACE.warning, + weak: DS_SURFACE.error, +} as const + +// ── Pre-market & market signal shorthand aliases ────────────────────────────── +// DS_COLORS.futureCard.controlled / signal are the full token sets. +// These aliases make imports more readable in supply-side components. + +export const DS_PRE_MARKET = DS_COLORS.futureCard.controlled +export const DS_MARKET_SIGNAL = DS_COLORS.futureCard.signal + +// ── Badge color presets ─────────────────────────────────────────────────────── +// Pass to GenericBadge.color instead of raw hex strings. +// BADGE_COLORS.confidenceHigh replaces '#1a7a4a' at call sites. + +export const BADGE_COLORS = { + confidenceHigh: '#1a7a4a', + confidenceMedium: '#d97706', + confidenceLow: '#c0392b', + riskHigh: '#c0392b', + riskMedium: '#d97706', + riskLow: '#1a7a4a', + preMarket: '#7c3aed', + marketSignal: '#1d4ed8', + verified: '#1e3a5f', + info: '#0369a1', + muted: '#64748b', + gold: '#d97706', + silver: '#64748b', + bronze: '#b45309', +} as const + +// ── Shadow tokens ───────────────────────────────────────────────────────────── + +export const DS_SHADOW = { + card: '0 1px 3px 0 rgba(0,0,0,0.08), 0 1px 2px -1px rgba(0,0,0,0.04)', + panel: '0 4px 6px -1px rgba(0,0,0,0.08), 0 2px 4px -2px rgba(0,0,0,0.04)', + dialog: '0 20px 25px -5px rgba(0,0,0,0.10), 0 8px 10px -6px rgba(0,0,0,0.06)', +} as const + // ── Inquiry status metadata ─────────────────────────────────────────────────── export const INQUIRY_STATUS_META: Record = {