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" "test:watch": "node --localstorage-file=.node-localstorage --test --watch test/**/*.test.js"
}, },
"dependencies": { "dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@tamagui/config": "^1.144.2", "@tamagui/config": "^1.144.2",
"@tamagui/core": "^1.144.2", "@tamagui/core": "^1.144.2",
"@tamagui/lucide-icons": "^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/App.jsx';
export * from './ui/components/index.js'; export * from './ui/components/index.js';
export * from './ui/runtime/general-settings.js'; export * from './ui/runtime/general-settings.js';
export * from './ui/styles/index.js';
// Re-export security modules // Re-export security modules
export * from './security/index.js'; 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 { securityService, useSecurityState } from '../runtime/security-service.js';
import { Panel } from '../../ui/components/Panel.jsx'; 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 ( return (
<YStack gap="$2"> <YStack gap="$2">
<Label htmlFor={id} size="$3" color="$color" fontWeight="600"> <Label htmlFor={id} size="$3" color="$textPrimary" fontWeight="600">
{label} {label}
</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> </YStack>
); );
}); });
@@ -64,8 +71,8 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
icon="login" icon="login"
title={title} title={title}
width="100%" width="100%"
headerFront={{ color: '$accentColor' }} headerFront={{ color: '$textPrimary' }}
headerBack={{ backgroundColor: '$accentSurface' }} headerBack={{ backgroundColor: '$bgPanel' }}
> >
<YStack <YStack
tag="form" tag="form"
@@ -73,12 +80,13 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
autoComplete="on" autoComplete="on"
> >
<Paragraph color="$color" opacity={0.78}> <Paragraph color="$textMuted">
{subtitle} {subtitle}
</Paragraph> </Paragraph>
<LoginField <LoginField
id="login-identifier" id="login-identifier"
label="Username or email" label="Username or email"
error={Boolean(errorMessage)}
placeholder="Username or email" placeholder="Username or email"
value={identifier} value={identifier}
onChangeText={setIdentifier} onChangeText={setIdentifier}
@@ -96,6 +104,7 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
<LoginField <LoginField
id="login-password" id="login-password"
label="Password" label="Password"
error={Boolean(errorMessage)}
ref={passwordInputRef} ref={passwordInputRef}
placeholder="Password" placeholder="Password"
value={password} value={password}
@@ -111,30 +120,28 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
onKeyDown={handlePasswordKeyDown} onKeyDown={handlePasswordKeyDown}
/> />
{errorMessage ? ( {errorMessage ? (
<Text color="#ef4444" fontSize="$4"> <Text color="$danger" fontSize="$4">
{errorMessage} {errorMessage}
</Text> </Text>
) : null} ) : null}
<Button <Button
themeInverse theme="accent"
backgroundColor="$accentColor"
color="white"
onPress={handleSubmit} onPress={handleSubmit}
disabled={security.loading || !security.enabled || !security.initialized} disabled={security.loading || !security.enabled || !security.initialized}
> >
{security.loading ? 'Signing In...' : 'Sign In'} {security.loading ? 'Signing In...' : 'Sign In'}
</Button> </Button>
{!security.enabled ? ( {!security.enabled ? (
<Paragraph fontSize="$3" color="#8b5e3c"> <Paragraph fontSize="$3" color="$textSecondary">
Identity is currently disabled in the active app profile. Identity is currently disabled in the active app profile.
</Paragraph> </Paragraph>
) : null} ) : null}
{security.enabled && !security.initialized ? ( {security.enabled && !security.initialized ? (
<Paragraph fontSize="$3" color="#8b5e3c"> <Paragraph fontSize="$3" color="$textSecondary">
Security is still initializing. Security is still initializing.
</Paragraph> </Paragraph>
) : null} ) : null}
<Paragraph fontSize="$3" color="$color" opacity={0.65}> <Paragraph fontSize="$3" color="$textMuted">
Demo credentials: admin / admin or demo / demo Demo credentials: admin / admin or demo / demo
</Paragraph> </Paragraph>
</YStack> </YStack>
@@ -153,7 +160,7 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
padding="$6" padding="$6"
backgroundColor="$background" backgroundColor="$bgPage"
> >
<YStack width="100%" maxWidth={520}> <YStack width="100%" maxWidth={520}>
{content} {content}
+4 -4
View File
@@ -36,22 +36,22 @@ export function AppInfo({ appName, swStatus, storageBackend, menuItems = [], ini
<XStack gap="$2"> <XStack gap="$2">
<Text>App:</Text> <Text>App:</Text>
<Text fontWeight="bold">{appName || 'Loading...'}</Text> <Text fontWeight="600">{appName || 'Loading...'}</Text>
</XStack> </XStack>
<XStack gap="$2"> <XStack gap="$2">
<Text>Service Worker:</Text> <Text>Service Worker:</Text>
<Text fontWeight="bold">{swStatus}</Text> <Text fontWeight="600">{swStatus}</Text>
</XStack> </XStack>
<XStack gap="$2"> <XStack gap="$2">
<Text>Storage Backend:</Text> <Text>Storage Backend:</Text>
<Text fontWeight="bold">{storageBackend}</Text> <Text fontWeight="600">{storageBackend}</Text>
</XStack> </XStack>
{initialized && menuItems.length > 0 && ( {initialized && menuItems.length > 0 && (
<YStack marginTop="$4" gap="$2"> <YStack marginTop="$4" gap="$2">
<Text fontWeight="bold">Menu Items:</Text> <Text fontWeight="600">Menu Items:</Text>
{menuItems.map((item) => ( {menuItems.map((item) => (
<XStack key={item.id} gap="$2"> <XStack key={item.id} gap="$2">
<Text> {item.label}</Text> <Text> {item.label}</Text>
+85 -58
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 { Button, Input, Paragraph, ScrollView, Separator, Spinner, Text, XStack, YStack } from 'tamagui';
import { getIcon } from './IconMapper.jsx'; import { getIcon } from './IconMapper.jsx';
import { normalizeColumnsArray } from './grid/utils.js'; import { normalizeColumnsArray } from './grid/utils.js';
import { getTypographyRoleProps } from '../styles/index.js';
const EMPTY_COLUMNS = []; const EMPTY_COLUMNS = [];
const EMPTY_ACTIONS = []; const EMPTY_ACTIONS = [];
@@ -43,15 +44,15 @@ function SummaryCards({ summary }) {
minWidth={140} minWidth={140}
padding="$3" padding="$3"
borderWidth={1} borderWidth={1}
borderColor="$borderColor" borderColor="$lineSubtle"
borderRadius="$4" borderRadius="$radiusMd"
backgroundColor="$accentSurface" backgroundColor="$bgPanel"
gap="$1" gap="$1"
> >
<Text fontSize="$3" color="$color" opacity={0.7}> <Text fontSize="$3" color="$textMuted">
{item.label} {item.label}
</Text> </Text>
<Text fontSize="$7" fontWeight="700" color="$accentColor"> <Text fontSize="$7" fontWeight="700" color="$accent">
{normalizeSummaryValue(item.value)} {normalizeSummaryValue(item.value)}
</Text> </Text>
</YStack> </YStack>
@@ -63,7 +64,8 @@ function SummaryCards({ summary }) {
function HeaderCell({ column, orderBy, order, onSort }) { function HeaderCell({ column, orderBy, order, onSort }) {
const sortable = column.sortable !== false; const sortable = column.sortable !== false;
const isActive = orderBy === column.id; 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); const justifyContent = getColumnJustify(column.align);
return ( return (
@@ -81,10 +83,23 @@ function HeaderCell({ column, orderBy, order, onSort }) {
padding={0} padding={0}
justifyContent={justifyContent} justifyContent={justifyContent}
width="100%" 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%"> <XStack width="100%" alignItems="center" justifyContent={justifyContent} gap="$2">
{column.label}{arrow ? ` ${arrow}` : ''} <Text {...getTypographyRoleProps('tableHeader')} textAlign={column.align || 'left'} numberOfLines={1}>
</Text> {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> </Button>
</XStack> </XStack>
); );
@@ -133,7 +148,11 @@ export function DirView({
bodyMaxHeight = 480, bodyMaxHeight = 480,
onRowClick = null, onRowClick = null,
onRowPress = null, onRowPress = null,
onRefresh = null onRefresh = null,
showHeader = true,
showSummary = true,
density = 'comfortable',
striped = false
}) { }) {
const [dataVersion, setDataVersion] = useState(0); const [dataVersion, setDataVersion] = useState(0);
const [records, setRecords] = useState([]); const [records, setRecords] = useState([]);
@@ -155,6 +174,7 @@ export function DirView({
const PreviousPageIcon = getIcon('chevron-left'); const PreviousPageIcon = getIcon('chevron-left');
const NextPageIcon = getIcon('chevron-right'); const NextPageIcon = getIcon('chevron-right');
const LastPageIcon = getIcon('last-page'); const LastPageIcon = getIcon('last-page');
const paddingRow = density === 'compact' ? '$2' : density === 'spacious' ? '$4' : '$3';
useEffect(() => { useEffect(() => {
if (!dataModel?.subscribe) { if (!dataModel?.subscribe) {
@@ -275,49 +295,54 @@ export function DirView({
return ( return (
<YStack gap="$4" width="100%"> <YStack gap="$4" width="100%">
<XStack justifyContent="space-between" alignItems="center" gap="$4" flexWrap="wrap"> {showHeader ? (
<XStack alignItems="center" gap="$3" flex={1} minWidth={240} flexWrap="wrap"> <XStack justifyContent="space-between" alignItems="center" gap="$4" flexWrap="wrap">
<Text fontSize="$8" fontWeight="800" color="$accentColor"> <XStack alignItems="center" gap="$3" flex={1} minWidth={240} flexWrap="wrap">
{title} <Text {...getTypographyRoleProps('sectionTitle')}>
</Text> {title}
{topLeftContent} </Text>
</XStack> {topLeftContent}
</XStack>
<XStack alignItems="center" justifyContent="flex-end" gap="$2" flexWrap="wrap"> <XStack alignItems="center" justifyContent="flex-end" gap="$2" flexWrap="wrap">
{searchConfig?.enabled ? ( {searchConfig?.enabled ? (
<Input <Input
width={260} width={260}
placeholder={searchConfig.placeholder || 'Search records...'} placeholder={searchConfig.placeholder || 'Search records...'}
value={effectiveSearchTerm} value={effectiveSearchTerm}
onChangeText={updateSearchTerm} onChangeText={updateSearchTerm}
backgroundColor="$bgPanel"
borderColor="$lineSubtle"
focusStyle={{ borderColor: '$accent' }}
/>
) : null}
{effectiveToolbarItems.map(renderToolbarButton)}
<Button
size="$3"
chromeless
circular
aria-label="Refresh directory"
icon={RefreshIcon ? <RefreshIcon size="sm" color="$textSecondary" /> : undefined}
onPress={handleRefresh}
disabled={loading}
/> />
) : null} {topRightContent}
{effectiveToolbarItems.map(renderToolbarButton)} </XStack>
<Button
size="$3"
chromeless
circular
aria-label="Refresh directory"
icon={RefreshIcon ? <RefreshIcon size={16} /> : undefined}
onPress={handleRefresh}
disabled={loading}
/>
{topRightContent}
</XStack> </XStack>
</XStack> ) : null}
{error ? ( {error ? (
<YStack padding="$3" borderRadius="$4" backgroundColor="#fef2f2" borderWidth={1} borderColor="#fecaca"> <YStack padding="$3" borderRadius="$radiusMd" backgroundColor="$dangerBg" borderWidth={1} borderColor="$danger">
<Text color="#b91c1c">{error}</Text> <Text color="$danger" fontWeight="600">{error}</Text>
</YStack> </YStack>
) : null} ) : null}
<SummaryCards summary={summary} /> {showSummary ? <SummaryCards summary={summary} /> : null}
{bodyHeaderContent} {bodyHeaderContent}
<YStack borderWidth={1} borderColor="$borderColor" borderRadius="$5" overflow="hidden" backgroundColor="$background"> <YStack borderWidth={1} borderColor="$lineSubtle" borderRadius="$radiusLg" overflow="hidden" backgroundColor="$bgPanel">
<XStack padding="$3" gap="$3" backgroundColor="$accentSurface" borderBottomWidth={1} borderBottomColor="$borderColor"> <XStack padding={paddingRow} gap="$3" backgroundColor="transparent" borderBottomWidth={1} borderBottomColor="$lineSubtle">
{resolvedColumns.map((column) => ( {resolvedColumns.map((column) => (
<HeaderCell <HeaderCell
key={column.id} key={column.id}
@@ -334,27 +359,29 @@ export function DirView({
))} ))}
</XStack> </XStack>
<ScrollView maxHeight={bodyMaxHeight}> <ScrollView {...(bodyMaxHeight != null ? { maxHeight: bodyMaxHeight } : {})}>
<YStack> <YStack>
{loading ? ( {loading ? (
<XStack justifyContent="center" padding="$6"> <XStack justifyContent="center" padding="$6">
<Spinner size="large" color="$accentColor" /> <Spinner size="large" color="$accent" />
</XStack> </XStack>
) : records.length === 0 ? ( ) : records.length === 0 ? (
<YStack padding="$6" alignItems="center"> <YStack padding="$6" alignItems="center" gap="$2">
<Paragraph color="$color" opacity={0.7}> <Paragraph color="$textSecondary">
No records found. No records found
</Paragraph> </Paragraph>
<Text color="$textMuted" fontSize="$3">Try adjusting your search or filters.</Text>
</YStack> </YStack>
) : ( ) : (
records.map((record, index) => ( records.map((record, index) => (
<YStack key={record?.[dataModel?.getIdField?.() || 'id'] || index}> <YStack key={record?.[dataModel?.getIdField?.() || 'id'] || index}>
<XStack <XStack
padding="$3" padding={paddingRow}
gap="$3" gap="$3"
alignItems="center" alignItems="center"
hoverStyle={{ backgroundColor: '$accentSurface' }} backgroundColor={striped && index % 2 === 1 ? '$bgPage' : 'transparent'}
pressStyle={{ backgroundColor: '$accentSurface' }} hoverStyle={{ backgroundColor: '$bgPage' }}
pressStyle={{ backgroundColor: '$bgPanelElev' }}
cursor={effectiveRowPress ? 'pointer' : undefined} cursor={effectiveRowPress ? 'pointer' : undefined}
onPress={() => effectiveRowPress?.(record)} onPress={() => effectiveRowPress?.(record)}
> >
@@ -362,7 +389,7 @@ export function DirView({
<RowCell key={column.id} column={column} record={record} /> <RowCell key={column.id} column={column} record={record} />
))} ))}
</XStack> </XStack>
{index < records.length - 1 ? <Separator /> : null} {index < records.length - 1 ? <Separator borderColor="$lineSubtle" /> : null}
</YStack> </YStack>
)) ))
)} )}
@@ -373,15 +400,15 @@ export function DirView({
{bodyFooterContent} {bodyFooterContent}
<XStack justifyContent="space-between" alignItems="center" gap="$3" flexWrap="wrap"> <XStack justifyContent="space-between" alignItems="center" gap="$3" flexWrap="wrap">
<Text color="$color" opacity={0.7}> <Text color="$textMuted">
Rows: {totalRecords} Rows: {totalRecords}
</Text> </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 <Button
size="$3" size="$3"
chromeless chromeless
aria-label="First page" aria-label="First page"
icon={FirstPageIcon ? <FirstPageIcon size={16} /> : undefined} icon={FirstPageIcon ? <FirstPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => setCurrentPage(1)} onPress={() => setCurrentPage(1)}
disabled={currentPage === 1 || loading} disabled={currentPage === 1 || loading}
/> />
@@ -389,18 +416,18 @@ export function DirView({
size="$3" size="$3"
chromeless chromeless
aria-label="Previous page" 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))} onPress={() => setCurrentPage((value) => Math.max(1, value - 1))}
disabled={currentPage === 1 || loading} disabled={currentPage === 1 || loading}
/> />
<Text color="$color" opacity={0.75}> <Text color="$textSecondary">
Page {currentPage} of {totalPages} Page {currentPage} of {totalPages}
</Text> </Text>
<Button <Button
size="$3" size="$3"
chromeless chromeless
aria-label="Next page" 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))} onPress={() => setCurrentPage((value) => Math.min(totalPages, value + 1))}
disabled={currentPage >= totalPages || loading} disabled={currentPage >= totalPages || loading}
/> />
@@ -408,7 +435,7 @@ export function DirView({
size="$3" size="$3"
chromeless chromeless
aria-label="Last page" aria-label="Last page"
icon={LastPageIcon ? <LastPageIcon size={16} /> : undefined} icon={LastPageIcon ? <LastPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => setCurrentPage(totalPages)} onPress={() => setCurrentPage(totalPages)}
disabled={currentPage >= totalPages || loading} disabled={currentPage >= totalPages || loading}
/> />
+111 -43
View File
@@ -1,19 +1,43 @@
import React from 'react'; import React from 'react';
import { Adapt, Button, Input, Label, Paragraph, Select, Separator, Sheet, Text, TextArea, XStack, YStack } from 'tamagui'; import {
import { Check, ChevronDown, ChevronUp } from '@tamagui/lucide-icons'; 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 { 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 }) { function FieldShell({ label, helperText, error, children }) {
return ( return (
<YStack gap="$2" width="100%"> <YStack gap="$2" width="100%">
{label ? ( {label ? (
<Label color="$color" fontWeight="600"> <Label {...getTypographyRoleProps('fieldLabel')}>
{label} {label}
</Label> </Label>
) : null} ) : null}
{children} {children}
{error || helperText ? ( {error || helperText ? (
<Paragraph color={error ? '#dc2626' : '$color'} opacity={error ? 1 : 0.7} fontSize="$3"> <Paragraph color={error ? '$danger' : '$textMuted'} fontSize="$3">
{error || helperText} {error || helperText}
</Paragraph> </Paragraph>
) : null} ) : 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 }) { function SelectField({ label, value, options = [], placeholder, onValueChange, error, helperText, disabled = false }) {
return ( return (
<FieldShell label={label} error={error} helperText={helperText}> <FieldShell label={label} error={error} helperText={helperText}>
<Select value={value ?? ''} onValueChange={onValueChange} disabled={disabled}> <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.Value placeholder={placeholder || label || 'Select'} />
</Select.Trigger> </Select.Trigger>
<Adapt when="sm" platform="touch"> <Adapt when="sm" platform="touch">
<Sheet modal dismissOnSnapToBottom snapPoints={[55]}> <Sheet modal dismissOnSnapToBottom snapPoints={[55]}>
<Sheet.Frame> <Sheet.Frame backgroundColor="$bgPanel">
<Sheet.ScrollView> <Sheet.ScrollView>
<Adapt.Contents /> <Adapt.Contents />
</Sheet.ScrollView> </Sheet.ScrollView>
</Sheet.Frame> </Sheet.Frame>
<Sheet.Overlay /> <Sheet.Overlay backgroundColor="$scrim" />
</Sheet> </Sheet>
</Adapt> </Adapt>
<Select.Content zIndex={200000}> <Select.Content zIndex={200000}>
<Select.ScrollUpButton alignItems="center" justifyContent="center" position="relative" width="100%" height="$3"> <Select.ScrollUpButton alignItems="center" justifyContent="center" position="relative" width="100%" height="$3">
<YStack zIndex={10}> <YStack zIndex={10}>
<ChevronUp size={18} /> {ChevronUp ? <ChevronUp size="sm" color="$textSecondary" /> : null}
</YStack> </YStack>
</Select.ScrollUpButton> </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.Item key={option.value} index={index} value={String(option.value)}>
<Select.ItemText>{option.label}</Select.ItemText> <Select.ItemText>{option.label}</Select.ItemText>
<Select.ItemIndicator marginLeft="auto"> <Select.ItemIndicator marginLeft="auto">
<Check size={16} /> {CheckIcon ? <CheckIcon size="sm" color="$accent" /> : null}
</Select.ItemIndicator> </Select.ItemIndicator>
</Select.Item> </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"> <Select.ScrollDownButton alignItems="center" justifyContent="center" position="relative" width="100%" height="$3">
<YStack zIndex={10}> <YStack zIndex={10}>
<ChevronDown size={18} /> {ChevronDown ? <ChevronDown size="sm" color="$textSecondary" /> : null}
</YStack> </YStack>
</Select.ScrollDownButton> </Select.ScrollDownButton>
</Select.Content> </Select.Content>
@@ -74,7 +130,7 @@ function SelectField({ label, value, options = [], placeholder, onValueChange, e
async function handleFilePick(fieldId, onChange, props = {}) { async function handleFilePick(fieldId, onChange, props = {}) {
const selection = await pickFile({ const selection = await pickFile({
accept: props.accept || '*', accept: props.accept || '*',
readAs: props.readAs || null readAs: props.readAs || null,
}); });
if (!selection?.file) { if (!selection?.file) {
@@ -103,17 +159,17 @@ export function FormField({
const fieldValue = value ?? (type === 'multiselect' ? [] : type === 'checkbox' ? false : ''); const fieldValue = value ?? (type === 'multiselect' ? [] : type === 'checkbox' ? false : '');
if (type === 'divider') { if (type === 'divider') {
return <Separator />; return <Separator borderColor="$lineSubtle" />;
} }
if (type === 'title') { if (type === 'title') {
return ( return (
<YStack gap="$1"> <YStack gap="$1">
<Text fontSize="$7" fontWeight="700" color="$accentColor"> <Text fontSize="$6" fontWeight="600" color="$textPrimary">
{label} {label}
</Text> </Text>
{helperText ? ( {helperText ? (
<Paragraph color="$color" opacity={0.7}> <Paragraph color="$textMuted">
{helperText} {helperText}
</Paragraph> </Paragraph>
) : null} ) : null}
@@ -151,12 +207,7 @@ export function FormField({
<Button <Button
key={option.value} key={option.value}
size="$3" size="$3"
theme={selected ? 'active' : undefined} {...chipProps(selected, disabled)}
backgroundColor={selected ? '$accentColor' : '$background'}
color={selected ? 'white' : '$color'}
borderWidth={1}
borderColor={selected ? '$accentColor' : '$borderColor'}
disabled={disabled}
onPress={() => { onPress={() => {
const nextValues = selected const nextValues = selected
? selectedValues.filter((item) => item !== String(option.value)) ? 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') { if (type === 'checkbox') {
return ( return (
<FieldShell label={label} error={error} helperText={helperText}> <FieldShell label={label} error={error} helperText={helperText}>
<Button <XStack alignItems="center" gap="$3">
size="$3" <Switch
alignSelf="flex-start" id={id}
backgroundColor={fieldValue ? '$accentColor' : '$background'} size="$3"
color={fieldValue ? 'white' : '$color'} checked={Boolean(fieldValue)}
borderWidth={1} disabled={disabled}
borderColor={fieldValue ? '$accentColor' : '$borderColor'} onCheckedChange={(next) => onChange?.(id, next)}
disabled={disabled} backgroundColor={fieldValue ? '$accent' : '$lineStrong'}
onPress={() => onChange?.(id, !fieldValue)} borderColor={error ? '$danger' : 'transparent'}
> borderWidth={error ? 1 : 0}
{fieldValue ? 'Enabled' : 'Disabled'} >
</Button> <Switch.Thumb animation="quick" backgroundColor="$bgPanel" />
</Switch>
<Text color="$textSecondary" fontSize="$3">
{fieldValue ? 'Enabled' : 'Disabled'}
</Text>
</XStack>
</FieldShell> </FieldShell>
); );
} }
@@ -202,11 +260,7 @@ export function FormField({
<Button <Button
key={option.value} key={option.value}
size="$3" size="$3"
backgroundColor={selected ? '$accentColor' : '$background'} {...chipProps(selected, disabled)}
color={selected ? 'white' : '$color'}
borderWidth={1}
borderColor={selected ? '$accentColor' : '$borderColor'}
disabled={disabled}
onPress={() => onChange?.(id, option.value)} onPress={() => onChange?.(id, option.value)}
> >
{option.label} {option.label}
@@ -222,10 +276,18 @@ export function FormField({
return ( return (
<FieldShell label={label} error={error} helperText={helperText}> <FieldShell label={label} error={error} helperText={helperText}>
<XStack alignItems="center" gap="$3" flexWrap="wrap"> <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'} {fieldValue?.name ? 'Replace File' : 'Choose File'}
</Button> </Button>
<Text color="$color" opacity={0.75}> <Text color="$textMuted">
{fieldValue?.name || 'No file selected'} {fieldValue?.name || 'No file selected'}
</Text> </Text>
</XStack> </XStack>
@@ -243,6 +305,9 @@ export function FormField({
disabled={disabled} disabled={disabled}
readOnly={readOnly} readOnly={readOnly}
minHeight={120} minHeight={120}
backgroundColor="$bgPanel"
borderColor={error ? '$danger' : '$lineSubtle'}
focusStyle={{ borderColor: error ? '$danger' : '$accent' }}
/> />
</FieldShell> </FieldShell>
); );
@@ -254,11 +319,11 @@ export function FormField({
<YStack <YStack
padding="$3" padding="$3"
borderWidth={1} borderWidth={1}
borderColor="$borderColor" borderColor="$lineSubtle"
borderRadius="$4" borderRadius="$radiusMd"
backgroundColor="$accentSurface" backgroundColor="$bgPage"
> >
<Text>{String(fieldValue || '')}</Text> <Text color="$textPrimary">{String(fieldValue || '')}</Text>
</YStack> </YStack>
</FieldShell> </FieldShell>
); );
@@ -275,6 +340,9 @@ export function FormField({
type={type === 'datetime' ? 'datetime-local' : type} type={type === 'datetime' ? 'datetime-local' : type}
keyboardType={type === 'number' ? 'numeric' : undefined} keyboardType={type === 'number' ? 'numeric' : undefined}
autoCapitalize={type === 'email' || type === 'password' ? 'none' : undefined} autoCapitalize={type === 'email' || type === 'password' ? 'none' : undefined}
backgroundColor="$bgPanel"
borderColor={error ? '$danger' : '$lineSubtle'}
focusStyle={{ borderColor: error ? '$danger' : '$accent' }}
/> />
</FieldShell> </FieldShell>
); );
+3 -1
View File
@@ -134,7 +134,9 @@ export function FormView({
) : null} ) : null}
{loading ? ( {loading ? (
<XStack justifyContent="flex-end"> <XStack justifyContent="flex-end">
<Button disabled>Saving...</Button> <Button size="$3" theme="accent" disabled>
Saving...
</Button>
</XStack> </XStack>
) : null} ) : null}
</YStack> </YStack>
+457 -297
View File
@@ -1,324 +1,484 @@
/** /**
* IconMapper - Maps icon names to Lucide icons from @tamagui/lucide-icons * IconMapper - Phosphor-backed icon system with Tamagui theme integration.
* Cross-platform compatible (web + React Native) *
* 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 React from 'react';
import { useTheme } from '@tamagui/core';
import { SizableText } from 'tamagui';
import { import {
AlertCircle, // navigation & shell
AlertTriangle, House,
ArrowDown, Gear,
ArrowLeft, List as MenuList,
ArrowRight, MagnifyingGlass,
ArrowUp,
ArrowUpDown,
BarChart3,
Bell, 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, User,
UserCircle, UserCircle,
Users, Users,
Video, Envelope,
Volume1, // files
Volume2, File,
VolumeX, FileText,
Wifi, Folder,
WifiOff, FolderOpen,
Article,
Book,
Books,
// actions
PencilSimple,
Trash,
FloppyDisk,
X, X,
ZoomIn, Check,
ZoomOut Plus,
} from '@tamagui/lucide-icons'; 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 * Phosphor's `color` prop only accepts plain CSS colors. Callers often pass
* Maps Material Design icon names to Lucide equivalents * 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 = { const iconMap = {
// Navigation & UI // ── Navigation & UI ────────────────────────────────────────────────────
'home': Home, 'home': wrap(House, 'House'),
'settings': Settings, 'settings': wrap(Gear, 'Gear'),
'user': User, 'gear': wrap(Gear, 'Gear'),
'person': User, 'user': wrap(User, 'User'),
'account': UserCircle, 'person': wrap(User, 'User'),
'menu': Menu, 'account': wrap(UserCircle, 'UserCircle'),
'hamburger': Menu, 'menu': wrap(MenuList, 'List'),
'search': Search, 'hamburger': wrap(MenuList, 'List'),
'bell': Bell, 'list': wrap(MenuList, 'List'),
'notifications': Bell, 'search': wrap(MagnifyingGlass, 'MagnifyingGlass'),
'mail': Mail, 'bell': wrap(Bell, 'Bell'),
'email': Mail, 'notifications': wrap(Bell, 'Bell'),
'mail': wrap(Envelope, 'Envelope'),
// Files & Folders 'email': wrap(Envelope, 'Envelope'),
'file': File,
'folder': Folder, // ── Files & Folders ────────────────────────────────────────────────────
'folder-open': FolderOpen, 'file': wrap(File, 'File'),
'folder': wrap(Folder, 'Folder'),
// Actions 'folder-open': wrap(FolderOpen, 'FolderOpen'),
'edit': SquarePen,
'delete': Trash2, // ── Actions ────────────────────────────────────────────────────────────
'save': Save, 'edit': wrap(PencilSimple, 'PencilSimple'),
'close': X, 'pencil': wrap(PencilSimple, 'PencilSimple'),
'x': X, 'delete': wrap(Trash, 'Trash'),
'check': Check, 'trash': wrap(Trash, 'Trash'),
'plus': Plus, 'save': wrap(FloppyDisk, 'FloppyDisk'),
'minus': Minus, 'close': wrap(X, 'X'),
'x': wrap(X, 'X'),
// Arrows & Navigation 'check': wrap(Check, 'Check'),
'arrow-right': ArrowRight, 'plus': wrap(Plus, 'Plus'),
'arrow-left': ArrowLeft, 'add': wrap(Plus, 'Plus'),
'arrow-up': ArrowUp, 'minus': wrap(Minus, 'Minus'),
'arrow-down': ArrowDown,
'chevron-right': ChevronRight, // ── Arrows & Chevrons ──────────────────────────────────────────────────
'chevron-down': ChevronDown, 'arrow-right': wrap(ArrowRight, 'ArrowRight'),
'chevron-up': ChevronUp, 'arrow-left': wrap(ArrowLeft, 'ArrowLeft'),
'chevron-left': ChevronLeft, 'arrow-up': wrap(ArrowUp, 'ArrowUp'),
'chevrons-right': ChevronsRight, 'arrow-down': wrap(ArrowDown, 'ArrowDown'),
'chevrons-left': ChevronsLeft, 'chevron-right': wrap(CaretRight, 'CaretRight'),
'more-vert': MoreVertical, 'chevron-down': wrap(CaretDown, 'CaretDown'),
'more-horiz': MoreHorizontal, 'chevron-up': wrap(CaretUp, 'CaretUp'),
'chevron-left': wrap(CaretLeft, 'CaretLeft'),
// Auth 'chevrons-right': wrap(CaretDoubleRight, 'CaretDoubleRight'),
'logout': LogOut, 'chevrons-left': wrap(CaretDoubleLeft, 'CaretDoubleLeft'),
'login': LogIn, 'caret-up': wrap(CaretUp, 'CaretUp'),
'lock': Lock, 'caret-down': wrap(CaretDown, 'CaretDown'),
'unlock': Unlock, 'caret-left': wrap(CaretLeft, 'CaretLeft'),
'caret-right': wrap(CaretRight, 'CaretRight'),
// Dashboard & Analytics 'more-vert': wrap(DotsThreeVertical, 'DotsThreeVertical'),
'dashboard': LayoutDashboard, 'more-horiz': wrap(DotsThree, 'DotsThree'),
'chart': BarChart3,
'analytics': TrendingUp, // ── Auth ───────────────────────────────────────────────────────────────
'money': DollarSign, 'logout': wrap(SignOut, 'SignOut'),
'group': Users, 'login': wrap(SignIn, 'SignIn'),
'report': FileText, 'lock': wrap(Lock, 'Lock'),
'unlock': wrap(LockOpen, 'LockOpen'),
// Status & Feedback
'info': Info, // ── Dashboards & Analytics ─────────────────────────────────────────────
'warning': AlertTriangle, 'dashboard': wrap(SquaresFour, 'SquaresFour'),
'error': AlertCircle, 'chart': wrap(ChartBar, 'ChartBar'),
'success': CheckCircle, 'analytics': wrap(TrendUp, 'TrendUp'),
'help': HelpCircle, 'money': wrap(CurrencyDollar, 'CurrencyDollar'),
'group': wrap(Users, 'Users'),
// Visibility 'users': wrap(Users, 'Users'),
'visibility': Eye, 'report': wrap(FileText, 'FileText'),
'visibility-off': EyeOff,
// ── Status ─────────────────────────────────────────────────────────────
// Media 'info': wrap(Info, 'Info'),
'image': Image, 'warning': wrap(Warning, 'Warning'),
'photo': Camera, 'error': wrap(WarningCircle, 'WarningCircle'),
'video': Video, 'success': wrap(CheckCircle, 'CheckCircle'),
'play': Play, 'help': wrap(Question, 'Question'),
'pause': Pause,
'stop': Square, // ── Visibility ─────────────────────────────────────────────────────────
'visibility': wrap(Eye, 'Eye'),
// Communication 'visibility-off': wrap(EyeSlash, 'EyeSlash'),
'chat': MessageCircle, 'eye': wrap(Eye, 'Eye'),
'message': MessageSquare, 'eye-off': wrap(EyeSlash, 'EyeSlash'),
'comment': MessageSquare,
'send': Send, // ── Media ──────────────────────────────────────────────────────────────
'phone': Phone, 'image': wrap(ImageIcon, 'Image'),
'photo': wrap(Camera, 'Camera'),
// Content 'video': wrap(VideoCamera, 'VideoCamera'),
'copy': Copy, 'play': wrap(Play, 'Play'),
'cut': Scissors, 'pause': wrap(Pause, 'Pause'),
'paste': Clipboard, 'stop': wrap(Square, 'Square'),
'link': Link, 'square': wrap(Square, 'Square'),
'attach': Paperclip, 'circle': wrap(Circle, 'Circle'),
// UI Controls // ── Communication ──────────────────────────────────────────────────────
'filter': Filter, 'chat': wrap(ChatCircle, 'ChatCircle'),
'sort': ArrowUpDown, 'message': wrap(ChatText, 'ChatText'),
'refresh': RefreshCw, 'comment': wrap(ChatText, 'ChatText'),
'download': Download, 'send': wrap(PaperPlaneTilt, 'PaperPlaneTilt'),
'upload': Upload, 'phone': wrap(Phone, 'Phone'),
'share': Share2,
'language': Globe, // ── Content ────────────────────────────────────────────────────────────
'locale': Globe, 'copy': wrap(Copy, 'Copy'),
'tune': SlidersHorizontal, 'cut': wrap(Scissors, 'Scissors'),
'first-page': SkipBack, 'paste': wrap(Clipboard, 'Clipboard'),
'last-page': SkipForward, 'link': wrap(Link, 'Link'),
'attach': wrap(Paperclip, 'Paperclip'),
// Favorites & Bookmarks
'favorite': Heart, // ── UI Controls ────────────────────────────────────────────────────────
'favorite-border': Heart, 'filter': wrap(FunnelSimple, 'FunnelSimple'),
'star': Star, 'sort': wrap(ArrowsDownUp, 'ArrowsDownUp'),
'star-border': Star, 'refresh': wrap(ArrowClockwise, 'ArrowClockwise'),
'bookmark': Bookmark, 'download': wrap(Download, 'Download'),
'upload': wrap(Upload, 'Upload'),
// Time & Calendar 'share': wrap(ShareNetwork, 'ShareNetwork'),
'calendar': Calendar, 'language': wrap(Globe, 'Globe'),
'time': Clock, 'locale': wrap(Globe, 'Globe'),
'tune': wrap(Sliders, 'Sliders'),
// Location 'first-page': wrap(SkipBack, 'SkipBack'),
'location': MapPin, 'last-page': wrap(SkipForward, 'SkipForward'),
'location-on': MapPin,
'map': Map, // ── Favorites & Bookmarks ──────────────────────────────────────────────
'navigation': Navigation, 'favorite': wrap(Heart, 'Heart'),
'favorite-border': wrap(Heart, 'Heart'),
// System 'star': wrap(Star, 'Star'),
'power': Power, 'star-border': wrap(Star, 'Star'),
'brightness': Sun, 'bookmark': wrap(BookmarkSimple, 'BookmarkSimple'),
'wifi': Wifi,
'wifi-off': WifiOff, // ── Time & Calendar ────────────────────────────────────────────────────
'calendar': wrap(Calendar, 'Calendar'),
// Media Controls 'time': wrap(Clock, 'Clock'),
'volume-up': Volume2, 'clock': wrap(Clock, 'Clock'),
'volume-down': Volume1,
'volume-off': VolumeX, // ── Location ───────────────────────────────────────────────────────────
'mute': VolumeX, 'location': wrap(MapPin, 'MapPin'),
'location-on': wrap(MapPin, 'MapPin'),
// Editing 'map': wrap(MapTrifold, 'MapTrifold'),
'zoom-in': ZoomIn, 'navigation': wrap(NavigationArrow, 'NavigationArrow'),
'zoom-out': ZoomOut,
'crop': Crop, // ── System ─────────────────────────────────────────────────────────────
'rotate': RotateCw, 'power': wrap(Power, 'Power'),
'brightness': wrap(Sun, 'Sun'),
// Formatting 'sun': wrap(Sun, 'Sun'),
'format-bold': Bold, 'wifi': wrap(WifiHigh, 'WifiHigh'),
'format-italic': Italic, 'wifi-off': wrap(WifiSlash, 'WifiSlash'),
'format-underline': Underline, 'lightning': wrap(Lightning, 'Lightning'),
'code': Code,
// ── Volume ─────────────────────────────────────────────────────────────
// Files & Documents 'volume-up': wrap(SpeakerHigh, 'SpeakerHigh'),
'document': FileText, 'volume-down': wrap(SpeakerLow, 'SpeakerLow'),
'article': FileText, 'volume-off': wrap(SpeakerSlash, 'SpeakerSlash'),
'book': Book, 'mute': wrap(SpeakerSlash, 'SpeakerSlash'),
'library': Library,
// ── Editing ────────────────────────────────────────────────────────────
// Cloud & Storage 'zoom-in': wrap(MagnifyingGlassPlus, 'MagnifyingGlassPlus'),
'cloud': Cloud, 'zoom-out': wrap(MagnifyingGlassMinus, 'MagnifyingGlassMinus'),
'cloud-upload': CloudUpload, 'crop': wrap(Crop, 'Crop'),
'cloud-download': CloudDownload, 'rotate': wrap(ArrowsClockwise, 'ArrowsClockwise'),
'drive': HardDrive,
// ── Text formatting ────────────────────────────────────────────────────
// Network 'format-bold': wrap(TextB, 'TextB'),
'network': Network, 'format-italic': wrap(TextItalic, 'TextItalic'),
'signal': Signal, 'format-underline': wrap(TextUnderline, 'TextUnderline'),
'code': wrap(Code, 'Code'),
// Print
'print': Printer, // ── Documents ──────────────────────────────────────────────────────────
'document': wrap(FileText, 'FileText'),
// Add more mappings as needed 'article': wrap(Article, 'Article'),
'book': wrap(Book, 'Book'),
'library': wrap(Books, 'Books'),
// ── Cloud & Storage ────────────────────────────────────────────────────
'cloud': wrap(Cloud, 'Cloud'),
'cloud-upload': wrap(CloudArrowUp, 'CloudArrowUp'),
'cloud-download': wrap(CloudArrowDown, 'CloudArrowDown'),
'drive': wrap(HardDrive, 'HardDrive'),
// ── Network ────────────────────────────────────────────────────────────
'network': wrap(Network, 'Network'),
'signal': wrap(CellSignalHigh, 'CellSignalHigh'),
// ── Print ──────────────────────────────────────────────────────────────
'print': wrap(Printer, 'Printer'),
}; };
/* -------------------------------------------------------------------------- */
/* Public API */
/* -------------------------------------------------------------------------- */
/** /**
* Get Lucide icon component by name * Look up a wrapped Phosphor icon by name. Returns a React component (the
* @param {string} iconName - Name of the icon (e.g., 'home', 'settings') * wrapper handles theme-aware color, sizing, and weight) or `null` if the
* @returns {React.Component|null} Lucide icon component or null * 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) { export function getIcon(iconName) {
if (!iconName || typeof iconName !== 'string') { if (typeof iconName !== 'string' || !iconName) return null;
return null; return iconMap[iconName.toLowerCase().trim()] || null;
}
const normalizedName = iconName.toLowerCase().trim();
return iconMap[normalizedName] || null;
} }
/** /**
* IconMapper Component * Convenience component: `<IconMapper iconName="home" size="md" />`.
* Renders a Lucide icon by name with Tamagui theme support * Falls back to a small Tamagui Text node when the input is an emoji or
* @param {string} iconName - Name of the icon * single character (e.g. user-entered avatar glyphs).
* @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
*/ */
export function IconMapper({ iconName, size = 24, color = 'currentColor', ...props }) { export function IconMapper({ iconName, size = DEFAULT_SIZE, color = '$textPrimary', ...props }) {
const IconComponent = getIcon(iconName); const Icon = getIcon(iconName);
if (Icon) {
if (!IconComponent) { return <Icon size={size} color={color} {...props} />;
// 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>;
}
return null;
} }
if (typeof iconName === 'string' && (iconName.length <= 2 || /[\u{1F300}-\u{1F9FF}]/u.test(iconName))) {
// Convert size if it's a number to a reasonable default return <SizableText fontSize={resolveSize(size)} color={color}>{iconName}</SizableText>;
const iconSize = typeof size === 'string' ? size : size; }
return null;
return <IconComponent size={iconSize} color={color} {...props} />; }
/**
* 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);
}
/**
* List of every registered alias. Handy for validation / docs.
*/
export function getIconNames() {
return Object.keys(iconMap);
} }
export default IconMapper; export default IconMapper;
+49 -62
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 = () => { const getBackgroundColor = () => {
if (selected) { if (selected) return '$accentBg';
return '$accentBackground'; if (hovered) return '$bgPage';
}
if (hovered) {
return '$backgroundPress';
}
return 'transparent'; return 'transparent';
}; };
const getIconColor = () => { const getIconColor = () => {
if (selected) { if (selected) return '$accent';
return '$accentColor'; if (menuItem.style === 'icon_only') return '$accent';
} return '$textPrimary';
if (menuItem.style === 'icon_only') {
return '$accentColor';
}
return '$color';
}; };
const getLabelColor = () => { const getLabelColor = () => {
if (selected) { if (selected) return '$accent';
return '$accentColor'; return '$textPrimary';
}
return '$color';
}; };
const getArrowColor = () => { const getArrowColor = () => {
if (selected) { if (selected) return '$accent';
return '$accentColor'; return '$textMuted';
}
return '$colorSecondary';
}; };
const ICON_SIZE = 'sm';
const CHEVRON_SIZE = 'sm';
// Determine display style (both, label_only, icon_only) // Determine display style (both, label_only, icon_only)
// Use displayStyle prop if provided, otherwise fall back to menuItem.style // Use displayStyle prop if provided, otherwise fall back to menuItem.style
const effectiveDisplayStyle = displayStyle !== undefined ? displayStyle : (menuItem.style || 'both'); const effectiveDisplayStyle = displayStyle !== undefined ? displayStyle : (menuItem.style || 'both');
@@ -325,11 +318,9 @@ export function MenuItemButton({
width="100%" width="100%"
alignItems="center" alignItems="center"
backgroundColor={getBackgroundColor()} backgroundColor={getBackgroundColor()}
borderWidth={selected ? 1 : 0} borderRadius="$radiusSm"
borderColor={selected ? '$accentBorder' : 'transparent'}
borderRadius="$2"
padding={padding} padding={padding}
opacity={menuItem.is_active !== false ? 1 : 0.5} opacity={menuItem.is_active !== false ? 1 : 0.55}
> >
{/* Icon + Label (clickable main area) */} {/* Icon + Label (clickable main area) */}
<XStack <XStack
@@ -337,10 +328,10 @@ export function MenuItemButton({
alignItems="center" alignItems="center"
cursor={menuItem.isActionable() || hasSubitems ? 'pointer' : 'default'} cursor={menuItem.isActionable() || hasSubitems ? 'pointer' : 'default'}
hoverStyle={{ hoverStyle={{
backgroundColor: hovered || selected ? getBackgroundColor() : '$backgroundHover' backgroundColor: selected ? '$accentBgHover' : '$bgPage'
}} }}
pressStyle={{ pressStyle={{
backgroundColor: selected ? '$accentHover' : '$backgroundPress' backgroundColor: selected ? '$accentBgHover' : '$bgPanelElev'
}} }}
onPress={handleMainClick} onPress={handleMainClick}
> >
@@ -348,18 +339,16 @@ export function MenuItemButton({
{showIcon && ( {showIcon && (
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center"> <XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
{typeof IconComponent === 'string' ? ( {typeof IconComponent === 'string' ? (
// Emoji fallback
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text> <Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
) : IconComponent ? ( ) : IconComponent ? (
// Material Design icon component <IconComponent size={ICON_SIZE} color={getIconColor()} />
<IconComponent size={typeof size === 'string' ? 24 : (size || 24)} color={getIconColor()} />
) : null} ) : null}
</XStack> </XStack>
)} )}
{/* Label */} {/* Label */}
{showLabel && ( {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} {menuItem.label}
</Text> </Text>
)} )}
@@ -372,16 +361,17 @@ export function MenuItemButton({
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
padding="$1" padding="$1"
borderRadius="$radiusSm"
hoverStyle={{ hoverStyle={{
backgroundColor: '$backgroundHover' backgroundColor: '$bgPage'
}} }}
pressStyle={{ pressStyle={{
backgroundColor: selected ? '$accentHover' : '$backgroundPress' backgroundColor: '$bgPanelElev'
}} }}
onPress={handleToggleExpand} onPress={handleToggleExpand}
> >
<ArrowIcon <ArrowIcon
size={16} size={CHEVRON_SIZE}
color={getArrowColor()} color={getArrowColor()}
style={{ marginLeft: 4, flexShrink: 0 }} style={{ marginLeft: 4, flexShrink: 0 }}
/> />
@@ -394,15 +384,15 @@ export function MenuItemButton({
<YStack <YStack
ref={popupRef} ref={popupRef}
position="fixed" position="fixed"
backgroundColor="$background" backgroundColor="$bgPanelElev"
borderRadius="$3" borderRadius="$radiusMd"
padding={0} padding={0}
borderWidth={1} borderWidth={1}
borderColor="$borderColor" borderColor="$lineSubtle"
shadowColor="$shadowColor" shadowColor="$shadowColor"
shadowOffset={{ width: 0, height: 4 }} shadowOffset={{ width: 0, height: 4 }}
shadowOpacity={0.2} shadowOpacity={0.18}
shadowRadius={8} shadowRadius={12}
elevation={8} elevation={8}
zIndex={9999} zIndex={9999}
minWidth={200} minWidth={200}
@@ -457,11 +447,9 @@ export function MenuItemButton({
width="100%" width="100%"
title={collapsed && menuItem.label ? menuItem.label : undefined} title={collapsed && menuItem.label ? menuItem.label : undefined}
backgroundColor={getBackgroundColor()} backgroundColor={getBackgroundColor()}
borderWidth={selected ? 1 : 0} borderRadius="$radiusSm"
borderColor={selected ? '$accentBorder' : 'transparent'}
borderRadius="$2"
padding={padding} padding={padding}
opacity={menuItem.is_active !== false ? 1 : 0.5} opacity={menuItem.is_active !== false ? 1 : 0.55}
> >
{/* Icon + Label (clickable main area) */} {/* Icon + Label (clickable main area) */}
<XStack <XStack
@@ -469,10 +457,10 @@ export function MenuItemButton({
alignItems="center" alignItems="center"
cursor={menuItem.isActionable() || hasSubitems ? 'pointer' : 'default'} cursor={menuItem.isActionable() || hasSubitems ? 'pointer' : 'default'}
hoverStyle={{ hoverStyle={{
backgroundColor: hovered || selected ? getBackgroundColor() : '$backgroundHover' backgroundColor: selected ? '$accentBgHover' : '$bgPage'
}} }}
pressStyle={{ pressStyle={{
backgroundColor: selected ? '$accentHover' : '$backgroundPress' backgroundColor: selected ? '$accentBgHover' : '$bgPanelElev'
}} }}
onPress={handleMainClick} onPress={handleMainClick}
> >
@@ -480,18 +468,16 @@ export function MenuItemButton({
{showIcon && ( {showIcon && (
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center"> <XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
{typeof IconComponent === 'string' ? ( {typeof IconComponent === 'string' ? (
// Emoji fallback
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text> <Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
) : IconComponent ? ( ) : IconComponent ? (
// Material Design icon component <IconComponent size={ICON_SIZE} color={getIconColor()} />
<IconComponent size={typeof size === 'string' ? 24 : (size || 24)} color={getIconColor()} />
) : null} ) : null}
</XStack> </XStack>
)} )}
{/* Label */} {/* Label */}
{showLabel && ( {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} {menuItem.label}
</Text> </Text>
)} )}
@@ -504,16 +490,17 @@ export function MenuItemButton({
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
padding="$1" padding="$1"
borderRadius="$radiusSm"
hoverStyle={{ hoverStyle={{
backgroundColor: '$backgroundHover' backgroundColor: '$bgPage'
}} }}
pressStyle={{ pressStyle={{
backgroundColor: selected ? '$accentHover' : '$backgroundPress' backgroundColor: '$bgPanelElev'
}} }}
onPress={handleToggleExpand} onPress={handleToggleExpand}
> >
<ArrowIcon <ArrowIcon
size={16} size={CHEVRON_SIZE}
color={getArrowColor()} color={getArrowColor()}
style={{ marginLeft: 8, flexShrink: 0 }} style={{ marginLeft: 8, flexShrink: 0 }}
/> />
@@ -553,15 +540,15 @@ export function MenuItemButton({
<YStack <YStack
ref={popupRef} ref={popupRef}
position="fixed" position="fixed"
backgroundColor="$background" backgroundColor="$bgPanelElev"
borderRadius="$3" borderRadius="$radiusMd"
padding={0} padding={0}
borderWidth={1} borderWidth={1}
borderColor="$borderColor" borderColor="$lineSubtle"
shadowColor="$shadowColor" shadowColor="$shadowColor"
shadowOffset={{ width: 0, height: 4 }} shadowOffset={{ width: 0, height: 4 }}
shadowOpacity={0.2} shadowOpacity={0.18}
shadowRadius={8} shadowRadius={12}
elevation={8} elevation={8}
zIndex={9999} zIndex={9999}
minWidth={200} minWidth={200}
+6 -5
View File
@@ -7,6 +7,7 @@
import React from 'react'; import React from 'react';
import { XStack, YStack, Text } from 'tamagui'; import { XStack, YStack, Text } from 'tamagui';
import { getIcon } from './IconMapper.jsx'; import { getIcon } from './IconMapper.jsx';
import { getTypographyRoleProps } from '../styles/index.js';
/** /**
* Page Component * Page Component
@@ -36,15 +37,15 @@ export function Page({
if (IconComponent) { if (IconComponent) {
headerLeftItems.push( headerLeftItems.push(
<XStack key="page-icon" alignItems="center" justifyContent="center" marginRight="$2"> <XStack key="page-icon" alignItems="center" justifyContent="center" marginRight="$2">
<IconComponent size={24} color="$accentColor" /> <IconComponent size="md" color="$textPrimary" />
</XStack> </XStack>
); );
} }
} }
if (title) { if (title) {
headerLeftItems.push( headerLeftItems.push(
<Text key="page-title" fontWeight="600" fontSize="$6" color="$accentColor"> <Text key="page-title" {...getTypographyRoleProps('pageTitle')}>
{title} {title}
</Text> </Text>
); );
@@ -66,8 +67,8 @@ export function Page({
width="100%" width="100%"
padding="$4" padding="$4"
borderBottomWidth={1} borderBottomWidth={1}
borderBottomColor="$accentBorder" borderBottomColor="$lineSubtle"
backgroundColor="$accentSurface" backgroundColor="$bgPanel"
alignItems="center" alignItems="center"
gap="$3" gap="$3"
minHeight={64} minHeight={64}
+25 -23
View File
@@ -7,6 +7,7 @@
import React from 'react'; import React from 'react';
import { XStack, YStack, Text } from 'tamagui'; import { XStack, YStack, Text } from 'tamagui';
import { getIcon } from './IconMapper.jsx'; import { getIcon } from './IconMapper.jsx';
import { getTypographyRoleProps } from '../styles/index.js';
/** /**
* Header size mapping function * Header size mapping function
@@ -19,29 +20,21 @@ import { getIcon } from './IconMapper.jsx';
function getHeaderSizeStyles(headerSize) { function getHeaderSizeStyles(headerSize) {
const sizeMap = { const sizeMap = {
1: { 1: {
iconSize: 24,
titleFontSize: '$6',
padding: '$3', padding: '$3',
borderRadius: '$4', borderRadius: '$4',
minHeight: 64 minHeight: 64
}, },
2: { 2: {
iconSize: 20,
titleFontSize: '$5',
padding: '$2', padding: '$2',
borderRadius: '$3', borderRadius: '$3',
minHeight: 48 minHeight: 48
}, },
3: { 3: {
iconSize: 18,
titleFontSize: '$4',
padding: '$1.5', padding: '$1.5',
borderRadius: '$2', borderRadius: '$2',
minHeight: 44 minHeight: 44
}, },
4: { 4: {
iconSize: 16,
titleFontSize: '$3',
padding: '$1', padding: '$1',
borderRadius: '$2', borderRadius: '$2',
minHeight: 36 minHeight: 36
@@ -83,13 +76,16 @@ export function Panel({
height = null, height = null,
headerSize = 2, headerSize = 2,
headerFront = null, headerFront = null,
headerBack = null headerBack = null,
density = 'comfortable',
bodyOverflow = 'auto'
}) { }) {
// Get size-specific styles // Get size-specific styles
const sizeStyles = getHeaderSizeStyles(headerSize); const sizeStyles = getHeaderSizeStyles(headerSize);
// Set default headerBack if not provided // 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 // Build headerLeft array: icon, title, then custom components
const headerLeftItems = []; const headerLeftItems = [];
@@ -99,7 +95,7 @@ export function Panel({
if (IconComponent) { if (IconComponent) {
headerLeftItems.push( headerLeftItems.push(
<XStack key="panel-icon" alignItems="center" justifyContent="center" marginRight="$2" {...(headerFront || {})}> <XStack key="panel-icon" alignItems="center" justifyContent="center" marginRight="$2" {...(headerFront || {})}>
<IconComponent size={sizeStyles.iconSize} {...(headerFront || {})} /> <IconComponent size="md" color="$textPrimary" {...(headerFront || {})} />
</XStack> </XStack>
); );
} }
@@ -107,11 +103,17 @@ export function Panel({
if (title) { if (title) {
headerLeftItems.push( headerLeftItems.push(
<Text <Text
key="panel-title" key="panel-title"
fontWeight="600" {...getTypographyRoleProps('panelTitle', {
fontSize={sizeStyles.titleFontSize} fontSize: headerSize === 1
color="$color" ? '$6'
: headerSize === 2
? '$5'
: headerSize === 3
? '$4'
: '$3'
})}
{...(headerFront || {})} {...(headerFront || {})}
> >
{title} {title}
@@ -142,19 +144,19 @@ export function Panel({
{...(Object.keys(containerStyle).length > 0 ? containerStyle : {})} {...(Object.keys(containerStyle).length > 0 ? containerStyle : {})}
{...(border ? { {...(border ? {
borderWidth: 1, borderWidth: 1,
borderColor: '$borderColor', borderColor: '$lineSubtle',
borderRadius: sizeStyles.borderRadius borderRadius: '$radiusMd'
} : {})} } : {})}
backgroundColor="$background" backgroundColor="$bgPanel"
overflow="hidden" overflow="hidden"
> >
{/* Header */} {/* Header */}
<XStack <XStack
width="100%" width="100%"
padding={sizeStyles.padding} padding={paddingByDensity}
borderBottomWidth={border ? 1 : 0} borderBottomWidth={border ? 1 : 0}
borderBottomColor="$borderColor" borderBottomColor="$lineSubtle"
backgroundColor="$background" backgroundColor="$bgPanel"
alignItems="center" alignItems="center"
gap="$3" gap="$3"
minHeight={sizeStyles.minHeight} minHeight={sizeStyles.minHeight}
@@ -187,7 +189,7 @@ export function Panel({
</XStack> </XStack>
{/* Body */} {/* 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} {children}
</YStack> </YStack>
</YStack> </YStack>
+4 -5
View File
@@ -91,8 +91,7 @@ export function ProgressBar({
width="100%" width="100%"
height={trackHeight} height={trackHeight}
borderRadius="$1" borderRadius="$1"
backgroundColor="$borderColor" backgroundColor="$lineSubtle"
opacity={0.55}
overflow="hidden" overflow="hidden"
> >
{mode === 'determinate' ? ( {mode === 'determinate' ? (
@@ -103,7 +102,7 @@ export function ProgressBar({
height="100%" height="100%"
width={determinateWidth} width={determinateWidth}
borderRadius="$1" borderRadius="$1"
backgroundColor="$accentColor" backgroundColor="$accent"
/> />
) : ( ) : (
<YStack <YStack
@@ -112,7 +111,7 @@ export function ProgressBar({
height="100%" height="100%"
width="42%" width="42%"
borderRadius="$1" borderRadius="$1"
backgroundColor="$accentColor" backgroundColor="$accent"
left={`${offset}%`} left={`${offset}%`}
/> />
)} )}
@@ -123,7 +122,7 @@ export function ProgressBar({
<YStack flex={1} minWidth={0} alignItems="center" gap="$1"> <YStack flex={1} minWidth={0} alignItems="center" gap="$1">
{label !== null && label !== undefined && label !== '' ? ( {label !== null && label !== undefined && label !== '' ? (
typeof label === 'string' ? ( 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} {label}
</Text> </Text>
) : ( ) : (
+25 -25
View File
@@ -11,13 +11,13 @@ function normalizeVariant(variant, styleVariant) {
return styleVariant || variant || 'accordion'; return styleVariant || variant || 'accordion';
} }
function renderHeaderIcon(icon, color = '$color') { function renderHeaderIcon(icon, color = '$textPrimary') {
const IconComponent = getIcon(icon); const IconComponent = getIcon(icon);
if (!IconComponent) { if (!IconComponent) {
return null; return null;
} }
return <IconComponent size={18} color={color} />; return <IconComponent size="sm" color={color} />;
} }
function normalizeContentStyle(contentStyle) { function normalizeContentStyle(contentStyle) {
@@ -188,7 +188,7 @@ export function SettingsPanel({
size="$3" size="$3"
aria-label={expanded ? `Collapse ${title}` : `Expand ${title}`} aria-label={expanded ? `Collapse ${title}` : `Expand ${title}`}
onPress={handleToggle} 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" justifyContent="center"
flexShrink={0} flexShrink={0}
> >
{renderHeaderIcon(icon, effectiveVariant === 'panel' ? '$accentColor' : '$color')} {renderHeaderIcon(icon, '$textPrimary')}
</XStack> </XStack>
) : null} ) : null}
<XStack flex={1} minWidth={0} gap="$2" alignItems="baseline" flexWrap="wrap"> <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} {title}
</Text> </Text>
{description ? ( {description ? (
<Paragraph size="$2" color="$color" opacity={0.72} flex={1} minWidth={160}> <Paragraph size="$2" color="$textMuted" flex={1} minWidth={160}>
{description} {description}
</Paragraph> </Paragraph>
) : null} ) : null}
@@ -240,8 +240,8 @@ export function SettingsPanel({
> >
<Tabs.List <Tabs.List
disablePassBorderRadius disablePassBorderRadius
backgroundColor="$backgroundStrong" backgroundColor="$bgPage"
borderRadius="$4" borderRadius="$radiusMd"
padding="$1" padding="$1"
gap="$1" gap="$1"
flexWrap="wrap" flexWrap="wrap"
@@ -250,14 +250,14 @@ export function SettingsPanel({
<Tabs.Tab <Tabs.Tab
key={item.id} key={item.id}
value={item.id} value={item.id}
borderRadius="$3" borderRadius="$radiusSm"
backgroundColor="$background" backgroundColor="transparent"
hoverStyle={{ backgroundColor: '$backgroundHover' }} hoverStyle={{ backgroundColor: '$bgPanel' }}
pressStyle={{ backgroundColor: '$backgroundPress' }} pressStyle={{ backgroundColor: '$bgPanelElev' }}
> >
<XStack alignItems="center" gap="$2"> <XStack alignItems="center" gap="$2">
{item.icon ? renderHeaderIcon(item.icon, '$accentColor') : null} {item.icon ? renderHeaderIcon(item.icon, '$textSecondary') : null}
<Text fontWeight="700" color="$color"> <Text fontWeight="600" color="$textPrimary">
{item.label || item.title} {item.label || item.title}
</Text> </Text>
</XStack> </XStack>
@@ -269,9 +269,9 @@ export function SettingsPanel({
<Tabs.Content key={item.id} value={item.id}> <Tabs.Content key={item.id} value={item.id}>
<YStack <YStack
borderWidth={1} borderWidth={1}
borderColor="$borderColor" borderColor="$lineSubtle"
borderRadius="$4" borderRadius="$radiusMd"
backgroundColor="$background" backgroundColor="$bgPanel"
padding="$3" padding="$3"
gap="$3" gap="$3"
> >
@@ -306,18 +306,18 @@ export function SettingsPanel({
return ( return (
<YStack <YStack
borderWidth={1} borderWidth={1}
borderColor="$borderColor" borderColor="$lineSubtle"
borderRadius="$4" borderRadius="$radiusMd"
backgroundColor="$background" backgroundColor="$bgPanel"
overflow="hidden" overflow="hidden"
> >
<XStack <XStack
paddingHorizontal="$3" paddingHorizontal="$3"
paddingVertical="$1.5" paddingVertical="$1.5"
alignItems="center" alignItems="center"
backgroundColor="$backgroundHover" backgroundColor="$bgPanel"
borderBottomWidth={expanded ? 1 : 0} borderBottomWidth={expanded ? 1 : 0}
borderBottomColor="$borderColor" borderBottomColor="$lineSubtle"
> >
{headerContent} {headerContent}
</XStack> </XStack>
@@ -339,8 +339,8 @@ export function SettingsPanel({
borderWidth={0} borderWidth={0}
padding={0} padding={0}
justifyContent="flex-start" justifyContent="flex-start"
hoverStyle={{ opacity: 0.9, backgroundColor: 'transparent' }} hoverStyle={{ backgroundColor: '$bgPage' }}
pressStyle={{ opacity: 0.75, backgroundColor: 'transparent' }} pressStyle={{ backgroundColor: '$bgPanelElev' }}
> >
<YStack gap="$2"> <YStack gap="$2">
{headerContent} {headerContent}
@@ -351,7 +351,7 @@ export function SettingsPanel({
{renderedBody} {renderedBody}
</YStack> </YStack>
) : null} ) : null}
<Separator borderColor="$borderColor" /> <Separator borderColor="$lineSubtle" />
</YStack> </YStack>
); );
} }
+3 -3
View File
@@ -572,7 +572,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
> >
{Icon ? ( {Icon ? (
<XStack alignItems="center" justifyContent="center" width={20} height={20} flexShrink={0}> <XStack alignItems="center" justifyContent="center" width={20} height={20} flexShrink={0}>
<Icon size={18} /> <Icon size="md" color="$textSecondary" />
</XStack> </XStack>
) : null} ) : null}
<YStack flex={1} gap="$1"> <YStack flex={1} gap="$1">
@@ -582,7 +582,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
</Text> </Text>
)} )}
{toast.message && ( {toast.message && (
<Text fontSize="$3" color="$color" opacity={0.9}> <Text fontSize="$3" color="$textSecondary">
{toast.message} {toast.message}
</Text> </Text>
)} )}
@@ -596,7 +596,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
> >
{(() => { {(() => {
const CloseIcon = getIcon('close'); const CloseIcon = getIcon('close');
return CloseIcon ? <CloseIcon size={16} /> : <Text>×</Text>; return CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : <Text color="$textSecondary">×</Text>;
})()} })()}
</Button> </Button>
</XStack> </XStack>
+14 -15
View File
@@ -229,9 +229,9 @@ function SideBarWide({
height="100%" height="100%"
gap="$2" gap="$2"
padding="$2" padding="$2"
backgroundColor="$accentSurface" backgroundColor="$bgPanel"
borderRightWidth={1} borderRightWidth={1}
borderRightColor="$accentBorder" borderRightColor="$lineSubtle"
animation="quick" animation="quick"
animateOnly={['width']} animateOnly={['width']}
> >
@@ -266,7 +266,7 @@ function SideBarWide({
)} )}
{appName && !isCollapsed && ( {appName && !isCollapsed && (
<Text fontWeight="bold" fontSize="$4" flex={1} color="$accentColor"> <Text fontWeight="600" fontSize="$5" flex={1} color="$textPrimary">
{appName} {appName}
</Text> </Text>
)} )}
@@ -278,10 +278,10 @@ function SideBarWide({
justifyContent="center" justifyContent="center"
padding="$1" padding="$1"
hoverStyle={{ hoverStyle={{
backgroundColor: '$accentBackground' backgroundColor: '$bgPage'
}} }}
pressStyle={{ pressStyle={{
backgroundColor: '$accentHover' backgroundColor: '$bgPanelElev'
}} }}
onPress={handleToggle} onPress={handleToggle}
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'} aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
@@ -291,8 +291,8 @@ function SideBarWide({
if (!ChevronIcon) return null; if (!ChevronIcon) return null;
return ( return (
<ChevronIcon <ChevronIcon
size={16} size="sm"
color="$accentColor" color="$textSecondary"
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
/> />
); );
@@ -366,17 +366,16 @@ function SideBarNarrow({ children }) {
alignItems="center" alignItems="center"
gap="$2" gap="$2"
padding="$2" padding="$2"
backgroundColor="$accentSurface" backgroundColor="$bgPanel"
borderBottomWidth={1} borderBottomWidth={1}
borderBottomColor="$accentBorder" borderBottomColor="$lineSubtle"
> >
{/* Hamburger Menu Button */} {/* Hamburger Menu Button */}
<Button <Button
size="$3" size="$3"
circular chromeless
icon={getIcon('menu')} icon={getIcon('menu')}
backgroundColor="$accentBackground" color="$textPrimary"
color="$accentColor"
onPress={() => setMenuOpen(true)} onPress={() => setMenuOpen(true)}
/> />
@@ -392,7 +391,7 @@ function SideBarNarrow({ children }) {
{/* App Name - takes remaining space */} {/* App Name - takes remaining space */}
{appName && ( {appName && (
<Text fontWeight="bold" fontSize="$4" flex={1} numberOfLines={1} color="$accentColor"> <Text fontWeight="600" fontSize="$5" flex={1} numberOfLines={1} color="$textPrimary">
{appName} {appName}
</Text> </Text>
)} )}
@@ -420,9 +419,9 @@ function SideBarNarrow({ children }) {
snapPoints={[85]} snapPoints={[85]}
dismissOnSnapToBottom dismissOnSnapToBottom
> >
<Sheet.Overlay /> <Sheet.Overlay backgroundColor="$scrim" />
<Sheet.Handle /> <Sheet.Handle />
<Sheet.Frame padding="$4" gap="$2"> <Sheet.Frame padding="$4" gap="$2" backgroundColor="$bgPanel">
<YStack gap="$2" width="100%"> <YStack gap="$2" width="100%">
{/* Primary Menu Items */} {/* Primary Menu Items */}
{organizedChildren.primaryMenuItems.map((item) => ( {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 { Button, ScrollView, Text, XStack, YStack } from 'tamagui';
import { getIcon } from './IconMapper.jsx'; 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 }) { function ActionButton({ action }) {
const IconComponent = action?.icon ? getIcon(action.icon) : null; const IconComponent = action?.icon ? getIcon(action.icon) : null;
return ( return (
@@ -11,7 +16,7 @@ function ActionButton({ action }) {
chromeless={action?.chromeless} chromeless={action?.chromeless}
disabled={action?.disabled} disabled={action?.disabled}
onPress={action?.onPress} onPress={action?.onPress}
icon={IconComponent ? <IconComponent size={16} /> : undefined} icon={IconComponent ? <IconComponent size="sm" /> : undefined}
> >
{action?.label} {action?.label}
</Button> </Button>
@@ -61,7 +66,7 @@ export function SidePanelShell({
right={0} right={0}
bottom={0} bottom={0}
left={0} left={0}
backgroundColor="rgba(15,23,42,0.26)" backgroundColor="$scrim"
opacity={open ? 1 : 0} opacity={open ? 1 : 0}
animation="quick" animation="quick"
onPress={onClose} onPress={onClose}
@@ -74,13 +79,13 @@ export function SidePanelShell({
bottom={0} bottom={0}
width={width} width={width}
maxWidth="96vw" maxWidth="96vw"
backgroundColor="$background" backgroundColor="$bgPanelElev"
borderLeftWidth={1} borderLeftWidth={1}
borderLeftColor="$borderColor" borderLeftColor="$lineSubtle"
shadowColor="$shadowColor" shadowColor="$shadowColor"
shadowOpacity={0.18} shadowOpacity={0.12}
shadowRadius={20} shadowRadius={24}
shadowOffset={{ width: -4, height: 0 }} shadowOffset={{ width: -6, height: 0 }}
style={{ style={{
transform: open ? 'translateX(0)' : 'translateX(100%)', transform: open ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 220ms ease' transition: 'transform 220ms ease'
@@ -92,10 +97,10 @@ export function SidePanelShell({
padding="$4" padding="$4"
gap="$3" gap="$3"
borderBottomWidth={1} borderBottomWidth={1}
borderBottomColor="$borderColor" borderBottomColor="$lineSubtle"
backgroundColor="$accentSurface" backgroundColor="$bgPanel"
> >
<Text fontSize="$7" fontWeight="700" color="$accentColor" flex={1}> <Text fontSize="$6" fontWeight="600" color="$textPrimary" flex={1}>
{title} {title}
</Text> </Text>
<XStack alignItems="center" gap="$2" flexWrap="wrap" justifyContent="flex-end"> <XStack alignItems="center" gap="$2" flexWrap="wrap" justifyContent="flex-end">
@@ -107,7 +112,7 @@ export function SidePanelShell({
circular circular
chromeless chromeless
onPress={onClose} onPress={onClose}
icon={CloseIcon ? <CloseIcon size={18} /> : undefined} icon={CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : undefined}
aria-label="Close panel" aria-label="Close panel"
/> />
</XStack> </XStack>
@@ -125,8 +130,8 @@ export function SidePanelShell({
gap="$2" gap="$2"
padding="$4" padding="$4"
borderTopWidth={1} borderTopWidth={1}
borderTopColor="$borderColor" borderTopColor="$lineSubtle"
backgroundColor="$accentSurface" backgroundColor="$bgPanel"
flexWrap="wrap" flexWrap="wrap"
> >
{footerActions.map((action, index) => ( {footerActions.map((action, index) => (
+19 -17
View File
@@ -195,9 +195,9 @@ function TopBarWide({
alignItems="center" alignItems="center"
gap="$2" gap="$2"
padding="$2" padding="$2"
backgroundColor="$accentSurface" backgroundColor="$bgPanel"
borderBottomWidth={1} borderBottomWidth={1}
borderBottomColor="$accentBorder" borderBottomColor="$lineSubtle"
> >
{/* Left Side */} {/* Left Side */}
<XStack <XStack
@@ -217,14 +217,14 @@ function TopBarWide({
resizeMode="contain" resizeMode="contain"
/> />
)} )}
{/* App Name */} {/* App Name */}
{appName && ( {appName && (
<Text fontWeight="bold" fontSize="$4" color="$accentColor"> <Text fontWeight="600" fontSize="$5" color="$textPrimary">
{appName} {appName}
</Text> </Text>
)} )}
{/* Left Side Items */} {/* Left Side Items */}
{organizedChildren.sections.leftSide} {organizedChildren.sections.leftSide}
</XStack> </XStack>
@@ -242,7 +242,7 @@ function TopBarWide({
</XStack> </XStack>
)} )}
{/* Right Side */} {/* Right Side — secondary actions get a hairline separator + tighter gap */}
{(effectiveRightWidth > 0 || effectiveRightWidth === 'auto') && ( {(effectiveRightWidth > 0 || effectiveRightWidth === 'auto') && (
<XStack <XStack
width={effectiveRightWidth === 'auto' ? undefined : effectiveRightWidth} width={effectiveRightWidth === 'auto' ? undefined : effectiveRightWidth}
@@ -251,6 +251,9 @@ function TopBarWide({
alignItems="center" alignItems="center"
justifyContent="flex-end" justifyContent="flex-end"
gap="$1" gap="$1"
paddingLeft="$2"
borderLeftWidth={1}
borderLeftColor="$lineSubtle"
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
> >
{organizedChildren.sections.rightSide} {organizedChildren.sections.rightSide}
@@ -289,20 +292,19 @@ function TopBarNarrow({ children }) {
alignItems="center" alignItems="center"
gap="$2" gap="$2"
padding="$2" padding="$2"
backgroundColor="$accentSurface" backgroundColor="$bgPanel"
borderBottomWidth={1} borderBottomWidth={1}
borderBottomColor="$accentBorder" borderBottomColor="$lineSubtle"
> >
{/* Hamburger Menu Button */} {/* Hamburger Menu Button — chromeless to avoid pulling the eye */}
<Button <Button
size="$3" size="$3"
circular chromeless
icon={getIcon('menu')} icon={getIcon('menu')}
backgroundColor="$accentBackground" color="$textPrimary"
color="$accentColor"
onPress={() => setMenuOpen(true)} onPress={() => setMenuOpen(true)}
/> />
{/* Brand Logo */} {/* Brand Logo */}
{brandLogo && ( {brandLogo && (
<Image <Image
@@ -312,10 +314,10 @@ function TopBarNarrow({ children }) {
resizeMode="contain" resizeMode="contain"
/> />
)} )}
{/* App Name - takes remaining space */} {/* App Name - takes remaining space */}
{appName && ( {appName && (
<Text fontWeight="bold" fontSize="$4" flex={1} numberOfLines={1} color="$accentColor"> <Text fontWeight="600" fontSize="$5" flex={1} numberOfLines={1} color="$textPrimary">
{appName} {appName}
</Text> </Text>
)} )}
@@ -359,9 +361,9 @@ function TopBarNarrow({ children }) {
snapPoints={[85]} snapPoints={[85]}
dismissOnSnapToBottom dismissOnSnapToBottom
> >
<Sheet.Overlay /> <Sheet.Overlay backgroundColor="$scrim" />
<Sheet.Handle /> <Sheet.Handle />
<Sheet.Frame padding="$4" gap="$2"> <Sheet.Frame padding="$4" gap="$2" backgroundColor="$bgPanel">
<YStack gap="$2" width="100%"> <YStack gap="$2" width="100%">
{/* Primary Menu Items - render with vertical orientation in Sheet */} {/* Primary Menu Items - render with vertical orientation in Sheet */}
{organizedChildren.primaryMenuItems.map((item) => ( {organizedChildren.primaryMenuItems.map((item) => (
+13
View File
@@ -24,6 +24,7 @@ export function GridView({
statusText = '', statusText = '',
selectable = false, selectable = false,
nested = false, nested = false,
closeable = true,
onClose = undefined, onClose = undefined,
onReload = undefined, onReload = undefined,
initialPageSize = 6, initialPageSize = 6,
@@ -197,8 +198,10 @@ export function GridView({
resolvedColumns, resolvedColumns,
visibleColumns, visibleColumns,
selectedIds, selectedIds,
setSelectedIds,
selectable, selectable,
nested, nested,
closeable,
isLoading, isLoading,
error, error,
tableViewportWidth, tableViewportWidth,
@@ -236,6 +239,15 @@ export function GridView({
return next; 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) => { setFilterValue: (key, value) => {
setOffset(0); setOffset(0);
setFilterBy((current) => ({ setFilterBy((current) => ({
@@ -260,6 +272,7 @@ export function GridView({
resolvedStatusText, resolvedStatusText,
rows, rows,
selectable, selectable,
closeable,
selectedIds, selectedIds,
sortBy, sortBy,
structure, structure,
+95 -70
View File
@@ -3,6 +3,7 @@ import { Button, Checkbox, Input, Paragraph, ScrollView, Text, XStack, YStack }
import { getIcon } from '../IconMapper.jsx'; import { getIcon } from '../IconMapper.jsx';
import { useGridView } from './context.js'; import { useGridView } from './context.js';
import { formatValueByColumn } from './utils.js'; import { formatValueByColumn } from './utils.js';
import { getTypographyRoleProps } from '../../styles/index.js';
function renderToolbarItem(item) { function renderToolbarItem(item) {
if (!item) { if (!item) {
@@ -22,7 +23,7 @@ function renderToolbarItem(item) {
theme={item.theme} theme={item.theme}
chromeless={item.chromeless} chromeless={item.chromeless}
disabled={item.disabled} disabled={item.disabled}
icon={IconComponent ? <IconComponent size={16} /> : undefined} icon={IconComponent ? <IconComponent size="sm" /> : undefined}
onPress={item.onClick || item.onPress} onPress={item.onClick || item.onPress}
> >
{item.label} {item.label}
@@ -32,21 +33,27 @@ function renderToolbarItem(item) {
if (item.kind === 'text') { if (item.kind === 'text') {
return ( return (
<Text key={item.key || item.text} color="$color" opacity={0.7}> <Text key={item.key || item.text} color="$textSecondary">
{item.text} {item.text}
</Text> </Text>
); );
} }
if (item.kind === 'search') { if (item.kind === 'search') {
const SearchIcon = getIcon('search');
return ( return (
<Input <XStack key={item.key || item.placeholder || 'search'} alignItems="center" gap="$2">
key={item.key || item.placeholder || 'search'} {SearchIcon ? <SearchIcon size="sm" color="$textMuted" /> : null}
width={item.width || 240} <Input
value={item.value} width={item.width || 240}
placeholder={item.placeholder || 'Search'} value={item.value}
onChangeText={(value) => item.onChange?.(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 ( return (
<YStack gap="$3"> <YStack gap="$3">
<YStack gap="$1"> <YStack gap="$1">
<Text fontSize="$3" letterSpacing={1} textTransform="uppercase" color="$accentColor"> <Text fontSize="$2" letterSpacing={1} textTransform="uppercase" color="$textSecondary">
Record Summary Record Summary
</Text> </Text>
<Text fontSize="$6" fontWeight="700"> <Text {...getTypographyRoleProps('sectionTitle')}>
{titleColumn ? row?.[titleColumn.field] : row?.id} {titleColumn ? row?.[titleColumn.field] : row?.id}
</Text> </Text>
{subtitleColumn ? ( {subtitleColumn ? (
<Paragraph color="$color" opacity={0.7}> <Paragraph color="$textSecondary">
{row?.[subtitleColumn.field] || ''} {row?.[subtitleColumn.field] || ''}
</Paragraph> </Paragraph>
) : null} ) : null}
@@ -94,15 +101,15 @@ function DefaultPanelRecordRenderer({ row }) {
key={`${row.id}-${column.field}-chip`} key={`${row.id}-${column.field}-chip`}
paddingHorizontal="$3" paddingHorizontal="$3"
paddingVertical="$2" paddingVertical="$2"
borderRadius="$6" borderRadius="$radiusMd"
backgroundColor="$accentSurface" backgroundColor="$bgPanel"
borderWidth={1} borderWidth={1}
borderColor="$accentBorder" borderColor="$lineSubtle"
> >
<Text fontSize="$2" color="$color" opacity={0.65}> <Text fontSize="$2" color="$textMuted">
{column.label} {column.label}
</Text> </Text>
<Text fontSize="$4" fontWeight="600"> <Text {...getTypographyRoleProps('tableHeader', { color: '$textPrimary' })}>
{formatValueByColumn(row?.[column.field], column)} {formatValueByColumn(row?.[column.field], column)}
</Text> </Text>
</YStack> </YStack>
@@ -116,16 +123,16 @@ function DefaultPanelRecordRenderer({ row }) {
minWidth={160} minWidth={160}
flex={1} flex={1}
padding="$3" padding="$3"
borderRadius="$4" borderRadius="$radiusMd"
borderWidth={1} borderWidth={1}
borderColor="$borderColor" borderColor="$lineSubtle"
backgroundColor="$background" backgroundColor="$bgPanel"
gap="$1" gap="$1"
> >
<Text fontSize="$3" color="$color" opacity={0.65}> <Text fontSize="$3" color="$textMuted">
{column.label} {column.label}
</Text> </Text>
<Text>{formatValueByColumn(row?.[column.field], column)}</Text> <Text color="$textPrimary">{formatValueByColumn(row?.[column.field], column)}</Text>
</YStack> </YStack>
))} ))}
</XStack> </XStack>
@@ -156,7 +163,7 @@ export function PanelFooterStatusBar({ text, visible = true }) {
} }
return ( return (
<Text color="$color" opacity={0.7}> <Text color="$textMuted">
{text || grid.statusText} {text || grid.statusText}
</Text> </Text>
); );
@@ -179,10 +186,10 @@ export function PanelHeader({ title, toolbarItems = [], visible = true, showDivi
padding="$3" padding="$3"
minHeight={64} minHeight={64}
borderBottomWidth={showDivider ? 1 : 0} borderBottomWidth={showDivider ? 1 : 0}
borderBottomColor="$borderColor" borderBottomColor="$lineSubtle"
backgroundColor="$accentSurface" backgroundColor="$bgPanel"
> >
<Text fontSize="$6" fontWeight="700" color="$accentColor"> <Text {...getTypographyRoleProps('panelTitle', { fontSize: '$6' })}>
{title} {title}
</Text> </Text>
@@ -192,17 +199,19 @@ export function PanelHeader({ title, toolbarItems = [], visible = true, showDivi
size="$3" size="$3"
chromeless chromeless
circular circular
icon={RefreshIcon ? <RefreshIcon size={16} /> : undefined} icon={RefreshIcon ? <RefreshIcon size="sm" color="$textSecondary" /> : undefined}
onPress={grid.reload} onPress={grid.reload}
/> />
<Button {grid.closeable !== false ? (
size="$3" <Button
chromeless size="$3"
circular chromeless
disabled={!grid.close} circular
icon={CloseIcon ? <CloseIcon size={16} /> : undefined} disabled={!grid.close}
onPress={grid.close} icon={CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : undefined}
/> onPress={grid.close}
/>
) : null}
</XStack> </XStack>
</XStack> </XStack>
); );
@@ -221,8 +230,8 @@ export function PanelFooter({ toolbarItems = [], visible = true }) {
padding="$3" padding="$3"
minHeight={56} minHeight={56}
borderTopWidth={1} borderTopWidth={1}
borderTopColor="$borderColor" borderTopColor="$lineSubtle"
backgroundColor="$background" backgroundColor="$bgPanel"
flexWrap="wrap" flexWrap="wrap"
> >
<PanelFooterStatusBar /> <PanelFooterStatusBar />
@@ -243,9 +252,11 @@ export function PanelBodyView({
} }
if (grid.error) { if (grid.error) {
const ErrorIcon = getIcon('error');
return ( return (
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5"> <YStack flex={1} alignItems="center" justifyContent="center" padding="$5" gap="$2">
<Text color="#b91c1c">{grid.error}</Text> {ErrorIcon ? <ErrorIcon size="lg" color="$danger" /> : null}
<Text color="$danger" fontWeight="600">{grid.error}</Text>
</YStack> </YStack>
); );
} }
@@ -253,15 +264,18 @@ export function PanelBodyView({
if (grid.isLoading && !grid.rows.length) { if (grid.isLoading && !grid.rows.length) {
return ( return (
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5"> <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> </YStack>
); );
} }
if (!grid.rows.length) { if (!grid.rows.length) {
const EmptyIcon = getIcon('folder');
return ( return (
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5"> <YStack flex={1} alignItems="center" justifyContent="center" padding="$5" gap="$2">
<Text color="$color" opacity={0.7}>No records available.</Text> {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> </YStack>
); );
} }
@@ -272,37 +286,49 @@ export function PanelBodyView({
<ScrollView flex={1}> <ScrollView flex={1}>
<YStack padding="$4" gap="$3"> <YStack padding="$4" gap="$3">
<XStack gap="$3" flexWrap="wrap"> <XStack gap="$3" flexWrap="wrap">
{grid.rows.map((row) => ( {grid.rows.map((row) => {
<YStack const isSelected = grid.selectedIds.has(row.id);
key={row.id} return (
minWidth={responsiveColumns > 1 ? 320 : 240} <YStack
flex={1} key={row.id}
flexBasis={responsiveColumns > 1 ? '48%' : '100%'} minWidth={responsiveColumns > 1 ? 320 : 240}
padding="$4" flex={1}
borderWidth={1} flexBasis={responsiveColumns > 1 ? '48%' : '100%'}
borderColor={grid.selectedIds.has(row.id) ? '$accentBorder' : '$borderColor'} padding="$4"
backgroundColor={grid.selectedIds.has(row.id) ? '$accentSurface' : '$background'} borderWidth={1}
borderRadius="$5" borderColor={isSelected ? '$accent' : '$lineSubtle'}
gap="$3" backgroundColor={isSelected ? '$accentBg' : '$bgPanel'}
> borderRadius="$radiusLg"
{grid.selectable ? ( gap="$3"
<XStack justifyContent="flex-start"> hoverStyle={isSelected ? undefined : { borderColor: '$lineStrong' }}
<Checkbox >
checked={grid.selectedIds.has(row.id)} {grid.selectable ? (
onCheckedChange={() => grid.toggleSelectRow(row.id)} <XStack justifyContent="flex-start">
> <Checkbox
<Checkbox.Indicator /> checked={isSelected}
</Checkbox> onCheckedChange={() => grid.toggleSelectRow(row.id)}
</XStack> borderColor="$lineStrong"
) : null} backgroundColor="$bgPanel"
focusStyle={{ borderColor: '$accent' }}
>
<Checkbox.Indicator>
{(() => {
const Check = getIcon('check');
return Check ? <Check size="sm" color="$accent" /> : null;
})()}
</Checkbox.Indicator>
</Checkbox>
</XStack>
) : null}
<RecordRenderer row={row} /> <RecordRenderer row={row} />
</YStack> </YStack>
))} );
})}
</XStack> </XStack>
{grid.isLoading ? ( {grid.isLoading ? (
<Text color="$color" opacity={0.7}> <Text color="$textMuted">
Refreshing records... Refreshing records...
</Text> </Text>
) : null} ) : null}
@@ -310,4 +336,3 @@ export function PanelBodyView({
</ScrollView> </ScrollView>
); );
} }
+87 -29
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
import { Button, Checkbox, ScrollView, Separator, Text, XStack, YStack } from 'tamagui'; import { Button, Checkbox, ScrollView, Separator, Text, XStack, YStack } from 'tamagui';
import { getIcon } from '../IconMapper.jsx'; import { getIcon } from '../IconMapper.jsx';
import { useGridView } from './context.js'; import { useGridView } from './context.js';
import { getTypographyRoleProps } from '../../styles/index.js';
import { import {
formatValueByColumn, formatValueByColumn,
getColumnJustify, getColumnJustify,
@@ -12,7 +13,7 @@ import {
function DefaultGridCellRenderer({ value, column }) { function DefaultGridCellRenderer({ value, column }) {
return ( 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)} {formatValueByColumn(value, column)}
</Text> </Text>
); );
@@ -29,15 +30,26 @@ function UtilityCell({ rowId }) {
if (grid.nested) { if (grid.nested) {
return ( return (
<XStack width={36} alignItems="center" justifyContent="center"> <XStack width={36} alignItems="center" justifyContent="center">
{ChevronRightIcon ? <ChevronRightIcon size={16} /> : <Text>{'>'}</Text>} {ChevronRightIcon ? <ChevronRightIcon size="sm" color="$textSecondary" /> : <Text color="$textSecondary">{'>'}</Text>}
</XStack> </XStack>
); );
} }
return ( return (
<XStack width={36} alignItems="center" justifyContent="center"> <XStack width={36} alignItems="center" justifyContent="center">
<Checkbox checked={grid.selectedIds.has(rowId)} onCheckedChange={() => grid.toggleSelectRow(rowId)}> <Checkbox
<Checkbox.Indicator /> 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> </Checkbox>
</XStack> </XStack>
); );
@@ -80,21 +92,46 @@ export function TableHeader({ visible = true, showTopBorder = true }) {
} }
const activeColumns = grid.visibleColumns?.length ? grid.visibleColumns : grid.resolvedColumns; 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 ( return (
<XStack <XStack
alignItems="stretch" alignItems="stretch"
borderTopWidth={showTopBorder ? 1 : 0} borderTopWidth={showTopBorder ? 1 : 0}
borderBottomWidth={1} borderBottomWidth={1}
borderColor="$accentBorder" borderColor="$lineSubtle"
backgroundColor="$accentSurface" backgroundColor="transparent"
paddingHorizontal="$2" 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) => { {activeColumns.map((column) => {
const activeSort = grid.sortBy.find((entry) => entry.field === column.field); const activeSort = grid.sortBy.find((entry) => entry.field === column.field);
const sortLabel = const isActive = Boolean(activeSort);
activeSort?.direction === 'asc' ? '↑' : activeSort?.direction === 'desc' ? '↓' : ''; const direction = activeSort?.direction;
return ( return (
<Button <Button
@@ -107,10 +144,28 @@ export function TableHeader({ visible = true, showTopBorder = true }) {
paddingVertical="$3" paddingVertical="$3"
paddingHorizontal="$2" paddingHorizontal="$2"
{...getColumnLayoutStyle(column)} {...getColumnLayoutStyle(column)}
hoverStyle={column.sortable ? { backgroundColor: '$bgPage' } : undefined}
pressStyle={column.sortable ? { backgroundColor: '$bgPanelElev' } : undefined}
> >
<Text width="100%" textAlign={column.align || 'left'} fontWeight="700"> <XStack width="100%" alignItems="center" justifyContent={getColumnJustify(column.align)} gap="$2">
{column.label}{sortLabel ? ` ${sortLabel}` : ''} <Text
</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> </Button>
); );
})} })}
@@ -131,7 +186,7 @@ export function TableBodyView({ visible = true }) {
if (grid.error) { if (grid.error) {
return ( return (
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5"> <YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
<Text color="#b91c1c">{grid.error}</Text> <Text color="$danger" fontWeight="600">{grid.error}</Text>
</YStack> </YStack>
); );
} }
@@ -144,7 +199,8 @@ export function TableBodyView({ visible = true }) {
<XStack <XStack
alignItems="stretch" alignItems="stretch"
paddingHorizontal="$2" 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} {grid.selectable || grid.nested ? <UtilityCell rowId={row.id} /> : null}
{activeColumns.map((column) => { {activeColumns.map((column) => {
@@ -166,19 +222,22 @@ export function TableBodyView({ visible = true }) {
); );
})} })}
</XStack> </XStack>
<Separator /> <Separator borderColor="$lineSubtle" />
</YStack> </YStack>
))} ))}
{!grid.rows.length && !grid.isLoading ? ( {!grid.rows.length && !grid.isLoading ? (
<YStack minHeight={120} alignItems="center" justifyContent="center" padding="$5"> <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> </YStack>
) : null} ) : null}
{grid.isLoading ? ( {grid.isLoading && !grid.rows.length ? (
<YStack minHeight={64} alignItems="center" justifyContent="center" padding="$4"> <YStack minHeight={64} padding="$4" gap="$2">
<Text color="$color" opacity={0.7}>Loading...</Text> {[0, 1, 2].map((i) => (
<XStack key={i} height={46} borderRadius="$radiusMd" backgroundColor="$bgPage" />
))}
</YStack> </YStack>
) : null} ) : null}
</YStack> </YStack>
@@ -205,21 +264,21 @@ export function TableFooter({ visible = true }) {
padding="$3" padding="$3"
minHeight={56} minHeight={56}
borderTopWidth={1} borderTopWidth={1}
borderTopColor="$borderColor" borderTopColor="$lineSubtle"
backgroundColor="$background" backgroundColor="$bgPanel"
flexWrap="wrap" flexWrap="wrap"
> >
<Text color="$color" opacity={0.7}> <Text color="$textMuted">
{grid.total} records {grid.total} records
</Text> </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 <Button
size="$3" size="$3"
chromeless chromeless
circular circular
disabled={grid.currentPage <= 1} disabled={grid.currentPage <= 1}
icon={FirstPageIcon ? <FirstPageIcon size={16} /> : undefined} icon={FirstPageIcon ? <FirstPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => grid.setPage(1)} onPress={() => grid.setPage(1)}
/> />
<Button <Button
@@ -227,10 +286,10 @@ export function TableFooter({ visible = true }) {
chromeless chromeless
circular circular
disabled={grid.currentPage <= 1} disabled={grid.currentPage <= 1}
icon={PreviousPageIcon ? <PreviousPageIcon size={16} /> : undefined} icon={PreviousPageIcon ? <PreviousPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => grid.setPage(grid.currentPage - 1)} onPress={() => grid.setPage(grid.currentPage - 1)}
/> />
<Text color="$color" opacity={0.75}> <Text color="$textSecondary">
Page {grid.currentPage} of {grid.pageCount} Page {grid.currentPage} of {grid.pageCount}
</Text> </Text>
<Button <Button
@@ -238,7 +297,7 @@ export function TableFooter({ visible = true }) {
chromeless chromeless
circular circular
disabled={grid.currentPage >= grid.pageCount} 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)} onPress={() => grid.setPage(grid.currentPage + 1)}
/> />
<Button <Button
@@ -246,11 +305,10 @@ export function TableFooter({ visible = true }) {
chromeless chromeless
circular circular
disabled={grid.currentPage >= grid.pageCount} disabled={grid.currentPage >= grid.pageCount}
icon={LastPageIcon ? <LastPageIcon size={16} /> : undefined} icon={LastPageIcon ? <LastPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => grid.setPage(grid.pageCount)} onPress={() => grid.setPage(grid.pageCount)}
/> />
</XStack> </XStack>
</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 { GeneralConfig, default as GeneralConfigDefault } from './GeneralConfig.jsx';
export { IdentityConfig, default as IdentityConfigDefault } from './IdentityConfig.jsx'; export { IdentityConfig, default as IdentityConfigDefault } from './IdentityConfig.jsx';
export * from './grid/index.js'; export * from './grid/index.js';
export { getTypographyRoleProps, getStyleTypography, TYPOGRAPHY_ROLE_KEYS } from '../styles/index.js';
// Re-export App helpers for convenience. // Re-export App helpers for convenience.
// The App component itself is exported from src/index.js and src/ui/App.jsx. // 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, ...configBase,
name: 'colorful', name: 'colorful',
displayName: '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: { tokens: {
...configBase.tokens, ...configBase.tokens,
radius: {
...configBase.tokens.radius,
radiusSm: 8,
radiusMd: 12,
radiusLg: 16,
},
// Colorful palette (vibrant colors) // Colorful palette (vibrant colors)
color: { color: {
...configBase.tokens.color, ...configBase.tokens.color,
@@ -90,6 +105,29 @@ export const ColorfulTheme = {
colorDisabled: '#94a3b8', colorDisabled: '#94a3b8',
borderColor: '#cbd5e1', borderColor: '#cbd5e1',
borderColorHover: '#94a3b8', 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: { dark: {
...configBase.themes.dark, ...configBase.themes.dark,
@@ -111,6 +149,29 @@ export const ColorfulTheme = {
colorDisabled: '#64748b', colorDisabled: '#64748b',
borderColor: '#334155', borderColor: '#334155',
borderColorHover: '#475569', 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: { settings: {
+62
View File
@@ -9,8 +9,24 @@ export const MaterialTheme = {
...configBase, ...configBase,
name: 'material', name: 'material',
displayName: 'Material Design', 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: { tokens: {
...configBase.tokens, ...configBase.tokens,
radius: {
...configBase.tokens.radius,
radiusSm: 4,
radiusMd: 6,
radiusLg: 8,
},
// Material UI color palette (base tokens - theme-agnostic) // Material UI color palette (base tokens - theme-agnostic)
color: { color: {
...configBase.tokens.color, ...configBase.tokens.color,
@@ -90,6 +106,29 @@ export const MaterialTheme = {
colorDisabled: 'rgba(0, 0, 0, 0.38)', colorDisabled: 'rgba(0, 0, 0, 0.38)',
borderColor: 'rgba(0, 0, 0, 0.12)', borderColor: 'rgba(0, 0, 0, 0.12)',
borderColorHover: 'rgba(0, 0, 0, 0.23)', 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: { dark: {
...configBase.themes.dark, ...configBase.themes.dark,
@@ -111,6 +150,29 @@ export const MaterialTheme = {
colorDisabled: 'rgba(255, 255, 255, 0.38)', colorDisabled: 'rgba(255, 255, 255, 0.38)',
borderColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.12)',
borderColorHover: 'rgba(255, 255, 255, 0.23)', 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: { settings: {
+61
View File
@@ -9,8 +9,23 @@ export const MinimalTheme = {
...configBase, ...configBase,
name: 'minimal', name: 'minimal',
displayName: '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: { tokens: {
...configBase.tokens, ...configBase.tokens,
radius: {
...configBase.tokens.radius,
radiusSm: 2,
radiusMd: 4,
radiusLg: 6,
},
// Minimal color palette (neutral, monochromatic) // Minimal color palette (neutral, monochromatic)
color: { color: {
...configBase.tokens.color, ...configBase.tokens.color,
@@ -90,6 +105,29 @@ export const MinimalTheme = {
colorDisabled: '#a0aec0', colorDisabled: '#a0aec0',
borderColor: '#e2e8f0', borderColor: '#e2e8f0',
borderColorHover: '#cbd5e0', 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: { dark: {
...configBase.themes.dark, ...configBase.themes.dark,
@@ -111,6 +149,29 @@ export const MinimalTheme = {
colorDisabled: '#718096', colorDisabled: '#718096',
borderColor: '#2d3748', borderColor: '#2d3748',
borderColorHover: '#4a5568', 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: { settings: {
+228 -6
View File
@@ -1,19 +1,202 @@
/** /**
* Style Themes Index * 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 { MaterialTheme } from './MaterialTheme.js';
import { MinimalTheme } from './MinimalTheme.js'; import { MinimalTheme } from './MinimalTheme.js';
import { ColorfulTheme } from './ColorfulTheme.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 = { export const STYLE_THEMES = {
azure: AzureTheme,
apple: AppleTheme,
material: MaterialTheme, material: MaterialTheme,
minimal: MinimalTheme, minimal: MinimalTheme,
colorful: ColorfulTheme, 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. * 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 * Get a style theme by name.
* @param {string} themeName - Theme name ('material', 'minimal', 'colorful') * @param {string} themeName - Theme name ('azure', 'apple', 'material', 'minimal', 'colorful')
* @returns {Object} Theme configuration * @returns {Object} Theme configuration
*/ */
export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) { export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) {
@@ -39,13 +222,52 @@ export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) {
return STYLE_THEMES[key]; 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 * @returns {string[]} Array of theme names
*/ */
export function getStyleThemeNames() { export function getStyleThemeNames() {
return Object.keys(STYLE_THEMES); 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 };