/* ============================================================
   GRAM DESIGN SYSTEM · COMPONENT LAYER (framework-neutral)
   ============================================================

   WHAT THIS IS
   ------------
   The Gram Design System publishes its components as React (.jsx) plus a
   compiled bundle. This app is server-rendered Python + htmx and cannot run
   React, so for a long time it could not consume the design system's components
   at all – it re-implemented them by hand, once per page. That is where 637 CSS
   classes, 29 card styles and 4 separate on/off toggles came from.

   Every DS component .jsx embeds its styling as a plain `const CSS` template
   string and injects it into <head> at runtime. That CSS is already
   framework-neutral. THIS FILE IS THAT CSS, LIFTED OUT VERBATIM – one block per
   component, in the DS's own order, with the source path recorded above each.

   Because the rules are copied rather than re-authored, the React component and
   the Python helper cannot look different: they are the same declarations.

   HOW TO USE IT
   -------------
   Don't hand-write these class names. Call the Python helpers in
   `app/src/shared/ui/components.py`, which emit exactly the markup each DS
   component produces:

       from ..shared.ui import components as c
       c.button("File report", variant="primary")
       c.card(inner_html, padded=True)
       c.switch("Email me before each deadline", name="remind", checked=True)

   Browse every component and variant at /design-system in the running app.

   SYNC RULES
   ----------
   - Upstream is Claude Design project 3ca67572-7bd6-4a3e-92f8-3abf4fe7327e.
   - Blocks marked `SOURCE: components/...jsx` are copied from the DS. Do not
     edit them here – change the DS and re-pull, or the next sync overwrites you.
   - Blocks marked `GRAM APP ORIGIN` were built in this app first because the DS
     has no equivalent (date picker, data table, status pill, sidebar-safe
     tooltip). They are candidates to push UP into the DS; see ds/VERSION.
   ============================================================ */


/* ============================================================
   CORE
   ============================================================ */

/* SOURCE: components/core/Button.jsx */
.gram-btn {
  --_bg: var(--accent);
  --_fg: var(--accent-fg);
  --_bd: transparent;
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  font-family: var(--font-sans); font-weight: 600; line-height: 1;
  border: 1px solid var(--_bd); border-radius: var(--radius-md);
  background: var(--_bg); color: var(--_fg);
  cursor: pointer; white-space: nowrap; text-decoration: none;
  transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out);
  -webkit-font-smoothing: antialiased;
}

/* sizes */
/* xs: the in-grid micro button (a confirm row inside a table header). Tight
   corners on purpose - radius-md on a 22px control reads as a pill. */
.gram-btn--xs { height: 22px; padding: 0 8px; font-size: 11px; border-radius: var(--radius-sm); }
.gram-btn--sm { height: 34px; padding: 0 14px; font-size: 13px; }
.gram-btn--md { height: 42px; padding: 0 18px; font-size: 14px; }
.gram-btn--lg { height: 48px; padding: 0 22px; font-size: 15px; }

/* variants */
.gram-btn--primary { --_bg: var(--accent); --_fg: var(--accent-fg); box-shadow: var(--shadow-xs); }
.gram-btn--primary:hover { --_bg: var(--accent-hover); }
.gram-btn--primary:active { --_bg: var(--accent-active); transform: translateY(0.5px); }

.gram-btn--secondary { --_bg: var(--white); --_fg: var(--text-strong); --_bd: var(--border-default); box-shadow: var(--shadow-xs); }
.gram-btn--secondary:hover { --_bg: var(--neutral-50); --_bd: var(--border-strong); }
.gram-btn--secondary:active { --_bg: var(--neutral-100); }

/* ghost = neutral tertiary. Always carries a surface (never background-less). */
.gram-btn--ghost { --_bg: var(--neutral-100); --_fg: var(--text-strong); }
.gram-btn--ghost:hover { --_bg: var(--neutral-200); }
.gram-btn--ghost:active { --_bg: var(--neutral-300); }

.gram-btn--soft { --_bg: var(--accent-soft); --_fg: var(--accent-hover); }
.gram-btn--soft:hover { --_bg: var(--accent-soft-hover); }

.gram-btn--danger { --_bg: var(--red-500); --_fg: #fff; box-shadow: var(--shadow-xs); }
.gram-btn--danger:hover { --_bg: var(--red-700); }

.gram-btn--block { display: flex; width: 100%; }

/* GRAM APP ORIGIN (2026-08-06): the submit that has to read as a word inside a
   sentence. The manual report's empty list offers two of them - "Sold the same
   products last time? Start from an earlier report, or add every product in
   your catalogue" - and both must stay real submits, because each posts a form
   and each has to work with no script. Anton's own brief for the first one was
   "kan det kanske vara en text pa en mening", and a boxed tertiary button in
   the middle of a clause is not that.
   It is a VARIANT rather than a page class overriding a component from the
   outside, which is the thing that breaks "change it once and everything
   follows". Height, padding and background are unset here so it does not matter
   which size token a caller pairs it with; it comes after the sizes, so it
   wins. */
.gram-btn--link {
  --_bg: transparent; --_fg: var(--accent-hover); --_bd: transparent;
  height: auto; padding: 0; font: inherit; font-weight: 600;
  text-decoration: underline; text-underline-offset: 2px; box-shadow: none;
  vertical-align: baseline;
}
.gram-btn--link:hover { --_bg: transparent; --_fg: var(--accent-active); }
.gram-btn--link:active { transform: none; }

/* SOURCE: components/core/IconButton.jsx */
.gram-iconbtn {
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: var(--radius-md); cursor: pointer;
  border: 1px solid transparent; background: transparent; color: var(--text-muted);
  transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);
}
.gram-iconbtn:hover { background: var(--neutral-100); color: var(--text-strong); }
.gram-iconbtn:active { background: var(--neutral-200); }
/* GRAM APP ORIGIN (2026-08-02): the INLINE icon button. 24px and round, for an
   affordance that sits in a line of text rather than standing on its own – the
   Registrations "our source" book, which appears beside a table cell's chevron
   and beside a section heading. The size existed in icon_button()'s signature
   with no rule behind it, so it rendered unstyled; this is that rule. Round,
   because at this size a square hover fill reads as a rendering artefact next
   to 11px uppercase text. */
.gram-iconbtn--xs { width: 24px; height: 24px; border-radius: var(--radius-full); }
.gram-iconbtn--sm { width: 32px; height: 32px; }

.gram-iconbtn--md { width: 40px; height: 40px; }
.gram-iconbtn--lg { width: 46px; height: 46px; }
.gram-iconbtn--solid { background: var(--white); border-color: var(--border-default); box-shadow: var(--shadow-xs); }
.gram-iconbtn--solid:hover { background: var(--neutral-50); border-color: var(--border-strong); }

/* GRAM APP ORIGIN (2026-08-02): THE inline ⓘ, and the only one.
   `info()` renders it. It is `.gram-tip` underneath, so it uses the same shared,
   viewport-clamped bubble as every other tooltip — no second mechanism.
   Replaces `.info-i` (which existed four times) AND the `.info-pop` click panel
   that those triggers used to open. Hover, not click: nothing to dismiss, and it
   cannot cover a phone screen. */
.gram-info {
  display: inline-flex; align-items: center; justify-content: center;
  flex: none; margin-left: 6px; vertical-align: -3px;
  color: var(--text-subtle); cursor: help; outline: none;
  border-radius: var(--radius-full);
  transition: color var(--dur-fast) var(--ease-out);
}
.gram-info:hover, .gram-info:focus-visible { color: var(--accent-hover); }
.gram-info:focus-visible { box-shadow: var(--ring); }


/* SOURCE: components/core/Icon.jsx – the React wrapper reads the Lucide UMD
   global; server-side we inline the same Lucide path data (see components.py
   ICONS). These are the shared presentation attributes. */
.gram-icon { flex: none; display: inline-block; vertical-align: middle; }

