/* ============================================================
   forms.css — Form controls: option cards, text fields, switch,
   radio group, input group, field states and companion text.
   Imported by ui.css, before its own rules; pages link ui.css,
   not this file. The focus ring shared with buttons is in ui.css.
   @tt-about ui-style-delivery
   ============================================================ */

/* Option cards provide room for an icon, name and description. Choose markup
according to selection behavior:
- Use label.option-card[radio-option] around a native radio for one-of-many
  selection in a fieldset with role=radiogroup. Include the input, icon, label
  text and optional small description.
- Use button.option-card with aria-pressed for optional single selection or
  multiple selections. The caller supplies keyboard handling for that pattern
  (docs/about-cross-system.html § Toggle Group).

The default tint is brand; override --_tint per group. A two-column grid keeps
the description under the label, beside the icon. */

.option-card {
  --_tint: var(--brand);

  display: grid;
  grid-template-columns: auto 1fr;
  align-items: center;
  gap: calc(var(--size-1) / 2) var(--size-3);
  padding: var(--size-3);
  border: var(--border-size-1) solid var(--border-color);
  border-radius: var(--radius-2);
  background: transparent;
  color: var(--text-1);
  /* Set the option-card font explicitly. Buttons otherwise use the browser’s default button font, while labels inherit the caption rule below. Both card variants should use the same typography. */
  font-family: inherit;
  font-size: var(--font-size-0);
  font-weight: var(--font-weight-4);
  text-align: left;
  cursor: pointer;
  transition: background .12s var(--ease-1), border-color .12s var(--ease-1);

  &:hover {
    background: color-mix(in oklch, var(--surface-3) 50%, transparent);
  }

  /* Share selected styling between radio and button cards. Read checked from the
wrapped radio, or aria-pressed from the button, so both forms use the same rule. */
  &:is([aria-pressed="true"], :has(> input[type="radio"]:checked)) {
    border-color: var(--_tint);
    background: color-mix(in oklch, var(--_tint) 5%, transparent);
    color: var(--_tint);
  }

  /* Draw the focus ring around a radio’s card because the transparent radio
cannot show its own outline. Button cards retain the browser’s focus ring. */
  &:has(> input[type="radio"]:focus-visible) {
    outline: var(--border-size-2) solid var(--brand);
    outline-offset: var(--border-size-2);
  }

  & > ui-icon {
    grid-row: 1 / -1;
    font-size: var(--font-size-3);
  }

  & > span {
    font-weight: var(--font-weight-5);
  }

  & > small {
    font-size: var(--font-size-0);
    color: var(--text-2);
  }

  /* `in oklab`, NOT `in oklch`: --text-2 is near-neutral rather than neutral, so
     its residual hue is not powerless and the engines carry it through a
     cylindrical interpolation differently — /ui/docs/ui-radio-group.html#color-mix. */
  &:is([aria-pressed="true"], :has(> input[type="radio"]:checked)) > small {
    color: color-mix(in oklab, var(--_tint) 80%, var(--text-2));
  }
}

/* ---- Form elements ---- */

.form-group {
  display: flex;
  flex-direction: column;
  gap: var(--size-1);
  margin: 0;
  padding: 0;
  border: none;
}

/* A form group stretches its children to the column's width, which a badge must not
   take: across a field's width a pill reads as a disabled control. Show a value the
   caller may not change as a field instead — an input-group holding a readonly input,
   see docs/ui-form-states.html. A badge that does belong in a form group is a status
   chip about the field rather than the field's value, and this keeps it at the width
   of its own text. */
.form-group > .badge {
  align-self: start;
}

.form-fields {
  display: flex;
  flex-direction: column;
  gap: var(--size-3);
}

label, legend {
  font-size: var(--font-size-0);
  font-weight: var(--font-weight-5);
}

