> ## 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/service-card.html
> Source: specs/components/service-card/service-card.md, specs/components/service-card/service-card-a11y.md, specs/components/service-card/capabilities.json

---

# ServiceCard · Oymyakon DS 3

> A service tile — the entry point into a service or vertical: a fixed-size rounded card with a
> caption title and a catalogue illustration composed by an [ImageContainer](https://github.com/inDriver/oymyakon-ds/blob/main/specs/primitives/image-container.md)
> recipe. A locked customCard configuration promoted into its own component — the DestinationButton
> precedent.

**Version:** 3.0.0 · **Status:** Draft · **Figma:** `[ServiceCard] 3.0` (branch
`A8v9RCbAnHa2XBnpBeSMoY`, set node `32149:6651`) — the branch is not merged; componentKeys are
uncaptured until it lands in the main components file

---

## 1. Description

ServiceCard presents one service (city ride, courier, delivery…) as a tappable tile: the service
name in a caption line and the service's 3D vehicle illustration from the canonical
`illu/3D/vehicle/full/*` catalogue, seated by a placement recipe. Grids and rows of these tiles
form service pickers and home-screen entry points.

It is a **locked configuration of the customCard base** (like DestinationButton is of
customButton): the base's slot model stays, the geometry and content are pinned. The illustration
is hosted by the [ImageContainer](https://github.com/inDriver/oymyakon-ds/blob/main/specs/primitives/image-container.md) primitive — the source
picture is always the canonical file, the composition comes from the recipe table, and the
container is an opaque boundary (its § 9 rules apply verbatim here).

---

## 2. Anatomy

```
ServiceCard                    .service-card — fixed-size rounded tile
├── BottomSlot                 .service-card__bottom — absolute, stretch-anchored to all
│   │                          four edges (inset 0): the full-bleed underlay; clips at the
│   │                          root radius
│   └── ImageContainer         .image-container .image-container--{l|m|s} — the primitive;
│                              recipe seats the artwork, negative offsets bleed past edges
├── StartText                  .service-card__title-row — pinned to the top, above the underlay:
│   │                          a column of up to two caption lines at gap s0
│   ├── title line             .service-card__line > .service-card__title — required
│   └── [subtitle line]        .service-card__line > .service-card__subtitle — hidden by default
└── [DecorateContainer]        composition — the tile wrapped in the primitive (§ 3)
```

| Element | Required | Description |
|---|---|---|
| **root** | Required | Fixed size per the Size axis, fill `Surface/OnWhite`, radius `s20` |
| **BottomSlot → ImageContainer** | Required | The artwork underlay. The slot sizes the container; everything inside is the primitive's business |
| **StartText → title** | Required | The first caption line; with the subtitle it forms the accessible name. Each line has two modes: Standard (plain text) and Expanded — up to two trailing slots on the line (any DS element; IconContainer by default, `s4` gaps) |
| **StartText → subtitle** | Optional | The second caption line in `TextAndIcon/Secondary`, hidden by default; `s0` gap to the title |
| **DecorateContainer** | Optional (Figma `decoration`) | The top-right decoration — the [DecorateContainer](https://super-dollop-pzmo65r.pages.github.io/decorate-container.md) primitive; the defaults are [Tag](https://super-dollop-pzmo65r.pages.github.io/tag.md) and [Indicator](https://super-dollop-pzmo65r.pages.github.io/indicator.md). On web it is composition, not a modifier: the tile is wrapped in the primitive (§ 3) |

---

## 3. Variants and sizes

| Axis | Values | Default | Notes |
|---|---|---|---|
| `Size` | `L` `160×160` / `M` `160×76` / `S` `76×76` | `L` | **Minimum** dimensions (the Figma General contract: 160×160 is the min size) — a grid track may stretch the tile; the artwork recipe holds its edge anchoring. `160` is `var(--sp-s160)` (the SP scale extends to `s196` as of this release); `76` is `var(--sp-s76)` |
| `State` | `Standard` / `Skeleton` | `Standard` | See § 4 |
| `RTL` | `Off` / `On` | `Off` | Mirrors the layout (title to the inline-end); the illustration mirrors with it — the ImageContainer flips every artwork under RTL (ImageContainer § 5) |
| `picture` | canonical asset name | `economy-white` | Swaps the illustration; the composition follows from the recipe table automatically |
| `title` | text | `Title` | One line; the accessible name, verbatim |

**`decoration` on web is composition, not a modifier.** The Figma set carries a `decoration`
boolean showing the top-right DecorateContainer. The slot's contract is the
[DecorateContainer](https://super-dollop-pzmo65r.pages.github.io/decorate-container.md) primitive (owner, 2026-09-11): it may
host **any DS component**; the defaults are [Tag](https://super-dollop-pzmo65r.pages.github.io/tag.md) and
[Indicator](https://super-dollop-pzmo65r.pages.github.io/indicator.md). On web the tile is WRAPPED in the primitive — the assembler
places the construction, the tile itself ships no extra class:

```html
<span class="decorate-container">
  <span class="decorate-container__slot1"><!-- the service-card instance --></span>
  <span class="decorate-container__slot2"><!-- Tag / Indicator --></span>
</span>
```

Known content gap: the specimen staged in the Figma set (`Custom-Label`, a 51×32 sticker) still
ships a raw `#FA4032` fill, a `TextAndIcon/OnColor` token absent from the semantic set, and
off-scale typography — that staged artwork stays Figma-side until its tokens are fixed; the clean
defaults above are what the web composes.

---

## 4. States

| State | Description |
|---|---|
| **Standard** | The tile at rest: surface, title, artwork |
| **Skeleton** | Surface and radius hold; the title row is replaced by a [Skeleton](https://super-dollop-pzmo65r.pages.github.io/skeleton.md) text line painted `Surface/Overlay` (instance-set — the Skeleton default `Skeleton/OnWhite` is invisible on the OnWhite tile; radius `s2`, shimmer); the artwork and the label are absent — the ImageContainer is removed, not skeletonized |

The set carries no pressed/disabled/loading variants. Press feedback exists at runtime and is
inherited from the customCard base — see § 5.

---

## 5. Animation and behavior

Tokens per `tokens/rules/motion-rules.md`; nothing is declared locally.

| Event | Pattern | Token | Property |
|---|---|---|---|
| Press / release | `pushItem` (inherited from the customCard base — the card family press) | `var(--component-push-item-press)` / `var(--component-push-item-release)` | `transform: scale(1 → 0.95 → 1)` |
| Skeleton shimmer | `Patterns/Shimmer` via the Skeleton component | `var(--pattern-shimmer)` | Skeleton's own |
| Lottie in the container | see ImageContainer § 6 | — | blocked on the DS Lottie player decision |

Reduced motion is zeroed at the token (`motion.css`); the component ships no override of its own.

---

## 6. Color tokens

| Element | Token (Light = Dark by name) |
|---|---|
| Root fill | `Surface/OnWhite` |
| Title | `TextAndIcon/Primary` |
| Subtitle | `TextAndIcon/Secondary` — a deliberate, owner-approved deviation (2026-09-11) from `color-rules.md` § 14: the Secondary pair is AA for large text only and the subtitle is a 12px caption; the Figma design keeps Secondary knowingly |
| Skeleton bar | `Surface/Overlay` — instance-set on the Skeleton line (the component default `Skeleton/OnWhite` is invisible on the OnWhite tile) |

The artwork brings its own colors (canonical PNG); nothing is tinted.

---

## 7. Typography

| Element | Style |
|---|---|
| Title | `Caption/Caption` — Suisse Intl Book, size `s12`, line-height `s16` (full scaling; the 12px caption floor holds at 100 %) |
| Subtitle | `Caption/Caption` in `TextAndIcon/Secondary`; hidden by default |

A caption-sized title is deliberate (Figma): the tile is compact and the name is short. Long names
clamp to one line with an ellipsis.

---

## 8. Spacing

| Property | Value |
|---|---|
| Root radius | `var(--sp-s20)` |
| Root sizes | L `var(--sp-s160)` square · M `var(--sp-s160)`×`var(--sp-s76)` · S `var(--sp-s76)` square — minimums, see § Properties |
| StartText padding | top `var(--sp-s10)`, sides `var(--sp-s12)` |
| Title–subtitle gap | `var(--sp-s0)` (the Specification frame; the staged component instance carries `s4` — a known Figma-side discrepancy for the owner) |
| Trailing-slot gap (Expanded line) | `var(--sp-s4)` |
| Decoration seating | the Tag inside the DecorateContainer; the container steps out of the tile by `var(--sp-s8)` above the top edge and `var(--sp-s4)` past the inline-end edge (negative instance offsets) |
| Artwork placement | NOT spacing — recipe data per ImageContainer § 4 (`box / bottom / end` per `size-class × picture`) |

---

## 9. Usage context

**Use it for** a grid or row of service entry points — each tile is one service, one tap, one
destination. The picture changes per service by name; the seat comes from the recipe.

**Do not use it for:**

- Content cards with body text, actions, or media pinning — that is [Card](https://super-dollop-pzmo65r.pages.github.io/card.md).
- A single full-width entry into an order flow — that is
  [DestinationButton](https://super-dollop-pzmo65r.pages.github.io/destination-button.md).
- Artwork placed outside the ImageContainer contract — a raw `<img>` in a card is a token-rule
  violation, not a variant.

Related components: [Card](https://super-dollop-pzmo65r.pages.github.io/card.md) (the customCard base),
[ImageContainer](https://github.com/inDriver/oymyakon-ds/blob/main/specs/primitives/image-container.md) (the artwork host),
[Skeleton](https://super-dollop-pzmo65r.pages.github.io/skeleton.md) (the loading state),
[DestinationButton](https://super-dollop-pzmo65r.pages.github.io/destination-button.md) (the promotion precedent).

---

## 10. Accessibility

One accessible element: the tile is a single button whose name is the StartText content — the title, then the subtitle when shown. The
illustration is decorative and silent (ImageContainer § 10). Skeleton is invisible to assistive
tech. Full spec: [`service-card-a11y.md`](https://super-dollop-pzmo65r.pages.github.io/service-card.md).

---

## 11. Analytics and coverage contract

Per `docs/prototype-analytics-and-coverage.md`.

| Field | Value |
|---|---|
| `data-ds-component` | `service-card` |
| Coverage unit | yes |
| Tap target model | root — the whole tile is the single target |
| Actions | `tap` |
| Internal targets | none (the artwork and title are not separately tappable) |
| Emits value | no |

Required prototype markup:

```html
<button class="service-card service-card--l"
        data-ds-component="service-card" data-ds-component-id="{unique}"
        data-ds-variant="l" data-ds-state="standard" data-ds-action="tap">
  <span class="service-card__bottom">
    <span class="image-container image-container--l" data-picture="economy-white" aria-hidden="true">
      <span class="image-container__artwork image-container__artwork--mirror">
        <img src="/illu/3D/vehicle/full/economy-white.png" alt="">
      </span>
    </span>
  </span>
  <span class="service-card__title-row"><span class="service-card__title">City Ride</span></span>
</button>
```

---

## Changelog

| Version | Date | Change |
|---|---|---|
| 3.0.0 | 2026-09-10 | First spec. Promoted from the customCard base (`[ServiceCard] 3.0`, Figma branch `A8v9RCbAnHa2XBnpBeSMoY`): Size L/M/S × State Standard/Skeleton × RTL, `picture`/`title` instance props; artwork via the new ImageContainer primitive and its recipe table; `decoration` (Custom-Label) recorded as a known Figma-side gap — not registered pending token fixes. |

---

# ServiceCard — Accessibility

> One tile = one button. The accessible name is the StartText content: the title, then the
> subtitle when shown, comma-joined — each line verbatim. The illustration is decorative and never
> announced; Skeleton is invisible to assistive tech; a decorated tile stays a single focus area.

Component spec: [`service-card.md`](https://super-dollop-pzmo65r.pages.github.io/service-card.md) · **Version:** 3.0.0

---

## Android · TalkBack

| Element | Label | Value | Trait | Hint |
|---|---|---|---|---|
| ServiceCard (root) | `{title}[, {subtitle}]` — the visible lines, verbatim, comma-joined; a hidden subtitle contributes nothing | — | Button | — |
| ImageContainer / artwork | not announced (`importantForAccessibility="no"`) | — | — | — |
| Title / subtitle text nodes | folded into the root's label — never separate stops | — | — | — |

- The whole tile is ONE focusable element; swipe navigation lands on it once.
- The label is computed from the visible StartText lines only. Visual truncation (ellipsis) does
  not truncate the announcement — TalkBack reads each full string.
- An Expanded line's trailing slots: the default IconContainer is decorative and stays silent
  (`importantForAccessibility="no"`); a meaningful trailing element folds its text into the root's
  label after that line — a trailing slot never becomes its own swipe stop.
- No state suffix: the tile has no checked/expanded semantics to announce.

### Edge states

- **Skeleton:** the tile is removed from focus order entirely (`importantForAccessibility="no"` on
  the root, no clickable span). Nothing is announced per tile; if the hosting screen announces
  loading, it does so once at list level (`aria-busy` equivalent on the container), never per tile.
- **RTL:** the reading order and label are unchanged — one element, one name; only the visual
  layout (illustration included) mirrors.
- **Decorated tile:** the DecorateContainer wrapper is the single focusable node
  (`screenReaderFocusable`; the slots inside are `importantForAccessibility="no"`); the role stays
  Button and a meaningful decorator lands in `stateDescription` — "City Ride, button, new"
  (DecorateContainer § 10).

---

## iOS · VoiceOver

| Element | Label | Value | Trait | Hint |
|---|---|---|---|---|
| ServiceCard (root) | `{title}[, {subtitle}]` — the visible lines, verbatim, comma-joined; a hidden subtitle contributes nothing | — | Button | — |
| ImageContainer / artwork | not an accessibility element (`isAccessibilityElement = false`) | — | — | — |
| Title / subtitle text nodes | folded into the root's label — never separate elements | — | — | — |

- One `UIAccessibilityElement` per tile with the Button trait; the rotor sees a single item.
- The announcement is the visible StartText lines alone — no "image" leaking from the artwork, no
  invented "service" wording the screen doesn't show.
- An Expanded line's trailing slots: the default IconContainer is decorative and stays silent
  (`isAccessibilityElement = false`); a meaningful trailing element folds its text into the label —
  never a separate element.

### Edge states

- **Skeleton:** the tile is skipped (`isAccessibilityElement = false`); VoiceOver never lands on a
  shimmering placeholder. Screen-level loading announcements are the host's business.
- **Dimmed/disabled:** does not exist — the component has no disabled state (spec § 4); a service
  that cannot be opened is not rendered as a tile.
- **Decorated tile:** the DecorateContainer wrapper is the accessibility element
  (`isAccessibilityElement = true`; the tile and the decorator slots are `false`); the trait stays
  Button and a meaningful decorator's meaning is appended to the label — "City Ride, new"
  (DecorateContainer § 10).

---

## Web preview notes

- The root renders as a real `<button>`; the accessible name comes from the visible StartText text
  content — no `aria-label` duplication of what the DOM already says.
- The one exception is the decorated tile: the decorator slots carry `aria-hidden="true"`
  (DecorateContainer § 10), so a meaningful decorator joins the name through an explicit
  `aria-label` on the host button — the StartText lines plus the decorator meaning,
  `aria-label="City Ride, new"`. A purely visual decorator adds nothing and needs no label.
- An Expanded line's default IconContainer sits `aria-hidden` inside the line; a meaningful
  trailing element's text participates in the name through the normal text content.
- The ImageContainer subtree carries `aria-hidden="true"` (ImageContainer § 10).
- Skeleton preview specimens render as non-focusable `<div>`s, never named `<button>`s (the
  SlidingButton review precedent).
- A Lottie in the container, when the player lands, stays inside the `aria-hidden` subtree and must
  respect `prefers-reduced-motion` (ImageContainer § 6).

---

## Changelog

| Version | Date | Change |
|---|---|---|
| 3.0.0 | 2026-09-10 | First spec: single-button model; the name = the visible StartText lines (title, then subtitle when shown, comma-joined); decorative artwork; skeleton invisible on both platforms; decorated tile = single focus area per DecorateContainer § 10 (web: slots `aria-hidden`, meaning joins via host `aria-label`); Expanded trailing slots silent by default, meaningful content folds into the name. |

---

## Machine contract — `specs/components/service-card/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": "service-card",
  "name": "ServiceCard",
  "version": "3.0.0",
  "description": "A service tile — the entry point into a service or vertical: a fixed-size rounded card (Surface/OnWhite, radius s20) with one caption title and a catalogue illustration seated by an ImageContainer recipe. A locked customCard configuration promoted into its own component (the DestinationButton precedent). Figma: [ServiceCard] 3.0, set node 32149:6651 on branch A8v9RCbAnHa2XBnpBeSMoY (not merged — componentKeys uncaptured until it lands).",
  "files": {
    "spec": "specs/components/service-card/service-card.md",
    "a11y": "specs/components/service-card/service-card-a11y.md",
    "preview": "src/service-card.njk",
    "css": "src/shared/shared.css"
  },
  "figma": {
    "library": "🕹️ Oymyakon 3.30.1 (components) — branch A8v9RCbAnHa2XBnpBeSMoY"
  },
  "root": {
    "class": "service-card",
    "dataDsComponent": "service-card"
  },
  "anatomy": {
    "bottom": {
      "class": "service-card__bottom",
      "notes": "The artwork underlay: absolute, stretch-anchored to all four edges (inset 0), clipped at the root radius s20. Holds one ImageContainer instance (specs/primitives/image-container.md) sized by this slot."
    },
    "imageContainer": {
      "class": "image-container",
      "notes": "The primitive, size-classed to match the tile (.image-container--l/--m/--s) and keyed by data-picture. OPAQUE BOUNDARY (owner, 2026-09-10): composition comes only from the recipe table — never positioned ad hoc here. aria-hidden — decorative."
    },
    "titleRow": {
      "class": "service-card__title-row",
      "notes": "StartText pinned to the top above the underlay: a column of up to two caption lines at gap s0; padding top s10, sides s12. Each line has Standard and Expanded modes — Expanded adds up to two trailing slots (any DS element; IconContainer default, s4 gaps)."
    },
    "title": {
      "class": "service-card__title",
      "notes": "The first caption line (Caption/Caption, TextAndIcon/Primary), clamps with an ellipsis; with the subtitle it forms the accessible name."
    },
    "subtitle": {
      "class": "service-card__subtitle",
      "notes": "The optional second caption line, TextAndIcon/Secondary, hidden by default; s0 gap to the title (the Specification frame — the staged instance carries s4, a known Figma-side discrepancy)."
    }
  },
  "axes": {
    "size": {
      "title": "Size",
      "type": "enum",
      "values": [
        "l",
        "m",
        "s"
      ],
      "default": "l",
      "css": {
        "modifierTemplate": ".service-card--{value}"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Size",
        "values": {
          "l": "L",
          "m": "M",
          "s": "S"
        }
      },
      "constraints": [
        "MINIMUM dimensions (the Figma General contract): L 160×160 (var(--sp-s160) per side), M var(--sp-s160)×var(--sp-s76), S var(--sp-s76) square — a grid track may stretch the tile; the artwork recipe holds its edge anchoring.",
        "The size also picks the ImageContainer size-class — the two never diverge."
      ]
    },
    "state": {
      "title": "State",
      "type": "enum",
      "values": [
        "standard",
        "skeleton"
      ],
      "default": "standard",
      "css": {
        "mechanism": "standard = no modifier; skeleton = .service-card--skeleton — the title row is replaced by a Skeleton text line painted Surface/Overlay (instance-set — the Skeleton default is invisible on the OnWhite tile; radius s2, shimmer) and the ImageContainer is REMOVED, not skeletonized."
      },
      "figma": {
        "kind": "variant-property",
        "property": "State",
        "values": {
          "standard": "Default",
          "skeleton": "Skeleton"
        }
      }
    },
    "picture": {
      "title": "Picture",
      "type": "text",
      "default": "economy-white",
      "customizable": "The canonical asset name from illu/3D/vehicle/full/ (exact file name, e.g. economy-white, Courier2). Binds as data-picture on the ImageContainer; the composition follows from the recipe table (size-class × picture) automatically. Every value must resolve to a file AND a recipe row — the ImageContainer § 4 gate."
    },
    "titleText": {
      "title": "Title",
      "type": "text",
      "default": "Title",
      "customizable": "One short service name. It is the whole accessible name — announced verbatim; visual ellipsis never truncates the announcement."
    },
    "rtl": {
      "title": "RTL",
      "type": "boolean",
      "default": false,
      "css": {
        "mechanism": "dir=\"rtl\" on the root — the layout mirrors (title to the inline-end); the artwork mirror does NOT re-flip: it belongs to the artwork (ImageContainer § 3)."
      },
      "figma": {
        "kind": "variant-property",
        "property": "RTL",
        "values": {
          "false": "Off",
          "true": "On"
        }
      }
    },
    "decoration": {
      "title": "Decoration",
      "type": "boolean",
      "default": false,
      "css": {
        "mechanism": "Composition, not a modifier: the tile is wrapped in the decorate-container primitive (specs/primitives/decorate-container.md) — slot1 hosts the service-card instance, slot2/slot3 host the decoration (defaults: Tag, Indicator). The tile ships no extra class; the primitive owns anchoring, offsets and the Scale Short enter/exit."
      },
      "figma": {
        "kind": "boolean-property",
        "property": "decoration",
        "values": {
          "false": "Off",
          "true": "On"
        }
      }
    },
    "subtitleText": {
      "title": "Subtitle",
      "type": "text",
      "default": "",
      "customizable": "The optional second caption line (hidden when empty). Joins the accessible name after the title."
    }
  },
  "states": {
    "default": [
      "standard",
      "skeleton"
    ]
  },
  "constraints": [
    "The Figma set's `decoration` boolean maps to COMPOSITION on web: the tile wrapped in the decorate-container primitive (defaults Tag and Indicator). The specimen staged in the Figma set (Custom-Label) still carries a raw #FA4032 fill, a non-existent TextAndIcon/OnColor token and off-scale typography — that staged artwork stays Figma-side until its tokens are fixed.",
    "ImageContainer is an opaque boundary: this component never positions the artwork — it picks (size-class × picture) and the recipe table does the rest. Ad-hoc artwork CSS on the consumer is prohibited (ImageContainer § 4).",
    "No pressed, disabled, or loading variants in the set. Press feedback is runtime-only, inherited from the customCard base: pushItem (var(--component-push-item-press)/-release), transform only.",
    "The title clamps to one line with an ellipsis at 100 % scale; the caption 12px floor holds.",
    "Skeleton removes the ImageContainer together with the artwork — a shimmering picture box does not exist.",
    "All internal spacing via SP tokens; recipe numbers are artwork data and live only in the ImageContainer recipe table / --img-* props."
  ],
  "analytics": {
    "dataDsComponent": "service-card",
    "action": "tap",
    "notes": "The tile root is the coverage unit and the single tap target; data-ds-variant carries the size (l | m | s). The artwork and title are not separately tappable; emits no value."
  },
  "rtl": {
    "supported": true,
    "notes": "Opt-in per instance (default Off). The layout mirrors via dir=\"rtl\" (logical properties); the artwork's baked mirror never re-flips. In Figma the mirrored composition is a variant of the set (RTL: Off | On)."
  }
}
```
