14 KiB
Subframe-Migration: Komponenten-Mapping
Vertrag für die Migration der shadcn/Radix-UI (apps/web/app/components/ui/*) auf das
Subframe-Designsystem „Basedesign" (apps/web/app/ui/*, project f023064a5ca0).
Grundsätze
- Theme: Subframe ist die alleinige Quelle (
apps/web/app/ui/theme.css, eingebunden inglobals.css). Der Brand ist grayscale und deckt sich faktisch mit dem bisherigen zinc-Primary. Die shadcn-Tokens (--primary,--background…) bleiben aktiv, bis ihre Consumer migriert sind, und werden am Ende entfernt (samt JS-@config→ beseitigt die 22 transitorischen CSS-Optimierungs-Warnungen). - Mechanismus: Strangler-Shim. Pro Primitive wird die Implementierung in
components/ui/<x>.tsxdurch einen Wrapper über die Subframe-Komponente ersetzt; die bestehende API bleibt erhalten, Call-Sites ändern sich nicht. Schwer abbildbare Spezialfälle bleiben vorerst auf dem Legacy-Pfad und werden später auf native Subframe-Komponenten (LinkButton, IconButton …) umgestellt. - Verifikation pro Schritt:
pnpm --filter @toolctrl/web typecheck+buildgrün, Komponenten-Tests grün, danachpnpm verify:a11y-coverage. - Generierte Dateien in
app/ui/gehören Subframe. Lokale Fixes nur mit// @subframe/sync-disable(sonst überschreibt der nächstepnpm subframe:sync).
Button ✅ migriert (Shim)
components/ui/button.tsx rendert für den Normalfall die Subframe-Button.
Prop-Mapping:
variant: default→primary, destructive→destructive, outline→tertiary, secondary→secondary, ghost→ghost, link→ghostsize: default→medium, sm→small, xs→small, lg→large, icon→LegacyisLoading→loading;dirty→eigene Klasse (bleibt);type→Defaultsubmiterzwungen (Subframe würde sonstbuttonerzwingen und Formular-Buttons brechen)
Legacy-Pfad (noch shadcn, später nativ):
asChild(46 Stellen) → Ziel: SubframeLinkButtonsize="icon"(16 Stellen) → Ziel: SubframeIconButtonbuttonVariants-Export (Consumer: alert-dialog, calendar, pagination, json/ShowModalButton)
Formularfelder (components/ui/input/*) — der eigentliche Hebel
Die bare input.tsx wird kaum genutzt (9×); die Frequenz liegt in den Feld-Komponenten.
Subframes TextInput/TextArea sind <label>-Container (Label + Box + *.Input) und passen
hier — nicht auf die bare input.tsx. Beide haben eine max-w-Begrenzung, die für volle
Feldbreite mit className="w-full max-w-none" überschrieben werden muss.
- InputText (129×) ✅ migriert — volle Subframe
TextInput/TextAreainkl. Label (rich Label-Content als ReactNode),error, controlled+uncontrolled, type-Mapping. Editor-Branches (monaco/wysiwyg) bleiben unverändert (in der Praxis ungenutzt). Label-Assoziation jetzt implizit (Input im<label>) stattfor/id→ a11y-Test entsprechend angepasst. Hinweis:type="password"/"search"zeigen jetzt Subframes eingebautes Lock-/Such-Icon. - Welle 2 ✅ migriert (Shim → Subframe, typecheck/build/a11y grün): InputNumber→NumberInput,
InputSearch/InputSearchWithURL→TextInput(type=search), InputSearchButton→Button,
InputSelect/InputSelector→Select, InputCheckbox/Inline/WithDescription→Checkbox,
InputCheckboxCards→CheckboxCard, InputRadioGroup→RadioGroup, InputRadioGroupCards→RadioCardGroup,
InputChoicebox→RadioCardGroup, Toggle→Switch. InputResponsiveSelector erbt via InputSelector.
app/ui-Patch: NumberInput (hardcodiertes
value=""entfernt, sync-disable). Trade-offs: InputNumbericon-Prop jetzt visuell No-op; InputCheckboxWithDescription Layout-Shift.
Welle 3 (Buttons-Folder / Banners / Atome)
- Migriert ✅ (typecheck/build/a11y grün):
buttons/ButtonShare+buttons/ButtonFlyout→ via Button-Shim (Rest des buttons/-Ordners lief schon über den Shim);banners/*(Error/Warning/ Success/Info) → BannerAlert (type-Mapping;onDismissals Overlay erhalten, da BannerAlerts dismiss-Button ein Mock ohne onClick ist);progress.tsx→ Progress;skeleton.tsx→ SkeletonText.
⚠️ Subframe-Komponenten, die NICHT passen (Mock oder Struktur-Mismatch) → bleiben shadcn
Rein präsentational (kein onChange/State/Navigation) oder strukturell inkompatibel — bewusst NICHT migriert:
- Combobox / MultiSelect —
<input value="">hartkodiert, kein onChange → InputCombobox bleibt shadcn. - DatePicker — hartkodierte Tage/Monate, Pfeile ohne onClick → InputDate/InputDatePicker bleiben shadcn (funktionaler
Calendarwrappt react-day-picker und bleibt). - SearchInput — value hartkodiert; echte Suchfelder nutzen
TextInput type=search. - Tooltip — statischer
<div>-Mock (kein Radix-Trigger/Content/Hover) →tooltip.tsxbleibt shadcn. - Alert — Subframe ist prop-basiert (title/description), shadcn ist Compound (Alert/AlertTitle/AlertDescription) → bleibt shadcn.
- Avatar — Subframe hat keine AvatarImage/AvatarFallback-Subkomponenten + keine Fallback-bei-Ladefehler-Logik → bleibt shadcn.
- Badge (+ badges/*) — BadgeFeedback erzwingt einen führenden Dot, ist
<div>statt<span>, keinasChild, keine freien Farben (Badge nutzt 8+ freie Farben) → bleibt shadcn.
Damit diese migrierbar werden, müsste das Subframe-Design die Interaktionen/Struktur liefern (oder wir behalten shadcn). Bei jedem Sync prüfen, ob upstream funktionale/passende Varianten kommen.
Welle 4 (funktionale Compounds) — Decke erreicht
Nur slider.tsx → Subframe Slider migriert (funktional, gleicher Radix; im Repo aktuell ungenutzt;
ref nicht weitergereicht, da span vs. div). Alle anderen Compounds gestoppt — die Subframe-
Komponenten sind zwar teils Radix-funktional, aber kompositorisch unvollständig:
- Dialog — Subframe exportiert nur Root+Content (kein Trigger/Close/Portal/Overlay), Root hardcodiert
Portal+Overlay+Title → würde an jeder Modal-Stelle doppeltes Overlay erzeugen. Bleibt shadcn.
(Korrektur:
components/ui/dialogwird direkt nur in 3 Dateien importiert; die Modal-Frequenz läuft transitiv übermodals/DialogSticky/CommandDialog/ModalBlank, ~57 Dateien.) - DropdownMenu — Subframe ist nur Item-/Surface-Styling; das funktionale Radix ist DASSELBE Paket wie bei shadcn (kein Gewinn, hohes Risiko). Bleibt shadcn.
- Drawer / Sheet — Subframe Drawer ist right-only, kein Trigger/Close, hardcodiertes Content-Styling;
sheetbrauchtside-Prop;drawerist ein vaul-Drag-Drawer. Bleiben shadcn. - Accordion — Subframe basiert auf Collapsible (kein Multi-Item
type/value). Bleibt shadcn.
Fazit: Die sauber migrierbare Fläche ist mit dem aktuellen „Basedesign"-Stand weitgehend ausgeschöpft. Compounds brauchen in Subframe komponierbare Teile (Trigger/Close/Portal/Content + Sub-Items), Mocks brauchen Funktionalität. Weiterer Fortschritt = Subframe-Design-Arbeit, nicht Code.
Welle 5 (Rest-Sweep „alles Migrierbare")
- Migriert ✅ (typecheck/build/a11y grün):
switch→Switch,checkbox→Checkbox,radio-group→RadioGroup (alle mit controlled/uncontrolled-Shim + null→undefined-Coercion),calendar→Subframe Calendar (= dasselbe react-day-picker, kein Feature-Verlust),button-group/split-button→ButtonGroup,loaders/Loading+FloatingLoader→Loader,steps/ProgressBar→StepperHorizontal. - Gestoppt (Struktur/Compound/Semantik-Mismatch, bleiben shadcn):
breadcrumb(+breadcrumbs/) — Subframe-Items sind keine Links;empty(+emptyState/) — Subframe-Button ist Mock ohne onClick;stepper— Subframe ist rein präsentational (keine Step-State/Trigger);toggle/toggle-group— Subframe ist single-only + brichttoggleVariants-Export;links/— URL-Filter-Toggle, kein Link-Button;errors/ErrorPage+ServerError— Full-Page-Layout vs. Inline-Karte, Retry-Button ist Mock;headers/HeaderFlyoutItem— Popover-Flyout vs. Top-Bar;stats/*— StatsCard erzwingt Trend-Icon + Pointer + bricht<dl>/sr-only-a11y;select— Compound (10 Exporte, kompositionale Inversion);table— Subframe Table nicht komponierbar (keine Header/Body/Footer/Caption-Teile).
Endstand der Code-Migration: Was mit dem aktuellen Subframe-Projekt sauber ging, ist migriert. Alles Weitere ist durch die Vollständigkeit von „Basedesign" blockiert (siehe Mock-/Compound-Listen oben).
Direkte 1:1-Ziele (gleiche Reihenfolge wie Migrationswellen)
| shadcn | Subframe | Hinweis |
|---|---|---|
| input | TextInput | placeholder=string |
| textarea | TextArea | |
| select | Select | gepatcht (sync-disable) |
| checkbox | Checkbox | |
| radio-group | RadioGroup | |
| switch | Switch | |
| badge | BadgeFeedback / Tag_Label | kein generisches Badge |
| avatar | Avatar | + AvatarGroup |
| tooltip | Tooltip | |
| tabs | Tabs | |
| accordion | Accordion | |
| breadcrumb | Breadcrumbs | |
| progress | Progress | + ProgressCircle |
| slider | Slider | |
| skeleton | SkeletonText / SkeletonCircle | |
| table | Table | |
| dialog | Dialog | + ModalBlank/ConfirmDialog/ResultDialog |
| drawer / sheet | Drawer | |
| dropdown-menu | DropdownMenu | |
| context-menu | ContextMenu | |
| sidebar | SidebarWithSections | |
| stepper | StepperHorizontal/Vertical | |
| toggle-group / toggle | ToggleGroup | |
| input-otp | InputOtp | |
| command | Combobox / SearchInput | keine Command-Palette; gepatcht |
| multi-select | MultiSelect | + MultiSelectTags |
| calendar | Calendar | + DatePicker |
| chart | AreaChart/BarChart/LineChart/PieChart | konkrete Typen statt Wrapper |
| card | CardIcon/CardImage/StatsCard | kein generisches Card |
| empty | EmptyState | |
| alert | Alert | |
| toast/toaster/sonner | Toast |
Lücken — bleiben shadcn (kein Subframe-Pendant)
aspect-ratio, carousel, collapsible, hover-card, input-group, label,
pagination, popover, resizable, scroll-area, separator.
Bekannte Generierungs-Bugs (gepatcht, sync-disabled)
Select: undefinedchecked/highlighted→ entfernt (Styling via data-state)Combobox,SearchInput:placeholderReactNode →string
Token-Zuordnung: shadcn → Subframe (für „Logik=Radix, Look=Subframe")
Strategie für alle noch nicht migrierten Komponenten: die bestehende Radix/shadcn-Komponente
BEHALTEN (Logik bleibt), NUR die Farb-/Stil-Klassen auf Subframe-Tokens umstellen. Keine
Struktur-/Import-/Export-Änderung — nur className. Sync-sicher (alles in components/ui; die
Subframe-Tokens kommen global aus app/ui/theme.css). Subframe-app/ui-Mock-Komponenten werden
für diese NICHT verwendet.
| shadcn-Token | Subframe-Token |
|---|---|
bg-background |
bg-surface-neutral-primary |
bg-card, bg-popover |
bg-surface-neutral-primary (Overlays/Modal: bg-surface-neutral-modal) |
text-foreground, text-card-foreground |
text-text-neutral-primary |
text-muted-foreground |
text-text-neutral-tertiary |
placeholder:text-muted-foreground |
placeholder:text-text-neutral-subtle |
bg-primary (hover /90) |
bg-brand-brand-surface-solid-default (hover -hover) |
text-primary-foreground |
text-text-neutral-on-solid |
bg-secondary, bg-muted, bg-accent |
bg-surface-neutral-secondary |
text-secondary-foreground, text-accent-foreground |
text-text-neutral-primary |
hover:bg-accent |
hover:bg-surface-neutral-secondary (aktiv: -tertiary) |
bg-destructive (hover /90) |
bg-surface-feedback-solid-negative-default (hover -hover) |
text-destructive |
text-text-feedback-negative |
border, border-border |
border-border-default |
border-input |
border-border-strong |
Fokus: ring-ring, ring-[3px], focus-visible:ring-* |
shadow-focus (+ border-brand-brand-border-solid-default) |
disabled: opacity-50/muted |
bg-surface-neutral-disabled, text-text-neutral-disabled, border-border-disabled |
Regel: nur diese Art Klassen ersetzen. Layout-/Spacing-/Radius-Utilities (flex, gap, px, rounded …) unverändert lassen. Im Zweifel an Button.tsx / TextInput.tsx orientieren (echte Subframe-Tokens).
Welle 6+7 — „Logik=Radix, Look=Subframe" (Restyle aller restlichen Komponenten)
Strategie-Wechsel (vom Designer bestätigt: es geht um Logik, nicht um neue Subframe-Komponenten): Die zuvor als „blockiert/Mock" eingestuften Komponenten werden NICHT durch Subframe-Mock- Komponenten ersetzt — stattdessen behalten sie ihre funktionierende Radix/shadcn-Logik und bekommen nur den Subframe-LOOK per Token-Swap (className). Sync-sicher (nur components/ui, Tokens global aus theme.css). Carbon nicht nötig — Radix ist die headless Logik-Basis.
Restyled (typecheck/build/a11y/axe grün): dialog, alert-dialog, modals/, dropdown-menu, context-menu, dropdowns/, tabs (+tabs/), accordion, collapsible, tooltip, popover, hover-card, tooltips/, table (+tables/), select, drawer, sheet, sidebar, slideOvers/, card, alert, avatar, badge (+badges/, freie Farben bewusst erhalten), breadcrumb (+breadcrumbs/), empty (+emptyState/), stepper, stats/, separator, scroll-area, resizable, input-group, input, textarea, choicebox, dividers/, command (+commandPalettes/), toast, notifications/, errors/, headers/*, chart (nur Tooltip/Legende), carousel (erbt via Button).
Bewusst NICHT umgestellt: freie Farben (Badge-Paletten, Trend-grün/rot, Status-Icons),
Overlay-Backdrops (bg-black/50), Sidebar-eigener sidebar-*-Token-Namespace, recharts-Datenfarben,
sowie Komponenten ohne Farb-Tokens (label, pagination, aspect-ratio — Look kommt via Button/Tokens).
Langer Schwanz (noch offen, meist Spezial/Utility, geringe Sichtbarkeit): lists/, sort/, selectors/, misc/, json/, code/, docs/, layouts/, media/, images/, sections/, wizards/, poll/, pdf/ + Spezial- Inputs (InputColorSelector/MultiText/Image/Media/RichTextEditor). Icons (111) bleiben außen vor.