/* ---- Align labels and feedback with field text ----
   Field text begins one border width plus size-2 inside the frame. Apply the
   same inset to labels and field-help, field-error and field-success messages.
   Inputs, prefixes and custom controls share that padding; keep these rules
   synchronized so text has one leading edge while the frame extends outside it.

   Match adjacency to a framed field rather than membership in form-group.
   Radio groups, checkboxes and button-group legends keep their own alignment.
   A value the caller may not change is a framed field too — an input-group
   holding a readonly input, see docs/ui-form-states.html — so it indents with
   the rest. Wrap the field selectors in where to avoid increasing specificity
   and defeating component overrides in prepended stylesheets. */
.form-group > :is(label, legend, span):has(+ :where(.input-group, input:not([type="radio"], [type="checkbox"]), textarea, select, x-select, ui-combobox)),
:where(.input-group, input:not([type="radio"], [type="checkbox"]), textarea, select, x-select, ui-combobox) + :is(.field-help, .field-error, .field-success) {
  padding-inline-start: calc(var(--size-2) + var(--border-size-1));
}

/* Exclude checkbox and radio inputs from text-field styling; extra padding, borders and backgrounds would surround their native controls. Use accent-color to tint them instead.
Wrap exclusions in :where() to keep specificity at (0,0,1). A bare :not() would raise it to (0,1,1), tying component overrides in prepended stylesheets and defeating them through source order. This matters for raw combobox padding. The separate raw + .input-group rules below decide which sides retain padding beside decorations. */

/* ---- One token on both axes ----
   A field takes --size-2 on both axes unless there is a good design reason not
   to. Its line box already carries half-leading above and below the glyphs, so
   equal padding reads as slightly taller than square rather than square.

   `.btn` keeps --size-3 deliberately: a label centred in a pill needs side room
   that a field's left-aligned text does not.

   This rule sets no line height, so a text field keeps the browser's `normal`
   and draws at that line box plus --size-2 twice plus the border. Every framed
   field in a form column has to land on that height, so a custom control built
   from a <button> or an inner <input> must set `line-height: normal` itself —
   `font: inherit` would otherwise hand it the page's body line height, which is
   taller. <x-select> and <ui-combobox> each declare it in their own stylesheet;
   see /ui/docs/ui-form.html section Field height.

   A native <select> needs that height written out instead, because it does not
   reach it on its own; the rule below does that. */
input:where(:not([type="radio"], [type="checkbox"])), textarea, select {
  border: var(--border-size-1) solid var(--border-color);
  border-radius: var(--radius-2);
  padding: var(--size-2);
  font-size: var(--font-size-1);
  font-family: inherit;
  background: var(--surface-1);
  color: var(--text-1);
  transition: border-color .2s var(--ease-2);

  &:hover {
    border-color: var(--border-strong);
  }
}

/* Set this in the shared layer so it works when copied to another application.
An app’s index.css cannot provide that guarantee (docs/about-conventions.html). */
input:where([type="radio"], [type="checkbox"]) {
  accent-color: var(--brand);
}

input, textarea {
  &::placeholder {
    color: var(--text-2);
  }
}

textarea {
  min-height: 12rem;
  resize: vertical;
}

/* Chromium wraps a select's chosen text in an internal button box two pixels
   taller than the line box a text field keeps, so the select drew 40px where
   everything else drew 38. Nothing about `appearance` reaches those two pixels,
   so the height is written out here instead, and `appearance` stays `auto` —
   each browser goes on drawing its own dropdown arrow. See
   /ui/docs/ui-form.html section Field height for what was measured.

   The calc is the height the rule above describes, with one substitution: the
   browser's `normal` line box is written as --font-lineheight-1, because CSS
   has no portable way to read `normal`. The `lh` unit is what it looks like,
   and it cannot be used here — Firefox resolves `lh` from the fallback font's
   metrics and does not recompute it when the web font arrives, which put the
   select at 41px on a cold load. 1.25 is what `normal` measures for this
   library's font at --font-size-1, so a different font family would move the
   fields and leave the select behind. The browser test compares the select
   against the <input> beside it rather than against a figure, and is what
   would catch that.

   Do not read the calc as redundant beside the padding and border already set:
   those alone leave the select two pixels tall in Chromium.

   A <select multiple> or one carrying `size` is a list box that sizes itself
   from its rows, so it is excluded; :where() keeps the specificity at (0,0,1)
   like the other field exclusions here. */
