diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 416d1c64..5e462ea4 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,5 +1,4 @@
-import { useEffect, useLayoutEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
-import { diagnosticsEnabled } from "./live/diagnostics";
+import { Suspense, useEffect, useLayoutEffect, useState, type ReactNode } from "react";
import { Link, Redirect, Route, Switch, useLocation, useSearch } from "wouter";
import { LoginPage } from "./auth/Login";
import { SignupPage, VerifyEmailPage } from "./auth/Signup";
@@ -7,35 +6,15 @@ import { ForgotPasswordPage, ResetPasswordPage } from "./auth/ForgotPassword";
import { InvitePage, OnboardPage } from "./auth/InviteOnboard";
import { PrivacyPage, TermsPage } from "./legal/LegalPages";
import { useApp } from "./state";
-import { Header, MoreSheet, PhoneTabs, Sidebar } from "./shell/Shell";
-import { HomeEmptyScreen, HomeScreen, PlacesScreen } from "./screens/Home";
-import { HubEmptyScreen, HubScreen } from "./screens/Hub";
-import {
- ExpensesEmptyScreen,
- ExpensesScreen,
- FundsScreen,
- RecurringScreen,
- SettleEvenScreen,
- SettleScreen,
-} from "./screens/Money";
-import {
- ActivityScreen,
- MembersScreen,
- NotesScreen,
- TemplatesScreen,
-} from "./screens/House";
-import { OperationsScreen } from "./screens/Operations";
-import { CommuneSettingsScreen, SettingsScreen } from "./screens/Settings";
-import { Windows } from "./windows/Windows";
-import { cx } from "./ui/cx";
-import { PAGE_TITLES, type ScreenId } from "./data/household";
-import { COMMUNE_SCREENS, forgetReturn, keepSearch, readAddress, readReturn, rememberReturn } from "./nav";
+import { forgetReturn, keepSearch, readAddress, readReturn, rememberReturn } from "./nav";
import { AuthLayout } from "./auth/AuthLayout";
import { PendingShell } from "./shell/PendingShell";
-import { AllActivityScreen, SearchScreen } from "./screens/Search";
import { ConnectionRecovery } from "./shell/ConnectionRecovery";
-import { RecoveryBoundary, ScreenRecovery } from "./shell/Recovery";
-import { isConnectionFailure } from "./live/connection";
+import { lazyComponent } from "./shell/lazyComponent";
+import { usePageTitle } from "./shell/pageTitle";
+
+// The signed-in app, so that the sign-in pages do not download every screen.
+const ProductShell = lazyComponent(() => import("./shell/ProductShell").then((m) => m.ProductShell));
export function App() {
return (
@@ -98,12 +77,6 @@ export function App() {
);
}
-// "Expenses · Flat 4 · Commune": short, most specific first.
-function usePageTitle(...parts: Array) {
- const title = [...parts.filter(Boolean), "Commune"].join(" · ");
- useEffect(() => { document.title = title; }, [title]);
-}
-
function PageTitle({ title }: { title: string }) {
usePageTitle(title);
return null;
@@ -127,9 +100,13 @@ function SignedIn({ hasHubs }: { hasHubs: boolean }) {
function ProtectedApp() {
const [path] = useLocation();
+ const known = Boolean(readAddress(path).screen);
+ // Fetched while the session is checked; until it arrives, the same
+ // loading page stays up.
+ useEffect(() => { if (known) ProductShell.preload().catch(() => {}); }, [known]);
// An address Commune does not have needs no sign-in to say so.
- if (!readAddress(path).screen) return <> >;
- return ;
+ if (!known) return <> >;
+ return }> ;
}
function AuthenticatedOnly({ children }: { children: ReactNode }) {
@@ -169,192 +146,3 @@ function AppUnavailable() {
const { refresh, signOut } = useApp();
return ;
}
-
-function ProductShell() {
- const { screen, navMode, moreOpen, native, dark, window: win, notes, noteKey, apiError, clearApiError, contentError, hub, refresh, searchTarget, unknownCommune } = useApp();
- const noteReader = screen === "notes" && win === "note" && Boolean(notes[noteKey]);
- const modalOpen = Boolean(win) && !noteReader;
- usePageFocus(screen, hub.id, Boolean(win) || Boolean(searchTarget));
- usePageTitle(unknownCommune ? "Commune not found" : (win && PAGE_TITLES[win]) || PAGE_TITLES[screen],
- COMMUNE_SCREENS.has(screen) && !unknownCommune ? hub.name : undefined);
- return (
-
-
-
Skip to content
-
-
-
- {diagnosticsEnabled() ?
: null}
- {apiError ?
{isConnectionFailure(apiError) ? <>Connection interrupted. Your last loaded view is still available. void refresh()}>Try again > : apiError}×
: null}
- {contentError && hub.activityState === "ready" ?
Could not refresh this commune. Showing its last loaded activity. void refresh()}>Try again
: null}
-
- }>
-
-
-
-
-
-
-
-
- );
-}
-
-// 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);
- const [result, setResult] = useState("");
- async function check() {
- setRunning(true);
- setResult("");
- try {
- const { inspectMembershipRead } = await import("./live/neon");
- const rows = [];
- for (let round = 1; round <= 5; round++) {
- rows.push({ round, reads: await inspectMembershipRead(hubId) });
- }
- setResult(JSON.stringify(rows, null, 2));
- } catch {
- setResult("Connection test could not complete.");
- } finally { setRunning(false); }
- }
- return
- Read-only connection diagnostics
- void check()}>{running ? "Checking…" : "Compare membership reads"}
- {result}
- ;
-}
-
-function Screen({ screen }: { screen: ScreenId }) {
- return (
-
- );
-}
-
-function ScreenBody({ screen }: { screen: ScreenId }) {
- const { session, emptyHub, hub, contentLoading, contentError, refresh, unknownCommune, go } = useApp();
- const hasHubs = Boolean(session?.hasHubs);
- const hasSettlement = hub.moves.some((move) => !move.even);
-
- // Never another commune's records in its place.
- if (unknownCommune) {
- return This commune isn’t available to you. You may have left it, or the link is for a different account.
go("home")}>Go to Home ;
- }
-
- if (contentLoading && screen !== "settings" && screen !== "search") {
- if (contentError) return Could not load {hub.name} {contentError}
void refresh()}>Try again ;
- return Loading shared records {screen === "hub" &&
{hub.name} {hub.switchMeta}
}
;
- }
-
- switch (screen) {
- case "home":
- return hasHubs ? : ;
- case "home-empty":
- return hasHubs ? : ;
- case "home-inbox":
- return hasHubs ? : ;
- case "communes":
- return ;
- case "hub":
- return !hasHubs ? : emptyHub ? : ;
- case "hub-empty":
- return emptyHub || !hasHubs ? : ;
- case "commune-settings":
- return ;
- case "expenses":
- return emptyHub ? : ;
- case "expenses-empty":
- return emptyHub || !hasHubs ? (
-
- ) : (
-
- );
- case "settle":
- return !hasSettlement || !hasHubs ? (
-
- ) : (
-
- );
- case "settle-even":
- return !hasSettlement || !hasHubs ? (
-
- ) : (
-
- );
- case "recurring":
- return ;
- case "funds":
- return ;
- case "templates":
- return ;
- case "members":
- return ;
- case "operations":
- return ;
- case "notes":
- return ;
- case "activity":
- return ;
- case "home-activity":
- return ;
- case "search":
- return ;
- case "settings":
- return ;
- default:
- return hasHubs ? : ;
- }
-}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 154a9b67..db7db1e7 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -1,18 +1,12 @@
+// First, so the cascade layers' order is declared before any stylesheet that
+// a component imports uses one (see styles/index.css).
+import "./styles/index.css";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { AppProvider } from "./state";
import { AppRecovery, RecoveryBoundary } from "./shell/Recovery";
import { watchErrors, watchSpeed } from "./live/reports";
-// Before the app's own stylesheets, as its separate link was, so their rules still come after it.
-import "./styles/calendar-date.css";
-import "./styles/shared.css";
-import "./styles/app.css";
-import "./styles/auth.css";
-import "./styles/legal.css";
-import "./styles/overrides.css";
-import "./styles/design-system.css";
-import "./styles/fonts.css";
const root = document.getElementById("root");
if (!root) throw new Error("Missing #root");
diff --git a/apps/web/src/screens/House.tsx b/apps/web/src/screens/House.tsx
index 2b3a6fde..2ee5afaf 100644
--- a/apps/web/src/screens/House.tsx
+++ b/apps/web/src/screens/House.tsx
@@ -1,8 +1,5 @@
import { useApp } from "../state";
import { ActivityLog } from "../ui/ActivityLog";
-export { NotesScreen } from "./Notes";
-
-export { MembersScreen } from "./Members";
export function ActivityScreen() {
const { hub, go } = useApp();
@@ -15,5 +12,3 @@ export function ActivityScreen() {
}))} />
);
}
-
-export { TemplatesScreen } from "./Templates";
diff --git a/apps/web/src/screens/Hub.css b/apps/web/src/screens/Hub.css
index 16be2a76..68fc7c18 100644
--- a/apps/web/src/screens/Hub.css
+++ b/apps/web/src/screens/Hub.css
@@ -1,3 +1,6 @@
+/* In the screens layer, above the shared base styles and below the dark
+ theme and the design-language primitives (see styles/index.css). */
+@layer screens {
/* The live Hub follows the selected desktop prototype. Other screens keep their own layout. */
.a-body.hub-editorial {
--hub-ink: #1a1e2b;
@@ -31,9 +34,11 @@
--hub-warning: #f4ba94;
--hub-heading-icon: #b5c6bb;
}
-.a-body.hub-editorial :is(.a-app, .a-main, .a-content) { background: var(--hub-paper); }
+/* The phone app's layout (app.css) keeps its own frame background and page padding. */
+.a-body.hub-editorial .a-app:where(:not(.is-native *)),
+.a-body.hub-editorial :is(.a-main, .a-content) { background: var(--hub-paper); }
/* Header geometry, typography and responsive behaviour belong to the shared shell. */
-.hub-editorial .a-content { padding: 22px 22px 32px; }
+.hub-editorial .a-content:where(:not(.is-native *)) { padding: 22px 22px 32px; }
.c-hub { max-width: 1208px; margin-inline: auto; display: grid; gap: 22px; color: var(--hub-ink); font-family: var(--font); font-size: 14px; line-height: 1.5; }
.c-hub :is(h1,h2,h3,p) { margin: 0; }
@@ -44,7 +49,7 @@
.c-hub-identity { position: relative; isolation: isolate; min-height: 300px; padding: 80px 29px 27px; display: flex; align-items: flex-end; border-radius: var(--radius-surface); overflow: hidden; background: var(--hub-sage); }
/* The commune type's tone shows until the cover arrives, or if it cannot. */
-.c-hub-identity.has-photo { color: #fff; background: var(--cover-tone, #1a1e2b); }
+.c-hub-identity.has-photo { color: #fff; background: var(--cover-tone, var(--navy)); }
.c-hub-cover { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center 60%; z-index: -2; }
.c-hub-identity.has-photo::after { content: ''; position: absolute; inset: 0; background: linear-gradient(0deg, rgba(20, 37, 26, 0.8) 0, rgba(20, 37, 26, 0.7) 116px, rgba(20, 37, 26, 0.5) 166px, transparent 90%); z-index: -1; }
.c-hub-identity-copy { min-width: 0; width: 100%; text-shadow: 0 1px 5px #0004; }
@@ -53,7 +58,6 @@
and the buttons above it carry their own (tests/cover-contrast.test.ts). */
.c-hub-identity.has-photo .c-hub-type { width: fit-content; padding: 2px 9px; border-radius: var(--radius-pill); background: rgba(20, 37, 26, 0.64); }
.c-hub .c-hub-identity h2 { margin: 3px 0 6px; font-size: 35px; font-weight: 500; letter-spacing: -1.2px; line-height: 1.2; overflow-wrap: anywhere; }
-.c-hub-people-count { font-size: 14px; }
.c-hub-location { font-size: 14px; }
.c-hub-location p { overflow-wrap: anywhere; }
.c-hub-location span { margin-right: 8px; opacity: .85; }
@@ -72,18 +76,8 @@
.c-hub-balance-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 24px; }
.c-hub-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 40px; padding: 10px 17px; border: 0; border-radius: var(--radius-control); background: var(--hub-action); color: var(--hub-action-text); font-size: 13px !important; font-weight: 500 !important; }
.c-hub-button:hover { background: var(--hub-action-hover); }
-.c-hub-stats { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); overflow: hidden; border: 1px solid var(--hub-line); border-radius: 14px; background: var(--hub-panel); }
-.c-hub-stats button { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; padding: 18px 24px; border: 0; background: none; color: var(--hub-ink); text-align: left; }
-.c-hub-stats button:hover { background: var(--hub-quiet); }
-.c-hub-stats button:nth-child(odd) { border-right: 1px solid var(--hub-line); }
-.c-hub-stats button:nth-child(-n+2) { border-bottom: 1px solid var(--hub-line); }
-.c-hub-stats :is(span,small) { color: var(--hub-muted); font-size: 12px; overflow-wrap: anywhere; }
-.c-hub-stats strong { display: block; font-size: 28px; font-weight: 450; letter-spacing: -.8px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
-
-.c-hub-quick { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); align-items: stretch; gap: 16px; }
.c-hub-lower { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); align-items: stretch; gap: 22px; }
.c-hub-panel { min-width: 0; padding: 23px; border: 1px solid var(--hub-line); border-radius: var(--radius-surface); background: var(--hub-panel); }
-.c-hub-quick .c-hub-panel { min-height: 225px; }
.c-hub-lower .c-hub-panel { min-height: 0; }
.c-hub-section-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.c-hub-section-title { display: flex; align-items: center; gap: 9px; font-size: 18px; font-weight: 550; letter-spacing: -.4px; }
@@ -132,8 +126,6 @@
.c-hub-note-row :is(strong,small) { display: block; overflow-wrap: anywhere; }
.c-hub-note-row strong { font-size: 13px; font-weight: 500; }
.c-hub-note-row small { margin-top: 4px; color: var(--hub-muted); font-size: 12px; }
-.c-hub-quick .c-hub-note-row small { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.c-hub-quick .c-hub-note-row { padding-block: 14px; }
.c-hub-lower .c-hub-note-row { padding-block: 10px; }
.c-hub-note-row svg { flex: none; color: var(--hub-muted); }
.c-hub-note-row:hover strong { color: var(--hub-action); }
@@ -146,28 +138,14 @@
.c-hub-expense-row small { margin-top: 4px; color: var(--hub-muted); font-size: 12px; }
.c-hub-expense-end { flex: none; text-align: right; }
.c-hub-expense-status { width: fit-content; margin-left: auto; padding: 2px 10px; border-radius: 20px; background: #fff0e5; color: #915321 !important; font-size: 10px !important; }
-.c-hub-expense-status.is-settled { background: #e8efe8; color: #2d6a4f !important; }
+.c-hub-expense-status.is-settled { background: #e8efe8; color: var(--sage) !important; }
.c-hub-expense-row:hover strong { color: var(--hub-action); }
-
-.c-hub-members { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 24px; margin-top: 26px; }
-.c-hub-member { display: flex; align-items: center; gap: 14px; min-width: 0; }
-.c-hub-member > .avatar { width: 46px; height: 46px; flex: none; }
-.c-hub-member > span:nth-child(2) { min-width: 0; flex: 1; }
-.c-hub-member :is(strong,small) { display: block; overflow-wrap: anywhere; }
-.c-hub-member strong { font-size: 13px; font-weight: 500; }
-.c-hub-member small, .c-hub-member-balance { color: var(--hub-muted); font-size: 12px; }
-.c-hub-member-balance { text-align: right; }
-
-@media(max-width:1050px) {
- .c-hub-quick { grid-template-columns: 1fr; }
- .c-hub-quick .c-hub-panel { min-height: 0; }
-}
@media(max-width:800px) {
.c-hub-overview, .c-hub-lower { grid-template-columns: 1fr; }
.c-hub-lower .c-hub-panel { min-height: 0; }
}
@media(max-width:560px) {
- .hub-editorial .a-content { padding: 16px 16px 30px; }
+ .hub-editorial .a-content:where(:not(.is-native *)) { padding: 16px 16px 30px; }
.c-hub { gap: 16px; }
.c-hub-identity { min-height: 280px; padding: 22px; }
.c-hub .c-hub-identity h2 { font-size: 29px; }
@@ -175,8 +153,6 @@
.c-hub-settings span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.c-hub-balance { padding: 24px; }
.c-hub-panel { padding: 18px; }
- .c-hub-quick .c-hub-note-row small { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; white-space: normal; }
- .c-hub-members { grid-template-columns: 1fr; }
.c-hub-expense-row > time { display: none; }
}
@@ -205,3 +181,4 @@
.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/Money.css b/apps/web/src/screens/Money.css
index 3c451cff..606e0092 100644
--- a/apps/web/src/screens/Money.css
+++ b/apps/web/src/screens/Money.css
@@ -1,3 +1,6 @@
+/* In the screens layer, above the shared base styles and below the dark
+ theme and the design-language primitives (see styles/index.css). */
+@layer screens {
/* Approved mockup language, retaining the application's page gutters and forms. */
.c-expense-list { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 22px; }
.c-expense-entry { width: 100%; min-width: 0; display: flex; flex-direction: column; align-items: stretch; gap: 20px; padding: 22px; border: 1px solid var(--border); border-radius: var(--radius-surface); background: var(--white); text-align: left; color: var(--ink); font: inherit; cursor: pointer; }
@@ -6,7 +9,8 @@
.c-expense-card-foot { display: flex; justify-content: space-between; align-items: center; gap: 12px; border-top: 1px solid var(--border); padding-top: 14px; font-size: 12px; color: var(--ink-soft); }
.c-expense-card-foot > span { min-width: 0; overflow-wrap: anywhere; }
.c-expense-footer-copy { flex: 1; }
-.c-expense-card-foot small { display: block; margin-top: 3px; }
+/* Not the calendar date's weekday, which keeps calendar-date.css's display and spacing. */
+.c-expense-card-foot small:where(:not(.c-calendar-weekday)) { display: block; margin-top: 3px; }
.c-expense-card-foot svg { flex: none; }
.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; }
@@ -22,26 +26,15 @@
@media(max-width:760px) { .c-expense-list { grid-template-columns: 1fr; } }
.c-money-empty { padding: 28px 0; color: var(--ink-soft); }
#expenses .a-btn, #expenses-empty .a-btn, #notes .a-btn, #funds .a-btn, #settle .a-btn { border-radius: var(--radius-control); font-weight: 500; gap: 7px; }
-#settle .a-settle-card { border-radius: var(--radius-surface); box-shadow: none; }
-#settle .a-settle-card > span { font-size: 18px; font-weight: 550; text-transform: none; letter-spacing: -.4px; }
-#settle .a-settle-card > strong { font-size: 41px; font-weight: 450; letter-spacing: -1.7px; }
#settle .a-card { box-shadow: none; border-radius: var(--radius-surface); }
#settle .a-move { gap: 16px; }
#settle .a-move .names { font-weight: 500; }
#settle .a-move > .avatar { flex: none; }
-.c-fund-card { margin-bottom: 22px; box-shadow: none; border-radius: var(--radius-surface); }
-.c-fund-create { max-width: 600px; margin-bottom: 22px; padding: 23px; background: var(--white); border-radius: var(--radius-surface); box-shadow: none; }
-.a-panel.c-fund-create h2 { font-size: 18px; font-weight: 550; }
-.c-fund-create > p { color: var(--ink-soft); font-size: 13px; line-height: 1.6; margin: 8px 0 20px; }
-.c-fund-create .a-field label { text-transform: none; letter-spacing: normal; font-weight: 500; }
-.c-fund-title { display: flex; align-items: center; gap: 9px; font-weight: 550; }
-.c-fund-title svg { color: #6c8277; flex: none; }
-.c-fund-card .a-fund-hero strong { font-size: 36px; font-weight: 450; letter-spacing: -1px; }
.c-fund-progress { display: flex; align-items: center; gap: 16px; margin: 18px 0 24px; }
.c-fund-progress > div { flex: 1; height: 6px; background: var(--border); border-radius: 4px; overflow: hidden; }
.c-fund-progress > div > span { display: block; height: 100%; background: var(--sage); border-radius: inherit; }
.c-fund-progress small { color: var(--ink-soft); font-size: 12px; }
-.c-reminder-dialog { padding: 28px; width: min(470px,calc(100% - 32px)); max-height: calc(100dvh - 32px); overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-surface); background: var(--white,#fffefa); color: var(--ink); }
+.c-reminder-dialog { padding: 28px; width: min(470px,calc(100% - 32px)); max-height: calc(100dvh - 32px); overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-surface); background: var(--white); color: var(--ink); }
.c-reminder-dialog::backdrop { background: #111b2866; }
.c-reminder-dialog h2 { font-size: 21px; font-weight: 550; line-height: 1.4; padding-right: 20px; }
.c-reminder-message { padding: 18px 0 14px; font-size: 14px; line-height: 1.7; }
@@ -84,9 +77,9 @@
#settle .c-settle-column .a-move > .amt { grid-column: 3; justify-self: end; }
}
-/* Repayment receipts: one form, one history pattern, everywhere. */
-/* Qualified: .c-product-dialog is bundled later and would otherwise win. */
-.c-product-dialog.c-receipt-dialog { width: min(480px, calc(100vw - 32px)); }
+/* Repayment receipts: one form, one history pattern, everywhere. The
+ receipt dialog's width and the history's spacing are in the overrides
+ layer at the end of this file. */
.c-receipt-context { font-size: 13px; line-height: 1.6; color: var(--ink-soft); margin: -8px 0 18px; overflow-wrap: anywhere; }
.c-receipt-outcome { font-size: 13px; font-weight: 500; line-height: 1.6; margin: 4px 0 16px; font-variant-numeric: tabular-nums; }
.c-receipt-note { font-size: 12px; line-height: 1.6; color: var(--ink-soft); margin: 14px 0 0; }
@@ -99,7 +92,6 @@
.c-share-block .a-split-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 6px 12px; border-bottom: 0; }
.c-share-block .a-row-actions { grid-column: 2 / -1; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 8px; }
.c-share-block .c-action-hint { text-align: right; }
-.c-share-block .c-receipt-history { margin: 0 0 14px 44px; }
.c-receipt-history strong { font-weight: 500; color: var(--ink); font-variant-numeric: tabular-nums; }
.c-receipt-history .is-reversed > span { text-decoration: none; }
.c-receipt-history .is-reversed strong { text-decoration: line-through; color: var(--ink-soft); }
@@ -111,10 +103,7 @@
.c-pending-report .a-row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.c-pending-report .a-btn { padding: 0 12px; font-size: 12px; }
.c-report-answer { margin: 0 0 14px 44px; font-size: 12px; line-height: 1.6; color: var(--ink-soft); overflow-wrap: anywhere; }
-.c-move-answer .c-report-answer, .a-move .c-report-answer { margin: 3px 0 0; }
-.a-move .c-report-line { color: var(--ink); }
-/* Compact beside the history line on desktop; phones keep the 44px target. */
-@media (min-width: 561px) { .a-windows .c-receipt-history .a-btn { min-height: 32px; } }
+.a-move .c-report-answer { margin: 3px 0 0; }
.a-panel.c-settle-recent { margin-top: 22px; background: var(--white); box-shadow: none; border-radius: var(--radius-surface); padding: 23px; }
.a-panel.c-settle-recent h2 { font-size: 18px; font-weight: 550; margin-bottom: 4px; }
.c-settle-receipt { display: flex; align-items: center; gap: 12px; width: 100%; padding: 14px 0; border: 0; border-top: 1px solid var(--border); background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
@@ -127,12 +116,11 @@
@media(max-width:560px) {
.c-settle-receipt { flex-wrap: wrap; }
.c-settle-receipt .grow { flex-basis: calc(100% - 100px); }
- .c-share-block .c-receipt-history { margin-left: 0; }
.c-share-block .a-row-actions { grid-column: 1 / -1; }
}
-/* Expense correction, void and version history. */
-.c-product-dialog.c-expense-change { width: min(600px, calc(100vw - 32px)); }
+/* Expense correction, void and version history (the dialog's width is in
+ the overrides layer at the end of this file). */
.c-change-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.c-change-lock { font-size: 13px; line-height: 1.6; padding: 12px 14px; border: 1px solid var(--border-strong); border-radius: var(--radius-control); margin: 4px 0 16px; }
.c-change-review { margin: 0 0 16px; }
@@ -158,8 +146,6 @@
.a-window .c-product-dialog .a-field label { font-size: 11px; color: var(--ink-soft); }
.a-window .c-product-dialog .a-field { padding: 14px 0; border-bottom: 1px solid rgba(23, 27, 36, 0.05); }
.a-window .c-product-dialog .a-field:last-of-type { border-bottom: 0; }
-.a-window :is(.c-product-dialog, .c-reminder-dialog) .a-field-row,
-.a-body.is-dark .a-window :is(.c-product-dialog, .c-reminder-dialog) .a-field-row { position: static; margin: 16px 0 0; padding: 0; background: transparent; border-top: 0; }
.a-window :is(.c-product-dialog, .c-reminder-dialog) .a-field-row .a-btn { height: auto; min-width: 0; }
/* A failure in a money dialog must not read like body copy. Same tone as the
@@ -190,7 +176,6 @@
/* Recurring: the usual split scaled to this period's amount. */
.c-period-split { margin: 0 0 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--paper-2); }
-.c-period-split .c-form-help { margin: 0 0 6px; }
.c-period-split ul { list-style: none; margin: 0; padding: 0; }
.c-period-split li { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0; font-size: 13px; }
.c-period-split li span:last-child { font-variant-numeric: tabular-nums; }
@@ -201,3 +186,22 @@
.c-fund-figures div { min-width: 0; }
.c-fund-figures dt { font-size: 11px; color: var(--ink-soft); }
.c-fund-figures dd { margin: 2px 0 0; font-size: 14px; font-weight: 550; color: var(--ink); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
+}
+
+/* These outrank the shared dialog, window and history rules
+ (design-system.css) and the dark theme (overrides.css), so they sit in the
+ same layer. */
+@layer overrides {
+/* Qualified: the shared .c-product-dialog would otherwise win. */
+.c-product-dialog.c-receipt-dialog { width: min(480px, calc(100vw - 32px)); }
+.c-share-block .c-receipt-history { margin: 0 0 14px 44px; }
+/* Compact beside the history line on desktop; phones keep the 44px target. */
+@media (min-width: 561px) { .a-windows .c-receipt-history .a-btn { min-height: 32px; } }
+@media(max-width:560px) { .c-share-block .c-receipt-history { margin-left: 0; } }
+.c-product-dialog.c-expense-change { width: min(600px, calc(100vw - 32px)); }
+/* A native dialog opened from inside a window keeps the shared dialog form,
+ not the window's sticky footer. */
+.a-window :is(.c-product-dialog, .c-reminder-dialog) .a-field-row,
+.a-body.is-dark .a-window :is(.c-product-dialog, .c-reminder-dialog) .a-field-row { position: static; margin: 16px 0 0; padding: 0; background: transparent; border-top: 0; }
+.c-period-split .c-form-help { margin: 0 0 6px; }
+}
diff --git a/apps/web/src/screens/Money.tsx b/apps/web/src/screens/Money.tsx
index cae82245..e62a32aa 100644
--- a/apps/web/src/screens/Money.tsx
+++ b/apps/web/src/screens/Money.tsx
@@ -204,7 +204,7 @@ export function SettleScreen() {
{receiving ? `${personName(share.userId, "A former member", { left: true })} owes you` : `You owe ${personName(expense.paidByUserId, "the bill payer", { left: true })}`}
{expense.name}{expense.dueOn ? ` · due ${expense.due}` : ""}
{share.settledMinor > 0 ? {formatMoney(share.settledMinor, hub.currency)} of {formatMoney(share.amountMinor, hub.currency)} received
: null}
- {report ? {receiving
+ {report ?
{receiving
? `${reportSummary(report, hub.currency, personName(share.userId, "A member"))}. Not yet confirmed.`
: `You said you sent ${formatMoney(report.amountMinor, hub.currency)} on ${formatReminderDay(report.sentOn)}. Waiting for ${payerName}.`}
: null}
{!receiving ?
personName(userId, "The bill payer")} /> : null}
@@ -289,6 +289,3 @@ export function SettleEvenScreen() {
);
}
-
-export { RecurringScreen } from "./Recurring";
-export { FundsScreen } from "./Funds";
diff --git a/apps/web/src/screens/Notes.css b/apps/web/src/screens/Notes.css
index 5f663443..1039fc93 100644
--- a/apps/web/src/screens/Notes.css
+++ b/apps/web/src/screens/Notes.css
@@ -1,3 +1,6 @@
+/* In the screens layer, above the shared base styles and below the dark
+ theme and the design-language primitives (see styles/index.css). */
+@layer screens {
.notes-layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -73,7 +76,7 @@
background: #fcfcf8;
}
.note-tile.is-selected {
- border-color: var(--text-green, #2d6a4f);
+ border-color: var(--text-green);
background: #f0f6f0;
}
.note-tile-icon {
@@ -121,7 +124,7 @@
position: absolute;
right: 16px;
top: 20px;
- color: var(--text-green, #2d6a4f);
+ color: var(--text-green);
}
.notes-pinned .note-tile {
flex-direction: row;
@@ -173,7 +176,7 @@
display: flex;
align-items: center;
gap: 9px;
- color: var(--text-green, #2d6a4f);
+ color: var(--text-green);
font-size: 12px;
font-weight: 500;
}
@@ -194,7 +197,7 @@
margin: 0 0 28px;
}
.note-pin-status svg {
- color: var(--text-green, #2d6a4f);
+ color: var(--text-green);
}
.note-details {
margin: 28px 0 0;
@@ -298,7 +301,7 @@
font-size: 14px;
background: transparent;
border: 0;
- color: var(--text-green, #2d6a4f);
+ color: var(--text-green);
min-height: 44px;
padding: 0 8px;
cursor: pointer;
@@ -309,7 +312,7 @@
}
.note-feedback {
font-size: 12px;
- color: var(--text-green, #2d6a4f);
+ color: var(--text-green);
min-height: 18px;
margin: 12px 0;
}
@@ -333,7 +336,7 @@
.notes-layout button:focus-visible,
.notes-layout select:focus-visible,
.notes-layout input:focus-visible {
- outline: 2px solid var(--focus-ring, #2d6a4f);
+ outline: 2px solid var(--focus-ring);
outline-offset: 4px;
}
.is-reading .notes-grid {
@@ -368,9 +371,9 @@
}
.has-note-reader .a-side,
.has-note-reader .a-header,
- .has-note-reader .a-bottom,
- .has-note-reader .notes-board,
- .has-note-reader .a-more-sheet {
+ /* The phone app's layout keeps its tabs under a note (app.css). */
+ .has-note-reader .a-bottom:where(:not(.is-native *)),
+ .has-note-reader .notes-board {
display: none;
}
.note-reader {
@@ -444,7 +447,9 @@
color: var(--ink-soft);
background: #fffdfa;
}
- .notes-mobile-search input {
+ /* With .c-record-filter: QuickSearch.css styles the same field, and the
+ screens' stylesheets can arrive in either order. */
+ .notes-mobile-search.c-record-filter input {
width: 100%;
min-width: 0;
border: 0;
@@ -477,7 +482,7 @@
}
.is-dark .note-tile,
.is-dark .notes-toolbar select,
-.is-dark .notes-mobile-search {
+.is-dark .notes-mobile-search.c-record-filter {
background: #252c30;
}
.is-dark .note-tile.is-selected {
@@ -495,3 +500,4 @@
transition: none;
}
}
+}
diff --git a/apps/web/src/shell/ProductShell.tsx b/apps/web/src/shell/ProductShell.tsx
new file mode 100644
index 00000000..7c2942c0
--- /dev/null
+++ b/apps/web/src/shell/ProductShell.tsx
@@ -0,0 +1,231 @@
+import { Suspense, useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
+import { diagnosticsEnabled } from "../live/diagnostics";
+import { useApp } from "../state";
+import { Header, MoreSheet, PhoneTabs, Sidebar } from "./Shell";
+import { HomeEmptyScreen, HomeScreen, PlacesScreen } from "../screens/Home";
+import { HubEmptyScreen, HubScreen } from "../screens/Hub";
+import { ExpensesEmptyScreen, ExpensesScreen, SettleEvenScreen, SettleScreen } from "../screens/Money";
+import { Windows } from "../windows/Windows";
+import { cx } from "../ui/cx";
+import { PAGE_TITLES, type ScreenId } from "../data/household";
+import { COMMUNE_SCREENS } from "../nav";
+import { RecoveryBoundary, ScreenRecovery } from "./Recovery";
+import { isConnectionFailure } from "../live/connection";
+import { lazyComponent, preloadWhenIdle } from "./lazyComponent";
+import { usePageTitle } from "./pageTitle";
+
+// The signed-in app. App draws it only for someone signed in, and starts
+// fetching it while the session is checked on one of its addresses (never on
+// the sign-in pages). Home, Hub, Expenses and Settle (the phone's four tabs)
+// come with it; the other screens load when first opened, or earlier once the
+// browser is idle.
+const FundsScreen = lazyComponent(() => import("../screens/Funds").then((m) => m.FundsScreen));
+const RecurringScreen = lazyComponent(() => import("../screens/Recurring").then((m) => m.RecurringScreen));
+const TemplatesScreen = lazyComponent(() => import("../screens/Templates").then((m) => m.TemplatesScreen));
+const MembersScreen = lazyComponent(() => import("../screens/Members").then((m) => m.MembersScreen));
+const NotesScreen = lazyComponent(() => import("../screens/Notes").then((m) => m.NotesScreen));
+const OperationsScreen = lazyComponent(() => import("../screens/Operations").then((m) => m.OperationsScreen));
+const ActivityScreen = lazyComponent(() => import("../screens/House").then((m) => m.ActivityScreen));
+const AllActivityScreen = lazyComponent(() => import("../screens/Search").then((m) => m.AllActivityScreen));
+const SearchScreen = lazyComponent(() => import("../screens/Search").then((m) => m.SearchScreen));
+const SettingsScreen = lazyComponent(() => import("../screens/Settings").then((m) => m.SettingsScreen));
+const CommuneSettingsScreen = lazyComponent(() => import("../screens/Settings").then((m) => m.CommuneSettingsScreen));
+
+export function ProductShell() {
+ const { screen, navMode, moreOpen, native, dark, window: win, notes, noteKey, apiError, clearApiError, contentError, hub, refresh, searchTarget, unknownCommune } = useApp();
+ const noteReader = screen === "notes" && win === "note" && Boolean(notes[noteKey]);
+ const modalOpen = Boolean(win) && !noteReader;
+ usePageFocus(screen, hub.id, Boolean(win) || Boolean(searchTarget));
+ // The screens and windows still to come load once the browser is idle, so
+ // they open at once later.
+ useEffect(() => preloadWhenIdle(), []);
+ usePageTitle(unknownCommune ? "Commune not found" : (win && PAGE_TITLES[win]) || PAGE_TITLES[screen],
+ COMMUNE_SCREENS.has(screen) && !unknownCommune ? hub.name : undefined);
+ return (
+
+
+
Skip to content
+
+
+
+ {diagnosticsEnabled() ?
: null}
+ {apiError ?
{isConnectionFailure(apiError) ? <>Connection interrupted. Your last loaded view is still available. void refresh()}>Try again > : apiError}×
: null}
+ {contentError && hub.activityState === "ready" ?
Could not refresh this commune. Showing its last loaded activity. void refresh()}>Try again
: null}
+
+ }>
+
+
+
+
+
+
+
+
+ );
+}
+
+// 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);
+ const [result, setResult] = useState("");
+ async function check() {
+ setRunning(true);
+ setResult("");
+ try {
+ const { inspectMembershipRead } = await import("../live/neon");
+ const rows = [];
+ for (let round = 1; round <= 5; round++) {
+ rows.push({ round, reads: await inspectMembershipRead(hubId) });
+ }
+ setResult(JSON.stringify(rows, null, 2));
+ } catch {
+ setResult("Connection test could not complete.");
+ } finally { setRunning(false); }
+ }
+ return
+ Read-only connection diagnostics
+ void check()}>{running ? "Checking…" : "Compare membership reads"}
+ {result}
+ ;
+}
+
+function Screen({ screen }: { screen: ScreenId }) {
+ return (
+
+ );
+}
+
+// The page's own skeleton, while its records load or a screen's code arrives.
+function ContentSkeleton({ label, children }: { label: string; children?: ReactNode }) {
+ return ;
+}
+
+function ScreenBody({ screen }: { screen: ScreenId }) {
+ const { session, emptyHub, hub, contentLoading, contentError, refresh, unknownCommune, go } = useApp();
+ const hasHubs = Boolean(session?.hasHubs);
+ const hasSettlement = hub.moves.some((move) => !move.even);
+
+ // Never another commune's records in its place.
+ if (unknownCommune) {
+ return This commune isn’t available to you. You may have left it, or the link is for a different account.
go("home")}>Go to Home ;
+ }
+
+ if (contentLoading && screen !== "settings" && screen !== "search") {
+ if (contentError) return Could not load {hub.name} {contentError}
void refresh()}>Try again ;
+ return {screen === "hub" && {hub.name} {hub.switchMeta}
} ;
+ }
+
+ switch (screen) {
+ case "home":
+ return hasHubs ? : ;
+ case "home-empty":
+ return hasHubs ? : ;
+ case "home-inbox":
+ return hasHubs ? : ;
+ case "communes":
+ return ;
+ case "hub":
+ return !hasHubs ? : emptyHub ? : ;
+ case "hub-empty":
+ return emptyHub || !hasHubs ? : ;
+ case "commune-settings":
+ return ;
+ case "expenses":
+ return emptyHub ? : ;
+ case "expenses-empty":
+ return emptyHub || !hasHubs ? (
+
+ ) : (
+
+ );
+ case "settle":
+ return !hasSettlement || !hasHubs ? (
+
+ ) : (
+
+ );
+ case "settle-even":
+ return !hasSettlement || !hasHubs ? (
+
+ ) : (
+
+ );
+ case "recurring":
+ return ;
+ case "funds":
+ return ;
+ case "templates":
+ return ;
+ case "members":
+ return ;
+ case "operations":
+ return ;
+ case "notes":
+ return ;
+ case "activity":
+ return ;
+ case "home-activity":
+ return ;
+ case "search":
+ return ;
+ case "settings":
+ return ;
+ default:
+ return hasHubs ? : ;
+ }
+}
diff --git a/apps/web/src/shell/connection-recovery.css b/apps/web/src/shell/connection-recovery.css
index 29c19340..d60d9bc1 100644
--- a/apps/web/src/shell/connection-recovery.css
+++ b/apps/web/src/shell/connection-recovery.css
@@ -1,3 +1,6 @@
+/* In the screens layer, above the shared base styles and below the dark
+ theme and the design-language primitives (see styles/index.css). */
+@layer screens {
.c-connection-recovery {
min-height: 100dvh;
box-sizing: border-box;
@@ -14,10 +17,11 @@
padding: 32px; background: var(--white); border: 1px solid var(--border);
border-radius: var(--radius-surface);
}
-.c-connection-icon { color: var(--soft); margin-bottom: 20px; }
+.c-connection-icon { color: var(--ink-soft); margin-bottom: 20px; }
.c-connection-card h1 { font-size: 21px; line-height: 1.3; font-weight: 550; letter-spacing: -.4px; margin: 0 0 12px; }
-.c-connection-card p { font-size: 14px; line-height: 1.6; color: var(--soft); margin: 0 0 24px; }
+.c-connection-card p { font-size: 14px; line-height: 1.6; color: var(--ink-soft); margin: 0 0 24px; }
.c-connection-card .a-btn { border-radius: var(--radius-control); min-height: 40px; }
.c-connection-actions { display: flex; flex-wrap: wrap; gap: 10px; }
-.c-connection-feedback { min-height: 36px; padding-top: 16px; font-size: 12px; line-height: 1.5; color: var(--soft); }
+.c-connection-feedback { min-height: 36px; padding-top: 16px; font-size: 12px; line-height: 1.5; color: var(--ink-soft); }
@media (max-width: 480px) { .c-connection-card { padding: 24px; } }
+}
diff --git a/apps/web/src/shell/lazyComponent.tsx b/apps/web/src/shell/lazyComponent.tsx
new file mode 100644
index 00000000..ffe7ab0a
--- /dev/null
+++ b/apps/web/src/shell/lazyComponent.tsx
@@ -0,0 +1,82 @@
+import { lazy, useState, type ComponentType } from "react";
+
+export type LazyComponent = ComponentType
& {
+ /** Starts loading the code now, so it is ready when it is first drawn. */
+ preload: () => Promise;
+};
+
+// Every component made here, so that preloadAll can find the code still to come.
+const everyComponent = new Set<{ preload: () => Promise }>();
+
+// A browser keeps a failed import() failed for the life of the page, so code
+// that could not load (a release replaced it, or the connection dropped) only
+// loads again with a fresh page. Reload once; if that was moments ago, let the
+// error reach the recovery card rather than loading the page over and over.
+const RELOADED_AT = "commune.codeReloadedAt";
+function reloadForCode(): boolean {
+ try {
+ if (Date.now() - Number(sessionStorage.getItem(RELOADED_AT) ?? 0) < 30_000) return false;
+ sessionStorage.setItem(RELOADED_AT, String(Date.now()));
+ } catch {
+ return false;
+ }
+ window.location.reload();
+ return true;
+}
+
+/**
+ * A component whose code loads when it is first drawn, or earlier through
+ * preload(). The nearest Suspense fallback shows while it loads; once the
+ * code is here, it draws straight away.
+ */
+export function lazyComponent(load: () => Promise>): LazyComponent {
+ let loaded: ComponentType
| null = null;
+ let loading: Promise> | null = null;
+ const preload = () => (loading ??= load().then((component) => (loaded = component)));
+ const Lazy = lazy(() => preload().then(
+ (component) => ({ default: component }),
+ (error: unknown) => {
+ // Stay on the loading state until the page reloads.
+ if (reloadForCode()) return new Promise(() => {});
+ throw error;
+ },
+ ));
+ function LazyComponent(props: P) {
+ // Chosen once for each use, so it never swaps underneath its own state.
+ const [Component] = useState>(() => loaded ?? Lazy);
+ return ;
+ }
+ const component = Object.assign(LazyComponent, { preload });
+ everyComponent.add(component);
+ return component;
+}
+
+/**
+ * Loads the code of every component made here so far, and of the components
+ * that code makes in turn (the signed-in app brings its own screens).
+ */
+export async function preloadAll(): Promise {
+ const started = new Set<{ preload: () => Promise }>();
+ for (;;) {
+ const next = [...everyComponent].filter((component) => !started.has(component));
+ if (!next.length) return;
+ for (const component of next) started.add(component);
+ await Promise.all(next.map((component) => component.preload()));
+ }
+}
+
+/**
+ * Loads the code still to come once the browser has nothing else to do,
+ * unless the browser asks to save data. Returns a cancel for an effect.
+ */
+export function preloadWhenIdle(): () => void {
+ if ((navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData) return () => {};
+ // A failure here is left for the first real use, which can reload the page.
+ const run = () => { preloadAll().catch(() => {}); };
+ if (typeof window.requestIdleCallback === "function") {
+ const id = window.requestIdleCallback(run, { timeout: 5000 });
+ return () => window.cancelIdleCallback(id);
+ }
+ const id = window.setTimeout(run, 2000);
+ return () => window.clearTimeout(id);
+}
diff --git a/apps/web/src/shell/pageTitle.ts b/apps/web/src/shell/pageTitle.ts
new file mode 100644
index 00000000..74484901
--- /dev/null
+++ b/apps/web/src/shell/pageTitle.ts
@@ -0,0 +1,7 @@
+import { useEffect } from "react";
+
+// "Expenses · Flat 4 · Commune": short, most specific first.
+export function usePageTitle(...parts: Array) {
+ const title = [...parts.filter(Boolean), "Commune"].join(" · ");
+ useEffect(() => { document.title = title; }, [title]);
+}
diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css
index 8a16f64c..e7a3a200 100644
--- a/apps/web/src/styles/app.css
+++ b/apps/web/src/styles/app.css
@@ -4,93 +4,6 @@
background: linear-gradient(180deg, #f8f5f0 0%, #f3eee7 100%);
}
-.a-body.is-gate .a-app {
- display: none;
-}
-
-.a-gate {
- display: none;
- min-height: 100vh;
- padding: 48px 20px 64px;
- background: linear-gradient(180deg, #1a1e2b 0%, #21262f 100%);
- color: #f5f1ea;
-}
-
-.a-body.is-gate .a-gate {
- display: grid;
- place-items: start center;
-}
-
-.a-gate-card {
- width: 100%;
- max-width: 420px;
- margin-top: 8vh;
-}
-
-.a-gate-brand {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 28px;
-}
-
-.a-gate-mark {
- width: 52px;
- height: 52px;
- border-radius: 14px;
- background: #12151e;
- display: grid;
- place-items: center;
-}
-
-.a-gate-mark img {
- width: 36px;
- height: 36px;
-}
-
-.a-gate-brand span {
- font-size: 14px;
- font-weight: 700;
- letter-spacing: 0.14em;
- text-transform: uppercase;
- color: #f5f1ea;
-}
-
-.a-gate-card h1 {
- margin: 0 0 8px;
- font-size: 1.7rem;
- letter-spacing: -0.04em;
- font-weight: 800;
- color: #f5f1ea;
-}
-
-.a-gate-card .lede {
- margin: 0 0 22px;
- font-size: 14px;
- line-height: 1.45;
- color: rgba(245, 241, 234, 0.62);
-}
-
-.a-gate-card .a-field-row {
- margin-top: 8px;
-}
-
-.a-gate-alt {
- margin: 18px 0 0;
- font-size: 13px;
- color: rgba(245, 241, 234, 0.55);
-}
-
-.a-gate-alt button {
- border: 0;
- background: none;
- padding: 0;
- font: inherit;
- font-weight: 700;
- color: #d9ebe5;
- cursor: pointer;
-}
-
.a-empty {
max-width: 440px;
padding: 12px 0 8px;
@@ -109,22 +22,6 @@
line-height: 1.5;
}
-.a-name-btn {
- margin: 0;
- padding: 0;
- border: 0;
- background: none;
- font: inherit;
- font-weight: 700;
- color: inherit;
- cursor: pointer;
- text-align: left;
-}
-
-.a-name-btn:hover {
- color: var(--sage-strong);
-}
-
.a-split {
display: grid;
gap: 0;
@@ -147,10 +44,6 @@
min-width: 0;
}
-.a-cover-empty {
- margin-top: 16px;
-}
-
.a-app {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
@@ -158,7 +51,7 @@
}
.a-side {
- background: #1a1e2b;
+ background: var(--navy);
color: #f8f5f0;
padding: 27px 16px 20px;
display: flex;
@@ -239,11 +132,11 @@
width: 28px;
height: 28px;
border-radius: 8px;
- background: linear-gradient(135deg, #d7e6dd, #1b4332);
+ background: linear-gradient(135deg, var(--sage-mist), var(--sage-strong));
display: grid;
place-items: center;
flex-shrink: 0;
- color: #1b4332;
+ color: var(--sage-strong);
}
.a-hub-switch strong {
@@ -756,63 +649,10 @@
}
.a-btn.nudge {
- background: #1f2330;
+ background: var(--navy-2);
color: #f5f1ea;
}
-.a-choose {
- position: relative;
- flex-shrink: 0;
-}
-
-.a-choose .a-btn {
- gap: 6px;
-}
-
-.a-choose .a-btn svg {
- width: 14px;
- height: 14px;
- flex-shrink: 0;
-}
-
-.a-choose-menu {
- position: absolute;
- right: 0;
- top: calc(100% + 6px);
- z-index: 24;
- display: grid;
- min-width: 200px;
- padding: 6px;
- background: #fff;
- border: 1px solid var(--border);
- border-radius: 14px;
- box-shadow: 0 12px 32px rgba(26, 30, 46, 0.18);
-}
-
-.a-choose-menu[hidden] {
- display: none;
-}
-
-.a-choose-menu button {
- width: 100%;
- margin: 0;
- padding: 10px 12px;
- border: 0;
- border-radius: 10px;
- background: transparent;
- font: inherit;
- font-size: 14px;
- font-weight: 650;
- color: var(--ink);
- text-align: left;
- cursor: pointer;
-}
-
-.a-choose-menu button:hover {
- background: var(--sage-soft);
- color: var(--sage-strong);
-}
-
.a-header .a-icon-btn.a-you-btn {
display: none;
padding: 0;
@@ -971,10 +811,6 @@
}
}
-.c-product-dialog.c-discard-dialog {
- width: min(420px, calc(100vw - 32px));
-}
-
.c-discard-dialog p {
margin: 0 0 20px;
color: var(--ink-soft);
@@ -982,9 +818,9 @@
line-height: 1.5;
}
-:is(.a-cover h1, .a-place-hero :is(h2, h3), .a-focus-hero h2,
+:is(.a-place-hero :is(h2, h3), .a-focus-hero h2,
.a-set-row-copy strong, .a-commune-set h2, .a-item strong,
- .a-member-row .who strong, .a-name-btn, .a-home-greet h1) {
+ .a-member-row .who strong) {
overflow-wrap: anywhere;
}
@@ -1060,21 +896,6 @@
line-height: 1.45;
}
-.a-set-meter {
- margin: 14px 0 6px;
- height: 6px;
- background: var(--cream, #f5f1ea);
- border-radius: 99px;
- overflow: hidden;
-}
-
-.a-set-meter i {
- display: block;
- height: 100%;
- background: var(--sage, #2d6a4f);
- border-radius: 99px;
-}
-
.a-set-meter-meta {
display: flex;
justify-content: space-between;
@@ -1088,15 +909,6 @@
color: var(--ink);
}
-.a-btn-upgrade {
- width: 100%;
- justify-content: center;
- height: 44px;
- background: var(--sage);
- color: #fff;
- font-weight: 750;
-}
-
.a-set-group {
margin: 0 0 22px;
}
@@ -1174,13 +986,6 @@ div.a-set-row {
line-height: 1.4;
}
-.a-set-chev {
- width: 16px;
- height: 16px;
- color: var(--ink-faint, #b8b3aa);
- flex-shrink: 0;
-}
-
.a-set-val {
font-size: 13px;
color: var(--ink-soft);
@@ -1230,7 +1035,7 @@ div.a-set-row {
.a-switch.is-on,
.a-switch[aria-checked="true"] {
- background: var(--ink, #1a1e2b);
+ background: var(--ink);
}
.a-switch.is-on::after,
@@ -1290,7 +1095,7 @@ div.a-set-row {
height: 32px;
border: 0;
border-radius: 999px;
- background: #1a1e2b;
+ background: var(--navy);
color: #fff;
display: grid;
place-items: center;
@@ -1367,13 +1172,6 @@ div.a-set-row {
margin-top: 2px;
}
-.a-more-chev {
- width: 16px;
- height: 16px;
- color: var(--ink-faint, #b8b3aa);
- flex-shrink: 0;
-}
-
.a-you-btn .avatar {
width: 100%;
height: 100%;
@@ -1381,11 +1179,6 @@ div.a-set-row {
font-size: 11px;
}
-.a-native-status,
-.a-native-home {
- display: none;
-}
-
.a-icon-btn {
position: relative;
width: 38px;
@@ -1421,3757 +1214,1741 @@ div.a-set-row {
box-shadow: 0 0 0 2px #fff;
}
-.a-avatar-btn {
- margin: 0;
- padding: 0;
- border: 0;
- background: none;
- cursor: pointer;
- border-radius: 999px;
- line-height: 0;
-}
-
.a-content {
padding: 24px 28px 40px;
container-type: inline-size;
container-name: a-page;
}
-@container a-page (max-width: 640px) {
- #expenses .a-panel:has(.a-table) {
- overflow: visible;
- background: transparent;
- border: 0;
- box-shadow: none;
- padding: 0;
- }
- #expenses .a-table {
- display: block;
- }
- #expenses .a-table thead {
- display: none;
- }
- #expenses .a-table tbody,
- #expenses .a-table tr,
- #expenses .a-table td {
- display: block;
- width: 100%;
- }
- #expenses .a-table tbody {
- display: flex;
- flex-direction: column;
- gap: 10px;
- }
- #expenses .a-table tr {
- background: #fff;
- border: 1px solid var(--border);
- border-radius: 16px;
- padding: 14px 16px 12px;
- box-shadow: var(--shadow-sm);
- }
- #expenses .a-table td {
- display: flex;
- justify-content: space-between;
- align-items: baseline;
- gap: 12px;
- padding: 6px 0;
- border: 0;
- text-align: right;
- width: auto;
- white-space: normal;
- }
- #expenses .a-table td::before {
- content: attr(data-label);
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.06em;
- text-transform: uppercase;
- color: var(--ink-soft);
- text-align: left;
- flex-shrink: 0;
- }
- #expenses .a-table td:first-child {
- display: block;
- text-align: left;
- padding-bottom: 8px;
- font-size: 15px;
- }
- #expenses .a-table td:first-child::before {
- display: none;
- }
- #expenses .a-table td:last-child {
- justify-content: flex-end;
- padding-top: 10px;
- width: auto;
- }
- #expenses .a-table td:last-child::before {
- display: none;
- }
- #expenses .a-table .a-row-actions,
- #expenses .a-table .a-choose {
- min-width: 0;
- width: 100%;
- }
- #expenses .a-table .a-row-actions .a-btn,
- #expenses .a-table .a-choose .a-btn {
- width: 100%;
- }
- #expenses .a-table .amt {
- text-align: right;
- }
-}
-
.screen[hidden] {
display: none !important;
}
-.a-hero {
- position: relative;
- overflow: hidden;
- background:
- radial-gradient(
- circle at top right,
- rgba(232, 225, 239, 0.18),
- transparent 32%
- ),
- linear-gradient(135deg, #1f2330 0%, #282d3b 100%);
- color: #f8f5f0;
- border: 1px solid rgba(255, 255, 255, 0.06);
- box-shadow: var(--shadow);
+.a-panel {
+ background: rgba(255, 255, 255, 0.82);
+ border: 1px solid var(--border);
+ box-shadow: var(--shadow-sm);
border-radius: var(--radius);
- padding: 28px 32px;
- display: grid;
- grid-template-columns: minmax(0, 1.3fr) minmax(260px, 340px);
- gap: 24px;
+ padding: 18px 20px;
}
-.a-hero::after {
- content: "";
- position: absolute;
- inset: auto -10% -42% auto;
- width: 280px;
- height: 280px;
- border-radius: 50%;
- background: radial-gradient(
- circle,
- rgba(216, 235, 228, 0.24) 0%,
- transparent 68%
- );
- pointer-events: none;
+.a-panel h2 {
+ margin: 0;
+ font-size: 0.95rem;
+ font-weight: 700;
}
-.a-hero-chip {
- width: fit-content;
- padding: 6px 12px;
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.08);
- border: 1px solid rgba(255, 255, 255, 0.08);
- font-size: 12px;
- font-weight: 600;
+.a-panel .sub {
+ margin: 4px 0 16px;
+ font-size: 13px;
+ color: var(--ink-soft);
}
-.a-hero h1 {
- margin: 14px 0 6px;
- font-size: 2rem;
- letter-spacing: -0.03em;
- font-weight: 800;
+.a-settle-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 14px 0;
+ border-bottom: 1px solid rgba(23, 27, 36, 0.05);
+ font-size: 13.5px;
}
-.a-hero h1 em {
- font-style: normal;
- color: #d9ebe5;
+.a-settle-row:last-child,
+.a-settle-row:has(+ .a-btn) {
+ border-bottom: 0;
}
-.a-hero p {
- margin: 0 0 18px;
- color: rgba(255, 250, 246, 0.72);
- font-size: 15px;
- max-width: 36rem;
+.a-settle-row .avatar {
+ width: 32px;
+ height: 32px;
}
-.a-hero-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.a-hero-actions .a-btn {
- position: relative;
- z-index: 1;
+.a-settle-row .amt {
+ margin-left: auto;
+ font-weight: 750;
+ font-size: 1.15rem;
+ letter-spacing: -0.02em;
}
-.a-hero-aside {
- position: relative;
- z-index: 1;
- padding: 16px;
- border-radius: 14px;
- background: rgba(255, 255, 255, 0.07);
- border: 1px solid rgba(255, 255, 255, 0.09);
+.a-settle-row button.a-btn {
+ flex-shrink: 0;
}
-.a-hero-aside .top {
+/* This Hub — D Home photo treatment, A chrome */
+
+.a-cover-people {
display: flex;
- justify-content: space-between;
- align-items: flex-start;
- margin-bottom: 12px;
+ align-items: center;
+ gap: 10px;
+ margin-top: 16px;
+ font-size: 13px;
+ font-weight: 600;
+ color: rgba(248, 245, 240, 0.92);
}
-.a-hero-aside .lbl {
- font-size: 12px;
- color: rgba(255, 250, 246, 0.55);
+.a-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 14px;
}
-.a-hero-aside .big {
- font-size: 1.55rem;
- font-weight: 800;
+.a-toolbar .a-filters {
+ margin-bottom: 0;
+ flex: 1;
+ min-width: 0;
}
-.a-aside-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 8px;
+.a-toolbar .a-btn {
+ margin-left: auto;
+ flex-shrink: 0;
}
-.a-aside-stat {
- padding: 10px 12px;
- border-radius: 10px;
- background: rgba(255, 255, 255, 0.06);
+.a-filters {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 16px;
}
-.a-aside-stat span {
- display: block;
- font-size: 10px;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: rgba(255, 250, 246, 0.5);
+.a-chip {
+ height: 32px;
+ padding: 0 12px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: #fff;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ color: var(--ink-soft);
}
-.a-aside-stat strong {
- font-size: 15px;
+.a-chip.is-on,
+.a-chip:hover {
+ background: var(--sage-soft);
+ color: var(--sage-strong);
+ border-color: transparent;
}
-.a-progress {
- margin-top: 12px;
-}
+/* Phones: a 44px touch area around each chip, without changing its look. */
+@media (max-width: 560px) {
+ .a-chip {
+ position: relative;
+ }
-.a-progress .bar {
- height: 8px;
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.1);
- overflow: hidden;
-}
+ .a-chip::before {
+ content: "";
+ position: absolute;
+ inset: -7px -2px;
+ }
-.a-progress .bar i {
- display: block;
- height: 100%;
- width: 89%;
- background: var(--sage, #2d6a4f);
- border-radius: 999px;
-}
+ /* Room for that area if the chip row scrolls sideways. */
+ .a-toolbar .a-filters {
+ padding-block: 6px;
+ margin-block: -6px;
+ }
-.a-kpis {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 12px;
- margin-top: 16px;
+ /* If the chips run under the filter field or the main button on a narrow
+ screen, those stay on top, as before. */
+ .a-toolbar:has(> .c-record-filter) > :is(.c-record-filter, .a-btn) {
+ position: relative;
+ }
}
-.a-kpis > * {
- min-width: 0;
+.a-reminder-usage {
+ position: relative;
+ width: fit-content;
+ max-width: 100%;
+ flex: 0 0 auto;
+ color: var(--ink-soft);
+ font-size: 12px;
}
-button.a-kpi {
- display: block;
- width: 100%;
- margin: 0;
- border: 1px solid var(--border-strong);
- font: inherit;
- color: inherit;
- text-align: left;
+.a-reminder-usage summary {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 34px;
+ padding: 6px 12px;
cursor: pointer;
- appearance: none;
- -webkit-appearance: none;
+ list-style: none;
+ border: 1px solid var(--border);
+ border-radius: 999px;
}
-.a-kpi {
- padding: 16px 16px 14px;
- border-radius: 16px;
- background: rgba(255, 255, 255, 0.86);
- border: 1px solid var(--border-strong);
- box-shadow: var(--shadow-sm);
+.a-reminder-usage summary::-webkit-details-marker { display: none; }
+.a-reminder-usage summary::after { content: "⌄"; content: "⌄" / ""; }
+.a-reminder-usage summary strong { color: var(--ink); }
+.a-reminder-usage summary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px; }
+.a-reminder-usage-details {
+ position: absolute;
+ right: 0;
+ top: calc(100% + 8px);
+ z-index: 5;
+ width: min(280px, calc(100vw - 48px));
+ padding: 16px;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: var(--paper);
}
+.a-window > .a-reminder-usage { margin: 12px 0; }
+.a-window > .a-reminder-usage .a-reminder-usage-details,
+.a-toolbar > .a-reminder-usage .a-reminder-usage-details { right: auto; left: 0; }
-.a-kpi.sage {
- background: linear-gradient(
- 180deg,
- rgba(216, 235, 228, 0.5),
- rgba(255, 255, 255, 0.92)
- );
-}
-.a-kpi.peach {
- background: linear-gradient(
- 180deg,
- rgba(239, 217, 200, 0.48),
- rgba(255, 255, 255, 0.92)
- );
-}
-.a-kpi.lilac {
- background: linear-gradient(
- 180deg,
- rgba(231, 222, 241, 0.52),
- rgba(255, 255, 255, 0.92)
- );
+.a-set-card .a-reminder-usage--account {
+ margin-top: 18px;
+ width: 100%;
}
-.a-kpi.ink {
- background: linear-gradient(
- 180deg,
- rgba(31, 26, 42, 0.06),
- rgba(255, 255, 255, 0.92)
- );
+.a-reminder-usage--account summary { width: fit-content; }
+.a-reminder-usage--account .a-reminder-usage-details {
+ position: static;
+ width: 100%;
+ margin-top: 8px;
}
+.a-content-loading { padding: 28px 0; }
+.a-content-loading p { color: var(--ink-soft); }
-.a-kpi span {
+.a-reminder-usage-copy > span {
display: block;
- font-size: 12px;
- font-weight: 600;
color: var(--ink-soft);
+ font-size: 12px;
+ font-weight: 700;
}
-.a-kpi strong {
+.a-reminder-usage-copy strong {
display: block;
- margin-top: 6px;
- font-size: 1.45rem;
- letter-spacing: -0.03em;
- font-weight: 800;
+ margin-top: 2px;
+ color: var(--ink);
+ font-size: 22px;
+ letter-spacing: -0.04em;
}
-.a-kpi small {
- display: block;
- margin-top: 4px;
- font-size: 12px;
+.a-reminder-usage-copy strong small {
color: var(--ink-soft);
+ font-size: 14px;
+ font-weight: 600;
+ letter-spacing: 0;
}
-.a-budget {
- margin-top: 16px;
- padding: 14px 16px;
- border-radius: 14px;
- background: #fff;
- border: 1px solid var(--border);
+.a-reminder-usage-copy p {
+ margin: 3px 0 0;
+ color: var(--ink-soft);
+ font-size: 12px;
}
-.a-budget .top {
- display: flex;
- justify-content: space-between;
- font-size: 13px;
- margin-bottom: 8px;
+.a-reminder-usage-side {
+ width: 100%;
+ margin-top: 12px;
+ flex: 0 0 auto;
+ text-align: left;
+ color: var(--ink-soft);
+ font-size: 12px;
}
-.a-budget .bar {
- height: 8px;
- border-radius: 999px;
- background: rgba(23, 27, 36, 0.08);
+.a-reminder-usage-track {
+ height: 7px;
+ margin-bottom: 5px;
overflow: hidden;
+ border-radius: 999px;
+ background: var(--sage-mist);
}
-.a-budget .bar i {
+.a-reminder-usage-track > span {
display: block;
height: 100%;
- width: 89%;
- background: var(--sage, #2d6a4f);
- border-radius: 999px;
-}
-
-.a-grid {
- display: grid;
- grid-template-columns: minmax(0, 1.55fr) minmax(300px, 0.95fr);
- gap: 16px;
- margin-top: 16px;
-}
-
-.a-panel {
- background: rgba(255, 255, 255, 0.82);
- border: 1px solid var(--border);
- box-shadow: var(--shadow-sm);
- border-radius: var(--radius);
- padding: 18px 20px;
-}
-
-.a-panel h2 {
- margin: 0;
- font-size: 0.95rem;
- font-weight: 700;
+ border-radius: inherit;
+ background: var(--sage);
}
-.a-panel .sub {
- margin: 4px 0 16px;
- font-size: 13px;
+.a-reminder-recipient-count {
+ display: block;
+ margin-top: 3px;
color: var(--ink-soft);
+ font-size: 12px;
}
-.a-panel-head {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- gap: 12px;
+#settle .a-move .amt {
+ font-size: 1.45rem;
+ font-weight: 800;
+ letter-spacing: -0.04em;
}
-.a-bars {
+.a-notice {
display: flex;
- align-items: flex-end;
- gap: 10px;
- height: 148px;
- padding-top: 8px;
-}
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ padding: 14px 0;
+ border: 0;
+ border-bottom: 1px solid rgba(23, 27, 36, 0.05);
+ background: transparent;
+ font: inherit;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+}
-.a-bars i {
- flex: 1;
- border-radius: 6px 6px 2px 2px;
- background: linear-gradient(180deg, #62c38a, #1b4332);
- opacity: 0.85;
+.a-notice:last-child {
+ border-bottom: 0;
}
-.a-bars.lbl {
- display: flex;
- justify-content: space-between;
- height: auto;
- margin-top: 8px;
- font-size: 11px;
+.a-notice .when {
+ font-size: 12px;
color: var(--ink-soft);
+ margin-top: 2px;
}
-.a-table {
- width: 100%;
- border-collapse: collapse;
+.a-notice .grow {
+ flex: 1;
+ min-width: 0;
}
-.a-table th {
- text-align: left;
+.a-field {
+ display: grid;
+ gap: 6px;
+ padding: 14px 0;
+ border-bottom: 1px solid rgba(23, 27, 36, 0.05);
+}
+
+.a-field:last-of-type {
+ border-bottom: 0;
+}
+
+.a-field label {
font-size: 11px;
- letter-spacing: 0.06em;
+ font-weight: 700;
+ letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-soft);
- font-weight: 650;
- padding: 0 8px 10px;
- border-bottom: 1px solid var(--border);
}
-.a-table td {
- padding: 14px 8px;
- border-bottom: 1px solid rgba(23, 27, 36, 0.05);
- font-size: 13.5px;
+.a-field-optional {
+ font-weight: 500;
+ letter-spacing: normal;
+ text-transform: none;
}
-.a-table tbody tr:hover td {
- background: rgba(45, 106, 79, 0.04);
+.a-field input,
+.a-field select,
+.a-field textarea {
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+ padding: 0 12px;
+ border-radius: var(--radius-control);
+ border: 1px solid var(--border);
+ background: #fff;
+ font: inherit;
+ font-size: 14px;
+ color: var(--ink);
}
-.a-table tr:last-child td {
- border-bottom: 0;
+.a-field input,
+.a-field select {
+ height: 40px;
}
-.a-table .amt {
- text-align: right;
- font-weight: 700;
- font-size: 1.15rem;
- letter-spacing: -0.02em;
+/* Choice controls must not inherit the full-width text-field dimensions. */
+.a-field input[type="checkbox"],
+.a-field input[type="radio"] {
+ flex: 0 0 18px;
+ width: 18px;
+ height: 18px;
+ margin: 0;
+ padding: 0;
+ accent-color: var(--sage);
}
-.a-cats {
- display: flex;
- flex-direction: column;
- gap: 10px;
+.a-field label.a-settle-row {
+ justify-content: flex-start;
+ font-size: 14px;
+ font-weight: 500;
+ letter-spacing: normal;
+ text-transform: none;
+ color: var(--ink);
}
-.a-cat {
- display: grid;
- grid-template-columns: 1fr auto;
- gap: 4px 12px;
+.a-field label.a-settle-row .amt { margin-left: auto; }
+
+.a-window .a-field label {
font-size: 13px;
+ letter-spacing: normal;
+ text-transform: none;
+ color: var(--ink);
}
-.a-cat .track {
- grid-column: 1 / -1;
- height: 6px;
- border-radius: 999px;
- background: rgba(23, 27, 36, 0.06);
- overflow: hidden;
+.a-field textarea {
+ min-height: 96px;
+ padding: 10px 12px;
+ resize: vertical;
+ line-height: 1.45;
}
-.a-cat .track i {
- display: block;
- height: 100%;
- background: var(--sage, #2d6a4f);
- border-radius: 999px;
+.a-field-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 16px;
}
-.a-settle-row {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 14px 0;
- border-bottom: 1px solid rgba(23, 27, 36, 0.05);
- font-size: 13.5px;
+.a-field label .need {
+ margin-left: 8px;
+ letter-spacing: 0.02em;
+ text-transform: none;
+ font-weight: 650;
+ color: var(--sage);
}
-.a-settle-row:last-child,
-.a-settle-row:has(+ .a-btn) {
- border-bottom: 0;
+.a-seg {
+ display: flex;
+ gap: 4px;
+ padding: 4px;
+ width: fit-content;
+ max-width: 100%;
+ background: #fff;
+ border: 1px solid var(--border);
+ border-radius: 999px;
}
-.a-settle-row .avatar {
- width: 32px;
+.a-seg button {
height: 32px;
+ padding: 0 14px;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ font: inherit;
+ font-size: 13px;
+ font-weight: 650;
+ color: var(--ink-soft);
+ cursor: pointer;
}
-.a-settle-row .amt {
- margin-left: auto;
- font-weight: 750;
- font-size: 1.15rem;
- letter-spacing: -0.02em;
+.a-seg button.is-on {
+ background: var(--sage);
+ color: #fff;
}
-.a-settle-row button.a-btn {
- flex-shrink: 0;
+.a-field .hint {
+ margin: 4px 0 0;
+ font-size: 12px;
+ line-height: 1.45;
+ color: var(--ink-soft);
}
-.a-attention {
+.a-member-row {
display: flex;
- gap: 10px;
- padding: 14px;
- border-radius: 12px;
- background: #fbf3ea;
- border: 1px solid rgba(234, 166, 129, 0.28);
- font-size: 13px;
- margin-top: 12px;
align-items: center;
+ gap: 16px;
+ padding: 18px 0;
+ border-bottom: 1px solid rgba(23, 27, 36, 0.06);
}
-.a-attention .avatar {
- width: 32px;
- height: 32px;
+.a-member-row:last-child {
+ border-bottom: 0;
}
-/* This Hub — D Home photo treatment, A chrome */
-.a-cover {
- position: relative;
- isolation: isolate;
- min-height: 320px;
- border-radius: var(--radius);
- overflow: hidden;
- padding: 28px 28px 26px;
- display: flex;
- flex-direction: column;
- justify-content: flex-end;
- color: #f8f5f0;
- background: #1a1e2b;
- border: 0;
+.a-member-row .avatar {
+ width: 36px;
+ height: 36px;
}
-.a-cover::before {
- content: "";
- position: absolute;
- inset: 0;
- z-index: 0;
- transform: scale(1.03);
- transform-origin: center;
+.a-member-row .who strong {
+ display: block;
+ font-size: 1.05rem;
+ font-weight: 750;
+ letter-spacing: -0.02em;
}
-.a-cover::after {
- content: "";
- position: absolute;
- inset: 0;
- z-index: 1;
- pointer-events: none;
- background: linear-gradient(
- 180deg,
- rgba(26, 30, 46, 0.08) 0%,
- rgba(26, 30, 46, 0.68) 100%
- );
+.a-member-row .who span {
+ font-size: 13px;
+ color: var(--ink-soft);
}
-.a-cover > * {
- position: relative;
- z-index: 2;
+.a-member-row .bal {
+ margin-left: auto;
+ font-weight: 800;
+ font-size: 1.45rem;
+ letter-spacing: -0.04em;
}
-.a-cover > .a-cover-settings {
- position: absolute;
- top: 16px;
- right: 16px;
- z-index: 3;
- display: inline-flex;
- align-items: center;
- gap: 6px;
+#operations .a-settle-row,
+#notes .a-settle-row,
+#activity .a-settle-row,
+#hub .a-settle-row {
+ padding: 18px 0;
+ gap: 16px;
}
-.a-cover .type {
- width: fit-content;
- height: 26px;
- padding: 0 10px;
- border-radius: 999px;
- display: inline-flex;
- align-items: center;
- background: rgba(245, 241, 234, 0.16);
- border: 1px solid rgba(245, 241, 234, 0.2);
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.12em;
- text-transform: uppercase;
+#operations .a-settle-row .pill {
+ margin-left: auto;
}
-.a-cover h1 {
- margin: 10px 0 6px;
- font-size: 2.15rem;
- letter-spacing: -0.03em;
- font-weight: 800;
- color: #f8f5f0;
+#operations .a-settle-row .avatar,
+#activity .a-settle-row .avatar,
+#hub .a-settle-row .avatar {
+ width: 36px;
+ height: 36px;
}
-.a-cover .lede {
- margin: 0;
- max-width: 34rem;
- color: rgba(248, 245, 240, 0.82);
- font-size: 15px;
-}
+#operations .a-settle-row strong,
+#notes .a-settle-row strong,
+#activity .a-settle-row strong {
+ font-size: 1.05rem;
+ font-weight: 750;
+ letter-spacing: -0.02em;
+}
-.a-cover-location {
+.a-fund-hero {
display: grid;
- gap: 3px;
- margin: 8px 0 0;
- max-width: 36rem;
- color: rgba(248, 245, 240, 0.92);
- font-size: 13px;
- line-height: 1.4;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: start;
+ gap: 12px 16px;
+ padding-bottom: 16px;
+ margin-bottom: 4px;
+ border-bottom: 1px solid rgba(23, 27, 36, 0.06);
}
-.a-cover-location strong {
- margin-right: 6px;
- color: #f8f5f0;
+.a-hero-kicker {
+ display: block;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--ink-soft);
}
-.a-cover-people {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-top: 16px;
- font-size: 13px;
- font-weight: 600;
- color: rgba(248, 245, 240, 0.92);
+.a-fund-hero .pill {
+ justify-self: end;
+ align-self: start;
+ margin-top: 2px;
}
-.a-members {
- display: grid;
- grid-template-columns: 1fr 1fr 1fr;
- gap: 12px;
- margin-top: 16px;
+.a-fund-hero strong {
+ display: block;
+ margin: 6px 0 4px;
+ font-size: 1.8rem;
+ letter-spacing: -0.03em;
+ font-weight: 800;
}
-.a-member {
- background: rgba(255, 255, 255, 0.82);
+.a-fund-hero p {
+ margin: 0;
+ font-size: 13px;
+ color: var(--ink-soft);
+}
+
+/* D-calm Home / who-owes language — numbers copied from D */
+
+.a-card {
+ background: rgba(255, 255, 255, 0.86);
border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 22px;
box-shadow: var(--shadow-sm);
- border-radius: var(--radius);
- padding: 14px 16px;
- display: flex;
- gap: 10px;
- align-items: center;
}
-.a-panel:has(.a-table) {
- overflow-x: auto;
+.a-card h2 {
+ margin: 0 0 4px;
+ font-size: 1.05rem;
+ font-weight: 750;
+ letter-spacing: -0.02em;
}
-.a-table th:last-child,
-.a-table td:last-child {
- text-align: right;
- width: 1%;
- white-space: nowrap;
- vertical-align: middle;
+.a-card .sub {
+ margin: 0 0 16px;
+ color: var(--ink-soft);
+ font-size: 14px;
}
-.a-table .a-row-actions,
-.a-table .a-choose {
- display: flex;
- justify-content: flex-end;
- align-items: center;
- gap: 6px;
- min-width: 128px;
- margin-left: auto;
+.when {
+ font-size: 13px;
+ color: var(--ink-soft);
}
-.a-table .a-row-actions .a-btn,
-.a-table .a-choose .a-btn {
- height: 38px;
- min-width: 118px;
- border-radius: 999px;
- flex-shrink: 0;
- justify-content: center;
+.a-move {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
+ gap: 16px;
+ align-items: center;
+ padding: 18px 0;
+ border-bottom: 1px solid rgba(23, 27, 36, 0.06);
}
-.a-table .a-muted-action {
- text-align: right;
- color: var(--ink-soft);
- font-weight: 500;
- min-width: 128px;
+.a-move:last-child {
+ border-bottom: 0;
}
-.a-member .avatar {
+.a-arrow {
width: 36px;
height: 36px;
+ border-radius: 999px;
+ background: var(--sage-soft);
+ color: var(--sage-strong);
+ display: grid;
+ place-items: center;
+ font-weight: 700;
}
-.a-member strong {
- display: block;
- font-size: 13.5px;
+.a-move .names {
+ font-size: 1.05rem;
+ font-weight: 750;
+ letter-spacing: -0.02em;
}
-.a-member span {
- font-size: 12px;
+.a-move .why {
color: var(--ink-soft);
+ font-size: 13px;
+ margin-top: 2px;
}
-.a-essentials {
- display: grid;
- grid-template-columns: 1fr 1fr 1fr;
- gap: 12px;
- margin-top: 16px;
+.a-move .amt {
+ font-size: 1.45rem;
+ font-weight: 800;
+ letter-spacing: -0.04em;
}
-.a-ess {
- padding: 14px 16px;
+.a-owes .a-move {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: 16px;
+ align-items: center;
+ width: 100%;
+ margin: 0;
+ padding: 16px 0;
+ border: 0;
+ border-bottom: 1px solid rgba(23, 27, 36, 0.06);
+ background: transparent;
+ font: inherit;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+ appearance: none;
+ -webkit-appearance: none;
}
-.a-ess span {
- display: block;
- font-size: 11px;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: var(--ink-soft);
- margin-bottom: 6px;
+.a-owes .a-move:last-child {
+ border-bottom: 0;
+ padding-bottom: 0;
}
-.a-ess strong {
- font-size: 1.15rem;
- letter-spacing: -0.02em;
+.a-owes .a-move:first-of-type {
+ padding-top: 4px;
}
-.a-toolbar {
+.a-card-head {
display: flex;
- align-items: center;
+ justify-content: space-between;
+ align-items: flex-start;
gap: 12px;
- margin-bottom: 14px;
+ margin-bottom: 8px;
}
-.a-toolbar .a-filters {
+.a-card-head .sub {
margin-bottom: 0;
- flex: 1;
- min-width: 0;
-}
-
-.a-toolbar .a-btn {
- margin-left: auto;
- flex-shrink: 0;
-}
-
-.a-filters {
- display: flex;
- gap: 8px;
- margin-bottom: 16px;
}
-.a-chip {
+.a-card-head .a-btn.ghost {
height: 32px;
padding: 0 12px;
- border-radius: 999px;
- border: 1px solid var(--border);
- background: #fff;
- font-size: 13px;
- font-weight: 600;
- cursor: pointer;
- color: var(--ink-soft);
+ flex-shrink: 0;
}
-.a-chip.is-on,
-.a-chip:hover {
- background: var(--sage-soft);
- color: var(--sage-strong);
- border-color: transparent;
+.a-week {
+ display: grid;
+ grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr);
+ gap: 16px;
+ margin-top: 16px;
+ align-items: stretch;
}
-/* Phones: a 44px touch area around each chip, without changing its look. */
-@media (max-width: 560px) {
- .a-chip {
- position: relative;
- }
-
- .a-chip::before {
- content: "";
- position: absolute;
- inset: -7px -2px;
- }
-
- /* Room for that area if the chip row scrolls sideways. */
- .a-toolbar .a-filters {
- padding-block: 6px;
- margin-block: -6px;
- }
-
- /* If the chips run under the filter field or the main button on a narrow
- screen, those stay on top, as before. */
- .a-toolbar:has(> .c-record-filter) > :is(.c-record-filter, .a-btn) {
- position: relative;
- }
+#home .a-week {
+ margin-top: 0;
}
-.a-page-head {
- display: flex;
- justify-content: space-between;
- align-items: center;
+.a-item {
+ display: grid;
+ grid-template-columns: 36px minmax(0, 1fr) auto;
gap: 16px;
- margin-bottom: 18px;
+ align-items: center;
+ padding: 18px 0;
+ border-bottom: 1px solid rgba(23, 27, 36, 0.06);
}
-.a-page-head p {
+button.a-item {
+ width: 100%;
margin: 0;
- color: var(--ink-soft);
- font-size: 14px;
- line-height: 1.45;
- max-width: 36rem;
+ border: 0;
+ border-bottom: 1px solid rgba(23, 27, 36, 0.06);
+ background: transparent;
+ font: inherit;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+ appearance: none;
+ -webkit-appearance: none;
}
-.a-settle-hero {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 12px;
- margin-bottom: 16px;
+.a-item:last-child {
+ border-bottom: 0;
+ padding-bottom: 0;
}
-.a-reminder-usage {
- position: relative;
- width: fit-content;
- max-width: 100%;
- flex: 0 0 auto;
- color: var(--ink-soft);
+.a-item .avatar {
+ width: 36px;
+ height: 36px;
font-size: 12px;
}
-.a-reminder-usage summary {
- display: flex;
- align-items: center;
- gap: 8px;
- min-height: 34px;
- padding: 6px 12px;
- cursor: pointer;
- list-style: none;
- border: 1px solid var(--border);
- border-radius: 999px;
+.a-item strong {
+ font-size: 1.05rem;
+ font-weight: 750;
+ letter-spacing: -0.02em;
}
-.a-reminder-usage summary::-webkit-details-marker { display: none; }
-.a-reminder-usage summary::after { content: "⌄"; content: "⌄" / ""; }
-.a-reminder-usage summary strong { color: var(--ink); }
-.a-reminder-usage summary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px; }
-.a-reminder-usage-details {
- position: absolute;
- right: 0;
- top: calc(100% + 8px);
- z-index: 5;
- width: min(280px, calc(100vw - 48px));
- padding: 16px;
- border: 1px solid var(--border);
- border-radius: 14px;
- background: var(--paper);
+#hub .a-member-row:last-child {
+ border-bottom: 0;
+ padding-bottom: 0;
}
-.a-window > .a-reminder-usage { margin: 12px 0; }
-.a-window > .a-reminder-usage .a-reminder-usage-details,
-.a-toolbar > .a-reminder-usage .a-reminder-usage-details { right: auto; left: 0; }
-.a-set-card .a-reminder-usage--account {
- margin-top: 18px;
- width: 100%;
-}
-.a-reminder-usage--account summary { width: fit-content; }
-.a-reminder-usage--account .a-reminder-usage-details {
- position: static;
- width: 100%;
- margin-top: 8px;
+/* Home variants — Places / Inbox / Focus */
+
+.a-place-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 14px;
}
-.a-settle-head { flex-wrap: wrap; }
-.a-settle-head > div:first-child { flex: 1 1 220px; min-width: 0; }
-.a-reminder-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
-.a-content-loading { padding: 28px 0; }
-.a-content-loading p { color: var(--ink-soft); }
-.a-loading-lines { display: grid; gap: 14px; margin-top: 24px; }
-.a-loading-lines span {
+
+.a-place {
display: block;
- height: 72px;
+ width: 100%;
+ padding: 0;
+ border: 0;
border-radius: 18px;
- background: var(--paper-2);
+ overflow: hidden;
+ background: var(--navy);
+ color: #f8f5f0;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ box-shadow: var(--shadow);
+ transition: transform 0.15s ease;
}
-.a-reminder-usage-copy > span {
- display: block;
- color: var(--ink-soft);
- font-size: 12px;
- font-weight: 700;
+.a-place:hover {
+ transform: translateY(-1px);
}
-.a-reminder-usage-copy strong {
- display: block;
- margin-top: 2px;
- color: var(--ink);
- font-size: 22px;
- letter-spacing: -0.04em;
+.a-place-hero {
+ position: relative;
+ isolation: isolate;
+ min-height: 208px;
+ padding: 18px 18px 16px;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ color: #f8f5f0;
+ background: var(--cover-tone, var(--navy));
}
-.a-reminder-usage-copy strong small {
- color: var(--ink-soft);
- font-size: 14px;
- font-weight: 600;
- letter-spacing: 0;
+/* The cover photo (ui/CoverImage.tsx) sits under the shade and the text. */
+.a-place-hero > .c-cover-photo,
+.a-focus-hero > .c-cover-photo {
+ position: absolute;
+ z-index: 0;
}
-.a-reminder-usage-copy p {
- margin: 3px 0 0;
- color: var(--ink-soft);
- font-size: 12px;
+/* Darkest behind the text at the bottom, so it stays readable over a bright
+ photo (tests/cover-contrast.test.ts). The top of the photo is as before. */
+.a-place-hero::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ pointer-events: none;
+ background: linear-gradient(
+ 0deg,
+ rgba(26, 30, 46, 0.72) 0,
+ rgba(26, 30, 46, 0.66) 104px,
+ rgba(26, 30, 46, 0.3) 140px,
+ rgba(26, 30, 46, 0.08) 100%
+ );
}
-.a-reminder-usage-side {
- width: 100%;
- margin-top: 12px;
- flex: 0 0 auto;
- text-align: left;
- color: var(--ink-soft);
- font-size: 12px;
+.a-place-hero > * {
+ position: relative;
+ z-index: 2;
}
-.a-reminder-usage-track {
- height: 7px;
- margin-bottom: 5px;
- overflow: hidden;
+.a-place-hero .type {
+ width: fit-content;
+ height: 24px;
+ padding: 0 9px;
border-radius: 999px;
- background: var(--sage-mist);
+ display: inline-flex;
+ align-items: center;
+ background: rgba(26, 30, 46, 0.64);
+ border: 1px solid rgba(245, 241, 234, 0.2);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
}
-.a-reminder-usage-track > span {
- display: block;
- height: 100%;
- border-radius: inherit;
- background: var(--sage);
+.a-place-hero :is(h2, h3) {
+ margin: 10px 0 4px;
+ font-size: 1.25rem;
+ font-weight: 800;
+ letter-spacing: -0.03em;
}
-.a-reminder-recipient-count {
- display: block;
- margin-top: 3px;
- color: var(--ink-soft);
- font-size: 12px;
+.a-place-hero p {
+ margin: 0;
+ font-size: 14px;
+ color: rgba(248, 245, 240, 0.9);
}
-.a-settle-card {
- padding: 20px;
+.a-place-hero .a-place-location,
+.a-focus-hero .a-place-location {
+ overflow: hidden;
+ max-width: 100%;
+ margin-bottom: 4px;
+ color: #f8f5f0;
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.a-settle-card span {
- font-size: 12px;
- color: var(--ink-soft);
- font-weight: 600;
+.a-place-foot {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 10px;
+ padding: 12px 16px 14px;
+ background: #141824;
+ color: rgba(248, 245, 240, 0.72);
+ font-size: 13px;
}
-.a-settle-card strong {
- display: block;
- font-size: 1.8rem;
- margin: 4px 0;
+.a-place-foot .amt {
+ font-size: 1.15rem;
+ font-weight: 800;
letter-spacing: -0.03em;
+ color: #f8f5f0;
}
-#settle .a-move .amt {
- font-size: 1.45rem;
- font-weight: 800;
- letter-spacing: -0.04em;
+.a-place-foot .amt.even {
+ color: rgba(248, 245, 240, 0.45);
}
-.a-cc-grid {
- display: grid;
- grid-template-columns: 1fr 1fr 1fr;
- gap: 14px;
+.a-place-foot .amt.credit {
+ color: var(--sage-soft);
}
-.a-commune-card {
- border-radius: var(--radius);
+.a-focus-lead .a-focus-hero {
+ border-radius: 0;
+ box-shadow: none;
+ cursor: inherit;
+}
+
+.a-focus-hero {
+ position: relative;
+ isolation: isolate;
+ width: 100%;
+ min-height: 292px;
+ padding: 28px 28px 26px;
+ border: 0;
overflow: hidden;
- border: 1px solid var(--border);
- background: rgba(255, 255, 255, 0.82);
- box-shadow: var(--shadow-sm);
- cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
text-align: left;
- padding: 0;
- width: 100%;
font: inherit;
- color: inherit;
+ color: #f8f5f0;
+ background: var(--cover-tone, var(--navy));
}
-.a-commune-card:hover {
- border-color: var(--border-strong);
+.a-focus-hero > .c-cover-photo {
+ transform: scale(1.03);
}
-.a-commune-card .band {
- height: 78px;
+/* As the smaller cards: darkest behind the text (tests/cover-contrast.test.ts). */
+.a-focus-hero::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ pointer-events: none;
+ background: linear-gradient(
+ 0deg,
+ rgba(26, 30, 46, 0.72) 0,
+ rgba(26, 30, 46, 0.68) 112px,
+ rgba(26, 30, 46, 0.64) 130px,
+ rgba(26, 30, 46, 0.52) 178px,
+ rgba(26, 30, 46, 0.26) 214px,
+ rgba(26, 30, 46, 0.08) 100%
+ );
}
-.a-commune-card .pad {
- padding: 14px 16px 16px;
+.a-focus-hero > * {
+ position: relative;
+ z-index: 2;
}
-.a-commune-card h3 {
- margin: 8px 0 4px;
- font-size: 1.05rem;
- font-weight: 750;
- letter-spacing: -0.02em;
+.a-focus-hero .type {
+ width: fit-content;
+ height: 26px;
+ padding: 0 10px;
+ border-radius: 999px;
+ display: inline-flex;
+ align-items: center;
+ background: rgba(26, 30, 46, 0.64);
+ border: 1px solid rgba(245, 241, 234, 0.2);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.a-focus-hero h2 {
+ margin: 12px 0 8px;
+ font-size: 2.1rem;
+ font-weight: 800;
+ letter-spacing: -0.035em;
}
-.a-commune-card .sub {
+.a-focus-hero p {
margin: 0;
- font-size: 13px;
- color: var(--ink-soft);
+ max-width: 34rem;
+ font-size: 15px;
+ color: rgba(248, 245, 240, 0.9);
}
-.a-notice {
- display: flex;
- align-items: center;
- gap: 12px;
- width: 100%;
- padding: 14px 0;
- border: 0;
- border-bottom: 1px solid rgba(23, 27, 36, 0.05);
- background: transparent;
- font: inherit;
- color: inherit;
- text-align: left;
- cursor: pointer;
+.a-pay-hero {
+ display: grid;
+ gap: 4px;
+ margin-bottom: 16px;
}
-.a-notice:last-child {
- border-bottom: 0;
+.a-pay-hero .a-hero-kicker {
+ font-weight: 650;
}
-.a-notice .when {
- font-size: 12px;
+.a-pay-hero strong {
+ display: block;
+ margin: 4px 0 8px;
+ font-size: 1.8rem;
+ letter-spacing: -0.04em;
+}
+
+.a-pay-hero p,
+.a-window-note {
+ margin: 0 0 14px;
+ font-size: 14px;
+ line-height: 1.45;
color: var(--ink-soft);
- margin-top: 2px;
}
-.a-notice .grow {
- flex: 1;
- min-width: 0;
+.a-windows {
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+ display: none;
}
-.a-field {
+.has-window .a-windows,
+.a-windows:not([hidden]) {
display: grid;
- gap: 6px;
- padding: 14px 0;
- border-bottom: 1px solid rgba(23, 27, 36, 0.05);
+ place-items: center;
+ padding: 28px 20px;
}
-.a-field:last-of-type {
- border-bottom: 0;
+.a-windows[hidden] {
+ display: none !important;
}
-.a-field label {
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- color: var(--ink-soft);
+.a-window-back {
+ position: absolute;
+ inset: 0;
+ border: 0;
+ background: rgba(26, 30, 46, 0.48);
+ cursor: pointer;
}
-.a-field-optional {
- font-weight: 500;
- letter-spacing: normal;
- text-transform: none;
+.a-window-frame {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ width: min(600px, 100%);
+ max-height: min(86vh, 840px);
+ overflow: hidden;
+ background: #f8f5f0;
+ border-radius: var(--radius-surface);
+ box-shadow: 0 24px 60px rgba(26, 30, 46, 0.28);
}
-.a-field input,
-.a-field select,
-.a-field textarea {
- width: 100%;
- min-width: 0;
- box-sizing: border-box;
- padding: 0 12px;
+.a-window-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 24px;
+ background: #f8f5f0;
+ border-bottom: 1px solid rgba(23, 27, 36, 0.06);
+}
+
+.a-window-bar h2 {
+ margin: 0;
+ font-size: 1.15rem;
+ letter-spacing: -0.03em;
+}
+
+.a-window-x {
+ width: 36px;
+ height: 36px;
border-radius: var(--radius-control);
border: 1px solid var(--border);
background: #fff;
- font: inherit;
- font-size: 14px;
- color: var(--ink);
+ display: grid;
+ place-items: center;
+ cursor: pointer;
}
-.a-field input,
-.a-field select {
- height: 40px;
+.a-window-x svg {
+ width: 16px;
+ height: 16px;
}
-/* Choice controls must not inherit the full-width text-field dimensions. */
-.a-field input[type="checkbox"],
-.a-field input[type="radio"] {
- flex: 0 0 18px;
- width: 18px;
- height: 18px;
- margin: 0;
- padding: 0;
- accent-color: var(--sage);
+.a-window-body {
+ overflow: auto;
+ padding: 20px 24px 24px;
+ /* Keep a control that takes focus clear of the sticky action row. */
+ scroll-padding-bottom: 96px;
}
-.a-field label.a-settle-row {
- justify-content: flex-start;
- font-size: 14px;
- font-weight: 500;
- letter-spacing: normal;
- text-transform: none;
- color: var(--ink);
+.a-window {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ column-gap: 16px;
+ row-gap: 2px;
+ align-content: start;
}
-.a-field label.a-settle-row .amt { margin-left: auto; }
+.a-window[hidden] {
+ display: none !important;
+}
-.a-window .a-field label {
- font-size: 13px;
- letter-spacing: normal;
- text-transform: none;
- color: var(--ink);
+.a-window > * {
+ grid-column: 1 / -1;
}
-.a-field textarea {
- min-height: 96px;
- padding: 10px 12px;
- resize: vertical;
- line-height: 1.45;
+.a-window > .a-field.half {
+ grid-column: span 1;
}
-.a-field-row {
+.a-window .a-field {
+ padding: 8px 0;
+ border-bottom: 0;
+}
+
+.a-window .a-field-row {
+ position: sticky;
+ bottom: -24px;
display: flex;
+ align-items: center;
flex-wrap: wrap;
gap: 8px;
- margin-top: 16px;
+ margin: 12px -24px 0;
+ padding: 16px 24px 0;
+ background: #f8f5f0;
+ border-top: 1px solid rgba(23, 27, 36, 0.06);
}
-.a-field label .need {
- margin-left: 8px;
- letter-spacing: 0.02em;
- text-transform: none;
- font-weight: 650;
- color: var(--sage);
+.a-window .a-field-row .a-btn {
+ height: 38px;
+ min-width: 118px;
+ justify-content: center;
}
-.a-seg {
+.a-window .a-seg {
+ width: 100%;
+ max-width: none;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+}
+
+.a-window .a-seg button {
+ width: 100%;
+}
+
+.a-note-types {
display: flex;
- gap: 4px;
- padding: 4px;
- width: fit-content;
- max-width: 100%;
- background: #fff;
- border: 1px solid var(--border);
- border-radius: 999px;
+ flex-wrap: wrap;
+ gap: 8px;
}
-.a-seg button {
+.a-note-types button {
height: 32px;
- padding: 0 14px;
- border: 0;
+ padding: 0 12px;
border-radius: 999px;
- background: transparent;
+ border: 1px solid var(--border);
+ background: #fff;
font: inherit;
font-size: 13px;
font-weight: 650;
- color: var(--ink-soft);
+ color: var(--ink);
cursor: pointer;
}
-.a-seg button.is-on {
+.a-note-types button.is-on {
background: var(--sage);
color: #fff;
+ border-color: var(--sage);
}
-.a-field .hint {
- margin: 4px 0 0;
- font-size: 12px;
- line-height: 1.45;
- color: var(--ink-soft);
-}
-
-.a-member-row {
- display: flex;
- align-items: center;
- gap: 16px;
- padding: 18px 0;
- border-bottom: 1px solid rgba(23, 27, 36, 0.06);
-}
-
-.a-member-row:last-child {
- border-bottom: 0;
-}
-
-.a-member-row .avatar {
- width: 36px;
- height: 36px;
-}
-
-.a-member-row .who strong {
- display: block;
- font-size: 1.05rem;
- font-weight: 750;
- letter-spacing: -0.02em;
-}
-
-.a-member-row .who span {
- font-size: 13px;
- color: var(--ink-soft);
-}
-
-.a-member-row .bal {
- margin-left: auto;
- font-weight: 800;
- font-size: 1.45rem;
- letter-spacing: -0.04em;
-}
-
-#operations .a-settle-row,
-#notes .a-settle-row,
-#activity .a-settle-row,
-#hub .a-settle-row {
- padding: 18px 0;
- gap: 16px;
-}
-
-#operations .a-settle-row .pill {
- margin-left: auto;
-}
-
-#operations .a-settle-row .avatar,
-#activity .a-settle-row .avatar,
-#hub .a-settle-row .avatar {
- width: 36px;
- height: 36px;
-}
-
-#operations .a-settle-row strong,
-#notes .a-settle-row strong,
-#activity .a-settle-row strong {
- font-size: 1.05rem;
- font-weight: 750;
- letter-spacing: -0.02em;
-}
-
-.a-fund-hero {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- align-items: start;
- gap: 12px 16px;
- padding-bottom: 16px;
- margin-bottom: 4px;
- border-bottom: 1px solid rgba(23, 27, 36, 0.06);
-}
-
-.a-hero-kicker {
- display: block;
- font-size: 12px;
- font-weight: 600;
- color: var(--ink-soft);
-}
-
-.a-fund-hero .pill {
- justify-self: end;
- align-self: start;
- margin-top: 2px;
-}
-
-.a-fund-hero strong {
- display: block;
- margin: 6px 0 4px;
- font-size: 1.8rem;
- letter-spacing: -0.03em;
- font-weight: 800;
-}
-
-.a-fund-hero p {
- margin: 0;
- font-size: 13px;
- color: var(--ink-soft);
-}
-
-/* D-calm Home / who-owes language — numbers copied from D */
-/* Home masthead — the number sits on the paper, not in a card */
-.a-mast {
- display: flex;
- align-items: flex-end;
- justify-content: space-between;
- gap: 24px;
- padding: 4px 4px 28px;
-}
-
-.a-mast span {
- display: block;
- font-size: 11px;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- color: var(--ink-soft);
- font-weight: 700;
-}
-
-.a-mast .num {
- font-size: 3.15rem;
- font-weight: 800;
- letter-spacing: -0.055em;
- line-height: 1.05;
- margin: 6px 0 8px;
-}
-
-.a-mast p {
- margin: 0;
- font-size: 15px;
- color: var(--ink-soft);
- max-width: 28rem;
- line-height: 1.45;
-}
-
-.a-mast .a-btn {
- flex-shrink: 0;
- margin-bottom: 4px;
-}
-
-.a-mast.is-credit .num {
- color: var(--sage);
-}
-
-.a-net {
- display: flex;
- gap: 40px;
- margin: 10px 0 12px;
-}
-
-.a-net span {
- display: block;
- font-size: 11px;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- color: var(--ink-soft);
- font-weight: 700;
-}
-
-.a-net strong {
- display: block;
- margin: 4px 0 2px;
- font-size: 3.15rem;
- font-weight: 800;
- letter-spacing: -0.055em;
- line-height: 1.05;
-}
-
-.a-net strong.is-credit {
- color: var(--sage);
-}
-
-.a-net small {
- display: block;
- font-size: 13px;
- color: var(--ink-soft);
+.a-window [hidden],
+[data-note-extra][hidden] {
+ display: none !important;
}
-.a-mast-home {
- padding-bottom: 22px;
- margin-bottom: 8px;
- border-bottom: 1px solid rgba(23, 27, 36, 0.08);
+.a-window-kicker {
+ display: none;
}
-.a-answers {
+.a-note-facts {
display: grid;
- grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
- gap: 12px;
- margin-top: 16px;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 10px;
+ margin: 8px 0 12px;
}
-.a-now-row {
+.a-note-fact {
display: grid;
- grid-template-columns: auto minmax(0, 1fr);
- gap: 12px;
- align-items: center;
- padding: 12px 0;
- border-bottom: 1px solid rgba(23, 27, 36, 0.05);
-}
-
-.a-now-row:last-child {
- border-bottom: 0;
- padding-bottom: 0;
-}
-
-.a-now-row:first-child {
- padding-top: 0;
-}
-
-button.a-now-row {
- width: 100%;
- margin: 0;
- border: 0;
- background: transparent;
- font: inherit;
- color: inherit;
- text-align: left;
- cursor: pointer;
- appearance: none;
- -webkit-appearance: none;
-}
-
-.a-ink {
+ gap: 4px;
+ min-width: 0;
+ padding: 12px 14px;
background: #fff;
- color: var(--ink);
- border-radius: 16px;
- padding: 20px 22px;
border: 1px solid var(--border);
- box-shadow: var(--shadow-sm);
+ border-radius: 12px;
}
-.a-ink span {
+.a-note-fact span {
font-size: 11px;
+ font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-soft);
- font-weight: 700;
-}
-
-.a-ink .num {
- font-size: 2.35rem;
- font-weight: 800;
- letter-spacing: -0.045em;
- margin: 4px 0 6px;
}
-.a-ink p {
- margin: 0 0 14px;
- font-size: 13.5px;
- color: var(--ink-soft);
- max-width: 28rem;
+.a-note-fact strong {
+ font-size: 15px;
+ font-weight: 650;
+ letter-spacing: -0.02em;
+ overflow-wrap: anywhere;
}
-.a-ink-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
+.a-bottom,
+.a-more {
+ display: none;
}
-.a-ink.is-credit .num {
- color: var(--sage);
+@media (max-width: 1100px) {
+ .a-search {
+ width: 190px;
+ }
}
-.a-card {
- background: rgba(255, 255, 255, 0.86);
- border: 1px solid var(--border);
- border-radius: 16px;
- padding: 22px;
- box-shadow: var(--shadow-sm);
+@media (max-width: 1000px) {
+ .a-week,
+ .a-set {
+ grid-template-columns: 1fr;
+ }
+ .a-place-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+ .a-set-row .a-seg {
+ width: 100%;
+ }
+ .a-set-row:has(.a-seg) {
+ flex-wrap: wrap;
+ }
}
-.a-owe-card.a-card {
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- min-height: 100%;
- background: #fff;
- border: 1px solid var(--border-strong);
- box-shadow: var(--shadow);
-}
+@media (max-width: 560px) {
-.a-owe-card > span {
- font-size: 11px;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- color: var(--ink-soft);
- font-weight: 700;
-}
-
-.a-owe-card .num {
- font-size: 2.6rem;
- font-weight: 800;
- letter-spacing: -0.05em;
- line-height: 1.05;
- margin: 8px 0 10px;
-}
-
-.a-owe-card p {
- margin: 0 0 16px;
- font-size: 14px;
- color: var(--ink-soft);
- max-width: 28rem;
- line-height: 1.45;
-}
-
-.a-owe-card.is-credit .num {
- color: var(--sage);
-}
-
-.a-card h2 {
- margin: 0 0 4px;
- font-size: 1.05rem;
- font-weight: 750;
- letter-spacing: -0.02em;
-}
-
-.a-card .sub {
- margin: 0 0 16px;
- color: var(--ink-soft);
- font-size: 14px;
-}
-
-.when {
- font-size: 13px;
- color: var(--ink-soft);
-}
-
-.a-move {
- display: grid;
- grid-template-columns: auto minmax(0, 1fr) auto auto;
- gap: 16px;
- align-items: center;
- padding: 18px 0;
- border-bottom: 1px solid rgba(23, 27, 36, 0.06);
-}
-
-.a-move:last-child {
- border-bottom: 0;
-}
-
-.a-arrow {
- width: 36px;
- height: 36px;
- border-radius: 999px;
- background: #d9ebe5;
- color: #1b4332;
- display: grid;
- place-items: center;
- font-weight: 700;
-}
-
-.a-move .names {
- font-size: 1.05rem;
- font-weight: 750;
- letter-spacing: -0.02em;
-}
-
-.a-move .why {
- color: var(--ink-soft);
- font-size: 13px;
- margin-top: 2px;
-}
-
-.a-move .amt {
- font-size: 1.45rem;
- font-weight: 800;
- letter-spacing: -0.04em;
-}
-
-.a-owes .a-move {
- display: grid;
- grid-template-columns: auto minmax(0, 1fr) auto;
- gap: 16px;
- align-items: center;
- width: 100%;
- margin: 0;
- padding: 16px 0;
- border: 0;
- border-bottom: 1px solid rgba(23, 27, 36, 0.06);
- background: transparent;
- font: inherit;
- color: inherit;
- text-align: left;
- cursor: pointer;
- appearance: none;
- -webkit-appearance: none;
-}
-
-.a-owes .a-move:last-child {
- border-bottom: 0;
- padding-bottom: 0;
-}
-
-.a-owes .a-move:first-of-type {
- padding-top: 4px;
-}
-
-.a-card-head {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- gap: 12px;
- margin-bottom: 8px;
-}
-
-.a-card-head .sub {
- margin-bottom: 0;
-}
-
-.a-card-head .a-btn.ghost {
- height: 32px;
- padding: 0 12px;
- flex-shrink: 0;
-}
-
-.a-week {
- display: grid;
- grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr);
- gap: 16px;
- margin-top: 16px;
- align-items: stretch;
-}
-
-#home .a-week {
- margin-top: 0;
-}
-
-.a-mini-bars {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.a-mini-bars .row {
- display: grid;
- grid-template-columns: 110px 1fr 64px;
- gap: 10px;
- align-items: center;
- font-size: 13px;
-}
-
-.a-mini-bars .track {
- height: 8px;
- border-radius: 999px;
- background: rgba(23, 27, 36, 0.06);
- overflow: hidden;
-}
-
-.a-mini-bars .track i {
- display: block;
- height: 100%;
- background: var(--sage, #2d6a4f);
- border-radius: 999px;
-}
-
-.a-mini-bars strong {
- text-align: right;
- font-weight: 750;
- letter-spacing: -0.02em;
-}
-
-.a-item {
- display: grid;
- grid-template-columns: 36px minmax(0, 1fr) auto;
- gap: 16px;
- align-items: center;
- padding: 18px 0;
- border-bottom: 1px solid rgba(23, 27, 36, 0.06);
-}
-
-button.a-item {
- width: 100%;
- margin: 0;
- border: 0;
- border-bottom: 1px solid rgba(23, 27, 36, 0.06);
- background: transparent;
- font: inherit;
- color: inherit;
- text-align: left;
- cursor: pointer;
- appearance: none;
- -webkit-appearance: none;
-}
-
-.a-item:last-child {
- border-bottom: 0;
- padding-bottom: 0;
-}
-
-.a-item .avatar {
- width: 36px;
- height: 36px;
- font-size: 12px;
-}
-
-.a-item strong {
- font-size: 1.05rem;
- font-weight: 750;
- letter-spacing: -0.02em;
-}
-
-.a-item-plain {
- grid-template-columns: 1fr;
-}
-
-#hub .a-member-row:last-child {
- border-bottom: 0;
- padding-bottom: 0;
-}
-
-/* Home variants — Places / Inbox / Focus */
-.a-home-greet {
- margin: 0 0 22px;
-}
-
-.a-home-greet.is-after {
- margin: 22px 0 14px;
-}
-
-.a-home-greet .kicker {
- display: block;
- font-size: 11px;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- font-weight: 700;
- color: var(--ink-soft);
-}
-
-.a-home-greet h1 {
- margin: 6px 0 8px;
- font-size: 1.85rem;
- letter-spacing: -0.035em;
- font-weight: 800;
-}
-
-.a-home-greet p {
- margin: 0;
- max-width: 38rem;
- font-size: 15px;
- line-height: 1.5;
- color: var(--ink-soft);
-}
-
-.a-place-grid {
- display: grid;
- grid-template-columns: 1fr 1fr 1fr;
- gap: 14px;
-}
-
-.a-place {
- display: block;
- width: 100%;
- padding: 0;
- border: 0;
- border-radius: 18px;
- overflow: hidden;
- background: #1a1e2b;
- color: #f8f5f0;
- font: inherit;
- text-align: left;
- cursor: pointer;
- box-shadow: var(--shadow);
- transition: transform 0.15s ease;
-}
-
-.a-place:hover {
- transform: translateY(-1px);
-}
-
-.a-place-hero {
- position: relative;
- isolation: isolate;
- min-height: 208px;
- padding: 18px 18px 16px;
- display: flex;
- flex-direction: column;
- justify-content: flex-end;
- color: #f8f5f0;
- background: var(--cover-tone, #1a1e2b);
-}
-
-/* The cover photo (ui/CoverImage.tsx) sits under the shade and the text. */
-.a-place-hero > .c-cover-photo,
-.a-focus-hero > .c-cover-photo {
- position: absolute;
- z-index: 0;
-}
-
-/* Darkest behind the text at the bottom, so it stays readable over a bright
- photo (tests/cover-contrast.test.ts). The top of the photo is as before. */
-.a-place-hero::after {
- content: "";
- position: absolute;
- inset: 0;
- z-index: 1;
- pointer-events: none;
- background: linear-gradient(
- 0deg,
- rgba(26, 30, 46, 0.72) 0,
- rgba(26, 30, 46, 0.66) 104px,
- rgba(26, 30, 46, 0.3) 140px,
- rgba(26, 30, 46, 0.08) 100%
- );
-}
-
-.a-place-hero > * {
- position: relative;
- z-index: 2;
-}
-
-.a-place-hero .type {
- width: fit-content;
- height: 24px;
- padding: 0 9px;
- border-radius: 999px;
- display: inline-flex;
- align-items: center;
- background: rgba(26, 30, 46, 0.64);
- border: 1px solid rgba(245, 241, 234, 0.2);
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.12em;
- text-transform: uppercase;
-}
-
-.a-place-hero :is(h2, h3) {
- margin: 10px 0 4px;
- font-size: 1.25rem;
- font-weight: 800;
- letter-spacing: -0.03em;
-}
-
-.a-place-hero p {
- margin: 0;
- font-size: 14px;
- color: rgba(248, 245, 240, 0.9);
-}
-
-.a-place-hero .a-place-location,
-.a-focus-hero .a-place-location {
- overflow: hidden;
- max-width: 100%;
- margin-bottom: 4px;
- color: #f8f5f0;
- font-size: 12px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.a-place-foot {
- display: flex;
- justify-content: space-between;
- align-items: baseline;
- gap: 10px;
- padding: 12px 16px 14px;
- background: #141824;
- color: rgba(248, 245, 240, 0.72);
- font-size: 13px;
-}
-
-.a-place-foot .amt {
- font-size: 1.15rem;
- font-weight: 800;
- letter-spacing: -0.03em;
- color: #f8f5f0;
-}
-
-.a-place-foot .amt.even {
- color: rgba(248, 245, 240, 0.45);
-}
-
-.a-place-foot .amt.credit {
- color: #d9ebe5;
-}
-
-.a-home-inbox {
- display: grid;
- grid-template-columns: minmax(260px, 0.82fr) minmax(0, 1.18fr);
- gap: 16px;
- align-items: start;
-}
-
-.a-inbox-list {
- padding-bottom: 10px;
-}
-
-.a-inbox-hub {
- display: grid;
- grid-template-columns: 56px minmax(0, 1fr) auto;
- gap: 12px;
- align-items: center;
- width: 100%;
- margin: 0;
- padding: 12px 0;
- border: 0;
- border-bottom: 1px solid rgba(23, 27, 36, 0.06);
- background: transparent;
- font: inherit;
- color: inherit;
- text-align: left;
- cursor: pointer;
-}
-
-.a-inbox-hub:hover {
- opacity: 0.92;
-}
-
-.a-inbox-hub:last-child {
- border-bottom: 0;
- padding-bottom: 4px;
-}
-
-.a-inbox-hub .thumb {
- display: block;
- width: 56px;
- height: 56px;
- border-radius: 12px;
- background-color: #1a1e2b;
- background-size: cover;
- background-position: center;
-}
-
-.a-inbox-hub strong {
- display: block;
- font-size: 1.05rem;
- font-weight: 750;
- letter-spacing: -0.02em;
-}
-
-.a-inbox-hub span {
- display: block;
- font-size: 13px;
- color: var(--ink-soft);
-}
-
-.a-inbox-hub .amt {
- font-size: 1.2rem;
- font-weight: 800;
- letter-spacing: -0.03em;
- color: var(--ink);
-}
-
-.a-inbox-hub .amt.even {
- color: var(--ink-soft);
-}
-
-.a-inbox-hub .amt.credit {
- color: var(--sage);
-}
-
-.a-focus-lead .a-focus-hero {
- border-radius: 0;
- box-shadow: none;
- cursor: inherit;
-}
-
-.a-focus-hero {
- position: relative;
- isolation: isolate;
- width: 100%;
- min-height: 292px;
- padding: 28px 28px 26px;
- border: 0;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- justify-content: flex-end;
- text-align: left;
- font: inherit;
- color: #f8f5f0;
- background: var(--cover-tone, #1a1e2b);
-}
-
-.a-focus-hero > .c-cover-photo {
- transform: scale(1.03);
-}
-
-/* As the smaller cards: darkest behind the text (tests/cover-contrast.test.ts). */
-.a-focus-hero::after {
- content: "";
- position: absolute;
- inset: 0;
- z-index: 1;
- pointer-events: none;
- background: linear-gradient(
- 0deg,
- rgba(26, 30, 46, 0.72) 0,
- rgba(26, 30, 46, 0.68) 112px,
- rgba(26, 30, 46, 0.64) 130px,
- rgba(26, 30, 46, 0.52) 178px,
- rgba(26, 30, 46, 0.26) 214px,
- rgba(26, 30, 46, 0.08) 100%
- );
-}
-
-.a-focus-hero > * {
- position: relative;
- z-index: 2;
-}
-
-.a-focus-hero .type {
- width: fit-content;
- height: 26px;
- padding: 0 10px;
- border-radius: 999px;
- display: inline-flex;
- align-items: center;
- background: rgba(26, 30, 46, 0.64);
- border: 1px solid rgba(245, 241, 234, 0.2);
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.12em;
- text-transform: uppercase;
-}
-
-.a-focus-hero h2 {
- margin: 12px 0 8px;
- font-size: 2.1rem;
- font-weight: 800;
- letter-spacing: -0.035em;
-}
-
-.a-focus-hero p {
- margin: 0;
- max-width: 34rem;
- font-size: 15px;
- color: rgba(248, 245, 240, 0.9);
-}
-
-.a-focus-pair {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 14px;
- margin-top: 14px;
-}
-
-.a-focus-pair .a-place-hero {
- min-height: 168px;
-}
-
-.a-move-acts {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- justify-content: flex-end;
-}
-
-.a-pay-hero {
- display: grid;
- gap: 4px;
- margin-bottom: 16px;
-}
-
-.a-pay-hero .a-hero-kicker {
- font-weight: 650;
-}
-
-.a-pay-hero strong {
- display: block;
- margin: 4px 0 8px;
- font-size: 1.8rem;
- letter-spacing: -0.04em;
-}
-
-.a-pay-hero p,
-.a-window-note {
- margin: 0 0 14px;
- font-size: 14px;
- line-height: 1.45;
- color: var(--ink-soft);
-}
-
-.a-pay-link {
- display: flex;
- flex-direction: column;
- gap: 2px;
- padding: 14px 16px;
- margin-bottom: 8px;
- border-radius: 14px;
- border: 1px solid var(--border);
- background: #fff;
- text-decoration: none;
- color: inherit;
-}
-
-.a-pay-link strong {
- font-size: 15px;
-}
-
-.a-pay-link span {
- font-size: 13px;
- color: var(--ink-soft);
-}
-
-.a-windows {
- position: fixed;
- inset: 0;
- z-index: 40;
- display: none;
-}
-
-.has-window .a-windows,
-.a-windows:not([hidden]) {
- display: grid;
- place-items: center;
- padding: 28px 20px;
-}
-
-.a-windows[hidden] {
- display: none !important;
-}
-
-.a-window-back {
- position: absolute;
- inset: 0;
- border: 0;
- background: rgba(26, 30, 46, 0.48);
- cursor: pointer;
-}
-
-.a-window-frame {
- position: relative;
- z-index: 1;
- display: grid;
- grid-template-rows: auto minmax(0, 1fr);
- width: min(600px, 100%);
- max-height: min(86vh, 840px);
- overflow: hidden;
- background: #f8f5f0;
- border-radius: var(--radius-surface);
- box-shadow: 0 24px 60px rgba(26, 30, 46, 0.28);
-}
-
-.a-window-bar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 14px 24px;
- background: #f8f5f0;
- border-bottom: 1px solid rgba(23, 27, 36, 0.06);
-}
-
-.a-window-bar h2 {
- margin: 0;
- font-size: 1.15rem;
- letter-spacing: -0.03em;
-}
-
-.a-window-x {
- width: 36px;
- height: 36px;
- border-radius: var(--radius-control);
- border: 1px solid var(--border);
- background: #fff;
- display: grid;
- place-items: center;
- cursor: pointer;
-}
-
-.a-window-x svg {
- width: 16px;
- height: 16px;
-}
-
-.a-window-body {
- overflow: auto;
- padding: 20px 24px 24px;
- /* Keep a control that takes focus clear of the sticky action row. */
- scroll-padding-bottom: 96px;
-}
-
-.a-window {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
- column-gap: 16px;
- row-gap: 2px;
- align-content: start;
-}
-
-.a-window[hidden] {
- display: none !important;
-}
-
-.a-window > * {
- grid-column: 1 / -1;
-}
-
-.a-window > .a-field.half {
- grid-column: span 1;
-}
-
-.a-window .a-field {
- padding: 8px 0;
- border-bottom: 0;
-}
-
-.a-window .a-field-row {
- position: sticky;
- bottom: -24px;
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 8px;
- margin: 12px -24px 0;
- padding: 16px 24px 0;
- background: #f8f5f0;
- border-top: 1px solid rgba(23, 27, 36, 0.06);
-}
-
-.a-window .a-field-row .a-btn,
-.a-window .a-field-row .a-choose .a-btn {
- height: 38px;
- min-width: 118px;
- justify-content: center;
-}
-
-.a-window .a-seg {
- width: 100%;
- max-width: none;
- display: grid;
- grid-template-columns: 1fr 1fr;
-}
-
-.a-window .a-seg button {
- width: 100%;
-}
-
-.a-note-types {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.a-note-types button {
- height: 32px;
- padding: 0 12px;
- border-radius: 999px;
- border: 1px solid var(--border);
- background: #fff;
- font: inherit;
- font-size: 13px;
- font-weight: 650;
- color: var(--ink);
- cursor: pointer;
-}
-
-.a-note-types button.is-on {
- background: var(--sage);
- color: #fff;
- border-color: var(--sage);
-}
-
-.a-window [hidden],
-[data-note-extra][hidden] {
- display: none !important;
-}
-
-.a-window-kicker {
- display: none;
-}
-
-.a-note-facts {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
- gap: 10px;
- margin: 8px 0 12px;
-}
-
-.a-note-fact {
- display: grid;
- gap: 4px;
- min-width: 0;
- padding: 12px 14px;
- background: #fff;
- border: 1px solid var(--border);
- border-radius: 12px;
-}
-
-.a-note-fact span {
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- color: var(--ink-soft);
-}
-
-.a-note-fact strong {
- font-size: 15px;
- font-weight: 650;
- letter-spacing: -0.02em;
- overflow-wrap: anywhere;
-}
-
-.a-bottom,
-.a-more {
- display: none;
-}
-
-.a-body.is-gate .a-bottom,
-.a-body.is-gate .a-more,
-.a-body.is-gate .a-sheet-scrim,
-.a-body.is-gate .a-windows {
- display: none !important;
-}
-
-@media (max-width: 1100px) {
- .a-search {
- width: 190px;
- }
-}
-
-@media (max-width: 1000px) {
- .a-kpis {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
- .a-grid,
- .a-hero,
- .a-answers,
- .a-week,
- .a-home-inbox,
- .a-focus-pair,
- .a-settle-hero,
- .a-set {
- grid-template-columns: 1fr;
- }
- .a-place-grid,
- .a-cc-grid,
- .a-members,
- .a-essentials {
- grid-template-columns: 1fr 1fr;
- }
- .a-set-row .a-seg {
- width: 100%;
- }
- .a-set-row:has(.a-seg) {
- flex-wrap: wrap;
- }
-}
-
-@media (max-width: 560px) {
-
- .a-app {
- grid-template-columns: 1fr;
- padding-bottom: 64px;
- }
- .a-side {
- display: none;
- }
- .a-header {
- padding: 12px 16px;
- background: #1a1e2b;
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
- color: #f5f1ea;
- }
- .a-header-title h1 {
- color: #f5f1ea;
- }
- .a-header-title p {
- color: rgba(245, 241, 234, 0.55);
- white-space: normal;
- overflow: visible;
- }
- .a-header-mark {
- display: block;
- width: 38px;
- height: 38px;
- padding: 10px;
- border-radius: 999px;
- border: 1px solid rgba(255, 255, 255, 0.12);
- background: rgba(255, 255, 255, 0.06);
- }
- .a-header .a-icon-btn {
- background: rgba(255, 255, 255, 0.06);
- border-color: rgba(255, 255, 255, 0.12);
- color: #f5f1ea;
- }
- .a-header .a-icon-btn.has-unread::after {
- background: var(--sage, #2d6a4f);
- box-shadow: 0 0 0 2px #1a1e2b;
- }
- .a-header .a-icon-btn.a-you-btn {
- display: grid;
- }
- .a-icon-btn.a-more-btn,
- .a-more-btn {
- display: grid;
- place-items: center;
- background: rgba(255, 255, 255, 0.06);
- border: 1px solid rgba(255, 255, 255, 0.12);
- color: #f5f1ea;
- }
- .a-search {
- display: none;
- }
- .a-content {
- padding: 16px 16px 28px;
- }
- .a-place-grid,
- .a-members,
- .a-essentials,
- .a-cc-grid {
- grid-template-columns: 1fr;
- }
- .a-mast {
- flex-direction: column;
- align-items: flex-start;
- }
- .jump-grid {
- grid-template-columns: 1fr 1fr;
- }
- .a-owes .a-move {
- grid-template-columns: auto minmax(0, 1fr);
- }
- .a-owes .a-move .amt {
- grid-column: 2;
- }
- .a-move {
- grid-template-columns: auto minmax(0, 1fr);
- }
- .a-move .amt,
- .a-move-acts,
- .a-move .a-choose {
- grid-column: 2;
- justify-self: start;
- }
- .a-choose.is-open .a-choose-menu {
- position: fixed;
- left: 12px;
- right: 12px;
- top: auto;
- bottom: calc(72px + env(safe-area-inset-bottom));
- min-width: 0;
- z-index: 45;
- }
- .a-bottom {
- display: flex;
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 30;
- height: 62px;
- padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
- background: #1a1e2b;
- color: rgba(245, 241, 234, 0.72);
- justify-content: space-around;
- border-top: 1px solid rgba(255, 255, 255, 0.06);
- }
- .a-bottom button {
- flex: 1;
- border: 0;
- background: none;
- color: inherit;
- font: inherit;
- font-size: 10px;
- font-weight: 650;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 3px;
- cursor: pointer;
- }
- .a-bottom svg {
- width: 20px;
- height: 20px;
- }
- .a-bottom button.is-active {
- color: #f5f1ea;
- }
- .has-window .a-windows {
- place-items: end center;
- padding: 0;
- }
- .a-window-frame {
- width: 100%;
- max-height: 92vh;
- border-radius: var(--radius-surface) var(--radius-surface) 0 0;
- }
- .a-window {
- grid-template-columns: 1fr;
- }
- .a-window > .a-field.half {
- grid-column: 1;
- }
- .a-note-facts {
- grid-template-columns: 1fr;
- }
- .a-set {
- grid-template-columns: 1fr;
- gap: 16px;
- }
- .a-body.more-open .a-more {
- display: flex;
- flex-direction: column;
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- top: auto;
- z-index: 41;
- width: 100%;
- max-height: min(86vh, 640px);
- overflow: auto;
- border: 0;
- border-radius: var(--radius-surface) var(--radius-surface) 0 0;
- padding: 10px 0 calc(18px + env(safe-area-inset-bottom, 0px));
- box-shadow: 0 -18px 50px rgba(26, 30, 43, 0.28);
- background: #fff;
- }
- .a-more::before {
- content: "";
- display: block;
- width: 36px;
- height: 4px;
- border-radius: 99px;
- background: var(--border);
- margin: 4px auto 6px;
- flex-shrink: 0;
- }
- .a-more-head {
- display: flex;
- align-items: flex-start;
- gap: 12px;
- padding: 4px 18px 12px;
- }
- .a-more-kicker {
- margin: 0;
- font-size: 18px;
- font-weight: 750;
- letter-spacing: -0.02em;
- }
- .a-more-lede {
- margin: 4px 0 0;
- font-size: 13px;
- color: var(--ink-soft);
- line-height: 1.4;
- }
- .a-more-x {
- width: 32px;
- height: 32px;
- border: 0;
- border-radius: 999px;
- background: #1a1e2b;
- color: #fff;
- display: grid;
- place-items: center;
- cursor: pointer;
- flex-shrink: 0;
- }
- .a-more-x svg {
- width: 14px;
- height: 14px;
- }
- .a-more > button {
- display: flex;
- align-items: center;
- gap: 12px;
- width: 100%;
- margin: 0;
- padding: 12px 18px;
- border: 0;
- border-bottom: 1px solid rgba(23, 27, 36, 0.06);
- background: transparent;
- font: inherit;
- text-align: left;
- cursor: pointer;
- color: inherit;
- }
- .a-more > button:last-child {
- border-bottom: 0;
- }
- .a-more > button:hover {
- background: #f8f5f0;
- }
- .a-more-ic {
- width: 44px;
- height: 44px;
- border-radius: 12px;
- background: #e8f0eb;
- color: var(--sage);
- display: grid;
- place-items: center;
- flex-shrink: 0;
- }
- .a-more-ic-dash {
- background: transparent;
- border: 1px dashed rgba(26, 30, 43, 0.28);
- color: var(--ink-soft);
- }
- .a-more-ic svg {
- width: 18px;
- height: 18px;
- }
- .a-more-copy {
- flex: 1;
- min-width: 0;
- }
- .a-more-copy strong {
- display: block;
- font-size: 15px;
- font-weight: 650;
- }
- .a-more-copy span {
- display: block;
- font-size: 13px;
- color: var(--ink-soft);
- margin-top: 2px;
- }
- .a-more-chev {
- width: 16px;
- height: 16px;
- color: var(--ink-faint, #b8b3aa);
- flex-shrink: 0;
- }
-}
-
-.a-body.is-native {
- background: #0d0f17;
-}
-
-.a-body.is-native .a-app {
- width: min(390px, 100%);
- margin: 0 auto;
- grid-template-columns: 1fr;
- min-height: 100vh;
- padding-bottom: 78px;
- background: linear-gradient(180deg, #f8f5f0 0%, #f3eee7 100%);
-}
-
-.a-body.is-native .a-side {
- display: none;
-}
-
-.a-body.is-native .a-header {
- padding: 54px 16px 12px;
- background: #1a1e2b;
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
- color: #f5f1ea;
-}
-
-.a-body.is-native .a-header-title h1 {
- color: #f5f1ea;
-}
-
-.a-body.is-native .a-header-title p {
- color: rgba(245, 241, 234, 0.55);
- white-space: normal;
- overflow: visible;
-}
-
-.a-body.is-native .a-header-mark {
- display: block;
- width: 38px;
- height: 38px;
- padding: 10px;
- border-radius: 999px;
- border: 1px solid rgba(255, 255, 255, 0.12);
- background: rgba(255, 255, 255, 0.06);
-}
-
-.a-body.is-native .a-header .a-icon-btn {
- background: rgba(255, 255, 255, 0.06);
- border-color: rgba(255, 255, 255, 0.12);
- color: #f5f1ea;
-}
-
-.a-body.is-native .a-header .a-icon-btn.a-you-btn {
- display: grid;
-}
-
-.a-body.is-native .a-icon-btn.a-more-btn,
-.a-body.is-native .a-more-btn {
- display: grid;
- place-items: center;
- background: rgba(255, 255, 255, 0.06);
- border: 1px solid rgba(255, 255, 255, 0.12);
- color: #f5f1ea;
-}
-
-.a-body.is-native .a-set {
- grid-template-columns: 1fr;
- gap: 16px;
-}
-
-.a-body.is-native.more-open .a-sheet-scrim {
- width: min(390px, 100%);
- left: 50%;
- right: auto;
- transform: translateX(-50%);
-}
-
-.a-body.is-native.more-open .a-more {
- display: flex;
- flex-direction: column;
- position: fixed;
- left: 50%;
- right: auto;
- bottom: 0;
- top: auto;
- z-index: 41;
- width: min(390px, 100%);
- max-height: min(86vh, 640px);
- overflow: auto;
- border: 0;
- border-radius: var(--radius-surface) var(--radius-surface) 0 0;
- padding: 10px 0 28px;
- transform: translateX(-50%);
- box-shadow: 0 -18px 50px rgba(26, 30, 43, 0.28);
- background: #fff;
-}
-
-.a-body.is-native.more-open .a-more::before {
- content: "";
- display: block;
- width: 36px;
- height: 4px;
- border-radius: 99px;
- background: var(--border);
- margin: 4px auto 6px;
- flex-shrink: 0;
-}
-
-.a-body.is-native.is-gate .a-sheet-scrim,
-.a-body.is-native.is-gate .a-more {
- display: none !important;
-}
-
-.a-body.is-native .a-search {
- display: none;
-}
-
-.a-body.is-native .a-content {
- padding: 16px 16px 28px;
-}
-
-.a-body.is-native .a-kpis {
- grid-template-columns: repeat(2, minmax(0, 1fr));
-}
-
-.a-body.is-native .a-grid,
-.a-body.is-native .a-hero,
-.a-body.is-native .a-answers,
-.a-body.is-native .a-week,
-.a-body.is-native .a-home-inbox,
-.a-body.is-native .a-focus-pair,
-.a-body.is-native .a-settle-hero,
-.a-body.is-native .a-place-grid,
-.a-body.is-native .a-members,
-.a-body.is-native .a-essentials,
-.a-body.is-native .a-cc-grid {
- grid-template-columns: 1fr;
-}
-
-.a-body.is-native .jump-grid {
- grid-template-columns: 1fr 1fr;
-}
-
-.a-body.is-native .a-bottom {
- display: flex;
- position: fixed;
- left: 50%;
- right: auto;
- bottom: 0;
- z-index: 30;
- width: min(390px, 100%);
- height: 78px;
- padding: 6px 8px 22px;
- transform: translateX(-50%);
- background: #1a1e2b;
- color: rgba(245, 241, 234, 0.72);
- justify-content: space-around;
- border-top: 1px solid rgba(255, 255, 255, 0.06);
-}
-
-.a-body.is-native .a-bottom button {
- flex: 1;
- border: 0;
- background: none;
- color: inherit;
- font: inherit;
- font-size: 10px;
- font-weight: 650;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 3px;
- cursor: pointer;
-}
-
-.a-body.is-native .a-bottom svg {
- width: 20px;
- height: 20px;
-}
-
-.a-body.is-native .a-bottom button.is-active {
- color: #f5f1ea;
-}
-
-/* The current tab also carries a bar, so it doesn't rely on colour alone. */
-.a-bottom button.is-active {
- position: relative;
-}
-
-.a-bottom button.is-active::before {
- content: "";
- position: absolute;
- top: -6px;
- left: 50%;
- width: 24px;
- height: 3px;
- border-radius: 0 0 3px 3px;
- background: currentColor;
- transform: translateX(-50%);
-}
-
-.a-body.is-native .a-native-status {
- display: flex;
- position: fixed;
- top: 0;
- left: 50%;
- z-index: 50;
- width: min(390px, 100%);
- height: 54px;
- padding: 14px 28px 0;
- transform: translateX(-50%);
- align-items: flex-start;
- justify-content: space-between;
- color: #f5f1ea;
- font-size: 15px;
- font-weight: 650;
- pointer-events: none;
-}
-
-.a-body.is-native .a-native-home {
- display: block;
- position: fixed;
- left: 50%;
- bottom: 8px;
- z-index: 32;
- width: 134px;
- height: 5px;
- margin-left: -67px;
- background: #f5f1ea;
- border-radius: 99px;
- pointer-events: none;
-}
-
-.a-body.is-native.is-gate .a-app,
-.a-body.is-native.is-gate .a-bottom,
-.a-body.is-native.is-gate .a-native-home {
- display: none;
-}
-
-.a-body.is-native.is-gate .a-gate {
- width: min(390px, 100%);
- margin: 0 auto;
- min-height: 100vh;
- padding: 64px 20px 40px;
-}
-
-.a-body.is-native .has-window .a-windows,
-.a-body.is-native .a-windows:not([hidden]) {
- place-items: end center;
- padding: 0;
- width: min(390px, 100%);
- left: 50%;
- right: auto;
- transform: translateX(-50%);
-}
-
-.a-body.is-native .a-window-frame {
- width: 100%;
- max-height: 92vh;
- border-radius: var(--radius-surface) var(--radius-surface) 0 0;
-}
-
-.a-body.is-native .a-choose.is-open .a-choose-menu {
- position: fixed;
- left: 12px;
- right: 12px;
- top: auto;
- bottom: 90px;
- min-width: 0;
-}
-
-@media (max-width: 480px) {
- .a-kpis {
- grid-template-columns: 1fr;
- }
-}
-
-/* Operations calendar */
-.ops {
- display: grid;
- gap: 18px;
-}
-
-.ops button {
- font-family: inherit;
-}
-
-.ops-overview {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- gap: 22px 24px;
- padding: 26px;
- overflow: hidden;
- color: #f8f5f0;
- background:
- radial-gradient(
- circle at 84% -30%,
- rgba(137, 190, 162, 0.28),
- transparent 44%
- ),
- linear-gradient(145deg, #1a1e2b 0%, #202a2b 100%);
- border-radius: 20px;
- box-shadow: 0 18px 42px rgba(26, 30, 43, 0.12);
-}
-
-.ops-overview-copy h2,
-.ops-calendar h3,
-.ops-week h3,
-.ops-day-panel h3,
-.ops-agenda h3 {
- margin: 0;
- letter-spacing: -0.035em;
-}
-
-.ops-overview-copy h2 {
- margin-top: 6px;
- max-width: 35rem;
- font-size: clamp(1.65rem, 3vw, 2.3rem);
- line-height: 1.06;
-}
-
-.ops-overview-copy p {
- max-width: 39rem;
- margin: 10px 0 0;
- color: rgba(248, 245, 240, 0.68);
- font-size: 14px;
- line-height: 1.5;
-}
-
-.ops-eyebrow {
- display: block;
- color: var(--ink-soft);
- font-size: 10px;
- font-weight: 750;
- letter-spacing: 0.14em;
- text-transform: uppercase;
-}
-
-.ops-overview .ops-eyebrow {
- color: rgba(248, 245, 240, 0.55);
-}
-
-.ops-add {
- align-self: start;
- gap: 7px;
- background: var(--sage);
- color: #fff;
- border-color: rgba(255, 255, 255, 0.12);
-}
-
-.ops-metrics {
- grid-column: 1 / -1;
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 10px;
-}
-
-.ops-metric {
- min-width: 0;
- padding: 14px 16px;
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 14px;
- color: inherit;
- background: rgba(255, 255, 255, 0.06);
- text-align: left;
- cursor: pointer;
-}
-
-.ops-metric:hover {
- background: rgba(255, 255, 255, 0.1);
-}
-
-.ops-metric span,
-.ops-metric small {
- display: block;
-}
-
-.ops-metric span {
- color: rgba(248, 245, 240, 0.58);
- font-size: 11px;
- font-weight: 700;
-}
-
-.ops-metric strong {
- display: block;
- margin: 4px 0;
- font-size: 1.35rem;
- letter-spacing: -0.04em;
-}
-
-.ops-metric small {
- overflow: hidden;
- color: rgba(248, 245, 240, 0.74);
- font-size: 12px;
- white-space: nowrap;
- text-overflow: ellipsis;
-}
-
-.ops-metric.is-warn strong {
- color: #f2b9a2;
-}
-
-.ops-metric.is-sage strong {
- color: #c7e6d5;
-}
-
-.ops-controls {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 14px;
-}
-
-.ops-view {
- background: rgba(255, 255, 255, 0.7);
-}
-
-.ops-filters {
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-.ops-filters button {
- height: 32px;
- padding: 0 13px;
- border: 1px solid transparent;
- border-radius: 999px;
- color: var(--ink-soft);
- background: transparent;
- font-size: 12px;
- font-weight: 700;
- cursor: pointer;
-}
-
-.ops-filters button.is-on {
- border-color: var(--border);
- color: var(--ink);
- background: #fff;
-}
-
-.ops-calendar-layout {
- display: grid;
- grid-template-columns: minmax(0, 1fr) 280px;
- gap: 14px;
- align-items: start;
-}
-
-.ops-calendar,
-.ops-day-panel,
-.ops-agenda,
-.ops-week {
- border: 1px solid var(--border);
- border-radius: 18px;
- background: rgba(255, 255, 255, 0.86);
- box-shadow: 0 10px 28px rgba(26, 30, 43, 0.05);
-}
-
-.ops-calendar {
- overflow: hidden;
-}
-
-.ops-week {
- min-width: 0;
- overflow: hidden;
-}
-
-.ops-week-scroll {
- overflow-x: auto;
-}
-
-.ops-week-inner {
- display: grid;
- grid-template-columns: 56px repeat(7, minmax(0, 1fr));
- min-width: 0;
-}
-
-.ops-week-corner {
- border-bottom: 1px solid var(--border);
- background: #f8f5f0;
-}
-
-.ops-week-dayhead {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 4px;
- padding: 10px 6px 12px;
- border: 0;
- border-bottom: 1px solid var(--border);
- border-left: 1px solid var(--border);
- color: inherit;
- background: #f8f5f0;
- cursor: pointer;
-}
-
-.ops-week-dayhead span {
- color: var(--ink-soft);
- font-size: 10px;
- font-weight: 750;
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}
-
-.ops-week-dayhead strong {
- width: 28px;
- height: 28px;
- display: grid;
- place-items: center;
- border-radius: 999px;
- font-size: 14px;
- font-weight: 750;
-}
-
-.ops-week-dayhead.is-today strong {
- color: #fff;
- background: var(--sage);
-}
-
-.ops-week-dayhead.is-selected:not(.is-today) strong {
- background: rgba(45, 106, 79, 0.12);
-}
-
-.ops-week-allday-label {
- display: flex;
- align-items: center;
- padding: 8px 8px 8px 10px;
- border-bottom: 1px solid var(--border);
- color: var(--ink-soft);
- font-size: 10px;
- font-weight: 750;
- letter-spacing: 0.04em;
- text-transform: uppercase;
-}
-
-.ops-week-allday {
- display: flex;
- flex-direction: column;
- gap: 4px;
- min-height: 52px;
- padding: 6px 5px;
- border-bottom: 1px solid var(--border);
- border-left: 1px solid var(--border);
-}
-
-.ops-week-allday.is-today,
-.ops-week-col.is-today {
- background: rgba(45, 106, 79, 0.04);
-}
-
-.ops-week-allday.is-selected,
-.ops-week-col.is-selected {
- background: rgba(26, 30, 43, 0.03);
-}
-
-.ops-week-hours {
- position: relative;
- border-right: 1px solid var(--border);
-}
-
-.ops-week-hours span {
- position: absolute;
- left: 0;
- right: 4px;
- transform: translateY(-6px);
- color: var(--ink-soft);
- font-size: 10px;
- font-weight: 650;
- text-align: right;
-}
-
-.ops-week-col {
- position: relative;
- border-left: 1px solid var(--border);
- background-image: repeating-linear-gradient(
- to bottom,
- transparent,
- transparent calc(52px - 1px),
- rgba(23, 27, 36, 0.07) calc(52px - 1px),
- rgba(23, 27, 36, 0.07) 52px
- );
-}
-
-.ops-week-slot {
- position: absolute;
- right: 0;
- left: 0;
- z-index: 0;
- padding: 0;
- border: 0;
- background: transparent;
- cursor: pointer;
-}
-
-.ops-week-slot:hover {
- background: rgba(45, 106, 79, 0.06);
-}
-
-.ops-week-card {
- z-index: 1;
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 2px;
- overflow: hidden;
- padding: 6px 8px;
- border: 0;
- border-left: 3px solid var(--sage);
- border-radius: 8px;
- color: inherit;
- background: #edf4ef;
- text-align: left;
- cursor: pointer;
-}
-
-.ops-week-card.is-allday {
- position: static;
- width: 100%;
-}
-
-.ops-week-card:not(.is-allday) {
- position: absolute;
-}
-
-.ops-week-card strong,
-.ops-week-card span {
- display: block;
- min-width: 0;
- max-width: 100%;
- white-space: normal;
- overflow-wrap: anywhere;
-}
-
-.ops-week-card strong {
- font-size: 12px;
- font-weight: 750;
- letter-spacing: -0.02em;
- line-height: 1.25;
-}
-
-.ops-week-card span {
- color: var(--ink-soft);
- font-size: 10px;
- font-weight: 650;
- line-height: 1.3;
-}
-
-.ops-week-card.is-delivery {
- border-left-color: #c79339;
- background: #f7efdf;
-}
-
-.ops-week-card.is-visit {
- border-left-color: #8c6a9d;
- background: #f3ecf6;
-}
-
-.ops-week-card.is-appointment {
- border-left-color: #4f8ba8;
- background: #e7f1f6;
-}
-
-.ops-week-card.is-other {
- border-left-color: #88939a;
- background: #eef1f3;
-}
-
-.ops-week-card.is-done {
- opacity: 0.55;
- text-decoration: line-through;
-}
-
-.operation-delete {
- padding: 14px 16px;
- border: 1px solid rgba(155, 78, 53, 0.22);
- border-radius: 12px;
- background: #fbede6;
-}
-
-.operation-delete p {
- margin: 0 0 12px;
- color: #9b4e35;
- font-size: 13px;
- font-weight: 650;
-}
-
-/* Skip and hand-over panels share the card's shape, without its warning tone. */
-.operation-delete.operation-panel {
- border-color: var(--border);
- background: var(--paper-2);
-}
-
-.operation-delete.operation-panel p {
- color: var(--ink);
- font-weight: 500;
-}
-
-.operation-panel .a-field {
- margin-bottom: 12px;
-}
-
-/* Task actions sit on one line on desktop. Remove is a quiet red link at the
- far end, apart from the everyday actions, so it is hard to hit by mistake. */
-.a-window .a-field-row.operation-actions {
- flex-wrap: nowrap;
-}
-
-.a-window .a-field-row.operation-actions .a-btn {
- min-width: 0;
- white-space: nowrap;
-}
-
-/* Desktop has room for the full label; a phone shows the short one. */
-.operation-label-short {
- display: none;
-}
-
-.operation-remove {
- height: 38px;
- margin-left: auto;
- padding: 0 4px;
- border: 0;
- background: none;
- color: #9b4e35;
- font: inherit;
- font-size: 13px;
- font-weight: 650;
- cursor: pointer;
-}
-
-.operation-remove:hover {
- text-decoration: underline;
-}
-
-/* On a phone the buttons sit closer together so the row still fits on one
- line. Only the narrowest screens wrap, with Remove last at the right. */
-@media (max-width: 560px) {
- .a-window .a-field-row.operation-actions {
- flex-wrap: wrap;
- gap: 6px;
+ .a-app {
+ grid-template-columns: 1fr;
+ padding-bottom: 64px;
+ }
+ .a-side {
+ display: none;
+ }
+ .a-header {
+ padding: 12px 16px;
+ background: var(--navy);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ color: #f5f1ea;
+ }
+ .a-header-title h1 {
+ color: #f5f1ea;
+ }
+ .a-header-title p {
+ color: rgba(245, 241, 234, 0.55);
+ white-space: normal;
+ overflow: visible;
+ }
+ .a-header-mark {
+ display: block;
+ width: 38px;
+ height: 38px;
+ padding: 10px;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.06);
+ }
+ .a-header .a-icon-btn {
+ background: rgba(255, 255, 255, 0.06);
+ border-color: rgba(255, 255, 255, 0.12);
+ color: #f5f1ea;
+ }
+ .a-header .a-icon-btn.has-unread::after {
+ background: var(--sage);
+ box-shadow: 0 0 0 2px var(--navy);
+ }
+ .a-header .a-icon-btn.a-you-btn {
+ display: grid;
+ }
+ .a-icon-btn.a-more-btn,
+ .a-more-btn {
+ display: grid;
+ place-items: center;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ color: #f5f1ea;
+ }
+ .a-search {
+ display: none;
+ }
+ .a-content {
+ padding: 16px 16px 28px;
+ }
+ .a-place-grid {
+ grid-template-columns: 1fr;
+ }
+ .a-owes .a-move {
+ grid-template-columns: auto minmax(0, 1fr);
+ }
+ .a-owes .a-move .amt {
+ grid-column: 2;
+ }
+ .a-move {
+ grid-template-columns: auto minmax(0, 1fr);
+ }
+ .a-move .amt {
+ grid-column: 2;
+ justify-self: start;
+ }
+ .a-bottom {
+ display: flex;
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 30;
+ height: 62px;
+ padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
+ background: var(--navy);
+ color: rgba(245, 241, 234, 0.72);
+ justify-content: space-around;
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+ }
+ .a-bottom button {
+ flex: 1;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ font-size: 10px;
+ font-weight: 650;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 3px;
+ cursor: pointer;
}
-
- .a-window .a-field-row.operation-actions .a-btn {
- padding: 0 10px;
+ .a-bottom svg {
+ width: 20px;
+ height: 20px;
}
-
- .operation-label-long {
- display: none;
+ .a-bottom button.is-active {
+ color: #f5f1ea;
}
-
- .operation-label-short {
- display: inline;
+ .has-window .a-windows {
+ place-items: end center;
+ padding: 0;
+ }
+ .a-window-frame {
+ width: 100%;
+ max-height: 92vh;
+ border-radius: var(--radius-surface) var(--radius-surface) 0 0;
+ }
+ .a-window {
+ grid-template-columns: 1fr;
+ }
+ .a-window > .a-field.half {
+ grid-column: 1;
+ }
+ .a-note-facts {
+ grid-template-columns: 1fr;
+ }
+ .a-set {
+ grid-template-columns: 1fr;
+ gap: 16px;
+ }
+ .a-body.more-open .a-more {
+ display: flex;
+ flex-direction: column;
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ top: auto;
+ z-index: 41;
+ width: 100%;
+ max-height: min(86vh, 640px);
+ overflow: auto;
+ border: 0;
+ border-radius: var(--radius-surface) var(--radius-surface) 0 0;
+ padding: 10px 0 calc(18px + env(safe-area-inset-bottom, 0px));
+ box-shadow: 0 -18px 50px rgba(26, 30, 43, 0.28);
+ background: #fff;
+ }
+ .a-more::before {
+ content: "";
+ display: block;
+ width: 36px;
+ height: 4px;
+ border-radius: 99px;
+ background: var(--border);
+ margin: 4px auto 6px;
+ flex-shrink: 0;
+ }
+ .a-more-head {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 4px 18px 12px;
+ }
+ .a-more-kicker {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 750;
+ letter-spacing: -0.02em;
+ }
+ .a-more-lede {
+ margin: 4px 0 0;
+ font-size: 13px;
+ color: var(--ink-soft);
+ line-height: 1.4;
+ }
+ .a-more-x {
+ width: 32px;
+ height: 32px;
+ border: 0;
+ border-radius: 999px;
+ background: var(--navy);
+ color: #fff;
+ display: grid;
+ place-items: center;
+ cursor: pointer;
+ flex-shrink: 0;
+ }
+ .a-more-x svg {
+ width: 14px;
+ height: 14px;
+ }
+ .a-more > button {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ margin: 0;
+ padding: 12px 18px;
+ border: 0;
+ border-bottom: 1px solid rgba(23, 27, 36, 0.06);
+ background: transparent;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ color: inherit;
+ }
+ .a-more > button:last-child {
+ border-bottom: 0;
+ }
+ .a-more > button:hover {
+ background: #f8f5f0;
+ }
+ .a-more-ic {
+ width: 44px;
+ height: 44px;
+ border-radius: 12px;
+ background: #e8f0eb;
+ color: var(--sage);
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+ }
+ .a-more-ic-dash {
+ background: transparent;
+ border: 1px dashed rgba(26, 30, 43, 0.28);
+ color: var(--ink-soft);
+ }
+ .a-more-ic svg {
+ width: 18px;
+ height: 18px;
+ }
+ .a-more-copy {
+ flex: 1;
+ min-width: 0;
+ }
+ .a-more-copy strong {
+ display: block;
+ font-size: 15px;
+ font-weight: 650;
+ }
+ .a-more-copy span {
+ display: block;
+ font-size: 13px;
+ color: var(--ink-soft);
+ margin-top: 2px;
}
}
-/* The window's sticky footer is for full-width action rows, not this card. */
-.a-window .operation-delete .a-field-row,
-.a-body.is-dark .a-window .operation-delete .a-field-row {
- position: static;
- margin: 0;
- padding: 0;
- background: transparent;
- border-top: 0;
-}
-
-.ops-calendar-head,
-.ops-agenda-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
- padding: 18px 20px;
-}
-
-.ops-calendar h3,
-.ops-week h3,
-.ops-day-panel h3,
-.ops-agenda h3 {
- margin-top: 5px;
- font-size: 1.25rem;
-}
-
-.ops-month-actions {
- display: flex;
- align-items: center;
- gap: 5px;
-}
-
-.ops-month-actions button {
- height: 32px;
- min-width: 32px;
- padding: 0 10px;
- border: 1px solid var(--border);
- border-radius: 999px;
- color: var(--ink);
- background: #fff;
- font-size: 12px;
- font-weight: 700;
- cursor: pointer;
-}
-
-.ops-month-actions button:first-child,
-.ops-month-actions button:last-child {
- padding: 0;
- font-size: 20px;
- line-height: 1;
-}
-
-.ops-weekdays,
-.ops-month-grid {
- display: grid;
- grid-template-columns: repeat(7, minmax(0, 1fr));
-}
-
-.ops-weekdays {
- border-top: 1px solid var(--border);
- border-bottom: 1px solid var(--border);
- background: #f8f5f0;
-}
-
-.ops-weekdays span {
- padding: 9px 8px;
- color: var(--ink-soft);
- font-size: 10px;
- font-weight: 750;
- letter-spacing: 0.09em;
- text-align: center;
- text-transform: uppercase;
-}
-
-.ops-day {
- min-width: 0;
- min-height: 110px;
- padding: 7px;
- border-right: 1px solid var(--border);
- border-bottom: 1px solid var(--border);
- background: rgba(255, 255, 255, 0.66);
-}
-
-.ops-day:nth-child(7n) {
- border-right: 0;
-}
-
-.ops-day:nth-last-child(-n + 7) {
- border-bottom: 0;
-}
-
-.ops-day.is-outside {
- background: rgba(245, 241, 234, 0.46);
-}
-
-.ops-day.is-selected {
- background: #f0f6f2;
- box-shadow: inset 0 0 0 1px rgba(45, 106, 79, 0.22);
-}
-
-.ops-day-number {
- width: 26px;
- height: 26px;
- padding: 0;
- border: 0;
- border-radius: 999px;
- color: var(--ink);
- background: transparent;
- font-size: 11px;
- font-weight: 750;
- cursor: pointer;
-}
-
-.ops-day.is-outside .ops-day-number {
- color: var(--ink-faint);
-}
-
-.ops-day.is-today .ops-day-number {
- color: #fff;
- background: var(--sage);
-}
-
-.ops-day-items {
- display: grid;
- gap: 4px;
- margin-top: 4px;
-}
-
-.ops-calendar-item {
- width: 100%;
- min-width: 0;
- padding: 5px 6px;
- overflow: hidden;
- border: 0;
- border-radius: 6px;
- color: #25352d;
- background: #e5f0e9;
- font-size: 10px;
- font-weight: 650;
- line-height: 1.2;
- text-align: left;
- white-space: nowrap;
- text-overflow: ellipsis;
- cursor: pointer;
-}
-
-.ops-calendar-item span {
- margin-right: 4px;
- color: rgba(37, 53, 45, 0.55);
- font-variant-numeric: tabular-nums;
+.a-body.is-native {
+ background: #0d0f17;
}
-.ops-calendar-item.is-delivery {
- color: #5d431b;
- background: #f5e9cf;
+.a-body.is-native .a-app {
+ width: min(390px, 100%);
+ margin: 0 auto;
+ grid-template-columns: 1fr;
+ min-height: 100vh;
+ padding-bottom: 78px;
+ background: linear-gradient(180deg, #f8f5f0 0%, #f3eee7 100%);
}
-.ops-calendar-item.is-visit {
- color: #4d3a5c;
- background: #ede4f1;
+.a-body.is-native .a-side {
+ display: none;
}
-.ops-calendar-item.is-appointment {
- color: #24485e;
- background: #dfeef4;
+.a-body.is-native .a-header {
+ padding: 54px 16px 12px;
+ background: var(--navy);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ color: #f5f1ea;
}
-.ops-calendar-item.is-other {
- color: #4a4750;
- background: #ebe8ed;
+.a-body.is-native .a-header-title h1 {
+ color: #f5f1ea;
}
-.ops-calendar-item.is-done {
- opacity: 0.52;
- text-decoration: line-through;
+.a-body.is-native .a-header-title p {
+ color: rgba(245, 241, 234, 0.55);
+ white-space: normal;
+ overflow: visible;
}
-.ops-more-count {
- padding: 0 4px;
- color: var(--ink-soft);
- font-size: 9px;
- font-weight: 700;
+.a-body.is-native .a-header-mark {
+ display: block;
+ width: 38px;
+ height: 38px;
+ padding: 10px;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.06);
}
-.ops-day-panel {
- position: sticky;
- top: 92px;
- padding: 18px;
+.a-body.is-native .a-header .a-icon-btn {
+ background: rgba(255, 255, 255, 0.06);
+ border-color: rgba(255, 255, 255, 0.12);
+ color: #f5f1ea;
}
-.ops-day-panel-head {
- padding-bottom: 14px;
- border-bottom: 1px solid var(--border);
+.a-body.is-native .a-header .a-icon-btn.a-you-btn {
+ display: grid;
}
-.ops-day-list {
+.a-body.is-native .a-icon-btn.a-more-btn,
+.a-body.is-native .a-more-btn {
display: grid;
- gap: 4px;
- padding: 8px 0;
+ place-items: center;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ color: #f5f1ea;
}
-.ops-day-empty {
- padding: 22px 2px;
+.a-body.is-native .a-set {
+ grid-template-columns: 1fr;
+ gap: 16px;
}
-.ops-day-empty span {
- font-size: 14px;
- font-weight: 750;
+.a-body.is-native.more-open .a-sheet-scrim {
+ width: min(390px, 100%);
+ left: 50%;
+ right: auto;
+ transform: translateX(-50%);
}
-.ops-day-empty p {
- margin: 5px 0 0;
- color: var(--ink-soft);
- font-size: 12px;
- line-height: 1.45;
+.a-body.is-native.more-open .a-more {
+ display: flex;
+ flex-direction: column;
+ position: fixed;
+ left: 50%;
+ right: auto;
+ bottom: 0;
+ top: auto;
+ z-index: 41;
+ width: min(390px, 100%);
+ max-height: min(86vh, 640px);
+ overflow: auto;
+ border: 0;
+ border-radius: var(--radius-surface) var(--radius-surface) 0 0;
+ padding: 10px 0 28px;
+ transform: translateX(-50%);
+ box-shadow: 0 -18px 50px rgba(26, 30, 43, 0.28);
+ background: #fff;
}
-.ops-day-add {
- width: 100%;
- justify-content: center;
- gap: 6px;
+.a-body.is-native.more-open .a-more::before {
+ content: "";
+ display: block;
+ width: 36px;
+ height: 4px;
+ border-radius: 99px;
+ background: var(--border);
+ margin: 4px auto 6px;
+ flex-shrink: 0;
}
-.ops-agenda {
- overflow: hidden;
+.a-body.is-native .a-search {
+ display: none;
}
-.ops-agenda-head {
- border-bottom: 1px solid var(--border);
+.a-body.is-native .a-content {
+ padding: 16px 16px 28px;
}
-.ops-agenda-head .a-btn {
- gap: 6px;
+.a-body.is-native .a-week,
+.a-body.is-native .a-place-grid {
+ grid-template-columns: 1fr;
}
-.ops-agenda-group {
- display: grid;
- grid-template-columns: 132px minmax(0, 1fr);
- border-bottom: 1px solid var(--border);
+.a-body.is-native .a-bottom {
+ display: flex;
+ position: fixed;
+ left: 50%;
+ right: auto;
+ bottom: 0;
+ z-index: 30;
+ width: min(390px, 100%);
+ height: 78px;
+ padding: 6px 8px 22px;
+ transform: translateX(-50%);
+ background: var(--navy);
+ color: rgba(245, 241, 234, 0.72);
+ justify-content: space-around;
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
}
-.ops-agenda-group:last-child {
- border-bottom: 0;
+.a-body.is-native .a-bottom button {
+ flex: 1;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ font-size: 10px;
+ font-weight: 650;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 3px;
+ cursor: pointer;
}
-.ops-date-block {
- padding: 19px 18px;
- border-right: 1px solid var(--border);
- background: #f8f5f0;
+.a-body.is-native .a-bottom svg {
+ width: 20px;
+ height: 20px;
}
-.ops-date-block strong,
-.ops-date-block span {
- display: block;
+.a-body.is-native .a-bottom button.is-active {
+ color: #f5f1ea;
}
-.ops-date-block strong {
- font-size: 15px;
- letter-spacing: -0.02em;
+/* The current tab also carries a bar, so it doesn't rely on colour alone. */
+.a-bottom button.is-active {
+ position: relative;
}
-.ops-date-block span {
- margin-top: 4px;
- color: var(--ink-soft);
- font-size: 11px;
+.a-bottom button.is-active::before {
+ content: "";
+ position: absolute;
+ top: -6px;
+ left: 50%;
+ width: 24px;
+ height: 3px;
+ border-radius: 0 0 3px 3px;
+ background: currentColor;
+ transform: translateX(-50%);
}
-.ops-date-block.is-overdue {
- color: #9b4e35;
- background: #fbede6;
+.a-body.is-native .has-window .a-windows,
+.a-body.is-native .a-windows:not([hidden]) {
+ place-items: end center;
+ padding: 0;
+ width: min(390px, 100%);
+ left: 50%;
+ right: auto;
+ transform: translateX(-50%);
}
-.ops-agenda-rows {
- min-width: 0;
- padding: 6px 18px;
+.a-body.is-native .a-window-frame {
+ width: 100%;
+ max-height: 92vh;
+ border-radius: var(--radius-surface) var(--radius-surface) 0 0;
}
-.ops-row {
- display: flex;
- min-width: 0;
- align-items: center;
- gap: 12px;
- min-height: 68px;
- border-bottom: 1px solid rgba(23, 27, 36, 0.06);
+/* Operations calendar */
+.ops {
+ display: grid;
+ gap: 18px;
}
-.ops-row:last-child {
- border-bottom: 0;
+.ops button {
+ font-family: inherit;
}
-.ops-row.is-compact {
- gap: 9px;
- min-height: 58px;
+.ops-add {
+ align-self: start;
+ gap: 7px;
+ background: var(--sage);
+ color: #fff;
+ border-color: rgba(255, 255, 255, 0.12);
}
-.ops-check {
- width: 23px;
- height: 23px;
- flex: 0 0 23px;
- padding: 0;
- border: 1.5px solid rgba(45, 106, 79, 0.42);
- border-radius: 999px;
- color: #fff;
- background: #fff;
- font-size: 12px;
- font-weight: 800;
- cursor: pointer;
+.operation-delete {
+ padding: 14px 16px;
+ border: 1px solid rgba(155, 78, 53, 0.22);
+ border-radius: 12px;
+ background: #fbede6;
}
-.ops-check[aria-pressed="true"] {
- border-color: var(--sage);
- background: var(--sage);
+.operation-delete p {
+ margin: 0 0 12px;
+ color: #9b4e35;
+ font-size: 13px;
+ font-weight: 650;
}
-.ops-event-mark {
- width: 10px;
- height: 10px;
- flex: 0 0 10px;
- border-radius: 999px;
- background: #88939a;
- box-shadow: 0 0 0 5px rgba(136, 147, 154, 0.12);
+/* Skip and hand-over panels share the card's shape, without its warning tone. */
+.operation-delete.operation-panel {
+ border-color: var(--border);
+ background: var(--paper-2);
}
-.ops-event-mark.is-delivery {
- background: #c79339;
- box-shadow: 0 0 0 5px rgba(199, 147, 57, 0.13);
+.operation-delete.operation-panel p {
+ color: var(--ink);
+ font-weight: 500;
}
-.ops-event-mark.is-visit {
- background: #8c6a9d;
- box-shadow: 0 0 0 5px rgba(140, 106, 157, 0.12);
+.operation-panel .a-field {
+ margin-bottom: 12px;
}
-.ops-event-mark.is-appointment {
- background: #4f8ba8;
- box-shadow: 0 0 0 5px rgba(79, 139, 168, 0.12);
+/* Task actions sit on one line on desktop. Remove is a quiet red link at the
+ far end, apart from the everyday actions, so it is hard to hit by mistake. */
+.a-window .a-field-row.operation-actions {
+ flex-wrap: nowrap;
}
-.ops-row-main {
+.a-window .a-field-row.operation-actions .a-btn {
min-width: 0;
- flex: 1;
- padding: 10px 0;
+ white-space: nowrap;
+}
+
+/* Desktop has room for the full label; a phone shows the short one. */
+.operation-label-short {
+ display: none;
+}
+
+.operation-remove {
+ height: 38px;
+ margin-left: auto;
+ padding: 0 4px;
border: 0;
- color: inherit;
- background: transparent;
- text-align: left;
+ background: none;
+ color: #9b4e35;
+ font: inherit;
+ font-size: 13px;
+ font-weight: 650;
cursor: pointer;
}
-.ops-row-topline {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- gap: 12px;
+.operation-remove:hover {
+ text-decoration: underline;
}
-.ops-row-topline strong {
- min-width: 0;
- font-size: 14px;
- font-weight: 730;
- letter-spacing: -0.015em;
- line-height: 1.3;
- overflow-wrap: anywhere;
-}
+/* On a phone the buttons sit closer together so the row still fits on one
+ line. Only the narrowest screens wrap, with Remove last at the right. */
+@media (max-width: 560px) {
+ .a-window .a-field-row.operation-actions {
+ flex-wrap: wrap;
+ gap: 6px;
+ }
-.ops-row-time {
- flex-shrink: 0;
- color: var(--ink-soft);
- font-size: 11px;
- font-variant-numeric: tabular-nums;
-}
+ .a-window .a-field-row.operation-actions .a-btn {
+ padding: 0 10px;
+ }
-.ops-row-meta {
- display: block;
- margin-top: 4px;
- color: var(--ink-soft);
- font-size: 11px;
- line-height: 1.35;
- overflow-wrap: anywhere;
+ .operation-label-long {
+ display: none;
+ }
+
+ .operation-label-short {
+ display: inline;
+ }
}
-.ops-row-avatar {
- width: 28px;
- height: 28px;
- flex: 0 0 28px;
- font-size: 9px;
+/* The window's sticky footer is for full-width action rows, not this card. */
+.a-window .operation-delete .a-field-row,
+.a-body.is-dark .a-window .operation-delete .a-field-row {
+ position: static;
+ margin: 0;
+ padding: 0;
+ background: transparent;
+ border-top: 0;
}
-.ops-row.is-done .ops-row-main {
- opacity: 0.55;
- text-decoration: line-through;
+.ops-month-actions {
+ display: flex;
+ align-items: center;
+ gap: 5px;
}
-.ops-agenda-empty {
- padding: 42px 20px;
- text-align: center;
+.ops-month-actions button {
+ height: 32px;
+ min-width: 32px;
+ padding: 0 10px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ color: var(--ink);
+ background: #fff;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
}
-.ops-agenda-empty p {
- margin: 7px 0 18px;
- color: var(--ink-soft);
- font-size: 14px;
+.ops-month-actions button:first-child,
+.ops-month-actions button:last-child {
+ padding: 0;
+ font-size: 20px;
+ line-height: 1;
}
.operation-detail-hero {
@@ -5239,190 +3016,12 @@ button.a-item {
outline: none;
}
-@media (max-width: 1000px) {
- .ops-calendar-layout {
- grid-template-columns: 1fr;
- }
-
- .ops-day-panel {
- position: static;
- }
-}
-
-@media (max-width: 720px) {
- .ops-day {
- min-height: 88px;
- padding: 5px;
- }
-
- .ops-calendar-item {
- padding: 4px;
- }
-}
-
@media (max-width: 560px) {
- .ops-overview {
- grid-template-columns: 1fr;
- gap: 18px;
- padding: 20px;
- border-radius: 16px;
- }
-
- .ops-overview-copy h2 {
- font-size: 1.65rem;
- }
.ops-add {
width: 100%;
justify-content: center;
}
-
- .ops-metrics {
- display: flex;
- grid-column: 1;
- margin: 0 -20px -4px;
- padding: 0 20px 4px;
- overflow-x: auto;
- scrollbar-width: none;
- }
-
- .ops-metric {
- min-width: 156px;
- }
-
- .ops-controls {
- align-items: stretch;
- flex-direction: column;
- }
-
- .ops-view {
- display: grid;
- width: 100%;
- grid-template-columns: 1fr 1fr 1fr;
- }
-
- .ops-filters {
- justify-content: center;
- }
-
- .ops-calendar-head,
- .ops-agenda-head {
- align-items: flex-start;
- padding: 15px;
- }
-
- .ops-calendar-head {
- flex-direction: column;
- }
-
- .ops-day {
- min-height: 55px;
- padding: 4px;
- }
-
- .ops-day-number {
- width: 22px;
- height: 22px;
- font-size: 10px;
- }
-
- .ops-day-items {
- display: flex;
- flex-wrap: wrap;
- gap: 3px;
- margin: 2px 3px 0;
- }
-
- .ops-calendar-item {
- width: 7px;
- height: 7px;
- padding: 0;
- overflow: hidden;
- border-radius: 999px;
- font-size: 0;
- }
-
- .ops-calendar-item span,
- .ops-more-count {
- display: none;
- }
-
- .ops-agenda-head .a-btn {
- min-width: 38px;
- padding: 0 12px;
- font-size: 0;
- }
-
- .ops-agenda-group {
- grid-template-columns: 78px minmax(0, 1fr);
- }
-
- .ops-date-block {
- padding: 16px 10px;
- }
-
- .ops-date-block strong {
- font-size: 13px;
- }
-
- .ops-agenda-rows {
- padding: 4px 12px;
- }
-
- .ops-row {
- gap: 9px;
- }
-
- .ops-row-avatar {
- display: none;
- }
-
- .ops-row-topline {
- display: block;
- }
-
- .ops-row-time {
- display: block;
- margin-top: 3px;
- }
-
- .ops-week-hours,
- .ops-week-slot,
- .ops-week-corner,
- .ops-week-allday-label {
- display: none;
- }
-
- .ops-week-inner {
- grid-template-columns: repeat(7, minmax(148px, 1fr));
- min-width: 1040px;
- }
-
- .ops-week-col {
- height: auto !important;
- min-height: 132px;
- padding: 6px 5px 10px;
- background-image: none;
- }
-
- .ops-week-card:not(.is-allday) {
- position: static;
- width: auto !important;
- height: auto !important;
- left: auto !important;
- margin-bottom: 6px;
- }
-}
-
-.a-body.is-native .ops-overview {
- grid-template-columns: 1fr;
- gap: 18px;
- padding: 20px;
- border-radius: 16px;
-}
-
-.a-body.is-native .ops-overview-copy h2 {
- font-size: 1.65rem;
}
.a-body.is-native .ops-add {
@@ -5430,151 +3029,6 @@ button.a-item {
justify-content: center;
}
-.a-body.is-native .ops-metrics {
- display: flex;
- grid-column: 1;
- margin: 0 -20px -4px;
- padding: 0 20px 4px;
- overflow-x: auto;
- scrollbar-width: none;
-}
-
-.a-body.is-native .ops-metric {
- min-width: 156px;
-}
-
-.a-body.is-native .ops-controls {
- align-items: stretch;
- flex-direction: column;
-}
-
-.a-body.is-native .ops-view {
- display: grid;
- width: 100%;
- grid-template-columns: 1fr 1fr 1fr;
-}
-
-.a-body.is-native .ops-filters {
- justify-content: center;
-}
-
-.a-body.is-native .ops-calendar-layout {
- grid-template-columns: 1fr;
-}
-
-.a-body.is-native .ops-calendar-head {
- align-items: flex-start;
- flex-direction: column;
- padding: 15px;
-}
-
-.a-body.is-native .ops-day {
- min-height: 55px;
- padding: 4px;
-}
-
-.a-body.is-native .ops-day-number {
- width: 22px;
- height: 22px;
- font-size: 10px;
-}
-
-.a-body.is-native .ops-day-items {
- display: flex;
- flex-wrap: wrap;
- gap: 3px;
- margin: 2px 3px 0;
-}
-
-.a-body.is-native .ops-calendar-item {
- width: 7px;
- height: 7px;
- padding: 0;
- overflow: hidden;
- border-radius: 999px;
- font-size: 0;
-}
-
-.a-body.is-native .ops-calendar-item span,
-.a-body.is-native .ops-more-count {
- display: none;
-}
-
-.a-body.is-native .ops-day-panel {
- position: static;
-}
-
-.a-body.is-native .ops-agenda-head {
- align-items: flex-start;
- padding: 15px;
-}
-
-.a-body.is-native .ops-agenda-head .a-btn {
- min-width: 38px;
- padding: 0 12px;
- font-size: 0;
-}
-
-.a-body.is-native .ops-agenda-group {
- grid-template-columns: 78px minmax(0, 1fr);
-}
-
-.a-body.is-native .ops-date-block {
- padding: 16px 10px;
-}
-
-.a-body.is-native .ops-date-block strong {
- font-size: 13px;
-}
-
-.a-body.is-native .ops-agenda-rows {
- padding: 4px 12px;
-}
-
-.a-body.is-native .ops-row {
- gap: 9px;
-}
-
-.a-body.is-native .ops-row-avatar {
- display: none;
-}
-
-.a-body.is-native .ops-row-topline {
- display: block;
-}
-
-.a-body.is-native .ops-row-time {
- display: block;
- margin-top: 3px;
-}
-
-.a-body.is-native .ops-week-hours,
-.a-body.is-native .ops-week-slot,
-.a-body.is-native .ops-week-corner,
-.a-body.is-native .ops-week-allday-label {
- display: none;
-}
-
-.a-body.is-native .ops-week-inner {
- grid-template-columns: repeat(7, minmax(148px, 1fr));
- min-width: 1040px;
-}
-
-.a-body.is-native .ops-week-col {
- height: auto !important;
- min-height: 132px;
- padding: 6px 5px 10px;
- background-image: none;
-}
-
-.a-body.is-native .ops-week-card:not(.is-allday) {
- position: static;
- width: auto !important;
- height: auto !important;
- left: auto !important;
- margin-bottom: 6px;
-}
-
.ops-prod {
display: grid;
gap: 16px;
@@ -5632,7 +3086,7 @@ button.a-item {
}
.ops-cuts button.is-on {
- background: #1a1e2b;
+ background: var(--navy);
color: #fff;
box-shadow: none;
}
@@ -5669,10 +3123,6 @@ button.a-item {
color: #a54e35;
}
-.ops-kicker.is-sage {
- color: #2d6a4f;
-}
-
.ops-card h2 {
margin: 6px 0 8px;
font-size: clamp(1.45rem, 2vw, 1.85rem);
@@ -5725,8 +3175,8 @@ button.a-item {
}
.ops-rotation em.is-on {
- background: #1a1e2b;
- border-color: #1a1e2b;
+ background: var(--navy);
+ border-color: var(--navy);
color: #fff;
}
@@ -5858,7 +3308,7 @@ button.a-item {
}
.ops-person .avatar.house {
- background: #1a1e2b;
+ background: var(--navy);
color: #f5f1ea;
}
@@ -5990,7 +3440,7 @@ button.a-item {
}
.ops-tile.is-today .ops-tile-num {
- background: #2d6a4f;
+ background: var(--sage);
color: #fff;
}
@@ -6002,7 +3452,7 @@ button.a-item {
border: 0;
border-radius: 8px;
background: #f4f0e9;
- color: #1a1e2b;
+ color: var(--navy);
font-size: 11px;
line-height: 1.25;
text-align: left;
@@ -6109,7 +3559,7 @@ button.a-item {
transform: translateX(-50%);
padding: 12px 16px;
border-radius: 999px;
- background: #1a1e2b;
+ background: var(--navy);
color: #f8f5f0;
font-size: 13px;
font-weight: 650;
diff --git a/apps/web/src/styles/auth.css b/apps/web/src/styles/auth.css
index 66e0ac68..6c14fd2e 100644
--- a/apps/web/src/styles/auth.css
+++ b/apps/web/src/styles/auth.css
@@ -9,7 +9,7 @@
}
.commune-auth-brand {
- background: linear-gradient(180deg, #1a1e2b 0%, #21262f 100%);
+ background: linear-gradient(180deg, var(--navy), var(--navy-3));
display: flex;
flex-direction: column;
justify-content: space-between;
@@ -152,7 +152,7 @@
.commune-auth-field input:focus {
outline: 2px solid rgba(45, 106, 79, 0.35);
outline-offset: 1px;
- border-color: #2d6a4f;
+ border-color: var(--sage);
}
.commune-auth-field input[aria-invalid="true"] {
@@ -166,17 +166,6 @@
color: #5f6878;
}
-.commune-auth-error {
- font-size: 0.75rem;
- color: #b42318;
-}
-
-.commune-auth-row {
- display: flex;
- justify-content: flex-end;
- margin: -4px 0 14px;
-}
-
.commune-auth-link {
border: 0;
background: none;
@@ -184,13 +173,13 @@
font: inherit;
font-size: 0.75rem;
font-weight: 600;
- color: #2d6a4f;
+ color: var(--sage);
cursor: pointer;
text-decoration: none;
}
.commune-auth-link:hover {
- color: #1b4332;
+ color: var(--sage-strong);
}
.commune-auth-alert .commune-auth-link {
@@ -222,7 +211,7 @@
padding: 0 18px;
border-radius: var(--radius-control);
border: 0;
- background: #1a1e2b;
+ background: var(--navy);
color: #fff;
font: inherit;
font-size: 0.95rem;
@@ -294,12 +283,6 @@ a.commune-auth-btn {
gap: 14px;
}
-.commune-auth-ok {
- text-align: center;
- font-weight: 600;
- color: #2d6a4f;
-}
-
.commune-auth-desktop-footer {
position: absolute;
right: 24px;
@@ -343,7 +326,7 @@ a.commune-auth-btn {
justify-content: center;
gap: 10px;
padding: 16px 24px;
- background: linear-gradient(135deg, #1a1e2b 0%, #21262f 100%);
+ background: linear-gradient(135deg, var(--navy), var(--navy-3));
}
.commune-auth-mobile-header img {
diff --git a/apps/web/src/styles/design-system.css b/apps/web/src/styles/design-system.css
index 98cd4b39..25ae6796 100644
--- a/apps/web/src/styles/design-system.css
+++ b/apps/web/src/styles/design-system.css
@@ -62,7 +62,7 @@
padding: 10px 14px;
border-radius: var(--radius-control);
background: #f5f1ea;
- color: #1a1e2b;
+ color: var(--navy);
font-size: 14px;
font-weight: 600;
text-decoration: none;
@@ -143,12 +143,16 @@
width: min(560px, calc(100vw - 32px));
max-height: calc(100dvh - 48px);
overflow: auto;
- background: var(--white, #fff);
+ background: var(--white);
color: var(--ink);
border: 1px solid var(--border);
border-radius: var(--radius-surface);
padding: 26px;
}
+/* Narrower for the question about discarding changes. */
+.c-product-dialog.c-discard-dialog {
+ width: min(420px, calc(100vw - 32px));
+}
.c-product-dialog::backdrop {
background: rgba(26, 30, 43, 0.45);
}
@@ -224,7 +228,7 @@
}
.c-type-chip.is-plan {
background: #edf3eb;
- color: #2d6a4f;
+ color: var(--sage);
}
.c-type-chip.is-shared {
background: #f5f1ea;
@@ -240,7 +244,7 @@
}
.a-body.is-dark .c-type-chip.is-shared {
background: #2e3646;
- color: #b5bdca;
+ color: var(--ink-soft);
}
/* Activity, a day at a time; Notices, a commune at a time; Home's Waiting on
others after what needs you. Each headed like search results. */
@@ -507,9 +511,6 @@
.c-product-dialog .a-field-row {
flex-wrap: wrap;
}
-.c-product-dialog .a-field-label {
- font-weight: 500;
-}
@media (max-width: 760px) {
.c-workflow-grid {
grid-template-columns: 1fr;
diff --git a/apps/web/src/styles/index.css b/apps/web/src/styles/index.css
new file mode 100644
index 00000000..48031459
--- /dev/null
+++ b/apps/web/src/styles/index.css
@@ -0,0 +1,23 @@
+/*
+ * Every stylesheet sits in a cascade layer, so which rule wins no longer
+ * depends on the order stylesheets arrive in: a screen's own stylesheet comes
+ * with its code, after these. From lowest to highest:
+ * base tokens, reset, and the shared app, sign-in and legal styles
+ * screens each screen's and window's own stylesheet, which wraps itself
+ * in this layer
+ * overrides the dark theme and the approved design-language primitives
+ * Main.tsx imports this file before anything else, so this order is declared
+ * before any stylesheet uses a layer.
+ */
+@layer base, screens, overrides;
+
+/* Before the app's own stylesheets, as its separate link was, so their rules still come after it. */
+@import "./calendar-date.css" layer(base);
+@import "./shared.css" layer(base);
+@import "./app.css" layer(base);
+@import "./auth.css" layer(base);
+@import "./legal.css" layer(base);
+@import "./overrides.css" layer(overrides);
+@import "./design-system.css" layer(overrides);
+/* Font faces only, which layers do not affect. */
+@import "./fonts.css";
diff --git a/apps/web/src/styles/overrides.css b/apps/web/src/styles/overrides.css
index bd7ecc86..a0e8ddbf 100644
--- a/apps/web/src/styles/overrides.css
+++ b/apps/web/src/styles/overrides.css
@@ -46,7 +46,7 @@ body,
.a-body.is-dark .a-app,
.a-body.is-dark .a-main,
.a-body.is-dark .a-content {
- background: linear-gradient(180deg, #1b202c 0%, #171b24 100%);
+ background: linear-gradient(180deg, var(--paper-2), var(--paper));
}
.a-body.is-dark .a-header {
@@ -65,7 +65,7 @@ body,
.a-more,
.a-owe-card.a-card
) {
- background: #242b39;
+ background: var(--white);
color: var(--ink);
border-color: var(--border);
}
@@ -90,7 +90,7 @@ body,
invisible. Invert it (cream face, navy text) and keep the chip's own tone. */
.a-body.is-dark :is(.ops-cuts button.is-on, .c-split-methods button[aria-pressed=true]) {
background: var(--ink);
- color: #1a1e2b;
+ color: var(--navy);
}
.a-body.is-dark .a-chip.is-on {
background: var(--sage-soft);
@@ -98,8 +98,9 @@ body,
border-color: transparent;
}
/* The sticky action row of a window kept its cream face in dark mode. */
-.a-body.is-dark .a-window .a-field-row {
- background: #242b39;
+/* Not a delete question in a task window, which keeps its plain row (app.css). */
+.a-body.is-dark .a-window .a-field-row:where(:not(.operation-delete *)) {
+ background: var(--white);
border-top-color: var(--border);
}
@@ -164,8 +165,6 @@ body,
/* Home keeps one featured commune with smaller places beneath it. */
.a-home-featured { margin-bottom: 16px; }
.a-home-places { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-bottom: 22px; }
-#home .a-home-greet h1 { font-size: 24px; font-weight: 550; }
-#home .a-home-greet p { font-size: 13px; color: var(--ink-soft); }
#home .a-place { box-shadow: none; border-radius: var(--radius-surface); }
#home .a-place-hero { min-height: 208px; padding: 18px; }
#home .a-place-hero h3 { font-size: 21px; font-weight: 500; letter-spacing: -.6px; }
@@ -189,7 +188,7 @@ body,
so in dark mode the titles turned light-on-cream and disappeared. Give its
surfaces the dark card face and keep the approved geometry unchanged. */
.a-body.is-dark :is(.ops-card, .ops-late, .ops-yours) {
- background: #242b39;
+ background: var(--white);
border-color: var(--border);
}
.a-body.is-dark :is(.ops-note, .ops-board-head small, .ops-cuts button:not(.is-on), .ops-week-row span) {
@@ -206,7 +205,7 @@ body,
.a-body.is-dark .ops-rotation em.is-on {
background: var(--ink);
border-color: var(--ink);
- color: #1a1e2b;
+ color: var(--navy);
}
.a-body.is-dark .ops-rotation i {
background: var(--border-strong);
@@ -215,17 +214,14 @@ body,
background: rgba(217, 235, 229, 0.07);
}
.a-body.is-dark .ops-kicker.is-late {
- color: #f2b9a2;
-}
-.a-body.is-dark .ops-kicker.is-sage {
- color: #9fd3b8;
+ color: var(--danger);
}
.a-body.is-dark :is(.ops-person, .ops-month-shell, .ops-tile, .ops-month-selected-list button) {
- background: #242b39;
+ background: var(--white);
border-color: var(--border);
}
.a-body.is-dark .ops-month-selected {
- background: #202633;
+ background: var(--paper-3);
border-top-color: var(--border);
}
.a-body.is-dark .ops-month-toolbar {
@@ -248,7 +244,7 @@ body,
color: var(--ink-soft);
}
.a-body.is-dark .ops-person-task.is-late strong {
- color: #f2b9a2;
+ color: var(--danger);
}
.a-body.is-dark .ops-month-toolbar button {
background: #1d2330;
@@ -256,7 +252,7 @@ body,
color: var(--ink);
}
.a-body.is-dark .hub-feed-go {
- color: #9fd3b8;
+ color: var(--text-green);
}
/* A pill with no tone (for example Voided) used a white face with soft ink,
@@ -275,7 +271,7 @@ body,
color: var(--ink-soft);
}
.a-body.is-dark .a-field label .need {
- color: #9fd3b8;
+ color: var(--text-green);
}
.a-body.is-dark .a-reminder-usage-track {
background: rgba(245, 241, 234, 0.14);
@@ -288,7 +284,7 @@ body,
box-shadow: inset 0 0 0 1px rgba(245, 241, 234, 0.28);
}
.a-body.is-dark .a-switch::after {
- background: #b5bdca;
+ background: var(--ink-soft);
box-shadow: none;
}
.a-body.is-dark .a-switch[aria-checked="true"] {
@@ -296,7 +292,7 @@ body,
box-shadow: none;
}
.a-body.is-dark .a-switch[aria-checked="true"]::after {
- background: #1a1e2b;
+ background: var(--navy);
}
.a-body.is-dark ::placeholder {
color: #9aa3b2;
diff --git a/apps/web/src/styles/shared.css b/apps/web/src/styles/shared.css
index f630e71e..70dcb120 100644
--- a/apps/web/src/styles/shared.css
+++ b/apps/web/src/styles/shared.css
@@ -117,9 +117,9 @@ img {
flex-shrink: 0;
}
-.avatar.maya { background: #d9ebe5; color: #1b4332; }
-.avatar.jordan { background: #efdccf; color: #7a3e1d; }
-.avatar.alex { background: #e8e1ef; color: #4a3d63; }
+.avatar.maya { background: var(--sage-soft); color: var(--sage-strong); }
+.avatar.jordan { background: var(--peach); color: #7a3e1d; }
+.avatar.alex { background: var(--lilac); color: #4a3d63; }
.avatar.priya { background: #f0e6d8; color: #6b4423; }
.avatar.sam { background: #dce8f0; color: #1e3a5f; }
.avatar.ness { background: #f3e0e6; color: #6b2d45; }
@@ -185,115 +185,6 @@ img {
.pill.open { background: #f8e8d8; color: #8a4b1f; border-color: transparent; }
.pill.settled { background: var(--sage-soft); color: var(--sage-strong); border-color: transparent; }
.pill.warn { background: #fde8e4; color: #9b2c2c; border-color: transparent; }
-.pill.navy { background: rgba(255,255,255,0.1); color: #f5f1ea; border-color: rgba(255,255,255,0.08); }
-
-.mock-bar {
- height: 40px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 16px;
- background: #0d0f17;
- color: rgba(245, 241, 234, 0.72);
- font-size: 12px;
- letter-spacing: 0.04em;
- z-index: 40;
- position: sticky;
- top: 0;
-}
-
-.mock-bar a {
- color: #f5f1ea;
- text-decoration: none;
- font-weight: 600;
-}
-
-.mock-bar a:hover {
- text-decoration: underline;
-}
-
-.mock-bar strong {
- color: #f5f1ea;
- font-weight: 600;
-}
-
-.layout-tabs {
- display: flex;
- align-items: center;
- gap: 6px;
- flex-wrap: wrap;
-}
-
-.layout-tabs button {
- height: 24px;
- padding: 0 10px;
- border-radius: 999px;
- border: 1px solid rgba(255, 255, 255, 0.16);
- background: transparent;
- color: #f5f1ea;
- font-size: 11px;
- font-weight: 600;
- letter-spacing: 0.02em;
- cursor: pointer;
-}
-
-.layout-tabs button.is-active {
- background: #f5f1ea;
- color: #0d0f17;
- border-color: transparent;
-}
-
-.layout-tabs a {
- margin-left: 4px;
-}
-
-.jump-grid {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 12px;
- margin-top: 16px;
-}
-
-.jump-tile {
- display: block;
- width: 100%;
- text-align: left;
- padding: 16px 18px;
- border-radius: 16px;
- border: 1px solid var(--border);
- background: rgba(255, 255, 255, 0.86);
- box-shadow: var(--shadow-sm);
- cursor: pointer;
- font: inherit;
- color: inherit;
-}
-
-.jump-tile:hover {
- border-color: var(--border-strong);
-}
-
-.jump-tile span {
- display: block;
- font-size: 11px;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- font-weight: 700;
- color: var(--ink-soft);
-}
-
-.jump-tile strong {
- display: block;
- margin: 8px 0 4px;
- font-size: 1.05rem;
- font-weight: 750;
- letter-spacing: -0.02em;
-}
-
-.jump-tile small {
- display: block;
- color: var(--ink-soft);
- font-size: 13px;
-}
.hub-feed {
display: flex;
@@ -344,9 +235,3 @@ img {
color: var(--sage);
white-space: nowrap;
}
-
-@media (max-width: 560px) {
- .jump-grid {
- grid-template-columns: 1fr 1fr;
- }
-}
diff --git a/apps/web/src/ui/QuickSearch.css b/apps/web/src/ui/QuickSearch.css
index 6df6ee93..65185699 100644
--- a/apps/web/src/ui/QuickSearch.css
+++ b/apps/web/src/ui/QuickSearch.css
@@ -1,6 +1,8 @@
+/* In the screens layer, above the shared base styles and below the dark
+ theme and the design-language primitives (see styles/index.css). */
+@layer screens {
/* One interaction, Commune's existing control/surface radius and restrained type. */
.a-header .c-search-trigger {
- display: flex;
width: 238px;
min-height: 36px;
gap: 9px;
@@ -13,24 +15,13 @@
font-size: 12px;
cursor: pointer;
}
+/* The phone app's layout hides the trigger (app.css). */
+.a-header .c-search-trigger:where(:not(.is-native *)) { display: flex; }
.c-search-trigger span { flex: 1; text-align: left; }
.c-search-trigger kbd, .c-search-key-hint kbd {
padding: 1px 4px; border: 1px solid var(--border); border-radius: 3px;
font: inherit; font-size: 10px; color: var(--ink-soft);
}
-.c-product-dialog.c-quick-search {
- width: min(640px, calc(100vw - 32px));
- max-width: 640px;
- max-height: calc(100dvh - 56px);
- padding: 0;
- margin: max(28px, 12dvh) auto auto;
- overflow: hidden;
- border-radius: var(--radius-surface);
- color: var(--ink);
- background: var(--white);
-}
-.c-product-dialog.c-quick-search > h2 { margin: 0; padding: 18px 22px 0; font-size: 17px; font-weight: 550; }
-.c-quick-search .c-dialog-close { top: 11px; right: 12px; }
.c-quick-search-input { display: flex; align-items: center; gap: 12px; padding: 18px 22px; color: var(--ink-soft); }
.c-quick-search-input input {
flex: 1; min-width: 0; border: 0; background: transparent; color: var(--ink);
@@ -88,10 +79,31 @@
@media (max-width: 700px) {
.a-header .c-search-trigger { width: 44px; min-height: 44px; flex-shrink: 0; padding: 12px; }
.c-search-trigger span, .c-search-trigger kbd { display: none; }
- .c-product-dialog.c-quick-search { margin-top: 20px; }
.c-quick-search-input input { font-size: 16px; }
.c-search-key-hint { display: none; }
.c-search-result-meta { max-width: 100px; }
.c-search-scope > span { max-width: 180px; }
.c-record-filter { flex: 1; min-width: 150px; }
}
+}
+
+/* The quick search window's own size, spacing and close button outrank the
+ shared dialog's (design-system.css), so they sit in the same layer. */
+@layer overrides {
+.c-product-dialog.c-quick-search {
+ width: min(640px, calc(100vw - 32px));
+ max-width: 640px;
+ max-height: calc(100dvh - 56px);
+ padding: 0;
+ margin: max(28px, 12dvh) auto auto;
+ overflow: hidden;
+ border-radius: var(--radius-surface);
+ color: var(--ink);
+ background: var(--white);
+}
+.c-product-dialog.c-quick-search > h2 { margin: 0; padding: 18px 22px 0; font-size: 17px; font-weight: 550; }
+.c-quick-search .c-dialog-close { top: 11px; right: 12px; }
+@media (max-width: 700px) {
+ .c-product-dialog.c-quick-search { margin-top: 20px; }
+}
+}
diff --git a/apps/web/src/windows/ExpenseEditor.css b/apps/web/src/windows/ExpenseEditor.css
index 3c8a7a56..4b4ddd22 100644
--- a/apps/web/src/windows/ExpenseEditor.css
+++ b/apps/web/src/windows/ExpenseEditor.css
@@ -1,5 +1,9 @@
-/* Qualified with the frame class: the shared 600px window width otherwise wins. */
-.a-window-frame.c-expense-frame { width: min(930px,100%); }
+/* In the screens layer, above the shared base styles and below the dark
+ theme and the design-language primitives (see styles/index.css). */
+@layer screens {
+/* Qualified with the frame class: the shared 600px window width otherwise wins.
+ The phone app's layout keeps its full-width window (app.css). */
+.a-window-frame.c-expense-frame:where(:not(.is-native *)) { width: min(930px,100%); }
.c-expense-frame .a-window-body { container-type: inline-size; }
.c-expense-editor { display: grid; grid-template-columns: minmax(0,1.65fr) minmax(240px,1fr); gap: 24px; }
.c-expense-form { padding: 0; }
@@ -7,7 +11,7 @@
.c-expense-form .a-field { align-content: start; }
.c-expense-actions { grid-column: 1 / -1; position: sticky; bottom: -24px; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 -24px -24px; padding: 16px 24px 24px; background: #f8f5f0; border-top: 1px solid rgba(23, 27, 36, 0.06); }
.c-expense-actions .a-btn { height: 38px; min-width: 118px; justify-content: center; }
-.is-dark .c-expense-actions { background: #242b39; border-top-color: var(--border); }
+.is-dark .c-expense-actions { background: var(--white); border-top-color: var(--border); }
.c-expense-form #exp-amt { font-size: 28px; font-weight: 450; letter-spacing: -.8px; }
.c-expense-form .a-field small { display: block; color: var(--ink-soft); font-size: 11px; margin-top: 6px; }
.c-expense-summary { align-self: start; border-radius: var(--radius-surface); padding: 22px; background: #edf1e7; color: var(--ink); }
@@ -17,7 +21,7 @@
.c-expense-summary p { margin-top: 18px; color: var(--ink-soft); font-size: 12px; line-height: 1.7; }
.c-split-methods { display: flex; gap: 3px; padding: 3px; background: var(--paper-2); border-radius: var(--radius-control); margin: 12px 0; }
.c-split-methods button { flex: 1; border: 0; padding: 9px 5px; border-radius: 7px; background: transparent; color: var(--ink); font: inherit; font-size: 12px; cursor: pointer; }
-.c-split-methods button[aria-pressed=true] { background: #1a1e2b; color: white; }
+.c-split-methods button[aria-pressed=true] { background: var(--navy); color: white; }
.c-split-person { gap: 10px; }
.c-split-person input[type=checkbox] { width: 16px; flex: none; }
.c-split-name { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: 13px; }
@@ -32,3 +36,4 @@
/* Two columns only when the dialog itself has room for them. */
@container (max-width: 640px) { .c-expense-editor { grid-template-columns: 1fr; } }
@media(max-width:700px) { .c-expense-editor { grid-template-columns: 1fr; } .c-split-person .avatar { display: none; } .c-split-value-slot { flex-basis: 64px; } }
+}
diff --git a/apps/web/src/windows/NoteEditor.css b/apps/web/src/windows/NoteEditor.css
index a00e5ee2..e8f19db8 100644
--- a/apps/web/src/windows/NoteEditor.css
+++ b/apps/web/src/windows/NoteEditor.css
@@ -1,3 +1,6 @@
+/* In the screens layer, above the shared base styles and below the dark
+ theme and the design-language primitives (see styles/index.css). */
+@layer screens {
.note-editor-intro {
margin: 0 0 12px;
color: var(--ink-soft);
@@ -58,3 +61,4 @@
.c-note-revision p { margin: 4px 0; }
.c-note-revision p span { color: var(--ink-soft); }
.c-contribution-history > .c-note-revision { display: block; }
+}
diff --git a/apps/web/src/windows/Windows.tsx b/apps/web/src/windows/Windows.tsx
index 254f0185..e0b794f9 100644
--- a/apps/web/src/windows/Windows.tsx
+++ b/apps/web/src/windows/Windows.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
+import { Suspense, useEffect, useId, useRef, useState, type CSSProperties } from "react";
import { NOTE_BODY_HINT, initialsOf, type NoteCard } from "../data/household";
import { LIMITS } from "../data/limits";
import { useApp } from "../state";
@@ -6,8 +6,6 @@ import { Icon } from "../ui/Icon";
import { Avatar, Pill } from "../ui/widgets";
import { ImagePick } from "../ui/photos";
import { cx } from "../ui/cx";
-import { OperationEdit, OperationNew, OperationView } from "./OperationsWindows";
-import { AnnouncementNew } from "./AnnouncementWindows";
import { CommuneLocationFields, EMPTY_COMMUNE_LOCATION } from "../ui/CommuneLocationFields";
import { RecipientReminderCount, ReminderAllowanceNotice, ReminderUsageMeter } from "../ui/ReminderUsage";
import { ReminderFlowDialog, useReminderFlow } from "../ui/ReminderConfirm";
@@ -28,6 +26,14 @@ import "./ExpenseEditor.css";
import { SplitEditor } from "../ui/SplitEditor";
import { moneyInputError, parseMoneyInput } from "../data/moneyInput";
import type { Notification, UnreadNotice } from "../live/model";
+import { lazyComponent } from "../shell/lazyComponent";
+
+// Schedule items and announcements open less often: their windows load when
+// first opened (or once the browser is idle), behind the record skeleton.
+const OperationView = lazyComponent(() => import("./OperationsWindows").then((m) => m.OperationView));
+const OperationNew = lazyComponent(() => import("./OperationsWindows").then((m) => m.OperationNew));
+const OperationEdit = lazyComponent(() => import("./OperationsWindows").then((m) => m.OperationEdit));
+const AnnouncementNew = lazyComponent(() => import("./AnnouncementWindows").then((m) => m.AnnouncementNew));
const NOTE_TYPES = [
["wifi", "Wi-Fi"],
@@ -139,6 +145,7 @@ export function Windows() {
:
}>
+
}>
{window === "notices" && }
{window === "reminder-preview" && }
{window === "add-expense" && }
@@ -159,6 +166,7 @@ export function Windows() {
{window === "operation-new" && }
{window === "operation-edit" && }
{window === "announcement-new" && }
+
}
diff --git a/apps/web/tests/accessibility.test.tsx b/apps/web/tests/accessibility.test.tsx
index 8b4bf191..a6b3a80d 100644
--- a/apps/web/tests/accessibility.test.tsx
+++ b/apps/web/tests/accessibility.test.tsx
@@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testi
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider, useApp, type AppState } from "../src/state";
import { api, post, loadCommunes, loadSnapshot, ApiError } from "../src/live/api";
import type { Commune, Notification, Snapshot } from "../src/live/model";
@@ -61,6 +62,9 @@ function mount(path: string) {
const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close");
const originalScroll = Element.prototype.scrollIntoView;
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
diff --git a/apps/web/tests/activity-log.test.tsx b/apps/web/tests/activity-log.test.tsx
index 9183aa84..f6caddda 100644
--- a/apps/web/tests/activity-log.test.tsx
+++ b/apps/web/tests/activity-log.test.tsx
@@ -3,6 +3,7 @@ import { cleanup, render, screen, within } from "@testing-library/react";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider } from "../src/state";
import { api, loadCommunes, loadSnapshot } from "../src/live/api";
import { hubFromSnapshot, type Commune, type Snapshot } from "../src/live/model";
@@ -56,6 +57,9 @@ function mount(path: string) {
return location;
}
const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeAll(() => {
Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } });
});
diff --git a/apps/web/tests/addresses.test.tsx b/apps/web/tests/addresses.test.tsx
index f4f3c4dc..066f0c0b 100644
--- a/apps/web/tests/addresses.test.tsx
+++ b/apps/web/tests/addresses.test.tsx
@@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testi
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider, useApp, type AppState } from "../src/state";
import { api, loadCommunes, loadSnapshot, post } from "../src/live/api";
import type { Commune, Expense, Snapshot } from "../src/live/model";
@@ -46,6 +47,9 @@ function mount(path: string) {
const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close");
const originalScroll = Element.prototype.scrollIntoView;
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
diff --git a/apps/web/tests/announcements.test.tsx b/apps/web/tests/announcements.test.tsx
index 1ed908a6..d0e0cfaa 100644
--- a/apps/web/tests/announcements.test.tsx
+++ b/apps/web/tests/announcements.test.tsx
@@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, within } from "@testing-librar
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider, useApp, type AppState } from "../src/state";
import { api, post, loadCommunes, loadSnapshot } from "../src/live/api";
import type { Announcement, Commune, Snapshot } from "../src/live/model";
@@ -38,6 +39,9 @@ function mount(role: "admin" | "member") {
}
const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
const originalScroll = Element.prototype.scrollIntoView;
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } });
diff --git a/apps/web/tests/calendar-date.test.tsx b/apps/web/tests/calendar-date.test.tsx
index 995edea1..5e8e7b78 100644
--- a/apps/web/tests/calendar-date.test.tsx
+++ b/apps/web/tests/calendar-date.test.tsx
@@ -49,7 +49,7 @@ describe("shared Operations calendar", () => {
const read = (path: string) => readFileSync(resolve(process.cwd(), path), "utf8");
// Bundled with the app's styles, so it no longer holds up the first paint.
expect(read("index.html")).not.toContain("calendar-date.css");
- expect(read("src/main.tsx")).toContain('import "./styles/calendar-date.css";');
+ expect(read("src/styles/index.css")).toContain('@import "./calendar-date.css" layer(base);');
expect(read("reference/design-language/index.html")).toContain('href="/src/styles/calendar-date.css"');
const css = read("src/styles/calendar-date.css");
expect(css).toContain("width: 60px;");
diff --git a/apps/web/tests/code-splitting.test.tsx b/apps/web/tests/code-splitting.test.tsx
new file mode 100644
index 00000000..aa852210
--- /dev/null
+++ b/apps/web/tests/code-splitting.test.tsx
@@ -0,0 +1,107 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
+import { Router } from "wouter";
+import { memoryLocation } from "wouter/memory-location";
+import { App } from "../src/App";
+import { AppProvider, useApp, type AppState } from "../src/state";
+import { api, loadCommunes, loadSnapshot, post } from "../src/live/api";
+import type { Commune, Snapshot } from "../src/live/model";
+
+vi.mock("../src/live/api", async (importOriginal) => ({
+ ...await importOriginal(),
+ api: vi.fn(), post: vi.fn(), loadCommunes: vi.fn(), loadSnapshot: vi.fn(),
+}));
+
+// Finding 65: the sign-in pages do not download every screen. Code a browser
+// loads on first use is held back here until a test lets it arrive.
+const held = vi.hoisted(() => {
+ const hold = () => {
+ let arrive = () => {};
+ const arrived = new Promise((resolve) => { arrive = resolve; });
+ return { arrived, arrive, loads: 0 };
+ };
+ return { app: hold(), templates: hold(), announcement: hold() };
+});
+vi.mock("../src/shell/ProductShell", async (importOriginal) => {
+ held.app.loads += 1;
+ await held.app.arrived;
+ return importOriginal();
+});
+vi.mock("../src/screens/Templates", async (importOriginal) => {
+ held.templates.loads += 1;
+ await held.templates.arrived;
+ return importOriginal();
+});
+vi.mock("../src/windows/AnnouncementWindows", async (importOriginal) => {
+ held.announcement.loads += 1;
+ await held.announcement.arrived;
+ return importOriginal();
+});
+
+const user = { id: "user-1", name: "Test Person", email: "test@example.test", emailVerified: true };
+const commune: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin" };
+const snapshot: Snapshot = { commune, members: [{ userId: user.id, name: user.name, role: "admin" }], expenses: [], funds: [], notifications: [], reminderTargets: [] };
+let signedIn: typeof user | null = null;
+let app: AppState;
+function Capture() { app = useApp(); return null; }
+function mount(path: string) {
+ const location = memoryLocation({ path, record: true });
+ render( );
+}
+
+beforeAll(() => { Element.prototype.scrollIntoView = vi.fn(); });
+beforeEach(() => {
+ vi.resetAllMocks();
+ sessionStorage.clear();
+ signedIn = user;
+ vi.mocked(api).mockImplementation(async (path) => {
+ if (path === "/auth/me") return { user: signedIn };
+ if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 6, recipientUsage: {}, nextSlotAt: null };
+ if (path.endsWith("/invites")) return { invites: [] };
+ throw new Error(`Unexpected API request ${path}`);
+ });
+ vi.mocked(post).mockResolvedValue({ ok: true });
+ vi.mocked(loadCommunes).mockResolvedValue({ communes: [commune] });
+ vi.mocked(loadSnapshot).mockResolvedValue(snapshot);
+});
+afterEach(cleanup);
+
+// In order: the code each test lets arrive stays loaded for the next.
+describe("code a browser loads on first use", () => {
+ it("is not part of the sign-in page", async () => {
+ signedIn = null;
+ mount("/login");
+ await screen.findByRole("heading", { name: "Welcome back" });
+ expect(held.app.loads).toBe(0);
+ });
+
+ it("keeps the loading page up until the signed-in app arrives", async () => {
+ mount("/hub");
+ await waitFor(() => expect(app.session?.userId).toBe(user.id));
+ expect(held.app.loads).toBe(1);
+ expect(screen.getByLabelText("Loading account").getAttribute("aria-busy")).toBe("true");
+ expect(screen.queryByRole("heading", { name: commune.name })).toBeNull();
+ await act(async () => held.app.arrive());
+ expect(await screen.findByRole("heading", { name: commune.name })).toBeTruthy();
+ expect(screen.queryByLabelText("Loading account")).toBeNull();
+ });
+
+ it("shows the page's skeleton while a screen's code arrives", async () => {
+ mount("/templates");
+ expect(await screen.findByRole("status", { name: "Loading this page" })).toBeTruthy();
+ expect(held.templates.loads).toBe(1);
+ await act(async () => held.templates.arrive());
+ expect(await screen.findByRole("button", { name: /Save template/ })).toBeTruthy();
+ expect(screen.queryByRole("status", { name: "Loading this page" })).toBeNull();
+ });
+
+ it("shows the window's loading state while a window's code arrives", async () => {
+ mount("/hub/announce");
+ const dialog = await screen.findByRole("dialog", { name: "Post an announcement" });
+ expect(await within(dialog).findByRole("status", { name: "Loading" })).toBeTruthy();
+ expect(held.announcement.loads).toBe(1);
+ await act(async () => held.announcement.arrive());
+ expect(await within(dialog).findByLabelText(/^Title/)).toBeTruthy();
+ expect(within(dialog).queryByRole("status", { name: "Loading" })).toBeNull();
+ });
+});
diff --git a/apps/web/tests/covers.test.tsx b/apps/web/tests/covers.test.tsx
index d0fc32c8..f60aab0f 100644
--- a/apps/web/tests/covers.test.tsx
+++ b/apps/web/tests/covers.test.tsx
@@ -1,8 +1,9 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider } from "../src/state";
import { api, loadCommunes, loadSnapshot } from "../src/live/api";
import type { Commune, Snapshot } from "../src/live/model";
@@ -22,6 +23,9 @@ const widths = (srcSet = "") => srcSet.split(", ").map((candidate) => {
const PLACEMENTS: CoverPlacement[] = ["featured", "card", "hero", "preview"];
const CUSTOM = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ";
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
describe("cover sources", () => {
it.each([
["Household", HUBS.park.photo],
diff --git a/apps/web/tests/email-reminders.test.tsx b/apps/web/tests/email-reminders.test.tsx
index 5449e450..63951813 100644
--- a/apps/web/tests/email-reminders.test.tsx
+++ b/apps/web/tests/email-reminders.test.tsx
@@ -1,8 +1,9 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider } from "../src/state";
import { api, loadCommunes, loadSnapshot } from "../src/live/api";
import type { Commune, Snapshot } from "../src/live/model";
@@ -18,6 +19,9 @@ const snapshot: Snapshot = { commune, members: [{ userId: user.id, name: user.na
let saved: boolean;
let failRead: boolean;
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeEach(() => {
vi.resetAllMocks();
sessionStorage.clear();
diff --git a/apps/web/tests/error-recovery.test.tsx b/apps/web/tests/error-recovery.test.tsx
index 7ac41d32..35b94c56 100644
--- a/apps/web/tests/error-recovery.test.tsx
+++ b/apps/web/tests/error-recovery.test.tsx
@@ -1,6 +1,7 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider } from "../src/state";
import { api, ApiError, loadCommunes, loadSnapshot, post } from "../src/live/api";
import type { Commune, Snapshot } from "../src/live/model";
@@ -18,8 +19,8 @@ vi.mock("../src/live/api", async (importOriginal) => ({
// Finding 21: a drawing error shows a way on in place of the part that broke,
// and errors and sampled speed figures are reported.
const broken = vi.hoisted(() => ({ screen: false, window: false }));
-vi.mock("../src/screens/Money", async (importOriginal) => {
- const original = await importOriginal();
+vi.mock("../src/screens/Funds", async (importOriginal) => {
+ const original = await importOriginal();
return { ...original, FundsScreen: () => {
if (broken.screen) throw new Error("Funds could not draw fund 0f8fe1a5-1234-4abc-9def-0123456789ab");
return Funds drawn
;
@@ -51,6 +52,9 @@ function start(path: string) {
render( );
}
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeAll(() => { Element.prototype.scrollIntoView = vi.fn(); });
beforeEach(() => {
vi.resetAllMocks();
diff --git a/apps/web/tests/fonts.test.ts b/apps/web/tests/fonts.test.ts
index d95b8d9e..8f1ae873 100644
--- a/apps/web/tests/fonts.test.ts
+++ b/apps/web/tests/fonts.test.ts
@@ -38,8 +38,10 @@ describe("self-hosted fonts", () => {
it("draws the wordmark with the subset made for it", () => {
const app = readFileSync(new URL("app.css", styles), "utf8");
expect(/\.a-wordmark\s*{[^}]*font-family:\s*"DM Sans Wordmark"/.exec(app)).not.toBeNull();
+ const index = readFileSync(new URL("index.css", styles), "utf8");
+ expect(index).toContain('@import "./fonts.css";');
const main = readFileSync(new URL("../main.tsx", styles), "utf8");
- expect(main).toContain('import "./styles/fonts.css";');
+ expect(main).toContain('import "./styles/index.css";');
expect(main).not.toContain("@fontsource");
});
});
diff --git a/apps/web/tests/former-members.test.tsx b/apps/web/tests/former-members.test.tsx
index 4d2f998b..ae47be92 100644
--- a/apps/web/tests/former-members.test.tsx
+++ b/apps/web/tests/former-members.test.tsx
@@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, within } from "@testing-librar
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider, useApp, type AppState } from "../src/state";
import { api, loadCommunes, loadSnapshot } from "../src/live/api";
import { hubFromSnapshot, personName, type Commune, type Snapshot } from "../src/live/model";
@@ -34,6 +35,9 @@ function mount(path: string) {
const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close");
const originalScroll = Element.prototype.scrollIntoView;
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } });
diff --git a/apps/web/tests/invitation-hint.test.tsx b/apps/web/tests/invitation-hint.test.tsx
index 867e7423..be643fde 100644
--- a/apps/web/tests/invitation-hint.test.tsx
+++ b/apps/web/tests/invitation-hint.test.tsx
@@ -1,8 +1,9 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider } from "../src/state";
import { api, loadCommunes, loadSnapshot, post } from "../src/live/api";
import type { Commune, Snapshot } from "../src/live/model";
@@ -30,6 +31,9 @@ function mount(path = invitation) {
return location;
}
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeEach(() => {
vi.resetAllMocks();
sessionStorage.clear();
diff --git a/apps/web/tests/invites-pledges-requests.test.tsx b/apps/web/tests/invites-pledges-requests.test.tsx
index aafe1231..3ae247e5 100644
--- a/apps/web/tests/invites-pledges-requests.test.tsx
+++ b/apps/web/tests/invites-pledges-requests.test.tsx
@@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testi
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider, useApp, type AppState } from "../src/state";
import { api, post, loadCommunes, loadSnapshot, ApiError } from "../src/live/api";
import type { Commune, Snapshot } from "../src/live/model";
@@ -38,6 +39,9 @@ function mount(path: string) {
const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close");
const originalScroll = Element.prototype.scrollIntoView;
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } });
diff --git a/apps/web/tests/lazy-component.test.tsx b/apps/web/tests/lazy-component.test.tsx
new file mode 100644
index 00000000..45cf3801
--- /dev/null
+++ b/apps/web/tests/lazy-component.test.tsx
@@ -0,0 +1,77 @@
+import { Component, Suspense, type ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import { lazyComponent, preloadAll } from "../src/shell/lazyComponent";
+
+class Boundary extends Component<{ children: ReactNode }, { failed: boolean }> {
+ state = { failed: false };
+ static getDerivedStateFromError() { return { failed: true }; }
+ render() { return this.state.failed ? Could not draw
: this.props.children; }
+}
+const show = (children: ReactNode) => render(Loading
}>{children});
+const unreachable = () => Promise.reject(new TypeError("Failed to fetch dynamically imported module"));
+
+beforeEach(() => sessionStorage.clear());
+afterEach(cleanup);
+
+describe("a component whose code loads on first use", () => {
+ it("shows the loading state until its code arrives", async () => {
+ let arrive = () => {};
+ const arrived = new Promise((resolve) => { arrive = resolve; });
+ const Greeting = lazyComponent(async () => { await arrived; return ({ name }: { name: string }) => Hello {name}
; });
+ show( );
+ expect(screen.getByText("Loading")).toBeTruthy();
+ arrive();
+ expect(await screen.findByText("Hello Ada")).toBeTruthy();
+ });
+
+ it("draws at once when its code was loaded ahead", async () => {
+ const Greeting = lazyComponent(async () => ({ name }: { name: string }) => Hello {name}
);
+ await Greeting.preload();
+ show( );
+ expect(screen.getByText("Hello Ada")).toBeTruthy();
+ });
+
+ it("loads its code once, however often it is asked for", async () => {
+ const load = vi.fn(async () => () => Loaded
);
+ const Loaded = lazyComponent(load);
+ await Promise.all([Loaded.preload(), Loaded.preload()]);
+ show(<> >);
+ expect(screen.getAllByText("Loaded")).toHaveLength(2);
+ expect(load).toHaveBeenCalledTimes(1);
+ });
+
+ it("loads, with preloadAll, the code of every component and of those it brings", async () => {
+ const Inner = { current: null as null | ReturnType> };
+ const inner = vi.fn(async () => () => Inner
);
+ const Outer = lazyComponent(async () => {
+ Inner.current = lazyComponent(inner);
+ return () => Outer
;
+ });
+ await preloadAll();
+ expect(inner).toHaveBeenCalledTimes(1);
+ const Loaded = Inner.current!;
+ show(<> >);
+ expect(screen.getByText("Outer")).toBeTruthy();
+ expect(screen.getByText("Inner")).toBeTruthy();
+ });
+
+ it("reloads the page once when its code cannot load, and stays on the loading state meanwhile", async () => {
+ const Broken = lazyComponent(unreachable);
+ show( );
+ // The time of the reload is kept just before it. jsdom cannot load a
+ // page (it says so on the console), so this one stays as it is.
+ await waitFor(() => expect(sessionStorage.getItem("commune.codeReloadedAt")).not.toBeNull());
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(screen.getByText("Loading")).toBeTruthy();
+ expect(screen.queryByText("Could not draw")).toBeNull();
+ });
+
+ it("lets the error reach the recovery card when the page was reloaded for code moments ago", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ sessionStorage.setItem("commune.codeReloadedAt", String(Date.now()));
+ const Broken = lazyComponent(unreachable);
+ show( );
+ expect(await screen.findByText("Could not draw")).toBeTruthy();
+ });
+});
diff --git a/apps/web/tests/lighter-loading.test.tsx b/apps/web/tests/lighter-loading.test.tsx
index 6967cba7..659f997e 100644
--- a/apps/web/tests/lighter-loading.test.tsx
+++ b/apps/web/tests/lighter-loading.test.tsx
@@ -1,8 +1,9 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider, useApp, type AppState } from "../src/state";
import { api, post, loadCommunes, loadExpensePage, loadExpenseRecords, loadSnapshot, ApiError } from "../src/live/api";
import { downloadText } from "../src/data/export";
@@ -77,6 +78,9 @@ const reads = (id: string, detail?: "summary") =>
// Lets every request the app has started finish: the mocks answer at once.
const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0)));
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeEach(() => {
vi.resetAllMocks();
sessionStorage.clear();
diff --git a/apps/web/tests/loading.test.tsx b/apps/web/tests/loading.test.tsx
index d81af507..ff2f414a 100644
--- a/apps/web/tests/loading.test.tsx
+++ b/apps/web/tests/loading.test.tsx
@@ -1,8 +1,9 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider, useApp, type AppState } from "../src/state";
import { api, post, loadCommunes, loadSnapshot, ApiError } from "../src/live/api";
import type { Commune, Snapshot } from "../src/live/model";
@@ -32,6 +33,9 @@ function mount(path = "/hub") {
render( );
return location;
}
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeEach(() => {
vi.resetAllMocks();
sessionStorage.clear();
diff --git a/apps/web/tests/money-accuracy.test.tsx b/apps/web/tests/money-accuracy.test.tsx
index ead10aa4..3cc0d589 100644
--- a/apps/web/tests/money-accuracy.test.tsx
+++ b/apps/web/tests/money-accuracy.test.tsx
@@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, within } from "@testing-librar
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider, useApp, type AppState } from "../src/state";
import { api, post, loadCommunes, loadSnapshot } from "../src/live/api";
import { money, type Commune, type Snapshot } from "../src/live/model";
@@ -36,6 +37,9 @@ function mount(path: string) {
const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close");
const originalScroll = Element.prototype.scrollIntoView;
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } });
diff --git a/apps/web/tests/money-lifecycle.test.tsx b/apps/web/tests/money-lifecycle.test.tsx
index 8ce455b6..902dee6d 100644
--- a/apps/web/tests/money-lifecycle.test.tsx
+++ b/apps/web/tests/money-lifecycle.test.tsx
@@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testi
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider, useApp, type AppState } from "../src/state";
import { api, post, loadCommunes, loadSnapshot, ApiError } from "../src/live/api";
import type { Commune, Snapshot } from "../src/live/model";
@@ -36,6 +37,9 @@ function mount(path: string) {
const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close");
const originalScroll = Element.prototype.scrollIntoView;
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } });
diff --git a/apps/web/tests/needs-you.test.tsx b/apps/web/tests/needs-you.test.tsx
index 110ef607..0ef98527 100644
--- a/apps/web/tests/needs-you.test.tsx
+++ b/apps/web/tests/needs-you.test.tsx
@@ -3,6 +3,7 @@ import { cleanup, render, screen, within } from "@testing-library/react";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider } from "../src/state";
import { api, loadCommunes, loadSnapshot } from "../src/live/api";
import { needsYou, type Commune, type PaymentReport, type Snapshot } from "../src/live/model";
@@ -77,6 +78,9 @@ const tripSummary = () => ({ ...snapshotOf(trip, {
const rows = (need: { title: string; kind: string; when: string; amount?: string }[]) => need.map((item) => [item.kind, item.title, item.when, item.amount]);
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
describe("the Needs you rule", () => {
it("puts payments to confirm first, then shares to pay, late and today's tasks, then pledges due", () => {
const { actions } = needsYou(homeSnapshot(), "me", today);
diff --git a/apps/web/tests/notices-across-communes.test.tsx b/apps/web/tests/notices-across-communes.test.tsx
index 8a0c9f9f..6dc80ed4 100644
--- a/apps/web/tests/notices-across-communes.test.tsx
+++ b/apps/web/tests/notices-across-communes.test.tsx
@@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testi
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider, useApp, type AppState } from "../src/state";
import { api, post, loadCommunes, loadSnapshot } from "../src/live/api";
import type { Commune, Notification, Snapshot, UnreadNotice } from "../src/live/model";
@@ -55,6 +56,9 @@ function mount(path: string) {
}
const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
const originalScroll = Element.prototype.scrollIntoView;
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } });
diff --git a/apps/web/tests/operations-history.test.tsx b/apps/web/tests/operations-history.test.tsx
index fae8d604..a98bd15c 100644
--- a/apps/web/tests/operations-history.test.tsx
+++ b/apps/web/tests/operations-history.test.tsx
@@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, within } from "@testing-librar
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider, useApp, type AppState } from "../src/state";
import { api, post, loadCommunes, loadSnapshot, ApiError } from "../src/live/api";
import type { Commune, Snapshot } from "../src/live/model";
@@ -40,6 +41,9 @@ function mount(path: string) {
const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close");
const originalScroll = Element.prototype.scrollIntoView;
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } });
diff --git a/apps/web/tests/password-and-sessions.test.tsx b/apps/web/tests/password-and-sessions.test.tsx
index 427ab836..7a64af94 100644
--- a/apps/web/tests/password-and-sessions.test.tsx
+++ b/apps/web/tests/password-and-sessions.test.tsx
@@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen, within } from "@testing-library/rea
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider } from "../src/state";
import { ApiError, api, loadCommunes, loadSnapshot, post } from "../src/live/api";
import type { Commune, Snapshot } from "../src/live/model";
@@ -20,6 +21,9 @@ let signedIn: boolean;
const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close");
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeAll(() => {
Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } });
Object.defineProperty(HTMLDialogElement.prototype, "close", { configurable: true, value: function (this: HTMLDialogElement) { this.removeAttribute("open"); } });
diff --git a/apps/web/tests/placeholder-screens.test.tsx b/apps/web/tests/placeholder-screens.test.tsx
index 1afb60fd..a55bdaaa 100644
--- a/apps/web/tests/placeholder-screens.test.tsx
+++ b/apps/web/tests/placeholder-screens.test.tsx
@@ -1,8 +1,9 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider } from "../src/state";
import { api, loadCommunes, loadSnapshot, post } from "../src/live/api";
import { hubFromSnapshot, type Commune, type Expense, type Snapshot } from "../src/live/model";
@@ -25,6 +26,9 @@ function mount(path: string) {
render( );
return location;
}
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeEach(() => {
vi.resetAllMocks();
sessionStorage.clear();
diff --git a/apps/web/tests/preferences.test.tsx b/apps/web/tests/preferences.test.tsx
index 1a23da3a..6a4242c9 100644
--- a/apps/web/tests/preferences.test.tsx
+++ b/apps/web/tests/preferences.test.tsx
@@ -1,8 +1,9 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider, useApp, type AppState } from "../src/state";
import { api, loadCommunes, loadSnapshot, post } from "../src/live/api";
import type { Commune, Snapshot } from "../src/live/model";
@@ -41,6 +42,9 @@ function setDeviceDark(dark: boolean) {
for (const listener of listeners) listener();
}
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeEach(() => {
vi.resetAllMocks();
sessionStorage.clear();
diff --git a/apps/web/tests/reminder-allowance.test.tsx b/apps/web/tests/reminder-allowance.test.tsx
index e0876b01..4eb134d1 100644
--- a/apps/web/tests/reminder-allowance.test.tsx
+++ b/apps/web/tests/reminder-allowance.test.tsx
@@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testi
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider, useApp, type AppState } from "../src/state";
import { api, loadCommunes, loadSnapshot } from "../src/live/api";
import type { Commune, ReminderTarget, ReminderUsage, Snapshot } from "../src/live/model";
@@ -51,6 +52,9 @@ function mount(path: string) {
}
const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close");
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeAll(() => {
Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } });
Object.defineProperty(HTMLDialogElement.prototype, "close", { configurable: true, value: function (this: HTMLDialogElement) { this.removeAttribute("open"); } });
diff --git a/apps/web/tests/session-safety.test.tsx b/apps/web/tests/session-safety.test.tsx
index 6c8864d7..a534719a 100644
--- a/apps/web/tests/session-safety.test.tsx
+++ b/apps/web/tests/session-safety.test.tsx
@@ -1,8 +1,9 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider, useApp, type AppState } from "../src/state";
import { api, post, loadCommunes, loadSnapshot, ApiError } from "../src/live/api";
import { ACCOUNT_CHANGED, currentViewer } from "../src/live/session";
@@ -29,6 +30,9 @@ function otherTab(userId: string | null) {
channel.postMessage({ type: "session", userId });
channel.close();
}
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeEach(() => {
vi.resetAllMocks();
sessionStorage.clear();
diff --git a/apps/web/tests/session-tabs.test.tsx b/apps/web/tests/session-tabs.test.tsx
index 52e519f4..80927d01 100644
--- a/apps/web/tests/session-tabs.test.tsx
+++ b/apps/web/tests/session-tabs.test.tsx
@@ -28,6 +28,9 @@ async function openTab(path: string) {
const live = await import("../src/live/api");
const { AppProvider } = await import("../src/state");
const { App } = await import("../src/App");
+ // The code a browser loads later comes with each tab's copy now, before
+ // the next tab replaces the modules it would load.
+ await (await import("../src/shell/lazyComponent")).preloadAll();
vi.mocked(live.api).mockImplementation(async (request) => {
if (request === "/auth/me") { checks += 1; return { user: signedIn }; }
if (request === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 6, recipientUsage: {}, nextSlotAt: null };
diff --git a/apps/web/tests/stylesheets.test.ts b/apps/web/tests/stylesheets.test.ts
new file mode 100644
index 00000000..07cecab1
--- /dev/null
+++ b/apps/web/tests/stylesheets.test.ts
@@ -0,0 +1,104 @@
+// @vitest-environment node
+import { readFileSync, readdirSync } from "node:fs";
+import { relative, resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+
+// Finding 61: every rule sits in a cascade layer, so a screen's stylesheet
+// that arrives later with its code cannot change which rule wins.
+const src = resolve(process.cwd(), "src");
+const read = (path: string) => readFileSync(resolve(src, path), "utf8");
+const stylesheets = (directory: string): string[] => readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ const path = resolve(directory, entry.name);
+ return entry.isDirectory() ? stylesheets(path) : entry.name.endsWith(".css") ? [relative(src, path)] : [];
+});
+const withoutComments = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, "");
+
+/** The blocks at the top level of a stylesheet, as their preludes ("@layer screens"). */
+function topLevel(css: string): string[] {
+ const text = withoutComments(css);
+ const blocks: string[] = [];
+ let depth = 0;
+ let prelude = "";
+ for (const char of text) {
+ if (char === "{") {
+ if (depth === 0) blocks.push(prelude.trim());
+ depth += 1;
+ } else if (char === "}") {
+ depth -= 1;
+ if (depth === 0) prelude = "";
+ } else if (depth === 0) {
+ prelude += char;
+ if (char === ";") { blocks.push(prelude.trim()); prelude = ""; }
+ }
+ }
+ expect(depth).toBe(0);
+ return blocks;
+}
+
+describe("cascade layers", () => {
+ const index = read("styles/index.css");
+
+ it("are declared, lowest first, before anything else", () => {
+ expect(topLevel(index)[0]).toBe("@layer base, screens, overrides;");
+ const main = read("main.tsx");
+ expect(main.match(/^import .*$/m)?.[0]).toBe('import "./styles/index.css";');
+ });
+
+ it("hold every shared stylesheet, in its old order, with only the font faces outside", () => {
+ expect(topLevel(index).slice(1)).toEqual([
+ '@import "./calendar-date.css" layer(base);',
+ '@import "./shared.css" layer(base);',
+ '@import "./app.css" layer(base);',
+ '@import "./auth.css" layer(base);',
+ '@import "./legal.css" layer(base);',
+ '@import "./overrides.css" layer(overrides);',
+ '@import "./design-system.css" layer(overrides);',
+ '@import "./fonts.css";',
+ ]);
+ expect(stylesheets(resolve(src, "styles")).sort()).toEqual([
+ "styles/app.css", "styles/auth.css", "styles/calendar-date.css", "styles/design-system.css",
+ "styles/fonts.css", "styles/index.css", "styles/legal.css", "styles/overrides.css", "styles/shared.css",
+ ]);
+ expect(topLevel(read("styles/fonts.css")).every((block) => block === "@font-face")).toBe(true);
+ });
+
+ it("hold every screen's and window's stylesheet in screens, or overrides where a rule must win there", () => {
+ const own = stylesheets(src).filter((path) => !path.startsWith("styles/"));
+ expect(own.length).toBeGreaterThan(0);
+ for (const path of own) {
+ const blocks = topLevel(read(path));
+ expect(blocks[0], path).toBe("@layer screens");
+ for (const block of blocks) expect(["@layer screens", "@layer overrides"], path).toContain(block);
+ }
+ });
+});
+
+describe("colours", () => {
+ it("are written as the token wherever a token holds that colour on every element", () => {
+ // A token defined once (on :root) and never set again has the same colour
+ // everywhere, so a literal of that colour can always name it instead.
+ // calendar-date.css keeps literals: the HTML design reference links it on
+ // its own, without the app's tokens.
+ const sheets = stylesheets(src).filter((path) => path !== "styles/calendar-date.css");
+ const css = sheets.map((path) => [path, withoutComments(read(path))] as const);
+ const hex = (value: string) => {
+ const digits = value.slice(1).toLowerCase();
+ return digits.length === 3 ? [...digits].map((digit) => digit + digit).join("") : digits;
+ };
+ const definitions = new Map();
+ for (const [, text] of css) {
+ for (const [, name, value] of text.matchAll(/(--[\w-]+)\s*:\s*([^;}]+)/g)) {
+ definitions.set(name, [...(definitions.get(name) ?? []), value.trim()]);
+ }
+ }
+ const fixed = new Map([...definitions]
+ .filter(([, values]) => values.length === 1 && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(values[0]))
+ .map(([name, [value]]) => [hex(value), name]));
+ expect(fixed.get("1a1e2b")).toBe("--navy");
+ expect(fixed.get("2d6a4f")).toBe("--sage");
+ const literals = css.flatMap(([path, text]) => [...text.replace(/--[\w-]+\s*:[^;}]+/g, "").matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)]
+ .filter(([match]) => fixed.has(hex(match)))
+ .map(([match]) => `${path}: ${match} is ${fixed.get(hex(match))}`));
+ expect(literals).toEqual([]);
+ });
+});
diff --git a/apps/web/tests/workflow-screens.test.tsx b/apps/web/tests/workflow-screens.test.tsx
index 812060ce..994ed246 100644
--- a/apps/web/tests/workflow-screens.test.tsx
+++ b/apps/web/tests/workflow-screens.test.tsx
@@ -20,6 +20,7 @@ import {
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
+import { preloadAll } from "../src/shell/lazyComponent";
import { AppProvider, useApp, type AppState } from "../src/state";
import {
api,
@@ -104,6 +105,9 @@ const closeDescriptor = Object.getOwnPropertyDescriptor(
"close",
);
const originalScroll = Element.prototype.scrollIntoView;
+// The screens and windows a browser loads on first use are loaded up front,
+// so each test sees them drawn at once.
+beforeAll(preloadAll);
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
diff --git a/docs/DESIGN-LANGUAGE.md b/docs/DESIGN-LANGUAGE.md
index d016f070..e2718938 100644
--- a/docs/DESIGN-LANGUAGE.md
+++ b/docs/DESIGN-LANGUAGE.md
@@ -171,7 +171,8 @@ beside an enabled button. Server authority remains essential.
`apps/web/src/ui/CalendarDate.tsx` and
`apps/web/src/styles/calendar-date.css` are the shared component and appearance
-authority. The app bundles that stylesheet (imported first in `src/main.tsx`)
+authority. The app bundles that stylesheet (first in the base layer of
+`src/styles/index.css`, which `src/main.tsx` imports before anything else)
and the HTML reference links `/src/styles/calendar-date.css`. Operations,
Hub's This week, Home's dated next actions, expense due dates and recurring
next-due dates use the same calendar. Inline prose dates, date inputs, avatars,
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index 9f7bead6..a5493d76 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -116,6 +116,24 @@ for a handoff, not a production release.
search text live in their screens. `apps/web/tests/render-counts.test.tsx`
guards how much re-renders; time a year's snapshot with
`cd apps/web && node node_modules/vitest/vitest.mjs bench --run tests/derive-snapshot.bench.ts`.
+- Also from 9 October, for review: styles and loading on demand, from the
+ same review. Every stylesheet sits in a cascade layer, declared once in
+ `apps/web/src/styles/index.css`, which `src/main.tsx` imports before
+ anything else: `base` (tokens and the shared app, sign-in and legal
+ styles), then `screens` (each screen's and window's own stylesheet, which
+ wraps itself in `@layer screens {}`), then `overrides` (the dark theme and
+ the design-language primitives). A screen rule that must beat those goes in
+ an `@layer overrides {}` block in its own file.
+ `apps/web/tests/stylesheets.test.ts` checks that every stylesheet is layered
+ and that fixed palette colours are written as their tokens. The sign-in
+ page loads only the entry and the Neon client. The signed-in app
+ (`shell/ProductShell.tsx`) is fetched while the session is checked; other
+ screens and windows load when first opened, or once the browser is idle
+ unless it asks to save data (`shell/lazyComponent.tsx`). A failed code load
+ reloads the page once (`commune.codeReloadedAt` in session storage, 30
+ seconds). Tests that draw the signed-in app call `preloadAll()` first.
+ `?native=1` shows the phone layout at any width; the `.is-native` rules
+ serve it.
- The website is `web/` (the editorial homepage); the earlier v2 site is in
`web-backup/`. Launch names `website` (3004) and `website-backup` (3003).
- No push, deployment, hosted migration or provider change has been made. Do