diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7b5192c2..fb007e00 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react"; import { diagnosticsEnabled } from "./live/diagnostics"; import { Redirect, Route, Switch } from "wouter"; import { LoginPage } from "./auth/Login"; @@ -111,9 +111,10 @@ function AppUnavailable() { } function ProductShell() { - const { screen, navMode, moreOpen, native, dark, window: win, notes, noteKey, apiError, clearApiError, contentError, hub, refresh } = useApp(); + const { screen, navMode, moreOpen, native, dark, window: win, notes, noteKey, apiError, clearApiError, contentError, hub, refresh, searchTarget } = useApp(); const noteReader = screen === "notes" && win === "note" && Boolean(notes[noteKey]); const modalOpen = Boolean(win) && !noteReader; + usePageFocus(screen, hub.id, Boolean(win) || Boolean(searchTarget)); return (
+ Skip to content
{diagnosticsEnabled() ? : null} {apiError ?
{isConnectionFailure(apiError) ? <>Connection interrupted. Your last loaded view is still available. : apiError}
: null} {contentError && hub.activityState === "ready" ?
Could not refresh this commune. Showing its last loaded activity.
: null} -
+
-
+
@@ -148,6 +150,43 @@ function ProductShell() { ); } +// Focus moves to an element that only holds it until the next step away. +function focusOnce(element: HTMLElement) { + if (!element.hasAttribute("tabindex")) { + element.tabIndex = -1; + element.addEventListener("blur", () => element.removeAttribute("tabindex"), { once: true }); + } + element.focus({ preventScroll: true }); +} + +function skipToContent(event: MouseEvent) { + const main = document.getElementById("main-content"); + if (!main) return; + event.preventDefault(); + focusOnce(main); +} + +// After moving to another page or commune, start at the new page's heading +// rather than on a control that has gone. A window, the note reader or a +// search result places focus itself; if one was opening, wait until it closes +// and only step in when focus would otherwise be lost. +function usePageFocus(screen: ScreenId, hubId: string, held: boolean) { + const shown = useRef({ screen, hubId }); + const waiting = useRef(false); + useEffect(() => { + const previous = shown.current; + shown.current = { screen, hubId }; + const moved = previous.screen !== screen || (Boolean(previous.hubId) && previous.hubId !== hubId); + if (moved) waiting.current = true; + if (!waiting.current || held) return; + waiting.current = false; + const active = document.activeElement; + if (!moved && active && active !== document.body) return; + const heading = document.querySelector(".a-header-title h1"); + if (heading) focusOnce(heading); + }, [screen, hubId, held]); +} + function ConnectionCheck() { const { hubId } = useApp(); const [running, setRunning] = useState(false); diff --git a/apps/web/src/auth/AuthLayout.tsx b/apps/web/src/auth/AuthLayout.tsx index 817423df..f6520017 100644 --- a/apps/web/src/auth/AuthLayout.tsx +++ b/apps/web/src/auth/AuthLayout.tsx @@ -5,7 +5,7 @@ export function AuthLayout({ children }: { children: ReactNode }) { const year = new Date().getFullYear(); return (
-
+
Commune Commune @@ -16,26 +16,29 @@ export function AuthLayout({ children }: { children: ReactNode }) {

Track group spending, settle balances, and keep shared money clear.

© {year} Commune

-
+
-
+ {/* Phones hide the brand panel, so its heading is repeated here for + screen readers: every page keeps one h1. */} +
Commune for people who do life, together. -
-
{children}
-
+

Effortlessly manage your shared expenses.

+ +
{children}
+
Privacy Terms -
-
+ +
© {year} Commune Privacy Terms -
+
); diff --git a/apps/web/src/auth/Login.tsx b/apps/web/src/auth/Login.tsx index 3927ba56..c8e3b97a 100644 --- a/apps/web/src/auth/Login.tsx +++ b/apps/web/src/auth/Login.tsx @@ -6,6 +6,7 @@ import { LIMITS } from "../data/limits"; import { keepSearch } from "../nav"; import { GoogleMark } from "../ui/Icon"; import { readStored, removeStored } from "../live/storage"; +import { useFormError } from "../ui/components"; export function LoginPage() { const { signInWithPassword, signInWithGoogle, signOutUnconfirmed, retrySignOut, sessionNotice } = useApp(); @@ -13,7 +14,7 @@ export function LoginPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); + const { error, setError, invalid, messageProps } = useFormError(); const [signOutRetry, setSignOutRetry] = useState<"idle" | "busy" | "failed">("idle"); async function onRetrySignOut() { @@ -25,7 +26,7 @@ export function LoginPage() { event.preventDefault(); setError(""); if (!email.includes("@") || !password) { - setError("Enter your email and password."); + setError("Enter your email and password.", email.includes("@") ? "login-password" : "login-email"); return; } setBusy(true); @@ -68,18 +69,18 @@ export function LoginPage() {
) : sessionNotice && !error ?
{sessionNotice}
: null} {error || new URLSearchParams(window.location.search).has("oauth") - ?
{error || "Google sign-in did not complete. Please try again."}
+ ?
{error || "Google sign-in did not complete. Please try again."}
: null}
- setEmail(event.target.value)} /> + setEmail(event.target.value)} {...invalid("login-email")} />
- setPassword(event.target.value)} /> + setPassword(event.target.value)} {...invalid("login-password")} />
-

