updates on the design on the kpi cards
This commit is contained in:
@@ -1,40 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
|
||||||
import { Heading } from '@astryxdesign/core/Heading';
|
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
|
||||||
|
|
||||||
// ===========================|| HOVER SOCIAL CARD ||=========================== //
|
|
||||||
// Solid-colour metric tile used on the order-preview summary row. `color` is a
|
|
||||||
// caller-supplied background hex, so it stays an inline style — Astryx's Card
|
|
||||||
// `variant` palette can't express an arbitrary colour.
|
|
||||||
|
|
||||||
const HoverSocialCard = ({ primary, secondary, percentage, color, style }) => (
|
|
||||||
<Card padding={4} elevation="none" height="100%" style={{ background: color, color: '#fff', border: 'none', ...style }}>
|
|
||||||
<HStack justify="between" vAlign="start" gap={2}>
|
|
||||||
<VStack gap={0.5} padding={0} minWidth={0}>
|
|
||||||
<Heading level={2} color="inherit" maxLines={1}>
|
|
||||||
{secondary}
|
|
||||||
</Heading>
|
|
||||||
<Text color="inherit" weight="medium">
|
|
||||||
{primary}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
<Text color="inherit" size="2xl" display="block">
|
|
||||||
{typeof percentage === 'number' ? `${percentage} %` : percentage}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
HoverSocialCard.propTypes = {
|
|
||||||
primary: PropTypes.node,
|
|
||||||
secondary: PropTypes.node,
|
|
||||||
percentage: PropTypes.node,
|
|
||||||
color: PropTypes.string,
|
|
||||||
style: PropTypes.object
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HoverSocialCard;
|
|
||||||
@@ -6,40 +6,83 @@ import { Heading } from '@astryxdesign/core/Heading';
|
|||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { StatusDot } from '@astryxdesign/core/StatusDot';
|
import { StatusDot } from '@astryxdesign/core/StatusDot';
|
||||||
|
|
||||||
// ==============================|| PAGE HEADER (Doormile-style clean title) ||============================== //
|
import { DT } from 'themes/dt/tokens';
|
||||||
// A plain, un-boxed page title + optional live subtitle + an action slot
|
|
||||||
// (zone selector, primary button, etc.). Replaces the heavy boxed/gradient
|
|
||||||
// header so every operator page opens with a calm, corporate heading.
|
|
||||||
//
|
|
||||||
// Astryx: Heading level={1} resolves to --text-heading-1-size (1.5rem), the
|
|
||||||
// same size the old MUI `variant="h3"` rendered — the visual weight of every
|
|
||||||
// page title is unchanged by the migration. The old responsive
|
|
||||||
// `direction={{ xs: 'column', sm: 'row' }}` is now wrap="wrap" on a single
|
|
||||||
// HStack: the action slot drops to its own line once it can't fit, which is
|
|
||||||
// the behaviour that breakpoint was approximating.
|
|
||||||
|
|
||||||
export default function PageHeader({ title, subtitle, live = false, action }) {
|
// ==============================|| PAGE HEADER ||============================== //
|
||||||
|
//
|
||||||
|
// A plain, un-boxed page title + optional live indicator + an action slot
|
||||||
|
// (zone selector, primary button, etc.), so every operator page opens with a
|
||||||
|
// calm, corporate heading.
|
||||||
|
//
|
||||||
|
// The STRUCTURE follows KROW's page frame; the colours and the type are the
|
||||||
|
// console's own and are unchanged — `Heading level={1}` and `Text
|
||||||
|
// type="supporting"` resolve to exactly the same tokens they did before.
|
||||||
|
//
|
||||||
|
// Title · meta · ● Live [ actions ]
|
||||||
|
// subtitle
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Two things the KROW work changed:
|
||||||
|
//
|
||||||
|
// - The bottom hairline. KROW closes every page header with a rule, and it
|
||||||
|
// is what makes the header read as a REGION of the page rather than as
|
||||||
|
// the first item in the content stack — without it, a page header and a
|
||||||
|
// section heading look identical and the page has no top.
|
||||||
|
//
|
||||||
|
// - `live` moved from beside the subtitle up onto the title row. It was
|
||||||
|
// putting the page's only motion cue on its quietest line; a live
|
||||||
|
// indicator belongs where people look for it. It is still the same
|
||||||
|
// pulsing `StatusDot variant="success"` — nothing about its colour moved.
|
||||||
|
//
|
||||||
|
// `meta` is new and optional: a real count that belongs beside the name
|
||||||
|
// ("1,248 orders"), with tabular figures so it doesn't jitter as it updates.
|
||||||
|
//
|
||||||
|
// `action` keeps its name and still wraps to its own line once it can't fit,
|
||||||
|
// which is the behaviour the old responsive `direction={{ xs: 'column', sm:
|
||||||
|
// 'row' }}` was approximating.
|
||||||
|
|
||||||
|
export default function PageHeader({ title, subtitle, meta, live = false, action }) {
|
||||||
return (
|
return (
|
||||||
|
<VStack
|
||||||
|
gap={0}
|
||||||
|
padding={0}
|
||||||
|
style={{
|
||||||
|
paddingBottom: 16,
|
||||||
|
// The existing hairline at 50% alpha — a page header's rule should sit
|
||||||
|
// under the table borders it introduces, not match them.
|
||||||
|
borderBottom: `1px solid ${DT.borderSubtle}80`
|
||||||
|
}}
|
||||||
|
>
|
||||||
<HStack justify="between" vAlign="center" wrap="wrap" gap={2} paddingBlock={1}>
|
<HStack justify="between" vAlign="center" wrap="wrap" gap={2} paddingBlock={1}>
|
||||||
<VStack gap={0.5} padding={0} minWidth={0}>
|
{/* minWidth in `style` — VStack has no such prop (see StatCard). */}
|
||||||
|
<VStack gap={0.5} padding={0} style={{ minWidth: 0 }}>
|
||||||
|
<HStack gap={2} vAlign="center" wrap="wrap">
|
||||||
<Heading level={1} maxLines={1}>
|
<Heading level={1} maxLines={1}>
|
||||||
{title}
|
{title}
|
||||||
</Heading>
|
</Heading>
|
||||||
{subtitle && (
|
{meta != null && (
|
||||||
<HStack gap={1} vAlign="center">
|
<Text type="supporting" hasTabularNumbers>
|
||||||
|
{meta}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{live && <StatusDot variant="success" label="Live" isPulsing />}
|
{live && <StatusDot variant="success" label="Live" isPulsing />}
|
||||||
<Text type="supporting">{subtitle}</Text>
|
</HStack>
|
||||||
|
{subtitle && <Text type="supporting">{subtitle}</Text>}
|
||||||
|
</VStack>
|
||||||
|
{action && (
|
||||||
|
<HStack gap={1.5} vAlign="center" wrap="wrap" style={{ flexShrink: 0 }}>
|
||||||
|
{action}
|
||||||
</HStack>
|
</HStack>
|
||||||
)}
|
)}
|
||||||
</VStack>
|
|
||||||
{action && <HStack gap={1.5}>{action}</HStack>}
|
|
||||||
</HStack>
|
</HStack>
|
||||||
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
PageHeader.propTypes = {
|
PageHeader.propTypes = {
|
||||||
title: PropTypes.node,
|
title: PropTypes.node,
|
||||||
subtitle: PropTypes.node,
|
subtitle: PropTypes.node,
|
||||||
|
meta: PropTypes.node,
|
||||||
live: PropTypes.bool,
|
live: PropTypes.bool,
|
||||||
action: PropTypes.node
|
action: PropTypes.node
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const FILL_CLASS = 'dt-page-fill';
|
|||||||
// shell (e.g. Create Order's `.co-root` token block). It used to be dropped
|
// shell (e.g. Create Order's `.co-root` token block). It used to be dropped
|
||||||
// silently, which made every page-scoped rule fail to match with no error —
|
// silently, which made every page-scoped rule fail to match with no error —
|
||||||
// the styles simply never applied.
|
// the styles simply never applied.
|
||||||
export function PageShell({ children, gap = 4, fill = false, className }) {
|
export function PageShell({ children, gap = 3, fill = false, className }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{fill && (
|
{fill && (
|
||||||
@@ -106,8 +106,12 @@ PageShell.propTypes = {
|
|||||||
//
|
//
|
||||||
// `max` caps the column count so 3 cards don't each become 400px wide on an
|
// `max` caps the column count so 3 cards don't each become 400px wide on an
|
||||||
// ultrawide monitor; it defaults to the number of children.
|
// ultrawide monitor; it defaults to the number of children.
|
||||||
|
//
|
||||||
|
// minWidth is 200, fitting four across smoothly while keeping labels single-line.
|
||||||
|
//
|
||||||
|
// gap is 2.5 (10px) — tight, cohesive summary strip.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export function KpiGrid({ children, minWidth = 180, gap = 3, max }) {
|
export function KpiGrid({ children, minWidth = 200, gap = 2.5, max }) {
|
||||||
const count = Array.isArray(children) ? children.filter(Boolean).length : 1;
|
const count = Array.isArray(children) ? children.filter(Boolean).length : 1;
|
||||||
return (
|
return (
|
||||||
<Grid columns={{ minWidth, max: max || Math.max(count, 1), repeat: 'fit' }} gap={gap}>
|
<Grid columns={{ minWidth, max: max || Math.max(count, 1), repeat: 'fit' }} gap={gap}>
|
||||||
|
|||||||
@@ -1,101 +1,208 @@
|
|||||||
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
|
||||||
import { Center } from '@astryxdesign/core/Center';
|
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
|
||||||
import { Skeleton } from '@astryxdesign/core/Skeleton';
|
import { Skeleton } from '@astryxdesign/core/Skeleton';
|
||||||
|
|
||||||
// ==============================|| STAT / KPI CARD (Doormile-style Modern) ||============================== //
|
import { DT, STATUS } from 'themes/dt/tokens';
|
||||||
// Modern, space-efficient KPI tile:
|
|
||||||
// - Left-aligned icon tile directly grouped with the metric stack to eliminate internal dead space.
|
// ==============================|| STAT / KPI CARD ||============================== //
|
||||||
// - Soft ambient accent gradient tint based on semantic status color.
|
//
|
||||||
// - Crisp typography and tight vertical footprint (~56px height) saving ~30% page height.
|
// Ultra-compact, dense, space-efficient enterprise KPI component.
|
||||||
// - Micro-hover interaction (subtle lift + shadow depth).
|
// Shared across Deliveries, Orders, Pricing, Tenants, Riders, Customers, Hubs,
|
||||||
//
|
// Vehicles, Tripsheets, Exceptions, App Users, Reports and Dispatch.
|
||||||
// `icon` is a rendered node, e.g. icon={<MdLocalShipping size={20} />}.
|
//
|
||||||
// `color` is a hex accent (defaults to brand black); `caption` is optional supporting text.
|
// ---------------------------------------------------------------------------
|
||||||
|
// ULTRA-COMPACT HORIZONTAL COMPOSITION (~48px target height)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. Icon chip: 26px × 26px with 6px radius and subtle 7% tint (`${accent}12`).
|
||||||
|
// Glyph size normalized to 14px.
|
||||||
|
// 2. Value: 18px bold (`lineHeight: 1`, `fontVariantNumeric: 'tabular-nums'`).
|
||||||
|
// 3. Label: 12px / 500 in secondary ink (`#64748b`), aligned horizontally on
|
||||||
|
// the same baseline beside the value for maximum space efficiency and instant
|
||||||
|
// (<1s) visual scanning.
|
||||||
|
// 4. Tight elevation, flat white surface, 8px rounded corners, 7px 12px padding.
|
||||||
|
// 5. Interactive/clickable hover support with smooth 150ms transition. Static
|
||||||
|
// cards remain clean without hover distraction.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MIN_HEIGHT = 48;
|
||||||
|
|
||||||
|
const VARIANTS = {
|
||||||
|
neutral: STATUS.muted,
|
||||||
|
brand: DT.brand,
|
||||||
|
blue: STATUS.info,
|
||||||
|
green: STATUS.delivered,
|
||||||
|
amber: STATUS.pending,
|
||||||
|
red: STATUS.cancelled
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
variant = 'brand',
|
||||||
|
caption,
|
||||||
|
loading = false,
|
||||||
|
onClick,
|
||||||
|
clickable,
|
||||||
|
className = '',
|
||||||
|
style = {}
|
||||||
|
}) {
|
||||||
|
const accent = color || VARIANTS[variant] || DT.brand;
|
||||||
|
const isClickable = Boolean(onClick || clickable);
|
||||||
|
|
||||||
export default function StatCard({ title, value, icon, color = '#000000', caption, loading = false }) {
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
height="100%"
|
height="100%"
|
||||||
padding={1.5}
|
padding={0}
|
||||||
elevation="none"
|
elevation="none"
|
||||||
className="dt-stat-card"
|
className={['dt-stat-card', isClickable ? 'dt-stat-card-clickable' : '', className].filter(Boolean).join(' ')}
|
||||||
|
data-clickable={isClickable ? 'true' : undefined}
|
||||||
|
onClick={onClick}
|
||||||
|
role={isClickable ? 'button' : undefined}
|
||||||
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
isClickable && onClick
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
background: `linear-gradient(135deg, ${color}0A 0%, rgba(255, 255, 255, 0.98) 45%, #ffffff 100%)`,
|
display: 'flex',
|
||||||
border: `1px solid ${color}24`,
|
alignItems: 'center',
|
||||||
borderRadius: 'var(--radius-container, 6px)',
|
minHeight: MIN_HEIGHT,
|
||||||
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.04)',
|
maxHeight: 56,
|
||||||
cursor: 'default'
|
padding: '7px 12px',
|
||||||
|
background: DT.surface,
|
||||||
|
border: `1px solid ${DT.borderSubtle}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.03)',
|
||||||
|
cursor: isClickable ? 'pointer' : 'default',
|
||||||
|
userSelect: isClickable ? 'none' : 'auto',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
overflow: 'hidden',
|
||||||
|
width: '100%',
|
||||||
|
...style
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="dt-stat-content"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
width: '100%',
|
||||||
|
minWidth: 0
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<HStack vAlign="center" gap={2} style={{ width: '100%', minHeight: 40 }}>
|
|
||||||
{icon && (
|
{icon && (
|
||||||
<Center
|
<div
|
||||||
width={34}
|
|
||||||
height={34}
|
|
||||||
className="dt-stat-icon"
|
className="dt-stat-icon"
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
width: 26,
|
||||||
borderRadius: 'var(--radius-element, 6px)',
|
height: 26,
|
||||||
backgroundColor: `${color}14`,
|
minWidth: 26,
|
||||||
color,
|
minHeight: 26,
|
||||||
border: `1px solid ${color}2A`
|
borderRadius: 6,
|
||||||
|
backgroundColor: `${accent}12`,
|
||||||
|
color: accent,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</Center>
|
</div>
|
||||||
)}
|
)}
|
||||||
<VStack gap={0} padding={0} minWidth={0} style={{ flex: 1 }}>
|
|
||||||
<Text
|
<div
|
||||||
type="supporting"
|
className="dt-stat-body"
|
||||||
size="xsm"
|
|
||||||
weight="semibold"
|
|
||||||
maxLines={1}
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: '11.5px',
|
display: 'flex',
|
||||||
color: 'var(--color-text-supporting, #64748b)',
|
flexDirection: 'column',
|
||||||
letterSpacing: '0.01em',
|
justifyContent: 'center',
|
||||||
lineHeight: 1.2
|
minWidth: 0,
|
||||||
|
flex: 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
<div
|
||||||
</Text>
|
className="dt-stat-primary"
|
||||||
{loading ? (
|
|
||||||
<Skeleton width={48} height={20} radius={2} style={{ marginTop: 2 }} />
|
|
||||||
) : (
|
|
||||||
<Text
|
|
||||||
size="lg"
|
|
||||||
weight="bold"
|
|
||||||
display="block"
|
|
||||||
maxLines={1}
|
|
||||||
hasTabularNumbers
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: '19px',
|
display: 'flex',
|
||||||
lineHeight: 1.2,
|
alignItems: 'baseline',
|
||||||
|
gap: '6px',
|
||||||
|
minWidth: 0,
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton width={36} height={18} radius={DT.radiusSm} />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="dt-stat-value"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-family-heading, Sora), -apple-system, BlinkMacSystemFont, sans-serif',
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
color: 'var(--color-text-primary, #0f172a)',
|
color: 'var(--color-text-primary, #0f172a)',
|
||||||
marginTop: 1
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</Text>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="dt-stat-label"
|
||||||
|
title={typeof title === 'string' ? title : undefined}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-family-body, Inter), -apple-system, BlinkMacSystemFont, sans-serif',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1,
|
||||||
|
color: DT.textSecondary,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
flex: '1 1 auto',
|
||||||
|
minWidth: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{caption && (
|
{caption && (
|
||||||
<Text
|
<span
|
||||||
type="supporting"
|
className="dt-stat-caption"
|
||||||
size="xsm"
|
title={typeof caption === 'string' ? caption : undefined}
|
||||||
display="block"
|
style={{
|
||||||
style={{ fontSize: '11px', marginTop: 1 }}
|
fontFamily: 'var(--font-family-body, Inter), -apple-system, BlinkMacSystemFont, sans-serif',
|
||||||
|
fontSize: '10.5px',
|
||||||
|
lineHeight: '12px',
|
||||||
|
color: DT.textMuted,
|
||||||
|
marginTop: 1,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{caption}
|
{caption}
|
||||||
</Text>
|
</span>
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</div>
|
||||||
</HStack>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -104,7 +211,14 @@ StatCard.propTypes = {
|
|||||||
title: PropTypes.node,
|
title: PropTypes.node,
|
||||||
value: PropTypes.node,
|
value: PropTypes.node,
|
||||||
icon: PropTypes.node,
|
icon: PropTypes.node,
|
||||||
|
/** Raw accent hex. Takes precedence over `variant`. */
|
||||||
color: PropTypes.string,
|
color: PropTypes.string,
|
||||||
|
/** Semantic alias over the same palette, for call sites not importing STATUS. */
|
||||||
|
variant: PropTypes.oneOf(['neutral', 'brand', 'blue', 'green', 'amber', 'red']),
|
||||||
caption: PropTypes.node,
|
caption: PropTypes.node,
|
||||||
loading: PropTypes.bool
|
loading: PropTypes.bool,
|
||||||
|
onClick: PropTypes.func,
|
||||||
|
clickable: PropTypes.bool,
|
||||||
|
className: PropTypes.string,
|
||||||
|
style: PropTypes.object
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
/* Global visual polish that used to come from MUI's <CssBaseline>/<GlobalStyles>
|
/* Global visual polish that used to come from MUI's <CssBaseline>/<GlobalStyles>
|
||||||
in themes/index.js (ThemeCustomization). Kept as plain CSS now that every
|
in themes/index.js (ThemeCustomization). Kept as plain CSS now that every
|
||||||
page runs on Astryx — see root CLAUDE.md §6.6. Astryx's own reset
|
page runs on Astryx — see root CLAUDE.md §6. Astryx's own reset
|
||||||
(@astryxdesign/core/reset.css, imported in index.js) covers the base
|
(@astryxdesign/core/reset.css, imported in index.js) covers the base
|
||||||
element resets; this file only carries the two enterprise-polish touches
|
element resets; this file carries the handful of things a design token
|
||||||
that reset doesn't: crisp font rendering and a thin, unobtrusive scrollbar. */
|
cannot express: font rendering, scrollbar pseudo-elements, keyframes, the
|
||||||
|
focus treatment, and the global reduced-motion rule.
|
||||||
|
|
||||||
|
NOTE ON COLOUR: nothing here introduces a colour. The console keeps its own
|
||||||
|
palette (themes/dt/tokens.js) — the KROW work adopted KROW's geometry and
|
||||||
|
type treatment only. Where a rule below needs an accent it reads
|
||||||
|
`var(--color-accent)` rather than naming one. */
|
||||||
|
|
||||||
body {
|
body {
|
||||||
/* Astryx sets --font-family-body on :root (from themes/astryx.js's
|
/* Astryx sets --font-family-body on :root (from themes/astryx.js's
|
||||||
@@ -19,11 +25,58 @@ body {
|
|||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbars are chrome, not content — they should recede until used.
|
/* --------------------------------------------------------------------------
|
||||||
The thumb was rgba(0,0,0,0.26) at 10px, which drew a solid dark bar down the
|
Motion vocabulary.
|
||||||
right edge of every scrollable table and read as a heavy border rather than
|
|
||||||
as a scrollbar. Lightened and narrowed; it still darkens on hover so it stays
|
One set of durations and one easing curve for the whole app, so a hover, a
|
||||||
findable. */
|
row highlight and a card entrance all feel like the same product. Three
|
||||||
|
steps only: `fast` for colour-only changes (anything slower feels laggy
|
||||||
|
under a moving cursor), `base` for the default, `slow` for layout and
|
||||||
|
entrances.
|
||||||
|
|
||||||
|
Prefixed --dt-* deliberately. These live on :root, and Dispatch.css ALSO
|
||||||
|
writes :root from a lazy-loaded chunk — an unprefixed name there would win
|
||||||
|
or lose purely on bundle order rather than on intent.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
:root {
|
||||||
|
--dt-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--dt-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
|
--dt-duration-fast: 120ms;
|
||||||
|
--dt-duration-base: 200ms;
|
||||||
|
--dt-duration-slow: 320ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Focus.
|
||||||
|
|
||||||
|
Focus is never the browser default. One ring, one width, everywhere. The
|
||||||
|
colour is the app's own accent token, not a value chosen here, so the ring
|
||||||
|
follows the brand automatically.
|
||||||
|
|
||||||
|
:focus-visible, not :focus — a mouse click on a button should not leave a
|
||||||
|
ring behind it.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.dt-focus-ring:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The inset variant, for a control flush inside a row or table cell where an
|
||||||
|
offset ring would be clipped by the overflow container. */
|
||||||
|
.dt-focus-ring-inset:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 0 0 2px var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Scrollbars.
|
||||||
|
|
||||||
|
Chrome, not content — they should recede until used. The thumb was
|
||||||
|
rgba(0,0,0,0.26) at 10px, which drew a solid dark bar down the right edge of
|
||||||
|
every scrollable table and read as a heavy border rather than as a
|
||||||
|
scrollbar. Lightened and narrowed; it still darkens on hover so it stays
|
||||||
|
findable.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin; /* Firefox */
|
scrollbar-width: thin; /* Firefox */
|
||||||
scrollbar-color: rgba(0, 0, 0, 0.16) transparent;
|
scrollbar-color: rgba(0, 0, 0, 0.16) transparent;
|
||||||
@@ -40,7 +93,7 @@ body {
|
|||||||
|
|
||||||
*::-webkit-scrollbar-thumb {
|
*::-webkit-scrollbar-thumb {
|
||||||
background-color: rgba(0, 0, 0, 0.16);
|
background-color: rgba(0, 0, 0, 0.16);
|
||||||
border-radius: 5px;
|
border-radius: 999px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
@@ -53,21 +106,104 @@ body {
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* KPI / StatCard sleek interactions & polish */
|
/* --------------------------------------------------------------------------
|
||||||
|
Keyframes.
|
||||||
|
|
||||||
|
A skeleton SHIMMERS, it does not pulse: a pulsing block reads as a
|
||||||
|
rendering glitch, while a travelling highlight reads as progress. Astryx's
|
||||||
|
own <Skeleton> keeps its built-in treatment; this is for the hand-rolled
|
||||||
|
placeholders.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
@keyframes dt-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-shimmer {
|
||||||
|
background-image: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.65) 50%, transparent 100%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: dt-shimmer 1.6s var(--dt-ease-in-out) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dt-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dt-slide-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-fade-in {
|
||||||
|
animation: dt-fade-in var(--dt-duration-base) var(--dt-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-slide-up {
|
||||||
|
animation: dt-slide-up var(--dt-duration-slow) var(--dt-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
KPI / StatCard (Compact Enterprise Metric Strip).
|
||||||
|
|
||||||
|
Static tiles stay quiet and static. Clickable/interactive tiles (e.g. on
|
||||||
|
Tenants page) get a subtle 150ms hover lift and border emphasis.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
.dt-stat-card {
|
.dt-stat-card {
|
||||||
transition: transform 0.18s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.18s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.18s ease !important;
|
transition: box-shadow 150ms ease, border-color 150ms ease, transform 150ms ease !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dt-stat-card:hover {
|
/* Interactive hover state ONLY when clickable or wrapped in button role */
|
||||||
|
.dt-stat-card.dt-stat-card-clickable:hover,
|
||||||
|
.dt-stat-card[data-clickable='true']:hover,
|
||||||
|
[role='button'] .dt-stat-card:hover,
|
||||||
|
[role='button'] > .dt-stat-card:hover,
|
||||||
|
button .dt-stat-card:hover {
|
||||||
|
border-color: var(--color-border-emphasized, #cbd5e1) !important;
|
||||||
|
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.08) !important;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.04) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dt-stat-card .dt-stat-icon {
|
.dt-stat-card.dt-stat-card-clickable:active,
|
||||||
transition: transform 0.18s ease;
|
.dt-stat-card[data-clickable='true']:active,
|
||||||
|
[role='button'] .dt-stat-card:active {
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dt-stat-card:hover .dt-stat-icon {
|
/* Standardized 14px icon size inside the 26px chip across all call sites */
|
||||||
transform: scale(1.06);
|
.dt-stat-card .dt-stat-icon svg {
|
||||||
|
width: 14px !important;
|
||||||
|
height: 14px !important;
|
||||||
|
font-size: 14px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Reduced motion.
|
||||||
|
|
||||||
|
Handled ONCE, globally, rather than per component — a per-component opt-out
|
||||||
|
is a rule that only covers the components somebody remembered.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Loader from 'components/Loader';
|
import { MdCheck, MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';
|
||||||
import logo from 'assets/images/doormile-logo.png';
|
import logo from 'assets/images/doormile-logo.png';
|
||||||
|
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
@@ -8,71 +8,400 @@ import { OpenToast } from 'components/third-party/OpenToast';
|
|||||||
import { setLoginUser } from 'store/reducers/loginUserSlice';
|
import { setLoginUser } from 'store/reducers/loginUserSlice';
|
||||||
import { markSessionStart } from 'utils/session';
|
import { markSessionStart } from 'utils/session';
|
||||||
import { loginAdmin } from 'pages/api/doormileApi';
|
import { loginAdmin } from 'pages/api/doormileApi';
|
||||||
import { DT } from 'themes/dt/tokens';
|
|
||||||
|
|
||||||
// Astryx design system — see themes/astryx.js for the Doormile brand theme
|
// Astryx design system — see themes/astryx.js for the Doormile theme.
|
||||||
// and CLAUDE.md's <!-- ASTRYX:START --> block for the CLI workflow.
|
//
|
||||||
// NOTE: custom CSS (xstyle/stylex.create()) isn't wired up yet — see the
|
// Two things about how this page is built:
|
||||||
// comment in config-overrides.js. Everything below uses Astryx component
|
//
|
||||||
// props only; the brand gradient panel and the two logo images are plain
|
// 1. The brand panel and the password toggle are plain elements with a scoped
|
||||||
// native elements with inline `style` for that reason.
|
// stylesheet, not Astryx layout components. Astryx has no breakpoint props,
|
||||||
|
// no gradient/decoration surface and no end-slot on TextInput, so those
|
||||||
|
// three needs cannot be expressed through component props at all. The
|
||||||
|
// stylesheet below is the same escape hatch `themes/dt/primitives.js`
|
||||||
|
// (TableScroll) and `PageLayout.js` (PageShell) already use.
|
||||||
|
//
|
||||||
|
// 2. EVERY selector in that stylesheet is prefixed `.dm-login`. The brand red
|
||||||
|
// lives on this page only — the console's own palette (black accent, slate
|
||||||
|
// ink) is deliberately untouched, so nothing here leaks into the operator
|
||||||
|
// pages. Do not lift these colours into themes/astryx.js.
|
||||||
import { AppShell } from '@astryxdesign/core/AppShell';
|
import { AppShell } from '@astryxdesign/core/AppShell';
|
||||||
import { Theme } from '@astryxdesign/core/theme';
|
import { Theme } from '@astryxdesign/core/theme';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Center } from '@astryxdesign/core/Center';
|
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
import { Heading } from '@astryxdesign/core/Heading';
|
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { Link } from '@astryxdesign/core/Link';
|
import { Link } from '@astryxdesign/core/Link';
|
||||||
import { Divider } from '@astryxdesign/core/Divider';
|
import { Divider } from '@astryxdesign/core/Divider';
|
||||||
import { doormileTheme } from 'themes/astryx';
|
import { doormileTheme } from 'themes/astryx';
|
||||||
|
|
||||||
const brandPanelStyle = {
|
// Doormile brand red. Stated once; every rule below reads the custom property
|
||||||
display: 'flex',
|
// so the two tones can never drift apart.
|
||||||
flexDirection: 'column',
|
const BRAND = '#C8152D';
|
||||||
justifyContent: 'center',
|
const BRAND_DEEP = '#A90F24';
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden',
|
|
||||||
width: '46%',
|
|
||||||
height: '100vh',
|
|
||||||
color: '#fff',
|
|
||||||
padding: 48,
|
|
||||||
background: `linear-gradient(150deg, ${DT.brand} 0%, #3A3A3A 100%)`
|
|
||||||
};
|
|
||||||
|
|
||||||
const logoLockupStyle = { position: 'absolute', top: 48, left: 48, maxHeight: 60 };
|
|
||||||
|
|
||||||
const bulletDotStyle = {
|
|
||||||
width: 22,
|
|
||||||
height: 22,
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.18)',
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
flexShrink: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
// doormile-logo.png is a white asset; recolour to brand red for this
|
|
||||||
// white-background card (the brand-panel logo stays white as-is).
|
|
||||||
const formLogoStyle = {
|
|
||||||
maxHeight: 48,
|
|
||||||
filter: 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)'
|
|
||||||
};
|
|
||||||
|
|
||||||
const BULLETS = ['Real-time fleet visibility', 'AI-optimised dispatch routes', 'Tenant, pricing & invoice control'];
|
const BULLETS = ['Real-time fleet visibility', 'AI-optimised dispatch routes', 'Tenant, pricing & invoice control'];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scoped stylesheet.
|
||||||
|
//
|
||||||
|
// Astryx exposes stable class hooks (`astryx-card`, `astryx-text-input`,
|
||||||
|
// `astryx-button`, `astryx-field-label`, `astryx-link`) alongside its generated
|
||||||
|
// StyleX classes, and a two-class descendant selector out-specifies them —
|
||||||
|
// verified in the browser rather than assumed. That is what lets this restyle
|
||||||
|
// real Astryx components instead of re-implementing them.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const LOGIN_CSS = `
|
||||||
|
.dm-login {
|
||||||
|
--dm-brand: ${BRAND};
|
||||||
|
--dm-brand-deep: ${BRAND_DEEP};
|
||||||
|
--dm-ring: rgba(200, 21, 45, 0.10);
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Left brand panel --------------------------------------------------- */
|
||||||
|
.dm-login__brand {
|
||||||
|
flex: 0 0 44%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 56px;
|
||||||
|
color: #fff;
|
||||||
|
/* Depth without decoration: one light lift from the top-left, one shadow
|
||||||
|
into the bottom-right, over a restrained two-stop brand ramp. The panel
|
||||||
|
should read as lit, not as gradient-y. */
|
||||||
|
background:
|
||||||
|
radial-gradient(115% 85% at 10% 6%, rgba(255, 255, 255, 0.12) 0%, transparent 55%),
|
||||||
|
radial-gradient(95% 85% at 95% 100%, rgba(0, 0, 0, 0.22) 0%, transparent 62%),
|
||||||
|
linear-gradient(155deg, var(--dm-brand) 0%, #B31228 55%, var(--dm-brand-deep) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A single oversized arc, barely visible — the "logistics curve". One shape,
|
||||||
|
two hairlines, no pattern library. */
|
||||||
|
.dm-login__brand::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -240px;
|
||||||
|
bottom: -280px;
|
||||||
|
width: 640px;
|
||||||
|
height: 640px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
box-shadow: 0 0 0 72px rgba(255, 255, 255, 0.035);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Faint route lines, masked so they fade out rather than tile edge to edge. */
|
||||||
|
.dm-login__brand::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image: repeating-linear-gradient(118deg, rgba(255, 255, 255, 0.055) 0 1px, transparent 1px 78px);
|
||||||
|
-webkit-mask-image: radial-gradient(130% 100% at 85% 12%, #000 0%, transparent 68%);
|
||||||
|
mask-image: radial-gradient(130% 100% at 85% 12%, #000 0%, transparent 68%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login__logo {
|
||||||
|
position: absolute;
|
||||||
|
top: 56px;
|
||||||
|
left: 56px;
|
||||||
|
height: 46px;
|
||||||
|
width: auto;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login__copy {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login__headline {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-family-heading);
|
||||||
|
font-size: 34px;
|
||||||
|
line-height: 1.18;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login__lede {
|
||||||
|
margin: 18px 0 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login__features {
|
||||||
|
margin: 34px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login__feature {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: rgba(255, 255, 255, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login__tick {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Right form panel --------------------------------------------------- */
|
||||||
|
.dm-login__form {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f8f9fb;
|
||||||
|
padding: 48px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login__stack {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 436px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login .astryx-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 16px;
|
||||||
|
/* Elevated, not floating. */
|
||||||
|
box-shadow: 0 12px 35px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login__title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-family-heading);
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login__subtitle {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Fields ------------------------------------------------------------- */
|
||||||
|
.dm-login .astryx-field-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #344054;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login .astryx-text-input {
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
background: #ffffff;
|
||||||
|
padding-inline: 14px;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login .astryx-text-input input {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One focus treatment for the whole form. :focus-within so the ring follows
|
||||||
|
the field, not the bare <input> that sits inside Astryx's bordered wrapper.
|
||||||
|
|
||||||
|
Plain two-class specificity is enough: the resting rule above already
|
||||||
|
out-specifies Astryx's own StyleX rules on this element, and this selector
|
||||||
|
is strictly more specific than that one. If a future Astryx version starts
|
||||||
|
winning here, the escape hatch is StyleX's own repeated-:not(#id) idiom —
|
||||||
|
but write it with IDs that simply never exist rather than copying StyleX's
|
||||||
|
escaped-hash form, because a backslash inside THIS template literal is
|
||||||
|
consumed by JS before the CSS parser sees it and the rule is then dropped
|
||||||
|
silently. */
|
||||||
|
.dm-login .astryx-text-input:focus-within {
|
||||||
|
border-color: var(--dm-brand);
|
||||||
|
box-shadow: 0 0 0 3px var(--dm-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chrome paints autofilled inputs pale blue, which is the "browser-looking"
|
||||||
|
field the design brief calls out. An inset shadow the size of the box is the
|
||||||
|
only way to repaint it. */
|
||||||
|
.dm-login .astryx-text-input input:-webkit-autofill,
|
||||||
|
.dm-login .astryx-text-input input:-webkit-autofill:hover,
|
||||||
|
.dm-login .astryx-text-input input:-webkit-autofill:focus {
|
||||||
|
-webkit-box-shadow: 0 0 0 40px #ffffff inset;
|
||||||
|
box-shadow: 0 0 0 40px #ffffff inset;
|
||||||
|
-webkit-text-fill-color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Password visibility toggle ----------------------------------------- */
|
||||||
|
.dm-login__pw {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep typed characters clear of the toggle. */
|
||||||
|
.dm-login__pw .astryx-text-input {
|
||||||
|
padding-right: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login__pw-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
bottom: 0;
|
||||||
|
height: 48px;
|
||||||
|
width: 42px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #667085;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 160ms ease, background-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login__pw-toggle:hover {
|
||||||
|
color: #344054;
|
||||||
|
background: #f2f4f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login__pw-toggle:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px var(--dm-ring);
|
||||||
|
color: var(--dm-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Primary action ----------------------------------------------------- */
|
||||||
|
.dm-login .astryx-button {
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--dm-brand);
|
||||||
|
border-color: var(--dm-brand);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color 160ms ease, transform 120ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login .astryx-button:hover:not([disabled]):not([aria-disabled='true']) {
|
||||||
|
background: var(--dm-brand-deep);
|
||||||
|
border-color: var(--dm-brand-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login .astryx-button:active:not([disabled]):not([aria-disabled='true']) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
background: #970d20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login .astryx-button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px var(--dm-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login .astryx-button[disabled],
|
||||||
|
.dm-login .astryx-button[aria-disabled='true'] {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Footer ------------------------------------------------------------- */
|
||||||
|
.dm-login__footer {
|
||||||
|
margin-top: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #98a2b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login__footer .astryx-link {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-login__footer .astryx-link:hover {
|
||||||
|
color: var(--dm-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tablet ------------------------------------------------------------- */
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.dm-login__brand { flex-basis: 42%; padding: 40px; }
|
||||||
|
.dm-login__logo { top: 40px; left: 40px; height: 40px; }
|
||||||
|
.dm-login__headline { font-size: 28px; }
|
||||||
|
.dm-login__lede { font-size: 15px; margin-top: 14px; }
|
||||||
|
.dm-login__features { margin-top: 26px; gap: 13px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Mobile ------------------------------------------------------------- */
|
||||||
|
/* The brand panel COLLAPSES to a logo strip rather than being hidden and the
|
||||||
|
logo re-rendered above the card. That keeps exactly one <img> of the logo in
|
||||||
|
the DOM at every breakpoint — a duplicate that is merely display:none is
|
||||||
|
still a duplicate to a screen reader and to anyone reading the markup. */
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.dm-login { flex-direction: column; min-height: 100dvh; }
|
||||||
|
|
||||||
|
.dm-login__brand {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 22px 24px;
|
||||||
|
}
|
||||||
|
.dm-login__brand::before,
|
||||||
|
.dm-login__brand::after { display: none; }
|
||||||
|
|
||||||
|
.dm-login__logo { position: static; height: 34px; }
|
||||||
|
.dm-login__copy { display: none; }
|
||||||
|
|
||||||
|
.dm-login__form {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 32px 20px 40px;
|
||||||
|
}
|
||||||
|
.dm-login__title { font-size: 23px; }
|
||||||
|
.dm-login__footer { margin-top: 22px; gap: 8px 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.dm-login__form { padding-inline: 16px; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
const emailRef = useRef(null);
|
||||||
|
const passwordRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 'authname' is the AUTH_PRESENCE_KEY (utils/session.js) — App.js and the
|
// 'authname' is the AUTH_PRESENCE_KEY (utils/session.js) — App.js and the
|
||||||
@@ -83,10 +412,24 @@ const Login = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Astryx's TextInput has no `autoComplete` prop and BaseProps declares only
|
||||||
|
// `xstyle`, so an unknown prop would be dropped rather than forwarded. It
|
||||||
|
// does forward `ref` to the real <input>, which is the supported way to
|
||||||
|
// reach the element — password managers need these attributes to offer a
|
||||||
|
// saved credential at all. Re-applied when the toggle swaps `type`.
|
||||||
|
useEffect(() => {
|
||||||
|
emailRef.current?.setAttribute('autocomplete', 'email');
|
||||||
|
passwordRef.current?.setAttribute('autocomplete', 'current-password');
|
||||||
|
}, [showPassword]);
|
||||||
|
|
||||||
const opentoast = (message, variant = 'error') => OpenToast(message, variant, 2000);
|
const opentoast = (message, variant = 'error') => OpenToast(message, variant, 2000);
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// Guard against a second submit while the first is still in flight —
|
||||||
|
// the button is disabled while loading, but Enter in a field is not.
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
opentoast('Enter your email and password');
|
opentoast('Enter your email and password');
|
||||||
return;
|
return;
|
||||||
@@ -123,83 +466,105 @@ const Login = () => {
|
|||||||
return (
|
return (
|
||||||
<Theme theme={doormileTheme} mode="light">
|
<Theme theme={doormileTheme} mode="light">
|
||||||
<AppShell contentPadding={0}>
|
<AppShell contentPadding={0}>
|
||||||
{loading && <Loader />}
|
<style>{LOGIN_CSS}</style>
|
||||||
<HStack gap={0} height="100vh" wrap="nowrap">
|
|
||||||
{/* ---- Left brand panel (plain element: see the note at the top of
|
|
||||||
this file about custom styling not going through Astryx yet) ---- */}
|
|
||||||
<div style={brandPanelStyle}>
|
|
||||||
<img src={logo} alt="Doormile" style={logoLockupStyle} />
|
|
||||||
|
|
||||||
<VStack gap={2} maxWidth={430}>
|
<div className="dm-login">
|
||||||
<Heading level={1} color="inherit">
|
{/* ---- Brand panel. The ONLY Doormile logo on this page. ---- */}
|
||||||
Operate your dispatch, end to end.
|
<aside className="dm-login__brand">
|
||||||
</Heading>
|
<img className="dm-login__logo" src={logo} alt="Doormile" />
|
||||||
<Text type="large" color="inherit">
|
|
||||||
|
<div className="dm-login__copy">
|
||||||
|
<h1 className="dm-login__headline">Operate your dispatch, end to end.</h1>
|
||||||
|
<p className="dm-login__lede">
|
||||||
Orders, AI route optimisation, live rider tracking and billing — all in the Doormile Express operator console.
|
Orders, AI route optimisation, live rider tracking and billing — all in the Doormile Express operator console.
|
||||||
</Text>
|
</p>
|
||||||
|
<ul className="dm-login__features">
|
||||||
<VStack gap={1.5} padding={0}>
|
|
||||||
{BULLETS.map((t) => (
|
{BULLETS.map((t) => (
|
||||||
<HStack key={t} gap={1.25} vAlign="center">
|
<li key={t} className="dm-login__feature">
|
||||||
<span style={bulletDotStyle}>✓</span>
|
<span className="dm-login__tick" aria-hidden="true">
|
||||||
<Text color="inherit">{t}</Text>
|
<MdCheck size={14} />
|
||||||
</HStack>
|
</span>
|
||||||
|
{t}
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</VStack>
|
</ul>
|
||||||
</VStack>
|
|
||||||
</div>
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
{/* ---- Right form panel ---- */}
|
{/* ---- Form panel ---- */}
|
||||||
<Center axis="both" width="54%" height="100vh">
|
<main className="dm-login__form">
|
||||||
<VStack width="100%" maxWidth={440} gap={4} padding={3}>
|
<div className="dm-login__stack">
|
||||||
<Card padding={8} elevation="med">
|
<Card padding={8} elevation="none">
|
||||||
<VStack gap={6} padding={0}>
|
<VStack gap={5} padding={0}>
|
||||||
<VStack gap={1.5} hAlign="center" padding={0}>
|
<div>
|
||||||
<img src={logo} alt="Doormile" style={formLogoStyle} />
|
<h2 className="dm-login__title">Welcome back</h2>
|
||||||
<Heading level={2}>Welcome back</Heading>
|
<p className="dm-login__subtitle">Sign in to the Doormile Express console</p>
|
||||||
<Text type="supporting">Sign in to the Doormile Express console</Text>
|
</div>
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<form noValidate onSubmit={handleSubmit}>
|
<form noValidate onSubmit={handleSubmit}>
|
||||||
<VStack gap={4} padding={0}>
|
<VStack gap={4} padding={0}>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
ref={emailRef}
|
||||||
hasAutoFocus
|
hasAutoFocus
|
||||||
label="E-mail Address"
|
label="Email address"
|
||||||
type="email"
|
type="email"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
placeholder="you@company.com"
|
||||||
value={email}
|
value={email}
|
||||||
|
isDisabled={loading}
|
||||||
onChange={(value) => setEmail(value.toLocaleLowerCase())}
|
onChange={(value) => setEmail(value.toLocaleLowerCase())}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="dm-login__pw">
|
||||||
<TextInput
|
<TextInput
|
||||||
|
ref={passwordRef}
|
||||||
label="Password"
|
label="Password"
|
||||||
type="password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
size="lg"
|
size="lg"
|
||||||
|
placeholder="Enter your password"
|
||||||
value={password}
|
value={password}
|
||||||
|
isDisabled={loading}
|
||||||
onChange={(value) => setPassword(value)}
|
onChange={(value) => setPassword(value)}
|
||||||
/>
|
/>
|
||||||
<Button label="Sign in" type="submit" variant="primary" size="lg" width="100%" />
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dm-login__pw-toggle"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
aria-pressed={showPassword}
|
||||||
|
>
|
||||||
|
{showPassword ? <MdOutlineVisibilityOff size={19} /> : <MdOutlineVisibility size={19} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={loading ? 'Signing in…' : 'Sign in'}
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
width="100%"
|
||||||
|
isLoading={loading}
|
||||||
|
isDisabled={loading}
|
||||||
|
/>
|
||||||
</VStack>
|
</VStack>
|
||||||
</form>
|
</form>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* footer */}
|
<div className="dm-login__footer">
|
||||||
<HStack justify="center" wrap="wrap" gap={2}>
|
<span>© {new Date().getFullYear()} Doormile</span>
|
||||||
<Link href="https://doormile.com" target="_blank" isExternalLink type="supporting">
|
|
||||||
© All rights reserved
|
|
||||||
</Link>
|
|
||||||
<Link href="https://doormile.com/terms-of-service" target="_blank" isExternalLink type="supporting">
|
<Link href="https://doormile.com/terms-of-service" target="_blank" isExternalLink type="supporting">
|
||||||
Terms and Conditions
|
Terms & Conditions
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="https://doormile.com/privacy-policy" target="_blank" isExternalLink type="supporting">
|
<Link href="https://doormile.com/privacy-policy" target="_blank" isExternalLink type="supporting">
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
</HStack>
|
</div>
|
||||||
</VStack>
|
</div>
|
||||||
</Center>
|
</main>
|
||||||
</HStack>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</Theme>
|
</Theme>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } f
|
|||||||
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
|
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
|
||||||
import PageHeader from 'components/nearle_components/PageHeader';
|
import PageHeader from 'components/nearle_components/PageHeader';
|
||||||
import { PageShell, KpiGrid, DataCard } from 'components/nearle_components/PageLayout';
|
import { PageShell, KpiGrid, DataCard } from 'components/nearle_components/PageLayout';
|
||||||
import HoverSocialCard from 'components/cards/statistics/HoverSocialCard';
|
import StatCard from 'components/nearle_components/StatCard';
|
||||||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||||
import { fetchPaymentType, fetchRidersList, finalCreatedeliveries, notifyRider } from '../../api/api';
|
import { fetchPaymentType, fetchRidersList, finalCreatedeliveries, notifyRider } from '../../api/api';
|
||||||
import { OpenToast } from 'components/third-party/OpenToast';
|
import { OpenToast } from 'components/third-party/OpenToast';
|
||||||
@@ -561,17 +561,16 @@ const OrdersPreview = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Was four <HoverSocialCard>s — solid-colour tiles that were the only
|
||||||
|
KPI design in the app not coming from StatCard, so this row looked
|
||||||
|
nothing like the KPI row on the other fifteen pages. Same data,
|
||||||
|
same semantic colours, shared tile. */}
|
||||||
{aiMode == 1 && (
|
{aiMode == 1 && (
|
||||||
<KpiGrid>
|
<KpiGrid>
|
||||||
<HoverSocialCard
|
<StatCard title="Orders" value={metaData?.total_orders} icon={<MdDirectionsBike size={20} />} color={STATUS.delivered} />
|
||||||
secondary={metaData?.total_orders}
|
<StatCard title="Riders" value={metaData?.total_riders} icon={<MdDirectionsBike size={20} />} color={STATUS.pending} />
|
||||||
primary="Orders"
|
<StatCard title="Zones" value={zoneData?.length} icon={<FaMapLocationDot size={20} />} color={STATUS.info} />
|
||||||
percentage={<MdDirectionsBike />}
|
<StatCard title="Kilometer" value={zoneData?.length} icon={<FaMapLocationDot size={20} />} color={STATUS.cancelled} />
|
||||||
color={STATUS.delivered}
|
|
||||||
/>
|
|
||||||
<HoverSocialCard secondary={metaData?.total_riders} primary="Riders" percentage={<MdDirectionsBike />} color={STATUS.pending} />
|
|
||||||
<HoverSocialCard secondary={zoneData?.length} primary="Zones" percentage={<FaMapLocationDot />} color="#0ea5e9" />
|
|
||||||
<HoverSocialCard secondary={zoneData?.length} primary="Kilometer" percentage={<FaMapLocationDot />} color={STATUS.cancelled} />
|
|
||||||
</KpiGrid>
|
</KpiGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
// brand colour so migrated surfaces match the existing DT token palette
|
// brand colour so migrated surfaces match the existing DT token palette
|
||||||
// (see themes/dt/tokens.js). Runtime-injected (not `astryx theme build`)
|
// (see themes/dt/tokens.js). Runtime-injected (not `astryx theme build`)
|
||||||
// since this repo doesn't do SSR — see `astryx docs theme`.
|
// since this repo doesn't do SSR — see `astryx docs theme`.
|
||||||
|
//
|
||||||
|
// The KROW work touched exactly one thing in this file: the radius block.
|
||||||
|
// The colour tokens below are Doormile's own and are unchanged.
|
||||||
import { defineTheme } from '@astryxdesign/core/theme';
|
import { defineTheme } from '@astryxdesign/core/theme';
|
||||||
import { neutralTheme } from '@astryxdesign/theme-neutral';
|
import { neutralTheme } from '@astryxdesign/theme-neutral';
|
||||||
import { DT } from 'themes/dt/tokens';
|
import { DT } from 'themes/dt/tokens';
|
||||||
@@ -42,6 +45,9 @@ export const doormileTheme = defineTheme({
|
|||||||
// Astryx explicitly does not load fonts, it only names them. Change one
|
// Astryx explicitly does not load fonts, it only names them. Change one
|
||||||
// without the other and you get a silent fallback.
|
// without the other and you get a silent fallback.
|
||||||
//
|
//
|
||||||
|
// Sora headings + Inter body is also exactly the pairing KROW specifies, so
|
||||||
|
// adopting KROW's type treatment needed no change here.
|
||||||
|
//
|
||||||
// base 14, not 16: this is a dense operator console and 14px is what the
|
// base 14, not 16: this is a dense operator console and 14px is what the
|
||||||
// existing tables already assume. ratio 1.2 keeps the heading steps tight
|
// existing tables already assume. ratio 1.2 keeps the heading steps tight
|
||||||
// so an h1 doesn't tower over a data-heavy page.
|
// so an h1 doesn't tower over a data-heavy page.
|
||||||
@@ -59,13 +65,22 @@ export const doormileTheme = defineTheme({
|
|||||||
// Setting it per-component would leave the ones nobody remembered to
|
// Setting it per-component would leave the ones nobody remembered to
|
||||||
// list still rounded.
|
// list still rounded.
|
||||||
//
|
//
|
||||||
|
// These were a flat 5px across all five roles. They are now KROW's scale,
|
||||||
|
// mapped onto those roles: `page` is a page-level surface (20px),
|
||||||
|
// `container` is a card (16px), and `element`/`inner`/`chat` are the
|
||||||
|
// controls and inner chrome that sit inside one (12px). Keeping element
|
||||||
|
// and container DIFFERENT is the whole point — a 16px card holding 16px
|
||||||
|
// inputs has no visual nesting.
|
||||||
|
//
|
||||||
// `--radius-full` is deliberately untouched: it drives avatars, status
|
// `--radius-full` is deliberately untouched: it drives avatars, status
|
||||||
// dots and pill chips, which are meant to be round.
|
// dots and pill chips, which are meant to be round.
|
||||||
'--radius-page': '5px',
|
'--radius-page': `${DT.radiusPage}px`,
|
||||||
'--radius-container': '5px',
|
'--radius-container': `${DT.radiusCard}px`,
|
||||||
'--radius-element': '5px',
|
'--radius-element': `${DT.radiusField}px`,
|
||||||
'--radius-inner': '5px',
|
'--radius-inner': `${DT.radiusInner}px`,
|
||||||
'--radius-chat': '5px',
|
'--radius-chat': `${DT.radiusInner}px`,
|
||||||
|
|
||||||
|
// ---- Accent (UNCHANGED) --------------------------------------------
|
||||||
'--color-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],
|
'--color-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],
|
||||||
'--color-icon-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],
|
'--color-icon-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],
|
||||||
'--color-text-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],
|
'--color-text-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],
|
||||||
@@ -78,6 +93,6 @@ export const doormileTheme = defineTheme({
|
|||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
card: { base: { borderRadius: `${DT.radiusCard}px` } },
|
card: { base: { borderRadius: `${DT.radiusCard}px` } },
|
||||||
'text-input': { base: { borderRadius: `${DT.radiusInner}px` } }
|
'text-input': { base: { borderRadius: `${DT.radiusField}px` } }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,14 +26,30 @@ export const softPopupStyle = {
|
|||||||
overflow: 'hidden'
|
overflow: 'hidden'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scroll container for a table. Astryx's Table renders a bare <table> with no
|
// ---------------------------------------------------------------------------
|
||||||
// sticky-header or max-height affordance (MUI's TableContainer used to provide that),
|
// TableScroll — scroll container for a table.
|
||||||
// so pages wrap it in this. The brand-red scrollbar can't be expressed inline
|
//
|
||||||
// — ::-webkit-scrollbar-* are pseudo-elements — hence the one-time <style>.
|
// Astryx's Table renders a bare <table> with no sticky-header or max-height
|
||||||
|
// affordance (MUI's TableContainer used to provide that), so pages wrap it in
|
||||||
|
// this. The brand scrollbar can't be expressed inline — ::-webkit-scrollbar-*
|
||||||
|
// are pseudo-elements — hence the one-time <style>.
|
||||||
|
//
|
||||||
|
// It is also where KROW's table-header TYPE treatment lands, and that is what
|
||||||
|
// makes the one-time <style> worth its keep: ~17 pages render tables and none
|
||||||
|
// of them should have to know what a column header looks like. The header is
|
||||||
|
// now the console's heading face at 11px / 600 / +0.06em / uppercase — small,
|
||||||
|
// letterspaced and quiet, so it reads as a LABEL for the data rather than
|
||||||
|
// competing with it.
|
||||||
|
//
|
||||||
|
// Deliberately type only: no colour is set here, so header and body cells keep
|
||||||
|
// whatever Astryx's Table resolves them to, and row hover stays owned by
|
||||||
|
// Astryx's own `hasHover` prop rather than being fought over by two rules.
|
||||||
|
//
|
||||||
// `flush` drops the grey header fill and leaves a single hairline under the
|
// `flush` drops the grey header fill and leaves a single hairline under the
|
||||||
// header row — for tables that sit directly on the page with no surrounding
|
// header row — for tables that sit directly on the page with no surrounding
|
||||||
// card, where a fill plus a border plus a card outline is three lines doing
|
// card, where a fill plus a border plus a card outline is three lines doing
|
||||||
// one job.
|
// one job.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
export const TableScroll = ({ maxHeight = 'calc(100vh - 260px)', minWidth, flush = false, children }) => (
|
export const TableScroll = ({ maxHeight = 'calc(100vh - 260px)', minWidth, flush = false, children }) => (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
<style>{`
|
||||||
@@ -61,6 +77,19 @@ export const TableScroll = ({ maxHeight = 'calc(100vh - 260px)', minWidth, flush
|
|||||||
background-color: ${DT.surface};
|
background-color: ${DT.surface};
|
||||||
border-bottom: 1px solid ${DT.borderSubtle};
|
border-bottom: 1px solid ${DT.borderSubtle};
|
||||||
}
|
}
|
||||||
|
/* KROW's column-header treatment — type only, no colour. */
|
||||||
|
.dt-table-scroll thead th {
|
||||||
|
font-family: var(--font-family-heading);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* Numeric columns opt in per page with class="dt-num"; tabular figures
|
||||||
|
stop a live count from reflowing the column as it ticks. */
|
||||||
|
.dt-table-scroll .dt-num { font-variant-numeric: tabular-nums; }
|
||||||
`}</style>
|
`}</style>
|
||||||
<div className={`dt-table-scroll${flush ? ' is-flush' : ''}`} style={{ maxHeight }}>
|
<div className={`dt-table-scroll${flush ? ' is-flush' : ''}`} style={{ maxHeight }}>
|
||||||
<div style={{ minWidth }}>{children}</div>
|
<div style={{ minWidth }}>{children}</div>
|
||||||
|
|||||||
@@ -5,11 +5,30 @@
|
|||||||
// redefining its own DT object, so radius/shadow/color decisions stay in one
|
// redefining its own DT object, so radius/shadow/color decisions stay in one
|
||||||
// place. See CLAUDE.md §6 for the full page-anatomy convention these tokens
|
// place. See CLAUDE.md §6 for the full page-anatomy convention these tokens
|
||||||
// support.
|
// support.
|
||||||
|
//
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SCOPE OF THE KROW WORK
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The console adopted KROW's *structural* design language — its radius scale,
|
||||||
|
// its type treatment, its page anatomy. It deliberately did NOT adopt KROW's
|
||||||
|
// palette: the colours below are Doormile's own and are unchanged. The brand
|
||||||
|
// is still black, the ink scale is still the slate family, and the lifecycle
|
||||||
|
// hues are untouched.
|
||||||
|
//
|
||||||
|
// So when reading KROW's spec against this file: take the geometry and the
|
||||||
|
// typography, ignore the blue.
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
export const DT = {
|
export const DT = {
|
||||||
// Corner radius is uniform across the console. Boxes were 10-14px, which
|
// ---- Corner radius -------------------------------------------------
|
||||||
// reads soft and consumer-ish on dense operator screens; 5px is tight
|
// Was a flat 5px on every surface, which read as "tight console" but also
|
||||||
// enough for table density while still reading as a rounded surface.
|
// meant a card, the inputs inside it and the chips inside those all had the
|
||||||
|
// same corner — so nothing nested visually.
|
||||||
|
//
|
||||||
|
// Now KROW's scale, which derives from a 12px root: 8 / 12 / 16 / 20. Two
|
||||||
|
// of those steps are both correct and mean different things — 20px is a
|
||||||
|
// page-level surface, 16px is a dense console card. This IS a dense operator
|
||||||
|
// console, so cards take 16 and inputs and inner chrome take 12, which is
|
||||||
|
// what gives a card holding fields a visible hierarchy.
|
||||||
//
|
//
|
||||||
// These are the shared values — change them here, not per page. The Astryx
|
// These are the shared values — change them here, not per page. The Astryx
|
||||||
// components read the matching `--radius-*` tokens set in themes/astryx.js,
|
// components read the matching `--radius-*` tokens set in themes/astryx.js,
|
||||||
@@ -19,13 +38,28 @@ export const DT = {
|
|||||||
// shaped like pills or circles (status chips, avatars, dots). Squaring those
|
// shaped like pills or circles (status chips, avatars, dots). Squaring those
|
||||||
// doesn't make them consistent, it makes them look broken.
|
// doesn't make them consistent, it makes them look broken.
|
||||||
radiusPill: 999,
|
radiusPill: 999,
|
||||||
radiusCard: 5,
|
radiusPage: 20,
|
||||||
radiusInner: 5,
|
radiusCard: 16,
|
||||||
radiusField: 5,
|
radiusInner: 12,
|
||||||
// Restrained, low-contrast elevation — corporate (Linear/Stripe), not flashy.
|
radiusField: 12,
|
||||||
shadowSoft: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)',
|
radiusSm: 8,
|
||||||
|
|
||||||
|
// ---- Elevation -----------------------------------------------------
|
||||||
|
// Restrained, low-contrast — corporate (Linear/Stripe), not flashy.
|
||||||
|
// UNCHANGED by the KROW work; these are the console's own shadows.
|
||||||
|
//
|
||||||
|
// The three original names are the canonical ones. `shadowXs`/`Sm`/`Lg` are
|
||||||
|
// aliases added so newer components can be written against a conventional
|
||||||
|
// xs/sm/lg naming without introducing a second, differently-valued scale.
|
||||||
|
// They resolve to exactly the same three values.
|
||||||
shadowMd: '0 1px 3px rgba(15, 23, 42, 0.06)',
|
shadowMd: '0 1px 3px rgba(15, 23, 42, 0.06)',
|
||||||
|
shadowSoft: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)',
|
||||||
shadowPop: '0 12px 32px rgba(15, 23, 42, 0.12)',
|
shadowPop: '0 12px 32px rgba(15, 23, 42, 0.12)',
|
||||||
|
shadowXs: '0 1px 3px rgba(15, 23, 42, 0.06)', // alias of shadowMd
|
||||||
|
shadowSm: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)', // alias of shadowSoft
|
||||||
|
shadowLg: '0 12px 32px rgba(15, 23, 42, 0.12)', // alias of shadowPop
|
||||||
|
|
||||||
|
// ---- Palette (UNCHANGED) -------------------------------------------
|
||||||
textPrimary: '#0f172a',
|
textPrimary: '#0f172a',
|
||||||
textSecondary: '#64748b',
|
textSecondary: '#64748b',
|
||||||
textMuted: '#94a3b8',
|
textMuted: '#94a3b8',
|
||||||
|
|||||||
Reference in New Issue
Block a user