updates on the design on the kpi cards

This commit is contained in:
2026-08-20 16:53:34 +05:30
parent ce32465a6f
commit 1bf89adb6a
10 changed files with 973 additions and 274 deletions

View File

@@ -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;

View File

@@ -6,40 +6,83 @@ import { Heading } from '@astryxdesign/core/Heading';
import { Text } from '@astryxdesign/core/Text';
import { StatusDot } from '@astryxdesign/core/StatusDot';
// ==============================|| PAGE HEADER (Doormile-style clean title) ||============================== //
// 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.
import { DT } from 'themes/dt/tokens';
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 (
<HStack justify="between" vAlign="center" wrap="wrap" gap={2} paddingBlock={1}>
<VStack gap={0.5} padding={0} minWidth={0}>
<Heading level={1} maxLines={1}>
{title}
</Heading>
{subtitle && (
<HStack gap={1} vAlign="center">
<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}>
{/* 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}>
{title}
</Heading>
{meta != null && (
<Text type="supporting" hasTabularNumbers>
{meta}
</Text>
)}
{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>
)}
</VStack>
{action && <HStack gap={1.5}>{action}</HStack>}
</HStack>
</HStack>
</VStack>
);
}
PageHeader.propTypes = {
title: PropTypes.node,
subtitle: PropTypes.node,
meta: PropTypes.node,
live: PropTypes.bool,
action: PropTypes.node
};

View File

@@ -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
// silently, which made every page-scoped rule fail to match with no error —
// the styles simply never applied.
export function PageShell({ children, gap = 4, fill = false, className }) {
export function PageShell({ children, gap = 3, fill = false, className }) {
return (
<>
{fill && (
@@ -106,8 +106,12 @@ PageShell.propTypes = {
//
// `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.
//
// 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;
return (
<Grid columns={{ minWidth, max: max || Math.max(count, 1), repeat: 'fit' }} gap={gap}>

View File

@@ -1,101 +1,208 @@
import React from 'react';
import PropTypes from 'prop-types';
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';
// ==============================|| STAT / KPI CARD (Doormile-style Modern) ||============================== //
// Modern, space-efficient KPI tile:
// - Left-aligned icon tile directly grouped with the metric stack to eliminate internal dead space.
// - Soft ambient accent gradient tint based on semantic status color.
// - Crisp typography and tight vertical footprint (~56px height) saving ~30% page height.
// - Micro-hover interaction (subtle lift + shadow depth).
//
// `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.
import { DT, STATUS } from 'themes/dt/tokens';
// ==============================|| STAT / KPI CARD ||============================== //
//
// Ultra-compact, dense, space-efficient enterprise KPI component.
// Shared across Deliveries, Orders, Pricing, Tenants, Riders, Customers, Hubs,
// Vehicles, Tripsheets, Exceptions, App Users, Reports and Dispatch.
//
// ---------------------------------------------------------------------------
// 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 (
<Card
height="100%"
padding={1.5}
padding={0}
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={{
position: 'relative',
background: `linear-gradient(135deg, ${color}0A 0%, rgba(255, 255, 255, 0.98) 45%, #ffffff 100%)`,
border: `1px solid ${color}24`,
borderRadius: 'var(--radius-container, 6px)',
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.04)',
cursor: 'default'
display: 'flex',
alignItems: 'center',
minHeight: MIN_HEIGHT,
maxHeight: 56,
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
}}
>
<HStack vAlign="center" gap={2} style={{ width: '100%', minHeight: 40 }}>
<div
className="dt-stat-content"
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
minWidth: 0
}}
>
{icon && (
<Center
width={34}
height={34}
<div
className="dt-stat-icon"
style={{
flexShrink: 0,
borderRadius: 'var(--radius-element, 6px)',
backgroundColor: `${color}14`,
color,
border: `1px solid ${color}2A`
width: 26,
height: 26,
minWidth: 26,
minHeight: 26,
borderRadius: 6,
backgroundColor: `${accent}12`,
color: accent,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}
>
{icon}
</Center>
</div>
)}
<VStack gap={0} padding={0} minWidth={0} style={{ flex: 1 }}>
<Text
type="supporting"
size="xsm"
weight="semibold"
maxLines={1}
<div
className="dt-stat-body"
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
minWidth: 0,
flex: 1
}}
>
<div
className="dt-stat-primary"
style={{
fontSize: '11.5px',
color: 'var(--color-text-supporting, #64748b)',
letterSpacing: '0.01em',
lineHeight: 1.2
display: 'flex',
alignItems: 'baseline',
gap: '6px',
minWidth: 0,
flexWrap: 'wrap'
}}
>
{title}
</Text>
{loading ? (
<Skeleton width={48} height={20} radius={2} style={{ marginTop: 2 }} />
) : (
<Text
size="lg"
weight="bold"
display="block"
maxLines={1}
hasTabularNumbers
{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)',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap'
}}
>
{value}
</span>
)}
<span
className="dt-stat-label"
title={typeof title === 'string' ? title : undefined}
style={{
fontSize: '19px',
lineHeight: 1.2,
color: 'var(--color-text-primary, #0f172a)',
marginTop: 1
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
}}
>
{value}
</Text>
)}
{title}
</span>
</div>
{caption && (
<Text
type="supporting"
size="xsm"
display="block"
style={{ fontSize: '11px', marginTop: 1 }}
<span
className="dt-stat-caption"
title={typeof caption === 'string' ? caption : undefined}
style={{
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}
</Text>
</span>
)}
</VStack>
</HStack>
</div>
</div>
</Card>
);
}
@@ -104,7 +211,14 @@ StatCard.propTypes = {
title: PropTypes.node,
value: PropTypes.node,
icon: PropTypes.node,
/** Raw accent hex. Takes precedence over `variant`. */
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,
loading: PropTypes.bool
loading: PropTypes.bool,
onClick: PropTypes.func,
clickable: PropTypes.bool,
className: PropTypes.string,
style: PropTypes.object
};

View File

@@ -1,9 +1,15 @@
/* Global visual polish that used to come from MUI's <CssBaseline>/<GlobalStyles>
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
element resets; this file only carries the two enterprise-polish touches
that reset doesn't: crisp font rendering and a thin, unobtrusive scrollbar. */
element resets; this file carries the handful of things a design token
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 {
/* Astryx sets --font-family-body on :root (from themes/astryx.js's
@@ -19,11 +25,58 @@ body {
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
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. */
/* --------------------------------------------------------------------------
Motion vocabulary.
One set of durations and one easing curve for the whole app, so a hover, a
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-color: rgba(0, 0, 0, 0.16) transparent;
@@ -40,7 +93,7 @@ body {
*::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.16);
border-radius: 5px;
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
@@ -53,21 +106,104 @@ body {
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 {
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);
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 {
transition: transform 0.18s ease;
.dt-stat-card.dt-stat-card-clickable:active,
.dt-stat-card[data-clickable='true']:active,
[role='button'] .dt-stat-card:active {
transform: translateY(0);
}
.dt-stat-card:hover .dt-stat-icon {
transform: scale(1.06);
/* Standardized 14px icon size inside the 26px chip across all call sites */
.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;
}
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
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 { useDispatch } from 'react-redux';
@@ -8,71 +8,400 @@ import { OpenToast } from 'components/third-party/OpenToast';
import { setLoginUser } from 'store/reducers/loginUserSlice';
import { markSessionStart } from 'utils/session';
import { loginAdmin } from 'pages/api/doormileApi';
import { DT } from 'themes/dt/tokens';
// Astryx design system — see themes/astryx.js for the Doormile brand 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
// comment in config-overrides.js. Everything below uses Astryx component
// props only; the brand gradient panel and the two logo images are plain
// native elements with inline `style` for that reason.
// Astryx design system — see themes/astryx.js for the Doormile theme.
//
// Two things about how this page is built:
//
// 1. The brand panel and the password toggle are plain elements with a scoped
// 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 { Theme } from '@astryxdesign/core/theme';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
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 { Button } from '@astryxdesign/core/Button';
import { Link } from '@astryxdesign/core/Link';
import { Divider } from '@astryxdesign/core/Divider';
import { doormileTheme } from 'themes/astryx';
const brandPanelStyle = {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
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%)'
};
// Doormile brand red. Stated once; every rule below reads the custom property
// so the two tones can never drift apart.
const BRAND = '#C8152D';
const BRAND_DEEP = '#A90F24';
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 dispatch = useDispatch();
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const emailRef = useRef(null);
const passwordRef = useRef(null);
useEffect(() => {
// '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 handleSubmit = async (e) => {
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) {
opentoast('Enter your email and password');
return;
@@ -123,83 +466,105 @@ const Login = () => {
return (
<Theme theme={doormileTheme} mode="light">
<AppShell contentPadding={0}>
{loading && <Loader />}
<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} />
<style>{LOGIN_CSS}</style>
<VStack gap={2} maxWidth={430}>
<Heading level={1} color="inherit">
Operate your dispatch, end to end.
</Heading>
<Text type="large" color="inherit">
<div className="dm-login">
{/* ---- Brand panel. The ONLY Doormile logo on this page. ---- */}
<aside className="dm-login__brand">
<img className="dm-login__logo" src={logo} alt="Doormile" />
<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.
</Text>
<VStack gap={1.5} padding={0}>
</p>
<ul className="dm-login__features">
{BULLETS.map((t) => (
<HStack key={t} gap={1.25} vAlign="center">
<span style={bulletDotStyle}>✓</span>
<Text color="inherit">{t}</Text>
</HStack>
<li key={t} className="dm-login__feature">
<span className="dm-login__tick" aria-hidden="true">
<MdCheck size={14} />
</span>
{t}
</li>
))}
</VStack>
</VStack>
</div>
</ul>
</div>
</aside>
{/* ---- Right form panel ---- */}
<Center axis="both" width="54%" height="100vh">
<VStack width="100%" maxWidth={440} gap={4} padding={3}>
<Card padding={8} elevation="med">
<VStack gap={6} padding={0}>
<VStack gap={1.5} hAlign="center" padding={0}>
<img src={logo} alt="Doormile" style={formLogoStyle} />
<Heading level={2}>Welcome back</Heading>
<Text type="supporting">Sign in to the Doormile Express console</Text>
</VStack>
{/* ---- Form panel ---- */}
<main className="dm-login__form">
<div className="dm-login__stack">
<Card padding={8} elevation="none">
<VStack gap={5} padding={0}>
<div>
<h2 className="dm-login__title">Welcome back</h2>
<p className="dm-login__subtitle">Sign in to the Doormile Express console</p>
</div>
<Divider />
<form noValidate onSubmit={handleSubmit}>
<VStack gap={4} padding={0}>
<TextInput
ref={emailRef}
hasAutoFocus
label="E-mail Address"
label="Email address"
type="email"
size="lg"
placeholder="you@company.com"
value={email}
isDisabled={loading}
onChange={(value) => setEmail(value.toLocaleLowerCase())}
/>
<TextInput
label="Password"
type="password"
<div className="dm-login__pw">
<TextInput
ref={passwordRef}
label="Password"
type={showPassword ? 'text' : 'password'}
size="lg"
placeholder="Enter your password"
value={password}
isDisabled={loading}
onChange={(value) => setPassword(value)}
/>
<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"
value={password}
onChange={(value) => setPassword(value)}
width="100%"
isLoading={loading}
isDisabled={loading}
/>
<Button label="Sign in" type="submit" variant="primary" size="lg" width="100%" />
</VStack>
</form>
</VStack>
</Card>
{/* footer */}
<HStack justify="center" wrap="wrap" gap={2}>
<Link href="https://doormile.com" target="_blank" isExternalLink type="supporting">
&copy; All rights reserved
</Link>
<div className="dm-login__footer">
<span>&copy; {new Date().getFullYear()} Doormile</span>
<Link href="https://doormile.com/terms-of-service" target="_blank" isExternalLink type="supporting">
Terms and Conditions
Terms &amp; Conditions
</Link>
<Link href="https://doormile.com/privacy-policy" target="_blank" isExternalLink type="supporting">
Privacy Policy
</Link>
</HStack>
</VStack>
</Center>
</HStack>
</div>
</div>
</main>
</div>
</AppShell>
</Theme>
);

View File

@@ -38,7 +38,7 @@ import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } f
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import PageHeader from 'components/nearle_components/PageHeader';
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 { fetchPaymentType, fetchRidersList, finalCreatedeliveries, notifyRider } from '../../api/api';
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 && (
<KpiGrid>
<HoverSocialCard
secondary={metaData?.total_orders}
primary="Orders"
percentage={<MdDirectionsBike />}
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} />
<StatCard title="Orders" value={metaData?.total_orders} icon={<MdDirectionsBike size={20} />} color={STATUS.delivered} />
<StatCard title="Riders" value={metaData?.total_riders} icon={<MdDirectionsBike size={20} />} color={STATUS.pending} />
<StatCard title="Zones" value={zoneData?.length} icon={<FaMapLocationDot size={20} />} color={STATUS.info} />
<StatCard title="Kilometer" value={zoneData?.length} icon={<FaMapLocationDot size={20} />} color={STATUS.cancelled} />
</KpiGrid>
)}

