diff --git a/app/src/app/_layout.tsx b/app/src/app/_layout.tsx index 3efb680..e1b51dc 100644 --- a/app/src/app/_layout.tsx +++ b/app/src/app/_layout.tsx @@ -2,10 +2,11 @@ import NetInfo from "@react-native-community/netinfo"; import { Link, Stack } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { useEffect } from "react"; -import { AppState } from "react-native"; +import { AppState, Pressable } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; 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"; export default function RootLayout() { @@ -35,12 +36,16 @@ export default function RootLayout() { return ( - + @@ -49,8 +54,13 @@ export default function RootLayout() { options={{ title: "GTD", headerRight: () => ( - - βš™οΈ + + [{ paddingHorizontal: space.sm }, pressed && { opacity: 0.5 }]} + > + + ), }} diff --git a/app/src/app/index.tsx b/app/src/app/index.tsx index 6745f67..0a6d81a 100644 --- a/app/src/app/index.tsx +++ b/app/src/app/index.tsx @@ -1,20 +1,45 @@ import { useFocusEffect, useRouter } from "expo-router"; -import { Pressable, StyleSheet, Text, View } from "react-native"; -import TaskRow from "../components/TaskRow"; +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +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 { daysSince, useLastReview } from "../lib/review"; import { todayTasks, visibleTasksByStatus } from "../lib/store"; -import { C } from "../lib/theme"; +import { C, shadow, space, type } from "../lib/theme"; import { useStore } from "../lib/useStore"; -const MENU = [ - { icon: "πŸ“…", label: "Today", href: "/today" }, - { icon: "▢️", label: "Next", href: "/list/next" }, - { icon: "⏳", label: "Waiting", href: "/list/waiting" }, - { icon: "πŸ’­", label: "Someday", href: "/list/someday" }, - { icon: "πŸ“", label: "Projects", href: "/projects" }, - { icon: "πŸ”„", label: "Review", href: "/review" }, -] as const; +const MENU: { icon: IconName; label: string; href: string }[] = [ + { icon: "today", label: "Today", href: "/today" }, + { icon: "next", label: "Next", href: "/list/next" }, + { icon: "waiting", label: "Waiting", href: "/list/waiting" }, + { icon: "someday", label: "Someday", href: "/list/someday" }, + { icon: "projects", label: "Projects", href: "/projects" }, + { icon: "review", label: "Review", href: "/review" }, +]; + +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 = ( + + + {text} + {!!onPress && } + + ); + if (!onPress) return body; + return ( + pressed && { opacity: 0.6 }}> + {body} + + ); +} export default function Home() { const router = useRouter(); @@ -25,103 +50,146 @@ export default function Home() { useFocusEffect(reloadReview); const reviewDays = daysSince(lastReview); const reviewDue = lastReview === null || (reviewDays !== null && reviewDays >= 7); + const preview = next.slice(0, PREVIEW); return ( - - - Next up - {next.slice(0, 3).map((t) => ( - - ))} - {next.length === 0 && Nothing next. Tap οΌ‹ to add.} - {next.length > 3 && ( - router.push("/list/next")}> - all next actions ({next.length}) β†’ + + + Next up + + {preview.length === 0 ? ( + Nothing queued. Add the next thing on your mind. + ) : ( + + {preview.map((t, i) => ( + + {i > 0 && } + + + ))} + + )} + + {next.length > PREVIEW && ( + router.push("/list/next")} + style={({ pressed }) => [s.viewAll, pressed && { opacity: 0.6 }]} + > + All {next.length} next actions + )} - {overdue.length > 0 && ( - router.push("/today")}> - {overdue.length} overdue β†’ - + + {(overdue.length > 0 || reviewDue || store.offline || !!store.syncError) && ( + <> + Needs attention + + {overdue.length > 0 && ( + router.push("/today")} + /> + )} + {reviewDue && ( + router.push("/review")} + /> + )} + {store.offline && ( + 0 + ? `Offline β€” ${store.pending} change${store.pending === 1 ? "" : "s"} queued` + : "Offline" + } + /> + )} + {!!store.syncError && } + + )} - {reviewDue && ( - router.push("/review")}> - - {lastReview === null ? "no review yet β€” start one β†’" : `review overdue (${reviewDays}d) β†’`} - - - )} - {store.offline && ( - - offline{store.pending > 0 ? ` β€” ${store.pending} change${store.pending === 1 ? "" : "s"} queued` : ""} - - )} - {store.syncError && {store.syncError}} - - + + + {MENU.map((m) => ( - router.push(m.href)}> - {m.icon} - {m.label} + [s.tab, pressed && { opacity: 0.5 }]} + onPress={() => { + haptic.tap(); + router.push(m.href); + }} + > + + {m.label} ))} + [s.fab, pressed && { transform: [{ scale: 0.94 }] }]} onPress={() => { haptic.tap(); router.push("/add"); }} > - οΌ‹ + - + ); } const s = StyleSheet.create({ - screen: { flex: 1, backgroundColor: C.bg }, - sectionTitle: { - color: C.muted, - 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: { + content: { paddingHorizontal: space.lg, paddingBottom: 120 }, + + viewAll: { 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, borderTopColor: C.border, - paddingTop: 10, - paddingBottom: 18, - backgroundColor: C.bg, + paddingTop: space.md, + paddingBottom: space.xxl, }, - menuItem: { flex: 1, alignItems: "center", gap: 3 }, - menuIcon: { fontSize: 20 }, - menuLabel: { color: C.muted, fontSize: 11 }, + tab: { flex: 1, alignItems: "center", gap: 5 }, + tabLabel: { fontSize: 11, color: C.textSecondary, letterSpacing: 0.1 }, + fab: { position: "absolute", - right: 20, - bottom: 92, - width: 58, - height: 58, - borderRadius: 29, + right: space.xl, + bottom: 104, + width: 56, + height: 56, + borderRadius: 28, backgroundColor: C.accent, alignItems: "center", justifyContent: "center", - elevation: 6, - shadowColor: "#000", - shadowOpacity: 0.35, - shadowRadius: 8, - shadowOffset: { width: 0, height: 4 }, + ...shadow.fab, }, - fabText: { color: "#FFFFFF", fontSize: 28, lineHeight: 32, fontWeight: "600" }, }); diff --git a/app/src/components/DateField.tsx b/app/src/components/DateField.tsx index c2ec88c..b1ba458 100644 --- a/app/src/components/DateField.tsx +++ b/app/src/components/DateField.tsx @@ -3,7 +3,8 @@ import { useMemo, useState } from "react"; import { Pressable, StyleSheet, Text, View } from "react-native"; 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 WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"]; @@ -97,8 +98,8 @@ function Calendar({ value, onPick }: { value: string | null; onPick: (v: string) @@ -133,9 +134,9 @@ export default function DateField({ ]; return ( - + - {label} + {label} {value != null && {pretty(value)}} @@ -148,7 +149,7 @@ export default function DateField({ onChange(value === q.v && clearable ? null : q.v); }} > - {q.label} + {q.label} ))} - πŸ“† Pick… + Pick a date… {clearable && value != null && ( a - b) : PRESETS; return ( - - Estimated time + + Estimated time {options.map((m) => { const active = value === m; return ( [ + s.chip, + active && s.chipActive, + pressed && !active && { backgroundColor: C.surface2 }, + ]} onPress={() => { haptic.select(); onChange(active ? null : m); }} > - {formatEstimate(m)} + {formatEstimate(m)} ); })} @@ -41,16 +46,17 @@ export default function EstimateChips({ } const s = StyleSheet.create({ - label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }, - rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, + wrap: { marginBottom: space.lg }, + rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: space.sm }, chip: { - paddingVertical: 8, - paddingHorizontal: 14, - borderRadius: 20, + paddingVertical: 9, + paddingHorizontal: 15, + borderRadius: radius.pill, backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, }, - chipActive: { backgroundColor: C.surface2, borderColor: C.accent }, - chipText: { color: C.muted, fontSize: 14 }, + chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent }, + chipText: { ...type.chip, color: C.textSecondary }, + chipTextActive: { color: C.accent, fontWeight: "600" }, }); diff --git a/app/src/components/Icon.tsx b/app/src/components/Icon.tsx new file mode 100644 index 0000000..bfcf533 --- /dev/null +++ b/app/src/components/Icon.tsx @@ -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 ( + + + + + + + + + ); + + case "next": + return ( + + + + ); + + case "waiting": + return ( + + + {/* hour hand, then minute hand β€” anchored at the dial centre */} + + + + + ); + + case "someday": + return ( + + + + + + ); + + case "projects": + return ( + + + + + + + ); + + case "review": + return ( + + {/* an open ring plus an arrowhead reads as "cycle" without an SVG path */} + + + + ); + + case "settings": + // Sliders, not a cog: three strokes with offset knobs stays legible at 20px. + return ( + + + {[0.62, 0.3, 0.48].map((knob, i) => ( + + + + + ))} + + + ); + + case "plus": + return ( + + + + + ); + + case "chevron": + return ( + + + + ); + } +} + +const s = StyleSheet.create({ + center: { alignItems: "center", justifyContent: "center" }, + sliderRow: { justifyContent: "center" }, +}); diff --git a/app/src/components/PriorityChips.tsx b/app/src/components/PriorityChips.tsx index 69fd002..a57c7e2 100644 --- a/app/src/components/PriorityChips.tsx +++ b/app/src/components/PriorityChips.tsx @@ -1,8 +1,9 @@ import { Pressable, StyleSheet, Text, View } from "react-native"; import { haptic } from "../lib/haptics"; -import { PRIORITIES, PRIORITY_COLOR, PRIORITY_LABEL } from "../lib/priority"; -import { C } from "../lib/theme"; +import { PRIORITIES, PRIORITY_COLOR, PRIORITY_LABEL, PRIORITY_TINT } from "../lib/priority"; +import { C, radius, space, type } from "../lib/theme"; import type { Task } from "../lib/types"; +import { SectionHeader } from "./ui"; export default function PriorityChips({ value, @@ -12,8 +13,8 @@ export default function PriorityChips({ onChange: (p: Task["priority"]) => void; }) { return ( - - Priority + + Priority {PRIORITIES.map((p) => { const active = value === p; @@ -21,14 +22,20 @@ export default function PriorityChips({ return ( [ + s.chip, + active && { borderColor: color, backgroundColor: PRIORITY_TINT[p] }, + pressed && !active && { backgroundColor: C.surface2 }, + ]} onPress={() => { haptic.select(); onChange(p); }} > - {PRIORITY_LABEL[p]} + + {PRIORITY_LABEL[p]} + ); })} @@ -38,19 +45,19 @@ export default function PriorityChips({ } const s = StyleSheet.create({ - label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }, - rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, + wrap: { marginBottom: space.lg }, + rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: space.sm }, chip: { flexDirection: "row", alignItems: "center", gap: 7, - paddingVertical: 8, - paddingHorizontal: 13, - borderRadius: 20, + paddingVertical: 9, + paddingHorizontal: 14, + borderRadius: radius.pill, backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, }, - flag: { width: 10, height: 10, borderRadius: 5 }, - text: { color: C.muted, fontSize: 14 }, + flag: { width: 9, height: 9, borderRadius: 999 }, + text: { ...type.chip, color: C.textSecondary }, }); diff --git a/app/src/components/TaskListScreen.tsx b/app/src/components/TaskListScreen.tsx index 813a05b..f9ad91d 100644 --- a/app/src/components/TaskListScreen.tsx +++ b/app/src/components/TaskListScreen.tsx @@ -3,10 +3,10 @@ import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native" import DraggableFlatList, { ScaleDecorator } from "react-native-draggable-flatlist"; import { haptic } from "../lib/haptics"; 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 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 // 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); } }} - renderItem={({ item, drag, isActive }) => ( - - - - - - )} + // The card is drawn by the rows themselves so drag-and-drop still owns + // the whole list: first/last rows round their outer corners, and a + // lifted row goes full-radius so it reads as detached from the stack. + renderItem={({ item, drag, isActive, getIndex }) => { + const i = getIndex() ?? 0; + return ( + + + + + + ); + }} + ItemSeparatorComponent={TaskSeparator} refreshControl={ {emptyHint}} ListFooterComponent={ hidesDeferred && (hiddenCount > 0 || showDeferred) ? ( - setShowDeferred((v) => !v)}> - - {showDeferred ? "hide deferred" : `⏸ ${hiddenCount} deferred β€” show`} + setShowDeferred((v) => !v)} + style={({ pressed }) => [s.deferredBtn, pressed && { opacity: 0.6 }]} + > + + {showDeferred ? "Hide deferred" : `${hiddenCount} deferred β€” show`} ) : null } - contentContainerStyle={{ paddingBottom: 32 }} + contentContainerStyle={s.content} /> ); @@ -86,7 +107,23 @@ export default function TaskListScreen({ const s = StyleSheet.create({ screen: { flex: 1, backgroundColor: C.bg }, - activeRow: { backgroundColor: C.surface2, borderRadius: 12 }, - empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 }, - deferredFooter: { color: C.muted, fontSize: 13, textAlign: "center", paddingVertical: 14 }, + content: { paddingHorizontal: space.lg, paddingTop: space.md, paddingBottom: 96 }, + row: { backgroundColor: C.surface }, + 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" }, }); diff --git a/app/src/components/TaskRow.tsx b/app/src/components/TaskRow.tsx index 7850d6b..1bfc709 100644 --- a/app/src/components/TaskRow.tsx +++ b/app/src/components/TaskRow.tsx @@ -5,7 +5,7 @@ import { dueMeta, friendlyDate, isDeferred } from "../lib/dates"; import { haptic } from "../lib/haptics"; import { formatEstimate, PRIORITY_COLOR } from "../lib/priority"; 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"; export default function TaskRow({ @@ -22,6 +22,7 @@ export default function TaskRow({ onLongPress?: () => void; }) { const router = useRouter(); + const done = task.status === "done"; // 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. const due = task.due_date ? dueMeta(task.due_date) : null; @@ -37,33 +38,42 @@ export default function TaskRow({ .filter(Boolean) .join(" Β· "); const meta = base || due; + const flag = PRIORITY_COLOR[task.priority]; const toggle = () => { - const done = task.status !== "done"; - if (done) haptic.success(); + const next = task.status !== "done"; + if (next) haptic.success(); else haptic.tap(); - updateTask(task.id, { status: done ? "done" : uncheckTo }); + updateTask(task.id, { status: next ? "done" : uncheckTo }); }; return ( - router.push(`/task/${task.id}`)} onLongPress={onLongPress} delayLongPress={200}> + [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. */} - {task.status === "done" && } + {done && βœ“} - + {task.title} {!!meta && ( - + {base} {!!base && !!due && " Β· "} - {due && {due.label}} + {due && {due.label}} )} @@ -76,21 +86,40 @@ const s = StyleSheet.create({ row: { flexDirection: "row", alignItems: "center", - gap: 12, - paddingVertical: 14, - paddingHorizontal: 16, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: C.border, + gap: space.md, + paddingVertical: space.md + 2, + paddingHorizontal: space.lg, + backgroundColor: C.surface, }, + rowPressed: { backgroundColor: C.surface2 }, checkbox: { width: 22, height: 22, - borderRadius: 11, - borderWidth: 2, + borderRadius: 7, + borderWidth: 1.5, alignItems: "center", justifyContent: "center", }, - checkboxFill: { width: 12, height: 12, borderRadius: 6, backgroundColor: C.done }, - title: { color: C.text, fontSize: 16 }, - meta: { color: C.muted, fontSize: 13, marginTop: 2 }, + checkboxDone: { backgroundColor: C.done }, + check: { color: "#FFFFFF", fontSize: 13, lineHeight: 16, fontWeight: "700" }, + 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 ( + + ); +} diff --git a/app/src/components/ui.tsx b/app/src/components/ui.tsx index 4866db2..87bd74b 100644 --- a/app/src/components/ui.tsx +++ b/app/src/components/ui.tsx @@ -1,9 +1,63 @@ -import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps } from "react-native"; -import { C } from "../lib/theme"; +import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps, type ViewStyle } from "react-native"; +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 {children}; +} + +/** + * 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 ( + + {children} + {count !== undefined && count > 0 && {count}} + + ); +} + +/** Small caps label above a group. Sentence case β€” SHOUTING CAPS reads dated. */ +export function SectionHeader({ children, tone }: { children: React.ReactNode; tone?: string }) { + return {children}; +} + +/** + * 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 {children}; +} + +/** Inset hairline for use between rows inside a `Card`. */ +export function Separator({ inset = 0 }: { inset?: number }) { + return ; +} + +/** Compact count/status pill. */ +export function Pill({ label, tone }: { label: string; tone?: string }) { + return ( + + {label} + + ); +} + +/** Centred empty state. One quiet line β€” no illustration, no exclamation. */ +export function Empty({ children }: { children: React.ReactNode }) { + return {children}; +} export function Field({ label, ...props }: TextInputProps & { label: string }) { return ( - + {label} @@ -24,18 +78,21 @@ export function Chips({ labels?: Record; }) { return ( - + {label} - {options.map((o) => ( - onChange(o)} - > - {labels?.[o] ?? o} - - ))} + {options.map((o) => { + const active = value === o; + return ( + [u.chip, active && u.chipActive, pressed && !active && u.chipPressed]} + onPress={() => onChange(o)} + > + {labels?.[o] ?? o} + + ); + })} ); @@ -45,50 +102,108 @@ export function Button({ title, onPress, danger, + secondary, }: { title: string; onPress: () => void; danger?: boolean; + secondary?: boolean; }) { + const variant = danger ? u.buttonDanger : secondary ? u.buttonSecondary : null; + const textVariant = danger ? u.buttonTextDanger : secondary ? u.buttonTextSecondary : null; return ( [u.button, variant, pressed && { opacity: 0.75 }]} onPress={onPress} > - {title} + {title} ); } 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: { backgroundColor: C.surface, color: C.text, - borderRadius: 10, - borderWidth: 1, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, borderColor: C.border, - padding: 12, + paddingHorizontal: space.md, + paddingVertical: space.md, fontSize: 16, }, - chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, + + chipRow: { flexDirection: "row", flexWrap: "wrap", gap: space.sm }, chip: { - paddingVertical: 8, - paddingHorizontal: 14, - borderRadius: 20, + paddingVertical: space.sm, + paddingHorizontal: space.lg - 2, + borderRadius: radius.pill, backgroundColor: C.surface, - borderWidth: 1, + borderWidth: StyleSheet.hairlineWidth, borderColor: C.border, }, - chipActive: { backgroundColor: C.surface2, borderColor: C.accent }, - chipText: { color: C.muted, fontSize: 14 }, + chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent }, + chipPressed: { backgroundColor: C.surface2 }, + chipText: { ...type.chip, color: C.textSecondary }, + chipTextActive: { color: C.accent, fontWeight: "600" }, + button: { backgroundColor: C.accent, - borderRadius: 10, - padding: 14, + borderRadius: radius.md, + paddingVertical: space.lg - 2, alignItems: "center", - marginTop: 8, - marginBottom: 12, + marginTop: space.sm, + 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 }, }); diff --git a/app/src/lib/priority.ts b/app/src/lib/priority.ts index 47f144a..bfade3c 100644 --- a/app/src/lib/priority.ts +++ b/app/src/lib/priority.ts @@ -9,11 +9,21 @@ export const PRIORITY_LABEL: Record = { 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 = { - 1: "#E05B5B", - 2: "#E0A93E", - 3: "#5B8DEF", - 4: "#6B7280", + 1: "#E5484D", + 2: "#D98324", + 3: "#2F6FED", + 4: "#9A9AA2", +}; + +export const PRIORITY_TINT: Record = { + 1: "#FDECEC", + 2: "#FBF0E2", + 3: "#E8F0FE", + 4: "#F0F0F3", }; export function formatEstimate(min: number | null): string | null { diff --git a/app/src/lib/theme.ts b/app/src/lib/theme.ts index 087ef43..cea5cb3 100644 --- a/app/src/lib/theme.ts +++ b/app/src/lib/theme.ts @@ -1,12 +1,115 @@ -export const C = { - bg: "#0F1115", - surface: "#1A1D24", - surface2: "#232733", - border: "#2C313D", - text: "#E6E8EC", - muted: "#8A909C", - accent: "#5B8DEF", - done: "#4CAF7D", - danger: "#E05B5B", - warn: "#E0A93E", +// Design tokens. +// +// The look is modelled on Things 3: a light, calm canvas, generous whitespace, +// hairline inset separators instead of boxes, and exactly one accent colour +// used sparingly. Everything visual in the app resolves through this file β€” +// no component hard-codes a hex value (priority flags are the one exception, +// and they live in `priority.ts` alongside their labels). + +/** Named palette. `dark` is complete and ready to wire to a theme toggle. */ +const light = { + /** 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;