From 490745369873ead535075bea6b3600260f82aed7 Mon Sep 17 00:00:00 2001 From: IamLRBA Date: Mon, 16 Feb 2026 11:37:39 +0300 Subject: [PATCH 01/10] formplayer: add react-refresh dep, document dev setup --- formulus-formplayer/README.md | 8 ++++++++ formulus-formplayer/package.json | 13 +++---------- 2 files changed, 11 insertions(+), 10 deletions(-) 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.json b/formulus-formplayer/package.json index 060ef620e..1f3d6a272 100644 --- a/formulus-formplayer/package.json +++ b/formulus-formplayer/package.json @@ -57,19 +57,12 @@ "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" }, "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] + "production": [">0.2%", "not dead", "not op_mini all"], + "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"] } } From be4d7a181f9461da6e5f26b32dd37bff3065fc90 Mon Sep 17 00:00:00 2001 From: IamLRBA Date: Tue, 17 Feb 2026 03:08:21 +0300 Subject: [PATCH 02/10] feat(formulus-formplayer): align with ODE tokens, adopt @ode/components for chrome --- formulus-formplayer/package-lock.json | 29 ++ formulus-formplayer/package.json | 13 +- formulus-formplayer/src/App.css | 24 ++ formulus-formplayer/src/App.tsx | 69 +++- .../src/DynamicEnumControl.tsx | 95 +++-- .../src/FormEvaluationContext.tsx | 11 +- formulus-formplayer/src/builtinExtensions.ts | 365 ++++++++++-------- .../src/components/DraftSelector.tsx | 31 +- .../src/components/ErrorBoundary.tsx | 38 +- .../src/components/FormLayout.tsx | 61 ++- .../src/components/FormProgressBar.tsx | 5 +- formulus-formplayer/src/ode-components.d.ts | 25 ++ .../src/renderers/AdateQuestionRenderer.tsx | 15 +- .../src/renderers/AudioQuestionRenderer.tsx | 10 +- .../src/renderers/FileQuestionRenderer.tsx | 25 +- .../src/renderers/FinalizeRenderer.tsx | 204 +++++----- .../src/renderers/GPSQuestionRenderer.tsx | 14 +- .../src/renderers/NumberStepperRenderer.tsx | 161 ++++++++ .../src/renderers/PhotoQuestionRenderer.tsx | 10 +- .../renderers/SignatureQuestionRenderer.tsx | 28 +- .../src/renderers/SwipeLayoutRenderer.tsx | 25 +- .../src/renderers/VideoQuestionRenderer.tsx | 20 +- .../src/theme/material-wrappers.tsx | 94 +++-- formulus-formplayer/src/theme/theme.ts | 62 ++- formulus-formplayer/vite.config.ts | 2 + packages/components/src/react-web/Button.tsx | 75 +++- 26 files changed, 1033 insertions(+), 478 deletions(-) create mode 100644 formulus-formplayer/src/ode-components.d.ts create mode 100644 formulus-formplayer/src/renderers/NumberStepperRenderer.tsx diff --git a/formulus-formplayer/package-lock.json b/formulus-formplayer/package-lock.json index 22df5869a..41cb16738 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 1f3d6a272..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", @@ -62,7 +63,15 @@ "vite": "^6.4.1" }, "browserslist": { - "production": [">0.2%", "not dead", "not op_mini all"], - "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"] + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] } } 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 a7d387561..cf086aa9e 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'; @@ -62,6 +63,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'; @@ -219,6 +221,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() { @@ -263,10 +267,8 @@ function App() { JsonFormsRendererRegistryEntry[] >([]); // Store extension functions for potential future use (e.g., validation context injection) - // eslint-disable-next-line @typescript-eslint/no-unused-vars const [extensionFunctions, setExtensionFunctions] = useState< - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - Map + Map any> >(new Map()); const [extensionDefinitions, setExtensionDefinitions] = useState< Record @@ -295,7 +297,10 @@ function App() { try { const properties = (formSchema as any)?.properties || {}; const dynamicEnumFields = Object.entries(properties) - .filter(([, propSchema]: [string, any]) => !!propSchema?.['x-dynamicEnum']) + .filter( + ([, propSchema]: [string, any]) => + !!propSchema?.['x-dynamicEnum'], + ) .map(([key]) => key); console.log('[Formplayer] Form init received', { @@ -306,7 +311,10 @@ function App() { dynamicEnumFields, }); } catch (schemaLogError) { - console.warn('[Formplayer] Failed to log schema details', schemaLogError); + console.warn( + '[Formplayer] Failed to log schema details', + schemaLogError, + ); } // Extract dark mode preference from params @@ -320,17 +328,20 @@ function App() { if (extensions) { try { const extensionResult = await loadExtensions(extensions); - + // 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); setExtensionFunctions(allFunctions); setExtensionDefinitions(extensionResult.definitions); - console.log('[Formplayer] Final extension functions:', Array.from(allFunctions.keys())); + console.log( + '[Formplayer] Final extension functions:', + Array.from(allFunctions.keys()), + ); // Log errors but don't fail form initialization if (extensionResult.errors.length > 0) { @@ -786,6 +797,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 ( @@ -836,10 +866,15 @@ function App() { p: 3, backgroundColor: 'background.paper', }}> - + Error Loading Form - + {loadError} @@ -895,7 +930,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 @@ -904,11 +939,11 @@ function App() { {loadError ? ( @@ -969,8 +1004,8 @@ function App() {
diff --git a/formulus-formplayer/src/DynamicEnumControl.tsx b/formulus-formplayer/src/DynamicEnumControl.tsx index 84e42d495..2c00bd91b 100644 --- a/formulus-formplayer/src/DynamicEnumControl.tsx +++ b/formulus-formplayer/src/DynamicEnumControl.tsx @@ -1,6 +1,6 @@ /** * DynamicEnumControl.tsx - * + * * Custom JSON Forms renderer for dynamic choice lists. * Supports x-dynamicEnum schema property to populate enum/oneOf values * from database queries at runtime. @@ -11,7 +11,14 @@ import { withJsonFormsControlProps } from '@jsonforms/react'; import { ControlProps, rankWith } from '@jsonforms/core'; import { useFormEvaluation } from './FormEvaluationContext'; import { useJsonForms } from '@jsonforms/react'; -import { Autocomplete, TextField, Box, Typography, Alert, CircularProgress } from '@mui/material'; +import { + Autocomplete, + TextField, + Box, + Typography, + Alert, + CircularProgress, +} from '@mui/material'; /** * Interface for x-dynamicEnum configuration @@ -29,12 +36,15 @@ interface DynamicEnumConfig { * Helper to resolve the actual field schema from a scope path * Example: scope="#/properties/test_village" -> schema.properties.test_village */ -function resolveSchemaFromScope(scope: string | undefined, rootSchema: any): any { +function resolveSchemaFromScope( + scope: string | undefined, + rootSchema: any, +): any { if (!scope || !rootSchema) return rootSchema; - + // Parse scope like "#/properties/field_name" or "#/properties/nested/properties/field" const parts = scope.split('/').filter(p => p && p !== '#'); - + let resolved = rootSchema; for (const part of parts) { if (resolved && typeof resolved === 'object') { @@ -43,7 +53,7 @@ function resolveSchemaFromScope(scope: string | undefined, rootSchema: any): any return rootSchema; // Fallback to root if path invalid } } - + return resolved || rootSchema; } @@ -52,7 +62,7 @@ function resolveSchemaFromScope(scope: string | undefined, rootSchema: any): any */ export const dynamicEnumTester = rankWith( 100, // High priority for x-dynamicEnum fields - (uischema: any, schema: any, context: any) => { + (uischema: any, schema: any, _context: any) => { // Resolve the actual field schema from the scope const fieldSchema = resolveSchemaFromScope(uischema?.scope, schema); return !!(fieldSchema as any)?.['x-dynamicEnum']; @@ -65,18 +75,22 @@ export const dynamicEnumTester = rankWith( */ function resolveTemplateParams( params: Record, - formData: Record + formData: Record, ): Record { const resolved: Record = {}; - + for (const [key, value] of Object.entries(params)) { - if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) { + if ( + typeof value === 'string' && + value.startsWith('{{') && + value.endsWith('}}') + ) { // Extract path: {{data.village}} -> data.village const path = value.slice(2, -2).trim(); - + // Remove "data." prefix if present (form data is already the data object) const dataPath = path.startsWith('data.') ? path.slice(5) : path; - + // Get nested value const pathParts = dataPath.split('.'); let resolvedValue: any = formData; @@ -94,7 +108,7 @@ function resolveTemplateParams( resolved[key] = value; } } - + return resolved; } @@ -113,17 +127,19 @@ const DynamicEnumControl: React.FC = ({ }) => { const { functions } = useFormEvaluation(); const ctx = useJsonForms(); - - const [choices, setChoices] = useState>([]); + + const [choices, setChoices] = useState>( + [], + ); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [localSchema, setLocalSchema] = useState(schema); - + // Get x-dynamicEnum configuration first const dynamicConfig = useMemo(() => { return (schema as any)?.['x-dynamicEnum'] as DynamicEnumConfig | undefined; }, [schema]); - + // Get current form data for template parameter resolution const currentFormData = ctx?.core?.data || {}; @@ -132,7 +148,7 @@ const DynamicEnumControl: React.FC = ({ (_event: any, newValue: { const: any; title: string } | null) => { handleChange(path, newValue ? newValue.const : ''); }, - [handleChange, path] + [handleChange, path], ); // Find selected option based on current data value - must be before early returns @@ -142,9 +158,14 @@ const DynamicEnumControl: React.FC = ({ // Get display label from schema or uischema - computed before early returns const label = useMemo(() => { - return (uischema as any)?.label || schema.title || path.split('.').pop() || 'Field'; + return ( + (uischema as any)?.label || + schema.title || + path.split('.').pop() || + 'Field' + ); }, [uischema, schema, path]); - + const description = schema.description; const hasValidationErrors = errors && errors.length > 0; @@ -167,7 +188,7 @@ const DynamicEnumControl: React.FC = ({ if (!func) { const availableFunctions = Array.from(functions.keys()).join(', '); setError( - `Function "${functionName}" not found. Available: ${availableFunctions || 'none'}.` + `Function "${functionName}" not found. Available: ${availableFunctions || 'none'}.`, ); return; } @@ -178,7 +199,10 @@ const DynamicEnumControl: React.FC = ({ try { // Resolve template parameters (if any - they will be ignored if unresolved) const resolvedParams = dynamicConfig.params - ? resolveTemplateParams(dynamicConfig.params, currentFormData as Record) + ? resolveTemplateParams( + dynamicConfig.params, + currentFormData as Record, + ) : {}; // Add configuration for valueField, labelField, and distinct @@ -193,14 +217,18 @@ const DynamicEnumControl: React.FC = ({ }; // Call the function with correct signature: (queryName, params, formData) - const result = await func(dynamicConfig.query, paramsWithConfig, currentFormData); + const result = await func( + dynamicConfig.query, + paramsWithConfig, + currentFormData, + ); if (!Array.isArray(result)) { throw new Error(`Function returned ${typeof result}, expected array`); } setChoices(result); - + // Update local schema with dynamic enum const updatedSchema = { ...localSchema, @@ -260,14 +288,14 @@ const DynamicEnumControl: React.FC = ({ {label} {schema.required && *} - + {/* Description */} {description && ( {description} )} - + {/* Validation Errors */} {hasValidationErrors && ( @@ -291,8 +319,7 @@ const DynamicEnumControl: React.FC = ({ variant="body2" color="primary" sx={{ cursor: 'pointer', textDecoration: 'underline' }} - onClick={loadChoices} - > + onClick={loadChoices}> Retry @@ -305,15 +332,21 @@ const DynamicEnumControl: React.FC = ({ value={selectedOption} onChange={handleValueChange} options={choices} - getOptionLabel={(option) => option.title || String(option.const)} + getOptionLabel={option => option.title || String(option.const)} isOptionEqualToValue={(option, value) => option.const === value.const} disabled={!enabled} sx={{ mt: 1 }} - renderInput={(params) => ( + renderInput={params => ( )} diff --git a/formulus-formplayer/src/FormEvaluationContext.tsx b/formulus-formplayer/src/FormEvaluationContext.tsx index c0dea6f8b..c2f573cac 100644 --- a/formulus-formplayer/src/FormEvaluationContext.tsx +++ b/formulus-formplayer/src/FormEvaluationContext.tsx @@ -1,6 +1,6 @@ /** * FormEvaluationContext.tsx - * + * * Provides extension functions to form evaluation context. * Allows renderers and other form components to access custom functions * defined in ext.json files. @@ -17,7 +17,7 @@ export interface FormEvaluationContextValue { * Key: function name (e.g., "getDynamicChoiceList") * Value: the actual function */ - functions: Map; + functions: Map any>; } /** @@ -30,9 +30,8 @@ const defaultContextValue: FormEvaluationContextValue = { /** * Form evaluation context */ -const FormEvaluationContext = createContext( - defaultContextValue, -); +const FormEvaluationContext = + createContext(defaultContextValue); /** * Hook to access form evaluation context @@ -49,7 +48,7 @@ export interface FormEvaluationProviderProps { /** * Map of extension functions to provide */ - functions: Map; + functions: Map any>; /** * Child components */ diff --git a/formulus-formplayer/src/builtinExtensions.ts b/formulus-formplayer/src/builtinExtensions.ts index 41177eb2a..3567c0bd9 100644 --- a/formulus-formplayer/src/builtinExtensions.ts +++ b/formulus-formplayer/src/builtinExtensions.ts @@ -1,6 +1,6 @@ /** * builtinExtensions.ts - * + * * Built-in extension functions that are always available in Formplayer. * These provide core functionality for dynamic choice lists and other features. */ @@ -26,27 +26,30 @@ declare global { */ function calculateAge(dateOfBirth: string | null | undefined): number | null { if (!dateOfBirth) return null; - + const birthDate = new Date(dateOfBirth); if (isNaN(birthDate.getTime())) return null; - + const today = new Date(); let age = today.getFullYear() - birthDate.getFullYear(); const monthDiff = today.getMonth() - birthDate.getMonth(); - - if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { + + if ( + monthDiff < 0 || + (monthDiff === 0 && today.getDate() < birthDate.getDate()) + ) { age--; } - + return age; } /** * Get dynamic choice list by querying local observations. - * + * * This function queries the native Formulus database via the WebView bridge, * then filters and formats the results based on the provided configuration. - * + * * @param queryName - Name of the query (typically a form type like 'household') * @param params - Query parameters including: * - _config.valueField: Path to extract value (e.g., 'data.hh_village_name') @@ -62,7 +65,7 @@ function calculateAge(dateOfBirth: string | null | undefined): number | null { export async function getDynamicChoiceList( queryName: string, params: Record = {}, - formData: Record = {}, + _formData: Record = {}, ): Promise> { // Check if Formulus bridge is available if (!window.formulus?.getObservationsByQuery) { @@ -76,33 +79,36 @@ export async function getDynamicChoiceList( const valueField = config.valueField || 'observationId'; const labelField = config.labelField || valueField; const distinct = config.distinct || false; - + // Build WHERE clause from params (excluding _config) // Support both 'where' and 'whereClause' for compatibility let whereClause = params.where || params.whereClause || null; - + // Get filter params (excluding _config, where, and whereClause) - const filterParams = Object.entries(params) - .filter(([key]) => key !== '_config' && key !== 'where' && key !== 'whereClause'); - + const filterParams = Object.entries(params).filter( + ([key]) => key !== '_config' && key !== 'where' && key !== 'whereClause', + ); + // Build WHERE clause from filter params if we have any if (filterParams.length > 0) { // Check if any filter values are null/undefined/empty - if so, return empty result - const hasEmptyValue = filterParams.some(([_, value]) => - value === null || value === undefined || value === '' + const hasEmptyValue = filterParams.some( + ([_, value]) => value === null || value === undefined || value === '', ); - + if (hasEmptyValue) { // Return empty choices when dependency values are not yet selected return []; } - - const conditions = filterParams.map(([fieldPath, value]) => { - // Escape single quotes in values - const escapedValue = String(value).replace(/'/g, "''"); - return `data.${fieldPath} = '${escapedValue}'`; - }).join(' AND '); - + + const conditions = filterParams + .map(([fieldPath, value]) => { + // Escape single quotes in values + const escapedValue = String(value).replace(/'/g, "''"); + return `data.${fieldPath} = '${escapedValue}'`; + }) + .join(' AND '); + // Combine with existing WHERE clause if present if (whereClause) { whereClause = `${whereClause} AND ${conditions}`; @@ -110,7 +116,7 @@ export async function getDynamicChoiceList( whereClause = conditions; } } - + // Helper to extract nested value from object path (e.g., 'data.hh_village_name') const getNestedValue = (obj: any, path: string): any => { return path.split('.').reduce((current, key) => current?.[key], obj); @@ -119,27 +125,35 @@ export async function getDynamicChoiceList( // Check if WHERE clause uses age_from_dob() syntax const usesAgeFromDob = whereClause && /age_from_dob\(/i.test(whereClause); const originalWhereClause = whereClause; - + // If using age_from_dob(), we need to filter in JavaScript after fetching // Remove age_from_dob conditions from SQL WHERE clause and filter in JS instead if (usesAgeFromDob && whereClause) { // Extract non-age conditions to keep in SQL WHERE clause // Pattern matches: age_from_dob(...) with optional NOT before it - const agePattern = /(NOT\s+)?age_from_dob\([^)]+\)\s*(>=|<=|>|<|=|!=)\s*\d+/gi; + const agePattern = + /(NOT\s+)?age_from_dob\([^)]+\)\s*(>=|<=|>|<|=|!=)\s*\d+/gi; const nonAgeConditions: string[] = []; - + // Split by AND/OR and keep non-age conditions // Handle parentheses and complex logic const parts = whereClause.split(/\s+(AND|OR)\s+/i); for (let i = 0; i < parts.length; i += 2) { const condition = parts[i].trim(); // Remove leading/trailing parentheses and NOT - const cleanCondition = condition.replace(/^NOT\s+/i, '').replace(/^\(+|\)+$/g, '').trim(); - if (cleanCondition && !agePattern.test(condition) && !agePattern.test(cleanCondition)) { + const cleanCondition = condition + .replace(/^NOT\s+/i, '') + .replace(/^\(+|\)+$/g, '') + .trim(); + if ( + cleanCondition && + !agePattern.test(condition) && + !agePattern.test(cleanCondition) + ) { nonAgeConditions.push(cleanCondition); } } - + // Rebuild WHERE clause without age conditions if (nonAgeConditions.length > 0) { whereClause = nonAgeConditions.join(' AND '); @@ -160,7 +174,8 @@ export async function getDynamicChoiceList( if (usesAgeFromDob && originalWhereClause) { // Parse the WHERE clause to extract age conditions // Pattern: age_from_dob(data.dob) >= 18 or NOT age_from_dob(data.dob) >= 18 - const ageConditionPattern = /(NOT\s+)?age_from_dob\(([^)]+)\)\s*(>=|<=|>|<|=|!=)\s*(\d+)/gi; + const ageConditionPattern = + /(NOT\s+)?age_from_dob\(([^)]+)\)\s*(>=|<=|>|<|=|!=)\s*(\d+)/gi; const ageConditions: Array<{ dobField: string; operator: string; @@ -169,154 +184,190 @@ export async function getDynamicChoiceList( position: number; beforeText: string; }> = []; - + // Find all age conditions and their positions let match; while ((match = ageConditionPattern.exec(originalWhereClause)) !== null) { const hasNot = !!(match[1] && match[1].trim().toUpperCase() === 'NOT'); const beforeText = originalWhereClause.substring(0, match.index); - + ageConditions.push({ dobField: match[2].trim(), operator: match[3], threshold: parseInt(match[4], 10), negated: hasNot, position: match.index, - beforeText: beforeText + beforeText: beforeText, }); } - + if (ageConditions.length > 0) { try { observations = observations.filter((obs: any) => { - // Get dob field (usually data.dob, but could be different) - const dobField = ageConditions[0].dobField; - const dob = getNestedValue(obs, dobField); - const age = calculateAge(dob); - - if (age === null) return false; - - // Helper to evaluate a single age condition - const evaluateCondition = (condition: typeof ageConditions[0]): boolean => { - let result: boolean; - switch (condition.operator) { - case '>=': result = age >= condition.threshold; break; - case '<=': result = age <= condition.threshold; break; - case '>': result = age > condition.threshold; break; - case '<': result = age < condition.threshold; break; - case '=': result = age === condition.threshold; break; - case '!=': result = age !== condition.threshold; break; - default: result = true; - } - return condition.negated ? !result : result; - }; - - // Parse the WHERE clause structure to determine logic between conditions - if (ageConditions.length === 1) { - // Single condition - return evaluateCondition(ageConditions[0]); - } else { - // Multiple conditions - need to parse the WHERE clause structure - // Check the text between conditions to determine AND/OR logic - const results: boolean[] = []; - const logics: string[] = []; - - for (let i = 0; i < ageConditions.length; i++) { - const condition = ageConditions[i]; - results.push(evaluateCondition(condition)); - - if (i < ageConditions.length - 1) { - // Check text between this condition and the next - const nextCondition = ageConditions[i + 1]; - const betweenText = originalWhereClause.substring( - condition.position + (originalWhereClause.substring(condition.position).match(/age_from_dob\([^)]+\)\s*(>=|<=|>|<|=|!=)\s*\d+/i)?.[0]?.length || 0), - nextCondition.position - ); - - // Check for OR (takes precedence in parsing) - if (/\bOR\b/i.test(betweenText)) { - logics.push('OR'); - } else if (/\bAND\b/i.test(betweenText)) { - logics.push('AND'); - } else { - // Default to AND if no explicit operator - logics.push('AND'); - } + // Get dob field (usually data.dob, but could be different) + const dobField = ageConditions[0].dobField; + const dob = getNestedValue(obs, dobField); + const age = calculateAge(dob); + + if (age === null) return false; + + // Helper to evaluate a single age condition + const evaluateCondition = ( + condition: (typeof ageConditions)[0], + ): boolean => { + let result: boolean; + switch (condition.operator) { + case '>=': + result = age >= condition.threshold; + break; + case '<=': + result = age <= condition.threshold; + break; + case '>': + result = age > condition.threshold; + break; + case '<': + result = age < condition.threshold; + break; + case '=': + result = age === condition.threshold; + break; + case '!=': + result = age !== condition.threshold; + break; + default: + result = true; } - } - - // Evaluate results based on logic operators - // Handle parentheses by checking if conditions are grouped - // For complex queries like (A AND B) OR C, we need to respect grouping - - // Check if there are parentheses grouping conditions - const firstConditionStart = ageConditions[0].position; - const beforeFirst = originalWhereClause.substring(0, firstConditionStart); - const lastCondition = ageConditions[ageConditions.length - 1]; - const lastConditionEnd = lastCondition.position + - (originalWhereClause.substring(lastCondition.position).match(/age_from_dob\([^)]+\)\s*(>=|<=|>|<|=|!=)\s*\d+/i)?.[0]?.length || 0); - const afterLast = originalWhereClause.substring(lastConditionEnd); - - // Check for NOT wrapping the entire block - const notBefore = /NOT\s*\(/i.test(beforeFirst.trim().slice(-10)); - const closingParenAfter = /^\s*\)/.test(afterLast); - const hasOpeningParen = /\(\s*$/.test(beforeFirst.trim().slice(-5)); - - // Check if conditions are grouped in parentheses - const isGrouped = hasOpeningParen && closingParenAfter; - - // Evaluate based on logic operators - let finalResult: boolean; - - if (logics.length === 0) { + return condition.negated ? !result : result; + }; + + // Parse the WHERE clause structure to determine logic between conditions + if (ageConditions.length === 1) { // Single condition - finalResult = results[0]; - } else if (logics.every(l => l === 'AND')) { - // All AND: all must be true - finalResult = results.every(r => r); - } else if (logics.every(l => l === 'OR')) { - // All OR: at least one must be true - finalResult = results.some(r => r); + return evaluateCondition(ageConditions[0]); } else { - // Mixed AND/OR - need to respect grouping - // For (A AND B) OR C pattern: - // - If grouped and first logic is AND, evaluate grouped part first - if (isGrouped && logics[0] === 'AND' && logics.some(l => l === 'OR')) { - // Find where OR starts (after grouped AND conditions) - const orIndex = logics.findIndex(l => l === 'OR'); - if (orIndex > 0) { - // Evaluate grouped AND conditions: (A AND B) - const groupedResult = results.slice(0, orIndex + 1).every(r => r); - // Then OR with remaining conditions: OR C - const remainingResults = results.slice(orIndex + 1); - finalResult = groupedResult || remainingResults.some(r => r); - } else { - // Fallback: OR all results - finalResult = results.some(r => r); + // Multiple conditions - need to parse the WHERE clause structure + // Check the text between conditions to determine AND/OR logic + const results: boolean[] = []; + const logics: string[] = []; + + for (let i = 0; i < ageConditions.length; i++) { + const condition = ageConditions[i]; + results.push(evaluateCondition(condition)); + + if (i < ageConditions.length - 1) { + // Check text between this condition and the next + const nextCondition = ageConditions[i + 1]; + const betweenText = originalWhereClause.substring( + condition.position + + (originalWhereClause + .substring(condition.position) + .match( + /age_from_dob\([^)]+\)\s*(>=|<=|>|<|=|!=)\s*\d+/i, + )?.[0]?.length || 0), + nextCondition.position, + ); + + // Check for OR (takes precedence in parsing) + if (/\bOR\b/i.test(betweenText)) { + logics.push('OR'); + } else if (/\bAND\b/i.test(betweenText)) { + logics.push('AND'); + } else { + // Default to AND if no explicit operator + logics.push('AND'); + } } - } else { - // Fallback: evaluate sequentially (left to right) + } + + // Evaluate results based on logic operators + // Handle parentheses by checking if conditions are grouped + // For complex queries like (A AND B) OR C, we need to respect grouping + + // Check if there are parentheses grouping conditions + const firstConditionStart = ageConditions[0].position; + const beforeFirst = originalWhereClause.substring( + 0, + firstConditionStart, + ); + const lastCondition = ageConditions[ageConditions.length - 1]; + const lastConditionEnd = + lastCondition.position + + (originalWhereClause + .substring(lastCondition.position) + .match(/age_from_dob\([^)]+\)\s*(>=|<=|>|<|=|!=)\s*\d+/i)?.[0] + ?.length || 0); + const afterLast = originalWhereClause.substring(lastConditionEnd); + + // Check for NOT wrapping the entire block + const notBefore = /NOT\s*\(/i.test(beforeFirst.trim().slice(-10)); + const closingParenAfter = /^\s*\)/.test(afterLast); + const hasOpeningParen = /\(\s*$/.test( + beforeFirst.trim().slice(-5), + ); + + // Check if conditions are grouped in parentheses + const isGrouped = hasOpeningParen && closingParenAfter; + + // Evaluate based on logic operators + let finalResult: boolean; + + if (logics.length === 0) { + // Single condition finalResult = results[0]; - for (let i = 0; i < logics.length; i++) { - if (logics[i] === 'OR') { - finalResult = finalResult || results[i + 1]; + } else if (logics.every(l => l === 'AND')) { + // All AND: all must be true + finalResult = results.every(r => r); + } else if (logics.every(l => l === 'OR')) { + // All OR: at least one must be true + finalResult = results.some(r => r); + } else { + // Mixed AND/OR - need to respect grouping + // For (A AND B) OR C pattern: + // - If grouped and first logic is AND, evaluate grouped part first + if ( + isGrouped && + logics[0] === 'AND' && + logics.some(l => l === 'OR') + ) { + // Find where OR starts (after grouped AND conditions) + const orIndex = logics.findIndex(l => l === 'OR'); + if (orIndex > 0) { + // Evaluate grouped AND conditions: (A AND B) + const groupedResult = results + .slice(0, orIndex + 1) + .every(r => r); + // Then OR with remaining conditions: OR C + const remainingResults = results.slice(orIndex + 1); + finalResult = + groupedResult || remainingResults.some(r => r); } else { - finalResult = finalResult && results[i + 1]; + // Fallback: OR all results + finalResult = results.some(r => r); + } + } else { + // Fallback: evaluate sequentially (left to right) + finalResult = results[0]; + for (let i = 0; i < logics.length; i++) { + if (logics[i] === 'OR') { + finalResult = finalResult || results[i + 1]; + } else { + finalResult = finalResult && results[i + 1]; + } } } } + + // Handle NOT wrapping + if (notBefore && closingParenAfter) { + // NOT (conditions) - negate the entire result + return !finalResult; + } + + return finalResult; } - - // Handle NOT wrapping - if (notBefore && closingParenAfter) { - // NOT (conditions) - negate the entire result - return !finalResult; - } - - return finalResult; - } }); - } catch (filterError: unknown) { + } catch (_filterError: unknown) { // If filtering fails, return empty array (better than crashing) observations = []; } @@ -334,7 +385,9 @@ export async function getDynamicChoiceList( }); // Filter out null/undefined values - choices = choices.filter(choice => choice.const != null && choice.const !== ''); + choices = choices.filter( + choice => choice.const != null && choice.const !== '', + ); // Apply distinct if requested if (distinct) { @@ -358,8 +411,8 @@ export async function getDynamicChoiceList( * Get all built-in extension functions as a Map * @returns Map of function name to function */ -export function getBuiltinExtensions(): Map { - 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..7b58e5e99 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,27 @@ class ErrorBoundary extends Component { return (

🚨 Something went wrong

-
+
Error Details (click to expand)
               {this.state.error?.toString()}
@@ -70,12 +72,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 = ({
          void;
+    disabled?: boolean;
+    loading?: boolean;
+    children?: React.ReactNode;
+    style?: React.CSSProperties;
+    className?: string;
+  }
+
+  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/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..eda803766 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/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-web/Button.tsx b/packages/components/src/react-web/Button.tsx
index 1eaaefde5..bc2b7ec5f 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,9 @@ 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 neutralDarkBg = getToken('color.neutral.800'); // Darker background for dark mode
   const textOnFill = getToken('color.neutral.white');
 
   const neutralGrey = getToken('color.neutral.600');
@@ -84,23 +132,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 +161,7 @@ const Button: React.FC = ({
     actualVariant === 'danger'
       ? isHovered
         ? 'transparent'
-        : errorRedAlpha || 'transparent'
+        : dangerDefaultBg
       : isActiveOrHovered
         ? hoverBg
         : 'transparent';

From 47c6a2f4a308e6d33b5d4c24f07e32d458cfb0ed Mon Sep 17 00:00:00 2001
From: IamLRBA 
Date: Tue, 17 Feb 2026 04:27:21 +0300
Subject: [PATCH 03/10] fix: CI - extension function types, Prettier, remove
 unused Button variable

---
 formulus-formplayer/src/components/ErrorBoundary.tsx |  2 +-
 formulus-formplayer/src/services/ExtensionsLoader.ts | 10 ++++++----
 formulus-formplayer/src/theme/theme.ts               |  2 +-
 packages/components/src/react-web/Button.tsx         |  1 -
 4 files changed, 8 insertions(+), 7 deletions(-)

diff --git a/formulus-formplayer/src/components/ErrorBoundary.tsx b/formulus-formplayer/src/components/ErrorBoundary.tsx
index 7b58e5e99..5f8f0d829 100644
--- a/formulus-formplayer/src/components/ErrorBoundary.tsx
+++ b/formulus-formplayer/src/components/ErrorBoundary.tsx
@@ -45,7 +45,7 @@ class ErrorBoundary extends Component {
             padding: (tokens as any).spacing?.[5] ?? '20px',
             margin: (tokens as any).spacing?.[5] ?? '20px',
             border: `${(tokens as any).border?.width?.medium ?? '2px'} solid ${tokens.color.semantic.error[500]}`,
-            borderRadius: tokens.border.radius.md, 
+            borderRadius: tokens.border.radius.md,
             backgroundColor: tokens.color.semantic.error[50],
             color: tokens.color.semantic.error[600],
           }}>
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/theme.ts b/formulus-formplayer/src/theme/theme.ts
index eda803766..dd2e26157 100644
--- a/formulus-formplayer/src/theme/theme.ts
+++ b/formulus-formplayer/src/theme/theme.ts
@@ -339,7 +339,7 @@ export const getThemeOptions = (mode: 'light' | 'dark'): ThemeOptions => {
               borderColor: tokens.color.semantic.error[500],
             },
           },
-          // Ensure size="small" 
+          // Ensure size="small"
           sizeSmall: {
             borderRadius: parsePx(tokens.border.radius.md),
             '& fieldset': {
diff --git a/packages/components/src/react-web/Button.tsx b/packages/components/src/react-web/Button.tsx
index bc2b7ec5f..5f0eea0dc 100644
--- a/packages/components/src/react-web/Button.tsx
+++ b/packages/components/src/react-web/Button.tsx
@@ -113,7 +113,6 @@ const Button: React.FC = ({
   const errorRed = getToken('color.semantic.error.500');
   const errorRedDark = getToken('color.semantic.error.600');
   const errorRedLight = getToken('color.semantic.error.50');
-  const neutralDarkBg = getToken('color.neutral.800'); // Darker background for dark mode
   const textOnFill = getToken('color.neutral.white');
 
   const neutralGrey = getToken('color.neutral.600');

From 8bd53a983fe3e9efd53ebe99502a712fe19e6abf Mon Sep 17 00:00:00 2001
From: IamLRBA 
Date: Tue, 17 Feb 2026 04:38:01 +0300
Subject: [PATCH 04/10] fix: use typed Map for extension functions to resolve
 TS2345

---
 formulus-formplayer/src/builtinExtensions.ts | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

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;
 }

From 88db7169607383631fd4307cb048928d3de5dba1 Mon Sep 17 00:00:00 2001
From: IamLRBA 
Date: Tue, 17 Feb 2026 06:55:34 +0300
Subject: [PATCH 05/10] Match danger button style to formplayer (colors,
 background, pressed state)

---
 .../components/src/react-native/Button.tsx    | 46 +++++++++++--------
 1 file changed, 28 insertions(+), 18 deletions(-)

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';

From c0dda76baeeae78e03177f54d26c4fce0b18fd5b Mon Sep 17 00:00:00 2001
From: IamLRBA 
Date: Tue, 17 Feb 2026 12:10:21 +0300
Subject: [PATCH 06/10] Add explicit token types and use TS path mapping for
 component types

---
 .../src/components/ErrorBoundary.tsx          | 20 +++---
 .../src/components/FormLayout.tsx             |  2 +-
 formulus-formplayer/src/ode-components.d.ts   | 25 --------
 .../src/theme/tokens-adapter.ts               | 62 ++++++++++++++++++-
 formulus-formplayer/tsconfig.json             |  7 ++-
 5 files changed, 78 insertions(+), 38 deletions(-)
 delete mode 100644 formulus-formplayer/src/ode-components.d.ts

diff --git a/formulus-formplayer/src/components/ErrorBoundary.tsx b/formulus-formplayer/src/components/ErrorBoundary.tsx
index 5f8f0d829..aa82eb534 100644
--- a/formulus-formplayer/src/components/ErrorBoundary.tsx
+++ b/formulus-formplayer/src/components/ErrorBoundary.tsx
@@ -42,27 +42,27 @@ class ErrorBoundary extends Component {
       return (
         

🚨 Something went wrong

+ style={{ marginTop: tokens.spacing?.[3] ?? '12px' }}> Error Details (click to expand)
               {this.state.error?.toString()}
@@ -72,8 +72,8 @@ class ErrorBoundary extends Component {