/* SOURCE: components/core/Badge.jsx */
.gram-badge {
  display: inline-flex; align-items: center; gap: 6px;
  font-family: var(--font-sans); font-weight: 600; font-size: 12px; line-height: 1;
  padding: 5px 9px; border-radius: var(--radius-full);
  letter-spacing: 0.005em; white-space: nowrap;
}
.gram-badge .gram-badge__dot { width: 6px; height: 6px; border-radius: var(--radius-full); background: currentColor; }
.gram-badge--neutral { background: var(--neutral-100); color: var(--text-muted); }
.gram-badge--ok { background: var(--status-ok-bg); color: var(--status-ok-fg); }
.gram-badge--warn { background: var(--status-warn-bg); color: var(--status-warn-fg); }
.gram-badge--error { background: var(--status-error-bg); color: var(--status-error-fg); }
.gram-badge--info { background: var(--status-info-bg); color: var(--status-info-fg); }
.gram-badge--accent { background: var(--accent-soft); color: var(--accent-hover); }
.gram-badge--outline { background: transparent; box-shadow: inset 0 0 0 1px var(--border-default); color: var(--text-muted); }

/* SOURCE: components/core/Tag.jsx */
.gram-tag {
  display: inline-flex; align-items: center; gap: 6px;
  font-family: var(--font-sans); font-size: 13px; font-weight: 500; line-height: 1;
  padding: 6px 10px; border-radius: var(--radius-sm);
  background: var(--neutral-100); color: var(--text-body);
  border: 1px solid transparent;
}
.gram-tag--outline { background: transparent; border-color: var(--border-default); }
.gram-tag--accent { background: var(--accent-soft); color: var(--accent-hover); }
.gram-tag__close {
  display: inline-flex; margin: -2px -3px -2px 1px; padding: 2px; border-radius: var(--radius-full);
  cursor: pointer; color: currentColor; opacity: 0.6; background: transparent; border: 0;
  transition: opacity var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out);
}
.gram-tag__close:hover { opacity: 1; background: rgba(0,0,0,0.06); }

/* SOURCE: components/core/Card.jsx */
.gram-card {
  background: var(--surface-card);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-sm);
  overflow: hidden;
}
.gram-card--flat { box-shadow: none; }
.gram-card--raised { box-shadow: var(--shadow-md); border-color: transparent; }
.gram-card--inset { background: var(--surface-inset); box-shadow: none; border-color: var(--border-subtle); }
.gram-card--interactive { cursor: pointer; transition: box-shadow var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out); }
/* GRAM APP ORIGIN (2026-07-31): a card that is a link must never underline.
   card(href=…) renders an <a>, and app.css's `a:hover { text-decoration:
   underline }` used to reach straight through it and underline the badge, the
   heading and the caption inside. The cascade layer in shell.py is what really
   fixes that; this is the belt-and-braces half, so the component stays correct
   even if it is ever rendered outside our own <head>. */
.gram-card, .gram-card:hover { text-decoration: none; }
/* Hover reads as a lift plus a hint of the brand green, rather than as a link.
   A touch more shadow than --shadow-md and a green-biased border: enough to
   say "this responds", not enough to shout. Daniel, 2026-07-31: keep the green
   for now, revisit whether shadow alone is better once it is on real pages. */
.gram-card--interactive:hover {
  box-shadow: 0 6px 16px -4px rgba(23, 25, 19, 0.10), 0 2px 6px -3px rgba(23, 25, 19, 0.06);
  border-color: color-mix(in srgb, var(--green-500) 35%, var(--border-default));
}
.gram-card--interactive:active { transform: translateY(0.5px); }
.gram-card__body { padding: var(--gutter); }


/* ============================================================
   FORMS
   ============================================================ */

/* SOURCE: components/forms/Field.jsx */
.gram-field { display: flex; flex-direction: column; gap: 6px; }
.gram-field__label { font-family: var(--font-sans); font-size: 13px; font-weight: 600; color: var(--text-strong); display: flex; align-items: center; gap: 6px; }
.gram-field__opt { font-weight: 400; color: var(--text-subtle); }
.gram-field__hint { font-size: 12px; color: var(--text-muted); line-height: 1.45; }
.gram-field__error { font-size: 12px; color: var(--status-error-fg); line-height: 1.45; display: flex; align-items: center; gap: 5px; }
/* GRAM APP ORIGIN (2026-07-31): fields on one row, actually lined up.
   Two fields side by side used to sit 12px apart whenever one of them carried
   a hint and the other did not: a flex row centres its children, so the
   shorter field drifted downwards and its label no longer matched its
   neighbour's. Bottom-aligning was the first attempt and was worse – it moved
   the LABEL of the field without a hint instead.

   Grid with `align-items: start` is the answer. Every field starts on the same
   line, the labels agree, the inputs agree because the label row is a fixed
   height, and a hint simply hangs off the bottom of its own column without
   moving anything. min-height on the label is what stops the `optional` marker
   from changing a row's height. */
.gram-form-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(var(--gram-field-min, 180px), 1fr));
  gap: 14px;
  align-items: start;
}
.gram-form-row .gram-field__label { min-height: 19px; }

/* SOURCE: components/forms/Input.jsx */
.gram-input-wrap { position: relative; display: flex; align-items: center; }
.gram-input {
  width: 100%; font-family: var(--font-sans); color: var(--text-strong);
  background: var(--white); border: 1px solid var(--border-default);
  border-radius: var(--radius-md); box-shadow: var(--shadow-xs);
  transition: border-color var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out);
  -webkit-font-smoothing: antialiased;
}
.gram-input::placeholder { color: var(--text-subtle); }
.gram-input:hover { border-color: var(--border-strong); }
.gram-input:focus { border-color: var(--accent); }
.gram-input:disabled { background: var(--neutral-50); color: var(--text-subtle); }
.gram-input--sm { height: 34px; font-size: 13px; padding: 0 11px; }
.gram-input--md { height: 42px; font-size: 14px; padding: 0 13px; }
.gram-input--lg { height: 50px; font-size: 16px; padding: 0 15px; }
.gram-input--invalid { border-color: var(--red-500); }
.gram-input--invalid:focus { box-shadow: 0 0 0 3px rgba(180,64,47,0.18); }
.gram-input--has-icon { padding-left: 38px; }
.gram-input__icon { position: absolute; left: 12px; color: var(--text-subtle); pointer-events: none; display: inline-flex; }

/* SOURCE: components/forms/Textarea.jsx */
.gram-textarea {
  width: 100%; font-family: var(--font-sans); font-size: 14px; color: var(--text-strong);
  background: var(--white); border: 1px solid var(--border-default);
  border-radius: var(--radius-md); box-shadow: var(--shadow-xs);
  padding: 11px 13px; line-height: 1.5; resize: vertical; min-height: 92px;
  transition: border-color var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out);
  -webkit-font-smoothing: antialiased;
}
.gram-textarea::placeholder { color: var(--text-subtle); }
.gram-textarea:hover { border-color: var(--border-strong); }
.gram-textarea:focus { border-color: var(--accent); }
.gram-textarea:disabled { background: var(--neutral-50); color: var(--text-subtle); }
.gram-textarea--invalid { border-color: var(--red-500); }

/* SOURCE: components/forms/Select.jsx */
.gram-select-wrap { position: relative; display: flex; align-items: center; }
.gram-select {
  width: 100%; appearance: none; -webkit-appearance: none;
  font-family: var(--font-sans); color: var(--text-strong);
  background: var(--white); border: 1px solid var(--border-default);
  border-radius: var(--radius-md); box-shadow: var(--shadow-xs);
  padding-right: 38px; cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out);
}
.gram-select:hover { border-color: var(--border-strong); }
.gram-select:focus { border-color: var(--accent); }
.gram-select:disabled { background: var(--neutral-50); color: var(--text-subtle); }
.gram-select--sm { height: 34px; font-size: 13px; padding-left: 11px; }
.gram-select--md { height: 42px; font-size: 14px; padding-left: 13px; }
.gram-select--lg { height: 50px; font-size: 16px; padding-left: 15px; }
/* GRAM APP ORIGIN (2026-08-02): the dense GRID select, the sibling of
   .gram-input--dense. Steps 3 and 4 used to dress grid <select>s as dense
   INPUTS, which cost them the chevron - the one mark that says "this opens".
   Same 28px rhythm as the dense input; the chevron is inlined (the wrap +
   positioned-icon anatomy is too heavy for a 28px grid cell). */
