Skip to content

feat(konsta): adaptive UI components — Konsta rendering on mobile#334

Open
hessius wants to merge 4 commits intoversion/2.4.0from
feat/konsta-adaptive
Open

feat(konsta): adaptive UI components — Konsta rendering on mobile#334
hessius wants to merge 4 commits intoversion/2.4.0from
feat/konsta-adaptive

Conversation

@hessius
Copy link
Copy Markdown
Owner

@hessius hessius commented Mar 27, 2026

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

Component Konsta Equivalent Adaptation
Button <Button> variant→outline/clear/tonal, size→small/large
Card <Card outline> Sub-components unchanged
Switch <Toggle> onCheckedChange→onChange event
Dialog Enhanced styling rounded-2xl, no border, shadow-xl
Input Styled native Taller, transparent bg, no shadow
Select Styled trigger Mobile-optimized SelectTrigger
Slider <Range> Array→single value, onValueChange→onChange
Checkbox <Checkbox> onCheckedChange→onChange event
Tabs Styled triggers Full-width, touch-first
Progress <Progressbar> 0-100→0-1 scale

Also Includes

  • Settings toggle: 'Use Konsta UI' in Appearance section
  • Vitest config: server.deps.inline: ['konsta'] for test compatibility

Testing

  • ✅ 416 frontend tests pass
  • ✅ Build clean (22s)
  • ✅ Lint: 0 errors

Part of #332

hessius and others added 2 commits March 27, 2026 14:08
…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>
@hessius hessius added this to the 2.4 milestone Mar 27, 2026
- 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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +44 to +52
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}
/>
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +15 to +24
// 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)
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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()

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +92
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}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.).

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +49
const { checked, onCheckedChange, disabled } = props
return (
<Toggle
checked={checked ?? false}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onCheckedChange?.(e.target.checked)}
disabled={disabled}
className={className}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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
)
}

Copilot uses AI. Check for mistakes.
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants