/* ==========================================================================
   SZ365 components.css
   The shared component library. Built ONLY on tokens.css custom properties —
   no one-off hex/px values. Logical properties only (RTL-first, lint would
   be `sz/no-physical-direction-classes` in the real app; here: self-discipline).

   Contents
   1.  Reset & base
   2.  Layout helpers (container, stack/row, visually-hidden)
   3.  Typography
   4.  Icons
   5.  Buttons
   6.  Badges / status pills
   7.  Forms (field, input, textarea, select, checkbox, radio, switch, stepper)
   8.  Cards
   9.  Tables
   10. Modals / dialogs
   11. Tabs
   12. Alerts / banners
   13. Toasts
   14. Empty / error / loading states
   15. Misc: pagination, breadcrumb, tooltip, segmented control, chips,
       progress bar, status timeline, avatar, notification bell, divider
   16. Prototype-only chrome: state switcher toolbar, "planned" placeholders
   17. Nav shells: storefront / merchant / admin
   18. Commerce: product card v2, pricing, rating, grid
   19. Commerce: facets, sort, breadcrumb, sheets, empty states
   20. Commerce: PDP, cart, reviews, storefront chrome
   21. POS kiosk: shell, command bar, entry, cart, totals, pay, status
   22. POS catalogue: category strip, product tiles, measure entry
   23. POS numbers: on-screen keypad, quick-cash, change due
   24. POS compliance: device readiness, ZATCA submission, fiscal mode
   25. POS flows: held tickets, returns, escalation notes
   26. Print: paper output for the 80mm receipt and the A4 tax invoice
   ========================================================================== */

/* --------------------------------------------------------------------------
   1. Reset & base
   -------------------------------------------------------------------------- */
*,
*::before,
*::after {
  box-sizing: border-box;
}

html,
body {
  height: 100%;
}

html {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

body {
  margin: 0;
  font-family: var(--font-sans);
  font-size: var(--font-size-body);
  line-height: var(--line-height-normal);
  color: var(--text-primary);
  background: var(--bg-canvas);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

/* Default document direction is RTL/Arabic — the primary design. */
html:not([dir]) {
  direction: rtl;
}

h1,
h2,
h3,
h4,
h5,
h6 {
  margin: 0;
  font-weight: var(--font-weight-semibold);
}
p {
  margin: 0;
}
ul,
ol {
  margin: 0;
  padding: 0;
  list-style: none;
}
figure {
  margin: 0;
}
img,
svg {
  display: block;
  max-width: 100%;
}
a {
  color: inherit;
  text-decoration: none;
}
button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
}
input,
textarea,
select {
  font: inherit;
  color: inherit;
}
table {
  border-collapse: collapse;
  width: 100%;
}
fieldset {
  border: 0;
  margin: 0;
  padding: 0;
}
hr {
  border: 0;
  border-block-start: 1px solid var(--border-default);
  margin: 0;
}
[hidden] {
  display: none !important;
}

/* Focus rings: only for keyboard users */
:focus {
  outline: none;
}
:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
  border-radius: var(--radius-xs);
}

::selection {
  background: var(--color-brand-200);
  color: var(--color-brand-900);
}

/* --------------------------------------------------------------------------
   2. Layout helpers
   -------------------------------------------------------------------------- */
.container {
  max-width: var(--container-max-width);
  margin-inline: auto;
  padding-inline: var(--space-4);
}
@media (min-width: 768px) {
  .container {
    padding-inline: var(--space-8);
  }
}

.u-flex {
  display: flex;
}
.u-flex-col {
  display: flex;
  flex-direction: column;
}
.u-items-center {
  align-items: center;
}
.u-items-start {
  align-items: flex-start;
}
.u-justify-between {
  justify-content: space-between;
}
.u-justify-center {
  justify-content: center;
}
.u-flex-wrap {
  flex-wrap: wrap;
}
.u-gap-1 {
  gap: var(--space-1);
}
.u-gap-2 {
  gap: var(--space-2);
}
.u-gap-3 {
  gap: var(--space-3);
}
.u-gap-4 {
  gap: var(--space-4);
}
.u-gap-6 {
  gap: var(--space-6);
}
.u-mt-2 {
  margin-block-start: var(--space-2);
}
.u-mt-4 {
  margin-block-start: var(--space-4);
}
.u-mt-6 {
  margin-block-start: var(--space-6);
}
.u-mt-8 {
  margin-block-start: var(--space-8);
}
.u-mb-2 {
  margin-block-end: var(--space-2);
}
.u-mb-4 {
  margin-block-end: var(--space-4);
}
.u-w-full {
  width: 100%;
}
.u-text-center {
  text-align: center;
}

/* prototype.js puts this on <html> for the lifetime of every open modal, on
   every screen, and NO RULE DEFINED IT — so the page behind a dialog kept
   scrolling. On a till that means the cashier can scroll the basket out from
   under a payment sheet; on the long back-office screens it means the dialog
   drifts off the top while they reach for it.

   `scrollbar-gutter: stable` on the same element so removing the scrollbar does
   not shift the whole page sideways when a dialog opens — the shift is small and
   the reason it is worth avoiding is that it moves the control the cashier was
   about to press. */
.no-scroll {
  overflow: hidden;
  scrollbar-gutter: stable;
}

.visually-hidden {
  position: absolute !important;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: absolute;
  inset-inline-start: var(--space-2);
  top: -3rem;
  background: var(--bg-surface);
  color: var(--text-brand);
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-e2);
  z-index: var(--z-toast);
  transition: top var(--duration-fast) var(--ease-standard);
}
.skip-link:focus {
  top: var(--space-2);
}

/* LTR-embedding — REQUIRED for every number/money/SKU/phone/order-id inside
   Arabic text. Prefer semantic <bdi dir="ltr">…</bdi>; this class is the
   fallback when <bdi> isn't practical (e.g. inside a <td>). */
bdi,
.bdi-ltr {
  unicode-bidi: isolate;
}
.bdi-ltr {
  direction: ltr;
  display: inline-block;
}
.font-mono {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

/* --------------------------------------------------------------------------
   3. Typography
   -------------------------------------------------------------------------- */
.text-display {
  font-size: var(--font-size-display);
  line-height: var(--line-height-tight);
  font-weight: var(--font-weight-bold);
  letter-spacing: -0.01em;
}
.text-h1 {
  font-size: var(--font-size-h1);
  line-height: var(--line-height-tight);
  font-weight: var(--font-weight-bold);
}
.text-h2 {
  font-size: var(--font-size-h2);
  line-height: var(--line-height-snug);
  font-weight: var(--font-weight-semibold);
}
.text-h3 {
  font-size: var(--font-size-h3);
  line-height: var(--line-height-snug);
  font-weight: var(--font-weight-semibold);
}
.text-h4 {
  font-size: var(--font-size-h4);
  line-height: var(--line-height-snug);
  font-weight: var(--font-weight-semibold);
}
.text-body-lg {
  font-size: var(--font-size-body-lg);
  line-height: var(--line-height-relaxed);
}
.text-body {
  font-size: var(--font-size-body);
  line-height: var(--line-height-normal);
}
.text-body-sm {
  font-size: var(--font-size-body-sm);
  line-height: var(--line-height-normal);
}
.text-caption {
  font-size: var(--font-size-caption);
  line-height: var(--line-height-normal);
}
.text-micro {
  font-size: var(--font-size-micro);
  line-height: var(--line-height-normal);
}

.text-secondary {
  color: var(--text-secondary);
}
.text-muted {
  color: var(--text-muted);
}
.text-brand {
  color: var(--text-brand);
}
.text-success {
  color: var(--text-success);
}
.text-warning {
  color: var(--text-warning);
}
.text-danger {
  color: var(--text-danger);
}
.text-info {
  color: var(--text-info);
}
.text-gold {
  color: var(--text-gold);
}
.font-medium {
  font-weight: var(--font-weight-medium);
}
.font-semibold {
  font-weight: var(--font-weight-semibold);
}
.font-bold {
  font-weight: var(--font-weight-bold);
}

.text-prose {
  max-width: 68ch;
  font-size: var(--font-size-body-lg);
  line-height: var(--line-height-loose);
  color: var(--text-secondary);
}
.text-prose > * + * {
  margin-block-start: var(--space-4);
}
.text-prose h2 {
  color: var(--text-primary);
  margin-block-start: var(--space-8);
  margin-block-end: var(--space-2);
}

/* --------------------------------------------------------------------------
   4. Icons
   -------------------------------------------------------------------------- */
.icon {
  width: 1.25em;
  height: 1.25em;
  stroke: currentColor;
  fill: none;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  vertical-align: -0.2em;
  flex-shrink: 0;
}
.icon--sm {
  width: 1em;
  height: 1em;
}
.icon--lg {
  width: 1.75em;
  height: 1.75em;
}
.icon--xl {
  width: 2.5em;
  height: 2.5em;
}
.icon-filled {
  fill: currentColor;
  stroke: none;
}

/* Directional chevrons: both built from the same "points toward end" glyph.
   Everything else (up/down, x, check, plus…) is direction-agnostic. */
.icon--start {
  transform: scaleX(-1);
}
.icon--end {
  transform: none;
}
[dir='rtl'] .icon--start {
  transform: none;
}
[dir='rtl'] .icon--end {
  transform: scaleX(-1);
}

/* --------------------------------------------------------------------------
   5. Buttons
   -------------------------------------------------------------------------- */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  height: var(--control-height-md);
  padding-inline: var(--space-4);
  border-radius: var(--control-radius);
  font-size: var(--control-font-size);
  font-weight: var(--font-weight-semibold);
  border: 1px solid transparent;
  white-space: nowrap;
  transition:
    background-color var(--duration-fast) var(--ease-standard),
    border-color var(--duration-fast) var(--ease-standard),
    color var(--duration-fast) var(--ease-standard),
    box-shadow var(--duration-fast) var(--ease-standard);
  user-select: none;
}
.btn:disabled,
.btn[aria-disabled='true'] {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn--primary {
  background: var(--action-brand-default);
  color: var(--text-on-brand);
}
.btn--primary:hover:not(:disabled) {
  background: var(--action-brand-hover);
}
.btn--primary:active:not(:disabled) {
  background: var(--action-brand-active);
}

.btn--secondary {
  background: var(--bg-surface);
  color: var(--text-primary);
  border-color: var(--border-strong);
}
.btn--secondary:hover:not(:disabled) {
  background: var(--bg-surface-sunken);
}

.btn--outline {
  background: transparent;
  color: var(--text-brand);
  border-color: var(--border-brand);
}
.btn--outline:hover:not(:disabled) {
  background: var(--bg-brand-subtle);
}

.btn--ghost {
  background: transparent;
  color: var(--text-secondary);
}
.btn--ghost:hover:not(:disabled) {
  background: var(--bg-surface-sunken);
  color: var(--text-primary);
}

.btn--danger {
  background: var(--action-danger-default);
  color: #fff;
}
.btn--danger:hover:not(:disabled) {
  background: var(--action-danger-hover);
}
.btn--danger:active:not(:disabled) {
  background: var(--action-danger-active);
}

.btn--danger-outline {
  background: transparent;
  color: var(--text-danger);
  border-color: var(--border-danger);
}
.btn--danger-outline:hover:not(:disabled) {
  background: var(--bg-danger-subtle);
}

.btn--link {
  height: auto;
  padding: 0;
  background: none;
  color: var(--text-link);
  font-weight: var(--font-weight-medium);
  text-decoration: underline;
  text-underline-offset: 3px;
}
.btn--link:hover:not(:disabled) {
  color: var(--action-brand-hover);
}

.btn--sm {
  height: var(--control-height-sm);
  padding-inline: var(--space-3);
  font-size: var(--font-size-body-sm);
}
.btn--lg {
  height: var(--control-height-lg);
  padding-inline: var(--space-6);
  font-size: var(--font-size-body-lg);
}

.btn--icon-only {
  width: var(--control-height-md);
  padding-inline: 0;
}
.btn--icon-only.btn--sm {
  width: var(--control-height-sm);
}
.btn--icon-only.btn--lg {
  width: var(--control-height-lg);
}

.btn--full {
  width: 100%;
}

.btn-group {
  display: inline-flex;
}
.btn-group > .btn {
  border-radius: 0;
  margin-inline-start: -1px;
}
.btn-group > .btn:first-child {
  border-start-start-radius: var(--control-radius);
  border-end-start-radius: var(--control-radius);
  margin-inline-start: 0;
}
.btn-group > .btn:last-child {
  border-start-end-radius: var(--control-radius);
  border-end-end-radius: var(--control-radius);
}

/* Loading state: hide label text visually (keep for a11y), show spinner */
.btn.is-loading {
  color: transparent !important;
  pointer-events: none;
  position: relative;
}
.btn.is-loading::after {
  content: '';
  position: absolute;
  inset: 0;
  margin: auto;
  width: 1.1em;
  height: 1.1em;
  border-radius: 50%;
  border: 2px solid currentColor;
  border-inline-end-color: transparent;
  color: var(--text-on-brand);
  animation: sz-spin 0.7s linear infinite;
}
.btn--secondary.is-loading::after,
.btn--outline.is-loading::after,
.btn--ghost.is-loading::after,
.btn--link.is-loading::after {
  color: var(--text-brand);
}
.btn--danger-outline.is-loading::after {
  color: var(--text-danger);
}

@keyframes sz-spin {
  to {
    transform: rotate(360deg);
  }
}

/* --------------------------------------------------------------------------
   6. Badges / status pills
   -------------------------------------------------------------------------- */
.badge {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding-inline: var(--space-2);
  height: 1.5rem;
  border-radius: var(--radius-full);
  font-size: var(--font-size-caption);
  font-weight: var(--font-weight-semibold);
  white-space: nowrap;
  line-height: 1;
}
.badge--neutral {
  background: var(--bg-surface-sunken);
  color: var(--text-secondary);
}
.badge--brand {
  background: var(--bg-brand-subtle);
  color: var(--text-brand);
}
.badge--success {
  background: var(--bg-success-subtle);
  color: var(--text-success);
}
.badge--warning {
  background: var(--bg-warning-subtle);
  color: var(--text-warning);
}
.badge--danger {
  background: var(--bg-danger-subtle);
  color: var(--text-danger);
}
.badge--info {
  background: var(--bg-info-subtle);
  color: var(--text-info);
}
.badge--gold {
  background: var(--bg-gold-subtle);
  color: var(--text-gold);
}
.badge--outline {
  background: transparent;
  border: 1px solid var(--border-strong);
  color: var(--text-secondary);
}

.badge__dot {
  width: 0.4rem;
  height: 0.4rem;
  border-radius: 50%;
  background: currentColor;
  flex-shrink: 0;
}

/* --------------------------------------------------------------------------
   7. Forms
   -------------------------------------------------------------------------- */
.field {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  max-width: 100%;
}
.field__label {
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-medium);
  color: var(--text-secondary);
}
.field__required {
  color: var(--text-danger);
  margin-inline-start: var(--space-1);
}
.field__hint {
  font-size: var(--font-size-caption);
  color: var(--text-muted);
}
.field__error {
  font-size: var(--font-size-caption);
  color: var(--text-danger);
  display: flex;
  align-items: center;
  gap: var(--space-1);
}
.field__success {
  font-size: var(--font-size-caption);
  color: var(--text-success);
  display: flex;
  align-items: center;
  gap: var(--space-1);
}

.input,
.textarea,
.select-wrap select {
  width: 100%;
  height: var(--control-height-md);
  padding-inline: var(--space-3);
  border: 1px solid var(--border-strong);
  border-radius: var(--control-radius);
  background: var(--bg-surface);
  color: var(--text-primary);
  font-size: 1rem; /* never below 16px: prevents iOS input-zoom */
}
.textarea {
  height: auto;
  min-height: 6rem;
  padding-block: var(--space-2);
  resize: vertical;
}
.input::placeholder,
.textarea::placeholder {
  color: var(--text-muted);
}
.input:focus,
.textarea:focus,
.select-wrap select:focus {
  border-color: var(--border-brand);
  box-shadow: 0 0 0 3px var(--bg-brand-subtle);
}
.input:disabled,
.textarea:disabled,
.select-wrap select:disabled {
  background: var(--bg-surface-sunken);
  color: var(--text-disabled);
  cursor: not-allowed;
}
/* NOT --border-danger here. That token is the tint used on an alert's edge, and it
   measures 2.03:1 on the surface — BELOW 1.4.11's 3:1 for a state indicator, and
   weaker than the 3.31:1 --border-strong every VALID field already draws. An invalid
   field was therefore outlined more faintly than a valid one, which is backwards.
   --color-danger-500 is 4.21:1, and the extra weight makes the state read at a glance
   rather than only via the message below. (Colour is never the sole channel: the
   field also carries aria-invalid and a role="alert" message.) */
.field.is-invalid .input,
.field.is-invalid .textarea,
.field.is-invalid .select-wrap select {
  border-color: var(--color-danger-500);
  border-width: 2px;
}
.input--sm {
  height: var(--control-height-sm);
}
.input--lg {
  height: var(--control-height-lg);
  font-size: var(--font-size-body-lg);
}
.input--mono,
.input.font-mono {
  font-family: var(--font-mono);
}
/* The mono stack (IBM Plex Mono, ui-monospace, …) has NO Arabic glyphs, so an
   Arabic placeholder inside a mono field falls back per-glyph and the cursive
   joining breaks — "امـسـح أو اكـتـب" instead of connected script. The VALUE in
   these fields is always a Latin code (SKU, barcode, coupon, order number), so
   keep mono for the value and set the placeholder in the Arabic-capable sans. */
.input--mono::placeholder,
.input.font-mono::placeholder {
  font-family: var(--font-sans);
}

/* Select: custom chevron, symmetric so it needs no RTL mirroring */
.select-wrap {
  position: relative;
  display: block;
}
.select-wrap select {
  -webkit-appearance: none;
  appearance: none;
  -moz-appearance: none;
  padding-inline-end: var(--space-8);
  cursor: pointer;
}
.select-wrap::after {
  content: '';
  position: absolute;
  inset-inline-end: var(--space-3);
  top: 50%;
  transform: translateY(-20%);
  width: 0;
  height: 0;
  border-inline-start: 5px solid transparent;
  border-inline-end: 5px solid transparent;
  border-block-start: 6px solid var(--text-secondary);
  pointer-events: none;
}

/* Input group: icon / unit prefix-suffix */
.input-group {
  position: relative;
  display: flex;
  align-items: stretch;
}
.input-group .input {
  padding-inline-start: var(--space-9, 2.25rem);
}
.input-group__icon {
  position: absolute;
  inset-inline-start: var(--space-3);
  top: 50%;
  transform: translateY(-50%);
  color: var(--text-muted);
  pointer-events: none;
}
.input-group__addon {
  display: inline-flex;
  align-items: center;
  padding-inline: var(--space-3);
  background: var(--bg-surface-sunken);
  border: 1px solid var(--border-strong);
  color: var(--text-secondary);
  font-size: var(--font-size-body-sm);
  white-space: nowrap;
}
.input-group__addon:first-child {
  border-start-start-radius: var(--control-radius);
  border-end-start-radius: var(--control-radius);
  border-inline-end: 0;
}
.input-group__addon:last-child {
  border-start-end-radius: var(--control-radius);
  border-end-end-radius: var(--control-radius);
  border-inline-start: 0;
}
.input-group__addon ~ .input {
  border-start-start-radius: 0;
  border-end-start-radius: 0;
}

/* Checkbox / Radio */
.checkbox,
.radio {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  cursor: pointer;
  font-size: var(--font-size-body-sm);
  color: var(--text-primary);
}
.checkbox input,
.radio input {
  -webkit-appearance: none;
  appearance: none;
  width: 1.25rem;
  height: 1.25rem;
  flex-shrink: 0;
  border: 1.5px solid var(--border-strong);
  background: var(--bg-surface);
  position: relative;
  cursor: pointer;
}
.checkbox input {
  border-radius: var(--radius-xs);
}
.radio input {
  border-radius: var(--radius-full);
}
.checkbox input:checked,
.radio input:checked {
  background: var(--action-brand-default);
  border-color: var(--action-brand-default);
}
.checkbox input:checked::after {
  content: '';
  position: absolute;
  inset: 0;
  margin: auto;
  width: 0.7rem;
  height: 0.45rem;
  /* Physical (not logical) properties are intentional: a checkmark glyph is a
     fixed visual symbol that must render identically in RTL and LTR. Using
     border-inline-start/border-block-end here would flip which side is drawn
     under dir="rtl" while the -45deg rotation stays fixed, mirroring the tick
     into a chevron-like shape instead of a checkmark. */
  border-left: 2px solid #fff;
  border-bottom: 2px solid #fff;
  transform: rotate(-45deg) translateY(-1px);
}
.radio input:checked::after {
  content: '';
  position: absolute;
  inset: 0;
  margin: auto;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background: #fff;
}
.checkbox input:focus-visible,
.radio input:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}
.checkbox input:disabled,
.radio input:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Switch */
.switch {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  cursor: pointer;
}
.switch input {
  -webkit-appearance: none;
  appearance: none;
  width: 2.5rem;
  height: 1.5rem;
  border-radius: var(--radius-full);
  background: var(--border-strong);
  position: relative;
  cursor: pointer;
  transition: background var(--duration-fast) var(--ease-standard);
  flex-shrink: 0;
}
.switch input::after {
  content: '';
  position: absolute;
  top: 2px;
  inset-inline-start: 2px;
  width: 1.25rem;
  height: 1.25rem;
  border-radius: 50%;
  background: #fff;
  box-shadow: var(--shadow-e1);
  transition: inset-inline-start var(--duration-fast) var(--ease-standard);
}
.switch input:checked {
  background: var(--action-brand-default);
}
.switch input:checked::after {
  inset-inline-start: calc(100% - 1.25rem - 2px);
}
.switch input:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

/* Stepper (POS qty +/-) */
.stepper {
  display: inline-flex;
  align-items: stretch;
  border: 1px solid var(--border-strong);
  border-radius: var(--control-radius);
  overflow: hidden;
  height: var(--control-height-md);
}
.stepper button {
  width: var(--control-height-md);
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-surface-sunken);
  color: var(--text-primary);
  font-size: 1.25rem;
  font-weight: var(--font-weight-semibold);
}
.stepper button:hover {
  background: var(--bg-brand-subtle);
  color: var(--text-brand);
}
.stepper button:disabled {
  opacity: 0.4;
}
.stepper input {
  width: 3.5rem;
  border: 0;
  border-inline: 1px solid var(--border-default);
  text-align: center;
  font-variant-numeric: tabular-nums;
  direction: ltr;
}
.stepper--lg {
  height: var(--control-height-lg);
}
.stepper--lg button {
  width: var(--control-height-lg);
  font-size: 1.5rem;
}

/* File input (styled as a button trigger) */
.file-drop {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  border: 1.5px dashed var(--border-strong);
  border-radius: var(--radius-md);
  padding: var(--space-4);
  background: var(--bg-surface-sunken);
  color: var(--text-secondary);
}
.file-drop input[type='file'] {
  display: none;
}

/* --------------------------------------------------------------------------
   8. Cards
   -------------------------------------------------------------------------- */
.card {
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-e1);
}
.card__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  padding: var(--space-4) var(--space-5);
  border-block-end: 1px solid var(--border-default);
}
.card__title {
  font-size: var(--font-size-h4);
  font-weight: var(--font-weight-semibold);
}
.card__subtitle {
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
  margin-block-start: var(--space-1);
}
.card__actions {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-shrink: 0;
}
.card__body {
  padding: var(--space-5);
}
.card__body + .card__body {
  border-block-start: 1px solid var(--border-default);
}
.card__footer {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-2);
  padding: var(--space-4) var(--space-5);
  border-block-start: 1px solid var(--border-default);
  background: var(--bg-surface-sunken);
  border-end-start-radius: var(--radius-lg);
  border-end-end-radius: var(--radius-lg);
}
.card--flush .card__body {
  padding: 0;
}

/* Stat / KPI tile */
.stat-tile {
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-lg);
  padding: var(--space-5);
}
.stat-tile__label {
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
}
.stat-tile__value {
  font-size: var(--font-size-h1);
  font-weight: var(--font-weight-bold);
  margin-block-start: var(--space-1);
  font-variant-numeric: tabular-nums;
  direction: ltr;
  unicode-bidi: isolate;
}
.stat-tile__trend {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  font-size: var(--font-size-caption);
  font-weight: var(--font-weight-medium);
  margin-block-start: var(--space-2);
}
.stat-tile__trend--up {
  color: var(--text-success);
}
.stat-tile__trend--down {
  color: var(--text-danger);
}

/* Product card: superseded by section 18 (Product card v2). The former block
   lived here and set `direction: ltr` on .product-card__price, which forced the
   currency symbol to the visual LEFT in Arabic — the reader met the number
   before the symbol. Removed with the markup sweep, not shadowed. */
/* --------------------------------------------------------------------------
   9. Tables
   -------------------------------------------------------------------------- */
/* Horizontally scrollable table shell.
   The scrolling always worked — what was missing is any SIGNAL that it scrolls,
   so a cut-off column read as a deleted column (in a screenshot it is
   indistinguishable). The edge fades + shadows below appear only when there is
   actually more content in that direction (background-attachment: local vs
   scroll does the detection with no JS). The pair is symmetric, so it is
   direction-agnostic and behaves identically in RTL and LTR. */
.table-scroll {
  width: 100%;
  overflow-x: auto;
  border: 1px solid var(--border-default);
  border-radius: var(--radius-lg);
  scrollbar-width: thin;
  background:
    linear-gradient(to right, var(--bg-surface) 30%, rgba(255, 255, 255, 0)) 0 0 / 2.5rem 100%
      no-repeat local,
    linear-gradient(to left, var(--bg-surface) 30%, rgba(255, 255, 255, 0)) 100% 0 / 2.5rem 100%
      no-repeat local,
    radial-gradient(farthest-side at 0 50%, rgba(15, 20, 22, 0.14), rgba(15, 20, 22, 0)) 0 0 /
      0.85rem 100% no-repeat scroll,
    radial-gradient(farthest-side at 100% 50%, rgba(15, 20, 22, 0.14), rgba(15, 20, 22, 0)) 100% 0 /
      0.85rem 100% no-repeat scroll;
  background-color: var(--bg-surface);
}
.table {
  width: 100%;
  min-width: 32rem;
  border-collapse: collapse;
  background: var(--bg-surface);
}
.table th,
.table td {
  padding: var(--space-3) var(--space-4);
  text-align: start;
  font-size: var(--font-size-body-sm);
  border-block-end: 1px solid var(--border-default);
  white-space: nowrap;
}
.table th {
  color: var(--text-secondary);
  font-weight: var(--font-weight-semibold);
  font-size: var(--font-size-caption);
  text-transform: uppercase;
  letter-spacing: 0.02em;
  background: var(--bg-surface-sunken);
  position: sticky;
  top: 0;
}
.table tbody tr:last-child td {
  border-block-end: 0;
}
.table tbody tr:hover {
  background: var(--bg-surface-hover);
}
.table--zebra tbody tr:nth-child(even) {
  background: var(--bg-surface-sunken);
}
.table td.is-numeric,
.table th.is-numeric {
  direction: ltr;
  text-align: end;
  font-variant-numeric: tabular-nums;
}
.table__actions {
  display: flex;
  gap: var(--space-2);
  justify-content: flex-end;
  white-space: nowrap;
}
/* M29 — when the class lands on the <td> ITSELF (3 cells in the delivery-zone
   table), `display: flex` takes the cell out of the table formatting context, so
   its row no longer shares the others' baseline and the divider draws a visible
   step. Keep it a table-cell and space the buttons with a sibling margin, which
   needs no flex context and leaves the row intact. */
td.table__actions,
th.table__actions {
  display: table-cell;
  text-align: end;
  vertical-align: middle;
}
td.table__actions > * + *,
th.table__actions > * + * {
  margin-inline-start: var(--space-2);
}
.table-empty {
  padding: var(--space-12) var(--space-4);
  text-align: center;
  color: var(--text-muted);
}
th.is-sortable {
  cursor: pointer;
  -webkit-user-select: none;
  user-select: none;
}
th.is-sortable .icon {
  opacity: 0.5;
}
th.is-sortable[aria-sort='ascending'] .icon,
th.is-sortable[aria-sort='descending'] .icon {
  opacity: 1;
  color: var(--text-brand);
}

/* --------------------------------------------------------------------------
   10. Modals / dialogs
   -------------------------------------------------------------------------- */
.modal-scrim {
  position: fixed;
  inset: 0;
  background: var(--bg-overlay-scrim);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-4);
  z-index: var(--z-modal);
}
.modal-scrim[hidden] {
  display: none;
}
.modal {
  background: var(--bg-surface);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-e4);
  width: 100%;
  max-width: 30rem;
  max-height: calc(100vh - var(--space-8));
  display: flex;
  flex-direction: column;
  animation: sz-modal-in var(--duration-base) var(--ease-decelerate);
}
.modal--sm {
  max-width: 24rem;
}
.modal--lg {
  max-width: 42rem;
}
.modal--xl {
  max-width: 56rem;
}
@keyframes sz-modal-in {
  from {
    opacity: 0;
    transform: translateY(8px) scale(0.98);
  }
  to {
    opacity: 1;
    transform: none;
  }
}
.modal__header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-3);
  padding: var(--space-5) var(--space-5) var(--space-3);
}
.modal__title {
  font-size: var(--font-size-h3);
  font-weight: var(--font-weight-semibold);
}
.modal__close {
  flex-shrink: 0;
  width: 2rem;
  height: 2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-full);
  color: var(--text-muted);
}
.modal__close:hover {
  background: var(--bg-surface-sunken);
  color: var(--text-primary);
}
.modal__body {
  padding: 0 var(--space-5) var(--space-5);
  overflow-y: auto;
  color: var(--text-secondary);
}
.modal__body > * + * {
  margin-block-start: var(--space-3);
}
.modal__footer {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-2);
  padding: var(--space-4) var(--space-5);
  border-block-start: 1px solid var(--border-default);
}
.modal__footer--between {
  justify-content: space-between;
}
.modal--danger .modal__title {
  color: var(--text-danger);
}

/* --------------------------------------------------------------------------
   11. Tabs
   -------------------------------------------------------------------------- */
.tabs__list {
  display: flex;
  gap: var(--space-1);
  border-block-end: 1px solid var(--border-default);
  overflow-x: auto;
}
.tabs__tab {
  padding: var(--space-3) var(--space-4);
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-medium);
  color: var(--text-secondary);
  border-block-end: 2px solid transparent;
  margin-block-end: -1px;
  white-space: nowrap;
}
.tabs__tab:hover {
  color: var(--text-primary);
}
.tabs__tab[aria-selected='true'] {
  color: var(--text-brand);
  border-block-end-color: var(--action-brand-default);
}
.tabs__panel {
  padding-block: var(--space-5);
}
.tabs__panel[hidden] {
  display: none;
}

/* --------------------------------------------------------------------------
   12. Alerts / banners
   -------------------------------------------------------------------------- */
.alert {
  display: flex;
  gap: var(--space-3);
  padding: var(--space-4);
  border-radius: var(--radius-md);
  border: 1px solid transparent;
  font-size: var(--font-size-body-sm);
}
.alert__icon {
  flex-shrink: 0;
  margin-block-start: 1px;
}
.alert__body {
  flex: 1;
}
.alert__title {
  font-weight: var(--font-weight-semibold);
  margin-block-end: var(--space-1);
}
.alert--info {
  background: var(--bg-info-subtle);
  border-color: var(--border-info);
  color: var(--text-info);
}
.alert--success {
  background: var(--bg-success-subtle);
  border-color: var(--border-success);
  color: var(--text-success);
}
.alert--warning {
  background: var(--bg-warning-subtle);
  border-color: var(--border-warning);
  color: var(--text-warning);
}
.alert--danger {
  background: var(--bg-danger-subtle);
  border-color: var(--border-danger);
  color: var(--text-danger);
}
.alert__body :where(p) {
  color: var(--text-primary);
}
.alert__dismiss {
  flex-shrink: 0;
  color: inherit;
  opacity: 0.7;
}
.alert__dismiss:hover {
  opacity: 1;
}

/* --------------------------------------------------------------------------
   13. Toasts
   -------------------------------------------------------------------------- */
.toast-region {
  position: fixed;
  inset-block-start: var(--space-4);
  inset-inline-end: var(--space-4);
  z-index: var(--z-toast);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  width: 22rem;
  max-width: calc(100vw - var(--space-8));
}
.toast {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  background: var(--color-neutral-900);
  color: var(--text-on-dark);
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-e3);
  animation: sz-toast-in var(--duration-base) var(--ease-decelerate);
}
@keyframes sz-toast-in {
  from {
    opacity: 0;
    transform: translateY(-6px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}
.toast--success {
  background: var(--color-success-800);
}
.toast--danger {
  background: var(--color-danger-800);
}
.toast--warning {
  background: var(--color-warning-800);
}

/* --------------------------------------------------------------------------
   14. Empty / error / loading states
   -------------------------------------------------------------------------- */
.empty-state,
.error-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  padding: var(--space-12) var(--space-6);
  color: var(--text-secondary);
}
.empty-state__icon,
.error-state__icon {
  width: 3.5rem;
  height: 3.5rem;
  border-radius: var(--radius-full);
  background: var(--bg-surface-sunken);
  color: var(--text-muted);
  display: flex;
  align-items: center;
  justify-content: center;
  margin-block-end: var(--space-4);
}
.error-state__icon {
  background: var(--bg-danger-subtle);
  color: var(--text-danger);
}
.empty-state__title,
.error-state__title {
  font-size: var(--font-size-h4);
  font-weight: var(--font-weight-semibold);
  color: var(--text-primary);
}
.empty-state__desc,
.error-state__desc {
  max-width: 32rem;
  margin-block-start: var(--space-1);
}
.empty-state__actions,
.error-state__actions {
  margin-block-start: var(--space-4);
  display: flex;
  gap: var(--space-2);
}

.skeleton {
  background: linear-gradient(
    90deg,
    var(--bg-surface-sunken) 25%,
    var(--border-default) 37%,
    var(--bg-surface-sunken) 63%
  );
  background-size: 400% 100%;
  animation: sz-skeleton 1.4s ease infinite;
  border-radius: var(--radius-sm);
}
@keyframes sz-skeleton {
  0% {
    background-position: 100% 50%;
  }
  100% {
    background-position: 0 50%;
  }
}
.skeleton--text {
  height: 0.85em;
  margin-block: 0.15em;
}
.skeleton--title {
  height: 1.5rem;
  width: 40%;
}
.skeleton--avatar {
  width: 2.5rem;
  height: 2.5rem;
  border-radius: 50%;
}

.spinner {
  display: inline-block;
  width: 1.25em;
  height: 1.25em;
  border: 2px solid var(--border-strong);
  border-inline-end-color: var(--action-brand-default);
  border-radius: 50%;
  animation: sz-spin 0.7s linear infinite;
}
.spinner--lg {
  width: 2.5rem;
  height: 2.5rem;
  border-width: 3px;
}
.loading-line {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  color: var(--text-secondary);
  padding: var(--space-6) 0;
}

/* --------------------------------------------------------------------------
   15. Misc
   -------------------------------------------------------------------------- */
/* Pagination */
.pagination {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}
/* M30 — this box deliberately opts OUT of the page direction: "1–12 / 41" is a run
   of European numerals, and under an RTL base the runs reverse to "41 / 1–12", so
   `direction: ltr` has to stay. The catch is that a logical margin resolves against
   the element's OWN direction, so `margin-inline-end: auto` became margin-RIGHT
   inside an RTL flex line — absorbing the space on the range's right and dragging it
   off the edge to sit beside the pager (measured 466px from the container edge).
   The RTL override below is the one place a direction-specific rule is correct:
   the element's direction and its parent's genuinely differ. */
.pagination__range {
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
  margin-inline-end: auto;
  direction: ltr;
  unicode-bidi: isolate;
}
[dir='rtl'] .pagination__range {
  margin-inline-end: 0;
  margin-inline-start: auto;
}
.pagination__btn {
  min-width: var(--control-height-sm);
  height: var(--control-height-sm);
  padding-inline: var(--space-2);
  border-radius: var(--radius-sm);
  color: var(--text-secondary);
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.pagination__btn:hover:not(:disabled) {
  background: var(--bg-surface-sunken);
}
.pagination__btn[aria-current='page'] {
  background: var(--action-brand-default);
  color: var(--text-on-brand);
}
.pagination__btn:disabled {
  opacity: 0.35;
  pointer-events: none;
}

/* Breadcrumb */
.breadcrumb {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
  flex-wrap: wrap;
}
.breadcrumb a:hover {
  color: var(--text-brand);
}
.breadcrumb__sep {
  color: var(--text-muted);
  width: 0.85em;
  height: 0.85em;
}
.breadcrumb__current {
  color: var(--text-primary);
  font-weight: var(--font-weight-medium);
}

/* Tooltip (CSS-only) */
[data-tooltip] {
  position: relative;
}
[data-tooltip]::after {
  content: attr(data-tooltip);
  position: absolute;
  bottom: calc(100% + 6px);
  inset-inline-start: 50%;
  transform: translateX(-50%) translateY(4px);
  background: var(--color-neutral-900);
  color: #fff;
  font-size: var(--font-size-micro);
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-sm);
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition:
    opacity var(--duration-fast) var(--ease-standard),
    transform var(--duration-fast) var(--ease-standard);
  z-index: var(--z-tooltip);
}
[dir='rtl'] [data-tooltip]::after {
  transform: translateX(50%) translateY(4px);
}
[data-tooltip]:hover::after,
[data-tooltip]:focus-visible::after {
  transform: translateX(-50%) translateY(0);
  opacity: 1;
}
[dir='rtl'] [data-tooltip]:hover::after,
[dir='rtl'] [data-tooltip]:focus-visible::after {
  transform: translateX(50%) translateY(0);
}

/* Segmented control */
.segmented {
  display: inline-flex;
  background: var(--bg-surface-sunken);
  border-radius: var(--control-radius);
  padding: 3px;
  gap: 2px;
}
.segmented__option {
  padding-inline: var(--space-4);
  height: calc(var(--control-height-md) - 6px);
  border-radius: calc(var(--control-radius) - 2px);
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-medium);
  color: var(--text-secondary);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
}
.segmented__option[aria-pressed='true'] {
  background: var(--bg-surface);
  color: var(--text-brand);
  box-shadow: var(--shadow-e1);
  font-weight: var(--font-weight-semibold);
}

/* Chip (filter / category) */
.chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  height: 2rem;
  /* Fixed-height pill in a horizontally scrolling row: multi-word Arabic labels
     ("منزل ومطبخ") wrapped to a second line and were clipped by the 2rem height.
     Keep them on one line and let the row scroll instead. */
  white-space: nowrap;
  flex-shrink: 0;
  padding-inline: var(--space-3);
  border-radius: var(--radius-full);
  border: 1px solid var(--border-strong);
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
  background: var(--bg-surface);
}
.chip:hover {
  border-color: var(--border-brand);
  color: var(--text-brand);
}
/* aria-current is the anchor form of the same state. The storefront category
   rail is a set of real navigations (§3.7 — the URL must stay crawlable), and a
   link cannot carry aria-pressed, so without this the current category renders
   with no active pill at all. */
.chip[aria-pressed='true'],
.chip[aria-current='page'] {
  background: var(--color-neutral-900);
  border-color: var(--color-neutral-900);
  color: #fff;
}
.chip__remove {
  margin-inline-start: var(--space-1);
  opacity: 0.7;
}
.chip__remove:hover {
  opacity: 1;
}

/* Progress bar */
.progress {
  height: 0.5rem;
  border-radius: var(--radius-full);
  background: var(--bg-surface-sunken);
  overflow: hidden;
}
.progress__bar {
  height: 100%;
  background: var(--action-brand-default);
  border-radius: var(--radius-full);
  transition: width var(--duration-slow) var(--ease-standard);
}
.progress--indeterminate .progress__bar {
  width: 40% !important;
  animation: sz-progress-indet 1.2s ease-in-out infinite;
}
@keyframes sz-progress-indet {
  0% {
    margin-inline-start: -40%;
  }
  100% {
    margin-inline-start: 100%;
  }
}

/* Status timeline (order lifecycle / referral chain) */
.timeline {
  display: flex;
  width: 100%;
}
.timeline__step {
  flex: 1;
  position: relative;
  text-align: center;
  padding-block-start: 0.7rem;
}
.timeline__step:not(:first-child)::before {
  content: '';
  position: absolute;
  inset-inline-end: 50%;
  top: 0.6rem;
  width: 100%;
  height: 2px;
  background: var(--border-default);
  z-index: 0;
}
.timeline__step--done:not(:first-child)::before {
  background: var(--color-brand-400);
}
.timeline__dot {
  position: relative;
  z-index: 1;
  width: 1.2rem;
  height: 1.2rem;
  border-radius: 50%;
  background: var(--bg-surface);
  border: 2px solid var(--border-strong);
  margin-inline: auto;
}
.timeline__step--done .timeline__dot {
  background: var(--action-brand-default);
  border-color: var(--action-brand-default);
}
.timeline__step--current .timeline__dot {
  border-color: var(--action-brand-default);
  box-shadow: 0 0 0 4px var(--bg-brand-subtle);
}
.timeline__label {
  display: block;
  margin-block-start: var(--space-2);
  font-size: var(--font-size-caption);
  color: var(--text-secondary);
}
.timeline__step--done .timeline__label,
.timeline__step--current .timeline__label {
  color: var(--text-primary);
  font-weight: var(--font-weight-medium);
}

/* Avatar */
.avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.5rem;
  height: 2.5rem;
  border-radius: var(--radius-full);
  background: var(--bg-brand-subtle);
  color: var(--text-brand);
  font-weight: var(--font-weight-semibold);
  font-size: var(--font-size-body-sm);
  flex-shrink: 0;
  overflow: hidden;
}
.avatar--sm {
  width: 2rem;
  height: 2rem;
  font-size: var(--font-size-caption);
}
.avatar--lg {
  width: 3.5rem;
  height: 3.5rem;
  font-size: var(--font-size-h4);
}

/* Logo placeholder (shop / brand mark) */
.logo-mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-md);
  background: var(--action-brand-default);
  color: #fff;
  font-weight: var(--font-weight-bold);
  flex-shrink: 0;
}

/* Notification bell */
.icon-btn {
  position: relative;
  width: var(--control-height-md);
  height: var(--control-height-md);
  border-radius: var(--radius-full);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--text-secondary);
}
.icon-btn:hover {
  background: var(--bg-surface-sunken);
  color: var(--text-primary);
}
.icon-btn__badge {
  position: absolute;
  top: 6px;
  inset-inline-end: 6px;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background: var(--color-danger-500);
  border: 2px solid var(--bg-surface);
}

.divider {
  height: 1px;
  background: var(--border-default);
  border: 0;
}
.divider--vertical {
  width: 1px;
  height: 100%;
  background: var(--border-default);
}

/* --------------------------------------------------------------------------
   16. Prototype-only chrome (not part of the real app — reviewer affordances)
   -------------------------------------------------------------------------- */
/* Reviewer-only chrome. It was previously a FULL-WIDTH BLACK SLAB sitting in the
   content flow of all 68 screens — inside the login card, inside the checkout
   form, six times on the admin KPI page — which dominated every screenshot and
   made a finished design read as a dev harness.
   Now: a compact, recessive pill aligned to the inline-end, out of the way of
   the composition. Append `?proto=0` to any URL to hide all prototype chrome
   entirely (for stakeholder demos and screenshot handoffs). */
.proto-bar {
  /* Deliberately NOT sticky: the app shells already stick their own topbar to
     top:0, and this reviewer-only affordance must never fight it for space. */
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-1) var(--space-2);
  background: transparent;
  color: var(--text-secondary);
  border: 1px dashed var(--border-strong);
  font-size: var(--font-size-micro);
  flex-wrap: wrap;
  border-radius: var(--radius-full);
  margin-block-end: var(--space-2);
  margin-inline-start: auto;
  opacity: 0.75;
}
.proto-bar:hover,
.proto-bar:focus-within {
  opacity: 1;
}
/* The bar is inline-flex, so it needs a full-width flow parent to sit at the end. */
.proto-bar {
  align-self: flex-end;
}
.proto-bar__label {
  font-weight: var(--font-weight-medium);
}
.proto-bar select {
  height: 1.5rem;
  background: var(--bg-surface);
  color: var(--text-primary);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  padding-inline: var(--space-2);
  font-size: var(--font-size-micro);
}
/* `?proto=0` — hide every reviewer-only affordance. */
html.proto-hidden [data-proto-chrome] {
  display: none !important;
}
.proto-bar__group {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
}
.proto-bar a {
  color: var(--color-brand-300);
  text-decoration: underline;
}

.planned-item {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  font-size: var(--font-size-body-sm);
}
.planned-item .badge {
  flex-shrink: 0;
}
.built-item {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  font-size: var(--font-size-body-sm);
}
.built-item:hover {
  background: var(--bg-brand-subtle-hover, var(--bg-surface-sunken));
}
.built-item a {
  font-weight: var(--font-weight-medium);
  color: var(--text-primary);
  flex: 1;
}
.built-item a:hover {
  color: var(--text-brand);
}

/* --------------------------------------------------------------------------
   17. Nav shells
   -------------------------------------------------------------------------- */

/* ---- Storefront: header + category rail + footer ------------------------ */
/* NOTE: .sf-header, .sf-header__search and .sf-header__cart-count moved to
   section 20 (rebuilt search-first). The rules below are still canonical and
   section 20 builds on them rather than restating them. */
.sf-utility-bar {
  background: var(--color-neutral-900);
  color: var(--text-on-dark);
  font-size: var(--font-size-micro);
}
.sf-utility-bar .container {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 2rem;
}
.sf-utility-bar a {
  opacity: 0.85;
}
.sf-utility-bar a:hover {
  opacity: 1;
}

.sf-header .container {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  padding-block: var(--space-3);
}
.sf-header__brand {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--font-size-h4);
  font-weight: var(--font-weight-bold);
  color: var(--text-brand);
  flex-shrink: 0;
}
.sf-header__search form {
  display: flex;
}
.sf-header__search input {
  border-start-end-radius: 0;
  border-end-end-radius: 0;
  border-inline-end: 0;
}
.sf-header__search button {
  border-start-start-radius: 0;
  border-end-start-radius: 0;
}
.sf-header__actions {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  flex-shrink: 0;
  margin-inline-start: auto;
}
.sf-header__mobile-toggle {
  display: none;
}

.sf-category-nav {
  background: var(--bg-surface);
  border-block-end: 1px solid var(--border-default);
}
.sf-category-nav .container {
  display: flex;
  gap: var(--space-2);
  padding-block: var(--space-3);
  overflow-x: auto;
}

.sf-footer {
  background: var(--color-neutral-900);
  color: var(--color-neutral-300);
  margin-block-start: var(--space-16);
}
.sf-footer .container {
  padding-block: var(--space-10);
  display: grid;
  gap: var(--space-8);
  grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
}
.sf-footer h3 {
  color: #fff;
  font-size: var(--font-size-body-sm);
  margin-block-end: var(--space-3);
}
.sf-footer ul {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
.sf-footer a {
  font-size: var(--font-size-body-sm);
}
.sf-footer a:hover {
  color: #fff;
}
.sf-footer__bottom {
  border-block-start: 1px solid var(--color-neutral-800);
  padding-block: var(--space-4);
  font-size: var(--font-size-caption);
  display: flex;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--space-2);
}

@media (max-width: 767px) {
  .sf-header .container {
    flex-wrap: wrap;
  }
  /* `width` is ignored for a flex item's main size — the base `flex: 1` sets
     flex-basis:0, so the field only wrapped onto its own row when something
     else happened to force a wrap. At 390px that left a 35px input stub, while
     360px (which did wrap) gave a usable 274px. Set the basis explicitly. */
  .sf-header__search {
    order: 3;
    max-width: none;
    flex: 1 0 100%;
  }
}

/* ---- Merchant: topbar + collapsible grouped sidebar ---------------------- */
.mc-shell {
  display: grid;
  grid-template-columns: auto 1fr;
  min-height: 100vh;
}
.mc-sidebar {
  width: 17rem;
  background: var(--color-neutral-900);
  color: var(--color-neutral-300);
  display: flex;
  flex-direction: column;
  transition: width var(--duration-base) var(--ease-standard);
  overflow: hidden;
}
.mc-shell.is-sidebar-collapsed .mc-sidebar {
  width: 4.25rem;
}
.mc-sidebar__brand {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-4);
  font-weight: var(--font-weight-bold);
  color: #fff;
  white-space: nowrap;
}
.mc-sidebar__scroll {
  flex: 1;
  overflow-y: auto;
  padding: var(--space-2);
}
.mc-sidebar__group + .mc-sidebar__group {
  margin-block-start: var(--space-4);
}
/* --text-muted-on-dark, not --color-neutral-500: the raw ramp value measured 4.05:1
   against the sidebar's --color-neutral-900, short of 1.4.3's 4.5:1, on all 40
   authenticated merchant screens. (The letter-spacing is safe on Arabic — Chromium
   does not insert it between joined letterforms, verified by measuring the rendered
   run; it only affects the Latin mirror.) */
.mc-sidebar__group-title {
  font-size: var(--font-size-micro);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-muted-on-dark);
  padding: var(--space-2) var(--space-3);
  white-space: nowrap;
}
/* Collapsed rail. Shrinking the width alone is NOT enough — the label spans
   must be removed from layout, otherwise they overflow the 4.25rem rail and
   every nav item renders chopped mid-word ("لوح", "نقط" / "Da", "PO"). */
.mc-shell.is-sidebar-collapsed .mc-sidebar__group-title {
  opacity: 0;
  height: 0;
  padding-block: 0;
  overflow: hidden;
}
/* Visually hidden, NOT display:none — the label is the link's accessible name,
   so an icon-only rail must keep it in the a11y tree. */
.mc-shell.is-sidebar-collapsed .mc-sidebar__link > span,
.mc-shell.is-sidebar-collapsed .mc-sidebar__brand > span:not(.logo-mark) {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.mc-shell.is-sidebar-collapsed .mc-sidebar__link,
.mc-shell.is-sidebar-collapsed .mc-sidebar__brand {
  justify-content: center;
  gap: 0;
}
.mc-shell.is-sidebar-collapsed .mc-sidebar__link .icon {
  flex-shrink: 0;
}
.mc-sidebar__link {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-md);
  font-size: var(--font-size-body-sm);
  color: var(--color-neutral-300);
  white-space: nowrap;
}
.mc-sidebar__link .icon {
  flex-shrink: 0;
}
.mc-sidebar__link:hover {
  background: var(--color-neutral-800);
  color: #fff;
}
.mc-sidebar__link[aria-current='page'] {
  background: var(--color-brand-700);
  color: #fff;
}
.mc-sidebar__toggle {
  margin: var(--space-2);
  color: var(--color-neutral-400);
  align-self: flex-end;
}
.mc-sidebar__toggle:hover {
  color: #fff;
}

.mc-main {
  display: flex;
  flex-direction: column;
  min-width: 0;
}
.mc-topbar {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-5);
  background: var(--bg-surface);
  border-block-end: 1px solid var(--border-default);
  position: sticky;
  top: 0;
  z-index: var(--z-sticky);
}
.mc-topbar__title {
  font-size: var(--font-size-h4);
  font-weight: var(--font-weight-semibold);
}
.mc-topbar__spacer {
  flex: 1;
}
.mc-impersonation-banner {
  background: var(--color-warning-500);
  color: var(--color-neutral-900);
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-medium);
  padding: var(--space-2) var(--space-5);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
}
.mc-content {
  padding: var(--space-6);
  flex: 1;
}

/* The drawer opener. Hidden on desktop where the sidebar is always visible;
   revealed below 1024px, which is the ONLY way to reach navigation there.
   (Do NOT re-add an inline style="display:none" to the button — that is what
   previously made the merchant + admin apps unnavigable at the 768px tablet
   floor, their stated primary form factor.) */
[data-sidebar-open-toggle] {
  display: none;
}

@media (max-width: 1023px) {
  .mc-shell {
    grid-template-columns: 1fr;
  }
  /* `visibility: hidden` while closed is doing two jobs, both load-bearing:
     (1) a11y — without it the drawer's 29 nav links stay in the tab order, so a
         keyboard user tabs off the header into an invisible menu and is lost;
     (2) layout — a position:fixed element parked outside the viewport still
         contributes to the document's scrollable overflow in Chromium, which is
         what put a phantom horizontal scrollbar on every merchant and admin
         screen below 1024px. `visibility` is animatable in the discrete sense, so
         delaying it until the slide-out finishes keeps the close transition. */
  .mc-sidebar {
    position: fixed;
    inset-block: 0;
    inset-inline-start: 0;
    z-index: var(--z-modal);
    transform: translateX(-100%);
    visibility: hidden;
    transition:
      transform var(--duration-base) var(--ease-standard),
      visibility 0s linear var(--duration-base);
    box-shadow: var(--shadow-e4);
  }
  [dir='rtl'] .mc-sidebar {
    transform: translateX(100%);
  }
  .mc-shell.is-sidebar-open .mc-sidebar {
    transform: none;
    width: 17rem;
    visibility: visible;
    transition:
      transform var(--duration-base) var(--ease-standard),
      visibility 0s;
  }
  .mc-sidebar__toggle {
    display: none;
  }
  .mc-content {
    padding: var(--space-4);
  }
  [data-sidebar-open-toggle] {
    display: inline-flex;
  }
  /* Dismiss scrim — tapping outside the drawer closes it. */
  .mc-shell.is-sidebar-open::before,
  .ad-shell.is-sidebar-open::before {
    content: '';
    position: fixed;
    inset: 0;
    background: var(--bg-overlay-scrim);
    z-index: var(--z-overlay);
  }
}

/* ---- Admin: topbar + grouped sidebar (desktop-only, denser) --------------- */
.ad-shell {
  display: grid;
  grid-template-columns: 15rem 1fr;
  min-height: 100vh;
}
.ad-sidebar {
  background: var(--bg-surface);
  border-inline-end: 1px solid var(--border-default);
  display: flex;
  flex-direction: column;
}
.ad-sidebar__brand {
  padding: var(--space-4);
  font-weight: var(--font-weight-bold);
  color: var(--text-brand);
  border-block-end: 1px solid var(--border-default);
}
.ad-sidebar__scroll {
  flex: 1;
  overflow-y: auto;
  padding: var(--space-2);
}
.ad-sidebar__group + .ad-sidebar__group {
  margin-block-start: var(--space-3);
}
.ad-sidebar__group-title {
  font-size: var(--font-size-micro);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-muted);
  padding: var(--space-2) var(--space-3);
}
.ad-sidebar__link {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
}
.ad-sidebar__link:hover {
  background: var(--bg-surface-sunken);
  color: var(--text-primary);
}
.ad-sidebar__link[aria-current='page'] {
  background: var(--bg-brand-subtle);
  color: var(--text-brand);
  font-weight: var(--font-weight-medium);
}
.ad-main {
  display: flex;
  flex-direction: column;
  min-width: 0;
}
.ad-topbar {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-5);
  background: var(--bg-surface);
  border-block-end: 1px solid var(--border-default);
}
.ad-topbar__title {
  font-size: var(--font-size-h4);
  font-weight: var(--font-weight-semibold);
}
.ad-topbar__spacer {
  flex: 1;
}
.ad-content {
  padding: var(--space-5);
  flex: 1;
}

@media (max-width: 1023px) {
  .ad-shell {
    grid-template-columns: 1fr;
  }
  .ad-sidebar {
    position: fixed;
    inset-block: 0;
    inset-inline-start: 0;
    width: 15rem;
    z-index: var(--z-modal);
    transform: translateX(-100%);
    transition: transform var(--duration-base) var(--ease-standard);
    box-shadow: var(--shadow-e4);
  }
  [dir='rtl'] .ad-sidebar {
    transform: translateX(100%);
  }
  .ad-shell.is-sidebar-open .ad-sidebar {
    transform: none;
  }
  .ad-content {
    padding: var(--space-4);
  }
}

/* --------------------------------------------------------------------------
   18. Commerce: product card v2, pricing, rating, grid
   -------------------------------------------------------------------------- */

/* --------------------------------------------------------------------------
   1. Product card v2
   Anatomy is a FIXED source order — media, badge, shop, name, rating, price,
   stock — so an Arabic card and an English card clamp at the same places and a
   mixed-locale screenshot diff stays readable.
   -------------------------------------------------------------------------- */
.product-card {
  position: relative;
  /* Contains the stretched-link ::after below so its z-index cannot escape the
     card and paint over a sticky header or the badge of the next card. */
  isolation: isolate;
  display: flex;
  flex-direction: column;
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-e1);
  overflow: hidden;
  color: var(--text-primary);
  /* Grid items default to min-inline-size:auto, so one unbroken model number
     ("SKU-AJW-1000-XLPACK") stretched its track and dragged every sibling
     column wider. The grid also uses minmax(0,1fr); belt and braces. */
  min-inline-size: 0;
}

/* Media: always 1:1. A fixed-height slot (the old 8rem) means a portrait photo
   and a landscape photo produce different card heights in the same row. */
.product-card__media {
  position: relative;
  aspect-ratio: 1 / 1;
  overflow: hidden;
  background: var(--bg-media);
  /* Block-start corners only — the media sits flush against the body below it. */
  border-start-start-radius: var(--radius-media);
  border-start-end-radius: var(--radius-media);
}
.product-card__media > img {
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
}
/* Letterbox instead of behead. Use when the photo's intrinsic ratio deviates
   more than ~25% from square: `cover` on a 3:1 banner-shaped shot crops the
   product out entirely, and on a tall bottle it cuts the label off. The
   --bg-media field is the letterbox, which is why that token is not white. */
.product-card__media--contain > img {
  object-fit: contain;
  padding: var(--space-3);
}