select:where(:not([multiple], [size])) {
  block-size: calc(var(--font-lineheight-1) * 1em + 2 * var(--size-2) + 2 * var(--border-size-1));
}

/* ---- Switch ----
   A toggle for a single on/off setting: a native
   <input type="checkbox" role="switch">. The checkbox already gives keyboard
   (Space toggles), focus, the `change` event, <label> association, and the
   checked→on/off mapping for assistive tech; CSS only hides the box and paints
   a track + sliding thumb. A switch is a single boolean — for a one-of-many
   choice use radios, not switches.

   Keyed off role="switch" (not a class) on purpose: role="switch" is required
   for the right SR announcement ("switch, on/off", not "checkbox"), so binding
   the appearance to it makes the look and the semantics inseparable — you can't
   render a switch that announces as a checkbox, or vice versa. Pair with a
   <label>. See docs/ui-switch.html.

   Fully token-driven, so it re-themes with the app automatically (dark mode is
   handled centrally in index.css by re-defining the semantic tokens). To restyle
   only the switch — without side effects — override the --switch-* hooks below
   in any scope. */

input[type="checkbox"][role="switch"] {
  /* Override hooks → private vars. The public --switch-* names are resolved
     through var() fallbacks here rather than declared on the element, so a
     value set in ANY ancestor scope (incl. an inline style on a wrapping
     <label>) inherits down and is picked up. Declaring --switch-* directly on
     the element instead would shadow that inherited value and silently break
     overrides. Defaults are the app's semantic tokens, so the switch re-themes
     centrally. See ui-switch.html § Theming. */
  --_size: var(--switch-size, 1.25em);              /* track block-size; inline-size is 2× */
  --_track: var(--switch-track, var(--surface-2));  /* off-state track */
  --_track-on: var(--switch-track-on, var(--brand)); /* on-state track */
  --_thumb: var(--switch-thumb, var(--surface-1));  /* sliding knob */
  /* Strengthen the off-state outline by mixing text-2 toward text-1. With stone-6
as text-2, contrast against the light track is only 2.54:1, below the 3:1 target.
The mix reaches about 3.5:1 in light mode and exceeds 10:1 in dark mode while
remaining gray. See the thumb rule and ui-switch.html § Theming. */
  --_border: var(--switch-border, color-mix(in srgb, var(--text-2) 65%, var(--text-1))); /* knob + track outline */
  --_inset: 0.15em;                                 /* thumb gap inside the track */

  appearance: none;
  display: inline-block;
  vertical-align: middle;
  position: relative;
  inline-size: calc(var(--_size) * 2);
  block-size: var(--_size);
  margin: 0;
  padding: 0;
  border: var(--border-size-1) solid var(--_border);
  border-radius: var(--radius-round);
  background: var(--_track);
  cursor: pointer;
  transition: background-color .2s var(--ease-2);

  /* Thumb. The border is what keeps the knob visible: --switch-thumb defaults
     to --surface-1, which can sit very close to the off-state track, so the
     thumb carries its own ≥3:1 outline rather than relying on track contrast. */
  &::before {
    content: "";
    position: absolute;
    inset-block-start: 50%;
    inset-inline-start: var(--_inset);
    inline-size: calc(var(--_size) - 2 * var(--_inset));
    block-size: calc(var(--_size) - 2 * var(--_inset));
    border: var(--border-size-1) solid var(--_border);
    border-radius: var(--radius-round);
    background: var(--_thumb);
    box-shadow: var(--shadow-1);
    translate: 0 -50%;
    transition: translate .2s var(--ease-2);
  }

  &:checked {
    background: var(--_track-on);
    border-color: var(--_track-on);
  }

  /* Travel = track − thumb − insets = 2·size − (size − 2·inset) − 2·inset = size. */
  &:checked::before {
    translate: var(--_size) -50%;
  }

  /* Native :disabled is the default — APG is silent on disabled, and a disabled
     form control is conventionally dropped from the tab order (still announced
     as unavailable in SR browse mode). A consumer who needs the disabled switch
     to stay focusable/discoverable (e.g. a toolbar toggle) can use
     aria-disabled="true" instead and block the toggle in JS; the dimmed look
     applies to both. See ui-switch.html § Accessibility. */
  &:disabled,
  &[aria-disabled="true"] {
    opacity: 0.5;
    cursor: not-allowed;
  }

  /* Focus ring: the same treatment as the other ui.css controls. */
  &:focus-visible {
    outline: var(--border-size-2) solid var(--brand);
    outline-offset: var(--border-size-2);
  }

  @media (prefers-reduced-motion: reduce) {
    transition: none;

    &::before {
      transition: none;
    }
  }

  /* Forced colors (Windows High Contrast): custom-painted backgrounds are
     dropped, so lean on system colors + the border to keep on/off legible. */
  @media (forced-colors: active) {
    border-color: CanvasText;

    &::before {
      background: CanvasText;
      border-color: CanvasText;
    }

    &:checked {
      background: Highlight;
      border-color: Highlight;
    }

    &:checked::before {
      background: HighlightText;
      border-color: HighlightText;
    }
  }
}

