> ## Oymyakon DS index
> Fetch the full index at: https://super-dollop-pzmo65r.pages.github.io/llms.txt
> HTML preview: https://super-dollop-pzmo65r.pages.github.io/text-field.html
> Source: specs/components/text-field/text-field.md, specs/components/text-field/text-field-a11y.md, specs/components/text-field/capabilities.json

---

# TextField · Oymyakon DS 3

> Text fields let users enter text into a UI. The assembled field: a rounded box hosting a
> floating label and a BaseTextLine core, framed by two icon slots, with an optional Description
> line below.

**Version:** 3.2.0 · **Status:** Draft · **Figma:** `[TextField] 3.2` (set `8216:7890`, master `8216:7544`, Specification frame `4800:44297`)

---

## 1. Description

TextField is the field the text-input family standardizes on. It owns everything BaseTextLine
deliberately does not: the surface, the Active/Error border, the floating label, the two side
slots, the Description (helper) line, the skeleton and the data-loading treatment. The editable
core inside is a [BaseTextLine](https://super-dollop-pzmo65r.pages.github.io/base-text-line.md) — prefix/suffix, masks,
alignment and the caret all come from that contract.

The family has three field types staged in the Figma spec — **Text Input** (this component),
**Password Input** (the `[PasswordField]` line) and **Phone Input** (the `[PhoneField]` line);
the latter two are separate components assembled on the same anatomy.

The Figma page ships the public master `[TextField] 3.2` — an outer wrapper carrying the
screen-edge margins (`s4` vertical / `s16` horizontal) with one `Type` instance-swap between the
predefined **TextField** set (`State × Disabled × Validation × RTL` + three booleans) and the
unlocked **CustomTextField** base — the customButton/CustomCell precedent.

### Platform implementations

| Platform | Entry point | Version | Notes |
|---|---|---|---|
| iOS | `Modules/DS3/DS3/Sources/TextField/` — `DsTextField`, `DsCustomTextField`, `DsSecureTextField` | 3.1 | SwiftUI; `CompactField` carries the floating-label mechanics; two trailing slots (`trailingView` + `additionalView`); built-in clear button (focus + text) |
| Android | `core/compose/…/ds/v3/textfield/` — `DsCustomTextField` (+ `DsTemplatedTextField`, `DsPasswordTextField`) | 3.2 (`@DsComponentInfo`; Password 3.1) | Compose; `startSlot` + `endInnerSlot` + `endOuterSlot`, label modes `Label / Placeholder / Empty`; the templated clear preset toggles clear ↔ info |
| Flutter | — | — | Not shipped |
| Web | `src/shared/shared.css` (`.text-field`) | 3.2.0 | Reference implementation, this repo; preview page `src/text-field.njk` |

The Figma line is 3.2 (Update 3.2, 2026-09-01: Description slots). Android ships 3.2; iOS ships
3.1 — and the Figma cover's platform badges still read 3.1/3.1, stale for Android (flagged).

---

## 2. Anatomy

```
[TextField] (public master)              outer margins s4 vert / s16 horiz · Type swap
└── TextField (.text-field)                the component root — a column, gap s0
    ├── box (.text-field__box)               the field surface: OnWhite, radius s20, min-height s56
    │   ├── Slot#1 (.text-field__start-slot)   optional — s40 × s40, a DS icon
    │   ├── StartText (.text-field__start-text)  fills — the label + the input line
    │   │     ├── label (.text-field__label)       floating: Main Body 16/20 ⇄ Compact Body 14/16
    │   │     └── input line (.base-text-line)     the BaseTextLine core (text · caret · prefix/suffix)
    │   └── Slot#2 (.text-field__end-slot)      optional — one or two s48 × s48 items
    └── Description (.text-field__description)  optional — helper line below the box
          ├── icon slot                          optional s16 leading icon (error: warning-filled)
          └── text                               Compact Body 14/16, multiline
```

| Part | Required | Description |
|---|---|---|
| **box** | Required | The surface and touch area — the whole component is one touch target |
| **Slot#1** | Off | A DS icon only (the Figma contract); a custom element must carry its own `s40` touch area |
| **label** | Required | Names the field; floats between the resting 16/20 line and the compact 14/16 line above the text |
| **input line** | Required | A BaseTextLine instance — entered text, caret, optional prefix/suffix, masks |
| **Slot#2** | Off | Default: the `info` icon; teams place other elements — the clear button, a hint, a text button. `Count = One \| Two` (two `s48` items at `s0` apart) |
| **Description** | Off | Hint or error text below the box; full-length, multiline — with an optional leading icon (3.2) |

- **Overflow:** long input text hides beyond the component's boundary — truncation (ellipsis) is
  not used. The Description is always displayed in full and can occupy multiple lines; the
  Description takes one line only when its trailing-slot form is enabled.
- **Clear Text Button** — the Slot#2 preset that clears the entered text; the 3.2 icon is
  `clear-filled` in `TextAndIcon/Secondary` (3.1 used `close` in Primary).

### RTL layout

`RTL` is a variant axis of the set: the row mirrors — Slot#1 leads from the right, the label and
text right-align, Slot#2 trails left; the Description mirrors with its icon. The BaseTextLine
rules still hold inside: masks keep their LTR direction, and amounts pin left-to-right.

---

## 3. Variants and sizes

One size; the axes:

| Axis | Values | Default | Web mechanism |
|---|---|---|---|
| **State** | `standard empty` · `active empty` · `active filled` · `standard filled` · `skeleton` (Figma: `rest …`) | `standard empty` | `:focus-within` drives active; `.text-field--filled` marks content; `.text-field--skeleton` |
| **Disabled** | off · on | off | `.text-field--disabled` + the input's `disabled` attribute |
| **Validation** | `none` · `error` | `none` | `.text-field--error` |
| **RTL** | off · on | off | `dir="rtl"` on the host |
| **Slot#1** | off · on | on (Figma default) | Presence of `.text-field__start-slot` |
| **Slot#2** | off · on, `Count = One \| Two` | on, One | Presence of `.text-field__end-slot` items |
| **Description** | off · on | on (Figma default) | Presence of `.text-field__description` |
| **Label mode** | `compact` (floating) · `placeholder` | `compact` | `.text-field--placeholder-label` — the label acts as a placeholder and hides when text is entered |

- The **label mode** parameter is enabled (compact) by default for every field type. In compact
  mode the label floats above the entered text; with the parameter disabled the label is a
  placeholder that disappears on input. The 3.2 Figma masters stage the compact form; the
  placeholder form is the spec's own contract (staged on the 3.0 instances) and both platforms
  ship it (`DsTextFieldLabelState.Placeholder`).
- **CustomTextField** unlocks every colour and layout value; the master's `Type` swap chooses
  between it and the predefined set.
- **Data loading is not a State value**: it is a busy overlay — `aria-busy` on the field and the
  loader replacing Slot#2 — over whichever state the field is in (the web stages it on
  `standard filled`).