/* No image yet: a shop-initial monogram on the shop's own subtle accent.
   Deliberately NOT the generic box glyph — that icon is already three storefront
   nav items, the cart empty state, the 404 state and the search empty state, so a
   grid of box glyphs reads as "everything is broken" rather than "no photo yet".
   Mark the monogram aria-hidden; the shop name is already in the card text. */
.product-card__media--empty {
  display: grid;
  place-items: center;
  background: var(--shop-accent-subtle);
}
.product-card__monogram {
  font-size: var(--font-size-h1);
  font-weight: var(--font-weight-bold);
  line-height: 1;
  color: var(--shop-accent);
  /* An initial is a single glyph run of unknown script — isolate it so a Latin
     "T" inside an Arabic card cannot reorder against neighbouring punctuation. */
  unicode-bidi: isolate;
}

/* Absolutely positioned so it takes NO flow space: a card with a sale badge and
   a card without must be exactly the same height in the same row. */
.product-card__badge {
  position: absolute;
  inset-block-start: var(--space-2);
  inset-inline-start: var(--space-2);
  z-index: 1;
}

.product-card__body {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  padding: var(--space-3);
  flex: 1;
  min-inline-size: 0;
}

/* Mall surfaces only (search, mall home, category). On a shop's own storefront
   every card is that shop, so the line is pure noise. */
.product-card__shop {
  position: relative;
  z-index: 1; /* above the stretched-link overlay so the shop link stays clickable */
  font-size: var(--font-size-micro);
  color: var(--text-muted);
  text-align: start;
  unicode-bidi: plaintext;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  /* An inline text link is exempt from the 44px target floor (WCAG 2.5.8's
     "inline" exception) — growing it to 44px would add ~27px to every card on
     the surface where space is scarcest. The negative margin buys a little slop
     without changing the card's block size. */
  padding-block: var(--space-0-5);
  margin-block: calc(var(--space-0-5) * -1);
}
.product-card__shop:hover {
  color: var(--text-brand);
}

.product-card__name {
  font-size: var(--font-size-card-title);
  font-weight: var(--font-weight-card-title);
  line-height: var(--line-height-snug);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  overflow-wrap: anywhere;
  /* `plaintext` resolves EACH title's direction from its own first strong
     character, and `start` then aligns to that resolved direction. Without it a
     Latin brand name ("Anker PowerCore 20000") inside the Arabic grid is laid
     out RTL — the trailing model number jumps to the front of the line — and an
     Arabic title inside the English grid does the mirror. This is the one place
     in the card where direction is decided per-item rather than per-page. */
  unicode-bidi: plaintext;
  text-align: start;
  /* `2lh` is exactly two lines of THIS element's line box. The old card used
     `min-height: 2.6em`, wrong twice: em is the font size, not the line box, and
     the Arabic type step raises --line-height-snug to 1.5 while Latin stays 1.35,
     so no single em value can be right for both scripts. The calc() is the same
     computation for engines without `lh`, derived from the same token, so the two
     cannot drift apart. */
  min-block-size: calc(2 * var(--line-height-snug) * 1em);
  min-block-size: 2lh;
}

/* Reserved ALWAYS, even for an unrated product. Ratings arrive per-item from the
   review aggregate; without the reservation the price lines of a mixed row sit at
   four different heights and the grid visibly reflows as data lands. */
.product-card__rating {
  display: flex;
  align-items: center;
  min-block-size: 1.25rem;
}

/* Pins the price to the foot of the card so a 1-line title and a 2-line title
   still align their prices. Markup may also put `.price` on this same element
   (class="product-card__price-row price") — both shapes are supported. */
.product-card__price-row {
  margin-block-start: auto;
  padding-block-start: var(--space-1);
}

.product-card__stock {
  align-self: start;
} /* pill visuals live in §6 */

/* Compact variant for the PDP "more from this shop" rail. */
.product-card--compact .product-card__body {
  padding: var(--space-2);
}
.product-card--compact .product-card__monogram {
  font-size: var(--font-size-h3);
}
/* The rail already carries the heading "المزيد من هذا المتجر" — repeating the
   shop name on all six cards below it is redundant. */
.product-card--compact .product-card__shop {
  display: none;
}

/* Stretched link: the card is not an <a>, because a card that wraps everything
   cannot also contain the shop link (nested anchors are invalid and Safari
   silently drops the inner one). The name link covers the card instead. */
.product-card__link,
.shop-card__link {
  color: inherit;
  text-decoration: none;
}
.product-card__link::after,
.shop-card__link::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
}
/* Put the ring on the CARD, not on the two-line name fragment. The `:has()`
   selector is the whole rule's guard: engines without it drop the rule and the
   link keeps its own default ring, so focus is never lost. */
.product-card:has(.product-card__link:focus-visible),
.shop-card:has(.shop-card__link:focus-visible) {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}
.product-card:has(.product-card__link:focus-visible) .product-card__link,
.shop-card:has(.shop-card__link:focus-visible) .shop-card__link {
  outline: none;
}

/* Hover lift. Guarded twice: `hover: hover` keeps a touch device from latching a
   sticky hover shadow after a tap, and the transition only exists when motion is
   welcome. The Phase-1 block applied both unconditionally — the first line here
   neutralises it for coarse pointers, which is the whole point of the guard. */
.product-card:hover {
  box-shadow: var(--shadow-e1);
  transform: none;
}
@media (prefers-reduced-motion: no-preference) {
  .product-card {
    transition:
      box-shadow var(--duration-fast) var(--ease-standard),
      transform var(--duration-fast) var(--ease-standard);
  }
}
@media (hover: hover) {
  .product-card:hover {
    box-shadow: var(--shadow-e3);
    /* -2px, expressed through the spacing scale rather than a raw px literal. */
    transform: translateY(calc(var(--space-0-5) * -1));
  }
}
@media (prefers-reduced-motion: reduce) {
  .product-card:hover {
    transform: none;
  }
}

/* --------------------------------------------------------------------------
   2. Price block
   SOURCE ORDER IS THE FEATURE: current price FIRST, compare-at SECOND, discount
   badge THIRD. In a logical flex row the first item sits at the inline-start
   edge, which is where an Arabic reader's eye lands first — so the payable
   figure is read before the struck-out one, in both scripts, with no `order:`
   hack. Never reorder these with `order:`: the accessibility tree follows DOM
   order, so a screen reader would announce the price the customer does NOT pay.
   -------------------------------------------------------------------------- */
.price {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-1) var(--space-2);
  /* Reserved slot. A card WITHOUT a compare-at price must occupy the same block
     size as one WITH it, or a mixed grid loses its baselines and jumps as prices
     resolve. Two lines are reserved because at the 2-up mobile width the
     compare-at and the discount badge wrap below the current price. The calc is
     built from the same tokens the children use, including --line-height-snug,
     so it follows the Arabic type step automatically. */
  min-block-size: calc(
    var(--font-size-body-lg) * var(--line-height-snug) + var(--font-size-caption) *
      var(--line-height-normal) + var(--space-1)
  );
}

.price__current {
  font-size: var(--font-size-body-lg);
  font-weight: var(--font-weight-bold);
  line-height: var(--line-height-snug);
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
  /* MONEY IS NEVER FORCED TO LTR. formatMoney emits "ر.س 45.00" — symbol first
     LOGICALLY — which renders correctly inside an RTL run. Forcing direction:ltr
     flips it to "45.00 ر.س" visually, so the Arabic reader meets the number
     before the currency. `isolate` alone is what we want: it seals the money run
     off from neighbouring neutrals (brackets, dashes, "من") without touching the
     order inside it. */
  unicode-bidi: isolate;
}

/* Rendered on an <s>. The strike must not be the only signal — markup carries a
   .visually-hidden "السعر قبل الخصم" label, because a line-through is invisible
   to a screen reader and `text-decoration` alone is a colour/decoration-only cue.
   (line-through does survive forced-colors, which is why it is the base signal
   rather than a grey tint.) */
.price__compare {
  font-size: var(--font-size-caption);
  line-height: var(--line-height-normal);
  color: var(--text-muted);
  text-decoration: line-through;
  font-variant-numeric: tabular-nums;
  unicode-bidi: isolate; /* same reason as .price__current — isolate, never ltr */
}

/* NOT .badge--danger. Red in this system means error or destructive action; a
   discount is neither, and reusing the danger pill trains customers to read the
   colour that also means "payment failed" as good news. This is its own solid
   treatment because the cream gold pill was illegible over arbitrary product
   photography. */
.price__discount-badge {
  display: inline-flex;
  align-items: center;
  block-size: 1.5rem;
  padding-inline: var(--space-2);
  border-radius: var(--radius-full);
  background: var(--badge-sale-bg);
  color: var(--badge-sale-fg);
  font-size: var(--font-size-caption);
  font-weight: var(--font-weight-bold);
  line-height: 1;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  /* "-27%" is a BARE NUMERAL RUN, not money: no currency symbol, so there is no
     symbol/number order to preserve, and a leading minus in an RTL run migrates
     to the wrong end of the digits. Numerals, SKUs, phones and order ids get the
     LTR island; prices never do. */
  direction: ltr;
  unicode-bidi: isolate;
}

/* PDP ONLY. Cards must NOT use this: the card price slot has room for exactly
   one supporting line, and that line is legally required to be the VAT label. */
.price__save {
  flex-basis: 100%; /* always starts its own line inside the same flex container */
  font-size: var(--font-size-caption);
  font-weight: var(--font-weight-medium);
  color: var(--text-success);
}

.price__vat-note {
  flex-basis: 100%;
  font-size: var(--font-size-micro);
  font-weight: var(--font-weight-regular);
  line-height: var(--line-height-normal);
  color: var(--text-muted);
}

/* PDP buy box. The reserve is recomputed at the larger step for the same reason. */
.price--lg {
  min-block-size: calc(
    var(--font-size-h2) * var(--line-height-tight) + var(--font-size-body-sm) *
      var(--line-height-normal) + var(--space-1)
  );
}
.price--lg .price__current {
  font-size: var(--font-size-h2);
  line-height: var(--line-height-tight);
}
.price--lg .price__compare {
  font-size: var(--font-size-body-sm);
}

/* Compat + repair for the Phase-1 money classes still present in the storefront
   markup, where every price is wrapped in <bdi dir="ltr">. An author rule beats
   the UA's [dir] rule regardless of specificity, so this restores the correct
   Arabic rendering immediately — but the markup must still drop the attribute,
   because `dir="ltr"` also lies to assistive tech about the run's language. */
.product-card__price,
.product-card__price--list,
.variant-row__price,
.variant-row__list-price {
  direction: inherit;
  unicode-bidi: isolate;
}
.product-card__price {
  font-size: var(--font-size-body-lg);
  font-weight: var(--font-weight-bold);
  color: var(--text-primary);
}
.product-card__price--list {
  font-size: var(--font-size-caption);
  color: var(--text-muted);
  text-decoration: line-through;
}

/* --------------------------------------------------------------------------
   3. Sale badge — a modifier on the existing .badge
   -------------------------------------------------------------------------- */
.badge--sale {
  background: var(--badge-sale-bg);
  color: var(--badge-sale-fg);
  font-weight: var(--font-weight-bold);
}
@media (forced-colors: active) {
  /* Backgrounds collapse to the system palette, so a solid pill loses its shape
     against the card. The border is what keeps it readable as a badge. */
  .badge--sale,
  .price__discount-badge {
    border: 1px solid CanvasText;
  }
}

/* --------------------------------------------------------------------------
   4. Rating stars
   -------------------------------------------------------------------------- */
.rating {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
}
/* Plain logical flex, no explicit direction: a rating is a METER, and a meter
   fills in reading direction. Under RTL the row therefore fills from the
   inline-start (right) edge for free. An LTR-pinned star row inside an Arabic
   page reads as an empty rating with a stray filled star at the far end. */
.rating__stars {
  display: inline-flex;
  align-items: center;
  gap: var(--space-0-5);
  flex-shrink: 0;
  font-size: var(--font-size-body); /* the 1em box the star glyphs size against */
}
.rating__star {
  position: relative;
  display: inline-block;
  inline-size: 1em;
  block-size: 1em;
  flex-shrink: 0;
}
.rating__star-glyph {
  display: block;
  inline-size: 100%;
  block-size: 100%;
  stroke-width: 2;
  stroke-linejoin: round;
}
/* The on/off distinction is FILL, not hue: a solid star vs an outlined star.
   Two shades of gold read as one continuous smear in greyscale, at 14px on a
   phone, and identically in forced-colors — the shape difference survives all
   three. */
.rating__star--on .rating__star-glyph {
  fill: var(--rating-star-on);
  stroke: none;
}
.rating__star--off .rating__star-glyph,
.rating__star--half .rating__star-glyph {
  fill: none;
  stroke: var(--rating-star-off);
}

/* Half star: an outline glyph with a solid copy clipped to the inline-start 50%.
   The clip is logical, so a 4.5 rating fills the right half of the third star in
   Arabic and the left half in English, both times "half filled from the start". */
.rating__star-fill {
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  inline-size: 50%;
  overflow: hidden;
}
.rating__star-fill .rating__star-glyph {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  /* Full size and pinned: the parent's overflow does the CLIPPING. Sizing the
     glyph to 100% of the half-width container would squash the star instead. */
  inline-size: 1em;
  /* …and §1's `img, svg { max-width: 100% }` reset is exactly that squash, applied
     from the other direction: it clamps this copy to its 50%-wide clipping parent,
     so `inline-size: 1em` above never wins and EVERY half star renders as a
     miniature star inside a full-size outline. Measured 7px inside a 14px glyph
     before this line. Two screens hit it independently and patched it page-locally. */
  max-inline-size: none;
  block-size: 1em;
  fill: var(--rating-star-on);
  stroke: none;
}

/* A bare "4.5" or "128" IS an LTR island — unlike money it carries no currency
   symbol whose order matters, and some engines reorder a lone decimal run
   against adjacent Arabic parentheses. dir="ltr" is correct here. */
.rating__value,
.rating__count {
  direction: ltr;
  unicode-bidi: isolate;
  font-variant-numeric: tabular-nums;
}
.rating__value {
  font-weight: var(--font-weight-semibold);
  color: var(--text-primary);
}
.rating__count {
  font-size: var(--font-size-caption);
  color: var(--text-muted);
}

/* When the rating links to the reviews section (PDP). Visual size stays at the
   type's height so the buy box keeps its rhythm; the HIT area is expanded to the
   44px floor with an overlay instead of by growing the box. */
.rating__link {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 3px;
}
.rating__link::after {
  content: '';
  position: absolute;
  inset-inline: 0;
  inset-block-start: 50%;
  block-size: var(--tap-target-min);
  min-inline-size: var(--tap-target-min);
  transform: translateY(-50%);
}

.rating--sm .rating__stars {
  font-size: var(--font-size-body-sm);
} /* 14px, card */
.rating--sm .rating__count {
  font-size: var(--font-size-micro);
}
.rating--lg .rating__stars {
  font-size: var(--font-size-h4);
} /* 20px, PDP */
.rating--lg .rating__value {
  font-size: var(--font-size-body-lg);
}

@media (forced-colors: active) {
  .rating__star--on .rating__star-glyph,
  .rating__star-fill .rating__star-glyph {
    fill: CanvasText;
    stroke: CanvasText;
  }
  .rating__star--off .rating__star-glyph,
  .rating__star--half .rating__star-glyph {
    fill: none;
    stroke: CanvasText;
  }
}

/* --------------------------------------------------------------------------
   5. Rating histogram
   -------------------------------------------------------------------------- */
.rating-histogram {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}
.rating-histogram__row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--space-2);
  inline-size: 100%;
  text-align: start;
}
/* The five bars must START on a common line or the histogram reads as noise
   rather than a distribution. Each row is its own grid, so the shared alignment
   comes from a floor on the label and count columns, not from subgrid. */
.rating-histogram__label {
  min-inline-size: 3.5rem;
  font-size: var(--font-size-caption);
  color: var(--text-secondary);
  white-space: nowrap;
}
.rating-histogram__count {
  min-inline-size: 2.75rem;
  text-align: end;
  font-size: var(--font-size-caption);
  color: var(--text-muted);
  direction: ltr; /* bare count — an LTR island, same rule as .rating__count */
  unicode-bidi: isolate;
  font-variant-numeric: tabular-nums;
}
.rating-histogram__bar {
  position: relative;
  block-size: var(--space-2);
  border-radius: var(--radius-full);
  background: var(--bg-surface-sunken);
  overflow: hidden;
}
/* Grows from the INLINE-START edge. A bar anchored left inside an RTL page is
   read as a different value — the eye starts at the right, sees empty track, and
   scores the row as low. `inline-size` is set inline by the markup (as
   .progress__bar already does with width). */
.rating-histogram__fill {
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  inline-size: 0;
  background: var(--rating-star-on);
  border-radius: var(--radius-full);
}
@media (prefers-reduced-motion: no-preference) {
  /* Animating inline-size rather than scaleX on purpose: transform-origin has no
     logical keyword, so `transform-origin: left` would grow an RTL bar from the
     wrong edge and there is no direction-agnostic way to express the other one. */
  .rating-histogram__fill {
    transition: inline-size var(--duration-slow) var(--ease-standard);
  }
}
/* Rows double as review filters on the PDP; only then do they need the 44px
   floor — applying it to a static histogram would make it 220px tall. */
.rating-histogram--interactive .rating-histogram__row {
  min-block-size: var(--tap-target-min);
}
.rating-histogram--interactive .rating-histogram__row:hover .rating-histogram__bar {
  background: var(--bg-brand-subtle);
}
.rating-histogram--interactive
  .rating-histogram__row[aria-pressed='true']
  .rating-histogram__label {
  font-weight: var(--font-weight-semibold);
  color: var(--text-primary);
}

@media (forced-colors: active) {
  /* Both backgrounds collapse to Canvas, which would erase the bar entirely. */
  .rating-histogram__bar {
    border: 1px solid CanvasText;
  }
  .rating-histogram__fill {
    background: Highlight;
  }
}

/* --------------------------------------------------------------------------
   6. Stock pill
   The LABEL is the signal ("متوفر" / "غير متوفر"); the dot and the tint are
   redundant reinforcement, which is what keeps this out of 1.4.1 colour-alone.
   -------------------------------------------------------------------------- */
.stock-pill,
.product-card__stock {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  block-size: 1.5rem;
  padding-inline: var(--space-2);
  border-radius: var(--radius-full);
  font-size: var(--font-size-caption);
  font-weight: var(--font-weight-medium);
  line-height: 1;
  white-space: nowrap;
}
.stock-pill__dot {
  inline-size: 0.4rem;
  block-size: 0.4rem;
  border-radius: 50%;
  background: currentColor;
  flex-shrink: 0;
}
.stock-pill--in,
.product-card__stock--in {
  background: var(--bg-success-subtle);
  color: var(--text-success);
}
.stock-pill--low {
  background: var(--bg-warning-subtle);
  color: var(--text-warning);
}
.stock-pill--out,
.product-card__stock--out {
  background: var(--bg-surface-sunken);
  color: var(--text-muted);
}

@media (forced-colors: active) {
  .stock-pill,
  .product-card__stock {
    border: 1px solid CanvasText;
  }
  /* All three dots force to CanvasText, so hollow the non-available ones — the
     same fill-not-hue trick the stars use. */
  .stock-pill--low .stock-pill__dot,
  .stock-pill--out .stock-pill__dot,
  .product-card__stock--out .stock-pill__dot {
    background: Canvas;
    border: 1px solid CanvasText;
  }
}

/* --------------------------------------------------------------------------
   7. Product grid
   EXPLICIT TRACKS, never auto-fill. `repeat(auto-fill, minmax(13rem, 1fr))`
   produced a different column count at every viewport width, so a 24-result page
   routinely ended in a half-empty row of one or two orphan cards, and the card
   width drifted continuously instead of landing on a designed size.
   Breakpoints are card-width-driven (640/1024/1440), not the layout breakpoint
   tokens — a card stops being legible below ~150px, which is what sets 640.
   -------------------------------------------------------------------------- */
.product-grid {
  display: grid;
  /* THE 2-UP MOBILE GRID IS THE SINGLE BIGGEST MOBILE FIX in this phase. One card
     per row meant a customer saw one and a half products per screen and had to
     scroll six times to compare three; every competing Saudi marketplace is 2-up.
     minmax(0,1fr) rather than 1fr so a long unbroken SKU cannot widen a track. */
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--grid-gap-card);
}
@media (min-width: 640px) {
  .product-grid {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    --grid-gap-card: var(--space-4);
  }
}
@media (min-width: 1024px) {
  .product-grid {
    grid-template-columns: repeat(4, minmax(0, 1fr));
    --grid-gap-card: var(--space-5);
  }
}
@media (min-width: 1440px) {
  .product-grid {
    grid-template-columns: repeat(5, minmax(0, 1fr));
    --grid-gap-card: var(--space-6);
  }
}
/* Placed AFTER the base breakpoints so it wins at every width from 1024 up: with
   a 17rem facet rail eating the inline space, a fifth column at 1440 leaves each
   card under 190px and the two-line title clamps mid-word. */
@media (min-width: 1024px) {
  .product-grid--with-rail {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }
}

/* --------------------------------------------------------------------------
   8. Skeleton card
   DIMENSIONALLY IDENTICAL to .product-card — same 1:1 media, same body padding
   and gap, a 2lh name block, the reserved rating row and the real price reserve.
   The old placeholder was a fixed 8rem block inside a .card while the result was
   a 1:1 .product-card, so the entire results grid jumped on every single load.
   Any change to §1's geometry must be mirrored here.
   -------------------------------------------------------------------------- */
.skeleton-card {
  display: flex;
  flex-direction: column;
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-e1);
  overflow: hidden;
}
.skeleton-card__media {
  aspect-ratio: 1 / 1;
  border-start-start-radius: var(--radius-media);
  border-start-end-radius: var(--radius-media);
}
.skeleton-card__body {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  padding: var(--space-3);
  flex: 1;
}
.skeleton-card__shop {
  inline-size: 45%;
}
.skeleton-card__name {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: var(--space-1);
  font-size: var(--font-size-card-title);
  line-height: var(--line-height-snug);
  min-block-size: calc(2 * var(--line-height-snug) * 1em); /* `lh` fallback, see §1 */
  min-block-size: 2lh;
}
.skeleton-card__name > .skeleton:last-child {
  inline-size: 62%;
}
.skeleton-card__rating {
  display: flex;
  align-items: center;
  min-block-size: 1.25rem;
}
.skeleton-card__rating > .skeleton {
  inline-size: 6rem;
}
.skeleton-card__price {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  margin-block-start: auto;
  padding-block-start: var(--space-1);
  min-block-size: calc(
    var(--font-size-body-lg) * var(--line-height-snug) + var(--font-size-caption) *
      var(--line-height-normal) + var(--space-1)
  );
}
/* The price bar must be the real weight, not a hairline: a thin grey line where a
   bold 18px price is about to appear is the most visible part of the jump. */
.skeleton-card__price > .skeleton:first-child {
  inline-size: 55%;
  block-size: calc(var(--font-size-body-lg) * var(--line-height-snug));
}

@media (prefers-reduced-motion: reduce) {
  /* components.css runs `sz-skeleton` as an INFINITE shimmer with no motion guard
     at all — on a slow connection that is a perpetual 1.4s sweep across a full
     grid of cards, which is exactly the pattern vestibular guidance asks us to
     stop. Freeze it flat; the placeholder still says "loading" through its shape. */
  .skeleton {
    animation: none;
    background: var(--bg-surface-sunken);
  }
}
@media (forced-colors: active) {
  /* Gradients are dropped, leaving invisible placeholders. Outline them instead. */
  .skeleton {
    background: Canvas;
    border: 1px solid GrayText;
  }
}

/* --------------------------------------------------------------------------
   9. Category tile
   -------------------------------------------------------------------------- */
.category-tile {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  aspect-ratio: 1 / 1;
  min-block-size: var(--tap-target-min);
  min-inline-size: var(--tap-target-min);
  padding: var(--space-3);
  border-radius: var(--radius-media);
  background: var(--bg-brand-subtle);
  color: var(--text-brand);
  text-align: center;
}
.category-tile__glyph {
  inline-size: 2rem;
  block-size: 2rem;
  flex-shrink: 0;
}
.category-tile__label {
  font-size: var(--font-size-card-title);
  font-weight: var(--font-weight-card-title);
  line-height: var(--line-height-snug);
  color: var(--text-primary);
  /* Arabic category names are short but multi-word ("جمال وعناية"); let them wrap
     to two lines inside the square rather than clip like the fixed-height .chip. */
  overflow-wrap: anywhere;
}
.category-tile__count {
  font-size: var(--font-size-micro);
  color: var(--text-muted);
  direction: ltr; /* bare count, LTR island */
  unicode-bidi: isolate;
  font-variant-numeric: tabular-nums;
}
/* Inside a themed shop region the tile picks up that shop's accent instead of
   platform brand — the accent allow-list already covers tinted surfaces. */
.category-tile--accent {
  background: var(--shop-accent-subtle);
  color: var(--shop-accent);
}

@media (prefers-reduced-motion: no-preference) {
  .category-tile {
    transition: background-color var(--duration-fast) var(--ease-standard);
  }
}
@media (hover: hover) {
  .category-tile:hover {
    background: var(--bg-brand-subtle-hover);
  }
}
@media (forced-colors: active) {
  .category-tile {
    border: 1px solid CanvasText;
  }
}

/* --------------------------------------------------------------------------
   10. Shop card (Mall home + /sellers directory)
   -------------------------------------------------------------------------- */
.shop-card {
  position: relative;
  isolation: isolate;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  grid-template-areas:
    'logo heading'
    'logo city'
    'tags tags'
    'cta  cta';
  align-items: start;
  column-gap: var(--space-3);
  row-gap: var(--space-1);
  padding: var(--space-4);
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-e1);
}
.shop-card__logo {
  grid-area: logo;
  align-self: center;
  inline-size: 3rem;
  block-size: 3rem;
  border-radius: var(--radius-md);
  object-fit: contain;
  background: var(--bg-media);
}
/* Same reasoning as the product card's empty media: an accent monogram, never a
   generic placeholder glyph repeated down a directory of forty shops. */
.shop-card__monogram {
  grid-area: logo;
  align-self: center;
  display: grid;
  place-items: center;
  inline-size: 3rem;
  block-size: 3rem;
  border-radius: var(--radius-md);
  background: var(--shop-accent-subtle);
  color: var(--shop-accent);
  font-size: var(--font-size-h4);
  font-weight: var(--font-weight-bold);
  line-height: 1;
  unicode-bidi: isolate;
}
.shop-card__heading {
  grid-area: heading;
  display: flex;
  align-items: center;
  gap: var(--space-1);
  min-inline-size: 0;
}
.shop-card__name {
  font-size: var(--font-size-body);
  font-weight: var(--font-weight-semibold);
  /* Shop names are user-supplied and mix scripts freely ("Tomorrow Tech تقنية"). */
  unicode-bidi: plaintext;
  text-align: start;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Renders as .badge.badge--brand with a check icon. The icon and its
   .visually-hidden "متجر موثّق" text are required — a colour-only tint next to a
   shop name is unreadable as "verified" in greyscale or forced-colors. */
.shop-card__verified {
  flex-shrink: 0;
}
.shop-card__city {
  grid-area: city;
  font-size: var(--font-size-caption);
  color: var(--text-muted);
}
.shop-card__tags {
  grid-area: tags;
  display: flex;
  gap: var(--space-1);
  margin-block-start: var(--space-2);
  min-inline-size: 0;
  overflow: hidden;
}
/* Hard cap at three. The API returns every category a shop sells; a fourth tag
   wraps the row and the directory's cards stop sharing a height, which is the
   whole point of a directory grid. */
.shop-card__tags > :nth-child(n + 4) {
  display: none;
}
.shop-card__cta {
  grid-area: cta;
  position: relative;
  z-index: 1; /* above the stretched-link overlay */
  margin-block-start: var(--space-3);
  min-block-size: var(--tap-target-min);
  min-inline-size: var(--tap-target-min);
}

@media (prefers-reduced-motion: no-preference) {
  .shop-card {
    transition: box-shadow var(--duration-fast) var(--ease-standard);
  }
}
@media (hover: hover) {
  .shop-card:hover {
    box-shadow: var(--shadow-e3);
  }
}

/* --------------------------------------------------------------------------
   19. Commerce: facets, sort, breadcrumb, sheets, empty states
   -------------------------------------------------------------------------- */

/* --------------------------------------------------------------------------
   1. Search shell + facet rail
   -------------------------------------------------------------------------- */
/* Replaces the boxed 4-across .filter-form whose full-bleed submit button was the
   heaviest element on the search page — a card of empty inputs sat above the
   results and pushed the actual products below the fold, and nothing on screen
   ever showed what was already filtered.
   Arithmetic (storefront --container-max-width: 1280px): 1280 - 2×--space-8
   padding = 1216, minus the 17rem rail, minus the --space-6 gap = 920px of grid,
   which is exactly a 5-up product row at the --grid-gap-card step. */
.search-shell {
  display: grid;
  grid-template-columns: 17rem minmax(0, 1fr);
  gap: var(--space-6);
  align-items: start;
}
/* minmax(0,1fr) above and min-inline-size:0 here for the same reason the PDP
   layout needs it: a grid child defaults to min-width:auto, so one wide card
   name would blow the track out past the viewport instead of ellipsing. */
.search-shell > * {
  min-inline-size: 0;
}

@media (max-width: 1023px) {
  .search-shell {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-4);
  }
}