/* ---- Radio group ----
   @tt-about one-of-many-picker

   One-of-many: a <fieldset role="radiogroup"> of native <input type="radio">
   sharing a `name`. The platform supplies the whole APG radio contract; this
   file only paints, with no JavaScript. `data-look` is opt-in decoration — a
   stacked list of native radios needs no rule from here. Intent, invariants
   and the traps: /ui/docs/ui-radio-group.html. */

/* A radio option is a label wrapping a native radio. Keep the radio transparent
and stretched across the option, preserving focus, accessibility and browser
test check() support (/ui/docs/ui-radio-group.html#hidden-radio).
Use radio-option for custom appearances; segmented-group labels receive this
structure automatically. The attribute defines composition rather than styling
(/ui/docs/styles-modifiers.html § Class, attribute, or neither). */
[data-look="segmented"] > label,
[radio-option] {
  position: relative;
  cursor: pointer;

  & > input[type="radio"] {
    appearance: none;
    position: absolute;
    inset: 0;
    margin: 0;
    opacity: 0;
    cursor: inherit;
  }
}

/* Style the segments as parts of one control. See /ui/docs/ui-radio-group.html#segmented for the shared frame, dividers and selection treatment. */

[data-look="segmented"] {
  /* Public hooks read through var() fallbacks rather than declared here, so a
     value set in ANY ancestor scope still inherits down — same trap as the
     switch's --switch-* above. */
  --_outline: var(--segmented-outline, var(--border-color));
  --_track: var(--segmented-track, transparent);
  /* `in oklab`, NOT `in oklch`: the engines resolve a near-neutral mix to
     different hues — /ui/docs/ui-radio-group.html#color-mix. */
  --_selected: var(--segmented-selected, color-mix(in oklab, var(--brand) 20%, var(--surface-1)));
  --_on-selected: var(--segmented-on-selected, var(--text-1));
  /* Use the control radius from /ui/docs/styles-shape.html. A caller can request a pill shape by overriding the radius hook; see /ui/docs/ui-radio-group.html#theming. */
  --_radius: var(--segmented-radius, var(--radius-2));

  /* `1fr` columns keep the segments equal without clipping the longest label,
     and a grid row cannot wrap. `fit-content`, because a <fieldset> is
     block-level and would otherwise span the pane. */
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  align-items: stretch;
  inline-size: fit-content;
  min-inline-size: 0;
  /* Keep segments adjacent so their borders form one frame with internal dividers
(/ui/docs/ui-radio-group.html#frame). Set gap explicitly to override form-group’s
column gap when both styles are applied. */
  gap: 0;
  margin: 0;
  padding: 0;
  /* No border here: a <legend> cuts a hole in one set on this element
     (/ui/docs/ui-radio-group.html#frame), so the segments draw the frame
     between them. The radius stays — it is the focus ring's shape. */
  border: 0;
  border-radius: var(--_radius);
  background: var(--_track);

  /* The browser places the legend above the grid. Remove its inline padding to
align it with the group, and add spacing before the options. */
  & > legend {
    padding: 0;
    margin-block-end: var(--size-1);
  }

  /* The segment. Structure (covering radio, position, cursor) is the shared rule
     above; this only decorates. `border-block` here, plus the adjacent and end
     rules below, are what draw the shared frame and its dividers. */
  & > label {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--size-1);
    min-inline-size: 0;
    padding: var(--size-1) var(--size-3);
    border-block: var(--border-size-1) solid var(--_outline);
    color: var(--text-2);
    /* Use body text weight for clickable segments rather than the heavier field-label
weight defined above. */
    font-weight: var(--font-weight-4);
    transition: background-color .12s var(--ease-1), color .12s var(--ease-1);
  }

  /* One hairline between each adjacent pair. Declared before the end rules below
     to keep this block in ascending specificity, per no-descending-specificity. */
  & > label + label {
    border-inline-start: var(--border-size-1) solid var(--_outline);
  }

  /* The check on the selected segment: `clip-path` on a currentColor box, sized
     to <ui-icon>'s own 1.1em so it can REPLACE a segment's icon in that exact
     space. Where no segment has an icon the slot is reserved in all of them and
     inked only on the selected one, so neither branch lets the row change width
     as the selection moves (/ui/docs/ui-radio-group.html#segment-icons).
     `:is(ui-icon, svg)` because a caller may inline the glyph instead. */
  &:not(:has(:is(ui-icon, svg))) > label::before,
  & > label:has(> input[type="radio"]:checked)::before {
    content: '';
    flex: none;
    inline-size: 1.1em;
    block-size: 1.1em;
    background: currentColor;
    clip-path: polygon(41% 86%, 4% 49%, 15% 38%, 41% 64%, 85% 20%, 96% 31%);
  }

  /* Only the row's ends are round: an interior segment's edge is a divider, not
     an outline. */
  & > label:first-of-type {
    border-inline-start: var(--border-size-1) solid var(--_outline);
    border-start-start-radius: var(--_radius);
    border-end-start-radius: var(--_radius);
  }

  & > label:last-of-type {
    border-inline-end: var(--border-size-1) solid var(--_outline);
    border-start-end-radius: var(--_radius);
    border-end-end-radius: var(--_radius);
  }

  /* Share the option-card hover tint. Selected styling takes precedence so the
chosen segment keeps its fill while hovered. */
  & > label:hover {
    background: color-mix(in oklab, var(--surface-3) 50%, transparent);
    color: var(--text-1);
  }

  /* Read off `:checked` through `:has()`, never from a class a handler
     maintains, so the look cannot drift from the control's real value
     (/ui/docs/ui-radio-group.html#checked-state). The fill reaches the frame:
     no shadow, no inset. */
  & > label:has(> input[type="radio"]:checked) {
    background: var(--_selected);
    color: var(--_on-selected);
  }

  /* Hide dividers beside the selected segment because its fill already marks
that boundary. Change color without changing width to avoid layout shifts.
The adjacent-label selector matches the preceding rule’s specificity, keeping
the block in ascending order. */
  & > label:has(> input[type="radio"]:checked) + label,
  & > label + label:has(> input[type="radio"]:checked) {
    border-inline-start-color: transparent;
  }

  &:not(:has(:is(ui-icon, svg))) > label:not(:has(> input[type="radio"]:checked))::before {
    visibility: hidden;
  }

  & > label:has(> input[type="radio"]:checked) > :is(ui-icon, svg) {
    display: none;
  }

  /* The ring goes on the group, because `opacity` applies to an outline too and
     the radio's own ring is as invisible as the radio
     (/ui/docs/ui-radio-group.html#focus-ring). */
  &:has(input[type="radio"]:focus-visible) {
    outline: var(--border-size-2) solid var(--brand);
    outline-offset: var(--border-size-2);
  }

  /* Keyed off the *radios* being disabled, so it reads `disabled` from the
     <fieldset> (propagated by the platform) or from the radios themselves;
     `&:disabled` would see only the first, and only while `data-look` sits on
     the fieldset itself. */
  &:has(> label > input[type="radio"]:disabled) {
    opacity: 0.5;
  }

  &:has(> label > input[type="radio"]:disabled) > label {
    cursor: not-allowed;
  }

  /* One refused choice while the rest of the group still works. The rule above
     dims the whole row as soon as any radio is disabled, which is what a row
     nobody can use needs and the wrong answer for one choice out of several, so
     this is a second, narrower selector rather than a change to that one.
     Keyed off aria-disabled because a choice that has to stay in the arrow-key
     walk and keep a tooltip's anchor cannot use native `disabled`
     (/ui/docs/ui-radio-group.html#refused-option). This only paints the choice;
     refusing it is the consumer's own handler. */
  /* stylelint-disable-next-line no-descending-specificity -- the higher-specificity label rules above it set background, colour, border-colour and cursor, and this one sets opacity and cursor. Only cursor is shared, and the one rule that sets it, the disabled row above, gives it the same value. So no source order between them changes a computed value, and moving this rule up among the :checked block would separate it from the disabled row it is the narrow counterpart of. */
  & > label:has(> input[type="radio"][aria-disabled="true"]) {
    opacity: 0.5;
    cursor: not-allowed;
  }

  @media (prefers-reduced-motion: reduce) {
    & > label {
      transition: none;
    }
  }

  /* Forced colors: the fill is discarded there, so the selection comes back
     through system colors — otherwise every segment reads identically. */
  @media (forced-colors: active) {
    & > label:has(> input[type="radio"]:checked) {
      background: Highlight;
      color: HighlightText;
      border-color: CanvasText;
      forced-color-adjust: none;
    }
  }
}

