1736 lines
52 KiB
CSS
1736 lines
52 KiB
CSS
/* ── Layer order ─────────────────────────────────────────────────────────────
|
||
Prescribed verbatim by the Usage block at the top of
|
||
@astryxdesign/core/tailwind-theme.css — not our invention. The ordering
|
||
matters: `utilities` sits last so Tailwind utilities keep winning over
|
||
`astryx-base`, which is what makes a half-migrated file behave.
|
||
|
||
Note `astryx.css` loads BEFORE the theme package: the theme supplies the
|
||
token values that the component CSS references. */
|
||
@layer reset, theme, base, astryx-base, astryx-theme, components, utilities;
|
||
|
||
/* Sora for display, Inter for body — the pairing read out of KROW's own
|
||
stylesheet (--font-display / --font-body). JetBrains Mono stays for the
|
||
monospace runs: ids, SKUs, PINs, invoice numbers. */
|
||
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||
|
||
@import 'tailwindcss/theme.css' layer(theme);
|
||
@import 'tailwindcss/preflight.css' layer(base);
|
||
@import '@astryxdesign/core/reset.css';
|
||
@import '@astryxdesign/core/astryx.css';
|
||
/* Generated by `npx astryx theme build src/theme/nearle.ts` — do not hand-edit. */
|
||
@import './theme/nearle.css';
|
||
@import '@astryxdesign/core/tailwind-theme.css';
|
||
@import 'tailwindcss/utilities.css' layer(utilities);
|
||
|
||
@theme {
|
||
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||
--font-display: 'Sora', ui-sans-serif, system-ui, sans-serif;
|
||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
||
|
||
/* ── Brand ──────────────────────────────────────────────────────────────
|
||
#662582 is the Nearle purple, taken from the app mark. It scores 9.76:1
|
||
on white. `--color-brand-strong` is the pressed/hover step. */
|
||
--color-brand: #662582;
|
||
--color-brand-strong: #531b6e;
|
||
--color-brand-soft: #f2ecf6;
|
||
/* The tint behind an active nav item and an unread row. KROW's
|
||
`krow-blue-tint`, restated in purple. */
|
||
--color-brand-tint: #f4eef8;
|
||
|
||
/* The 4-step ink scale every component reads from.
|
||
ink-1 headings/values · ink-2 body · ink-3 secondary · ink-4 meta */
|
||
--color-ink-1: #0f172a;
|
||
--color-ink-2: #414b5a;
|
||
--color-ink-3: #657081;
|
||
--color-ink-4: #97a1b0;
|
||
|
||
/* Surfaces. `subtle` sits under a resting input, `sunken` under a hover. */
|
||
--color-surface: #ffffff;
|
||
--color-surface-subtle: #fafbfc;
|
||
--color-surface-sunken: #f2f4f7;
|
||
--color-line: #e0e4ea;
|
||
|
||
/* Neutral ramp — the ink scale every surface and label reads from. */
|
||
--color-slate-50: #f7f7f7;
|
||
--color-slate-100: #eff1f4;
|
||
--color-slate-200: #e1e5ea;
|
||
--color-slate-300: #cbd2db;
|
||
--color-slate-400: #97a1b0;
|
||
--color-slate-500: #657081;
|
||
--color-slate-600: #4a5464;
|
||
--color-slate-900: #0f172a;
|
||
|
||
/* Ambient canvas stops — see body::before below. */
|
||
--color-canvas-violet: #f2ecf6;
|
||
--color-canvas-mid: #f8f8f8;
|
||
--color-canvas-warm: #f8f6f1;
|
||
|
||
--container-page: 80rem;
|
||
--container-wide: 108rem;
|
||
--container-admin: 102rem;
|
||
}
|
||
|
||
/* ── The ambient canvas ──────────────────────────────────────────────────────
|
||
KROW's single most distinctive visual trait, restated in Nearle purple: a
|
||
fixed, viewport-sized horizontal gradient painted behind the entire app.
|
||
|
||
Three implementation notes, each of which is a design rule in its own right:
|
||
|
||
- Fixed, not on `body` — the gradient runs left-to-right and must be sized
|
||
to the VIEWPORT, not the document, or it stretches on long pages.
|
||
- `z-index: -1` — it never enters layout, so no shell needs a wrapper.
|
||
- The stops sit close together in lightness on purpose. The result should
|
||
read as *softer*, not as *there is a gradient*. */
|
||
body::before {
|
||
content: '';
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: -1;
|
||
pointer-events: none;
|
||
background: linear-gradient(
|
||
90deg,
|
||
var(--color-canvas-violet) 0%,
|
||
#f5f2f7 28%,
|
||
#f7f6f7 45%,
|
||
var(--color-canvas-mid) 65%,
|
||
#f8f7f4 86%,
|
||
var(--color-canvas-warm) 100%
|
||
);
|
||
}
|
||
|
||
/* Every application shell is transparent so the canvas reads through. */
|
||
html,
|
||
body,
|
||
#root {
|
||
min-height: 100%;
|
||
background: transparent;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
font-family: var(--font-sans);
|
||
-webkit-font-smoothing: antialiased;
|
||
-moz-osx-font-smoothing: grayscale;
|
||
}
|
||
|
||
/* Focus is never the browser default. Declared once, globally, rather than
|
||
per component. */
|
||
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
|
||
outline: 2px solid var(--color-brand);
|
||
outline-offset: 2px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
/* Reduced motion handled once, globally. */
|
||
@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;
|
||
}
|
||
}
|
||
|
||
/* Numeric columns line up only with tabular figures. Used by every KPI value
|
||
and every money/count cell in a table. */
|
||
.tabular {
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
/* The hamburger and the mobile sheet only exist below the tabs breakpoint;
|
||
above it the text tabs are visible and a second nav affordance is noise. */
|
||
.lg-hidden {
|
||
display: none;
|
||
}
|
||
@media (max-width: 1023px) {
|
||
.lg-hidden {
|
||
display: inline-flex;
|
||
}
|
||
}
|
||
|
||
/* Focus treatment for the auth inputs, which are hand-styled rather than
|
||
Astryx controls — the auth page is the one screen the reference also builds
|
||
outside the component layer. */
|
||
input:focus-visible {
|
||
border-color: var(--color-brand) !important;
|
||
background: var(--color-surface) !important;
|
||
box-shadow: none !important;
|
||
outline: none !important;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
RESPONSIVE LAYER
|
||
═══════════════════════════════════════════════════════════════════════════
|
||
|
||
Breakpoints are Tailwind's stock set, which is what the reference uses — it
|
||
declares no `screens` key, so the defaults apply:
|
||
|
||
base 0–639 phone
|
||
sm 640 large phone / small tablet
|
||
md 768 tablet · small laptop
|
||
lg 1024 laptop
|
||
xl 1280 small desktop
|
||
2xl 1536 large desktop
|
||
|
||
Mobile-first throughout: the base rule is the phone state and every override
|
||
is a min-width query. Written as classes rather than inline styles because a
|
||
media query cannot live in a style attribute, and because layout that depends
|
||
on JS measuring the viewport flashes the wrong thing on first paint.
|
||
═════════════════════════════════════════════════════════════════════════ */
|
||
|
||
/* ── Gutters ───────────────────────────────────────────────────────────────
|
||
16px on a phone, 24px from lg.
|
||
|
||
The shell is fluid up to `--container-admin` (1632px of content) and then
|
||
stops and centres. Fluid-forever was wrong: on a 2560px monitor it stretched
|
||
a six-column table to 1660px, opening a dead 600px gap between "Tenant" and
|
||
"City" and stranding three KPI tiles at the far left of a 2100px row. A row
|
||
the eye cannot track from label to value is not more information, it is less.
|
||
|
||
The cap lands on BOTH the header row and the body, because they share this
|
||
class — nav, page title and table all stay on the same left edge at every
|
||
width. `calc(... + 48px)` is the content cap plus the two gutters, so the
|
||
measured content box is exactly `--container-admin`. */
|
||
.app-gutter {
|
||
padding-left: 16px;
|
||
padding-right: 16px;
|
||
margin-inline: auto;
|
||
width: 100%;
|
||
max-width: calc(var(--container-admin) + 32px);
|
||
}
|
||
@media (min-width: 1024px) {
|
||
.app-gutter {
|
||
padding-left: 24px;
|
||
padding-right: 24px;
|
||
max-width: calc(var(--container-admin) + 48px);
|
||
}
|
||
}
|
||
|
||
/* ── Visibility ─────────────────────────────────────────────────────────────
|
||
`display` is set here rather than inline, so an inline style cannot win over
|
||
the query. Each class names the breakpoint it turns on at. */
|
||
.show-from-md {
|
||
display: none;
|
||
}
|
||
@media (min-width: 768px) {
|
||
.show-from-md {
|
||
display: flex;
|
||
}
|
||
}
|
||
|
||
.show-from-lg {
|
||
display: none;
|
||
}
|
||
@media (min-width: 1024px) {
|
||
.show-from-lg {
|
||
display: flex;
|
||
}
|
||
}
|
||
|
||
.lg-hidden {
|
||
display: inline-flex;
|
||
}
|
||
@media (min-width: 1024px) {
|
||
.lg-hidden {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
/* ── Admin body ────────────────────────────────────────────────────────────
|
||
A column on a phone so the assistant stacks under the page; a row from md,
|
||
where a tablet already lays the inline column out acceptably. Only phones
|
||
stack — that threshold is md (768), not lg, on purpose. */
|
||
.admin-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
@media (min-width: 768px) {
|
||
.admin-body {
|
||
flex-direction: row;
|
||
gap: 24px;
|
||
align-items: flex-start;
|
||
}
|
||
}
|
||
|
||
/* ── Assistant column ──────────────────────────────────────────────────────
|
||
Stacked and fixed-height below md; a sticky 380px column above it, sized so
|
||
the page beside it still has a usable measure. */
|
||
.assistant {
|
||
width: 100%;
|
||
height: 32rem;
|
||
padding-top: 0;
|
||
padding-bottom: 24px;
|
||
}
|
||
@media (min-width: 768px) {
|
||
.assistant {
|
||
/* Flush: the column starts where the header ends and runs to the bottom of
|
||
the window. KROW insets its assistant by 24px top and bottom, but that
|
||
leaves two strips of page background inside what reads as one panel —
|
||
the edge-to-edge surface is the whole point of a docked column rather
|
||
than a floating card. Height is therefore the viewport minus the header
|
||
alone. */
|
||
position: sticky;
|
||
top: 56px;
|
||
width: 340px;
|
||
flex: none;
|
||
padding-top: 0;
|
||
padding-bottom: 0;
|
||
height: calc(100dvh - 56px);
|
||
margin-right: -16px;
|
||
}
|
||
}
|
||
@media (min-width: 1024px) {
|
||
.assistant {
|
||
margin-right: -24px;
|
||
}
|
||
}
|
||
/* 380px only once the page beside it can afford it. At 1280 the split was
|
||
828/380 and the tenant table's last column got clipped mid-word; holding
|
||
Buddy at 340 until 1536 gives the page the width back where it is scarce and
|
||
the extra 40px where it is not. */
|
||
@media (min-width: 1536px) {
|
||
.assistant {
|
||
width: 380px;
|
||
}
|
||
}
|
||
|
||
/* ── Page header ───────────────────────────────────────────────────────────
|
||
22px on a phone, 24px from sm. */
|
||
.page-title {
|
||
font-size: 22px;
|
||
}
|
||
@media (min-width: 640px) {
|
||
.page-title {
|
||
font-size: 24px;
|
||
}
|
||
}
|
||
|
||
/* ── Tables ────────────────────────────────────────────────────────────────
|
||
A table is the one thing that genuinely cannot reflow: columns have meaning
|
||
and dropping them silently loses data. So it scrolls sideways inside its own
|
||
container, and the page never does. */
|
||
.table-scroll {
|
||
overflow-x: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
|
||
/* A scroll shadow, so a clipped column reads as "there is more this way"
|
||
rather than as a rendering bug. The two `local` layers are opaque covers
|
||
that travel WITH the content; the two `scroll` layers are the shadows
|
||
pinned to the container. When the content is flush to an edge its cover
|
||
sits over that shadow and hides it, so the hint appears only on the side
|
||
that actually has more to show — no JS, no scroll listener. */
|
||
background:
|
||
linear-gradient(to right, var(--color-surface) 30%, rgb(255 255 255 / 0)) left center / 28px 100%
|
||
no-repeat,
|
||
linear-gradient(to left, var(--color-surface) 30%, rgb(255 255 255 / 0)) right center / 28px 100%
|
||
no-repeat,
|
||
radial-gradient(farthest-side at 0 50%, rgb(16 24 32 / 0.1), rgb(16 24 32 / 0)) left center /
|
||
12px 100% no-repeat,
|
||
radial-gradient(farthest-side at 100% 50%, rgb(16 24 32 / 0.1), rgb(16 24 32 / 0)) right center /
|
||
12px 100% no-repeat;
|
||
background-attachment: local, local, scroll, scroll;
|
||
}
|
||
.table-scroll > table {
|
||
min-width: 720px;
|
||
}
|
||
|
||
/* ── Toolbars and filter rows ──────────────────────────────────────────────
|
||
Stacked on a phone so a search field gets a full row, inline from sm. */
|
||
.toolbar-stack {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 8px;
|
||
width: 100%;
|
||
}
|
||
@media (min-width: 640px) {
|
||
.toolbar-stack {
|
||
flex-direction: row;
|
||
align-items: center;
|
||
width: auto;
|
||
}
|
||
}
|
||
|
||
/* ── Login ─────────────────────────────────────────────────────────────────
|
||
One column below md, two from md — the reference splits at exactly that
|
||
point. Padding relaxes at sm so a phone is not spending 80px on margins. */
|
||
.login-shell {
|
||
padding: 12px;
|
||
}
|
||
@media (min-width: 640px) {
|
||
.login-shell {
|
||
padding: 24px;
|
||
}
|
||
}
|
||
|
||
.login-split {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
}
|
||
@media (min-width: 768px) {
|
||
.login-split {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
}
|
||
|
||
.login-brand {
|
||
padding: 24px;
|
||
border-bottom: 1px solid var(--color-line);
|
||
border-right: 0;
|
||
gap: 20px;
|
||
}
|
||
@media (min-width: 640px) {
|
||
.login-brand {
|
||
padding: 32px;
|
||
}
|
||
}
|
||
@media (min-width: 768px) {
|
||
.login-brand {
|
||
padding: 40px;
|
||
gap: 32px;
|
||
border-bottom: 0;
|
||
border-right: 1px solid var(--color-line);
|
||
}
|
||
}
|
||
|
||
.login-form {
|
||
padding: 24px;
|
||
}
|
||
@media (min-width: 640px) {
|
||
.login-form {
|
||
padding: 32px;
|
||
}
|
||
}
|
||
@media (min-width: 768px) {
|
||
.login-form {
|
||
padding: 40px;
|
||
}
|
||
}
|
||
|
||
/* The hero image earns its space on a laptop; on a phone it is the thing
|
||
pushing the form below the fold, so it goes. */
|
||
.login-hero {
|
||
display: none;
|
||
}
|
||
@media (min-width: 768px) {
|
||
.login-hero {
|
||
display: block;
|
||
}
|
||
}
|
||
|
||
/* ── Dialogs ───────────────────────────────────────────────────────────────
|
||
Full-bleed on a phone; the width cap engages from sm. */
|
||
.dialog-panel {
|
||
width: 100vw;
|
||
max-width: 100vw;
|
||
}
|
||
@media (min-width: 640px) {
|
||
.dialog-panel {
|
||
width: 640px;
|
||
max-width: calc(100vw - 32px);
|
||
}
|
||
}
|
||
|
||
/* Touch targets: a 32px control is fine with a mouse and small with a thumb,
|
||
so coarse pointers get the comfortable minimum. */
|
||
@media (pointer: coarse) {
|
||
button,
|
||
[role='button'],
|
||
a[href] {
|
||
min-height: 40px;
|
||
}
|
||
}
|
||
|
||
/* ── KPI rows ──────────────────────────────────────────────────────────────
|
||
Two up on a phone, three from md, then natural-width tiles from lg.
|
||
|
||
`auto-fill` rather than `auto-fit` above lg is deliberate: `auto-fit`
|
||
collapses the empty tracks and stretches three tiles across a 1400px row,
|
||
which makes a five-word label sit in the middle of a billboard. `auto-fill`
|
||
keeps the empty tracks, so a tile stays tile-sized however few there are. */
|
||
.kpi-grid {
|
||
display: grid;
|
||
gap: 10px;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
@media (min-width: 768px) {
|
||
.kpi-grid {
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
}
|
||
@media (min-width: 1024px) {
|
||
.kpi-grid {
|
||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
}
|
||
}
|
||
|
||
/* ── Form rows ─────────────────────────────────────────────────────────────
|
||
One field per row on a phone, two from sm, three from lg. A three-across
|
||
form on a 768px tablet puts a postcode field at 200px, which is narrower
|
||
than the value it holds. */
|
||
.form-grid {
|
||
display: grid;
|
||
gap: 16px;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
@media (min-width: 640px) {
|
||
.form-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
@media (min-width: 1024px) {
|
||
.form-grid {
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
/* Address rows want four short fields, so they get a tighter ladder. */
|
||
.form-grid-4 {
|
||
display: grid;
|
||
gap: 16px;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
@media (min-width: 1024px) {
|
||
.form-grid-4 {
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
/* ── Product / catalogue cards ─────────────────────────────────────────────
|
||
Two up on a phone — a product grid at one card per row reads as a list and
|
||
loses the visual comparison that makes a grid worth having. */
|
||
.card-grid {
|
||
display: grid;
|
||
gap: 12px;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
@media (min-width: 640px) {
|
||
.card-grid {
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
}
|
||
@media (min-width: 1024px) {
|
||
.card-grid {
|
||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
}
|
||
}
|
||
|
||
/* ── Container queries for content grids ───────────────────────────────────
|
||
The reference uses viewport queries throughout, and for the shell that is
|
||
right. For the grids INSIDE the page it is wrong, and the assistant column is
|
||
what exposes it: at 768px the viewport says "tablet, two columns please"
|
||
while the main column is actually 390px wide because a 340px panel is sitting
|
||
beside it. The result is a two-column form at phone width, with labels
|
||
wrapping onto three lines.
|
||
|
||
A grid does not care how wide the window is. It cares how wide its own
|
||
container is. So `main` becomes a query container and these grids ask it
|
||
directly. The viewport rules above stay as the fallback for anything that
|
||
cannot do container queries; where both apply, these win by cascade order. */
|
||
main {
|
||
container-type: inline-size;
|
||
container-name: page;
|
||
}
|
||
|
||
@container page (min-width: 520px) {
|
||
.form-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
@container page (min-width: 860px) {
|
||
.form-grid {
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@container page (max-width: 519px) {
|
||
.form-grid,
|
||
.form-grid-4 {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
}
|
||
@container page (min-width: 520px) {
|
||
.form-grid-4 {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
@container page (min-width: 860px) {
|
||
.form-grid-4 {
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@container page (min-width: 380px) {
|
||
.kpi-grid,
|
||
.card-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
@container page (min-width: 620px) {
|
||
.kpi-grid,
|
||
.card-grid {
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
}
|
||
@container page (min-width: 900px) {
|
||
/* `auto-fit`, not `auto-fill`. `auto-fill` keeps the empty tracks it makes
|
||
room for, so a four-tile row on a wide screen renders four tiles and a
|
||
ragged hole where a fifth would have gone. `auto-fit` collapses those
|
||
tracks and the tiles divide the row evenly.
|
||
|
||
The reason to have preferred `auto-fill` — three tiles stretching across a
|
||
very wide row — no longer applies: the shell caps content at
|
||
`--container-admin`, so the widest a page column ever gets is 1228px, and
|
||
three tiles across that is a legible 400px each rather than an absurd 700. */
|
||
.kpi-grid,
|
||
.card-grid {
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
}
|
||
}
|
||
|
||
/* ── Terminal cards ────────────────────────────────────────────────────────
|
||
Its own grid rather than `.card-grid`, because these cards carry sentences,
|
||
not figures: a chip row, a line of what is actually true, and a line of what
|
||
to do about it. `.card-grid` opens at two columns even on a phone, which is
|
||
right for a branch tile of four numbers and wrong here — 180px of width turns
|
||
"Bills stranded" into four wrapped lines.
|
||
|
||
So: one column until there is room for prose, then `auto-fit` on a 280px
|
||
floor. Against the 1228px content cap that tops out at four across. */
|
||
.terminal-grid {
|
||
display: grid;
|
||
gap: 12px;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
@container page (min-width: 560px) {
|
||
.terminal-grid {
|
||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||
}
|
||
}
|
||
|
||
/* ── Product grid ──────────────────────────────────────────────────────────
|
||
Column counts matched to the old console — staff already read products at
|
||
that density, and a wider card just strands the 160px photo band.
|
||
|
||
Container queries, not viewport ones: at 768px the viewport says "tablet,
|
||
three columns" while the page column is 390px with the assistant beside it. */
|
||
.product-grid {
|
||
display: grid;
|
||
gap: 16px;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
@container page (min-width: 460px) {
|
||
.product-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||
}
|
||
@container page (min-width: 720px) {
|
||
.product-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||
}
|
||
@container page (min-width: 1000px) {
|
||
.product-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||
}
|
||
@container page (min-width: 1180px) {
|
||
.product-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||
}
|
||
|
||
/* The import drawer sits outside `main`, so it has no `page` container to ask
|
||
and sizes off its own width instead. */
|
||
.import-scroll { container-type: inline-size; container-name: drawer; }
|
||
@container drawer (min-width: 460px) {
|
||
.import-scroll .product-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||
}
|
||
@container drawer (min-width: 700px) {
|
||
.import-scroll .product-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||
}
|
||
@container drawer (min-width: 900px) {
|
||
.import-scroll .product-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||
}
|
||
|
||
/* ────────────────────────────────────────────────────────────────────────────
|
||
Global catalogue — the rail and the product card
|
||
────────────────────────────────────────────────────────────────────────────
|
||
|
||
Modelled on the storefront reference, translated to this console's tokens
|
||
rather than its palette: the same proportions and the same rhythm, in
|
||
Nearle's purple and the greys every other page uses. Lifting the reference's
|
||
orange would have made one page look like a different product.
|
||
──────────────────────────────────────────────────────────────────────────── */
|
||
|
||
/* The search + rail-toggle row, lifted level with the page's tab row.
|
||
42px is the measured distance between the two rows' centres. The bottom
|
||
margin then restores the 24px the grid should keep below the tab row —
|
||
without it the pull-up drags the whole grid up with it and the first row of
|
||
cards slides under the tabs. */
|
||
@media (min-width: 900px) {
|
||
.catalogue-controls {
|
||
margin-top: -42px;
|
||
margin-bottom: 18px;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
}
|
||
|
||
.catalogue-layout {
|
||
display: grid;
|
||
gap: 20px;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
align-items: start;
|
||
}
|
||
|
||
@media (min-width: 900px) {
|
||
/* The rail is fixed, the grid takes what is left — a rail that grows with the
|
||
window steals width from the photographs, which are the content. */
|
||
.catalogue-layout[data-rail='open'] {
|
||
grid-template-columns: 216px minmax(0, 1fr);
|
||
}
|
||
}
|
||
|
||
.catalogue-rail {
|
||
border: 1px solid var(--color-line);
|
||
border-radius: 14px;
|
||
background: var(--color-surface);
|
||
position: sticky;
|
||
top: 72px;
|
||
/* The clip lives here, on the element that owns the radius. */
|
||
overflow: hidden;
|
||
}
|
||
|
||
.rail-scroll {
|
||
padding: 6px;
|
||
max-height: calc(100dvh - 98px);
|
||
overflow-y: auto;
|
||
/* A thin, quiet scrollbar. The default is wide enough to read as a second
|
||
column beside a 216px rail. */
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--color-line) transparent;
|
||
}
|
||
|
||
.rail-scroll::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.rail-scroll::-webkit-scrollbar-thumb {
|
||
border-radius: 999px;
|
||
background: var(--color-line);
|
||
}
|
||
|
||
.rail-scroll::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
.rail-all,
|
||
.rail-brand,
|
||
.rail-category,
|
||
.rail-more {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
width: 100%;
|
||
border: none;
|
||
background: transparent;
|
||
border-radius: 9px;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
text-align: left;
|
||
color: var(--color-ink-1);
|
||
}
|
||
|
||
.rail-all {
|
||
padding: 9px 10px;
|
||
font-size: 13.5px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.rail-brand {
|
||
padding: 8px 10px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.rail-brand-name {
|
||
text-transform: capitalize;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.rail-category {
|
||
padding: 5px 10px 5px 20px;
|
||
font-size: 12.5px;
|
||
color: var(--color-ink-3);
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.rail-all:hover,
|
||
.rail-brand:hover,
|
||
.rail-category:hover,
|
||
.rail-more:hover {
|
||
background: var(--color-surface-sunken);
|
||
}
|
||
|
||
.rail-all[data-active='true'],
|
||
.rail-brand[data-active='true'] {
|
||
background: color-mix(in oklab, var(--color-brand) 8%, transparent);
|
||
color: var(--color-brand);
|
||
}
|
||
|
||
.rail-category[data-active='true'] {
|
||
color: var(--color-brand);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.rail-group[data-open='true'] {
|
||
background: color-mix(in oklab, var(--color-brand) 4%, transparent);
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.rail-count {
|
||
flex: none;
|
||
font-size: 11px;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--color-ink-4);
|
||
}
|
||
|
||
.rail-more {
|
||
padding: 8px 10px;
|
||
margin-top: 2px;
|
||
border-top: 1px solid var(--color-line);
|
||
border-radius: 0 0 9px 9px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--color-brand);
|
||
justify-content: center;
|
||
}
|
||
|
||
.rail-skeleton {
|
||
height: 13px;
|
||
border-radius: 6px;
|
||
background: var(--color-surface-sunken);
|
||
}
|
||
|
||
/* ── The card ─────────────────────────────────────────────────────────────── */
|
||
|
||
.pcard {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
border: 1px solid var(--color-line);
|
||
border-radius: 14px;
|
||
background: var(--color-surface);
|
||
overflow: hidden;
|
||
transition: border-color .18s, box-shadow .18s;
|
||
}
|
||
|
||
.pcard:hover {
|
||
border-color: color-mix(in oklab, var(--color-brand) 38%, transparent);
|
||
box-shadow: 0 10px 24px -12px rgb(16 24 40 / .22);
|
||
}
|
||
|
||
.pcard-media {
|
||
position: relative;
|
||
/* Flex, not grid, and the reason is not taste: a grid row is sized by its
|
||
content unless told otherwise, so an oversized child grows the row past the
|
||
container and a percentage max-height then resolves against the GROWN row —
|
||
which is why a 240px pack shot stayed 220px tall inside a 150px band. In a
|
||
flex container with a definite height, the child's percentage resolves
|
||
against that height. */
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
/* A fixed band rather than a square. At `aspect-ratio: 1` the photograph took
|
||
more than half the card's height and pushed the name, the size and the
|
||
price below the fold of a grid row — the picture identifies the product,
|
||
but the text is what is read once it has. */
|
||
height: 150px;
|
||
padding: 0;
|
||
border: none;
|
||
border-bottom: 1px solid var(--color-line);
|
||
background: var(--color-surface);
|
||
cursor: pointer;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.pcard-media img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
/* Catalogue photography is shot on white; without this the photo's own
|
||
ground reads as a grey panel inside the card. */
|
||
mix-blend-mode: multiply;
|
||
transition: transform .5s ease;
|
||
}
|
||
|
||
.pcard:hover .pcard-media img {
|
||
transform: scale(1.06);
|
||
}
|
||
|
||
.pcard-noimage {
|
||
display: grid;
|
||
place-items: center;
|
||
color: var(--color-line);
|
||
}
|
||
|
||
/* The click target, under the controls rather than around them. */
|
||
.pcard-open {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 1;
|
||
padding: 0;
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* ── The photo switcher ───────────────────────────────────────────────────── */
|
||
|
||
.pcard-arrow {
|
||
position: absolute;
|
||
top: 50%;
|
||
z-index: 3;
|
||
display: grid;
|
||
place-items: center;
|
||
width: 26px;
|
||
height: 26px;
|
||
transform: translateY(-50%);
|
||
border: 1px solid var(--color-line);
|
||
border-radius: 999px;
|
||
background: var(--color-surface);
|
||
color: var(--color-ink-3);
|
||
cursor: pointer;
|
||
opacity: 0;
|
||
transition: opacity .16s, background .16s, color .16s;
|
||
box-shadow: 0 2px 6px rgb(16 24 40 / .12);
|
||
}
|
||
|
||
.pcard-arrow.is-left { left: 6px; }
|
||
.pcard-arrow.is-right { right: 6px; }
|
||
|
||
/* Shown on hover, and on keyboard focus — otherwise the only way to reach a
|
||
product's other photos would be a mouse. */
|
||
.pcard:hover .pcard-arrow,
|
||
.pcard-arrow:focus-visible {
|
||
opacity: 1;
|
||
}
|
||
|
||
.pcard-arrow:hover {
|
||
background: var(--color-brand);
|
||
border-color: var(--color-brand);
|
||
color: #fff;
|
||
}
|
||
|
||
.pcard-dots {
|
||
position: absolute;
|
||
bottom: 7px;
|
||
left: 50%;
|
||
z-index: 3;
|
||
display: flex;
|
||
gap: 4px;
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.pcard-dot {
|
||
width: 5px;
|
||
height: 5px;
|
||
padding: 0;
|
||
border: none;
|
||
border-radius: 999px;
|
||
background: var(--color-line);
|
||
cursor: pointer;
|
||
transition: width .2s, background .2s;
|
||
}
|
||
|
||
.pcard-dot[data-active] {
|
||
width: 13px;
|
||
background: var(--color-brand);
|
||
}
|
||
|
||
.pcard-owned {
|
||
position: absolute;
|
||
top: 8px;
|
||
padding: 3px 8px;
|
||
border-radius: 7px;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
letter-spacing: .04em;
|
||
text-transform: uppercase;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.pcard-owned {
|
||
right: 8px;
|
||
background: #059669;
|
||
color: #fff;
|
||
}
|
||
|
||
.pcard-rail {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
opacity: 0;
|
||
transform: translateX(6px);
|
||
transition: opacity .18s, transform .18s;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Hidden while the owned badge holds that corner — two things stacked there
|
||
is how a badge ends up unreadable. */
|
||
.pcard:hover .pcard-rail {
|
||
opacity: 1;
|
||
transform: none;
|
||
}
|
||
|
||
.pcard[data-imported] .pcard-rail {
|
||
display: none;
|
||
}
|
||
|
||
.pcard-railbtn {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 999px;
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-line);
|
||
color: var(--color-ink-3);
|
||
box-shadow: 0 2px 6px rgb(16 24 40 / .1);
|
||
}
|
||
|
||
.pcard-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
gap: 5px;
|
||
padding: 11px 12px 12px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.pcard-name {
|
||
padding: 0;
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
font: inherit;
|
||
/* Medium, not bold. In the reference the product name is the quiet line and
|
||
the photograph does the identifying — a grid of two dozen bold names reads
|
||
as a list of headlines. */
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
line-height: 1.4;
|
||
color: var(--color-ink-1);
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.pcard-name:hover {
|
||
color: var(--color-brand);
|
||
}
|
||
|
||
.pcard-meta {
|
||
margin: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
min-width: 0;
|
||
font-size: 11.5px;
|
||
color: var(--color-ink-4);
|
||
}
|
||
|
||
.pcard-sku {
|
||
font-family: var(--font-mono);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.pcard-size {
|
||
flex: none;
|
||
padding: 1px 6px;
|
||
border-radius: 6px;
|
||
background: var(--color-surface-sunken);
|
||
color: var(--color-ink-3);
|
||
}
|
||
|
||
.pcard-foot {
|
||
margin-top: auto;
|
||
padding-top: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
|
||
.pcard-price {
|
||
white-space: nowrap;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--color-ink-1);
|
||
}
|
||
|
||
.pcard-photos {
|
||
flex: none;
|
||
font-size: 11px;
|
||
color: var(--color-ink-4);
|
||
}
|
||
|
||
/* ── The action ───────────────────────────────────────────────────────────────
|
||
An outlined pill that fills on hover, which is the reference's treatment and
|
||
has no equivalent among Astryx's four Button variants (primary, secondary,
|
||
ghost, destructive — all filled or transparent, none outlined). It is drawn
|
||
here rather than approximated with the wrong stock variant, in Nearle's
|
||
purple rather than the reference's orange: borrowing the palette would have
|
||
made one page look like a different product.
|
||
──────────────────────────────────────────────────────────────────────────── */
|
||
|
||
.pcard-action {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
flex: none;
|
||
padding: 6px 11px;
|
||
border: 1px solid var(--color-line);
|
||
border-radius: 999px;
|
||
background: var(--color-surface);
|
||
color: var(--color-ink-2, var(--color-ink-1));
|
||
font: inherit;
|
||
font-size: 11.5px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
cursor: pointer;
|
||
transition: background .15s, border-color .15s, color .15s;
|
||
}
|
||
|
||
.pcard-action:hover:not(:disabled) {
|
||
background: var(--color-brand);
|
||
border-color: var(--color-brand);
|
||
color: #fff;
|
||
}
|
||
|
||
.pcard-action:disabled {
|
||
cursor: default;
|
||
opacity: .55;
|
||
}
|
||
|
||
.pcard-action.is-added {
|
||
border-color: color-mix(in oklab, #059669 40%, transparent);
|
||
background: color-mix(in oklab, #059669 8%, transparent);
|
||
color: #047857;
|
||
cursor: default;
|
||
}
|
||
|
||
@media (min-width: 560px) {
|
||
.catalogue-layout .product-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||
}
|
||
@media (min-width: 1100px) {
|
||
.catalogue-layout .product-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||
}
|
||
@media (min-width: 1400px) {
|
||
.catalogue-layout .product-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||
}
|
||
|
||
|
||
/* ────────────────────────────────────────────────────────────────────────────
|
||
Demo-mode flag — development only, dropped from a production build
|
||
──────────────────────────────────────────────────────────────────────────── */
|
||
|
||
.demo-flag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex: none;
|
||
padding: 4px 6px 4px 10px;
|
||
border: 1px solid #f0b429;
|
||
border-radius: 999px;
|
||
background: #fef6e4;
|
||
color: #8a5a00;
|
||
font: inherit;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: .02em;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.demo-flag-exit {
|
||
padding: 2px 7px;
|
||
border-radius: 999px;
|
||
background: #8a5a00;
|
||
color: #fff;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.demo-flag:hover .demo-flag-exit {
|
||
background: #6b4600;
|
||
}
|
||
|
||
/* ─────────────────────────────────────────────────────────────────────────
|
||
Store setup
|
||
|
||
Layout only. Every colour is an existing token — brand purple for the
|
||
current step, the ink scale for text, --color-line for anything at rest —
|
||
so the flow reads as part of the console rather than a microsite bolted
|
||
onto it.
|
||
───────────────────────────────────────────────────────────────────────── */
|
||
|
||
.sr-only {
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 1px;
|
||
padding: 0;
|
||
margin: -1px;
|
||
overflow: hidden;
|
||
clip: rect(0, 0, 0, 0);
|
||
white-space: nowrap;
|
||
border: 0;
|
||
}
|
||
|
||
/* The compact indicator is the phone's, and the full one everything above it.
|
||
Not a squeeze of the same markup: five labelled nodes are unreadable at
|
||
360px, and "Step 2 of 5" answers the same question in the space there is. */
|
||
.ob-stepper-compact { display: block; }
|
||
.ob-stepper { display: none; }
|
||
|
||
@media (min-width: 640px) {
|
||
.ob-stepper-compact { display: none; }
|
||
.ob-stepper {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
gap: 0;
|
||
}
|
||
}
|
||
|
||
.ob-step {
|
||
position: relative;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.ob-step-node {
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 999px;
|
||
display: grid;
|
||
place-items: center;
|
||
font: 600 12px/1 var(--font-sans);
|
||
font-variant-numeric: tabular-nums;
|
||
border: 1px solid var(--color-line);
|
||
background: var(--color-surface);
|
||
color: var(--color-ink-4);
|
||
transition: background .2s, border-color .2s, color .2s, box-shadow .2s;
|
||
}
|
||
|
||
.ob-step-label {
|
||
font: 500 12.5px/1.3 var(--font-sans);
|
||
color: var(--color-ink-4);
|
||
text-align: center;
|
||
transition: color .2s;
|
||
}
|
||
|
||
/* The connector sits behind the nodes and stops short of both, so it reads as
|
||
a join rather than an underline. */
|
||
.ob-step-line {
|
||
position: absolute;
|
||
top: 15px;
|
||
left: calc(50% + 22px);
|
||
right: calc(-50% + 22px);
|
||
height: 1px;
|
||
background: var(--color-line);
|
||
transition: background .3s;
|
||
}
|
||
.ob-step-line[data-filled='yes'] { background: var(--color-brand); }
|
||
|
||
.ob-step[data-state='current'] .ob-step-node {
|
||
background: var(--color-brand);
|
||
border-color: var(--color-brand);
|
||
color: #fff;
|
||
/* A soft ring rather than a heavier border: it marks the position without
|
||
making the node look like a button waiting to be pressed. */
|
||
box-shadow: 0 0 0 4px var(--color-brand-tint);
|
||
}
|
||
.ob-step[data-state='current'] .ob-step-label {
|
||
color: var(--color-ink-1);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.ob-step[data-state='done'] .ob-step-node {
|
||
background: var(--color-brand);
|
||
border-color: var(--color-brand);
|
||
color: #fff;
|
||
}
|
||
.ob-step[data-state='done'] .ob-step-label { color: var(--color-ink-2); }
|
||
|
||
/* Selectable cards — the catalogue choice and the delivery question. Hover and
|
||
selection are carried by the border and a tint, never by a shadow that would
|
||
make one card float above the others. */
|
||
.ob-choice {
|
||
display: block;
|
||
width: 100%;
|
||
text-align: left;
|
||
padding: 20px;
|
||
border: 1px solid var(--color-line);
|
||
border-radius: 14px;
|
||
background: var(--color-surface);
|
||
cursor: pointer;
|
||
transition: border-color .18s, background .18s, transform .18s;
|
||
}
|
||
.ob-choice:hover { border-color: var(--color-brand); background: var(--color-brand-tint); }
|
||
.ob-choice:focus-visible {
|
||
outline: 2px solid var(--color-brand);
|
||
outline-offset: 2px;
|
||
}
|
||
.ob-choice[aria-pressed='true'],
|
||
.ob-choice[data-selected='true'] {
|
||
border-color: var(--color-brand);
|
||
background: var(--color-brand-tint);
|
||
}
|
||
|
||
/* The drop zone. The dashed edge is the only place in the console that uses
|
||
one, and it earns it: it says "put something here" before any label is
|
||
read. */
|
||
.ob-drop {
|
||
border: 1.5px dashed var(--color-slate-300);
|
||
border-radius: 14px;
|
||
background: var(--color-surface-subtle);
|
||
padding: 32px 20px;
|
||
text-align: center;
|
||
transition: border-color .18s, background .18s;
|
||
}
|
||
.ob-drop[data-over='true'] {
|
||
border-color: var(--color-brand);
|
||
background: var(--color-brand-tint);
|
||
}
|
||
|
||
/* One step in, one step out. Short enough not to be waited on. */
|
||
@keyframes ob-step-in {
|
||
from { opacity: 0; transform: translateY(6px); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
.ob-panel { animation: ob-step-in .22s cubic-bezier(.4,0,.2,1); }
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.ob-panel { animation: none; }
|
||
.ob-step-line, .ob-step-node, .ob-choice { transition: none; }
|
||
}
|
||
|
||
/* Three benefit cards on the welcome screen: one column on a phone, three
|
||
across from tablet. Same grid rhythm as .card-grid elsewhere. */
|
||
.ob-benefits {
|
||
display: grid;
|
||
gap: 16px;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
@media (min-width: 720px) {
|
||
.ob-benefits { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||
}
|
||
|
||
/* Two large choices — upload or add by hand. Stacked on a phone so neither is
|
||
the cramped one. */
|
||
.ob-choices {
|
||
display: grid;
|
||
gap: 16px;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
@media (min-width: 720px) {
|
||
.ob-choices { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||
}
|
||
|
||
/* The stock routine, drawn. Vertical on a phone so each stage reads as a line;
|
||
a row from tablet, where six across still fits without shrinking the text. */
|
||
.ob-flow {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.ob-flow-step {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 12px;
|
||
border-radius: 10px;
|
||
background: var(--color-surface-subtle);
|
||
border: 1px solid var(--color-line);
|
||
}
|
||
.ob-flow-icon {
|
||
/* Sized for a 17px glyph rather than the two digits that used to sit here. */
|
||
width: 28px;
|
||
height: 28px;
|
||
flex: none;
|
||
border-radius: 9px;
|
||
display: grid;
|
||
place-items: center;
|
||
background: var(--color-brand-tint);
|
||
color: var(--color-brand);
|
||
}
|
||
@media (min-width: 900px) {
|
||
.ob-flow { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||
}
|
||
|
||
/* One tick, drawn once. A business tool does not need confetti, and anything
|
||
longer than this delays the three actions the screen exists to offer. */
|
||
.ob-tick {
|
||
width: 68px;
|
||
height: 68px;
|
||
border-radius: 999px;
|
||
display: grid;
|
||
place-items: center;
|
||
background: #10b981;
|
||
color: #fff;
|
||
animation: ob-pop .38s cubic-bezier(.34, 1.56, .64, 1);
|
||
}
|
||
@keyframes ob-pop {
|
||
0% { transform: scale(.6); opacity: 0; }
|
||
100% { transform: scale(1); opacity: 1; }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.ob-tick { animation: none; }
|
||
}
|
||
|
||
/* ── Onboarding: the step frame ─────────────────────────────────────────────
|
||
A single card per step, in three bands: head (position, title, progress),
|
||
body (the step's own fields) and foot (Back / Continue). The bands are
|
||
separated by rules rather than gaps so the card reads as one object. */
|
||
.ob-frame {
|
||
border: 1px solid var(--color-line);
|
||
border-radius: 18px;
|
||
background: var(--color-surface);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.ob-frame-head {
|
||
padding: 20px 22px 18px;
|
||
border-bottom: 1px solid var(--color-line);
|
||
background: var(--color-surface);
|
||
}
|
||
|
||
.ob-eyebrow {
|
||
text-transform: uppercase;
|
||
letter-spacing: .07em;
|
||
color: var(--color-brand);
|
||
}
|
||
|
||
.ob-bar {
|
||
flex: 1;
|
||
height: 7px;
|
||
border-radius: 999px;
|
||
background: var(--color-line);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.ob-bar-fill {
|
||
height: 100%;
|
||
border-radius: 999px;
|
||
background: var(--color-brand);
|
||
transition: width .4s cubic-bezier(.4, 0, .2, 1);
|
||
}
|
||
|
||
.ob-frame-body { padding: 22px; }
|
||
|
||
.ob-frame-foot {
|
||
padding: 16px 22px;
|
||
border-top: 1px solid var(--color-line);
|
||
/* Tinted, so the action row separates from the fields above it without
|
||
needing a heavier rule. */
|
||
background: var(--color-surface-subtle);
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.ob-frame-head { padding: 16px 16px 14px; }
|
||
.ob-frame-body { padding: 16px; }
|
||
.ob-frame-foot { padding: 14px 16px; }
|
||
/* Full-width actions on a phone: a 96px button in the corner of a 360px
|
||
screen is the hardest thing on the page to hit. */
|
||
.ob-frame-foot button { flex: 1; justify-content: center; }
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.ob-bar-fill { transition: none; }
|
||
}
|
||
|
||
/* ── Onboarding: the way back from an errand ───────────────────────────────
|
||
Shown on Inventory when setup sent the merchant there. Brand-tinted while
|
||
the errand is outstanding, and green once products actually exist. */
|
||
.ob-returnbar {
|
||
padding: 12px 16px;
|
||
border: 1px solid var(--color-brand);
|
||
border-radius: 14px;
|
||
background: var(--color-brand-tint);
|
||
}
|
||
|
||
.ob-returnbar[data-done='yes'] {
|
||
border-color: var(--color-success, #1f9d55);
|
||
background: var(--color-success-tint, #eaf7ef);
|
||
}
|
||
|
||
.ob-returnbar-mark {
|
||
width: 28px;
|
||
height: 28px;
|
||
flex: none;
|
||
border-radius: 999px;
|
||
display: grid;
|
||
place-items: center;
|
||
background: var(--color-brand);
|
||
color: #fff;
|
||
font: 600 12px/1 var(--font-sans);
|
||
}
|
||
|
||
.ob-returnbar[data-done='yes'] .ob-returnbar-mark {
|
||
background: var(--color-success, #1f9d55);
|
||
}
|
||
|
||
/* ── Onboarding: the review summary ────────────────────────────────────────
|
||
One row per step: icon, what it says, and a way to change it. Grid rather
|
||
than flex so the Edit link stays hard right however long the detail runs. */
|
||
.ob-review {
|
||
display: grid;
|
||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||
gap: 12px;
|
||
align-items: start;
|
||
padding: 14px 16px;
|
||
border: 1px solid var(--color-line);
|
||
border-radius: 14px;
|
||
background: var(--color-surface);
|
||
}
|
||
|
||
/* Text renders inline, so the title and its detail ran together on one line
|
||
("Store informationHalfmart · 2 Test Street"). Stacked here rather than by
|
||
wrapping each in a div, so the row stays one grid cell. */
|
||
.ob-review > div > * { display: block; }
|
||
|
||
.ob-review-icon {
|
||
width: 34px;
|
||
height: 34px;
|
||
flex: none;
|
||
border-radius: 10px;
|
||
display: grid;
|
||
place-items: center;
|
||
background: var(--color-brand-tint);
|
||
color: var(--color-brand);
|
||
}
|
||
|
||
.ob-review-edit {
|
||
align-self: center;
|
||
border: 0;
|
||
background: none;
|
||
padding: 4px 6px;
|
||
border-radius: 8px;
|
||
font: 600 13px/1 var(--font-sans);
|
||
color: var(--color-brand);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.ob-review-edit:hover { background: var(--color-brand-tint); }
|
||
.ob-review-edit:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||
|
||
/* The one consequence people do not expect, called out rather than listed. */
|
||
.ob-warn {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
padding: 12px 14px;
|
||
border-radius: 12px;
|
||
border: 1px solid var(--color-warning-line, #e8d9a8);
|
||
background: var(--color-warning-tint, #fdf7e6);
|
||
color: var(--color-ink-1);
|
||
}
|
||
|
||
@media (max-width: 520px) {
|
||
/* The detail wraps under the icon before the Edit link gets squeezed off. */
|
||
.ob-review { grid-template-columns: auto minmax(0, 1fr); }
|
||
.ob-review-edit { grid-column: 2; justify-self: start; padding-left: 0; }
|
||
}
|
||
|
||
/* ── Onboarding: work beside its guidance ──────────────────────────────────
|
||
The upload panel and the tips that govern it, side by side on a desktop and
|
||
stacked on a phone — where a narrow second column would be unreadable. */
|
||
.ob-two {
|
||
display: grid;
|
||
gap: 12px;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
|
||
@media (min-width: 900px) {
|
||
/* Guidance is secondary, so it takes the narrower column. */
|
||
.ob-two { grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); }
|
||
}
|
||
|
||
.ob-tips {
|
||
margin: 0;
|
||
padding: 0;
|
||
list-style: none;
|
||
display: grid;
|
||
gap: 9px;
|
||
}
|
||
|
||
.ob-tips li {
|
||
display: grid;
|
||
grid-template-columns: auto minmax(0, 1fr);
|
||
gap: 8px;
|
||
align-items: start;
|
||
}
|
||
|
||
.ob-tips svg {
|
||
color: var(--color-success, #1f9d55);
|
||
margin-top: 2px;
|
||
flex: none;
|
||
}
|
||
|
||
/* ── Bulk selection ────────────────────────────────────────────────────────
|
||
A row of checkboxes with no visible action reads as broken, and a bar that
|
||
shouts at rest pushes the list itself down the page. So it sits quiet until
|
||
something is picked, then takes the brand tint and shows its actions. */
|
||
.bulkbar {
|
||
padding: 10px 14px;
|
||
border: 1px solid var(--color-line);
|
||
border-radius: 12px;
|
||
background: var(--color-surface);
|
||
transition: background .15s ease, border-color .15s ease;
|
||
}
|
||
|
||
.bulkbar[data-active='yes'] {
|
||
border-color: var(--color-brand);
|
||
background: var(--color-brand-tint);
|
||
}
|
||
|
||
.bulkbar-all {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
/* Sized up from the browser default: these are hit on a phone, and a 13px
|
||
target next to a 40px product thumbnail is the hardest thing on the row. */
|
||
.bulkbar-all input[type='checkbox'],
|
||
.bulkbar-tick {
|
||
width: 17px;
|
||
height: 17px;
|
||
flex: none;
|
||
accent-color: var(--color-brand);
|
||
cursor: pointer;
|
||
margin: 0;
|
||
}
|
||
|
||
.bulkbar-all input[type='checkbox']:focus-visible,
|
||
.bulkbar-tick:focus-visible {
|
||
outline: 2px solid var(--color-brand);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.bulkbar { transition: none; }
|
||
}
|
||
|
||
/* The tick on a catalogue card. Top-left: the photo arrows own the right edge
|
||
and the "Imported" badge owns the top-right. Given its own opaque chip so it
|
||
stays visible against a light product photo. */
|
||
.pcard-tick {
|
||
position: absolute;
|
||
top: 8px;
|
||
left: 8px;
|
||
z-index: 3;
|
||
display: grid;
|
||
place-items: center;
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 8px;
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-line);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.pcard-tick input {
|
||
width: 15px;
|
||
height: 15px;
|
||
margin: 0;
|
||
accent-color: var(--color-brand);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.pcard-tick:focus-within {
|
||
outline: 2px solid var(--color-brand);
|
||
outline-offset: 1px;
|
||
}
|
||
|
||
/* ── Bulk stock request ────────────────────────────────────────────────────
|
||
One line per product, each with its own quantity: a shop asks for two crates
|
||
of oil and twenty packets of biscuits, so a single figure for the batch
|
||
would be ignored or wrong. Scrolls internally so the send button stays put
|
||
however many products were picked. */
|
||
.bulkreq-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
max-height: 46vh;
|
||
overflow-y: auto;
|
||
padding-right: 4px;
|
||
}
|
||
|
||
.bulkreq-line {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
gap: 12px;
|
||
align-items: center;
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--color-line);
|
||
border-radius: 10px;
|
||
background: var(--color-surface);
|
||
}
|
||
|
||
.qty-step {
|
||
width: 28px;
|
||
height: 28px;
|
||
flex: none;
|
||
display: grid;
|
||
place-items: center;
|
||
border: 1px solid var(--color-line);
|
||
border-radius: 8px;
|
||
background: var(--color-surface);
|
||
color: var(--color-ink-2);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.qty-step:hover { border-color: var(--color-brand); color: var(--color-brand); }
|
||
.qty-step:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; }
|
||
|
||
.qty-input {
|
||
width: 46px;
|
||
height: 28px;
|
||
text-align: center;
|
||
border: 1px solid var(--color-line);
|
||
border-radius: 8px;
|
||
background: var(--color-surface);
|
||
color: var(--color-ink-1);
|
||
font: 500 13px/1 var(--font-sans);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.qty-input:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; }
|
||
|