.facet-rail {
  position: sticky;
  inset-block-start: calc(var(--header-h) + var(--space-4));
  /* dvh, never vh: on mobile Safari vh measures against the COLLAPSED toolbar,
     so the rail would be taller than the visible viewport and its last facet
     group unreachable. */
  max-block-size: calc(100dvh - var(--header-h) - var(--space-8));
  overflow-y: auto;
  /* Without this, flicking past the end of the rail chains the scroll to the
     document and throws the results grid off screen. */
  overscroll-behavior: contain;
  scrollbar-width: thin;
  /* The inline padding is not decoration: focus-visible draws at
     outline-offset 2px, and an outline on the first/last control would be
     clipped by the scroll container's own edge without it. */
  padding-inline: var(--space-1);
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-lg);
}

@media (max-width: 1023px) {
  /* One column: a sticky rail here would pin filters over the results instead of
     beside them. Screens that move filters into .bottom-sheet add
     .facet-rail--sheet-only; the rail is NOT hidden by default, because on a
     screen without the sheet that would delete the only copy of the filters. */
  .facet-rail {
    position: static;
    max-block-size: none;
    overflow: visible;
  }
  .facet-rail--sheet-only {
    display: none;
  }
}

.facet-rail__header {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-block-size: var(--tap-target-min);
  padding: var(--space-2) var(--space-3);
  border-block-end: 1px solid var(--border-default);
}
.facet-rail__title {
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-semibold);
}
.facet-rail__clear {
  margin-inline-start: auto;
  min-block-size: var(--tap-target-min);
  min-inline-size: var(--tap-target-min);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding-inline: var(--space-2);
  font-size: var(--font-size-caption);
  color: var(--text-link);
  text-decoration: underline;
  text-underline-offset: 3px;
}

.facet-group {
  border-block-end: 1px solid var(--border-default);
}
.facet-group:last-child {
  border-block-end: 0;
}

.facet-group__summary {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-block-size: var(--tap-target-min);
  padding-inline: var(--space-3);
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-semibold);
  color: var(--text-primary);
  cursor: pointer;
  /* Both spellings: list-style covers Firefox/Chromium, the -webkit marker
     pseudo covers Safari, which still paints its own triangle otherwise —
     and that triangle does NOT mirror in RTL. */
  list-style: none;
}
.facet-group__summary::-webkit-details-marker {
  display: none;
}
.facet-group__summary:hover {
  background: var(--bg-surface-hover);
}

/* Uses the .icon--end mirroring convention (components.css §4) so the closed
   chevron points inline-FORWARD in both locales. The open state composes the
   rotation ON TOP of the RTL flip instead of replacing transform, which is what
   silently unmirrors an icon: `transform: rotate(90deg)` alone would overwrite
   `[dir="rtl"] .icon--end { transform: scaleX(-1) }`. */
.facet-group__chevron {
  margin-inline-start: auto;
  color: var(--text-muted);
}
.facet-group[open] .facet-group__chevron.icon--end {
  transform: rotate(90deg);
}
[dir='rtl'] .facet-group[open] .facet-group__chevron.icon--end {
  transform: scaleX(-1) rotate(90deg);
}

@media (prefers-reduced-motion: no-preference) {
  .facet-group__chevron {
    transition: transform var(--duration-fast) var(--ease-standard);
  }
}

.facet-group__body {
  display: flex;
  flex-direction: column;
  padding: var(--space-1) var(--space-2) var(--space-3);
}

/* A long facet list (brands, cities) is capped and revealed by this toggle
   rather than making the rail scroll twice. */
.facet-group__more {
  align-self: start;
  min-block-size: var(--tap-target-min);
  display: inline-flex;
  align-items: center;
  padding-inline: var(--space-2);
  font-size: var(--font-size-caption);
  color: var(--text-link);
}

/* Pair with .checkbox on the <label>: components.css draws the 20px box, this
   turns the row into the target. The BOX stays 20px on purpose — the whole
   44px row is clickable because it is the label, so there is no undersized
   hit area to compensate for. */
.facet-option {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-block-size: var(--tap-target-min);
  padding-inline: var(--space-2);
  border-radius: var(--radius-sm);
  cursor: pointer;
}
.facet-option:hover {
  background: var(--bg-surface-hover);
}
.facet-option__label {
  min-inline-size: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Bare numeral, not money: a count has no currency symbol to invert, so LTR
   embedding is correct here. (Prices elsewhere in this file deliberately do
   NOT get this treatment — see .sticky-cta__price.) */
.facet-option__count {
  margin-inline-start: auto;
  flex-shrink: 0;
  /* Reserved slot: the count arrives with the facet payload. Without a floor the
     labels re-align the moment counts land. */
  min-inline-size: 2.5ch;
  text-align: end;
  direction: ltr;
  unicode-bidi: isolate;
  font-size: var(--font-size-caption);
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
}
/* Selected state is carried by WEIGHT as well as the checkbox fill, so it
   survives greyscale, forced-colors and a colour-blind reader. */
.facet-option:has(input:checked) {
  font-weight: var(--font-weight-medium);
}
.facet-option.is-empty {
  color: var(--text-disabled);
  pointer-events: none;
}

.facet-price {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}
.facet-price__field {
  flex: 1 1 0;
  min-inline-size: 0;
}
.facet-price__label {
  display: block;
  font-size: var(--font-size-caption);
  color: var(--text-secondary);
}
/* The INPUT gets dir="ltr" in the markup because its value is a bare numeral
   being typed — not money. A price *display* must never be forced to LTR
   (formatMoney emits "ر.س 45.00", symbol first logically), but an empty numeric
   entry field has no symbol and reads wrong RTL. Labels stay Arabic-direction. */
.facet-price__input {
  min-block-size: var(--tap-target-min);
  text-align: start;
  font-variant-numeric: tabular-nums;
}
.facet-price__sep {
  flex-shrink: 0;
  color: var(--text-muted);
}
.facet-price__apply {
  min-block-size: var(--tap-target-min);
}

/* --------------------------------------------------------------------------
   2. Active filter chips
   -------------------------------------------------------------------------- */
/* The single most-missed affordance in the current design: filters were applied
   inside a form that then rendered nothing, so a shopper looking at 3 results
   had no way to see WHICH constraint emptied the page, and no way to drop one
   without hunting the form. Every applied facet gets a chip; every chip removes
   exactly one constraint; "مسح الكل" removes all of them. */
.filter-chips {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  /* Reserved slot — the row is populated from the query state after hydration.
     Without a floor the results grid jumps a row's height as chips arrive. */
  min-block-size: var(--tap-target-min);
  margin-block-end: var(--space-3);
}

/* Built on .chip / .chip__remove (§15). The nowrap + flex-shrink pair is what
   keeps a two-word Arabic facet ("منزل ومطبخ") on one line inside the fixed 2rem
   pill instead of wrapping and being clipped by its own height. */
.filter-chips__chip {
  position: relative;
  flex-shrink: 0;
  white-space: nowrap;
  scroll-snap-align: start;
  background: var(--bg-brand-subtle);
  border-color: var(--border-brand);
  color: var(--text-brand);
}
.filter-chips__chip:hover {
  background: var(--bg-brand-subtle-hover);
  border-color: var(--border-brand);
  color: var(--text-brand);
}
.filter-chips__chip:hover .chip__remove {
  opacity: 1;
}
/* .chip is a 2rem pill by design and must stay one. Expand the HIT AREA instead
   of the pill: --space-2 of bleed on each block edge takes a 32px control to a
   48px target (one step over the 44px floor — there is no 6px token). */
.filter-chips__chip::after {
  content: '';
  position: absolute;
  inset-inline: 0;
  inset-block: calc(-1 * var(--space-2));
}
.filter-chips__clear {
  flex-shrink: 0;
  min-block-size: var(--tap-target-min);
  display: inline-flex;
  align-items: center;
  padding-inline: var(--space-2);
  font-size: var(--font-size-body-sm);
  color: var(--text-link);
  text-decoration: underline;
  text-underline-offset: 3px;
}

@media (max-width: 767px) {
  /* One scrollable row rather than three wrapped ones — six applied facets must
     not push the first product off a 390px screen. Snap so a chip never rests
     half-cut, which is what makes a scroll row read as clipped content. */
  .filter-chips {
    flex-wrap: nowrap;
    overflow-x: auto;
    scroll-snap-type: inline mandatory;
    scrollbar-width: none;
    /* Bleed to the container edge so the next chip is visibly cut off — the
       signal that the row scrolls at all (same reasoning as .table-scroll §9). */
    margin-inline: calc(-1 * var(--space-4));
    padding-inline: var(--space-4);
  }
}

@media (forced-colors: active) {
  /* The subtle brand fill is dropped in forced-colors, so an applied chip would
     be indistinguishable from an unapplied category chip. */
  .filter-chips__chip {
    border-width: 2px;
  }
}

/* --------------------------------------------------------------------------
   3. Result header + sort select
   -------------------------------------------------------------------------- */
.result-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  flex-wrap: wrap;
  min-block-size: var(--tap-target-min);
  margin-block-end: var(--space-4);
}
.result-header__count {
  min-inline-size: 0;
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
}
/* LTR island around the numerals only — the surrounding "منتج" stays RTL.
   A count, not a price: no currency symbol is being re-ordered here. */
.result-header__num {
  direction: ltr;
  unicode-bidi: isolate;
  font-variant-numeric: tabular-nums;
  font-weight: var(--font-weight-semibold);
  color: var(--text-primary);
}
.result-header__controls {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-shrink: 0;
}

@media (max-width: 479px) {
  /* flex-basis, not width: `width` is ignored for a flex item's main size, which
     is the same defect that left .sf-header__search as a 35px stub at 390px. */
  .result-header__count,
  .result-header__controls {
    flex: 1 0 100%;
  }
  .result-header__controls {
    justify-content: space-between;
  }
}

/* Sort — built on .select-wrap + .input (§7); this only labels it and holds the
   target size. */
.sort-select {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}
.sort-select__label {
  flex-shrink: 0;
  white-space: nowrap;
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
}
.sort-select .select-wrap {
  flex: 1 1 auto;
}
.sort-select .select-wrap select {
  min-block-size: var(--tap-target-min);
  /* No width token exists; 11rem is the longest Arabic option ("الأكثر مبيعًا")
     plus the .select-wrap chevron gutter, so the control never resizes as the
     selection changes. */
  min-inline-size: 11rem;
}

/* --------------------------------------------------------------------------
   4. Facet-change busy state
   -------------------------------------------------------------------------- */
/* Deliberately NOT the skeleton treatment. First load has nothing to preserve,
   so skeletons are right there. A facet change has a complete, still-valid
   previous list — swapping it for skeletons makes the whole grid strobe on every
   chip click and destroys the shopper's scroll position and place in the list.
   Dim it, freeze it, keep it. */
.results {
  scroll-margin-block-start: calc(var(--header-h) + var(--space-2));
}
.results--busy,
.results[aria-busy='true'] {
  opacity: 0.6;
  pointer-events: none;
  cursor: progress;
}
@media (prefers-reduced-motion: no-preference) {
  .results {
    transition: opacity var(--duration-fast) var(--ease-standard);
  }
}
@media (forced-colors: active) {
  /* Opacity is not a reliable state signal once the OS forces the foreground
     colour — give the frozen region a visible edge instead. */
  .results--busy,
  .results[aria-busy='true'] {
    opacity: 1;
    outline: 2px dashed CanvasText;
    outline-offset: var(--space-2);
  }
}

/* --------------------------------------------------------------------------
   5. Breadcrumb — EXTENDS components.css §15
   The existing .breadcrumb / .breadcrumb__sep / .breadcrumb__current are left
   exactly as they are; this adds the <nav><ol> structure, the truncating last
   crumb and the mobile collapse they never had.
   -------------------------------------------------------------------------- */
.breadcrumb__list {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  /* nowrap + a truncating last item: a long Arabic product name used to wrap the
     whole trail onto a second line and shove the PDP title down. */
  flex-wrap: nowrap;
  max-inline-size: 100%;
}
.breadcrumb__item {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-inline-size: 0;
  flex-shrink: 0;
}
/* Only the last crumb gives way. Everything before it is short, structural and
   must stay legible. */
.breadcrumb__item:last-child {
  flex-shrink: 1;
}
.breadcrumb__item:last-child .breadcrumb__current {
  min-inline-size: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.breadcrumb__link {
  text-decoration: none;
}
.breadcrumb__link:hover {
  text-decoration: underline;
  text-underline-offset: 3px;
}
/* Adds the one property the §15 rule was missing — the separator was shrinking
   to a sliver once the trail ran long. Mirroring still comes from .icon--end. */
.breadcrumb__sep {
  flex-shrink: 0;
}

.breadcrumb__item--ellipsis {
  display: none;
}

@media (max-width: 767px) {
  /* Collapse to the last two crumbs behind a "…" toggle. NOT a horizontal
     scroller: the crumb a shopper needs (the parent category) is the one that
     would sit off-screen, and a scrolling trail gives no hint it exists. */
  .breadcrumb__item--ellipsis {
    display: flex;
  }
  .breadcrumb:not(.is-expanded)
    .breadcrumb__item:not(.breadcrumb__item--ellipsis):not(:nth-last-child(-n + 2)) {
    display: none;
  }
  /* The row is 44px on mobile and the links fill it, so every crumb is a real
     target without an overlay that could swallow taps meant for its neighbours. */
  .breadcrumb__list {
    min-block-size: var(--tap-target-min);
  }
  .breadcrumb__link,
  .breadcrumb__expand {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-block-size: var(--tap-target-min);
    min-inline-size: var(--tap-target-min);
    padding-inline: var(--space-1);
  }
}
.breadcrumb__expand {
  color: var(--text-muted);
  font-weight: var(--font-weight-semibold);
}
.breadcrumb__expand:hover {
  color: var(--text-primary);
}

/* --------------------------------------------------------------------------
   6. Bottom sheet
   Rides the EXISTING modal machinery (components.css §10 + prototype.js):
   markup is <div class="modal-scrim modal-scrim--sheet" id="…">
             <div class="modal bottom-sheet"> … </div></div>
   so [data-modal-open] / [data-modal-close] / ESC / backdrop click / scroll lock
   and the single focus trap in prototype.js all apply unchanged. No second trap.
   ::backdrop is styled too, so the same classes also work on a native <dialog>.
   -------------------------------------------------------------------------- */
.modal-scrim--sheet {
  align-items: flex-end;
  justify-content: center;
  padding: 0;
}

/* Compound selector on purpose: these five properties each override a .modal
   default, and .modal.bottom-sheet wins on specificity no matter which order the
   two stylesheets are concatenated in. */
.modal.bottom-sheet {
  inline-size: 100%;
  max-inline-size: none;
  /* Replaces .modal's max-height: calc(100vh - …). vh sizes against the
     collapsed mobile-Safari toolbar, which pushed the sticky action footer of
     this very component below the visible viewport. */
  max-block-size: 85dvh;
  border-radius: 0;
  border-start-start-radius: var(--radius-2xl);
  border-start-end-radius: var(--radius-2xl);
  /* .modal animates in with sz-modal-in unconditionally; kill it here so the
     reduced-motion default is genuinely motionless, then re-add the slide below
     only for users who have not asked for less motion. */
  animation: none;
}
.bottom-sheet {
  display: flex;
  flex-direction: column;
  overflow: hidden;
  box-shadow: var(--shadow-e4);
}
.bottom-sheet::backdrop {
  background: var(--bg-overlay-scrim);
}

@media (prefers-reduced-motion: no-preference) {
  .modal.bottom-sheet {
    animation: sz-sheet-in var(--duration-base) var(--ease-decelerate);
  }
  /* Physical translateY is intentional and direction-agnostic: the sheet rises
     from the block-end edge, which is the same edge in RTL and LTR. */
  @keyframes sz-sheet-in {
    from {
      transform: translateY(100%);
    }
    to {
      transform: none;
    }
  }
}

/* Decorative — aria-hidden. The real dismiss affordances are .modal__close, ESC
   and the backdrop, so this 4px bar never has to be a 44px target itself. If a
   screen makes it draggable it must wrap it in .bottom-sheet__grabber. */
.bottom-sheet__handle {
  flex-shrink: 0;
  inline-size: 2.25rem; /* 36px — no size token at this step */
  block-size: var(--space-1);
  margin-inline: auto;
  margin-block: var(--space-3) var(--space-1);
  border-radius: var(--radius-full);
  background: var(--border-strong);
}
.bottom-sheet__grabber {
  display: flex;
  align-items: center;
  justify-content: center;
  min-block-size: var(--tap-target-min);
  inline-size: 100%;
  flex-shrink: 0;
}

.bottom-sheet__body {
  flex: 1;
  min-block-size: 0;
  overflow-y: auto;
  /* Contain, so flicking the facet list inside the sheet cannot scroll the
     results page underneath it — the classic "my place is gone" bug. */
  overscroll-behavior: contain;
  padding: var(--space-2) var(--space-4);
}

/* Sticky, not fixed: it stays on screen while a 40-brand facet list scrolls
   past it, without leaving the dialog's stacking context. */
.bottom-sheet__footer {
  position: sticky;
  inset-block-end: 0;
  z-index: var(--z-sticky);
  display: flex;
  gap: var(--space-2);
  flex-shrink: 0;
  padding: var(--space-3) var(--space-4);
  /* iOS home-indicator devices: without this the primary action sits under the
     gesture bar and the first tap is swallowed. */
  padding-block-end: calc(var(--space-3) + env(safe-area-inset-bottom));
  background: var(--bg-surface);
  border-block-start: 1px solid var(--border-default);
}
.bottom-sheet__footer > .btn {
  flex: 1;
  min-block-size: var(--tap-target-min);
}

/* A sort sheet is a radio list; a filter sheet reuses .facet-group. */
.sheet-option {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-block-size: var(--tap-target-min);
  padding-inline: var(--space-2);
  border-radius: var(--radius-md);
  cursor: pointer;
}
.sheet-option:hover {
  background: var(--bg-surface-hover);
}
.sheet-option__check {
  margin-inline-start: auto;
  color: var(--text-brand);
  opacity: 0;
}
.sheet-option[aria-checked='true'] {
  font-weight: var(--font-weight-medium);
}
.sheet-option[aria-checked='true'] .sheet-option__check,
.sheet-option:has(input:checked) .sheet-option__check {
  opacity: 1;
}
.sheet-option:has(input:checked) {
  font-weight: var(--font-weight-medium);
}

@media (min-width: 768px) {
  /* Above the phone breakpoint the sheet degrades to an ordinary centred modal —
     an 85dvh full-bleed slab on a 1440px screen reads as a broken overlay. */
  .modal-scrim--sheet {
    align-items: center;
    padding: var(--space-4);
  }
  .modal.bottom-sheet {
    max-inline-size: 30rem;
    border-radius: var(--radius-xl);
    max-block-size: calc(100dvh - var(--space-16));
  }
  .bottom-sheet__handle,
  .bottom-sheet__grabber {
    display: none;
  }
}

/* --------------------------------------------------------------------------
   7. Sticky CTA bar
   -------------------------------------------------------------------------- */
/* tokens.css already resets --shop-accent to the platform brand on .sticky-cta:
   a shop-coloured "أضف للسلة" on a page that produces a platform-numbered ZATCA
   tax invoice is a trust regression. Nothing here re-themes it. */
.sticky-cta {
  position: fixed;
  inset-block-end: 0;
  inset-inline: 0;
  z-index: var(--z-sticky-cta);
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-block-size: var(--sticky-cta-h);
  padding: var(--space-3) var(--space-4);
  padding-block-end: calc(var(--space-3) + env(safe-area-inset-bottom));
  background: var(--bg-surface);
  border-block-start: 1px solid var(--border-default);
  box-shadow: var(--shadow-e3);
}
.sticky-cta__info {
  min-inline-size: 0;
  display: flex;
  flex-direction: column;
}
.sticky-cta__label {
  font-size: var(--font-size-caption);
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* NO direction/unicode-bidi override here, on purpose. formatMoney emits
   "ر.س 45.00" — symbol first logically — which renders correctly inside an RTL
   run. Forcing dir="ltr" reorders it so the Arabic reader meets the number
   before the currency, which is the wrong reading order for the primary locale.
   Bare numerals (counts, SKUs, order ids) get LTR; prices never do. */
.sticky-cta__price {
  font-size: var(--font-size-body-lg);
  font-weight: var(--font-weight-bold);
  font-variant-numeric: tabular-nums;
}
.sticky-cta__action {
  flex: 1 1 auto;
  min-block-size: var(--tap-target-min);
  max-inline-size: 16rem;
  margin-inline-start: auto;
}

/* Accent-filled variant. The focus ring must switch to the inverse token: with
   outline-offset the ring lands ON the filled bar, and --focus-ring (#0F413C)
   is measured against a light background, not against the accent. */
.sticky-cta--accent {
  background: var(--shop-accent);
  color: var(--shop-on-accent);
  border-block-start-color: transparent;
}
.sticky-cta--accent .sticky-cta__label {
  color: var(--shop-on-accent);
  opacity: 0.85;
}
.sticky-cta--accent :focus-visible {
  outline-color: var(--focus-ring-inverse);
}

/* The counterpart body class. Without it the last row of the results grid — or
   the PDP's "add to cart" itself — sits permanently under the bar and can never
   be reached, which is the classic sticky-footer trap. */
.has-sticky-cta,
.has-sticky-cta--mobile {
  padding-block-end: calc(var(--sticky-cta-h) + env(safe-area-inset-bottom));
}
.sticky-cta.is-hidden {
  transform: translateY(100%);
  visibility: hidden;
}

@media (prefers-reduced-motion: no-preference) {
  .sticky-cta {
    transition:
      transform var(--duration-base) var(--ease-standard),
      visibility var(--duration-base) var(--ease-standard);
  }
}

@media (min-width: 1024px) {
  /* Paired: the bar and the padding that compensates for it disappear together,
     otherwise the desktop page keeps a 4.5rem dead strip at its end. */
  .sticky-cta--mobile {
    display: none;
  }
  .has-sticky-cta--mobile {
    padding-block-end: 0;
  }
}

@media (forced-colors: active) {
  /* box-shadow is dropped in forced-colors, so the bar would float over the
     content with no separation at all. */
  .sticky-cta {
    border-block-start: 2px solid CanvasText;
  }
  .sticky-cta--accent {
    border-block-start-color: CanvasText;
  }
}

/* --------------------------------------------------------------------------
   8. Empty results
   -------------------------------------------------------------------------- */
/* Distinct from .empty-state (§14), which is a dead end: an icon, a sentence and
   nothing to do. Zero results on a search is almost always one filter too many,
   so this state carries the way out — the echoed query, clear-all, a targeted
   "drop the narrowest filter", category tiles and the shop directory. */
.empty-results {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--space-3);
  padding: var(--space-10) var(--space-4);
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-xl);
}
.empty-results__title {
  font-size: var(--font-size-h4);
  font-weight: var(--font-weight-semibold);
}
/* The echoed query is user input in an unknown language. `plaintext` takes its
   direction from the first strong character, so an English query inside the
   Arabic page reads left-to-right without the page forcing either direction —
   and `anywhere` stops a 200-character paste from widening the card. */
.empty-results__query {
  unicode-bidi: plaintext;
  overflow-wrap: anywhere;
  max-inline-size: 40ch;
  color: var(--text-primary);
  font-weight: var(--font-weight-semibold);
}
.empty-results__desc {
  max-inline-size: 44ch;
  color: var(--text-secondary);
  font-size: var(--font-size-body-sm);
}
.empty-results__actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-2);
}
.empty-results__actions > .btn {
  min-block-size: var(--tap-target-min);
}
/* The highest-value action on the screen: name the one facet that is costing the
   most results and offer to drop just that one. */
.empty-results__suggestion {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-block-size: var(--tap-target-min);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-full);
  background: var(--bg-brand-subtle);
  color: var(--text-brand);
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-medium);
}
.empty-results__suggestion:hover {
  background: var(--bg-brand-subtle-hover);
}
.empty-results__tiles {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
  gap: var(--grid-gap-card);
  inline-size: 100%;
  max-inline-size: 40rem;
  margin-block-start: var(--space-2);
}
.empty-results__tile {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-block-size: var(--tap-target-min);
  padding: var(--space-3) var(--space-2);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  background: var(--bg-surface-sunken);
  font-size: var(--font-size-body-sm);
  color: var(--text-primary);
}
.empty-results__tile:hover {
  border-color: var(--border-brand);
  color: var(--text-brand);
}
.empty-results__directory {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  min-block-size: var(--tap-target-min);
  color: var(--text-link);
  font-size: var(--font-size-body-sm);
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* --------------------------------------------------------------------------
   9. Pagination — EXTENDS components.css §15
   -------------------------------------------------------------------------- */
/* §15 sizes the buttons at --control-height-sm: 36px on the storefront, 28px on
   admin. min-block-size CLAMPS an explicit height upward, so this lifts the two
   touch surfaces to 44px without editing the original rule. Admin is a
   mouse-only desktop shell and stays dense (still over the 24px 2.5.8 floor). */
[data-app='storefront'] .pagination__btn,
[data-app='merchant'] .pagination__btn {
  min-block-size: var(--tap-target-min);
  min-inline-size: var(--tap-target-min);
}
.pagination {
  flex-wrap: wrap;
}

/* "Load more" variant — infinite lists on mobile, where numbered pages are a
   44px-target minefield and lose the shopper's scroll position on every jump. */
.pagination--load-more {
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
}
/* margin-inline-end:auto pushes the range to the far edge in the numbered
   layout; centred here it must be reset or it drags the text off-axis. */
.pagination--load-more .pagination__range {
  margin-inline-end: 0;
}
.pagination__load-more {
  min-block-size: var(--tap-target-min);
  min-inline-size: 14rem; /* no width token; wide enough that the label never wraps */
}
/* Progress under the button ("عرض 24 من 96") — a bare count, LTR-embedded. */
.pagination__progress {
  direction: ltr;
  unicode-bidi: isolate;
  font-size: var(--font-size-caption);
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}

/* --------------------------------------------------------------------------
   20. Commerce: PDP, cart, reviews, storefront chrome
   -------------------------------------------------------------------------- */

/* --------------------------------------------------------------------------
   MONEY — read before touching any price rule in this file.

   formatMoney emits "ر.س 45.00": the currency symbol comes FIRST logically.
   Inside an RTL run that renders exactly as an Arabic reader expects — symbol,
   then amount. Forcing `direction: ltr` on a money element re-orders the run so
   the reader meets the digits before the symbol, i.e. the opposite of what the
   formatter produced. So: NO money element in this file sets `direction`, and
   none is wrapped in `dir="ltr"` markup.

   Bare numerals DO get isolated — a rating average ("4.6"), a cart count ("3"),
   a qty ("2"), a CR/VAT number — because those have no directional currency
   symbol to protect and would otherwise be re-ordered by neighbouring Arabic.
   Each such rule below carries its own justification.

   `font-variant-numeric: tabular-nums` is safe on money: it changes glyph
   advance, not bidi order.
   -------------------------------------------------------------------------- */

/* --------------------------------------------------------------------------
   C1. PDP layout
   -------------------------------------------------------------------------- */
/* Two tracks, and the SHORT one is the sticky one. That inversion is the whole
   point: previously the gallery (short) sat in a tall track and the buy box
   (also short) sat beside it, so once the description and reviews pushed the
   page past ~1800px there was a ~650px empty quadrant under the buy box for the
   entire scroll. Making the buy box sticky against `--header-h` fills that
   quadrant with the only thing a shopper wants there — the price and the CTA —
   while the tall column keeps scrolling real content past it. */
.pdp-layout {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(20rem, 24rem);
  gap: var(--space-8);
  align-items: start;
  padding-block: var(--space-6) var(--space-12);
}
/* Grid children default to min-width:auto, so one wide child (a long variant
   row, an un-clamped title) drags its track wider and every sibling stretches
   with it, overflowing a 360px viewport. */
.pdp-layout > * {
  min-inline-size: 0;
}

.pdp-layout__gallery {
  min-inline-size: 0;
}

.pdp-layout__buy {
  position: sticky;
  /* The one number the sticky header, this rail and every scroll anchor share.
     Hard-coding 64px here is how the buy box ends up tucked under the header on
     storefront ≥1024, where --header-h steps from 3.5rem to 4rem. */
  inset-block-start: calc(var(--header-h) + var(--space-4));
  min-inline-size: 0;
}

/* Full-width rows below the fold (description, specs, reviews). */
.pdp-layout__full {
  grid-column: 1 / -1;
  /* Anchor links from the review summary land here; without the scroll margin
     the section title stops UNDER the sticky header and reads as a mis-fire. */
  scroll-margin-block-start: calc(var(--header-h) + var(--space-4));
}

@media (max-width: 1023px) {
  .pdp-layout {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-6);
    /* Room for the mobile sticky CTA bar (owned elsewhere) so the last line of
       the page is not permanently parked behind it. */
    padding-block-end: calc(var(--sticky-cta-h) + var(--space-6));
  }
  /* Sticky in a single column means the buy box pins itself over its own
     following content instead of beside it. Release it. */
  .pdp-layout__buy {
    position: static;
  }
}