Forgot your password?

+

Forgot your password?

diff --git a/apps/web/src/auth/Signup.tsx b/apps/web/src/auth/Signup.tsx index 3f2b456d..ff713a35 100644 --- a/apps/web/src/auth/Signup.tsx +++ b/apps/web/src/auth/Signup.tsx @@ -6,6 +6,7 @@ import { LIMITS } from "../data/limits"; import { keepSearch } from "../nav"; import { GoogleMark } from "../ui/Icon"; import { readStored, removeStored } from "../live/storage"; +import { useFormError } from "../ui/components"; export function SignupPage() { const { signUpWithPassword, verifyEmailCode, signInWithPassword, signInWithGoogle } = useApp(); @@ -14,15 +15,24 @@ export function SignupPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); + const { error, setError, invalid, messageProps } = useFormError(); const [verificationPending, setVerificationPending] = useState(false); const [code, setCode] = useState(""); async function onSubmit(event: FormEvent) { event.preventDefault(); setError(""); - if (!name.trim() || !email.includes("@") || password.length < 12) { - setError("Enter your name, a valid email and a password of at least 12 characters."); + // The first field to fix is named, marked and focused. + if (!name.trim()) { + setError("Enter your name.", "su-name"); + return; + } + if (!email.includes("@")) { + setError("Enter a valid email address.", "su-email"); + return; + } + if (password.length < 12) { + setError("Use at least 12 characters for your password.", "su-password"); return; } setBusy(true); @@ -77,7 +87,7 @@ export function SignupPage() {

{verificationPending ? `Enter the code sent to ${email}. If your message has a verification link, open it and then sign in.` : "Your expenses and membership will be shared securely with the people you invite."}

- {error ?
{error}
: null} + {error ?
{error}
: null} {verificationPending ?
@@ -90,15 +100,16 @@ export function SignupPage() { :
- setName(event.target.value)} /> + setName(event.target.value)} {...invalid("su-name")} />
- setEmail(event.target.value)} /> + setEmail(event.target.value)} {...invalid("su-email")} />
- setPassword(event.target.value)} /> + setPassword(event.target.value)} {...invalid("su-password", "su-password-hint")} /> +

At least 12 characters.

: null} -

Back to sign in

+

Back to sign in

