feat(konsta): adaptive UI components — Konsta rendering on mobile#334
feat(konsta): adaptive UI components — Konsta rendering on mobile#334hessius wants to merge 4 commits intoversion/2.4.0from
Conversation
…ove custom theme CSS
- Install konsta@5.0.8 (Tailwind-native mobile UI components)
- Add KonstaProvider wrapper in App.tsx (active on mobile or when forced via settings)
- Create useKonstaOverride hook (isMobile || settings toggle)
- Refactor usePlatformTheme to resolve KonstaTheme ('ios'|'material')
- Remove custom ios-theme.css and material-theme.css (replaced by Konsta)
- Remove theme CSS imports from main.tsx
- Add @import 'konsta/react/theme.css' to index.css
- Add USE_KONSTA_UI storage key to constants
- Add i18n keys for Konsta UI toggle in all 6 locales
Part of #332
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…onsta on mobile Modify 10 shadcn ui/ components to conditionally render Konsta UI equivalents when useKonstaOverride() returns true (mobile viewport or forced via settings): - Button: maps variant/size props to Konsta filled/outline/clear/tonal/rounded - Card: renders Konsta Card with outline, sub-components unchanged - Switch: renders Konsta Toggle, adapts onCheckedChange→onChange - Dialog: enhanced mobile styling (rounded-2xl, no border, shadow-xl) - Input: Konsta-styled native input (taller, transparent bg, no shadow) - Select: SelectTrigger gets mobile-optimized styling - Slider: renders Konsta Range, adapts array→single value - Checkbox: renders Konsta Checkbox, adapts event handlers - Tabs: TabsList/TabsTrigger get full-width touch-first styling - Progress: renders Konsta Progressbar, adapts 0-100→0-1 Also adds: - Settings toggle: 'Use Konsta UI' in Appearance section (SettingsView) - Vitest config: inline konsta dependency for test compatibility Zero consumer file changes — all existing imports work unchanged. Part of #332 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix same-tab reactivity bug: dispatch custom event from useKonstaToggle so useKonstaOverride re-renders without page refresh (follows aiPreferences pattern) - Remove 'as any' type assertion in Slider, destructure props properly - Forward aria-*/data-* attributes in Progress Konsta path - Add explanatory comments for CSS-only Dialog adaptation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Introduces Konsta UI as an adaptive rendering layer for mobile (or a forced settings toggle), making existing shadcn UI component imports render Konsta equivalents without changing consumer code.
Changes:
- Add Konsta dependency + theme CSS import and adjust Vitest config for Konsta ESM compatibility.
- Add
useKonstaOverride()/useKonstaToggle()and wire a “Use Konsta UI” Appearance setting + i18n strings. - Update core UI components (Button/Card/Switch/Checkbox/Slider/etc.) to render Konsta-styled equivalents when the override is active.
Reviewed changes
Copilot reviewed 27 out of 28 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/web/package.json | Adds konsta dependency. |
| apps/web/bun.lock | Locks konsta (and related resolution changes). |
| apps/web/src/index.css | Imports konsta/react/theme.css. |
| apps/web/vitest.config.ts | Inlines konsta for Vitest runtime compatibility. |
| apps/web/src/lib/constants.ts | Adds STORAGE_KEYS.USE_KONSTA_UI. |
| apps/web/src/hooks/useKonstaOverride.ts | New hook(s) for Konsta override + settings toggle storage. |
| apps/web/src/hooks/usePlatformTheme.ts | Refactors platform theme handling to output konstaTheme instead of HTML classes. |
| apps/web/src/App.tsx | Wraps app content in Konsta <App> when override is active. |
| apps/web/src/main.tsx | Removes legacy platform theme CSS imports. |
| apps/web/src/styles/ios-theme.css | Deletes old iOS theme overrides (Konsta replaces). |
| apps/web/src/styles/material-theme.css | Deletes old Material theme overrides (Konsta replaces). |
| apps/web/src/components/SettingsView.tsx | Adds Appearance toggle for “Use Konsta UI”. |
| apps/web/src/components/ui/button.tsx | Renders Konsta Button when override is active (with variant/size mapping). |
| apps/web/src/components/ui/card.tsx | Renders Konsta Card when override is active. |
| apps/web/src/components/ui/switch.tsx | Renders Konsta Toggle when override is active. |
| apps/web/src/components/ui/checkbox.tsx | Renders Konsta Checkbox when override is active. |
| apps/web/src/components/ui/slider.tsx | Renders Konsta Range when override is active. |
| apps/web/src/components/ui/progress.tsx | Uses Konsta Progressbar when override is active (0–100 to 0–1 scaling). |
| apps/web/src/components/ui/input.tsx | Applies Konsta-friendly input styling when override is active. |
| apps/web/src/components/ui/select.tsx | Adjusts SelectTrigger styling for Konsta mode. |
| apps/web/src/components/ui/tabs.tsx | Adjusts Tabs styling for Konsta mode. |
| apps/web/src/components/ui/dialog.tsx | Applies mobile-friendly dialog styling when Konsta is active (CSS-only). |
| apps/web/public/locales/en/translation.json | Adds Appearance strings for Konsta UI toggle. |
| apps/web/public/locales/de/translation.json | Adds Appearance strings for Konsta UI toggle. |
| apps/web/public/locales/es/translation.json | Adds Appearance strings for Konsta UI toggle. |
| apps/web/public/locales/fr/translation.json | Adds Appearance strings for Konsta UI toggle. |
| apps/web/public/locales/it/translation.json | Adds Appearance strings for Konsta UI toggle. |
| apps/web/public/locales/sv/translation.json | Adds Appearance strings for Konsta UI toggle. |
| const { checked, onCheckedChange, disabled, name } = props | ||
| return ( | ||
| <KCheckbox | ||
| checked={checked === true} | ||
| onChange={(e: React.ChangeEvent<HTMLInputElement>) => onCheckedChange?.(e.target.checked)} | ||
| disabled={disabled} | ||
| name={name} | ||
| className={className} | ||
| /> |
There was a problem hiding this comment.
In Konsta mode, the Checkbox wrapper only forwards checked, onChange, disabled, name, and className, so props like aria-label/aria-* and id are dropped. This is a concrete regression: RecommendationSelectionDialog passes an aria-label to Checkbox, which will be ignored under Konsta rendering.
Please forward aria-*/data-* (and other standard input props) through to the Konsta checkbox so existing accessibility annotations keep working.
| // Subscribe to both cross-tab (StorageEvent) and same-tab (custom event) changes | ||
| function subscribe(callback: () => void) { | ||
| const storageHandler = (e: StorageEvent) => { | ||
| if (e.key === STORAGE_KEYS.USE_KONSTA_UI) callback() | ||
| } | ||
| window.addEventListener('storage', storageHandler) | ||
| window.addEventListener(KONSTA_CHANGED, callback) | ||
| return () => { | ||
| window.removeEventListener('storage', storageHandler) | ||
| window.removeEventListener(KONSTA_CHANGED, callback) |
There was a problem hiding this comment.
useKonstaOverride() uses useSyncExternalStore(subscribe, ...), but subscribe() attaches window-level storage and custom event listeners for every hook instance. Since this hook is used inside very common leaf components (e.g. Button/Card/Input/etc.), a typical screen with many buttons will register many duplicate global listeners.
To avoid unnecessary listeners/memory overhead, consider implementing a shared subscription (single window listener that fan-outs to a Set of callbacks), or lifting the forced Konsta toggle state into a top-level context/provider so leaf components only subscribe to React state.
| // Subscribe to both cross-tab (StorageEvent) and same-tab (custom event) changes | |
| function subscribe(callback: () => void) { | |
| const storageHandler = (e: StorageEvent) => { | |
| if (e.key === STORAGE_KEYS.USE_KONSTA_UI) callback() | |
| } | |
| window.addEventListener('storage', storageHandler) | |
| window.addEventListener(KONSTA_CHANGED, callback) | |
| return () => { | |
| window.removeEventListener('storage', storageHandler) | |
| window.removeEventListener(KONSTA_CHANGED, callback) | |
| // Shared subscription state so we only attach a single pair of global listeners. | |
| const konstaSubscribers = new Set<() => void>() | |
| let konstaListening = false | |
| let konstaStorageHandler: ((e: StorageEvent) => void) | null = null | |
| let konstaCustomEventHandler: (() => void) | null = null | |
| function ensureKonstaListeners() { | |
| if (typeof window === 'undefined' || konstaListening) return | |
| konstaStorageHandler = (e: StorageEvent) => { | |
| if (e.key === STORAGE_KEYS.USE_KONSTA_UI) { | |
| konstaSubscribers.forEach((cb) => cb()) | |
| } | |
| } | |
| konstaCustomEventHandler = () => { | |
| konstaSubscribers.forEach((cb) => cb()) | |
| } | |
| window.addEventListener('storage', konstaStorageHandler) | |
| window.addEventListener(KONSTA_CHANGED, konstaCustomEventHandler) | |
| konstaListening = true | |
| } | |
| function teardownKonstaListenersIfIdle() { | |
| if (typeof window === 'undefined' || !konstaListening) return | |
| if (konstaSubscribers.size > 0) return | |
| if (konstaStorageHandler) { | |
| window.removeEventListener('storage', konstaStorageHandler) | |
| konstaStorageHandler = null | |
| } | |
| if (konstaCustomEventHandler) { | |
| window.removeEventListener(KONSTA_CHANGED, konstaCustomEventHandler) | |
| konstaCustomEventHandler = null | |
| } | |
| konstaListening = false | |
| } | |
| // Subscribe to both cross-tab (StorageEvent) and same-tab (custom event) changes | |
| function subscribe(callback: () => void) { | |
| konstaSubscribers.add(callback) | |
| ensureKonstaListeners() | |
| return () => { | |
| konstaSubscribers.delete(callback) | |
| teardownKonstaListenersIfIdle() |
| function Slider({ | ||
| className, | ||
| defaultValue, | ||
| value, | ||
| min = 0, | ||
| max = 100, | ||
| onValueChange, | ||
| step, | ||
| disabled, | ||
| ...props | ||
| }: ComponentProps<typeof SliderPrimitive.Root>) { | ||
| const useKonsta = useKonstaOverride() | ||
|
|
||
| if (!useKonsta) { | ||
| return <ShadcnSlider className={className} defaultValue={defaultValue} value={value} min={min} max={max} onValueChange={onValueChange} step={step} disabled={disabled} {...props} /> | ||
| } | ||
|
|
||
| const singleValue = Array.isArray(value) ? value[0] : (Array.isArray(defaultValue) ? defaultValue[0] : min) | ||
|
|
||
| return ( | ||
| <Range | ||
| value={singleValue} | ||
| min={min} | ||
| max={max} | ||
| step={step ?? 1} | ||
| disabled={disabled} | ||
| onChange={(e: React.ChangeEvent<HTMLInputElement>) => onValueChange?.([Number(e.target.value)])} | ||
| className={className} |
There was a problem hiding this comment.
In Konsta mode, this Slider wrapper only triggers onValueChange, but the app uses onValueCommit (e.g. ControlCenterExpanded brightness slider). When Konsta UI is active, onValueCommit is ignored so that slider won’t call any handler at all.
Consider mapping Konsta Range events to both onValueChange and onValueCommit (or emulating commit on pointer/touch end), and forwarding the remaining props needed by current consumers (e.g. aria-* like aria-label, id, etc.).
| const { checked, onCheckedChange, disabled } = props | ||
| return ( | ||
| <Toggle | ||
| checked={checked ?? false} | ||
| onChange={(e: React.ChangeEvent<HTMLInputElement>) => onCheckedChange?.(e.target.checked)} | ||
| disabled={disabled} | ||
| className={className} |
There was a problem hiding this comment.
When useKonstaOverride() is true, the Switch renders Konsta <Toggle> but only passes checked, onChange, disabled, and className. This drops important props like id and aria-*.
We currently rely on id for label association in SettingsView (e.g. Label htmlFor="theme-toggle" + <Switch id="theme-toggle" ... />), so in Konsta mode the label won’t be connected/clickable and accessibility regresses. Please forward id (and ideally name, value, aria-*, data-*, and defaultChecked/uncontrolled semantics) to the Konsta Toggle as well.
| const { checked, onCheckedChange, disabled } = props | |
| return ( | |
| <Toggle | |
| checked={checked ?? false} | |
| onChange={(e: React.ChangeEvent<HTMLInputElement>) => onCheckedChange?.(e.target.checked)} | |
| disabled={disabled} | |
| className={className} | |
| const { | |
| checked, | |
| defaultChecked, | |
| onCheckedChange, | |
| disabled, | |
| ...rest | |
| } = props | |
| const forwardedProps = Object.fromEntries( | |
| Object.entries(rest).filter(([key]) => | |
| key === "id" || | |
| key === "name" || | |
| key === "value" || | |
| key === "defaultValue" || | |
| key.startsWith("aria-") || | |
| key.startsWith("data-") | |
| ) | |
| ) as Record<string, unknown> | |
| return ( | |
| <Toggle | |
| {...forwardedProps} | |
| disabled={disabled} | |
| className={className} | |
| {...(checked !== undefined ? { checked } : {})} | |
| {...(defaultChecked !== undefined ? { defaultChecked } : {})} | |
| onChange={(e) => | |
| onCheckedChange?.( | |
| (e.target as HTMLInputElement).checked | |
| ) | |
| } |
- Checkbox: forward id, aria-*, data-* props to Konsta component - Switch: forward id, name, aria-*, data-*, defaultChecked to Konsta Toggle - Slider: map onInput→onValueChange, onChange→onValueCommit; forward aria-* - useKonstaOverride: shared subscriber set (single global listener pair) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Makes all 10 shadcn UI components Konsta-aware. When
useKonstaOverride()is true (mobile or settings toggle), components render their Konsta equivalents. Zero consumer file changes — all existing imports work unchanged.Depends on PR #333 (foundation).
Components Adapted
<Button><Card outline><Toggle><Range><Checkbox><Progressbar>Also Includes
server.deps.inline: ['konsta']for test compatibilityTesting
Part of #332