/* --------------------------------------------------------------------------
   C2. PDP gallery
   -------------------------------------------------------------------------- */
/* Centred in its track, deliberately. The gallery sits in the only `fr` track, so
   it always absorbs whatever the buy box does not take — 800px at a 1280 container
   — while the hero below caps at 34rem. Left flush to inline-start that surplus
   became a ~290px hole between the product image and the buy box, which read as a
   broken layout rather than as breathing room. Centring splits it evenly and keeps
   the section full-bleed, so the reviews and the related rail underneath still line
   up with the same container edges. */
.pdp-gallery {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
}

/* ONE ratio, everywhere. The old 4:3 main frame beside 1:1 thumbs and 1:1 grid
   cards meant three derivative sets per image and a visible crop jump when a
   thumb was promoted to the main frame. 1:1 is the card ratio, so the same
   derivative serves both. */
.pdp-gallery__main {
  inline-size: min(100%, 34rem);
  aspect-ratio: 1 / 1;
  background: var(--bg-media);
  border-radius: var(--radius-media);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-muted);
  overflow: hidden;
}

.pdp-gallery__slide {
  flex: 0 0 100%;
  aspect-ratio: 1 / 1;
  display: flex;
  align-items: center;
  justify-content: center;
  scroll-snap-align: center;
}
/* `contain`, never `cover`: product photography arrives at whatever ratio the
   merchant uploaded, and `cover` silently crops the top off a bottle. --bg-media
   is the letterbox field that makes the un-cropped result look intentional. */
.pdp-gallery__slide img {
  inline-size: 100%;
  block-size: 100%;
  object-fit: contain;
}
/* Desktop shows one slide; the mobile block below turns the same markup into a
   swipe rail, so there is no duplicated image DOM. */
@media (min-width: 768px) {
  .pdp-gallery__slide:not(.is-active) {
    display: none;
  }
}

/* auto-fit + a hard cap per thumb. `repeat(2, 1fr)` with three children is the
   current defect: two thumbs on row one and a lone orphan on row two, which
   reads as a broken grid rather than a third image. auto-fit puts all three on
   one row — but auto-fit alone then stretches each to ~11rem, so the "thumbs"
   become almost as large as the main frame. The max-inline-size is what keeps
   them thumbnail-sized while still completing the row. */
.pdp-gallery__thumbs {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(4.5rem, 1fr));
  gap: var(--space-2);
  inline-size: min(100%, 34rem);
  justify-content: start;
}

.pdp-gallery__thumb {
  position: relative;
  max-inline-size: 6rem;
  min-inline-size: var(--tap-target-min);
  min-block-size: var(--tap-target-min);
  aspect-ratio: 1 / 1;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-media);
  border-radius: var(--radius-md);
  /* 2px always, transparent when inactive: selecting a thumb must not change
     its box size, or the whole strip nudges sideways on every click. */
  border: 2px solid transparent;
  color: var(--text-muted);
  cursor: pointer;
}
.pdp-gallery__thumb img {
  inline-size: 100%;
  block-size: 100%;
  object-fit: contain;
}
.pdp-gallery__thumb:hover {
  border-color: var(--border-strong);
}

/* Shop accent, allowed use 1 of 6: gallery selection. */
.pdp-gallery__thumb.is-active,
.pdp-gallery__thumb[aria-current='true'] {
  border-color: var(--shop-accent);
  /* Second ring inside the border so a pale accent (AMBER, SKY) still separates
     from a pale product photo. */
  box-shadow: 0 0 0 1px var(--shop-accent) inset;
}

.pdp-gallery__dots {
  display: none;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);
  margin-block-start: var(--space-3);
}
.pdp-gallery__dot {
  position: relative;
  inline-size: var(--space-2);
  block-size: var(--space-2);
  border-radius: var(--radius-full);
  background: var(--border-strong);
  flex-shrink: 0;
}
/* Visual dot stays 8px; the touch target is a symmetric negative-inset overlay.
   `inset` with one negative value grows the box equally on all four sides, so it
   needs no direction handling and no transform. */
.pdp-gallery__dot::after {
  content: '';
  position: absolute;
  inset: calc((var(--tap-target-min) - var(--space-2)) / -2);
}
/* Neutral ink, not --shop-accent: the accent budget for the gallery is already
   spent on the active thumb ring, and a second accent mark 8px tall carries no
   information the ring does not. */
.pdp-gallery__dot[aria-current='true'] {
  background: var(--text-primary);
  inline-size: var(--space-5);
}

@media (max-width: 767px) {
  .pdp-gallery__main {
    inline-size: 100%;
    /* `overflow-x` (not an `overflow-inline` logical form, which is not shipped
       broadly enough to rely on) — this is a scroll AXIS, not a layout offset,
       and it behaves identically under RTL because the axis is inline. */
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: inline mandatory;
    overscroll-behavior-inline: contain;
    scrollbar-width: none;
  }
  .pdp-gallery__main::-webkit-scrollbar {
    display: none;
  }
  .pdp-gallery__thumbs {
    display: none;
  }
  .pdp-gallery__dots {
    display: flex;
  }
}

/* --------------------------------------------------------------------------
   C3. Buy box
   Source order is the spec order and is load-bearing for both reading order and
   the a11y tree: price → variants → stock → qty → add → trust → shop.
   -------------------------------------------------------------------------- */
.buy-box {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-e1);
  padding: var(--space-5);
}

.buy-box__price-block {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}
.buy-box__price-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-2) var(--space-3);
}
/* No `direction` — see the MONEY note at the top of this file. */
.buy-box__price {
  font-size: var(--font-size-h2);
  line-height: var(--line-height-tight);
  font-weight: var(--font-weight-bold);
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}
/* Reserved slot. The compare-at price renders only on discounted variants; in a
   buy box that swaps variants in place, an appearing/disappearing strike line
   shoves the CTA up and down under the user's thumb. Hold the line either way. */
.buy-box__compare-at {
  display: block;
  min-block-size: calc(var(--font-size-body-sm) * var(--line-height-normal));
  font-size: var(--font-size-body-sm);
  color: var(--text-muted);
  text-decoration: line-through;
  font-variant-numeric: tabular-nums;
}
.buy-box__vat-note {
  font-size: var(--font-size-caption);
  color: var(--text-muted);
}

.buy-box__stock {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-block-size: calc(var(--font-size-body-sm) * var(--line-height-normal));
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-medium);
}
/* Colour is the SECOND channel here, never the only one: each modifier is paired
   with a text label ("متوفر" / "بقي قطعتان" / "غير متوفر") and an icon, so the
   state survives greyscale, forced-colors and a colour-blind reader. */
.buy-box__stock--in {
  color: var(--text-success);
}
.buy-box__stock--low {
  color: var(--text-warning);
}
.buy-box__stock--out {
  color: var(--text-muted);
}

.buy-box__qty-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  flex-wrap: wrap;
}
.buy-box__qty-label {
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
}

/* Shop accent, allowed use 2 of 6: the primary shop-side CTA. Note that
   `.buy-box__checkout` and `.cart-summary__cta` are reset to platform brand by
   tokens.css — the checkout path is the PLATFORM's rails and must not wear the
   shop's colour. */
.buy-box__add {
  inline-size: 100%;
  block-size: var(--control-height-lg);
  min-block-size: var(--tap-target-min);
  background: var(--shop-accent);
  color: var(--shop-on-accent);
  border-color: transparent;
  font-size: var(--font-size-body-lg);
}
.buy-box__add:hover:not(:disabled):not([aria-disabled='true']) {
  background: var(--shop-accent-hover);
}
.buy-box__add:disabled,
.buy-box__add[aria-disabled='true'] {
  background: var(--bg-surface-sunken);
  color: var(--text-muted);
  border-color: var(--border-default);
}

.buy-box__checkout {
  inline-size: 100%;
  block-size: var(--control-height-lg);
  min-block-size: var(--tap-target-min);
}

.buy-box__actions {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

/* Shop attribution card — the last block in the buy box, so "who am I buying
   from" is answered next to the button that commits the purchase. Shop accent,
   allowed use 3 of 6. */
.shop-attribution {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  background: var(--shop-accent-subtle);
}
.shop-attribution__logo {
  inline-size: 2.5rem;
  block-size: 2.5rem;
  flex-shrink: 0;
  border-radius: var(--radius-md);
  /* Hairline border: a white-on-white logo is otherwise an invisible hole. */
  border: 1px solid var(--border-default);
  background: var(--bg-surface);
  object-fit: contain;
}

/* Monogram fallback — the DEFAULT state at launch, not an edge case: most shops
   have not uploaded a logo. Two screens independently invented the same
   page-local rule for this, which is the signal that it belongs here.
   `.product-card__monogram` cannot be reused: it is sized at --font-size-h1 and
   overflows a 2.5rem box. */
.shop-attribution__monogram {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--font-size-body);
  font-weight: var(--font-weight-bold);
  line-height: 1;
  color: var(--shop-accent);
  background: var(--shop-accent-subtle);
  border-color: transparent;
  /* the glyph is decorative; the shop NAME beside it carries the meaning */
  -webkit-user-select: none;
  user-select: none;
}
.shop-attribution__body {
  min-inline-size: 0;
  flex: 1;
}
/* Shop names are tenant data in either language — resolve each from its own
   first strong character rather than the page's. */
.shop-attribution__name {
  font-weight: var(--font-weight-semibold);
  font-size: var(--font-size-body-sm);
  unicode-bidi: plaintext;
  text-align: start;
}
.shop-attribution__meta {
  font-size: var(--font-size-caption);
  color: var(--text-secondary);
}
.shop-attribution__link {
  display: inline-flex;
  align-items: center;
  min-block-size: var(--tap-target-min);
  min-inline-size: var(--tap-target-min);
  justify-content: center;
  padding-inline: var(--space-2);
  color: var(--text-link);
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-medium);
  flex-shrink: 0;
}
.shop-attribution__link:hover {
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* --------------------------------------------------------------------------
   C4. Variant chips
   Chips, NOT a table. The table this replaces is a plain flex row per variant,
   so omitting the add button on an out-of-stock row hands that row's width back
   to its siblings: the label, the price and the stock text all shift, and the
   "columns" stop lining up down the list. There are no columns to break here —
   each variant is one self-contained control.
   -------------------------------------------------------------------------- */
.variant-chips {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}
.variant-chips__group {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
.variant-chips__label {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-medium);
  color: var(--text-secondary);
}
.variant-chips__selected {
  color: var(--text-primary);
  font-weight: var(--font-weight-semibold);
}
.variant-chips__options {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}

.variant-chip {
  position: relative;
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-0-5);
  min-inline-size: var(--tap-target-min);
  min-block-size: var(--tap-target-min);
  padding-inline: var(--space-3);
  padding-block: var(--space-1);
  /* 2px in every state, transparent-to-accent on select: a 1px→2px border on
     selection re-flows the whole wrapped row. */
  border: 2px solid var(--border-strong);
  border-radius: var(--control-radius);
  background: var(--bg-surface);
  color: var(--text-primary);
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-medium);
  text-align: center;
  cursor: pointer;
}
.variant-chip__price {
  font-size: var(--font-size-micro);
  font-weight: var(--font-weight-regular);
  color: var(--text-muted);
  /* Money — no direction. */
}
.variant-chip:hover:not([aria-disabled='true']) {
  border-color: var(--shop-accent);
}

/* Shop accent, allowed use 4 of 6: variant selection. */
.variant-chip[aria-pressed='true'],
.variant-chip.is-selected {
  border-color: var(--shop-accent);
  background: var(--shop-accent-subtle);
}

/* STAYS rendered, stays focusable. `aria-disabled` rather than `disabled` keeps
   it in the tab order so a screen-reader user hears that the 1kg jar exists and
   is sold out. Removing an unavailable size from the DOM — the usual "fix" —
   tells the shopper the shop never carried it, and they leave to find one that
   does. */
.variant-chip[aria-disabled='true'] {
  color: var(--text-muted);
  border-color: var(--border-default);
  background: var(--bg-surface-sunken);
  cursor: not-allowed;
  overflow: hidden;
}
/* Diagonal strike. Physical `to top left` is deliberate and follows the
   checkmark precedent in components.css §7: a strike is a fixed visual symbol,
   not a layout offset, and mirroring it per direction would make the same state
   look like two different states across locales. */
.variant-chip[aria-disabled='true']::before {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(
    to top left,
    transparent calc(50% - 1px),
    var(--border-strong) calc(50% - 1px),
    var(--border-strong) calc(50% + 1px),
    transparent calc(50% + 1px)
  );
}

/* --------------------------------------------------------------------------
   C5. Qty stepper
   Distinct from the POS `.stepper` (§7): that one is 2.5rem tall and tuned for a
   stylus on a merchant tablet. This one is a shopper's thumb.
   -------------------------------------------------------------------------- */
.qty-stepper {
  display: inline-flex;
  align-items: stretch;
  block-size: var(--tap-target-min);
  border: 1px solid var(--border-strong);
  border-radius: var(--control-radius);
  background: var(--bg-surface);
  /* No `overflow: hidden` — an ancestor with it clips the focus outline of the
     buttons inside, which is exactly how a keyboard user loses the ring. Corners
     are rounded per-child instead, as .btn-group does. */
}
.qty-stepper__btn {
  inline-size: var(--tap-target-min);
  min-inline-size: var(--tap-target-min);
  min-block-size: var(--tap-target-min);
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-surface-sunken);
  color: var(--text-primary);
}
.qty-stepper__btn:first-child {
  border-start-start-radius: var(--control-radius);
  border-end-start-radius: var(--control-radius);
}
.qty-stepper__btn:last-child {
  border-start-end-radius: var(--control-radius);
  border-end-end-radius: var(--control-radius);
}
.qty-stepper__btn:hover:not(:disabled) {
  background: var(--bg-brand-subtle);
  color: var(--text-brand);
}
.qty-stepper__btn:disabled {
  color: var(--text-disabled);
  cursor: not-allowed;
}
.qty-stepper__value {
  inline-size: var(--space-12);
  min-inline-size: var(--space-12);
  border: 0;
  border-inline: 1px solid var(--border-default);
  background: var(--bg-surface);
  text-align: center;
  font-size: 1rem; /* never below 16px — prevents iOS input-zoom, per §7 */
  font-variant-numeric: tabular-nums;
  /* LTR island. This is a bare COUNT with no currency symbol, so isolating it
     protects "12" from being re-ordered by the Arabic label beside it. Contrast
     with .buy-box__price, which must NOT be isolated. */
  direction: ltr;
  unicode-bidi: isolate;
}
.qty-stepper--sm {
  block-size: var(--control-height-md);
}
/* Visual height shrinks, hit area does not. */
.qty-stepper--sm .qty-stepper__btn {
  inline-size: var(--control-height-md);
  min-inline-size: var(--control-height-md);
  min-block-size: var(--control-height-md);
  position: relative;
}
.qty-stepper--sm .qty-stepper__btn::after {
  content: '';
  position: absolute;
  inset-block: calc((var(--tap-target-min) - var(--control-height-md)) / -2);
  inset-inline: 0;
}

/* --------------------------------------------------------------------------
   C6. Trust row
   Icon + one line. Used in the buy box, the cart summary and the pre-footer.
   -------------------------------------------------------------------------- */
.trust-row {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
.trust-row__item {
  display: flex;
  align-items: flex-start;
  gap: var(--space-2);
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
}
.trust-row__item .icon {
  color: var(--text-brand);
  flex-shrink: 0;
  margin-block-start: 0.15em;
}
.trust-row__text {
  min-inline-size: 0;
}
/* Four items, hard stop. The buy box column is 20–24rem; a fifth line pushes the
   shop attribution card below the fold on a 1366px laptop, and a trust list long
   enough to need scrolling has stopped being reassuring. */
.trust-row__item:nth-child(n + 5) {
  display: none;
}

.trust-row--inline {
  flex-direction: row;
  flex-wrap: wrap;
  gap: var(--space-3) var(--space-6);
}
.trust-row--inline .trust-row__item {
  align-items: center;
}

/* --------------------------------------------------------------------------
   C7. Reviews
   -------------------------------------------------------------------------- */

/* ---- Star display ------------------------------------------------------- */
/* The on/off distinction is FILL vs OUTLINE, not hue — so it survives
   greyscale, a colour-blind reader and forced-colors, where both stars collapse
   to CanvasText but a solid star still differs from a hollow one. */
.review-stars {
  display: inline-flex;
  align-items: center;
  gap: var(--space-0-5);
  flex-shrink: 0;
}
.review-stars .icon {
  inline-size: 1.125em;
  block-size: 1.125em;
  vertical-align: -0.15em;
}
.review-stars__star--on {
  color: var(--rating-star-on);
  fill: currentColor;
  stroke: none;
}
.review-stars__star--off {
  color: var(--rating-star-off);
  fill: none;
  stroke: currentColor;
}

/* ---- Review summary (sits under the PDP H1) ----------------------------- */
.review-summary {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-3);
  /* Reserved slot: ratings arrive with the product payload, and on a PDP the
     row sits between the H1 and the price. Without a floor, the price jumps
     down the moment the rating resolves — under a thumb already reaching for
     "add to cart". --tap-target-min is also the height its link needs. */
  min-block-size: var(--tap-target-min);
}
.review-summary__average {
  font-size: var(--font-size-h3);
  line-height: var(--line-height-tight);
  font-weight: var(--font-weight-bold);
  font-variant-numeric: tabular-nums;
  /* Bare numeral ("4.6"), no currency symbol: isolate it so the Arabic count
     text beside it cannot re-order the decimal. */
  direction: ltr;
  unicode-bidi: isolate;
}
.review-summary__count {
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
}
.review-summary__link {
  display: inline-flex;
  align-items: center;
  min-block-size: var(--tap-target-min);
  color: var(--text-link);
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-medium);
  text-decoration: underline;
  text-underline-offset: 3px;
}
.review-summary--empty {
  color: var(--text-muted);
}

/* ---- Review list + card -------------------------------------------------- */
.review-list {
  display: flex;
  flex-direction: column;
}

.review-card {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding-block: var(--space-5);
  border-block-end: 1px solid var(--border-default);
}
.review-card:last-child {
  border-block-end: 0;
}

.review-card__head {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-3);
}
.review-card__author {
  font-weight: var(--font-weight-semibold);
  unicode-bidi: plaintext;
  text-align: start;
}
.review-card__date {
  font-size: var(--font-size-caption);
  color: var(--text-muted);
  margin-inline-start: auto;
}
/* Reserved slot: the verified badge is conditional, and without a floor the
   header row of a verified card is 24px taller than an unverified one, so every
   card in the list starts at a different offset. */
.review-card__meta {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-block-size: 1.5rem; /* = .badge height, §6 */
}

.review-card__title {
  font-weight: var(--font-weight-semibold);
  font-size: var(--font-size-body);
  /* Same direction problem as the body below — see that comment. */
  unicode-bidi: plaintext;
  text-align: start;
}

/* A review carries the direction of the REVIEW's language, not the page's.
   An English review rendered inside this RTL page inherits `direction: rtl`,
   which throws its full stop, its closing bracket and its quotation marks to the
   wrong end — "Great honey, arrived fast." renders as ".Great honey, arrived
   fast" — and the same happens in reverse for an Arabic review on the English
   storefront. The markup emits dir="auto" (or an explicit dir from the stored
   review locale); these rules make the isolation and the alignment follow it
   rather than the inherited page direction. The UA sheet already isolates
   [dir] elements; what it does NOT do is fix `text-align`, which is the half
   that actually shows up in a screenshot. */
.review-card__body {
  color: var(--text-primary);
  line-height: var(--line-height-relaxed);
  unicode-bidi: isolate;
  text-align: start;
  overflow-wrap: anywhere;
}
.review-card__body[dir='ltr'] {
  direction: ltr;
  text-align: start;
}
.review-card__body[dir='rtl'] {
  direction: rtl;
  text-align: start;
}
/* dir="auto" resolves in the UA; plaintext is the fallback for markup that
   forgot the attribute entirely. */
.review-card__body[dir='auto'],
.review-card__body:not([dir]) {
  unicode-bidi: plaintext;
}

.review-card__actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-1);
  margin-block-start: var(--space-1);
}
.review-card__action {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-block-size: var(--tap-target-min);
  min-inline-size: var(--tap-target-min);
  justify-content: center;
  padding-inline: var(--space-3);
  border-radius: var(--radius-full);
  color: var(--text-secondary);
  font-size: var(--font-size-body-sm);
}
.review-card__action:hover {
  background: var(--bg-surface-sunken);
  color: var(--text-primary);
}
.review-card__action[aria-pressed='true'] {
  background: var(--bg-brand-subtle);
  color: var(--text-brand);
  font-weight: var(--font-weight-medium);
}
.review-card__action--report {
  color: var(--text-muted);
}
.review-card__action--report:hover {
  color: var(--text-danger);
  background: var(--bg-danger-subtle);
}

/* Merchant reply: indented on the inline-start so the nesting is visible, and
   badged so it is never mistaken for a second customer. The rule is
   --border-strong rather than --shop-accent: an accent-coloured rail on a reply
   inside a review LIST (which may span shops on a mall page) implies the shop
   authored the review above it too. */
.review-card__reply {
  margin-block-start: var(--space-2);
  margin-inline-start: var(--space-6);
  padding: var(--space-3) var(--space-4);
  background: var(--bg-surface-sunken);
  border-radius: var(--radius-md);
  border-inline-start: 3px solid var(--border-strong);
}
.review-card__reply-head {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-block-end: var(--space-1);
}
.review-card__reply-author {
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-semibold);
  unicode-bidi: plaintext;
  text-align: start;
}
.review-card__reply-body {
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
  line-height: var(--line-height-relaxed);
  unicode-bidi: isolate;
  text-align: start;
}
.review-card__reply-body[dir='ltr'] {
  direction: ltr;
  text-align: start;
}
.review-card__reply-body[dir='rtl'] {
  direction: rtl;
  text-align: start;
}

@media (max-width: 559px) {
  .review-card__reply {
    margin-inline-start: var(--space-3);
  }
}

/* ---- Zero-review state -------------------------------------------------- */
/* NOT an empty list and NOT an `.empty-state` shrug. Zero reviews on a new
   shop's product is the single highest-leverage place to ask for one, so this
   is an invitation with an eligibility-aware CTA: a shopper who has not bought
   the product is told why the button is off instead of being shown a button
   that fails after they fill the form. */
.review-invite {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-3);
  padding: var(--space-6);
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-lg);
  background: var(--bg-surface);
}
.review-invite__title {
  font-size: var(--font-size-h4);
  font-weight: var(--font-weight-semibold);
}
.review-invite__desc {
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
  max-inline-size: 52ch;
}
.review-invite__cta {
  min-block-size: var(--tap-target-min);
}
.review-invite__ineligible {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--font-size-caption);
  color: var(--text-muted);
}
.review-invite__trust {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--font-size-caption);
  color: var(--text-muted);
  border-block-start: 1px solid var(--border-default);
  padding-block-start: var(--space-3);
  inline-size: 100%;
}
.review-invite__trust .icon {
  color: var(--text-brand);
  flex-shrink: 0;
}

/* ---- Write-review sheet -------------------------------------------------- */
.write-review-sheet {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  background: var(--bg-surface);
  border-radius: var(--radius-xl);
  padding: var(--space-5);
  inline-size: 100%;
  max-inline-size: 34rem;
  /* dvh, never vh. On mobile Safari `vh` measures against the EXPANDED toolbar
     viewport, so a sheet sized at 90vh runs its own submit button off the bottom
     of the screen while the toolbar is showing — the one control the sheet
     exists for. */
  max-block-size: calc(100dvh - var(--space-8));
  overflow-y: auto;
}
.write-review-sheet__title {
  font-size: var(--font-size-h3);
  font-weight: var(--font-weight-semibold);
}
.write-review-sheet__consent {
  font-size: var(--font-size-caption);
  color: var(--text-muted);
  background: var(--bg-surface-sunken);
  border-radius: var(--radius-md);
  padding: var(--space-3);
}
.write-review-sheet__consent a {
  color: var(--text-link);
  text-decoration: underline;
  text-underline-offset: 3px;
}
.write-review-sheet__footer {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-2);
  /* Sticky inside the scrolling sheet: on a long form the submit button must not
     require scrolling to the very end to find. */
  position: sticky;
  inset-block-end: 0;
  background: var(--bg-surface);
  padding-block-start: var(--space-3);
  border-block-start: 1px solid var(--border-default);
}
.write-review-sheet__footer .btn {
  min-block-size: var(--tap-target-min);
}

@media (max-width: 767px) {
  .write-review-sheet {
    max-inline-size: none;
    max-block-size: 90dvh;
    border-start-start-radius: var(--radius-xl);
    border-start-end-radius: var(--radius-xl);
    border-end-start-radius: 0;
    border-end-end-radius: 0;
  }
}

/* Star input: a real radio group. Each option is a 44px label wrapping a native
   radio, so arrow keys, roving focus and form submission all work with no JS,
   and the visible star is only the paint. */
.star-input {
  display: flex;
  align-items: center;
  gap: var(--space-1);
}
.star-input__option {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-inline-size: var(--tap-target-min);
  min-block-size: var(--tap-target-min);
  border-radius: var(--radius-sm);
  color: var(--rating-star-off);
  cursor: pointer;
}
/* Opacity, not display:none and not .visually-hidden's clip — the input must
   stay in the same box as its star so the focus ring drawn on the wrapper below
   lands where the user is looking. */
.star-input__option input {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
}
.star-input__option .icon {
  inline-size: 1.75em;
  block-size: 1.75em;
  fill: none;
  stroke: currentColor;
}
.star-input__option:hover,
.star-input__option.is-on {
  color: var(--rating-star-on);
}
.star-input__option:has(input:checked) {
  color: var(--rating-star-on);
}
.star-input__option:has(input:checked) .icon,
.star-input__option.is-on .icon {
  fill: currentColor;
  stroke: none;
}
/* The native radio is invisible, so the WRAPPER carries the ring. */
.star-input__option:has(input:focus-visible) {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}
.star-input__value {
  margin-inline-start: var(--space-2);
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
}

/* --------------------------------------------------------------------------
   C8. Cart
   -------------------------------------------------------------------------- */
/* Today the cart is a 32rem column centred in a 1440px viewport: a 512px ribbon
   with ~460px of empty canvas on each side, and the total pushed below the fold
   because it sits AFTER the lines instead of beside them. Two tracks fix both —
   lines get the room a 72px thumbnail plus a stepper plus a total actually
   needs, and the summary is pinned in view for the whole scroll. */
.cart-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-6);
  padding-block: var(--space-6) var(--space-12);
}
.cart-layout__main {
  min-inline-size: 0;
}
.cart-layout__aside {
  min-inline-size: 0;
}

@media (min-width: 1024px) {
  .cart-layout {
    grid-template-columns: minmax(0, 1fr) 22rem;
    gap: var(--space-8);
    align-items: start;
  }
  .cart-layout__aside {
    position: sticky;
    inset-block-start: calc(var(--header-h) + var(--space-4));
  }
}
@media (max-width: 1023px) {
  /* Room for the mobile sticky checkout bar. */
  .cart-layout {
    padding-block-end: calc(var(--sticky-cta-h) + var(--space-6));
  }
}

/* Grid areas rather than a flex row: grid areas are resolved against the
   writing mode, so column 1 is the inline-start column under RTL with no
   mirroring rules, and — unlike flex — removing a cell (no per-line error, no
   variant caption) leaves the other cells exactly where they were. */
.cart-line {
  display: grid;
  grid-template-columns: 4.5rem minmax(0, 1fr) auto;
  grid-template-areas:
    'media info    remove'
    'media control total'
    'error error   error';
  align-items: center;
  gap: var(--space-2) var(--space-3);
  padding-block: var(--space-4);
}
.cart-line + .cart-line {
  border-block-start: 1px solid var(--border-default);
}

.cart-line__media {
  grid-area: media;
  align-self: start;
  inline-size: 4.5rem; /* 72px */
  block-size: 4.5rem;
  aspect-ratio: 1 / 1;
  border-radius: var(--radius-md);
  background: var(--bg-media);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-muted);
  overflow: hidden;
}
.cart-line__media img {
  inline-size: 100%;
  block-size: 100%;
  object-fit: contain;
}

.cart-line__info {
  grid-area: info;
  min-inline-size: 0;
  align-self: center;
}
/* Product names are tenant data in either language. `plaintext` resolves each
   name from its OWN first strong character, so an English SKU-ish name in an
   Arabic cart keeps its trailing "500g" on the correct end. */
