diff --git a/src/components/AppDialog.tsx b/src/components/AppDialog.tsx new file mode 100644 index 0000000..fd989ab --- /dev/null +++ b/src/components/AppDialog.tsx @@ -0,0 +1,108 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * PHASE 6 — Astryx's Dialog, plus the one behaviour it does not provide. + * + * Dialog gives Escape, a focus trap and a scroll lock. It does NOT restore + * focus when it closes: verified in the browser, focus lands on , which + * drops a keyboard user back to the top of the document every time they dismiss + * a modal. On screens like the staff panel or the order board — where a dialog + * is opened from a row action, dismissed, and the next row acted on — that + * means re-tabbing through the whole page between every edit. + * + * This wrapper captures whatever was focused at the moment the dialog opened + * and hands focus back on close. Everything else passes straight through, so a + * call site swaps `Dialog` for `AppDialog` and changes nothing else. + * + * When Astryx adds focus restoration upstream, delete this file and change the + * imports back — nothing else here depends on it. + */ + +import { useEffect, useRef } from 'react'; +import { Dialog, type DialogProps } from '@astryxdesign/core/Dialog'; + +/** + * Hands focus back to `el`, retrying briefly until it takes. + * + * Two things make this less trivial than one `el.focus()`: + * + * 1. Timing. Closing a native makes the browser run its own focus + * fixup, and Astryx unwinds its focus trap on top of that. A restore that + * lands inside either pass is overwritten and focus ends up on — + * the exact bug this file exists to fix. + * 2. Scheduler. The obvious tool is requestAnimationFrame, and it is the wrong + * one: Chrome does not fire rAF in a background tab, so the restore silently + * never happened. Timers keep running (throttled), so they are used instead. + * + * Attempts stop as soon as focus lands, and give up quietly if the opener has + * been removed from the document meanwhile. + */ +function restoreFocusSoon(el: HTMLElement) { + const attempt = () => { + if (document.activeElement === el) return true; + if (!document.contains(el)) return true; // opener is gone; nothing to aim at + el.focus?.(); + return document.activeElement === el; + }; + if (attempt()) return; + for (const delay of [0, 30, 90]) { + setTimeout(() => { attempt(); }, delay); + } +} + +export function AppDialog({ isOpen, ...rest }: DialogProps) { + /** What had focus when this dialog opened. */ + const openerRef = useRef(null); + /** Distinguishes "closed because it was dismissed" from "closed since mount". */ + const wasOpenRef = useRef(false); + + // Captured during render, NOT in an effect — and that is the whole trick. + // + // React runs child effects before parent ones, and AppDialog is the parent of + // Dialog. An effect here therefore fires *after* Astryx has already pulled + // focus into the dialog, so it captures the dialog's own heading rather than + // the control that opened it — and that heading is unmounted moments later, + // so the restore silently no-ops and focus lands on . Which is exactly + // the symptom this file was written to cure, reproduced by the cure. + // + // Render runs before any of that. Reading document.activeElement here is a + // side-effect-free DOM read into a ref, and the guard makes it idempotent + // under StrictMode's double render. + if (isOpen && !wasOpenRef.current) { + openerRef.current = document.activeElement as HTMLElement | null; + wasOpenRef.current = true; + } + + useEffect(() => { + if (isOpen) return; + if (!wasOpenRef.current) return; + wasOpenRef.current = false; + + const opener = openerRef.current; + openerRef.current = null; + // The opener can be gone by now — a row action whose row the dialog just + // deleted, for one. Restoring focus to a detached node silently sends it + // to , which is the bug this exists to fix. + if (!opener || !document.contains(opener)) return; + restoreFocusSoon(opener); + }, [isOpen]); + + // Unmounting while open is how MOST of these actually close. Several call + // sites — PosStaffPanel's Modal, PosView's PosModal — pass a constant + // `isOpen` and mount the dialog conditionally instead (`{editing && }`), + // so the effect above never sees a true→false transition and this is the only + // path that runs for them. + useEffect(() => { + return () => { + const opener = openerRef.current; + if (wasOpenRef.current && opener && document.contains(opener)) restoreFocusSoon(opener); + }; + }, []); + + return ; +} + +export default AppDialog; diff --git a/src/components/CatalogueGallery.tsx b/src/components/CatalogueGallery.tsx index 91e03bb..caa0ea6 100644 --- a/src/components/CatalogueGallery.tsx +++ b/src/components/CatalogueGallery.tsx @@ -20,6 +20,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Box, ChevronLeft, ChevronRight, X } from 'lucide-react'; import { IconButton } from '@astryxdesign/core/IconButton'; +import { AppDialog } from './AppDialog'; /** * The image on a browse card, with the rest reachable. @@ -136,29 +137,39 @@ export function Lightbox({ [index, images.length, onIndex], ); + // PHASE 5 — only the arrow keys are hand-wired now. + // + // Escape and the body scroll lock used to live here too, and the lock had to + // save/restore the previous value rather than clear it, because the import + // drawer beneath this one locks the body as well. Dialog owns both now, and + // owns the unwinding order with them, so that whole balancing act is gone. useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - else if (e.key === 'ArrowRight') move(1); + if (e.key === 'ArrowRight') move(1); else if (e.key === 'ArrowLeft') move(-1); }; window.addEventListener('keydown', onKey); - // Restored rather than cleared: the import modal beneath this one also - // locks the body, and clearing outright would let the page behind it - // scroll once this closes. - const prev = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - return () => { - window.removeEventListener('keydown', onKey); - document.body.style.overflow = prev; - }; - }, [move, onClose]); + return () => window.removeEventListener('keydown', onKey); + }, [move]); if (!images.length) return null; + // Renders in the top layer, so it sits above the import drawer without the + // z-[400] this used to need. return ( -
-
+ { if (!open) onClose(); }} + // Dialog paints a light surface. A photo viewer needs the opposite: every + // control in here is white-on-dark, and on the default ground the close + // button, the counter and the arrows all vanished into the background. + style={{ background: 'rgb(2 6 23 / 0.95)', padding: 0 }} + > + {/* Fills the dialog so the floating close has something to anchor to — + `absolute` against the itself lands outside the painted box. */} +
} @@ -216,7 +227,8 @@ export function Lightbox({ )}
-
+
+ ); } diff --git a/src/components/ComparisonModal.tsx b/src/components/ComparisonModal.tsx index 5e19fc0..91b9afb 100644 --- a/src/components/ComparisonModal.tsx +++ b/src/components/ComparisonModal.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'; import { X, CheckCircle2, ArrowRight } from 'lucide-react'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; +import { AppDialog } from './AppDialog'; import { useCompare } from '../contexts/CompareContext'; export default function ComparisonModal() { @@ -51,10 +52,23 @@ export default function ComparisonModal() { )} - {/* Comparison Drawer */} + {/* PHASE 5 — was a hand-rolled `fixed inset-0` bottom sheet with its own + backdrop, no Escape and no focus trap. Now a fullscreen Dialog, which + is the closest shape to a sheet that fills the width. + + NOTE: this could not be exercised in the browser, because nothing can + open it — `toggleProduct` in CompareContext is never called anywhere + in the app, so `selectedProducts` is always empty and the component + returns null above. The conversion typechecks and builds; it has not + been seen running. */} {isComparing && ( -
-
+ { if (!open) setIsComparing(false); }} + > +
{/* Header */}
@@ -242,7 +256,7 @@ export default function ComparisonModal() {
-
+ )} , document.body diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index d085162..84b5a3e 100644 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { AlertTriangle } from 'lucide-react'; -import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; +import { DialogHeader } from '@astryxdesign/core/Dialog'; +import { AppDialog } from './AppDialog'; import { Button } from '@astryxdesign/core/Button'; /** @@ -46,7 +47,7 @@ export function ConfirmModal({ isLoading = false, }: ConfirmModalProps) { return ( - { // Ignore every close request while a mutation is running — the original @@ -79,7 +80,7 @@ export function ConfirmModal({ onClick={onConfirm} />
-
+ ); } diff --git a/src/components/DeliveriesView.tsx b/src/components/DeliveriesView.tsx index 299c27f..c9ea704 100644 --- a/src/components/DeliveriesView.tsx +++ b/src/components/DeliveriesView.tsx @@ -23,7 +23,7 @@ import { shortTime } from '../services/fiestaMappers'; import AwaitingApi from './AwaitingApi'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; -import { Dialog } from '@astryxdesign/core/Dialog'; +import { AppDialog } from './AppDialog'; import { DateInput } from '@astryxdesign/core/DateInput'; import type { ISODateString } from '@astryxdesign/core/utils'; import { Selector } from '@astryxdesign/core/Selector'; @@ -441,7 +441,7 @@ function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[] // page sits inside several, so the panel used to collapse. Dialog renders in // the browser's top layer, which no ancestor can contain. return ( - { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info"> + { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
@@ -495,6 +495,6 @@ function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]
-
+ ); } diff --git a/src/components/OfflineSalesUpload.tsx b/src/components/OfflineSalesUpload.tsx index 4b8500c..3eb6fbf 100644 --- a/src/components/OfflineSalesUpload.tsx +++ b/src/components/OfflineSalesUpload.tsx @@ -55,6 +55,7 @@ import { import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; import { FileInput } from '@astryxdesign/core/FileInput'; +import { AppDialog } from './AppDialog'; import { Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, } from '@astryxdesign/core/Table'; @@ -176,11 +177,19 @@ export default function OfflineSalesUpload({ ? template?.locations[0]?.locationname || 'your store' : `all ${storeCount} stores`; + // PHASE 5 — was a hand-rolled `fixed inset-0` with its own backdrop div and a + // click-to-dismiss handler on it. Dialog supplies the backdrop, the top-layer + // placement, Escape and the focus trap; the width and max-height are passed + // through so the panel keeps the size it had. return ( -
-
- -
+ { if (!open) onClose(); }} + > +
@@ -507,7 +516,7 @@ export default function OfflineSalesUpload({
)}
-
+
); } diff --git a/src/components/OrdersDeliveriesView.tsx b/src/components/OrdersDeliveriesView.tsx index c5013ab..bfe507d 100644 --- a/src/components/OrdersDeliveriesView.tsx +++ b/src/components/OrdersDeliveriesView.tsx @@ -33,7 +33,7 @@ import { } from './consoleUi'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; -import { Dialog } from '@astryxdesign/core/Dialog'; +import { AppDialog } from './AppDialog'; import { DateInput } from '@astryxdesign/core/DateInput'; import type { ISODateString } from '@astryxdesign/core/utils'; import { Heading } from '@astryxdesign/core/Heading'; @@ -426,7 +426,7 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void // the panel used to collapse. Dialog renders in the browser's top layer, which // no ancestor can contain. return ( - { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info"> + { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
{/* Modal header */}
@@ -532,6 +532,6 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
-
+ ); } diff --git a/src/components/OrdersView.tsx b/src/components/OrdersView.tsx index 4551fd7..ed7f20c 100644 --- a/src/components/OrdersView.tsx +++ b/src/components/OrdersView.tsx @@ -26,7 +26,7 @@ import { } from './consoleUi'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; -import { Dialog } from '@astryxdesign/core/Dialog'; +import { AppDialog } from './AppDialog'; import { DateInput } from '@astryxdesign/core/DateInput'; // DateInput's value is a branded `YYYY-MM-DD` template-literal type, while our // dates are plain strings out of `ymd()`. Importing the real type keeps the cast @@ -724,7 +724,7 @@ function SelectedOrdersPage({ // it applies to every Dialog in the app, and it is a missing gain rather // than a regression (the old overlay managed no focus at all), so it is not // patched here one screen at a time. - { if (!open) onClose(); }} variant="fullscreen" purpose="info"> + { if (!open) onClose(); }} variant="fullscreen" purpose="info">
{/* Sticky page header with the assign controls */}
@@ -820,7 +820,7 @@ function SelectedOrdersPage({ )}
-
+ ); } @@ -852,7 +852,7 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void // collapse to a sliver. Dialog renders in the browser's top layer, which no // ancestor can contain — so the escape hatches are no longer needed. return ( - { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info"> + { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
@@ -895,6 +895,6 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
-
+ ); } diff --git a/src/components/PosStaffPanel.tsx b/src/components/PosStaffPanel.tsx index f781de6..ca524e5 100644 --- a/src/components/PosStaffPanel.tsx +++ b/src/components/PosStaffPanel.tsx @@ -24,7 +24,8 @@ import React, { useMemo, useState } from 'react'; import { TextInput } from '@astryxdesign/core/TextInput'; import { Selector } from '@astryxdesign/core/Selector'; import { Button } from '@astryxdesign/core/Button'; -import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; +import { DialogHeader } from '@astryxdesign/core/Dialog'; +import { AppDialog } from './AppDialog'; import { TimeInput } from '@astryxdesign/core/TimeInput'; import { IconButton } from '@astryxdesign/core/IconButton'; import { ToggleButton } from '@astryxdesign/core/ToggleButton'; @@ -967,10 +968,10 @@ function Modal({ // from a `width` prop rather than a Tailwind utility — so the collision that // bug came from cannot reach it. return ( - { if (!open) onClose(); }} width={400} purpose="info"> + { if (!open) onClose(); }} width={400} purpose="info"> {/* Names come from user input and can be long and unbroken. */} { if (!open) onClose(); }} /> {children} - + ); } diff --git a/src/components/PosView.tsx b/src/components/PosView.tsx index e9b55db..e64b008 100644 --- a/src/components/PosView.tsx +++ b/src/components/PosView.tsx @@ -3,7 +3,7 @@ import { Search, Plus, Trash2, Receipt, X, Tag, QrCode, CreditCard, PieChart, Ba import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries'; import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; import { useNotify } from './Toasts'; -import { Dialog } from '@astryxdesign/core/Dialog'; +import { AppDialog } from './AppDialog'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; import { TextInput } from '@astryxdesign/core/TextInput'; @@ -47,7 +47,7 @@ function PosModal({ children: React.ReactNode; }) { return ( - { if (!open) onClose(); }} width={width} @@ -55,7 +55,7 @@ function PosModal({ purpose="info" > {children} - + ); } diff --git a/src/components/ReportsView.tsx b/src/components/ReportsView.tsx index 5bedf0f..1648137 100644 --- a/src/components/ReportsView.tsx +++ b/src/components/ReportsView.tsx @@ -41,6 +41,8 @@ import { stockRowToProduct } from '../services/fiestaMappers'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; import { Selector } from '@astryxdesign/core/Selector'; +import { ProgressBar } from '@astryxdesign/core/ProgressBar'; +import { AppDialog } from './AppDialog'; import { Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, } from '@astryxdesign/core/Table'; @@ -1156,10 +1158,22 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
- {/* EXPORTING DIALOG MODAL */} + {/* PHASE 5 — the export progress dialog, was a hand-rolled `fixed inset-0`. + `purpose="required"` rather than "info": this one has no dismiss + affordance and never had, because the export is already running and + there is nothing to cancel it with. Blocking Escape matches what the + overlay did; it unmounts itself when the export finishes, so the + latching that `required` does cannot strand anyone. */} {exportingFormat && ( -
-
+ {}} + > +
@@ -1170,16 +1184,20 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba Compiling database records and SVG vector curves...

- {/* Progress track */} -
-
-
- {exportProgress}% + {/* Was two nested divs with a width percentage — visible progress + that reported nothing. ProgressBar carries role="progressbar" + and aria-valuenow, so the percentage is now announced as it + moves rather than only drawn. */} +
-
+
)} ) : ( diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 4dd24a9..dc66b6c 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -27,7 +27,7 @@ import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; import { TextInput } from '@astryxdesign/core/TextInput'; import { Selector } from '@astryxdesign/core/Selector'; -import { Dialog } from '@astryxdesign/core/Dialog'; +import { AppDialog } from './AppDialog'; import { Badge } from '@astryxdesign/core/Badge'; import { Heading } from '@astryxdesign/core/Heading'; import { EmptyState } from '@astryxdesign/core/EmptyState'; @@ -52,7 +52,7 @@ function SettingsModal({ children: React.ReactNode; }) { return ( - { if (!open) onClose(); }} width={width} @@ -60,7 +60,7 @@ function SettingsModal({ purpose="info" > {children} - + ); } diff --git a/src/components/StoreCatalogView.tsx b/src/components/StoreCatalogView.tsx index 8fce0c3..738ebc5 100644 --- a/src/components/StoreCatalogView.tsx +++ b/src/components/StoreCatalogView.tsx @@ -42,7 +42,7 @@ import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; import { Badge } from '@astryxdesign/core/Badge'; -import { Dialog } from '@astryxdesign/core/Dialog'; +import { AppDialog } from './AppDialog'; import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; import { Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, @@ -65,7 +65,7 @@ function CatalogueModal({ children: React.ReactNode; }) { return ( - { if (!open) onClose(); }} width={width} @@ -73,7 +73,7 @@ function CatalogueModal({ purpose="info" > {children} - + ); } diff --git a/src/components/StoreDetailView.tsx b/src/components/StoreDetailView.tsx index ef9a34e..9767345 100644 --- a/src/components/StoreDetailView.tsx +++ b/src/components/StoreDetailView.tsx @@ -53,7 +53,7 @@ import { IconButton } from '@astryxdesign/core/IconButton'; import { TextInput } from '@astryxdesign/core/TextInput'; import { TextArea } from '@astryxdesign/core/TextArea'; import { NumberInput } from '@astryxdesign/core/NumberInput'; -import { Dialog } from '@astryxdesign/core/Dialog'; +import { AppDialog } from './AppDialog'; import { TabList, Tab } from '@astryxdesign/core/TabList'; import { Badge } from '@astryxdesign/core/Badge'; import { ProgressBar } from '@astryxdesign/core/ProgressBar'; @@ -96,7 +96,7 @@ function StoreModal({ children: React.ReactNode; }) { return ( - { if (!open && isDismissable) onClose(); }} width={width} @@ -109,7 +109,7 @@ function StoreModal({ purpose={isDismissable ? 'info' : 'required'} > {children} - + ); } diff --git a/src/components/UserStorePage.tsx b/src/components/UserStorePage.tsx index 9898a9c..f681b49 100644 --- a/src/components/UserStorePage.tsx +++ b/src/components/UserStorePage.tsx @@ -4,7 +4,6 @@ */ import React, { useState } from 'react'; -import { createPortal } from 'react-dom'; import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { AlertTriangle, @@ -30,6 +29,8 @@ import { } from '../services/fiestaQueries'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; +import { DialogHeader } from '@astryxdesign/core/Dialog'; +import { AppDialog } from './AppDialog'; import { str as fstr, num as fnum, roleName, ymd } from '../services/fiestaApi'; import type { AuthUser } from '../services/auth'; import Header from './Header'; @@ -425,25 +426,28 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
- {/* Store QR — centered modal opened from the navbar QR button. Portaled to - body so `fixed inset-0` is viewport-relative regardless of ancestors. */} - {showQrModal && - createPortal( -
{ if (e.target === e.currentTarget) setShowQrModal(false); }} + {/* PHASE 5 — Store QR, opened from the navbar QR button. + Was a hand-rolled `fixed inset-0` portaled to body, which is what it + took to make the overlay viewport-relative regardless of ancestors. + Dialog renders in the top layer, so neither the portal nor the manual + backdrop is needed — and it brings Escape and the focus trap the + hand-rolled version never had. */} + {showQrModal && ( + { if (!open) setShowQrModal(false); }} > -
- } - label="Close store QR code" - tooltip="Close" - variant="ghost" - onClick={() => setShowQrModal(false)} - style={{ height: 'auto', width: 'auto', minWidth: 0 }} - className="absolute top-3 right-3 z-10 p-1.5 rounded-full bg-white/15 hover:bg-white/25 text-white ring-1 ring-white/25 backdrop-blur-sm cursor-pointer transition-colors" - /> + {/* The close is DialogHeader's, not a hand-placed one. An + absolutely-positioned button here lands outside the painted + surface: the element is wider and taller than the card + Astryx paints inside it, so `absolute top-3 right-3` against + this wrapper put the X off the card entirely — and the same + mismatch clipped the Download button off the bottom. */} + { if (!open) setShowQrModal(false); }} /> +
-
, - document.body, - )} +
+ )}
diff --git a/src/components/UsersPanel.tsx b/src/components/UsersPanel.tsx index e1bfb05..98b8fd1 100644 --- a/src/components/UsersPanel.tsx +++ b/src/components/UsersPanel.tsx @@ -42,7 +42,7 @@ import { TextInput } from '@astryxdesign/core/TextInput'; import { Selector } from '@astryxdesign/core/Selector'; import { RadioList, RadioListItem } from '@astryxdesign/core/RadioList'; import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; -import { Dialog } from '@astryxdesign/core/Dialog'; +import { AppDialog } from './AppDialog'; import { Heading } from '@astryxdesign/core/Heading'; import { EmptyState } from '@astryxdesign/core/EmptyState'; import { Spinner } from '@astryxdesign/core/Spinner'; @@ -65,7 +65,7 @@ function UsersModal({ children: React.ReactNode; }) { return ( - { if (!open) onClose(); }} width={width} @@ -73,7 +73,7 @@ function UsersModal({ purpose="info" > {children} - + ); } diff --git a/src/main.tsx b/src/main.tsx index 0ca5718..ca0f205 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -51,14 +51,33 @@ createRoot(document.getElementById('root')!).render( The theme carries a full dark palette and `mode="system"` does work — but only Astryx-backed components respond to it. Everything still on raw - Tailwind is pinned to the light scheme by hardcoded classes: KpiStrip - renders `bg-white text-slate-900`, SearchPill sets `color: TEXT` - (#0f172a). Switching to "system" today means any user whose OS is dark - gets white cards on a dark page and a search box with black text on a - black field. Verified in the browser, not assumed. + Tailwind or inline hex is pinned to the light scheme. - Flip this to "system" once Phase 6 has removed the hardcoded colours. - That single word is the dark-mode launch switch. + Measured, not assumed — 2026-08-14, by flipping this to "dark" and + looking: + + - The page ground stays white. Astryx's own surfaces (table rules, card + borders, dialog chrome) go dark and sit on it, so the result is a + light app wearing dark furniture rather than a dark app. + - On /store/pos-till the scan field becomes near-black text on a + near-black field, and Browse / Refund / Park / Retrieve / Report all + wash out to unreadable grey. That screen is a till: it is the worst + possible one to render illegible. + + The scale of what has to change first, counted across src/**: + + 2468 colour references in 53 files + 174 bg-white + 1400 pinned #rrggbb (685 of them in DispatchView.css alone) + 894 text-/bg-/border-slate|zinc|gray-NNN + + So this is not a tail-end cleanup of Phase 6 — it is its own body of work, + and it needs a token layer plus a brand decision before it can start: our + BRAND #662582 fails contrast on a dark ground, and no lightened accent has + been signed off. + + Flip this to "system" once that lands. That single word is the dark-mode + launch switch. Outermost so nothing above it renders unthemed, but inside StrictMode so it gets the same double-render checks as everything else.