diff --git a/formulus-formplayer/README.md b/formulus-formplayer/README.md index b81286aa9..b6a4e92f2 100644 --- a/formulus-formplayer/README.md +++ b/formulus-formplayer/README.md @@ -15,6 +15,14 @@ The formplayer is solely responsible for - submitting the forms to Formulus (either as draft or final) - (soft-)deleting observations +## Development setup +This project depends on `@ode/tokens` (local `packages/tokens`). On a fresh clone or new branch, install in order: + +1. From repo root: `cd packages/tokens && npm install` +2. Then: `cd formulus-formplayer && npm install && npm start` + +If you run `npm install` only in formulus-formplayer, the tokens package’s `prepare` script may fail with "Cannot find module 'style-dictionary'" until tokens has its own dependencies installed. + ## Building this project Use 'npm run build:rn' to build the project. This will build the project and copy the build to the formulus app. diff --git a/formulus-formplayer/package-lock.json b/formulus-formplayer/package-lock.json index a0f3b0e9a..d54e5b886 100644 --- a/formulus-formplayer/package-lock.json +++ b/formulus-formplayer/package-lock.json @@ -17,6 +17,7 @@ "@mui/icons-material": "^7.3.7", "@mui/material": "^7.3.7", "@mui/x-date-pickers": "^8.26.0", + "@ode/components": "file:../packages/components", "@ode/tokens": "file:../packages/tokens", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -45,10 +46,34 @@ "eslint-plugin-react-hooks": "^7.0.1", "globals": "^17.1.0", "prettier": "3.8.1", + "react-refresh": "^0.17.0", "typescript-eslint": "^8.54.0", "vite": "^6.4.1" } }, + "../packages/components": { + "name": "@ode/components", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.0", + "@ode/tokens": "file:../tokens", + "react-native-svg": "^15.15.3" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.57.0", + "prettier": "^3.0.0", + "typescript": "^5.0.4" + }, + "peerDependencies": { + "@ode/tokens": "file:../tokens", + "react": ">=18.0.0", + "react-native": ">=0.70.0" + } + }, "../packages/tokens": { "name": "@ode/tokens", "version": "1.0.0", @@ -1746,6 +1771,10 @@ "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@ode/components": { + "resolved": "../packages/components", + "link": true + }, "node_modules/@ode/tokens": { "resolved": "../packages/tokens", "link": true diff --git a/formulus-formplayer/package.json b/formulus-formplayer/package.json index 060ef620e..4c53b7498 100644 --- a/formulus-formplayer/package.json +++ b/formulus-formplayer/package.json @@ -14,6 +14,7 @@ "@mui/icons-material": "^7.3.7", "@mui/material": "^7.3.7", "@mui/x-date-pickers": "^8.26.0", + "@ode/components": "file:../packages/components", "@ode/tokens": "file:../packages/tokens", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -57,6 +58,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "globals": "^17.1.0", "prettier": "3.8.1", + "react-refresh": "^0.17.0", "typescript-eslint": "^8.54.0", "vite": "^6.4.1" }, diff --git a/formulus-formplayer/src/App.css b/formulus-formplayer/src/App.css index 00e93b8b6..b956c98a5 100644 --- a/formulus-formplayer/src/App.css +++ b/formulus-formplayer/src/App.css @@ -36,3 +36,27 @@ transform: rotate(360deg); } } + +/* Reverse-style button */ +.button-reverse-primary { + background-color: var(--ode-color-brand-primary-500, #4f7f4e) !important; + color: var(--ode-color-neutral-white, #ffffff) !important; + border-color: var(--ode-color-brand-primary-500, #4f7f4e) !important; +} + +.button-reverse-primary:hover { + background-color: transparent !important; + color: var(--ode-color-brand-primary-500, #4f7f4e) !important; + border-color: var(--ode-color-brand-primary-500, #4f7f4e) !important; +} + +/* Hide native number input spinners - replaced with custom NumberStepperRenderer */ +input[type='number']::-webkit-inner-spin-button, +input[type='number']::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type='number'] { + -moz-appearance: textfield; +} diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index e6e842761..3cb50a9df 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -24,6 +24,7 @@ import { ThemeProvider, } from '@mui/material'; import { createTheme, getThemeOptions } from './theme/theme'; +import { tokens } from './theme/tokens-adapter'; import Ajv from 'ajv'; import addErrors from 'ajv-errors'; import addFormats from 'ajv-formats'; @@ -65,6 +66,7 @@ import AdateQuestionRenderer, { adateQuestionTester, } from './renderers/AdateQuestionRenderer'; import { shellMaterialRenderers } from './theme/material-wrappers'; +import { numberStepperRenderer } from './renderers/NumberStepperRenderer'; import DynamicEnumControl, { dynamicEnumTester } from './DynamicEnumControl'; import ErrorBoundary from './components/ErrorBoundary'; @@ -223,6 +225,8 @@ export const customRenderers = [ { tester: adateQuestionTester, renderer: AdateQuestionRenderer }, // Dynamic choice list renderer for x-dynamicEnum fields { tester: dynamicEnumTester, renderer: DynamicEnumControl }, + // Number/integer fields with simple +/- buttons via InputAdornment + numberStepperRenderer, ]; function App() { @@ -269,8 +273,7 @@ function App() { // Store extension functions for potential future use (e.g., validation context injection) const [extensionFunctions, setExtensionFunctions] = useState< - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - Map + Map any> >(new Map()); const [extensionDefinitions, setExtensionDefinitions] = useState< Record @@ -333,7 +336,7 @@ function App() { // Merge loaded functions with built-ins (loaded functions take precedence) extensionResult.functions.forEach((func, name) => { - allFunctions.set(name, func); + allFunctions.set(name, func as (...args: any[]) => any); }); setExtensionRenderers(extensionResult.renderers); @@ -799,6 +802,25 @@ function App() { return createTheme(getThemeOptions(darkMode ? 'dark' : 'light')); }, [darkMode]); + // Set CSS custom properties from tokens for use in CSS files + // Must be called before any early returns to follow React Hooks rules + useEffect(() => { + const root = document.documentElement; + root.style.setProperty( + '--ode-color-brand-primary-500', + tokens.color.brand.primary[500], + ); + root.style.setProperty( + '--ode-color-neutral-white', + tokens.color.neutral.white, + ); + root.style.setProperty( + '--ode-color-neutral-200', + tokens.color.neutral[200], + ); + root.style.setProperty('--ode-color-neutral-50', tokens.color.neutral[50]); + }, []); + // Show draft selector if we have pending form init and available drafts if (showDraftSelector && pendingFormInit) { return ( @@ -913,7 +935,7 @@ function App() { style={{ width: process.env.NODE_ENV === 'development' ? '60%' : '100%', overflow: 'hidden', // Prevent outer scrolling - FormLayout handles scrolling internally - padding: '4px', + padding: tokens.spacing[1], boxSizing: 'border-box', height: '100%', // Ensure it takes full height backgroundColor: 'transparent', // Use theme background @@ -922,11 +944,11 @@ function App() { {loadError ? ( @@ -987,8 +1009,8 @@ function App() {
diff --git a/formulus-formplayer/src/builtinExtensions.ts b/formulus-formplayer/src/builtinExtensions.ts index bfbb0ce20..3567c0bd9 100644 --- a/formulus-formplayer/src/builtinExtensions.ts +++ b/formulus-formplayer/src/builtinExtensions.ts @@ -411,10 +411,8 @@ export async function getDynamicChoiceList( * Get all built-in extension functions as a Map * @returns Map of function name to function */ -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -export function getBuiltinExtensions(): Map { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - const functions = new Map(); +export function getBuiltinExtensions(): Map any> { + const functions = new Map any>(); functions.set('getDynamicChoiceList', getDynamicChoiceList); return functions; } diff --git a/formulus-formplayer/src/components/DraftSelector.tsx b/formulus-formplayer/src/components/DraftSelector.tsx index 27f375c06..5e78f93c9 100644 --- a/formulus-formplayer/src/components/DraftSelector.tsx +++ b/formulus-formplayer/src/components/DraftSelector.tsx @@ -12,7 +12,6 @@ import { Card, CardContent, CardActions, - Button, IconButton, Dialog, DialogTitle, @@ -23,9 +22,9 @@ import { Grid, Divider, } from '@mui/material'; +import { Button } from '@ode/components/react-web'; import { Delete as DeleteIcon, - PlayArrow as ResumeIcon, Schedule as ClockIcon, Description as FormIcon, } from '@mui/icons-material'; @@ -220,10 +219,9 @@ export const DraftSelector: React.FC = ({ @@ -251,10 +249,10 @@ export const DraftSelector: React.FC = ({ Begin a new form without any saved data. @@ -271,11 +269,10 @@ export const DraftSelector: React.FC = ({ - - + @@ -303,7 +300,11 @@ export const DraftSelector: React.FC = ({ alignItems: 'center', }}> Select Draft - {onClose && } + {onClose && ( + + )} {content} diff --git a/formulus-formplayer/src/components/ErrorBoundary.tsx b/formulus-formplayer/src/components/ErrorBoundary.tsx index 22ae189ea..c31357af8 100644 --- a/formulus-formplayer/src/components/ErrorBoundary.tsx +++ b/formulus-formplayer/src/components/ErrorBoundary.tsx @@ -1,4 +1,5 @@ import React, { Component, ReactNode } from 'react'; +import { tokens } from '../theme/tokens-adapter'; interface Props { children: ReactNode; @@ -41,26 +42,26 @@ class ErrorBoundary extends Component { return (

🚨 Something went wrong

-
+
Error Details (click to expand)
               {this.state.error?.toString()}
@@ -70,12 +71,12 @@ class ErrorBoundary extends Component {
           
               )}
               {nextButton && (
                 
               )}
diff --git a/formulus-formplayer/src/components/FormProgressBar.tsx b/formulus-formplayer/src/components/FormProgressBar.tsx
index d7076cc36..4c2188335 100644
--- a/formulus-formplayer/src/components/FormProgressBar.tsx
+++ b/formulus-formplayer/src/components/FormProgressBar.tsx
@@ -1,5 +1,6 @@
 import React, { useMemo } from 'react';
 import { Box, LinearProgress, Typography } from '@mui/material';
+import { tokens } from '../theme/tokens-adapter';
 
 type JsonSchema = {
   type?: string | string[];
@@ -188,7 +189,7 @@ const FormProgressBar: React.FC = ({
             flexGrow: 1,
             height: 8,
             borderRadius: 4,
-            backgroundColor: 'rgba(0, 0, 0, 0.1)',
+            backgroundColor: `rgba(0, 0, 0, ${(tokens as any).opacity?.['10'] ?? 0.1})`,
             '& .MuiLinearProgress-bar': {
               borderRadius: 4,
               transition: 'transform 0.4s ease-in-out',
@@ -198,7 +199,7 @@ const FormProgressBar: React.FC = ({
         [];
+    variant?: ButtonVariant;
+    className?: string;
+    style?: CSSProperties;
+  }
+
+  // Re-export shared types
+  export type {
+    ButtonProps,
+    ButtonVariant,
+    ButtonSize,
+    InputProps,
+    CardProps,
+    BadgeProps,
+  };
+
+  // Export components with proper types
+  export const Button: FC;
+  export const ButtonGroup: FC;
+  export const Input: FC;
+  export const Card: FC;
+  export const Badge: FC;
+}
diff --git a/formulus-formplayer/src/renderers/AdateQuestionRenderer.tsx b/formulus-formplayer/src/renderers/AdateQuestionRenderer.tsx
index bad15c0f6..354bc2a5b 100644
--- a/formulus-formplayer/src/renderers/AdateQuestionRenderer.tsx
+++ b/formulus-formplayer/src/renderers/AdateQuestionRenderer.tsx
@@ -10,6 +10,13 @@ import {
 import { TextField, Box, Typography, Alert, Button } from '@mui/material';
 import { CalendarToday } from '@mui/icons-material';
 import QuestionShell from '../components/QuestionShell';
+import { tokens } from '../theme/tokens-adapter';
+
+// Helper to parse pixel values from tokens
+const parsePx = (value: string): number => {
+  return parseInt(value.replace('px', ''), 10);
+};
+
 import {
   adateToStorageFormat,
   storageFormatToAdate,
@@ -339,7 +346,13 @@ const AdateQuestionRenderer: React.FC = ({
 
         {/* Display current value */}
         {displayValue && displayValue !== 'n/a' && (
-          
+          
             
               Current value: {displayValue}
             
diff --git a/formulus-formplayer/src/renderers/AudioQuestionRenderer.tsx b/formulus-formplayer/src/renderers/AudioQuestionRenderer.tsx
index c133562d8..22606b25c 100644
--- a/formulus-formplayer/src/renderers/AudioQuestionRenderer.tsx
+++ b/formulus-formplayer/src/renderers/AudioQuestionRenderer.tsx
@@ -20,6 +20,12 @@ import {
 import FormulusClient from '../services/FormulusInterface';
 import { AudioResult } from '../types/FormulusInterfaceDefinition';
 import QuestionShell from '../components/QuestionShell';
+import { tokens } from '../theme/tokens-adapter';
+
+// Helper to parse pixel values from tokens
+const parsePx = (value: string): number => {
+  return parseInt(value.replace('px', ''), 10);
+};
 
 interface AudioQuestionRendererProps extends ControlProps {
   data: any;
@@ -229,7 +235,7 @@ const AudioQuestionRenderer: React.FC = ({
         variant="outlined"
         sx={{
           p: 3,
-          borderRadius: 2,
+          borderRadius: `${parsePx(tokens.border.radius.md)}px`, // Match button border radius
           backgroundColor: hasAudio ? 'background.paper' : 'grey.50',
         }}>
         {!hasAudio ? (
@@ -386,7 +392,7 @@ const AudioQuestionRenderer: React.FC = ({
                   mt: 2,
                   p: 1,
                   backgroundColor: 'grey.100',
-                  borderRadius: 1,
+                  borderRadius: `${parsePx(tokens.border.radius.md)}px`,
                 }}>
                 
                   Dev Info: {audioData.uri}
diff --git a/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx b/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx
index b8b004b9b..667db6d80 100644
--- a/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx
+++ b/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx
@@ -23,6 +23,13 @@ import {
   and,
   schemaMatches,
 } from '@jsonforms/core';
+import { tokens } from '../theme/tokens-adapter';
+
+// Helper to parse pixel values from tokens
+const parsePx = (value: string): number => {
+  return parseInt(value.replace('px', ''), 10);
+};
+
 import FormulusClient from '../services/FormulusInterface';
 import { FileResult } from '../types/FormulusInterfaceDefinition';
 import QuestionShell from '../components/QuestionShell';
@@ -89,21 +96,21 @@ const FileQuestionRenderer: React.FC = ({
     setError(null);
   }, [handleChange, path]);
 
-  // Get file icon based on MIME type
+  // Get file icon based on MIME type - using tokens for colors
   const getFileIcon = (mimeType: string) => {
     if (mimeType.startsWith('image/')) {
-      return ;
+      return ;
     } else if (mimeType === 'application/pdf') {
-      return ;
+      return ;
     } else if (
       mimeType.startsWith('text/') ||
       mimeType.includes('document') ||
       mimeType.includes('spreadsheet') ||
       mimeType.includes('presentation')
     ) {
-      return ;
+      return ;
     } else {
-      return ;
+      return ;
     }
   };
 
@@ -161,7 +168,13 @@ const FileQuestionRenderer: React.FC = ({
       helperText="Attach a file. Images, PDFs, and documents are supported."
       metadata={
         process.env.NODE_ENV === 'development' ? (
-          
+          
             
               Debug: fieldId="{fieldId}", path="{path}", format="select_file"
             
diff --git a/formulus-formplayer/src/renderers/FinalizeRenderer.tsx b/formulus-formplayer/src/renderers/FinalizeRenderer.tsx
index aacd992ac..594b8c84e 100644
--- a/formulus-formplayer/src/renderers/FinalizeRenderer.tsx
+++ b/formulus-formplayer/src/renderers/FinalizeRenderer.tsx
@@ -1,14 +1,7 @@
 import React, { useMemo } from 'react';
-import {
-  Box,
-  Button,
-  List,
-  ListItem,
-  Typography,
-  Paper,
-  Divider,
-  Link,
-} from '@mui/material';
+import { Box, List, ListItem, Typography, IconButton } from '@mui/material';
+import { tokens } from '../theme/tokens-adapter';
+import { Button } from '@ode/components/react-web';
 import { JsonFormsRendererRegistryEntry } from '@jsonforms/core';
 import { withJsonFormsControlProps, useJsonForms } from '@jsonforms/react';
 import { ControlProps } from '@jsonforms/core';
@@ -302,55 +295,45 @@ const FinalizeRenderer = ({ data }: ControlProps) => {
       sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
       {hasErrors ? (
         <>
-          
+          
             Please fix the following errors before finalizing:
           
-          
-            
-              {errors.map((error: ErrorObject, index: number) => (
-                
-              ))}
-            
-          
+          
+            {errors.map((error: ErrorObject, index: number) => (
+              
+            ))}
+          
         
       ) : (
-        
+        
           All validations passed! You can now finalize your submission.
         
       )}
@@ -364,44 +347,66 @@ const FinalizeRenderer = ({ data }: ControlProps) => {
             display: 'flex',
             flexDirection: 'column',
             mb: 3,
+            backgroundColor: 'transparent',
           }}>
-          
+          
             FORM SUMMARY
           
           
+            sx={{ mb: 2, textAlign: 'center' }}>
             Review all your entered data below. Click on any field to edit it.
           
-          
             
               {summaryItems.map((item, index) => (
                 
                    {
+                      const lineColor =
+                        theme.palette.mode === 'dark'
+                          ? theme.palette.divider
+                          : ((
+                              theme.palette.grey as unknown as Record<
+                                number,
+                                string
+                              >
+                            )?.[300] ?? theme.palette.divider);
+                      return {
+                        flexDirection: 'column',
+                        alignItems: 'stretch',
+                        py: 1.5,
+                        px: 2,
+                        backgroundColor: 'transparent',
+                        borderLeft: `${(tokens as any).border?.width?.thin ?? '1px'} solid ${lineColor}`,
+                        '&:hover': {
+                          backgroundColor: 'action.hover',
+                          borderRadius: 0,
+                        },
+                      };
                     }}>
                      {
                         
                       
                       {item.pageIndex >= 0 && (
-                         handleFieldEdit(item)}
+                          size="small"
                           sx={{
-                            display: 'flex',
-                            alignItems: 'center',
-                            gap: 0.5,
-                            cursor: 'pointer',
-                            textDecoration: 'none',
-                            color: 'primary.main',
+                            padding: (tokens as any).spacing?.[1] ?? '4px',
+                            color: 'text.secondary',
                             '&:hover': {
-                              textDecoration: 'underline',
+                              color: 'primary.main',
+                              backgroundColor: 'transparent',
                             },
                             flexShrink: 0,
-                          }}>
-                          
-                          Edit
-                        
+                          }}
+                          aria-label="Edit field">
+                          
+                        
                       )}
                     
                   
                   {index < summaryItems.length - 1 && (
-                    
+                     {
+                        const lineColor =
+                          theme.palette.mode === 'dark'
+                            ? theme.palette.divider
+                            : ((
+                                theme.palette.grey as unknown as Record<
+                                  number,
+                                  string
+                                >
+                              )?.[300] ?? theme.palette.divider);
+                        return {
+                          height: (tokens as any).border?.width?.thin ?? '1px',
+                          width: '100%',
+                          background: `linear-gradient(to right, ${lineColor}, ${lineColor} 75%, transparent 100%)`,
+                        };
+                      }}
+                    />
                   )}
                 
               ))}
             
-          
+          
         
       )}
 
       
         
       
diff --git a/formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx b/formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx
index 64a27acf0..c2829f9e2 100644
--- a/formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx
+++ b/formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx
@@ -19,6 +19,12 @@ import {
   MyLocation as MyLocationIcon,
 } from '@mui/icons-material';
 import QuestionShell from '../components/QuestionShell';
+import { tokens } from '../theme/tokens-adapter';
+
+// Helper to parse pixel values from tokens
+const parsePx = (value: string): number => {
+  return parseInt(value.replace('px', ''), 10);
+};
 // GPS is now captured automatically by the native app for all forms.
 // This renderer is kept only for backward-compatibility with existing
 // form schemas that still reference the "gps" format. It no longer
@@ -118,7 +124,13 @@ const GPSQuestionRenderer: React.FC = props => {
       helperText="GPS is captured automatically; use this only if the form requires manual capture."
       metadata={
         process.env.NODE_ENV === 'development' ? (
-          
+          
             
               Debug - Path: {path} | Data: {JSON.stringify(data)}
             
diff --git a/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx b/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx
new file mode 100644
index 000000000..e9bc9443e
--- /dev/null
+++ b/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx
@@ -0,0 +1,161 @@
+/**
+ * NumberStepperRenderer
+ *
+ * Custom renderer for number/integer fields that adds simple +/- buttons
+ * via Material-UI's InputAdornment. Preserves all default Material-UI behavior.
+ */
+
+import React, { useState } from 'react';
+import {
+  ControlProps,
+  RankedTester,
+  rankWith,
+  schemaMatches,
+  JsonFormsRendererRegistryEntry,
+} from '@jsonforms/core';
+import { withJsonFormsControlProps } from '@jsonforms/react';
+import { TextField, InputAdornment, IconButton } from '@mui/material';
+import { Add, Remove } from '@mui/icons-material';
+
+const isNumberControl: RankedTester = rankWith(
+  5, // Higher priority to override default Material-UI number renderer (rank 2)
+  schemaMatches(schema => {
+    const type = schema.type;
+    return type === 'number' || type === 'integer';
+  }),
+);
+
+const NumberStepperRenderer = ({
+  data,
+  handleChange,
+  path,
+  schema,
+  uischema: _uischema,
+  errors,
+  enabled = true,
+  label, // ControlProps includes label automatically resolved by JSON Forms
+}: ControlProps) => {
+  const numericValue =
+    data !== undefined && data !== null && data !== '' ? Number(data) : 0;
+  const min = schema.minimum ?? (schema as any).minimum;
+  const max = schema.maximum ?? (schema as any).maximum;
+  const step = schema.multipleOf ?? (schema as any).step ?? 1;
+
+  const handleAdd = () => {
+    const currentValue = numericValue || 0;
+    const newValue = currentValue + step;
+    if (max === undefined || newValue <= max) {
+      handleChange(path, newValue);
+    }
+  };
+
+  const handleSubtract = () => {
+    const currentValue = numericValue || 0;
+    const newValue = currentValue - step;
+    if (min === undefined || newValue >= min) {
+      handleChange(path, newValue);
+    }
+  };
+
+  const handleInputChange = (event: React.ChangeEvent) => {
+    const value = event.target.value;
+    if (value === '') {
+      handleChange(path, undefined);
+      return;
+    }
+    const numValue = Number(value);
+    if (!isNaN(numValue)) {
+      // Apply min/max constraints
+      let constrainedValue = numValue;
+      if (min !== undefined && constrainedValue < min) {
+        constrainedValue = min;
+      }
+      if (max !== undefined && constrainedValue > max) {
+        constrainedValue = max;
+      }
+      handleChange(path, constrainedValue);
+    }
+  };
+
+  const currentValue = numericValue || 0;
+  const addDisabled = max !== undefined && currentValue >= max;
+  const subtractDisabled = min !== undefined && currentValue <= min;
+
+  // Track focus state to show helper text only when focused
+  const [isFocused, setIsFocused] = useState(false);
+
+  // Use schema.description for helper text (like "Please enter your age", "Height in centimeters")
+  // Show errors if present, otherwise show description only when focused
+  const helperText = errors
+    ? Array.isArray(errors)
+      ? errors.join(', ')
+      : String(errors)
+    : isFocused
+      ? schema.description
+      : undefined;
+
+  return (
+     setIsFocused(true)}
+      onBlur={() => setIsFocused(false)}
+      disabled={!enabled}
+      error={Boolean(errors)}
+      helperText={helperText}
+      inputProps={{
+        min,
+        max,
+        step,
+      }}
+      InputProps={{
+        endAdornment: isFocused ? (
+          
+             e.preventDefault()}
+              disabled={subtractDisabled || !enabled}
+              edge="end"
+              aria-label={`Decrease ${label || 'value'}`}>
+              
+            
+             e.preventDefault()}
+              disabled={addDisabled || !enabled}
+              edge="end"
+              aria-label={`Increase ${label || 'value'}`}>
+              
+            
+          
+        ) : null,
+      }}
+      sx={{
+        width: '100%',
+        // Hide native number input spinners
+        '& input[type="number"]': {
+          '-moz-appearance': 'textfield',
+          '&::-webkit-outer-spin-button': {
+            '-webkit-appearance': 'none',
+            margin: 0,
+          },
+          '&::-webkit-inner-spin-button': {
+            '-webkit-appearance': 'none',
+            margin: 0,
+          },
+        },
+      }}
+    />
+  );
+};
+
+export const numberStepperRenderer: JsonFormsRendererRegistryEntry = {
+  tester: isNumberControl,
+  renderer: withJsonFormsControlProps(NumberStepperRenderer),
+};
+
+export default withJsonFormsControlProps(NumberStepperRenderer);
diff --git a/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx b/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx
index f0eb64fd2..49aa77415 100644
--- a/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx
+++ b/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx
@@ -19,6 +19,12 @@ import { PhotoCamera, Delete, Refresh } from '@mui/icons-material';
 import FormulusClient from '../services/FormulusInterface';
 import { CameraResult } from '../types/FormulusInterfaceDefinition';
 import QuestionShell from '../components/QuestionShell';
+import { tokens } from '../theme/tokens-adapter';
+
+// Helper to parse pixel values from tokens
+const parsePx = (value: string): number => {
+  return parseInt(value.replace('px', ''), 10);
+};
 
 // Tester function to identify photo question types
 export const photoQuestionTester = rankWith(
@@ -213,8 +219,8 @@ const PhotoQuestionRenderer: React.FC = ({
             sx={{
               p: 1,
               bgcolor: 'background.paper',
-              borderRadius: 1,
-              border: '1px solid',
+              borderRadius: `${parsePx(tokens.border.radius.md)}px`, // Match button border radius
+              border: `${tokens.border.width.thin} solid`,
               borderColor: 'divider',
             }}>
              {
+  return parseInt(value.replace('px', ''), 10);
+};
 
 // Tester function - determines when this renderer should be used
 export const signatureQuestionTester = rankWith(
@@ -143,7 +149,7 @@ const SignatureQuestionRenderer: React.FC = ({
       ctx.beginPath();
       ctx.moveTo(lastPointRef.current.x, lastPointRef.current.y);
       ctx.lineTo(point.x, point.y);
-      ctx.strokeStyle = '#000';
+      ctx.strokeStyle = tokens.color.neutral.black;
       ctx.lineWidth = 2;
       ctx.lineCap = 'round';
       ctx.lineJoin = 'round';
@@ -236,7 +242,9 @@ const SignatureQuestionRenderer: React.FC = ({
         const isDark =
           window.matchMedia &&
           window.matchMedia('(prefers-color-scheme: dark)').matches;
-        ctx.fillStyle = isDark ? '#212121' : 'white';
+        ctx.fillStyle = isDark
+          ? tokens.color.neutral[900]
+          : tokens.color.neutral.white;
         ctx.fillRect(0, 0, canvas.width, canvas.height);
       }
     }
@@ -268,8 +276,8 @@ const SignatureQuestionRenderer: React.FC = ({
               mt: 1,
               p: 1,
               bgcolor: 'background.paper',
-              borderRadius: 1,
-              border: '1px solid',
+              borderRadius: parsePx(tokens.border.radius.md), // Match button border radius
+              border: `${tokens.border.width.thin} solid`,
               borderColor: 'divider',
             }}>
              = ({
           
            = ({
              = ({
               
               
+          
         }
         anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
         sx={{
           '& .MuiSnackbarContent-root': {
-            backgroundColor: 'rgba(0, 0, 0, 0.87)',
-            color: '#fff',
-            boxShadow:
-              '0px 3px 5px -1px rgba(0,0,0,0.2), 0px 6px 10px 0px rgba(0,0,0,0.14), 0px 1px 18px 0px rgba(0,0,0,0.12)',
+            backgroundColor: `rgba(0, 0, 0, ${(tokens as any).opacity?.['90'] ?? 0.9})`,
+            color: tokens.color.neutral.white,
+            boxShadow: (tokens as any).shadow?.portal?.md ?? tokens.shadow?.md,
           },
         }}
       />
diff --git a/formulus-formplayer/src/renderers/VideoQuestionRenderer.tsx b/formulus-formplayer/src/renderers/VideoQuestionRenderer.tsx
index de728343a..3d844a8f3 100644
--- a/formulus-formplayer/src/renderers/VideoQuestionRenderer.tsx
+++ b/formulus-formplayer/src/renderers/VideoQuestionRenderer.tsx
@@ -1,6 +1,7 @@
 import React, { useState, useEffect, useRef } from 'react';
 import { rankWith, ControlProps, formatIs } from '@jsonforms/core';
 import { withJsonFormsControlProps } from '@jsonforms/react';
+import { tokens } from '../theme/tokens-adapter';
 import {
   Typography,
   Box,
@@ -11,6 +12,11 @@ import {
   Divider,
   IconButton,
 } from '@mui/material';
+
+// Helper to parse pixel values from tokens
+const parsePx = (value: string): number => {
+  return parseInt(value.replace('px', ''), 10);
+};
 import {
   Videocam as VideocamIcon,
   PlayArrow as PlayIcon,
@@ -151,7 +157,13 @@ const VideoQuestionRenderer: React.FC = props => {
       helperText="Capture a video if required. Current app version may not support recording."
       metadata={
         process.env.NODE_ENV === 'development' ? (
-          
+          
             
               Debug - Path: {path} | Data: {JSON.stringify(data)}
             
@@ -184,10 +196,10 @@ const VideoQuestionRenderer: React.FC = props => {
                 src={videoData.uri}
                 style={{
                   width: '100%',
-                  maxWidth: '400px',
+                  maxWidth: `${parsePx((tokens as any).spacing?.[5] ?? '20px') * 20}px`,
                   height: 'auto',
-                  borderRadius: '8px',
-                  backgroundColor: '#000',
+                  borderRadius: tokens.border.radius.md, // Match button border radius
+                  backgroundColor: tokens.color.neutral.black,
                 }}
                 onEnded={() => setIsPlaying(false)}
                 onLoadedMetadata={() => {
diff --git a/formulus-formplayer/src/services/ExtensionsLoader.ts b/formulus-formplayer/src/services/ExtensionsLoader.ts
index 184cb5807..14884ec14 100644
--- a/formulus-formplayer/src/services/ExtensionsLoader.ts
+++ b/formulus-formplayer/src/services/ExtensionsLoader.ts
@@ -44,8 +44,7 @@ export interface LoadedRenderer {
  */
 export interface ExtensionLoadResult {
   renderers: JsonFormsRendererRegistryEntry[];
-  // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
-  functions: Map;
+  functions: Map any>;
   definitions: Record;
   errors: Array<{ type: string; message: string; details?: any }>;
 }
@@ -58,7 +57,7 @@ export async function loadExtensions(
 ): Promise {
   const result: ExtensionLoadResult = {
     renderers: [],
-    functions: new Map(),
+    functions: new Map any>(),
     definitions: metadata.definitions || {},
     errors: [],
   };
@@ -95,7 +94,10 @@ export async function loadExtensions(
       try {
         const loadedFunction = await loadFunction(funcMeta, basePath);
         if (loadedFunction) {
-          result.functions.set(funcMeta.name, loadedFunction);
+          result.functions.set(
+            funcMeta.name,
+            loadedFunction as (...args: any[]) => any,
+          );
           console.log(
             `[ExtensionsLoader] Registered extension function "${funcMeta.name}" from module "${funcMeta.module}" (metadata key: ${key})`,
           );
diff --git a/formulus-formplayer/src/theme/material-wrappers.tsx b/formulus-formplayer/src/theme/material-wrappers.tsx
index 278d1747a..f5ba5916f 100644
--- a/formulus-formplayer/src/theme/material-wrappers.tsx
+++ b/formulus-formplayer/src/theme/material-wrappers.tsx
@@ -5,20 +5,19 @@ import {
   ControlProps,
 } from '@jsonforms/core';
 import { withJsonFormsControlProps } from '@jsonforms/react';
-import {
-  Card,
-  CardActionArea,
-  CardContent,
-  Typography,
-  Box,
-} from '@mui/material';
+import { Typography, Box, useTheme } from '@mui/material';
 import QuestionShell from '../components/QuestionShell';
+import { tokens } from './tokens-adapter';
+
+const parsePx = (value: string): number =>
+  parseInt(String(value).replace('px', ''), 10) || 1;
 
 type AnyControlProps = ControlProps & { errors?: string };
 
 const cardEnumControlTester: RankedTester = rankWith(6, isEnumControl);
 
 const CardEnumControl = (props: AnyControlProps) => {
+  const theme = useTheme();
   const {
     data,
     handleChange,
@@ -51,26 +50,71 @@ const CardEnumControl = (props: AnyControlProps) => {
         {options.map(opt => {
           const selected = data === opt.value;
           return (
-             enabled && handleChange(path, opt.value)}
+              sx={theme => {
+                const isDark = theme.palette.mode === 'dark';
+                const grey = theme.palette.grey as unknown as
+                  | Record
+                  | undefined;
+                const lineColor = isDark
+                  ? (grey?.[800] ?? theme.palette.divider)
+                  : (grey?.[200] ?? theme.palette.divider);
+                const borderColor = selected
+                  ? theme.palette.primary.main
+                  : lineColor;
+                const leftBorderWidth =
+                  (tokens as any).border?.width?.medium ?? '2px';
+                const linePx = parsePx(leftBorderWidth);
+                const borderBg = (color: string) => ({
+                  backgroundImage: [
+                    `linear-gradient(to right, ${color} 0, ${color} ${linePx}px, transparent 100%)`,
+                    `linear-gradient(to right, ${color} 0, ${color} ${linePx}px, transparent 100%)`,
+                  ].join(', '),
+                  backgroundSize: `100% ${linePx}px, 100% ${linePx}px`,
+                  backgroundPosition: '0 0, 0 100%',
+                  backgroundRepeat: 'no-repeat',
+                });
+                return {
+                  position: 'relative',
+                  borderRadius: tokens.border.radius.lg,
+                  backgroundColor: 'transparent',
+                  ...borderBg(borderColor),
+                  cursor: enabled ? 'pointer' : 'default',
+                  overflow: 'hidden',
+                  px: 2,
+                  py: 1.5,
+                  '&:hover': enabled
+                    ? {
+                        ...borderBg(theme.palette.primary.main),
+                      }
+                    : {},
+                  '&:active': enabled
+                    ? {
+                        ...borderBg(theme.palette.primary.main),
+                        '--option-active-color': theme.palette.primary.main,
+                        '& > .MuiTypography-root.MuiTypography-subtitle1': {
+                          color: 'var(--option-active-color)',
+                          fontWeight: 500,
+                        },
+                      }
+                    : {},
+                };
               }}>
-               handleChange(path, opt.value)}
-                sx={{ p: 1.5 }}>
-                
-                  
-                    {opt.label}
-                  
-                
-              
-            
+              
+                {opt.label}
+              
+            
           );
         })}
       
diff --git a/formulus-formplayer/src/theme/theme.ts b/formulus-formplayer/src/theme/theme.ts
index 980107f7c..dd2e26157 100644
--- a/formulus-formplayer/src/theme/theme.ts
+++ b/formulus-formplayer/src/theme/theme.ts
@@ -23,6 +23,22 @@ const parsePx = (value: string): number => {
   return parseInt(value.replace('px', ''), 10);
 };
 
+// Token-based helpers (single source: @ode/tokens)
+const getDisabledOpacity = (): number =>
+  parseFloat(
+    (tokens as any).opacity?.['40'] ?? (tokens as any).opacity?.['50'] ?? '0.4',
+  );
+const _getDarkInputBackground = (): string =>
+  (tokens as any).color?.theme?.['theme-dark']?.background?.elevated ??
+  tokens.color.neutral[800];
+const getDarkElevationShadow = (level: 'sm' | 'md' | 'lg'): string =>
+  (tokens as any).shadow?.portal?.[level] ??
+  (level === 'sm'
+    ? `0 2px 8px rgba(0, 0, 0, ${(tokens as any).opacity?.['40'] ?? 0.4})`
+    : level === 'md'
+      ? `0 4px 12px rgba(0, 0, 0, ${(tokens as any).opacity?.['50'] ?? 0.5})`
+      : `0 8px 24px rgba(0, 0, 0, ${(tokens as any).opacity?.['60'] ?? 0.6})`);
+
 /**
  * Get theme options based on the mode (light or dark)
  * @param mode - 'light' or 'dark'
@@ -185,7 +201,7 @@ export const getThemeOptions = (mode: 'light' | 'dark'): ThemeOptions => {
               boxShadow: tokens.shadow.md,
             },
             '&:disabled': {
-              opacity: 0.38,
+              opacity: getDisabledOpacity(),
               backgroundColor: isDark
                 ? tokens.color.neutral[800]
                 : tokens.color.neutral[300],
@@ -233,9 +249,10 @@ export const getThemeOptions = (mode: 'light' | 'dark'): ThemeOptions => {
             width: '100%',
             marginBottom: parsePx(tokens.spacing[4]),
             '& .MuiOutlinedInput-root': {
-              borderRadius: parsePx(tokens.border.radius.sm), // 4px - Material Design 3 text field
-              backgroundColor: isDark ? '#2d2d2d' : 'transparent', // Dark: #2d2d2d (slightly lighter than paper for subtle differentiation), Light: transparent
+              borderRadius: parsePx(tokens.border.radius.md), // 8px - match button, not too rounded
+              backgroundColor: 'transparent',
               '& fieldset': {
+                borderRadius: parsePx(tokens.border.radius.md), // Same as root so all sizes match
                 borderColor: isDark
                   ? tokens.color.neutral[700]
                   : tokens.color.neutral[400], // Dark: #616161, Light: #BDBDBD
@@ -254,8 +271,12 @@ export const getThemeOptions = (mode: 'light' | 'dark'): ThemeOptions => {
                 borderColor: tokens.color.semantic.error[500],
               },
               '&.Mui-disabled': {
-                backgroundColor: isDark ? '#2d2d2d' : tokens.color.neutral[100],
-                opacity: isDark ? 0.5 : 1,
+                backgroundColor: isDark
+                  ? 'transparent'
+                  : tokens.color.neutral[100],
+                opacity: isDark
+                  ? parseFloat((tokens as any).opacity?.['50'] ?? '0.5')
+                  : 1,
                 '& fieldset': {
                   borderColor: isDark
                     ? tokens.color.neutral[700]
@@ -296,8 +317,10 @@ export const getThemeOptions = (mode: 'light' | 'dark'): ThemeOptions => {
       MuiOutlinedInput: {
         styleOverrides: {
           root: {
-            borderRadius: parsePx(tokens.border.radius.sm),
+            borderRadius: parsePx(tokens.border.radius.md), // 8px - match button (Name, Birth date, etc.)
+            backgroundColor: 'transparent',
             '& fieldset': {
+              borderRadius: parsePx(tokens.border.radius.md), // Force fieldset to match (fixes size="small" e.g. Birth date)
               borderColor: isDark
                 ? tokens.color.neutral[700]
                 : tokens.color.neutral[400],
@@ -316,6 +339,13 @@ export const getThemeOptions = (mode: 'light' | 'dark'): ThemeOptions => {
               borderColor: tokens.color.semantic.error[500],
             },
           },
+          // Ensure size="small"
+          sizeSmall: {
+            borderRadius: parsePx(tokens.border.radius.md),
+            '& fieldset': {
+              borderRadius: parsePx(tokens.border.radius.md),
+            },
+          },
           input: {
             padding: parsePx(tokens.spacing[4]),
             fontSize: parsePx(tokens.typography.fontSize.base),
@@ -426,7 +456,7 @@ export const getThemeOptions = (mode: 'light' | 'dark'): ThemeOptions => {
               margin: 2,
               transitionDuration: '300ms',
               '&.Mui-checked': {
-                transform: 'translateX(20px)',
+                transform: `translateX(${tokens.spacing?.[5] ?? '20px'})`,
                 color: tokens.color.neutral.white,
                 '& + .MuiSwitch-track': {
                   backgroundColor: tokens.color.brand.primary[500],
@@ -462,7 +492,7 @@ export const getThemeOptions = (mode: 'light' | 'dark'): ThemeOptions => {
       MuiSelect: {
         styleOverrides: {
           root: {
-            borderRadius: parsePx(tokens.border.radius.sm),
+            borderRadius: parsePx(tokens.border.radius.md), // Same as text fields (8px, match button)
             minHeight: `${tokens.touchTarget.large}px`,
             '&.Mui-focused': {
               '& .MuiOutlinedInput-notchedOutline': {
@@ -493,19 +523,13 @@ export const getThemeOptions = (mode: 'light' | 'dark'): ThemeOptions => {
               : tokens.color.neutral.white, // Dark: #212121, Light: #FFFFFF
           },
           elevation1: {
-            boxShadow: isDark
-              ? '0 2px 8px rgba(0, 0, 0, 0.4)'
-              : tokens.shadow.sm,
+            boxShadow: isDark ? getDarkElevationShadow('sm') : tokens.shadow.sm,
           },
           elevation2: {
-            boxShadow: isDark
-              ? '0 4px 12px rgba(0, 0, 0, 0.5)'
-              : tokens.shadow.md,
+            boxShadow: isDark ? getDarkElevationShadow('md') : tokens.shadow.md,
           },
           elevation3: {
-            boxShadow: isDark
-              ? '0 8px 24px rgba(0, 0, 0, 0.6)'
-              : tokens.shadow.lg,
+            boxShadow: isDark ? getDarkElevationShadow('lg') : tokens.shadow.lg,
           },
         },
       },
@@ -517,9 +541,7 @@ export const getThemeOptions = (mode: 'light' | 'dark'): ThemeOptions => {
             backgroundColor: isDark
               ? tokens.color.neutral[800]
               : tokens.color.neutral.white, // Dark: #424242 (medium dark for cards), Light: #FFFFFF
-            boxShadow: isDark
-              ? '0 2px 8px rgba(0, 0, 0, 0.4)'
-              : tokens.shadow.sm,
+            boxShadow: isDark ? getDarkElevationShadow('sm') : tokens.shadow.sm,
           },
         },
       },
diff --git a/formulus-formplayer/src/theme/tokens-adapter.ts b/formulus-formplayer/src/theme/tokens-adapter.ts
index 3a4a84700..dad3e3ab1 100644
--- a/formulus-formplayer/src/theme/tokens-adapter.ts
+++ b/formulus-formplayer/src/theme/tokens-adapter.ts
@@ -8,6 +8,70 @@
 
 import tokensJson from '@ode/tokens/dist/json/tokens.json';
 
+// TypeScript interface for tokens structure
+export interface Tokens {
+  spacing: Record;
+  border: {
+    width: Record;
+    radius: Record;
+  };
+  color: {
+    semantic: {
+      error: Record;
+      success: Record;
+      info: Record;
+      warning: Record;
+      scanner: Record;
+      theme: Record;
+      'theme-light': Record;
+      ui: Record;
+    };
+    neutral: Record;
+    brand: {
+      primary: Record & {
+        alpha?: Record;
+      };
+      secondary: Record & {
+        alpha?: Record;
+      };
+    };
+  };
+  typography: {
+    fontFamily: Record;
+    fontSize: Record;
+    fontWeight: Record;
+    lineHeight: Record;
+    letterSpacing: Record;
+  };
+  font?: {
+    family: Record;
+    size: Record;
+    weight: Record;
+    lineHeight: Record;
+    letterSpacing: Record;
+  };
+  touchTarget: {
+    min: number;
+    comfortable: number;
+    large: number;
+  };
+  contrast?: Record;
+  focus?: Record;
+  filter?: Record;
+  duration?: Record;
+  easing?: Record;
+  opacity?: Record;
+  shadow: Record;
+  zIndex?: Record;
+  component?: Record;
+  icon?: Record;
+  avatar?: Record;
+  logo?: Record;
+  breakpoint?: Record;
+  container?: Record;
+  grid?: Record;
+}
+
 // Helper to recursively extract values from { value: "..." } structure
 const extractValues = (obj: any): any => {
   if (
@@ -44,6 +108,7 @@ const parsePx = (value: string): number => {
 const transformed = extractValues(tokensJson);
 
 // Map font structure to typography structure to match theme.ts expectations
+// typography is always created from font, so it's guaranteed to exist
 if (transformed.font) {
   transformed.typography = {
     fontFamily: transformed.font.family,
@@ -53,6 +118,15 @@ if (transformed.font) {
     letterSpacing: transformed.font.letterSpacing,
   };
   // Keep font for backwards compatibility, but typography is the primary
+} else {
+  // Fallback if font doesn't exist (shouldn't happen, but TypeScript needs this)
+  transformed.typography = {
+    fontFamily: {},
+    fontSize: {},
+    fontWeight: {},
+    lineHeight: {},
+    letterSpacing: {},
+  };
 }
 
 // Parse touchTarget values from "48px" strings to numbers (48)
@@ -64,4 +138,4 @@ if (transformed.touchTarget) {
   };
 }
 
-export const tokens = transformed as any;
+export const tokens: Tokens = transformed;
diff --git a/formulus-formplayer/vite.config.ts b/formulus-formplayer/vite.config.ts
index 4267219ba..785e5e251 100644
--- a/formulus-formplayer/vite.config.ts
+++ b/formulus-formplayer/vite.config.ts
@@ -57,6 +57,8 @@ export default defineConfig({
   resolve: {
     alias: {
       '@': path.resolve(__dirname, './src'),
+      // Resolve @ode/tokens from app root when bundling @ode/components (symlinked package)
+      '@ode/tokens': path.resolve(__dirname, 'node_modules/@ode/tokens'),
     },
   },
 
diff --git a/packages/components/src/react-native/Button.tsx b/packages/components/src/react-native/Button.tsx
index 6bd0cc94e..ca6aff04d 100644
--- a/packages/components/src/react-native/Button.tsx
+++ b/packages/components/src/react-native/Button.tsx
@@ -6,7 +6,14 @@
  */
 
 import React, { useState, useMemo } from 'react';
-import { TouchableOpacity, Text, StyleSheet, View, ActivityIndicator } from 'react-native';
+import {
+  TouchableOpacity,
+  Text,
+  StyleSheet,
+  View,
+  ActivityIndicator,
+  useColorScheme,
+} from 'react-native';
 import { ButtonProps, ButtonVariant } from '../shared/types';
 import { getOppositeVariant } from '../shared/utils';
 import tokens from '@ode/tokens/dist/react-native/tokens-resolved';
@@ -38,6 +45,8 @@ const Button: React.FC = ({
   accessibilityLabel,
 }) => {
   const [isPressed, setIsPressed] = useState(false);
+  const colorScheme = useColorScheme();
+  const isDarkMode = colorScheme === 'dark';
   const isActiveOrPressed = active || isPressed;
 
   // Determine actual variant - if paired, use opposite of paired variant
@@ -51,7 +60,10 @@ const Button: React.FC = ({
   const primaryGreen = tokens.color.brand.primary[500];
   const errorRed =
     (tokens.color.semantic as any)?.error?.[500] ?? tokens.color.semantic?.error?.[500];
-  const errorRedAlpha = (tokens.color.semantic as any)?.error?.alpha?.[15];
+  const errorRedDark =
+    (tokens.color.semantic as any)?.error?.[600] ?? tokens.color.semantic?.error?.[600];
+  const errorRedLight =
+    (tokens.color.semantic as any)?.error?.[50] ?? tokens.color.semantic?.error?.[50];
   const neutralGrey = tokens.color.neutral[600];
   const textOnFill = tokens.color.neutral.white;
 
@@ -70,34 +82,32 @@ const Button: React.FC = ({
     }
   }, [actualVariant, primaryGreen, neutralGrey, errorRed]);
 
-  const dangerRed = errorRed ?? tokens.color.semantic?.error?.[500];
-  const dangerRedAlpha = errorRedAlpha ?? (tokens.color.semantic as any)?.error?.alpha?.[15];
-  const dangerDefaultBorder = dangerRed;
-  const dangerPressedBorder = neutralGrey;
+  // Danger button style (matches formplayer error buttons):
+  // Default: light pink background (error.50) in light mode, error.500 at 15% in dark mode
+  //          red border (error.500), darker red text (error.600)
+  // Pressed: transparent background, red border (same), darker red text (same)
+  const dangerDefaultBorder = errorRed ?? neutralGrey;
+  const dangerDefaultText = errorRedDark ?? errorRed ?? neutralGrey;
+  const dangerDefaultBg = isDarkMode
+    ? 'rgba(244, 67, 54, 0.15)' // error.500 (#f44336) at 15% for dark mode
+    : (errorRedLight ?? 'transparent'); // Light pink (error.50) in light mode
+
   const pressedBg = actualVariant === 'danger' ? 'transparent' : borderColor;
-  const pressedBorderColor = actualVariant === 'danger' ? dangerPressedBorder : 'transparent';
+  const pressedBorderColor = actualVariant === 'danger' ? dangerDefaultBorder : 'transparent';
 
   const textColor =
     actualVariant === 'danger'
-      ? isPressed
-        ? neutralGrey
-        : dangerRed
+      ? dangerDefaultText
       : isActiveOrPressed
         ? textOnFill
         : borderColor;
   const activeBorderColor =
-    actualVariant === 'danger'
-      ? isPressed
-        ? dangerPressedBorder
-        : dangerDefaultBorder
-      : isActiveOrPressed
-        ? pressedBorderColor
-        : borderColor;
+    actualVariant === 'danger' ? dangerDefaultBorder : isActiveOrPressed ? pressedBorderColor : borderColor;
   const backgroundColor =
     actualVariant === 'danger'
       ? isPressed
         ? 'transparent'
-        : (dangerRedAlpha ?? 'transparent')
+        : dangerDefaultBg
       : isActiveOrPressed
         ? pressedBg
         : 'transparent';
diff --git a/packages/components/src/react-web/Button.tsx b/packages/components/src/react-web/Button.tsx
index 1eaaefde5..5f0eea0dc 100644
--- a/packages/components/src/react-web/Button.tsx
+++ b/packages/components/src/react-web/Button.tsx
@@ -5,7 +5,7 @@
  * thin border, token-based spacing/typography. Same border radius in px for common design language.
  */
 
-import React, { useState, useMemo } from 'react';
+import React, { useState, useMemo, useEffect } from 'react';
 import type { ButtonProps, ButtonVariant } from '../shared/types';
 import { getOppositeVariant } from '../shared/utils';
 import tokensJson from '@ode/tokens/dist/json/tokens.json';
@@ -41,8 +41,54 @@ const Button: React.FC = ({
   accessibilityLabel,
 }) => {
   const [isHovered, setIsHovered] = useState(false);
+  const [isDarkMode, setIsDarkMode] = useState(() => {
+    if (typeof window === 'undefined') return false;
+    // Check MUI theme mode first (if available)
+    const muiThemeMode = document.documentElement.getAttribute('data-mui-color-scheme');
+    if (muiThemeMode === 'dark') return true;
+    // Fallback to system preference
+    if (window.matchMedia) {
+      return window.matchMedia('(prefers-color-scheme: dark)').matches;
+    }
+    return false;
+  });
   const isActiveOrHovered = active || isHovered;
 
+  // Listen for dark mode changes (both system preference and MUI theme)
+  useEffect(() => {
+    if (typeof window === 'undefined') return;
+    
+    // Check for MUI theme changes via MutationObserver
+    const observer = new MutationObserver(() => {
+      const muiThemeMode = document.documentElement.getAttribute('data-mui-color-scheme');
+      if (muiThemeMode === 'dark' || muiThemeMode === 'light') {
+        setIsDarkMode(muiThemeMode === 'dark');
+      }
+    });
+    observer.observe(document.documentElement, {
+      attributes: true,
+      attributeFilter: ['data-mui-color-scheme'],
+    });
+
+    // Also listen to system preference changes
+    if (window.matchMedia) {
+      const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+      const handleChange = (e: MediaQueryListEvent) => {
+        // Only update if MUI theme mode is not set
+        if (!document.documentElement.getAttribute('data-mui-color-scheme')) {
+          setIsDarkMode(e.matches);
+        }
+      };
+      mediaQuery.addEventListener('change', handleChange);
+      return () => {
+        observer.disconnect();
+        mediaQuery.removeEventListener('change', handleChange);
+      };
+    }
+    
+    return () => observer.disconnect();
+  }, []);
+
   // Determine actual variant - if paired, use opposite of paired variant
   const actualVariant: ButtonVariant = useMemo(() => {
     if (isPaired && pairedVariant) {
@@ -65,7 +111,8 @@ const Button: React.FC = ({
 
   const primaryGreen = getToken('color.brand.primary.500');
   const errorRed = getToken('color.semantic.error.500');
-  const errorRedAlpha = getToken('color.semantic.error.alpha.15');
+  const errorRedDark = getToken('color.semantic.error.600');
+  const errorRedLight = getToken('color.semantic.error.50');
   const textOnFill = getToken('color.neutral.white');
 
   const neutralGrey = getToken('color.neutral.600');
@@ -84,23 +131,28 @@ const Button: React.FC = ({
     }
   }, [actualVariant, primaryGreen, neutralGrey, errorRed]);
 
-  // Danger: default = red (border, text, tint bg); hover = grey (border, text), transparent
+  // Danger button style (matches Logout button in formulus app):
+  // Default: light pink background (error.50) in light mode, darker background in dark mode
+  //          red border (error.500), darker red text (error.600)
+  // Hover: transparent background, red border (same thickness), darker red text (error.600)
   const dangerDefaultBorder = errorRed;
-  const dangerHoverBorder = neutralGrey;
+  const dangerDefaultText = errorRedDark;
+  // Use darker background in dark mode, light pink in light mode
+  // In dark mode, use a darker red with alpha (error.500 with 15% opacity) for better contrast
+  // In light mode, use light pink background (error.50)
+  const dangerDefaultBg = isDarkMode
+    ? `rgba(244, 67, 54, 0.15)` // error.500 (#f44336) with 15% opacity for dark mode
+    : errorRedLight; // Light pink (#fef2f2 / error.50) in light mode
   const hoverBg = actualVariant === 'danger' ? 'transparent' : borderColor;
   const activeBorderColor =
     actualVariant === 'danger'
-      ? isHovered
-        ? dangerHoverBorder
-        : dangerDefaultBorder
+      ? dangerDefaultBorder 
       : isActiveOrHovered
         ? 'transparent'
         : borderColor;
   const activeTextColor =
     actualVariant === 'danger'
-      ? isHovered
-        ? neutralGrey
-        : errorRed
+      ? dangerDefaultText 
       : isActiveOrHovered
         ? textOnFill
         : borderColor;
@@ -108,7 +160,7 @@ const Button: React.FC = ({
     actualVariant === 'danger'
       ? isHovered
         ? 'transparent'
-        : errorRedAlpha || 'transparent'
+        : dangerDefaultBg
       : isActiveOrHovered
         ? hoverBg
         : 'transparent';
diff --git a/packages/components/src/react-web/ButtonGroup.tsx b/packages/components/src/react-web/ButtonGroup.tsx
index f8ff8c3db..a981463bd 100644
--- a/packages/components/src/react-web/ButtonGroup.tsx
+++ b/packages/components/src/react-web/ButtonGroup.tsx
@@ -14,7 +14,7 @@ interface WebButtonProps extends ButtonProps {
   position?: 'left' | 'right' | 'middle' | 'standalone';
 }
 
-interface ButtonGroupProps {
+export interface ButtonGroupProps {
   /**
    * Buttons to render
    */
diff --git a/packages/components/src/react-web/Input.tsx b/packages/components/src/react-web/Input.tsx
index 8eba3970a..fe571ce08 100644
--- a/packages/components/src/react-web/Input.tsx
+++ b/packages/components/src/react-web/Input.tsx
@@ -34,7 +34,7 @@ const Input: React.FC = ({
   className = '',
   style,
   testID,
-}) => {
+}: InputProps) => {
   const [isFocused, setIsFocused] = useState(false);
   const inputRef = useRef(null);
 
diff --git a/packages/components/src/react-web/index.ts b/packages/components/src/react-web/index.ts
index 4b195735e..4e18a442c 100644
--- a/packages/components/src/react-web/index.ts
+++ b/packages/components/src/react-web/index.ts
@@ -10,4 +10,7 @@ export { default as Input } from './Input';
 export { default as Card } from './Card';
 export { default as Badge } from './Badge';
 
-// Types are available via the components themselves or can be imported from '../shared/types'
+// Export types for TypeScript inference
+export type { ButtonProps, ButtonVariant, ButtonSize, InputProps, CardProps, BadgeProps } from '../shared/types';
+export type { WebButtonProps } from './Button';
+export type { ButtonGroupProps } from './ButtonGroup';