.cart-line__name {
  font-weight: var(--font-weight-medium);
  unicode-bidi: plaintext;
  text-align: start;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.cart-line__variant {
  font-size: var(--font-size-caption);
  color: var(--text-muted);
}
.cart-line__shop {
  font-size: var(--font-size-caption);
  color: var(--text-secondary);
}

.cart-line__control {
  grid-area: control;
  justify-self: start;
}
.cart-line__total {
  grid-area: total;
  justify-self: end;
  font-weight: var(--font-weight-semibold);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  /* Money — no direction. See the MONEY note at the top of this file. */
}
.cart-line__remove {
  grid-area: remove;
  justify-self: end;
  align-self: start;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-inline-size: var(--tap-target-min);
  min-block-size: var(--tap-target-min);
  border-radius: var(--radius-full);
  color: var(--text-muted);
}
.cart-line__remove:hover {
  color: var(--text-danger);
  background: var(--bg-danger-subtle);
}

/* Per-line error, carrying role="alert". Deliberately NOT height-reserved: the
   reserved-slot rule exists to keep a GRID of peer cards on the same baseline,
   and a cart is a stacked list where a growing row pushes only the rows below
   it. Holding an empty red slot on every line would cost ~1.3rem per line and
   paint an error affordance next to items that are fine. */
.cart-line__error {
  grid-area: error;
  display: flex;
  align-items: flex-start;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  background: var(--bg-danger-subtle);
  color: var(--text-danger);
  font-size: var(--font-size-caption);
}
.cart-line__error:empty {
  display: none;
}
.cart-line__error .icon {
  flex-shrink: 0;
  margin-block-start: 0.1em;
}
.cart-line.is-unavailable .cart-line__media,
.cart-line.is-unavailable .cart-line__info {
  opacity: 0.6;
}

/* ---- Summary rail -------------------------------------------------------- */
.cart-summary {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-e1);
  padding: var(--space-5);
}
.cart-summary__title {
  font-size: var(--font-size-h4);
  font-weight: var(--font-weight-semibold);
}
.cart-summary__rows {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
.cart-summary__row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-4);
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
  /* Reserved slot: discount and delivery rows appear only once a coupon or an
     address resolves. Without a floor the grand total slides up the rail while
     the shopper is reading it. */
  min-block-size: calc(var(--font-size-body-sm) * var(--line-height-normal));
}
.cart-summary__value {
  color: var(--text-primary);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  /* Money — no direction. */
}
.cart-summary__row--discount,
.cart-summary__row--discount .cart-summary__value {
  color: var(--text-success);
}
.cart-summary__row--muted .cart-summary__value {
  color: var(--text-muted);
}

/* Visually dominant: the grand total is the number the shopper is deciding on,
   so it gets a heavier rule above it, a size step and the primary ink, while
   every contributing line stays at body-sm secondary. */
.cart-summary__total {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-4);
  padding-block-start: var(--space-3);
  border-block-start: 2px solid var(--border-strong);
  color: var(--text-primary);
  font-size: var(--font-size-body-lg);
  font-weight: var(--font-weight-bold);
}
.cart-summary__total-value {
  font-size: var(--font-size-h3);
  line-height: var(--line-height-tight);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  /* Money — no direction. */
}
.cart-summary__cta {
  inline-size: 100%;
  block-size: var(--control-height-lg);
  min-block-size: var(--tap-target-min);
}
.cart-summary__note {
  font-size: var(--font-size-caption);
  color: var(--text-muted);
}
.cart-summary .trust-row {
  border-block-start: 1px solid var(--border-default);
  padding-block-start: var(--space-3);
}

/* --------------------------------------------------------------------------
   C9. Storefront chrome
   -------------------------------------------------------------------------- */

/* ---- Header -------------------------------------------------------------- */
/* Supersedes the §17 shell. Row 1 = brand | scoped search | account | cart.
   Row 2 = the category chip rail. */
.sf-header {
  position: sticky;
  inset-block-start: 0;
  z-index: var(--z-sticky);
  background: var(--bg-surface);
  border-block-end: 1px solid var(--border-default);
}

/* FIXED height, never auto. The search field mounts a suggestion dropdown, the
   cart badge changes width from "9" to "12", and the account control swaps a
   label in on login — with an auto-height header every one of those events
   re-lays-out the header and shifts the ENTIRE page down. That is the most
   expensive class of layout shift there is: it happens at the top of the
   document, so it moves every pixel below it, and it happens exactly while the
   user is reaching for something. The dropdown is absolutely positioned out of
   flow (below) precisely so this number can stay fixed. */
.sf-header .sf-header__row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  block-size: var(--header-h);
  padding-block: 0;
}

.sf-header__search {
  position: relative;
  flex: 1;
  min-inline-size: 0;
  max-inline-size: 42rem;
}
.sf-header__search form {
  display: flex;
  align-items: stretch;
}

/* Category scope select: start segment of the search group. */
.sf-header__scope {
  flex: 0 0 auto;
  max-inline-size: 9rem;
  block-size: var(--control-height-md);
  min-block-size: var(--tap-target-min);
  background: var(--bg-surface-sunken);
  color: var(--text-secondary);
  border: 1px solid var(--border-strong);
  border-inline-end: 0;
  border-start-start-radius: var(--control-radius);
  border-end-start-radius: var(--control-radius);
  border-start-end-radius: 0;
  border-end-end-radius: 0;
  padding-inline: var(--space-3);
  font-size: var(--font-size-body-sm);
}
/* Middle segment — square on BOTH ends. The §17 rule only squared the end
   corners, which was correct for a 2-part group and wrong for this 3-part one. */
.sf-header__search .input {
  min-inline-size: 0;
  block-size: var(--control-height-md);
  min-block-size: var(--tap-target-min);
  border-radius: 0;
  border-inline-end: 0;
}
.sf-header__search button {
  flex: 0 0 auto;
  min-inline-size: var(--tap-target-min);
  min-block-size: var(--tap-target-min);
  block-size: var(--control-height-md);
  border-start-start-radius: 0;
  border-end-start-radius: 0;
  border-start-end-radius: var(--control-radius);
  border-end-end-radius: var(--control-radius);
}

/* Out of flow, so mounting it cannot grow the header. */
.sf-header__suggest {
  position: absolute;
  inset-block-start: calc(100% + var(--space-1));
  inset-inline: 0;
  z-index: var(--z-dropdown);
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-e3);
  max-block-size: 60dvh; /* dvh, not vh — see .write-review-sheet */
  overflow-y: auto;
  padding-block: var(--space-1);
}
.sf-header__suggest[hidden] {
  display: none;
}
.sf-header__suggest-item {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-block-size: var(--tap-target-min);
  padding-inline: var(--space-3);
  font-size: var(--font-size-body-sm);
  color: var(--text-primary);
}
.sf-header__suggest-item:hover,
.sf-header__suggest-item[aria-selected='true'] {
  background: var(--bg-surface-sunken);
}

.sf-header__action {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-inline-size: var(--tap-target-min);
  min-block-size: var(--tap-target-min);
  padding-inline: var(--space-2);
  border-radius: var(--radius-md);
  color: var(--text-secondary);
  flex-shrink: 0;
}
.sf-header__action:hover {
  background: var(--bg-surface-sunken);
  color: var(--text-primary);
}
.sf-header__action-label {
  font-size: var(--font-size-body-sm);
  white-space: nowrap;
}

/* Logical placement (the §17 rule used `top`), and an LTR island because the
   count is a bare numeral with no currency symbol. */
.sf-header__cart-count {
  position: absolute;
  inset-block-start: var(--space-0-5);
  inset-inline-end: var(--space-0-5);
  min-inline-size: 1.125rem;
  block-size: 1.125rem;
  padding-inline: var(--space-0-5);
  border-radius: var(--radius-full);
  background: var(--color-gold-600);
  color: var(--text-on-brand);
  font-size: var(--font-size-micro);
  font-weight: var(--font-weight-bold);
  line-height: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  direction: ltr;
  unicode-bidi: isolate;
}

/* Row 2: the category rail. `.chip` already holds white-space:nowrap and
   flex-shrink:0 (§15) — what was missing is a container that actually scrolls
   and a hit area tall enough to tap. */
.sf-header__nav {
  border-block-start: 1px solid var(--border-default);
  background: var(--bg-surface);
}
/* The `.sf-header` prefix is the same specificity repair the footer carries
   below: the rail's canonical markup is `<div class="container
   sf-header__nav-inner">`, and §17's `.sf-header .container` (0,2,0) outranks a
   bare class — including its `@media (max-width: 767px) { flex-wrap: wrap }`,
   which would let the rail WRAP into stacked rows on a phone instead of
   scrolling. That is defect M6, re-created by cascade. `nowrap` is stated
   explicitly here so it can never be inherited away again. */
.sf-header .sf-header__nav-inner {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: var(--space-2);
  padding-block: var(--space-2);
  /* Scroll AXIS, not a layout offset — behaves identically under RTL. */
  overflow-x: auto;
  scrollbar-width: thin;
  scroll-snap-type: inline proximity;
}
.sf-header__nav-inner .chip {
  scroll-snap-align: start;
  position: relative;
}
/* The chip stays visually 2rem — a 44px pill row would eat a third of the
   viewport height on a phone. The target is expanded instead, symmetrically, on
   the block axis only. */
.sf-header__nav-inner .chip::after {
  content: '';
  position: absolute;
  inset-block: calc((var(--tap-target-min) - 2rem) / -2);
  inset-inline: 0;
}

@media (max-width: 767px) {
  /* Search drops to its own full-width row. `width` is ignored for a flex
     item's main size, so the basis must be set explicitly (same defect the §17
     media query documents). The header row keeps its fixed height; the search
     row is a SECOND fixed row, not a growth of the first. */
  .sf-header .sf-header__row {
    gap: var(--space-2);
  }
  .sf-header__search {
    order: 3;
    flex: 1 0 100%;
    max-inline-size: none;
  }
  .sf-header .sf-header__row {
    flex-wrap: wrap;
    block-size: auto;
    padding-block: var(--space-2);
  }
  .sf-header__action-label {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
  .sf-header__scope {
    max-inline-size: 7rem;
  }
}

/* ---- Shop header band ---------------------------------------------------- */
/* Shop accent, allowed use 5 of 6: the top rule that tells a shopper they have
   left the mall chrome and entered a shop. The brief called for 6px; the 4px
   spacing scale has no 6px step and tokens.css is generated, so this rounds to
   --space-2 (8px) rather than inventing a token. */
.shop-header-band {
  background: var(--bg-surface);
  border-block-start: var(--space-2) solid var(--shop-accent);
  border-block-end: 1px solid var(--border-default);
}
.shop-header-band__inner {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-4);
  padding-block: var(--space-5);
}
.shop-header-band__logo {
  inline-size: 4.5rem; /* 72px */
  block-size: 4.5rem;
  flex-shrink: 0;
  border-radius: var(--radius-lg);
  /* Hairline border. Most merchant logos are white-background PNGs; on a white
     surface they render as nothing at all without an edge. */
  border: 1px solid var(--border-default);
  background: var(--bg-surface);
  object-fit: contain;
}

/* Monogram fallback at band size — see .shop-attribution__monogram for why this
   is the default state rather than an edge case. */
.shop-header-band__monogram {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--font-size-h2);
  font-weight: var(--font-weight-bold);
  line-height: 1;
  color: var(--shop-accent);
  background: var(--shop-accent-subtle);
  border-color: transparent;
  -webkit-user-select: none;
  user-select: none;
}
.shop-header-band__body {
  flex: 1;
  min-inline-size: 0;
}
.shop-header-band__name {
  font-size: var(--font-size-h3);
  line-height: var(--line-height-snug);
  font-weight: var(--font-weight-bold);
  unicode-bidi: plaintext;
  text-align: start;
}
.shop-header-band__meta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-3);
  margin-block-start: var(--space-1);
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
  /* Reserved slot: rating, city and product count all arrive from different
     queries; without a floor the whole page below the band steps down as each
     one lands. */
  min-block-size: var(--tap-target-min);
}
.shop-header-band__meta-item {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
}
.shop-header-band__actions {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-shrink: 0;
  margin-inline-start: auto;
}
.shop-header-band__actions .btn {
  min-block-size: var(--tap-target-min);
}

@media (max-width: 559px) {
  .shop-header-band__logo {
    inline-size: 3.5rem;
    block-size: 3.5rem;
  }
  .shop-header-band__actions {
    margin-inline-start: 0;
    inline-size: 100%;
  }
  .shop-header-band__actions .btn {
    flex: 1;
  }
}

/* ---- Pre-footer value-prop strip ---------------------------------------- */
/* Fills the ~130px of dead canvas that --space-16 of footer margin used to
   leave above the footer on every short page. */
.pre-footer-strip {
  background: var(--bg-surface);
  border-block-start: 1px solid var(--border-default);
}
.pre-footer-strip__inner {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--space-6);
  padding-block: var(--space-8);
}
.pre-footer-strip__item {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
}
.pre-footer-strip__icon {
  inline-size: 2.5rem;
  block-size: 2.5rem;
  flex-shrink: 0;
  border-radius: var(--radius-full);
  background: var(--bg-brand-subtle);
  color: var(--text-brand);
  display: flex;
  align-items: center;
  justify-content: center;
}
.pre-footer-strip__title {
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-semibold);
}
.pre-footer-strip__desc {
  font-size: var(--font-size-caption);
  color: var(--text-secondary);
}
/* The strip IS the gap now — keeping §17's 4rem footer margin on top of it just
   moves the dead band down one element. */
.pre-footer-strip + .sf-footer {
  margin-block-start: 0;
}

@media (max-width: 767px) {
  .pre-footer-strip__inner {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-4);
    padding-block: var(--space-6);
  }
}

/* ---- Footer -------------------------------------------------------------- */
/* Supersedes the §17 shell: four named columns instead of an auto-fit row that
   collapsed to two ragged columns whenever a link list was short.
   The redundant-looking `.sf-footer` prefix on this rule, on __bottom and on
   both media queries is a SPECIFICITY repair, not decoration: the canonical
   markup is `<div class="container sf-footer__cols">`, and §17's
   `.sf-footer .container` (0,2,0) would otherwise outrank a bare
   `.sf-footer__cols` (0,1,0) and re-impose the very auto-fit row this rule
   exists to replace — 3 columns and an orphan at 768–1023px. §20's header rule
   already carries the same prefix for the same reason (`.sf-header
   .sf-header__row`); the footer was the one that got missed. */
.sf-footer .sf-footer__cols {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: var(--space-8);
  padding-block: var(--space-10);
}
.sf-footer__col-title {
  color: var(--text-on-dark);
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-semibold);
  margin-block-end: var(--space-2);
}
.sf-footer__links {
  display: flex;
  flex-direction: column;
}
.sf-footer__link {
  display: inline-flex;
  align-items: center;
  min-block-size: var(--tap-target-min);
  font-size: var(--font-size-body-sm);
  color: var(--color-neutral-300);
}
.sf-footer__link:hover {
  color: var(--text-on-dark);
  text-decoration: underline;
  text-underline-offset: 3px;
}
/* --focus-ring is 11.4:1 on a light SURFACE — on neutral-900 it is very nearly
   invisible. The footer is the one field in this file where the ring genuinely
   lands on a dark background, so it is the one place --focus-ring-inverse is
   correct. (Filled CTAs like .buy-box__add do NOT qualify: outline-offset:2px
   puts their ring on the white card behind the button, not on the accent.) */
.sf-footer__link:focus-visible,
.sf-footer a:focus-visible {
  outline: 2px solid var(--focus-ring-inverse);
  outline-offset: 2px;
}

.sf-footer .sf-footer__bottom {
  border-block-start: 1px solid var(--color-neutral-800);
  padding-block: var(--space-5);
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-3) var(--space-6);
  font-size: var(--font-size-caption);
  color: var(--color-neutral-400);
}

@media (max-width: 1023px) {
  .sf-footer .sf-footer__cols {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--space-6);
  }
}
@media (max-width: 559px) {
  .sf-footer .sf-footer__cols {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* ---- Seller legal identity ---------------------------------------------- */
/* ZATCA/CR disclosure. The LABELS are locale text and stay in the page
   direction; the VALUES that are numbers (CR, VAT, phone) are LTR islands —
   a 15-digit VAT number re-ordered by surrounding Arabic is not merely ugly,
   it is a WRONG number on a legal disclosure. Mono + tabular so the digit
   groups stay comparable. */
.seller-legal-block {
  display: grid;
  /* 15rem, not 13rem: a KSA VAT number is 15 digits, and at a 13rem track the
     value's `overflow-wrap` broke it across two lines as 310123456700 / 003.
     A wrapped number on a legal disclosure is a WRONG number, not a layout nit. */
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: var(--space-1) var(--space-6);
  font-size: var(--font-size-caption);
  color: var(--text-secondary);
}
.seller-legal-block__item {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  min-inline-size: 0;
}
.seller-legal-block__label {
  flex-shrink: 0;
  color: var(--text-muted);
}
.seller-legal-block__value {
  color: var(--text-primary);
  unicode-bidi: plaintext;
  text-align: start;
  min-inline-size: 0;
  overflow-wrap: anywhere;
}
.seller-legal-block__value--num {
  direction: ltr;
  unicode-bidi: isolate;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}
/* Dark-field variant. neutral-500 would be 4.0:1 on neutral-900 — below the
   4.5:1 floor for this size — so labels take neutral-400 (6.4:1) and values
   take neutral-100. */
.sf-footer .seller-legal-block {
  color: var(--color-neutral-300);
}
.sf-footer .seller-legal-block__label {
  color: var(--color-neutral-400);
}
.sf-footer .seller-legal-block__value {
  color: var(--color-neutral-100);
}

/* --------------------------------------------------------------------------
   C10. Motion, forced-colors and focus repairs
   -------------------------------------------------------------------------- */

/* ---- Motion -------------------------------------------------------------- */
/* Every transition and every scroll animation in this file lives inside this
   block. Nothing above declares `transition`, so an OS-level "reduce motion"
   setting yields a completely still interface with no override needed. */
@media (prefers-reduced-motion: no-preference) {
  .pdp-gallery__thumb,
  .variant-chip,
  .qty-stepper__btn,
  .review-card__action,
  .sf-header__action,
  .sf-header__suggest-item,
  .star-input__option,
  .cart-line__remove,
  .shop-attribution__link {
    transition:
      background-color var(--duration-fast) var(--ease-standard),
      border-color var(--duration-fast) var(--ease-standard),
      color var(--duration-fast) var(--ease-standard),
      box-shadow var(--duration-fast) var(--ease-standard);
  }
  .pdp-gallery__dot {
    transition:
      background-color var(--duration-fast) var(--ease-standard),
      inline-size var(--duration-base) var(--ease-standard);
  }
  .pdp-gallery__main {
    scroll-behavior: smooth;
  }
  .sf-header__nav-inner {
    scroll-behavior: smooth;
  }
  .pdp-layout__full {
    scroll-behavior: smooth;
  }
}

/* Belt-and-braces for the base library, landed here because these are the
   screens where it shows: an infinite background-position shimmer under a full
   page of product skeletons is exactly the pattern that triggers vestibular
   symptoms, and it runs for as long as the request is in flight. Spinners are
   deliberately NOT stopped — a still spinner is a broken progress indicator,
   and rotation of a small element is not the offending motion class. */
@media (prefers-reduced-motion: reduce) {
  .skeleton {
    animation: none;
    background: var(--bg-surface-sunken);
  }
  .progress--indeterminate .progress__bar {
    animation: none;
  }
  .modal {
    animation: none;
  }
  .toast {
    animation: none;
  }
  .pdp-gallery__main,
  .sf-header__nav-inner {
    scroll-behavior: auto;
  }
}

/* ---- Focus radius repair ------------------------------------------------- */
/* The base `:focus-visible` rule (§1) sets `border-radius: var(--radius-xs)` on
   whatever it hits, which is fine for a bare link but visibly snaps a pill or a
   0.75rem storefront control to a 4px corner the instant it is tabbed to — the
   control changes SHAPE on focus. Restore each shape explicitly. */
.variant-chip:focus-visible,
.qty-stepper__btn:focus-visible,
.sf-header__scope:focus-visible,
.sf-header__search .input:focus-visible {
  border-radius: var(--control-radius);
}
.pdp-gallery__thumb:focus-visible,
.sf-header__action:focus-visible,
.sf-header__suggest-item:focus-visible {
  border-radius: var(--radius-md);
}
.pdp-gallery__dot:focus-visible,
.review-card__action:focus-visible,
.shop-attribution__link:focus-visible,
.cart-line__remove:focus-visible {
  border-radius: var(--radius-full);
}

/* Every focusable block in this file uses the standard ring: 2px, --focus-ring,
   and offset 2px so the ring sits on the BACKGROUND its contrast was measured
   against rather than on top of the control's own fill. */
.pdp-gallery__thumb:focus-visible,
.pdp-gallery__dot:focus-visible,
.variant-chip:focus-visible,
.qty-stepper__btn:focus-visible,
.buy-box__add:focus-visible,
.buy-box__checkout:focus-visible,
.cart-summary__cta:focus-visible,
.cart-line__remove:focus-visible,
.review-card__action:focus-visible,
.review-summary__link:focus-visible,
.shop-attribution__link:focus-visible,
.sf-header__action:focus-visible,
.sf-header__suggest-item:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

/* ---- Forced colors ------------------------------------------------------- */
/* Windows High Contrast flattens every custom colour, so anything that carried
   state through fill or hue alone loses it. Re-express those states as system
   colours and as borders/outlines, which forced-colors preserves. */
@media (forced-colors: active) {
  .pdp-gallery__thumb {
    border-color: ButtonBorder;
  }
  .pdp-gallery__thumb.is-active,
  .pdp-gallery__thumb[aria-current='true'] {
    border-color: Highlight;
    outline: 2px solid Highlight;
    outline-offset: -4px;
    box-shadow: none;
  }
  .pdp-gallery__dot {
    background: ButtonBorder;
  }
  .pdp-gallery__dot[aria-current='true'] {
    background: Highlight;
  }

  .variant-chip {
    border-color: ButtonBorder;
  }
  .variant-chip[aria-pressed='true'],
  .variant-chip.is-selected {
    border-color: Highlight;
    outline: 2px solid Highlight;
    outline-offset: -4px;
  }
  /* GrayText is the only reliable "unavailable" signal here; the diagonal
     strike is kept as the second, non-colour channel. */
  .variant-chip[aria-disabled='true'] {
    color: GrayText;
    border-color: GrayText;
  }
  .variant-chip[aria-disabled='true']::before {
    background: none;
    border-block-end: 1px solid GrayText;
  }

  /* Solid vs hollow geometry survives the flatten; forcing the fill off would
     destroy it, so opt the stars out of adjustment entirely. */
  .review-stars__star--on,
  .star-input__option:has(input:checked) .icon,
  .star-input__option.is-on .icon {
    forced-color-adjust: none;
  }

  /* Subtle tinted backgrounds vanish; the border is what remains. */
  .review-card__action[aria-pressed='true'],
  .cart-line__error,
  .shop-attribution,
  .buy-box__stock--low,
  .buy-box__stock--out {
    border: 1px solid CanvasText;
  }

  .cart-summary__total {
    border-block-start-color: CanvasText;
  }
  .shop-header-band {
    border-block-start-color: Highlight;
  }
  .sf-header,
  .sf-header__nav {
    border-block-end-color: CanvasText;
  }
}

/* --------------------------------------------------------------------------
   21. POS kiosk: shell, command bar, entry, cart, totals, pay, status
   --------------------------------------------------------------------------
   The till is the one screen in the template that is NOT a document. It is a
   fixed-height appliance: the viewport is the frame, and exactly one region
   scrolls (the cart). Everything else is a fixed row or column, so the pay
   control and the grand total are unconditionally visible at every supported
   size. That invariant is the whole point of this section — see
   POS-REBUILD-PLAN.md §1 for the measured failure it replaces.

   Two rules make it hold, and both are load-bearing:

   (a) EVERY flexible track is minmax(0, 1fr) and every zone carries
       min-inline-size / min-block-size: 0. A grid item's min-width defaults to
       auto = its min-content width; the old .pos-layout inherited a 658.7px
       floor from the cart <table> (.table has min-width: 32rem and cells are
       nowrap, §9) and overflowed every viewport from 910px to 1120px.

   (b) The cart is a LIST, not a <table>. §9's min-width and nowrap are correct
       for a report and fatal for a till. That is why .pos-cart__scroll holds
       <li class="pos-line"> and not <tr>.

   Corollary, enforced by having nowhere to put one: no banner may insert a
   shell row. Blocking conditions surface in the readiness pill, on the pay
   button's own label, or in a modal — never as a strip that shoves the cart
   down.
   -------------------------------------------------------------------------- */

[data-pos-kiosk] {
  /* Query container for the large-text layout in §27. `size`, not inline-size:
     the constraint that starves the cart at 200% text is HEIGHT, and a 1440x900
     till has plenty of width while having none of the height it needs. The
     block-size below is 100dvh — definite, and independent of its contents —
     which is what makes size containment safe here.
     It sits on the BODY rather than on .pos-shell because a container query can
     only style DESCENDANTS: the height variables it needs to shrink are declared
     on this element and consumed by .pos-shell's own grid, so a container one
     level down could never reach them. A first version put it on .pos-shell and
     the variable overrides silently did nothing. */
  container-type: size;
  container-name: posshell;
  block-size: 100dvh;
  overflow: hidden;
  /* An Android POS terminal's pull-to-refresh would discard the in-memory
     cart, which PRD 6.9 requires be preserved across a disconnect. */
  overscroll-behavior: none;
  background: var(--bg-canvas);
}

.pos-shell {
  block-size: 100dvh;
  display: grid;
  grid-template-rows: var(--pos-bar-h) minmax(0, 1fr) var(--pos-status-h);
}

/* --- Command bar (zone A) - replaces the 29-link back-office rail --------- */
.pos-bar {
  grid-row: 1;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding-inline: var(--space-3);
  min-inline-size: 0;
  background: var(--color-neutral-900);
  color: var(--color-neutral-0);
}
/* --- D4: surviving 200% TEXT zoom -------------------------------------------
   WCAG 1.4.4 requires text to scale to 200% without loss of content or
   functionality. At 200% the command bar overflowed the document by 175px at
   1024x768 and by 431px at 768x1024 — both supported sizes — because it is a
   non-wrapping flex row whose children do not shrink.

   A media query CANNOT fix this. Font-relative units in a media query are
   resolved against the INITIAL font size, not the user's, so `@media
   (max-width: 62em)` sees the same 1024px whether text is at 100% or 200%. A
   container query is the one mechanism that does see it: inside `@container`,
   `em` resolves against the QUERY CONTAINER'S OWN font size, so a 1024px bar is
   64em at 100% text and 32em at 200%.

   What gets dropped, in order, is decided by what a cashier loses without:
   the brand word (the mark stays), then the branch and operator names. The
   readiness pill is never dropped at any size — it is the control that says
   whether this till may legally sell, and a cashier who cannot see it is the
   failure this whole screen exists to prevent. */
.pos-bar {
  container-type: inline-size;
  container-name: posbar;
  overflow-x: auto;
  scrollbar-width: none;
}
.pos-bar::-webkit-scrollbar {
  display: none;
}

@container posbar (max-width: 52em) {
  .pos-bar__brand span:last-child {
    display: none;
  }
}
@container posbar (max-width: 42em) {
  .pos-bar__fact {
    display: none;
  }
}
/* Past that there is nothing left to drop that a till can spare, so the bar
   scrolls rather than pushing the document sideways. WCAG 1.4.10 permits
   two-dimensional scrolling for parts that need it; a toolbar that has already
   shed every optional label qualifies, and it beats an unreachable Lock.

   This sits on the base rule, NOT inside a @container block: a container query
   styles its DESCENDANTS and can never style the container itself, so the
   first version of this was a no-op that measured as a 55px improvement and
   read as a fix. At normal text size there is nothing to scroll and it costs
   nothing. */

.pos-bar__brand {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-weight: var(--font-weight-semibold);
  font-size: var(--font-size-body-sm);
  white-space: nowrap;
}
.pos-bar__fact {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  font-size: var(--font-size-caption);
  color: var(--text-muted-on-dark);
  white-space: nowrap;
  min-inline-size: 0;
}
.pos-bar__fact b {
  font-weight: var(--font-weight-medium);
  color: var(--color-neutral-0);
}
.pos-bar__spacer {
  flex: 1 1 auto;
  min-inline-size: 0;
}
.pos-bar .btn--ghost,
.pos-bar .icon-btn {
  color: var(--color-neutral-0);
}
.pos-bar .btn--ghost:hover,
.pos-bar .icon-btn:hover {
  background: var(--color-neutral-800);
}

/* Readiness pill. The one thing in the bar allowed to shout, because an
   expired CSID rolls the whole sale back INCLUDING the stock decrement - the
   cashier must learn that before building a 20-line basket, not at pay. */
.pos-ready {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  min-block-size: var(--tap-target-min);
  padding-inline: var(--space-3);
  border-radius: var(--radius-full);
  font-size: var(--font-size-caption);
  font-weight: var(--font-weight-medium);
  white-space: nowrap;
  /* FAILS CLOSED. The base treatment is the neutral "cannot determine" one, and
     only a recognised value opts into green (see §24). Previously the base was
     unconditional success-green, so an unrecognised or absent
     data-device-ready rendered a solid green pill with no text at all — the
     screen asserting a device is fine on no evidence, which is the one thing
     §24 exists to prevent. */
  background: var(--color-neutral-700);
  color: var(--color-neutral-0);
}

/* --- Work area (grid row 2) ----------------------------------------------
   The state root lives here, and it is the ONLY [data-state-root] on the page:
   prototype.js initStateSwitchers inserts a .proto-bar before EVERY state
   root, in flow, so nested roots would silently eat the height budget. Variant
   axes (fiscal mode, UoM, ZATCA status, printer, connectivity) are root-level
   data-* attributes driven by CSS instead. */
.pos-work-wrap {
  grid-row: 2;
  min-block-size: 0;
  min-inline-size: 0;
}
.pos-work-wrap > [data-state-root],
.pos-work-wrap > [data-state-root] > [data-state] {
  block-size: 100%;
  min-block-size: 0;
}

/* BASE = portrait / narrow: everything stacks in one column. */
.pos-work {
  display: grid;
  min-block-size: 0;
  min-inline-size: 0;
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: var(--pos-entry-h) var(--pos-tiles-h) minmax(0, 1fr) auto auto;
  grid-template-areas: 'entry' 'catalogue' 'cart' 'totals' 'pay';
}
.pos-entry {
  grid-area: entry;
}
.pos-catalogue {
  grid-area: catalogue;
}
.pos-cart {
  grid-area: cart;
}
.pos-totals {
  grid-area: totals;
}
.pos-pay {
  grid-area: pay;
}
.pos-work > * {
  min-inline-size: 0;
  min-block-size: 0;
}

/* B1 - landscape at or above the PRD's 768px hard minimum: two columns. The
   till column is deliberately the NARROW one; the catalogue takes the larger
   area, which is the conventional retail-POS arrangement. */
@media (min-width: 48rem) and (orientation: landscape) {
  .pos-work {
    grid-template-columns: var(--pos-till-w) minmax(0, 1fr);
    grid-template-rows: var(--pos-entry-h) minmax(0, 1fr) auto auto;
    grid-template-areas:
      'entry     catalogue'
      'cart      catalogue'
      'totals    catalogue'
      'pay       catalogue';
  }
}

/* --- Entry strip (zone B) ------------------------------------------------ */
.pos-entry {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  background: var(--bg-surface);
  border-block-end: 1px solid var(--border-default);
}
.pos-entry .input {
  flex: 1 1 auto;
  min-inline-size: 0;
}
/* Barcodes are Latin even on an Arabic page, but the PLACEHOLDER is Arabic, so
   the value is forced LTR while the empty field stays in the page direction -
   which keeps the Arabic prompt right-aligned under its label. */
.pos-entry__scan {
  direction: ltr;
  text-align: start;
  font-family: var(--font-mono);
}
.pos-entry__scan:placeholder-shown {
  direction: inherit;
  font-family: var(--font-sans);
}

/* --- Cart (zone C) - the only scrolling region --------------------------- */
/* Three rows: head, an auto row for the blocked banner, then the scroll region.
   The `auto` row is load-bearing — adding the banner as a third child without
   extending this template put it in the 1fr and left the list to an implicit
   row, so the banner painted straight over the cart lines. (Second time this
   exact mistake has bitten: see .pos-catalogue.) The banner living INSIDE
   .pos-cart is deliberate: .pos-cart is itself the flexible track of .pos-work,
   so the banner can only shrink the cart's scroll area — it can never push the
   pay control off screen. */
.pos-cart {
  display: grid;
  grid-template-rows: var(--pos-cart-head-h) auto minmax(0, 1fr);
  min-block-size: 0;
  background: var(--bg-surface);
  border-inline-end: 1px solid var(--border-default);
}
/* EXPLICIT row placement, not auto-placement. `.pos-blocked` is display:none
   until a device blocks, and a display:none grid item leaves the grid entirely —
   so the cart list slid up into the `auto` row and sized itself to its content
   while the 1fr row sat empty. That made the scroll region as tall as the
   basket: three lines gave a 180px cart inside a 702px column. This is the same
   family of bug as adding a child without extending grid-template-rows, and
   naming the rows kills it permanently. */
.pos-cart__head {
  grid-row: 1;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding-inline: var(--space-3);
  font-size: var(--font-size-micro);
  font-weight: var(--font-weight-semibold);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-muted);
  background: var(--bg-surface-sunken);
  border-block-end: 1px solid var(--border-default);
}
.pos-cart__count {
  margin-inline-start: auto;
  font-variant-numeric: tabular-nums;
}
/* The query container for the cart line's stacking rule. */
.pos-cart__scroll {
  container-type: inline-size;
  container-name: poscart;
  grid-row: 3;
  overflow-y: auto;
  overflow-x: hidden;
  min-block-size: 0;
  scrollbar-width: thin;
  overscroll-behavior: contain;
}

.pos-line {
  /* position:relative is load-bearing: ::before draws the selection bar, and
     without it the bar would resolve against the scroll container and paint
     once for the whole list instead of per line. */
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  align-items: center;
  gap: var(--space-2);
  min-block-size: var(--pos-line-h);
  padding-inline: var(--space-3);
  padding-block: var(--space-1);
  border-block-end: 1px solid var(--border-default);
}
/* The whole name/meta block is the select target - a cashier taps the item, not
   a chevron. It is a <button> rather than a click handler on the <li> so that
   the qty and remove controls stay siblings: nesting them inside would be
   invalid markup and would swallow their own clicks. */
.pos-line__main {
  min-inline-size: 0;
  display: block;
  inline-size: 100%;
  min-block-size: var(--tap-target-min);
  padding: 0;
  text-align: start;
  background: none;
  border: 0;
  color: inherit;
  font: inherit;
  cursor: pointer;
}
/* Name + status flags share a row so the name can ellipsize while a badge keeps
   its full width. The badge belongs here rather than in the meta row: the till
   column leaves ~186px for this block, and a meta row carrying SKU + price +
   struck list price + badge needs ~212px, which wrapped and split the SKU
   across two lines. */
.pos-line__title {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  min-inline-size: 0;
}
.pos-line__name {
  flex: 1 1 auto;
  min-inline-size: 0;
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-medium);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.pos-line__meta {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-inline-size: 0;
  overflow: hidden;
  font-size: var(--font-size-micro);
  color: var(--text-muted);
}
/* Never let a SKU or an amount break mid-token — a hyphenated SKU split across
   two lines is unreadable and silently changes the row height. */
.pos-line__meta > * {
  white-space: nowrap;
}
/* Ordering of who gives way matters more than it looks. Money must NEVER be
   partially rendered: clipping "ر.س 110.00" mid-glyph produced a struck-through
   "0.00", i.e. a price the data does not support — the same defect class the
   review caught eight times on the old screen. So the SKU is the only element
   allowed to shrink, and it ellipsizes honestly; the prices are rigid. */
.pos-line__meta > .font-mono {
  flex: 0 1 auto;
  min-inline-size: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
.pos-line__meta > span {
  flex: 0 0 auto;
}
.pos-line__total {
  font-variant-numeric: tabular-nums;
  font-weight: var(--font-weight-semibold);
  white-space: nowrap;
}

/* Promo pricing. effectivePrice() returns { effectiveMinor, listMinor, onSale },
   and a cashier asked "why is this cheaper?" needs the list price visible, not
   just a lower number. line-through alone is not a status - the badge carries
   the meaning for anyone who cannot see the strike. */
.pos-line__was {
  text-decoration: line-through;
  color: var(--text-muted);
}
.pos-line__flag {
  display: inline-flex;
  align-items: center;
  padding-inline: var(--space-1);
  border-radius: var(--radius-xs);
  font-size: var(--font-size-micro);
  font-weight: var(--font-weight-medium);
  white-space: nowrap;
}
.pos-line__flag--sale {
  background: var(--bg-danger-subtle);
  color: var(--text-danger);
}
.pos-line__flag--stock {
  background: var(--bg-warning-subtle);
  color: var(--text-warning);
}
/* .pos-line__flag--out is deleted rather than kept "just in case". Nothing can
   produce a cart line for a zero-stock product — addToCart refuses one, and the
   tile for it is disabled — so the rule matched nothing in any reachable state.
   If server-side stock ever drops to zero for a line already in the basket, the
   flag belongs in lineHtml in the SAME commit as the rule that styles it; a rule
   waiting for markup that may never come is indistinguishable from a rule whose
   markup was deleted. */
/* Selection. The bar is an inline-start edge marker, mirrored automatically
   because it is drawn with inset-inline-start. */
.pos-line.is-selected {
  background: var(--bg-brand-subtle);
}
.pos-line.is-selected::before {
  content: '';
  position: absolute;
  inset-inline-start: 0;
  inset-block: 0;
  inline-size: 4px;
  background: var(--action-brand-default);
}

/* Line actions open INSIDE the cart's scroll region, so they consume scroll
   space rather than shell height - the pay control cannot move. */
/* At 200% text the three-column line stops fitting a 400px till column: the
   quantity stepper and the measure input take their content width, squeezing the
   name to nothing and pushing the line total 83px outside its own box at
   800x600. A clipped total does not read as broken, it reads as a different and
   smaller amount, which is the worst way for money to fail.

   So past a point the line stacks: the product on its own row, the controls and
   the total beneath it. A container query, not a media query, for the same
   reason as the command bar — `em` inside @container resolves against the
   container's font size and so tracks text zoom, which is the thing that
   actually changed. */
@container poscart (max-width: 14em) {
  .pos-line {
    grid-template-columns: minmax(0, 1fr) auto;
  }
  .pos-line__main {
    grid-column: 1 / -1;
  }
}

.pos-line__actions {
  grid-column: 1 / -1;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-2);
  padding-block: var(--space-2);
  border-block-start: 1px dashed var(--border-default);
}
.pos-line__disc {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}
.pos-line__disc-input {
  inline-size: 5.5rem;
  direction: ltr;
  text-align: end;
}
/* The contract takes an absolute amount in halalas, but the ROLE CAP is
   expressed in basis points against the line - so an amount alone tells the
   cashier nothing about whether they are allowed to apply it. Both are shown. */
.pos-line__cap {
  font-size: var(--font-size-micro);
  color: var(--text-muted);
  white-space: nowrap;
}
/* Hitting the stock ceiling is not the same problem as an over-cap discount, so
   it gets its own signal. It is a momentary "there is no more of this" rather
   than a state the sale is stuck in, so it flashes the LINE rather than marking
   the discount field. */
.pos-line.is-stock-capped {
  animation: pos-stock-flash 1.2s ease-out;
}
@keyframes pos-stock-flash {
  0%,
  60% {
    background: var(--bg-warning-subtle);
  }
  100% {
    background: transparent;
  }
}
@media (prefers-reduced-motion: reduce) {
  .pos-line.is-stock-capped {
    animation: none;
    background: var(--bg-warning-subtle);
  }
}

.pos-line.is-over-cap .pos-line__cap {
  color: var(--text-warning);
  font-weight: var(--font-weight-medium);
}
.pos-line.is-over-cap .pos-line__disc-input {
  border-color: var(--border-warning);
  border-width: 2px;
}

/* Pending removal. Only the line CONTENT fades - the undo control stays at full
   contrast, because it is the one thing in this state the cashier must be able
   to read and is an enabled control (so it gets no opacity exemption). */
.pos-line.is-removing .pos-line__main,
.pos-line.is-removing .pos-qty,
.pos-line.is-removing .pos-line__total {
  opacity: 0.45;
}
.pos-line.is-removing .pos-line__name {
  text-decoration: line-through;
}

.pos-line.is-new {
  animation: pos-line-flash var(--duration-slow) var(--ease-decelerate);
}
@keyframes pos-line-flash {
  from {
    background: var(--bg-info-subtle);
  }
  to {
    background: transparent;
  }
}

/* Scan feedback. A cashier is looking at the customer, not the screen, so the
   entry strip itself changes colour - a message alone is not enough. */
.pos-entry__msg {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  font-size: var(--font-size-caption);
  font-weight: var(--font-weight-medium);
  white-space: nowrap;
}
.pos-entry.is-not-found {
  background: var(--bg-danger-subtle);
}
.pos-entry.is-not-found .pos-entry__msg {
  color: var(--text-danger);
}
.pos-entry.is-found {
  background: var(--bg-success-subtle);
}
.pos-entry.is-found .pos-entry__msg {
  color: var(--text-success);
}

.pos-cart__empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  block-size: 100%;
  padding: var(--space-6);
  text-align: center;
  color: var(--text-muted);
  font-size: var(--font-size-body-sm);
}

