This commit is contained in:
2026-08-14 15:28:06 +05:30
parent cd079950eb
commit 8b187b1c1d
17 changed files with 282 additions and 97 deletions

View File

@@ -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 <body>, 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 <dialog> 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 <body> —
* 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<HTMLElement | null>(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 <body>. 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 <body>, 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 && <Modal/>}`),
// 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 <Dialog isOpen={isOpen} {...rest} />;
}
export default AppDialog;

View File

@@ -20,6 +20,7 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Box, ChevronLeft, ChevronRight, X } from 'lucide-react'; import { Box, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { AppDialog } from './AppDialog';
/** /**
* The image on a browse card, with the rest reachable. * The image on a browse card, with the rest reachable.
@@ -136,29 +137,39 @@ export function Lightbox({
[index, images.length, onIndex], [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(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === 'ArrowRight') move(1);
else if (e.key === 'ArrowRight') move(1);
else if (e.key === 'ArrowLeft') move(-1); else if (e.key === 'ArrowLeft') move(-1);
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
// Restored rather than cleared: the import modal beneath this one also return () => window.removeEventListener('keydown', onKey);
// locks the body, and clearing outright would let the page behind it }, [move]);
// 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]);
if (!images.length) return null; 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 ( return (
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4"> <AppDialog
<div className="absolute inset-0 bg-slate-950/85" onClick={onClose} /> isOpen
variant="fullscreen"
purpose="info"
onOpenChange={(open) => { 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 <dialog> itself lands outside the painted box. */}
<div className="relative w-full h-full flex items-center justify-center p-4">
<IconButton <IconButton
icon={<X size={18} />} icon={<X size={18} />}
@@ -216,7 +227,8 @@ export function Lightbox({
</> </>
)} )}
</div> </div>
</div> </div>
</AppDialog>
); );
} }

View File

@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { X, CheckCircle2, ArrowRight } from 'lucide-react'; import { X, CheckCircle2, ArrowRight } from 'lucide-react';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { AppDialog } from './AppDialog';
import { useCompare } from '../contexts/CompareContext'; import { useCompare } from '../contexts/CompareContext';
export default function ComparisonModal() { export default function ComparisonModal() {
@@ -51,10 +52,23 @@ export default function ComparisonModal() {
</div> </div>
)} )}
{/* 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 && ( {isComparing && (
<div className="fixed inset-0 bg-[#0f172a]/60 backdrop-blur-md z-[250] flex flex-col justify-end"> <AppDialog
<div className="bg-white border-t border-[#e2e8f0] w-full h-[80vh] sm:h-[70vh] shadow-[0_-20px_50px_rgba(0,0,0,0.15)] rounded-t-3xl overflow-hidden flex flex-col animate-in slide-in-from-bottom duration-300"> isOpen
variant="fullscreen"
purpose="info"
onOpenChange={(open) => { if (!open) setIsComparing(false); }}
>
<div className="bg-white w-full h-full overflow-hidden flex flex-col">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-5 border-b border-slate-100 bg-slate-50 shrink-0"> <div className="flex items-center justify-between p-5 border-b border-slate-100 bg-slate-50 shrink-0">
@@ -242,7 +256,7 @@ export default function ComparisonModal() {
</div> </div>
</div> </div>
</div> </AppDialog>
)} )}
</>, </>,
document.body document.body

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { AlertTriangle } from 'lucide-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'; import { Button } from '@astryxdesign/core/Button';
/** /**
@@ -46,7 +47,7 @@ export function ConfirmModal({
isLoading = false, isLoading = false,
}: ConfirmModalProps) { }: ConfirmModalProps) {
return ( return (
<Dialog <AppDialog
isOpen={isOpen} isOpen={isOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
// Ignore every close request while a mutation is running — the original // Ignore every close request while a mutation is running — the original
@@ -79,7 +80,7 @@ export function ConfirmModal({
onClick={onConfirm} onClick={onConfirm}
/> />
</div> </div>
</Dialog> </AppDialog>
); );
} }

View File

@@ -23,7 +23,7 @@ import { shortTime } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi'; import AwaitingApi from './AwaitingApi';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { Dialog } from '@astryxdesign/core/Dialog'; import { AppDialog } from './AppDialog';
import { DateInput } from '@astryxdesign/core/DateInput'; import { DateInput } from '@astryxdesign/core/DateInput';
import type { ISODateString } from '@astryxdesign/core/utils'; import type { ISODateString } from '@astryxdesign/core/utils';
import { Selector } from '@astryxdesign/core/Selector'; 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 // page sits inside several, so the panel used to collapse. Dialog renders in
// the browser's top layer, which no ancestor can contain. // the browser's top layer, which no ancestor can contain.
return ( return (
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info"> <AppDialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
<div className="flex flex-col max-h-[90vh]"> <div className="flex flex-col max-h-[90vh]">
<div className="p-4 flex justify-between items-center shrink-0"> <div className="p-4 flex justify-between items-center shrink-0">
<Heading level={3}> <Heading level={3}>
@@ -495,6 +495,6 @@ function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]
<Button label="Close" onClick={onClose} /> <Button label="Close" onClick={onClose} />
</div> </div>
</div> </div>
</Dialog> </AppDialog>
); );
} }

View File

@@ -55,6 +55,7 @@ import {
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { FileInput } from '@astryxdesign/core/FileInput'; import { FileInput } from '@astryxdesign/core/FileInput';
import { AppDialog } from './AppDialog';
import { import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table'; } from '@astryxdesign/core/Table';
@@ -176,11 +177,19 @@ export default function OfflineSalesUpload({
? template?.locations[0]?.locationname || 'your store' ? template?.locations[0]?.locationname || 'your store'
: `all ${storeCount} stores`; : `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 ( return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4"> <AppDialog
<div className="absolute inset-0 bg-slate-900/50" onClick={onClose} /> isOpen
width={1152}
<div className="relative z-10 flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl bg-white shadow-2xl"> maxHeight="92vh"
purpose="info"
onOpenChange={(open) => { if (!open) onClose(); }}
>
<div className="flex w-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-[#662582] px-6 py-4"> <div className="flex shrink-0 items-center justify-between bg-[#662582] px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<FileSpreadsheet size={20} className="text-white/90" /> <FileSpreadsheet size={20} className="text-white/90" />
@@ -507,7 +516,7 @@ export default function OfflineSalesUpload({
</div> </div>
)} )}
</div> </div>
</div> </AppDialog>
); );
} }