.gram-select--dense {
  height: 28px; font-size: 13px; padding: 0 22px 0 7px;
  border-radius: var(--radius-sm); border-color: var(--border-subtle);
  box-shadow: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='13' height='13' viewBox='0 0 24 24' fill='none' stroke='%235d6058' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 6px center;
}
.gram-select__chevron { position: absolute; right: 13px; pointer-events: none; color: var(--text-subtle); }
/* QUIET select: sits flat in dense chrome (a grid header) and only shows its
   box on hover/focus. Standalone - used directly on a bare <select> with no
   wrapper, so it carries its own chevron as a background image. */
.gram-select--quiet { appearance: none; -webkit-appearance: none;
  border: 1px solid transparent; border-radius: var(--radius-sm);
  background: transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235d6058' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") no-repeat right 4px center;
  font-family: var(--font-sans); font-size: 13px; font-weight: 500;
  color: var(--text-strong); padding: 3px 22px 3px 6px; max-width: 190px;
  cursor: pointer; }
.gram-select--quiet:hover { border-color: var(--border-default); background-color: var(--white); }
.gram-select--quiet:focus { border-color: var(--accent); box-shadow: var(--ring);
  background-color: var(--white); outline: none; }

/* SOURCE: components/forms/Checkbox.jsx */
.gram-check { display: inline-flex; align-items: flex-start; gap: 10px; margin: 0; cursor: pointer; font-family: var(--font-sans); font-size: 14px; color: var(--text-body); line-height: 1.4; }
.gram-check input { position: absolute; opacity: 0; width: 0; height: 0; }
/* The box follows the native "software" checkbox Daniel pointed at (2026-08-02):
   16px, tight corners, solid accent fill when checked. --radius-xs is allowed
   HERE AND ONLY HERE — at 16px the 6px radius read as a blob, not a box. */
.gram-check__box {
  /* calc, not 2px: centres the 16px box on the label's FIRST LINE whatever
     the font size. A fixed 2px was tuned for 14px text and sat visibly low
     beside the 9.5px labels in the matrix (Daniel, 2026-08-02). */
  flex: none; width: 16px; height: 16px; margin-top: calc(0.7em - 8px);
  border-radius: var(--radius-xs);
  border: 1.5px solid var(--border-strong); background: var(--white);
  display: inline-flex; align-items: center; justify-content: center; color: #fff;
  transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);
}
.gram-check__box svg { opacity: 0; transform: scale(0.6); transition: opacity var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out); }
.gram-check:hover .gram-check__box { border-color: var(--accent); }
.gram-check input:checked + .gram-check__box { background: var(--accent); border-color: var(--accent); }
.gram-check input:checked + .gram-check__box svg { opacity: 1; transform: scale(1); }

/* The dense grid's checkbox: same box, smaller voice. Products and Step 3
   used to restyle .gram-check per page (12px there, 9.5px here); one variant
   ends that. */
.gram-check--sm { font-size: 12px; gap: 5px; }
.gram-check--sm .gram-check__box { width: 14px; height: 14px; margin-top: 1px; }

/* SOURCE: components/forms/Radio.jsx */
.gram-radio { display: inline-flex; align-items: flex-start; gap: 10px; margin: 0; cursor: pointer; font-family: var(--font-sans); font-size: 14px; color: var(--text-body); line-height: 1.4; }
.gram-radio input { position: absolute; opacity: 0; width: 0; height: 0; }
/* Same 16px family as the checkbox, or two sizes sit on one form. */
.gram-radio__dot {
  flex: none; width: 16px; height: 16px; margin-top: calc(0.7em - 8px);
  border-radius: var(--radius-full);
  border: 1.5px solid var(--border-strong); background: var(--white);
  display: inline-flex; align-items: center; justify-content: center;
  transition: border-color var(--dur-fast) var(--ease-out);
}
.gram-radio__dot::after { content: ''; width: 8px; height: 8px; border-radius: var(--radius-full); background: var(--accent); transform: scale(0); transition: transform var(--dur-fast) var(--ease-out); }
.gram-radio:hover .gram-radio__dot { border-color: var(--accent); }
.gram-radio input:checked + .gram-radio__dot { border-color: var(--accent); }
.gram-radio input:checked + .gram-radio__dot::after { transform: scale(1); }

/* SOURCE: components/forms/Switch.jsx */
.gram-switch { display: inline-flex; align-items: center; gap: 10px; margin: 0; cursor: pointer; font-family: var(--font-sans); font-size: 14px; color: var(--text-body); }
.gram-switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.gram-switch__track {
  flex: none; box-sizing: border-box; width: 42px; height: 24px; border-radius: var(--radius-full); background: var(--neutral-300);
  padding: 2px; display: inline-flex; align-items: center;
  transition: background var(--dur-fast) var(--ease-out);
}
.gram-switch__thumb {
  display: block; box-sizing: border-box; width: 18px; height: 18px; border-radius: var(--radius-full); background: #fff;
  box-shadow: 0 1px 2px rgba(23,25,19,0.22), 0 0 0 0.5px rgba(23,25,19,0.04); transition: transform var(--dur-normal) var(--ease-spring);
}
.gram-switch input:checked + .gram-switch__track { background: var(--accent); }
.gram-switch input:checked + .gram-switch__track .gram-switch__thumb { transform: translateX(20px); }


/* ============================================================
   FEEDBACK
   ============================================================ */

/* SOURCE: components/feedback/Callout.jsx */
.gram-callout {
  display: flex; gap: 12px; align-items: flex-start;
  padding: 14px 16px; border-radius: var(--radius-md);
  font-family: var(--font-sans); font-size: 14px; line-height: 1.5;
  border: 1px solid transparent;
}
.gram-callout__icon { flex: none; margin-top: 1px; }
/* THE COMPACT SIZE, `callout(compact=True)`. /report/start must fit in a 720px
   viewport without scrolling (Anton, 2026-08-01) and can carry three notices at
   once. Padding and type only — the colours, the icon and the border stay the
   component's, so a restyle still lands in this file and follows everywhere.
   The icon is sized through the helper's `compact` flag rather than a rule
   here, so the SVG ships at the right size instead of being scaled after the
   fact. (Ours from main; see the note on components.callout.) */
.gram-callout--sm { padding: 9px 12px; font-size: 13px; line-height: 1.45;
                    gap: 9px; }
.gram-callout__body { color: var(--text-body); }
.gram-callout__title { font-weight: 600; color: var(--text-strong); margin: 0 0 2px; }
.gram-callout--info { background: var(--status-info-bg); border-color: color-mix(in srgb, var(--blue-500) 18%, transparent); }
.gram-callout--info .gram-callout__icon { color: var(--status-info-fg); }
.gram-callout--ok { background: var(--status-ok-bg); border-color: color-mix(in srgb, var(--green-500) 22%, transparent); }
.gram-callout--ok .gram-callout__icon { color: var(--status-ok-fg); }
.gram-callout--warn { background: var(--status-warn-bg); border-color: color-mix(in srgb, var(--amber-500) 24%, transparent); }
.gram-callout--warn .gram-callout__icon { color: var(--status-warn-fg); }
.gram-callout--error { background: var(--status-error-bg); border-color: color-mix(in srgb, var(--red-500) 22%, transparent); }
.gram-callout--error .gram-callout__icon { color: var(--status-error-fg); }
/* GRAM APP ORIGIN (2026-07-31): stacked callouts need air between them.
   Adjacent-sibling rather than a margin on the component itself, so a lone
   callout still carries no stray outer margin and the caller keeps control of
   the space around the block as a whole. */
.gram-callout + .gram-callout { margin-top: 10px; }