---

## 4. States

| State | Border | Label | What shows |
|---|---|---|---|
| **Standard empty** (Figma: rest empty) | none | Main Body 16/20, `TextAndIcon/Secondary`, vertically centred | The label alone (compact) — or the placeholder |
| **Active empty** | `Border/Active`, `s2` inside | Compact Body 14/16, floated up | The caret below the label; the label stays until the first character in placeholder mode |
| **Active filled** | `Border/Active`, `s2` inside | Compact 14/16 | Text 16/20 `TextAndIcon/Primary` + caret |
| **Standard filled** (Figma: rest filled) | none | Compact 14/16 | The entered text keeps the floated label |
| **Error** (`Validation=error`) | `Border/Error`, `s2` inside | per state | The Description turns `TextAndIcon/Error` and shows the `warning-filled` icon at 16×16. The red border does not disappear until the next validation |
| **Disabled** | none | `TextAndIcon/Disabled` | Everything — label, text, icons, description — tints Disabled; the "inactive state of the component" |
| **Skeleton** | — | — | The whole box becomes a `Skeleton/OnWhite` rectangle at radius `s20` with the DS shimmer; no Description |
| **Data loading** | per state | per state | Waiting for the server: a loader replaces Slot#2 |

- The touch area is the **entire component** — tapping anywhere (label, box, description side)
  focuses the input. On the web the box is a `<label>`; the component script forwards
  Description clicks to the input (the same script that owns `.text-field--filled`).
- Error is an independent axis: it combines with rest/active and empty/filled.
- **Height:** Figma binds the box as a `s56` *minimum*; both platforms ship it as a **fixed**
  height 56 capped ×130 — a recorded nuance. The web keeps the Figma minimum.
- **Data-loading gates diverge:** iOS hides the Description while loading and shows the loader
  even when disabled; Android keeps the Description visible and suppresses the loader when
  disabled. Flagged for an owner ruling.

---

## 5. Animation and behavior