View File

@@ -33,7 +33,7 @@ import {
} from './consoleUi'; } from './consoleUi';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { Dialog } from '@astryxdesign/core/Dialog'; import { AppDialog } from './AppDialog';
import { DateInput } from '@astryxdesign/core/DateInput'; import { DateInput } from '@astryxdesign/core/DateInput';
import type { ISODateString } from '@astryxdesign/core/utils'; import type { ISODateString } from '@astryxdesign/core/utils';
import { Heading } from '@astryxdesign/core/Heading'; 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 // the panel used to collapse. Dialog renders in the browser's top layer, which
// no ancestor can contain. // no ancestor can contain.
return ( return (
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info"> <AppDialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
<div className="flex flex-col max-h-[90vh]"> <div className="flex flex-col max-h-[90vh]">
{/* Modal header */} {/* Modal header */}
<div className="p-4 flex justify-between items-center shrink-0"> <div className="p-4 flex justify-between items-center shrink-0">
@@ -532,6 +532,6 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
<Button label="Close" onClick={onClose} /> <Button label="Close" onClick={onClose} />
</div> </div>
</div> </div>
</Dialog> </AppDialog>
); );
} }

View File

@@ -26,7 +26,7 @@ import {
} from './consoleUi'; } from './consoleUi';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { Dialog } from '@astryxdesign/core/Dialog'; import { AppDialog } from './AppDialog';
import { DateInput } from '@astryxdesign/core/DateInput'; import { DateInput } from '@astryxdesign/core/DateInput';
// DateInput's value is a branded `YYYY-MM-DD` template-literal type, while our // 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 // 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 // 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 // than a regression (the old overlay managed no focus at all), so it is not
// patched here one screen at a time. // patched here one screen at a time.
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} variant="fullscreen" purpose="info"> <AppDialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} variant="fullscreen" purpose="info">
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
{/* Sticky page header with the assign controls */} {/* Sticky page header with the assign controls */}
<div className="sticky top-0 z-10 border-b" style={{ background: 'var(--color-background-card, #fff)', borderColor: BORDER }}> <div className="sticky top-0 z-10 border-b" style={{ background: 'var(--color-background-card, #fff)', borderColor: BORDER }}>
@@ -820,7 +820,7 @@ function SelectedOrdersPage({
)} )}
</div> </div>
</div> </div>
</Dialog> </AppDialog>
); );
} }
@@ -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 // 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. // ancestor can contain — so the escape hatches are no longer needed.
return ( return (
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info"> <AppDialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
<div className="flex flex-col max-h-[90vh]"> <div className="flex flex-col max-h-[90vh]">
<div className="p-4 flex justify-between items-center shrink-0"> <div className="p-4 flex justify-between items-center shrink-0">
<Heading level={3}> <Heading level={3}>
@@ -895,6 +895,6 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
<Button label="Close" onClick={onClose} /> <Button label="Close" onClick={onClose} />
</div> </div>
</div> </div>
</Dialog> </AppDialog>
); );
} }

