From 911f863b34eb322e7f5d27c89ef7c82717ebe168 Mon Sep 17 00:00:00 2001 From: Jesper Hessius Date: Fri, 27 Mar 2026 14:35:34 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(konsta):=20adaptive=20UI=20components?= =?UTF-8?q?=20=E2=80=94=20all=20shadcn=20components=20render=20Konsta=20on?= =?UTF-8?q?=20mobile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- apps/web/src/components/SettingsView.tsx | 15 ++++++++ apps/web/src/components/ui/button.tsx | 35 ++++++++++++++++++- apps/web/src/components/ui/card.tsx | 10 +++++- apps/web/src/components/ui/checkbox.tsx | 26 +++++++++++++- apps/web/src/components/ui/dialog.tsx | 7 +++- apps/web/src/components/ui/input.tsx | 25 +++++++++++++- apps/web/src/components/ui/progress.tsx | 30 ++++++++++------ apps/web/src/components/ui/select.tsx | 7 +++- apps/web/src/components/ui/slider.tsx | 34 +++++++++++++++++- apps/web/src/components/ui/switch.tsx | 25 +++++++++++++- apps/web/src/components/ui/tabs.tsx | 44 ++++++++---------------- apps/web/vitest.config.ts | 5 +++ 12 files changed, 216 insertions(+), 47 deletions(-) diff --git a/apps/web/src/components/SettingsView.tsx b/apps/web/src/components/SettingsView.tsx index 3fda19ed..217d4e70 100644 --- a/apps/web/src/components/SettingsView.tsx +++ b/apps/web/src/components/SettingsView.tsx @@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Alert, AlertDescription } from '@/components/ui/alert' import { Progress } from '@/components/ui/progress' import { hasFeature } from '@/lib/featureFlags' +import { useKonstaToggle } from '@/hooks/useKonstaOverride' import { CaretLeft, GithubLogo, @@ -105,6 +106,7 @@ const METICULOUS_ADDON_UPDATE_SNIPPET = 'docker exec -it meticai bash -lc "cd /a export function SettingsView({ onBack, showBlobs, onToggleBlobs, isDark, isFollowSystem, onToggleTheme, onSetFollowSystem, platformTheme, onSetPlatformTheme }: SettingsViewProps) { const { t } = useTranslation() + const { enabled: useKonstaUi, setEnabled: setUseKonstaUi } = useKonstaToggle() const [settings, setSettings] = useState({ geminiApiKey: '', @@ -1166,6 +1168,19 @@ export function SettingsView({ onBack, showBlobs, onToggleBlobs, isDark, isFollo )} + {/* Konsta UI toggle */} +
+
+ +

{t('appearance.useKonstaUiDescription')}

+
+ +
+ )} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index d831d42d..45d81951 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -1,6 +1,8 @@ import { ComponentProps } from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" +import { Button as KButton } from 'konsta/react' +import { useKonstaOverride } from '@/hooks/useKonstaOverride' import { cn } from "@/lib/utils" @@ -43,7 +45,7 @@ const buttonVariants = cva( } ) -function Button({ +function ShadcnButton({ className, variant, size, @@ -64,4 +66,35 @@ function Button({ ) } +function Button(props: ComponentProps<"button"> & VariantProps & { asChild?: boolean }) { + const useKonsta = useKonstaOverride() + const { variant, size, asChild = false, className, children, ...rest } = props + + if (!useKonsta || size === 'icon' || asChild) { + return + } + + const isOutline = variant === 'outline' + const isClear = variant === 'ghost' || variant === 'link' + const isTonal = variant === 'ember' || variant === 'secondary' + const isRounded = variant === 'liquid' || variant === 'dark-brew' || variant === 'frosted' || variant === 'ember' + const isSmall = size === 'sm' + const isLarge = size === 'lg' + + return ( + + {children} + + ) +} + export { Button, buttonVariants } diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx index e955b4be..db6a4827 100644 --- a/apps/web/src/components/ui/card.tsx +++ b/apps/web/src/components/ui/card.tsx @@ -1,8 +1,10 @@ import { ComponentProps } from "react" +import { Card as KCard } from 'konsta/react' +import { useKonstaOverride } from '@/hooks/useKonstaOverride' import { cn } from "@/lib/utils" -function Card({ className, ...props }: ComponentProps<"div">) { +function ShadcnCard({ className, ...props }: ComponentProps<"div">) { return (
) { ) } +function Card({ className, ...props }: ComponentProps<"div">) { + const useKonsta = useKonstaOverride() + if (!useKonsta) return + return +} + function CardHeader({ className, ...props }: ComponentProps<"div">) { return (
) { @@ -29,4 +31,26 @@ function Checkbox({ ) } +function Checkbox({ + className, + ...props +}: ComponentProps) { + const useKonsta = useKonstaOverride() + + if (!useKonsta) { + return + } + + const { checked, onCheckedChange, disabled, name } = props + return ( + ) => onCheckedChange?.(e.target.checked)} + disabled={disabled} + name={name} + className={className} + /> + ) +} + export { Checkbox } diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index e7eac9df..a45562da 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -1,6 +1,7 @@ import { ComponentProps } from "react" import * as DialogPrimitive from "@radix-ui/react-dialog" import XIcon from "lucide-react/dist/esm/icons/x" +import { useKonstaOverride } from '@/hooks/useKonstaOverride' import { cn } from "@/lib/utils" @@ -49,13 +50,17 @@ function DialogContent({ children, ...props }: ComponentProps) { + const useKonsta = useKonstaOverride() + return ( ) { +function ShadcnInput({ className, type, ...props }: ComponentProps<"input">) { return ( ) { ) } +function Input({ className, type, ...props }: ComponentProps<"input">) { + const useKonsta = useKonstaOverride() + + if (!useKonsta) { + return + } + + return ( + + ) +} + export { Input } diff --git a/apps/web/src/components/ui/progress.tsx b/apps/web/src/components/ui/progress.tsx index 737bb1be..df73344d 100644 --- a/apps/web/src/components/ui/progress.tsx +++ b/apps/web/src/components/ui/progress.tsx @@ -1,20 +1,14 @@ import { ComponentProps } from "react" import * as ProgressPrimitive from "@radix-ui/react-progress" - +import { Progressbar } from 'konsta/react' +import { useKonstaOverride } from '@/hooks/useKonstaOverride' import { cn } from "@/lib/utils" -function Progress({ - className, - value, - ...props -}: ComponentProps) { +function ShadcnProgress({ className, value, ...props }: ComponentProps) { return ( ) { + const useKonsta = useKonstaOverride() + + if (!useKonsta) { + return + } + + // Konsta Progressbar expects 0-1, shadcn Progress uses 0-100 + return ( + + ) +} + export { Progress } diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 517728c2..d90ca72f 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -4,6 +4,7 @@ import CheckIcon from "lucide-react/dist/esm/icons/check" import ChevronDownIcon from "lucide-react/dist/esm/icons/chevron-down" import ChevronUpIcon from "lucide-react/dist/esm/icons/chevron-up" +import { useKonstaOverride } from '@/hooks/useKonstaOverride' import { cn } from "@/lib/utils" function Select({ @@ -33,12 +34,16 @@ function SelectTrigger({ }: ComponentProps & { size?: "sm" | "default" }) { + const useKonsta = useKonstaOverride() + return ( ) { + const useKonsta = useKonstaOverride() + + if (!useKonsta) { + return + } + + const singleValue = Array.isArray(value) ? value[0] : (Array.isArray(defaultValue) ? defaultValue[0] : min) + const { onValueChange, step, disabled } = props as any + + return ( + onValueChange?.([Number(e.target.value)])} + className={className} + /> + ) +} + export { Slider } diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx index 3367d083..5d8a9daf 100644 --- a/apps/web/src/components/ui/switch.tsx +++ b/apps/web/src/components/ui/switch.tsx @@ -2,10 +2,12 @@ import { ComponentProps } from "react" import * as SwitchPrimitive from "@radix-ui/react-switch" +import { Toggle } from 'konsta/react' +import { useKonstaOverride } from '@/hooks/useKonstaOverride' import { cn } from "@/lib/utils" -function Switch({ +function ShadcnSwitch({ className, ...props }: ComponentProps) { @@ -28,4 +30,25 @@ function Switch({ ) } +function Switch({ + className, + ...props +}: ComponentProps) { + const useKonsta = useKonstaOverride() + + if (!useKonsta) { + return + } + + const { checked, onCheckedChange, disabled } = props + return ( + ) => onCheckedChange?.(e.target.checked)} + disabled={disabled} + className={className} + /> + ) +} + export { Switch } diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx index d1a5b7a3..ebc6fec6 100644 --- a/apps/web/src/components/ui/tabs.tsx +++ b/apps/web/src/components/ui/tabs.tsx @@ -2,31 +2,24 @@ import { ComponentProps } from "react" import * as TabsPrimitive from "@radix-ui/react-tabs" - +import { useKonstaOverride } from '@/hooks/useKonstaOverride' import { cn } from "@/lib/utils" -function Tabs({ - className, - ...props -}: ComponentProps) { +function Tabs({ className, ...props }: ComponentProps) { return ( - + ) } -function TabsList({ - className, - ...props -}: ComponentProps) { +function TabsList({ className, ...props }: ComponentProps) { + const useKonsta = useKonstaOverride() return ( ) { +function TabsTrigger({ className, ...props }: ComponentProps) { + const useKonsta = useKonstaOverride() return ( ) { +function TabsContent({ className, ...props }: ComponentProps) { return ( - + ) } diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index c7152848..1f305e09 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -12,6 +12,11 @@ export default defineConfig({ setupFiles: './src/test/setup.ts', css: true, exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**', '**/.{idea,git,cache,output,temp}/**'], + server: { + deps: { + inline: ['konsta'], + }, + }, coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], From 0866aa37adfcb0a3ca91a42fd3f7c8c0f88a2ac6 Mon Sep 17 00:00:00 2001 From: Jesper Hessius Date: Fri, 27 Mar 2026 14:53:52 +0100 Subject: [PATCH 2/3] fix: address code review findings for Konsta UI integration - 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> --- apps/web/src/components/ui/card.tsx | 3 ++- apps/web/src/components/ui/dialog.tsx | 3 +++ apps/web/src/components/ui/progress.tsx | 9 +++++++++ apps/web/src/components/ui/slider.tsx | 8 +++++--- apps/web/src/hooks/useKonstaOverride.ts | 15 +++++++++++---- 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx index db6a4827..82de8d7c 100644 --- a/apps/web/src/components/ui/card.tsx +++ b/apps/web/src/components/ui/card.tsx @@ -20,7 +20,8 @@ function ShadcnCard({ className, ...props }: ComponentProps<"div">) { function Card({ className, ...props }: ComponentProps<"div">) { const useKonsta = useKonstaOverride() if (!useKonsta) return - return + // contentWrap={false} because sub-components (CardHeader, CardContent, etc.) handle their own spacing + return } function CardHeader({ className, ...props }: ComponentProps<"div">) { diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index a45562da..fe8a7d92 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -45,6 +45,9 @@ function DialogOverlay({ ) } +// CSS-only adaptation: Konsta Dialog uses a fundamentally different API (imperative +// title/content/buttons props) that doesn't map to Radix's declarative portal pattern. +// Instead, we enhance Radix DialogContent with mobile-friendly styling when Konsta is active. function DialogContent({ className, children, diff --git a/apps/web/src/components/ui/progress.tsx b/apps/web/src/components/ui/progress.tsx index df73344d..b7edc376 100644 --- a/apps/web/src/components/ui/progress.tsx +++ b/apps/web/src/components/ui/progress.tsx @@ -28,10 +28,19 @@ function Progress({ className, value, ...props }: ComponentProps + const forwardProps: Record = {} + if (id) forwardProps.id = id + for (const [key, val] of Object.entries(rest)) { + if (key.startsWith('aria-') || key.startsWith('data-')) forwardProps[key] = val + } + return ( ) } diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx index 388dab9b..35cdd123 100644 --- a/apps/web/src/components/ui/slider.tsx +++ b/apps/web/src/components/ui/slider.tsx @@ -68,16 +68,18 @@ function Slider({ value, min = 0, max = 100, + onValueChange, + step, + disabled, ...props }: ComponentProps) { const useKonsta = useKonstaOverride() if (!useKonsta) { - return + return } const singleValue = Array.isArray(value) ? value[0] : (Array.isArray(defaultValue) ? defaultValue[0] : min) - const { onValueChange, step, disabled } = props as any return ( onValueChange?.([Number(e.target.value)])} + onChange={(e: React.ChangeEvent) => onValueChange?.([Number(e.target.value)])} className={className} /> ) diff --git a/apps/web/src/hooks/useKonstaOverride.ts b/apps/web/src/hooks/useKonstaOverride.ts index a339a588..a7aca3b9 100644 --- a/apps/web/src/hooks/useKonstaOverride.ts +++ b/apps/web/src/hooks/useKonstaOverride.ts @@ -2,6 +2,8 @@ import { useState, useCallback, useSyncExternalStore } from 'react' import { useIsMobile } from '@/hooks/use-mobile' import { STORAGE_KEYS } from '@/lib/constants' +const KONSTA_CHANGED = 'konsta-ui-changed' + function getStoredValue(): boolean { try { return localStorage.getItem(STORAGE_KEYS.USE_KONSTA_UI) === 'true' @@ -10,13 +12,17 @@ function getStoredValue(): boolean { } } -// Subscribe to storage events so multiple tabs stay in sync +// Subscribe to both cross-tab (StorageEvent) and same-tab (custom event) changes function subscribe(callback: () => void) { - const handler = (e: StorageEvent) => { + const storageHandler = (e: StorageEvent) => { if (e.key === STORAGE_KEYS.USE_KONSTA_UI) callback() } - window.addEventListener('storage', handler) - return () => window.removeEventListener('storage', handler) + window.addEventListener('storage', storageHandler) + window.addEventListener(KONSTA_CHANGED, callback) + return () => { + window.removeEventListener('storage', storageHandler) + window.removeEventListener(KONSTA_CHANGED, callback) + } } /** @@ -39,6 +45,7 @@ export function useKonstaToggle() { setEnabledState(value) try { localStorage.setItem(STORAGE_KEYS.USE_KONSTA_UI, String(value)) + window.dispatchEvent(new Event(KONSTA_CHANGED)) } catch { /* noop */ } }, []) From 82ee81522c7d623a6030148c63bfad16347775f3 Mon Sep 17 00:00:00 2001 From: Jesper Hessius Date: Fri, 27 Mar 2026 20:55:11 +0100 Subject: [PATCH 3/3] fix: address code review findings for konsta adaptive components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- apps/web/src/components/ui/checkbox.tsx | 11 +++++++++- apps/web/src/components/ui/slider.tsx | 17 ++++++++++++--- apps/web/src/components/ui/switch.tsx | 13 +++++++++++- apps/web/src/hooks/useKonstaOverride.ts | 28 ++++++++++++++++--------- 4 files changed, 54 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx index ec6f744d..2280320c 100644 --- a/apps/web/src/components/ui/checkbox.tsx +++ b/apps/web/src/components/ui/checkbox.tsx @@ -41,7 +41,15 @@ function Checkbox({ return } - const { checked, onCheckedChange, disabled, name } = props + const { checked, onCheckedChange, disabled, name, ...rest } = props + // Forward id, aria-*, data-* attributes for accessibility + const forwardedProps: Record = {} + for (const [key, val] of Object.entries(rest)) { + if (key === 'id' || key === 'value' || key.startsWith('aria-') || key.startsWith('data-')) { + forwardedProps[key] = val + } + } + return ( ) } diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx index 35cdd123..169c904b 100644 --- a/apps/web/src/components/ui/slider.tsx +++ b/apps/web/src/components/ui/slider.tsx @@ -69,18 +69,27 @@ function Slider({ min = 0, max = 100, onValueChange, + onValueCommit, step, disabled, - ...props + ...rest }: ComponentProps) { const useKonsta = useKonstaOverride() if (!useKonsta) { - return + return } const singleValue = Array.isArray(value) ? value[0] : (Array.isArray(defaultValue) ? defaultValue[0] : min) + // Forward id, aria-*, data-* attributes + const forwardedProps: Record = {} + for (const [key, val] of Object.entries(rest)) { + if (key === 'id' || key.startsWith('aria-') || key.startsWith('data-')) { + forwardedProps[key] = val + } + } + return ( ) => onValueChange?.([Number(e.target.value)])} + onInput={(e: React.ChangeEvent) => onValueChange?.([Number(e.target.value)])} + onChange={(e: React.ChangeEvent) => onValueCommit?.([Number(e.target.value)])} className={className} + {...forwardedProps} /> ) } diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx index 5d8a9daf..1819b4be 100644 --- a/apps/web/src/components/ui/switch.tsx +++ b/apps/web/src/components/ui/switch.tsx @@ -40,13 +40,24 @@ function Switch({ return } - const { checked, onCheckedChange, disabled } = props + const { checked, defaultChecked, onCheckedChange, disabled, ...rest } = props + + // Forward id, name, aria-*, data-* for accessibility and label association + const forwardedProps: Record = {} + for (const [key, val] of Object.entries(rest)) { + if (key === 'id' || key === 'name' || key === 'value' || key.startsWith('aria-') || key.startsWith('data-')) { + forwardedProps[key] = val + } + } + return ( ) => onCheckedChange?.(e.target.checked)} disabled={disabled} className={className} + {...(defaultChecked !== undefined ? { defaultChecked } : {})} + {...forwardedProps} /> ) } diff --git a/apps/web/src/hooks/useKonstaOverride.ts b/apps/web/src/hooks/useKonstaOverride.ts index a7aca3b9..8cb38f5e 100644 --- a/apps/web/src/hooks/useKonstaOverride.ts +++ b/apps/web/src/hooks/useKonstaOverride.ts @@ -12,17 +12,25 @@ function getStoredValue(): boolean { } } -// Subscribe to both cross-tab (StorageEvent) and same-tab (custom event) changes +// Shared subscription: single pair of global listeners fans out to all subscribers +const subscribers = new Set<() => void>() +let listening = false + +function ensureListeners() { + if (typeof window === 'undefined' || listening) return + window.addEventListener('storage', (e: StorageEvent) => { + if (e.key === STORAGE_KEYS.USE_KONSTA_UI) subscribers.forEach(cb => cb()) + }) + window.addEventListener(KONSTA_CHANGED, () => { + subscribers.forEach(cb => cb()) + }) + listening = true +} + 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) - } + subscribers.add(callback) + ensureListeners() + return () => { subscribers.delete(callback) } } /**