/* ---- Input group (input + prefix/suffix decoration) ----
   Modern-CSS pattern: flex layout + Open Props tokens + :has(:focus-visible).
   Works on <label class="input-group"> (preferred for single-input groups),
   <div class="input-group" role="group">, or <fieldset class="input-group">.
   The wrapper owns the border/background/focus ring; native inputs inside go
   transparent. ui-* controls nested inside must carry `raw` to strip their
   own chrome. Prefix/suffix are natural-sized flex siblings, so the group
   adapts to any decoration size and fills available inline-size when wide. */

.input-group {
  display: flex;
  align-items: stretch;
  inline-size: 100%;
  min-inline-size: 0;
  margin: 0;
  padding: 0;
  border: var(--border-size-1) solid var(--border-color);
  border-radius: var(--radius-2);
  background: var(--surface-1);
  color: var(--text-1);
  transition: border-color .2s var(--ease-2);
  overflow: hidden;
}

fieldset.input-group {
  min-inline-size: 0;
}

/* Brightening the border is how an editable field says it is ready for typing, so a
field the caller cannot edit does not answer the pointer. A readonly or disabled group
is already dimmed to say the opposite, and a locked group's children take no pointer
events at all, so a hover cue on the frame would contradict what the group has said.
The focus ring below is deliberately NOT excluded: readonly and locked fields stay in
the tab order, so they must still show where focus is. Keep the filter inside :where()
so this rule holds its (0,2,0) weight and its rung on the ladder below. */
.input-group:where(:not(
  [data-state="locked"],
  :has(:where(input, textarea)[readonly]),
  :has(:disabled)
)):hover {
  border-color: var(--border-strong);
}