View File

@@ -24,7 +24,8 @@ import React, { useMemo, useState } from 'react';
import { TextInput } from '@astryxdesign/core/TextInput'; import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { Button } from '@astryxdesign/core/Button'; 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 { TimeInput } from '@astryxdesign/core/TimeInput';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { ToggleButton } from '@astryxdesign/core/ToggleButton'; 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 // from a `width` prop rather than a Tailwind utility — so the collision that
// bug came from cannot reach it. // bug came from cannot reach it.
return ( return (
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={400} purpose="info"> <AppDialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={400} purpose="info">
{/* Names come from user input and can be long and unbroken. */} {/* Names come from user input and can be long and unbroken. */}
<DialogHeader title={title} onOpenChange={(open) => { if (!open) onClose(); }} /> <DialogHeader title={title} onOpenChange={(open) => { if (!open) onClose(); }} />
{children} {children}
</Dialog> </AppDialog>
); );
} }

View File

@@ -3,7 +3,7 @@ import { Search, Plus, Trash2, Receipt, X, Tag, QrCode, CreditCard, PieChart, Ba
import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries'; import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
import { useNotify } from './Toasts'; import { useNotify } from './Toasts';
import { Dialog } from '@astryxdesign/core/Dialog'; import { AppDialog } from './AppDialog';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { TextInput } from '@astryxdesign/core/TextInput'; import { TextInput } from '@astryxdesign/core/TextInput';
@@ -47,7 +47,7 @@ function PosModal({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<Dialog <AppDialog
isOpen isOpen
onOpenChange={(open) => { if (!open) onClose(); }} onOpenChange={(open) => { if (!open) onClose(); }}
width={width} width={width}
@@ -55,7 +55,7 @@ function PosModal({
purpose="info" purpose="info"
> >
{children} {children}
</Dialog> </AppDialog>
); );
} }

View File

@@ -41,6 +41,8 @@ import { stockRowToProduct } from '../services/fiestaMappers';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
import { AppDialog } from './AppDialog';
import { import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table'; } from '@astryxdesign/core/Table';
@@ -1156,10 +1158,22 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
</div> </div>
</div> </div>
{/* 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 && ( {exportingFormat && (
<div className="fixed inset-0 bg-[#0f172a]/35 backdrop-blur-sm z-[200] flex items-center justify-center p-md"> <AppDialog
<div className="bg-white border border-[#e2e8f0] rounded-2xl w-full max-w-[20rem] p-lg flex flex-col items-center justify-center shadow-2xl animate-in zoom-in-95 duration-200 text-center font-sans"> isOpen
width={320}
purpose="required"
// Required by the type, and deliberately a no-op: `required` blocks
// every dismissal route anyway, and this closes when the export ends.
onOpenChange={() => {}}
>
<div className="w-full p-lg flex flex-col items-center justify-center text-center font-sans">
<div className="w-12 h-12 rounded-full bg-purple-50 flex items-center justify-center text-[#581c87] mb-md animate-bounce"> <div className="w-12 h-12 rounded-full bg-purple-50 flex items-center justify-center text-[#581c87] mb-md animate-bounce">
<Download size={20} /> <Download size={20} />
</div> </div>
@@ -1170,16 +1184,20 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
Compiling database records and SVG vector curves... Compiling database records and SVG vector curves...
</p> </p>
{/* Progress track */} {/* Was two nested divs with a width percentage — visible progress
<div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden mb-sm relative"> that reported nothing. ProgressBar carries role="progressbar"
<div and aria-valuenow, so the percentage is now announced as it
className="bg-gradient-to-r from-purple-500 to-[#581c87] h-full rounded-full transition-all duration-150" moves rather than only drawn. */}
style={{ width: `${exportProgress}%` }} <ProgressBar
/> label={`Generating ${exportingFormat} report`}
</div> isLabelHidden
<span className="text-xs font-mono font-bold text-purple-750">{exportProgress}%</span> value={exportProgress}
max={100}
hasValueLabel
className="w-full mb-sm"
/>
</div> </div>
</div> </AppDialog>
)} )}
</> </>
) : ( ) : (

View File

@@ -27,7 +27,7 @@ import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { TextInput } from '@astryxdesign/core/TextInput'; import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { Dialog } from '@astryxdesign/core/Dialog'; import { AppDialog } from './AppDialog';
import { Badge } from '@astryxdesign/core/Badge'; import { Badge } from '@astryxdesign/core/Badge';
import { Heading } from '@astryxdesign/core/Heading'; import { Heading } from '@astryxdesign/core/Heading';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
@@ -52,7 +52,7 @@ function SettingsModal({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<Dialog <AppDialog
isOpen isOpen
onOpenChange={(open) => { if (!open) onClose(); }} onOpenChange={(open) => { if (!open) onClose(); }}
width={width} width={width}
@@ -60,7 +60,7 @@ function SettingsModal({
purpose="info" purpose="info"
> >
{children} {children}
</Dialog> </AppDialog>
); );
} }

View File

@@ -42,7 +42,7 @@ import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { Badge } from '@astryxdesign/core/Badge'; import { Badge } from '@astryxdesign/core/Badge';
import { Dialog } from '@astryxdesign/core/Dialog'; import { AppDialog } from './AppDialog';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
@@ -65,7 +65,7 @@ function CatalogueModal({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<Dialog <AppDialog
isOpen isOpen
onOpenChange={(open) => { if (!open) onClose(); }} onOpenChange={(open) => { if (!open) onClose(); }}
width={width} width={width}
@@ -73,7 +73,7 @@ function CatalogueModal({
purpose="info" purpose="info"
> >
{children} {children}
</Dialog> </AppDialog>
); );
} }

View File

@@ -53,7 +53,7 @@ import { IconButton } from '@astryxdesign/core/IconButton';
import { TextInput } from '@astryxdesign/core/TextInput'; import { TextInput } from '@astryxdesign/core/TextInput';
import { TextArea } from '@astryxdesign/core/TextArea'; import { TextArea } from '@astryxdesign/core/TextArea';
import { NumberInput } from '@astryxdesign/core/NumberInput'; import { NumberInput } from '@astryxdesign/core/NumberInput';
import { Dialog } from '@astryxdesign/core/Dialog'; import { AppDialog } from './AppDialog';
import { TabList, Tab } from '@astryxdesign/core/TabList'; import { TabList, Tab } from '@astryxdesign/core/TabList';
import { Badge } from '@astryxdesign/core/Badge'; import { Badge } from '@astryxdesign/core/Badge';
import { ProgressBar } from '@astryxdesign/core/ProgressBar'; import { ProgressBar } from '@astryxdesign/core/ProgressBar';
@@ -96,7 +96,7 @@ function StoreModal({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<Dialog <AppDialog
isOpen isOpen
onOpenChange={(open) => { if (!open && isDismissable) onClose(); }} onOpenChange={(open) => { if (!open && isDismissable) onClose(); }}
width={width} width={width}
@@ -109,7 +109,7 @@ function StoreModal({
purpose={isDismissable ? 'info' : 'required'} purpose={isDismissable ? 'info' : 'required'}
> >
{children} {children}
</Dialog> </AppDialog>
); );
} }

View File

@@ -4,7 +4,6 @@
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { import {
AlertTriangle, AlertTriangle,
@@ -30,6 +29,8 @@ import {
} from '../services/fiestaQueries'; } from '../services/fiestaQueries';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; 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 { str as fstr, num as fnum, roleName, ymd } from '../services/fiestaApi';
import type { AuthUser } from '../services/auth'; import type { AuthUser } from '../services/auth';
import Header from './Header'; import Header from './Header';
@@ -425,25 +426,28 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
</main> </main>
</div> </div>
{/* Store QR — centered modal opened from the navbar QR button. Portaled to {/* PHASE 5 — Store QR, opened from the navbar QR button.
body so `fixed inset-0` is viewport-relative regardless of ancestors. */} Was a hand-rolled `fixed inset-0` portaled to body, which is what it
{showQrModal && took to make the overlay viewport-relative regardless of ancestors.
createPortal( Dialog renders in the top layer, so neither the portal nor the manual
<div backdrop is needed — and it brings Escape and the focus trap the
className="fixed inset-0 z-[200] flex items-center justify-center p-4" hand-rolled version never had. */}
style={{ background: 'rgba(15,23,42,0.45)', backdropFilter: 'blur(4px)' }} {showQrModal && (
onClick={(e) => { if (e.target === e.currentTarget) setShowQrModal(false); }} <AppDialog
isOpen
width={600}
maxHeight="90vh"
purpose="info"
onOpenChange={(open) => { if (!open) setShowQrModal(false); }}
> >
<div className="relative w-full mx-auto" style={{ maxWidth: 600 }}> {/* The close is DialogHeader's, not a hand-placed one. An
<IconButton absolutely-positioned button here lands outside the painted
icon={<X size={16} />} surface: the <dialog> element is wider and taller than the card
label="Close store QR code" Astryx paints inside it, so `absolute top-3 right-3` against
tooltip="Close" this wrapper put the X off the card entirely — and the same
variant="ghost" mismatch clipped the Download button off the bottom. */}
onClick={() => setShowQrModal(false)} <DialogHeader title="Store QR code" onOpenChange={(open) => { if (!open) setShowQrModal(false); }} />
style={{ height: 'auto', width: 'auto', minWidth: 0 }} <div className="w-full mx-auto">
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"
/>
<StoreQRView <StoreQRView
tenantId={tenantId} tenantId={tenantId}
locationid={resolvedLocationId || undefined} locationid={resolvedLocationId || undefined}
@@ -456,9 +460,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
storeAddress={matchedLoc ? fstr(matchedLoc.address) || undefined : undefined} storeAddress={matchedLoc ? fstr(matchedLoc.address) || undefined : undefined}
/> />
</div> </div>
</div>, </AppDialog>
document.body, )}
)}
<ComparisonModal /> <ComparisonModal />
</div> </div>

View File

@@ -42,7 +42,7 @@ import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { RadioList, RadioListItem } from '@astryxdesign/core/RadioList'; import { RadioList, RadioListItem } from '@astryxdesign/core/RadioList';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { Dialog } from '@astryxdesign/core/Dialog'; import { AppDialog } from './AppDialog';
import { Heading } from '@astryxdesign/core/Heading'; import { Heading } from '@astryxdesign/core/Heading';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Spinner } from '@astryxdesign/core/Spinner'; import { Spinner } from '@astryxdesign/core/Spinner';
@@ -65,7 +65,7 @@ function UsersModal({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<Dialog <AppDialog
isOpen isOpen
onOpenChange={(open) => { if (!open) onClose(); }} onOpenChange={(open) => { if (!open) onClose(); }}
width={width} width={width}
@@ -73,7 +73,7 @@ function UsersModal({
purpose="info" purpose="info"
> >
{children} {children}
</Dialog> </AppDialog>
); );
} }

View File

@@ -51,14 +51,33 @@ createRoot(document.getElementById('root')!).render(
The theme carries a full dark palette and `mode="system"` does work — but The theme carries a full dark palette and `mode="system"` does work — but
only Astryx-backed components respond to it. Everything still on raw only Astryx-backed components respond to it. Everything still on raw
Tailwind is pinned to the light scheme by hardcoded classes: KpiStrip Tailwind or inline hex is pinned to the light scheme.
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.
Flip this to "system" once Phase 6 has removed the hardcoded colours. Measured, not assumed — 2026-08-14, by flipping this to "dark" and
That single word is the dark-mode launch switch. 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 Outermost so nothing above it renders unthemed, but inside StrictMode so
it gets the same double-render checks as everything else. it gets the same double-render checks as everything else.