Initial commit

This commit is contained in:
2026-08-24 20:35:18 +05:30
commit 1dc582ba07
85 changed files with 21081 additions and 0 deletions

613
src/index.css Normal file
View File

@@ -0,0 +1,613 @@
/* ── 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 {
position: sticky;
top: 56px;
width: 340px;
flex: none;
padding-top: 0px;
padding-bottom: 0px;
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));
}
}