UI: light Things-3 design system — tokens, task rows, chips, home

Replaces the dark navy palette with a light grouped-background system:
- theme.ts: full token set (space/radius/type/shadow) + light+dark palettes
- ui.tsx: Screen/Title/SectionHeader/Card/Separator/Pill/Empty primitives
- TaskRow: rounded-square checkbox carrying priority colour, inset separators
- TaskListScreen: rows grouped into a card, drag lift, refined deferred footer
- Icon.tsx: dependency-free geometric icon set (emoji don't take a tint)
- home: large title, Next-up card, "needs attention" rows, icon tab bar
- Priority/Estimate/Date chips unified on the accent-soft active state

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-09-01 22:36:26 +10:00
parent 65efb632db
commit 1af86cd27c
11 changed files with 863 additions and 217 deletions

View File

@@ -2,10 +2,11 @@ import NetInfo from "@react-native-community/netinfo";
import { Link, Stack } from "expo-router"; import { Link, Stack } from "expo-router";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { useEffect } from "react"; import { useEffect } from "react";
import { AppState } from "react-native"; import { AppState, Pressable } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import { getState, hydrate, subscribe, sync } from "../lib/store"; import { getState, hydrate, subscribe, sync } from "../lib/store";
import { C } from "../lib/theme"; import Icon from "../components/Icon";
import { C, space } from "../lib/theme";
import { refreshNextUpWidget } from "../widget/refresh"; import { refreshNextUpWidget } from "../widget/refresh";
export default function RootLayout() { export default function RootLayout() {
@@ -35,12 +36,16 @@ export default function RootLayout() {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="light" /> <StatusBar style="dark" />
<Stack <Stack
screenOptions={{ screenOptions={{
headerStyle: { backgroundColor: C.bg }, headerStyle: { backgroundColor: C.bg },
headerTintColor: C.text, headerTintColor: C.text,
// No hairline under the bar: the header should melt into the grouped
// background so the in-content Title carries the hierarchy instead.
headerShadowVisible: false, headerShadowVisible: false,
headerTitleStyle: { fontSize: 17, fontWeight: "600", color: C.text, letterSpacing: -0.2 },
headerBackButtonDisplayMode: "minimal",
contentStyle: { backgroundColor: C.bg }, contentStyle: { backgroundColor: C.bg },
}} }}
> >
@@ -49,8 +54,13 @@ export default function RootLayout() {
options={{ options={{
title: "GTD", title: "GTD",
headerRight: () => ( headerRight: () => (
<Link href="/settings" style={{ paddingHorizontal: 8, fontSize: 18 }}> <Link href="/settings" asChild>
⚙️ <Pressable
hitSlop={12}
style={({ pressed }) => [{ paddingHorizontal: space.sm }, pressed && { opacity: 0.5 }]}
>
<Icon name="settings" size={22} color={C.textSecondary} />
</Pressable>
</Link> </Link>
), ),
}} }}

View File

@@ -1,20 +1,45 @@
import { useFocusEffect, useRouter } from "expo-router"; import { useFocusEffect, useRouter } from "expo-router";
import { Pressable, StyleSheet, Text, View } from "react-native"; import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import TaskRow from "../components/TaskRow"; import Icon, { type IconName } from "../components/Icon";
import TaskRow, { TaskSeparator } from "../components/TaskRow";
import { Card, Empty, Screen, SectionHeader, Title } from "../components/ui";
import { haptic } from "../lib/haptics"; import { haptic } from "../lib/haptics";
import { daysSince, useLastReview } from "../lib/review"; import { daysSince, useLastReview } from "../lib/review";
import { todayTasks, visibleTasksByStatus } from "../lib/store"; import { todayTasks, visibleTasksByStatus } from "../lib/store";
import { C } from "../lib/theme"; import { C, shadow, space, type } from "../lib/theme";
import { useStore } from "../lib/useStore"; import { useStore } from "../lib/useStore";
const MENU = [ const MENU: { icon: IconName; label: string; href: string }[] = [
{ icon: "📅", label: "Today", href: "/today" }, { icon: "today", label: "Today", href: "/today" },
{ icon: "▶️", label: "Next", href: "/list/next" }, { icon: "next", label: "Next", href: "/list/next" },
{ icon: "⏳", label: "Waiting", href: "/list/waiting" }, { icon: "waiting", label: "Waiting", href: "/list/waiting" },
{ icon: "💭", label: "Someday", href: "/list/someday" }, { icon: "someday", label: "Someday", href: "/list/someday" },
{ icon: "📁", label: "Projects", href: "/projects" }, { icon: "projects", label: "Projects", href: "/projects" },
{ icon: "🔄", label: "Review", href: "/review" }, { icon: "review", label: "Review", href: "/review" },
] as const; ];
const PREVIEW = 3;
/**
* One tappable line of status — overdue, review due, sync trouble. Rendered as
* a real row with a chevron rather than coloured micro-text, so it reads as
* something you can act on.
*/
function Alert({ tone, text, onPress }: { tone: string; text: string; onPress?: () => void }) {
const body = (
<View style={s.alert}>
<View style={[s.dot, { backgroundColor: tone }]} />
<Text style={[s.alertText, { color: tone }]}>{text}</Text>
{!!onPress && <Icon name="chevron" size={16} color={tone} />}
</View>
);
if (!onPress) return body;
return (
<Pressable onPress={onPress} style={({ pressed }) => pressed && { opacity: 0.6 }}>
{body}
</Pressable>
);
}
export default function Home() { export default function Home() {
const router = useRouter(); const router = useRouter();
@@ -25,103 +50,146 @@ export default function Home() {
useFocusEffect(reloadReview); useFocusEffect(reloadReview);
const reviewDays = daysSince(lastReview); const reviewDays = daysSince(lastReview);
const reviewDue = lastReview === null || (reviewDays !== null && reviewDays >= 7); const reviewDue = lastReview === null || (reviewDays !== null && reviewDays >= 7);
const preview = next.slice(0, PREVIEW);
return ( return (
<View style={s.screen}> <Screen>
<View style={{ flex: 1 }}> <ScrollView contentContainerStyle={s.content} showsVerticalScrollIndicator={false}>
<Text style={s.sectionTitle}>Next up</Text> <Title count={next.length}>Next up</Title>
{next.slice(0, 3).map((t) => (
<TaskRow key={t.id} task={t} /> {preview.length === 0 ? (
))} <Empty>Nothing queued. Add the next thing on your mind.</Empty>
{next.length === 0 && <Text style={s.empty}>Nothing next. Tap + to add.</Text>} ) : (
{next.length > 3 && ( <Card>
<Pressable onPress={() => router.push("/list/next")}> {preview.map((t, i) => (
<Text style={s.viewAll}>all next actions ({next.length}) →</Text> <View key={t.id}>
{i > 0 && <TaskSeparator />}
<TaskRow task={t} />
</View>
))}
</Card>
)}
{next.length > PREVIEW && (
<Pressable
onPress={() => router.push("/list/next")}
style={({ pressed }) => [s.viewAll, pressed && { opacity: 0.6 }]}
>
<Text style={s.viewAllText}>All {next.length} next actions</Text>
<Icon name="chevron" size={16} color={C.accent} />
</Pressable> </Pressable>
)} )}
{overdue.length > 0 && (
<Pressable onPress={() => router.push("/today")}> {(overdue.length > 0 || reviewDue || store.offline || !!store.syncError) && (
<Text style={s.overdue}>{overdue.length} overdue →</Text> <>
</Pressable> <SectionHeader>Needs attention</SectionHeader>
<Card>
{overdue.length > 0 && (
<Alert
tone={C.danger}
text={`${overdue.length} overdue`}
onPress={() => router.push("/today")}
/>
)}
{reviewDue && (
<Alert
tone={C.warn}
text={
lastReview === null
? "No weekly review yet"
: `Weekly review is ${reviewDays} days overdue`
}
onPress={() => router.push("/review")}
/>
)}
{store.offline && (
<Alert
tone={C.textSecondary}
text={
store.pending > 0
? `Offline — ${store.pending} change${store.pending === 1 ? "" : "s"} queued`
: "Offline"
}
/>
)}
{!!store.syncError && <Alert tone={C.danger} text={store.syncError} />}
</Card>
</>
)} )}
{reviewDue && ( </ScrollView>
<Pressable onPress={() => router.push("/review")}>
<Text style={s.reviewNudge}> <View style={s.tabBar}>
{lastReview === null ? "no review yet — start one →" : `review overdue (${reviewDays}d) →`}
</Text>
</Pressable>
)}
{store.offline && (
<Text style={s.offline}>
offline{store.pending > 0 ? ` — ${store.pending} change${store.pending === 1 ? "" : "s"} queued` : ""}
</Text>
)}
{store.syncError && <Text style={s.error}>{store.syncError}</Text>}
</View>
<View style={s.menu}>
{MENU.map((m) => ( {MENU.map((m) => (
<Pressable key={m.label} style={s.menuItem} onPress={() => router.push(m.href)}> <Pressable
<Text style={s.menuIcon}>{m.icon}</Text> key={m.label}
<Text style={s.menuLabel}>{m.label}</Text> style={({ pressed }) => [s.tab, pressed && { opacity: 0.5 }]}
onPress={() => {
haptic.tap();
router.push(m.href);
}}
>
<Icon name={m.icon} size={21} color={C.textSecondary} bg={C.surface} />
<Text style={s.tabLabel}>{m.label}</Text>
</Pressable> </Pressable>
))} ))}
</View> </View>
<Pressable <Pressable
style={s.fab} style={({ pressed }) => [s.fab, pressed && { transform: [{ scale: 0.94 }] }]}
onPress={() => { onPress={() => {
haptic.tap(); haptic.tap();
router.push("/add"); router.push("/add");
}} }}
> >
<Text style={s.fabText}>+</Text> <Icon name="plus" size={26} color={C.onAccent} />
</Pressable> </Pressable>
</View> </Screen>
); );
} }
const s = StyleSheet.create({ const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: C.bg }, content: { paddingHorizontal: space.lg, paddingBottom: 120 },
sectionTitle: {
color: C.muted, viewAll: {
fontSize: 13,
textTransform: "uppercase",
letterSpacing: 1,
paddingHorizontal: 16,
paddingTop: 18,
paddingBottom: 6,
},
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
viewAll: { color: C.muted, fontSize: 14, padding: 16 },
overdue: { color: C.danger, fontSize: 14, paddingHorizontal: 16, paddingBottom: 8 },
reviewNudge: { color: C.warn, fontSize: 14, paddingHorizontal: 16, paddingBottom: 8 },
error: { color: C.danger, paddingHorizontal: 16, paddingTop: 8 },
offline: { color: C.muted, fontSize: 13, paddingHorizontal: 16, paddingTop: 8 },
menu: {
flexDirection: "row", flexDirection: "row",
alignItems: "center",
gap: space.xs,
paddingVertical: space.md,
paddingHorizontal: space.xs,
},
viewAllText: { ...type.body, color: C.accent, fontWeight: "500" },
alert: {
flexDirection: "row",
alignItems: "center",
gap: space.md,
paddingVertical: space.md + 1,
paddingHorizontal: space.lg,
},
dot: { width: 7, height: 7, borderRadius: 999 },
alertText: { ...type.body, flex: 1, fontWeight: "500" },
tabBar: {
flexDirection: "row",
backgroundColor: C.surface,
borderTopWidth: StyleSheet.hairlineWidth, borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: C.border, borderTopColor: C.border,
paddingTop: 10, paddingTop: space.md,
paddingBottom: 18, paddingBottom: space.xxl,
backgroundColor: C.bg,
}, },
menuItem: { flex: 1, alignItems: "center", gap: 3 }, tab: { flex: 1, alignItems: "center", gap: 5 },
menuIcon: { fontSize: 20 }, tabLabel: { fontSize: 11, color: C.textSecondary, letterSpacing: 0.1 },
menuLabel: { color: C.muted, fontSize: 11 },
fab: { fab: {
position: "absolute", position: "absolute",
right: 20, right: space.xl,
bottom: 92, bottom: 104,
width: 58, width: 56,
height: 58, height: 56,
borderRadius: 29, borderRadius: 28,
backgroundColor: C.accent, backgroundColor: C.accent,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
elevation: 6, ...shadow.fab,
shadowColor: "#000",
shadowOpacity: 0.35,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
}, },
fabText: { color: "#FFFFFF", fontSize: 28, lineHeight: 32, fontWeight: "600" },
}); });

View File

@@ -3,7 +3,8 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native"; import { Pressable, StyleSheet, Text, View } from "react-native";
import { haptic } from "../lib/haptics"; import { haptic } from "../lib/haptics";
import { C } from "../lib/theme"; import { C, radius, space, type } from "../lib/theme";
import { SectionHeader } from "./ui";
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
const WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"]; const WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"];
@@ -97,8 +98,8 @@ function Calendar({ value, onPick }: { value: string | null; onPick: (v: string)
<Text <Text
style={[ style={[
s.dayText, s.dayText,
!inMonth && { color: C.border }, !inMonth && { color: C.muted, opacity: 0.45 },
selected && { color: "#FFFFFF", fontWeight: "700" }, selected && { color: C.onAccent, fontWeight: "700" },
!selected && isToday && { color: C.accent }, !selected && isToday && { color: C.accent },
]} ]}
> >
@@ -133,9 +134,9 @@ export default function DateField({
]; ];
return ( return (
<View style={{ marginBottom: 14 }}> <View style={{ marginBottom: space.lg }}>
<View style={s.labelRow}> <View style={s.labelRow}>
<Text style={s.label}>{label}</Text> <SectionHeader>{label}</SectionHeader>
{value != null && <Text style={s.chosen}>{pretty(value)}</Text>} {value != null && <Text style={s.chosen}>{pretty(value)}</Text>}
</View> </View>
<View style={s.rowWrap}> <View style={s.rowWrap}>
@@ -148,7 +149,7 @@ export default function DateField({
onChange(value === q.v && clearable ? null : q.v); onChange(value === q.v && clearable ? null : q.v);
}} }}
> >
<Text style={[s.chipText, value === q.v && { color: C.text }]}>{q.label}</Text> <Text style={[s.chipText, value === q.v && s.chipTextActive]}>{q.label}</Text>
</Pressable> </Pressable>
))} ))}
<Pressable <Pressable
@@ -158,7 +159,7 @@ export default function DateField({
setOpen(!open); setOpen(!open);
}} }}
> >
<Text style={[s.chipText, open && { color: C.text }]}>📆 Pick…</Text> <Text style={[s.chipText, open && s.chipTextActive]}>Pick a date…</Text>
</Pressable> </Pressable>
{clearable && value != null && ( {clearable && value != null && (
<Pressable <Pressable
@@ -186,34 +187,34 @@ export default function DateField({
} }
const s = StyleSheet.create({ const s = StyleSheet.create({
labelRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "baseline", marginBottom: 6 }, labelRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
label: { color: C.muted, fontSize: 13, textTransform: "uppercase", letterSpacing: 0.5 }, chosen: { ...type.chip, color: C.accent, fontWeight: "600" },
chosen: { color: C.accent, fontSize: 14, fontWeight: "600" }, rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
chip: { chip: {
paddingVertical: 8, paddingVertical: 9,
paddingHorizontal: 14, paddingHorizontal: 15,
borderRadius: 20, borderRadius: radius.pill,
backgroundColor: C.surface, backgroundColor: C.surface,
borderWidth: 1, borderWidth: 1,
borderColor: C.border, borderColor: C.border,
}, },
chipActive: { backgroundColor: C.surface2, borderColor: C.accent }, chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent },
chipText: { color: C.muted, fontSize: 14 }, chipText: { ...type.chip, color: C.textSecondary },
chipTextActive: { color: C.accent, fontWeight: "600" },
calendar: { calendar: {
marginTop: 10, marginTop: space.md,
backgroundColor: C.surface, backgroundColor: C.surface,
borderRadius: 14, borderRadius: radius.lg,
borderWidth: 1, borderWidth: 1,
borderColor: C.border, borderColor: C.border,
padding: 10, padding: space.md,
}, },
calHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }, calHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: space.sm },
calTitle: { color: C.text, fontSize: 15, fontWeight: "600" }, calTitle: { color: C.text, fontSize: 16, fontWeight: "600", letterSpacing: -0.2 },
arrow: { paddingHorizontal: 14, paddingVertical: 4 }, arrow: { paddingHorizontal: 14, paddingVertical: 4 },
arrowText: { color: C.accent, fontSize: 22, fontWeight: "600" }, arrowText: { color: C.accent, fontSize: 24, fontWeight: "500" },
week: { flexDirection: "row" }, week: { flexDirection: "row" },
weekday: { flex: 1, textAlign: "center", color: C.muted, fontSize: 11, paddingVertical: 4 }, weekday: { flex: 1, textAlign: "center", color: C.muted, fontSize: 11, fontWeight: "600", letterSpacing: 0.4, paddingVertical: 6 },
day: { day: {
flex: 1, flex: 1,
aspectRatio: 1.15, aspectRatio: 1.15,
@@ -223,6 +224,6 @@ const s = StyleSheet.create({
margin: 1, margin: 1,
}, },
daySelected: { backgroundColor: C.accent }, daySelected: { backgroundColor: C.accent },
dayToday: { borderWidth: 1, borderColor: C.accent }, dayToday: { backgroundColor: C.accentSoft },
dayText: { color: C.text, fontSize: 14 }, dayText: { color: C.text, fontSize: 15 },
}); });

View File

@@ -4,7 +4,8 @@
import { Pressable, StyleSheet, Text, View } from "react-native"; import { Pressable, StyleSheet, Text, View } from "react-native";
import { haptic } from "../lib/haptics"; import { haptic } from "../lib/haptics";
import { formatEstimate } from "../lib/priority"; import { formatEstimate } from "../lib/priority";
import { C } from "../lib/theme"; import { C, radius, space, type } from "../lib/theme";
import { SectionHeader } from "./ui";
const PRESETS = [15, 30, 45, 60, 90, 120]; const PRESETS = [15, 30, 45, 60, 90, 120];
@@ -17,21 +18,25 @@ export default function EstimateChips({
}) { }) {
const options = value && !PRESETS.includes(value) ? [...PRESETS, value].sort((a, b) => a - b) : PRESETS; const options = value && !PRESETS.includes(value) ? [...PRESETS, value].sort((a, b) => a - b) : PRESETS;
return ( return (
<View style={{ marginBottom: 14 }}> <View style={s.wrap}>
<Text style={s.label}>Estimated time</Text> <SectionHeader>Estimated time</SectionHeader>
<View style={s.rowWrap}> <View style={s.rowWrap}>
{options.map((m) => { {options.map((m) => {
const active = value === m; const active = value === m;
return ( return (
<Pressable <Pressable
key={m} key={m}
style={[s.chip, active && s.chipActive]} style={({ pressed }) => [
s.chip,
active && s.chipActive,
pressed && !active && { backgroundColor: C.surface2 },
]}
onPress={() => { onPress={() => {
haptic.select(); haptic.select();
onChange(active ? null : m); onChange(active ? null : m);
}} }}
> >
<Text style={[s.chipText, active && { color: C.text }]}>{formatEstimate(m)}</Text> <Text style={[s.chipText, active && s.chipTextActive]}>{formatEstimate(m)}</Text>
</Pressable> </Pressable>
); );
})} })}
@@ -41,16 +46,17 @@ export default function EstimateChips({
} }
const s = StyleSheet.create({ const s = StyleSheet.create({
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }, wrap: { marginBottom: space.lg },
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
chip: { chip: {
paddingVertical: 8, paddingVertical: 9,
paddingHorizontal: 14, paddingHorizontal: 15,
borderRadius: 20, borderRadius: radius.pill,
backgroundColor: C.surface, backgroundColor: C.surface,
borderWidth: 1, borderWidth: 1,
borderColor: C.border, borderColor: C.border,
}, },
chipActive: { backgroundColor: C.surface2, borderColor: C.accent }, chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent },
chipText: { color: C.muted, fontSize: 14 }, chipText: { ...type.chip, color: C.textSecondary },
chipTextActive: { color: C.accent, fontWeight: "600" },
}); });

260
app/src/components/Icon.tsx Normal file
View File

@@ -0,0 +1,260 @@
// A tiny geometric icon set drawn from plain Views.
//
// The app ships no vector-icon dependency and emoji render differently on
// every platform (and can't take the accent colour), so these are built from
// borders and rectangles instead: monochrome, tintable, pixel-identical on
// Android and web, and zero bytes of new dependency.
import { StyleSheet, View } from "react-native";
import { C } from "../lib/theme";
export type IconName =
| "today"
| "next"
| "waiting"
| "someday"
| "projects"
| "review"
| "settings"
| "plus"
| "chevron";
type Props = {
name: IconName;
size?: number;
color?: string;
/** Used to carve the crescent out of the "someday" moon. */
bg?: string;
};
export default function Icon({ name, size = 22, color = C.textSecondary, bg = C.bg }: Props) {
const box = { width: size, height: size };
const w = 1.7; // one consistent stroke weight across the whole set
switch (name) {
case "today":
return (
<View style={[box, s.center]}>
<View
style={{
width: size * 0.86,
height: size * 0.82,
borderWidth: w,
borderColor: color,
borderRadius: 5,
overflow: "hidden",
}}
>
<View style={{ height: size * 0.2, backgroundColor: color }} />
<View style={[s.center, { flex: 1 }]}>
<View
style={{ width: w * 2, height: w * 2, borderRadius: 999, backgroundColor: color }}
/>
</View>
</View>
</View>
);
case "next":
return (
<View style={[box, s.center]}>
<View
style={{
width: 0,
height: 0,
borderTopWidth: size * 0.32,
borderBottomWidth: size * 0.32,
borderLeftWidth: size * 0.52,
borderTopColor: "transparent",
borderBottomColor: "transparent",
borderLeftColor: color,
marginLeft: size * 0.12,
}}
/>
</View>
);
case "waiting":
return (
<View style={[box, s.center]}>
<View
style={{
width: size * 0.84,
height: size * 0.84,
borderRadius: 999,
borderWidth: w,
borderColor: color,
}}
>
{/* hour hand, then minute hand — anchored at the dial centre */}
<View
style={{
position: "absolute",
left: size * 0.42 - w / 2,
top: size * 0.2,
width: w,
height: size * 0.22,
backgroundColor: color,
borderRadius: w,
}}
/>
<View
style={{
position: "absolute",
left: size * 0.42 - w / 2,
top: size * 0.42 - w / 2,
width: size * 0.24,
height: w,
backgroundColor: color,
borderRadius: w,
}}
/>
</View>
</View>
);
case "someday":
return (
<View style={[box, s.center]}>
<View
style={{
width: size * 0.84,
height: size * 0.84,
borderRadius: 999,
backgroundColor: color,
overflow: "hidden",
}}
>
<View
style={{
position: "absolute",
right: -size * 0.22,
top: -size * 0.26,
width: size * 0.84,
height: size * 0.84,
borderRadius: 999,
backgroundColor: bg,
}}
/>
</View>
</View>
);
case "projects":
return (
<View style={[box, s.center]}>
<View style={{ width: size * 0.86, height: size * 0.78 }}>
<View
style={{
width: size * 0.4,
height: size * 0.16,
borderTopLeftRadius: 3,
borderTopRightRadius: 3,
backgroundColor: color,
}}
/>
<View
style={{
flex: 1,
borderWidth: w,
borderColor: color,
borderRadius: 5,
borderTopLeftRadius: 0,
}}
/>
</View>
</View>
);
case "review":
return (
<View style={[box, s.center]}>
{/* an open ring plus an arrowhead reads as "cycle" without an SVG path */}
<View
style={{
width: size * 0.82,
height: size * 0.82,
borderRadius: 999,
borderWidth: w,
borderColor: color,
borderTopColor: "transparent",
transform: [{ rotate: "45deg" }],
}}
/>
<View
style={{
position: "absolute",
top: size * 0.06,
right: size * 0.12,
width: 0,
height: 0,
borderLeftWidth: size * 0.16,
borderRightWidth: size * 0.16,
borderBottomWidth: size * 0.2,
borderLeftColor: "transparent",
borderRightColor: "transparent",
borderBottomColor: color,
transform: [{ rotate: "115deg" }],
}}
/>
</View>
);
case "settings":
// Sliders, not a cog: three strokes with offset knobs stays legible at 20px.
return (
<View style={[box, s.center]}>
<View style={{ width: size * 0.88, gap: size * 0.16 }}>
{[0.62, 0.3, 0.48].map((knob, i) => (
<View key={i} style={s.sliderRow}>
<View style={{ flex: 1, height: w, backgroundColor: color, borderRadius: w }} />
<View
style={{
position: "absolute",
left: `${knob * 100}%`,
width: w * 3,
height: w * 3,
marginLeft: -w * 1.5,
marginTop: -w,
borderRadius: 999,
borderWidth: w,
borderColor: color,
backgroundColor: bg,
}}
/>
</View>
))}
</View>
</View>
);
case "plus":
return (
<View style={[box, s.center]}>
<View style={{ position: "absolute", width: size * 0.7, height: w + 0.5, borderRadius: 2, backgroundColor: color }} />
<View style={{ position: "absolute", width: w + 0.5, height: size * 0.7, borderRadius: 2, backgroundColor: color }} />
</View>
);
case "chevron":
return (
<View style={[box, s.center]}>
<View
style={{
width: size * 0.34,
height: size * 0.34,
borderRightWidth: w,
borderTopWidth: w,
borderColor: color,
transform: [{ rotate: "45deg" }],
marginLeft: -size * 0.06,
}}
/>
</View>
);
}
}
const s = StyleSheet.create({
center: { alignItems: "center", justifyContent: "center" },
sliderRow: { justifyContent: "center" },
});

View File

@@ -1,8 +1,9 @@
import { Pressable, StyleSheet, Text, View } from "react-native"; import { Pressable, StyleSheet, Text, View } from "react-native";
import { haptic } from "../lib/haptics"; import { haptic } from "../lib/haptics";
import { PRIORITIES, PRIORITY_COLOR, PRIORITY_LABEL } from "../lib/priority"; import { PRIORITIES, PRIORITY_COLOR, PRIORITY_LABEL, PRIORITY_TINT } from "../lib/priority";
import { C } from "../lib/theme"; import { C, radius, space, type } from "../lib/theme";
import type { Task } from "../lib/types"; import type { Task } from "../lib/types";
import { SectionHeader } from "./ui";
export default function PriorityChips({ export default function PriorityChips({
value, value,
@@ -12,8 +13,8 @@ export default function PriorityChips({
onChange: (p: Task["priority"]) => void; onChange: (p: Task["priority"]) => void;
}) { }) {
return ( return (
<View style={{ marginBottom: 14 }}> <View style={s.wrap}>
<Text style={s.label}>Priority</Text> <SectionHeader>Priority</SectionHeader>
<View style={s.rowWrap}> <View style={s.rowWrap}>
{PRIORITIES.map((p) => { {PRIORITIES.map((p) => {
const active = value === p; const active = value === p;
@@ -21,14 +22,20 @@ export default function PriorityChips({
return ( return (
<Pressable <Pressable
key={p} key={p}
style={[s.chip, active && { borderColor: color, backgroundColor: `${color}22` }]} style={({ pressed }) => [
s.chip,
active && { borderColor: color, backgroundColor: PRIORITY_TINT[p] },
pressed && !active && { backgroundColor: C.surface2 },
]}
onPress={() => { onPress={() => {
haptic.select(); haptic.select();
onChange(p); onChange(p);
}} }}
> >
<View style={[s.flag, { backgroundColor: color }]} /> <View style={[s.flag, { backgroundColor: color }]} />
<Text style={[s.text, active && { color: C.text }]}>{PRIORITY_LABEL[p]}</Text> <Text style={[s.text, active && { color, fontWeight: "600" }]}>
{PRIORITY_LABEL[p]}
</Text>
</Pressable> </Pressable>
); );
})} })}
@@ -38,19 +45,19 @@ export default function PriorityChips({
} }
const s = StyleSheet.create({ const s = StyleSheet.create({
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }, wrap: { marginBottom: space.lg },
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
chip: { chip: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: 7, gap: 7,
paddingVertical: 8, paddingVertical: 9,
paddingHorizontal: 13, paddingHorizontal: 14,
borderRadius: 20, borderRadius: radius.pill,
backgroundColor: C.surface, backgroundColor: C.surface,
borderWidth: 1, borderWidth: 1,
borderColor: C.border, borderColor: C.border,
}, },
flag: { width: 10, height: 10, borderRadius: 5 }, flag: { width: 9, height: 9, borderRadius: 999 },
text: { color: C.muted, fontSize: 14 }, text: { ...type.chip, color: C.textSecondary },
}); });

View File

@@ -3,10 +3,10 @@ import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native"
import DraggableFlatList, { ScaleDecorator } from "react-native-draggable-flatlist"; import DraggableFlatList, { ScaleDecorator } from "react-native-draggable-flatlist";
import { haptic } from "../lib/haptics"; import { haptic } from "../lib/haptics";
import { reorderTask, sync, tasksByStatus, visibleTasksByStatus } from "../lib/store"; import { reorderTask, sync, tasksByStatus, visibleTasksByStatus } from "../lib/store";
import { C } from "../lib/theme"; import { C, radius, shadow, space, type } from "../lib/theme";
import { useStore } from "../lib/useStore"; import { useStore } from "../lib/useStore";
import type { TaskStatus } from "../lib/types"; import type { TaskStatus } from "../lib/types";
import TaskRow from "./TaskRow"; import TaskRow, { TaskSeparator } from "./TaskRow";
// Deferred tasks are hidden from these lists until their date — you can't act // Deferred tasks are hidden from these lists until their date — you can't act
// on them yet, so they shouldn't cost attention. A one-line footer keeps them // on them yet, so they shouldn't cost attention. A one-line footer keeps them
@@ -50,13 +50,31 @@ export default function TaskListScreen({
reorderTask(data[to].id, status, to); reorderTask(data[to].id, status, to);
} }
}} }}
renderItem={({ item, drag, isActive }) => ( // The card is drawn by the rows themselves so drag-and-drop still owns
<ScaleDecorator activeScale={1.03}> // the whole list: first/last rows round their outer corners, and a
<View style={isActive && s.activeRow}> // lifted row goes full-radius so it reads as detached from the stack.
<TaskRow task={item} uncheckTo={status === "done" ? "next" : status} onLongPress={archive ? undefined : drag} /> renderItem={({ item, drag, isActive, getIndex }) => {
</View> const i = getIndex() ?? 0;
</ScaleDecorator> return (
)} <ScaleDecorator activeScale={1.02}>
<View
style={[
s.row,
i === 0 && s.first,
i === tasks.length - 1 && s.last,
isActive && s.lifted,
]}
>
<TaskRow
task={item}
uncheckTo={status === "done" ? "next" : status}
onLongPress={archive ? undefined : drag}
/>
</View>
</ScaleDecorator>
);
}}
ItemSeparatorComponent={TaskSeparator}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={refreshing} refreshing={refreshing}
@@ -71,14 +89,17 @@ export default function TaskListScreen({
ListEmptyComponent={<Text style={s.empty}>{emptyHint}</Text>} ListEmptyComponent={<Text style={s.empty}>{emptyHint}</Text>}
ListFooterComponent={ ListFooterComponent={
hidesDeferred && (hiddenCount > 0 || showDeferred) ? ( hidesDeferred && (hiddenCount > 0 || showDeferred) ? (
<Pressable onPress={() => setShowDeferred((v) => !v)}> <Pressable
<Text style={s.deferredFooter}> onPress={() => setShowDeferred((v) => !v)}
{showDeferred ? "hide deferred" : `⏸ ${hiddenCount} deferred — show`} style={({ pressed }) => [s.deferredBtn, pressed && { opacity: 0.6 }]}
>
<Text style={s.deferredText}>
{showDeferred ? "Hide deferred" : `${hiddenCount} deferred — show`}
</Text> </Text>
</Pressable> </Pressable>
) : null ) : null
} }
contentContainerStyle={{ paddingBottom: 32 }} contentContainerStyle={s.content}
/> />
</View> </View>
); );
@@ -86,7 +107,23 @@ export default function TaskListScreen({
const s = StyleSheet.create({ const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: C.bg }, screen: { flex: 1, backgroundColor: C.bg },
activeRow: { backgroundColor: C.surface2, borderRadius: 12 }, content: { paddingHorizontal: space.lg, paddingTop: space.md, paddingBottom: 96 },
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 }, row: { backgroundColor: C.surface },
deferredFooter: { color: C.muted, fontSize: 13, textAlign: "center", paddingVertical: 14 }, first: { borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg },
last: {
borderBottomLeftRadius: radius.lg,
borderBottomRightRadius: radius.lg,
...shadow.card,
},
lifted: { borderRadius: radius.lg, ...shadow.fab },
empty: {
...type.body,
color: C.muted,
textAlign: "center",
marginTop: 72,
paddingHorizontal: space.xxl,
lineHeight: 22,
},
deferredBtn: { paddingVertical: space.lg, alignItems: "center" },
deferredText: { ...type.meta, color: C.textSecondary, fontWeight: "500" },
}); });

View File

@@ -5,7 +5,7 @@ import { dueMeta, friendlyDate, isDeferred } from "../lib/dates";
import { haptic } from "../lib/haptics"; import { haptic } from "../lib/haptics";
import { formatEstimate, PRIORITY_COLOR } from "../lib/priority"; import { formatEstimate, PRIORITY_COLOR } from "../lib/priority";
import { updateTask } from "../lib/store"; import { updateTask } from "../lib/store";
import { C } from "../lib/theme"; import { C, radius, space, type } from "../lib/theme";
import type { Task, TaskStatus } from "../lib/types"; import type { Task, TaskStatus } from "../lib/types";
export default function TaskRow({ export default function TaskRow({
@@ -22,6 +22,7 @@ export default function TaskRow({
onLongPress?: () => void; onLongPress?: () => void;
}) { }) {
const router = useRouter(); const router = useRouter();
const done = task.status === "done";
// Due date is rendered separately so it can carry a tone color; the reader // Due date is rendered separately so it can carry a tone color; the reader
// sees "due today" in amber, never does date math on an ISO string. // sees "due today" in amber, never does date math on an ISO string.
const due = task.due_date ? dueMeta(task.due_date) : null; const due = task.due_date ? dueMeta(task.due_date) : null;
@@ -37,33 +38,42 @@ export default function TaskRow({
.filter(Boolean) .filter(Boolean)
.join(" · "); .join(" · ");
const meta = base || due; const meta = base || due;
const flag = PRIORITY_COLOR[task.priority];
const toggle = () => { const toggle = () => {
const done = task.status !== "done"; const next = task.status !== "done";
if (done) haptic.success(); if (next) haptic.success();
else haptic.tap(); else haptic.tap();
updateTask(task.id, { status: done ? "done" : uncheckTo }); updateTask(task.id, { status: next ? "done" : uncheckTo });
}; };
return ( return (
<Animated.View entering={FadeIn.duration(150)}> <Animated.View entering={FadeIn.duration(150)}>
<Pressable style={s.row} onPress={() => router.push(`/task/${task.id}`)} onLongPress={onLongPress} delayLongPress={200}> <Pressable
style={({ pressed }) => [s.row, pressed && s.rowPressed]}
onPress={() => router.push(`/task/${task.id}`)}
onLongPress={onLongPress}
delayLongPress={200}
>
{/* Rounded square rather than a circle — the Things checkbox shape.
The border carries the priority colour, so priority is legible at a
glance without spending a whole column on a flag. */}
<Pressable <Pressable
style={[s.checkbox, { borderColor: PRIORITY_COLOR[task.priority] }]} style={[s.checkbox, { borderColor: done ? C.done : flag }, done && s.checkboxDone]}
hitSlop={10} hitSlop={10}
onPress={toggle} onPress={toggle}
> >
{task.status === "done" && <View style={s.checkboxFill} />} {done && <Text style={s.check}>✓</Text>}
</Pressable> </Pressable>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={s.title} numberOfLines={2}> <Text style={[s.title, done && s.titleDone]} numberOfLines={2}>
{task.title} {task.title}
</Text> </Text>
{!!meta && ( {!!meta && (
<Text style={s.meta}> <Text style={s.meta} numberOfLines={1}>
{base} {base}
{!!base && !!due && " · "} {!!base && !!due && " · "}
{due && <Text style={{ color: dueColor }}>{due.label}</Text>} {due && <Text style={{ color: dueColor, fontWeight: "500" }}>{due.label}</Text>}
</Text> </Text>
)} )}
</View> </View>
@@ -76,21 +86,40 @@ const s = StyleSheet.create({
row: { row: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: 12, gap: space.md,
paddingVertical: 14, paddingVertical: space.md + 2,
paddingHorizontal: 16, paddingHorizontal: space.lg,
borderBottomWidth: StyleSheet.hairlineWidth, backgroundColor: C.surface,
borderBottomColor: C.border,
}, },
rowPressed: { backgroundColor: C.surface2 },
checkbox: { checkbox: {
width: 22, width: 22,
height: 22, height: 22,
borderRadius: 11, borderRadius: 7,
borderWidth: 2, borderWidth: 1.5,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
checkboxFill: { width: 12, height: 12, borderRadius: 6, backgroundColor: C.done }, checkboxDone: { backgroundColor: C.done },
title: { color: C.text, fontSize: 16 }, check: { color: "#FFFFFF", fontSize: 13, lineHeight: 16, fontWeight: "700" },
meta: { color: C.muted, fontSize: 13, marginTop: 2 }, title: { ...type.row, color: C.text },
titleDone: { color: C.muted, textDecorationLine: "line-through" },
meta: { ...type.meta, color: C.muted, marginTop: 3 },
}); });
/** Inset hairline sized to line up under the task title, not the checkbox. */
export const TASK_ROW_INSET = space.lg + 22 + space.md;
export function TaskSeparator() {
return (
<View
style={{
height: StyleSheet.hairlineWidth,
backgroundColor: C.separator,
marginLeft: TASK_ROW_INSET,
// Keeps the hairline from showing through a card's rounded corner.
borderRadius: radius.sm,
}}
/>
);
}

View File

@@ -1,9 +1,63 @@
import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps } from "react-native"; import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps, type ViewStyle } from "react-native";
import { C } from "../lib/theme"; import { C, radius, shadow, space, type } from "../lib/theme";
/**
* Screen background + consistent gutter. Every screen sits on the grouped
* background so white cards read as raised paper.
*/
export function Screen({ children, style }: { children: React.ReactNode; style?: ViewStyle }) {
return <View style={[u.screen, style]}>{children}</View>;
}
/**
* Large in-content heading. Nav-bar titles are tiny and centred; Things puts
* the screen name in the content at display size, which is what gives each
* screen its sense of place.
*/
export function Title({ children, count }: { children: React.ReactNode; count?: number }) {
return (
<View style={u.titleRow}>
<Text style={u.title}>{children}</Text>
{count !== undefined && count > 0 && <Text style={u.titleCount}>{count}</Text>}
</View>
);
}
/** Small caps label above a group. Sentence case — SHOUTING CAPS reads dated. */
export function SectionHeader({ children, tone }: { children: React.ReactNode; tone?: string }) {
return <Text style={[u.sectionHeader, !!tone && { color: tone }]}>{children}</Text>;
}
/**
* Rounded white group. Rows inside are separated by inset hairlines rather
* than full-bleed dividers, so the card keeps a clean silhouette.
*/
export function Card({ children, style }: { children: React.ReactNode; style?: ViewStyle }) {
return <View style={[u.card, style]}>{children}</View>;
}
/** Inset hairline for use between rows inside a `Card`. */
export function Separator({ inset = 0 }: { inset?: number }) {
return <View style={[u.separator, { marginLeft: inset }]} />;
}
/** Compact count/status pill. */
export function Pill({ label, tone }: { label: string; tone?: string }) {
return (
<View style={[u.pill, !!tone && { backgroundColor: `${tone}18` }]}>
<Text style={[u.pillText, !!tone && { color: tone }]}>{label}</Text>
</View>
);
}
/** Centred empty state. One quiet line — no illustration, no exclamation. */
export function Empty({ children }: { children: React.ReactNode }) {
return <Text style={u.empty}>{children}</Text>;
}
export function Field({ label, ...props }: TextInputProps & { label: string }) { export function Field({ label, ...props }: TextInputProps & { label: string }) {
return ( return (
<View style={{ marginBottom: 14 }}> <View style={{ marginBottom: space.lg }}>
<Text style={u.label}>{label}</Text> <Text style={u.label}>{label}</Text>
<TextInput style={u.input} placeholderTextColor={C.muted} {...props} /> <TextInput style={u.input} placeholderTextColor={C.muted} {...props} />
</View> </View>
@@ -24,18 +78,21 @@ export function Chips<T extends string>({
labels?: Record<string, string>; labels?: Record<string, string>;
}) { }) {
return ( return (
<View style={{ marginBottom: 14 }}> <View style={{ marginBottom: space.lg }}>
<Text style={u.label}>{label}</Text> <Text style={u.label}>{label}</Text>
<View style={u.chipRow}> <View style={u.chipRow}>
{options.map((o) => ( {options.map((o) => {
<Pressable const active = value === o;
key={o} return (
style={[u.chip, value === o && u.chipActive]} <Pressable
onPress={() => onChange(o)} key={o}
> style={({ pressed }) => [u.chip, active && u.chipActive, pressed && !active && u.chipPressed]}
<Text style={[u.chipText, value === o && { color: C.text }]}>{labels?.[o] ?? o}</Text> onPress={() => onChange(o)}
</Pressable> >
))} <Text style={[u.chipText, active && u.chipTextActive]}>{labels?.[o] ?? o}</Text>
</Pressable>
);
})}
</View> </View>
</View> </View>
); );
@@ -45,50 +102,108 @@ export function Button({
title, title,
onPress, onPress,
danger, danger,
secondary,
}: { }: {
title: string; title: string;
onPress: () => void; onPress: () => void;
danger?: boolean; danger?: boolean;
secondary?: boolean;
}) { }) {
const variant = danger ? u.buttonDanger : secondary ? u.buttonSecondary : null;
const textVariant = danger ? u.buttonTextDanger : secondary ? u.buttonTextSecondary : null;
return ( return (
<Pressable <Pressable
style={[u.button, danger && { backgroundColor: "transparent", borderColor: C.danger, borderWidth: 1 }]} style={({ pressed }) => [u.button, variant, pressed && { opacity: 0.75 }]}
onPress={onPress} onPress={onPress}
> >
<Text style={[u.buttonText, danger && { color: C.danger }]}>{title}</Text> <Text style={[u.buttonText, textVariant]}>{title}</Text>
</Pressable> </Pressable>
); );
} }
const u = StyleSheet.create({ const u = StyleSheet.create({
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }, screen: { flex: 1, backgroundColor: C.bg },
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: space.md,
paddingHorizontal: space.lg,
paddingTop: space.md,
paddingBottom: space.lg,
},
title: { ...type.title, color: C.text },
titleCount: { ...type.title, color: C.muted, fontWeight: "600" },
sectionHeader: {
...type.section,
color: C.textSecondary,
paddingHorizontal: space.xs,
paddingTop: space.xl,
paddingBottom: space.sm,
},
card: {
backgroundColor: C.surface,
borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
overflow: "hidden",
...shadow.card,
},
separator: { height: StyleSheet.hairlineWidth, backgroundColor: C.separator },
pill: {
paddingHorizontal: space.sm,
paddingVertical: 3,
borderRadius: radius.pill,
backgroundColor: C.surface2,
},
pillText: { ...type.meta, color: C.textSecondary, fontWeight: "500" },
empty: { color: C.muted, textAlign: "center", marginTop: 56, ...type.body },
label: {
...type.section,
color: C.textSecondary,
marginBottom: space.sm,
},
input: { input: {
backgroundColor: C.surface, backgroundColor: C.surface,
color: C.text, color: C.text,
borderRadius: 10, borderRadius: radius.md,
borderWidth: 1, borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border, borderColor: C.border,
padding: 12, paddingHorizontal: space.md,
paddingVertical: space.md,
fontSize: 16, fontSize: 16,
}, },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
chip: { chip: {
paddingVertical: 8, paddingVertical: space.sm,
paddingHorizontal: 14, paddingHorizontal: space.lg - 2,
borderRadius: 20, borderRadius: radius.pill,
backgroundColor: C.surface, backgroundColor: C.surface,
borderWidth: 1, borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border, borderColor: C.border,
}, },
chipActive: { backgroundColor: C.surface2, borderColor: C.accent }, chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent },
chipText: { color: C.muted, fontSize: 14 }, chipPressed: { backgroundColor: C.surface2 },
chipText: { ...type.chip, color: C.textSecondary },
chipTextActive: { color: C.accent, fontWeight: "600" },
button: { button: {
backgroundColor: C.accent, backgroundColor: C.accent,
borderRadius: 10, borderRadius: radius.md,
padding: 14, paddingVertical: space.lg - 2,
alignItems: "center", alignItems: "center",
marginTop: 8, marginTop: space.sm,
marginBottom: 12, marginBottom: space.md,
}, },
buttonText: { color: C.text, fontSize: 16, fontWeight: "600" }, buttonDanger: { backgroundColor: "transparent", borderWidth: StyleSheet.hairlineWidth, borderColor: C.danger },
buttonSecondary: { backgroundColor: C.surface, borderWidth: StyleSheet.hairlineWidth, borderColor: C.border },
buttonText: { fontSize: 16, fontWeight: "600", color: C.onAccent },
buttonTextDanger: { color: C.danger },
buttonTextSecondary: { color: C.text },
}); });

View File

@@ -9,11 +9,21 @@ export const PRIORITY_LABEL: Record<Task["priority"], string> = {
4: "Low", 4: "Low",
}; };
// Todoist's flag mapping (urgent red → low grey), retuned to sit calmly on a
// light canvas. `PRIORITY_TINT` is the same hue at wash strength, for the
// selected state of a chip — a saturated fill would shout on a white card.
export const PRIORITY_COLOR: Record<Task["priority"], `#${string}`> = { export const PRIORITY_COLOR: Record<Task["priority"], `#${string}`> = {
1: "#E05B5B", 1: "#E5484D",
2: "#E0A93E", 2: "#D98324",
3: "#5B8DEF", 3: "#2F6FED",
4: "#6B7280", 4: "#9A9AA2",
};
export const PRIORITY_TINT: Record<Task["priority"], `#${string}`> = {
1: "#FDECEC",
2: "#FBF0E2",
3: "#E8F0FE",
4: "#F0F0F3",
}; };
export function formatEstimate(min: number | null): string | null { export function formatEstimate(min: number | null): string | null {

View File

@@ -1,12 +1,115 @@
export const C = { // Design tokens.
bg: "#0F1115", //
surface: "#1A1D24", // The look is modelled on Things 3: a light, calm canvas, generous whitespace,
surface2: "#232733", // hairline inset separators instead of boxes, and exactly one accent colour
border: "#2C313D", // used sparingly. Everything visual in the app resolves through this file —
text: "#E6E8EC", // no component hard-codes a hex value (priority flags are the one exception,
muted: "#8A909C", // and they live in `priority.ts` alongside their labels).
accent: "#5B8DEF",
done: "#4CAF7D", /** Named palette. `dark` is complete and ready to wire to a theme toggle. */
danger: "#E05B5B", const light = {
warn: "#E0A93E", /** Screen background behind grouped content. */
bg: "#F7F7F9",
/** Card / row surface that sits on `bg`. */
surface: "#FFFFFF",
/** Inputs, pressed states, subtle fills. */
surface2: "#EFEFF3",
/** Card outline. Deliberately barely-there. */
border: "#E6E6EB",
/** Inset hairline between rows inside a card. */
separator: "#ECECF0",
/** Primary reading colour. Near-black, never pure #000. */
text: "#16161A",
/** Supporting copy that should still be comfortably readable. */
textSecondary: "#6E6E76",
/** Metadata, placeholders, de-emphasised chrome. */
muted: "#9A9AA2",
accent: "#2F6FED",
/** Tinted accent wash for selected chips and active states. */
accentSoft: "#E8F0FE",
done: "#2FA36B",
danger: "#E5484D",
warn: "#D98324",
/** Text that sits on top of `accent`. */
onAccent: "#FFFFFF",
} as const;
const dark = {
bg: "#0C0C0F",
surface: "#17171B",
surface2: "#212127",
border: "#2A2A31",
separator: "#26262C",
text: "#F2F2F5",
textSecondary: "#A6A6B0",
muted: "#7A7A85",
accent: "#4C86F0",
accentSoft: "#1B2740",
done: "#3FBF84",
danger: "#F0605F",
warn: "#E0A93E",
onAccent: "#FFFFFF",
} as const;
export const PALETTES = { light, dark } as const;
/**
* Active palette. Styles are created at module scope (StyleSheet.create) so
* this is a static choice rather than a hook — switching themes at runtime
* would mean moving every stylesheet inside its component.
*/
export const C = light;
/** 4pt spacing grid. Screen gutter is `space.lg`. */
export const space = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
xxl: 28,
} as const;
export const radius = {
sm: 8,
md: 12,
/** Cards and grouped lists. */
lg: 16,
/** Chips and pills. */
pill: 999,
} as const;
/**
* Type scale. `title` is the large in-content screen heading that replaces a
* cramped nav-bar title; `row` is the task title size — 17pt, matching the
* platform body size Things and Todoist both settle on.
*/
export const type = {
title: { fontSize: 30, fontWeight: "700", letterSpacing: -0.6 },
section: { fontSize: 13, fontWeight: "600", letterSpacing: 0.3 },
row: { fontSize: 17, letterSpacing: -0.2 },
body: { fontSize: 15 },
meta: { fontSize: 13 },
chip: { fontSize: 14, fontWeight: "500" },
} as const;
/**
* Card elevation. Kept extremely soft — a Things-like surface reads as paper,
* not as a floating panel.
*/
export const shadow = {
card: {
shadowColor: "#0B0B12",
shadowOpacity: 0.05,
shadowRadius: 10,
shadowOffset: { width: 0, height: 2 },
elevation: 1,
},
fab: {
shadowColor: "#0B0B12",
shadowOpacity: 0.22,
shadowRadius: 14,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
},
} as const; } as const;