.input-group:has(:focus-visible) {
  outline: var(--border-size-2) solid var(--brand);
  outline-offset: var(--border-size-2);
  border-color: transparent;
}

/* Field access: readonly, disabled and locked. These states affect editing, while validation states below describe the value. Keep border colors available for validation. readonly and disabled have equal specificity; disabled comes second so a control with both uses opacity 0.6.
Match the readonly attribute, not :read-only. That pseudo-class also matches non-editable decorations, buttons and input types that do not support readonly, which would dim unrelated groups.
All three states suppress the hover rule above and keep the focus ring. Do not make the two symmetrical: a readonly or locked field is still a Tab stop, so removing its ring would leave a keyboard user unable to see where they are. docs/ui-form-states.html section "Which fields Tab reaches" publishes both halves. */
.input-group:has(:where(input, textarea)[readonly]) {
  opacity: 0.85;
}

.input-group:has(:disabled) {
  opacity: 0.6;
  cursor: not-allowed;
}

/* Locked fields are unavailable because of permissions. Keep them undimmed and keyboard-reachable, but disable pointer interaction inside the group. Leave the border color to validation states. Place this before busy styling so a concurrent update still shows its progress cursor. */
.input-group[data-state="locked"] {
  cursor: not-allowed;

  & > * {
    pointer-events: none;
  }
}