/* --- Totals (zone D, part 1) --------------------------------------------- */
.pos-totals {
  padding: var(--space-2) var(--space-3) 0;
  background: var(--bg-surface);
  border-block-start: 1px solid var(--border-default);
}
.pos-totals__row {
  display: flex;
  justify-content: space-between;
  gap: var(--space-3);
  padding-block: 2px;
  font-size: var(--font-size-body-sm);
  color: var(--text-secondary);
}
.pos-totals__row--discount {
  color: var(--text-success);
}
.pos-totals__row--grand {
  font-size: var(--font-size-h4);
  font-weight: var(--font-weight-bold);
  color: var(--text-primary);
  border-block-start: 2px solid var(--border-strong);
  margin-block-start: var(--space-1);
  padding-block-start: var(--space-2);
}
/* B4 - short viewports collapse the breakdown to the grand total alone, so the
   cart keeps its 6 lines. Keyed to HEIGHT rather than orientation, because an
   orientation-keyed rule leaves short portrait viewports showing 3 lines.
   Two thresholds, not one: portrait also pays 176px for the tiles row, so it
   has to collapse ~130px earlier than landscape. A single threshold cannot
   serve both - at 800px tall it either strips the VAT breakdown off a 1280x800
   desktop that has room for it, or starves the cart on a 768x800 tablet. */
@media (max-height: 799px), (max-height: 880px) and (orientation: portrait) {
  /* The DISCOUNT row survives the collapse when it is non-zero. It was being
     hidden at 1024x768 and 1024x600 — the two commonest POS resolutions — so a
     cashier could not confirm a discount they had just applied, on the majority
     of real tills. The subtotal goes instead: it is derivable from the lines,
     and its label disagrees with the contract's own `subtotalMinor` anyway. */
  .pos-totals__row:not(.pos-totals__row--grand):not(.pos-totals__row--discount) {
    display: none;
  }
  .pos-totals__row--discount.is-zero {
    display: none;
  }
  .pos-totals__row--grand {
    border-block-start: 0;
    margin-block-start: 0;
    padding-block-start: 0;
  }
}

/* --- Pay (zone D, part 2) - never scrolls, always carries the amount ------ */
.pos-pay {
  padding: var(--space-3);
  background: var(--bg-surface);
}
.pos-pay__btn {
  inline-size: 100%;
  min-block-size: var(--pos-pay-h);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);
  font-size: var(--font-size-h4);
  font-weight: var(--font-weight-bold);
}
.pos-pay__amount {
  font-variant-numeric: tabular-nums;
}

/* --- Catalogue (zone E) - also the search-results surface ---------------- */
/* Three rows: search, category strip, scrolling body. The 1fr MUST stay on the
   last row — when the category strip was added as a third child without
   updating this, the strip inherited the 1fr and absorbed every spare pixel,
   floating the chips in the middle of an empty column. */
.pos-catalogue {
  position: relative;
  display: grid;
  grid-template-rows: auto auto minmax(0, 1fr);
  min-block-size: 0;
  background: var(--bg-canvas);
}
/* The catalogue is a fixed-height zone holding an open-ended list, so in
   portrait it almost always cuts a row in half at its bottom edge. Without an
   affordance that clipping reads as a broken layout rather than "there is more
   below". The cart's 6-line floor outranks giving this zone more height, so it
   gets the fade instead. On ::after deliberately: a background-image on the
   zone itself would make the contrast auditor skip every label inside it. */
.pos-catalogue::after {
  content: '';
  position: absolute;
  inset-inline: 0;
  inset-block-end: 0;
  block-size: var(--space-5);
  background: linear-gradient(to top, var(--bg-canvas), transparent);
  pointer-events: none;
}
.pos-catalogue__bar {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  overflow-x: auto;
  scrollbar-width: none;
  border-block-end: 1px solid var(--border-default);
}
.pos-catalogue__bar::-webkit-scrollbar {
  display: none;
}
.pos-catalogue__body {
  /* The query container for the tile grid. Named, so a future container higher
     up cannot accidentally capture the query. */
  container-type: inline-size;
  container-name: poscat;
  overflow-y: auto;
  min-block-size: 0;
  padding: var(--space-3);
  scrollbar-width: thin;
}
/* B5 - short portrait: the tile row shrinks to its category strip so the cart
   keeps its 6 lines. Height-keyed for the same reason as B4. */
@media (max-height: 880px) and (orientation: portrait) {
  [data-pos-kiosk] {
    --pos-tiles-h: 2.75rem;
  }
  .pos-catalogue__body {
    display: none;
  }
}

/* B6 - COMPACT tier for very short terminals (1024x600 and similar cheap
   Android POS hardware). Every fixed row gives back height so the cart still
   clears 6 lines; the line itself drops to 52px, which is exactly a 44px
   control plus its block padding, so touch targets are unaffected. Without
   this tier 1024x600 shows 4 lines. */
@media (max-height: 640px) {
  /* At this height the discount row cannot be afforded as well: keeping it costs
     ~24px and drops the cart from 6 whole lines to 5. Six lines wins — the
     cashier can still open the line to see its discount, but they cannot conjure
     cart space. The row survives everywhere there is room for it, including
     1024x768, the commonest POS resolution. */
  .pos-totals__row--discount {
    display: none;
  }
  [data-pos-kiosk] {
    --pos-bar-h: 3rem;
    --pos-status-h: 2.25rem;
    --pos-entry-h: 3.25rem;
    --pos-cart-head-h: 1.5rem;
    --pos-pay-h: 3.5rem;
    --pos-line-h: 3.25rem;
  }
  .pos-pay {
    padding: var(--space-2) var(--space-3);
  }
  .pos-pay__btn {
    font-size: var(--font-size-body);
  }
  .pos-totals__row--grand {
    font-size: var(--font-size-body);
  }
}

.pos-results {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
.pos-result {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  inline-size: 100%;
  min-block-size: var(--tap-target-min);
  padding: var(--space-2) var(--space-3);
  text-align: start;
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
}
.pos-result:hover {
  background: var(--bg-surface-sunken);
}
.pos-result__name {
  display: block;
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-medium);
}
.pos-result__sku {
  font-size: var(--font-size-micro);
  color: var(--text-muted);
}
.pos-result__price {
  font-variant-numeric: tabular-nums;
  font-weight: var(--font-weight-semibold);
  white-space: nowrap;
}

/* --- Status strip (zone F) ----------------------------------------------- */
.pos-status {
  grid-row: 3;
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding-inline: var(--space-3);
  min-inline-size: 0;
  overflow-x: auto;
  scrollbar-width: none;
  background: var(--bg-surface-sunken);
  border-block-start: 1px solid var(--border-default);
  font-size: var(--font-size-caption);
  color: var(--text-secondary);
}
.pos-status::-webkit-scrollbar {
  display: none;
}
.pos-status__spacer {
  flex: 1 1 auto;
  min-inline-size: 0;
}
.pos-status__fact {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  white-space: nowrap;
}

/* --- Full-area states (loading / no-device / error / sale complete) ------- */
.pos-panel {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);
  block-size: 100%;
  overflow-y: auto;
  padding: var(--space-6);
  text-align: center;
}
.pos-panel__inner {
  inline-size: 100%;
  max-inline-size: 28rem;
}

/* --- Prototype chrome inside the kiosk ------------------------------------
   The state switcher is INJECTED into the flow by prototype.js, which would
   otherwise steal a row from the very budget this section exists to protect.
   :has() targets only the generated bar; authored .proto-bar demo strips stay
   in normal flow inside scrollable regions, where they cost nothing. */
[data-pos-kiosk] .proto-bar:has([data-generated-switcher]) {
  position: fixed;
  inset-block-end: var(--space-2);
  inset-inline-start: var(--space-2);
  z-index: var(--z-sticky-cta);
  margin: 0;
  box-shadow: var(--shadow-e3);
}

/* --------------------------------------------------------------------------
   22. POS catalogue: category strip, product tiles, measure entry
   --------------------------------------------------------------------------
   The tile grid is the single reason the old screen did not read as a till. Any
   shop selling unbarcoded goods — produce, bakery, cafe, services — cannot
   transact without it, because there is nothing to scan.

   No product imagery: the platform has no object-storage adapter yet (see
   HANDOFF.md), so a tile that promised a photo would be promising something the
   product cannot deliver. Tiles use the same monogram convention as the
   storefront cards until images exist.
   -------------------------------------------------------------------------- */

.pos-cats {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: 0 var(--space-3) var(--space-2);
  overflow-x: auto;
  scrollbar-width: none;
}
.pos-cats::-webkit-scrollbar {
  display: none;
}
.pos-cat {
  flex: 0 0 auto;
  min-block-size: var(--tap-target-min);
  padding-inline: var(--space-3);
  border-radius: var(--radius-full);
  border: 1px solid var(--border-default);
  background: var(--bg-surface);
  color: var(--text-secondary);
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-medium);
  white-space: nowrap;
  cursor: pointer;
}
.pos-cat:hover {
  background: var(--bg-surface-sunken);
}
.pos-cat[aria-pressed='true'] {
  background: var(--action-brand-default);
  border-color: var(--action-brand-default);
  color: var(--text-on-brand);
}

/* EXPLICIT TRACKS, never auto-fill — the house rule from section 7. A till has
   a known column count per breakpoint; auto-fill would reflow the grid under
   the cashier's finger as the cart pane changes width. */
/* The column count asks the CATALOGUE how wide it is, not the viewport, and asks
   in `em` so the answer changes with text size. The media queries this replaces
   could not see 200% text zoom — at 1024x768 they still chose three columns,
   which at 200% made each column 37px wide against ~86px of content, cutting
   every monogram, name and price by 44-49px. A tile whose price is half painted
   is worse than no tile.

   Asking the container also happens to be more correct at 100%: the catalogue is
   ~600px on a 1024 landscape till but ~736px on a 768 portrait one, so the old
   viewport-keyed rule gave the WIDER strip fewer columns. */
.pos-tiles {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-2);
}
@container poscat (min-width: 36em) {
  .pos-tiles {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}
@container poscat (min-width: 60em) {
  .pos-tiles {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }
}

.pos-tile {
  position: relative;
  display: grid;
  grid-template-rows: auto 1fr auto;
  gap: var(--space-1);
  min-block-size: 5.5rem;
  padding: var(--space-2);
  text-align: start;
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  cursor: pointer;
}
.pos-tile:hover {
  background: var(--bg-surface-sunken);
  border-color: var(--border-strong);
}
.pos-tile__monogram {
  display: flex;
  align-items: center;
  justify-content: center;
  inline-size: 2rem;
  block-size: 2rem;
  border-radius: var(--radius-sm);
  background: var(--bg-brand-subtle);
  color: var(--text-brand);
  font-weight: var(--font-weight-bold);
  /* A single glyph of unknown script — isolate so a Latin initial inside an
     Arabic tile cannot reorder against its neighbours. */
  unicode-bidi: isolate;
}
.pos-tile__name {
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-medium);
  line-height: var(--line-height-snug);
  /* Two lines then clamp: a till tile that grows to fit a long name breaks the
     grid's rhythm and moves every other tile under the cashier's finger. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.pos-tile__price {
  font-variant-numeric: tabular-nums;
  font-weight: var(--font-weight-semibold);
  font-size: var(--font-size-body-sm);
}
.pos-tile__flag {
  position: absolute;
  inset-block-start: var(--space-1);
  inset-inline-end: var(--space-1);
  padding-inline: var(--space-1);
  border-radius: var(--radius-xs);
  font-size: var(--font-size-micro);
  font-weight: var(--font-weight-medium);
}
.pos-tile__flag--sale {
  background: var(--bg-danger-subtle);
  color: var(--text-danger);
}
/* A variant group shows how many options sit behind the tile, so a cashier
   knows the tap opens a picker instead of dropping a line straight in. */
.pos-tile__flag--variants {
  background: var(--bg-info-subtle);
  color: var(--text-info);
}
.pos-tile__flag--out {
  background: var(--action-danger-default);
  color: var(--color-neutral-0);
}
/* The dimming is applied to the tile's PARTS, not to the tile.
   `opacity` on the button composites every descendant with it and a child cannot
   opt out — `.pos-tile__flag--out { opacity: 1 }` inside an `opacity: .55` parent
   still paints at .55, which is why the first attempt at this measured no better.
   Dimming each part individually leaves the "نفد" badge at full strength.

   It is the one label that has to survive: at .55 it measured 2.72:1 against the
   tile — technically inside 1.4.3's inactive-component exception, and technically
   beside the point, because the badge exists to say WHY the tile is disabled.
   Dimming it with everything else defeats the only thing it is for. Its own
   colours measure 5.75:1. */
.pos-tile:disabled {
  cursor: not-allowed;
}
.pos-tile:disabled .pos-tile__monogram,
.pos-tile:disabled .pos-tile__name,
.pos-tile:disabled .pos-tile__price {
  opacity: 0.55;
}

/* --- Measure entry (weight / volume stores) --------------------------------
   uomMode is store-wide, chosen once at setup — PRD 6.3 puts mixed UoM per
   product in v2, and the server applies ONE mode to every line and rejects the
   whole cart otherwise. So this is driven by a root data-uom attribute, not per
   line: a cart that mixes a stepper and a measure input is a cart the product
   cannot accept, which is exactly what the old screen drew. */
/* SCOPED to the till. These five attribute selectors were unscoped and global —
   `[data-uom='unit'] .pos-measure` would have matched any element carrying that
   attribute on any of the 79 screens once these styles ship as one stylesheet.
   The attributes all sit on the POS <body>, which also carries [data-pos-kiosk],
   so the marker is the natural fence. */
[data-pos-kiosk][data-uom='unit'] .pos-measure {
  display: none;
}
[data-pos-kiosk][data-uom='weight'] .pos-qty {
  display: none;
}
.pos-measure {
  display: flex;
  align-items: center;
  gap: var(--space-1);
}
.pos-measure__input {
  inline-size: 4.5rem;
  min-block-size: var(--tap-target-min);
  padding-inline: var(--space-1);
  text-align: end;
  font-variant-numeric: tabular-nums;
  direction: ltr;
}
.pos-measure__unit {
  font-size: var(--font-size-micro);
  color: var(--text-muted);
  white-space: nowrap;
}

/* --- Variant picker ---------------------------------------------------------
   A product-level barcode on a variant-matrix product resolves server-side to
   the lexicographically-first SKU. Without a picker the cashier silently sells
   the wrong size or colour. */
.pos-variants {
  display: grid;
  gap: var(--space-2);
}
.pos-variant {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  min-block-size: var(--tap-target-min);
  padding: var(--space-2) var(--space-3);
  text-align: start;
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  cursor: pointer;
}
.pos-variant:hover {
  background: var(--bg-surface-sunken);
}
.pos-variant__opts {
  display: flex;
  gap: var(--space-2);
  font-size: var(--font-size-body-sm);
  font-weight: var(--font-weight-medium);
}
.pos-variant__stock {
  font-size: var(--font-size-micro);
  color: var(--text-muted);
}

/* --------------------------------------------------------------------------
   23. POS numbers: on-screen keypad, quick-cash, change due
   --------------------------------------------------------------------------
   The keypad is not a convenience. PRD 6.9 targets Android POS terminals, and
   on a kiosked terminal with no soft keyboard there is otherwise no way to
   enter a quantity or a tendered amount at all.

   It is position:fixed, never a grid row. Section 21's whole invariant is that
   only the cart flexes and nothing can push the pay control off screen; a
   keypad docked into the shell would break exactly that. Fixed also lets it sit
   beside the cart rather than on top of it.
   -------------------------------------------------------------------------- */

.keypad {
  position: fixed;
  /* ABOVE --z-modal (40), not --z-sticky-cta (15). The fields the keypad exists
     for — tendered, split amounts, return quantities — all live inside modals,
     and at 15 the pad painted BEHIND the scrim: the tap landed on the backdrop
     and closed the payment dialog. On the kiosked Android terminal §23 is
     written for, that left no way to enter a tendered amount at all. Below
     --z-toast (50) so a toast still wins. */
  z-index: 45;
  display: grid;
  gap: var(--space-2);
  padding: var(--space-3);
  background: var(--bg-surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-e4);
  /* Portrait: under the entry strip. It covers the catalogue and, since the pad
     is taller than the catalogue zone, the TOP OF THE CART too — which is
     acceptable and was previously mis-documented as "never covers the cart".
     What matters is that it never reaches the totals or the pay button, and the
     arithmetic holds: bar + entry = 116px, pad ≈ 368px, so it ends around 484px
     while the totals begin at roughly 735px on the shortest supported portrait. */
  inset-block-start: calc(var(--pos-bar-h) + var(--pos-entry-h) + var(--space-2));
  inset-inline: var(--space-3);
}
@media (min-width: 48rem) and (orientation: landscape) {
  .keypad {
    /* Landscape: the till column is at inline-START, so the pad hugs the far
       side of it — over the catalogue but immediately beside the cart it is
       editing. Anchoring to inset-inline-end put it on the OPPOSITE edge of the
       screen from the cart, which is what the old comment claimed it avoided. */
    inset-block-start: auto;
    inset-inline-end: auto;
    inset-block-end: calc(var(--pos-status-h) + var(--space-3));
    inset-inline-start: calc(var(--pos-till-w) + var(--space-3));
    inline-size: 19rem;
  }
}

.keypad__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-2);
  min-inline-size: 0;
}
.keypad__label {
  font-size: var(--font-size-caption);
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.keypad__value {
  font-size: var(--font-size-h3);
  font-weight: var(--font-weight-bold);
  font-variant-numeric: tabular-nums;
  /* The value readout is a bare numeral, so it is direction-locked LTR even on
     an Arabic page - same rule as SKUs and order ids. */
  direction: ltr;
  white-space: nowrap;
}
/* direction:ltr is deliberate and must NOT be "fixed" to a logical property.
   A numeric keypad is never mirrored in RTL — every phone, calculator and till
   in the Arab world lays out 7-8-9 / 4-5-6 / 1-2-3 left to right, because the
   digits themselves are Western and read LTR. Left to the page direction the
   grid renders 9-8-7 and a cashier's muscle memory breaks. Same reasoning as
   the LTR-locked money and SKU runs elsewhere in the template. */
.keypad__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-2);
  direction: ltr;
}
.keypad__key {
  min-block-size: 3.25rem;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--font-size-body-lg);
  font-weight: var(--font-weight-semibold);
  font-variant-numeric: tabular-nums;
  background: var(--bg-surface-sunken);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  color: var(--text-primary);
  cursor: pointer;
}
.keypad__key:hover {
  background: var(--bg-surface-hover);
}
.keypad__key:active {
  background: var(--bg-brand-subtle);
}
.keypad__key--fn {
  font-size: var(--font-size-body);
  color: var(--text-secondary);
}
.keypad__actions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-2);
}

