Spaces:
Runtime error
Runtime error
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 filesBuild-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 +239 -0
- M3_THEME_PLAN.md +221 -0
- frontend/PWA/design/m3/current-components.md +304 -0
- frontend/PWA/design/m3/current-theme.md +155 -0
- frontend/PWA/design/m3/feasibility.md +90 -0
- frontend/PWA/design/m3/m3-spec.md +253 -0
- frontend/PWA/index.html +3 -8
- frontend/PWA/package-lock.json +17 -0
- frontend/PWA/package.json +2 -0
- frontend/PWA/scripts/gen-m3.mjs +33 -0
- frontend/PWA/src/main.tsx +13 -8
- frontend/PWA/src/styles/global.css +56 -0
- frontend/PWA/src/theme/ThemeModeProvider.tsx +83 -0
- frontend/PWA/src/theme/index.ts +212 -93
- frontend/PWA/src/theme/m3/generate.ts +340 -0
- frontend/PWA/src/theme/m3/roles.ts +199 -0
- frontend/PWA/src/theme/m3/tokens.css +199 -0
- frontend/PWA/src/theme/motion.ts +60 -12
- frontend/PWA/src/theme/palette.ts +118 -38
- frontend/PWA/src/theme/theme.d.ts +91 -16
- frontend/PWA/tsconfig.app.tsbuildinfo +1 -1
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 |
-
<
|
| 12 |
-
|
| 13 |
-
<
|
| 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 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
import '@fontsource/
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 24 |
<CssBaseline />
|
| 25 |
<QueryClientProvider client={queryClient}>
|
| 26 |
<ToastProvider>
|
|
@@ -31,6 +36,6 @@ createRoot(rootEl).render(
|
|
| 31 |
</BrowserRouter>
|
| 32 |
</ToastProvider>
|
| 33 |
</QueryClientProvider>
|
| 34 |
-
</
|
| 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 {
|
| 3 |
-
import { motion } from './motion';
|
|
|
|
|
|
|
| 4 |
import './theme.d';
|
| 5 |
|
| 6 |
-
//
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
|
|
|
|
| 9 |
const fontFamily = [
|
| 10 |
-
'
|
|
|
|
|
|
|
| 11 |
'-apple-system',
|
| 12 |
'"Segoe UI"',
|
| 13 |
-
'Roboto',
|
| 14 |
'sans-serif',
|
| 15 |
].join(',');
|
| 16 |
|
| 17 |
-
//
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 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
|
| 30 |
-
palette
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 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 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
},
|
| 76 |
},
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
},
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
| 86 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
},
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
MuiOutlinedInput: {
|
| 92 |
-
styleOverrides: {
|
| 93 |
-
root: { borderRadius: radius.md, fontSize: 16 },
|
| 94 |
},
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 20 |
export const easing = {
|
| 21 |
-
fast: 'cubic-bezier(0,0,0.2,1)', // easeOut
|
| 22 |
-
base: 'cubic-bezier(.2,
|
| 23 |
-
page: 'cubic-bezier(.2,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 29 |
page: [0.2, 0, 0, 1] as [number, number, number, number],
|
| 30 |
} as const;
|
| 31 |
|
| 32 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2 |
-
//
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
//
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 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"}
|