Expand theme system and refresh UI components

This commit is contained in:
Amer Agovic
2026-04-29 22:05:47 -05:00
parent 4177411d3f
commit 94744b3e59
28 changed files with 5290 additions and 777 deletions
+3450 -87
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -32,6 +32,7 @@
"test:watch": "node --localstorage-file=.node-localstorage --test --watch test/**/*.test.js"
},
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@tamagui/config": "^1.144.2",
"@tamagui/core": "^1.144.2",
"@tamagui/lucide-icons": "^1.144.2",
+1
View File
@@ -16,6 +16,7 @@ export * from './data/index.js';
export * from './ui/App.jsx';
export * from './ui/components/index.js';
export * from './ui/runtime/general-settings.js';
export * from './ui/styles/index.js';
// Re-export security modules
export * from './security/index.js';
+21 -14
View File
@@ -4,13 +4,20 @@ import { getRouterPath, setRouterPath } from '../../platform/compat.js';
import { securityService, useSecurityState } from '../runtime/security-service.js';
import { Panel } from '../../ui/components/Panel.jsx';
const LoginField = forwardRef(function LoginField({ id, label, ...props }, ref) {
const LoginField = forwardRef(function LoginField({ id, label, error, ...props }, ref) {
return (
<YStack gap="$2">
<Label htmlFor={id} size="$3" color="$color" fontWeight="600">
<Label htmlFor={id} size="$3" color="$textPrimary" fontWeight="600">
{label}
</Label>
<Input ref={ref} id={id} {...props} />
<Input
ref={ref}
id={id}
backgroundColor="$bgPanel"
borderColor={error ? '$danger' : '$lineSubtle'}
focusStyle={{ borderColor: error ? '$danger' : '$accent' }}
{...props}
/>
</YStack>
);
});
@@ -64,8 +71,8 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
icon="login"
title={title}
width="100%"
headerFront={{ color: '$accentColor' }}
headerBack={{ backgroundColor: '$accentSurface' }}
headerFront={{ color: '$textPrimary' }}
headerBack={{ backgroundColor: '$bgPanel' }}
>
<YStack
tag="form"
@@ -73,12 +80,13 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
onSubmit={handleFormSubmit}
autoComplete="on"
>
<Paragraph color="$color" opacity={0.78}>
<Paragraph color="$textMuted">
{subtitle}
</Paragraph>
<LoginField
id="login-identifier"
label="Username or email"
error={Boolean(errorMessage)}
placeholder="Username or email"
value={identifier}
onChangeText={setIdentifier}
@@ -96,6 +104,7 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
<LoginField
id="login-password"
label="Password"
error={Boolean(errorMessage)}
ref={passwordInputRef}
placeholder="Password"
value={password}
@@ -111,30 +120,28 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
onKeyDown={handlePasswordKeyDown}
/>
{errorMessage ? (
<Text color="#ef4444" fontSize="$4">
<Text color="$danger" fontSize="$4">
{errorMessage}
</Text>
) : null}
<Button
themeInverse
backgroundColor="$accentColor"
color="white"
theme="accent"
onPress={handleSubmit}
disabled={security.loading || !security.enabled || !security.initialized}
>
{security.loading ? 'Signing In...' : 'Sign In'}
</Button>
{!security.enabled ? (
<Paragraph fontSize="$3" color="#8b5e3c">
<Paragraph fontSize="$3" color="$textSecondary">
Identity is currently disabled in the active app profile.
</Paragraph>
) : null}
{security.enabled && !security.initialized ? (
<Paragraph fontSize="$3" color="#8b5e3c">
<Paragraph fontSize="$3" color="$textSecondary">
Security is still initializing.
</Paragraph>
) : null}
<Paragraph fontSize="$3" color="$color" opacity={0.65}>
<Paragraph fontSize="$3" color="$textMuted">
Demo credentials: admin / admin or demo / demo
</Paragraph>
</YStack>
@@ -153,7 +160,7 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
alignItems="center"
justifyContent="center"
padding="$6"
backgroundColor="$background"
backgroundColor="$bgPage"
>
<YStack width="100%" maxWidth={520}>
{content}
+4 -4
View File
@@ -36,22 +36,22 @@ export function AppInfo({ appName, swStatus, storageBackend, menuItems = [], ini
<XStack gap="$2">
<Text>App:</Text>
<Text fontWeight="bold">{appName || 'Loading...'}</Text>
<Text fontWeight="600">{appName || 'Loading...'}</Text>
</XStack>
<XStack gap="$2">
<Text>Service Worker:</Text>
<Text fontWeight="bold">{swStatus}</Text>
<Text fontWeight="600">{swStatus}</Text>
</XStack>
<XStack gap="$2">
<Text>Storage Backend:</Text>
<Text fontWeight="bold">{storageBackend}</Text>
<Text fontWeight="600">{storageBackend}</Text>
</XStack>
{initialized && menuItems.length > 0 && (
<YStack marginTop="$4" gap="$2">
<Text fontWeight="bold">Menu Items:</Text>
<Text fontWeight="600">Menu Items:</Text>
{menuItems.map((item) => (
<XStack key={item.id} gap="$2">
<Text> {item.label}</Text>
+59 -32
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { Button, Input, Paragraph, ScrollView, Separator, Spinner, Text, XStack, YStack } from 'tamagui';
import { getIcon } from './IconMapper.jsx';
import { normalizeColumnsArray } from './grid/utils.js';
import { getTypographyRoleProps } from '../styles/index.js';
const EMPTY_COLUMNS = [];
const EMPTY_ACTIONS = [];
@@ -43,15 +44,15 @@ function SummaryCards({ summary }) {
minWidth={140}
padding="$3"
borderWidth={1}
borderColor="$borderColor"
borderRadius="$4"
backgroundColor="$accentSurface"
borderColor="$lineSubtle"
borderRadius="$radiusMd"
backgroundColor="$bgPanel"
gap="$1"
>
<Text fontSize="$3" color="$color" opacity={0.7}>
<Text fontSize="$3" color="$textMuted">
{item.label}
</Text>
<Text fontSize="$7" fontWeight="700" color="$accentColor">
<Text fontSize="$7" fontWeight="700" color="$accent">
{normalizeSummaryValue(item.value)}
</Text>
</YStack>
@@ -63,7 +64,8 @@ function SummaryCards({ summary }) {
function HeaderCell({ column, orderBy, order, onSort }) {
const sortable = column.sortable !== false;
const isActive = orderBy === column.id;
const arrow = isActive ? (order === 'asc' ? '↑' : '↓') : '';
const CaretUp = getIcon('caret-up');
const CaretDown = getIcon('caret-down');
const justifyContent = getColumnJustify(column.align);
return (
@@ -81,10 +83,23 @@ function HeaderCell({ column, orderBy, order, onSort }) {
padding={0}
justifyContent={justifyContent}
width="100%"
hoverStyle={sortable ? { backgroundColor: '$bgPage' } : undefined}
pressStyle={sortable ? { backgroundColor: '$bgPanelElev' } : undefined}
>
<Text fontSize="$4" fontWeight="700" color="$color" textAlign={column.align || 'left'} width="100%">
{column.label}{arrow ? ` ${arrow}` : ''}
<XStack width="100%" alignItems="center" justifyContent={justifyContent} gap="$2">
<Text {...getTypographyRoleProps('tableHeader')} textAlign={column.align || 'left'} numberOfLines={1}>
{column.label}
</Text>
{sortable ? (
isActive ? (
order === 'asc'
? (CaretUp ? <CaretUp size="xs" color="$textSecondary" /> : null)
: (CaretDown ? <CaretDown size="xs" color="$textSecondary" /> : null)
) : (
CaretDown ? <CaretDown size="xs" color="$textMuted" style={{ opacity: 0.6 }} /> : null
)
) : null}
</XStack>
</Button>
</XStack>
);
@@ -133,7 +148,11 @@ export function DirView({
bodyMaxHeight = 480,
onRowClick = null,
onRowPress = null,
onRefresh = null
onRefresh = null,
showHeader = true,
showSummary = true,
density = 'comfortable',
striped = false
}) {
const [dataVersion, setDataVersion] = useState(0);
const [records, setRecords] = useState([]);
@@ -155,6 +174,7 @@ export function DirView({
const PreviousPageIcon = getIcon('chevron-left');
const NextPageIcon = getIcon('chevron-right');
const LastPageIcon = getIcon('last-page');
const paddingRow = density === 'compact' ? '$2' : density === 'spacious' ? '$4' : '$3';
useEffect(() => {
if (!dataModel?.subscribe) {
@@ -275,9 +295,10 @@ export function DirView({
return (
<YStack gap="$4" width="100%">
{showHeader ? (
<XStack justifyContent="space-between" alignItems="center" gap="$4" flexWrap="wrap">
<XStack alignItems="center" gap="$3" flex={1} minWidth={240} flexWrap="wrap">
<Text fontSize="$8" fontWeight="800" color="$accentColor">
<Text {...getTypographyRoleProps('sectionTitle')}>
{title}
</Text>
{topLeftContent}
@@ -290,6 +311,9 @@ export function DirView({
placeholder={searchConfig.placeholder || 'Search records...'}
value={effectiveSearchTerm}
onChangeText={updateSearchTerm}
backgroundColor="$bgPanel"
borderColor="$lineSubtle"
focusStyle={{ borderColor: '$accent' }}
/>
) : null}
{effectiveToolbarItems.map(renderToolbarButton)}
@@ -298,26 +322,27 @@ export function DirView({
chromeless
circular
aria-label="Refresh directory"
icon={RefreshIcon ? <RefreshIcon size={16} /> : undefined}
icon={RefreshIcon ? <RefreshIcon size="sm" color="$textSecondary" /> : undefined}
onPress={handleRefresh}
disabled={loading}
/>
{topRightContent}
</XStack>
</XStack>
) : null}
{error ? (
<YStack padding="$3" borderRadius="$4" backgroundColor="#fef2f2" borderWidth={1} borderColor="#fecaca">
<Text color="#b91c1c">{error}</Text>
<YStack padding="$3" borderRadius="$radiusMd" backgroundColor="$dangerBg" borderWidth={1} borderColor="$danger">
<Text color="$danger" fontWeight="600">{error}</Text>
</YStack>
) : null}
<SummaryCards summary={summary} />
{showSummary ? <SummaryCards summary={summary} /> : null}
{bodyHeaderContent}
<YStack borderWidth={1} borderColor="$borderColor" borderRadius="$5" overflow="hidden" backgroundColor="$background">
<XStack padding="$3" gap="$3" backgroundColor="$accentSurface" borderBottomWidth={1} borderBottomColor="$borderColor">
<YStack borderWidth={1} borderColor="$lineSubtle" borderRadius="$radiusLg" overflow="hidden" backgroundColor="$bgPanel">
<XStack padding={paddingRow} gap="$3" backgroundColor="transparent" borderBottomWidth={1} borderBottomColor="$lineSubtle">
{resolvedColumns.map((column) => (
<HeaderCell
key={column.id}
@@ -334,27 +359,29 @@ export function DirView({
))}
</XStack>
<ScrollView maxHeight={bodyMaxHeight}>
<ScrollView {...(bodyMaxHeight != null ? { maxHeight: bodyMaxHeight } : {})}>
<YStack>
{loading ? (
<XStack justifyContent="center" padding="$6">
<Spinner size="large" color="$accentColor" />
<Spinner size="large" color="$accent" />
</XStack>
) : records.length === 0 ? (
<YStack padding="$6" alignItems="center">
<Paragraph color="$color" opacity={0.7}>
No records found.
<YStack padding="$6" alignItems="center" gap="$2">
<Paragraph color="$textSecondary">
No records found
</Paragraph>
<Text color="$textMuted" fontSize="$3">Try adjusting your search or filters.</Text>
</YStack>
) : (
records.map((record, index) => (
<YStack key={record?.[dataModel?.getIdField?.() || 'id'] || index}>
<XStack
padding="$3"
padding={paddingRow}
gap="$3"
alignItems="center"
hoverStyle={{ backgroundColor: '$accentSurface' }}
pressStyle={{ backgroundColor: '$accentSurface' }}
backgroundColor={striped && index % 2 === 1 ? '$bgPage' : 'transparent'}
hoverStyle={{ backgroundColor: '$bgPage' }}
pressStyle={{ backgroundColor: '$bgPanelElev' }}
cursor={effectiveRowPress ? 'pointer' : undefined}
onPress={() => effectiveRowPress?.(record)}
>
@@ -362,7 +389,7 @@ export function DirView({
<RowCell key={column.id} column={column} record={record} />
))}
</XStack>
{index < records.length - 1 ? <Separator /> : null}
{index < records.length - 1 ? <Separator borderColor="$lineSubtle" /> : null}
</YStack>
))
)}
@@ -373,15 +400,15 @@ export function DirView({
{bodyFooterContent}
<XStack justifyContent="space-between" alignItems="center" gap="$3" flexWrap="wrap">
<Text color="$color" opacity={0.7}>
<Text color="$textMuted">
Rows: {totalRecords}
</Text>
<XStack alignItems="center" gap="$2" flexWrap="wrap">
<XStack alignItems="center" gap="$2" flexWrap="wrap" padding="$1" borderWidth={1} borderColor="$lineSubtle" borderRadius="$radiusMd" backgroundColor="$bgPanel">
<Button
size="$3"
chromeless
aria-label="First page"
icon={FirstPageIcon ? <FirstPageIcon size={16} /> : undefined}
icon={FirstPageIcon ? <FirstPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => setCurrentPage(1)}
disabled={currentPage === 1 || loading}
/>
@@ -389,18 +416,18 @@ export function DirView({
size="$3"
chromeless
aria-label="Previous page"
icon={PreviousPageIcon ? <PreviousPageIcon size={16} /> : undefined}
icon={PreviousPageIcon ? <PreviousPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => setCurrentPage((value) => Math.max(1, value - 1))}
disabled={currentPage === 1 || loading}
/>
<Text color="$color" opacity={0.75}>
<Text color="$textSecondary">
Page {currentPage} of {totalPages}
</Text>
<Button
size="$3"
chromeless
aria-label="Next page"
icon={NextPageIcon ? <NextPageIcon size={16} /> : undefined}
icon={NextPageIcon ? <NextPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => setCurrentPage((value) => Math.min(totalPages, value + 1))}
disabled={currentPage >= totalPages || loading}
/>
@@ -408,7 +435,7 @@ export function DirView({
size="$3"
chromeless
aria-label="Last page"
icon={LastPageIcon ? <LastPageIcon size={16} /> : undefined}
icon={LastPageIcon ? <LastPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => setCurrentPage(totalPages)}
disabled={currentPage >= totalPages || loading}
/>
+107 -39
View File
@@ -1,19 +1,43 @@
import React from 'react';
import { Adapt, Button, Input, Label, Paragraph, Select, Separator, Sheet, Text, TextArea, XStack, YStack } from 'tamagui';
import { Check, ChevronDown, ChevronUp } from '@tamagui/lucide-icons';
import {
Adapt,
Button,
Input,
Label,
Paragraph,
Select,
Separator,
Sheet,
Switch,
Text,
TextArea,
XStack,
YStack,
} from 'tamagui';
import { getIcon } from './IconMapper.jsx';
import { pickFile } from '../../platform/compat.js';
import { getTypographyRoleProps } from '../styles/index.js';
const ChevronDown = getIcon('chevron-down');
const ChevronUp = getIcon('chevron-up');
const CheckIcon = getIcon('check');
/**
* Wrapper that renders a label, the field's children, and either a helper
* line or an error line below. Error styling reads from the semantic
* `$danger` token so it follows the active theme.
*/
function FieldShell({ label, helperText, error, children }) {
return (
<YStack gap="$2" width="100%">
{label ? (
<Label color="$color" fontWeight="600">
<Label {...getTypographyRoleProps('fieldLabel')}>
{label}
</Label>
) : null}
{children}
{error || helperText ? (
<Paragraph color={error ? '#dc2626' : '$color'} opacity={error ? 1 : 0.7} fontSize="$3">
<Paragraph color={error ? '$danger' : '$textMuted'} fontSize="$3">
{error || helperText}
</Paragraph>
) : null}
@@ -21,29 +45,61 @@ function FieldShell({ label, helperText, error, children }) {
);
}
/**
* Style fragment shared by all chip-like buttons (multiselect, radio).
* Selected: tinted accent background, accent text + border.
* Unselected: panel background, hairline border, primary text.
*/
function chipProps(selected, disabled) {
return selected
? {
backgroundColor: '$accentBg',
color: '$accent',
borderColor: '$accent',
borderWidth: 1,
hoverStyle: { backgroundColor: '$accentBgHover' },
pressStyle: { backgroundColor: '$accentBgHover' },
disabled,
}
: {
backgroundColor: '$bgPanel',
color: '$textPrimary',
borderColor: '$lineSubtle',
borderWidth: 1,
hoverStyle: { backgroundColor: '$bgPage', borderColor: '$lineStrong' },
pressStyle: { backgroundColor: '$bgPage' },
disabled,
};
}
function SelectField({ label, value, options = [], placeholder, onValueChange, error, helperText, disabled = false }) {
return (
<FieldShell label={label} error={error} helperText={helperText}>
<Select value={value ?? ''} onValueChange={onValueChange} disabled={disabled}>
<Select.Trigger iconAfter={ChevronDown}>
<Select.Trigger
iconAfter={ChevronDown ? <ChevronDown size="sm" color="$textSecondary" /> : undefined}
borderColor={error ? '$danger' : '$lineSubtle'}
backgroundColor="$bgPanel"
focusStyle={{ borderColor: error ? '$danger' : '$accent' }}
>
<Select.Value placeholder={placeholder || label || 'Select'} />
</Select.Trigger>
<Adapt when="sm" platform="touch">
<Sheet modal dismissOnSnapToBottom snapPoints={[55]}>
<Sheet.Frame>
<Sheet.Frame backgroundColor="$bgPanel">
<Sheet.ScrollView>
<Adapt.Contents />
</Sheet.ScrollView>
</Sheet.Frame>
<Sheet.Overlay />
<Sheet.Overlay backgroundColor="$scrim" />
</Sheet>
</Adapt>
<Select.Content zIndex={200000}>
<Select.ScrollUpButton alignItems="center" justifyContent="center" position="relative" width="100%" height="$3">
<YStack zIndex={10}>
<ChevronUp size={18} />
{ChevronUp ? <ChevronUp size="sm" color="$textSecondary" /> : null}
</YStack>
</Select.ScrollUpButton>
@@ -53,7 +109,7 @@ function SelectField({ label, value, options = [], placeholder, onValueChange, e
<Select.Item key={option.value} index={index} value={String(option.value)}>
<Select.ItemText>{option.label}</Select.ItemText>
<Select.ItemIndicator marginLeft="auto">
<Check size={16} />
{CheckIcon ? <CheckIcon size="sm" color="$accent" /> : null}
</Select.ItemIndicator>
</Select.Item>
))}
@@ -62,7 +118,7 @@ function SelectField({ label, value, options = [], placeholder, onValueChange, e
<Select.ScrollDownButton alignItems="center" justifyContent="center" position="relative" width="100%" height="$3">
<YStack zIndex={10}>
<ChevronDown size={18} />
{ChevronDown ? <ChevronDown size="sm" color="$textSecondary" /> : null}
</YStack>
</Select.ScrollDownButton>
</Select.Content>
@@ -74,7 +130,7 @@ function SelectField({ label, value, options = [], placeholder, onValueChange, e
async function handleFilePick(fieldId, onChange, props = {}) {
const selection = await pickFile({
accept: props.accept || '*',
readAs: props.readAs || null
readAs: props.readAs || null,
});
if (!selection?.file) {
@@ -103,17 +159,17 @@ export function FormField({
const fieldValue = value ?? (type === 'multiselect' ? [] : type === 'checkbox' ? false : '');
if (type === 'divider') {
return <Separator />;
return <Separator borderColor="$lineSubtle" />;
}
if (type === 'title') {
return (
<YStack gap="$1">
<Text fontSize="$7" fontWeight="700" color="$accentColor">
<Text fontSize="$6" fontWeight="600" color="$textPrimary">
{label}
</Text>
{helperText ? (
<Paragraph color="$color" opacity={0.7}>
<Paragraph color="$textMuted">
{helperText}
</Paragraph>
) : null}
@@ -151,12 +207,7 @@ export function FormField({
<Button
key={option.value}
size="$3"
theme={selected ? 'active' : undefined}
backgroundColor={selected ? '$accentColor' : '$background'}
color={selected ? 'white' : '$color'}
borderWidth={1}
borderColor={selected ? '$accentColor' : '$borderColor'}
disabled={disabled}
{...chipProps(selected, disabled)}
onPress={() => {
const nextValues = selected
? selectedValues.filter((item) => item !== String(option.value))
@@ -173,21 +224,28 @@ export function FormField({
);
}
// True boolean control instead of a Button-pretending-to-be-a-checkbox.
// Switch is the right semantic for an on/off setting.
if (type === 'checkbox') {
return (
<FieldShell label={label} error={error} helperText={helperText}>
<Button
<XStack alignItems="center" gap="$3">
<Switch
id={id}
size="$3"
alignSelf="flex-start"
backgroundColor={fieldValue ? '$accentColor' : '$background'}
color={fieldValue ? 'white' : '$color'}
borderWidth={1}
borderColor={fieldValue ? '$accentColor' : '$borderColor'}
checked={Boolean(fieldValue)}
disabled={disabled}
onPress={() => onChange?.(id, !fieldValue)}
onCheckedChange={(next) => onChange?.(id, next)}
backgroundColor={fieldValue ? '$accent' : '$lineStrong'}
borderColor={error ? '$danger' : 'transparent'}
borderWidth={error ? 1 : 0}
>
<Switch.Thumb animation="quick" backgroundColor="$bgPanel" />
</Switch>
<Text color="$textSecondary" fontSize="$3">
{fieldValue ? 'Enabled' : 'Disabled'}
</Button>
</Text>
</XStack>
</FieldShell>
);
}
@@ -202,11 +260,7 @@ export function FormField({
<Button
key={option.value}
size="$3"
backgroundColor={selected ? '$accentColor' : '$background'}
color={selected ? 'white' : '$color'}
borderWidth={1}
borderColor={selected ? '$accentColor' : '$borderColor'}
disabled={disabled}
{...chipProps(selected, disabled)}
onPress={() => onChange?.(id, option.value)}
>
{option.label}
@@ -222,10 +276,18 @@ export function FormField({
return (
<FieldShell label={label} error={error} helperText={helperText}>
<XStack alignItems="center" gap="$3" flexWrap="wrap">
<Button disabled={disabled} onPress={() => handleFilePick(id, onChange, props)}>
<Button
size="$3"
chromeless
backgroundColor="$bgPanel"
borderColor="$lineSubtle"
borderWidth={1}
disabled={disabled}
onPress={() => handleFilePick(id, onChange, props)}
>
{fieldValue?.name ? 'Replace File' : 'Choose File'}
</Button>
<Text color="$color" opacity={0.75}>
<Text color="$textMuted">
{fieldValue?.name || 'No file selected'}
</Text>
</XStack>
@@ -243,6 +305,9 @@ export function FormField({
disabled={disabled}
readOnly={readOnly}
minHeight={120}
backgroundColor="$bgPanel"
borderColor={error ? '$danger' : '$lineSubtle'}
focusStyle={{ borderColor: error ? '$danger' : '$accent' }}
/>
</FieldShell>
);
@@ -254,11 +319,11 @@ export function FormField({
<YStack
padding="$3"
borderWidth={1}
borderColor="$borderColor"
borderRadius="$4"
backgroundColor="$accentSurface"
borderColor="$lineSubtle"
borderRadius="$radiusMd"
backgroundColor="$bgPage"
>
<Text>{String(fieldValue || '')}</Text>
<Text color="$textPrimary">{String(fieldValue || '')}</Text>
</YStack>
</FieldShell>
);
@@ -275,6 +340,9 @@ export function FormField({
type={type === 'datetime' ? 'datetime-local' : type}
keyboardType={type === 'number' ? 'numeric' : undefined}
autoCapitalize={type === 'email' || type === 'password' ? 'none' : undefined}
backgroundColor="$bgPanel"
borderColor={error ? '$danger' : '$lineSubtle'}
focusStyle={{ borderColor: error ? '$danger' : '$accent' }}
/>
</FieldShell>
);
+3 -1
View File
@@ -134,7 +134,9 @@ export function FormView({
) : null}
{loading ? (
<XStack justifyContent="flex-end">
<Button disabled>Saving...</Button>
<Button size="$3" theme="accent" disabled>
Saving...
</Button>
</XStack>
) : null}
</YStack>
+431 -271
View File
@@ -1,324 +1,484 @@
/**
* IconMapper - Maps icon names to Lucide icons from @tamagui/lucide-icons
* Cross-platform compatible (web + React Native)
* IconMapper - Phosphor-backed icon system with Tamagui theme integration.
*
* Why a wrapper layer:
* - Lets us swap icon families without touching call sites.
* - Each style preset declares an `iconWeight` (regular / light / bold /
* duotone ...). The wrapper reads the active preset and applies that
* weight, so themes feel cohesive without per-call configuration.
* - Lets callers pass Tamagui token strings like `"$accent"` or
* `"$textPrimary"` for `color`. Phosphor itself only accepts CSS
* colors; the wrapper resolves tokens against the live theme.
*
* Call-site contract (back-compatible with Lucide era):
* const Icon = getIcon('home');
* <Icon size="md" color="$textPrimary" /> // semantic size
* <Icon size={20} color="$accent" /> // numeric size
* <Icon size="sm" color="#0a84ff" weight="bold" /> // override weight
*
* Semantic sizes (px):
* xs:14 sm:16 md:20 lg:24 xl:32
*/
import React from 'react';
import { useTheme } from '@tamagui/core';
import { SizableText } from 'tamagui';
import {
AlertCircle,
AlertTriangle,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowUpDown,
BarChart3,
// navigation & shell
House,
Gear,
List as MenuList,
MagnifyingGlass,
Bell,
Bold,
Bookmark,
Book,
Calendar,
Camera,
Check,
CheckCircle,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ChevronUp,
Clock,
Cloud,
CloudDownload,
CloudUpload,
Clipboard,
Code,
Copy,
Crop,
DollarSign,
Download,
Eye,
EyeOff,
File,
FileText,
Filter,
Folder,
FolderOpen,
Globe,
HardDrive,
Heart,
HelpCircle,
Home,
Image,
Info,
Italic,
LayoutDashboard,
Library,
Link,
Lock,
LogIn,
LogOut,
Mail,
Map,
MapPin,
Menu,
MessageCircle,
MessageSquare,
Minus,
MoreHorizontal,
MoreVertical,
Navigation,
Network,
Paperclip,
Pause,
Phone,
Play,
Plus,
Power,
Printer,
RefreshCw,
RotateCw,
Save,
Scissors,
Search,
Send,
Settings,
Share2,
Signal,
SkipBack,
SkipForward,
Square,
SquarePen,
Star,
SlidersHorizontal,
Sun,
Trash2,
TrendingUp,
Underline,
Unlock,
Upload,
User,
UserCircle,
Users,
Video,
Volume1,
Volume2,
VolumeX,
Wifi,
WifiOff,
Envelope,
// files
File,
FileText,
Folder,
FolderOpen,
Article,
Book,
Books,
// actions
PencilSimple,
Trash,
FloppyDisk,
X,
ZoomIn,
ZoomOut
} from '@tamagui/lucide-icons';
Check,
Plus,
Minus,
// arrows / chevrons
ArrowRight,
ArrowLeft,
ArrowUp,
ArrowDown,
CaretRight,
CaretLeft,
CaretUp,
CaretDown,
CaretDoubleRight,
CaretDoubleLeft,
DotsThreeVertical,
DotsThree,
// auth
SignIn,
SignOut,
Lock,
LockOpen,
// dashboards
SquaresFour,
ChartBar,
TrendUp,
CurrencyDollar,
// status
Info,
Warning,
WarningCircle,
CheckCircle,
Question,
// visibility
Eye,
EyeSlash,
// media
Image as ImageIcon,
Camera,
VideoCamera,
Play,
Pause,
Square,
// communication
ChatCircle,
ChatText,
PaperPlaneTilt,
Phone,
// content
Copy,
Scissors,
Clipboard,
Link,
Paperclip,
// controls
FunnelSimple,
ArrowsDownUp,
ArrowClockwise,
Download,
Upload,
ShareNetwork,
Globe,
Sliders,
SkipBack,
SkipForward,
// favorites
Heart,
Star,
BookmarkSimple,
// time / location
Calendar,
Clock,
MapPin,
MapTrifold,
NavigationArrow,
// system
Power,
Sun,
WifiHigh,
WifiSlash,
// volume
SpeakerHigh,
SpeakerLow,
SpeakerSlash,
// editing
MagnifyingGlassPlus,
MagnifyingGlassMinus,
Crop,
ArrowsClockwise,
// formatting
TextB,
TextItalic,
TextUnderline,
Code,
// cloud / storage
Cloud,
CloudArrowUp,
CloudArrowDown,
HardDrive,
// network
Network,
CellSignalHigh,
// print / misc
Printer,
Circle,
Lightning,
} from '@phosphor-icons/react';
import { themeManager } from '../theme-controller.js';
import { getIconWeight, ICON_WEIGHTS } from '../styles/index.js';
/* -------------------------------------------------------------------------- */
/* Sizing */
/* -------------------------------------------------------------------------- */
const SIZE_PX = Object.freeze({
xs: 14,
sm: 16,
md: 20,
lg: 24,
xl: 32,
});
const DEFAULT_SIZE = 'sm';
function resolveSize(size) {
if (typeof size === 'number' && Number.isFinite(size)) return size;
if (typeof size === 'string' && SIZE_PX[size] != null) return SIZE_PX[size];
// numeric string ('20', '24px') — best-effort parse
if (typeof size === 'string') {
const n = parseFloat(size);
if (!Number.isNaN(n)) return n;
}
return SIZE_PX[DEFAULT_SIZE];
}
/* -------------------------------------------------------------------------- */
/* Color resolution */
/* -------------------------------------------------------------------------- */
/**
* Icon name to Lucide icon component mapping
* Maps Material Design icon names to Lucide equivalents
* Phosphor's `color` prop only accepts plain CSS colors. Callers often pass
* Tamagui token strings like `"$accent"`. We resolve those against the live
* theme via Tamagui's `useTheme()`. Anything else is passed through.
*/
function resolveColor(color, theme) {
if (color == null) return undefined;
if (typeof color !== 'string') return color;
if (color === 'currentColor') return 'currentColor';
if (!color.startsWith('$')) return color;
const key = color.slice(1);
const token = theme?.[key];
if (!token) return undefined;
if (typeof token.val !== 'undefined') return token.val;
if (typeof token.get === 'function') return token.get();
return String(token);
}
/* -------------------------------------------------------------------------- */
/* Active icon weight (subscribes to themeManager) */
/* -------------------------------------------------------------------------- */
function useActiveIconWeight() {
const [name, setName] = React.useState(() => themeManager.getStyleThemeName());
React.useEffect(() => {
return themeManager.subscribe((s) => setName(s.styleThemeName));
}, []);
return getIconWeight(name);
}
/* -------------------------------------------------------------------------- */
/* Wrapper factory */
/* -------------------------------------------------------------------------- */
function wrap(Icon, displayName) {
const Wrapped = React.forwardRef(function PhosphorIcon(
{ size = DEFAULT_SIZE, color = '$textPrimary', weight, ...rest },
ref,
) {
const theme = useTheme();
const presetWeight = useActiveIconWeight();
const finalWeight = ICON_WEIGHTS.includes(weight) ? weight : presetWeight;
return (
<Icon
ref={ref}
size={resolveSize(size)}
color={resolveColor(color, theme)}
weight={finalWeight}
{...rest}
/>
);
});
Wrapped.displayName = `Icon(${displayName})`;
return Wrapped;
}
/* -------------------------------------------------------------------------- */
/* Name → Phosphor wrapper map */
/* */
/* Keys preserve every Lucide-era alias used in the codebase so existing */
/* call sites work without changes. Add new aliases freely — keep the */
/* vocabulary stable. */
/* -------------------------------------------------------------------------- */
const iconMap = {
// Navigation & UI
'home': Home,
'settings': Settings,
'user': User,
'person': User,
'account': UserCircle,
'menu': Menu,
'hamburger': Menu,
'search': Search,
'bell': Bell,
'notifications': Bell,
'mail': Mail,
'email': Mail,
// ── Navigation & UI ────────────────────────────────────────────────────
'home': wrap(House, 'House'),
'settings': wrap(Gear, 'Gear'),
'gear': wrap(Gear, 'Gear'),
'user': wrap(User, 'User'),
'person': wrap(User, 'User'),
'account': wrap(UserCircle, 'UserCircle'),
'menu': wrap(MenuList, 'List'),
'hamburger': wrap(MenuList, 'List'),
'list': wrap(MenuList, 'List'),
'search': wrap(MagnifyingGlass, 'MagnifyingGlass'),
'bell': wrap(Bell, 'Bell'),
'notifications': wrap(Bell, 'Bell'),
'mail': wrap(Envelope, 'Envelope'),
'email': wrap(Envelope, 'Envelope'),
// Files & Folders
'file': File,
'folder': Folder,
'folder-open': FolderOpen,
// ── Files & Folders ────────────────────────────────────────────────────
'file': wrap(File, 'File'),
'folder': wrap(Folder, 'Folder'),
'folder-open': wrap(FolderOpen, 'FolderOpen'),
// Actions
'edit': SquarePen,
'delete': Trash2,
'save': Save,
'close': X,
'x': X,
'check': Check,
'plus': Plus,
'minus': Minus,
// ── Actions ────────────────────────────────────────────────────────────
'edit': wrap(PencilSimple, 'PencilSimple'),
'pencil': wrap(PencilSimple, 'PencilSimple'),
'delete': wrap(Trash, 'Trash'),
'trash': wrap(Trash, 'Trash'),
'save': wrap(FloppyDisk, 'FloppyDisk'),
'close': wrap(X, 'X'),
'x': wrap(X, 'X'),
'check': wrap(Check, 'Check'),
'plus': wrap(Plus, 'Plus'),
'add': wrap(Plus, 'Plus'),
'minus': wrap(Minus, 'Minus'),
// Arrows & Navigation
'arrow-right': ArrowRight,
'arrow-left': ArrowLeft,
'arrow-up': ArrowUp,
'arrow-down': ArrowDown,
'chevron-right': ChevronRight,
'chevron-down': ChevronDown,
'chevron-up': ChevronUp,
'chevron-left': ChevronLeft,
'chevrons-right': ChevronsRight,
'chevrons-left': ChevronsLeft,
'more-vert': MoreVertical,
'more-horiz': MoreHorizontal,
// ── Arrows & Chevrons ──────────────────────────────────────────────────
'arrow-right': wrap(ArrowRight, 'ArrowRight'),
'arrow-left': wrap(ArrowLeft, 'ArrowLeft'),
'arrow-up': wrap(ArrowUp, 'ArrowUp'),
'arrow-down': wrap(ArrowDown, 'ArrowDown'),
'chevron-right': wrap(CaretRight, 'CaretRight'),
'chevron-down': wrap(CaretDown, 'CaretDown'),
'chevron-up': wrap(CaretUp, 'CaretUp'),
'chevron-left': wrap(CaretLeft, 'CaretLeft'),
'chevrons-right': wrap(CaretDoubleRight, 'CaretDoubleRight'),
'chevrons-left': wrap(CaretDoubleLeft, 'CaretDoubleLeft'),
'caret-up': wrap(CaretUp, 'CaretUp'),
'caret-down': wrap(CaretDown, 'CaretDown'),
'caret-left': wrap(CaretLeft, 'CaretLeft'),
'caret-right': wrap(CaretRight, 'CaretRight'),
'more-vert': wrap(DotsThreeVertical, 'DotsThreeVertical'),
'more-horiz': wrap(DotsThree, 'DotsThree'),
// Auth
'logout': LogOut,
'login': LogIn,
'lock': Lock,
'unlock': Unlock,
// ── Auth ───────────────────────────────────────────────────────────────
'logout': wrap(SignOut, 'SignOut'),
'login': wrap(SignIn, 'SignIn'),
'lock': wrap(Lock, 'Lock'),
'unlock': wrap(LockOpen, 'LockOpen'),
// Dashboard & Analytics
'dashboard': LayoutDashboard,
'chart': BarChart3,
'analytics': TrendingUp,
'money': DollarSign,
'group': Users,
'report': FileText,
// ── Dashboards & Analytics ─────────────────────────────────────────────
'dashboard': wrap(SquaresFour, 'SquaresFour'),
'chart': wrap(ChartBar, 'ChartBar'),
'analytics': wrap(TrendUp, 'TrendUp'),
'money': wrap(CurrencyDollar, 'CurrencyDollar'),
'group': wrap(Users, 'Users'),
'users': wrap(Users, 'Users'),
'report': wrap(FileText, 'FileText'),
// Status & Feedback
'info': Info,
'warning': AlertTriangle,
'error': AlertCircle,
'success': CheckCircle,
'help': HelpCircle,
// ── Status ─────────────────────────────────────────────────────────────
'info': wrap(Info, 'Info'),
'warning': wrap(Warning, 'Warning'),
'error': wrap(WarningCircle, 'WarningCircle'),
'success': wrap(CheckCircle, 'CheckCircle'),
'help': wrap(Question, 'Question'),
// Visibility
'visibility': Eye,
'visibility-off': EyeOff,
// ── Visibility ─────────────────────────────────────────────────────────
'visibility': wrap(Eye, 'Eye'),
'visibility-off': wrap(EyeSlash, 'EyeSlash'),
'eye': wrap(Eye, 'Eye'),
'eye-off': wrap(EyeSlash, 'EyeSlash'),
// Media
'image': Image,
'photo': Camera,
'video': Video,
'play': Play,
'pause': Pause,
'stop': Square,
// ── Media ──────────────────────────────────────────────────────────────
'image': wrap(ImageIcon, 'Image'),
'photo': wrap(Camera, 'Camera'),
'video': wrap(VideoCamera, 'VideoCamera'),
'play': wrap(Play, 'Play'),
'pause': wrap(Pause, 'Pause'),
'stop': wrap(Square, 'Square'),
'square': wrap(Square, 'Square'),
'circle': wrap(Circle, 'Circle'),
// Communication
'chat': MessageCircle,
'message': MessageSquare,
'comment': MessageSquare,
'send': Send,
'phone': Phone,
// ── Communication ──────────────────────────────────────────────────────
'chat': wrap(ChatCircle, 'ChatCircle'),
'message': wrap(ChatText, 'ChatText'),
'comment': wrap(ChatText, 'ChatText'),
'send': wrap(PaperPlaneTilt, 'PaperPlaneTilt'),
'phone': wrap(Phone, 'Phone'),
// Content
'copy': Copy,
'cut': Scissors,
'paste': Clipboard,
'link': Link,
'attach': Paperclip,
// ── Content ────────────────────────────────────────────────────────────
'copy': wrap(Copy, 'Copy'),
'cut': wrap(Scissors, 'Scissors'),
'paste': wrap(Clipboard, 'Clipboard'),
'link': wrap(Link, 'Link'),
'attach': wrap(Paperclip, 'Paperclip'),
// UI Controls
'filter': Filter,
'sort': ArrowUpDown,
'refresh': RefreshCw,
'download': Download,
'upload': Upload,
'share': Share2,
'language': Globe,
'locale': Globe,
'tune': SlidersHorizontal,
'first-page': SkipBack,
'last-page': SkipForward,
// ── UI Controls ────────────────────────────────────────────────────────
'filter': wrap(FunnelSimple, 'FunnelSimple'),
'sort': wrap(ArrowsDownUp, 'ArrowsDownUp'),
'refresh': wrap(ArrowClockwise, 'ArrowClockwise'),
'download': wrap(Download, 'Download'),
'upload': wrap(Upload, 'Upload'),
'share': wrap(ShareNetwork, 'ShareNetwork'),
'language': wrap(Globe, 'Globe'),
'locale': wrap(Globe, 'Globe'),
'tune': wrap(Sliders, 'Sliders'),
'first-page': wrap(SkipBack, 'SkipBack'),
'last-page': wrap(SkipForward, 'SkipForward'),
// Favorites & Bookmarks
'favorite': Heart,
'favorite-border': Heart,
'star': Star,
'star-border': Star,
'bookmark': Bookmark,
// ── Favorites & Bookmarks ──────────────────────────────────────────────
'favorite': wrap(Heart, 'Heart'),
'favorite-border': wrap(Heart, 'Heart'),
'star': wrap(Star, 'Star'),
'star-border': wrap(Star, 'Star'),
'bookmark': wrap(BookmarkSimple, 'BookmarkSimple'),
// Time & Calendar
'calendar': Calendar,
'time': Clock,
// ── Time & Calendar ────────────────────────────────────────────────────
'calendar': wrap(Calendar, 'Calendar'),
'time': wrap(Clock, 'Clock'),
'clock': wrap(Clock, 'Clock'),
// Location
'location': MapPin,
'location-on': MapPin,
'map': Map,
'navigation': Navigation,
// ── Location ───────────────────────────────────────────────────────────
'location': wrap(MapPin, 'MapPin'),
'location-on': wrap(MapPin, 'MapPin'),
'map': wrap(MapTrifold, 'MapTrifold'),
'navigation': wrap(NavigationArrow, 'NavigationArrow'),
// System
'power': Power,
'brightness': Sun,
'wifi': Wifi,
'wifi-off': WifiOff,
// ── System ─────────────────────────────────────────────────────────────
'power': wrap(Power, 'Power'),
'brightness': wrap(Sun, 'Sun'),
'sun': wrap(Sun, 'Sun'),
'wifi': wrap(WifiHigh, 'WifiHigh'),
'wifi-off': wrap(WifiSlash, 'WifiSlash'),
'lightning': wrap(Lightning, 'Lightning'),
// Media Controls
'volume-up': Volume2,
'volume-down': Volume1,
'volume-off': VolumeX,
'mute': VolumeX,
// ── Volume ─────────────────────────────────────────────────────────────
'volume-up': wrap(SpeakerHigh, 'SpeakerHigh'),
'volume-down': wrap(SpeakerLow, 'SpeakerLow'),
'volume-off': wrap(SpeakerSlash, 'SpeakerSlash'),
'mute': wrap(SpeakerSlash, 'SpeakerSlash'),
// Editing
'zoom-in': ZoomIn,
'zoom-out': ZoomOut,
'crop': Crop,
'rotate': RotateCw,
// ── Editing ────────────────────────────────────────────────────────────
'zoom-in': wrap(MagnifyingGlassPlus, 'MagnifyingGlassPlus'),
'zoom-out': wrap(MagnifyingGlassMinus, 'MagnifyingGlassMinus'),
'crop': wrap(Crop, 'Crop'),
'rotate': wrap(ArrowsClockwise, 'ArrowsClockwise'),
// Formatting
'format-bold': Bold,
'format-italic': Italic,
'format-underline': Underline,
'code': Code,
// ── Text formatting ────────────────────────────────────────────────────
'format-bold': wrap(TextB, 'TextB'),
'format-italic': wrap(TextItalic, 'TextItalic'),
'format-underline': wrap(TextUnderline, 'TextUnderline'),
'code': wrap(Code, 'Code'),
// Files & Documents
'document': FileText,
'article': FileText,
'book': Book,
'library': Library,
// ── Documents ──────────────────────────────────────────────────────────
'document': wrap(FileText, 'FileText'),
'article': wrap(Article, 'Article'),
'book': wrap(Book, 'Book'),
'library': wrap(Books, 'Books'),
// Cloud & Storage
'cloud': Cloud,
'cloud-upload': CloudUpload,
'cloud-download': CloudDownload,
'drive': HardDrive,
// ── Cloud & Storage ────────────────────────────────────────────────────
'cloud': wrap(Cloud, 'Cloud'),
'cloud-upload': wrap(CloudArrowUp, 'CloudArrowUp'),
'cloud-download': wrap(CloudArrowDown, 'CloudArrowDown'),
'drive': wrap(HardDrive, 'HardDrive'),
// Network
'network': Network,
'signal': Signal,
// ── Network ────────────────────────────────────────────────────────────
'network': wrap(Network, 'Network'),
'signal': wrap(CellSignalHigh, 'CellSignalHigh'),
// Print
'print': Printer,
// Add more mappings as needed
// ── Print ──────────────────────────────────────────────────────────────
'print': wrap(Printer, 'Printer'),
};
/* -------------------------------------------------------------------------- */
/* Public API */
/* -------------------------------------------------------------------------- */
/**
* Get Lucide icon component by name
* @param {string} iconName - Name of the icon (e.g., 'home', 'settings')
* @returns {React.Component|null} Lucide icon component or null
* Look up a wrapped Phosphor icon by name. Returns a React component (the
* wrapper handles theme-aware color, sizing, and weight) or `null` if the
* name is unknown.
*
* @param {string} iconName - canonical alias from {@link iconMap}
* @returns {React.ComponentType<{size?:string|number,color?:string,weight?:string}>|null}
*/
export function getIcon(iconName) {
if (!iconName || typeof iconName !== 'string') {
return null;
}
const normalizedName = iconName.toLowerCase().trim();
return iconMap[normalizedName] || null;
if (typeof iconName !== 'string' || !iconName) return null;
return iconMap[iconName.toLowerCase().trim()] || null;
}
/**
* IconMapper Component
* Renders a Lucide icon by name with Tamagui theme support
* @param {string} iconName - Name of the icon
* @param {number|string} size - Size of the icon (number or Tamagui token like '$4')
* @param {string} color - Color of the icon (CSS color or Tamagui token like '$color')
* @returns {React.ReactElement|null} Rendered icon or null
* Convenience component: `<IconMapper iconName="home" size="md" />`.
* Falls back to a small Tamagui Text node when the input is an emoji or
* single character (e.g. user-entered avatar glyphs).
*/
export function IconMapper({ iconName, size = 24, color = 'currentColor', ...props }) {
const IconComponent = getIcon(iconName);
if (!IconComponent) {
// Fallback for emojis or unknown icons
if (iconName && (iconName.length <= 2 || /[\u{1F300}-\u{1F9FF}]/u.test(iconName))) {
return <span style={{ fontSize: typeof size === 'string' ? size : `${size}px`, color }}>{iconName}</span>;
export function IconMapper({ iconName, size = DEFAULT_SIZE, color = '$textPrimary', ...props }) {
const Icon = getIcon(iconName);
if (Icon) {
return <Icon size={size} color={color} {...props} />;
}
if (typeof iconName === 'string' && (iconName.length <= 2 || /[\u{1F300}-\u{1F9FF}]/u.test(iconName))) {
return <SizableText fontSize={resolveSize(size)} color={color}>{iconName}</SizableText>;
}
return null;
}
// Convert size if it's a number to a reasonable default
const iconSize = typeof size === 'string' ? size : size;
/**
* Get the px value for a semantic size token. Useful when laying out
* non-icon children (avatars, badges) alongside icons.
*/
export function getIconSize(size) {
return resolveSize(size);
}
return <IconComponent size={iconSize} color={color} {...props} />;
/**
* List of every registered alias. Handy for validation / docs.
*/
export function getIconNames() {
return Object.keys(iconMap);
}
export default IconMapper;
+45 -58
View File
@@ -258,41 +258,34 @@ export function MenuItemButton({
}
}
// Determine background color based on state
// Static state colors. Hover treatments live in `hoverStyle`/`pressStyle`
// below so they animate via Tamagui's pseudo-state machinery instead of
// being baked into the resting style.
const getBackgroundColor = () => {
if (selected) {
return '$accentBackground';
}
if (hovered) {
return '$backgroundPress';
}
if (selected) return '$accentBg';
if (hovered) return '$bgPage';
return 'transparent';
};
const getIconColor = () => {
if (selected) {
return '$accentColor';
}
if (menuItem.style === 'icon_only') {
return '$accentColor';
}
return '$color';
if (selected) return '$accent';
if (menuItem.style === 'icon_only') return '$accent';
return '$textPrimary';
};
const getLabelColor = () => {
if (selected) {
return '$accentColor';
}
return '$color';
if (selected) return '$accent';
return '$textPrimary';
};
const getArrowColor = () => {
if (selected) {
return '$accentColor';
}
return '$colorSecondary';
if (selected) return '$accent';
return '$textMuted';
};
const ICON_SIZE = 'sm';
const CHEVRON_SIZE = 'sm';
// Determine display style (both, label_only, icon_only)
// Use displayStyle prop if provided, otherwise fall back to menuItem.style
const effectiveDisplayStyle = displayStyle !== undefined ? displayStyle : (menuItem.style || 'both');
@@ -325,11 +318,9 @@ export function MenuItemButton({
width="100%"
alignItems="center"
backgroundColor={getBackgroundColor()}
borderWidth={selected ? 1 : 0}
borderColor={selected ? '$accentBorder' : 'transparent'}
borderRadius="$2"
borderRadius="$radiusSm"
padding={padding}
opacity={menuItem.is_active !== false ? 1 : 0.5}
opacity={menuItem.is_active !== false ? 1 : 0.55}
>
{/* Icon + Label (clickable main area) */}
<XStack
@@ -337,10 +328,10 @@ export function MenuItemButton({
alignItems="center"
cursor={menuItem.isActionable() || hasSubitems ? 'pointer' : 'default'}
hoverStyle={{
backgroundColor: hovered || selected ? getBackgroundColor() : '$backgroundHover'
backgroundColor: selected ? '$accentBgHover' : '$bgPage'
}}
pressStyle={{
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
backgroundColor: selected ? '$accentBgHover' : '$bgPanelElev'
}}
onPress={handleMainClick}
>
@@ -348,18 +339,16 @@ export function MenuItemButton({
{showIcon && (
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
{typeof IconComponent === 'string' ? (
// Emoji fallback
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
) : IconComponent ? (
// Material Design icon component
<IconComponent size={typeof size === 'string' ? 24 : (size || 24)} color={getIconColor()} />
<IconComponent size={ICON_SIZE} color={getIconColor()} />
) : null}
</XStack>
)}
{/* Label */}
{showLabel && (
<Text flex={1} fontSize={size} fontWeight={selected ? 'bold' : 'normal'} color={getLabelColor()}>
<Text flex={1} fontSize={size} fontWeight={selected ? '600' : '400'} color={getLabelColor()}>
{menuItem.label}
</Text>
)}
@@ -372,16 +361,17 @@ export function MenuItemButton({
alignItems="center"
justifyContent="center"
padding="$1"
borderRadius="$radiusSm"
hoverStyle={{
backgroundColor: '$backgroundHover'
backgroundColor: '$bgPage'
}}
pressStyle={{
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
backgroundColor: '$bgPanelElev'
}}
onPress={handleToggleExpand}
>
<ArrowIcon
size={16}
size={CHEVRON_SIZE}
color={getArrowColor()}
style={{ marginLeft: 4, flexShrink: 0 }}
/>
@@ -394,15 +384,15 @@ export function MenuItemButton({
<YStack
ref={popupRef}
position="fixed"
backgroundColor="$background"
borderRadius="$3"
backgroundColor="$bgPanelElev"
borderRadius="$radiusMd"
padding={0}
borderWidth={1}
borderColor="$borderColor"
borderColor="$lineSubtle"
shadowColor="$shadowColor"
shadowOffset={{ width: 0, height: 4 }}
shadowOpacity={0.2}
shadowRadius={8}
shadowOpacity={0.18}
shadowRadius={12}
elevation={8}
zIndex={9999}
minWidth={200}
@@ -457,11 +447,9 @@ export function MenuItemButton({
width="100%"
title={collapsed && menuItem.label ? menuItem.label : undefined}
backgroundColor={getBackgroundColor()}
borderWidth={selected ? 1 : 0}
borderColor={selected ? '$accentBorder' : 'transparent'}
borderRadius="$2"
borderRadius="$radiusSm"
padding={padding}
opacity={menuItem.is_active !== false ? 1 : 0.5}
opacity={menuItem.is_active !== false ? 1 : 0.55}
>
{/* Icon + Label (clickable main area) */}
<XStack
@@ -469,10 +457,10 @@ export function MenuItemButton({
alignItems="center"
cursor={menuItem.isActionable() || hasSubitems ? 'pointer' : 'default'}
hoverStyle={{
backgroundColor: hovered || selected ? getBackgroundColor() : '$backgroundHover'
backgroundColor: selected ? '$accentBgHover' : '$bgPage'
}}
pressStyle={{
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
backgroundColor: selected ? '$accentBgHover' : '$bgPanelElev'
}}
onPress={handleMainClick}
>
@@ -480,18 +468,16 @@ export function MenuItemButton({
{showIcon && (
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
{typeof IconComponent === 'string' ? (
// Emoji fallback
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
) : IconComponent ? (
// Material Design icon component
<IconComponent size={typeof size === 'string' ? 24 : (size || 24)} color={getIconColor()} />
<IconComponent size={ICON_SIZE} color={getIconColor()} />
) : null}
</XStack>
)}
{/* Label */}
{showLabel && (
<Text flex={1} fontSize={size} fontWeight={selected ? 'bold' : 'normal'} color={getLabelColor()}>
<Text flex={1} fontSize={size} fontWeight={selected ? '600' : '400'} color={getLabelColor()}>
{menuItem.label}
</Text>
)}
@@ -504,16 +490,17 @@ export function MenuItemButton({
alignItems="center"
justifyContent="center"
padding="$1"
borderRadius="$radiusSm"
hoverStyle={{
backgroundColor: '$backgroundHover'
backgroundColor: '$bgPage'
}}
pressStyle={{
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
backgroundColor: '$bgPanelElev'
}}
onPress={handleToggleExpand}
>
<ArrowIcon
size={16}
size={CHEVRON_SIZE}
color={getArrowColor()}
style={{ marginLeft: 8, flexShrink: 0 }}
/>
@@ -553,15 +540,15 @@ export function MenuItemButton({
<YStack
ref={popupRef}
position="fixed"
backgroundColor="$background"
borderRadius="$3"
backgroundColor="$bgPanelElev"
borderRadius="$radiusMd"
padding={0}
borderWidth={1}
borderColor="$borderColor"
borderColor="$lineSubtle"
shadowColor="$shadowColor"
shadowOffset={{ width: 0, height: 4 }}
shadowOpacity={0.2}
shadowRadius={8}
shadowOpacity={0.18}
shadowRadius={12}
elevation={8}
zIndex={9999}
minWidth={200}
+5 -4
View File
@@ -7,6 +7,7 @@
import React from 'react';
import { XStack, YStack, Text } from 'tamagui';
import { getIcon } from './IconMapper.jsx';
import { getTypographyRoleProps } from '../styles/index.js';
/**
* Page Component
@@ -36,7 +37,7 @@ export function Page({
if (IconComponent) {
headerLeftItems.push(
<XStack key="page-icon" alignItems="center" justifyContent="center" marginRight="$2">
<IconComponent size={24} color="$accentColor" />
<IconComponent size="md" color="$textPrimary" />
</XStack>
);
}
@@ -44,7 +45,7 @@ export function Page({
if (title) {
headerLeftItems.push(
<Text key="page-title" fontWeight="600" fontSize="$6" color="$accentColor">
<Text key="page-title" {...getTypographyRoleProps('pageTitle')}>
{title}
</Text>
);
@@ -66,8 +67,8 @@ export function Page({
width="100%"
padding="$4"
borderBottomWidth={1}
borderBottomColor="$accentBorder"
backgroundColor="$accentSurface"
borderBottomColor="$lineSubtle"
backgroundColor="$bgPanel"
alignItems="center"
gap="$3"
minHeight={64}
+23 -21
View File
@@ -7,6 +7,7 @@
import React from 'react';
import { XStack, YStack, Text } from 'tamagui';
import { getIcon } from './IconMapper.jsx';
import { getTypographyRoleProps } from '../styles/index.js';
/**
* Header size mapping function
@@ -19,29 +20,21 @@ import { getIcon } from './IconMapper.jsx';
function getHeaderSizeStyles(headerSize) {
const sizeMap = {
1: {
iconSize: 24,
titleFontSize: '$6',
padding: '$3',
borderRadius: '$4',
minHeight: 64
},
2: {
iconSize: 20,
titleFontSize: '$5',
padding: '$2',
borderRadius: '$3',
minHeight: 48
},
3: {
iconSize: 18,
titleFontSize: '$4',
padding: '$1.5',
borderRadius: '$2',
minHeight: 44
},
4: {
iconSize: 16,
titleFontSize: '$3',
padding: '$1',
borderRadius: '$2',
minHeight: 36
@@ -83,13 +76,16 @@ export function Panel({
height = null,
headerSize = 2,
headerFront = null,
headerBack = null
headerBack = null,
density = 'comfortable',
bodyOverflow = 'auto'
}) {
// Get size-specific styles
const sizeStyles = getHeaderSizeStyles(headerSize);
// Set default headerBack if not provided
const effectiveHeaderBack = headerBack || { backgroundColor: '$backgroundHover' };
const effectiveHeaderBack = headerBack || { backgroundColor: '$bgPanel' };
const paddingByDensity = density === 'compact' ? '$2' : density === 'spacious' ? '$4' : sizeStyles.padding;
// Build headerLeft array: icon, title, then custom components
const headerLeftItems = [];
@@ -99,7 +95,7 @@ export function Panel({
if (IconComponent) {
headerLeftItems.push(
<XStack key="panel-icon" alignItems="center" justifyContent="center" marginRight="$2" {...(headerFront || {})}>
<IconComponent size={sizeStyles.iconSize} {...(headerFront || {})} />
<IconComponent size="md" color="$textPrimary" {...(headerFront || {})} />
</XStack>
);
}
@@ -109,9 +105,15 @@ export function Panel({
headerLeftItems.push(
<Text
key="panel-title"
fontWeight="600"
fontSize={sizeStyles.titleFontSize}
color="$color"
{...getTypographyRoleProps('panelTitle', {
fontSize: headerSize === 1
? '$6'
: headerSize === 2
? '$5'
: headerSize === 3
? '$4'
: '$3'
})}
{...(headerFront || {})}
>
{title}
@@ -142,19 +144,19 @@ export function Panel({
{...(Object.keys(containerStyle).length > 0 ? containerStyle : {})}
{...(border ? {
borderWidth: 1,
borderColor: '$borderColor',
borderRadius: sizeStyles.borderRadius
borderColor: '$lineSubtle',
borderRadius: '$radiusMd'
} : {})}
backgroundColor="$background"
backgroundColor="$bgPanel"
overflow="hidden"
>
{/* Header */}
<XStack
width="100%"
padding={sizeStyles.padding}
padding={paddingByDensity}
borderBottomWidth={border ? 1 : 0}
borderBottomColor="$borderColor"
backgroundColor="$background"
borderBottomColor="$lineSubtle"
backgroundColor="$bgPanel"
alignItems="center"
gap="$3"
minHeight={sizeStyles.minHeight}
@@ -187,7 +189,7 @@ export function Panel({
</XStack>
{/* Body */}
<YStack width="100%" padding={sizeStyles.padding} overflow="auto" {...(height !== null ? { flex: 1 } : {})}>
<YStack width="100%" padding={paddingByDensity} overflow={bodyOverflow} {...(height !== null ? { flex: 1 } : {})}>
{children}
</YStack>
</YStack>
+4 -5
View File
@@ -91,8 +91,7 @@ export function ProgressBar({
width="100%"
height={trackHeight}
borderRadius="$1"
backgroundColor="$borderColor"
opacity={0.55}
backgroundColor="$lineSubtle"
overflow="hidden"
>
{mode === 'determinate' ? (
@@ -103,7 +102,7 @@ export function ProgressBar({
height="100%"
width={determinateWidth}
borderRadius="$1"
backgroundColor="$accentColor"
backgroundColor="$accent"
/>
) : (
<YStack
@@ -112,7 +111,7 @@ export function ProgressBar({
height="100%"
width="42%"
borderRadius="$1"
backgroundColor="$accentColor"
backgroundColor="$accent"
left={`${offset}%`}
/>
)}
@@ -123,7 +122,7 @@ export function ProgressBar({
<YStack flex={1} minWidth={0} alignItems="center" gap="$1">
{label !== null && label !== undefined && label !== '' ? (
typeof label === 'string' ? (
<Text fontSize="$2" color="$colorSecondary" numberOfLines={1} textAlign="center" width="100%">
<Text fontSize="$2" color="$textSecondary" numberOfLines={1} textAlign="center" width="100%">
{label}
</Text>
) : (
+25 -25
View File
@@ -11,13 +11,13 @@ function normalizeVariant(variant, styleVariant) {
return styleVariant || variant || 'accordion';
}
function renderHeaderIcon(icon, color = '$color') {
function renderHeaderIcon(icon, color = '$textPrimary') {
const IconComponent = getIcon(icon);
if (!IconComponent) {
return null;
}
return <IconComponent size={18} color={color} />;
return <IconComponent size="sm" color={color} />;
}
function normalizeContentStyle(contentStyle) {
@@ -188,7 +188,7 @@ export function SettingsPanel({
size="$3"
aria-label={expanded ? `Collapse ${title}` : `Expand ${title}`}
onPress={handleToggle}
icon={ToggleIcon ? <ToggleIcon size={16} /> : undefined}
icon={ToggleIcon ? <ToggleIcon size="sm" color="$textSecondary" /> : undefined}
/>
);
@@ -208,15 +208,15 @@ export function SettingsPanel({
justifyContent="center"
flexShrink={0}
>
{renderHeaderIcon(icon, effectiveVariant === 'panel' ? '$accentColor' : '$color')}
{renderHeaderIcon(icon, '$textPrimary')}
</XStack>
) : null}
<XStack flex={1} minWidth={0} gap="$2" alignItems="baseline" flexWrap="wrap">
<Text fontWeight="700" fontSize={effectiveVariant === 'panel' ? '$4' : '$5'} color="$color">
<Text fontWeight="600" fontSize={effectiveVariant === 'panel' ? '$4' : '$5'} color="$textPrimary">
{title}
</Text>
{description ? (
<Paragraph size="$2" color="$color" opacity={0.72} flex={1} minWidth={160}>
<Paragraph size="$2" color="$textMuted" flex={1} minWidth={160}>
{description}
</Paragraph>
) : null}
@@ -240,8 +240,8 @@ export function SettingsPanel({
>
<Tabs.List
disablePassBorderRadius
backgroundColor="$backgroundStrong"
borderRadius="$4"
backgroundColor="$bgPage"
borderRadius="$radiusMd"
padding="$1"
gap="$1"
flexWrap="wrap"
@@ -250,14 +250,14 @@ export function SettingsPanel({
<Tabs.Tab
key={item.id}
value={item.id}
borderRadius="$3"
backgroundColor="$background"
hoverStyle={{ backgroundColor: '$backgroundHover' }}
pressStyle={{ backgroundColor: '$backgroundPress' }}
borderRadius="$radiusSm"
backgroundColor="transparent"
hoverStyle={{ backgroundColor: '$bgPanel' }}
pressStyle={{ backgroundColor: '$bgPanelElev' }}
>
<XStack alignItems="center" gap="$2">
{item.icon ? renderHeaderIcon(item.icon, '$accentColor') : null}
<Text fontWeight="700" color="$color">
{item.icon ? renderHeaderIcon(item.icon, '$textSecondary') : null}
<Text fontWeight="600" color="$textPrimary">
{item.label || item.title}
</Text>
</XStack>
@@ -269,9 +269,9 @@ export function SettingsPanel({
<Tabs.Content key={item.id} value={item.id}>
<YStack
borderWidth={1}
borderColor="$borderColor"
borderRadius="$4"
backgroundColor="$background"
borderColor="$lineSubtle"
borderRadius="$radiusMd"
backgroundColor="$bgPanel"
padding="$3"
gap="$3"
>
@@ -306,18 +306,18 @@ export function SettingsPanel({
return (
<YStack
borderWidth={1}
borderColor="$borderColor"
borderRadius="$4"
backgroundColor="$background"
borderColor="$lineSubtle"
borderRadius="$radiusMd"
backgroundColor="$bgPanel"
overflow="hidden"
>
<XStack
paddingHorizontal="$3"
paddingVertical="$1.5"
alignItems="center"
backgroundColor="$backgroundHover"
backgroundColor="$bgPanel"
borderBottomWidth={expanded ? 1 : 0}
borderBottomColor="$borderColor"
borderBottomColor="$lineSubtle"
>
{headerContent}
</XStack>
@@ -339,8 +339,8 @@ export function SettingsPanel({
borderWidth={0}
padding={0}
justifyContent="flex-start"
hoverStyle={{ opacity: 0.9, backgroundColor: 'transparent' }}
pressStyle={{ opacity: 0.75, backgroundColor: 'transparent' }}
hoverStyle={{ backgroundColor: '$bgPage' }}
pressStyle={{ backgroundColor: '$bgPanelElev' }}
>
<YStack gap="$2">
{headerContent}
@@ -351,7 +351,7 @@ export function SettingsPanel({
{renderedBody}
</YStack>
) : null}
<Separator borderColor="$borderColor" />
<Separator borderColor="$lineSubtle" />
</YStack>
);
}
+3 -3
View File
@@ -572,7 +572,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
>
{Icon ? (
<XStack alignItems="center" justifyContent="center" width={20} height={20} flexShrink={0}>
<Icon size={18} />
<Icon size="md" color="$textSecondary" />
</XStack>
) : null}
<YStack flex={1} gap="$1">
@@ -582,7 +582,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
</Text>
)}
{toast.message && (
<Text fontSize="$3" color="$color" opacity={0.9}>
<Text fontSize="$3" color="$textSecondary">
{toast.message}
</Text>
)}
@@ -596,7 +596,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
>
{(() => {
const CloseIcon = getIcon('close');
return CloseIcon ? <CloseIcon size={16} /> : <Text>×</Text>;
return CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : <Text color="$textSecondary">×</Text>;
})()}
</Button>
</XStack>
+14 -15
View File
@@ -229,9 +229,9 @@ function SideBarWide({
height="100%"
gap="$2"
padding="$2"
backgroundColor="$accentSurface"
backgroundColor="$bgPanel"
borderRightWidth={1}
borderRightColor="$accentBorder"
borderRightColor="$lineSubtle"
animation="quick"
animateOnly={['width']}
>
@@ -266,7 +266,7 @@ function SideBarWide({
)}
{appName && !isCollapsed && (
<Text fontWeight="bold" fontSize="$4" flex={1} color="$accentColor">
<Text fontWeight="600" fontSize="$5" flex={1} color="$textPrimary">
{appName}
</Text>
)}
@@ -278,10 +278,10 @@ function SideBarWide({
justifyContent="center"
padding="$1"
hoverStyle={{
backgroundColor: '$accentBackground'
backgroundColor: '$bgPage'
}}
pressStyle={{
backgroundColor: '$accentHover'
backgroundColor: '$bgPanelElev'
}}
onPress={handleToggle}
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
@@ -291,8 +291,8 @@ function SideBarWide({
if (!ChevronIcon) return null;
return (
<ChevronIcon
size={16}
color="$accentColor"
size="sm"
color="$textSecondary"
style={{ flexShrink: 0 }}
/>
);
@@ -366,17 +366,16 @@ function SideBarNarrow({ children }) {
alignItems="center"
gap="$2"
padding="$2"
backgroundColor="$accentSurface"
backgroundColor="$bgPanel"
borderBottomWidth={1}
borderBottomColor="$accentBorder"
borderBottomColor="$lineSubtle"
>
{/* Hamburger Menu Button */}
<Button
size="$3"
circular
chromeless
icon={getIcon('menu')}
backgroundColor="$accentBackground"
color="$accentColor"
color="$textPrimary"
onPress={() => setMenuOpen(true)}
/>
@@ -392,7 +391,7 @@ function SideBarNarrow({ children }) {
{/* App Name - takes remaining space */}
{appName && (
<Text fontWeight="bold" fontSize="$4" flex={1} numberOfLines={1} color="$accentColor">
<Text fontWeight="600" fontSize="$5" flex={1} numberOfLines={1} color="$textPrimary">
{appName}
</Text>
)}
@@ -420,9 +419,9 @@ function SideBarNarrow({ children }) {
snapPoints={[85]}
dismissOnSnapToBottom
>
<Sheet.Overlay />
<Sheet.Overlay backgroundColor="$scrim" />
<Sheet.Handle />
<Sheet.Frame padding="$4" gap="$2">
<Sheet.Frame padding="$4" gap="$2" backgroundColor="$bgPanel">
<YStack gap="$2" width="100%">
{/* Primary Menu Items */}
{organizedChildren.primaryMenuItems.map((item) => (
+18 -13
View File
@@ -2,6 +2,11 @@ import React, { useEffect, useState } from 'react';
import { Button, ScrollView, Text, XStack, YStack } from 'tamagui';
import { getIcon } from './IconMapper.jsx';
/**
* Toolbar / footer action button.
* Pass `theme="accent"` on a single primary action; everything else stays
* chromeless to keep the action hierarchy clear.
*/
function ActionButton({ action }) {
const IconComponent = action?.icon ? getIcon(action.icon) : null;
return (
@@ -11,7 +16,7 @@ function ActionButton({ action }) {
chromeless={action?.chromeless}
disabled={action?.disabled}
onPress={action?.onPress}
icon={IconComponent ? <IconComponent size={16} /> : undefined}
icon={IconComponent ? <IconComponent size="sm" /> : undefined}
>
{action?.label}
</Button>
@@ -61,7 +66,7 @@ export function SidePanelShell({
right={0}
bottom={0}
left={0}
backgroundColor="rgba(15,23,42,0.26)"
backgroundColor="$scrim"
opacity={open ? 1 : 0}
animation="quick"
onPress={onClose}
@@ -74,13 +79,13 @@ export function SidePanelShell({
bottom={0}
width={width}
maxWidth="96vw"
backgroundColor="$background"
backgroundColor="$bgPanelElev"
borderLeftWidth={1}
borderLeftColor="$borderColor"
borderLeftColor="$lineSubtle"
shadowColor="$shadowColor"
shadowOpacity={0.18}
shadowRadius={20}
shadowOffset={{ width: -4, height: 0 }}
shadowOpacity={0.12}
shadowRadius={24}
shadowOffset={{ width: -6, height: 0 }}
style={{
transform: open ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 220ms ease'
@@ -92,10 +97,10 @@ export function SidePanelShell({
padding="$4"
gap="$3"
borderBottomWidth={1}
borderBottomColor="$borderColor"
backgroundColor="$accentSurface"
borderBottomColor="$lineSubtle"
backgroundColor="$bgPanel"
>
<Text fontSize="$7" fontWeight="700" color="$accentColor" flex={1}>
<Text fontSize="$6" fontWeight="600" color="$textPrimary" flex={1}>
{title}
</Text>
<XStack alignItems="center" gap="$2" flexWrap="wrap" justifyContent="flex-end">
@@ -107,7 +112,7 @@ export function SidePanelShell({
circular
chromeless
onPress={onClose}
icon={CloseIcon ? <CloseIcon size={18} /> : undefined}
icon={CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : undefined}
aria-label="Close panel"
/>
</XStack>
@@ -125,8 +130,8 @@ export function SidePanelShell({
gap="$2"
padding="$4"
borderTopWidth={1}
borderTopColor="$borderColor"
backgroundColor="$accentSurface"
borderTopColor="$lineSubtle"
backgroundColor="$bgPanel"
flexWrap="wrap"
>
{footerActions.map((action, index) => (
+15 -13
View File
@@ -195,9 +195,9 @@ function TopBarWide({
alignItems="center"
gap="$2"
padding="$2"
backgroundColor="$accentSurface"
backgroundColor="$bgPanel"
borderBottomWidth={1}
borderBottomColor="$accentBorder"
borderBottomColor="$lineSubtle"
>
{/* Left Side */}
<XStack
@@ -220,7 +220,7 @@ function TopBarWide({
{/* App Name */}
{appName && (
<Text fontWeight="bold" fontSize="$4" color="$accentColor">
<Text fontWeight="600" fontSize="$5" color="$textPrimary">
{appName}
</Text>
)}
@@ -242,7 +242,7 @@ function TopBarWide({
</XStack>
)}
{/* Right Side */}
{/* Right Side — secondary actions get a hairline separator + tighter gap */}
{(effectiveRightWidth > 0 || effectiveRightWidth === 'auto') && (
<XStack
width={effectiveRightWidth === 'auto' ? undefined : effectiveRightWidth}
@@ -251,6 +251,9 @@ function TopBarWide({
alignItems="center"
justifyContent="flex-end"
gap="$1"
paddingLeft="$2"
borderLeftWidth={1}
borderLeftColor="$lineSubtle"
style={{ flexShrink: 0 }}
>
{organizedChildren.sections.rightSide}
@@ -289,17 +292,16 @@ function TopBarNarrow({ children }) {
alignItems="center"
gap="$2"
padding="$2"
backgroundColor="$accentSurface"
backgroundColor="$bgPanel"
borderBottomWidth={1}
borderBottomColor="$accentBorder"
borderBottomColor="$lineSubtle"
>
{/* Hamburger Menu Button */}
{/* Hamburger Menu Button — chromeless to avoid pulling the eye */}
<Button
size="$3"
circular
chromeless
icon={getIcon('menu')}
backgroundColor="$accentBackground"
color="$accentColor"
color="$textPrimary"
onPress={() => setMenuOpen(true)}
/>
@@ -315,7 +317,7 @@ function TopBarNarrow({ children }) {
{/* App Name - takes remaining space */}
{appName && (
<Text fontWeight="bold" fontSize="$4" flex={1} numberOfLines={1} color="$accentColor">
<Text fontWeight="600" fontSize="$5" flex={1} numberOfLines={1} color="$textPrimary">
{appName}
</Text>
)}
@@ -359,9 +361,9 @@ function TopBarNarrow({ children }) {
snapPoints={[85]}
dismissOnSnapToBottom
>
<Sheet.Overlay />
<Sheet.Overlay backgroundColor="$scrim" />
<Sheet.Handle />
<Sheet.Frame padding="$4" gap="$2">
<Sheet.Frame padding="$4" gap="$2" backgroundColor="$bgPanel">
<YStack gap="$2" width="100%">
{/* Primary Menu Items - render with vertical orientation in Sheet */}
{organizedChildren.primaryMenuItems.map((item) => (
+13
View File
@@ -24,6 +24,7 @@ export function GridView({
statusText = '',
selectable = false,
nested = false,
closeable = true,
onClose = undefined,
onReload = undefined,
initialPageSize = 6,
@@ -197,8 +198,10 @@ export function GridView({
resolvedColumns,
visibleColumns,
selectedIds,
setSelectedIds,
selectable,
nested,
closeable,
isLoading,
error,
tableViewportWidth,
@@ -236,6 +239,15 @@ export function GridView({
return next;
});
},
toggleSelectAll: () => {
setSelectedIds((current) => {
const allIds = (rows || []).map((row) => row?.id).filter((id) => id != null);
if (!allIds.length) return new Set();
const allSelected = allIds.every((id) => current.has(id));
return allSelected ? new Set() : new Set(allIds);
});
},
clearSelection: () => setSelectedIds(new Set()),
setFilterValue: (key, value) => {
setOffset(0);
setFilterBy((current) => ({
@@ -260,6 +272,7 @@ export function GridView({
resolvedStatusText,
rows,
selectable,
closeable,
selectedIds,
sortBy,
structure,
+63 -38
View File
@@ -3,6 +3,7 @@ import { Button, Checkbox, Input, Paragraph, ScrollView, Text, XStack, YStack }
import { getIcon } from '../IconMapper.jsx';
import { useGridView } from './context.js';
import { formatValueByColumn } from './utils.js';
import { getTypographyRoleProps } from '../../styles/index.js';
function renderToolbarItem(item) {
if (!item) {
@@ -22,7 +23,7 @@ function renderToolbarItem(item) {
theme={item.theme}
chromeless={item.chromeless}
disabled={item.disabled}
icon={IconComponent ? <IconComponent size={16} /> : undefined}
icon={IconComponent ? <IconComponent size="sm" /> : undefined}
onPress={item.onClick || item.onPress}
>
{item.label}
@@ -32,21 +33,27 @@ function renderToolbarItem(item) {
if (item.kind === 'text') {
return (
<Text key={item.key || item.text} color="$color" opacity={0.7}>
<Text key={item.key || item.text} color="$textSecondary">
{item.text}
</Text>
);
}
if (item.kind === 'search') {
const SearchIcon = getIcon('search');
return (
<XStack key={item.key || item.placeholder || 'search'} alignItems="center" gap="$2">
{SearchIcon ? <SearchIcon size="sm" color="$textMuted" /> : null}
<Input
key={item.key || item.placeholder || 'search'}
width={item.width || 240}
value={item.value}
placeholder={item.placeholder || 'Search'}
onChangeText={(value) => item.onChange?.(value)}
backgroundColor="$bgPanel"
borderColor="$lineSubtle"
focusStyle={{ borderColor: '$accent' }}
/>
</XStack>
);
}
@@ -75,14 +82,14 @@ function DefaultPanelRecordRenderer({ row }) {
return (
<YStack gap="$3">
<YStack gap="$1">
<Text fontSize="$3" letterSpacing={1} textTransform="uppercase" color="$accentColor">
<Text fontSize="$2" letterSpacing={1} textTransform="uppercase" color="$textSecondary">
Record Summary
</Text>
<Text fontSize="$6" fontWeight="700">
<Text {...getTypographyRoleProps('sectionTitle')}>
{titleColumn ? row?.[titleColumn.field] : row?.id}
</Text>
{subtitleColumn ? (
<Paragraph color="$color" opacity={0.7}>
<Paragraph color="$textSecondary">
{row?.[subtitleColumn.field] || ''}
</Paragraph>
) : null}
@@ -94,15 +101,15 @@ function DefaultPanelRecordRenderer({ row }) {
key={`${row.id}-${column.field}-chip`}
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius="$6"
backgroundColor="$accentSurface"
borderRadius="$radiusMd"
backgroundColor="$bgPanel"
borderWidth={1}
borderColor="$accentBorder"
borderColor="$lineSubtle"
>
<Text fontSize="$2" color="$color" opacity={0.65}>
<Text fontSize="$2" color="$textMuted">
{column.label}
</Text>
<Text fontSize="$4" fontWeight="600">
<Text {...getTypographyRoleProps('tableHeader', { color: '$textPrimary' })}>
{formatValueByColumn(row?.[column.field], column)}
</Text>
</YStack>
@@ -116,16 +123,16 @@ function DefaultPanelRecordRenderer({ row }) {
minWidth={160}
flex={1}
padding="$3"
borderRadius="$4"
borderRadius="$radiusMd"
borderWidth={1}
borderColor="$borderColor"
backgroundColor="$background"
borderColor="$lineSubtle"
backgroundColor="$bgPanel"
gap="$1"
>
<Text fontSize="$3" color="$color" opacity={0.65}>
<Text fontSize="$3" color="$textMuted">
{column.label}
</Text>
<Text>{formatValueByColumn(row?.[column.field], column)}</Text>
<Text color="$textPrimary">{formatValueByColumn(row?.[column.field], column)}</Text>
</YStack>
))}
</XStack>
@@ -156,7 +163,7 @@ export function PanelFooterStatusBar({ text, visible = true }) {
}
return (
<Text color="$color" opacity={0.7}>
<Text color="$textMuted">
{text || grid.statusText}
</Text>
);
@@ -179,10 +186,10 @@ export function PanelHeader({ title, toolbarItems = [], visible = true, showDivi
padding="$3"
minHeight={64}
borderBottomWidth={showDivider ? 1 : 0}
borderBottomColor="$borderColor"
backgroundColor="$accentSurface"
borderBottomColor="$lineSubtle"
backgroundColor="$bgPanel"
>
<Text fontSize="$6" fontWeight="700" color="$accentColor">
<Text {...getTypographyRoleProps('panelTitle', { fontSize: '$6' })}>
{title}
</Text>
@@ -192,17 +199,19 @@ export function PanelHeader({ title, toolbarItems = [], visible = true, showDivi
size="$3"
chromeless
circular
icon={RefreshIcon ? <RefreshIcon size={16} /> : undefined}
icon={RefreshIcon ? <RefreshIcon size="sm" color="$textSecondary" /> : undefined}
onPress={grid.reload}
/>
{grid.closeable !== false ? (
<Button
size="$3"
chromeless
circular
disabled={!grid.close}
icon={CloseIcon ? <CloseIcon size={16} /> : undefined}
icon={CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : undefined}
onPress={grid.close}
/>
) : null}
</XStack>
</XStack>
);
@@ -221,8 +230,8 @@ export function PanelFooter({ toolbarItems = [], visible = true }) {
padding="$3"
minHeight={56}
borderTopWidth={1}
borderTopColor="$borderColor"
backgroundColor="$background"
borderTopColor="$lineSubtle"
backgroundColor="$bgPanel"
flexWrap="wrap"
>
<PanelFooterStatusBar />
@@ -243,9 +252,11 @@ export function PanelBodyView({
}
if (grid.error) {
const ErrorIcon = getIcon('error');
return (
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
<Text color="#b91c1c">{grid.error}</Text>
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5" gap="$2">
{ErrorIcon ? <ErrorIcon size="lg" color="$danger" /> : null}
<Text color="$danger" fontWeight="600">{grid.error}</Text>
</YStack>
);
}
@@ -253,15 +264,18 @@ export function PanelBodyView({
if (grid.isLoading && !grid.rows.length) {
return (
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
<Text color="$color" opacity={0.7}>Loading cards...</Text>
<Text color="$textMuted">Loading cards...</Text>
</YStack>
);
}
if (!grid.rows.length) {
const EmptyIcon = getIcon('folder');
return (
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
<Text color="$color" opacity={0.7}>No records available.</Text>
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5" gap="$2">
{EmptyIcon ? <EmptyIcon size="xl" color="$textMuted" /> : null}
<Text color="$textSecondary" fontWeight="600">No records available</Text>
<Text color="$textMuted" fontSize="$3">There's nothing to show here yet.</Text>
</YStack>
);
}
@@ -272,7 +286,9 @@ export function PanelBodyView({
<ScrollView flex={1}>
<YStack padding="$4" gap="$3">
<XStack gap="$3" flexWrap="wrap">
{grid.rows.map((row) => (
{grid.rows.map((row) => {
const isSelected = grid.selectedIds.has(row.id);
return (
<YStack
key={row.id}
minWidth={responsiveColumns > 1 ? 320 : 240}
@@ -280,29 +296,39 @@ export function PanelBodyView({
flexBasis={responsiveColumns > 1 ? '48%' : '100%'}
padding="$4"
borderWidth={1}
borderColor={grid.selectedIds.has(row.id) ? '$accentBorder' : '$borderColor'}
backgroundColor={grid.selectedIds.has(row.id) ? '$accentSurface' : '$background'}
borderRadius="$5"
borderColor={isSelected ? '$accent' : '$lineSubtle'}
backgroundColor={isSelected ? '$accentBg' : '$bgPanel'}
borderRadius="$radiusLg"
gap="$3"
hoverStyle={isSelected ? undefined : { borderColor: '$lineStrong' }}
>
{grid.selectable ? (
<XStack justifyContent="flex-start">
<Checkbox
checked={grid.selectedIds.has(row.id)}
checked={isSelected}
onCheckedChange={() => grid.toggleSelectRow(row.id)}
borderColor="$lineStrong"
backgroundColor="$bgPanel"
focusStyle={{ borderColor: '$accent' }}
>
<Checkbox.Indicator />
<Checkbox.Indicator>
{(() => {
const Check = getIcon('check');
return Check ? <Check size="sm" color="$accent" /> : null;
})()}
</Checkbox.Indicator>
</Checkbox>
</XStack>
) : null}
<RecordRenderer row={row} />
</YStack>
))}
);
})}
</XStack>
{grid.isLoading ? (
<Text color="$color" opacity={0.7}>
<Text color="$textMuted">
Refreshing records...
</Text>
) : null}
@@ -310,4 +336,3 @@ export function PanelBodyView({
</ScrollView>
);
}
+86 -28
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
import { Button, Checkbox, ScrollView, Separator, Text, XStack, YStack } from 'tamagui';
import { getIcon } from '../IconMapper.jsx';
import { useGridView } from './context.js';
import { getTypographyRoleProps } from '../../styles/index.js';
import {
formatValueByColumn,
getColumnJustify,
@@ -12,7 +13,7 @@ import {
function DefaultGridCellRenderer({ value, column }) {
return (
<Text width="100%" textAlign={column.align || 'left'} opacity={value == null || value === '' ? 0.6 : 1}>
<Text width="100%" textAlign={column.align || 'left'} color="$textPrimary" opacity={value == null || value === '' ? 0.6 : 1}>
{formatValueByColumn(value, column)}
</Text>
);
@@ -29,15 +30,26 @@ function UtilityCell({ rowId }) {
if (grid.nested) {
return (
<XStack width={36} alignItems="center" justifyContent="center">
{ChevronRightIcon ? <ChevronRightIcon size={16} /> : <Text>{'>'}</Text>}
{ChevronRightIcon ? <ChevronRightIcon size="sm" color="$textSecondary" /> : <Text color="$textSecondary">{'>'}</Text>}
</XStack>
);
}
return (
<XStack width={36} alignItems="center" justifyContent="center">
<Checkbox checked={grid.selectedIds.has(rowId)} onCheckedChange={() => grid.toggleSelectRow(rowId)}>
<Checkbox.Indicator />
<Checkbox
checked={grid.selectedIds.has(rowId)}
onCheckedChange={() => grid.toggleSelectRow(rowId)}
borderColor="$lineStrong"
backgroundColor="$bgPanel"
focusStyle={{ borderColor: '$accent' }}
>
<Checkbox.Indicator>
{(() => {
const Check = getIcon('check');
return Check ? <Check size="sm" color="$accent" /> : null;
})()}
</Checkbox.Indicator>
</Checkbox>
</XStack>
);
@@ -80,21 +92,46 @@ export function TableHeader({ visible = true, showTopBorder = true }) {
}
const activeColumns = grid.visibleColumns?.length ? grid.visibleColumns : grid.resolvedColumns;
const CaretUp = getIcon('caret-up');
const CaretDown = getIcon('caret-down');
const allIds = (grid.rows || []).map((row) => row?.id).filter((id) => id != null);
const selectedCount = allIds.filter((id) => grid.selectedIds.has(id)).length;
const allSelected = allIds.length > 0 && selectedCount === allIds.length;
const someSelected = selectedCount > 0 && !allSelected;
return (
<XStack
alignItems="stretch"
borderTopWidth={showTopBorder ? 1 : 0}
borderBottomWidth={1}
borderColor="$accentBorder"
backgroundColor="$accentSurface"
borderColor="$lineSubtle"
backgroundColor="transparent"
paddingHorizontal="$2"
>
{grid.selectable || grid.nested ? <XStack width={36} /> : null}
{grid.selectable || grid.nested ? (
<XStack width={36} alignItems="center" justifyContent="center">
{grid.selectable ? (
<Checkbox
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
onCheckedChange={() => grid.toggleSelectAll?.()}
borderColor="$lineStrong"
backgroundColor="$bgPanel"
focusStyle={{ borderColor: '$accent' }}
>
<Checkbox.Indicator>
{(() => {
const Check = getIcon('check');
return Check ? <Check size="sm" color="$accent" /> : null;
})()}
</Checkbox.Indicator>
</Checkbox>
) : null}
</XStack>
) : null}
{activeColumns.map((column) => {
const activeSort = grid.sortBy.find((entry) => entry.field === column.field);
const sortLabel =
activeSort?.direction === 'asc' ? '↑' : activeSort?.direction === 'desc' ? '↓' : '';
const isActive = Boolean(activeSort);
const direction = activeSort?.direction;
return (
<Button
@@ -107,10 +144,28 @@ export function TableHeader({ visible = true, showTopBorder = true }) {
paddingVertical="$3"
paddingHorizontal="$2"
{...getColumnLayoutStyle(column)}
hoverStyle={column.sortable ? { backgroundColor: '$bgPage' } : undefined}
pressStyle={column.sortable ? { backgroundColor: '$bgPanelElev' } : undefined}
>
<Text width="100%" textAlign={column.align || 'left'} fontWeight="700">
{column.label}{sortLabel ? ` ${sortLabel}` : ''}
<XStack width="100%" alignItems="center" justifyContent={getColumnJustify(column.align)} gap="$2">
<Text
width="auto"
textAlign={column.align || 'left'}
numberOfLines={1}
{...getTypographyRoleProps('tableHeader')}
>
{column.label}
</Text>
{column.sortable ? (
isActive ? (
direction === 'asc'
? (CaretUp ? <CaretUp size="xs" color="$textSecondary" /> : null)
: (CaretDown ? <CaretDown size="xs" color="$textSecondary" /> : null)
) : (
CaretDown ? <CaretDown size="xs" color="$textMuted" style={{ opacity: 0.6 }} /> : null
)
) : null}
</XStack>
</Button>
);
})}
@@ -131,7 +186,7 @@ export function TableBodyView({ visible = true }) {
if (grid.error) {
return (
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
<Text color="#b91c1c">{grid.error}</Text>
<Text color="$danger" fontWeight="600">{grid.error}</Text>
</YStack>
);
}
@@ -144,7 +199,8 @@ export function TableBodyView({ visible = true }) {
<XStack
alignItems="stretch"
paddingHorizontal="$2"
backgroundColor={grid.selectedIds.has(row.id) ? '$accentSurface' : '$background'}
backgroundColor={grid.selectedIds.has(row.id) ? '$accentBg' : index % 2 === 1 ? '$bgPage' : 'transparent'}
hoverStyle={{ backgroundColor: '$bgPage' }}
>
{grid.selectable || grid.nested ? <UtilityCell rowId={row.id} /> : null}
{activeColumns.map((column) => {
@@ -166,19 +222,22 @@ export function TableBodyView({ visible = true }) {
);
})}
</XStack>
<Separator />
<Separator borderColor="$lineSubtle" />
</YStack>
))}
{!grid.rows.length && !grid.isLoading ? (
<YStack minHeight={120} alignItems="center" justifyContent="center" padding="$5">
<Text color="$color" opacity={0.7}>No records available.</Text>
<Text color="$textSecondary" fontWeight="600">No records available</Text>
<Text color="$textMuted" fontSize="$3">There's nothing to show here yet.</Text>
</YStack>
) : null}
{grid.isLoading ? (
<YStack minHeight={64} alignItems="center" justifyContent="center" padding="$4">
<Text color="$color" opacity={0.7}>Loading...</Text>
{grid.isLoading && !grid.rows.length ? (
<YStack minHeight={64} padding="$4" gap="$2">
{[0, 1, 2].map((i) => (
<XStack key={i} height={46} borderRadius="$radiusMd" backgroundColor="$bgPage" />
))}
</YStack>
) : null}
</YStack>
@@ -205,21 +264,21 @@ export function TableFooter({ visible = true }) {
padding="$3"
minHeight={56}
borderTopWidth={1}
borderTopColor="$borderColor"
backgroundColor="$background"
borderTopColor="$lineSubtle"
backgroundColor="$bgPanel"
flexWrap="wrap"
>
<Text color="$color" opacity={0.7}>
<Text color="$textMuted">
{grid.total} records
</Text>
<XStack gap="$1" alignItems="center" flexWrap="wrap">
<XStack gap="$1" alignItems="center" flexWrap="wrap" padding="$1" borderWidth={1} borderColor="$lineSubtle" borderRadius="$radiusMd" backgroundColor="$bgPanel">
<Button
size="$3"
chromeless
circular
disabled={grid.currentPage <= 1}
icon={FirstPageIcon ? <FirstPageIcon size={16} /> : undefined}
icon={FirstPageIcon ? <FirstPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => grid.setPage(1)}
/>
<Button
@@ -227,10 +286,10 @@ export function TableFooter({ visible = true }) {
chromeless
circular
disabled={grid.currentPage <= 1}
icon={PreviousPageIcon ? <PreviousPageIcon size={16} /> : undefined}
icon={PreviousPageIcon ? <PreviousPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => grid.setPage(grid.currentPage - 1)}
/>
<Text color="$color" opacity={0.75}>
<Text color="$textSecondary">
Page {grid.currentPage} of {grid.pageCount}
</Text>
<Button
@@ -238,7 +297,7 @@ export function TableFooter({ visible = true }) {
chromeless
circular
disabled={grid.currentPage >= grid.pageCount}
icon={NextPageIcon ? <NextPageIcon size={16} /> : undefined}
icon={NextPageIcon ? <NextPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => grid.setPage(grid.currentPage + 1)}
/>
<Button
@@ -246,11 +305,10 @@ export function TableFooter({ visible = true }) {
chromeless
circular
disabled={grid.currentPage >= grid.pageCount}
icon={LastPageIcon ? <LastPageIcon size={16} /> : undefined}
icon={LastPageIcon ? <LastPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => grid.setPage(grid.pageCount)}
/>
</XStack>
</XStack>
);
}
+1
View File
@@ -24,6 +24,7 @@ export { SettingsPanel, default as SettingsPanelDefault } from './SettingsPanel.
export { GeneralConfig, default as GeneralConfigDefault } from './GeneralConfig.jsx';
export { IdentityConfig, default as IdentityConfigDefault } from './IdentityConfig.jsx';
export * from './grid/index.js';
export { getTypographyRoleProps, getStyleTypography, TYPOGRAPHY_ROLE_KEYS } from '../styles/index.js';
// Re-export App helpers for convenience.
// The App component itself is exported from src/index.js and src/ui/App.jsx.
+193
View File
@@ -0,0 +1,193 @@
/**
* Apple Theme
* Warm, glassy look inspired by macOS / iOS Human Interface Guidelines.
*
* Aesthetic intent
* ----------------
* - Warm neutral surfaces (slightly off-white page, pure white panels)
* - Subtle but visible shadows on raised surfaces
* - Generous radii (8 / 12 / 16) — pill controls, rounded cards
* - System-blue accent for primary actions
* - Slightly larger typography, comfortable density
* - Phosphor icons at "duotone" weight for a friendly, premium feel
*
* Implements the semantic token contract documented in styles/index.js.
*/
import { config as configBase } from '@tamagui/config/v3';
export const AppleTheme = {
...configBase,
name: 'apple',
displayName: 'Apple',
iconWeight: 'duotone',
typography: {
fieldLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
detailLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
pageTitle: { fontSize: '$6', fontWeight: '600', color: '$textPrimary' },
panelTitle: { fontWeight: '600', color: '$textPrimary' },
tableHeader: { fontSize: '$4', fontWeight: '500', color: '$textSecondary' },
sectionTitle: { fontSize: '$6', fontWeight: '600', color: '$textPrimary' },
},
tokens: {
...configBase.tokens,
radius: {
...configBase.tokens.radius,
radiusSm: 8,
radiusMd: 12,
radiusLg: 16,
},
color: {
...configBase.tokens.color,
// System blue
primary: '#0a84ff',
primaryLight: '#5ea8ff',
primaryDark: '#0066cc',
// Secondary system gray
secondary: '#8e8e93',
secondaryLight: '#aeaeb2',
secondaryDark: '#636366',
// System status colors
error: '#ff3b30',
warning: '#ff9500',
info: '#0a84ff',
success: '#34c759',
},
space: {
...configBase.tokens.space,
0: 0,
1: 4,
2: 8,
3: 12,
4: 16,
5: 20,
6: 28,
7: 36,
8: 48,
},
size: {
...configBase.tokens.size,
xs: 11,
sm: 13,
md: 15,
base: 15,
lg: 17,
xl: 19,
'2xl': 22,
'3xl': 26,
'4xl': 32,
'5xl': 40,
'6xl': 56,
},
// Soft, visible shadows
shadowColor: {
...configBase.tokens.shadowColor,
elevation0: 'transparent',
elevation1: 'rgba(0, 0, 0, 0.04)',
elevation2: 'rgba(0, 0, 0, 0.06)',
elevation4: 'rgba(0, 0, 0, 0.08)',
elevation8: 'rgba(0, 0, 0, 0.12)',
elevation12: 'rgba(0, 0, 0, 0.14)',
elevation16: 'rgba(0, 0, 0, 0.16)',
},
},
themes: {
...configBase.themes,
light: {
...configBase.themes.light,
// ---- Legacy keys ----
background: '#ffffff',
backgroundHover: '#f2f2f7',
backgroundPress: '#e5e5ea',
backgroundFocus: '#e8f1ff',
surface: '#ffffff',
surfaceVariant: '#fbfbfd',
accentBackground: '#d6e8ff',
accentSurface: '#eaf3ff',
accentColor: '#0a84ff',
accentBorder: 'rgba(10, 132, 255, 0.28)',
accentHover: '#cee0ff',
accentPress: '#bcd3ff',
color: '#1d1d1f',
colorHover: '#1d1d1f',
colorSecondary: '#6e6e73',
colorDisabled: '#aeaeb2',
borderColor: '#e5e5ea',
borderColorHover: '#d1d1d6',
// ---- Contract tokens ----
bgPage: '#fbfbfd',
bgPanel: '#ffffff',
bgPanelElev: '#ffffff',
bgInverse: '#1d1d1f',
scrim: 'rgba(0, 0, 0, 0.32)',
lineSubtle: '#e5e5ea',
lineStrong: '#d1d1d6',
textPrimary: '#1d1d1f',
textSecondary: '#6e6e73',
textMuted: '#8e8e93',
textOnAccent: '#ffffff',
accent: '#0a84ff',
accentBg: '#eaf3ff',
accentBgHover: '#d6e8ff',
danger: '#ff3b30',
dangerBg: '#ffe5e3',
warning: '#ff9500',
warningBg: '#fff1de',
success: '#34c759',
successBg: '#e2f7e6',
info: '#0a84ff',
infoBg: '#eaf3ff',
},
dark: {
...configBase.themes.dark,
// ---- Legacy keys ----
background: '#000000',
backgroundHover: '#1c1c1e',
backgroundPress: '#2c2c2e',
backgroundFocus: '#1a3a66',
surface: '#1c1c1e',
surfaceVariant: '#2c2c2e',
accentBackground: '#0b3866',
accentSurface: '#0a2a4d',
accentColor: '#5ea8ff',
accentBorder: 'rgba(94, 168, 255, 0.32)',
accentHover: '#11437a',
accentPress: '#0e3868',
color: '#f5f5f7',
colorHover: '#f5f5f7',
colorSecondary: '#aeaeb2',
colorDisabled: '#636366',
borderColor: '#2c2c2e',
borderColorHover: '#3a3a3c',
// ---- Contract tokens ----
bgPage: '#000000',
bgPanel: '#1c1c1e',
bgPanelElev: '#2c2c2e',
bgInverse: '#f5f5f7',
scrim: 'rgba(0, 0, 0, 0.6)',
lineSubtle: '#2c2c2e',
lineStrong: '#3a3a3c',
textPrimary: '#f5f5f7',
textSecondary: '#aeaeb2',
textMuted: '#8e8e93',
textOnAccent: '#0b1f3d',
accent: '#5ea8ff',
accentBg: '#0a2a4d',
accentBgHover: '#0b3866',
danger: '#ff6961',
dangerBg: 'rgba(255, 105, 97, 0.14)',
warning: '#ffb340',
warningBg: 'rgba(255, 179, 64, 0.14)',
success: '#5fd66f',
successBg: 'rgba(95, 214, 111, 0.14)',
info: '#5ea8ff',
infoBg: 'rgba(94, 168, 255, 0.14)',
},
},
settings: {
...configBase.settings,
styleCompat: 'web',
},
};
export default AppleTheme;
+193
View File
@@ -0,0 +1,193 @@
/**
* Azure Theme
* Clean enterprise look inspired by Microsoft Azure / Fluent UI.
*
* Aesthetic intent
* ----------------
* - Cool neutral surfaces (very subtle gray page, pure white panels)
* - Hairline borders, shadows reserved for popovers / modals only
* - Tight radii (4 / 6 / 8) — confident rectangles, not pills
* - Neutral blue accent reserved for primary action and selected state
* - Compact typography weights (400 body, 600 titles, no 700 bold)
* - Phosphor icons at "regular" weight, 1.5px stroke
*
* Implements the semantic token contract documented in styles/index.js.
*/
import { config as configBase } from '@tamagui/config/v3';
export const AzureTheme = {
...configBase,
name: 'azure',
displayName: 'Azure',
iconWeight: 'regular',
typography: {
fieldLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
detailLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
pageTitle: { fontSize: '$6', fontWeight: '600', color: '$textPrimary' },
panelTitle: { fontWeight: '600', color: '$textPrimary' },
tableHeader: { fontSize: '$4', fontWeight: '600', color: '$textSecondary' },
sectionTitle: { fontSize: '$6', fontWeight: '600', color: '$textPrimary' },
},
tokens: {
...configBase.tokens,
radius: {
...configBase.tokens.radius,
radiusSm: 4,
radiusMd: 6,
radiusLg: 8,
},
color: {
...configBase.tokens.color,
// Neutral-blue accent (Microsoft brand-adjacent, not exact)
primary: '#2563eb',
primaryLight: '#3b82f6',
primaryDark: '#1d4ed8',
// Secondary kept as a quiet teal-ish neutral
secondary: '#475569',
secondaryLight: '#64748b',
secondaryDark: '#334155',
// Status (Fluent-inspired)
error: '#c5221f',
warning: '#bf6900',
info: '#0078d4',
success: '#0f7b3a',
},
space: {
...configBase.tokens.space,
0: 0,
1: 4,
2: 8,
3: 12,
4: 16,
5: 20,
6: 24,
7: 32,
8: 40,
},
size: {
...configBase.tokens.size,
xs: 11,
sm: 12,
md: 14,
base: 14,
lg: 16,
xl: 18,
'2xl': 20,
'3xl': 24,
'4xl': 28,
'5xl': 36,
'6xl': 48,
},
// Near-zero shadows — surfaces are defined by border, not depth
shadowColor: {
...configBase.tokens.shadowColor,
elevation0: 'transparent',
elevation1: 'rgba(15, 23, 42, 0.04)',
elevation2: 'rgba(15, 23, 42, 0.06)',
elevation4: 'rgba(15, 23, 42, 0.08)',
elevation8: 'rgba(15, 23, 42, 0.10)',
elevation12: 'rgba(15, 23, 42, 0.12)',
elevation16: 'rgba(15, 23, 42, 0.14)',
},
},
themes: {
...configBase.themes,
light: {
...configBase.themes.light,
// ---- Legacy Tamagui keys (kept for backward compat) ----
background: '#ffffff',
backgroundHover: '#f3f4f6',
backgroundPress: '#e5e7eb',
backgroundFocus: '#eef4ff',
surface: '#ffffff',
surfaceVariant: '#f7f8fa',
accentBackground: '#dbeafe',
accentSurface: '#eef4ff',
accentColor: '#2563eb',
accentBorder: 'rgba(37, 99, 235, 0.28)',
accentHover: '#dbe6ff',
accentPress: '#c7d8ff',
color: '#0f172a',
colorHover: '#0f172a',
colorSecondary: '#475569',
colorDisabled: '#94a3b8',
borderColor: '#e5e7eb',
borderColorHover: '#cbd5e1',
// ---- Contract tokens ----
bgPage: '#f7f8fa',
bgPanel: '#ffffff',
bgPanelElev: '#ffffff',
bgInverse: '#0f172a',
scrim: 'rgba(15, 23, 42, 0.32)',
lineSubtle: '#e5e7eb',
lineStrong: '#cbd5e1',
textPrimary: '#0f172a',
textSecondary: '#475569',
textMuted: '#64748b',
textOnAccent: '#ffffff',
accent: '#2563eb',
accentBg: '#eef4ff',
accentBgHover: '#dbeafe',
danger: '#c5221f',
dangerBg: '#fdecea',
warning: '#bf6900',
warningBg: '#fdf3e2',
success: '#0f7b3a',
successBg: '#e6f4ea',
info: '#0078d4',
infoBg: '#deecf9',
},
dark: {
...configBase.themes.dark,
// ---- Legacy keys ----
background: '#0b1220',
backgroundHover: '#111827',
backgroundPress: '#1f2937',
backgroundFocus: '#1e3a8a',
surface: '#111827',
surfaceVariant: '#1f2937',
accentBackground: '#1e3a8a',
accentSurface: '#1e293b',
accentColor: '#60a5fa',
accentBorder: 'rgba(96, 165, 250, 0.32)',
accentHover: '#243763',
accentPress: '#2a4079',
color: '#f1f5f9',
colorHover: '#f1f5f9',
colorSecondary: '#cbd5e1',
colorDisabled: '#64748b',
borderColor: '#1f2937',
borderColorHover: '#334155',
// ---- Contract tokens ----
bgPage: '#0b1220',
bgPanel: '#111827',
bgPanelElev: '#1f2937',
bgInverse: '#f1f5f9',
scrim: 'rgba(0, 0, 0, 0.6)',
lineSubtle: '#1f2937',
lineStrong: '#334155',
textPrimary: '#f1f5f9',
textSecondary: '#cbd5e1',
textMuted: '#94a3b8',
textOnAccent: '#0b1220',
accent: '#60a5fa',
accentBg: '#1e293b',
accentBgHover: '#1e3a8a',
danger: '#f87171',
dangerBg: 'rgba(248, 113, 113, 0.14)',
warning: '#fbbf24',
warningBg: 'rgba(251, 191, 36, 0.14)',
success: '#4ade80',
successBg: 'rgba(74, 222, 128, 0.14)',
info: '#60a5fa',
infoBg: 'rgba(96, 165, 250, 0.14)',
},
},
settings: {
...configBase.settings,
styleCompat: 'web',
},
};
export default AzureTheme;
+61
View File
@@ -9,8 +9,23 @@ export const ColorfulTheme = {
...configBase,
name: 'colorful',
displayName: 'Colorful',
iconWeight: 'bold',
typography: {
fieldLabel: { fontSize: '$4', fontWeight: '600', color: '$textPrimary' },
detailLabel: { fontSize: '$4', fontWeight: '600', color: '$textPrimary' },
pageTitle: { fontSize: '$6', fontWeight: '700', color: '$textPrimary' },
panelTitle: { fontWeight: '600', color: '$textPrimary' },
tableHeader: { fontSize: '$4', fontWeight: '600', color: '$textSecondary' },
sectionTitle: { fontSize: '$6', fontWeight: '700', color: '$textPrimary' },
},
tokens: {
...configBase.tokens,
radius: {
...configBase.tokens.radius,
radiusSm: 8,
radiusMd: 12,
radiusLg: 16,
},
// Colorful palette (vibrant colors)
color: {
...configBase.tokens.color,
@@ -90,6 +105,29 @@ export const ColorfulTheme = {
colorDisabled: '#94a3b8',
borderColor: '#cbd5e1',
borderColorHover: '#94a3b8',
// ---- Contract tokens (mapped to Colorful palette) ----
bgPage: '#f8fafc',
bgPanel: '#ffffff',
bgPanelElev: '#ffffff',
bgInverse: '#0f172a',
scrim: 'rgba(15, 23, 42, 0.45)',
lineSubtle: '#e2e8f0',
lineStrong: '#cbd5e1',
textPrimary: '#0f172a',
textSecondary: '#475569',
textMuted: '#94a3b8',
textOnAccent: '#ffffff',
accent: '#2563eb',
accentBg: '#eef4ff',
accentBgHover: '#dbeafe',
danger: '#ef4444',
dangerBg: '#fee2e2',
warning: '#f59e0b',
warningBg: '#fef3c7',
success: '#10b981',
successBg: '#d1fae5',
info: '#06b6d4',
infoBg: '#cffafe',
},
dark: {
...configBase.themes.dark,
@@ -111,6 +149,29 @@ export const ColorfulTheme = {
colorDisabled: '#64748b',
borderColor: '#334155',
borderColorHover: '#475569',
// ---- Contract tokens ----
bgPage: '#0f172a',
bgPanel: '#1e293b',
bgPanelElev: '#334155',
bgInverse: '#f1f5f9',
scrim: 'rgba(0, 0, 0, 0.6)',
lineSubtle: '#334155',
lineStrong: '#475569',
textPrimary: '#f1f5f9',
textSecondary: '#cbd5e1',
textMuted: '#64748b',
textOnAccent: '#0a1929',
accent: '#bfdbfe',
accentBg: '#1e3a8a',
accentBgHover: '#1d4ed8',
danger: '#fca5a5',
dangerBg: 'rgba(252, 165, 165, 0.14)',
warning: '#fbbf24',
warningBg: 'rgba(251, 191, 36, 0.14)',
success: '#6ee7b7',
successBg: 'rgba(110, 231, 183, 0.14)',
info: '#67e8f9',
infoBg: 'rgba(103, 232, 249, 0.14)',
},
},
settings: {
+62
View File
@@ -9,8 +9,24 @@ export const MaterialTheme = {
...configBase,
name: 'material',
displayName: 'Material Design',
// Preset-level metadata (consumed by IconMapper)
iconWeight: 'regular',
typography: {
fieldLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
detailLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
pageTitle: { fontSize: '$6', fontWeight: '500', color: '$textPrimary' },
panelTitle: { fontWeight: '500', color: '$textPrimary' },
tableHeader: { fontSize: '$4', fontWeight: '500', color: '$textSecondary' },
sectionTitle: { fontSize: '$6', fontWeight: '500', color: '$textPrimary' },
},
tokens: {
...configBase.tokens,
radius: {
...configBase.tokens.radius,
radiusSm: 4,
radiusMd: 6,
radiusLg: 8,
},
// Material UI color palette (base tokens - theme-agnostic)
color: {
...configBase.tokens.color,
@@ -90,6 +106,29 @@ export const MaterialTheme = {
colorDisabled: 'rgba(0, 0, 0, 0.38)',
borderColor: 'rgba(0, 0, 0, 0.12)',
borderColorHover: 'rgba(0, 0, 0, 0.23)',
// ---- Contract tokens (mapped to Material palette) ----
bgPage: '#fafafa',
bgPanel: '#ffffff',
bgPanelElev: '#ffffff',
bgInverse: '#323232',
scrim: 'rgba(0, 0, 0, 0.32)',
lineSubtle: 'rgba(0, 0, 0, 0.12)',
lineStrong: 'rgba(0, 0, 0, 0.23)',
textPrimary: 'rgba(0, 0, 0, 0.87)',
textSecondary: 'rgba(0, 0, 0, 0.6)',
textMuted: 'rgba(0, 0, 0, 0.38)',
textOnAccent: '#ffffff',
accent: '#1976d2',
accentBg: '#f4f8ff',
accentBgHover: '#e3f2fd',
danger: '#d32f2f',
dangerBg: 'rgba(211, 47, 47, 0.08)',
warning: '#ed6c02',
warningBg: 'rgba(237, 108, 2, 0.08)',
success: '#2e7d32',
successBg: 'rgba(46, 125, 50, 0.08)',
info: '#0288d1',
infoBg: 'rgba(2, 136, 209, 0.08)',
},
dark: {
...configBase.themes.dark,
@@ -111,6 +150,29 @@ export const MaterialTheme = {
colorDisabled: 'rgba(255, 255, 255, 0.38)',
borderColor: 'rgba(255, 255, 255, 0.12)',
borderColorHover: 'rgba(255, 255, 255, 0.23)',
// ---- Contract tokens ----
bgPage: '#121212',
bgPanel: '#1e1e1e',
bgPanelElev: '#2c2c2c',
bgInverse: '#f0f0f0',
scrim: 'rgba(0, 0, 0, 0.6)',
lineSubtle: 'rgba(255, 255, 255, 0.12)',
lineStrong: 'rgba(255, 255, 255, 0.23)',
textPrimary: 'rgba(255, 255, 255, 0.87)',
textSecondary: 'rgba(255, 255, 255, 0.6)',
textMuted: 'rgba(255, 255, 255, 0.38)',
textOnAccent: '#0a1929',
accent: '#90caf9',
accentBg: '#1b3c5b',
accentBgHover: '#17324d',
danger: '#ef9a9a',
dangerBg: 'rgba(239, 154, 154, 0.12)',
warning: '#ffb74d',
warningBg: 'rgba(255, 183, 77, 0.12)',
success: '#81c784',
successBg: 'rgba(129, 199, 132, 0.12)',
info: '#4fc3f7',
infoBg: 'rgba(79, 195, 247, 0.12)',
},
},
settings: {
+61
View File
@@ -9,8 +9,23 @@ export const MinimalTheme = {
...configBase,
name: 'minimal',
displayName: 'Minimal',
iconWeight: 'light',
typography: {
fieldLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
detailLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
pageTitle: { fontSize: '$6', fontWeight: '600', color: '$textPrimary' },
panelTitle: { fontWeight: '500', color: '$textPrimary' },
tableHeader: { fontSize: '$4', fontWeight: '500', color: '$textSecondary' },
sectionTitle: { fontSize: '$6', fontWeight: '600', color: '$textPrimary' },
},
tokens: {
...configBase.tokens,
radius: {
...configBase.tokens.radius,
radiusSm: 2,
radiusMd: 4,
radiusLg: 6,
},
// Minimal color palette (neutral, monochromatic)
color: {
...configBase.tokens.color,
@@ -90,6 +105,29 @@ export const MinimalTheme = {
colorDisabled: '#a0aec0',
borderColor: '#e2e8f0',
borderColorHover: '#cbd5e0',
// ---- Contract tokens (mapped to Minimal palette) ----
bgPage: '#fafafa',
bgPanel: '#ffffff',
bgPanelElev: '#ffffff',
bgInverse: '#1a202c',
scrim: 'rgba(26, 32, 44, 0.42)',
lineSubtle: '#e2e8f0',
lineStrong: '#cbd5e0',
textPrimary: '#1a202c',
textSecondary: '#4a5568',
textMuted: '#a0aec0',
textOnAccent: '#ffffff',
accent: '#4f46e5',
accentBg: '#f5f7fb',
accentBgHover: '#eceff4',
danger: '#e53e3e',
dangerBg: 'rgba(229, 62, 62, 0.08)',
warning: '#dd6b20',
warningBg: 'rgba(221, 107, 32, 0.08)',
success: '#38a169',
successBg: 'rgba(56, 161, 105, 0.08)',
info: '#3182ce',
infoBg: 'rgba(49, 130, 206, 0.08)',
},
dark: {
...configBase.themes.dark,
@@ -111,6 +149,29 @@ export const MinimalTheme = {
colorDisabled: '#718096',
borderColor: '#2d3748',
borderColorHover: '#4a5568',
// ---- Contract tokens ----
bgPage: '#0f1419',
bgPanel: '#1a202c',
bgPanelElev: '#2d3748',
bgInverse: '#f7fafc',
scrim: 'rgba(0, 0, 0, 0.6)',
lineSubtle: '#2d3748',
lineStrong: '#4a5568',
textPrimary: '#f7fafc',
textSecondary: '#cbd5e0',
textMuted: '#718096',
textOnAccent: '#0f1419',
accent: '#c7d2fe',
accentBg: '#333f5d',
accentBgHover: '#2c3650',
danger: '#feb2b2',
dangerBg: 'rgba(254, 178, 178, 0.12)',
warning: '#fbd38d',
warningBg: 'rgba(251, 211, 141, 0.12)',
success: '#9ae6b4',
successBg: 'rgba(154, 230, 180, 0.12)',
info: '#90cdf4',
infoBg: 'rgba(144, 205, 244, 0.12)',
},
},
settings: {
+228 -6
View File
@@ -1,19 +1,202 @@
/**
* Style Themes Index
* Exports all available style themes
* Exports all available style themes and the semantic token contract.
*
* ============================================================================
* SEMANTIC TOKEN CONTRACT
* ============================================================================
* Every style theme MUST expose this set of tokens in `themes.light` and
* `themes.dark`. Component code should read ONLY these contract tokens —
* never raw colors, and never interaction tokens (`$accentSurface`,
* `$backgroundHover`, etc.) for static surfaces.
*
* Surfaces
* --------
* bgPage page background (subtly off-white in light)
* bgPanel card / panel surface (usually pure white in light)
* bgPanelElev raised panel (shadow OR slightly different bg)
* bgInverse inverted surface (toasts, popovers on dark)
* scrim modal/drawer overlay (semi-transparent backdrop)
*
* Lines
* -----
* lineSubtle panel borders, hairline (~6-10% black equivalent)
* lineStrong separators, dividers (~14-20% black equivalent)
*
* Text
* ----
* textPrimary body text (high contrast)
* textSecondary supporting text (medium contrast)
* textMuted hints, disabled, captions (low contrast)
* textOnAccent text on accent-colored backgrounds (usually white)
*
* Accent
* ------
* accent brand / primary action color
* accentBg ~6% accent tint (selected backgrounds)
* accentBgHover ~12% accent tint
* accentBorder accent-colored border
*
* Status (each + Bg variant)
* --------------------------
* danger / dangerBg
* warning / warningBg
* success / successBg
* info / infoBg
*
* Radii (preset overrides via tokens.radius)
* ------------------------------------------
* radiusSm tight controls (4-8)
* radiusMd cards, inputs (6-12)
* radiusLg large containers (8-16)
*
* Font weights (preset overrides via tokens.weight)
* -------------------------------------------------
* weightRegular 400
* weightMedium 500
* weightSemibold 600
* weightBold 700
*
* Preset-level metadata (NOT theme tokens — top-level preset properties)
* -----------------------------------------------------------------------
* iconWeight Phosphor weight: 'thin' | 'light' | 'regular' |
* 'bold' | 'fill' | 'duotone'
*
* Typography roles (preset-level contract)
* ----------------------------------------
* typography.fieldLabel
* typography.detailLabel
* typography.pageTitle
* typography.panelTitle
* typography.tableHeader
* typography.sectionTitle
* ============================================================================
*/
import { MaterialTheme } from './MaterialTheme.js';
import { MinimalTheme } from './MinimalTheme.js';
import { ColorfulTheme } from './ColorfulTheme.js';
import { AzureTheme } from './AzureTheme.js';
import { AppleTheme } from './AppleTheme.js';
/**
* Theme keys that every preset MUST define in both light and dark variants.
* Used by `validateStyleTheme` for diagnostics.
*/
export const CONTRACT_KEYS = Object.freeze([
// surfaces
'bgPage', 'bgPanel', 'bgPanelElev', 'bgInverse', 'scrim',
// lines
'lineSubtle', 'lineStrong',
// text
'textPrimary', 'textSecondary', 'textMuted', 'textOnAccent',
// accent
'accent', 'accentBg', 'accentBgHover', 'accentBorder',
// status
'danger', 'dangerBg',
'warning', 'warningBg',
'success', 'successBg',
'info', 'infoBg',
]);
/**
* Valid Phosphor icon weights.
*/
export const ICON_WEIGHTS = Object.freeze([
'thin', 'light', 'regular', 'bold', 'fill', 'duotone',
]);
export const STYLE_THEMES = {
azure: AzureTheme,
apple: AppleTheme,
material: MaterialTheme,
minimal: MinimalTheme,
colorful: ColorfulTheme,
};
export const DEFAULT_STYLE_THEME = 'material';
export const DEFAULT_STYLE_THEME = 'azure';
export const TYPOGRAPHY_ROLE_KEYS = Object.freeze([
'fieldLabel',
'detailLabel',
'pageTitle',
'panelTitle',
'tableHeader',
'sectionTitle',
]);
const DEFAULT_TYPOGRAPHY_ROLES = Object.freeze({
fieldLabel: Object.freeze({
fontSize: '$4',
fontWeight: '500',
color: '$textPrimary',
}),
detailLabel: Object.freeze({
fontSize: '$4',
fontWeight: '500',
color: '$textPrimary',
}),
pageTitle: Object.freeze({
fontSize: '$6',
fontWeight: '600',
color: '$textPrimary',
}),
panelTitle: Object.freeze({
fontWeight: '600',
color: '$textPrimary',
}),
tableHeader: Object.freeze({
fontSize: '$4',
fontWeight: '600',
color: '$textSecondary',
}),
sectionTitle: Object.freeze({
fontSize: '$6',
fontWeight: '600',
color: '$textPrimary',
}),
});
let activeStyleThemeName = DEFAULT_STYLE_THEME;
/**
* Validate that a preset implements the contract.
* Logs a warning per missing key per variant; never throws.
* Useful while authoring presets — silent in production.
* @param {Object} preset
* @returns {{ ok: boolean, missing: string[] }}
*/
export function validateStyleTheme(preset) {
if (!preset || !preset.themes) {
return { ok: false, missing: ['themes'] };
}
const missing = [];
for (const variant of ['light', 'dark']) {
const theme = preset.themes[variant];
if (!theme) {
missing.push(`themes.${variant}`);
continue;
}
for (const key of CONTRACT_KEYS) {
if (theme[key] == null) {
missing.push(`themes.${variant}.${key}`);
}
}
}
if (!preset.typography) {
missing.push('typography');
} else {
for (const key of TYPOGRAPHY_ROLE_KEYS) {
if (!preset.typography[key]) {
missing.push(`typography.${key}`);
}
}
}
if (missing.length && typeof console !== 'undefined') {
console.warn(`[styles] Preset "${preset.name || 'unknown'}" is missing contract tokens:`, missing);
}
return { ok: missing.length === 0, missing };
}
/**
* Map arbitrary input (storage, profile, UI) to a registered style theme id.
@@ -30,8 +213,8 @@ export function normalizeStyleThemeName(name) {
}
/**
* Get a style theme by name
* @param {string} themeName - Theme name ('material', 'minimal', 'colorful')
* Get a style theme by name.
* @param {string} themeName - Theme name ('azure', 'apple', 'material', 'minimal', 'colorful')
* @returns {Object} Theme configuration
*/
export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) {
@@ -39,13 +222,52 @@ export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) {
return STYLE_THEMES[key];
}
export function setActiveStyleThemeName(themeName) {
activeStyleThemeName = normalizeStyleThemeName(themeName);
return activeStyleThemeName;
}
export function getActiveStyleThemeName() {
return activeStyleThemeName;
}
export function getStyleTypography(themeName = activeStyleThemeName) {
const preset = getStyleTheme(themeName);
const typography = preset?.typography || {};
const resolved = {};
for (const key of TYPOGRAPHY_ROLE_KEYS) {
resolved[key] = {
...(DEFAULT_TYPOGRAPHY_ROLES[key] || {}),
...(typography[key] || {}),
};
}
return resolved;
}
export function getTypographyRoleProps(role, overrides = null, themeName = activeStyleThemeName) {
const typography = getStyleTypography(themeName);
const base = typography[role] || {};
return overrides ? { ...base, ...overrides } : { ...base };
}
/**
* Get all available style theme names
* Get all available style theme names.
* @returns {string[]} Array of theme names
*/
export function getStyleThemeNames() {
return Object.keys(STYLE_THEMES);
}
export { MaterialTheme, MinimalTheme, ColorfulTheme };
/**
* Get the active preset's icon weight (for Phosphor).
* Falls back to 'regular'.
* @param {string} [themeName]
* @returns {string}
*/
export function getIconWeight(themeName = DEFAULT_STYLE_THEME) {
const preset = getStyleTheme(themeName);
const weight = preset?.iconWeight;
return ICON_WEIGHTS.includes(weight) ? weight : 'regular';
}
export { MaterialTheme, MinimalTheme, ColorfulTheme, AzureTheme, AppleTheme };