astryx
This commit is contained in:
108
src/components/AppDialog.tsx
Normal file
108
src/components/AppDialog.tsx
Normal 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;
|
||||||
@@ -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} />}
|
||||||
@@ -217,6 +228,7 @@ export function Lightbox({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`}
|
||||||
|
isLabelHidden
|
||||||
|
value={exportProgress}
|
||||||
|
max={100}
|
||||||
|
hasValueLabel
|
||||||
|
className="w-full mb-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-mono font-bold text-purple-750">{exportProgress}%</span>
|
</AppDialog>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +460,7 @@ 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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
src/main.tsx
33
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
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user