/* Field and form states. Native :user-valid and :user-invalid provide validation feedback after interaction. data-state and aria-invalid represent application states. locked is handled with access states above.
Priority follows source order: hover < focus < valid < user-invalid < flux < updating/busy < error.
Keep native validation below pending work: an incomplete field must not hide an active save indicator. Explicit error states take priority over everything else, including a retry, so a rejected value remains visible.
All border rules have specificity (0,2,0), and their nested icon rules have (0,4,0). Put incidental filters, including chips exclusions and the focused-group check, inside :where() to preserve those equal weights. Change priority by moving a whole state block, not by adding specificity or !important. Keep border and icon rules together so their priorities agree. */

.input-group .suffix.state {
  color: var(--text-2);
  transition: color .2s var(--ease-2);
}

/* Show a green valid state only for .modified groups whose value differs from its starting value. Reverting a value removes that confirmation. Invalid feedback does not require .modified: an emptied required field may equal its initial value and still need an error. :user-invalid supplies the interaction check. While the group has focus, hide its green border so the focus ring has a neutral background; keep the icon green. */
.input-group.modified:where(:has(:user-valid)) {
  &:where(:not(:has(:focus-visible))) {
    border-color: var(--success, forestgreen);
  }

  & .suffix.state {
    color: var(--success, forestgreen);
  }
}

/* Native constraint errors take priority over valid styling but yield to pending work and explicit errors.
Exclude chips-mode combobox inputs from this persistent styling. They hold a query that is cleared programmatically after actions, and browsers retain the user-edited flag through those clears. Applying :user-invalid would then show an unexplained red field. Native constraints still block submission with a message; persistent chips errors use aria-invalid with .field-error or data-state="error". Keep the exclusion in :where() to preserve specificity. */
.input-group:has(:user-invalid):where(
  :not(:has(ui-combobox:is([chips], [multiple]) :user-invalid))
) {
  border-color: var(--error, crimson);

  & .suffix.state {
    color: var(--error, crimson);
  }
}

/* ---- flux ---- */
.input-group[data-state="flux"] {
  /* Orange-6 is scheme-constant: bright enough for dark, dark enough for light. */
  border-color: var(--orange-6, orange);

  & .suffix.state {
    color: var(--orange-6, orange);
  }
}