Motion reference: [`motion-rules.md`](https://super-dollop-pzmo65r.pages.github.io/motion.md). The field's own
motion is the **Scale Medium** pattern:

| Event | Token | Notes |
|---|---|---|
| Label floats up / returns | `var(--pattern-scale-medium-appear)` / `var(--pattern-scale-medium-hide)` | 16/20 → 14/16 on activation. Both platforms run exactly Scale Medium's numbers (400ms, StandardEaseInOut); Android interpolates the real font sizes, iOS approximates with a geometric scale (≈ ×0.8) — and its Secure field plays 250ms, an internal inconsistency (both flagged). The web transitions `font-size`/`line-height`/`height` — a **recorded deviation** from the compositor-only guidance (the Indicator width-glide precedent), mirroring Android's real-font interpolation |
| Active border appears | `var(--pattern-scale-medium-appear)` | "The Border appears from transparency upon field activation (using a curve and duration from Scale Medium)" |
| Slot button press | platform-native | Android: a circular ripple; iOS: the icon swaps to its Pressed colour. **No press animation on the field itself** — required only for the buttons in Slot#1 (Phone field) and Slot#2 |
| Skeleton sweep | `var(--pattern-shimmer)` | The DS shimmer over the box |
| Loader (data loading) | `var(--pattern-spin)` | The spinner replacing Slot#2 (the Button spinner precedent on the web) |

Reduced motion zeroes every pattern token at `motion.css`; the component ships no override.

### Input behaviour

The editable core is BaseTextLine — masks (`X`/`9`), prefix/suffix (currency symbols, units of
measurement), forced-LTR amounts and the caret contract all apply unchanged. The caret adopts
the Input Text colour.

---

## 6. Color tokens

| Element | Token | Notes |
|---|---|---|
| Box fill | `var(--surface-on-white)` | Every non-skeleton state |
| Active border | `var(--border-active)` | `s2` inside, active states only |
| Error border | `var(--border-error)` | `s2` inside, until the next validation. Android paints it with `textAndIconError` instead of the border token — flagged |
| Label / placeholder | `var(--text-and-icon-secondary)` | Both label sizes |
| Input text / caret | `var(--text-and-icon-primary)` | Via the BaseTextLine slots |
| Slot icons | `var(--text-and-icon-primary)` | Slot#1 and Slot#2 defaults |
| Clear button icon | `var(--text-and-icon-secondary)` | `clear-filled` (3.2) |
| Description text / icon | `var(--text-and-icon-secondary)` | Default |
| Description in error | `var(--text-and-icon-error)` | Text and the `warning-filled` icon |
| Disabled (all content) | `var(--text-and-icon-disabled)` | The fill stays OnWhite |
| Skeleton | `var(--skeleton-on-white)` | The whole box |

The web custom properties: `--text-field-fill` / `--text-field-border` / `--text-field-label` /
`--text-field-description` — re-pointed by the state modifiers; a Custom instance re-points them
inline (the CustomTextField contract).

---

## 7. Typography

| Element | Style | Tokens |
|---|---|---|
| Label at rest (empty) / placeholder mode | Body/Main Body | `var(--text-body-main-body-*)` — 16/20 |
| Label floated (compact) | Body/Compact Body | `var(--text-body-compact-body-*)` — 14/16 |
| Input text | Body/Main Body | via BaseTextLine |
| Description | Body/Compact Body | 14/16, multiline |

---

## 8. Spacing

Values follow the Figma Specification frame; the box's radius and minimum height scale capped at
×130.

| Property | Token | Value @100% | Notes |
|---|---|---|---|
| Box min-height | `var(--sp-s56)` (max ×130%) | 56 | Grows with the SP mode up to 130% |
| Box radius | `var(--sp-s20)` (max ×130%) | 20 | |
| Border width | `var(--sp-s2)` | 2 | Inside; Active / Error only |
| Edge ↔ Slot#1 | `var(--sp-s8)` | 8 | Box padding-left |
| Slot#1 ↔ Text | `var(--sp-s8)` | 8 | StartText's own side padding |
| Label ↔ Text | `var(--sp-s0)` | 0 | The floated label sits directly above the line |
| Text ↔ Slot#2 | `var(--sp-s8)` | 8 | |
| Edge ↔ Slot#2 | `var(--sp-s4)` | 4 | Box padding-right |
| Edge ↔ Text, slots disabled | `var(--sp-s16)` | 16 | Total on either side when its slot is off |
| Between two Slot#2 items | `var(--sp-s0)` | 0 | `Count=Two` |
| Slot#1 | `var(--sp-s40)` square | 40 | Icon `s24` inside (the IconContainer) |
| Slot#2 item | `var(--sp-s48)` square | 48 | Icon `s24` inside |
| Box ↔ Description | `var(--sp-s8)` | 8 | The Description's own top padding |
| Description side paddings | `var(--sp-s16)` | 16 | Aligns the helper with the slots-off text edge |
| Description icon ↔ text | `var(--sp-s4)` | 4 | Icon `s16`; aligns with the first line of long text |
| Outer margins (public master) | `var(--sp-s4)` / `var(--sp-s16)` | 4 / 16 | Vertical / horizontal, on the wrapper — not on the component root |
| Custom slot elements | — | — | A custom element in a slot sets its touch area to `s40` height and at least `s40` width |

Figma observation (flagged): with `Slot#2` hidden the set's own auto-layout composes the right
edge at `4 + 8 = 12`, while the Specification frame states `s16` for the slots-off edge — the web
follows the stated `s16` (the box's right padding steps `s4 → s8` when the slot is absent).
Android composes the same `12`; iOS switches to `s16` and matches the spec.

### Recorded platform divergences (beyond the ones above)

- **Skeleton radius**: the spec and Android use the field's own `s20`; iOS draws `s24` — flagged.
- **Radius scaling**: iOS caps the background radius ×130 but not the border overlay's; Android
  does not cap the radius at all — flagged.
- **Outer margins** (`s4`/`s16`): the Figma public master alone carries them; neither platform
  implements the wrapper — margins are the caller's duty, and the web mirrors that (the page
  chrome, not the component root).
- **In-field text button**: iOS radius `s12` (cap ×100) vs Android `16` — flagged.
- **Disabled prefix/suffix**: Android does not render them at all; iOS renders them tinted.
- **Border colour transition**: Android animates error ↔ active over 200ms linear; iOS snaps.

---

## 9. Usage context

### When to use TextField

- Any single-line text entry in a form or bottom sheet: names, e-mails, amounts, promo codes,
  addresses.
- As the base anatomy for the family: Password Input (`[PasswordField]`, secure entry with the
  eye toggle) and Phone Input (`[PhoneField]`, the country-code button in Slot#1) reuse this
  contract.

### When not to use

- Multi-line entry — that is TextArea's contract (`BaseTextArea` line).
- A bare line inside a custom composition — use BaseTextLine directly and carry the host duties
  (name, focus affordance, target) yourself.
- Free-form styling beyond the colour slots — that is CustomTextField's unlock, not an override
  of the predefined set.

### Related components

- [BaseTextLine](https://super-dollop-pzmo65r.pages.github.io/base-text-line.md) — the editable core.
- `[PasswordField]` / `[PhoneField]` (Figma lines; not in the web DS yet) — the sibling types.
- Button — the in-field text button block (`button`, 68×40) staged in the building blocks.

---

## 10. Accessibility

Brief summary. Full spec: [`text-field-a11y.md`](https://super-dollop-pzmo65r.pages.github.io/text-field.md).

- One focus area for the field itself; the Figma voiced preview: *[Label, Helper, Double-tap to
  enter text. Double-tap and hold to long press]* — the label and the helper join the field's
  announcement.
- Slot#2 interactive elements are their own focus areas: *[Description. Button. Double tap to
  activate]*; the clear button announces *[Clear text. Button. Double tap to activate]*.
- The error state is conveyed by the Description text (read with the field), never by colour
  alone — the red border pairs with the error text and the 16×16 icon.
- Disabled uses the platform-native disabled semantics; the whole component tints.
- The floating label is one visual element in two positions — it is always the field's name,
  never announced twice.

---

## 11. Analytics and coverage contract

Contract per [`prototype-analytics-and-coverage.md`](https://github.com/inDriver/oymyakon-ds/blob/main/docs/prototype-analytics-and-coverage.md).

| Field | Value |
|---|---|
| `data-ds-component` | `text-field` |
| Coverage unit | yes |
| Tap target model | The field is one target (focus); each Slot#2 button is its own internal target |
| Actions | `focus` on the field; `tap` on slot buttons (`data-ds-action` per button, e.g. `clear`) |
| Internal targets | Slot#1 button (Phone field), Slot#2 buttons — each carries `data-ds-target` |
| Emits value | **Never the entered text** (the BaseTextLine rule); at most empty/filled and validation flags |

Attribute model: `data-ds-variant` = the slot shape (`plain` \| `start` \| `end` \| `start-end`),
`data-ds-state` = `standard-empty` \| `active-empty` \| `active-filled` \| `standard-filled` \|
`error` \| `disabled` \| `skeleton` (per spec-conventions, Figma's `rest` maps to `standard`).
Data loading is not a `data-ds-state` value — it is `aria-busy="true"` on the field plus the
loader in Slot#2, over whichever state the field is in.

```html
<div class="text-field" data-ds-component="text-field" data-ds-component-id="signup-email"
     data-ds-variant="end" data-ds-state="standard-empty">
  <label class="text-field__box">
    <span class="text-field__start-text">
      <span class="text-field__label" id="signup-email-label">Email</span>
      <span class="base-text-line" data-ds-component="base-text-line">
        <input class="base-text-line__input" type="text" inputmode="email"
               aria-labelledby="signup-email-label" aria-describedby="signup-email-desc">
      </span>
    </span>
    <span class="text-field__end-slot">
      <button class="text-field__slot-button" type="button" aria-label="Clear text"
              data-ds-action="clear" data-ds-target="clear-button"><!-- clear-filled icon --></button>
    </span>
  </label>
  <span class="text-field__description" id="signup-email-desc">Description</span>
</div>
```

---

## Changelog

| Version | Date | Change |
|---|---|---|
| 3.2.0 | 2026-09-22 | Initial spec, aligned to the Figma line `[TextField] 3.2` (set `8216:7890`: State × Disabled × Validation × RTL + StartSlot/EndSlot/Description booleans; public master = the outer-margin wrapper with the `Type` swap to CustomTextField; Update 3.2 of 2026-09-01: Description leading-icon slot at `s16`, `warning-filled` in Secondary, one-line rule with a trailing slot, clear icon `close`/Primary → `clear-filled`/Secondary; the staged Large description size was removed — one size ships). Contract ingested from the Specification frame (`4800:44297`): anatomy of 8 parts, spacing model (s8/s8/s0/s8/s4, s16 with slots off, s0 between the two Slot#2 items), Scale Medium for the label float and the border fade, press animation on slot buttons only, skeleton = the whole box, loader replaces Slot#2, no truncation, error border persists until the next validation, whole-component touch area, voiced previews. Both platforms ship 3.1 (`DsTextField`/`DsCustomTextField`/`DsSecureTextField`; Android `DsCustomTextField` + templated/password, two trailing slots) — the Description icon slot is the recorded 3.1→3.2 delta. Web build shipped on the BaseTextLine core. Figma observation flagged: the slots-off right edge composes to 12, the spec states `s16` — the web follows the spec. Review pass (same day): states renamed to the house Standard (Figma `rest` kept as the parenthetical), the web's font/height tween recorded as a compositor-guidance deviation (the Indicator precedent), the Description click forwarded to the input (whole-component touch area), data loading clarified as a busy overlay rather than a State value, and the error text's contrast measured ≈ 3.5:1 Day — under the small-text floor, a DS-wide status-token fact flagged to the owner. |

---

# TextField — Accessibility

The assembled field is one focus area that carries a real name — the label — plus the helper as
its description; the interactive slot elements are their own focus areas after it. The Figma
spec's voiced previews are the contract:

- The field: *[Label, Helper, Double-tap to enter text. Double-tap and hold to long press]*
- A Slot#2 element: *[Description. Button. Double tap to activate]*
- The clear button: *[Clear text. Button. Double tap to activate]*

Label rule: the label names the field's purpose ("Email", "Parcel cost") — the floating motion is
visual only; the name never changes or doubles when the label moves.

## Android · TalkBack

| Element | Label | Value | Trait | Hint |
|---|---|---|---|---|
| Field | The label text | The entered text; empty otherwise | `EditText` (edit box) | The Description text joins the announcement; "double-tap to edit" (system) |
| Slot#1 icon (decorative) | — | — | Not a separate element | — |
| Slot#1 button (Phone field) | Its action ("Country code") | The current value | Button — own focus area, circular ripple on press | — |
| Slot#2 button | Its action ("Clear text", "Show hint") | — | Button — own focus area | — |
| Description | — | — | Read with the field (its description), not a separate stop | — |
| Skeleton | — | — | Not announced; the loading container carries the busy semantics | — |

- The whole component is the touch target for focusing the input; the slot buttons sit on top
  with their own `s40`+ targets (ripple radius `28`, clicks debounced, active only while both
  the slot and the field are enabled).
- **Error is a semantic, not just text**: the shipped build sets the description as the node's
  `error(...)` semantic — TalkBack announces it as an error, not as plain text.
- **Verbatim reading**: `inputContentDescription` replaces the node text with a verbatim TTS
  annotation, so phone-number-like values read digit by digit.
- **Error:** the Description text changes to the error message and is re-announced with the
  field; the red border and the 16×16 `warning-filled` icon are the visual channel — colour
  alone never carries the error.
- **Disabled:** native disabled semantics; all content tints `TextAndIcon/Disabled`.
- **Data loading:** the loader replacing Slot#2 is decorative; the field reports busy via the
  loading container.

### Edge states

- **Placeholder label mode:** the label is still the accessible name even while it renders as a
  placeholder — it must not vanish from the announcement when typing hides it visually.
- **RTL:** mirrored layout, unchanged announcements.
- **Reduced motion:** the label float and border fade zero at the token; nothing announces.

---

## iOS · VoiceOver

| Element | Label | Value | Trait | Hint |
|---|---|---|---|---|
| Field | The label text | The entered text | `Textfield` (secure text field in the Password type) | The Description text as `accessibilityHint` / joined announcement |
| Slot#1 icon (decorative) | — | — | Hidden | — |
| Slot#2 button | Its action | — | Button — own element; the icon swaps to its Pressed colour on press | — |
| Description | — | — | Read with the field | — |

- The field, then each interactive slot, are separate swipe stops in reading order.
- **The shipped iOS build joins the pieces itself**: the field's `accessibilityLabel` is built
  as *placeholder, description, prefix* and the visible Description line is
  `accessibilityHidden(true)` — one announcement, no double-reading. The clear button carries
  the localized "clear text" label; the loader is hidden.
- **Secure field**: the whole component combines into ONE element (label = placeholder +
  description, the button trait removed); the eye toggle is visually a button but exposed as a
  named **custom action** (show/hide password), as is an interactive leading icon.

### Edge states

- **Error / Disabled / Skeleton / Data loading:** platform-native semantics, no custom masking;
  the error text joins the field's announcement, the loader is hidden.
- **Placeholder label mode:** `isCompact = false` — the placeholder string still names the field.
- **RTL:** mirrored layout, unchanged announcements.
- **Reduced motion:** the label float and border fade zero at the token.

---

## Web preview (reference)

- **The input is the one editable element**; the visible label is its accessible name and the
  Description its accessible description:

  ```html
  <div class="text-field" data-ds-component="text-field">
    <label class="text-field__box">
      <span class="text-field__start-text">
        <span class="text-field__label" id="f-label">Email</span>
        <span class="base-text-line" data-ds-component="base-text-line">
          <input class="base-text-line__input" type="text"
                 aria-labelledby="f-label" aria-describedby="f-desc">
        </span>
      </span>
    </label>
    <span class="text-field__description" id="f-desc">Description</span>
  </div>
  ```

  The `<label>` box makes the surface focus the input; the component script forwards
  Description clicks too — together the whole component is the touch area, per the spec.
  The label association is implicit (the first labelable descendant): today that is always the
  input, because Slot#1 is a decorative `<span>`. **The moment Slot#1 becomes a `<button>`**
  (the Phone field's country code), the implicit target would silently shift to it — that
  variant must ship an explicit `for=` → input `id` pairing.
- **Slot buttons are real `<button type="button">`** elements inside the box, named for the
  action (`aria-label="Clear text"`); a `<label>` wrapper forwards clicks to the input, so slot
  buttons must call `event.preventDefault()`-free native button behaviour — they are separate
  tab stops after the input.
- **Error:** add the error text to the Description node (already in `aria-describedby`) and
  toggle `aria-invalid="true"` on the input. The border and icon are the visual channel; the
  text is the announced one. The red border persists until the next validation — so does
  `aria-invalid`.
- **The floating label is one node.** It moves and rescales via transform/font swap — never
  duplicate it as a separate placeholder element, and never use `placeholder=` as the name
  (the BaseTextLine rule). In placeholder-label mode the visible text hides on input but the
  element keeps naming the field.
- **Disabled:** the real `disabled` attribute on the input; slot buttons get `disabled` too —
  a tinted-but-clickable button is the classic miss (CSS only re-points colours; the attribute
  is the contract, demonstrated in the States · Disabled specimen). Never `aria-disabled`
  masquerades.
- **Skeleton:** `aria-hidden="true"` on the box; the loading container carries
  `aria-busy="true"` (the DS pattern).
- **Focus visibility:** the Active border (`var(--border-active)` at `var(--sp-s2)`) IS the
  focus indicator — driven by `:focus-within`, it satisfies the visible-focus duty BaseTextLine
  delegates to its host. Keyboard and pointer focus draw the same border.
- **Reduced motion:** the label float and border fade sit on `var(--pattern-scale-medium-*)`,
  zeroed at the token; no override in the component.

### Colour and contrast

| Pairing | Tokens | Requirement |
|---|---|---|
| Input text on the box | `--text-and-icon-primary` on `--surface-on-white` | ≥ 4.5:1 — ≈ 15:1 Day |
| Label / Description | `--text-and-icon-secondary` on `--surface-on-white` | ≥ 4.5:1 — measured ≈ 4.6:1 Day (little margin; the BaseTextLine measurement) |
| Error text / icon | `--text-and-icon-error` on `--surface-on-white` | ≥ 4.5:1 — **measured ≈ 3.5:1 Day / ≈ 3.8:1 Night**: under the small-text floor at Compact Body 14. The error is announced (the text is in `aria-describedby` + `aria-invalid`), and the icon adds a non-colour channel — but the red-on-light shortfall is a DS-wide status-token fact (the Notification/Indicator reds measured 3.98/3.47 before), flagged to the owner |
| Active border | `--border-active` against the surrounding surface | ≥ 3:1 (non-text) — near-black on light, white on dark |
| Error border | `--border-error` | ≥ 3:1 (non-text) |
| Slot icons | `--text-and-icon-primary` | ≥ 3:1 |
| Disabled content | `--text-and-icon-disabled` | None — disabled and reported as such |
| Skeleton | `--skeleton-on-white` | None — no content of its own |

A CustomTextField re-pointing the `--text-field-*` slots carries the same duty for its pairings.

---

## Machine contract — `specs/components/text-field/capabilities.json`

Axes, allowed values, defaults, constraints and CSS/Figma bindings. Read this instead of guessing what the component can do.

```json
{
  "$schema": "../component-capabilities.schema.json",
  "id": "text-field",
  "name": "TextField",
  "version": "3.2.0",
  "description": "The assembled text field on the BaseTextLine core: an OnWhite box (radius s20, min-height s56, both max ×130%) with a floating label, two icon slots, an Active/Error s2 inside border, and an optional Description line below. Text Input of the field family; Password and Phone are sibling lines on the same anatomy.",
  "files": {
    "spec": "specs/components/text-field/text-field.md",
    "a11y": "specs/components/text-field/text-field-a11y.md",
    "preview": "src/text-field.njk",
    "css": "src/shared/shared.css"
  },
  "figma": {
    "library": "🕹️ Oymyakon 3.32.3 (components)",
    "fileKey": "7vdl5YkZFDWvh9QvSmydsH",
    "componentSets": {
      "TextField": {
        "key": "f72076c988aa330f78d165998782b462b4aae592"
      }
    },
    "capturedAt": "2026-09-22"
  },
  "root": {
    "class": "text-field",
    "dataDsComponent": "text-field"
  },
  "anatomy": {
    "box": {
      "class": "text-field__box",
      "notes": "The field surface and the one touch area — a <label>, so tapping anywhere focuses the input. Fill Surface/OnWhite in every non-skeleton state; the s2 inside border (box-shadow) carries Active/Error."
    },
    "startSlot": {
      "class": "text-field__start-slot",
      "optional": true,
      "notes": "Slot#1 — s40 square, a DS icon only (s24 glyph); a custom element carries its own s40 touch area. Edge gap s8."
    },
    "startText": {
      "class": "text-field__start-text",
      "notes": "The floating label above the BaseTextLine input line; fills the row, side paddings s8, long text hides beyond the boundary — no truncation."
    },
    "label": {
      "class": "text-field__label",
      "notes": "The field's name — Main Body 16/20 at rest-empty, Compact Body 14/16 floated; one node, moved on Scale Medium, never duplicated."
    },
    "endSlot": {
      "class": "text-field__end-slot",
      "optional": true,
      "notes": "Slot#2 — one or two s48 items at s0 apart (Count=One|Two); edge gap s4. Default content: the info icon; presets: clear button (.text-field__slot-button--clear, clear-filled in Secondary), the loader (.text-field__spinner replaces the inner item while waiting for the server)."
    },
    "slotButton": {
      "class": "text-field__slot-button",
      "optional": true,
      "notes": "A real <button> inside a slot — its own focus stop and s48 target; press feedback is platform-native (ripple / Pressed colour), the field itself never pushes."
    },
    "description": {
      "class": "text-field__description",
      "optional": true,
      "notes": "The helper line — Compact Body 14/16, multiline, top gap s8, side paddings s16, optional s16 leading icon at s4 gap (3.2); one line only with a trailing slot. Turns TextAndIcon/Error with the warning-filled icon in error."
    }
  },
  "axes": {
    "state": {
      "title": "State",
      "type": "enum",
      "values": [
        "standard-empty",
        "active-empty",
        "active-filled",
        "standard-filled",
        "skeleton"
      ],
      "default": "standard-empty",
      "css": {
        "mechanism": "active = the box's :focus-within (border + label float); filled = .text-field--filled (JS toggles on input so the floated label survives blur); skeleton = .text-field--skeleton"
      },
      "figma": {
        "kind": "variant-property",
        "property": "State",
        "values": {
          "standard-empty": "rest empty",
          "active-empty": "active empty",
          "active-filled": "active filled",
          "standard-filled": "rest filled",
          "skeleton": "skeleton"
        },
        "notes": "Figma's 'rest' maps to 'standard' per spec-conventions."
      },
      "notes": "Data loading is not a State value — aria-busy + the loader in Slot#2 over the current state."
    },
    "disabled": {
      "title": "Disabled",
      "type": "boolean",
      "default": false,
      "css": {
        "modifier": ".text-field--disabled",
        "mechanism": "plus the input's and slot buttons' disabled attribute; every colour slot re-points to TextAndIcon/Disabled, the fill stays"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Disabled"
      }
    },
    "validation": {
      "title": "Validation",
      "type": "enum",
      "values": [
        "none",
        "error"
      ],
      "default": "none",
      "css": {
        "modifier": ".text-field--error",
        "mechanism": "Border/Error s2 inside (wins over focus) + the Description turns error with the 16×16 warning-filled icon; persists until the next validation"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Validation"
      }
    },
    "rtl": {
      "title": "RTL",
      "type": "boolean",
      "default": false,
      "css": {
        "mechanism": "dir=\"rtl\" on the host mirrors the row, label, and Description; the BaseTextLine mask/amount rules hold inside"
      },
      "figma": {
        "kind": "variant-property",
        "property": "RTL"
      }
    },
    "startSlot": {
      "title": "Slot#1",
      "type": "boolean",
      "default": true,
      "css": {
        "mechanism": "presence of .text-field__start-slot"
      },
      "figma": {
        "kind": "boolean-property",
        "property": "🔴StartSlot"
      }
    },
    "endSlot": {
      "title": "Slot#2",
      "type": "enum",
      "values": [
        "none",
        "one",
        "two"
      ],
      "default": "one",
      "css": {
        "mechanism": "number of items in .text-field__end-slot; when absent the box's right padding steps s4 → s8 so the edge composes the spec's s16"
      },
      "figma": {
        "kind": "boolean-property",
        "property": "🔵EndSlot",
        "notes": "The boolean shows/hides the slot; the item count is the nested 🔵EndSlot set's Count=One|Two."
      }
    },
    "descriptionSlot": {
      "title": "Description",
      "type": "boolean",
      "default": true,
      "css": {
        "mechanism": "presence of .text-field__description"
      },
      "figma": {
        "kind": "boolean-property",
        "property": "Description"
      }
    },
    "labelMode": {
      "title": "Label mode",
      "type": "enum",
      "values": [
        "compact",
        "placeholder"
      ],
      "default": "compact",
      "css": {
        "modifier": ".text-field--placeholder-label",
        "mechanism": "compact = the floating label; placeholder = the label renders as the input's placeholder (visually hidden node keeps the name), nothing floats"
      },
      "figma": {
        "kind": "none",
        "notes": "The 3.2 masters stage the compact form; the placeholder form is the Specification frame's contract, shipped on both platforms (DsTextFieldLabelState.Placeholder / isCompact=false)."
      }
    },
    "colorSlots": {
      "title": "Colour slots",
      "type": "token",
      "customizable": true,
      "tokens": [
        "--surface-on-white",
        "--border-active",
        "--border-error",
        "--text-and-icon-secondary",
        "--text-and-icon-error",
        "--text-and-icon-disabled"
      ],
      "css": {
        "mechanism": "--text-field-fill / --text-field-border / --text-field-label / --text-field-description re-pointed per instance — the CustomTextField contract (Figma: the Type swap on the public master)"
      },
      "figma": {
        "kind": "none",
        "notes": "CustomTextField component 13561:14702 unlocks the values; the master 8216:7544 swaps Type between it and the set."
      }
    }
  },
  "analytics": {
    "dataDsComponent": "text-field",
    "action": "focus",
    "valueAttr": "data-ds-state",
    "notes": "Coverage unit. The field is one focus target; each slot button is its own internal target with data-ds-action (e.g. clear) + data-ds-target. Never emit the entered text — at most empty/filled and validation flags; data-ds-state carries standard-empty|active-empty|active-filled|standard-filled|error|disabled|skeleton (data loading = aria-busy, not a state value)."
  },
  "rtl": {
    "supported": true,
    "notes": "A Figma variant axis: the row, label alignment and Description mirror. Inside, BaseTextLine keeps masks LTR and pins amounts via .base-text-line--ltr."
  },
  "constraints": [
    "The editable core is a BaseTextLine instance — its contract (masks, prefix/suffix, caret, never-truncate affixes) applies unchanged.",
    "Long input text hides beyond the component's boundary — truncation is not used; the Description is full-length and multiline (one line only with a trailing slot).",
    "The error border does not disappear until the next validation.",
    "No press animation on the field itself — only the slot buttons animate (platform-native).",
    "Skeleton is the whole box (one rectangle, radius s20, DS shimmer), identical for every field type; the loader replaces only the inner Slot#2 item.",
    "The public master's outer margins (s4/s16) are the Figma wrapper's alone — neither platform nor the web put them on the component root.",
    "Never emit the entered text through analytics."
  ]
}
```
