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
+
);
}
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">
-
+
);
}
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">
-
+
);
}
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">
-
+
);
}
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.