; } diff --git a/apps/web/src/screens/Funds.tsx b/apps/web/src/screens/Funds.tsx index 35e4a589..0ca04bd2 100644 --- a/apps/web/src/screens/Funds.tsx +++ b/apps/web/src/screens/Funds.tsx @@ -571,13 +571,13 @@ export function FundsScreen() { variant="secondary" onClick={() => open({ kind: "view", fundId: item.id })} > - View fund + View fund{" "}{item.name} {!my && !item.archivedAt && ( )} {item.archivedAt && isAdmin && ( @@ -590,7 +590,7 @@ export function FundsScreen() { ) } > - Restore + Restore{" "}{item.name} )}
diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index 4ff51ffe..dc143bb3 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -15,7 +15,7 @@ function PhotoFace({ hub, focus, children }: { hub: LiveHub; focus?: boolean; ch ); } -function PlaceCard({ hub, focus = false }: { hub: LiveHub; focus?: boolean }) { +function PlaceCard({ hub, focus = false, level = 3 }: { hub: LiveHub; focus?: boolean; level?: 2 | 3 }) { const { go } = useApp(); const balance = hub.oweAmount.replace(/\.00\b/, ""); const hasBalance = hub.moves.some((move) => !move.even); @@ -25,7 +25,7 @@ function PlaceCard({ hub, focus = false }: { hub: LiveHub; focus?: boolean }) { -
{places.map((hub) => )}
+ {/* The cards sit straight under the page title, so each name is an h2. */} +
{places.map((hub) => )}
); } diff --git a/apps/web/src/screens/Hub.css b/apps/web/src/screens/Hub.css index 68d469f2..43c7fbb9 100644 --- a/apps/web/src/screens/Hub.css +++ b/apps/web/src/screens/Hub.css @@ -187,3 +187,15 @@ .c-hub-announcement-seen { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-top: 4px; color: var(--hub-muted); font-size: 12px; } .c-hub-announcement-done { color: var(--hub-action); font-weight: 500; } .c-hub-announcement-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; color: var(--hub-muted); font-size: 12px; } + +/* The task box sits in a label, which phones widen to a 44px touch area. */ +.c-hub-check { display: flex; flex: none; } +/* Phones: links, View buttons and task boxes reach 44px through an invisible + touch area, so the cards keep their look. */ +@media (max-width: 560px) { + .c-hub-link, .c-hub-attention-end button { position: relative; } + .c-hub-link::before, .c-hub-attention-end button::before { content: ""; position: absolute; inset: -9px 0; } + .c-hub-attention-end button::before { inset: -8px 0; } + .c-hub-task-feedback .c-hub-link::before { content: none; } + .c-hub-check { align-items: center; width: 44px; height: 44px; margin: -13.5px -15px -13.5px -12px; padding-left: 12px; } +} diff --git a/apps/web/src/screens/Hub.tsx b/apps/web/src/screens/Hub.tsx index 7f4a16f8..37e2ee7f 100644 --- a/apps/web/src/screens/Hub.tsx +++ b/apps/web/src/screens/Hub.tsx @@ -97,8 +97,8 @@ function Responsibilities() { - {item.kind === "task" && void complete(item.id, item.title)} />} + {item.kind === "task" && } ; })} @@ -149,9 +149,9 @@ function Announcements() { } {canTakeDown && (confirmDown === item.id ?
Take it down? It stays in the activity history. - +
- :
)} + :
)} ; })} {error &&

{error}

} @@ -181,9 +181,9 @@ export function HubScreen() {
{hub.type}

{hub.name}

-
@@ -201,7 +201,7 @@ export function HubScreen() { {outstanding.slice(0, 3).map((move, index) =>
{move.why}{move.names} - {move.amt} + {move.amt}
)} {toConfirm.slice(0, 2).map(item =>
{item.title}Announcement · please confirm you have seen it diff --git a/apps/web/src/screens/Members.tsx b/apps/web/src/screens/Members.tsx index f97b3967..18636342 100644 --- a/apps/web/src/screens/Members.tsx +++ b/apps/web/src/screens/Members.tsx @@ -159,6 +159,8 @@ export function MembersScreen() { ); const me = member.userId === session?.userId; const lastAdmin = profile?.role === "admin" && admins.length <= 1; + // Why an action is unavailable is shown, not left to a hover tooltip. + const hintId = `member-hint-${member.userId}`; return (
@@ -179,11 +181,7 @@ export function MembersScreen() { {!me && ( )} @@ -218,11 +217,7 @@ export function MembersScreen() { )} + {lastAdmin && (isAdmin || me) ? ( +

+ A commune must keep an admin. Promote someone else first. +

+ ) : null}
); diff --git a/apps/web/src/screens/Money.css b/apps/web/src/screens/Money.css index 0a4202a0..3c451cff 100644 --- a/apps/web/src/screens/Money.css +++ b/apps/web/src/screens/Money.css @@ -8,8 +8,8 @@ .c-expense-footer-copy { flex: 1; } .c-expense-card-foot small { display: block; margin-top: 3px; } .c-expense-card-foot svg { flex: none; } -.c-expense-entry:hover .c-expense-copy strong { color: var(--sage); } -.c-expense-entry:focus-visible { outline: 2px solid var(--sage); outline-offset: 3px; } +.c-expense-entry:hover .c-expense-copy strong { color: var(--text-green); } +.c-expense-entry:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px; } .c-money-icon { width: 37px; height: 37px; flex: none; border-radius: 10px; background: #edf0f3; color: #637689; display: grid; place-items: center; } .c-expense-copy { flex: 1; min-width: 0; } .c-expense-copy strong, .c-expense-amount strong { display: block; font-size: 14px; font-weight: 500; overflow-wrap: anywhere; } diff --git a/apps/web/src/screens/Money.tsx b/apps/web/src/screens/Money.tsx index d0572260..4ca62d38 100644 --- a/apps/web/src/screens/Money.tsx +++ b/apps/web/src/screens/Money.tsx @@ -206,32 +206,32 @@ export function SettleScreen() { {receiving && report ? (
) : receiving ? (
{reminderReason &&

{reminderReason}

}
) : report ? (
) : (
)} diff --git a/apps/web/src/screens/Notes.css b/apps/web/src/screens/Notes.css index 5f920912..5f663443 100644 --- a/apps/web/src/screens/Notes.css +++ b/apps/web/src/screens/Notes.css @@ -73,7 +73,7 @@ background: #fcfcf8; } .note-tile.is-selected { - border-color: var(--sage, #2d6a4f); + border-color: var(--text-green, #2d6a4f); background: #f0f6f0; } .note-tile-icon { @@ -121,7 +121,7 @@ position: absolute; right: 16px; top: 20px; - color: var(--sage, #2d6a4f); + color: var(--text-green, #2d6a4f); } .notes-pinned .note-tile { flex-direction: row; @@ -173,7 +173,7 @@ display: flex; align-items: center; gap: 9px; - color: var(--sage, #2d6a4f); + color: var(--text-green, #2d6a4f); font-size: 12px; font-weight: 500; } @@ -194,22 +194,23 @@ margin: 0 0 28px; } .note-pin-status svg { - color: var(--sage, #2d6a4f); + color: var(--text-green, #2d6a4f); } .note-details { margin: 28px 0 0; } +/* Term and value on the left, the buttons beside both, notes underneath. */ .note-fact { - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; align-items: center; - flex-wrap: wrap; - gap: 12px; + column-gap: 12px; padding: 22px 0; border-top: 1px solid var(--border); } -.note-fact-text { - flex: 1; - min-width: 0; +.note-fact dt { + grid-column: 1; + align-self: end; } .note-fact dt, .note-body h3 { @@ -221,15 +222,27 @@ } .note-fact dd { margin: 0; +} +.note-fact-value { + grid-column: 1; + align-self: start; font-size: 18px; line-height: 1.4; overflow-wrap: anywhere; white-space: pre-wrap; } .note-fact-actions { + grid-column: 2; + grid-row: 1 / span 2; display: flex; gap: 8px; } +.note-fact-note { + grid-column: 1 / -1; + display: grid; + gap: 12px; + padding-top: 12px; +} .note-icon-button { display: inline-flex; align-items: center; @@ -250,7 +263,6 @@ border-color: transparent; } .note-fact small { - flex-basis: 100%; font-size: 12px; line-height: 1.5; color: var(--ink-soft); @@ -286,7 +298,7 @@ font-size: 14px; background: transparent; border: 0; - color: var(--sage, #2d6a4f); + color: var(--text-green, #2d6a4f); min-height: 44px; padding: 0 8px; cursor: pointer; @@ -297,7 +309,7 @@ } .note-feedback { font-size: 12px; - color: var(--sage, #2d6a4f); + color: var(--text-green, #2d6a4f); min-height: 18px; margin: 12px 0; } @@ -321,7 +333,7 @@ .notes-layout button:focus-visible, .notes-layout select:focus-visible, .notes-layout input:focus-visible { - outline: 2px solid var(--sage, #2d6a4f); + outline: 2px solid var(--focus-ring, #2d6a4f); outline-offset: 4px; } .is-reading .notes-grid { diff --git a/apps/web/src/screens/Notes.tsx b/apps/web/src/screens/Notes.tsx index 61fd4f8c..af299115 100644 --- a/apps/web/src/screens/Notes.tsx +++ b/apps/web/src/screens/Notes.tsx @@ -83,7 +83,6 @@ export function NotesScreen() { type="button" className={`note-tile${selected && noteKey === id ? " is-selected" : ""}`} data-note-id={id} - aria-label={`Open ${note.title}`} aria-pressed={Boolean(selected && noteKey === id)} onClick={() => go("note", { note: id })} > @@ -366,13 +365,13 @@ function NoteFact({ label, value }: { label: string; value: string }) { setError("Could not copy. Select and copy the text instead."); } } + // A list group holds only its term and descriptions, so the buttons and + // the notes under them sit in descriptions of their own. return (
-
-
{label}
-
{secret && !revealed ? "••••••••••" : value}
-
-
+
{label}
+
{secret && !revealed ? "••••••••••" : value}
+
{secret ? ( -
- {secret ? Only reveal when you need it. : null} - {copied ? {label} copied. : null} - {error ? {error} : null} + + {secret || copied || error ? ( +
+ {secret ? Only reveal when you need it. : null} + {copied ? {label} copied. : null} + {error ? {error} : null} +
+ ) : null}
); } diff --git a/apps/web/src/screens/Operations.tsx b/apps/web/src/screens/Operations.tsx index e9b5de89..2474cf7f 100644 --- a/apps/web/src/screens/Operations.tsx +++ b/apps/web/src/screens/Operations.tsx @@ -18,6 +18,8 @@ import { CalendarDate } from "../ui/CalendarDate"; import { cx } from "../ui/cx"; type Cut = "week" | "people" | "month"; +/** The late card, and which of its buttons, an Undo puts focus back on. */ +type LateAction = { id: string; action: "skip" | "done" }; const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const CATEGORY_LABEL: Record = { @@ -58,11 +60,13 @@ export function OperationsScreen() { const [cut, setCut] = useState("week"); const [month, setMonth] = useState(today.slice(0, 7)); const [selectedDate, setSelectedDate] = useState(today); - const [toast, setToast] = useState<{ text: string; undoId?: string; error?: boolean } | null>(null); + const [toast, setToast] = useState<{ text: string; undoId?: string; from?: LateAction } | null>(null); + const [error, setError] = useState(""); const [undoing, setUndoing] = useState(false); const toastTimer = useRef(undefined); const toastRef = useRef(null); const toastHeld = useRef(false); + const refocus = useRef(null); const ordered = useMemo( () => @@ -110,8 +114,29 @@ export function OperationsScreen() { useEffect(() => () => window.clearTimeout(toastTimer.current), []); - // Messages with an Undo, and errors, stay long enough to read and use. The - // timer pauses while the pointer or keyboard focus is on the toast. + // A late card's buttons go once its task moves on, so a message with an + // Undo takes focus, where the keyboard can reach it in time. Undo puts focus + // back on the button that was used. The message stays while focus or the + // pointer is on it. + useEffect(() => { + const box = toastRef.current; + if (!toast || !box) return; + const active = document.activeElement; + const lost = !active || active === document.body || box.contains(active); + const back = refocus.current; + refocus.current = null; + if (back && lost) { + const card = [...document.querySelectorAll(".ops-late")].find((item) => item.dataset.late === back.id); + (card?.querySelector(`[data-late-action="${back.action}"]`) + ?? document.querySelector(".ops-cuts [aria-pressed=\"true\"]"))?.focus(); + } else if (toast.undoId && (lost || active?.closest(".ops-late"))) { + box.querySelector(".ops-toast-undo")?.focus(); + } + toastHeld.current = box.contains(document.activeElement) || box.matches(":hover"); + }, [toast]); + + // Messages with an Undo stay long enough to read and use. The timer pauses + // while the pointer or keyboard focus is on the toast. function scheduleToastClose(ms: number) { window.clearTimeout(toastTimer.current); toastTimer.current = window.setTimeout(() => { @@ -121,27 +146,31 @@ export function OperationsScreen() { if (hadFocus) document.querySelector(".ops-prod-head button")?.focus(); }, ms); } - function flash(text: string, undoId?: string) { - setToast({ text, undoId }); + function flash(text: string, undoId?: string, from?: LateAction) { + setError(""); + setToast({ text, undoId, from }); scheduleToastClose(undoId ? 9000 : 2600); } - function flashError(text: string) { - setToast({ text, error: true }); - scheduleToastClose(8000); + // An error stays on the screen, as a form's does, until the next action. + function fail(text: string) { + window.clearTimeout(toastTimer.current); + setToast(null); + setError(text); } function holdToast(held: boolean) { toastHeld.current = held; if (!held && toast) scheduleToastClose(4000); } - async function undo(occurrenceId: string) { + async function undo(occurrenceId: string, from?: LateAction) { if (undoing) return; setUndoing(true); try { const result = await undoOperation(occurrenceId); + refocus.current = from ?? null; flash(`Undone · back with ${firstNameOf(result.assignee ?? undefined) || "the commune"} for ${shortDateFormatter.format(dateFromIso(result.date))}`); } catch (error) { - flashError(error instanceof Error ? error.message : "Could not undo that"); + fail(error instanceof Error ? error.message : "Could not undo that"); } finally { setUndoing(false); } @@ -170,18 +199,19 @@ export function OperationsScreen() { ? `Done · next ${shortDateFormatter.format(dateFromIso(result.date))}` : `Done · ${item.title}`, result.occurrenceId, + { id: item.id, action: "done" }, ); } catch (error) { - flashError(error instanceof Error ? error.message : "Could not update task"); + fail(error instanceof Error ? error.message : "Could not update task"); } } async function skipTask(item: OperationItem) { try { const result = await skipOperation(item.id); - flash(`Moved to ${shortDateFormatter.format(dateFromIso(result.date))}`, result.occurrenceId); + flash(`Moved to ${shortDateFormatter.format(dateFromIso(result.date))}`, result.occurrenceId, { id: item.id, action: "skip" }); } catch (error) { - flashError(error instanceof Error ? error.message : "Could not move task"); + fail(error instanceof Error ? error.message : "Could not move task"); } } @@ -220,13 +250,12 @@ export function OperationsScreen() { return (
-
+
{(["week", "people", "month"] as const).map((id) => (
+ {error ?

{error}

: null} {cut === "week" ? (
@@ -396,6 +426,8 @@ export function OperationsScreen() { className="ops-tile-num" type="button" aria-label={dayFormatter.format(dateFromIso(date))} + aria-current={date === today ? "date" : undefined} + aria-pressed={date === selectedDate} onClick={() => showDate(date)} > {Number(date.slice(-2))} @@ -414,7 +446,10 @@ export function OperationsScreen() { go("operation", { operation: item.id }); }} > - {item.title} + {item.title}{" "} + + {item.kind === "task" ? item.date < today ? "(late task)" : "(task)" : "(event)"} + ))}
@@ -454,7 +489,6 @@ export function OperationsScreen() {
holdToast(true)} onMouseLeave={() => holdToast(false)} @@ -463,7 +497,7 @@ export function OperationsScreen() { > {toast ? {toast.text} : null} {toast?.undoId ? ( - ) : null} @@ -493,7 +527,7 @@ function LateCard({ : ""; const who = firstNameOf(item.assignee); return ( -
+

{lateLabel(item.date, today)} · {who || "Unassigned"}

@@ -516,16 +550,16 @@ function LateCard({

{/* As in the task window, only a repeating task can be skipped. */} {item.repeat !== "none" ? ( - ) : null} -
diff --git a/apps/web/src/screens/Recurring.tsx b/apps/web/src/screens/Recurring.tsx index 43a8714f..30bff9af 100644 --- a/apps/web/src/screens/Recurring.tsx +++ b/apps/web/src/screens/Recurring.tsx @@ -574,7 +574,7 @@ export function RecurringScreen() { setConfirm(bill); }} > - Record paid bill + Record paid bill{" "}for {bill.title} )} {!bill.paused && actions.record && canSkipNextPeriod(bill) && ( @@ -586,7 +586,7 @@ export function RecurringScreen() { setSkipping(bill); }} > - Skip this period + Skip this period{" "}for {bill.title} )} {actions.edit && ( @@ -598,7 +598,7 @@ export function RecurringScreen() { setEditor({ id: bill.id, resuming: false }); }} > - Edit + Edit{" "}{bill.title} )} {actions.pause && (!bill.paused || canResume) && ( @@ -617,7 +617,7 @@ export function RecurringScreen() { ); }} > - {bill.paused ? "Resume" : "Pause"} + {bill.paused ? "Resume" : "Pause"}{" "}{bill.title} )} {actions.archive && bill.paused && ( @@ -631,7 +631,7 @@ export function RecurringScreen() { ) } > - Archive + Archive{" "}{bill.title} )} {actions.restore && ( @@ -649,7 +649,7 @@ export function RecurringScreen() { ) } > - Restore + Restore{" "}{bill.title} )}
diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index 8d86bcff..5e3ccbc9 100644 --- a/apps/web/src/screens/Settings.tsx +++ b/apps/web/src/screens/Settings.tsx @@ -38,7 +38,7 @@ export function SettingsScreen() { const emailReminders = useEmailReminders(session?.userId); return (
- +
-

Get more out of Commune

+

Get more out of Commune

-

Preferences

+

Preferences

@@ -191,12 +191,12 @@ export function SettingsScreen() {
-

Support & legal

+

Support & legal

Contact us - ourcommune.io + ourcommune.io (opens in a new tab) @@ -291,7 +291,7 @@ export function CommuneSettingsScreen() { return (
- +

Shared details

diff --git a/apps/web/src/screens/Templates.tsx b/apps/web/src/screens/Templates.tsx index 1e1c1e27..1b3db664 100644 --- a/apps/web/src/screens/Templates.tsx +++ b/apps/web/src/screens/Templates.tsx @@ -263,7 +263,7 @@ export function TemplatesScreen() { disabled={missing || pending} onClick={() => go("add-expense", { template: template.id })} > - Use split + Use split{" "}{template.name} )} {allowed && ( @@ -276,7 +276,7 @@ export function TemplatesScreen() { setEditor(template); }} > - {archived ? "Edit & restore" : "Edit"} + {archived ? "Edit & restore" : "Edit"}{" "}{template.name} )} diff --git a/apps/web/src/shell/Shell.tsx b/apps/web/src/shell/Shell.tsx index 47929bde..736e0b79 100644 --- a/apps/web/src/shell/Shell.tsx +++ b/apps/web/src/shell/Shell.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { ArrowRightLeft, ChartColumn, Clock3, CreditCard, FileText, Files, House, LayoutGrid, ReceiptText, Repeat2, ShoppingBag, SquareCheck, UsersRound, type LucideIcon } from "lucide-react"; import type { IconName } from "../ui/Icon"; import { Icon } from "../ui/Icon"; @@ -47,9 +47,9 @@ const PEOPLE: NavItem[] = [ { id: "notes", label: "Notes", icon: "notes" }, ]; -function CreateCommune({ onClick, menu }: { onClick: () => void; menu?: boolean }) { +function CreateCommune({ onClick }: { onClick: () => void }) { return ( - {open ? ( -
+
{places.map((place) => ( ))} { setOpen(false); go("commune-new"); @@ -246,6 +253,7 @@ export function Header({ screen }: { screen: ScreenId }) { } = useApp(); const [searchOpen, setSearchOpen] = useState(false); const searchingAll = ["home", "home-empty", "communes", "home-activity"].includes(screen); + const unread = noticesOn ? notifications.filter((notice) => !notice.readAt).length : 0; useEffect(() => { function shortcut(event: KeyboardEvent) { if (event.isComposing || event.repeat) return; @@ -271,16 +279,16 @@ export function Header({ screen }: { screen: ScreenId }) {

{pageTitle}

{pageLede ?

{pageLede}

: null}
-
-
@@ -298,20 +306,19 @@ export function PhoneTabs({ screen }: { screen: ScreenId }) { { id: "expenses", label: "Expenses", icon: "expenses" }, { id: "settle", label: "Settle", icon: "settle" }, ]; + const current = (id: ScreenId) => + (id === "home" && (screen === "home" || screen === "home-empty" || screen === "home-inbox")) || + (id === "hub" && (screen === "hub" || screen === "hub-empty" || screen === "commune-settings")) || + (id === "expenses" && (screen === "expenses" || screen === "expenses-empty")) || + (id === "settle" && (screen === "settle" || screen === "settle-even")); return (