View File

@@ -2,6 +2,9 @@
// brand colour so migrated surfaces match the existing DT token palette
// (see themes/dt/tokens.js). Runtime-injected (not `astryx theme build`)
// 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 { neutralTheme } from '@astryxdesign/theme-neutral';
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
// 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
// existing tables already assume. ratio 1.2 keeps the heading steps tight
// 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
// 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
// dots and pill chips, which are meant to be round.
'--radius-page': '5px',
'--radius-container': '5px',
'--radius-element': '5px',
'--radius-inner': '5px',
'--radius-chat': '5px',
'--radius-page': `${DT.radiusPage}px`,
'--radius-container': `${DT.radiusCard}px`,
'--radius-element': `${DT.radiusField}px`,
'--radius-inner': `${DT.radiusInner}px`,
'--radius-chat': `${DT.radiusInner}px`,
// ---- Accent (UNCHANGED) --------------------------------------------
'--color-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],
@@ -78,6 +93,6 @@ export const doormileTheme = defineTheme({
},
components: {
card: { base: { borderRadius: `${DT.radiusCard}px` } },
'text-input': { base: { borderRadius: `${DT.radiusInner}px` } }
'text-input': { base: { borderRadius: `${DT.radiusField}px` } }
}
});

View File

@@ -26,14 +26,30 @@ export const softPopupStyle = {
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),
// 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>.
// ---------------------------------------------------------------------------
// TableScroll — 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), 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
// 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
// one job.
// ---------------------------------------------------------------------------
export const TableScroll = ({ maxHeight = 'calc(100vh - 260px)', minWidth, flush = false, children }) => (
<>
<style>{`
@@ -61,6 +77,19 @@ export const TableScroll = ({ maxHeight = 'calc(100vh - 260px)', minWidth, flush
background-color: ${DT.surface};
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>
<div className={`dt-table-scroll${flush ? ' is-flush' : ''}`} style={{ maxHeight }}>
<div style={{ minWidth }}>{children}</div>

View File

@@ -5,11 +5,30 @@
// 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
// 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 = {
// Corner radius is uniform across the console. Boxes were 10-14px, which
// reads soft and consumer-ish on dense operator screens; 5px is tight
// enough for table density while still reading as a rounded surface.
// ---- Corner radius -------------------------------------------------
// Was a flat 5px on every surface, which read as "tight console" but also
// 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
// 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
// doesn't make them consistent, it makes them look broken.
radiusPill: 999,
radiusCard: 5,
radiusInner: 5,
radiusField: 5,
// Restrained, low-contrast elevation — corporate (Linear/Stripe), not flashy.
shadowSoft: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)',
radiusPage: 20,
radiusCard: 16,
radiusInner: 12,
radiusField: 12,
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)',
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)',
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',
textSecondary: '#64748b',
textMuted: '#94a3b8',