// Shared authoring primitives. Component modules `@use '../tokens' as t;` and
// compose from here — nothing in a component stylesheet hardcodes a colour,
// a physical direction, or a breakpoint value.

@use 'sass:list';
@use 'sass:map';
@use 'sass:string';
@use 'breakpoints';
@use 'colors';
@use 'motion';
@use 'radius';
@use 'space';
@use 'stroke';
@use 'typography';
@use 'units';

/// Keyboard-only focus, drawn from the accent role so it themes automatically.
@mixin focus-ring(
  $role: 'accent',
  $width: stroke.stroke(thick),
  $offset: stroke.stroke(thick)
) {
  &:focus-visible {
    outline: $width solid colors.role($role);
    outline-offset: $offset;
  }

  &:focus:not(:focus-visible) {
    outline: none;
  }
}

/// Removed from the visual flow, still announced by screen readers.
///
/// `inset-inline-start` is the one line the canonical recipe (a11y-project's, Bootstrap's
/// `.visually-hidden`) omits, and omitting it is what made this widen a document. `position:
/// absolute` with no inset leaves the box at its STATIC position — where the content flow put it —
/// and only takes it out of flow. After a long run of inline text the static position is already
/// past the viewport edge; the box then escapes the truncating ancestor's `overflow: hidden`,
/// because that ancestor is not positioned and so is not its containing block, and its right edge
/// extends the scrollable area. Measured in ai-maxxing: `Link`'s `.hint` ("opens in a new tab")
/// after truncated titles put seven boxes at right edges of 753–1119px, making the document 1119px
/// wide in a 390px viewport and 1480px wide in a 1440px one — a whole page scrolling sideways for
/// text no sighted user sees.
///
/// LOGICAL, never `left: -9999px`: this file's own header refuses a physical direction, and the
/// classic idiom also puts the box a screen away from the content it annotates, which is where an
/// RTL document and a focus scroll both go wrong. Only the INLINE start is set. The block position
/// stays static on purpose — a `.hint` that is focusable (`Checkbox`, `Radio`, `Switch` and
/// `Dropzone` each hide a real input through this) must scroll into view beside its own control,
/// not at the top of the page, and a static block position cannot widen anything.
@mixin visually-hidden {
  position: absolute;
  inset-inline-start: 0;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

@mixin truncate {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

@mixin line-clamp($lines: 2) {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: $lines;
  overflow: hidden;
}

/// Style by the component's own width, not the viewport — the portable default.
@mixin container-query($name, $min) {
  @container #{$name} (min-width: #{$min}) {
    @content;
  }
}

/// Declare the element a query container. Pair with `container-query`.
@mixin container($name) {
  container-type: inline-size;
  container-name: $name;
}

// --- Viewport breakpoints ----------------------------------------------------
// Three intents over ONE map, so a rung is defined in one place: at and above (`respond-to`),
// below (`respond-down`), and from one rung up to the next (`respond-between`). Prefer a container
// query to any of them — reach here only when the layout depends on the viewport itself.

/// What a max-width arm stops short of its rung by. `max-width: 768px` and `min-width: 768px` both
/// match at exactly 768px, which is how one rule ends up applying in two ranges at once; 0.02px is
/// below any width a browser reports and above the rounding that would collapse it.
$-under: 0.02px;

/// A rung's width. Compared as TEXT as well as by value: `2xl` in a map is the number 2 with the
/// unit `xl`, so the string `'2xl'` every stylesheet writes is a different key to `map.get`.
@function -rung($bp, $mixin) {
  @each $name, $width in breakpoints.$breakpoints {
    @if '#{$name}' == '#{$bp}' {
      @return $width;
    }
  }
  @error string.unquote('X_TOKEN_UNKNOWN: #{$mixin}("#{$bp}") — that breakpoint is not in $breakpoints. fix: use one of #{map.keys(breakpoints.$breakpoints)}');
}

/// At a rung and wider. Named rungs, never a raw px value.
@mixin respond-to($bp) {
  @media (min-width: -rung($bp, 'respond-to')) {
    @content;
  }
}

/// Narrower than a rung — the complement of `respond-to` on the same rung, with no shared pixel.
@mixin respond-down($bp) {
  @media (max-width: (-rung($bp, 'respond-down') - $-under)) {
    @content;
  }
}

/// From `$from` up to, and not including, `$to`.
@mixin respond-between($from, $to) {
  $min: -rung($from, 'respond-between');
  $max: -rung($to, 'respond-between');

  @if $min == $max {
    @error string.unquote('X_UI_INVALID_VALUE: respond-between("#{$from}", "#{$to}") is an empty range — both ends are #{$min}. fix: write respond-to("#{$from}"), or name a higher rung as $to');
  }
  @if $min > $max {
    @error string.unquote('X_UI_INVALID_VALUE: respond-between("#{$from}", "#{$to}") is reversed — #{$min} is not below #{$max}. fix: write respond-between("#{$to}", "#{$from}")');
  }

  @media (min-width: $min) and (max-width: ($max - $-under)) {
    @content;
  }
}

// --- Logical properties ------------------------------------------------------
// RTL comes free: authors never write `left`/`right`/`margin-left`, so there is
// no mirrored stylesheet and no `[dir='rtl']` override anywhere in the package.

@mixin margin-inline($start, $end: $start) {
  margin-inline-start: $start;
  margin-inline-end: $end;
}

@mixin margin-block($start, $end: $start) {
  margin-block-start: $start;
  margin-block-end: $end;
}

@mixin padding-inline($start, $end: $start) {
  padding-inline-start: $start;
  padding-inline-end: $end;
}

@mixin padding-block($start, $end: $start) {
  padding-block-start: $start;
  padding-block-end: $end;
}

@mixin inset-inline($start, $end: $start) {
  inset-inline-start: $start;
  inset-inline-end: $end;
}

@mixin inset-block($start, $end: $start) {
  inset-block-start: $start;
  inset-block-end: $end;
}

@mixin border-inline-start($width: 1px, $role: 'line') {
  border-inline-start: $width solid colors.role($role);
}

// --- Composites --------------------------------------------------------------

/// Transition any number of properties on the shared duration + easing.
@mixin transition($props...) {
  $list: ();
  @each $prop in $props {
    $list: list.append($list, #{$prop} motion.duration(base) motion.easing(out), comma);
  }
  transition: $list;
}

/// Card/sheet/popover background pair. `raised` adds the deeper elevation.
@mixin surface($level: 'surface', $elevation: 'sm') {
  background: colors.role($level);
  border: 1px solid colors.role('line');
  border-radius: radius.radius(lg);
  box-shadow: var(--shadow-#{$elevation});
}

/// Both scrollbar engines, plus no scroll chaining to the page.
@mixin custom-scrollbar($thumb: 'line', $track: 'bg-soft') {
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: colors.role($thumb) colors.role($track);

  &::-webkit-scrollbar {
    width: units.rem(10px);
    height: units.rem(10px);
  }

  &::-webkit-scrollbar-track {
    background: colors.role($track);
  }

  &::-webkit-scrollbar-thumb {
    border: stroke.stroke(thick) solid transparent;
    border-radius: radius.radius(pill);
    background: colors.role($thumb);
    background-clip: content-box;
  }
}

/// The inert state, spelled once. Both selectors, because a `<button disabled>` and a
/// `[aria-disabled='true']` div are the same state to a reader and must look the same.
@mixin disabled($opacity: 0.55) {
  &:disabled,
  &[aria-disabled='true'] {
    opacity: $opacity;
    cursor: not-allowed;
  }
}

/// The `::backdrop` behind a `<dialog>`. Blur is opt-in — it costs a compositor layer.
@mixin scrim-backdrop($alpha: 0.45, $blur: 0) {
  &::backdrop {
    background: colors.role('scrim', $alpha);

    @if $blur != 0 {
      backdrop-filter: blur($blur);
    }
  }
}

/// The `data` family with tabular figures, for slugs, counts, timestamps, ids, URLs and every
/// figure in a chart axis or a table column — data whose columns must not wobble as the value
/// changes. Lifted from an app that had restated these two lines in six stylesheets before the
/// catalog carried them; reads `--font-data` since 2026-10, a slot `defineTheme()` can restyle
/// without touching `mono` (code).
@mixin data-text {
  font-family: var(--font-data);
  font-variant-numeric: tabular-nums;
}

/// The section-label voice: smallest text step, medium weight, wide tracking, uppercase. An
/// eyebrow over a hero, a stat tile's label, a table's column head.
@mixin label-caps {
  font-size: typography.text('xs');
  font-weight: typography.weight('medium');
  letter-spacing: typography.tracking('wide');
  text-transform: uppercase;
}

/// A dot grid faded toward the top-left, where the text sits, over the soft background — the
/// "quietly technical" ground a hero or a dashboard main sits on. Two layers: the fade (so the
/// dots never compete with the copy) and the grid itself, pitched on one spacing step. Every
/// colour is a role, so it themes with the page.
@mixin dot-grid($pitch: space.space(5), $alpha: 0.55) {
  background:
    radial-gradient(ellipse 70% 90% at 0% 0%, colors.role('bg') 35%, transparent 100%),
    radial-gradient(colors.role('line', $alpha) 1px, transparent 1px) 0 0 / $pitch $pitch,
    colors.role('bg-soft');
}

/// Inline run of items on one baseline — a toolbar, a header, an icon beside a label.
@mixin row($gap: var(--space-3), $align: center, $justify: flex-start) {
  display: flex;
  flex-direction: row;
  align-items: $align;
  justify-content: $justify;
  gap: $gap;
}

/// Vertical run. Paired with `row` so no component writes `flex-direction` by hand.
@mixin column($gap: var(--space-3), $align: stretch) {
  display: flex;
  flex-direction: column;
  align-items: $align;
  gap: $gap;
}

// --- tones -------------------------------------------------------------------
// One tone class set, generated. Button and IconButton each carried a hand-written copy of the
// same six blocks; a seventh tone meant editing both, and they had already drifted.

/// The four custom properties every toned component reads.
@mixin tone-vars($base, $strong, $on, $soft) {
  --tone: #{$base};
  --tone-strong: #{$strong};
  --tone-fg: #{$on};
  --tone-soft: #{$soft};
}

/// Emits `.tone-<name>` for every rung of `colors.$tones`. Include once, at the top level of a
/// component module, then read `var(--tone)` / `var(--tone-strong)` / `--tone-fg` / `--tone-soft`.
@mixin tone-classes {
  .tone-neutral {
    @include tone-vars(
      colors.role('fg-strong'),
      colors.role('fg'),
      colors.role('bg'),
      colors.role('bg-soft')
    );
  }

  .tone-accent {
    // The one tone with no `-soft` token: accent tints composite off the same channels.
    @include tone-vars(
      colors.role('accent'),
      colors.role('accent-strong'),
      colors.role('accent-fg'),
      colors.role('accent', 0.12)
    );
  }

  @each $tone in (success, warning, danger, info) {
    .tone-#{$tone} {
      @include tone-vars(
        colors.role($tone),
        colors.role($tone),
        colors.role('#{$tone}-fg'),
        colors.role('#{$tone}-soft')
      );
    }
  }
}

/// Shared shape for every text-ish form control so Input/Textarea/Select match.
@mixin control {
  @include focus-ring;
  @include transition(border-color, box-shadow, background);
  inline-size: 100%;
  padding-block: var(--space-2);
  padding-inline: var(--space-3);
  border: 1px solid colors.role('line');
  border-radius: radius.radius(md);
  background: colors.role('surface-raised');
  color: colors.role('fg');
  font: inherit;
  font-size: var(--text-md);

  &::placeholder {
    color: colors.role('fg-muted');
  }

  &:hover:not(:disabled, [aria-disabled='true']) {
    border-color: colors.role('accent', 0.5);
  }

  &:disabled,
  &[aria-disabled='true'] {
    background: colors.role('bg-soft');
    color: colors.role('fg-muted');
    cursor: not-allowed;
  }

  &[aria-invalid='true'] {
    border-color: colors.role('danger');
  }
}
