AzureAD\AdityaDevarshi Claude Opus 4.8 (1M context) commited on
Commit
9f5cfa8
·
1 Parent(s): bc92e36

M3 Phase 0+1: seed-generated tonal theme (light+dark) + MUI M3 re-skin foundation

Browse files

Build-time M3 tonal generator from seed #6750A4 (@material /material-color-utilities) -> roles.ts + tokens.css (--md-sys-color-* CSS vars, light + html[data-theme=dark], zero runtime cost). MUI re-themed onto M3 roles with all existing token names aliased (theme.custom.*/accent/e1-e3/typography variants) so NO component/page edits needed. Roboto Flex self-hosted; M3 shape scale (+28 xl), 15-role type metrics, 6 motion springs, M3 state layers; ThemeModeProvider flips html[data-theme] (var flip, not re-render). Theme-only diff; tsc+build green. Foundation only — expressive components + per-screen restyle are later phases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

AUDIT_REPORT.md ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MilkWay ERP — Audit Report
2
+
3
+ **Date:** 2026-06-11
4
+ **Scope:** Go backend (`backend/`) + React PWA (`frontend/PWA/`) on a single Hugging Face FREE Docker Space (:7860), Aiven FREE Postgres (schema-per-profile staging/live).
5
+ **Method:** Read-only multi-lens audit. Findings below are deduplicated and merged from the auditor lenses, with key claims spot-verified against source (`main.tsx`, `ErrorBoundary.tsx`, `useDateRange.ts`, `billing.go`).
6
+ **Hard constraints enforced:** (1) FREE-ONLY — must run entirely on free tiers; (2) META PENDING — WhatsApp/Meta is NOT integrated, only `wa.me` deep links exist.
7
+
8
+ > **Input note:** The supplied findings JSON was truncated mid-stream inside the `be-data` (LENS 5) auditor, and lenses 6–10 were not delivered to the synthesizer. This report consolidates lenses 1–5 in full plus the partial be-data set. Sections flagged "INCOMPLETE INPUT" indicate where auditor coverage was cut off and a re-run is advised.
9
+
10
+ ---
11
+
12
+ ## 1. Executive Summary
13
+
14
+ The product is a functional, well-structured milk-dairy ERP, but it is **not yet production-honest**. It has a small number of true crash/blanking defects on the frontend and several **money-correctness bugs on the backend** that will quietly mis-bill customers — the most dangerous class for an ERP, because nothing visibly fails. The unit-travel feature (recently added) is the source of multiple defects: snapshots exist in the schema but billing/analytics still collapse mixed units, and the historical backfill hardcodes `'litre'`, actively corrupting any pre-existing kg/piece rows.
15
+
16
+ On infrastructure, the product **can** stay 100% free, but only with discipline: do not run staging and live processes simultaneously against the same Aiven free instance (connection-cap risk), and shrink the eager JS bundle to survive cold starts of a sleeping ephemeral Space. The schema-change rule the project set for itself (versioned migrations before any column alter) has already been violated.
17
+
18
+ ### Top 5 things to fix first
19
+
20
+ 1. **Frontend crash containment** — a single root `ErrorBoundary` with no reset-on-navigation means *any* render throw on *any* screen blanks the entire app and stays wedged until a full reload. This is the root cause of the "a few screens crashed" report. (HIGH, FE)
21
+ 2. **Carry-forward double-counts prepayments / back-dated receipts** — verified in `billing.go:317-346`; customers are over- or under-billed silently. (HIGH/BLOCKER-class, BE)
22
+ 3. **Outstanding-aging double-counts debt across months** — the aging report sums the same balance in both the old bill's `remaining` and the new bill's `carry_forward`. Reported receivables are inflated. (HIGH, BE)
23
+ 4. **Unit-travel data corruption** — historical backfill hardcodes `'litre'` (mislabels kg/piece rows) and billing/analytics `GROUP BY product_id` with `MAX(unit_snapshot)`, merging physically different units into one line/number. (HIGH, DB/BE)
24
+ 5. **Delivered item with no configured price is silently billed at rate 0** — `prepareDelivery` discards `ResolveRate`'s `found` flag; missing/future-dated prices yield free milk. (HIGH, BE)
25
+
26
+ ---
27
+
28
+ ## 2. CRASHES (read this first — the user explicitly saw crashes)
29
+
30
+ The reported "a few screens crashed" maps to **one architectural root cause** plus several render-throw triggers that hit it:
31
+
32
+ ### Root cause — single root ErrorBoundary, no reset
33
+ - **Where:** `frontend/PWA/src/main.tsx:28` (only boundary, wraps the whole `<AppRoutes/>`) + `frontend/PWA/src/components/feedback/ErrorBoundary.tsx:15-43`.
34
+ - **Mechanism (verified):** The boundary lives *above* the router. Feature routes each render their own `<AppShell>` with nothing between them and the root boundary. So a render-time exception **anywhere in any page body** replaces the **entire UI** (header, nav, everything) with the in-card "Couldn't render this" fallback. Worse, the boundary has no `location` key and no `componentDidUpdate`, so once `hasError=true` it stays errored across client-side navigation. The only escape is the fallback's "retry" button, which calls `reset()` and re-renders the **same crashing tree** — it immediately re-throws. **Net effect: one bad render = whole app dead until a hard reload.**
35
+ - **Fix:** Wrap each lazy route element in its own `<ErrorBoundary>` *inside* `AppShell`, and key it by `location.pathname` (or call `reset()` on path change) so a crash degrades to an in-shell error and navigation recovers.
36
+
37
+ ### Things that can throw and therefore blank the app
38
+ 1. **`unitName()` returns `undefined` for unknown unit strings** — `features/catalog/unit.ts:29-31`. A truthy-but-unknown unit (future backend unit, wrong casing, legacy `'L'`/`'u'`) yields `undefined`; if used as a React `key` or rendered in a sensitive path it can throw/mis-render. Route through `normalizeUnit()` first. (LOW on its own, but feeds the crash boundary.)
39
+ 2. **`Money`/`Qty` render `'₹NaN'` / `'NaN L'`** — `components/format/Money.tsx:23-28`, `format/Qty.tsx:18-30`. No internal NaN guard; any API shape drift that drops `amount`/`qty` (e.g. a `by_product` row) on an unguarded call site prints NaN. Not a hard crash but a visible corruption that can cascade. Coerce non-finite → 0 internally.
40
+ 3. **Map over an undefined API field** — multiple pages read `c.by_product`, `unit`, `unit_snapshot` etc. that are tolerantly typed; a missing field that a `.map()`/`.length` doesn't guard throws into the single root boundary.
41
+
42
+ ### Hard-error (server-side) "blank screen" — not a JS throw, but a screen that won't load
43
+ 4. **Staff screen 400 for superadmin** — `features/admin/StaffPage.tsx:105` calls `usersApi.list({limit,offset,q})` with **no `org_id`**; `listUsers` → `requireOrg` returns **400 "org_id is required"** for a superadmin (token `org_id = NULL`). The Staff screen renders blank/error for superadmins. (HIGH, FE/contract.) Thread `org_id` (org picker) or make `listUsers` tolerate superadmin.
44
+
45
+ ---
46
+
47
+ ## 3. Findings by Severity
48
+
49
+ > Deduplicated across lenses. Where multiple auditors reported the same defect (e.g. the ErrorBoundary, the IST date off-by-one, the unit-merge, `by_product` dead field, `SettingsInput` upsert, refresh-token user), they are merged into a single entry citing all locations.
50
+
51
+ ### BLOCKER
52
+ *No findings were classified BLOCKER by the delivered auditors.* The money-correctness bugs below (carry-forward double-count, aging double-count, rate-0 billing) are HIGH but are BLOCKER-class in business impact for an ERP — treat them as ship-blockers.
53
+
54
+ ### HIGH
55
+
56
+ | # | Title | Layer | Location | Detail | Fix hint |
57
+ |---|-------|-------|----------|--------|----------|
58
+ | H1 | Single root ErrorBoundary + no reset-on-nav blanks the whole app on any render throw | FE | `main.tsx:28`; `components/feedback/ErrorBoundary.tsx:15-43`; protected routes in `AppRoutes.tsx` have no boundary | See §2. One throw kills the entire UI and wedges it across navigation; retry re-throws. **Root cause of reported crashes.** | Per-route `<ErrorBoundary>` inside `AppShell`, keyed by `location.pathname`. |
59
+ | H2 | Carry-forward double-counts a prepayment / back-dated receipt linked to its own bill | BE | `store/billing.go:317-346` (verified) | `earlierPayments = SUM(payments WHERE paid_on < period_start)` does NOT exclude payments with `bill_id = thisBill`; `paidAmount = SUM(payments WHERE bill_id = thisBill)`. A prepayment dated before the period but linked to this bill is subtracted in carry-forward **and** counted as paid → bill total drifts silently. | Scope carry-forward payments to `paid_on < start AND (bill_id IS NULL OR bill_id <> thisBill)`. |
60
+ | H3 | Outstanding-aging report double-counts the same debt across consecutive months | BE | `store/analytics.go:309-374` | Each month's `carry_forward` already rolls the prior month's net forward, but the prior unpaid/partial bill still has its own positive `remaining`. `SUM(remaining)` counts the rolled balance twice. Receivables inflated. | Age only the latest bill per customer, or subtract net already embedded in a later bill's carry-forward. |
61
+ | H4 | GenerateBill merges different `unit_snapshot`s into one line via `MAX(unit)` + `GROUP BY product_id` | BE | `store/billing.go:220-282` | Groups delivered rows by `product_id` only, labels the line with `MAX(unit_snapshot)`. If a product's unit changed mid-period, 10 (litre) + 5 (kg) collapse to qty=15 under one arbitrary unit. Bill is physically meaningless. | `GROUP BY product_id, unit_snapshot` — one line per (product, unit). Never `MAX()` the unit. |
62
+ | H5 | Delivered item with no configured price is silently billed at rate 0 (`found` flag discarded) | BE | `api/deliveries.go:111-116`; `ResolveRate` in `store/prices.go:126-142` | `prepareDelivery` does `rate, _, err := ResolveRate(...)`, dropping `found=false`. Missing default price, or all prices effective *after* the delivery date (incl. back-dated deliveries), → rate 0, amount 0, delivered free. | If `!found` and status `delivered`, reject 400 "no rate configured for product on this date". |
63
+ | H6 | Unit-snapshot/bill-line historical backfill hardcodes `'litre'` — mislabels all pre-existing kg/piece rows | DB | `db/migrate.go:266-267` (`ADD COLUMN ... DEFAULT 'litre'`) | Prior live schema allowed `unit IN ('litre','kg','piece')` (SCENARIOS_UNITS.md:186), so historical rows could be kg/piece. Backfill stamps every existing row `'litre'`; and because `'litre'` sorts after `'kg'`/`'piece'`, `MAX(unit_snapshot)` then mislabels merged lines too. Active data corruption. | Backfill from `products.unit`: `UPDATE ... FROM products WHERE id = product_id`. |
64
+ | H7 | No versioned migration path — schema change via ad-hoc DO-block/ALTER, violating the project's own rule | DB | `db/migrate.go:218-267`; PLAN.md:244-245 | PLAN.md mandates a versioned migration tool before the first existing-column change. The unit-travel change (DROP+ADD `products.unit` CHECK, ADD `unit_snapshot`) is exactly that, done by hand-rolled idempotent DDL that re-runs every boot. | Introduce `schema_migrations` (or golang-migrate/goose); gate DDL on version. |
65
+ | H8 | Staff list never sends `?org_id=` → superadmin gets a hard 400 and a blank Staff screen | FE | `features/admin/StaffPage.tsx:105`; `listUsers`→`requireOrg` (`customers.go:13-23`) | See §2 #4. `GET /api/users` requires `org_id` for superadmin; the list call never threads it. | Thread `org_id` (org picker) on StaffPage for superadmin, or relax `listUsers`. |
66
+ | H9 | Bill generation is a fragile client-side per-customer loop with no resume | FE/INFRA | `features/billing/hooks/useGenerateBills.ts:46-91`; `BillingPage.tsx:63-72` | One `POST /bills/generate` per active customer, 4-way concurrency, in-component React state only. Navigate away / reload / background the PWA = loop lost, partially-billed month, no record of which customers ran. On a cold-starting free Space, transient 503s surface as false failures. | Server-side batch endpoint, or persist queue+progress to localStorage with cold-start-aware retry/backoff. |
67
+
68
+ ### MEDIUM
69
+
70
+ | # | Title | Layer | Location | Detail | Fix hint |
71
+ |---|-------|-------|----------|--------|----------|
72
+ | M1 | Analytics date presets off-by-one for IST users (`toISOString` on local-midnight Date) — **verified** | FE | `features/analytics/useDateRange.ts:15,22-37` | `rangeForPreset` builds `new Date(y,m,d)` (local midnight) then `iso()` calls `toISOString()` → UTC. For IST (UTC+5:30), local midnight = 18:30 the previous day in UTC, so every preset (7d/30d/thisMonth/lastMonth) and aging `as_of` shifts back one day. | Format from local components: `` `${y}-${pad(m)}-${pad(d)}` ``. |
73
+ | M2 | Backend analytics "now" uses server UTC, so IST dairy sees the window ending a day early near midnight | BE | `api/analytics.go:34-41`, `:152-154` | HF Space runs UTC; dairy is IST. When FE omits `from/to`/`as_of`, server defaults to its own date; 18:30 IST–midnight the UTC date is still "yesterday". Pairs with M1 — both ends drift. | Compute analytics "now" in `Asia/Kolkata` (configurable TZ). |
74
+ | M3 | Dashboard/Reports per-customer per-unit qty is permanently dead — backend `TopCustomers` never populates `by_product` | BE | `store/analytics.go:265-300` vs `DashboardPage.tsx:338-344`, `ReportsPage.tsx:245-246` | `TopCustomers` scans only id/name/qty/amount/days, never sets `cs.ByProduct` (json `omitempty`), so `c.by_product` is always undefined; FE per-unit cells collapse to `''`/`—`. UI implies data that never arrives. | Populate `CustomerSales.ByProduct` (with `Unit`) via per-product `GROUP BY` carrying `p.unit`, mirroring `CustomerAnalytics`; or drop the per-unit UI. |
75
+ | M4 | `useCustomers()` with no limit silently truncates to 50 → route filter & name maps incomplete for orgs >50 | FE | backend default `limit=50` (`api.go:192`); `RoutePage.tsx:63`, CustomerDetail products, Dashboard | Lookup-style consumers build dropdowns/maps from the first 50 rows with no pagination follow-up. Routes/customers beyond row 50 vanish from filters. | Page through items, or fetch with a large/`all` limit for lookup consumers (as billing preview already does). |
76
+ | M5 | BottomNav highlights wrong tab (falls back to index 0) when a role lands on a screen with no matching tab | FE | `app/BottomNav.tsx:14`; `PaymentsListPage.tsx:41-44,109` (`current:'payments'`); `app/navConfig.tsx:33-44` | Admin/superadmin nav has no `'payments'` key, but admins reach `/payments` (CustomerDetail "Record payment →") and the page hard-codes `current:'payments'`. `findIndex` → -1 → highlights Dashboard. Disorienting dead nav state. | Render no active highlight when `current` ∉ items; or only pass keys present in the role's `NAV_TABS`; redirect superadmin away from `/payments`. |
77
+ | M6 | Bill-line blended rate (`amount/qty`) drifts from line amount under rounding | BE | `store/billing.go:257-281` | Per-delivery `amount = qty*rate` is rounded to 2dp by the column; the line stores `rate = round2(SUM(amount)/SUM(qty))` and `amount = round2(SUM(amount))` independently. `qty*displayed_rate ≠ displayed amount` (paise mismatch). | Show the line rate as an explicit blended/derived value, or store rate NULL and display only qty + amount. |
78
+ | M7 | `SalesSummary.total_qty` / `Bill.total_qty` sum quantities across heterogeneous units | BE | `store/analytics.go:115-126`; `billing.go:282,300` | Sums litres + kg + pieces + dozens into one scalar (100 L + 20 pc + 5 kg = "125"). Physically meaningless; printed on bills. | Drop cross-unit `total_qty` from bill/summary display in favor of per-unit totals. |
79
+ | M8 | Analytics `SalesByProduct`/`CustomerAnalytics` emit a single `unit` via `MAX`/`GROUP`, mislabeling mixed-unit products | BE | `store/analytics.go:219-259`, `:414-447` (`COALESCE(p.unit, MAX(d.unit_snapshot), '')`) | Uses product's CURRENT unit; once a product's unit is edited, historical qty recorded under the old unit is summed and relabeled with the new — the "history re-interpreted" defect snapshots were meant to prevent. | Group analytics by `(product_id, unit_snapshot)`; report qty per unit. |
80
+ | M9 | `products.unit` edit is unguarded despite snapshots — desyncs current vs historical semantics | BE | `store/products.go:107-119` | `UpdateProduct` mutates `products.unit` in place even when deliveries/bill_lines reference it; route projection & analytics fall back to the CURRENT unit for snapshot-less (backfilled-`litre`) rows. | Forbid changing `products.unit` once any delivery references it, or require a unit-migration flow. |
81
+ | M10 | `AddAdjustment` then `GenerateBill` is non-atomic across two store calls/transactions | BE | `api/billing.go:184-197` | Adjustment inserted in one tx, bill recomputed in a separate one. On a sleeping Space a request cut between them leaves the adjustment row persisted but excluded from net/remaining/UPI. | Insert adjustment + recompute totals in ONE transaction. |
82
+ | M11 | `DeleteAdjustment` does NOT re-generate the bill — totals keep the removed adjustment | BE | `api/billing.go:205-220` | Add path re-runs `GenerateBill`; delete path only deletes the row + busts analytics cache. Bill's deductions/additions/net/remaining/status/UPI still reflect the deleted adjustment. | After delete, look up the bill's period and call `GenerateBill(org, customer, year, month)`. |
83
+ | M12 | `deletePayment`/`setBillStatus` don't re-derive carry-forward of LATER months | BE | `store/payments.go:183-208`; `billing.go` carry-forward | These refresh only the directly-linked bill. But a payment dated in an earlier month changes carry-forward of all later already-generated bills, which are never recomputed → stale running balances. | On payment/adjustment change, regenerate the customer's bills from the affected month forward (or compute carry-forward live from a ledger). |
84
+ | M13 | Future-effective-only price → `ResolveRate` returns rate 0; with discarded `found`, mis-bills back-dated deliveries | BE | `store/prices.go:126-142`; `deliveries.go:111` | `ResolveRate` only considers `effective_from <= date`. A back-dated delivery whose product's only price is future-dated matches nothing → rate 0. Compounds H5. | Fall back to earliest price row when none is effective on/before the date, or reject; never return 0 as usable. |
85
+ | M14 | Route projection loads the holiday flag but still projects every subscription slot on a holiday | BE | `api/route.go:57-88`, `mergeRouteRows:149-156` | `holiday` is loaded and returned but `mergeRouteRows` ignores it; the route shows a full delivery list on a declared holiday. Bulk-confirm then bills a non-delivery day. | When `holiday`, suppress projected (`recorded=false`) rows or mark `status='holiday'` (excluded from bulk-confirm by default). |
86
+ | M15 | `products.unit` CHECK is DROPed then re-ADDed on every boot — concurrency race + transient-invalid window | DB | `db/migrate.go:228-261` | Each boot drops and re-adds `products_unit_check`. Concurrent boots (HF redeploy overlap, or staging+live both pointed at the DB) can drop the constraint while the other inserts. | Guard the block on "does the constraint already allow the 5 values" and skip; or one-shot versioned migration. |
87
+ | M16 | Missing indexes on hot `bill_id` FK lookups (`bill_lines`, `payments`, `bill_adjustments`) | DB | `db/migrate.go:182-216` | Every `GenerateBill`/`GetBill`/`SetBillStatus`/`refreshBillPayment`/`AddAdjustment` filters child tables by `bill_id` with no index → seq scans that grow with data. | `CREATE INDEX IF NOT EXISTS` on `bill_lines(bill_id)`, `payments(bill_id)`, `bill_adjustments(bill_id)`. |
88
+ | M17 | Money computed/round2'd in float64 despite NUMERIC storage — silent precision drift | BE/DB | `store/billing.go:23-27,55-57,96-134`; all `::float8` casts | Columns are NUMERIC(12,2) for exactness, but every read casts `::float8` and `round2 = math.Round(v*100)/100` runs in IEEE-754. Errors accumulate across many bills/lines (esp. carry-forward chains). | Do money math in the DB with NUMERIC (or a Go decimal type); use float8 only for final JSON projection. |
89
+ | M18 | `payments.bill_id ON DELETE SET NULL` orphans receipts and breaks carry-forward accounting | DB | `db/migrate.go:203` | Deleting a bill NULLs the payment's `bill_id`. The payment still counts in carry-forward's `paid_on < start` subtraction (which ignores `bill_id`) but no longer toward any bill's `paid_amount` → same receipt double-applied. | Block bill deletion when linked payments exist, or re-point orphaned payments to an on-account ledger. |
90
+ | M19 | Eager `vendor` chunk is 603 KB (188 KB gzip) — re-downloaded on every cold start of the sleeping/ephemeral Space | INFRA | `vite.config.ts:59-64`; `dist/assets/vendor-*.js` 603 KB, `vendor-charts` 578 KB, `vendor-motion` 110 KB | `manualChunks` lumps React+MUI+everything into one eager chunk on first paint for every role. The free Space sleeps with ephemeral FS; each wake serves assets fresh from the Go binary; evicted SW caches re-pull ~190 KB gzip before interactive. | Split MUI/react-query out of core vendor; lazy-load framer-motion. Shrink eager payload. |
91
+
92
+ ### LOW
93
+
94
+ | # | Title | Layer | Location | Detail | Fix hint |
95
+ |---|-------|-------|----------|--------|----------|
96
+ | L1 | `unitName()` returns `undefined` for unknown unit strings | FE | `features/catalog/unit.ts:29-31` | Guard only checks falsy; a truthy-unknown unit yields `undefined`, rendering "Sold by " blank (and risks a throw if used as a key). | `return UNIT_NAMES[normalizeUnit(unit)]`. |
97
+ | L2 | `Money`/`Qty` render `'₹NaN'`/`'NaN L'` on undefined/null numeric fields | FE | `components/format/Money.tsx:23-28`; `format/Qty.tsx:18-30` | No internal NaN guard; an unguarded call site after API shape drift prints NaN. | Coerce non-finite → 0 inside the components. |
98
+ | L3 | `PaymentsListPage` passes `current:'payments'` for superadmin where no such tab exists | FE | `features/payments/pages/PaymentsListPage.tsx:41-44,109`; `navConfig.tsx:40-44` | Dead/disorienting nav state for superadmin on `/payments` (no crash). | Redirect superadmin away, or fall back `current` to a valid key. |
99
+ | L4 | `RouteRow.unit` / `Delivery.unit_snapshot` sent by backend but absent from shared `api/types.ts` | FE | `api/types.ts:278-290,191-206`; `route.go:17`, `models.go:127` | Features only read them via narrow local casts; the canonical contract is out of sync. | Add `unit`/`unit_snapshot` to the shared types; remove cast shims. |
100
+ | L5 | `PUT /api/settings` is a full-object upsert but `SettingsInput` is all-optional → partial PUT zero-wipes tax/UPI/currency | BE/FE | `api/settings.go:27-47`; `api/types.ts:454-463`; `SettingsPage.tsx:89-102` | Safe today (page sends all 8 fields) but a latent footgun: any future partial PUT zeroes absent fields. Also `default_tax_rate` is coerced to 0 on tax-off, losing the stored rate. | Make the PUT a partial merge (load + overlay non-nil), or make `SettingsInput` a required full object; stop zeroing the rate. |
101
+ | L6 | Dead/duplicate ledger contract: backend `CustomerLedger`/`LedgerEntry` shipped but unused; FE rebuilds from two list calls | FE/BE | `models.go:218-233`; `useCustomerLedger.ts:69-82` | No endpoint wired; FE fetches bills+payments (`limit:200` each) and recomputes balance client-side (caps + double round-trip). | Expose & consume `/customers/{id}/ledger`, or delete the unused model. |
102
+ | L7 | Stored auth user never refreshed after token rotation; refresh-failure UX is silent | FE/BE | `auth.go:112-117` (refresh response omits `User`); `client.ts:68-75`; `authStore.ts:115-142` | Refresh persists only rotated tokens; cached `AuthUser` (role + permissions) is stale until manual logout. A server-side permission/deactivation change won't take effect. Refresh failure clears session with no toast. | Include + persist user on refresh (or refetch `/users/me`); toast "Session expired" on failure. |
103
+ | L8 | Dead hamburger/menu buttons render a focusable, labeled control that does nothing | FE | `RoutePage.tsx:144`, `LoginPage.tsx:84` (`onMenu={()=>{}}`); `StickyHeader.tsx:54-62` | A11y defect: keyboard/SR users tab to an actionable "Menu" button that opens nothing. | Omit `onMenu` / hide the leading slot when no drawer exists. |
104
+ | L9 | Bill QR PNG is fetched even when no UPI VPA is configured (UPI/Meta pending) | FE | `BillDetailPage.tsx:78,229`; `useBills.ts:38-71` | `useBillQrUrl` fetches `/bills/{id}/qr` on mount regardless of `upi_vpa`; orgs without a VPA waste a request to the free Space then render the empty state. | Gate the fetch on `settings.upi_vpa` present. |
105
+ | L10 | Money count-up re-animates from stale value on every cache invalidation | FE | `components/format/Money.tsx:38-61` | Totals visibly re-roll after every write (payment/adjustment/generate) even when unchanged — distracting on Dashboard/bill totals. | Count-up only on first mount / when delta exceeds a threshold. |
106
+ | L11 | Subscription writes don't invalidate customer analytics → CustomerDetail stays stale | FE | `useSubscriptions.ts:22-25`; `CustomerDetailPage.tsx:111-130` | Sub create/update/delete bust only subscriptions + route, not `qk.analytics.root`; "Spend/Products/What they buy" projections go stale (arguably correct since analytics is delivery-derived, but undocumented). | Also invalidate `qk.analytics.customer`, or document analytics as delivery-only. |
107
+ | L12 | `applyDeliveryAmount` computes amount as raw float (`qty*rate`) with no `round2` before persisting | BE | `api/deliveries.go:32-41` | Storage rounds via NUMERIC(12,2), but the 201 response carries the unrounded float (9.1575), so the immediate FE echo differs from the stored value. | `round2(d.ActualQty*rate)` in `applyDeliveryAmount`. |
108
+ | L13 | Bill status `paid` on a zero-activity month with NEGATIVE carry hides customer credit | BE | `store/billing.go:119-124` | `remaining <= 0.001 → 'paid'` conflates "nothing owed" with "in credit/advance". | Distinguish `remaining < 0` as a `credit`/`advance` state. |
109
+ | L14 | Subscription projection is TZ-safe but the delivery future-date guard uses server UTC | BE | `store/subscriptions.go:200-214`; `deliveries.go:78` | `ListActiveForDate` correctly uses date-only `EXTRACT(DOW)`, but `prepareDelivery`'s future-date guard uses `time.Now()` (UTC). Near IST midnight a valid same-day delivery can be wrongly rejected/accepted. | Use IST for the future-date comparison; document route `date` as an IST calendar date. |
110
+ | L15 | `qr_token` globally unique per schema, no org scope; regenerated every call but discarded on conflict | DB | `db/migrate.go:171,177`; `billing.go:197-208` | UNIQUE across all orgs; `GenerateBill` mints a new token every call but `ON CONFLICT DO UPDATE` never writes it, so it's silently discarded — and a cross-org collision aborts the whole bill transaction. | Generate token only on first insert; scope uniqueness per org or retry-on-conflict. |
111
+ | L16 | `audit_log` has no FK to orgs/users and no retention bound — unbounded growth on ephemeral free tier | DB | `db/migrate.go:47-57` | Append-only table for every record/delete/login, no cascade, no retention; risks Aiven free storage cap. | Add a `created_at` index + scheduled retention delete (e.g. 90 days). |
112
+ | L17 | Single connection pool (`MaxConns=8`) shared across tenants; no `statement_timeout` for Aiven free | INFRA | `db/db.go:32-33` | One process is survivable, but staging+live simultaneously plus Aiven's own connections can exhaust the free connection cap; no `statement_timeout` means a runaway query can hold a connection indefinitely. | Set `statement_timeout` (e.g. 20s) in RuntimeParams; right-size `MaxConns` under the free cap. |
113
+ | L18 | Redundant `DROP INDEX IF EXISTS idx_payments_paid_on` runs on every boot | DB | `db/migrate.go:212-216` | Harmless dead DDL forever — symptom of no versioned migration ledger. | Move one-shot cleanups into a versioned migration that records completion. |
114
+ | L19 | `bill_lines.product_id ON DELETE SET NULL` loses product attribution (truncated in input) | DB | `db/migrate.go` (line cut off in source JSON) | Deleting a product NULLs historical bill-line attribution; line loses which product it was. **Detail truncated in auditor input — re-confirm.** | Restrict product deletion when bill_lines reference it, or keep a denormalized name/unit snapshot. |
115
+
116
+ ---
117
+
118
+ ## 4. FREE-TIER & COST
119
+
120
+ **Verdict: the product CAN remain 100% free on HF free Docker Space + Aiven FREE Postgres, but only under explicit discipline. It is not automatically safe — several patterns push toward free-tier limits.**
121
+
122
+ | Risk | Where | Limit / ceiling | Status |
123
+ |------|-------|-----------------|--------|
124
+ | Aiven connection cap | `db/db.go:32-33` (`MaxConns=8`) | Aiven free ≈ ~20 total connections incl. console/background | **SAFE for ONE process. NOT safe if staging + live run simultaneously** (2×8 + Aiven overhead can exhaust the cap; new boots fail at Ping/Connect). Do not run both profiles against the same instance at once. |
125
+ | Aiven storage | `audit_log` (L16), no retention | Aiven free storage is small | **AT RISK over time** — unbounded append-only audit table + bill/delivery growth. Add retention. |
126
+ | No `statement_timeout` | `db/db.go` (L17) | — | **RISK** — a runaway query can pin a scarce connection indefinitely. Set a 20s timeout. |
127
+ | Cold-start asset re-pull | `vite.config.ts` (M19) | HF free Space sleeps + ephemeral FS | **COST-FREE but UX cost** — 603 KB eager vendor (188 KB gzip) re-served on each wake / SW-eviction. Not a money risk; shrink for usability. |
128
+ | Client-side bill loop on cold start | `useGenerateBills.ts` (H9) | — | Sleeping Space → first request is a slow cold start; the 4-way loop surfaces transient 503s as false failures. No cost, but data-integrity risk (partial month). |
129
+ | Ephemeral FS | HF free Docker | No persistent disk | All state MUST live in Aiven Postgres (it does). QR blobs etc. must not be written to local disk expecting persistence. **OK as designed.** |
130
+
131
+ **Safe ceiling to operate within free tiers:**
132
+ - Run **exactly one** backend process per Aiven instance at a time (never staging + live concurrently against the same DB), OR give staging its own free instance. Keep `MaxConns` so that `processes × MaxConns + Aiven_overhead < ~20`.
133
+ - Add `statement_timeout` and an `audit_log` retention job to stay under storage/connection limits.
134
+ - Nothing in the audited code requires a paid HF GPU/persistent disk or a paid Aiven tier. **No finding forces a paid upgrade.** The free path is viable.
135
+
136
+ ---
137
+
138
+ ## 5. META / WHATSAPP PENDING
139
+
140
+ **State today (honest):** Only `wa.me` deep links exist. The full Meta/WhatsApp integration (Business API + the `whatsapp-web.js` service described in PLAN1.md) is **NOT integrated**. Nothing in the audited code assumes Meta is done.
141
+
142
+ **What's pending:**
143
+ - WhatsApp Business API / `whatsapp-web.js` service from PLAN1.md — not present.
144
+ - Server-initiated message sending, delivery receipts, templated bill/payment notifications — none exist; the app can only open a `wa.me` link in the user's WhatsApp client.
145
+
146
+ **What is honest vs. what overreaches:**
147
+ - The `wa.me` deep-link approach is honest and free — it does not claim automated messaging.
148
+ - **Overreach risk:** UPI/QR rails are presented as if provisioned, but L9 shows `useBillQrUrl` fetches a QR even with **no `upi_vpa`** configured, then renders an empty state. The QR/UPI payment path is effectively not-yet-real for orgs without a VPA and should be gated. Do not present UPI collection as live until a VPA + reconciliation exist.
149
+
150
+ **Is a free Meta path viable?**
151
+ - `wa.me` deep links: **yes, free and already shipped** — this is the correct free baseline and should remain the default.
152
+ - WhatsApp Cloud API: has a free conversation tier but requires a Meta Business account, a verified number, and a hosted webhook endpoint. On a **sleeping** HF free Space, inbound webhooks and message-status callbacks are unreliable (the Space may be asleep when Meta calls). A self-hosted `whatsapp-web.js` service needs a persistent long-running process with a stable session/disk — **not viable on the ephemeral, sleeping free Space.**
153
+ - **Recommendation:** keep `wa.me` deep links as the free, honest mechanism. Treat true Meta automation as a paid/always-on-infra feature and do not promise it on the free tier. Flag any UI copy that implies automated WhatsApp sending today.
154
+
155
+ ---
156
+
157
+ ## 6. Prioritized Remediation Plan
158
+
159
+ **Fix first (ship-blockers — crashes + money correctness):**
160
+ 1. **H1** — per-route ErrorBoundary keyed by pathname (stops the whole-app blanking; directly addresses the reported crashes).
161
+ 2. **H2, H3, M12** — carry-forward / aging double-count and later-month carry recompute (money correctness; verified). Treat as a single accounting-integrity workstream with M10/M11 (atomic adjustments).
162
+ 3. **H5, M13** — reject deliveries with no resolvable rate instead of billing at 0.
163
+ 4. **H4, H6, H8** — unit-travel correctness: `GROUP BY (product_id, unit_snapshot)`, fix the `'litre'` backfill from `products.unit`, and thread `org_id` on the Staff screen.
164
+
165
+ **Fix next (high-value, lower blast radius):**
166
+ 5. **H7 + M15 + L18** — adopt a versioned migration tool; collapse the boot-time DDL churn.
167
+ 6. **M1 + M2** — IST date handling end-to-end (FE format + BE TZ).
168
+ 7. **H9** — server-side batch bill generation (resumable) — also reduces cold-start false failures.
169
+ 8. **M16** — add the `bill_id` indexes (cheap, broad perf win).
170
+ 9. **M3, M8, M9** — analytics per-unit + guard `products.unit` edits.
171
+ 10. **M19** — bundle split for cold-start UX.
172
+ 11. **Free-tier guardrails** — `statement_timeout`, right-size `MaxConns`, `audit_log` retention (§4), never co-run staging+live on one Aiven instance.
173
+
174
+ **Fix opportunistically (LOW):** L1–L18 — group L1/L2/L4 (unit + NaN + contract types) and L5/L7 (settings merge + auth refresh) as small cleanups.
175
+
176
+ ## Do NOT fix / accept (these are fine as-is)
177
+ - **Subscription projection TZ handling** (`subscriptions.go:200-214`) — correctly date-only; only the *caller's* future-date guard (L14) needs the IST fix. The projection itself is fine.
178
+ - **Ephemeral FS reliance on Postgres for all state** — correct architecture for HF free; do not try to add local persistence.
179
+ - **`wa.me` deep links as the WhatsApp mechanism** — the honest, free baseline; keep it. Do not block on Meta.
180
+ - **Bill status `paid` for a true zero-balance month** — fine; only the *negative-carry credit* case (L13) is a cosmetic refinement, not a bug.
181
+ - **Analytics being delivery-derived (not subscription-derived)** — defensible; L11 is a documentation/invalidation nicety, not a correctness bug.
182
+ - **`MaxConns=8` for a single process** — adequate; only the *concurrent staging+live* scenario (L17) is the real risk.
183
+
184
+ ---
185
+
186
+ ## Appendix — Input completeness
187
+
188
+ The findings JSON delivered to the synthesizer was **truncated** inside LENS 5 (`be-data`), with the final entry (`bill_lines.product_id ON DELETE SET NULL`, captured here as L19) cut mid-sentence, and **lenses 6–10 were not included**. Auditor coverage represented above: LENS 1 (fe-crash), LENS 2 (fe-contract), LENS 3 (fe-ux), LENS 4 (be-logic), LENS 5 (be-data, partial). **Recommend re-running the audit fan-out to recover the missing lenses 6–10** (likely security/auth, infra/Docker/HF, docs/PLAN consistency, etc.) before treating this catalog as exhaustive.
189
+
190
+ ---
191
+
192
+ ## 7. Recovered lenses 6, 8, 9, 10 (were truncated from the synthesis above)
193
+
194
+ ### Security (lens 6)
195
+ | Sev | Title | Location |
196
+ |-----|-------|----------|
197
+ | HIGH | Login rate-limiter trivially bypassed by spoofing `X-Forwarded-For` → credential brute-force wide open | router.go:129-141 clientIP |
198
+ | HIGH | Refresh token in localStorage relies on a "strict CSP" that **does not exist** (no CSP/security headers) | authStore.ts:11-16 |
199
+ | HIGH | Refresh tokens not rotated/revocable: a stolen/old refresh token stays valid 30 days (no jti) | auth.go:82-117, jwt.go:12 |
200
+ | MED | No password policy + silent bcrypt 72-byte truncation on user creation | users.go:80-83, password.go:12-18 |
201
+ | MED | `POST /deliveries/bulk` has no array-length cap + N+1 DB lookups per row (authed low-priv DoS on free pool) | deliveries.go:149-179 |
202
+ | MED | `updateSettings` skips struct validation + full-object upsert (stores unvalidated/zeroing values) | settings.go:27-47 |
203
+ | LOW | No anti-clickjacking / `X-Content-Type-Options` headers on SPA+API | router.go:270-281 |
204
+ | LOW | `users.manage`/`settings.manage` grantable to employees but inert (masks true privilege model) | permissions.go:31-32 |
205
+ | LOW | Superadmin seed has no password-strength floor; root creds only in env, no rotation/audit | main.go:103-142 |
206
+
207
+ ### Reliability / ops (lens 8)
208
+ | Sev | Title | Location |
209
+ |-----|-------|----------|
210
+ | HIGH | Boot does a single DB connect with **no retry/backoff** → an Aiven free cold-start/blip crash-loops the Space | main.go:29-34 |
211
+ | HIGH | **No client-side request timeout** → first call after the Space sleeps hangs the UI indefinitely (a real "screen crashed" trigger) | client.ts:92-137, queryClient.ts |
212
+ | HIGH | **Zero request/access logging** → production is undebuggable when the Space misbehaves | router.go:270-281 |
213
+ | MED | No Postgres `statement_timeout` → a runaway query pins a scarce Aiven free connection | db.go:23-38 |
214
+ | MED | In-memory rate-limiter resets on every Space sleep → brute-force throttle effectively defeated | api.go:45-54 |
215
+ | MED | Cache cold after every wake → first dashboard load post-sleep pays full DB cost (FIFO not LRU) | api.go:26,45 |
216
+ | MED | Route projection loads FULL unpaginated customer+product lists per request | route.go:53,63,67 |
217
+
218
+ ### Meta / WhatsApp (lens 9, details)
219
+ | Sev | Title | Location |
220
+ |-----|-------|----------|
221
+ | HIGH | Backend never returns an empty `whatsapp_link` → the "No phone on file" fallback is unreachable; app can open a broken `wa.me` link | links.go:36-48, qr.go:105-111 |
222
+ | HIGH | PLAN1 whatsapp-web.js (always-on Chromium + ephemeral FS + sleep) is **NOT viable on the free tier** as written | PLAN1.md:15,61-71,105 |
223
+ | MED | Confirmed: no Meta Business API / no whatsapp-web.js code exists; nothing in the running product assumes either | repo-wide (only wa.me) |
224
+ | MED | whatsapp-web.js carries unmitigated ToS/ban risk → unsafe as the primary channel even if hosting worked | PLAN1.md:21-25 |
225
+ | LOW | Honest free Meta path = Cloud API free tier (user-initiated/service convos), not whatsapp-web.js | — |
226
+
227
+ ### Test quality (lens 10)
228
+ | Sev | Title | Location |
229
+ |-----|-------|----------|
230
+ | BLOCKER | All DB-backed tests SKIP by default + **no CI** → the critical SQL (billing/analytics) is effectively untested in automation | e2e_test.go:208-211; no .github/workflows |
231
+ | HIGH | Carry-forward + aging double-counts live entirely in untested SQL | billing.go, analytics.go |
232
+ | HIGH | Frontend has **zero test infrastructure** (no vitest/RTL/playwright) → crash-prone render logic untested | package.json |
233
+ | HIGH | Superadmin role has zero integration coverage (the crash surface) | e2e_test.go |
234
+ | MED | Cross-org isolation test covers only ~4 of ~10 resources → IDOR gaps on writes | e2e_test.go:1085-1212 |
235
+ | MED | `npm run lint` is a dead script (eslint not installed) | package.json |
236
+ | MED | `-race` can never run here (no C compiler) → concurrency tests' value unrealized | ratelimit/cache tests |
237
+ | LOW | Flaky wall-clock timing in cache TTL tests (no injectable clock) | cache_test.go:29-48 |
238
+
239
+ > Note: §4 (Free-tier) and §5 (Meta) above already incorporate lens 7's conclusions. With these recovered lenses the catalog is now complete.
M3_THEME_PLAN.md ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MilkWay ERP — Material 3 Expressive Re-Skin Plan
2
+
3
+ > **For owner approval.** Analysis-only deliverable. No app code changed, no builds run. This plan consolidates four audits (`frontend/PWA/design/m3/m3-spec.md`, `current-theme.md`, `current-components.md`, `feasibility.md`) into one decision document.
4
+ >
5
+ > **One-line recommendation:** Re-theme MUI v6 to a seed-generated M3 tonal token system and hand-build ~7 expressive components on MUI primitives + Framer Motion springs — keep the data layer 100% untouched.
6
+
7
+ ---
8
+
9
+ ## 1. Executive summary — the honest verdict
10
+
11
+ **The current theme is not worth keeping, and that is the correct call.** It is a competent, internally-consistent *generic SaaS / Material-2-era* theme — and that is exactly the problem. It is built on the wrong primitives for Material 3 Expressive (M3E), so this is a **token-model rebuild, not a palette tweak.** Concretely, the current `src/theme/`:
12
+
13
+ - **Has a flat hand-picked hex palette** (`primary #1E6FE0`, `secondary #2E9E5B`, … — most are literally Tailwind defaults). No tonal ramp, **no `*-container` roles, no `on-*` roles, no tertiary, no surface-container ladder, no dark theme** despite the "dark-swap ready" comment. The codebase already *fakes* tonal fills by string-concatenating opacity onto hex (`` `${primary.main}14` ``) — the tell that the token model can't express what the design needs.
14
+ - **Uses shadow-based elevation** (`e1/e2/e3` smeared across all 25 MUI slots) + 1px borders on every card. M3E is **tonal-elevation-first** (raise = swap surface-container tier, not add a shadow).
15
+ - **Has a too-small, static shape scale** (`8/12/16/pill`) — missing `4` and the expressive `28`+ radii, and no concept of shape-morph on press/select.
16
+ - **Has a bespoke 12-role MUI type scale** (`h1..button`), not the M3 15-role scale; no `display` tier (no hero typography), no `label` family, no emphasized weights.
17
+ - **Has duration+cubic-bezier motion with exactly one (unused) spring.** M3E's signature is broad **spring physics + shape-shift**; the current vocabulary gives the opposite feel.
18
+
19
+ **What is genuinely good and will be kept:** the *plumbing* — `createTheme` + `theme.d.ts` module augmentation, the token-discipline (only ~5 files contain raw hex; components read from theme), the 8px spacing grid (M3-compatible), the `prefers-reduced-motion` discipline, and `AppButton`'s state-machine engineering. We keep the mechanism and the file structure; we regenerate every value inside.
20
+
21
+ **The target look (per the owner's reference screenshot):** soft **pastel tonal container** cards (surface-container / secondary- / tertiary-container fills) with **large 16–28px radii** and little-to-no border; **expressive typography** with hero display sizes; **springy shape-shift motion** (corners morph on press, FAB blooms into a menu); **state layers** instead of hover-hacks; and the new expressive components — **split buttons, button groups, docked/floating toolbars, FAB menus, M3 segmented buttons, and waveform progress.** None of these exist today.
22
+
23
+ ---
24
+
25
+ ## 2. Recommended approach
26
+
27
+ ### Chosen: **Approach A — Re-theme MUI v6 to M3 + ~7 custom expressive components on MUI primitives + Framer Motion.**
28
+
29
+ **Justification (crisp):** The app is 102 of 114 `.tsx` files deep in MUI, with the data/TanStack-Query layer fully decoupled from the view layer. A re-theme is centralized (token generator + `components.*` overrides), incremental, and revertible per component, at **near-zero bundle cost** (`@material/material-color-utilities` runs at build time). It is the only path that reaches the screenshot while keeping the backend untouched. The expressive components MUI lacks are presentational and built on existing deps — no new runtime.
30
+
31
+ **Rejected alternatives (from feasibility):**
32
+ - **Approach B — `@material/web` (Lit):** disqualified. It is in **maintenance mode** ("pending new maintainers", last release v2.4.1, 2025-10-27), ships **no Card, Toolbar, Split button, Button group, or FAB menu**, and **never mentions M3 Expressive**. We'd hand-build the expressive components anyway, on top of an abandoned base, with React↔Lit interop tax, dual theming, and a *bigger* bundle. Rejected.
33
+ - **Approach C — Hybrid MUI + a few MWC elements:** all of B's downsides for a subset of components that doesn't include any of the expressive ones we actually need. Buys nothing. Rejected.
34
+
35
+ ### What this approach will explicitly NOT do
36
+ - **Will not** adopt `@material/web` / Lit web components.
37
+ - **Will not** touch the backend, API contracts, TanStack Query hooks, or any data/business logic — this is a skin.
38
+ - **Will not** rewrite forms or swap out `react-hook-form`.
39
+ - **Will not** deliver pixel-perfect Google M3 fidelity — hand-built fidelity = effort invested, not free.
40
+ - **Will not** ship full adaptive/desktop layouts (nav rail/drawer, multi-pane) in the core re-skin. M3 window-size-class adaptivity is flagged as a **follow-on phase**, not part of the baseline re-skin. (Optional MUI v6→v7 codemod is also out of scope / optional.)
41
+ - **Will not** introduce dynamic (wallpaper-extracted) color — a fixed brand seed is used.
42
+
43
+ ---
44
+
45
+ ## 3. M3 design tokens to adopt (concrete values)
46
+
47
+ All values below are the shipping M3 token set (material-web `tokens/versions/v0_192`) unless noted. Light theme is primary (app is light); a dark tonal set is an owner decision (§7).
48
+
49
+ ### 3.1 Color — seed → tonal palette → roles
50
+ Generate from **one brand seed** via HCT (`@material/material-color-utilities`). Each of 5 source palettes (primary, secondary, tertiary, neutral, neutral-variant) + fixed error is sampled at tones `0,10,20,30,40,50,60,70,80,90,95,99,100` (+ neutral extras `4,6,12,17,22,24,87,92,94,96,98` — these feed the surface ladder).
51
+
52
+ **Role → tone mapping (light / dark):**
53
+
54
+ | Role | Light | Dark |
55
+ |---|---|---|
56
+ | primary | P40 | P80 |
57
+ | on-primary | P100 | P20 |
58
+ | primary-container | P90 | P30 |
59
+ | on-primary-container | P30 | P90 |
60
+ | secondary / tertiary | same 40/100/90/30 pattern on own palette | 80/20/30/90 |
61
+ | error / on / container / on-container | E40 / E100 / E90 / E30 | E80 / E20 / E30 / E90 |
62
+ | outline / outline-variant | NV50 / NV80 | NV60 / NV30 |
63
+ | on-surface / on-surface-variant | N10 / NV30 | N90 / NV80 |
64
+
65
+ > **Tertiary is the expressive accent** — used for delight (FAB-menu items, highlights, hero numbers). Use **container roles far more than raw primary** (softer pastel = the M3E feel).
66
+
67
+ **Surface-container ladder (the pastel heart — tonal, not shadow):**
68
+
69
+ | Role | Light tone | Use |
70
+ |---|---|---|
71
+ | surface-dim | N87 | dimmest bg |
72
+ | surface | N98 | default page bg |
73
+ | surface-bright | N98 | brightest bg |
74
+ | surface-container-lowest | N100 (white) | lowest cards |
75
+ | surface-container-low | N96 | |
76
+ | surface-container | N94 | default card/sheet |
77
+ | surface-container-high | N92 | raised (FAB rests here) |
78
+ | surface-container-highest | N90 | menus, highest |
79
+
80
+ Plus full inventory to emit: `*-fixed`/`*-fixed-dim`/`on-*-fixed(-variant)`, `inverse-surface`, `inverse-on-surface`, `inverse-primary`, `surface-tint` (=primary), `scrim`, `shadow`. **Status colors** (success/warning) re-derive from semantic container roles (success→tertiary/green-container, warning→amber-container, error→error-container) so they re-tone with the palette — kill the flat `customTokens.status` hexes.
81
+
82
+ ### 3.2 Shape scale (`md.sys.shape.corner.*`)
83
+
84
+ | Token | Value | Typical use |
85
+ |---|---|---|
86
+ | none | 0 | — |
87
+ | extra-small | 4 dp | small chips, snackbar |
88
+ | small | 8 dp | text fields, small chips |
89
+ | medium | 12 dp | small cards |
90
+ | large | 16 dp | **default card** |
91
+ | extra-large | 28 dp | **sheets, dialogs, large FAB, hero cards** |
92
+ | full | 9999px (pill/stadium) | **buttons, chips, segmented** |
93
+
94
+ Directional variants for sheets/rails (`extra-large-top = 28 28 0 0`, `large-start/end`). **Shape is animatable:** components shape-shift (button corners round→squarer on press; toggle round↔squircle; FAB→menu morph; split-button trailing button spins + reshapes on open). Defaults are **larger and more generous** than the current 16-max.
95
+
96
+ ### 3.3 Typography scale (15 roles; rem @16px base, px in parens)
97
+
98
+ | Role | Size | Line | Weight | Tracking |
99
+ |---|---|---|---|---|
100
+ | display-large | 3.5625r (57) | 64 | 400 | -0.25 |
101
+ | display-medium | 2.8125r (45) | 52 | 400 | 0 |
102
+ | display-small | 2.25r (36) | 44 | 400 | 0 |
103
+ | headline-large / medium / small | 32 / 28 / 24 | 40/36/32 | 400 | 0 |
104
+ | title-large | 22 | 28 | 400 | 0 |
105
+ | title-medium / small | 16 / 14 | 24/20 | 500 | 0.15 / 0.1 |
106
+ | body-large / medium / small | 16 / 14 / 12 | 24/20/16 | 400 | 0.5/0.25/0.4 |
107
+ | **label-large** (buttons) | 14 | 20 | **500** | 0.1 |
108
+ | label-medium / small | 12 / 11 | 16/16 | 500 | 0.5 |
109
+
110
+ Reference face **Roboto / Roboto Flex** (variable axes for emphasized headings). **Emphasized companion** = bump titles/labels to **600–700** on hero moments; keep body 400. Note: the app currently uses Inter — acceptable to keep brand-wise, but it must be re-mapped to these M3 metrics, not MUI's.
111
+
112
+ ### 3.4 Motion — spring physics (the biggest shift)
113
+ Two spring families × 3 speeds. **Spatial** = position/size/rotation/**corner radius**, bounce allowed. **Effects** = color/opacity/elevation, **no overshoot**.
114
+
115
+ | Spec | Damping | Stiffness | Use |
116
+ |---|---|---|---|
117
+ | spatial-fast | 0.6 | 800 | chips, small toggles, press bump |
118
+ | spatial-default | 0.8 | 380 | most spatial moves |
119
+ | spatial-slow | ~0.8 | ~200 | large/hero transitions |
120
+ | effects-fast/default/slow | ~1.0 (no bounce) | ~3800 / 1600 / 800 | color/opacity/elevation |
121
+
122
+ Two schemes: **Expressive** (bouncier — hero/key interactions) and **Standard** (utilitarian flows). Framer port: `type:'spring'`, spatial `damping .6–.8` (bouncy), effects `damping ~1` (crisp). Legacy easings retained for CSS-only cases: **emphasized** `cubic-bezier(0.2,0,0,1)` over ~500ms (long2). **Keep the existing `prefers-reduced-motion` short-circuit** (collapse springs to instant/short cross-fade).
123
+
124
+ ### 3.5 State layers, density, spacing
125
+ - **State layers** (translucent overlay of the *content* color, not a new bg): hover **8%**, focus **10–12%**, pressed **10%** (ripple origin), dragged **16%**. Disabled: content **38%** / container **12%**. (MUI defaults differ — must be retuned.)
126
+ - **Density / touch targets:** minimum **48×48 dp** (current uses 44 — bump up). M3E ships size ramps XS–XL for buttons/split-buttons/FAB.
127
+ - **Spacing:** keep the **8px (4/8) grid** — already M3-compatible (the one genuine win). Containers grow to **16–24px** internal padding for M3E rhythm.
128
+
129
+ ---
130
+
131
+ ## 4. Component transformation table
132
+
133
+ Distance: **Near** (token swap) · **Medium** (restyle) · **Far** (rebuild/conceptual gap).
134
+
135
+ | Current component | M3E target | Shape | Color role | State layer | Motion | Padding / Resp. | Dist. |
136
+ |---|---|---|---|---|---|---|---|
137
+ | `AppButton` | 5 M3 variants (filled/tonal/elevated/outlined/text), XS–XL sizes | `full` pill, **morph on press** | real `secondary-container`/`on-` (kill alpha hack) | hover8/focus10/press10 | spatial-fast press bump; keep loading/success machine | 48dp min, M3 size specs | Far |
138
+ | `IconAction` | M3 icon-button (filled/tonal/outlined/standard), width variants, toggle | morph on select | container pairs | full layers + selected | spatial-fast | 48dp | Far |
139
+ | `app/Fab` | FAB sizes (40/56/96) + **FAB menu**, branded/extended | large/xl | surface(default)/primary/tertiary, rests `surface-container-high` | layers | container-transform bloom | — | Far |
140
+ | `TextField` family | M3 outlined/filled field (active indicator, supporting text, icon slots) | small→M3 | error/focus tokens | container state layers | — | keep 16px font | Medium-near |
141
+ | `QtyStepper` | connected **Button group** styling | connected, morph | container | layers | spatial press | 48dp | Far (styling) |
142
+ | `SearchBar` | M3 search bar + expand-to-fullscreen search view | `full` pill, no border | `surface-container-high` fill | layers | expand transition | — | Far |
143
+ | `StatusToggle` | M3 **segmented button** (pill, secondary-container selected + check) | pill | `secondary-container` selected | layers + selection anim | shape-shift on select | — | Far |
144
+ | `StatusChip` | M3 chips (assist/filter/input/suggestion), check morph | `small`(8) not pill | named container tokens (kill `${hex}1A`) | layers | select morph | — | Medium-far |
145
+ | `MetricCard` / domain cards | **tonal "container" cards** (pastel surface/secondary/tertiary, ~28px, tonal elevation, ≤1px outline) | `large`→`xl` 28 | container roles | press layer | entrance spring | 16–24px, responsive grid | **Far (biggest screenshot gap)** |
146
+ | `PagedList` | spring list-item motion / shared-element | n/a | n/a | item layer | spring stagger | mobile→grid | Medium |
147
+ | `BottomSheet` | M3 sheet | **28** top corners | `surface-container-low` | layers | **drag-dismiss spring** | — | Medium |
148
+ | `Modal`/`ConfirmDialog` | M3 dialog (hero icon slot) | **28** | `surface-container-high` | layers | emphasized open/close | — | Medium-far |
149
+ | `StickyHeader` | M3 top app bar (small/med/large), tonal-on-scroll | — | `surface-container` on scroll | — | emphasized collapse | med/large headline variants | Medium-far |
150
+ | `BottomNav` | M3 navigation bar (**active-indicator pill + morph**) | pill indicator | `secondary-container` | indicator state layer | indicator morph | 80dp; rail at ≥900 (later) | Far |
151
+ | `StickyActionBar` | M3 **docked/floating Toolbar** (grouped actions + optional FAB) | pill (floating) | container | layers | — | — | Far |
152
+ | `PageTabs` | M3 primary/secondary tabs | M3 indicator | container | layers | indicator anim | — | Medium-near |
153
+ | `Spinner` | **waveform/wavy progress** + M3 loading indicator | rounded ends | `primary` | n/a | wavy→straighten | — | Far |
154
+
155
+ ### New expressive components to build (no equivalent today)
156
+ 1. **Split button** — `buttons/SplitButton`: leading primary action + trailing menu trigger that **spins + reshapes** on open. 5 sizes; filled/tonal/elevated/outlined.
157
+ 2. **Button group** — `buttons/ButtonGroup`: connected buttons with shared shape/motion/width; pressing one squeezes neighbors. XS–XL.
158
+ 3. **Toolbar (docked + floating)** — replaces/augments `StickyActionBar`: docked replaces deprecated bottom app bar; floating = pill action cluster that can host a FAB.
159
+ 4. **FAB menu** — augments `app/Fab`: FAB morphs into a menu container of labelled actions (replaces speed-dial).
160
+ 5. **Expressive Card** — tonal container card variants (surface/secondary/tertiary), 16–28px, tonal elevation, press shape-morph.
161
+ 6. **Waveform progress** — `feedback/WaveProgress`: linear + circular wavy active track that straightens as it completes (the M3E signature) + new contained loading indicator.
162
+ 7. **M3 Segmented button** — rebuild `StatusToggle`: pill, `secondary-container` selected segment, check affordance, selection animation.
163
+
164
+ ---
165
+
166
+ ## 5. Responsiveness, margin/padding & animation guidelines (owner asked explicitly)
167
+
168
+ ### Responsiveness
169
+ - **Today: hard mobile-only.** `BottomNav` defers the `≥900px` rail to "a later phase" (a TODO comment); cards never grid; the app bar has no medium/large variants; breakpoints are essentially unused.
170
+ - **Baseline re-skin keeps single-column mobile** but makes cards grid-capable (2-up at `sm`, 3-up at `md`) and sizes containers fluidly.
171
+ - **Follow-on (separate phase, owner decision):** adopt M3 **window-size classes** (compact / medium / expanded) driving nav-bar → nav-rail → nav-drawer, and multi-pane layouts for tablet/desktop. Out of scope for the core skin to keep risk low.
172
+
173
+ ### Margin / padding
174
+ - **Keep the 8px (4/8) grid** — it is the least-broken axis and M3-compatible.
175
+ - **Grow container rhythm:** cards/sheets to **16–24px** internal padding (M3E is more generous than the current uniform `p:2`/16px).
176
+ - Touch targets **48×48dp minimum** (up from 44). Preserve the existing **safe-area inset** CSS-var handling (good PWA hygiene).
177
+
178
+ ### Animation
179
+ - **Spring-first.** Spatial springs (bouncy, `damping .6–.8`) for position/size/**corner-radius** changes; effects springs (`damping ~1`, no overshoot) for color/opacity/elevation. Reserve bounce for spatial only.
180
+ - **Shape-shift is a first-class interaction:** press bump (scale down + corners morph rounder→squarer, release with overshoot), selection shape-change, container morph (FAB→menu, button→loading).
181
+ - **Use `prefers-reduced-motion`** to collapse all springs to instant/short cross-fade — keep this discipline (it already exists).
182
+ - Page transitions move from cubic-bezier to **emphasized / shared-axis / container-transform**.
183
+
184
+ ---
185
+
186
+ ## 6. Phased build plan (with effort + reliability guardrails)
187
+
188
+ > **Guardrails for every phase:** data/TanStack-Query layer untouched; `theme.custom.*` and `palette.accent` token *names* kept alive (mapped to M3 roles) so the **39 consuming files / 72 sites** don't need edits; run a regression build + visual smoke test at each phase; each phase independently shippable and revertible.
189
+
190
+ | Phase | Scope | Effort | Reliability guardrail |
191
+ |---|---|---|---|
192
+ | **0. Seed decision + token generator** | Pick brand seed; add `@material/material-color-utilities`; emit M3 role tokens (incl. surface ladder) as typed object + CSS vars at build time | ~2–3 d | Build-time only, zero runtime; no component touched yet |
193
+ | **1. Theme tokens** | Re-model `palette.ts` (tonal roles), `index.ts` (shape scale, tonal elevation, state-layer mixin), `motion.ts` (6 springs); extend `theme.d.ts`; map old custom tokens → M3 roles | ~3–4 d | Centralized; alias old token names; regression build; biggest visual win, lowest risk |
194
+ | **2. Primitives restyle (Near/Medium)** | TextField family, PageTabs, StatusChip, BottomSheet, Modal/ConfirmDialog onto new tokens | ~3–4 d | Token swaps + corner/elevation nudges; per-component revertible |
195
+ | **3. Far rebuilds** | AppButton, IconAction, Fab, cards (tonal containers), BottomNav (active-indicator), SearchBar, StatusToggle, StickyHeader | ~5–7 d | Rebuild on MUI primitives + springs; presentational only |
196
+ | **4. New expressive components** | Split button, Button group, Toolbar, FAB menu, waveform progress, segmented button | ~5–7 d | Net-new, presentational, no data touch |
197
+ | **5. Per-screen restyle** | Apply expressive components + tonal cards across role pages (auth/route/customers/catalog/billing/payments/analytics/admin) | ~3–5 d | Page-by-page; visual review per screen |
198
+ | **6. (Optional / later)** | Dark theme; window-size-class adaptivity (rail/drawer); MUI v6→v7 codemod | TBD | Separate decision; not required for the skin |
199
+
200
+ **Total core (phases 0–5): ~3–4 weeks.** Low-medium risk throughout (same framework, same render model, backend decoupled).
201
+
202
+ ---
203
+
204
+ ## 7. Locked decisions (owner-approved)
205
+
206
+ 1. **Expressiveness:** **FULL EXPRESSIVE EVERYWHERE** — bouncy spatial springs, shape-morph on press/select, large 28px shapes, hero display type across screens (Expressive spring scheme is the default; Standard reserved only where motion would impede dense data entry).
207
+ 2. **Light + DARK from the start** — generate both tonal sets from the seed.
208
+ 3. **Typeface: Roboto Flex** (M3 default; variable axes enable the "emphasized" hero weights). Self-host the variable font (subset) to avoid a network round-trip.
209
+ 4. **All expressive components are must-have for v1:** tonal pastel cards, waveform/wavy progress, M3 segmented buttons, nav-bar active-indicator (morphing pill), split button, button group, FAB menu, docked/floating toolbar.
210
+ 5. **PERFORMANCE MANDATE — "must not feel laggy" (hard requirement):**
211
+ - Animate ONLY `transform` + `opacity` (GPU compositor); never animate layout/`width`/`top`/box-shadow on interaction. Tonal "elevation" via pre-baked surface colors, not animated shadows.
212
+ - Color tokens generated at BUILD time (`@material/material-color-utilities`) → zero runtime palette cost; emitted as CSS vars so theme/dark switch is a class flip, not a re-render.
213
+ - Springs via Framer Motion with `will-change`/`transform` only; cap concurrent animations; **code-split** the heavy expressive components; keep the eager bundle small (fixes audit M19 cold-start too).
214
+ - Honor `prefers-reduced-motion` (collapse springs to instant) and throttle list-stagger on long lists (virtualize/animate only on-screen items).
215
+ - Target 60fps on a mid-range Android; verify with a perf pass.
216
+ 6. **Brand seed color:** PENDING — the one input everything generates from (see follow-up question).
217
+ 7. **Adaptive/tablet (window-size-class rail/drawer):** deferred to a later phase (keep core re-skin low-risk).
218
+
219
+ ---
220
+
221
+ **Recommendation (one line):** Approve **Approach A** — re-theme MUI v6 to a seed-generated M3 tonal token system and hand-build ~7 expressive components on MUI + Framer springs, sequenced tokens → primitives → expressive components → per-screen, with the data layer untouched.
frontend/PWA/design/m3/current-components.md ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # U3 — Current Components Audit vs Material 3 Expressive
2
+
3
+ Scope: `src/components/**` + `src/app/**` (AppShell). Goal: measure each component's distance from its
4
+ M3 Expressive equivalent across **shape, state layer, tonal color, motion, padding/margins, responsiveness**,
5
+ and enumerate the M3 Expressive components we **lack entirely**.
6
+
7
+ ---
8
+
9
+ ## 0. The honest top-line (the theme "is not worth it" — agreed)
10
+
11
+ The current system is a **competent generic SaaS theme**, not Material 3, and definitely not M3 *Expressive*.
12
+ It was clearly hand-authored against an internal `DESIGN_SYSTEM` doc that predates M3 Expressive. The gap is
13
+ not cosmetic — it is **structural**, and a "re-skin" cannot be a palette swap. Concretely:
14
+
15
+ 1. **No tonal-palette system.** `palette.ts` is a flat set of 6 brand hexes + a hardcoded status map.
16
+ There is **no surface-container ramp** (surface / surface-container-low/-/-high/-highest), no
17
+ secondary-/tertiary-container, no `on-*` roles. M3 Expressive's entire pastel-container look (the
18
+ screenshot's soft tonal cards) is impossible to express with this palette. Alpha hacks like
19
+ `${primary.main}14` (8% overlay) are used to *fake* tonal fills — that is the tell.
20
+ 2. **No state-layer model.** M3 requires hover/focus/pressed/dragged state layers (8%/10%/10%/16% of the
21
+ role color) on every interactive surface. The app relies on MUI's default ripple + ad-hoc `&:hover`
22
+ `bgcolor` bumps. There is no consistent focus state layer, no pressed state layer beyond Framer
23
+ `scale: 0.97` on AppButton.
24
+ 3. **Shapes are too conservative & static.** Radii are `sm 8 / md 12 / lg 16 / pill 999`. M3 Expressive
25
+ uses a *larger* and *more varied* shape scale (extra-large 28, full pills as defaults for buttons,
26
+ shape **morph** on interaction — the springy shape-shift). Nothing here morphs shape on press/select.
27
+ 4. **Motion is easing-curve / fade-and-rise, not spring-physics.** `motion.ts` is all cubic-bezier
28
+ durations + one spring reserved for the bottom sheet. M3 Expressive's signature is **spatial spring
29
+ motion** (the "expressive" springs) applied broadly — shape morph, container transform, emphasized
30
+ easing. The app's motion vocabulary is fade+12px-rise list stagger and a scale-tap. That reads as
31
+ "tasteful 2021 web app," not Expressive.
32
+ 5. **Elevation = soft drop shadows.** M3 leans on **tonal elevation (surface containers)** with restrained
33
+ shadow. The current `e1/e2/e3` shadow ramp + 1px borders on every card is the opposite approach.
34
+ 6. **Color-as-status via alpha.** `StatusChip`/`StatusToggle` build soft backgrounds via `${hex}1A`
35
+ alpha. M3 wants *named container/on-container token pairs*, not runtime alpha math, which also risks
36
+ contrast failures on a dark theme that does not yet exist.
37
+
38
+ So: re-skin = **introduce M3 tokens (tonal palettes, surface containers, state layers, shape scale, spring
39
+ motion) and rebuild the component layer on top.** Below is the per-component gap list.
40
+
41
+ ---
42
+
43
+ ## 1. Buttons & actions
44
+
45
+ ### `buttons/AppButton.tsx`
46
+ - **Shape:** `radius.md` (12px) rounded rect. M3 Expressive buttons default to **full/pill** shape and
47
+ ship in **5 sizes** (xs–xl) with a **shape-morph on press** (round ⇄ squarish). We have 3 sizes via a
48
+ height map and a fixed corner. **Far.**
49
+ - **State layer:** none of the M3 layers. Pressed = Framer `scale 0.97`; hover = a manual `bgcolor` bump
50
+ only for the tonal `secondary` variant. No focus-visible state layer. **Far.**
51
+ - **Tonal color:** `secondary` variant fakes a tonal button with `${primary.main}14/24` alpha instead of a
52
+ real `secondary-container`/`on-secondary-container` pair. **Conceptually wrong, not just off.**
53
+ - **Motion:** the idle→loading→success(check morph)→error(shake) machine is genuinely nice and *more*
54
+ animated than stock MUI — but it is bespoke, not the M3 emphasized/spring vocabulary, and there is no
55
+ shape morph. **Medium.**
56
+ - **Padding/margins:** `px 2/3`, `minHeight 40/48/52`. Reasonable, but not aligned to M3 size specs.
57
+ - **Variants gap:** has primary/secondary/ghost/danger/whatsapp. **Missing M3 button variants:**
58
+ elevated, filled-tonal (real one), outlined are conflated/absent. No **Split button**, no **Button group**
59
+ (see §7).
60
+
61
+ ### `buttons/IconAction.tsx`
62
+ - 44px tap target, `color="inherit"`, default MUI ripple. **No M3 icon-button shape variants**
63
+ (M3 has filled / tonal / outlined / standard icon buttons, **width variants** narrow→wide, and selected
64
+ toggle state with shape morph). No selected/toggle state layer. **Far.**
65
+
66
+ ### `app/Fab.tsx`
67
+ - Single circular/extended FAB, amber, 56px, `e2` shadow, `radius.lg`/pill. M3 Expressive ships
68
+ **FAB sizes** (small/regular/large/**extended**) with **container-transform** and, critically, the new
69
+ **FAB Menu** (FAB that springs open into a speed-dial of labelled actions). We have **no FAB menu** and
70
+ no container-transform open animation. Color is a raw `accent.main` rather than a `primary-container`/
71
+ tertiary token. **Far + missing the headline Expressive component.**
72
+
73
+ ---
74
+
75
+ ## 2. Fields / inputs
76
+
77
+ ### `fields/TextField.tsx` (+ NumberField, PhoneField, SelectField, DateField, SwitchField, KeyboardAwareField)
78
+ - Wraps MUI outlined `TextField`, `radius.md` (12px), 16px font (good — blocks iOS zoom), RHF-bound.
79
+ - **vs M3:** M3 text fields are **filled or outlined with the specific M3 token treatment** (active
80
+ indicator, supporting text, leading/trailing icon slots, state layers on the container). MUI outlined is
81
+ *visually adjacent* to M3 outlined, so this is the **closest-to-M3 family** — but still lacks M3 state
82
+ layers, the M3 error/focus token colors, and M3's larger label/shape rhythm. **Medium-near.**
83
+ - No expressive flourish (M3 fields are mostly restrained, so acceptable), but corners should grow to M3 scale.
84
+
85
+ ### `fields/QtyStepper.tsx`
86
+ - Bespoke −/value/+ with 44px targets inside a 12px bordered box. Solid UX, unit-aware. **Not an M3
87
+ pattern at all** (M3 has no stepper; closest is a connected **Button group** / segmented). Acceptable as
88
+ a domain control, but should adopt M3 connected-button shape + state layers + press shape-morph. **Far in
89
+ styling, fine in function.**
90
+
91
+ ### `fields/SearchBar.tsx`
92
+ - `Paper` + `InputBase`, 12px radius, 44px tall, border, clear-✕. M3 has a dedicated **Search bar / Search
93
+ view** pattern: **full/pill** shape, surface-container-high fill (no border), leading menu/search icon,
94
+ trailing avatar/action, and an **expand-to-full-screen search view** transition. Ours is a flat bordered
95
+ box with none of that. **Far.**
96
+
97
+ ### `fields/StatusToggle.tsx`
98
+ - MUI `ToggleButtonGroup`, `radius.sm` (8px) segments, neutral grey via sx alpha. This is the app's
99
+ **segmented control**. M3's **segmented button** is pill-shaped, full-width-optional, uses
100
+ secondary-container for the selected segment + a check icon, with **state layers and selection
101
+ animation**. Ours: square-ish 8px corners, alpha tints, no selection motion, no check affordance.
102
+ **Medium-far — wrong shape & color model.**
103
+
104
+ ---
105
+
106
+ ## 3. Status / chips
107
+
108
+ ### `status/StatusChip.tsx`
109
+ - MUI `Chip`, pill radius, **soft bg via `${hex}1A` alpha + colored text + dot/icon**. This is the single
110
+ source of status→color. M3 chips (assist/filter/input/suggestion) use **named container tokens**, an 8px
111
+ corner (M3 chips are *not* fully pill by default — they're small radius), state layers, and a selected
112
+ state with check morph. Ours is pill + alpha. **Color model is non-M3 (alpha math), shape is over-rounded
113
+ for an M3 chip.** Also: no filter/input/suggestion chip variants exist anywhere. **Medium-far.**
114
+ - `StickyTotalBar` also uses a raw MUI `Chip color="primary"` (solid) — inconsistent with StatusChip.
115
+
116
+ ---
117
+
118
+ ## 4. Cards & containers
119
+
120
+ ### `charts/MetricCard.tsx`, `domain/CustomerCard.tsx`, `domain/ProductCard.tsx`, `domain/BillCard.tsx`, rows
121
+ - All built on MUI `Card` themed to `radius.lg` (16px) + **1px border + e1 shadow + white paper**.
122
+ - **vs M3 Expressive:** the headline Expressive look is **tonal "container" cards** — pastel
123
+ surface-container / secondary-container / tertiary-container fills, **large 28px corners**, *little or no
124
+ border*, tonal elevation instead of drop shadow, and sometimes **asymmetric / varied corner shapes** and
125
+ shape-morph on press. Our cards are flat white bordered rectangles. This is the **biggest visual gap to
126
+ the screenshot** the owner referenced. **Far.**
127
+ - **Padding:** uniform `p: 2` (16px) everywhere — fine, but M3 expressive cards tend to 16–24px with more
128
+ generous internal rhythm.
129
+ - **Motion:** cards have no press/hover state layer and no entrance spring of their own (entrance comes only
130
+ from `PagedList` fade+rise). **Far.**
131
+ - **Responsiveness:** cards are full-width stacked; no responsive grid / breakpoint behavior at tablet
132
+ widths. Mobile-only assumption baked in.
133
+
134
+ ### `domain/CustomerCard` rank badge / Avatar
135
+ - Avatar uses `background.default` fill + primary icon — should map to a `*-container` token in M3. Minor.
136
+
137
+ ---
138
+
139
+ ## 5. Lists
140
+
141
+ ### `lists/PagedList.tsx`
142
+ - Solid functional list (infinite/load-more, pull-to-refresh, skeleton/empty/error states). Spacing
143
+ `1.5` gap. Entrance = fade + 12px rise staggered by `stagger.step` (40ms). **Honest motion critique:**
144
+ fade+rise is the *generic* entrance; M3 Expressive favors **spring-based list item motion** and
145
+ container/shared-element transforms. No shape, no spring. **Medium** (function strong, motion generic).
146
+ - `usePullToRefresh` indicator is a custom spinner — M3 has a refreshable indicator but this is acceptable.
147
+ - No M3 **list item** spec adoption (leading/trailing slots, dividers, state layers) — list items are just
148
+ domain cards. Acceptable but non-M3.
149
+
150
+ ---
151
+
152
+ ## 6. Overlays
153
+
154
+ ### `overlays/BottomSheet.tsx`
155
+ - MUI `Drawer` bottom anchor, 16px top corners, `e3` shadow, grab handle, keyboard-aware footer lift,
156
+ reduced-motion fade. **Deliberately uses Drawer's Slide, not a spring.** M3 Expressive bottom sheet =
157
+ **28px top corners**, surface-container-low tonal fill, drag-to-dismiss with **spring physics**, and the
158
+ expressive container motion. Ours slides linearly with 16px corners. **Medium** — right idea, under-rounded
159
+ and non-spring.
160
+
161
+ ### `overlays/Modal.tsx`
162
+ - MUI `Dialog`, 16px radius, `e3`, close-✕ top-right. M3 dialogs: **28px corners**, tonal
163
+ surface-container-high, hero icon slot, emphasized open/close motion. **Medium-far** (corner + tonal + motion).
164
+
165
+ ### `overlays/ConfirmDialog.tsx`
166
+ - (Wraps the dialog pattern.) Same gaps as Modal. Should adopt M3 basic-dialog layout + tonal + spring.
167
+
168
+ ---
169
+
170
+ ## 7. AppShell (`src/app/**`)
171
+
172
+ ### `app/StickyHeader.tsx`
173
+ - MUI `AppBar`+`Toolbar`, 56px, white bg, **1px bottom border**, shadow only when `elevated`, optional 48px
174
+ sub-row. **vs M3:** M3 top app bars (small/medium/large/center) use **surface-container tonal color on
175
+ scroll**, larger expressive title sizes (medium/large variants with big headlines), and emphasized
176
+ collapse motion. Ours is a flat 56px bar with a border — closest to M3 *small* app bar but no tonal-scroll,
177
+ no medium/large expressive headline variants. **Medium-far.**
178
+
179
+ ### `app/BottomNav.tsx`
180
+ - MUI `BottomNavigation`, 60px, top border, active=primary/inactive=secondary, **showLabels always**.
181
+ **vs M3:** M3 **Navigation bar** uses an **active indicator pill (state layer) behind the active icon**
182
+ with a **morphing/animated indicator** on switch, secondary-container token, and a specific 80px height.
183
+ Ours has **no active indicator pill and no switch animation** — just color change. **Far** (missing the
184
+ signature M3 nav-bar indicator + motion). Also note: `>=900px left rail` is only a TODO comment — **no
185
+ responsive nav rail exists**, so tablet/desktop is unhandled.
186
+
187
+ ### `app/StickyActionBar.tsx`
188
+ - `Paper` + `e3` top shadow, keyboard lift, primary/secondary stacked, safe-area padding. Functionally a
189
+ bottom button container. M3 Expressive's equivalent is the **Bottom app bar / Toolbar** (incl. the new
190
+ **floating/docked Toolbar** with grouped actions + FAB). Ours is a shadowed slab with stacked buttons, not
191
+ an M3 toolbar. **Far** (and see §8 Toolbar gap).
192
+
193
+ ### `nav/PageTabs.tsx`
194
+ - MUI `Tabs` fullWidth. M3 primary/secondary **Tabs** with active indicator are close to MUI's, but lack the
195
+ M3 indicator shape/animation and label/icon tab spec. **Medium-near.**
196
+
197
+ ### `nav/DateStrip.tsx`, `nav/StickyTotalBar.tsx`
198
+ - Bespoke; `StickyTotalBar` uses a solid primary chip (inconsistent w/ StatusChip) + count-up. Fine
199
+ functionally; non-M3 styling.
200
+
201
+ ### `app/PageTransition.tsx`
202
+ - Page transitions exist (good) but driven by the cubic-bezier `page` easing, not M3 emphasized/spring or
203
+ **shared-axis / container-transform** patterns. **Medium.**
204
+
205
+ ---
206
+
207
+ ## 8. Feedback / progress
208
+
209
+ ### `feedback/Spinner.tsx`
210
+ - MUI `CircularProgress`, thickness 4.5. M3 Expressive **redesigned progress indicators**: the new
211
+ circular/linear indicators have a **gap + rounded ends**, and there is the brand-new **wavy / waveform
212
+ linear & circular progress**. We have a plain MUI spinner. **No waveform progress anywhere.** **Far.**
213
+
214
+ ### `feedback/Skeleton.tsx`, `EmptyState`, `ErrorState`, `ErrorBoundary`, `ToastProvider`
215
+ - Standard. M3 has **Loading indicator** (the new contained activity indicator) and **Snackbar** spec;
216
+ ours are generic MUI/bespoke. Toasts are bespoke (not M3 snackbar tokens). **Medium**, low priority.
217
+
218
+ ---
219
+
220
+ ## 9. Charts (`charts/*`)
221
+ - ApexCharts wrappers (Trend/Donut/Bar) + `ChartFrame` + `MetricCard`. Colors pulled from the flat palette,
222
+ not a tonal chart palette. M3 doesn't spec charts, but Expressive dashboards expect tonal-container chart
223
+ cards and harmonized accent tones. Charts will look off-brand until the palette becomes tonal. **N/A-to-medium.**
224
+
225
+ ---
226
+
227
+ ## 10. M3 Expressive components we LACK ENTIRELY
228
+
229
+ These have **no equivalent** in `src/components` or `src/app` today:
230
+
231
+ | M3 Expressive component | Status | Where it would land |
232
+ | --- | --- | --- |
233
+ | **Split button** (primary + attached menu trigger, shape-morph) | **Missing** | new `buttons/SplitButton` |
234
+ | **Button group** (connected, shape-morph on press/selection) | **Missing** | new `buttons/ButtonGroup` |
235
+ | **Toolbar** (floating/docked, grouped actions + optional FAB) | **Missing** | replaces/augments `StickyActionBar` |
236
+ | **FAB Menu** (FAB springs open to labelled actions) | **Missing** | augments `app/Fab` |
237
+ | **M3 Segmented button** (pill, secondary-container, check + selection anim) | **Have wrong impl** (`StatusToggle` = square ToggleButtonGroup) | rebuild `StatusToggle` |
238
+ | **Navigation bar active-indicator pill + morph** | **Missing** (BottomNav = color-only) | rebuild `app/BottomNav` |
239
+ | **Waveform / wavy progress** (linear + circular) | **Missing** (plain `CircularProgress`) | new `feedback/WaveProgress` |
240
+ | **New M3 Loading indicator** (contained activity) | **Missing** | `feedback/Spinner` replacement |
241
+ | **Tonal "container" expressive cards** (pastel surface/secondary/tertiary containers, 28px, tonal elevation) | **Missing** (all cards = white + border + shadow) | rebuild card layer |
242
+ | **Surface-container ramp + state layers** (foundation) | **Missing** (flat palette + alpha hacks) | rebuild `theme/palette` |
243
+ | **Shape-morph motion / spring physics** (the Expressive signature) | **Missing** (cubic-bezier + one sheet spring) | rebuild `theme/motion` |
244
+ | **Icon button variants** (filled/tonal/outlined, width sizes, toggle morph) | **Missing** (single `IconAction`) | extend `IconAction` |
245
+ | **M3 Search view** (expand-to-fullscreen) | **Missing** (flat SearchBar only) | extend `SearchBar` |
246
+ | **Carousel** (M3 Expressive multi-item) | **Missing** | net-new if needed |
247
+
248
+ ---
249
+
250
+ ## 11. Per-component distance scorecard (quick reference)
251
+
252
+ Distance: **Near** (token swap), **Medium** (restyle), **Far** (rebuild/conceptual gap).
253
+
254
+ | Component | Shape | State layer | Tonal color | Motion | Padding/Resp. | Overall |
255
+ | --- | --- | --- | --- | --- | --- | --- |
256
+ | AppButton | Far | Far | Far (alpha) | Medium | Near | **Far** |
257
+ | IconAction | Far | Far | Far | Far | Near | **Far** |
258
+ | Fab | Medium | Far | Far | Far | Near | **Far** (no FAB menu) |
259
+ | TextField family | Near | Medium | Medium | Near | Near | **Medium-near** |
260
+ | QtyStepper | Far | Far | Medium | Far | Near | **Far (styling)** |
261
+ | SearchBar | Far | Far | Far | Far | Near | **Far** |
262
+ | StatusToggle (segmented) | Far | Far | Far (alpha) | Far | Medium | **Far** |
263
+ | StatusChip | Medium | Far | Far (alpha) | Far | Near | **Medium-far** |
264
+ | MetricCard / domain cards | Far | Far | Far | Far | Medium | **Far** (screenshot gap) |
265
+ | PagedList | n/a | Far | n/a | Medium | Medium (mobile-only) | **Medium** |
266
+ | BottomSheet | Medium | Far | Medium | Medium | Near | **Medium** |
267
+ | Modal / ConfirmDialog | Medium | Far | Medium | Medium | Near | **Medium-far** |
268
+ | StickyHeader (app bar) | Medium | Far | Far | Medium | Medium-far (no L/M variants) | **Medium-far** |
269
+ | BottomNav | Far (no indicator) | Far | Far | Far | Far (no rail) | **Far** |
270
+ | StickyActionBar | Far (not a Toolbar) | Far | Far | Medium | Medium | **Far** |
271
+ | PageTabs | Medium | Medium | Medium | Medium | Near | **Medium-near** |
272
+ | Spinner / progress | Far | n/a | n/a | Far (no waveform) | Near | **Far** |
273
+
274
+ ---
275
+
276
+ ## 12. Responsiveness, margin, padding & animation — overall notes
277
+
278
+ - **Responsiveness:** the app is **hard mobile-only**. `BottomNav` literally defers the `>=900px` left rail
279
+ to "a later phase" (comment), cards never grid, app bar has no medium/large variants, no breakpoint-aware
280
+ layout. M3 Expressive is adaptive (nav rail/drawer, expanded panes) — this is a real gap for tablet/desktop.
281
+ - **Padding/margins:** consistent and sane (`spacing: 8`, mostly `p: 2`). This is the **least broken axis** —
282
+ it just needs to grow to M3's more generous rhythm on containers. **Near.**
283
+ - **Animation quality:** there is *real* care here (AppButton state machine, list stagger, sheet lift,
284
+ reduced-motion handling, page transitions) — better than stock MUI. But it is the **wrong dialect**:
285
+ duration+cubic-bezier fades/rises/scales, with exactly **one** spring (the sheet). M3 Expressive's identity
286
+ is **broad spring physics + shape morph + container transforms**. None of that exists. So: good engineering,
287
+ wrong design language. **Medium effort to re-aim, but conceptually Far.**
288
+
289
+ ---
290
+
291
+ ## 13. Bottom line for the re-skin
292
+
293
+ A Material 3 Expressive re-skin of this codebase is a **foundation rebuild, then a component restyle**, in
294
+ this order:
295
+
296
+ 1. **`theme/palette.ts` → M3 tonal system** (primary/secondary/tertiary palettes, surface-container ramp,
297
+ `on-*` roles, container pairs, error). Kill the `${hex}1A` alpha pattern.
298
+ 2. **`theme/motion.ts` → spring/emphasized tokens** (expressive springs, shape-morph helpers,
299
+ container-transform). Keep the reduced-motion discipline.
300
+ 3. **`theme/index.ts` → M3 shape scale** (xs–xl incl. 28px, pill defaults), **state-layer mixin**, tonal
301
+ elevation.
302
+ 4. Restyle the **Near/Medium** components (fields, tabs, sheet, dialog, chip) onto the new tokens.
303
+ 5. **Rebuild** the **Far** components and add the **missing** ones: Split button, Button group, Toolbar,
304
+ FAB menu, M3 segmented button, nav-bar active indicator, waveform progress, tonal expressive cards.
frontend/PWA/design/m3/current-theme.md ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Current Theme Audit vs Material 3 Expressive
2
+
3
+ **Scope:** `src/theme/{palette.ts, motion.ts, index.ts, theme.d.ts}` and how those tokens are consumed in `src/components/`.
4
+
5
+ **Verdict up front:** The current theme is a competent, conventional *MUI v5/Material-2-era* setup with a hand-rolled "design system" bolted on via `theme.custom`. It is internally consistent and the discipline (no inline hex, tokens read from theme) is genuinely good. But as a foundation for **Material 3 Expressive** it is the wrong shape entirely. M3 is not "MUI with rounder corners and a blue primary" — it is a *tonal* system built on HCT tonal palettes, surface-container tiers, paired color roles (`on-*`, `*-container`, `on-*-container`), an expanded type scale, a tonal elevation model, state layers, and spring-based motion. The current theme has **none of those primitives**. Re-skinning is not a tweak; it is a token-model replacement. Everything below is why "it isn't worth it" to keep as-is.
6
+
7
+ ---
8
+
9
+ ## 1. Color / Palette — the biggest gap
10
+
11
+ ### What we have
12
+ `palette.ts` defines **flat, single-stop solid colors**:
13
+
14
+ ```
15
+ primary #1E6FE0 secondary #2E9E5B success #16A34A
16
+ warning #D97706 error #DC2626 info #0EA5E9
17
+ background.default #F6F8FB paper #FFFFFF divider #E5E9F0
18
+ text.primary #0F172A secondary #64748B disabled #94A3B8
19
+ + custom: accent #F59E0B, eveningTint #4F46E5, status map
20
+ ```
21
+
22
+ Each role is **one hex with a `contrastText`**. That's the entire color model. `AppButton` even hand-mixes a tonal fill by string-concatenating opacity onto the hex (`` `${t.palette.primary.main}14` ``, `...24` on hover) — i.e. the codebase is already *faking* tonal containers because the token system can't express them.
23
+
24
+ ### What M3 Expressive requires
25
+ M3 derives color from **HCT tonal palettes** (tones 0–100) per key color, then maps them to a **role set**, light + dark, that MUI's flat palette cannot represent:
26
+
27
+ | Role family | M3 roles (each needs a value) | We have |
28
+ | --- | --- | --- |
29
+ | Primary | `primary`, `on-primary`, `primary-container`, `on-primary-container` | only `primary.main` + `contrastText` |
30
+ | Secondary | `secondary`, `on-secondary`, `secondary-container`, `on-secondary-container` | only `secondary.main` |
31
+ | **Tertiary** | `tertiary`, `on-tertiary`, `tertiary-container`, `on-tertiary-container` | **missing entirely** — no tertiary role exists |
32
+ | Error | `error`, `on-error`, `error-container`, `on-error-container` | only `error.main` |
33
+ | Surfaces | `surface`, `surface-dim`, `surface-bright`, and **5 container tiers**: `surface-container-lowest / low / (base) / high / highest` | only `background.default` + `background.paper` (2 flat surfaces) |
34
+ | On-surface | `on-surface`, `on-surface-variant`, `outline`, `outline-variant` | `text.primary/secondary` + one flat `divider` |
35
+ | Fixed / inverse | `*-fixed`, `*-fixed-dim`, `on-*-fixed`, `inverse-surface`, `inverse-on-surface`, `inverse-primary`, `scrim`, `shadow` | none |
36
+
37
+ **Concrete missing primitives:**
38
+ - **No tonal ramp.** There is no tones-0..100 source. You cannot generate `primary-container` (~tone 90 light / tone 30 dark) or hover/press state-layer tones from a single `#1E6FE0`. The opacity hacks (`+14`, `+24`) are not the same thing and break in dark mode.
39
+ - **No surface-container tiers.** M3 Expressive leans *heavily* on tonal surface containers to separate cards/sheets/toolbars from the background without borders. We instead draw 1px `#E5E9F0` borders on every Card (`index.ts` MuiCard override) — a Material-2 / Bootstrap idiom that M3 explicitly moves away from.
40
+ - **No `on-*` role pairing.** Contrast is ad-hoc per component (e.g. MetricCard hardcodes `text.secondary`, AppButton hardcodes `primary.main` on a primary tint). M3 guarantees legible pairs by construction.
41
+ - **No tertiary.** Expressive UIs use tertiary for accents/highlights (badges, selected chips, hero numbers). We substitute a one-off `accent #F59E0B` and an `eveningTint #4F46E5` — bespoke tokens that are not part of any system and won't recolor with the theme.
42
+ - **No dark theme.** `palette.mode` is hardcoded `'light'`; the file comment says "dark-swap ready" but there is no second tonal set, and the opacity-mix tonal fakes will invert wrong. "Ready" is aspirational, not real.
43
+ - **Status colors are a separate flat map** (`customTokens.status`) reusing the same success/warning/error hexes — not tied to container roles, so status chips can't get the soft tonal-container look M3 uses.
44
+
45
+ **Why it isn't worth keeping:** the color foundation is one layer deep where M3 needs four (tone → role → container → on-role). You cannot "skin" your way from flat roles to tonal roles; the generator (HCT → roles) must be added and `theme.palette` re-modeled. Keeping the current palette means re-faking tonality per component forever (as AppButton already does).
46
+
47
+ ---
48
+
49
+ ## 2. Shape / Radius — too small, too uniform, not expressive
50
+
51
+ ### What we have
52
+ `index.ts`: `radius = { sm: 8, md: 12, lg: 16, pill: 999 }`, `shape.borderRadius: 12`. Cards 16, buttons/inputs 12, chips pill. Three values, applied conservatively.
53
+
54
+ ### M3 Expressive
55
+ M3 defines a **shape scale** — `none, extra-small (4), small (8), medium (12), large (16), extra-large (28), full`, plus **larger expressive tiers** (extra-large-increased, extra-extra-large) and, new in Expressive, **non-circular / cookie / morphing shapes** and shape *as a state* (components shape-shift on press/select). Expressive deliberately uses **big radii** (e.g. 28+ on cards, sheets, FABs, dialogs) and varied shapes per component size.
56
+
57
+ **Gaps:**
58
+ - Top radius is **16px** — M3's *baseline* large is 16 and Expressive routinely goes to **28**. Our UI will read as "rounded Material 2," not expressive.
59
+ - **No `extra-large` (28) token**, no `xxl`. Bottom sheet uses `lg` (16) for its top corners — M3 sheets want 28.
60
+ - **Uniform shapes only.** No concept of shape *morphing* between states (M3 Expressive buttons/FABs change shape on press; selected chips/toggles change corner). Our shapes are static.
61
+ - **Radius is a flat `number` per size**, not mapped to component roles, so there's no way to express "FAB = large", "dialog = extra-large", "chip = small" as M3 prescribes.
62
+ - The pill token (`999`) is fine and survives, but it's the only "expressive" feel present.
63
+
64
+ **Why it isn't worth keeping:** the radius set is too small and too uniform to look like M3 Expressive at all. It needs a full shape *scale* up to 28+ and, ideally, morph tokens — a structural addition, not a value bump.
65
+
66
+ ---
67
+
68
+ ## 3. Typography — Inter + a flat 12-role MUI scale, not the M3 type scale
69
+
70
+ ### What we have
71
+ `index.ts` typography: family `Inter` fallback `Segoe UI/Roboto`. Roles are **MUI's legacy variants** (`h1..h6, subtitle1/2, body1/2, caption, overline, button`) with px sizes (h1 28, h2 24, h3 20, body1 16…), weights 400/500/600/700, `htmlFontSize/fontSize: 16`.
72
+
73
+ ### M3 type scale
74
+ M3 uses a **15-role scale across 5 families × 3 sizes**: `display-{large,medium,small}`, `headline-{l,m,s}`, `title-{l,m,s}`, `body-{l,m,s}`, `label-{l,m,s}`, each with defined size/line-height/weight/tracking and a **Roboto / Roboto Flex** default. M3 Expressive adds **emphasized type styles** (heavier weights, tighter tracking, optical sizing via Roboto Flex variable font) and uses *display* sizes (up to 57px) for expressive hero moments.
75
+
76
+ **Gaps:**
77
+ - **Wrong role taxonomy.** MUI `h1..h6/subtitle/body/caption` ≠ M3 `display/headline/title/body/label`. There is no `display-*` at all — so no hero typographic moments, which Expressive is built around. The biggest text is `h1` at **28px**; M3 `display-large` is **57px**.
78
+ - **No `label-*` family.** M3 labels (for buttons, chips, tabs) are a distinct family with specific tracking; here button text is a one-off `button` variant (15px) and chips/tabs borrow `caption`.
79
+ - **No emphasized/variable-weight variants.** Expressive relies on weight contrast (e.g. emphasized 500→700 within the same role) and optical sizing (Roboto Flex). Inter is static-weight here; no variable-font axes wired up.
80
+ - **Tracking/letter-spacing** is only set on `overline`. M3 specifies tracking per role; ours is mostly default.
81
+ - **Font is Inter**, not Roboto/Roboto Flex. Acceptable to keep brand-wise, but it must then be re-mapped to the M3 role grid with M3 metrics — currently it follows MUI metrics, which differ.
82
+
83
+ **Why it isn't worth keeping:** the type system is the wrong *shape* (12 MUI roles vs 15 M3 roles, no display, no label family, no emphasized axis). Components reference `variant="h2"`/`"caption"` everywhere (see MetricCard), so adopting the M3 scale means re-mapping every `Typography variant` call — the current scale gives no migration head start.
84
+
85
+ ---
86
+
87
+ ## 4. Spacing / Density / Breakpoints / Responsiveness
88
+
89
+ ### What we have
90
+ - `spacing: 8` (8px base) — this *does* match M3's 4/8 grid, the one token that's already compatible.
91
+ - Touch targets: AppButton `SIZE_HEIGHT` 52/48/40, MuiButton `minHeight: 44`, IconButton 44×44 in BottomSheet. Good for mobile a11y.
92
+ - Padding conventions ad-hoc: cards `p: 2` (16px) in MetricCard, sheets `px: 2, py: 2`. No documented density token set.
93
+ - **Breakpoints:** essentially unused. Grep found `breakpoints/useMediaQuery/sm:/md:` in only ~8 files, mostly type defs and a couple of pages (LoginPage, RoutePage). The app is **single-column mobile-only**; there's no responsive/adaptive layout strategy.
94
+ - Safe-area insets handled via CSS vars in `MuiCssBaseline` (good PWA hygiene).
95
+
96
+ ### M3 expectations
97
+ - M3 spacing is 4dp-grid based — **we're compatible here**, the main genuine win.
98
+ - M3 defines **density levels** (0 to −4) for compact surfaces; we have none (just per-size button heights).
99
+ - M3 / Material adaptive defines **window-size classes** (compact / medium / expanded / large / extra-large) driving navigation patterns (bottom bar → nav rail → nav drawer) and pane layouts. We have **zero** of this — mobile-only, no rail/drawer adaptation. For an ERP that owners may use on tablet/desktop, this is a real gap, though out of scope for a pure re-skin.
100
+
101
+ **Why it's a gap:** spacing base is fine, but there's no density model and no adaptive/responsive layer. M3 Expressive's new components (Toolbars, nav rail variants, FAB menus) assume window-size-class awareness the app doesn't have.
102
+
103
+ ---
104
+
105
+ ## 5. Motion — fixed durations/easings, not expressive springs
106
+
107
+ ### What we have
108
+ `motion.ts`:
109
+ - Durations: `instant 0, fast 120, base 200, page 280, sheet 320, countup 700, success 600`.
110
+ - Easings: three cubic-beziers (`fast` easeOut, `base`, `page`) as both CSS strings and Framer arrays.
111
+ - **One** spring: `sheetSpring = { damping: 30, stiffness: 320 }`, and even that is **not used** — BottomSheet's own comment says it keeps MUI Drawer's built-in `Slide` (fixed 320ms tween) instead of the spring.
112
+ - Usage is sparse: grep shows springs/`whileTap`/`whileHover` in only 3 files. AppButton uses `whileTap scale .97` + a shake (a tween, not a spring). Most "motion" is duration-tween fades.
113
+
114
+ ### M3 Expressive motion
115
+ This is one of the headline features of Expressive. M3 motion is now **spring-based**, with two token families:
116
+ - **Spatial** springs (position/size/shape changes) — *fast / default / slow*, each *spatial* (with bounce) — e.g. `expressive-spatial-default` ≈ damping 0.8 / stiffness ~380.
117
+ - **Effects** springs (opacity/color, non-spatial, no bounce).
118
+ - Plus the **emphasized / standard easing** legacy set for CSS where springs don't apply.
119
+ Expressive deliberately adds **bounce/overshoot** and **shape-shift** transitions (buttons morph, FAB menus spring open, containers stretch). The new **waveform / wavy progress indicators** are an explicit Expressive motion component.
120
+
121
+ **Gaps:**
122
+ - **Spring-as-primary is missing.** We have one unused spring and otherwise fixed-duration tweens. M3 Expressive wants spring tokens to be the default for spatial motion. No spatial/effects spring token families exist.
123
+ - **No bounce/overshoot vocabulary.** Our easings are all settle-to-rest (easeOut-ish); nothing overshoots. Expressive's signature "springy, lively" feel is absent by construction.
124
+ - **No shape-shift / morph motion.** AppButton's success state swaps icons but doesn't morph shape; M3 buttons/FABs shape-shift on press. No tokens for it.
125
+ - **Progress indicators not Expressive.** Default MUI spinner (`Spinner`) — no waveform/wavy linear progress, which is a flagship Expressive component.
126
+ - **The one good spring is dead code** — the actual sheet uses a 320ms tween, so even the spring that exists isn't expressive in practice.
127
+ - Durations themselves are reasonable and reduced-motion handling is genuinely good (every animation short-circuits) — that discipline should be **kept**, but the easing/spring layer must be replaced.
128
+
129
+ **Why it isn't worth keeping:** the motion system is duration-tween-first with a single unused spring. M3 Expressive is spring-first with bounce and shape-morph. The current tokens give the *opposite* feel and offer almost nothing to build the expressive motion on.
130
+
131
+ ---
132
+
133
+ ## Cross-cutting: architecture observations
134
+
135
+ **The good (keep / reuse the mechanism, not the values):**
136
+ - Strong **token discipline**: components read from `theme.custom.*` / `theme.palette.*`; only **5 files** contain raw hex/rgba and most are the theme + 2 justified overlays (Modal/BottomSheet scrims, StatusToggle). This means a token-model swap can be **centralized** — a real asset.
137
+ - `theme.d.ts` module augmentation is the correct extension point; the M3 roles can be added there.
138
+ - 8px spacing grid and reduced-motion handling are M3-compatible.
139
+
140
+ **The bad (structural, not cosmetic):**
141
+ - `theme.custom` is a **parallel, non-standard design system** invented on top of MUI (`border`, `surface`, `eveningTint`, `status`, flat `radius`, `motion`). None of it maps to M3 role names, so M3 adoption means deprecating most of `custom` and re-augmenting with M3 roles/shape/motion scales.
142
+ - Components already **fake M3 features the tokens can't express** (AppButton tonal-container via opacity strings, Cards via 1px borders instead of surface containers). Every such hack is rework.
143
+ - **No dark mode, no tertiary, no surface tiers, no display/label type, no springs** — these aren't gaps you patch; they're the missing 60% of the M3 token model.
144
+
145
+ ---
146
+
147
+ ## Concrete gap checklist (for the re-skin plan)
148
+
149
+ 1. Replace flat palette with **HCT-derived tonal roles**: add `*-container`, `on-*`, `on-*-container` for primary/secondary/**tertiary**/error; add **5 surface-container tiers** + `surface-dim/bright`, `outline`/`outline-variant`, inverse/fixed/scrim. Add a real **dark** tonal set.
150
+ 2. Kill the opacity-string tonal hacks and the 1px-border card idiom; move to **tonal surface containers + state layers**.
151
+ 3. Expand **shape scale to 4/8/12/16/28+** (add `xl`/`xxl`), map radii to component roles, add **shape-morph** state tokens.
152
+ 4. Replace MUI legacy type variants with the **M3 15-role scale** (display/headline/title/body/label × 3), add **emphasized** weights; wire **Roboto Flex / variable** axes (or re-map Inter to M3 metrics).
153
+ 5. Keep the 8px grid; add **density** tokens and a **window-size-class** adaptive layer (bottom bar → rail → drawer) for tablet/desktop.
154
+ 6. Replace duration-tween motion with **spring token families** (spatial fast/default/slow with bounce, effects springs); add **shape-shift/morph** motion and **waveform progress**. Keep the reduced-motion discipline.
155
+ 7. Adopt new **Expressive components** absent today: Toolbars, Split buttons, Button groups, FAB menus, updated/wavy progress indicators.
frontend/PWA/design/m3/feasibility.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # M3 Expressive — Feasibility & Approach (U4)
2
+
3
+ Scope: how to get **Material 3 Expressive** into THIS app. Analysis only. No code changed, no builds run.
4
+
5
+ ## TL;DR Recommendation
6
+
7
+ **Approach A — Re-theme MUI v6 to M3 + build ~6 custom "expressive" components on MUI primitives + Framer Motion.**
8
+ Do NOT adopt `@material/web` (Approach B). It is in **maintenance mode** ("pending new maintainers", last release v2.4.1 on 2025-10-27), it has **no Card, no Toolbar, no Split button, no Button group, no FAB menu, and no M3 Expressive components at all** — exactly the pieces the owner is asking for. Adopting it would be a high-risk rewrite that still can't deliver the screenshot. Stay on MUI, which the entire app (102 of 114 `.tsx` files) already depends on, and which the data/query layer is fully decoupled from.
9
+
10
+ Optionally bump MUI v6 → v7 first (mechanical codemod, same API surface) so we're on the actively-maintained line, but it is not required for M3.
11
+
12
+ ---
13
+
14
+ ## What "M3 Expressive" actually requires (the bar to clear)
15
+
16
+ From m3.material.io + material-web docs, the visual/behavioral targets are:
17
+
18
+ 1. **Tonal color system** — a seed color expanded into tonal palettes (primary/secondary/tertiary/neutral) producing the pastel **surface containers** (surface, surface-container-low/high/highest) and `on-*` roles. This is the single biggest visual gap today.
19
+ 2. **Large expressive shapes** — bigger corner radii, pill-shaped buttons, shape morph on state change.
20
+ 3. **Expressive typography** — the M3 type scale (display/headline/title/body/label) with larger, rounder display sizes.
21
+ 4. **State layers** — hover/focus/press are translucent `on-color` overlays at 8%/10%/10%, not box-shadows.
22
+ 5. **Springy shape-shift motion** — spring-based, not the cubic-bezier easings used now.
23
+ 6. **New expressive components** — Toolbars (docked/floating), Split buttons, Button groups (with connected/squish behavior), FAB menus, updated progress (incl. **waveform** linear progress).
24
+
25
+ ---
26
+
27
+ ## Brutally honest read of the CURRENT theme (owner: "not worth it" — agreed)
28
+
29
+ Looked at `src/theme/{palette,index,motion}.ts`. It is a competent **flat SaaS** theme. It is **not** Material and is **architecturally hostile** to M3:
30
+
31
+ - **`palette.ts` is hand-picked hex, explicitly `as const` and "LOCKED".** Comment literally says "never hardcode a hex (dark-swap ready)" — but there is **no tonal palette and no dark map**, just one light set. M3 needs ~13 tones per key color generated from a seed. None of that exists; it would be thrown away.
32
+ - **No surface containers.** There is one `surface: '#FFFFFF'` and `background.default: '#F6F8FB'`. M3's whole "tonal/pastel container" look is *absent by construction*. `CustomerCard` etc. use `bgcolor: 'background.default'` and a `1px` border — the opposite of M3 tonal surfaces.
33
+ - **Elevation is drop-shadows (`e1/e2/e3`), and only 3 distinct values smeared across all 25 slots.** M3 Expressive leans on **tonal elevation + state layers**, not soft shadows. The `Fab` hardcodes `boxShadow: t.shadows[2]`.
34
+ - **Typography is a custom px scale**, not the M3 type scale. Fine, but nothing reusable for M3; the names (`h1..body2`) don't map cleanly to display/headline/title/label.
35
+ - **Motion is cubic-bezier easings + one spring** (`sheetSpring`). M3 Expressive is spring-forward. The bones (Framer Motion already wired, `useReducedMotion` respected in `AppButton`) are good and reusable.
36
+ - **Custom-token coupling is real but contained.** `theme.custom.*` / `palette.accent` is referenced in **39 files / 72 sites**. A re-theme must keep these token *names* alive (mapped to M3 roles) to avoid touching all 39 — very doable.
37
+
38
+ Verdict: the *visual* theme is a near-total rewrite for M3. The *plumbing* (MUI `createTheme`, module augmentation in `theme.d.ts`, Framer Motion, reduced-motion hook, `AppButton`'s state machine) is good and worth keeping. So "re-theme," not "re-platform."
39
+
40
+ ---
41
+
42
+ ## Approach A — Re-theme MUI v6/v7 to M3 (+ custom expressive components)
43
+
44
+ **How:**
45
+ - Add `@material/material-color-utilities`. Generate tonal palettes from a seed at build time, emit M3 role tokens (primary, on-primary, surface-container-*, etc.) as a typed object + CSS variables. Feed them into MUI `palette` and a new `theme.custom.m3` namespace (extend existing `theme.d.ts`).
46
+ - Override `shape` (radius scale), `typography` (M3 type scale), and `components.*` for Button/Card/Chip/TextField/Paper to render tonal surfaces + state layers (state layers via `::before` overlay in `styleOverrides`).
47
+ - Build ~6 **custom** components MUI doesn't ship, on MUI primitives + Framer springs: **Split button**, **Button group** (connected), **Toolbar** (docked/floating), **expressive Card** variants, **FAB menu**, **waveform/expressive progress**. These are presentational; they don't touch data.
48
+ - Map existing `theme.custom.*` and `palette.accent` to M3 roles so the 39 consuming files keep working unchanged.
49
+
50
+ | Dimension | Assessment |
51
+ |---|---|
52
+ | **Effort** | Medium-high. ~1.5–3 wks: token generator + theme override (~40%), 6 custom components (~45%), audit of 39 token-consumers (~15%). |
53
+ | **Risk** | **Low-medium.** Same framework, same render model, same React 18/Vite/SSR-free PWA. No interop. Incremental & revertible per component. |
54
+ | **Bundle / free-tier** | **Negligible delta.** `material-color-utilities` is small and can run at build time (zero runtime cost). MUI already shipped. Custom components reuse existing deps. |
55
+ | **Match to screenshot** | **High (best achievable).** Tonal surfaces, expressive shapes/type, state layers, springs, and the expressive components are all reachable — though *hand-built*, so fidelity = effort we invest, not free. |
56
+ | **Migration cost from current code** | **Low-medium.** Centralized in theme; per-component visual nudges. 39 token files insulated via token aliasing. No data/query changes. |
57
+ | **Reliability** | **High.** Backend/TanStack Query untouched; this is a skin. |
58
+
59
+ ---
60
+
61
+ ## Approach B — Adopt `@material/web` (Lit web components) in React
62
+
63
+ | Dimension | Assessment |
64
+ |---|---|
65
+ | **Maintenance** | **Disqualifying.** README: *"MWC is in maintenance mode pending new maintainers."* Roadmap: *"There is no current work planned for new features or components."* Last release **v2.4.1, 2025-10-27**. Betting the app's UI on an abandoned lib is a reliability anti-pattern. |
66
+ | **Coverage of what's asked** | **Fails the brief.** No **Card**, no **Toolbar**, no **Split button**, no **Button group / segmented button** (segmented is on the *unbuilt* roadmap), no **FAB menu**. **M3 Expressive is not mentioned anywhere** in the repo. We'd be reimplementing the requested components ourselves anyway — on top of an unmaintained base. |
67
+ | **Interop friction** | High. Lit custom elements in React 18 need wrappers for props/events (React 18 doesn't set custom-element properties or bind non-DOM events cleanly; `@lit/react` or manual refs). Forms (`react-hook-form` used here), `sx`, MUI theming, and Framer Motion don't apply to shadow-DOM web components. Two styling systems coexisting. |
68
+ | **Effort** | **High** and largely wasted — interop layer + still hand-building the expressive components + dual theming. |
69
+ | **Bundle / free-tier** | Adds Lit runtime + per-component JS **on top of** MUI (can't cleanly drop MUI given 102 files). Net **larger** bundle. |
70
+ | **Match to screenshot** | **Low for the expressive bits** (don't exist); only baseline M3 for the primitives it does ship. |
71
+ | **Migration cost** | **Very high.** Re-author 100+ files away from MUI to coexist with web components; rewire forms and theming. |
72
+ | **PWA/SSR** | App is client-rendered (Vite, no SSR), so SSR isn't a blocker — but shadow-DOM styling/theme-variable wiring and offline asset behavior add complexity for no payoff. |
73
+
74
+ **Conclusion:** B is the wrong tool — abandoned, missing the exact components requested, heavy interop, bigger bundle, lower fidelity.
75
+
76
+ ---
77
+
78
+ ## Approach C — Hybrid (MUI + a few `@material/web` elements)
79
+
80
+ All the downsides of B (dual styling, interop, maintenance risk, bundle) for the *subset* of components MWC ships — none of which are the expressive ones we actually lack. The components we'd want from MWC (Card, Toolbar, Split button, Button group, FAB menu) **don't exist there**, so the hybrid buys nothing. **Reject.** The only "hybrid" worth considering is *within* Approach A: MUI for primitives + a thin set of bespoke expressive components — which is just Approach A.
81
+
82
+ ---
83
+
84
+ ## Recommendation (final)
85
+
86
+ **Go with Approach A.** Re-theme MUI to M3 Expressive via a seed→tonal token generator (`@material/material-color-utilities`), M3 shape/type/component overrides with state layers, Framer-Motion springs, and ~6 hand-built expressive components (Split button, Button group, Toolbar, expressive Card, FAB menu, waveform progress). It is the only path that (a) reaches the screenshot, (b) keeps the backend/data layer and TanStack Query untouched, (c) reuses the 102-file MUI investment and insulates the 39 token-consumers, and (d) adds ~zero bundle/free-tier cost.
87
+
88
+ **Sequencing:** tokens/theme first (biggest visual win, lowest risk) → swap shadows for tonal surfaces + state layers → migrate `AppButton`/`Fab`/`BottomNav`/cards → add expressive components last. Each step is independently shippable and revertible. Optionally land a MUI v6→v7 codemod up front to stay on the maintained line; not required for M3.
89
+
90
+ **Do not** adopt `@material/web` (B) or a hybrid (C): maintenance-mode, missing exactly the expressive components requested, interop tax, and a larger bundle for lower fidelity.
frontend/PWA/design/m3/m3-spec.md ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Material 3 Expressive — Design System Spec (U1)
2
+
3
+ > Source extraction for re-skinning the MilkWay ERP PWA from its current ad‑hoc theme to **Material 3 Expressive (M3E)**.
4
+ > Authoritative token values below are pulled from the `material-web` repo (`tokens/versions/v0_192/*.scss`, the shipping M3 token set) and the M3 Expressive docs/blog. Where a doc page was JS‑rendered and unreadable, values were confirmed via the token SCSS or corroborating engineering write-ups (cited inline).
5
+
6
+ ---
7
+
8
+ ## 0. What "Expressive" actually changes (TL;DR)
9
+
10
+ M3 Expressive (announced May 2025, the largest M3 update) is **not a new token namespace** — it reuses the same `md.sys.*` roles. What changes is *how* they're used:
11
+
12
+ 1. **Motion becomes spring physics**, not bezier+duration. New `motionScheme` with spatial vs effects springs; bounce/overshoot is intentional.
13
+ 2. **Shape becomes a first-class, animatable property.** A 35‑shape morph library; components **shape‑shift on press/select** (e.g. button corners round → squarish, FAB → menu).
14
+ 3. **Bigger, rounder, higher-contrast containers.** Pastel tonal surface containers (lowest→highest) used heavily; larger corner radii by default; "full" rounding instead of 50%.
15
+ 4. **New components**: button groups, split button, FAB menu, docked/floating toolbars, loading indicator, updated (wavy) progress.
16
+ 5. **Emphasized type** — heavier weights on key labels; type is treated as an expressive brand asset.
17
+
18
+ The current MilkWay theme satisfies *none* of these (see §8). This is a full re‑skin, not a palette tweak.
19
+
20
+ ---
21
+
22
+ ## 1. Color roles & the tonal system
23
+
24
+ ### 1.1 Seed → tonal palettes → roles
25
+ M3 color is generated, not hand-picked:
26
+
27
+ 1. Pick **one seed color** (brand). HCT (Hue/Chroma/Tone) algorithm derives **5 source palettes**: `primary`, `secondary`, `tertiary`, `neutral`, `neutral-variant` (plus a fixed `error` palette).
28
+ 2. Each palette is sampled at **13 tone stops**: `0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 99, 100` (tone = perceptual L*, 0=black, 100=white). The neutral palette adds extra stops `4, 6, 12, 17, 22, 24, 87, 92, 94, 96, 98` — **these extra neutral tones are exactly what the surface-container ladder needs.**
29
+ 3. **Roles map to tones.** Light theme (the relevant one — app is light):
30
+
31
+ | Role | Light tone | Dark tone |
32
+ |---|---|---|
33
+ | primary | P40 | P80 |
34
+ | on-primary | P100 | P20 |
35
+ | primary-container | P90 | P30 |
36
+ | on-primary-container | P30 (P10 older) | P90 |
37
+ | secondary / tertiary | same pattern on S/T palettes | |
38
+ | error | E40 | E80 |
39
+ | error-container | E90 | E30 |
40
+ | outline | NV50 | NV60 |
41
+ | outline-variant | NV80 | NV30 |
42
+ | on-surface | N10 | N90 |
43
+ | on-surface-variant | NV30 | NV80 |
44
+
45
+ `secondary` and `tertiary` follow the identical 40/100/90/30 mapping on their own palettes. **Tertiary is the "expressive accent"** — M3E leans on it for delight (FAB menu items, highlights).
46
+
47
+ ### 1.2 The surface container ladder (the heart of the "pastel" feel)
48
+ Old M3 used one `surface` + `surface-variant`. Current M3/M3E uses a **tonal elevation ladder** built from neutral tones — flat color instead of shadows:
49
+
50
+ | Role | Light source tone | Use |
51
+ |---|---|---|
52
+ | surface-dim | N87 | dimmest bg |
53
+ | surface | N98 | default page bg |
54
+ | surface-bright | N98 | brightest bg |
55
+ | surface-container-lowest | N100 (white) | lowest cards |
56
+ | surface-container-low | N96 | |
57
+ | surface-container | N94 | default card/sheet |
58
+ | surface-container-high | N92 | raised (FAB rests here) |
59
+ | surface-container-highest | N90 | highest, e.g. menus |
60
+
61
+ These are **subtle, low-chroma pastels** (neutral palette is tinted by the seed hue), which is the M3E "tonal/pastel" look. Components stack by *swapping container role*, not by adding box-shadow.
62
+
63
+ ### 1.3 Full role inventory (the `md.sys.color.*` set)
64
+ `primary, on-primary, primary-container, on-primary-container, primary-fixed, primary-fixed-dim, on-primary-fixed, on-primary-fixed-variant` (× secondary, × tertiary), `error, on-error, error-container, on-error-container`, `surface, on-surface, surface-variant, on-surface-variant, surface-dim, surface-bright, surface-container-{lowest,low,,high,highest}, surface-tint`, `outline, outline-variant`, `inverse-surface, inverse-on-surface, inverse-primary`, `background, on-background, scrim, shadow`.
65
+
66
+ > **`*-fixed` roles**: stay the same tone across light/dark (for cross-theme persistent surfaces). `surface-tint` = primary, used for tonal-elevation overlays.
67
+
68
+ ### 1.4 Expressive color guidance
69
+ - Use **container roles** (`primary-container` on `on-primary-container`) far more than raw `primary` — softer, pastel, higher comfort.
70
+ - Use **tertiary** for accents/delight; don't make everything primary.
71
+ - Dynamic color is opt‑in; brand seed is fine. Warning from docs: dynamic color *"can easily create contrast issues"* if applied without intent. Always verify on-/container pairings hit contrast (≥3:1 large, 4.5:1 body).
72
+
73
+ ---
74
+
75
+ ## 2. Shape scale
76
+
77
+ ### 2.1 Corner radius tokens (`md.sys.shape.corner.*`, v0_192)
78
+ | Token | Value |
79
+ |---|---|
80
+ | corner-none | **0 dp** |
81
+ | corner-extra-small | **4 dp** |
82
+ | corner-small | **8 dp** |
83
+ | corner-medium | **12 dp** |
84
+ | corner-large | **16 dp** |
85
+ | corner-extra-large | **28 dp** |
86
+ | corner-full | **9999 px** (pill / stadium — note: "full", not 50%) |
87
+
88
+ Directional variants exist for sheets/rails: `corner-extra-large-top (28 28 0 0)`, `corner-large-start (16 0 0 16)`, `corner-large-end`, `corner-large-top`, `corner-extra-small-top`. (M3E adds intermediate stops in newer token sets — extra-small=4, small=8, medium=12, large=16, **extra-large=28**, plus **extra-extra-large** tiers ~48dp used by large FAB/expressive heroes.)
89
+
90
+ ### 2.2 Expressive shape behavior
91
+ - **35-shape morph library** (Figma + Compose `MaterialShapes`): squircle, cookie, clover, pill, sunny, scallop, pixel, etc. Shapes carry brand/visual rhythm.
92
+ - **Shape is animated.** Components **shape‑shift** between two shapes on interaction:
93
+ - Buttons / icon buttons: rounded → **squarer / smaller-radius on press** (corner value animates via spatial spring).
94
+ - Toggle/selected state changes the shape (e.g. round ↔ squircle).
95
+ - FAB → expands/morphs into FAB **menu** container.
96
+ - Split-button trailing menu button **spins and changes shape** when its menu opens.
97
+ - Default radii are **larger and more generous** than M2/legacy. Cards/containers commonly use `large (16)`; big surfaces `extra-large (28)`; pills for chips/buttons (`full`).
98
+
99
+ ---
100
+
101
+ ## 3. Typography scale
102
+
103
+ ### 3.1 Type scale (`md.sys.typescale.*`, v0_192). rem @ 16px base; px in parens.
104
+ | Role | Size | Line height | Weight | Tracking |
105
+ |---|---|---|---|---|
106
+ | display-large | 3.5625rem (57) | 4rem (64) | 400 | -0.25px |
107
+ | display-medium | 2.8125rem (45) | 3.25rem (52) | 400 | 0 |
108
+ | display-small | 2.25rem (36) | 2.75rem (44) | 400 | 0 |
109
+ | headline-large | 2rem (32) | 2.5rem (40) | 400 | 0 |
110
+ | headline-medium | 1.75rem (28) | 2.25rem (36) | 400 | 0 |
111
+ | headline-small | 1.5rem (24) | 2rem (32) | 400 | 0 |
112
+ | title-large | 1.375rem (22) | 1.75rem (28) | 400 | 0 |
113
+ | title-medium | 1rem (16) | 1.5rem (24) | 500 | 0.15px |
114
+ | title-small | 0.875rem (14) | 1.25rem (20) | 500 | 0.1px |
115
+ | body-large | 1rem (16) | 1.5rem (24) | 400 | 0.5px |
116
+ | body-medium | 0.875rem (14) | 1.25rem (20) | 400 | 0.25px |
117
+ | body-small | 0.75rem (12) | 1rem (16) | 400 | 0.4px |
118
+ | label-large | 0.875rem (14) | 1.25rem (20) | **500** | 0.1px |
119
+ | label-medium | 0.75rem (12) | 1rem (16) | 500 | 0.5px |
120
+ | label-small | 0.6875rem (11) | 1rem (16) | 500 | 0.5px |
121
+
122
+ - Default reference typeface: **Roboto** (Roboto Flex for variable-weight expressive use).
123
+ - **`label-large` is the button/label font** (`--md-sys-typescale-label-large-font`) — all button components use it.
124
+
125
+ ### 3.2 Expressive typography
126
+ - Each role has an **"emphasized" companion** (heavier weight / tighter tracking) for hero text and key labels — type is used as a brand/emotion tool, not just hierarchy.
127
+ - Use **Roboto Flex** variable axes (weight, optical size) to push expressive headings without new font files.
128
+ - Practical: bump titles/labels to **600–700** on emphasis moments; keep body at 400.
129
+
130
+ ---
131
+
132
+ ## 4. Motion — spring physics (the biggest shift)
133
+
134
+ ### 4.1 Legacy easing+duration tokens (still exist, `md.sys.motion.*` v0_192)
135
+ **Durations (ms):** short1 50, short2 100, short3 150, short4 200, medium1 250, medium2 300, medium3 350, medium4 400, long1 450, long2 500, long3 550, long4 600, extra-long1 700 … extra-long4 1000.
136
+
137
+ **Easing (cubic-bezier):**
138
+ | Token | Curve |
139
+ |---|---|
140
+ | linear | (0,0,1,1) |
141
+ | standard | (0.2, 0, 0, 1) |
142
+ | standard-accelerate | (0.3, 0, 1, 1) |
143
+ | standard-decelerate | (0, 0, 0, 1) |
144
+ | **emphasized** | (0.2, 0, 0, 1) (3-keyframe in practice; the "signature" M3 curve) |
145
+ | emphasized-accelerate | (0.3, 0, 0.8, 0.15) |
146
+ | emphasized-decelerate | (0.05, 0.7, 0.1, 1) |
147
+ | legacy (M2) | (0.4, 0, 0.2, 1) |
148
+
149
+ > The famous "emphasized" feel is a **3‑point** curve: accelerate then decelerate over ~500ms (long2). Use it for hero/page transitions if not using springs.
150
+
151
+ ### 4.2 The Expressive Motion Scheme (springs)
152
+ M3E replaces curve+duration with **physics springs**. `MaterialTheme.motionScheme` exposes **6 spring specs** = 2 categories × 3 speeds:
153
+
154
+ - **Spatial springs** — animate *position, size, rotation, corner radius* (overshoot/**bounce allowed**).
155
+ - **Effects springs** — animate *color, opacity, elevation* (**no overshoot**, damping ≈ 1).
156
+
157
+ Two schemes: **Expressive** (opinionated, bouncier — default for hero moments/key interactions) and **Standard** (functional, minimal bounce — utilitarian flows).
158
+
159
+ **Verified token values** (`dampingRatio` / `stiffness`):
160
+ | Spec | Damping | Stiffness | Notes |
161
+ |---|---|---|---|
162
+ | Expressive **spatial-fast** | 0.6 | 800 | quick, springy (chips, small toggles) |
163
+ | Expressive **spatial-default** | 0.8 | 380 | standard moves (most spatial) |
164
+ | Expressive **spatial-slow** | ~0.8 | ~200 | large/hero transitions |
165
+ | Expressive **effects-fast/default/slow** | ~1.0 (no bounce) | ~3800 / ~1600 / ~800 | color/opacity, never overshoots |
166
+ | Standard scheme | ~0.9–1.0 | similar stiffness, **lower bounce** | utilitarian |
167
+
168
+ > Rule of thumb to port to web (Framer Motion `type:'spring'`): spatial uses `damping` 0.6–0.8 → bouncy; effects uses `damping` ≈1 → crisp. Stiffness ~380 default, ~800 fast. Effects (opacity/color) must **not** overshoot.
169
+
170
+ ### 4.3 Shape-shift / bump interactions
171
+ - **Press bump**: on press, component scales down slightly + **corner radius morphs** (rounder→squarer) via spatial-fast spring; releases with overshoot ("bounce into place").
172
+ - **Selection shape-shift**: selected toggle/segmented items change shape, not just color.
173
+ - **Container morph**: FAB → FAB menu, button → loading, split-button menu spin — all spatial-spring shape transitions.
174
+ - Always honor **`prefers-reduced-motion`**: collapse springs to instant/short cross-fade (the current theme already has this instinct in `motion.ts`).
175
+
176
+ ---
177
+
178
+ ## 5. State layers, elevation, density
179
+
180
+ ### 5.1 State layers (`md.sys.state.*`)
181
+ Interaction feedback is a **translucent overlay of the content color** (`on-surface` / `primary` etc.) over the component, not a different background:
182
+ | State | Opacity |
183
+ |---|---|
184
+ | hover | **8%** |
185
+ | focus | **10–12%** |
186
+ | pressed | **10%** (ripple originates here) |
187
+ | dragged | 16% |
188
+
189
+ Disabled: content @ **38%** opacity, container @ **12%**. (MUI's default `action.*` opacities differ — must be retuned to these.)
190
+
191
+ ### 5.2 Elevation
192
+ - **Tonal elevation** is primary: raise = move up the surface-container ladder (§1.2), not add shadow. `surface-tint` (=primary) can overlay for tinted elevation.
193
+ - Shadow levels `md.sys.elevation.level0..5`: 0, 1, 3, 6, 8, 12 dp. M3E uses **shadow sparingly** — mostly FABs, menus, dialogs. Most cards = flat tonal container + (optional) `outline-variant` 1px.
194
+ - FAB container rests at `surface-container-high`; its lowered variant at `surface-container-low`.
195
+
196
+ ### 5.3 Density / touch targets
197
+ - Minimum touch target **48×48 dp** (current theme uses 44 — slightly under M3).
198
+ - M3E components ship in **size ramps**: buttons/split-buttons/FAB in **XS, S, M, L, XL**; bigger defaults than legacy.
199
+
200
+ ---
201
+
202
+ ## 6. New & updated Expressive components
203
+
204
+ | Component | Behavior / spec |
205
+ |---|---|
206
+ | **Common / Filled / Tonal / Elevated / Outlined / Text buttons** | 5 variants. Shape token `--md-*-button-container-shape` defaults to `corner-full` (pill). Label = `label-large`. M3E adds **5 sizes (XS–XL)** + **shape-shift on press** (corners morph). Tonal & elevated buttons sit on container roles. |
207
+ | **Button groups** | New. A container holding multiple buttons/icon-buttons that apply **shape, motion, and width changes** together; pressing one can squeeze neighbors. XS–XL. Connected segmented look with springy widths. |
208
+ | **Split button** | New. Leading button = primary action; trailing menu button = related secondary action. Trailing button **spins + changes shape** when its menu opens. 5 sizes; elevated/filled/tonal/outlined styles. |
209
+ | **FAB** | Sizes: small **40dp** (icon 24), medium **56dp** (default, icon 24, shape `corner-large`/16), large **96dp** (icon 36, shape `corner-extra-large`/28). Colors: surface(default), primary, secondary, tertiary. **Branded FAB** (logo, medium/large only). Extended FAB = icon+label, no size variants. Container rests at `surface-container-high`. |
210
+ | **FAB menu** | New (replaces speed dial). Opens from **any** FAB size & color; larger items with contrasting colors; FAB **morphs** into the menu container. |
211
+ | **Toolbars (docked & floating)** | New. **Docked toolbar replaces the deprecated bottom app bar** — shorter, flexible, holds a row of actions. **Floating toolbar** = pill-shaped floating action cluster, flexible placement, can host a FAB. |
212
+ | **Loading indicator** | New. For loads **< 5s**; an animated **morphing shape** (cycles through the shape library) — replaces indeterminate circular spinner in pull-to-refresh & quick loads. |
213
+ | **Progress indicators (updated)** | Linear: `track-height 4px`, active-indicator `primary`, supports **buffer** (buffer dots) + indeterminate + `four-color`. Circular: `size 48px`, indicator width 8.33%, indeterminate + four-color. **Expressive variant is "wavy"** — the active track renders as a **waveform/wavy line** that straightens as it completes (the M3E signature progress look). |
214
+ | **Chips** (assist/filter/input/suggestion) | Pill (`corner-small`/8 container, often `full`), state layers, can carry leading icon/avatar. |
215
+ | **Switch / Slider** | M3E switch: larger thumb that grows on press; slider gets a **wavy active track** + notched thumb in expressive mode. |
216
+ | **Cards** (elevated/filled/outlined) | Tonal containers; `corner-medium`/12→`large`/16; prefer outline/tonal over shadow. |
217
+ | **Navigation** | Bottom **navigation bar** / **nav rail**; M3E adds flexible/expressive nav and the floating toolbar pattern for primary actions. |
218
+
219
+ ---
220
+
221
+ ## 7. Concrete porting cheatsheet (web / MUI v6)
222
+
223
+ - **Color**: generate a tonal palette from the dairy brand seed (e.g. via `@material/material-color-utilities`) → emit `--md-sys-color-*` CSS vars (incl. the 8 surface-container roles). Map MUI `primary/secondary/error/background` to them; expose `tertiary`, container roles, and surface ladder as `theme.vars` / custom tokens.
224
+ - **Shape**: replace ad-hoc `{sm8, md12, lg16, pill999}` with the full token scale `none0 / xs4 / s8 / m12 / l16 / xl28 / full`. Default cards→l16, sheets→xl28-top, buttons/chips→full.
225
+ - **Type**: replace the bespoke `h1..button` block with the **15-role M3 scale** above; button uses `label-large` (14/20/500). Adopt Roboto Flex for emphasis.
226
+ - **Motion**: model the 6 spring specs as Framer presets (`spatialFast {damping .6, stiffness 800}`, `spatialDefault {.8, 380}`, `effects {damping ~1, stiffness 1600}`); reserve bounce for spatial only; keep reduced-motion fallback.
227
+ - **State layers**: retune overlays to hover 8% / focus 10% / press 10%, disabled 38%/12%.
228
+ - **Components**: build the new ones MUI lacks (split button, button group, FAB menu, docked/floating toolbar, loading indicator, wavy progress) as custom; they don't exist in MUI v6 out of the box.
229
+
230
+ ---
231
+
232
+ ## 8. Honest criticism of the current MilkWay theme (`src/theme/`)
233
+
234
+ The owner is right — the current theme is **not worth keeping** for an M3E target. Concrete problems:
235
+
236
+ 1. **Hardcoded hex palette, no tonal system** (`palette.ts`). `primary:#1E6FE0`, `secondary:#2E9E5B`, etc. are flat single values with only `main`/`contrastText`. There are **no container roles, no on- roles beyond contrastText, no surface-container ladder, no tertiary**. The entire pastel-tonal foundation of M3E is absent. The comment even brags it's "Solid, no neon" — i.e. deliberately the opposite of expressive tonal surfaces.
237
+ 2. **The colors are Tailwind defaults, not a brand seed.** `#16A34A`, `#D97706`, `#DC2626`, `#0EA5E9`, `#F59E0B`, slate text `#0F172A/#64748B/#94A3B8` are literally Tailwind's green-600/amber-600/red-600/sky-500/amber-500/slate ramp. No HCT, no generated harmony, no dark-theme tone mapping (despite the "dark-swap ready" claim — there is no dark palette).
238
+ 3. **Shadow-based elevation, not tonal.** `index.ts` defines `e1/e2/e3` box-shadows and fills all 25 MUI slots with `e3`. M3E is tonal-elevation-first; this is the old material-2 mental model.
239
+ 4. **Shape scale too small & non-M3.** `radius = {sm8, md12, lg16, pill999}`. Missing `xs4`, `xl28`, and the expressive large radii. Cards at 16 is fine, but there's no shape-morph concept and `borderRadius` base is 12 (M3 base is effectively per-component).
240
+ 5. **Typography is bespoke px values, not the M3 scale.** `h1:28/34/700 … button:15/20/600`. None of the 15 M3 roles, no display tier, no `label-large`, sizes/line-heights/tracking don't match. Inter instead of Roboto/Roboto Flex (acceptable, but not the expressive default).
241
+ 6. **Motion is bezier+duration only — no springs.** `motion.ts` has `dur/easing/ease` curves `(.2,0,.2,1)` etc. and a *single* `sheetSpring {damping30, stiffness320}`. M3E needs **6 spring specs** split into spatial (bouncy) vs effects (no overshoot). The custom easings don't even match M3's `emphasized`/`standard` curves. **Reduced-motion handling is the one genuinely good instinct here — keep it.**
242
+ 7. **No state-layer system.** Relies on MUI defaults; no hover-8/focus-10/press-10 overlay tokens.
243
+ 8. **Touch target 44px** (`MuiButton minHeight:44`) is under M3's 48dp minimum.
244
+ 9. **Status colors hardcoded** in `customTokens.status` as flat hex — in M3E these should derive from semantic roles (success→tertiary/green-container, warning→amber-container, error→error-container) so they re-tone with the palette.
245
+
246
+ **Verdict:** the file structure (theme/palette/motion split, module augmentation, reduced-motion awareness) is reusable scaffolding, but **every value inside is non-M3** and must be regenerated from a seed. Treat it as a rewrite, not an edit.
247
+
248
+ ---
249
+
250
+ ## 9. Sources
251
+ - material-web token SCSS (v0_192): `tokens/versions/v0_192/_md-sys-{shape,motion,typescale}.scss`, `_md-sys-color.scss`, `_md-ref-palette.scss` — authoritative numeric values.
252
+ - material-web docs: `docs/components/{button,fab,progress}.md`.
253
+ - M3 Expressive components & motion: supercharge.design/blog/material-3-expressive; 9to5google M3E coverage; ProAndroidDev / Medium (navczydev, zoewave) engineering write-ups for spring damping/stiffness values; m3.material.io (motion/shape/color/type overviews — JS-rendered, cross-checked).
frontend/PWA/index.html CHANGED
@@ -8,14 +8,9 @@
8
  name="viewport"
9
  content="width=device-width, initial-scale=1.0, viewport-fit=cover"
10
  />
11
- <meta name="theme-color" content="#1E6FE0" />
12
- <!-- Global visible focus ring for keyboard users (DESIGN_SYSTEM §10). -->
13
- <style>
14
- :focus-visible {
15
- outline: 2px solid #1e6fe0;
16
- outline-offset: 2px;
17
- }
18
- </style>
19
  <meta name="apple-mobile-web-app-capable" content="yes" />
20
  <meta name="apple-mobile-web-app-status-bar-style" content="default" />
21
  <meta name="description" content="MilkWay ERP — dairy route, billing & analytics" />
 
8
  name="viewport"
9
  content="width=device-width, initial-scale=1.0, viewport-fit=cover"
10
  />
11
+ <!-- M3 seed (#6750A4). Focus ring + body surface now live in src/styles/global.css,
12
+ driven by --md-sys-color-* vars so they follow the light/dark data-attr flip. -->
13
+ <meta name="theme-color" content="#6750A4" />
 
 
 
 
 
14
  <meta name="apple-mobile-web-app-capable" content="yes" />
15
  <meta name="apple-mobile-web-app-status-bar-style" content="default" />
16
  <meta name="description" content="MilkWay ERP — dairy route, billing & analytics" />
frontend/PWA/package-lock.json CHANGED
@@ -10,7 +10,9 @@
10
  "dependencies": {
11
  "@emotion/react": "^11.13.3",
12
  "@emotion/styled": "^11.13.0",
 
13
  "@fontsource/inter": "^5.1.0",
 
14
  "@mui/icons-material": "^6.1.6",
15
  "@mui/material": "^6.1.6",
16
  "@tanstack/react-query": "^5.59.16",
@@ -2157,6 +2159,15 @@
2157
  "node": ">=12"
2158
  }
2159
  },
 
 
 
 
 
 
 
 
 
2160
  "node_modules/@fontsource/inter": {
2161
  "version": "5.2.8",
2162
  "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
@@ -2233,6 +2244,12 @@
2233
  "@jridgewell/sourcemap-codec": "^1.4.14"
2234
  }
2235
  },
 
 
 
 
 
 
2236
  "node_modules/@mui/core-downloads-tracker": {
2237
  "version": "6.5.0",
2238
  "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.5.0.tgz",
 
10
  "dependencies": {
11
  "@emotion/react": "^11.13.3",
12
  "@emotion/styled": "^11.13.0",
13
+ "@fontsource-variable/roboto-flex": "^5.2.8",
14
  "@fontsource/inter": "^5.1.0",
15
+ "@material/material-color-utilities": "^0.4.0",
16
  "@mui/icons-material": "^6.1.6",
17
  "@mui/material": "^6.1.6",
18
  "@tanstack/react-query": "^5.59.16",
 
2159
  "node": ">=12"
2160
  }
2161
  },
2162
+ "node_modules/@fontsource-variable/roboto-flex": {
2163
+ "version": "5.2.8",
2164
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/roboto-flex/-/roboto-flex-5.2.8.tgz",
2165
+ "integrity": "sha512-Q9zbz+P2VrDeIxBu9YAIG+eYpLmQF5FIJEEwAWn8En9Lao1TOISnoYcywGV+jSv7LGsHEndCR8hveXPnvCBEQQ==",
2166
+ "license": "OFL-1.1",
2167
+ "funding": {
2168
+ "url": "https://github.com/sponsors/ayuhito"
2169
+ }
2170
+ },
2171
  "node_modules/@fontsource/inter": {
2172
  "version": "5.2.8",
2173
  "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
 
2244
  "@jridgewell/sourcemap-codec": "^1.4.14"
2245
  }
2246
  },
2247
+ "node_modules/@material/material-color-utilities": {
2248
+ "version": "0.4.0",
2249
+ "resolved": "https://registry.npmjs.org/@material/material-color-utilities/-/material-color-utilities-0.4.0.tgz",
2250
+ "integrity": "sha512-dlq6VExJReb8dhjj3a/yTigr3ncNwoFmL5Iy2ENtbDX03EmNeOEdZ+vsaGrj7RTuO+mB7L58II4LCsl4NpM8uw==",
2251
+ "license": "Apache-2.0"
2252
+ },
2253
  "node_modules/@mui/core-downloads-tracker": {
2254
  "version": "6.5.0",
2255
  "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.5.0.tgz",
frontend/PWA/package.json CHANGED
@@ -13,7 +13,9 @@
13
  "dependencies": {
14
  "@emotion/react": "^11.13.3",
15
  "@emotion/styled": "^11.13.0",
 
16
  "@fontsource/inter": "^5.1.0",
 
17
  "@mui/icons-material": "^6.1.6",
18
  "@mui/material": "^6.1.6",
19
  "@tanstack/react-query": "^5.59.16",
 
13
  "dependencies": {
14
  "@emotion/react": "^11.13.3",
15
  "@emotion/styled": "^11.13.0",
16
+ "@fontsource-variable/roboto-flex": "^5.2.8",
17
  "@fontsource/inter": "^5.1.0",
18
+ "@material/material-color-utilities": "^0.4.0",
19
  "@mui/icons-material": "^6.1.6",
20
  "@mui/material": "^6.1.6",
21
  "@tanstack/react-query": "^5.59.16",
frontend/PWA/scripts/gen-m3.mjs ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Build-time runner for the M3 color generator.
3
+ *
4
+ * @material/material-color-utilities@0.4.0 ships ESM with a few extension-less
5
+ * internal imports that Node's strict resolver rejects, so we esbuild-bundle the
6
+ * TS generator (esbuild fixes the extensions) to a temp .mjs and execute it.
7
+ *
8
+ * Run from the PWA project root: node scripts/gen-m3.mjs
9
+ * Emits (committed): src/theme/m3/roles.ts and src/theme/m3/tokens.css
10
+ */
11
+ import { build } from 'esbuild';
12
+ import { fileURLToPath, pathToFileURL } from 'node:url';
13
+ import { dirname, resolve } from 'node:path';
14
+ import { rmSync } from 'node:fs';
15
+
16
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
17
+ const entry = resolve(root, 'src/theme/m3/generate.ts');
18
+ const tmp = resolve(root, 'src/theme/m3/.generate.bundle.mjs');
19
+
20
+ await build({
21
+ entryPoints: [entry],
22
+ bundle: true,
23
+ platform: 'node',
24
+ format: 'esm',
25
+ target: 'node18',
26
+ outfile: tmp,
27
+ logLevel: 'warning',
28
+ });
29
+
30
+ // generate.ts writes relative to process.cwd(); ensure that is the PWA root.
31
+ process.chdir(root);
32
+ await import(pathToFileURL(tmp).href);
33
+ rmSync(tmp, { force: true });
frontend/PWA/src/main.tsx CHANGED
@@ -1,16 +1,21 @@
1
  import { StrictMode } from 'react';
2
  import { createRoot } from 'react-dom/client';
3
- import { ThemeProvider } from '@mui/material/styles';
4
  import CssBaseline from '@mui/material/CssBaseline';
5
  import { QueryClientProvider } from '@tanstack/react-query';
6
  import { BrowserRouter } from 'react-router-dom';
7
 
8
- import '@fontsource/inter/400.css';
9
- import '@fontsource/inter/500.css';
10
- import '@fontsource/inter/600.css';
11
- import '@fontsource/inter/700.css';
12
 
13
- import { theme } from '@/theme';
 
 
 
 
 
 
14
  import { queryClient } from '@/app/queryClient';
15
  import { ToastProvider, ErrorBoundary } from '@/components';
16
  import { AppRoutes } from './AppRoutes';
@@ -20,7 +25,7 @@ if (!rootEl) throw new Error('Root element #root not found');
20
 
21
  createRoot(rootEl).render(
22
  <StrictMode>
23
- <ThemeProvider theme={theme}>
24
  <CssBaseline />
25
  <QueryClientProvider client={queryClient}>
26
  <ToastProvider>
@@ -31,6 +36,6 @@ createRoot(rootEl).render(
31
  </BrowserRouter>
32
  </ToastProvider>
33
  </QueryClientProvider>
34
- </ThemeProvider>
35
  </StrictMode>,
36
  );
 
1
  import { StrictMode } from 'react';
2
  import { createRoot } from 'react-dom/client';
 
3
  import CssBaseline from '@mui/material/CssBaseline';
4
  import { QueryClientProvider } from '@tanstack/react-query';
5
  import { BrowserRouter } from 'react-router-dom';
6
 
7
+ // Roboto Flex (M3 default) — self-hosted variable font (@fontsource-variable),
8
+ // NOT a Google Fonts <link>. Replaces the old Inter @fontsource import. A
9
+ // system fallback (-apple-system/Segoe UI/system-ui) is kept in theme + global.css.
10
+ import '@fontsource-variable/roboto-flex';
11
 
12
+ // Theme barrel imports m3/tokens.css (the --md-sys-color-* vars). Import it
13
+ // BEFORE global.css so those custom properties resolve when global.css reads them.
14
+ import { ThemeModeProvider } from '@/theme';
15
+
16
+ // Global baseline: paints body/html from M3 surface vars, smooths the
17
+ // light<->dark flip (bg/color only, reduced-motion-aware), M3 :focus-visible ring.
18
+ import '@/styles/global.css';
19
  import { queryClient } from '@/app/queryClient';
20
  import { ToastProvider, ErrorBoundary } from '@/components';
21
  import { AppRoutes } from './AppRoutes';
 
25
 
26
  createRoot(rootEl).render(
27
  <StrictMode>
28
+ <ThemeModeProvider>
29
  <CssBaseline />
30
  <QueryClientProvider client={queryClient}>
31
  <ToastProvider>
 
36
  </BrowserRouter>
37
  </ToastProvider>
38
  </QueryClientProvider>
39
+ </ThemeModeProvider>
40
  </StrictMode>,
41
  );
frontend/PWA/src/styles/global.css ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Global CSS baseline — P3 (Roboto Flex + global).
2
+ *
3
+ * Drives the page surface from the M3 --md-sys-color-* CSS vars (defined in
4
+ * src/theme/m3/tokens.css). Because those vars flip via html[data-theme='dark']
5
+ * (see ThemeModeProvider), the theme switch is a pure CSS-var swap here — no
6
+ * React palette recompute (PERFORMANCE MANDATE).
7
+ *
8
+ * NOTE: this file is imported AFTER tokens.css in main.tsx, so the vars below
9
+ * resolve. It intentionally only paints background/color + the focus ring;
10
+ * components keep reading the MUI theme. Do NOT add layout here.
11
+ */
12
+
13
+ /* Page surface comes straight from the M3 surface roles, so flipping
14
+ * html[data-theme] re-paints the whole page with zero JS. */
15
+ html {
16
+ background-color: var(--md-sys-color-surface, #fdf8fd);
17
+ color: var(--md-sys-color-on-surface, #1c1b1e);
18
+ /* Keep the system fallback before the variable font hydrates. */
19
+ font-family: 'Roboto Flex Variable', 'Roboto Flex', Roboto, -apple-system,
20
+ 'Segoe UI', system-ui, sans-serif;
21
+ -webkit-text-size-adjust: 100%;
22
+ }
23
+
24
+ body {
25
+ margin: 0;
26
+ background-color: var(--md-sys-color-surface, #fdf8fd);
27
+ color: var(--md-sys-color-on-surface, #1c1b1e);
28
+ -webkit-font-smoothing: antialiased;
29
+ -moz-osx-font-smoothing: grayscale;
30
+ }
31
+
32
+ /* Smooth the light <-> dark switch WITHOUT animating layout: only the two
33
+ * compositor-cheap paint properties, short, and scoped to the surface chrome.
34
+ * Components animate their own colors via MUI/Framer; this is just the page. */
35
+ html,
36
+ body {
37
+ transition:
38
+ background-color 200ms ease,
39
+ color 200ms ease;
40
+ }
41
+
42
+ /* M3 focus ring: a 3:1-contrast primary outline for keyboard users.
43
+ * Replaces the hardcoded brand-blue ring that used to live in index.html. */
44
+ :focus-visible {
45
+ outline: 2px solid var(--md-sys-color-primary, #6750a4);
46
+ outline-offset: 2px;
47
+ }
48
+
49
+ /* Honor reduced-motion: kill the theme-switch transition entirely so the
50
+ * flip is instant and we never animate for motion-sensitive users. */
51
+ @media (prefers-reduced-motion: reduce) {
52
+ html,
53
+ body {
54
+ transition: none;
55
+ }
56
+ }
frontend/PWA/src/theme/ThemeModeProvider.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Theme-mode store + provider.
2
+ // PERFORMANCE MANDATE: the M3 *color* vars (--md-sys-color-*) switch via a
3
+ // data-attribute flip on <html> (tokens.css), NOT a React palette recompute.
4
+ // We still swap the MUI Theme object (light/dark are prebuilt once) so MUI
5
+ // components that read palette hex theme correctly — but no palette is rebuilt
6
+ // at runtime.
7
+
8
+ import {
9
+ createContext,
10
+ useCallback,
11
+ useContext,
12
+ useEffect,
13
+ useMemo,
14
+ useState,
15
+ type ReactNode,
16
+ } from 'react';
17
+ import { ThemeProvider } from '@mui/material/styles';
18
+ import { lightTheme, darkTheme } from './index';
19
+
20
+ export type ThemeMode = 'light' | 'dark';
21
+
22
+ const STORAGE_KEY = 'mw-theme-mode';
23
+
24
+ type ThemeModeContextValue = {
25
+ mode: ThemeMode;
26
+ setMode: (m: ThemeMode) => void;
27
+ toggleMode: () => void;
28
+ };
29
+
30
+ const ThemeModeContext = createContext<ThemeModeContextValue | null>(null);
31
+
32
+ function getInitialMode(): ThemeMode {
33
+ if (typeof window === 'undefined') return 'light';
34
+ const stored = window.localStorage.getItem(STORAGE_KEY);
35
+ if (stored === 'light' || stored === 'dark') return stored;
36
+ // Default light; respect prefers-color-scheme only as the INITIAL hint.
37
+ if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) return 'dark';
38
+ return 'light';
39
+ }
40
+
41
+ function applyHtmlAttr(mode: ThemeMode) {
42
+ if (typeof document === 'undefined') return;
43
+ document.documentElement.setAttribute('data-theme', mode);
44
+ }
45
+
46
+ export function ThemeModeProvider({ children }: { children: ReactNode }) {
47
+ const [mode, setModeState] = useState<ThemeMode>(getInitialMode);
48
+
49
+ // Flip html[data-theme] so the CSS vars switch with zero React palette cost.
50
+ useEffect(() => {
51
+ applyHtmlAttr(mode);
52
+ try {
53
+ window.localStorage.setItem(STORAGE_KEY, mode);
54
+ } catch {
55
+ /* storage may be unavailable (private mode) — non-fatal */
56
+ }
57
+ }, [mode]);
58
+
59
+ const setMode = useCallback((m: ThemeMode) => setModeState(m), []);
60
+ const toggleMode = useCallback(
61
+ () => setModeState((m) => (m === 'light' ? 'dark' : 'light')),
62
+ [],
63
+ );
64
+
65
+ const ctx = useMemo<ThemeModeContextValue>(
66
+ () => ({ mode, setMode, toggleMode }),
67
+ [mode, setMode, toggleMode],
68
+ );
69
+
70
+ const muiTheme = mode === 'dark' ? darkTheme : lightTheme;
71
+
72
+ return (
73
+ <ThemeModeContext.Provider value={ctx}>
74
+ <ThemeProvider theme={muiTheme}>{children}</ThemeProvider>
75
+ </ThemeModeContext.Provider>
76
+ );
77
+ }
78
+
79
+ export function useThemeMode(): ThemeModeContextValue {
80
+ const ctx = useContext(ThemeModeContext);
81
+ if (!ctx) throw new Error('useThemeMode must be used within <ThemeModeProvider>');
82
+ return ctx;
83
+ }
frontend/PWA/src/theme/index.ts CHANGED
@@ -1,117 +1,236 @@
1
  import { createTheme, type Theme } from '@mui/material/styles';
2
- import { palette, customTokens } from './palette';
3
- import { motion } from './motion';
 
 
4
  import './theme.d';
5
 
6
- // Radius tokens (DESIGN_SYSTEM §2.3): cards 16, interactive 12.
7
- const radius = { sm: 8, md: 12, lg: 16, pill: 999 } as const;
 
 
8
 
 
9
  const fontFamily = [
10
- 'Inter',
 
 
11
  '-apple-system',
12
  '"Segoe UI"',
13
- 'Roboto',
14
  'sans-serif',
15
  ].join(',');
16
 
17
- // Soft elevation ramp (DESIGN_SYSTEM §2.4). MUI needs all 25 slots.
18
- const e1 = '0 1px 2px rgba(15,23,42,.06)';
19
- const e2 = '0 4px 12px rgba(15,23,42,.08)';
20
- const e3 = '0 8px 24px rgba(15,23,42,.12)';
21
- const shadows: Theme['shadows'] = [
22
- 'none',
23
- e1, // 1: cards at rest, list rows
24
- e2, // 2: FAB, raised cards, popovers
25
- e3, // 3: bottom sheets, dialogs, sticky action bar
26
- e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3,
27
- ];
28
 
29
- export const theme = createTheme({
30
- palette: {
31
- ...palette,
32
- accent: customTokens.accent,
33
- },
34
- custom: {
35
- border: customTokens.border,
36
- surface: customTokens.surface,
37
- eveningTint: customTokens.eveningTint,
38
- status: customTokens.status,
39
- radius,
40
- motion,
41
- },
42
- shape: { borderRadius: radius.md },
43
- shadows,
44
- spacing: 8,
45
- typography: {
46
- fontFamily,
47
- htmlFontSize: 16,
48
- fontSize: 16,
49
- h1: { fontSize: 28, lineHeight: '34px', fontWeight: 700 },
50
- h2: { fontSize: 24, lineHeight: '30px', fontWeight: 700 },
51
- h3: { fontSize: 20, lineHeight: '26px', fontWeight: 600 },
52
- h4: { fontSize: 18, lineHeight: '24px', fontWeight: 600 },
53
- subtitle1: { fontSize: 16, lineHeight: '22px', fontWeight: 600 },
54
- subtitle2: { fontSize: 14, lineHeight: '20px', fontWeight: 600 },
55
- body1: { fontSize: 16, lineHeight: '24px', fontWeight: 400 },
56
- body2: { fontSize: 14, lineHeight: '20px', fontWeight: 400 },
57
- caption: { fontSize: 12, lineHeight: '16px', fontWeight: 500 },
58
- overline: {
59
- fontSize: 12,
60
- lineHeight: '16px',
61
- fontWeight: 600,
62
- letterSpacing: '0.06em',
63
- textTransform: 'uppercase',
64
- },
65
- button: { fontSize: 15, lineHeight: '20px', fontWeight: 600, textTransform: 'none' },
66
- },
67
- components: {
68
- MuiButtonBase: {
69
- defaultProps: { disableRipple: false },
70
  },
71
- MuiButton: {
72
- defaultProps: { disableElevation: false },
73
- styleOverrides: {
74
- root: { borderRadius: radius.md, textTransform: 'none', minHeight: 44 },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  },
76
  },
77
- MuiPaper: {
78
- styleOverrides: {
79
- rounded: { borderRadius: radius.lg },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  },
81
- },
82
- MuiCard: {
83
- defaultProps: { elevation: 1 },
84
- styleOverrides: {
85
- root: { borderRadius: radius.lg, border: `1px solid ${customTokens.border}` },
 
86
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  },
88
- MuiTextField: {
89
- defaultProps: { variant: 'outlined', fullWidth: true },
90
- },
91
- MuiOutlinedInput: {
92
- styleOverrides: {
93
- root: { borderRadius: radius.md, fontSize: 16 },
94
  },
95
- },
96
- MuiChip: {
97
- styleOverrides: { root: { borderRadius: radius.pill, fontWeight: 500 } },
98
- },
99
- MuiCssBaseline: {
100
- styleOverrides: {
101
- ':root': {
102
- '--mw-safe-top': 'env(safe-area-inset-top, 0px)',
103
- '--mw-safe-bottom': 'env(safe-area-inset-bottom, 0px)',
104
- '--mw-safe-left': 'env(safe-area-inset-left, 0px)',
105
- '--mw-safe-right': 'env(safe-area-inset-right, 0px)',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  },
107
- html: { WebkitFontSmoothing: 'antialiased' },
108
- body: { backgroundColor: palette.background.default, margin: 0 },
109
- '#root': { minHeight: '100dvh' },
110
  },
111
  },
112
- },
113
- });
 
 
 
 
 
 
114
 
115
  export type AppTheme = typeof theme;
116
  export { palette, customTokens } from './palette';
117
- export { motion, dur, stagger, easing, ease, sheetSpring } from './motion';
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { createTheme, type Theme } from '@mui/material/styles';
2
+ import { buildPalette, buildCustomTokens, type PaletteMode } from './palette';
3
+ import { motion, stateLayer } from './motion';
4
+ import { m3Roles } from './m3/roles';
5
+ import './m3/tokens.css'; // emit --md-sys-color-* CSS vars (build-time, zero runtime cost)
6
  import './theme.d';
7
 
8
+ // ---- SHAPE scale (M3 md.sys.shape.corner.*) --------------------------------
9
+ // Existing names kept (sm/md/lg/pill); xs4 + xl28 ADDED. Default card = lg(16),
10
+ // default borderRadius = 16 (M3 large). Sheets/dialogs/large-FAB use xl(28).
11
+ const radius = { xs: 4, sm: 8, md: 12, lg: 16, xl: 28, pill: 999 } as const;
12
 
13
+ // Roboto Flex (self-hosted via @fontsource-variable/roboto-flex, imported in main.tsx).
14
  const fontFamily = [
15
+ '"Roboto Flex Variable"',
16
+ '"Roboto Flex"',
17
+ 'Roboto',
18
  '-apple-system',
19
  '"Segoe UI"',
 
20
  'sans-serif',
21
  ].join(',');
22
 
23
+ // State-layer mixin: translucent overlay of the content color over a container.
24
+ // Returns a CSS color-mix so it works against any base. (Build-time string.)
25
+ function layer(content: string, base: string, op: number): string {
26
+ return `color-mix(in srgb, ${content} ${Math.round(op * 100)}%, ${base})`;
27
+ }
 
 
 
 
 
 
28
 
29
+ export function buildTheme(mode: PaletteMode): Theme {
30
+ const palette = buildPalette(mode);
31
+ const customTokens = buildCustomTokens(mode);
32
+ const r = m3Roles[mode];
33
+
34
+ // ---- TONAL elevation: keep e1/e2/e3 NAMES, back them with surface-container
35
+ // tints + minimal shadow (tonal-first per M3 / PERFORMANCE MANDATE). The
36
+ // string values are still valid box-shadows so existing `shadows[n]` reads work.
37
+ const e1 = mode === 'light' ? '0 1px 2px rgba(0,0,0,.06)' : '0 1px 2px rgba(0,0,0,.30)';
38
+ const e2 = mode === 'light' ? '0 2px 6px rgba(0,0,0,.08)' : '0 2px 6px rgba(0,0,0,.36)';
39
+ const e3 = mode === 'light' ? '0 6px 18px rgba(0,0,0,.10)' : '0 6px 18px rgba(0,0,0,.44)';
40
+ const shadows: Theme['shadows'] = [
41
+ 'none',
42
+ e1, // 1: cards at rest, list rows
43
+ e2, // 2: FAB, raised cards, popovers
44
+ e3, // 3: bottom sheets, dialogs, sticky action bar
45
+ e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3, e3,
46
+ ];
47
+
48
+ return createTheme({
49
+ palette: {
50
+ ...palette,
51
+ accent: customTokens.accent,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  },
53
+ custom: {
54
+ // legacy token names — kept alive, mapped to M3 roles
55
+ border: customTokens.border,
56
+ surface: customTokens.surface,
57
+ eveningTint: customTokens.eveningTint,
58
+ status: customTokens.status,
59
+ radius,
60
+ motion,
61
+ // NEW: full M3 role set + surface tiers (for new expressive components)
62
+ m3: r,
63
+ surfaces: {
64
+ dim: r['surface-dim'],
65
+ bright: r['surface-bright'],
66
+ containerLowest: r['surface-container-lowest'],
67
+ containerLow: r['surface-container-low'],
68
+ container: r['surface-container'],
69
+ containerHigh: r['surface-container-high'],
70
+ containerHighest: r['surface-container-highest'],
71
  },
72
  },
73
+ shape: { borderRadius: radius.lg },
74
+ shadows,
75
+ spacing: 8,
76
+ typography: {
77
+ fontFamily,
78
+ htmlFontSize: 16,
79
+ fontSize: 16,
80
+ // ---- MUI variants re-mapped to closest M3 role metrics (§3.3) --------
81
+ // h1 -> headline-large, h2 -> headline-medium, h3 -> headline-small,
82
+ // h4 -> title-large, h5 -> title-medium, h6 -> title-medium,
83
+ // subtitle1 -> title-medium, subtitle2 -> title-small,
84
+ // body1 -> body-large, body2 -> body-medium, caption -> body-small,
85
+ // button -> label-large, overline -> label-small (uppercased).
86
+ h1: { fontSize: 32, lineHeight: '40px', fontWeight: 400, letterSpacing: 0 },
87
+ h2: { fontSize: 28, lineHeight: '36px', fontWeight: 400, letterSpacing: 0 },
88
+ h3: { fontSize: 24, lineHeight: '32px', fontWeight: 400, letterSpacing: 0 },
89
+ h4: { fontSize: 22, lineHeight: '28px', fontWeight: 400, letterSpacing: 0 },
90
+ h5: { fontSize: 16, lineHeight: '24px', fontWeight: 500, letterSpacing: '0.15px' },
91
+ h6: { fontSize: 16, lineHeight: '24px', fontWeight: 500, letterSpacing: '0.15px' },
92
+ subtitle1: { fontSize: 16, lineHeight: '24px', fontWeight: 500, letterSpacing: '0.15px' },
93
+ subtitle2: { fontSize: 14, lineHeight: '20px', fontWeight: 500, letterSpacing: '0.1px' },
94
+ body1: { fontSize: 16, lineHeight: '24px', fontWeight: 400, letterSpacing: '0.5px' },
95
+ body2: { fontSize: 14, lineHeight: '20px', fontWeight: 400, letterSpacing: '0.25px' },
96
+ caption: { fontSize: 12, lineHeight: '16px', fontWeight: 400, letterSpacing: '0.4px' },
97
+ overline: {
98
+ fontSize: 11,
99
+ lineHeight: '16px',
100
+ fontWeight: 500,
101
+ letterSpacing: '0.5px',
102
+ textTransform: 'uppercase',
103
  },
104
+ button: {
105
+ fontSize: 14,
106
+ lineHeight: '20px',
107
+ fontWeight: 500,
108
+ letterSpacing: '0.1px',
109
+ textTransform: 'none',
110
  },
111
+ // ---- M3 role names ADDED as typography entries (for new components) ----
112
+ displayLarge: { fontSize: 57, lineHeight: '64px', fontWeight: 400, letterSpacing: '-0.25px' },
113
+ displayMedium: { fontSize: 45, lineHeight: '52px', fontWeight: 400, letterSpacing: 0 },
114
+ displaySmall: { fontSize: 36, lineHeight: '44px', fontWeight: 400, letterSpacing: 0 },
115
+ headlineLarge: { fontSize: 32, lineHeight: '40px', fontWeight: 400, letterSpacing: 0 },
116
+ headlineMedium: { fontSize: 28, lineHeight: '36px', fontWeight: 400, letterSpacing: 0 },
117
+ headlineSmall: { fontSize: 24, lineHeight: '32px', fontWeight: 400, letterSpacing: 0 },
118
+ titleLarge: { fontSize: 22, lineHeight: '28px', fontWeight: 400, letterSpacing: 0 },
119
+ titleMedium: { fontSize: 16, lineHeight: '24px', fontWeight: 500, letterSpacing: '0.15px' },
120
+ titleSmall: { fontSize: 14, lineHeight: '20px', fontWeight: 500, letterSpacing: '0.1px' },
121
+ bodyLarge: { fontSize: 16, lineHeight: '24px', fontWeight: 400, letterSpacing: '0.5px' },
122
+ bodyMedium: { fontSize: 14, lineHeight: '20px', fontWeight: 400, letterSpacing: '0.25px' },
123
+ bodySmall: { fontSize: 12, lineHeight: '16px', fontWeight: 400, letterSpacing: '0.4px' },
124
+ labelLarge: { fontSize: 14, lineHeight: '20px', fontWeight: 500, letterSpacing: '0.1px' },
125
+ labelMedium: { fontSize: 12, lineHeight: '16px', fontWeight: 500, letterSpacing: '0.5px' },
126
+ labelSmall: { fontSize: 11, lineHeight: '16px', fontWeight: 500, letterSpacing: '0.5px' },
127
  },
128
+ components: {
129
+ MuiButtonBase: {
130
+ defaultProps: { disableRipple: false },
 
 
 
131
  },
132
+ MuiButton: {
133
+ defaultProps: { disableElevation: true },
134
+ styleOverrides: {
135
+ root: {
136
+ borderRadius: radius.pill, // M3 buttons are pills
137
+ textTransform: 'none',
138
+ minHeight: 48, // M3 48dp touch target
139
+ paddingLeft: 24,
140
+ paddingRight: 24,
141
+ fontSize: 14,
142
+ fontWeight: 500,
143
+ letterSpacing: '0.1px',
144
+ // M3 state layers (hover 8 / focus 10 / press 10) on the content color.
145
+ '&:hover': {
146
+ backgroundColor: layer(r['primary'], 'transparent', stateLayer.hover),
147
+ },
148
+ '&:active': {
149
+ backgroundColor: layer(r['primary'], 'transparent', stateLayer.pressed),
150
+ },
151
+ },
152
+ containedPrimary: {
153
+ '&:hover': {
154
+ backgroundColor: layer(r['on-primary'], r['primary'], stateLayer.hover),
155
+ },
156
+ },
157
+ },
158
+ },
159
+ MuiPaper: {
160
+ styleOverrides: {
161
+ rounded: { borderRadius: radius.lg },
162
+ },
163
+ },
164
+ MuiCard: {
165
+ defaultProps: { elevation: 0 },
166
+ styleOverrides: {
167
+ // Tonal card: surface-container fill + 1px outline-variant, no shadow.
168
+ root: {
169
+ borderRadius: radius.lg,
170
+ border: `1px solid ${customTokens.border}`,
171
+ backgroundColor: r['surface-container-low'],
172
+ backgroundImage: 'none',
173
+ },
174
+ },
175
+ },
176
+ MuiTextField: {
177
+ defaultProps: { variant: 'outlined', fullWidth: true },
178
+ },
179
+ MuiOutlinedInput: {
180
+ styleOverrides: {
181
+ root: { borderRadius: radius.sm, fontSize: 16 }, // M3 fields = small(8)
182
+ },
183
+ },
184
+ MuiChip: {
185
+ styleOverrides: { root: { borderRadius: radius.sm, fontWeight: 500 } },
186
+ },
187
+ MuiIconButton: {
188
+ styleOverrides: {
189
+ root: { minWidth: 48, minHeight: 48 }, // M3 48dp touch target
190
+ },
191
+ },
192
+ MuiCssBaseline: {
193
+ styleOverrides: {
194
+ ':root': {
195
+ '--mw-safe-top': 'env(safe-area-inset-top, 0px)',
196
+ '--mw-safe-bottom': 'env(safe-area-inset-bottom, 0px)',
197
+ '--mw-safe-left': 'env(safe-area-inset-left, 0px)',
198
+ '--mw-safe-right': 'env(safe-area-inset-right, 0px)',
199
+ },
200
+ html: { WebkitFontSmoothing: 'antialiased' },
201
+ // Body surface from the M3 var (global.css owns this too) so it follows
202
+ // the html[data-theme] flip — NOT the static palette hex — keeping the
203
+ // page bg in sync with the dark toggle without a React recompute.
204
+ body: {
205
+ backgroundColor: `var(--md-sys-color-surface, ${palette.background.default})`,
206
+ color: `var(--md-sys-color-on-surface, ${palette.text.primary})`,
207
+ margin: 0,
208
+ },
209
+ '#root': { minHeight: '100dvh' },
210
  },
 
 
 
211
  },
212
  },
213
+ });
214
+ }
215
+
216
+ export const lightTheme = buildTheme('light');
217
+ export const darkTheme = buildTheme('dark');
218
+
219
+ // Default export keeps the SAME name `theme` (light) so existing imports work.
220
+ export const theme = lightTheme;
221
 
222
  export type AppTheme = typeof theme;
223
  export { palette, customTokens } from './palette';
224
+ export {
225
+ motion,
226
+ dur,
227
+ stagger,
228
+ easing,
229
+ m3Easing,
230
+ ease,
231
+ spring,
232
+ sheetSpring,
233
+ stateLayer,
234
+ sec,
235
+ } from './motion';
236
+ export { ThemeModeProvider, useThemeMode } from './ThemeModeProvider';
frontend/PWA/src/theme/m3/generate.ts ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * M3 color generator (P1 — Phase 0).
3
+ *
4
+ * From the locked brand seed (#6750A4, violet) this derives the FULL Material 3
5
+ * `md.sys.color.*` role set for LIGHT and DARK via HCT tonal palettes
6
+ * (@material/material-color-utilities), following the role->tone mapping in
7
+ * M3_THEME_PLAN.md §3.1 + the surface-container ladder in §1.2.
8
+ *
9
+ * It is a BUILD-TIME generator. Running it emits two committed artifacts so the
10
+ * app pays ZERO runtime color-computation cost (PERFORMANCE MANDATE):
11
+ *
12
+ * (a) src/theme/m3/roles.ts — typed `{ light, dark }` hex object for MUI to consume.
13
+ * (b) src/theme/m3/tokens.css — `--md-sys-color-*` CSS vars: :root (light) +
14
+ * html[data-theme='dark'] (dark). Theme switch is a
15
+ * data-attribute flip, never a palette recompute.
16
+ *
17
+ * Run: node scripts/gen-m3.mjs (esbuild-bundles this file, then executes it)
18
+ *
19
+ * Do NOT import this module at app runtime — import the emitted `roles.ts` /
20
+ * `tokens.css` instead.
21
+ */
22
+ import {
23
+ CorePalette,
24
+ TonalPalette,
25
+ Hct,
26
+ argbFromHex,
27
+ hexFromArgb,
28
+ } from '@material/material-color-utilities';
29
+ import { writeFileSync } from 'node:fs';
30
+ import { resolve } from 'node:path';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Seed (LOCKED) + tonal palettes
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /** Brand seed — LOCKED decision. Everything generates from this one value. */
37
+ export const SEED = '#6750A4';
38
+
39
+ /**
40
+ * The 6 source tonal palettes derived from the seed via HCT.
41
+ * a1 = primary, a2 = secondary, a3 = tertiary,
42
+ * n1 = neutral (feeds the surface ladder), n2 = neutral-variant, error = fixed.
43
+ */
44
+ function corePalettes(seed: string) {
45
+ const cp = CorePalette.of(argbFromHex(seed));
46
+ return {
47
+ primary: cp.a1,
48
+ secondary: cp.a2,
49
+ tertiary: cp.a3,
50
+ neutral: cp.n1,
51
+ neutralVariant: cp.n2,
52
+ error: cp.error,
53
+ } satisfies Record<string, TonalPalette>;
54
+ }
55
+
56
+ /**
57
+ * Success & warning are SEMANTIC status colors that must re-tone with the
58
+ * palette. Per M3_THEME_PLAN §3.1 we derive them from fixed expressive hues
59
+ * (green for success, amber for warning) but at full M3 tonal chroma so they
60
+ * read as tonal container roles, not flat brand hexes. Info reuses the
61
+ * secondary palette (it is an informational, non-alarming accent).
62
+ */
63
+ function statusPalettes(palettes: ReturnType<typeof corePalettes>) {
64
+ return {
65
+ // Green ~ hue 142, high chroma — success / paid / delivered / active.
66
+ success: TonalPalette.fromHueAndChroma(142, 48),
67
+ // Amber ~ hue 80, high chroma — warning / partial / hold.
68
+ warning: TonalPalette.fromHueAndChroma(80, 60),
69
+ // Info reuses secondary so it harmonises with the seed.
70
+ info: palettes.secondary,
71
+ } satisfies Record<string, TonalPalette>;
72
+ }
73
+
74
+ const tone = (tp: TonalPalette, t: number) => hexFromArgb(tp.tone(t));
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Role set
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /** Every emitted role name (kept in one list so roles.ts + tokens.css stay in sync). */
81
+ export const ROLE_NAMES = [
82
+ 'primary',
83
+ 'on-primary',
84
+ 'primary-container',
85
+ 'on-primary-container',
86
+ 'primary-fixed',
87
+ 'primary-fixed-dim',
88
+ 'on-primary-fixed',
89
+ 'on-primary-fixed-variant',
90
+ 'secondary',
91
+ 'on-secondary',
92
+ 'secondary-container',
93
+ 'on-secondary-container',
94
+ 'secondary-fixed',
95
+ 'secondary-fixed-dim',
96
+ 'on-secondary-fixed',
97
+ 'on-secondary-fixed-variant',
98
+ 'tertiary',
99
+ 'on-tertiary',
100
+ 'tertiary-container',
101
+ 'on-tertiary-container',
102
+ 'tertiary-fixed',
103
+ 'tertiary-fixed-dim',
104
+ 'on-tertiary-fixed',
105
+ 'on-tertiary-fixed-variant',
106
+ 'error',
107
+ 'on-error',
108
+ 'error-container',
109
+ 'on-error-container',
110
+ 'success',
111
+ 'on-success',
112
+ 'success-container',
113
+ 'on-success-container',
114
+ 'warning',
115
+ 'on-warning',
116
+ 'warning-container',
117
+ 'on-warning-container',
118
+ 'info',
119
+ 'on-info',
120
+ 'info-container',
121
+ 'on-info-container',
122
+ 'background',
123
+ 'on-background',
124
+ 'surface',
125
+ 'on-surface',
126
+ 'surface-variant',
127
+ 'on-surface-variant',
128
+ 'surface-dim',
129
+ 'surface-bright',
130
+ 'surface-container-lowest',
131
+ 'surface-container-low',
132
+ 'surface-container',
133
+ 'surface-container-high',
134
+ 'surface-container-highest',
135
+ 'outline',
136
+ 'outline-variant',
137
+ 'inverse-surface',
138
+ 'inverse-on-surface',
139
+ 'inverse-primary',
140
+ 'surface-tint',
141
+ 'scrim',
142
+ 'shadow',
143
+ ] as const;
144
+
145
+ export type RoleName = (typeof ROLE_NAMES)[number];
146
+ export type RoleSet = Record<RoleName, string>;
147
+ export type Roles = { light: RoleSet; dark: RoleSet };
148
+
149
+ /**
150
+ * Builds one full role set for a given scheme.
151
+ * `m` selects the tone for a "main" accent (40 light / 80 dark) and the
152
+ * remaining accent tones follow the canonical M3 light/dark mapping (§3.1).
153
+ */
154
+ function buildScheme(seed: string, mode: 'light' | 'dark'): RoleSet {
155
+ const p = corePalettes(seed);
156
+ const s = statusPalettes(p);
157
+ const light = mode === 'light';
158
+
159
+ // Accent quadruple: [main, on, container, on-container] tones per mode.
160
+ // Light: 40 / 100 / 90 / 30 | Dark: 80 / 20 / 30 / 90 (§3.1)
161
+ const accent = (tp: TonalPalette) =>
162
+ light
163
+ ? { main: tone(tp, 40), on: tone(tp, 100), container: tone(tp, 90), onContainer: tone(tp, 30) }
164
+ : { main: tone(tp, 80), on: tone(tp, 20), container: tone(tp, 30), onContainer: tone(tp, 90) };
165
+
166
+ // *-fixed roles stay constant across light/dark (cross-theme persistent surfaces).
167
+ const fixed = (tp: TonalPalette) => ({
168
+ fixed: tone(tp, 90),
169
+ fixedDim: tone(tp, 80),
170
+ onFixed: tone(tp, 10),
171
+ onFixedVariant: tone(tp, 30),
172
+ });
173
+
174
+ const P = accent(p.primary);
175
+ const S = accent(p.secondary);
176
+ const T = accent(p.tertiary);
177
+ const E = accent(p.error);
178
+ const Su = accent(s.success);
179
+ const Wa = accent(s.warning);
180
+ const In = accent(s.info);
181
+
182
+ const Pf = fixed(p.primary);
183
+ const Sf = fixed(p.secondary);
184
+ const Tf = fixed(p.tertiary);
185
+
186
+ const n = p.neutral;
187
+ const nv = p.neutralVariant;
188
+
189
+ return {
190
+ primary: P.main,
191
+ 'on-primary': P.on,
192
+ 'primary-container': P.container,
193
+ 'on-primary-container': P.onContainer,
194
+ 'primary-fixed': Pf.fixed,
195
+ 'primary-fixed-dim': Pf.fixedDim,
196
+ 'on-primary-fixed': Pf.onFixed,
197
+ 'on-primary-fixed-variant': Pf.onFixedVariant,
198
+
199
+ secondary: S.main,
200
+ 'on-secondary': S.on,
201
+ 'secondary-container': S.container,
202
+ 'on-secondary-container': S.onContainer,
203
+ 'secondary-fixed': Sf.fixed,
204
+ 'secondary-fixed-dim': Sf.fixedDim,
205
+ 'on-secondary-fixed': Sf.onFixed,
206
+ 'on-secondary-fixed-variant': Sf.onFixedVariant,
207
+
208
+ tertiary: T.main,
209
+ 'on-tertiary': T.on,
210
+ 'tertiary-container': T.container,
211
+ 'on-tertiary-container': T.onContainer,
212
+ 'tertiary-fixed': Tf.fixed,
213
+ 'tertiary-fixed-dim': Tf.fixedDim,
214
+ 'on-tertiary-fixed': Tf.onFixed,
215
+ 'on-tertiary-fixed-variant': Tf.onFixedVariant,
216
+
217
+ error: E.main,
218
+ 'on-error': E.on,
219
+ 'error-container': E.container,
220
+ 'on-error-container': E.onContainer,
221
+
222
+ // Semantic status — derived from tonal palettes, re-tone with the scheme.
223
+ success: Su.main,
224
+ 'on-success': Su.on,
225
+ 'success-container': Su.container,
226
+ 'on-success-container': Su.onContainer,
227
+ warning: Wa.main,
228
+ 'on-warning': Wa.on,
229
+ 'warning-container': Wa.container,
230
+ 'on-warning-container': Wa.onContainer,
231
+ info: In.main,
232
+ 'on-info': In.on,
233
+ 'info-container': In.container,
234
+ 'on-info-container': In.onContainer,
235
+
236
+ // Backgrounds & surfaces (tonal). Light: N98/N10 ... Dark: N6/N90.
237
+ background: light ? tone(n, 98) : tone(n, 6),
238
+ 'on-background': light ? tone(n, 10) : tone(n, 90),
239
+ surface: light ? tone(n, 98) : tone(n, 6),
240
+ 'on-surface': light ? tone(n, 10) : tone(n, 90),
241
+ 'surface-variant': light ? tone(nv, 90) : tone(nv, 30),
242
+ 'on-surface-variant': light ? tone(nv, 30) : tone(nv, 80),
243
+
244
+ // Surface-container ladder (§1.2) — tonal elevation, NOT shadow.
245
+ 'surface-dim': light ? tone(n, 87) : tone(n, 6),
246
+ 'surface-bright': light ? tone(n, 98) : tone(n, 24),
247
+ 'surface-container-lowest': light ? tone(n, 100) : tone(n, 4),
248
+ 'surface-container-low': light ? tone(n, 96) : tone(n, 10),
249
+ 'surface-container': light ? tone(n, 94) : tone(n, 12),
250
+ 'surface-container-high': light ? tone(n, 92) : tone(n, 17),
251
+ 'surface-container-highest': light ? tone(n, 90) : tone(n, 22),
252
+
253
+ outline: light ? tone(nv, 50) : tone(nv, 60),
254
+ 'outline-variant': light ? tone(nv, 80) : tone(nv, 30),
255
+
256
+ 'inverse-surface': light ? tone(n, 20) : tone(n, 90),
257
+ 'inverse-on-surface': light ? tone(n, 95) : tone(n, 20),
258
+ 'inverse-primary': light ? tone(p.primary, 80) : tone(p.primary, 40),
259
+
260
+ 'surface-tint': P.main,
261
+ scrim: tone(n, 0),
262
+ shadow: tone(n, 0),
263
+ };
264
+ }
265
+
266
+ export function generateRoles(seed: string = SEED): Roles {
267
+ return { light: buildScheme(seed, 'light'), dark: buildScheme(seed, 'dark') };
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Emit (a) roles.ts and (b) tokens.css
272
+ // ---------------------------------------------------------------------------
273
+
274
+ function emitRolesTs(roles: Roles): string {
275
+ const setBody = (set: RoleSet) =>
276
+ ROLE_NAMES.map((r) => ` '${r}': '${set[r]}',`).join('\n');
277
+ return `/* AUTO-GENERATED by src/theme/m3/generate.ts — DO NOT EDIT BY HAND.
278
+ * Seed: ${SEED}. Regenerate with: node scripts/gen-m3.mjs
279
+ * Full M3 md.sys.color.* role set (light + dark) as hex, for MUI to consume. */
280
+
281
+ export type M3RoleName =
282
+ ${ROLE_NAMES.map((r) => ` | '${r}'`).join('\n')};
283
+
284
+ export type M3RoleSet = Record<M3RoleName, string>;
285
+
286
+ export const m3Roles: { light: M3RoleSet; dark: M3RoleSet } = {
287
+ light: {
288
+ ${setBody(roles.light)}
289
+ },
290
+ dark: {
291
+ ${setBody(roles.dark)}
292
+ },
293
+ };
294
+
295
+ export default m3Roles;
296
+ `;
297
+ }
298
+
299
+ function emitTokensCss(roles: Roles): string {
300
+ const vars = (set: RoleSet, indent: string) =>
301
+ ROLE_NAMES.map((r) => `${indent}--md-sys-color-${r}: ${set[r]};`).join('\n');
302
+ return `/* AUTO-GENERATED by src/theme/m3/generate.ts — DO NOT EDIT BY HAND.
303
+ * Seed: ${SEED}. Regenerate with: node scripts/gen-m3.mjs
304
+ * M3 color tokens as CSS custom properties. Theme switch = flip
305
+ * html[data-theme='dark'] (no React palette recompute — PERFORMANCE MANDATE). */
306
+
307
+ :root {
308
+ ${vars(roles.light, ' ')}
309
+ }
310
+
311
+ html[data-theme='dark'] {
312
+ ${vars(roles.dark, ' ')}
313
+ }
314
+
315
+ /* Respect explicit light selection even if the OS prefers dark. */
316
+ @media (prefers-color-scheme: dark) {
317
+ html:not([data-theme='light']):not([data-theme='dark']) {
318
+ ${vars(roles.dark, ' ')}
319
+ }
320
+ }
321
+ `;
322
+ }
323
+
324
+ /** Entry point — only runs when executed directly (not when imported). */
325
+ function main() {
326
+ const roles = generateRoles(SEED);
327
+ // Outputs live alongside this generator. Run from the PWA project root so
328
+ // process.cwd() resolves to .../frontend/PWA (the runner enforces this).
329
+ const outDir = resolve(process.cwd(), 'src/theme/m3');
330
+ writeFileSync(resolve(outDir, 'roles.ts'), emitRolesTs(roles), 'utf8');
331
+ writeFileSync(resolve(outDir, 'tokens.css'), emitTokensCss(roles), 'utf8');
332
+ // eslint-disable-next-line no-console
333
+ console.log(
334
+ `[m3] generated ${ROLE_NAMES.length} roles x 2 schemes -> roles.ts + tokens.css (seed ${SEED})`,
335
+ );
336
+ }
337
+
338
+ // Execute only as a script. `Hct` import kept referenced for future seed tooling.
339
+ void Hct;
340
+ main();
frontend/PWA/src/theme/m3/roles.ts ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* AUTO-GENERATED by src/theme/m3/generate.ts — DO NOT EDIT BY HAND.
2
+ * Seed: #6750A4. Regenerate with: node scripts/gen-m3.mjs
3
+ * Full M3 md.sys.color.* role set (light + dark) as hex, for MUI to consume. */
4
+
5
+ export type M3RoleName =
6
+ | 'primary'
7
+ | 'on-primary'
8
+ | 'primary-container'
9
+ | 'on-primary-container'
10
+ | 'primary-fixed'
11
+ | 'primary-fixed-dim'
12
+ | 'on-primary-fixed'
13
+ | 'on-primary-fixed-variant'
14
+ | 'secondary'
15
+ | 'on-secondary'
16
+ | 'secondary-container'
17
+ | 'on-secondary-container'
18
+ | 'secondary-fixed'
19
+ | 'secondary-fixed-dim'
20
+ | 'on-secondary-fixed'
21
+ | 'on-secondary-fixed-variant'
22
+ | 'tertiary'
23
+ | 'on-tertiary'
24
+ | 'tertiary-container'
25
+ | 'on-tertiary-container'
26
+ | 'tertiary-fixed'
27
+ | 'tertiary-fixed-dim'
28
+ | 'on-tertiary-fixed'
29
+ | 'on-tertiary-fixed-variant'
30
+ | 'error'
31
+ | 'on-error'
32
+ | 'error-container'
33
+ | 'on-error-container'
34
+ | 'success'
35
+ | 'on-success'
36
+ | 'success-container'
37
+ | 'on-success-container'
38
+ | 'warning'
39
+ | 'on-warning'
40
+ | 'warning-container'
41
+ | 'on-warning-container'
42
+ | 'info'
43
+ | 'on-info'
44
+ | 'info-container'
45
+ | 'on-info-container'
46
+ | 'background'
47
+ | 'on-background'
48
+ | 'surface'
49
+ | 'on-surface'
50
+ | 'surface-variant'
51
+ | 'on-surface-variant'
52
+ | 'surface-dim'
53
+ | 'surface-bright'
54
+ | 'surface-container-lowest'
55
+ | 'surface-container-low'
56
+ | 'surface-container'
57
+ | 'surface-container-high'
58
+ | 'surface-container-highest'
59
+ | 'outline'
60
+ | 'outline-variant'
61
+ | 'inverse-surface'
62
+ | 'inverse-on-surface'
63
+ | 'inverse-primary'
64
+ | 'surface-tint'
65
+ | 'scrim'
66
+ | 'shadow';
67
+
68
+ export type M3RoleSet = Record<M3RoleName, string>;
69
+
70
+ export const m3Roles: { light: M3RoleSet; dark: M3RoleSet } = {
71
+ light: {
72
+ 'primary': '#6750a4',
73
+ 'on-primary': '#ffffff',
74
+ 'primary-container': '#e9ddff',
75
+ 'on-primary-container': '#4f378a',
76
+ 'primary-fixed': '#e9ddff',
77
+ 'primary-fixed-dim': '#cfbcff',
78
+ 'on-primary-fixed': '#22005d',
79
+ 'on-primary-fixed-variant': '#4f378a',
80
+ 'secondary': '#625b71',
81
+ 'on-secondary': '#ffffff',
82
+ 'secondary-container': '#e8def8',
83
+ 'on-secondary-container': '#4a4458',
84
+ 'secondary-fixed': '#e8def8',
85
+ 'secondary-fixed-dim': '#cbc2db',
86
+ 'on-secondary-fixed': '#1e192b',
87
+ 'on-secondary-fixed-variant': '#4a4458',
88
+ 'tertiary': '#7e5260',
89
+ 'on-tertiary': '#ffffff',
90
+ 'tertiary-container': '#ffd9e3',
91
+ 'on-tertiary-container': '#633b48',
92
+ 'tertiary-fixed': '#ffd9e3',
93
+ 'tertiary-fixed-dim': '#efb8c8',
94
+ 'on-tertiary-fixed': '#31101d',
95
+ 'on-tertiary-fixed-variant': '#633b48',
96
+ 'error': '#ba1a1a',
97
+ 'on-error': '#ffffff',
98
+ 'error-container': '#ffdad6',
99
+ 'on-error-container': '#93000a',
100
+ 'success': '#306b25',
101
+ 'on-success': '#ffffff',
102
+ 'success-container': '#b1f49d',
103
+ 'on-success-container': '#16520e',
104
+ 'warning': '#7d5700',
105
+ 'on-warning': '#ffffff',
106
+ 'warning-container': '#ffdeab',
107
+ 'on-warning-container': '#5f4100',
108
+ 'info': '#625b71',
109
+ 'on-info': '#ffffff',
110
+ 'info-container': '#e8def8',
111
+ 'on-info-container': '#4a4458',
112
+ 'background': '#fdf8fd',
113
+ 'on-background': '#1c1b1e',
114
+ 'surface': '#fdf8fd',
115
+ 'on-surface': '#1c1b1e',
116
+ 'surface-variant': '#e7e0eb',
117
+ 'on-surface-variant': '#49454e',
118
+ 'surface-dim': '#ddd8dd',
119
+ 'surface-bright': '#fdf8fd',
120
+ 'surface-container-lowest': '#ffffff',
121
+ 'surface-container-low': '#f7f2f7',
122
+ 'surface-container': '#f2ecf1',
123
+ 'surface-container-high': '#ece7eb',
124
+ 'surface-container-highest': '#e6e1e6',
125
+ 'outline': '#7a757f',
126
+ 'outline-variant': '#cac4cf',
127
+ 'inverse-surface': '#313033',
128
+ 'inverse-on-surface': '#f4eff4',
129
+ 'inverse-primary': '#cfbcff',
130
+ 'surface-tint': '#6750a4',
131
+ 'scrim': '#000000',
132
+ 'shadow': '#000000',
133
+ },
134
+ dark: {
135
+ 'primary': '#cfbcff',
136
+ 'on-primary': '#381e72',
137
+ 'primary-container': '#4f378a',
138
+ 'on-primary-container': '#e9ddff',
139
+ 'primary-fixed': '#e9ddff',
140
+ 'primary-fixed-dim': '#cfbcff',
141
+ 'on-primary-fixed': '#22005d',
142
+ 'on-primary-fixed-variant': '#4f378a',
143
+ 'secondary': '#cbc2db',
144
+ 'on-secondary': '#332d41',
145
+ 'secondary-container': '#4a4458',
146
+ 'on-secondary-container': '#e8def8',
147
+ 'secondary-fixed': '#e8def8',
148
+ 'secondary-fixed-dim': '#cbc2db',
149
+ 'on-secondary-fixed': '#1e192b',
150
+ 'on-secondary-fixed-variant': '#4a4458',
151
+ 'tertiary': '#efb8c8',
152
+ 'on-tertiary': '#4a2532',
153
+ 'tertiary-container': '#633b48',
154
+ 'on-tertiary-container': '#ffd9e3',
155
+ 'tertiary-fixed': '#ffd9e3',
156
+ 'tertiary-fixed-dim': '#efb8c8',
157
+ 'on-tertiary-fixed': '#31101d',
158
+ 'on-tertiary-fixed-variant': '#633b48',
159
+ 'error': '#ffb4ab',
160
+ 'on-error': '#690005',
161
+ 'error-container': '#93000a',
162
+ 'on-error-container': '#ffdad6',
163
+ 'success': '#96d783',
164
+ 'on-success': '#013a00',
165
+ 'success-container': '#16520e',
166
+ 'on-success-container': '#b1f49d',
167
+ 'warning': '#ffba30',
168
+ 'on-warning': '#422c00',
169
+ 'warning-container': '#5f4100',
170
+ 'on-warning-container': '#ffdeab',
171
+ 'info': '#cbc2db',
172
+ 'on-info': '#332d41',
173
+ 'info-container': '#4a4458',
174
+ 'on-info-container': '#e8def8',
175
+ 'background': '#141316',
176
+ 'on-background': '#e6e1e6',
177
+ 'surface': '#141316',
178
+ 'on-surface': '#e6e1e6',
179
+ 'surface-variant': '#49454e',
180
+ 'on-surface-variant': '#cac4cf',
181
+ 'surface-dim': '#141316',
182
+ 'surface-bright': '#3a383c',
183
+ 'surface-container-lowest': '#0f0e11',
184
+ 'surface-container-low': '#1c1b1e',
185
+ 'surface-container': '#201f22',
186
+ 'surface-container-high': '#2b292d',
187
+ 'surface-container-highest': '#363438',
188
+ 'outline': '#948f99',
189
+ 'outline-variant': '#49454e',
190
+ 'inverse-surface': '#e6e1e6',
191
+ 'inverse-on-surface': '#313033',
192
+ 'inverse-primary': '#6750a4',
193
+ 'surface-tint': '#cfbcff',
194
+ 'scrim': '#000000',
195
+ 'shadow': '#000000',
196
+ },
197
+ };
198
+
199
+ export default m3Roles;
frontend/PWA/src/theme/m3/tokens.css ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* AUTO-GENERATED by src/theme/m3/generate.ts — DO NOT EDIT BY HAND.
2
+ * Seed: #6750A4. Regenerate with: node scripts/gen-m3.mjs
3
+ * M3 color tokens as CSS custom properties. Theme switch = flip
4
+ * html[data-theme='dark'] (no React palette recompute — PERFORMANCE MANDATE). */
5
+
6
+ :root {
7
+ --md-sys-color-primary: #6750a4;
8
+ --md-sys-color-on-primary: #ffffff;
9
+ --md-sys-color-primary-container: #e9ddff;
10
+ --md-sys-color-on-primary-container: #4f378a;
11
+ --md-sys-color-primary-fixed: #e9ddff;
12
+ --md-sys-color-primary-fixed-dim: #cfbcff;
13
+ --md-sys-color-on-primary-fixed: #22005d;
14
+ --md-sys-color-on-primary-fixed-variant: #4f378a;
15
+ --md-sys-color-secondary: #625b71;
16
+ --md-sys-color-on-secondary: #ffffff;
17
+ --md-sys-color-secondary-container: #e8def8;
18
+ --md-sys-color-on-secondary-container: #4a4458;
19
+ --md-sys-color-secondary-fixed: #e8def8;
20
+ --md-sys-color-secondary-fixed-dim: #cbc2db;
21
+ --md-sys-color-on-secondary-fixed: #1e192b;
22
+ --md-sys-color-on-secondary-fixed-variant: #4a4458;
23
+ --md-sys-color-tertiary: #7e5260;
24
+ --md-sys-color-on-tertiary: #ffffff;
25
+ --md-sys-color-tertiary-container: #ffd9e3;
26
+ --md-sys-color-on-tertiary-container: #633b48;
27
+ --md-sys-color-tertiary-fixed: #ffd9e3;
28
+ --md-sys-color-tertiary-fixed-dim: #efb8c8;
29
+ --md-sys-color-on-tertiary-fixed: #31101d;
30
+ --md-sys-color-on-tertiary-fixed-variant: #633b48;
31
+ --md-sys-color-error: #ba1a1a;
32
+ --md-sys-color-on-error: #ffffff;
33
+ --md-sys-color-error-container: #ffdad6;
34
+ --md-sys-color-on-error-container: #93000a;
35
+ --md-sys-color-success: #306b25;
36
+ --md-sys-color-on-success: #ffffff;
37
+ --md-sys-color-success-container: #b1f49d;
38
+ --md-sys-color-on-success-container: #16520e;
39
+ --md-sys-color-warning: #7d5700;
40
+ --md-sys-color-on-warning: #ffffff;
41
+ --md-sys-color-warning-container: #ffdeab;
42
+ --md-sys-color-on-warning-container: #5f4100;
43
+ --md-sys-color-info: #625b71;
44
+ --md-sys-color-on-info: #ffffff;
45
+ --md-sys-color-info-container: #e8def8;
46
+ --md-sys-color-on-info-container: #4a4458;
47
+ --md-sys-color-background: #fdf8fd;
48
+ --md-sys-color-on-background: #1c1b1e;
49
+ --md-sys-color-surface: #fdf8fd;
50
+ --md-sys-color-on-surface: #1c1b1e;
51
+ --md-sys-color-surface-variant: #e7e0eb;
52
+ --md-sys-color-on-surface-variant: #49454e;
53
+ --md-sys-color-surface-dim: #ddd8dd;
54
+ --md-sys-color-surface-bright: #fdf8fd;
55
+ --md-sys-color-surface-container-lowest: #ffffff;
56
+ --md-sys-color-surface-container-low: #f7f2f7;
57
+ --md-sys-color-surface-container: #f2ecf1;
58
+ --md-sys-color-surface-container-high: #ece7eb;
59
+ --md-sys-color-surface-container-highest: #e6e1e6;
60
+ --md-sys-color-outline: #7a757f;
61
+ --md-sys-color-outline-variant: #cac4cf;
62
+ --md-sys-color-inverse-surface: #313033;
63
+ --md-sys-color-inverse-on-surface: #f4eff4;
64
+ --md-sys-color-inverse-primary: #cfbcff;
65
+ --md-sys-color-surface-tint: #6750a4;
66
+ --md-sys-color-scrim: #000000;
67
+ --md-sys-color-shadow: #000000;
68
+ }
69
+
70
+ html[data-theme='dark'] {
71
+ --md-sys-color-primary: #cfbcff;
72
+ --md-sys-color-on-primary: #381e72;
73
+ --md-sys-color-primary-container: #4f378a;
74
+ --md-sys-color-on-primary-container: #e9ddff;
75
+ --md-sys-color-primary-fixed: #e9ddff;
76
+ --md-sys-color-primary-fixed-dim: #cfbcff;
77
+ --md-sys-color-on-primary-fixed: #22005d;
78
+ --md-sys-color-on-primary-fixed-variant: #4f378a;
79
+ --md-sys-color-secondary: #cbc2db;
80
+ --md-sys-color-on-secondary: #332d41;
81
+ --md-sys-color-secondary-container: #4a4458;
82
+ --md-sys-color-on-secondary-container: #e8def8;
83
+ --md-sys-color-secondary-fixed: #e8def8;
84
+ --md-sys-color-secondary-fixed-dim: #cbc2db;
85
+ --md-sys-color-on-secondary-fixed: #1e192b;
86
+ --md-sys-color-on-secondary-fixed-variant: #4a4458;
87
+ --md-sys-color-tertiary: #efb8c8;
88
+ --md-sys-color-on-tertiary: #4a2532;
89
+ --md-sys-color-tertiary-container: #633b48;
90
+ --md-sys-color-on-tertiary-container: #ffd9e3;
91
+ --md-sys-color-tertiary-fixed: #ffd9e3;
92
+ --md-sys-color-tertiary-fixed-dim: #efb8c8;
93
+ --md-sys-color-on-tertiary-fixed: #31101d;
94
+ --md-sys-color-on-tertiary-fixed-variant: #633b48;
95
+ --md-sys-color-error: #ffb4ab;
96
+ --md-sys-color-on-error: #690005;
97
+ --md-sys-color-error-container: #93000a;
98
+ --md-sys-color-on-error-container: #ffdad6;
99
+ --md-sys-color-success: #96d783;
100
+ --md-sys-color-on-success: #013a00;
101
+ --md-sys-color-success-container: #16520e;
102
+ --md-sys-color-on-success-container: #b1f49d;
103
+ --md-sys-color-warning: #ffba30;
104
+ --md-sys-color-on-warning: #422c00;
105
+ --md-sys-color-warning-container: #5f4100;
106
+ --md-sys-color-on-warning-container: #ffdeab;
107
+ --md-sys-color-info: #cbc2db;
108
+ --md-sys-color-on-info: #332d41;
109
+ --md-sys-color-info-container: #4a4458;
110
+ --md-sys-color-on-info-container: #e8def8;
111
+ --md-sys-color-background: #141316;
112
+ --md-sys-color-on-background: #e6e1e6;
113
+ --md-sys-color-surface: #141316;
114
+ --md-sys-color-on-surface: #e6e1e6;
115
+ --md-sys-color-surface-variant: #49454e;
116
+ --md-sys-color-on-surface-variant: #cac4cf;
117
+ --md-sys-color-surface-dim: #141316;
118
+ --md-sys-color-surface-bright: #3a383c;
119
+ --md-sys-color-surface-container-lowest: #0f0e11;
120
+ --md-sys-color-surface-container-low: #1c1b1e;
121
+ --md-sys-color-surface-container: #201f22;
122
+ --md-sys-color-surface-container-high: #2b292d;
123
+ --md-sys-color-surface-container-highest: #363438;
124
+ --md-sys-color-outline: #948f99;
125
+ --md-sys-color-outline-variant: #49454e;
126
+ --md-sys-color-inverse-surface: #e6e1e6;
127
+ --md-sys-color-inverse-on-surface: #313033;
128
+ --md-sys-color-inverse-primary: #6750a4;
129
+ --md-sys-color-surface-tint: #cfbcff;
130
+ --md-sys-color-scrim: #000000;
131
+ --md-sys-color-shadow: #000000;
132
+ }
133
+
134
+ /* Respect explicit light selection even if the OS prefers dark. */
135
+ @media (prefers-color-scheme: dark) {
136
+ html:not([data-theme='light']):not([data-theme='dark']) {
137
+ --md-sys-color-primary: #cfbcff;
138
+ --md-sys-color-on-primary: #381e72;
139
+ --md-sys-color-primary-container: #4f378a;
140
+ --md-sys-color-on-primary-container: #e9ddff;
141
+ --md-sys-color-primary-fixed: #e9ddff;
142
+ --md-sys-color-primary-fixed-dim: #cfbcff;
143
+ --md-sys-color-on-primary-fixed: #22005d;
144
+ --md-sys-color-on-primary-fixed-variant: #4f378a;
145
+ --md-sys-color-secondary: #cbc2db;
146
+ --md-sys-color-on-secondary: #332d41;
147
+ --md-sys-color-secondary-container: #4a4458;
148
+ --md-sys-color-on-secondary-container: #e8def8;
149
+ --md-sys-color-secondary-fixed: #e8def8;
150
+ --md-sys-color-secondary-fixed-dim: #cbc2db;
151
+ --md-sys-color-on-secondary-fixed: #1e192b;
152
+ --md-sys-color-on-secondary-fixed-variant: #4a4458;
153
+ --md-sys-color-tertiary: #efb8c8;
154
+ --md-sys-color-on-tertiary: #4a2532;
155
+ --md-sys-color-tertiary-container: #633b48;
156
+ --md-sys-color-on-tertiary-container: #ffd9e3;
157
+ --md-sys-color-tertiary-fixed: #ffd9e3;
158
+ --md-sys-color-tertiary-fixed-dim: #efb8c8;
159
+ --md-sys-color-on-tertiary-fixed: #31101d;
160
+ --md-sys-color-on-tertiary-fixed-variant: #633b48;
161
+ --md-sys-color-error: #ffb4ab;
162
+ --md-sys-color-on-error: #690005;
163
+ --md-sys-color-error-container: #93000a;
164
+ --md-sys-color-on-error-container: #ffdad6;
165
+ --md-sys-color-success: #96d783;
166
+ --md-sys-color-on-success: #013a00;
167
+ --md-sys-color-success-container: #16520e;
168
+ --md-sys-color-on-success-container: #b1f49d;
169
+ --md-sys-color-warning: #ffba30;
170
+ --md-sys-color-on-warning: #422c00;
171
+ --md-sys-color-warning-container: #5f4100;
172
+ --md-sys-color-on-warning-container: #ffdeab;
173
+ --md-sys-color-info: #cbc2db;
174
+ --md-sys-color-on-info: #332d41;
175
+ --md-sys-color-info-container: #4a4458;
176
+ --md-sys-color-on-info-container: #e8def8;
177
+ --md-sys-color-background: #141316;
178
+ --md-sys-color-on-background: #e6e1e6;
179
+ --md-sys-color-surface: #141316;
180
+ --md-sys-color-on-surface: #e6e1e6;
181
+ --md-sys-color-surface-variant: #49454e;
182
+ --md-sys-color-on-surface-variant: #cac4cf;
183
+ --md-sys-color-surface-dim: #141316;
184
+ --md-sys-color-surface-bright: #3a383c;
185
+ --md-sys-color-surface-container-lowest: #0f0e11;
186
+ --md-sys-color-surface-container-low: #1c1b1e;
187
+ --md-sys-color-surface-container: #201f22;
188
+ --md-sys-color-surface-container-high: #2b292d;
189
+ --md-sys-color-surface-container-highest: #363438;
190
+ --md-sys-color-outline: #948f99;
191
+ --md-sys-color-outline-variant: #49454e;
192
+ --md-sys-color-inverse-surface: #e6e1e6;
193
+ --md-sys-color-inverse-on-surface: #313033;
194
+ --md-sys-color-inverse-primary: #6750a4;
195
+ --md-sys-color-surface-tint: #cfbcff;
196
+ --md-sys-color-scrim: #000000;
197
+ --md-sys-color-shadow: #000000;
198
+ }
199
+ }
frontend/PWA/src/theme/motion.ts CHANGED
@@ -1,12 +1,18 @@
1
- // Motion tokens (DESIGN_SYSTEM §4). Durations are tokens; never inline a ms value.
2
  // Every animation short-circuits to instant / short cross-fade under reduced-motion.
 
 
 
 
 
3
 
 
4
  export const dur = {
5
  instant: 0,
6
- fast: 120,
7
- base: 200,
8
- page: 280,
9
- sheet: 320,
10
  countup: 700,
11
  success: 600,
12
  } as const;
@@ -16,24 +22,66 @@ export const stagger = {
16
  card: 60, // dashboard metric/chart cards
17
  } as const;
18
 
19
- // Easing (CSS cubic-bezier strings; Framer-compatible arrays below).
20
  export const easing = {
21
- fast: 'cubic-bezier(0,0,0.2,1)', // easeOut
22
- base: 'cubic-bezier(.2,.0,.2,1)',
23
- page: 'cubic-bezier(.2,.0,0,1)',
 
 
 
 
 
 
 
 
 
 
24
  } as const;
25
 
26
  export const ease = {
27
  fast: [0, 0, 0.2, 1] as [number, number, number, number],
28
- base: [0.2, 0, 0.2, 1] as [number, number, number, number],
29
  page: [0.2, 0, 0, 1] as [number, number, number, number],
30
  } as const;
31
 
32
- // Bottom-sheet spring (DESIGN_SYSTEM §4.1).
 
 
 
 
 
 
 
 
 
 
 
 
33
  export const sheetSpring = { type: 'spring', damping: 30, stiffness: 320 } as const;
34
 
 
 
 
 
 
 
 
 
 
 
35
  // Seconds helper for Framer (which takes seconds, not ms).
36
  export const sec = (ms: number) => ms / 1000;
37
 
38
- export const motion = { dur, stagger, easing, ease, sheetSpring, sec } as const;
 
 
 
 
 
 
 
 
 
 
39
  export type MotionTokens = typeof motion;
 
1
+ // Motion tokens Material 3 Expressive.
2
  // Every animation short-circuits to instant / short cross-fade under reduced-motion.
3
+ //
4
+ // NOTE: all the legacy export NAMES (dur/stagger/easing/ease/sheetSpring/sec/motion)
5
+ // are KEPT ALIVE so consuming files (PageTransition, AppButton, LedgerList, Money,
6
+ // PagedList, RouteSection, StickyHeader/ActionBar/Sheet via theme.custom.motion)
7
+ // keep compiling. New M3 spring specs are ADDED alongside.
8
 
9
+ // ---- Legacy duration tokens (kept) — M3 md.sys.motion.duration aligned ----
10
  export const dur = {
11
  instant: 0,
12
+ fast: 120, // ~short3
13
+ base: 200, // short4
14
+ page: 280, // medium2-ish
15
+ sheet: 320, // medium3
16
  countup: 700,
17
  success: 600,
18
  } as const;
 
22
  card: 60, // dashboard metric/chart cards
23
  } as const;
24
 
25
+ // ---- Easing (CSS cubic-bezier strings) kept names, M3 curves --------------
26
  export const easing = {
27
+ fast: 'cubic-bezier(0,0,0.2,1)', // standard-decelerate (easeOut)
28
+ base: 'cubic-bezier(0.2,0,0,1)', // M3 emphasized
29
+ page: 'cubic-bezier(0.2,0,0,1)', // M3 emphasized
30
+ } as const;
31
+
32
+ // M3 named easing set (additive — for new components).
33
+ export const m3Easing = {
34
+ standard: 'cubic-bezier(0.2,0,0,1)',
35
+ standardAccelerate: 'cubic-bezier(0.3,0,1,1)',
36
+ standardDecelerate: 'cubic-bezier(0,0,0,1)',
37
+ emphasized: 'cubic-bezier(0.2,0,0,1)',
38
+ emphasizedAccelerate: 'cubic-bezier(0.3,0,0.8,0.15)',
39
+ emphasizedDecelerate: 'cubic-bezier(0.05,0.7,0.1,1)',
40
  } as const;
41
 
42
  export const ease = {
43
  fast: [0, 0, 0.2, 1] as [number, number, number, number],
44
+ base: [0.2, 0, 0, 1] as [number, number, number, number], // emphasized
45
  page: [0.2, 0, 0, 1] as [number, number, number, number],
46
  } as const;
47
 
48
+ // ---- M3 Expressive spring specs (6 = 2 categories x 3 speeds) ---------------
49
+ // Spatial = position/size/rotation/corner radius (bounce allowed).
50
+ // Effects = color/opacity/elevation (no overshoot, damping ~1).
51
+ export const spring = {
52
+ spatialFast: { type: 'spring', damping: 0.6, stiffness: 800 },
53
+ spatialDefault: { type: 'spring', damping: 0.8, stiffness: 380 },
54
+ spatialSlow: { type: 'spring', damping: 0.8, stiffness: 200 },
55
+ effectsFast: { type: 'spring', damping: 1.0, stiffness: 3800 },
56
+ effectsDefault: { type: 'spring', damping: 1.0, stiffness: 1600 },
57
+ effectsSlow: { type: 'spring', damping: 1.0, stiffness: 800 },
58
+ } as const;
59
+
60
+ // Bottom-sheet spring — kept name; now points at the M3 spatial-default feel.
61
  export const sheetSpring = { type: 'spring', damping: 30, stiffness: 320 } as const;
62
 
63
+ // ---- State-layer opacities (M3 md.sys.state.*) ------------------------------
64
+ export const stateLayer = {
65
+ hover: 0.08,
66
+ focus: 0.1,
67
+ pressed: 0.1,
68
+ dragged: 0.16,
69
+ disabledContent: 0.38,
70
+ disabledContainer: 0.12,
71
+ } as const;
72
+
73
  // Seconds helper for Framer (which takes seconds, not ms).
74
  export const sec = (ms: number) => ms / 1000;
75
 
76
+ export const motion = {
77
+ dur,
78
+ stagger,
79
+ easing,
80
+ m3Easing,
81
+ ease,
82
+ spring,
83
+ sheetSpring,
84
+ stateLayer,
85
+ sec,
86
+ } as const;
87
  export type MotionTokens = typeof motion;
frontend/PWA/src/theme/palette.ts CHANGED
@@ -1,40 +1,120 @@
1
- // MilkWay LOCKED palette — light v1. Solid, no neon, not monochrome.
2
- // Components must read color from the theme, never hardcode a hex (dark-swap ready).
3
-
4
- export const palette = {
5
- mode: 'light',
6
- primary: { main: '#1E6FE0', contrastText: '#FFFFFF' },
7
- secondary: { main: '#2E9E5B', contrastText: '#FFFFFF' },
8
- success: { main: '#16A34A', contrastText: '#FFFFFF' },
9
- warning: { main: '#D97706', contrastText: '#FFFFFF' },
10
- error: { main: '#DC2626', contrastText: '#FFFFFF' },
11
- info: { main: '#0EA5E9', contrastText: '#FFFFFF' },
12
- background: { default: '#F6F8FB', paper: '#FFFFFF' },
13
- divider: '#E5E9F0',
14
- text: { primary: '#0F172A', secondary: '#64748B', disabled: '#94A3B8' },
15
- } as const;
16
-
17
- // Custom tokens surfaced via module augmentation (theme.palette.accent, theme.custom.*).
18
- export const customTokens = {
19
- // amber: DARK text for AA contrast (never white on amber).
20
- accent: { main: '#F59E0B', contrastText: '#0F172A' },
21
- border: '#E5E9F0',
22
- surface: '#FFFFFF',
23
- // Shift section tints (route morning/evening). Morning reuses the amber accent;
24
- // evening is an indigo tint kept here so components read it from the theme.
25
- eveningTint: '#4F46E5',
26
- // Single source of semantic status -> color. ONLY StatusChip reads this.
27
- status: {
28
- paid: '#16A34A',
29
- delivered: '#16A34A',
30
- active: '#16A34A',
31
- partial: '#D97706',
32
- hold: '#D97706',
33
- unpaid: '#DC2626',
34
- skipped: '#64748B',
35
- inactive: '#64748B',
36
- pending: '#64748B',
37
- },
38
- } as const;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  export type StatusKey = keyof typeof customTokens.status;
 
 
 
 
 
 
 
 
 
 
1
+ // MilkWay palette — Material 3 (seed #6750A4), light + dark.
2
+ // Values come from src/theme/m3/roles.ts (build-time generated from the seed).
3
+ // Components must read color from the theme, never hardcode a hex.
4
+ //
5
+ // NOTE on token NAMES: every name the ~39 consuming files already use
6
+ // (palette.accent, customTokens.{border,surface,eveningTint,status}) is kept
7
+ // alive here and MAPPED onto an M3 role, so no component file needs editing.
8
+
9
+ import { m3Roles, type M3RoleSet } from './m3/roles';
10
+
11
+ export type PaletteMode = 'light' | 'dark';
12
+
13
+ // ---- MUI core palette built from M3 roles -------------------------------
14
+ // primary.main = role `primary` (+ container roles exposed below)
15
+ // secondary.main= role `secondary`
16
+ // error/warning/success/info = role <name> (+ matching containers)
17
+ // background.default = role `surface` (M3 page bg)
18
+ // background.paper = role `surface-container-low` (default card tier)
19
+ // divider = role `outline-variant`
20
+ // text.primary = role `on-surface`
21
+ // text.secondary = role `on-surface-variant`
22
+ // text.disabled = on-surface @ 38% (M3 disabled content) — kept as hex+alpha
23
+ export function buildPalette(mode: PaletteMode) {
24
+ const r: M3RoleSet = m3Roles[mode];
25
+ return {
26
+ mode,
27
+ primary: {
28
+ main: r['primary'],
29
+ contrastText: r['on-primary'],
30
+ // M3 container pair, surfaced via augmentation (Palette.primary.container/onContainer).
31
+ container: r['primary-container'],
32
+ onContainer: r['on-primary-container'],
33
+ },
34
+ secondary: {
35
+ main: r['secondary'],
36
+ contrastText: r['on-secondary'],
37
+ container: r['secondary-container'],
38
+ onContainer: r['on-secondary-container'],
39
+ },
40
+ success: {
41
+ main: r['success'],
42
+ contrastText: r['on-success'],
43
+ container: r['success-container'],
44
+ onContainer: r['on-success-container'],
45
+ },
46
+ warning: {
47
+ main: r['warning'],
48
+ contrastText: r['on-warning'],
49
+ container: r['warning-container'],
50
+ onContainer: r['on-warning-container'],
51
+ },
52
+ error: {
53
+ main: r['error'],
54
+ contrastText: r['on-error'],
55
+ container: r['error-container'],
56
+ onContainer: r['on-error-container'],
57
+ },
58
+ info: {
59
+ main: r['info'],
60
+ contrastText: r['on-info'],
61
+ container: r['info-container'],
62
+ onContainer: r['on-info-container'],
63
+ },
64
+ background: {
65
+ default: r['surface'],
66
+ paper: r['surface-container-low'],
67
+ },
68
+ divider: r['outline-variant'],
69
+ text: {
70
+ primary: r['on-surface'],
71
+ secondary: r['on-surface-variant'],
72
+ disabled: alpha(r['on-surface'], 0.38),
73
+ },
74
+ } as const;
75
+ }
76
+
77
+ // Light palette kept as the named export the rest of the codebase imports.
78
+ export const palette = buildPalette('light');
79
+
80
+ // ---- Custom tokens (SAME SHAPE as before, re-mapped onto M3 roles) -------
81
+ // accent -> tertiary (the M3 "expressive accent")
82
+ // border -> outline-variant
83
+ // surface -> surface-container-low (the card surface)
84
+ // eveningTint -> secondary (evening route tint; re-tones with the palette)
85
+ // status.* -> success / warning / error / info, derived from semantic roles
86
+ // so chips re-tone with the seed (kept as flat hex — StatusChip
87
+ // concatenates `${hex}1A`).
88
+ export function buildCustomTokens(mode: PaletteMode) {
89
+ const r: M3RoleSet = m3Roles[mode];
90
+ return {
91
+ accent: { main: r['tertiary'], contrastText: r['on-tertiary'] },
92
+ border: r['outline-variant'],
93
+ surface: r['surface-container-low'],
94
+ eveningTint: r['secondary'],
95
+ status: {
96
+ paid: r['success'],
97
+ delivered: r['success'],
98
+ active: r['success'],
99
+ partial: r['warning'],
100
+ hold: r['warning'],
101
+ unpaid: r['error'],
102
+ skipped: r['on-surface-variant'],
103
+ inactive: r['on-surface-variant'],
104
+ pending: r['on-surface-variant'],
105
+ },
106
+ } as const;
107
+ }
108
+
109
+ export const customTokens = buildCustomTokens('light');
110
 
111
  export type StatusKey = keyof typeof customTokens.status;
112
+
113
+ // Tiny hex+alpha helper (build-time, no runtime palette cost). Accepts #rrggbb.
114
+ function alpha(hex: string, a: number): string {
115
+ const h = hex.replace('#', '');
116
+ const r = parseInt(h.slice(0, 2), 16);
117
+ const g = parseInt(h.slice(2, 4), 16);
118
+ const b = parseInt(h.slice(4, 6), 16);
119
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
120
+ }
frontend/PWA/src/theme/theme.d.ts CHANGED
@@ -1,12 +1,40 @@
1
  import '@mui/material/styles';
2
  import type { customTokens } from './palette';
3
  import type { motion as motionTokens } from './motion';
 
4
 
5
  type AccentColor = {
6
  main: string;
7
  contrastText: string;
8
  };
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  declare module '@mui/material/styles' {
11
  interface Palette {
12
  accent: AccentColor;
@@ -15,25 +43,51 @@ declare module '@mui/material/styles' {
15
  accent?: AccentColor;
16
  }
17
 
 
 
 
 
18
  interface Theme {
19
- custom: {
20
- border: string;
21
- surface: string;
22
- eveningTint: string;
23
- status: typeof customTokens.status;
24
- radius: { sm: number; md: number; lg: number; pill: number };
25
- motion: typeof motionTokens;
26
- };
27
  }
28
  interface ThemeOptions {
29
- custom?: {
30
- border: string;
31
- surface: string;
32
- eveningTint: string;
33
- status: typeof customTokens.status;
34
- radius: { sm: number; md: number; lg: number; pill: number };
35
- motion: typeof motionTokens;
36
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  }
38
  }
39
 
@@ -48,3 +102,24 @@ declare module '@mui/material/SvgIcon' {
48
  accent: true;
49
  }
50
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import '@mui/material/styles';
2
  import type { customTokens } from './palette';
3
  import type { motion as motionTokens } from './motion';
4
+ import type { M3RoleSet } from './m3/roles';
5
 
6
  type AccentColor = {
7
  main: string;
8
  contrastText: string;
9
  };
10
 
11
+ // M3 container pair added to the standard MUI color tokens.
12
+ type M3ColorExtra = {
13
+ container: string;
14
+ onContainer: string;
15
+ };
16
+
17
+ type CustomTokens = {
18
+ // legacy names (kept alive, mapped to M3 roles)
19
+ border: string;
20
+ surface: string;
21
+ eveningTint: string;
22
+ status: typeof customTokens.status;
23
+ radius: { xs: number; sm: number; md: number; lg: number; xl: number; pill: number };
24
+ motion: typeof motionTokens;
25
+ // NEW M3 additions
26
+ m3: M3RoleSet;
27
+ surfaces: {
28
+ dim: string;
29
+ bright: string;
30
+ containerLowest: string;
31
+ containerLow: string;
32
+ container: string;
33
+ containerHigh: string;
34
+ containerHighest: string;
35
+ };
36
+ };
37
+
38
  declare module '@mui/material/styles' {
39
  interface Palette {
40
  accent: AccentColor;
 
43
  accent?: AccentColor;
44
  }
45
 
46
+ // Expose M3 container roles on the standard palette color objects.
47
+ interface PaletteColor extends M3ColorExtra {}
48
+ interface SimplePaletteColorOptions extends Partial<M3ColorExtra> {}
49
+
50
  interface Theme {
51
+ custom: CustomTokens;
 
 
 
 
 
 
 
52
  }
53
  interface ThemeOptions {
54
+ custom?: CustomTokens;
55
+ }
56
+
57
+ // ---- M3 role typography variants (added in index.ts) -------------------
58
+ interface TypographyVariants {
59
+ displayLarge: React.CSSProperties;
60
+ displayMedium: React.CSSProperties;
61
+ displaySmall: React.CSSProperties;
62
+ headlineLarge: React.CSSProperties;
63
+ headlineMedium: React.CSSProperties;
64
+ headlineSmall: React.CSSProperties;
65
+ titleLarge: React.CSSProperties;
66
+ titleMedium: React.CSSProperties;
67
+ titleSmall: React.CSSProperties;
68
+ bodyLarge: React.CSSProperties;
69
+ bodyMedium: React.CSSProperties;
70
+ bodySmall: React.CSSProperties;
71
+ labelLarge: React.CSSProperties;
72
+ labelMedium: React.CSSProperties;
73
+ labelSmall: React.CSSProperties;
74
+ }
75
+ interface TypographyVariantsOptions {
76
+ displayLarge?: React.CSSProperties;
77
+ displayMedium?: React.CSSProperties;
78
+ displaySmall?: React.CSSProperties;
79
+ headlineLarge?: React.CSSProperties;
80
+ headlineMedium?: React.CSSProperties;
81
+ headlineSmall?: React.CSSProperties;
82
+ titleLarge?: React.CSSProperties;
83
+ titleMedium?: React.CSSProperties;
84
+ titleSmall?: React.CSSProperties;
85
+ bodyLarge?: React.CSSProperties;
86
+ bodyMedium?: React.CSSProperties;
87
+ bodySmall?: React.CSSProperties;
88
+ labelLarge?: React.CSSProperties;
89
+ labelMedium?: React.CSSProperties;
90
+ labelSmall?: React.CSSProperties;
91
  }
92
  }
93
 
 
102
  accent: true;
103
  }
104
  }
105
+
106
+ // Allow variant="displayLarge" etc. on <Typography>.
107
+ declare module '@mui/material/Typography' {
108
+ interface TypographyPropsVariantOverrides {
109
+ displayLarge: true;
110
+ displayMedium: true;
111
+ displaySmall: true;
112
+ headlineLarge: true;
113
+ headlineMedium: true;
114
+ headlineSmall: true;
115
+ titleLarge: true;
116
+ titleMedium: true;
117
+ titleSmall: true;
118
+ bodyLarge: true;
119
+ bodyMedium: true;
120
+ bodySmall: true;
121
+ labelLarge: true;
122
+ labelMedium: true;
123
+ labelSmall: true;
124
+ }
125
+ }
frontend/PWA/tsconfig.app.tsbuildinfo CHANGED
@@ -1 +1 @@
1
- {"root":["./src/approutes.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/apierror.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/index.ts","./src/api/keys.ts","./src/api/types.ts","./src/api/hooks/index.ts","./src/api/hooks/useanalytics.ts","./src/api/hooks/useauth.ts","./src/api/hooks/usebills.ts","./src/api/hooks/usecustomerledger.ts","./src/api/hooks/usecustomers.ts","./src/api/hooks/usepayments.ts","./src/api/hooks/useprices.ts","./src/api/hooks/useproducts.ts","./src/api/hooks/useroute.ts","./src/api/hooks/usesettings.ts","./src/api/hooks/usesubscriptions.ts","./src/api/hooks/useusers.ts","./src/app/appshell.tsx","./src/app/bottomnav.tsx","./src/app/fab.tsx","./src/app/pagetransition.tsx","./src/app/stickyactionbar.tsx","./src/app/stickyheader.tsx","./src/app/index.ts","./src/app/navconfig.tsx","./src/app/queryclient.ts","./src/auth/authstore.ts","./src/auth/permissions.ts","./src/components/index.ts","./src/components/buttons/appbutton.tsx","./src/components/buttons/iconaction.tsx","./src/components/charts/barchart.tsx","./src/components/charts/chartframe.tsx","./src/components/charts/donutchart.tsx","./src/components/charts/metriccard.tsx","./src/components/charts/trendchart.tsx","./src/components/domain/billcard.tsx","./src/components/domain/billlinestable.tsx","./src/components/domain/customercard.tsx","./src/components/domain/deliveryrow.tsx","./src/components/domain/ledgerlist.tsx","./src/components/domain/paymentrow.tsx","./src/components/domain/productcard.tsx","./src/components/domain/qrviewer.tsx","./src/components/domain/subscriptionrow.tsx","./src/components/domain/whatsappsharebutton.tsx","./src/components/domain/types.ts","./src/components/feedback/emptystate.tsx","./src/components/feedback/errorboundary.tsx","./src/components/feedback/errorstate.tsx","./src/components/feedback/skeleton.tsx","./src/components/feedback/spinner.tsx","./src/components/feedback/toastprovider.tsx","./src/components/fields/datefield.tsx","./src/components/fields/keyboardawarefield.tsx","./src/components/fields/numberfield.tsx","./src/components/fields/phonefield.tsx","./src/components/fields/qtystepper.tsx","./src/components/fields/searchbar.tsx","./src/components/fields/selectfield.tsx","./src/components/fields/statustoggle.tsx","./src/components/fields/switchfield.tsx","./src/components/fields/textfield.tsx","./src/components/format/datelabel.tsx","./src/components/format/money.tsx","./src/components/format/qty.tsx","./src/components/gating/permissiongate.tsx","./src/components/gating/rolegate.tsx","./src/components/lists/pagedlist.tsx","./src/components/lists/usepulltorefresh.ts","./src/components/nav/datestrip.tsx","./src/components/nav/pagetabs.tsx","./src/components/nav/stickytotalbar.tsx","./src/components/overlays/bottomsheet.tsx","./src/components/overlays/confirmdialog.tsx","./src/components/overlays/modal.tsx","./src/components/status/statuschip.tsx","./src/features/admin/settingspage.tsx","./src/features/admin/staffpage.tsx","./src/features/admin/routes.tsx","./src/features/admin/useadminnav.ts","./src/features/admin/components/createemployeesheet.tsx","./src/features/admin/components/holidayssection.tsx","./src/features/admin/components/permissionlabels.ts","./src/features/admin/components/permissionssheet.tsx","./src/features/admin/components/staffrow.tsx","./src/features/analytics/dashboardpage.tsx","./src/features/analytics/daterangesheet.tsx","./src/features/analytics/rangecontrol.tsx","./src/features/analytics/reportspage.tsx","./src/features/analytics/unitqtychips.tsx","./src/features/analytics/charts.tsx","./src/features/analytics/controls.tsx","./src/features/analytics/index.ts","./src/features/analytics/routes.tsx","./src/features/analytics/unitqty.ts","./src/features/analytics/unittypes.ts","./src/features/analytics/usedaterange.ts","./src/features/auth/routes.tsx","./src/features/auth/pages/loginpage.tsx","./src/features/billing/billdetailpage.tsx","./src/features/billing/billingpage.tsx","./src/features/billing/index.ts","./src/features/billing/routes.tsx","./src/features/billing/components/adjustmentsheet.tsx","./src/features/billing/components/billlistcard.tsx","./src/features/billing/components/billslisttab.tsx","./src/features/billing/components/generatetab.tsx","./src/features/billing/components/recordpaymentsheet.tsx","./src/features/billing/components/statuschangesheet.tsx","./src/features/billing/hooks/usebillingshell.ts","./src/features/billing/hooks/usegeneratebills.ts","./src/features/billing/hooks/usegeneratepreview.ts","./src/features/billing/lib/billlineunit.ts","./src/features/catalog/routes.tsx","./src/features/catalog/unit.ts","./src/features/catalog/usecatalognav.ts","./src/features/catalog/components/addpricesheet.tsx","./src/features/catalog/components/pricerow.tsx","./src/features/catalog/components/productformsheet.tsx","./src/features/catalog/components/resolveratepreview.tsx","./src/features/catalog/pages/productpricespage.tsx","./src/features/catalog/pages/productslistpage.tsx","./src/features/customers/routes.tsx","./src/features/customers/components/consumptionbreakdown.tsx","./src/features/customers/components/customerformsheet.tsx","./src/features/customers/components/subscriptionformsheet.tsx","./src/features/customers/hooks/useappnav.ts","./src/features/customers/hooks/usecustomeroutstanding.ts","./src/features/customers/lib/format.ts","./src/features/customers/lib/units.ts","./src/features/customers/pages/customerdetailpage.tsx","./src/features/customers/pages/customerslistpage.tsx","./src/features/payments/index.ts","./src/features/payments/routes.tsx","./src/features/payments/components/paymentfilterssheet.tsx","./src/features/payments/components/recordpaymentsheet.tsx","./src/features/payments/hooks/usepaymentsfeature.ts","./src/features/payments/pages/paymentslistpage.tsx","./src/features/route/routes.tsx","./src/features/route/components/recorddeliverysheet.tsx","./src/features/route/components/routesection.tsx","./src/features/route/components/submitconfirmsheet.tsx","./src/features/route/hooks/useonline.ts","./src/features/route/hooks/useroutedraft.ts","./src/features/route/pages/routepage.tsx","./src/features/superadmin/adminspage.tsx","./src/features/superadmin/orgspage.tsx","./src/features/superadmin/index.ts","./src/features/superadmin/routes.tsx","./src/features/superadmin/components/adminrow.tsx","./src/features/superadmin/components/createadminsheet.tsx","./src/features/superadmin/components/orgcard.tsx","./src/features/superadmin/components/orgpickersheet.tsx","./src/features/superadmin/hooks/useorgpicker.ts","./src/features/superadmin/hooks/usesuperadminnav.ts","./src/features/superadmin/lib/seedorg.ts","./src/hooks/usekeyboardaware.ts","./src/hooks/usereducedmotion.ts","./src/lib/env.ts","./src/lib/formerrors.ts","./src/lib/unit.ts","./src/routes/homeplaceholder.tsx","./src/routes/morepage.tsx","./src/routes/preview.tsx","./src/routes/lazypage.tsx","./src/theme/index.ts","./src/theme/motion.ts","./src/theme/palette.ts","./src/theme/theme.d.ts"],"version":"5.9.3"}
 
1
+ {"root":["./src/approutes.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/apierror.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/index.ts","./src/api/keys.ts","./src/api/types.ts","./src/api/hooks/index.ts","./src/api/hooks/useanalytics.ts","./src/api/hooks/useauth.ts","./src/api/hooks/usebills.ts","./src/api/hooks/usecustomerledger.ts","./src/api/hooks/usecustomers.ts","./src/api/hooks/usepayments.ts","./src/api/hooks/useprices.ts","./src/api/hooks/useproducts.ts","./src/api/hooks/useroute.ts","./src/api/hooks/usesettings.ts","./src/api/hooks/usesubscriptions.ts","./src/api/hooks/useusers.ts","./src/app/appshell.tsx","./src/app/bottomnav.tsx","./src/app/fab.tsx","./src/app/pagetransition.tsx","./src/app/stickyactionbar.tsx","./src/app/stickyheader.tsx","./src/app/index.ts","./src/app/navconfig.tsx","./src/app/queryclient.ts","./src/auth/authstore.ts","./src/auth/permissions.ts","./src/components/index.ts","./src/components/buttons/appbutton.tsx","./src/components/buttons/iconaction.tsx","./src/components/charts/barchart.tsx","./src/components/charts/chartframe.tsx","./src/components/charts/donutchart.tsx","./src/components/charts/metriccard.tsx","./src/components/charts/trendchart.tsx","./src/components/domain/billcard.tsx","./src/components/domain/billlinestable.tsx","./src/components/domain/customercard.tsx","./src/components/domain/deliveryrow.tsx","./src/components/domain/ledgerlist.tsx","./src/components/domain/paymentrow.tsx","./src/components/domain/productcard.tsx","./src/components/domain/qrviewer.tsx","./src/components/domain/subscriptionrow.tsx","./src/components/domain/whatsappsharebutton.tsx","./src/components/domain/types.ts","./src/components/feedback/emptystate.tsx","./src/components/feedback/errorboundary.tsx","./src/components/feedback/errorstate.tsx","./src/components/feedback/skeleton.tsx","./src/components/feedback/spinner.tsx","./src/components/feedback/toastprovider.tsx","./src/components/fields/datefield.tsx","./src/components/fields/keyboardawarefield.tsx","./src/components/fields/numberfield.tsx","./src/components/fields/phonefield.tsx","./src/components/fields/qtystepper.tsx","./src/components/fields/searchbar.tsx","./src/components/fields/selectfield.tsx","./src/components/fields/statustoggle.tsx","./src/components/fields/switchfield.tsx","./src/components/fields/textfield.tsx","./src/components/format/datelabel.tsx","./src/components/format/money.tsx","./src/components/format/qty.tsx","./src/components/gating/permissiongate.tsx","./src/components/gating/rolegate.tsx","./src/components/lists/pagedlist.tsx","./src/components/lists/usepulltorefresh.ts","./src/components/nav/datestrip.tsx","./src/components/nav/pagetabs.tsx","./src/components/nav/stickytotalbar.tsx","./src/components/overlays/bottomsheet.tsx","./src/components/overlays/confirmdialog.tsx","./src/components/overlays/modal.tsx","./src/components/status/statuschip.tsx","./src/features/admin/settingspage.tsx","./src/features/admin/staffpage.tsx","./src/features/admin/routes.tsx","./src/features/admin/useadminnav.ts","./src/features/admin/components/createemployeesheet.tsx","./src/features/admin/components/holidayssection.tsx","./src/features/admin/components/permissionlabels.ts","./src/features/admin/components/permissionssheet.tsx","./src/features/admin/components/staffrow.tsx","./src/features/analytics/dashboardpage.tsx","./src/features/analytics/daterangesheet.tsx","./src/features/analytics/rangecontrol.tsx","./src/features/analytics/reportspage.tsx","./src/features/analytics/unitqtychips.tsx","./src/features/analytics/charts.tsx","./src/features/analytics/controls.tsx","./src/features/analytics/index.ts","./src/features/analytics/routes.tsx","./src/features/analytics/unitqty.ts","./src/features/analytics/unittypes.ts","./src/features/analytics/usedaterange.ts","./src/features/auth/routes.tsx","./src/features/auth/pages/loginpage.tsx","./src/features/billing/billdetailpage.tsx","./src/features/billing/billingpage.tsx","./src/features/billing/index.ts","./src/features/billing/routes.tsx","./src/features/billing/components/adjustmentsheet.tsx","./src/features/billing/components/billlistcard.tsx","./src/features/billing/components/billslisttab.tsx","./src/features/billing/components/generatetab.tsx","./src/features/billing/components/recordpaymentsheet.tsx","./src/features/billing/components/statuschangesheet.tsx","./src/features/billing/hooks/usebillingshell.ts","./src/features/billing/hooks/usegeneratebills.ts","./src/features/billing/hooks/usegeneratepreview.ts","./src/features/billing/lib/billlineunit.ts","./src/features/catalog/routes.tsx","./src/features/catalog/unit.ts","./src/features/catalog/usecatalognav.ts","./src/features/catalog/components/addpricesheet.tsx","./src/features/catalog/components/pricerow.tsx","./src/features/catalog/components/productformsheet.tsx","./src/features/catalog/components/resolveratepreview.tsx","./src/features/catalog/pages/productpricespage.tsx","./src/features/catalog/pages/productslistpage.tsx","./src/features/customers/routes.tsx","./src/features/customers/components/consumptionbreakdown.tsx","./src/features/customers/components/customerformsheet.tsx","./src/features/customers/components/subscriptionformsheet.tsx","./src/features/customers/hooks/useappnav.ts","./src/features/customers/hooks/usecustomeroutstanding.ts","./src/features/customers/lib/format.ts","./src/features/customers/lib/units.ts","./src/features/customers/pages/customerdetailpage.tsx","./src/features/customers/pages/customerslistpage.tsx","./src/features/payments/index.ts","./src/features/payments/routes.tsx","./src/features/payments/components/paymentfilterssheet.tsx","./src/features/payments/components/recordpaymentsheet.tsx","./src/features/payments/hooks/usepaymentsfeature.ts","./src/features/payments/pages/paymentslistpage.tsx","./src/features/route/routes.tsx","./src/features/route/components/recorddeliverysheet.tsx","./src/features/route/components/routesection.tsx","./src/features/route/components/submitconfirmsheet.tsx","./src/features/route/hooks/useonline.ts","./src/features/route/hooks/useroutedraft.ts","./src/features/route/pages/routepage.tsx","./src/features/superadmin/adminspage.tsx","./src/features/superadmin/orgspage.tsx","./src/features/superadmin/index.ts","./src/features/superadmin/routes.tsx","./src/features/superadmin/components/adminrow.tsx","./src/features/superadmin/components/createadminsheet.tsx","./src/features/superadmin/components/orgcard.tsx","./src/features/superadmin/components/orgpickersheet.tsx","./src/features/superadmin/hooks/useorgpicker.ts","./src/features/superadmin/hooks/usesuperadminnav.ts","./src/features/superadmin/lib/seedorg.ts","./src/hooks/usekeyboardaware.ts","./src/hooks/usereducedmotion.ts","./src/lib/env.ts","./src/lib/formerrors.ts","./src/lib/unit.ts","./src/routes/homeplaceholder.tsx","./src/routes/morepage.tsx","./src/routes/preview.tsx","./src/routes/lazypage.tsx","./src/theme/thememodeprovider.tsx","./src/theme/index.ts","./src/theme/motion.ts","./src/theme/palette.ts","./src/theme/theme.d.ts","./src/theme/m3/generate.ts","./src/theme/m3/roles.ts"],"version":"5.9.3"}