/* ── 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; }