/* Quick cash. The denominations are the notes that actually circulate in SAR -
   5, 10, 50, 100, 200, 500 - plus "exact". A cashier handed a 100 for a 63.50
   basket taps one button; typing 100.00 on a keypad is three unnecessary taps
   and a chance to fat-finger a zero. */
/* An explicit 3-column grid, not wrapping flex: with flex the fifth button lands
   alone on the second row and stretches to full width, which reads as a
   different, more important control than the four beside it. */
.cash-quick {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-2);
}
.cash-quick__btn {
  min-inline-size: 0;
  min-block-size: var(--tap-target-min);
  padding-inline: var(--space-3);
  font-variant-numeric: tabular-nums;
  font-weight: var(--font-weight-semibold);
  background: var(--bg-surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  color: var(--text-primary);
  cursor: pointer;
}
.cash-quick__btn:hover {
  background: var(--bg-surface-sunken);
}
.cash-quick__btn--exact {
  background: var(--bg-brand-subtle);
  border-color: var(--border-brand);
  color: var(--text-brand);
}

/* Change due. Shown DURING tender entry, not only on the completed-sale screen:
   the moment a cashier needs it is while counting notes back to the customer. */
.change-due {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-3);
  padding: var(--space-3);
  border-radius: var(--radius-md);
  background: var(--bg-success-subtle);
  color: var(--text-success);
}
.change-due__label {
  font-size: var(--font-size-body);
  font-weight: var(--font-weight-medium);
}
.change-due__amount {
  font-size: var(--font-size-h2);
  font-weight: var(--font-weight-bold);
  font-variant-numeric: tabular-nums;
}
/* Short of the total is not "negative change" - it is an unpaid balance, and
   the sale cannot complete. Different word, different colour. */
.change-due.is-short {
  background: var(--bg-warning-subtle);
  color: var(--text-warning);
}

/* --- Tender modes ----------------------------------------------------------
   Cash, card and split are three different jobs, not one form with optional
   fields: a card payment has no tendered amount and no change, and a split has
   no single amount at all. Showing all three at once is how the old screen ended
   up with a 674px totals pane. */
/* Scoped to the pane rather than to [data-pos-kiosk]: unlike the others, these
   two attributes live on the tender pane itself, not on <body>. Unscoped,
   `[data-tender-view] { display: none }` hid ANY element anywhere carrying that
   attribute — the single most dangerous rule in this file. */
.pos-tender-pane [data-tender-view] {
  display: none;
}
.pos-tender-pane[data-tender-mode='cash'] [data-tender-view='cash'],
.pos-tender-pane[data-tender-mode='card'] [data-tender-view='card'],
.pos-tender-pane[data-tender-mode='split'] [data-tender-view='split'] {
  display: grid;
  gap: var(--space-3);
}

.pos-pay-row {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}
.pos-pay-row .select-wrap {
  flex: 1.2;
  min-inline-size: 0;
}
.pos-pay-row__amount {
  flex: 1;
  min-inline-size: 0;
  direction: ltr;
  text-align: end;
  font-variant-numeric: tabular-nums;
}

/* The running balance. Payment lines must sum EXACTLY to the grand total in
   integer halalas or the server rejects the sale with PAYMENT_TOTAL_MISMATCH —
   so this is not decoration, it is the gate on the charge button. */
.pos-remaining {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-md);
  font-weight: var(--font-weight-semibold);
  font-variant-numeric: tabular-nums;
  background: var(--bg-warning-subtle);
  color: var(--text-warning);
}
.pos-remaining.is-balanced {
  background: var(--bg-success-subtle);
  color: var(--text-success);
}
.pos-remaining.is-over {
  background: var(--bg-danger-subtle);
  color: var(--text-danger);
}

/* --------------------------------------------------------------------------
   24. POS compliance: device readiness, ZATCA submission, fiscal mode
   --------------------------------------------------------------------------
   Every state here is driven by a root data-* attribute rather than a nested
   [data-state-root], for the reason in section 21: prototype.js injects a
   42px switcher bar before EVERY state root, and nine of them would eat the
   height budget the whole rebuild exists to protect.

   The states are not decorative. A VAT-registered tenant whose device is not
   Phase-2 live-ready has its checkout guard run INSIDE the sale transaction,
   so a block discards the sale, every line, every payment line AND the stock
   decrement. The cashier must learn that before the first scan, not after pay.
   -------------------------------------------------------------------------- */

/* --- Device readiness (4 reason codes + unknown + not-applicable) ---------- */
.pos-ready__msg {
  display: none;
}
/* The DEFAULT message is "cannot determine". A value the CSS does not recognise
   — or a missing attribute — must say so rather than render an empty pill; an
   empty pill is indistinguishable from a healthy one at a glance. Recognised
   values switch it off below. */
.pos-ready__unknown {
  display: inline;
}
[data-pos-kiosk][data-device-ready='ready'] .pos-ready__unknown,
[data-pos-kiosk][data-device-ready='not-applicable'] .pos-ready__unknown,
[data-pos-kiosk][data-device-ready='onboarding-incomplete'] .pos-ready__unknown,
[data-pos-kiosk][data-device-ready='device-inactive'] .pos-ready__unknown,
[data-pos-kiosk][data-device-ready='no-csid'] .pos-ready__unknown,
[data-pos-kiosk][data-device-ready='csid-expired'] .pos-ready__unknown {
  display: none;
}
[data-pos-kiosk][data-device-ready='ready'] .pos-ready__ready,
[data-pos-kiosk][data-device-ready='unknown'] .pos-ready__unknown,
[data-pos-kiosk][data-device-ready='not-applicable'] .pos-ready__na,
[data-pos-kiosk][data-device-ready='onboarding-incomplete'] .pos-ready__onboarding,
[data-pos-kiosk][data-device-ready='device-inactive'] .pos-ready__inactive,
[data-pos-kiosk][data-device-ready='no-csid'] .pos-ready__nocsid,
[data-pos-kiosk][data-device-ready='csid-expired'] .pos-ready__expired {
  display: inline;
}
/* Any of the four reason codes is a hard block. "unknown" is NOT a block — it
   means the readiness read is unavailable to this role (the endpoint is gated
   on settings.manage, which a cashier does not hold), so the screen must not
   claim the device is either fine or broken. */
[data-pos-kiosk][data-device-ready='onboarding-incomplete'] .pos-ready,
[data-pos-kiosk][data-device-ready='device-inactive'] .pos-ready,
[data-pos-kiosk][data-device-ready='no-csid'] .pos-ready,
[data-pos-kiosk][data-device-ready='csid-expired'] .pos-ready {
  background: var(--color-danger-700);
  color: var(--color-neutral-0);
}
[data-pos-kiosk][data-device-ready='ready'] .pos-ready {
  background: var(--color-success-900);
  color: var(--color-success-100);
}
[data-pos-kiosk][data-device-ready='unknown'] .pos-ready {
  background: var(--color-neutral-700);
  color: var(--color-neutral-0);
}
/* D3 — --text-muted-on-dark measures 5.50:1 against --color-neutral-900, which is
   the background it was chosen for (the sidebar and the command bar). This pill
   sits on the LIGHTER --color-neutral-800, where the same grey drops to 4.05:1,
   under 1.4.3's 4.5:1 floor. The token is correct where it is used elsewhere, so
   the fix belongs here: --color-neutral-400 measures 5.13:1 on this background
   and still reads as de-emphasised against the white of the other states, which
   is the point — a non-VAT till is informational, not a warning. */
[data-pos-kiosk][data-device-ready='not-applicable'] .pos-ready {
  background: var(--color-neutral-800);
  color: var(--color-neutral-400);
}

/* The blocked banner. It is NOT a shell row and it is NOT inside the scroll
   region either — it is the cart grid's OWN second row, a sibling of the
   scroller, so it cannot push the pay control off screen and the scroller
   absorbs its height instead.

   The old comment here claimed it sat inside the scroll region. That is the
   comment that hid the third instance of the grid bug in this file: a child was
   added without extending grid-template-rows, and because the banner is
   display:none by default, the cart list slid into the `auto` row and sized to
   content — 180px inside a 702px column. All three children now carry an
   explicit grid-row for exactly that reason. */
.pos-blocked {
  grid-row: 2;
  display: none;
  flex-direction: column;
  gap: var(--space-2);
  margin: var(--space-3);
  padding: var(--space-3);
  border-radius: var(--radius-md);
  background: var(--bg-danger-subtle);
  border: 1px solid var(--border-danger);
}
[data-pos-kiosk][data-device-ready='onboarding-incomplete'] .pos-blocked,
[data-pos-kiosk][data-device-ready='device-inactive'] .pos-blocked,
[data-pos-kiosk][data-device-ready='no-csid'] .pos-blocked,
[data-pos-kiosk][data-device-ready='csid-expired'] .pos-blocked {
  display: flex;
}
.pos-blocked__title {
  font-weight: var(--font-weight-semibold);
  color: var(--text-danger);
}
.pos-blocked__what {
  font-size: var(--font-size-body-sm);
  color: var(--text-primary);
}
/* Who has to act. Every one of the four fixes needs settings.manage, which
   neither a cashier nor a manager holds — so the honest instruction is always
   "fetch the owner", never a button the cashier can press. */
.pos-blocked__who {
  font-size: var(--font-size-caption);
  color: var(--text-secondary);
}

/* --- ZATCA submission: six states, not two -------------------------------- */
.pos-zatca {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-2);
  padding-block: var(--space-4);
  border-block: 1px solid var(--border-default);
}
.pos-zatca__state {
  display: none;
  flex-direction: column;
  align-items: center;
  gap: var(--space-2);
}
[data-pos-kiosk][data-zatca='pending'] .pos-zatca__pending,
[data-pos-kiosk][data-zatca='reported'] .pos-zatca__reported,
[data-pos-kiosk][data-zatca='failed-retrying'] .pos-zatca__retrying,
[data-pos-kiosk][data-zatca='failed-final'] .pos-zatca__final {
  display: flex;
}
.pos-zatca__note {
  font-size: var(--font-size-caption);
  color: var(--text-muted);
  max-inline-size: 28rem;
  text-align: center;
}

/* --- Fiscal mode (PRD 16.2) ------------------------------------------------
   A non-VAT-registered merchant issues a plain receipt: no VAT line, no ZATCA
   submission, no QR, and no device gate at all. Showing "prices include VAT" to
   a merchant who charges none asserts a fact the data denies. */
[data-pos-kiosk][data-fiscal='plain'] .is-vat-only {
  display: none;
}
[data-pos-kiosk][data-fiscal='vat'] .is-plain-only {
  display: none;
}

/* --------------------------------------------------------------------------
   25. POS flows: held tickets, returns, escalation notes
   --------------------------------------------------------------------------
   Several controls here name something a CASHIER cannot do. That is deliberate
   and it is the honest shape: the cashier is the person standing in front of
   the customer, so the screen must explain who can act rather than hide the
   door or offer a button that 403s.
   -------------------------------------------------------------------------- */

.pos-tickets {
  display: grid;
  gap: var(--space-2);
}
.pos-ticket {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--space-2) var(--space-3);
  inline-size: 100%;
  min-block-size: var(--tap-target-min);
  padding: var(--space-3);
  text-align: start;
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  cursor: pointer;
}
.pos-ticket:hover {
  background: var(--bg-surface-sunken);
}
.pos-ticket__label {
  font-weight: var(--font-weight-semibold);
  font-size: var(--font-size-body-sm);
}
.pos-ticket__meta {
  grid-column: 1 / -1;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-3);
  font-size: var(--font-size-micro);
  color: var(--text-muted);
}
/* The total is PROVISIONAL and must say so. Nothing on the server is holding
   the price or the stock of a parked basket, so on recall both may have moved —
   the recall path re-quotes. */
.pos-ticket__total {
  font-variant-numeric: tabular-nums;
  font-weight: var(--font-weight-semibold);
  white-space: nowrap;
}
.pos-ticket__provisional {
  font-size: var(--font-size-micro);
  font-weight: var(--font-weight-regular);
  color: var(--text-muted);
}
.pos-ticket.is-stale .pos-ticket__meta {
  color: var(--text-warning);
}

/* Escalation. Used wherever the till exposes an action whose permission the
   cashier does not hold — returns, reservation release, resubmission. */
.pos-escalate {
  display: flex;
  align-items: flex-start;
  gap: var(--space-2);
  padding: var(--space-3);
  border-radius: var(--radius-md);
  background: var(--bg-warning-subtle);
  color: var(--text-warning);
  font-size: var(--font-size-body-sm);
}
.pos-escalate .icon {
  flex-shrink: 0;
  margin-block-start: 2px;
}

/* Return lines: quantity returnable is bounded by what was actually sold and
   not already returned, so each row states its own ceiling. */
.pos-return-line {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--space-2) var(--space-3);
  padding-block: var(--space-3);
  border-block-end: 1px solid var(--border-default);
}
.pos-return-line__meta {
  grid-column: 1 / -1;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-3);
  font-size: var(--font-size-micro);
  color: var(--text-muted);
}
.pos-return-line__qty {
  inline-size: 4.5rem;
  min-block-size: var(--tap-target-min);
  text-align: center;
  font-variant-numeric: tabular-nums;
  direction: ltr;
}

/* Printer outcomes are three different problems with three different actions,
   and the old screen collapsed them into one "check the printer" warning. */
.pos-print-state {
  display: none;
}
[data-pos-kiosk][data-print='not-configured'] .pos-print-state--notconfigured,
[data-pos-kiosk][data-print='unavailable'] .pos-print-state--unavailable,
[data-pos-kiosk][data-print='no-pdf'] .pos-print-state--nopdf {
  display: flex;
}

/* --- POS touch floor -------------------------------------------------------
   The house rule for a till is 44px, well above WCAG 2.5.8's 24px. Nine
   controls sat at 36px — including the DESTRUCTIVE Remove and the discount
   input — and target-audit.mjs reported zero failures because it measures the
   WCAG floor, not the house rule. The harness was passing the wrong rule, so
   the sweep now asserts 44px as well. */
[data-pos-kiosk] .btn--sm,
[data-pos-kiosk] .input--sm,
[data-pos-kiosk] .pos-line__disc-input,
[data-pos-kiosk] .pos-return-line__qty,
[data-pos-kiosk] .cash-quick__btn,
[data-pos-kiosk] .pos-cat {
  min-block-size: var(--tap-target-min);
}

/* The floor stopped at the working panel. Every one of the eleven modals had
   controls under it, because pos-sweep only measures `.pos-work` and the modals
   are never open while it runs — the rule was enforced exactly where it had
   already been fixed and nowhere else.

   Three real offenders, all reachable mid-sale on a touch screen:
     - the close X on every dialog, at 32px
     - the tender-method and channel switches, at 38px — the control that
       chooses between cash, card and split
     - the return sheet's "resellable" checkboxes, at 20px, which decide whether
       stock goes back on the shelf */
[data-pos-kiosk] .modal__close {
  min-inline-size: var(--tap-target-min);
  min-block-size: var(--tap-target-min);
}
[data-pos-kiosk] .segmented__option,
[data-pos-kiosk] .segmented__btn {
  min-block-size: var(--tap-target-min);
}
/* A checkbox cannot simply be grown — the box itself is a fixed 20px control
   drawn by the platform. The LABEL is the tap target, so it takes the floor and
   the box stays visually correct inside it. */
[data-pos-kiosk] .modal .checkbox {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-block-size: var(--tap-target-min);
}
[data-pos-kiosk] .modal .checkbox input[type='checkbox'] {
  inline-size: 1.25rem;
  block-size: 1.25rem;
}

/* Undo lives in the line row so it survives another line being selected. It
   spans the row because the row's three columns are already spoken for. */
.pos-line [data-action='undo-remove'] {
  grid-column: 1 / -1;
  justify-self: start;
}

/* Docked into a dialog: the pad lays out in flow beneath the fields it drives,
   instead of floating over them. See initKeypad's dock(). */
.keypad--docked {
  position: static;
  inline-size: auto;
  margin-block-start: var(--space-3);
  box-shadow: none;
  border-color: var(--border-default);
}

/* --- Moved out of pos.html's page-local <style> (E5) ------------------------
   These thirteen rules lived in a <style> block inside the screen. That is the
   same defect class as the duplicated .pos-remaining: a second place for a rule
   to live is a second place for it to drift, and the merge would have carried
   the markup across while leaving these behind — the port would have looked
   almost right and been wrong in a dozen small ways.

   None of them collided with an existing rule; they are simply POS component
   styles that never made it into the numbered sections. They belong to §21
   (the quantity stepper), §23 (the tender pane, split rows and PIN fields) and
   §25 (camera, reservation, payment lines, print row, ZATCA status), and are
   kept together here rather than scattered so the move is reviewable as one
   diff. All are already `.pos-`-namespaced, so no extra scoping is needed. */
/* Page-local only. Everything structural lives in components.css §21 so the
zone budget is reviewable in one place. */
.pos-qty {
  display: flex;
  align-items: center;
  gap: var(--space-1);
}
.pos-qty__input {
  inline-size: 2.75rem;
  min-block-size: var(--tap-target-min);
  padding-inline: var(--space-1);
  text-align: center;
  font-variant-numeric: tabular-nums;
  font-weight: var(--font-weight-medium);
  /* A bare numeral, so LTR even on an Arabic page — same rule as SKUs. */
  direction: ltr;
}
.pos-tender-pane {
  display: grid;
  gap: var(--space-3);
  max-inline-size: 32rem;
}
.pos-split-row {
  display: flex;
  gap: var(--space-2);
  align-items: center;
}
.pos-split-row .select-wrap {
  flex: 1.3;
  min-inline-size: 0;
}
.pos-split-row input {
  flex: 1;
  min-inline-size: 0;
}
/* .pos-remaining used to be styled here AND in components.css §24, and the
    two disagreed: the local copy drew a hairline and no pill, so the split
    balance rendered as a plain row with a border rather than the coloured
    pill the component defines, and its .is-mismatch state was a fourth state
    nothing ever sets (the real ones are .is-balanced and .is-over). §24 owns
    the element and all three of its real states; this copy is gone. */
.pos-pin-input {
  text-align: center;
  letter-spacing: 0.6em;
  font-family: var(--font-mono);
  direction: ltr;
}
.pos-approver-pin {
  background: var(--bg-warning-subtle);
  padding: var(--space-3);
  border-radius: var(--radius-md);
}
.pos-camera-frame {
  aspect-ratio: 4 / 3;
  background: var(--color-neutral-900);
  border-radius: var(--radius-lg);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);
  color: #fff;
}
.pos-reservation-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-block: var(--space-3);
  border-block-end: 1px solid var(--border-default);
  gap: var(--space-3);
}
.pos-payment-line {
  display: flex;
  justify-content: space-between;
  padding-block: var(--space-1);
}
.pos-print-row {
  display: flex;
  gap: var(--space-2);
  flex-wrap: wrap;
  justify-content: center;
}
.pos-zatca-status {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  align-items: center;
  padding-block: var(--space-4);
  border-block: 1px solid var(--border-default);
}

/* --------------------------------------------------------------------------
   26. Print
   --------------------------------------------------------------------------
   PRD §6.9 / Appendix C: receipts are 80mm CSS print templates and A4 invoices
   are rendered from HTML. Both documents already exist as real artboards in
   print-templates.html — the ZATCA QR, the ICV counter and the PIH chain are all
   on them. What was missing is the paper: without an @media print block the
   browser printed the sidebar, the topbar and the prototype's own toolbar around
   a receipt, which is not a receipt.

   The page BOX cannot be selected by class — @page takes no selectors — so the
   size rule is injected at print time by the screen's own script. Everything
   here is the part that can be expressed statically: strip the application
   chrome, and let the chosen artboard be the whole page. */
/* Which gate is holding the pay button. */
.pos-pay__why {
  margin-block-start: var(--space-1);
  font-size: var(--font-size-caption);
  color: var(--text-danger);
  text-align: center;
}

/* An approved over-cap discount is no longer an error state — it is authorised,
   and must not keep shouting at the cashier who just got it authorised. */
.pos-line.is-approved .pos-line__cap {
  color: var(--text-success);
}

/* A coupon either applied or it did not, and the cashier needs to know which
   before the customer is charged. */
.pos-coupon__msg {
  margin-block-start: var(--space-2);
  font-size: var(--font-size-sm);
}
.pos-coupon__msg.is-ok {
  color: var(--text-success);
}
.pos-coupon__msg.is-bad {
  color: var(--text-danger);
}

/* The print screen's own notices sit above the artboards and never go to paper
   (the @media print block strips [data-proto-chrome]). */
.pr-notice {
  margin-block-end: var(--space-4);
  text-align: start;
}

@media print {
  /* Nothing that belongs to the app, the prototype, or the browser's idea of a
     web page survives onto paper. */
  .mc-sidebar,
  .mc-topbar,
  .pr-toolbar,
  .pr-stage__label,
  [data-proto-chrome],
  .keypad,
  .modal-scrim {
    display: none !important;
  }

  html,
  body,
  .mc-shell,
  .mc-main {
    background: #fff;
    margin: 0;
    padding: 0;
    display: block;
    block-size: auto;
    min-block-size: 0;
  }

  /* Only the artboard on screen is the artboard on paper. `[hidden]` already
     hides the other one; this stops the visible one from inheriting the page
     padding that centres it in the workspace. */
  .pr-stage {
    margin: 0;
    padding: 0;
    display: block;
  }

  /* The receipt is sized for the paper, not for the screen preview: an 80mm
     roll is 80mm wide with a ~4mm unprintable margin each side, and it has no
     height — it is a roll. Shadows and rounded corners are screen affordances
     and cost thermal ink for nothing. */
  .rcpt {
    inline-size: 72mm;
    max-inline-size: 72mm;
    margin: 0;
    border: 0;
    border-radius: 0;
    box-shadow: none;
    font-size: 9pt;
  }

  .a4 {
    inline-size: auto;
    max-inline-size: none;
    margin: 0;
    border: 0;
    border-radius: 0;
    box-shadow: none;
  }

  /* Thermal paper is monochrome. Anything relying on a background colour to
     carry meaning would print as an empty box, so tone is carried by weight and
     rules instead. */
  .rcpt * {
    background: transparent !important;
    color: #000 !important;
  }

  /* A receipt that breaks a line item across two pieces of paper is a receipt a
     customer will query. */
  .rcpt__lines tr,
  .rcpt__totals > div,
  .rcpt__qr,
  .a4__lines tr {
    break-inside: avoid;
  }

  a[href]::after {
    content: '';
  }
}

/* --------------------------------------------------------------------------
   27. Large text: give the cart back its lines
   --------------------------------------------------------------------------
   At 200% text every fixed zone doubles while the cart's minmax(0, 1fr) gets
   whatever is left — which was NOTHING. Measured at 1024x768 with 32px root:
   bar 112 + status 88 + entry 120 + cart head 64 + totals 125 + pay 176 = 685 of
   768, leaving the cart 83px against a 209px line. Zero whole cart lines, at
   every supported size. The cashier could not see what they were selling.

   zoom-audit did not catch it because it asserted no overflow, nothing clipped
   and pay above the fold — all true, all passing, on a screen with no visible
   basket. Exactly the blind spot pos-sweep had before it started counting lines.

   The threshold is the SHELL'S HEIGHT in `em`, which is what "the text is too
   big for this screen" actually means. Inside @container, `em` resolves against
   the container's own font size, so the same 768px shell reads 48em at 100% text
   and 24em at 200%.

   35em is chosen against the supported range, not by feel. At 100% the shortest
   supported viewport is 600px = 37.5em, so the rule cannot fire on a normal
   till. At 200% the tallest is 1080px = 33.75em, so it fires on all of them.
   That leaves a deliberately narrow 33.75-37.5em window; pin it with
   zoom-audit if the supported sizes ever change.

   What yields, in order of what a cashier can least afford to lose: the tile
   grid goes first (they can still scan and search, and seeing the basket beats
   browsing), then the totals breakdown collapses to the grand total, then the
   chrome tightens. The pay control and the readiness pill never yield. */
@container posshell (max-height: 35em) {
  .pos-catalogue {
    display: none;
  }
  /* Hiding the catalogue is not enough on its own: in portrait it occupies a
     grid ROW sized by --pos-tiles-h, and display:none leaves that row reserving
     11rem — 352px at 200% text, which is most of the space the cart needed.
     The row has to collapse as well as the content. */
  .pos-work {
    --pos-tiles-h: 0px;
  }
  .pos-totals__row:not(.pos-totals__row--grand) {
    display: none;
  }
  .pos-cart__head {
    font-size: var(--font-size-caption);
  }

  /* Every fixed zone tightens. These stay in rem so they still scale with the
     cashier's text size — they are simply less generous about it. At 200% the
     smallest of them is 2.5rem = 80px, comfortably above the 44px POS touch
     floor, so nothing here trades reach for room.

     Declared on .pos-shell, not on [data-pos-kiosk]: the body IS the query
     container and a container cannot style ITSELF, so the same overrides written
     one level up silently did nothing — twice, at two different levels, before
     this landed. .pos-shell consumes --pos-bar-h and --pos-status-h in its own
     grid, and everything below inherits the rest. */
  .pos-shell {
    --pos-bar-h: 2.75rem;
    --pos-status-h: 1.75rem;
    --pos-entry-h: 3rem;
    --pos-cart-head-h: 1.25rem;
    --pos-pay-h: 3rem;
  }
  .pos-pay {
    padding: var(--space-1) var(--space-2);
  }
  .pos-totals {
    padding-block: var(--space-1);
  }
}

/* A second, tighter tier. 600px of screen at 200% text is 300px of effective
   height — a 600px-tall till is 18.75em here, against 37.5em at normal text — and
   at that point even one whole cart line does not fit alongside the status strip.
   The strip is the least costly thing left to lose: the branch, the device id and
   the operator name are all reference information, and none of them is needed to
   ring up the item in front of the cashier. The readiness pill is NOT here; it
   lives in the command bar and survives every tier. */
@container posshell (max-height: 20em) {
  .pos-status {
    display: none;
  }
  .pos-shell {
    grid-template-rows: var(--pos-bar-h) minmax(0, 1fr);
  }

  /* The cart's own header goes. It is a label and a count — "السلة · 6" — and at
     this size those 40px are the difference between seeing a whole cart line and
     seeing most of one. The count is derivable by looking.

     Un-stacking the line was tried here first and is the WRONG trade, which is
     worth recording because it looks like the obvious one. On a 400px till column
     at 200% text the three columns want 0px + 272px + 149px against 399px
     available: the quantity stepper and the total fit, and the PRODUCT NAME is
     squeezed to zero. A line that shows a quantity and a price but not what the
     item is, is not a cart line. Stacking keeps the name; it just needs the
     header's height to be seen whole. */
  .pos-cart__head {
    display: none;
  }
  .pos-cart {
    grid-template-rows: 0 auto minmax(0, 1fr);
  }
  .pos-line__meta {
    display: none;
  }
}