/* ---- updating / busy ----
   aria-busy is the same state addressed to a screen reader: set it when the
   SR should pause until the update completes. Children stop taking pointer
   events but keep their tab stops, so the value is still copyable. */
.input-group:is([data-state="updating"], [aria-busy="true"]) {
  border-color: var(--border-strong);
  cursor: progress;

  & > * {
    pointer-events: none;
  }

  & .suffix.state {
    color: var(--text-2);
  }
}

/* Explicit aria-invalid="true" on the field and data-state="error" on the group share one priority. Place them last so a rejected value remains visibly marked even during a retry. */
.input-group:is(
  :has([aria-invalid="true"]),
  [data-state="error"]
) {
  border-color: var(--error, crimson);

  & .suffix.state {
    color: var(--error, crimson);
  }
}

/* ---- Companion field text under the input ----
   One <small> holds the feedback. Default class is .field-help (muted,
   describes what to type). On :user-invalid, swap the class to .field-error
   and write the error text; on :user-valid, swap to .field-success. Only one
   message applies at a time, so the element is referenced once from
   aria-describedby; aria-invalid carries the SR's error signal.
   aria-live="polite" on the region re-announces the swap without the
   cross-SR flakiness of toggling `hidden` on a role="alert" element. For
   server-side errors, set aria-invalid="true" + .field-error directly —
   no :user-invalid interaction required.

   Two modifiers change what a .field-help line says without making the value
   wrong. .info marks a line worked out from the current value, such as a total
   or a converted amount. .warning marks a value that is allowed but unusual and
   needs attention. Neither sets aria-invalid, and the form still submits. When
   several apply, show the highest: error, warning, info, then the plain hint.

   Only the type is here: the indent that lines this line up with the field's own
   text is declared with the label's, in the form-elements section above, since
   one rule sets both. */

.field-help,
.field-error,
.field-success {
  display: block;
  font-size: var(--font-size-0);
  margin-block-start: var(--size-1);
}

.field-help    { color: var(--text-2); }
.field-error   { color: var(--error, crimson); }
.field-success { color: var(--success, seagreen); }

/* The warning hue is a fill colour and too light to read as text on a light page, so the
   light scheme mixes it with the text colour. The dark scheme uses it unmixed: mixed with
   light text it would fade to almost white and look like an ordinary hint. */
.field-help.info    { color: var(--info, steelblue); }
.field-help.warning {
  color: light-dark(
    color-mix(in oklab, var(--warning, goldenrod) 50%, var(--text-1)),
    var(--warning, goldenrod));
}

.input-group > .prefix,
.input-group > .suffix {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  padding-inline: var(--size-2);
  color: var(--text-2);
  user-select: none;
}

.input-group > :is(input, textarea, ui-combobox, x-select) {
  flex: 1 1 auto;
  min-inline-size: 0;

  /* Normalize font-size for field-semantic children so a <label
     class="input-group"> wrapper's font-size-0 cascade doesn't shrink a
     nested <ui-combobox> input (native <input>/<textarea> already override
     via the global input rule, but ui-combobox's inner input uses
     font: inherit). Siblings like .prefix/.suffix stay at label-size. */
  font-size: var(--font-size-1);
}

.input-group > :is(input, textarea) {
  border: none;
  background: transparent;
  border-radius: 0;
  padding: var(--size-2);
  outline: none;
}

/* ---- Input-group buttons and focus ---- */

.input-group > button.prefix,
.input-group > button.suffix {
  background: transparent;
  border: none;
  cursor: pointer;
  color: inherit;
}

.input-group > button:focus-visible {
  outline: var(--border-size-1) solid var(--brand);
  outline-offset: calc(-1 * var(--border-size-1) - var(--size-1));
  border-radius: var(--radius-2);
}

.input-group :is(input, textarea):focus-visible {
  outline: none;
}