/* SOURCE: components/feedback/ProgressMeter.jsx */
.gram-meter { display: flex; flex-direction: column; gap: 7px; font-family: var(--font-sans); }
.gram-meter__head { display: flex; justify-content: space-between; align-items: baseline; }
.gram-meter__label { font-size: 13px; font-weight: 600; color: var(--text-strong); }
.gram-meter__value { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.gram-meter__track { height: 8px; border-radius: var(--radius-full); background: var(--neutral-200); overflow: hidden; }
.gram-meter__fill { height: 100%; border-radius: var(--radius-full); background: var(--accent); transition: width var(--dur-slow) var(--ease-out); }
.gram-meter--ok .gram-meter__fill { background: var(--green-500); }
.gram-meter--warn .gram-meter__fill { background: var(--amber-500); }
.gram-meter--error .gram-meter__fill { background: var(--red-500); }

/* REMOVED 2026-07-31: .gram-tt / .gram-tt__bubble, the wrap-the-trigger tooltip
   lifted from components/feedback/Tooltip.jsx.
   Its bubble was `position: absolute`, and every .gram-card sets
   `overflow: hidden`, so a tooltip inside a card was clipped – which is nearly
   every tooltip we have, because the thing being explained is usually a badge
   on a card. There is now ONE tooltip: the shared, JS-placed .gram-bub below,
   which is fixed and therefore unclippable. tooltip() renders .gram-tip too.
   Owed upstream: the DS's React Tooltip has the same flaw. See ds/VERSION. */

/* SOURCE: components/feedback/Toast.jsx */
.gram-toast {
  display: flex; align-items: flex-start; gap: 12px;
  background: var(--ink); color: #fff; font-family: var(--font-sans);
  padding: 14px 16px; border-radius: var(--radius-md); box-shadow: var(--shadow-lg);
  width: max-content; max-width: 380px;
}
.gram-toast__icon { flex: none; margin-top: 1px; }
.gram-toast__icon--ok { color: var(--green-300); }
.gram-toast__icon--error { color: #f0a99d; }
.gram-toast__icon--info { color: #9dc2e6; }
.gram-toast__body { font-size: 14px; line-height: 1.45; }
.gram-toast__title { font-weight: 600; margin: 0 0 1px; }
.gram-toast__msg { color: rgba(255,255,255,0.72); }
.gram-toast__close { margin-left: 6px; background: transparent; border: 0; color: rgba(255,255,255,0.6); cursor: pointer; padding: 2px; border-radius: var(--radius-sm); display: inline-flex; }
.gram-toast__close:hover { color: #fff; background: rgba(255,255,255,0.1); }

/* SOURCE: components/feedback/Dialog.jsx, rebuilt 2026-08-01 on the native
   <dialog> element. Upstream renders a positioned <div> overlay; a <dialog>
   opened with showModal() lives in the browser's TOP LAYER, so it cannot be
   clipped by an ancestor's overflow:hidden and cannot be painted under a sticky
   header, and it brings a focus trap, Escape-to-close and inert background with
   it. The div version had none of that without us writing it, and the clipping
   failure is the same one that made the old wrapper tooltip unusable.
   `.gram-dialog__overlay` is gone; ::backdrop replaces it.
   Owed upstream: see [owed-upstream] in ds/VERSION. */
.gram-dialog {
  padding: 0; border: 0; color: var(--text-body);
  background: var(--surface-card); border-radius: var(--radius-lg); box-shadow: var(--shadow-xl);
  width: calc(100% - 48px); max-width: 480px; max-height: calc(100vh - 48px); overflow: auto;
}
.gram-dialog::backdrop {
  background: rgba(23, 25, 19, 0.32);
  backdrop-filter: blur(3px);
  animation: gram-dialog-fade var(--dur-normal) var(--ease-out);
}
.gram-dialog[open] { animation: gram-dialog-rise var(--dur-normal) var(--ease-out); }
.gram-dialog__head { display: flex; align-items: flex-start; gap: 16px; padding: 22px 24px 0; }
.gram-dialog__titles { flex: 1; }
.gram-dialog__title { font-family: var(--font-sans); font-size: 19px; font-weight: 600; letter-spacing: -0.01em; color: var(--text-strong); margin: 0; }
.gram-dialog__desc { font-family: var(--font-sans); font-size: 14px; color: var(--text-muted); line-height: 1.5; margin: 6px 0 0; }
.gram-dialog__body { padding: 18px 24px; }
.gram-dialog__foot { display: flex; justify-content: flex-end; gap: 10px; padding: 0 24px 22px; }
.gram-dialog__x { flex: none; background: transparent; border: 0; color: var(--text-subtle); cursor: pointer; padding: 4px; border-radius: var(--radius-md); display: inline-flex; }
.gram-dialog__x:hover { background: var(--neutral-100); color: var(--text-strong); }
@keyframes gram-dialog-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes gram-dialog-rise { from { opacity: 0; transform: translateY(8px) scale(0.99); } to { opacity: 1; transform: none; } }


/* ============================================================
   GRAM APP ORIGIN – components this product needed before the DS had them.
   These are candidates to push UP into the Design System; see ds/VERSION.
   ============================================================ */

/* REMOVED 2026-08-01: `.gram-dialog__overlay` and its `data-gram-open` toggle.
   Dialog.jsx returns null when closed, which is a React luxury; server-side the
   markup is always in the page, so visibility needed an attribute. The native
   <dialog> has that built in (`open`, showModal(), close()), so the extra
   element and the extra attribute both went. */

/* GRAM APP ORIGIN: date field + calendar popover.
   Built for the EPR Registrations page (deadline / registered-on dates) because
   the native date input renders as OS chrome and reads as un-branded. Driven by
   ds.js, which delegates from document so it survives instant-nav and hx-boost
   swaps and works on any .gram-date-wrap that appears later. */
.gram-date-wrap { position: relative; }
.gram-date { cursor: pointer; background: var(--white); }
.gram-date-ic { position: absolute; right: 11px; top: 50%; transform: translateY(-50%);
                color: var(--text-muted); pointer-events: none; display: flex; }
/* FIXED, not absolute: an anchor inside an open <dialog> lives in the top
   layer, and a body-mounted absolute popover can only paint BEHIND it (the
   supplier form's Last-contacted field). ds.js mounts the popover into the
   dialog when the field is in one, and fixed coordinates read the same in
   both layers. Same fix the tooltip bubble needed. */
.gram-cal { position: fixed; z-index: 70; width: 268px; background: var(--white);
            border: 1px solid var(--border-default); border-radius: var(--radius-lg);
            box-shadow: var(--shadow-lg); padding: 12px; user-select: none; }
.gram-cal-head { display: flex; align-items: center; justify-content: space-between; margin: 0 0 8px; }
.gram-cal-title { font-size: 13.5px; font-weight: 650; color: var(--ink); }
.gram-cal-nav { display: flex; gap: 2px; }
.gram-cal-nav button { background: none; border: 0; cursor: pointer; color: var(--text-muted);
            border-radius: var(--radius-sm); width: 28px; height: 28px; display: inline-flex;
            align-items: center; justify-content: center; font-size: 15px; }
.gram-cal-nav button:hover { background: var(--neutral-100); color: var(--accent); }
.gram-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; }
.gram-cal-dow { font-size: 10.5px; font-weight: 700; color: var(--text-subtle); text-align: center;
                padding: 4px 0; }
.gram-cal-day { border: 0; background: none; font: inherit; font-size: 12.5px; color: var(--ink);
                height: 32px; border-radius: var(--radius-sm); cursor: pointer; }
.gram-cal-day:hover { background: var(--neutral-100); }
.gram-cal-day.out { color: var(--neutral-300); }
.gram-cal-day.today { box-shadow: inset 0 0 0 1.5px var(--green-300); }
.gram-cal-day.sel { background: var(--accent); color: #fff; font-weight: 650; }
.gram-cal-foot { display: flex; justify-content: space-between; margin: 8px 2px 0; }
.gram-cal-foot button { background: none; border: 0; cursor: pointer; color: var(--accent);
                        font: inherit; font-size: 12.5px; font-weight: 600; padding: 2px; }

/* GRAM APP ORIGIN: THE tooltip. One `data-tip="…"` on any element, no wrapper
   markup, which is what a server-rendered template wants. Since 2026-07-31 this
   is the only tooltip: the wrapper variant above was deleted because its
   absolutely-positioned bubble was clipped by any card it sat inside.

   The bubble is a SINGLE shared .gram-bub element that the tooltip script in
   shared/ui/shell.py::_TIP_JS positions with position:fixed and clamps inside
   the viewport. It used to be a ::after pseudo-element on each anchor, which had
   two structural faults: the bubble was laid out even at opacity 0, so an anchor
   near the right edge silently added up to 260px of horizontal PAGE scroll; and
   being in normal flow it was painted behind any sticky header above it. A
   fixed, JS-placed bubble can do neither – fixed boxes never extend scrollable
   overflow, and it always flips/clamps into view. That also retires
   `--gram-tip-shift`: the script measures instead of guessing. */
.gram-tip { outline: none; }
.gram-tip:focus-visible { border-radius: var(--radius-sm); }
.gram-bub { position: fixed; z-index: 200; left: 0; top: 0; max-width: 420px;
            background: var(--ink); color: #fff; text-align: left;
            font: 500 12.5px/1.55 var(--font-sans); padding: 11px 14px;
            border-radius: var(--radius-sm); box-shadow: var(--shadow-md);
            pointer-events: none; opacity: 0; white-space: normal;
            transition: opacity var(--dur-fast) var(--ease-out); }
.gram-bub[data-show="1"] { opacity: 1; }

/* GRAM APP ORIGIN: data table. Dense rows, tabular figures, hairline rules –
   the density the brand guide reserves for data. */
.gram-table { width: 100%; border-collapse: collapse; font-family: var(--font-sans); font-size: 13.5px; }
.gram-table th {
  text-align: left; font-size: 11px; font-weight: 700; letter-spacing: 0.05em;
  text-transform: uppercase; color: var(--text-subtle);
  padding: 8px 12px; border-bottom: 1px solid var(--border-default); white-space: nowrap;
}
.gram-table td { padding: 10px 12px; border-bottom: 1px solid var(--neutral-100); color: var(--text-body); vertical-align: middle; }
.gram-table tbody tr:last-child td { border-bottom: 0; }
.gram-table--hover tbody tr { transition: background var(--dur-fast) var(--ease-out); }
.gram-table--hover tbody tr:hover { background: var(--neutral-50); }
.gram-table__num { text-align: right; font-variant-numeric: tabular-nums; }
/* GRAM APP ORIGIN (2026-07-31): the header has to follow its column.
   table() already tags the <th> with .gram-table__num, but `.gram-table th`
   above is specificity 0,1,1 and .gram-table__num is 0,1,0, so the header
   stayed left-aligned while the numbers under it went right. Qualifying the
   selector is the whole fix; the helper never needed changing. */
.gram-table th.gram-table__num { text-align: right; }
/* Long tables: pin the header. Offset clears the sticky top bar. */
.gram-table--sticky th { position: sticky; top: var(--gram-table-top, 0); z-index: 2;
                         background: var(--surface-card); }
/* GRAM APP ORIGIN (2026-08-02): SORTABLE COLUMN HEADER, part of the table.
   A header that orders the table has to be a real <button> – it is keyboard
   operable and it carries aria-sort – but it must read as the column label
   until you reach for it, so it inherits the header's own type and only the
   arrow appears on hover. Rendered by components.sort_header(); the caller
   supplies the sort key and the script that reorders. Toggling `.is-active`
   / `.is-desc` is what a page's sort script does, so the STATE is in the
   markup rather than only in the script's head. */
.gram-table__sort {
  display: inline-flex; align-items: center; gap: 3px; border: 0;
  background: none; padding: 0; font: inherit; color: inherit;
  letter-spacing: inherit; text-transform: inherit; cursor: pointer;
  border-radius: var(--radius-sm);
}
.gram-table__sort:hover { color: var(--text-strong); }
.gram-table__sort-arrow { opacity: 0; flex: none;
                          transition: transform var(--dur-fast) var(--ease-out); }
.gram-table__sort:hover .gram-table__sort-arrow { opacity: 0.45; }
.gram-table__sort.is-active { color: var(--text-strong); }
.gram-table__sort.is-active .gram-table__sort-arrow { opacity: 1; }
.gram-table__sort.is-active.is-desc .gram-table__sort-arrow { transform: rotate(180deg); }

/* GRAM APP ORIGIN: section heading + empty state – the two bits of page
   furniture every list view needs, so nobody re-rolls them per page. */
.gram-section { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin: 0 0 14px; }
.gram-section__title { font-size: 15px; font-weight: 650; color: var(--ink); margin: 0; letter-spacing: -0.005em; }
.gram-section__sub { font-size: 12.5px; color: var(--text-muted); margin: 3px 0 0; line-height: 1.5; }
.gram-empty { text-align: center; padding: 40px 24px; color: var(--text-muted); font-size: 13.5px; line-height: 1.6; }
.gram-empty__title { font-size: 15px; font-weight: 600; color: var(--ink); margin: 0 0 5px; }

/* GRAM APP ORIGIN: stack/row layout primitives. Small, but they stop every page
   inventing its own `display:flex; gap:12px` wrapper class. */
.gram-stack { display: flex; flex-direction: column; gap: var(--stack); }
.gram-stack--tight { gap: var(--space-2); }
.gram-stack--loose { gap: var(--space-6); }
.gram-row { display: flex; align-items: center; gap: var(--inline-gap); }
.gram-row--wrap { flex-wrap: wrap; }
.gram-row--end { justify-content: flex-end; }
.gram-row--between { justify-content: space-between; }


/* ============================================================
   GRAM APP ORIGIN (2026-07-31) – INTERACTION STATES
   Owed upstream: see [owed-upstream] in ds/VERSION.

   Webflow's design-system guidance treats hover / focus / pressed / busy as a
   LAYER that combines with any component, not as something each component
   re-declares. Ours had drifted the other way, because every block above was
   lifted verbatim from a separate upstream .jsx file that carried its own CSS:

     - `box-shadow: var(--ring)` on focus was retyped NINE times.
     - Disabled had four different answers: button 0.5, icon button 0.45,
       checkbox/radio/switch 0.5, and the text controls no opacity at all.
     - There was no busy/loading state anywhere in the system, which is why the
       one place that needed one invented `<div>Opening</div>` and inherited
       Chrome's blue focus ring.

   Grouping the selectors is the whole fix. One place to look, one place to
   change.
   ============================================================ */

/* THE focus ring. Every focusable component, one declaration. */
.gram-btn:focus-visible,
.gram-iconbtn:focus-visible,
.gram-input:focus,
.gram-textarea:focus,
.gram-select:focus,
.gram-tip:focus-visible,
.gram-tab:focus-visible,
.gram-check input:focus-visible + .gram-check__box,
.gram-radio input:focus-visible + .gram-radio__dot,
.gram-switch input:focus-visible + .gram-switch__track {
  outline: none;
  box-shadow: var(--ring);
}

/* THE disabled treatment. One opacity, so a disabled row reads as one thing.
   The text controls keep their background swap as well: an empty box at 55%
   is ambiguous, an empty GREY box is not. */
.gram-btn:disabled, .gram-btn[aria-disabled="true"],
.gram-iconbtn:disabled, .gram-iconbtn[aria-disabled="true"],
.gram-tab:disabled,
.gram-check input:disabled ~ *,
.gram-radio input:disabled ~ *,
.gram-switch input:disabled ~ * {
  opacity: 0.5;
}
.gram-btn:disabled, .gram-btn[aria-disabled="true"],
.gram-iconbtn:disabled, .gram-iconbtn[aria-disabled="true"],
.gram-check--disabled, .gram-radio--disabled, .gram-switch--disabled,
.gram-input:disabled, .gram-textarea:disabled, .gram-select:disabled {
  cursor: not-allowed;
}
.gram-btn:disabled, .gram-btn[aria-disabled="true"] { pointer-events: none; }

/* THE busy state. `htmx-request` is set by htmx on the element that fired an
   in-flight request and removed when it settles, so every form button in the
   app gets this for free with no JavaScript of ours. aria-busy is the manual
   equivalent for the server-rendered case. */
.gram-btn.htmx-request, .gram-btn[aria-busy="true"],
.gram-iconbtn.htmx-request, .gram-iconbtn[aria-busy="true"] {
  cursor: progress;
  pointer-events: none;
}
.gram-btn.htmx-request::before, .gram-btn[aria-busy="true"]::before,
.gram-iconbtn.htmx-request::before, .gram-iconbtn[aria-busy="true"]::before {
  content: "";
  width: 13px; height: 13px; flex: none;
  border: 2px solid currentColor; border-top-color: transparent;
  border-radius: 50%;
  opacity: 0.75;
  animation: gram-spin 0.7s linear infinite;
}
@keyframes gram-spin { to { transform: rotate(360deg); } }

/* THE skeleton. For a wait whose SHAPE we already know: a table of rows, a
   card, a paragraph. Where the shape is unknown, use the busy state above
   instead – three different ways to say "wait" is worse than two. */
.gram-skel {
  display: block;
  border-radius: var(--radius-sm);
  background: linear-gradient(90deg,
              var(--neutral-100) 25%, var(--neutral-200) 37%, var(--neutral-100) 63%);
  background-size: 400% 100%;
  animation: gram-sheen-bg 1.4s var(--ease-in-out) infinite;
}
@keyframes gram-sheen-bg { from { background-position: 100% 50%; } to { background-position: 0 50%; } }
.gram-skel--text { height: 13px; }
.gram-skel--line { height: 13px; margin-top: 9px; }
.gram-skel--line:first-child { margin-top: 0; }
.gram-skel--title { height: 19px; }
.gram-skel--block { height: 100%; min-height: 64px; border-radius: var(--radius-md); }
.gram-skel-group { display: block; }

/* Reduced motion: hold the mid tone rather than pulsing, and stop the spinner
   at an angle that still reads as a spinner. */
@media (prefers-reduced-motion: reduce) {
  .gram-skel { animation: none; background: var(--neutral-100); }
  .gram-btn.htmx-request::before, .gram-btn[aria-busy="true"]::before,
  .gram-iconbtn.htmx-request::before, .gram-iconbtn[aria-busy="true"]::before {
    animation: none; transform: rotate(45deg);
  }
}

/* A native <dialog> must not show the browser's default focus ring. The picker
   in shared/dialog.py does exactly that today. */
dialog:focus, dialog:focus-visible { outline: none; }


/* ============================================================
   GRAM APP ORIGIN (2026-07-31) – PROMOTED FROM THE APP
   Owed upstream: see [owed-upstream] in ds/VERSION.

   Each of these already existed in the app, more than once, because there was
   nowhere shared to put it. The promotion rule we now hold ourselves to:
   ONE CALLER IS NOT A COMPONENT – promote at two.

   Deliberately NOT promoted yet, and why:
     - the download list (.dl-list): one caller, no drift
     - the accordion card (.land-block): one caller
     - the country marker, deadline row and progress ring: all three live on
       Registrations, which is LAST in the migration order. Building them now
       would be guessing at requirements we will see properly in a few weeks.
   ============================================================ */

/* Segmented control / tab bar. A NEW component rather than a button variant:
   it coordinates several mutually-exclusive children, which is Webflow's own
   test for "this is not a variant". Replaces .pw-sub, .reg-tabs and
   katalog/tab.css, which were three implementations of one idea.
   Renders as links or buttons; `aria-current="page"` / .is-active marks the
   selected one, so the state is in the markup and not only in the CSS. */
.gram-tabs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.gram-tab {
  display: inline-flex; align-items: center; gap: 7px;
  font-family: var(--font-sans); font-size: 13.5px; font-weight: 550; line-height: 1;
  padding: 7px 14px; border-radius: var(--radius-full);
  background: var(--white); color: var(--text-muted);
  border: 1px solid var(--border-subtle); cursor: pointer; text-decoration: none;
  transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.gram-tab:hover { border-color: var(--border-default); color: var(--text-strong); text-decoration: none; }
.gram-tab.is-active, .gram-tab[aria-current="page"], .gram-tab[aria-selected="true"] {
  background: var(--accent-soft); border-color: var(--accent); color: var(--accent-hover);
}
.gram-tab__count {
  font-size: 12px; font-variant-numeric: tabular-nums; opacity: 0.75;
}

/* Tag: selected state and a count. A VARIANT, not a new component – same
   shape, one extra optional slot and one state. This is what the four separate
   filter chips (.rap-chip.is-on, .pw-fchip.active, .m-chip.sel, .reg-tabs
   button) were each re-inventing. */
.gram-tag--selected {
  background: var(--accent-soft); color: var(--accent-hover); border-color: var(--accent);
}
.gram-tag--button { cursor: pointer; font: inherit; font-size: 13px; }
.gram-tag--button:hover { border-color: var(--border-default); }
.gram-tag--button.gram-tag--selected:hover { border-color: var(--accent-hover); }
.gram-tag__count { font-variant-numeric: tabular-nums; opacity: 0.7; }

/* Dense input: the FOURTH size, not a new component. The md control is 42px,
   which is right for a form and impossible in the materials grid – eight per
   row across twenty rows would double the page. Quieter at rest than the other
   sizes (the border only appears on hover) because a grid of twenty rows of
   bordered boxes is a cage. */
/* A spreadsheet cell, not a small form control.

   Two designs were tried and rejected before this one, and both failed the
   same way: THE CELL HAS TO BE FINDABLE.
     1. Fully transparent, box only on hover. You cannot see where to type.
     2. A soft --neutral-50 fill and no border. Invisible the moment the table
        is not on white: on a --paper-sunken panel the two colours are two
        shades apart. A component that is only legible on one background is
        broken, even if the one background is where it happens to live today.

   So: white fill, hairline border, --radius-sm. It reads as a field on any
   surface. A hairline is not a cage – what made the old grid feel like one was
   --radius-md pills at 28px tall, not the border.

   Sizing: 72px is what the hand-rolled `.cell-input` settled on over a year of
   use, wide enough for "1234.5" and narrow enough for eight per row. The base
   .gram-input is width:100%, right in a form and wrong here.
   Focus: a 2px ring, not the standard 3px, which at 28px tall swallows the
   cell and collides with its neighbour. */
.gram-input--dense {
  width: 72px; height: 28px; font-size: 13px; padding: 0 7px;
  text-align: right; font-variant-numeric: tabular-nums;
  border-radius: var(--radius-sm);
  border-color: var(--border-subtle); background: var(--white); box-shadow: none;
  color: var(--text-strong);
}
/* The dense grid's TEXT cell (name columns): same chrome as --dense, but
   left-aligned prose wide enough for real names. 72px truncated "Shipping
   box" to "Shipping b" on Step 4. Width, not min-width: the grid's tables
   are laid out fixed. */
.gram-input--dense.gram-input--wide {
  width: 190px; text-align: left; font-variant-numeric: normal;
}
/* Inside a grid the wide cell FILLS its column (Daniel, 2026-08-03): the name
   column takes the table's slack, and a fixed 190px box in it left long
   packaging names cut off beside empty space. */
.gram-grid .gram-input--dense.gram-input--wide { width: 100%; min-width: 190px; max-width: 520px; }
.gram-input--dense::placeholder { color: var(--neutral-400); }
.gram-input--dense:hover { border-color: var(--border-strong); }
.gram-input--dense:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px rgba(42, 133, 96, 0.16);
}
/* The cell still needs a number. Amber, not red: nothing is wrong yet, it is
   simply not filled in. The border tints with the fill so an empty required
   cell is findable at a glance down a column of twenty. */
.gram-input--needs {
  background: var(--status-warn-bg);
  border-color: color-mix(in srgb, var(--amber-500) 35%, var(--border-subtle));
}
.gram-input--needs:hover { border-color: var(--amber-500); }

/* Data-grid cell. Use on every <td> of a row whose cells hold a dense input.
   ONE LINE, ALWAYS: `vertical-align: top` is the whole rule, and it is not
   cosmetic. Table cells in a row all take the height of the tallest, so
   middle-aligning centres each cell's CONTENT inside that height. The moment
   one cell carries something extra under its input (a "barrier" checkbox, a
   type dropdown) its input floats upward and its neighbours do not. On the
   materials grid that put nine inputs in a single row at five different
   heights, 18px apart, and the row read as broken.

   Aligning to the top puts every control on one line and lets the optional
   extra hang below, which is the same rule form_row() applies to fields for
   exactly the same reason. If you build a grid, use this; the failure is
   invisible until a cell grows a second element, and then it is everywhere. */
.gram-cell { vertical-align: top; padding-top: 10px; }
.gram-cell__sub { display: block; margin-top: 3px; line-height: 1; }

/* GRAM APP ORIGIN (2026-08-02): the file input. Nine of them across seven
   files — every import page, the workspace uploader, the PPWR vault — and until
   now they were styled only by an `input[type=file]` element rule in app.css,
   which is to say by the layer that is scheduled for deletion.

   The native control is kept rather than replaced by a styled label: it is the
   one input where the browser's own text ("2 files selected") is genuinely
   better than anything we would write, and replacing it costs keyboard and
   screen-reader behaviour for nothing. What is branded is the BUTTON part, via
   ::file-selector-button, which is the only bit that looked like the OS. */
.gram-file {
  width: 100%; font-family: var(--font-sans); font-size: 14px;
  color: var(--text-body); background: var(--surface-card);
  border: 1px solid var(--border-default); border-radius: var(--radius-md);
  padding: 8px 11px; box-shadow: var(--shadow-xs);
  transition: border-color var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out);
}
.gram-file:hover { border-color: var(--border-strong); }

/* GRAM APP ORIGIN (2026-08-02): THE upload control. Promoted from Step 1's
   "Choose or drop files" dropzone, which Daniel picked as the standard for
   every upload in the app ("the nicest one", everywhere). The inline
   .gram-file above survives only inside this component (hidden) — do not
   reach for it on its own for a new upload.

   Behaviour (drag states, chosen-file feedback, keyboard open) is in ds.js.
   A label with data-gram-drop="manual" opts out of the shared behaviour and
   brings its own script — Step 1 does, for its per-file inspection list. */
.gram-drop {
  display: block; border: 1px dashed var(--border-default);
  border-radius: var(--radius-lg); padding: 44px 24px; text-align: center;
  cursor: pointer; font-family: var(--font-sans); font-weight: 400;
  transition: border-color var(--dur-fast) var(--ease-out),
              background var(--dur-fast) var(--ease-out);
}
.gram-drop input[type="file"] { display: none; }
.gram-drop:hover { border-color: var(--accent); }
.gram-drop:focus-visible { outline: none; box-shadow: var(--ring); border-color: var(--accent); }
.gram-drop--active { border-color: var(--accent); background: var(--accent-soft); }
.gram-drop--invalid { border-color: var(--red-500); }
.gram-drop__title { display: block; font-size: 15px; color: var(--text-strong); }
.gram-drop__sub { display: block; font-size: 12px; color: var(--text-subtle); margin-top: 4px; }
.gram-drop__files { display: block; font-size: 13px; color: var(--text-body); margin-top: 8px; }
.gram-drop__files[hidden] { display: none; }
/* Inside a panel or dialog a 44px-padded field shouts; same box, less air. */
.gram-drop--compact { padding: 18px 16px; }

/* GRAM APP ORIGIN (2026-08-02): the dense EDITABLE grid. table() above is a
   read-only data list (roomy cells, hover rows); this is the opposite animal:
   twenty rows of nine controls, a sticky header, and cells tight enough that
   the whole matrix fits a desktop without a horizontal page scroll. It was
   three near-copies (.ws-sheet on Step 3, the Step-4 shipping table, and
   .prod-table on Products); this is the one definition. COLUMN WIDTHS are page
   layout and stay in page CSS, scoped by the page's own class alongside.

   The box shrink-wraps its table (fit-content) and never exceeds its column
   (max-width:100%); a genuinely wider table scrolls INSIDE the box, never the
   page. The sticky header needs the solid background + inset hairline because
   border-bottom does not travel with a sticky th, and a z-index above the
   native inputs which otherwise paint through it. */
.gram-grid { overflow-x: auto; max-width: 100%; }
/* width:100%, not auto: a grid with few columns fills its card instead of
   huddling left and clipping its last column; one with many columns still
   grows past 100% and scrolls, which is what overflow-x above is for. */
.gram-grid table { border-collapse: collapse; width: 100%;
  font-family: var(--font-sans); font-size: 13px; }
/* NO text-align and NO vertical-align here, deliberately: this file is
   unlayered and would silently beat every page's .num right-alignment and
   top-aligned stacked cells (it did - Daniel's screenshot, 2026-08-02, numeric
   headers drifting left off their columns). Alignment belongs to the columns,
   which are page layout. */
.gram-grid th { position: sticky; top: 0; z-index: 30; background: var(--white);
  font-size: 10.5px; font-weight: 600; text-transform: uppercase;
  letter-spacing: -.01em; color: var(--text-subtle); padding: 8px 5px;
  white-space: nowrap; border-bottom: 0;
  box-shadow: inset 0 -1px 0 var(--border-subtle); }
.gram-grid td { padding: 6px 5px; border-bottom: 1px solid var(--neutral-100);
  color: var(--text-body); }
.gram-grid tr:last-child td { border-bottom: 0; }

/* GRAM APP ORIGIN (2026-08-03): THE ROW ACTIONS THAT NEVER SCROLL AWAY.

   Three list pages (Products, Orders, Suppliers) grew past the width of a
   1280px desktop, and the column that went off the edge first was the one
   holding Edit and Remove: the merchant could see the data but could not reach
   the two controls the page exists for, and nothing on screen said the row
   continued. Daniel's call (Q4, 2026-08-03) was both halves at once: PIN the
   actions to the right edge, and FADE the scrolling middle where it passes
   under them.

   THREE PIECES, EACH DOING ONE THING.

   `.gram-scroll` is the box: the read-only sibling of `.gram-grid`'s scroller,
   for a `.gram-table` that is wider than its column. It is what app.css's
   `.scroll-x` was, under a name the design system owns; the result page, the
   Step-3 table and the admin list are still on the old one, so this is where
   they land as they migrate rather than a second box beside it.

   `.gram-pin` is the column, set on the `<th>` and the `<td>` of the LAST
   column. It has to carry its own background or the rows slide through it in
   plain sight - a sticky cell is transparent by default. The background is a
   knob because a table can sit on the page canvas (all three catalog lists) or
   inside a card, and the pinned column has to match whatever the row it belongs
   to is drawn on: set `--gram-pin-bg: var(--surface-card)` on the wrapper or
   the table when the table is on a card.

   THE FADE IS THE PIN'S OWN LEFT SHADOW, and that is deliberate rather than a
   shortcut. A separate gradient element at the right edge of the scroller would
   land ON TOP of the pinned column, which is the one thing Daniel ruled out;
   hung off the pinned cell it tracks the column's width for free, and it says
   the one thing a merchant needs to know - the row continues under here. The
   hairline is the first shadow rather than a `border-left` for the same reason
   the sticky header above uses an inset shadow: a border on a sticky cell in a
   `border-collapse: collapse` table is shared with the neighbour that just
   scrolled under it, and does not reliably paint.

   Always drawn, not only while there is overflow: telling the two apart needs
   scroll state, which is JavaScript, and a permanently soft divider between the
   data and its actions is the right reading of that column anyway. */
.gram-scroll { overflow-x: auto; max-width: 100%; }
.gram-pin {
  position: sticky; right: 0; z-index: 3;
  background: var(--gram-pin-bg, var(--surface-page));
  /* The hairline, then the fade. The fade's geometry is deliberate: shrunk 6px
     and moved 12px left, so its own right edge sits UNDER the opaque cell and
     what shows is a ~15px gradient hugging the column, darkest against it and
     gone by the time the eye is back on the data. Offset it further and the
     dark band detaches from the column, which reads as a stray line. */
  box-shadow: -1px 0 0 var(--border-subtle),
              -12px 0 18px -6px rgba(23, 25, 19, 0.20);
}
/* Above `.gram-grid th`'s own z-index 30: the header is sticky in the other
   axis, so where the two meet the corner cell has to win both. */
th.gram-pin {
  z-index: 31;
  box-shadow: inset 0 -1px 0 var(--border-subtle),
              -1px 0 0 var(--border-subtle),
              -12px 0 18px -6px rgba(23, 25, 19, 0.20);
}
/* The pinned HEADER cell keeps the header background its neighbours have,
   whatever the row knob is set to, or it reads as a hole in the header rule. */
.gram-grid th.gram-pin { background: var(--white); }
.gram-table--sticky th.gram-pin { background: var(--surface-card); }
.gram-table--hover tbody tr:hover .gram-pin { background: var(--neutral-50); }

/* The pair of controls that lives in that column. It was three different bits
   of markup for one idea: Products put an 8px margin on the second button,
   Suppliers had them touching with no gap at all, and /packaging drew a
   right-aligned flex row of its own with a 12px gap. One definition, so the
   space between Edit and Remove is the same space on every list. */
.gram-row-actions {
  display: flex; align-items: center; justify-content: flex-end;
  gap: var(--space-2); white-space: nowrap;
}

/* GRAM APP ORIGIN (2026-08-03): THE LIST SEARCH ROW.
   A field and a submit, laid out as one control. Two pages had built it
   separately: Products hand-wrote the form and gave the input no size at all,
   so a 37px field sat beside a 34px button, and /packaging borrowed the
   picker's box, which is `type="search"` and therefore carries the browser's
   own blue cancel cross - a control the design system does not draw anywhere
   else and cannot restyle. One row, one size, no native chrome. */
.gram-search { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
/* A search box has a natural size: wide enough for "Search SKU, name or
   supplier", not as wide as the page. It still shrinks when the row does, so
   this is a size rather than a constraint. */
.gram-search .gram-input { width: 280px; max-width: 100%; }

.gram-file::file-selector-button,
.gram-file::-webkit-file-upload-button {
  font: inherit; font-weight: 550; cursor: pointer;
  margin-right: 10px; padding: 6px 12px;
  background: var(--surface-card); color: var(--text-strong);
  border: 1px solid var(--border-default); border-radius: var(--radius-md);
  transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);
}
.gram-file::file-selector-button:hover,
.gram-file::-webkit-file-upload-button:hover {
  background: var(--neutral-50); border-color: var(--border-strong);
}
.gram-file:disabled { background: var(--neutral-50); color: var(--text-subtle); }

/* Code / SKU. Mono, tabular, and selectable as one word so a merchant can copy
   an article number out of a table without catching the whitespace around it. */
.gram-code {
  font-family: var(--font-mono); font-size: 0.9em; font-variant-numeric: tabular-nums;
  color: var(--text-body); letter-spacing: -0.01em; white-space: nowrap;
}

/* GRAM APP ORIGIN (2026-08-02): COPYABLE FIGURE, the sibling of .gram-code.
   Some screens are read and some are worked FROM. A PRO's portal takes typed
   figures, not files, so on the result page every article code and every kilo
   is about to be retyped by hand into another window, and a mistyped kilo is a
   wrong filing. Those values are click-to-copy.

   THE FIGURE COMES FIRST, THE CONTROL SECOND. It borrows .gram-code's mono,
   tabular face so a column of them still lines up and still reads as data, and
   it draws no button chrome at rest: the plate appears on hover, which is the
   moment the affordance needs announcing and the only moment it is in the way.

   TWO ELEMENTS, ONE LOOK. It is rendered server-side as a <span> - plain
   selectable text, so the page works with scripting off - and swapped for a
   real <button> by the page's script. Both must be indistinguishable, so the
   button reset lives in this rule rather than in a second one, and only the
   things that are true of a control (cursor, hover, active, focus ring) are
   qualified to `button`. */
.gram-copy {
  display: inline-block; font-family: var(--font-mono); font-size: 0.9em;
  font-variant-numeric: tabular-nums; letter-spacing: -0.01em; white-space: nowrap;
  font-weight: inherit; line-height: inherit; text-align: inherit; color: inherit;
  background: none; border: 0; padding: 2px 5px; margin: 0 -5px;
  border-radius: var(--radius-sm);
  transition: background var(--dur-fast) var(--ease-out),
              color var(--dur-fast) var(--ease-out);
}
button.gram-copy { cursor: pointer; }
button.gram-copy:hover { background: var(--accent-soft); color: var(--text-strong); }
button.gram-copy:active { background: var(--accent-soft-hover); }
button.gram-copy:focus-visible { outline: none; box-shadow: var(--ring); }
/* The confirmation. Copying is instant and reversible, so it does not deserve a
   toast: one quiet word beside the value, fading in on the same 150ms as the
   hover it followed, and gone again about a second later. Keeping your place in
   a twenty-row list is the ROW's job (the page dims the one you copied from),
   not this label's. */
.gram-copy__flag {
  margin-left: 6px; font-family: var(--font-sans); font-size: 11.5px;
  font-weight: 600; color: var(--accent);
  animation: gram-copy-in var(--dur-fast) var(--ease-out);
}
@keyframes gram-copy-in { from { opacity: 0 } to { opacity: 1 } }

/* Wizard stepper. Two implementations today (shell.py::_step_mini and
   workspace/chrome.py) for one idea. A step is done, current, or ahead; only a
   done step is a link, because you cannot skip forward into a step whose input
   does not exist yet. */
.gram-steps { display: flex; align-items: center; gap: 0; flex-wrap: wrap; font-family: var(--font-sans); }
.gram-step {
  display: inline-flex; align-items: center; gap: 8px;
  font-family: var(--font-sans);
  font-size: 13px; font-weight: 550; color: var(--text-subtle);
  text-decoration: none; white-space: nowrap;
  /* A step can be an <a>, a <span> or a <button> (see steps(submit=True)), and
     all three have to look identical. The reset is what makes that true: left
     off, the button arrived with the browser's border, background and font. */
  background: none; border: 0; padding: 0; margin: 0;
}
button.gram-step { cursor: pointer; }
.gram-step__n {
  width: 21px; height: 21px; flex: none; border-radius: var(--radius-full);
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 11.5px; font-weight: 650; font-variant-numeric: tabular-nums;
  border: 1px solid var(--border-default); color: var(--text-subtle); background: var(--white);
}
.gram-step--done { color: var(--text-body); }
.gram-step--done .gram-step__n { border-color: var(--accent); color: var(--accent); }
.gram-step--done:hover { color: var(--accent-hover); text-decoration: none; }
.gram-step--done:hover .gram-step__n { border-color: var(--accent-hover); color: var(--accent-hover); }
.gram-step--current { color: var(--text-strong); font-weight: 650; }
.gram-step--current .gram-step__n { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
.gram-steps__sep { width: 26px; height: 1px; background: var(--border-default); flex: none; margin: 0 10px; }
/* On a narrow screen five labels do not fit, so the numbered circles carry the
   progress on their own and the connectors tighten. Lifted from the app's own
   step bar, which had this rule and would have lost it in the migration. */
@media (max-width: 680px) {
  .gram-step__label { display: none; }
  .gram-steps__sep { width: 10px; margin: 0 6px; }
}


/* THE BARE SELECT, and THE BOX CHECKBOX. Two variants Steg 2 asked for, added
   here rather than overridden on the page, because a component restyled from
   outside is two definitions wearing one name.

   --bare: no frame until hover or focus, so a list of them reads as text while
   every line stays editable where it is written. Steg 2's column list is the
   only caller today; it was five iterations of design review to land on.

   --box: the native checkbox drawn as the control instead of hidden behind a
   painted one. The browser will not anchor "please tick this box" to an input
   it cannot see, and on Steg 2 that message IS the guard. */
.gram-select-wrap--bare .gram-select__chevron {
  right: var(--gram-select-chevron-inset, 10px);
}
select.gram-select--bare {
  border-color: transparent;
  background-color: transparent;
  box-shadow: none;
}
select.gram-select--bare:hover {
  border-color: var(--border-default);
  background-color: var(--surface-sunken);
}
.gram-check--box .gram-check__box { display: none; }
.gram-check--box input[type="checkbox"] { opacity: 1; position: static; }
