This commit is contained in:
2026-10-18 22:42:00 +01:00
commit 2df34ab938
95 changed files with 12358 additions and 0 deletions
+614
View File
@@ -0,0 +1,614 @@
import React, { useEffect, useRef } from 'react';
import {
Animated,
Easing,
Pressable,
ScrollView,
StyleSheet,
TextInput,
View,
type TextInputProps,
} from 'react-native';
import { Text } from 'heroui-native/text';
import { Spinner } from 'heroui-native/spinner';
import { Surface } from 'heroui-native/surface';
import {
BellOff,
CircleAlert,
CircleDashed,
ShieldCheck,
Siren,
TriangleAlert,
WifiOff,
type LucideIcon,
} from 'lucide-react-native';
import {
border,
danger,
dangerDeep,
focusRing,
minTapTarget,
ok,
radiusFull,
radiusLg,
space1,
space2,
space3,
space4,
space6,
stateAlarmDeep,
surface,
surfaceRaised,
text,
textFaint,
textMuted,
textSecondary,
typeBody,
typeBodyStrong,
typeCaption,
typeCode,
typeHeading,
typeLabel,
typeMicro,
typeTitle,
warn,
STATE_META,
type AlarmState,
} from '../theme/tokens';
// ── Primitives ───────────────────────────────────────────────────────────────
export function AppText({
children,
style,
className,
numberOfLines,
accessibilityLabel,
accessibilityLiveRegion,
}: {
children: React.ReactNode;
style?: object;
className?: string;
numberOfLines?: number;
accessibilityLabel?: string;
accessibilityLiveRegion?: 'none' | 'polite' | 'assertive';
}) {
return (
<Text
className={className}
style={style}
numberOfLines={numberOfLines}
accessibilityLabel={accessibilityLabel}
accessibilityLiveRegion={accessibilityLiveRegion}
>
{children}
</Text>
);
}
export function Screen({
children,
scroll = true,
}: {
children: React.ReactNode;
scroll?: boolean;
}) {
if (!scroll) {
return <View style={styles.flex}>{children}</View>;
}
return (
<ScrollView
style={styles.flex}
contentContainerStyle={styles.screenContent}
keyboardShouldPersistTaps="handled"
>
{children}
</ScrollView>
);
}
export function Field({
label,
children,
accessibilityLabel,
}: {
label: string;
children: React.ReactNode;
accessibilityLabel?: string;
}) {
return (
<View style={styles.field}>
<AppText style={{ ...typeLabel, color: textMuted }}>{label}</AppText>
<View accessibilityLabel={accessibilityLabel}>{children}</View>
</View>
);
}
export function TextInputBox(props: TextInputProps) {
const [focused, setFocused] = React.useState(false);
const { style, onFocus, onBlur, ...rest } = props;
return (
<TextInput
placeholderTextColor={textFaint}
onFocus={e => {
setFocused(true);
onFocus?.(e);
}}
onBlur={e => {
setFocused(false);
onBlur?.(e);
}}
// The border token is decorative; a control boundary uses focusRing
// (DESIGN.md §2.1).
style={[styles.input, { borderColor: focused ? focusRing : border }, style]}
{...rest}
/>
);
}
export function Card({
children,
onPress,
style,
accessibilityLabel,
}: {
children: React.ReactNode;
onPress?: () => void;
style?: object;
accessibilityLabel?: string;
}) {
const inner = (
<Surface style={[styles.card, style]}>
<View accessibilityLabel={accessibilityLabel}>{children}</View>
</Surface>
);
if (onPress) {
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
>
{inner}
</Pressable>
);
}
return inner;
}
export type ButtonVariant =
| 'primary'
| 'secondary'
| 'ghost'
| 'alarm'
| 'danger'
| 'warn'
| 'outlineWhite'
| 'white';
export interface ButtonProps {
label: string;
onPress?: () => void;
variant?: ButtonVariant;
height?: number;
disabled?: boolean;
icon?: LucideIcon;
accessibilityLabel?: string;
style?: object;
}
const BUTTON_VARIANTS: Record<
ButtonVariant,
{ fill: string; fg: string; border: string; borderWidth: number }
> = {
primary: { fill: focusRing, fg: '#06121F', border: 'transparent', borderWidth: 0 },
secondary: { fill: surfaceRaised, fg: text, border: border, borderWidth: 1 },
ghost: { fill: 'transparent', fg: text, border: 'transparent', borderWidth: 0 },
alarm: { fill: stateAlarmDeep, fg: '#FFFFFF', border: 'transparent', borderWidth: 0 },
danger: { fill: dangerDeep, fg: '#FFFFFF', border: 'transparent', borderWidth: 0 },
warn: { fill: warn, fg: '#2A1A02', border: 'transparent', borderWidth: 0 },
outlineWhite: { fill: 'transparent', fg: '#FFFFFF', border: '#FFFFFF', borderWidth: 2 },
white: { fill: '#FFFFFF', fg: '#0B0D10', border: 'transparent', borderWidth: 0 },
};
export function Button({
label,
onPress,
variant = 'primary',
height = 56,
disabled,
icon: Icon,
accessibilityLabel,
style,
}: ButtonProps) {
const palette = BUTTON_VARIANTS[variant];
return (
<Pressable
onPress={disabled ? undefined : onPress}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? label}
accessibilityState={{ disabled: !!disabled }}
style={[
styles.button,
{
height,
backgroundColor: palette.fill,
borderColor: palette.border,
borderWidth: palette.borderWidth,
},
// Disabled is opacity .4 — never a colour-only change (DESIGN.md).
disabled ? styles.disabled : null,
style,
]}
>
{Icon ? <Icon size={20} color={palette.fg} /> : null}
<AppText style={{ ...typeBodyStrong, color: palette.fg }}>{label}</AppText>
</Pressable>
);
}
export function EmptyState({
icon: Icon,
title,
subtitle,
action,
}: {
icon?: LucideIcon;
title: string;
subtitle?: string;
action?: React.ReactNode;
}) {
return (
<View style={styles.empty}>
{Icon ? <Icon size={36} color={textFaint} /> : null}
<AppText style={{ ...typeHeading, color: text, marginTop: space4 }}>{title}</AppText>
{subtitle ? (
<AppText
style={{ ...typeCaption, color: textSecondary, marginTop: space2, textAlign: 'center' }}
>
{subtitle}
</AppText>
) : null}
{action ? <View style={styles.emptyAction}>{action}</View> : null}
</View>
);
}
export function LoadingView({ label = 'Loading…' }: { label?: string }) {
return (
<View style={styles.loading}>
<Spinner />
<AppText style={{ ...typeCaption, color: textSecondary, marginTop: space3 }}>{label}</AppText>
</View>
);
}
export function ErrorBanner({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<View style={styles.errorBanner} accessibilityLiveRegion="polite">
<View style={styles.errorRow}>
<CircleAlert size={18} color={danger} />
<AppText style={{ ...typeLabel, color: danger, flex: 1 }}>{message}</AppText>
</View>
{onRetry ? (
<Pressable
onPress={onRetry}
accessibilityRole="button"
accessibilityLabel="Try again"
style={styles.retry}
>
<AppText style={{ ...typeLabel, color: danger, textDecorationLine: 'underline' }}>
Try again
</AppText>
</Pressable>
) : null}
</View>
);
}
/** Persistent offline strip (Cross-screen rules) — never an ErrorBanner. */
export function OfflineStrip() {
return (
<View style={styles.offlineStrip}>
<WifiOff size={16} color={warn} />
<AppText style={{ ...typeCaption, color: warn, flex: 1 }}>
Offline — actions will be sent when you reconnect
</AppText>
</View>
);
}
export function Header({
title,
subtitle,
onBack,
right,
}: {
title: string;
subtitle?: string;
onBack?: () => void;
right?: React.ReactNode;
}) {
return (
<View style={styles.header}>
{onBack ? (
<Pressable
onPress={onBack}
accessibilityRole="button"
accessibilityLabel="Back"
style={styles.headerBack}
>
<AppText style={{ ...typeBodyStrong, color: textMuted }}>‹</AppText>
</Pressable>
) : null}
<View style={styles.flex}>
<AppText style={{ ...typeTitle, color: text }} numberOfLines={1}>
{title}
</AppText>
{subtitle ? (
<AppText style={{ ...typeCaption, color: textSecondary }} numberOfLines={1}>
{subtitle}
</AppText>
) : null}
</View>
{right}
</View>
);
}
// ── State components ─────────────────────────────────────────────────────────
const STATE_ICONS: Record<AlarmState, LucideIcon> = {
idle: CircleDashed,
armed: ShieldCheck,
triggered: Siren,
disarmed: BellOff,
};
export function stateIcon(state: AlarmState): LucideIcon {
return STATE_ICONS[state];
}
export function StatePill({ state, style }: { state: AlarmState; style?: object }) {
const meta = STATE_META[state];
const Icon = STATE_ICONS[state];
const filled = meta.chipFill !== null;
return (
<View
style={[
styles.pill,
{
backgroundColor: meta.chipFill ?? 'transparent',
borderColor: meta.color,
borderWidth: filled ? 0 : 1,
},
style,
]}
accessibilityLabel={meta.label}
>
<Icon size={14} color={filled ? meta.chipText : meta.color} />
<AppText style={{ ...typeMicro, color: filled ? meta.chipText : meta.color }}>
{meta.label}
</AppText>
</View>
);
}
/** Breathe (armed, 1800ms) / pulse (triggered, 700ms) / static otherwise. */
export function useStateMotion(state: AlarmState, reducedMotion = false) {
const anim = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (reducedMotion || (state !== 'armed' && state !== 'triggered')) {
anim.setValue(1);
return;
}
const duration = state === 'armed' ? 1800 : 700;
const loop = Animated.loop(
Animated.sequence([
Animated.timing(anim, {
toValue: state === 'armed' ? 1.04 : 1.06,
duration: duration / 2,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(anim, {
toValue: 1,
duration: duration / 2,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
]),
);
loop.start();
return () => loop.stop();
}, [state, reducedMotion, anim]);
return anim;
}
/**
* The state ring: width from the state meta, dashed for idle, plus corner ticks
* for triggered. Shape + icon + label carry the state, so colour is never the
* only signal.
*/
export function StateRing({
state,
size = 160,
reducedMotion = false,
}: {
state: AlarmState;
size?: number;
reducedMotion?: boolean;
}) {
const meta = STATE_META[state];
const Icon = STATE_ICONS[state];
const scale = useStateMotion(state, reducedMotion);
return (
<Animated.View style={{ transform: [{ scale }], width: size, height: size }}>
<View
style={[
styles.ring,
{
width: size,
height: size,
borderColor: meta.color,
borderWidth: meta.ringWidth,
borderStyle: meta.dashed ? 'dashed' : 'solid',
},
]}
>
<Icon size={48} color={meta.color} />
</View>
{state === 'triggered' ? (
<>
<View style={[styles.tickTopLeft, { borderTopColor: meta.color }]} />
<View style={[styles.tickTopRight, { borderTopColor: meta.color }]} />
<View style={[styles.tickBottomLeft, { borderBottomColor: meta.color }]} />
<View style={[styles.tickBottomRight, { borderBottomColor: meta.color }]} />
</>
) : null}
</Animated.View>
);
}
export function IconLine({
icon: Icon,
color,
children,
}: {
icon: LucideIcon;
color: string;
children: React.ReactNode;
}) {
return (
<View style={styles.iconLine}>
<Icon size={16} color={color} />
<AppText style={{ ...typeCaption, color, flex: 1 }}>{children}</AppText>
</View>
);
}
/** Trailing readiness icon: circle-check (ok) / triangle-alert / circle-alert. */
export function HealthIcon({ status }: { status: 'ok' | 'warn' | 'danger' }) {
if (status === 'ok') {
return (
<View style={styles.checkCircle}>
<AppText style={{ ...typeMicro, color: '#06240F' }}>✓</AppText>
</View>
);
}
if (status === 'warn') return <TriangleAlert size={20} color={warn} />;
return <CircleAlert size={20} color={danger} />;
}
export function Divider() {
return <View style={styles.divider} />;
}
export const styles = StyleSheet.create({
flex: { flex: 1 },
screenContent: { padding: space4, paddingBottom: 40 },
field: { gap: space2 },
input: {
borderRadius: radiusLg,
borderWidth: 1,
backgroundColor: surfaceRaised,
paddingHorizontal: space4,
paddingVertical: space3,
color: text,
...typeBody,
},
card: { backgroundColor: surface, borderRadius: radiusLg, padding: space4, elevation: 4 },
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: space2,
borderRadius: radiusLg,
paddingHorizontal: space4,
minHeight: minTapTarget,
},
disabled: { opacity: 0.4 },
empty: {
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: space6,
paddingVertical: 48,
},
emptyAction: { marginTop: space6, alignSelf: 'stretch' },
loading: { alignItems: 'center', justifyContent: 'center', paddingVertical: 48 },
errorBanner: {
borderWidth: 1,
borderColor: 'rgba(248,113,113,0.3)',
backgroundColor: 'rgba(248,113,113,0.1)',
borderRadius: radiusLg,
padding: space4,
marginBottom: space4,
},
errorRow: { flexDirection: 'row', alignItems: 'center', gap: space2 },
retry: { marginTop: space2, minHeight: 32, justifyContent: 'center' },
offlineStrip: {
flexDirection: 'row',
alignItems: 'center',
gap: space2,
backgroundColor: 'rgba(251,191,36,0.12)',
paddingHorizontal: space4,
paddingVertical: space2,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: space2,
paddingHorizontal: space4,
paddingVertical: space3,
borderBottomWidth: 1,
borderBottomColor: border,
},
headerBack: {
width: minTapTarget,
height: minTapTarget,
alignItems: 'center',
justifyContent: 'center',
},
pill: {
flexDirection: 'row',
alignItems: 'center',
gap: space1,
borderRadius: radiusFull,
paddingHorizontal: space3,
paddingVertical: space1,
alignSelf: 'flex-start',
},
ring: { alignItems: 'center', justifyContent: 'center', borderRadius: radiusFull },
tickTopLeft: { position: 'absolute', top: 0, left: 0, width: 18, height: 18, borderTopWidth: 4 },
tickTopRight: { position: 'absolute', top: 0, right: 0, width: 18, height: 18, borderTopWidth: 4 },
tickBottomLeft: {
position: 'absolute',
bottom: 0,
left: 0,
width: 18,
height: 18,
borderBottomWidth: 4,
},
tickBottomRight: {
position: 'absolute',
bottom: 0,
right: 0,
width: 18,
height: 18,
borderBottomWidth: 4,
},
iconLine: { flexDirection: 'row', alignItems: 'center', gap: space2 },
checkCircle: {
width: 20,
height: 20,
borderRadius: radiusFull,
backgroundColor: ok,
alignItems: 'center',
justifyContent: 'center',
},
divider: { height: 1, backgroundColor: border, marginVertical: space3 },
mono: { ...typeCode, color: text },
body: { ...typeBody, color: text },
caption: { ...typeCaption, color: textSecondary },
});