From 2db97b801a25ecd7698ece276d51ca4084f69f63 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:58:45 +0000 Subject: [PATCH 01/11] Put every stylesheet in a cascade layer Finding 61. Which rule won depended on the order stylesheets reached the page: the screens' stylesheets came first (imported through App) and the shared ones after. Lazily loaded screens would bring their stylesheets later and change that. Now styles/index.css declares the order once, first: base calendar-date, shared, app, auth and legal screens each screen's and window's own stylesheet (wraps itself) overrides the dark theme and the design-language primitives and main.tsx imports it before anything else, in the same order as before. Precedence that the layers would have changed was kept rule by rule, found by recording every cross-file competition on 376 captured states (desktop and phone, light and dark, signed out and in, windows and dialogs open, the ?native=1 layout) and checking each against every order the screens' stylesheets can arrive in: - screen rules that outrank a design-language or dark-theme rule (quick search, receipt, correction and period-split dialogs, receipt history, dialog footers in a window) move to an overrides block in their file; - screen rules that lost to the ?native=1 layout no longer match there (:where(:not(.is-native *)), no change in specificity), and the expense card's small text no longer matches the calendar weekday; - the discard dialog's width moves next to the shared dialog rule, and the dark window footer no longer matches a task's delete question; - Notes' search field rules name .c-record-filter so they beat QuickSearch.css whichever arrives first; - .a-move .c-report-line's colour, which never won, is removed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/src/main.tsx | 12 +--- apps/web/src/screens/Hub.css | 12 +++- apps/web/src/screens/Money.css | 43 +++++++++---- apps/web/src/screens/Notes.css | 13 +++- apps/web/src/shell/connection-recovery.css | 4 ++ apps/web/src/styles/app.css | 4 -- apps/web/src/styles/design-system.css | 4 ++ apps/web/src/styles/index.css | 23 +++++++ apps/web/src/styles/overrides.css | 3 +- apps/web/src/ui/QuickSearch.css | 42 +++++++----- apps/web/src/windows/ExpenseEditor.css | 9 ++- apps/web/src/windows/NoteEditor.css | 4 ++ apps/web/tests/calendar-date.test.tsx | 2 +- apps/web/tests/fonts.test.ts | 4 +- apps/web/tests/stylesheets.test.ts | 74 ++++++++++++++++++++++ 15 files changed, 200 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/styles/index.css create mode 100644 apps/web/tests/stylesheets.test.ts 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/Hub.css b/apps/web/src/screens/Hub.css index 16be2a76..e1cf0641 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; } @@ -167,7 +172,7 @@ .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; } @@ -205,3 +210,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..c0fbf03e 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; } @@ -84,9 +88,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 +103,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); } @@ -112,9 +115,6 @@ .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-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 +127,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 +157,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 +187,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 +197,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/Notes.css b/apps/web/src/screens/Notes.css index 5f663443..7fe27e24 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); @@ -368,7 +371,8 @@ } .has-note-reader .a-side, .has-note-reader .a-header, - .has-note-reader .a-bottom, + /* 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, .has-note-reader .a-more-sheet { display: none; @@ -444,7 +448,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 +483,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 +501,4 @@ transition: none; } } +} diff --git a/apps/web/src/shell/connection-recovery.css b/apps/web/src/shell/connection-recovery.css index 29c19340..5086740c 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; @@ -21,3 +24,4 @@ .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); } @media (max-width: 480px) { .c-connection-card { padding: 24px; } } +} diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 8a16f64c..ab15a5ab 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -971,10 +971,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); diff --git a/apps/web/src/styles/design-system.css b/apps/web/src/styles/design-system.css index 98cd4b39..93d7d7cc 100644 --- a/apps/web/src/styles/design-system.css +++ b/apps/web/src/styles/design-system.css @@ -149,6 +149,10 @@ 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); } 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..13238ce4 100644 --- a/apps/web/src/styles/overrides.css +++ b/apps/web/src/styles/overrides.css @@ -98,7 +98,8 @@ 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 { +/* 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: #242b39; border-top-color: var(--border); } 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..0edc7741 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; } @@ -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/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/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/stylesheets.test.ts b/apps/web/tests/stylesheets.test.ts new file mode 100644 index 00000000..99c31c59 --- /dev/null +++ b/apps/web/tests/stylesheets.test.ts @@ -0,0 +1,74 @@ +// @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); + } + }); +}); From eb50d7657f6b5ce157ebb73c37a97318262b4958 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:58:55 +0000 Subject: [PATCH 02/11] Load the signed-in app, and its less used screens and windows, on first use Finding 65 (code splitting). The sign-in pages downloaded every screen. Now App loads the signed-in app (shell/ProductShell.tsx, moved out of App.tsx unchanged) only on a signed-in address, starting while the session is checked, behind the same loading page. It brings Home, Hub, Expenses and Settle (the phone's four tabs); Funds, Recurring, Templates, Members, Notes, Operations, Activity, Search and the settings load when first opened, behind the page's own skeleton, and the schedule and announcement windows behind the window's record skeleton. Once the browser is idle (and not saving data) the rest loads anyway, so later screens open at once. A failed code load (a new release replaced the files, or the connection dropped) reloads the page once; a second failure within 30 seconds reaches the recovery card instead of reloading again. Money.tsx and House.tsx no longer re-export screens, which would have pulled them into the signed-in app's chunk. Tests that draw the signed-in app load the lazily loaded code first (preloadAll), as the browser does once idle; new tests cover the sign-in page not loading the app, each fallback, the reload and preloading. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/src/App.tsx | 238 +----------------- apps/web/src/screens/House.tsx | 5 - apps/web/src/screens/Money.tsx | 3 - apps/web/src/shell/ProductShell.tsx | 229 +++++++++++++++++ apps/web/src/shell/lazyComponent.tsx | 82 ++++++ apps/web/src/shell/pageTitle.ts | 7 + apps/web/src/windows/Windows.tsx | 14 +- apps/web/tests/accessibility.test.tsx | 4 + apps/web/tests/activity-log.test.tsx | 4 + apps/web/tests/addresses.test.tsx | 4 + apps/web/tests/announcements.test.tsx | 4 + apps/web/tests/code-splitting.test.tsx | 107 ++++++++ apps/web/tests/covers.test.tsx | 6 +- apps/web/tests/email-reminders.test.tsx | 6 +- apps/web/tests/error-recovery.test.tsx | 8 +- apps/web/tests/former-members.test.tsx | 4 + apps/web/tests/invitation-hint.test.tsx | 6 +- .../tests/invites-pledges-requests.test.tsx | 4 + apps/web/tests/lazy-component.test.tsx | 77 ++++++ apps/web/tests/lighter-loading.test.tsx | 6 +- apps/web/tests/loading.test.tsx | 6 +- apps/web/tests/money-accuracy.test.tsx | 4 + apps/web/tests/money-lifecycle.test.tsx | 4 + apps/web/tests/needs-you.test.tsx | 4 + .../tests/notices-across-communes.test.tsx | 4 + apps/web/tests/operations-history.test.tsx | 4 + apps/web/tests/password-and-sessions.test.tsx | 4 + apps/web/tests/placeholder-screens.test.tsx | 6 +- apps/web/tests/preferences.test.tsx | 6 +- apps/web/tests/reminder-allowance.test.tsx | 4 + apps/web/tests/session-safety.test.tsx | 6 +- apps/web/tests/session-tabs.test.tsx | 3 + apps/web/tests/workflow-screens.test.tsx | 4 + 33 files changed, 631 insertions(+), 246 deletions(-) create mode 100644 apps/web/src/shell/ProductShell.tsx create mode 100644 apps/web/src/shell/lazyComponent.tsx create mode 100644 apps/web/src/shell/pageTitle.ts create mode 100644 apps/web/tests/code-splitting.test.tsx create mode 100644 apps/web/tests/lazy-component.test.tsx 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. : apiError}
: null} - {contentError && hub.activityState === "ready" ?
Could not refresh this commune. Showing its last loaded activity.
: 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 - -
{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.

; - } - - if (contentLoading && screen !== "settings" && screen !== "search") { - if (contentError) return

Could not load {hub.name}

{contentError}

; - return
Loading shared records{screen === "hub" &&

{hub.name}

{hub.switchMeta}

}