{
  "generated": "2026-10-02",
  "count": 43,
  "components": [
    {
      "id": "back-link",
      "name": "Back link",
      "group": "Actions",
      "status": "stable",
      "summary": "A link with a leading arrow that leads one level up, e.g. from a detail page back to its list.",
      "description": [
        "Place it above the page title of detail and sub-pages (\"Zurück zu Trainings\"). It names the destination, so people know where they land — never just \"Zurück\".",
        "It is a real link to the parent URL, not `history.back()`: deep links from emails and push notifications have no history to go back to (UX guide §3.3). Apps may restore the list's scroll position and filters when the parent is the previous route.",
        "The arrow mirrors automatically in right-to-left layouts."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "href",
          "label"
        ],
        "properties": {
          "href": {
            "type": "string",
            "minLength": 1,
            "description": "URL of the parent page, including its filters (e.g. `/members?status=pending`)."
          },
          "label": {
            "type": "string",
            "minLength": 1,
            "description": "Visible text naming the destination (\"Zurück zu Trainings\", \"Alle Mitglieder\")."
          }
        }
      },
      "examples": [
        {
          "title": "Detail page back to list",
          "data": {
            "href": "/trainings",
            "label": "Zurück zu Trainings"
          },
          "html": "<a class=\"back-link\" href=\"/trainings\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#arrow-left\"/></svg>Zurück zu Trainings</a>"
        },
        {
          "title": "Back to a filtered list",
          "data": {
            "href": "/members?status=pending",
            "label": "Offene Anmeldungen"
          },
          "html": "<a class=\"back-link\" href=\"/members?status=pending\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#arrow-left\"/></svg>Offene Anmeldungen</a>"
        }
      ],
      "markup": {
        "root": ".back-link",
        "element": "a",
        "attributes": [
          {
            "name": "href",
            "values": "URL",
            "description": "Parent page."
          }
        ],
        "parts": [
          {
            "name": "svg.icon",
            "description": "Leading `arrow-left` icon (decorative, mirrored in RTL)."
          }
        ],
        "states": [
          {
            "name": ":hover",
            "description": "Underline."
          },
          {
            "name": ":focus-visible",
            "description": "Global focus ring."
          }
        ]
      },
      "cssVars": [],
      "a11y": [
        "Native `<a href>`; the arrow icon is `aria-hidden`, the text is the accessible name.",
        "The link text names the destination so it makes sense out of context (screen-reader link lists).",
        "Minimum target height 44 px."
      ],
      "guidance": {
        "do": [
          "Name the destination: \"Zurück zu Trainings\".",
          "Link to the parent URL with its filters in the query string."
        ],
        "dont": [
          "Don't use `javascript:history.back()` — deep-linked pages have no history.",
          "Don't style it as a button; it navigates."
        ]
      },
      "related": [
        "button",
        "page-header",
        "breadcrumbs"
      ]
    },
    {
      "id": "button",
      "name": "Button",
      "group": "Actions",
      "status": "stable",
      "summary": "Triggers an action, or navigates when given an `href`.",
      "description": [
        "Variants express importance, not decoration: one `primary` per view (placed last, at the end side), `secondary` for alternatives, `ghost` for low-emphasis actions, `danger` for destructive ones (which also need an undo or a confirm — see UX guide §5).",
        "Labels are verbs that name the result (\"Freigeben\", \"Prüfung abschliessen\"), never \"OK\" or \"Ja\".",
        "Set `busy` while the action runs: the button keeps its width, shows a spinner and ignores clicks. Icon-only buttons require `ariaLabel`."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [],
        "properties": {
          "label": {
            "type": "string",
            "minLength": 1,
            "description": "Visible text. Required unless `iconOnly`."
          },
          "variant": {
            "type": "string",
            "enum": [
              "secondary",
              "primary",
              "ghost",
              "danger",
              "danger-ghost"
            ],
            "default": "secondary",
            "description": "Visual weight / meaning."
          },
          "size": {
            "type": "string",
            "enum": [
              "sm",
              "md",
              "lg"
            ],
            "default": "md",
            "description": "`sm` (36 px) only in dense desktop tables; `lg` (52 px) for the single main action of a screen."
          },
          "icon": {
            "type": "string",
            "description": "Icon id from the sprite, shown before the label."
          },
          "iconOnly": {
            "type": "boolean",
            "default": false,
            "description": "Square button showing only `icon`. Requires `ariaLabel`."
          },
          "ariaLabel": {
            "type": "string",
            "description": "Accessible name when the visible label is missing or ambiguous (\"Weitere Aktionen für Lea Müller\")."
          },
          "href": {
            "type": "string",
            "description": "Render an `<a>` that looks like a button (navigation, not actions)."
          },
          "type": {
            "type": "string",
            "enum": [
              "button",
              "submit",
              "reset"
            ],
            "default": "button",
            "description": "Button type inside forms."
          },
          "value": {
            "type": "string",
            "description": "Form value, e.g. `cancel` / `confirm` inside `<form method=\"dialog\">`."
          },
          "name": {
            "type": "string",
            "description": "Form field name when the button submits a value."
          },
          "disabled": {
            "type": "boolean",
            "default": false,
            "description": "Not available now. Prefer explaining why nearby over silently disabling."
          },
          "busy": {
            "type": "boolean",
            "default": false,
            "description": "Action in progress (`aria-busy`)."
          },
          "formNoValidate": {
            "type": "boolean",
            "default": false,
            "description": "Submit without native validation — use on \"Abbrechen\" inside `<form method=\"dialog\">` so required fields do not block closing."
          },
          "block": {
            "type": "string",
            "enum": [
              "always",
              "mobile"
            ],
            "description": "Full width always, or only below 768 px."
          },
          "behavior": {
            "type": "string",
            "description": "Behaviour hook (`data-behavior`), e.g. `open-dialog`, `theme-toggle`."
          },
          "action": {
            "type": "string",
            "pattern": "^[a-z][a-zA-Z0-9-]*$",
            "description": "Screen action name (`data-action`). Declared in the `actions` of the screen with a handler and a risk class; the engine adds undo or confirm accordingly."
          },
          "actionId": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]+$",
            "description": "Id of the thing the action applies to (`data-id`), when the button is not inside an element that has one."
          },
          "target": {
            "type": "string",
            "description": "Id of the element a behaviour acts on (`data-target`), e.g. the dialog to open."
          },
          "popoverTarget": {
            "type": "string",
            "description": "Id of a popover menu this button toggles (`popovertarget`)."
          }
        }
      },
      "examples": [
        {
          "title": "Primary action",
          "data": {
            "label": "Freigeben",
            "variant": "primary"
          },
          "html": "<button class=\"button\" type=\"button\" data-variant=\"primary\">Freigeben</button>"
        },
        {
          "title": "With icon",
          "data": {
            "label": "Training starten",
            "variant": "primary",
            "size": "lg",
            "icon": "play"
          },
          "html": "<button class=\"button\" type=\"button\" data-variant=\"primary\" data-size=\"lg\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#play\"/></svg>Training starten</button>"
        },
        {
          "title": "Icon only",
          "data": {
            "icon": "more",
            "iconOnly": true,
            "variant": "ghost",
            "ariaLabel": "Weitere Aktionen für Lea Müller"
          },
          "html": "<button class=\"button\" type=\"button\" data-variant=\"ghost\" data-icon-only aria-label=\"Weitere Aktionen für Lea Müller\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#more\"/></svg></button>"
        },
        {
          "title": "Destructive",
          "data": {
            "label": "Löschen",
            "variant": "danger"
          },
          "html": "<button class=\"button\" type=\"button\" data-variant=\"danger\">Löschen</button>"
        },
        {
          "title": "Busy",
          "data": {
            "label": "Speichern",
            "variant": "primary",
            "busy": true
          },
          "html": "<button class=\"button\" type=\"button\" data-variant=\"primary\" aria-busy=\"true\">Speichern</button>"
        },
        {
          "title": "Link styled as button",
          "data": {
            "label": "Formular ansehen",
            "href": "#formular",
            "icon": "external-link"
          },
          "html": "<a class=\"button\" href=\"#formular\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#external-link\"/></svg>Formular ansehen</a>"
        }
      ],
      "markup": {
        "root": ".button",
        "element": "button | a",
        "attributes": [
          {
            "name": "data-variant",
            "values": "primary | ghost | danger | danger-ghost",
            "default": "(none = secondary)",
            "description": "Visual variant."
          },
          {
            "name": "data-size",
            "values": "sm | lg",
            "default": "(none = 44 px)",
            "description": "Height."
          },
          {
            "name": "data-icon-only",
            "values": "—",
            "description": "Square icon button."
          },
          {
            "name": "data-block",
            "values": "\"\" | mobile",
            "description": "Full width (always / only on phones)."
          },
          {
            "name": "aria-label",
            "values": "text",
            "description": "Required for icon-only buttons."
          },
          {
            "name": "aria-busy",
            "values": "true",
            "description": "Shows the spinner, blocks clicks."
          },
          {
            "name": "data-behavior / data-target / popovertarget",
            "values": "see behaviours",
            "description": "Connect the button to dialogs, menus, theme toggle."
          },
          {
            "name": "data-action / data-id",
            "values": "action name / id",
            "description": "Trigger a declared screen action (see Screens)."
          }
        ],
        "parts": [
          {
            "name": "svg.icon",
            "description": "Optional leading icon from the sprite."
          }
        ],
        "states": [
          {
            "name": ":hover / :active",
            "description": "Background shift / 1 px press."
          },
          {
            "name": ":focus-visible",
            "description": "Global focus ring."
          },
          {
            "name": ":disabled, [aria-disabled=\"true\"]",
            "description": "Muted colours, not-allowed cursor."
          },
          {
            "name": "[aria-busy=\"true\"]",
            "description": "A spinner replaces the leading icon (width kept); the label stays; clicks blocked. Without a leading icon the spinner is added before the label."
          }
        ]
      },
      "cssVars": [],
      "a11y": [
        "Native `<button>` (or `<a href>` for navigation) — never a clickable `div`.",
        "Icon-only buttons need `aria-label`; the icon itself is `aria-hidden`.",
        "Minimum target 44 × 44 px (36 px only in desktop tables)."
      ],
      "guidance": {
        "do": [
          "One primary per view; put it last.",
          "Name the outcome: \"Prüfung abschliessen\" rather than \"Weiter\"."
        ],
        "dont": [
          "No more than 3 buttons in a row — move the rest into a menu.",
          "Don't use `danger` for \"Abbrechen\"."
        ]
      },
      "related": [
        "menu",
        "confirm-dialog"
      ]
    },
    {
      "id": "choice-group",
      "name": "Checkbox / radio group",
      "group": "Forms",
      "status": "stable",
      "summary": "A question with checkboxes (several answers) or radio buttons (one answer), grouped under a legend.",
      "description": [
        "Use radios for one answer out of up to ~5 options and checkboxes for any number of answers (UX guide §6.3: for ≤ 5 choices prefer these over a `select`). For 2–4 very short options that switch a view or text, use the segmented control instead.",
        "The whole row is the hit area (min. 44 px): clicking the label text ticks the box. Each option can carry a hint line, e.g. a price or an age range.",
        "Hint and error belong to the whole group: they are linked to the `fieldset` with `aria-describedby`, and an error marks every input `aria-invalid`. Consent checkboxes are a group of their own and are never pre-ticked (UX guide §6.6).",
        "`required` sets the native `required` attribute on radios and on a single checkbox (e.g. \"Ich akzeptiere die Statuten\"). \"At least one of several checkboxes\" can't be enforced natively — validate it in page code and show `error`."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "legend",
          "type",
          "options"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Id of the fieldset; prefix for `{id}-hint`, `{id}-error` and the option ids `{id}-1`, `{id}-2`, … Must be unique on the page."
          },
          "legend": {
            "type": "string",
            "minLength": 1,
            "description": "The question, shown as `<legend>` (\"Mitgliedschaft\", \"Einwilligungen\")."
          },
          "type": {
            "type": "string",
            "enum": [
              "checkbox",
              "radio"
            ],
            "description": "`radio`: exactly one answer; `checkbox`: any number."
          },
          "name": {
            "type": "string",
            "description": "Form field name of all inputs; defaults to `id`. For checkboxes the server receives one value per ticked box."
          },
          "hint": {
            "type": "string",
            "description": "Help text below the legend, linked to the group."
          },
          "error": {
            "type": "string",
            "description": "Error message for the group; marks all inputs invalid. Say what to do: \"Bitte wähle eine Mitgliedschaft.\""
          },
          "required": {
            "type": "boolean",
            "default": false,
            "description": "An answer is required (native `required` on radios / a single checkbox)."
          },
          "optional": {
            "type": "boolean",
            "default": false,
            "description": "Append \"(optional)\" to the legend (in forms where most questions are required)."
          },
          "options": {
            "type": "array",
            "minItems": 1,
            "description": "Answers in display order.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "value",
                "label"
              ],
              "properties": {
                "value": {
                  "type": "string",
                  "description": "Submitted value."
                },
                "label": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Visible answer text."
                },
                "hint": {
                  "type": "string",
                  "description": "Secondary line under the answer (\"CHF 480.– pro Jahr\")."
                },
                "checked": {
                  "type": "boolean",
                  "default": false,
                  "description": "Selected. Only one option for radios. Never pre-tick consent."
                },
                "disabled": {
                  "type": "boolean",
                  "default": false,
                  "description": "Not selectable now (e.g. group full); explain why in its hint."
                }
              }
            }
          }
        }
      },
      "examples": [
        {
          "title": "Radio group with hints",
          "data": {
            "id": "membership",
            "legend": "Mitgliedschaft",
            "type": "radio",
            "required": true,
            "options": [
              {
                "value": "single",
                "label": "Einzelmitglied",
                "hint": "CHF 480.– pro Jahr",
                "checked": true
              },
              {
                "value": "family",
                "label": "Familie",
                "hint": "ab 2 Personen, CHF 780.–"
              },
              {
                "value": "passive",
                "label": "Passivmitglied",
                "hint": "Zurzeit keine neuen Passivmitglieder",
                "disabled": true
              }
            ]
          },
          "html": "<fieldset class=\"fieldset\" id=\"membership\">\n  <legend>Mitgliedschaft</legend>\n  \n  \n  <label class=\"check\"><input type=\"radio\" id=\"membership-1\" name=\"membership\" value=\"single\" checked required><span class=\"check__text\">Einzelmitglied<span class=\"check__hint\">CHF 480.– pro Jahr</span></span></label><label class=\"check\"><input type=\"radio\" id=\"membership-2\" name=\"membership\" value=\"family\" required><span class=\"check__text\">Familie<span class=\"check__hint\">ab 2 Personen, CHF 780.–</span></span></label><label class=\"check\"><input type=\"radio\" id=\"membership-3\" name=\"membership\" value=\"passive\" disabled required><span class=\"check__text\">Passivmitglied<span class=\"check__hint\">Zurzeit keine neuen Passivmitglieder</span></span></label>\n</fieldset>"
        },
        {
          "title": "Consent checkboxes",
          "data": {
            "id": "consent",
            "legend": "Einwilligungen",
            "type": "checkbox",
            "hint": "Du kannst das jederzeit im Profil ändern.",
            "options": [
              {
                "value": "photos",
                "label": "Fotos dürfen veröffentlicht werden",
                "hint": "Website, Newsletter, Social Media"
              },
              {
                "value": "newsletter",
                "label": "Newsletter per E-Mail erhalten"
              }
            ]
          },
          "html": "<fieldset class=\"fieldset\" id=\"consent\" aria-describedby=\"consent-hint\">\n  <legend>Einwilligungen</legend>\n  <p class=\"field__hint\" id=\"consent-hint\">Du kannst das jederzeit im Profil ändern.</p>\n  \n  <label class=\"check\"><input type=\"checkbox\" id=\"consent-1\" name=\"consent\" value=\"photos\"><span class=\"check__text\">Fotos dürfen veröffentlicht werden<span class=\"check__hint\">Website, Newsletter, Social Media</span></span></label><label class=\"check\"><input type=\"checkbox\" id=\"consent-2\" name=\"consent\" value=\"newsletter\"><span class=\"check__text\">Newsletter per E-Mail erhalten</span></label>\n</fieldset>"
        },
        {
          "title": "With error after submit",
          "data": {
            "id": "group-pref",
            "legend": "Bevorzugte Trainingszeit",
            "type": "radio",
            "name": "slot",
            "required": true,
            "error": "Bitte wähle eine Trainingszeit.",
            "options": [
              {
                "value": "tue",
                "label": "Dienstag, 18:00–19:15"
              },
              {
                "value": "thu",
                "label": "Donnerstag, 18:00–19:15"
              }
            ]
          },
          "html": "<fieldset class=\"fieldset\" id=\"group-pref\" aria-describedby=\"group-pref-error\">\n  <legend>Bevorzugte Trainingszeit</legend>\n  \n  <p class=\"field__error\" id=\"group-pref-error\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-circle\"/></svg>Bitte wähle eine Trainingszeit.</p>\n  <label class=\"check\"><input type=\"radio\" id=\"group-pref-1\" name=\"slot\" value=\"tue\" required aria-invalid=\"true\"><span class=\"check__text\">Dienstag, 18:00–19:15</span></label><label class=\"check\"><input type=\"radio\" id=\"group-pref-2\" name=\"slot\" value=\"thu\" required aria-invalid=\"true\"><span class=\"check__text\">Donnerstag, 18:00–19:15</span></label>\n</fieldset>"
        }
      ],
      "markup": {
        "root": ".fieldset",
        "element": "fieldset",
        "attributes": [
          {
            "name": "aria-describedby (on fieldset)",
            "values": "\"{id}-hint {id}-error\"",
            "description": "Links group hint and error."
          },
          {
            "name": "aria-invalid (on inputs)",
            "values": "true",
            "description": "Error state; red border on every box."
          }
        ],
        "parts": [
          {
            "name": "legend",
            "description": "The question."
          },
          {
            "name": ".field__hint / .field__error",
            "description": "Group hint and error (same parts as the form field)."
          },
          {
            "name": ".check",
            "description": "`<label>` row wrapping one input — the whole row is clickable."
          },
          {
            "name": ".check__text / .check__hint",
            "description": "Answer text and its secondary line."
          }
        ],
        "states": [
          {
            "name": "input:checked",
            "description": "Accent fill with check mark / dot."
          },
          {
            "name": "input[aria-invalid=\"true\"]",
            "description": "Danger border."
          },
          {
            "name": "input:disabled",
            "description": "Muted, not-allowed cursor."
          },
          {
            "name": ":focus-visible",
            "description": "Global focus ring on the box."
          }
        ]
      },
      "cssVars": [],
      "a11y": [
        "Groups use `fieldset` + `legend`, so screen readers announce the question with every option.",
        "Each input is wrapped in its `<label>`; the hint line is part of the label text.",
        "Radios in a group share one `name`; arrow keys move between them natively.",
        "The error is text linked via `aria-describedby`, not only the red border."
      ],
      "guidance": {
        "do": [
          "Order options logically (most common first, or natural order).",
          "Use a separate group for each consent; never pre-tick it."
        ],
        "dont": [
          "Don't use a single checkbox for a yes/no question that needs an explicit answer — use two radios.",
          "Don't use checkboxes for settings that apply immediately — that's a switch."
        ]
      },
      "related": [
        "field",
        "segmented",
        "switch",
        "scale",
        "error-summary"
      ]
    },
    {
      "id": "error-summary",
      "name": "Error summary",
      "group": "Forms",
      "status": "stable",
      "summary": "A box at the top of a form that lists every error after a failed submit, each linked to its field.",
      "description": [
        "Shown only after a submit fails (UX guide §6.2): all errors at once, in the order of the form. Each entry links to its field (`#fieldId`), so a click or Enter jumps there. The same messages also appear at the fields themselves.",
        "After rendering, move focus to the summary (`el.focus()` — it has `tabindex=\"-1\"`) and scroll it into view below the sticky top bar. It has `role=\"alert\"`, so the count and the list are announced.",
        "The title states the number of problems (\"Bitte prüfe 2 Angaben\"). Messages say what to do, not what is wrong in technical terms. Server errors (problem+json `errors[]`) map onto the same entries."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "errors"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "default": "error-summary",
            "description": "Id of the box; the title gets `{id}-title`. Must be unique on the page."
          },
          "title": {
            "type": "string",
            "minLength": 1,
            "description": "Heading. Defaults to \"Bitte prüfe 1 Angabe\" / \"Bitte prüfe {n} Angaben\"."
          },
          "errors": {
            "type": "array",
            "minItems": 1,
            "description": "One entry per invalid field, in form order.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "fieldId",
                "message"
              ],
              "properties": {
                "fieldId": {
                  "type": "string",
                  "pattern": "^[A-Za-z][\\w-]*$",
                  "description": "Id of the invalid control (link target `#fieldId`). For a choice group use the id of its first input (`{id}-1`)."
                },
                "message": {
                  "type": "string",
                  "minLength": 1,
                  "description": "What to do, usually the same text as the field's error (\"Gib ein Geburtsdatum ein.\")."
                }
              }
            }
          }
        }
      },
      "examples": [
        {
          "title": "Two errors (default title)",
          "data": {
            "errors": [
              {
                "fieldId": "email",
                "message": "E-Mail ist unvollständig"
              },
              {
                "fieldId": "birthdate",
                "message": "Geburtsdatum fehlt"
              }
            ]
          },
          "html": "<div class=\"error-summary\" id=\"error-summary\" tabindex=\"-1\" role=\"alert\" aria-labelledby=\"error-summary-title\">\n  <h2 class=\"error-summary__title\" id=\"error-summary-title\">Bitte prüfe 2 Angaben</h2>\n  <ul><li><a href=\"#email\">E-Mail ist unvollständig</a></li><li><a href=\"#birthdate\">Geburtsdatum fehlt</a></li></ul>\n</div>"
        },
        {
          "title": "Custom title and id",
          "data": {
            "id": "guardian-errors",
            "title": "Angaben zur erziehungsberechtigten Person fehlen",
            "errors": [
              {
                "fieldId": "guardian-name",
                "message": "Gib den Namen der erziehungsberechtigten Person ein."
              }
            ]
          },
          "html": "<div class=\"error-summary\" id=\"guardian-errors\" tabindex=\"-1\" role=\"alert\" aria-labelledby=\"guardian-errors-title\">\n  <h2 class=\"error-summary__title\" id=\"guardian-errors-title\">Angaben zur erziehungsberechtigten Person fehlen</h2>\n  <ul><li><a href=\"#guardian-name\">Gib den Namen der erziehungsberechtigten Person ein.</a></li></ul>\n</div>"
        }
      ],
      "markup": {
        "root": ".error-summary",
        "element": "div",
        "attributes": [
          {
            "name": "role=\"alert\"",
            "values": "—",
            "description": "Announced when inserted."
          },
          {
            "name": "tabindex=\"-1\"",
            "values": "—",
            "description": "Can receive focus programmatically after submit."
          },
          {
            "name": "aria-labelledby",
            "values": "\"{id}-title\"",
            "description": "Named by its heading."
          }
        ],
        "parts": [
          {
            "name": ".error-summary__title",
            "description": "The `h2` title with the number of problems: semibold, `--color-danger-text`."
          },
          {
            "name": "ul > li > a[href=\"#fieldId\"]",
            "description": "Linked error messages."
          }
        ],
        "states": []
      },
      "cssVars": [],
      "behaviour": {
        "module": "(page code)",
        "setup": "Render on failed submit, then `summary.focus()` and scroll into view. Remove it when the form is submitted again successfully. Clicking a link moves focus to the field (native fragment navigation focuses focusable targets).",
        "api": [],
        "events": []
      },
      "a11y": [
        "Focus moves to the summary after a failed submit; `role=\"alert\"` announces it.",
        "Every error is a link to its field, and the same message appears at the field (WCAG 3.3.1 / 3.3.3).",
        "Errors are identified in text — the danger tint and the 4 px danger edge on the start side (design guide §9.2) are not the only signal."
      ],
      "guidance": {
        "do": [
          "List errors in form order.",
          "Keep the filled-in values — a failed submit never clears fields."
        ],
        "dont": [
          "Don't show the summary while the user is still typing.",
          "Don't use a toast for field errors."
        ]
      },
      "related": [
        "field",
        "choice-group",
        "alert"
      ]
    },
    {
      "id": "field",
      "name": "Form field",
      "group": "Forms",
      "status": "stable",
      "summary": "A labelled input, select or textarea with optional hint and error message.",
      "description": [
        "The building block of every form: label above the control, optional hint below the label, error below the control. The label is always visible — placeholders are examples, never labels.",
        "Hint and error are linked to the control with `aria-describedby`; an error sets `aria-invalid=\"true\"`. Validate on submit and on blur of a field that already showed an error; show all errors at once in an error summary.",
        "Choose `type`, `inputmode` and `autocomplete` so phones show the right keyboard and browsers can fill in (e.g. `email`, `tel`, `bday`, `postal-code`, `one-time-code`)."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "label"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Id of the control; also prefix for `{id}-hint` and `{id}-error`. Must be unique on the page."
          },
          "label": {
            "type": "string",
            "minLength": 1,
            "description": "Visible label (\"E-Mail\", \"Geburtsdatum\")."
          },
          "control": {
            "type": "string",
            "enum": [
              "input",
              "select",
              "textarea"
            ],
            "default": "input",
            "description": "Which control to render."
          },
          "type": {
            "type": "string",
            "enum": [
              "text",
              "email",
              "tel",
              "date",
              "time",
              "number",
              "url",
              "search",
              "password"
            ],
            "default": "text",
            "description": "Input type (control = input)."
          },
          "name": {
            "type": "string",
            "description": "Form field name; defaults to `id`."
          },
          "value": {
            "type": [
              "string",
              "number"
            ],
            "description": "Current value."
          },
          "placeholder": {
            "type": "string",
            "description": "Example value (\"z. B. 3011\"). Never a substitute for the label."
          },
          "hint": {
            "type": "string",
            "description": "Help text shown between label and control."
          },
          "error": {
            "type": "string",
            "description": "Error message; when set the control is marked invalid. Say what to do: \"Bitte gib … ein\"."
          },
          "required": {
            "type": "boolean",
            "default": false,
            "description": "Field must be filled. Optional fields are marked \"(optional)\" instead of marking required ones."
          },
          "optional": {
            "type": "boolean",
            "default": false,
            "description": "Append \"(optional)\" to the label (use in forms where most fields are required)."
          },
          "disabled": {
            "type": "boolean",
            "default": false,
            "description": "Read-only for now; explain why in the hint."
          },
          "autocomplete": {
            "type": "string",
            "description": "HTML autocomplete token, e.g. `email`, `given-name`, `bday`, `one-time-code`."
          },
          "inputmode": {
            "type": "string",
            "enum": [
              "text",
              "numeric",
              "decimal",
              "tel",
              "email",
              "url",
              "search"
            ],
            "description": "Virtual keyboard hint, e.g. `numeric` for PLZ."
          },
          "maxlength": {
            "type": "integer",
            "minimum": 1,
            "description": "Maximum characters."
          },
          "rows": {
            "type": "integer",
            "minimum": 2,
            "default": 4,
            "description": "Visible lines (control = textarea)."
          },
          "options": {
            "type": "array",
            "description": "Choices (control = select). Put a sensible default first or add an empty \"Bitte wählen\" option.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "value",
                "label"
              ],
              "properties": {
                "value": {
                  "type": "string",
                  "description": "Submitted value."
                },
                "label": {
                  "type": "string",
                  "description": "Shown text."
                },
                "disabled": {
                  "type": "boolean",
                  "description": "Not selectable (e.g. full group)."
                }
              }
            }
          }
        }
      },
      "examples": [
        {
          "title": "Text with autocomplete",
          "data": {
            "id": "first-name",
            "label": "Vorname",
            "autocomplete": "given-name",
            "required": true
          },
          "html": "<div class=\"field\">\n  <label class=\"field__label\" for=\"first-name\">Vorname</label>\n  \n  <input class=\"input\" id=\"first-name\" name=\"first-name\" required type=\"text\" autocomplete=\"given-name\">\n  \n</div>"
        },
        {
          "title": "With hint and error",
          "data": {
            "id": "email",
            "label": "E-Mail",
            "type": "email",
            "autocomplete": "email",
            "inputmode": "email",
            "hint": "An diese Adresse schicken wir die Bestätigung.",
            "value": "lea.mueller@",
            "error": "Bitte gib eine vollständige E-Mail-Adresse ein, z. B. name@beispiel.ch."
          },
          "html": "<div class=\"field\">\n  <label class=\"field__label\" for=\"email\">E-Mail</label>\n  <p class=\"field__hint\" id=\"email-hint\">An diese Adresse schicken wir die Bestätigung.</p>\n  <input class=\"input\" id=\"email\" name=\"email\" aria-describedby=\"email-hint email-error\" aria-invalid=\"true\" type=\"email\" value=\"lea.mueller@\" autocomplete=\"email\" inputmode=\"email\">\n  <p class=\"field__error\" id=\"email-error\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-circle\"/></svg>Bitte gib eine vollständige E-Mail-Adresse ein, z. B. name@beispiel.ch.</p>\n</div>"
        },
        {
          "title": "Select",
          "data": {
            "id": "group",
            "label": "Trainingsgruppe",
            "control": "select",
            "value": "kids",
            "options": [
              {
                "value": "tigers",
                "label": "Tigers (4–6)"
              },
              {
                "value": "kids",
                "label": "Kinder 7–10"
              },
              {
                "value": "youth",
                "label": "Jugend 11–15",
                "disabled": true
              }
            ]
          },
          "html": "<div class=\"field\">\n  <label class=\"field__label\" for=\"group\">Trainingsgruppe</label>\n  \n  <select class=\"select\" id=\"group\" name=\"group\"><option value=\"tigers\">Tigers (4–6)</option><option value=\"kids\" selected>Kinder 7–10</option><option value=\"youth\" disabled>Jugend 11–15</option></select>\n  \n</div>"
        },
        {
          "title": "Optional textarea",
          "data": {
            "id": "note",
            "label": "Bemerkung",
            "control": "textarea",
            "optional": true,
            "rows": 3
          },
          "html": "<div class=\"field\">\n  <label class=\"field__label\" for=\"note\">Bemerkung <span class=\"field__optional\">(optional)</span></label>\n  \n  <textarea class=\"textarea\" id=\"note\" name=\"note\" rows=\"3\"></textarea>\n  \n</div>"
        }
      ],
      "markup": {
        "root": ".field",
        "element": "div",
        "attributes": [
          {
            "name": "aria-describedby (on control)",
            "values": "\"{id}-hint {id}-error\"",
            "description": "Links hint and error to the control."
          },
          {
            "name": "aria-invalid (on control)",
            "values": "true | false",
            "description": "Error state; drives the red border."
          }
        ],
        "parts": [
          {
            "name": ".field__label",
            "description": "`<label for>` — always visible."
          },
          {
            "name": ".field__optional",
            "description": "\"(optional)\" suffix inside the label."
          },
          {
            "name": ".field__hint",
            "description": "Help text (id `{id}-hint`)."
          },
          {
            "name": ".input / .select / .textarea",
            "description": "The control (16 px text so iOS doesn't zoom)."
          },
          {
            "name": ".field__error",
            "description": "Error message with icon (id `{id}-error`)."
          }
        ],
        "states": [
          {
            "name": "[aria-invalid=\"true\"]",
            "description": "`--color-danger` border."
          },
          {
            "name": ":focus-visible",
            "description": "Global focus ring (3 px `--color-focus`, 2 px offset); the border stays."
          },
          {
            "name": "[readonly]",
            "description": "No border, `--color-surface-2` fill."
          },
          {
            "name": ":disabled",
            "description": "Muted, not-allowed cursor."
          }
        ]
      },
      "cssVars": [],
      "a11y": [
        "Label is a real `<label for>`; clicking it focuses the control.",
        "Errors are announced via `aria-describedby`; after a failed submit move focus to the error summary.",
        "Don't rely on placeholder text; it disappears while typing and often fails contrast."
      ],
      "guidance": {
        "do": [
          "Group related short fields with `.form-row`.",
          "Ask only for data the task needs (especially for minors)."
        ],
        "dont": [
          "Don't validate on every keystroke.",
          "Don't show raw server errors (\"422 Unprocessable\") — map them to field messages."
        ]
      },
      "related": [
        "error-summary",
        "choice-group",
        "search"
      ]
    },
    {
      "id": "scale",
      "name": "Scale input",
      "group": "Forms",
      "status": "stable",
      "summary": "A 1–N rating as a row of numbered buttons with words at both ends, e.g. sleep, mood or effort (RPE).",
      "description": [
        "For quick self-ratings in the athlete app (wellness check-in, training load) and short feedback questions. Each step is a large tap target; the anchors at both ends say what the extremes mean (\"schlecht\" … \"super\").",
        "Underneath it is a radio group: one value, arrow keys to change, submitted with the form. Each radio has an accessible name like \"1 von 5, schlecht\", so the anchors are only decorative for screen readers.",
        "Use the same direction everywhere: low = bad / easy on the start side, high = good / hard on the end side. Don't pre-select a value unless the person already answered."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "legend"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Id prefix for the option ids `{id}-1` … `{id}-{max}`. Must be unique on the page."
          },
          "legend": {
            "type": "string",
            "minLength": 1,
            "description": "The question (\"Wie hast du geschlafen?\")."
          },
          "name": {
            "type": "string",
            "description": "Form field name; defaults to `id`."
          },
          "max": {
            "type": "integer",
            "minimum": 3,
            "maximum": 10,
            "default": 5,
            "description": "Number of steps; the scale runs from 1 to `max`."
          },
          "value": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "description": "Selected step, if already answered."
          },
          "minLabel": {
            "type": "string",
            "description": "Anchor word for 1 (\"schlecht\", \"sehr leicht\")."
          },
          "maxLabel": {
            "type": "string",
            "description": "Anchor word for `max` (\"super\", \"maximal\")."
          },
          "required": {
            "type": "boolean",
            "default": false,
            "description": "An answer is required (native `required`)."
          }
        }
      },
      "examples": [
        {
          "title": "Wellness: sleep",
          "data": {
            "id": "sleep",
            "legend": "Wie hast du geschlafen?",
            "minLabel": "schlecht",
            "maxLabel": "super",
            "value": 4
          },
          "html": "<fieldset class=\"fieldset\">\n  <legend>Wie hast du geschlafen?</legend>\n  <div class=\"scale\">\n    <span class=\"scale__anchor\" aria-hidden=\"true\">schlecht</span>\n    <div class=\"scale__options\"><label><input type=\"radio\" id=\"sleep-1\" name=\"sleep\" value=\"1\" aria-label=\"1 von 5, schlecht\"><span>1</span></label><label><input type=\"radio\" id=\"sleep-2\" name=\"sleep\" value=\"2\" aria-label=\"2 von 5\"><span>2</span></label><label><input type=\"radio\" id=\"sleep-3\" name=\"sleep\" value=\"3\" aria-label=\"3 von 5\"><span>3</span></label><label><input type=\"radio\" id=\"sleep-4\" name=\"sleep\" value=\"4\" aria-label=\"4 von 5\" checked><span>4</span></label><label><input type=\"radio\" id=\"sleep-5\" name=\"sleep\" value=\"5\" aria-label=\"5 von 5, super\"><span>5</span></label></div>\n    <span class=\"scale__anchor\" aria-hidden=\"true\">super</span>\n  </div>\n</fieldset>"
        },
        {
          "title": "Effort (RPE 1–10)",
          "data": {
            "id": "rpe",
            "legend": "Wie anstrengend war das Training?",
            "max": 10,
            "minLabel": "sehr leicht",
            "maxLabel": "maximal",
            "required": true
          },
          "html": "<fieldset class=\"fieldset\">\n  <legend>Wie anstrengend war das Training?</legend>\n  <div class=\"scale\">\n    <span class=\"scale__anchor\" aria-hidden=\"true\">sehr leicht</span>\n    <div class=\"scale__options\" style=\"--scale-steps: 10\"><label><input type=\"radio\" id=\"rpe-1\" name=\"rpe\" value=\"1\" aria-label=\"1 von 10, sehr leicht\" required><span>1</span></label><label><input type=\"radio\" id=\"rpe-2\" name=\"rpe\" value=\"2\" aria-label=\"2 von 10\" required><span>2</span></label><label><input type=\"radio\" id=\"rpe-3\" name=\"rpe\" value=\"3\" aria-label=\"3 von 10\" required><span>3</span></label><label><input type=\"radio\" id=\"rpe-4\" name=\"rpe\" value=\"4\" aria-label=\"4 von 10\" required><span>4</span></label><label><input type=\"radio\" id=\"rpe-5\" name=\"rpe\" value=\"5\" aria-label=\"5 von 10\" required><span>5</span></label><label><input type=\"radio\" id=\"rpe-6\" name=\"rpe\" value=\"6\" aria-label=\"6 von 10\" required><span>6</span></label><label><input type=\"radio\" id=\"rpe-7\" name=\"rpe\" value=\"7\" aria-label=\"7 von 10\" required><span>7</span></label><label><input type=\"radio\" id=\"rpe-8\" name=\"rpe\" value=\"8\" aria-label=\"8 von 10\" required><span>8</span></label><label><input type=\"radio\" id=\"rpe-9\" name=\"rpe\" value=\"9\" aria-label=\"9 von 10\" required><span>9</span></label><label><input type=\"radio\" id=\"rpe-10\" name=\"rpe\" value=\"10\" aria-label=\"10 von 10, maximal\" required><span>10</span></label></div>\n    <span class=\"scale__anchor\" aria-hidden=\"true\">maximal</span>\n  </div>\n</fieldset>"
        }
      ],
      "markup": {
        "root": ".scale",
        "element": "div (inside fieldset.fieldset)",
        "attributes": [
          {
            "name": "style=\"--scale-steps: N\"",
            "values": "integer",
            "description": "Number of columns (set by `render` when `max` ≠ 5)."
          },
          {
            "name": "aria-label (on radios)",
            "values": "\"{n} von {max}[, anchor]\"",
            "description": "Accessible name per step."
          }
        ],
        "parts": [
          {
            "name": ".scale__anchor",
            "description": "Anchor words at start and end (`aria-hidden`; the meaning is in the radio names)."
          },
          {
            "name": ".scale__options",
            "description": "Grid of steps."
          },
          {
            "name": ".scale__options label > input + span",
            "description": "Transparent radio over the visible number."
          }
        ],
        "states": [
          {
            "name": "input:checked + span",
            "description": "Accent fill."
          },
          {
            "name": "input:focus-visible + span",
            "description": "Focus ring."
          }
        ]
      },
      "cssVars": [
        {
          "name": "--scale-steps",
          "default": "5",
          "description": "Number of grid columns in `.scale__options`."
        }
      ],
      "a11y": [
        "Native radios in a `fieldset` with `legend`: the question is announced, arrow keys move between steps.",
        "Each radio is named \"3 von 5\"; the end steps include the anchor word (\"1 von 5, schlecht\").",
        "Steps are at least 44 px high; on narrow phones scales above 7 steps get tight — prefer 5."
      ],
      "guidance": {
        "do": [
          "Keep the same scale and anchors for the same question everywhere, so answers stay comparable.",
          "Use 5 steps for wellness, 10 only for established scales like RPE."
        ],
        "dont": [
          "Don't use colour alone (red … green) to mean good/bad.",
          "Don't pre-select a \"neutral\" middle value."
        ]
      },
      "related": [
        "choice-group",
        "segmented"
      ]
    },
    {
      "id": "search",
      "name": "Search field",
      "group": "Forms",
      "status": "stable",
      "summary": "A search input with a magnifier icon and a visually hidden label, for filtering lists.",
      "description": [
        "Sits above a list or table and filters it (\"Mitglieder suchen\"). The label is visually hidden because the icon and placeholder make the purpose obvious — but it is always present for screen readers.",
        "Filter as the user types (debounced ~250 ms) for local lists, or on Enter for server searches. Put the query into the URL (`?q=…`) so reload and back keep it (UX guide §3.3).",
        "When nothing matches, show an empty state that repeats the query and offers to reset filters (\"Keine Mitglieder für «Mueler»\"). Announce result counts in a polite live region if the list updates while typing."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "label"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Id of the input. Must be unique on the page."
          },
          "label": {
            "type": "string",
            "minLength": 1,
            "description": "Accessible label, visually hidden (\"Mitglieder suchen\")."
          },
          "name": {
            "type": "string",
            "default": "q",
            "description": "Form field / query-string name."
          },
          "value": {
            "type": "string",
            "description": "Current query (e.g. restored from the URL)."
          },
          "placeholder": {
            "type": "string",
            "description": "Example of what can be searched (\"Name, z. B. Müller\"). Not a substitute for the label."
          },
          "controls": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Id of the list or table the search filters (`aria-controls`)."
          }
        }
      },
      "examples": [
        {
          "title": "Member search",
          "data": {
            "id": "member-search",
            "label": "Mitglieder suchen",
            "placeholder": "Name, z. B. Müller",
            "controls": "member-list"
          },
          "html": "<div class=\"search\">\n  <svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#search\"/></svg>\n  <label class=\"u-visually-hidden\" for=\"member-search\">Mitglieder suchen</label>\n  <input class=\"input\" id=\"member-search\" name=\"q\" type=\"search\" enterkeyhint=\"search\" autocomplete=\"off\" placeholder=\"Name, z. B. Müller\" aria-controls=\"member-list\">\n</div>"
        },
        {
          "title": "With query from the URL",
          "data": {
            "id": "exercise-search",
            "label": "Übungen suchen",
            "value": "Dollyo Chagi",
            "placeholder": "Übung oder Technik"
          },
          "html": "<div class=\"search\">\n  <svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#search\"/></svg>\n  <label class=\"u-visually-hidden\" for=\"exercise-search\">Übungen suchen</label>\n  <input class=\"input\" id=\"exercise-search\" name=\"q\" type=\"search\" enterkeyhint=\"search\" autocomplete=\"off\" value=\"Dollyo Chagi\" placeholder=\"Übung oder Technik\">\n</div>"
        }
      ],
      "markup": {
        "root": ".search",
        "element": "div",
        "attributes": [
          {
            "name": "type=\"search\" (on input)",
            "values": "—",
            "description": "Search keyboard on phones, native clear button."
          },
          {
            "name": "enterkeyhint=\"search\"",
            "values": "—",
            "description": "Enter key labelled \"Suchen\" on phones."
          },
          {
            "name": "aria-controls (on input)",
            "values": "id",
            "description": "The filtered list."
          }
        ],
        "parts": [
          {
            "name": "svg.icon",
            "description": "Magnifier, decorative, positioned inside the input."
          },
          {
            "name": "label.u-visually-hidden",
            "description": "Hidden label bound with `for`."
          },
          {
            "name": ".input",
            "description": "The search input with extra start padding for the icon."
          }
        ],
        "states": [
          {
            "name": ":focus-visible",
            "description": "Global focus ring (from `.input`)."
          }
        ]
      },
      "cssVars": [],
      "a11y": [
        "A real `<label for>` exists, only visually hidden — the placeholder is not the label.",
        "The icon is `aria-hidden`.",
        "If results update while typing, announce the count in a polite live region (\"12 Mitglieder gefunden\")."
      ],
      "guidance": {
        "do": [
          "Keep the query in the URL.",
          "Search across the fields people actually know (name, not member number only)."
        ],
        "dont": [
          "Don't hide the search behind an icon button on desktop.",
          "Don't clear the query when the user opens a result and comes back."
        ]
      },
      "related": [
        "field",
        "list",
        "table",
        "empty-state"
      ]
    },
    {
      "id": "segmented",
      "name": "Segmented control",
      "group": "Forms",
      "status": "stable",
      "summary": "A single choice among 2–4 short, mutually exclusive options, shown side by side.",
      "description": [
        "Use it when the options are few and short and the choice changes the current view or text: the form of address in a reply (\"du / ihr / Sie\"), \"Liste / Kalender\", \"Woche / Monat\". Underneath it is a radio group, so it also works inside forms.",
        "For longer option texts, more than four options or options that need a hint, use a radio choice group instead.",
        "The legend names the choice. In toolbars where the context is obvious it may be visually hidden (`hideLegend`) — it is still read by screen readers."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "legend",
          "options"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Id prefix: `{id}-legend` and option ids `{id}-1`, `{id}-2`, … Must be unique on the page."
          },
          "legend": {
            "type": "string",
            "minLength": 1,
            "description": "What is being chosen (\"Anrede in der Antwort\")."
          },
          "hideLegend": {
            "type": "boolean",
            "default": false,
            "description": "Hide the legend visually (kept for screen readers)."
          },
          "name": {
            "type": "string",
            "description": "Form field name of the radios; defaults to `id`."
          },
          "value": {
            "type": "string",
            "description": "Value of the selected option. Without it, the first option is selected — a segmented control always has a selection."
          },
          "block": {
            "type": "boolean",
            "default": false,
            "description": "Stretch to the full width of its container (e.g. on phones)."
          },
          "options": {
            "type": "array",
            "minItems": 2,
            "maxItems": 4,
            "description": "2–4 options in display order.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "value",
                "label"
              ],
              "properties": {
                "value": {
                  "type": "string",
                  "description": "Submitted value."
                },
                "label": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 20,
                  "description": "Short visible text (one or two words)."
                }
              }
            }
          }
        }
      },
      "examples": [
        {
          "title": "Form of address",
          "data": {
            "id": "register",
            "legend": "Anrede in der Antwort",
            "value": "du",
            "options": [
              {
                "value": "du",
                "label": "du"
              },
              {
                "value": "ihr",
                "label": "ihr"
              },
              {
                "value": "sie",
                "label": "Sie"
              }
            ]
          },
          "html": "<fieldset class=\"fieldset\">\n  <legend id=\"register-legend\">Anrede in der Antwort</legend>\n  <div class=\"segmented\" role=\"radiogroup\" aria-labelledby=\"register-legend\"><label><input type=\"radio\" id=\"register-1\" name=\"register\" value=\"du\" checked><span>du</span></label><label><input type=\"radio\" id=\"register-2\" name=\"register\" value=\"ihr\"><span>ihr</span></label><label><input type=\"radio\" id=\"register-3\" name=\"register\" value=\"sie\"><span>Sie</span></label></div>\n</fieldset>"
        },
        {
          "title": "View switch, legend hidden, full width",
          "data": {
            "id": "view",
            "legend": "Ansicht",
            "hideLegend": true,
            "block": true,
            "value": "calendar",
            "options": [
              {
                "value": "list",
                "label": "Liste"
              },
              {
                "value": "calendar",
                "label": "Kalender"
              }
            ]
          },
          "html": "<fieldset class=\"fieldset\">\n  <legend id=\"view-legend\" class=\"u-visually-hidden\">Ansicht</legend>\n  <div class=\"segmented\" role=\"radiogroup\" aria-labelledby=\"view-legend\" data-block><label><input type=\"radio\" id=\"view-1\" name=\"view\" value=\"list\"><span>Liste</span></label><label><input type=\"radio\" id=\"view-2\" name=\"view\" value=\"calendar\" checked><span>Kalender</span></label></div>\n</fieldset>"
        }
      ],
      "markup": {
        "root": ".segmented",
        "element": "div (inside fieldset.fieldset)",
        "attributes": [
          {
            "name": "role=\"radiogroup\"",
            "values": "—",
            "description": "Group role, named by the legend (`aria-labelledby=\"{id}-legend\"`)."
          },
          {
            "name": "data-block",
            "values": "—",
            "description": "Full width."
          }
        ],
        "parts": [
          {
            "name": "label",
            "description": "One segment; wraps the radio and its text."
          },
          {
            "name": "input[type=\"radio\"]",
            "description": "Transparent radio covering the segment (keeps native keyboard behaviour)."
          },
          {
            "name": "span",
            "description": "Visible segment text."
          }
        ],
        "states": [
          {
            "name": "input:checked + span",
            "description": "Raised surface, full-contrast text."
          },
          {
            "name": "input:focus-visible + span",
            "description": "Focus ring on the segment."
          }
        ]
      },
      "cssVars": [],
      "a11y": [
        "Native radios: Tab enters the group, arrow keys change the selection.",
        "The group is named by its legend, even when the legend is visually hidden.",
        "Segment text is short but complete — no icon-only segments without accessible text."
      ],
      "guidance": {
        "do": [
          "Keep labels to one or two words.",
          "Always have exactly one option selected."
        ],
        "dont": [
          "Don't use it for actions (\"Speichern / Abbrechen\") — those are buttons.",
          "Don't use more than 4 segments; switch to a radio group or select."
        ]
      },
      "related": [
        "choice-group",
        "tabs"
      ]
    },
    {
      "id": "switch",
      "name": "Switch",
      "group": "Forms",
      "status": "stable",
      "summary": "An on/off toggle for a setting that takes effect immediately.",
      "description": [
        "Use a switch only when flipping it changes something right away — notification settings, \"Erinnerung am Trainingstag\", a module flag. The page saves the change on `change` and shows a save status; there is no \"Speichern\" button.",
        "For choices that are submitted with a form (consent, \"Ich nehme teil\"), use a checkbox in a choice group instead.",
        "The label sits at the start, the switch at the end of the row, so lists of settings line up. An optional hint explains the effect (\"Am Morgen jedes Trainingstags um 8:00\")."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "label"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Id of the input; also `{id}-hint` for the hint line. Must be unique on the page."
          },
          "label": {
            "type": "string",
            "minLength": 1,
            "description": "Name of the setting, phrased as the \"on\" state (\"Push-Nachrichten\")."
          },
          "name": {
            "type": "string",
            "description": "Setting key / form field name; defaults to `id`."
          },
          "hint": {
            "type": "string",
            "description": "Secondary line explaining the effect."
          },
          "checked": {
            "type": "boolean",
            "default": false,
            "description": "Current state: on."
          },
          "disabled": {
            "type": "boolean",
            "default": false,
            "description": "Can't be changed now (e.g. not permitted); explain why in the hint."
          }
        }
      },
      "examples": [
        {
          "title": "On",
          "data": {
            "id": "push",
            "label": "Push-Nachrichten",
            "checked": true
          },
          "html": "<label class=\"switch\">Push-Nachrichten<input type=\"checkbox\" role=\"switch\" id=\"push\" name=\"push\" checked></label>"
        },
        {
          "title": "With hint",
          "data": {
            "id": "reminder",
            "label": "Erinnerung am Trainingstag",
            "hint": "Am Morgen jedes Trainingstags um 8:00"
          },
          "html": "<label class=\"switch\"><span class=\"switch__text\"><span class=\"switch__label\" id=\"reminder-label\">Erinnerung am Trainingstag</span><span class=\"switch__hint\" id=\"reminder-hint\">Am Morgen jedes Trainingstags um 8:00</span></span><input type=\"checkbox\" role=\"switch\" id=\"reminder\" name=\"reminder\" aria-labelledby=\"reminder-label\" aria-describedby=\"reminder-hint\"></label>"
        },
        {
          "title": "Disabled",
          "data": {
            "id": "sms",
            "label": "SMS bei Trainingsausfall",
            "hint": "Nur verfügbar, wenn der Verein SMS gebucht hat.",
            "disabled": true
          },
          "html": "<label class=\"switch\"><span class=\"switch__text\"><span class=\"switch__label\" id=\"sms-label\">SMS bei Trainingsausfall</span><span class=\"switch__hint\" id=\"sms-hint\">Nur verfügbar, wenn der Verein SMS gebucht hat.</span></span><input type=\"checkbox\" role=\"switch\" id=\"sms\" name=\"sms\" aria-labelledby=\"sms-label\" aria-describedby=\"sms-hint\" disabled></label>"
        }
      ],
      "markup": {
        "root": ".switch",
        "element": "label",
        "attributes": [
          {
            "name": "role=\"switch\" (on input)",
            "values": "—",
            "description": "Announced as \"Schalter, ein/aus\" instead of a checkbox."
          },
          {
            "name": "aria-labelledby / aria-describedby (on input)",
            "values": "\"{id}-label\" / \"{id}-hint\"",
            "description": "With a hint: the name is the label text only and the hint is the description (not both in the name)."
          }
        ],
        "parts": [
          {
            "name": "input[type=\"checkbox\"]",
            "description": "The 44×24 track and its thumb (`::after`), drawn by CSS."
          },
          {
            "name": ".switch__text",
            "description": "Wrapper of label text and hint (only rendered with a hint)."
          },
          {
            "name": ".switch__label",
            "description": "The label text inside `.switch__text`, id `{id}-label`."
          },
          {
            "name": ".switch__hint",
            "description": "Secondary line (`--text-sm`, muted), id `{id}-hint`."
          }
        ],
        "states": [
          {
            "name": "input:not(:checked)",
            "description": "Off: `--color-border-strong` track, `--color-surface` thumb."
          },
          {
            "name": "input:checked",
            "description": "On: `--color-accent` track, thumb moved to the end (mirrored in RTL)."
          },
          {
            "name": "input:disabled",
            "description": "Not operable: `--color-surface-2` track, disabled label colour."
          },
          {
            "name": ":focus-visible",
            "description": "Global focus ring on the track."
          }
        ]
      },
      "cssVars": [],
      "behaviour": {
        "module": "(page code)",
        "setup": "No built-in behaviour. Listen for `change` on the input, save the setting immediately (optimistic), show the save status, and revert the switch if saving fails.",
        "api": [],
        "events": [
          {
            "name": "change",
            "detail": "event.target.checked → new state, event.target.name → setting key",
            "description": "Native change event."
          }
        ]
      },
      "a11y": [
        "Native checkbox with `role=\"switch\"`: Space toggles it; state is announced as on/off.",
        "The whole row is the `<label>`, so the label text is the accessible name and the hit area.",
        "State is shown by thumb position and colour, and announced by the role — no on/off text needed."
      ],
      "guidance": {
        "do": [
          "Save immediately and show a save status nearby.",
          "Phrase the label so \"on\" is the obvious meaning (\"Push-Nachrichten\", not \"Push-Nachrichten deaktivieren\")."
        ],
        "dont": [
          "Don't put switches in a form with a submit button — use checkboxes.",
          "Don't use a switch for choices with more than two states."
        ]
      },
      "related": [
        "choice-group",
        "save-status"
      ]
    },
    {
      "id": "avatar",
      "name": "Avatar",
      "group": "Data display",
      "status": "stable",
      "summary": "A round picture of a person (photo when consent exists, otherwise initials), or a club logo with rounded corners.",
      "description": [
        "Avatars help recognise people in lists, attendance and headers. Initials are the default, on `--color-surface-2` in `--color-text-muted`; a photo (`src`) is shown only when the person (or a guardian, for minors) consented to photos (UX guide §12.4).",
        "An avatar is usually decorative because the name is shown right next to it — then it is hidden from assistive tech. When the avatar is the only identification (e.g. the signed-in user in the top bar), pass `label`, which makes it an image with that name (\"Angemeldet als Sandra Keller\").",
        "Sizes (design guide §9.3): `xs` (24 px) inline in text and dense tables, `sm` (32 px) in dense lists, the top bar and the club switcher, `md` (40 px) default, `lg` (64 px) in profile headers.",
        "`variant: \"club\"` shows a club logo: square with `--radius-md` instead of a circle, the logo fitted inside (club switcher, club lists)."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [],
        "properties": {
          "initials": {
            "type": "string",
            "minLength": 1,
            "maxLength": 3,
            "description": "One to three initials (\"LM\"). Required unless `src` is set; also the fallback text."
          },
          "src": {
            "type": "string",
            "description": "Photo URL (only with photo consent). Rendered as `<img>` filling the circle."
          },
          "alt": {
            "type": "string",
            "default": "",
            "description": "Alt text of the photo. Leave empty when the name is shown next to the avatar (decorative)."
          },
          "label": {
            "type": "string",
            "description": "Accessible name when the avatar stands alone (\"Angemeldet als Sandra Keller\"); sets `role=\"img\"` + `aria-label`."
          },
          "size": {
            "type": "string",
            "enum": [
              "xs",
              "sm",
              "md",
              "lg"
            ],
            "default": "md",
            "description": "`xs` 24 px, `sm` 32 px, `md` 40 px, `lg` 64 px."
          },
          "variant": {
            "type": "string",
            "enum": [
              "person",
              "club"
            ],
            "default": "person",
            "description": "`person`: circle. `club`: club logo, square with `--radius-md`."
          }
        }
      },
      "examples": [
        {
          "title": "Initials (decorative, next to a name)",
          "data": {
            "initials": "LM"
          },
          "html": "<span class=\"avatar\" aria-hidden=\"true\">LM</span>"
        },
        {
          "title": "Small, standing alone in the top bar",
          "data": {
            "initials": "SK",
            "size": "sm",
            "label": "Angemeldet als Sandra Keller"
          },
          "html": "<span class=\"avatar\" data-size=\"sm\" role=\"img\" aria-label=\"Angemeldet als Sandra Keller\">SK</span>"
        },
        {
          "title": "Extra small, inline",
          "data": {
            "initials": "NK",
            "size": "xs"
          },
          "html": "<span class=\"avatar\" data-size=\"xs\" aria-hidden=\"true\">NK</span>"
        },
        {
          "title": "Photo with consent",
          "data": {
            "src": "/media/members/m-1042.jpg",
            "size": "lg",
            "initials": "LM"
          },
          "html": "<span class=\"avatar\" data-size=\"lg\" aria-hidden=\"true\"><img src=\"/media/members/m-1042.jpg\" alt=\"\"></span>"
        },
        {
          "title": "Club logo",
          "data": {
            "src": "/media/clubs/tkd-bern.svg",
            "size": "sm",
            "variant": "club",
            "initials": "TB"
          },
          "html": "<span class=\"avatar\" data-size=\"sm\" data-variant=\"club\" aria-hidden=\"true\"><img src=\"/media/clubs/tkd-bern.svg\" alt=\"\"></span>"
        }
      ],
      "markup": {
        "root": ".avatar",
        "element": "span",
        "attributes": [
          {
            "name": "data-size",
            "values": "xs | sm | lg",
            "default": "(none = md, 40 px)",
            "description": "Size: 24 / 32 / 64 px."
          },
          {
            "name": "data-variant",
            "values": "club",
            "default": "(none = person)",
            "description": "Club logo: `--radius-md` corners instead of a circle."
          },
          {
            "name": "aria-hidden=\"true\"",
            "values": "—",
            "description": "Decorative avatar (no `label`, and no `alt` on a photo)."
          },
          {
            "name": "role=\"img\" aria-label",
            "values": "text",
            "description": "Standalone avatar with `label`."
          }
        ],
        "parts": [
          {
            "name": "img",
            "description": "Photo, `object-fit: cover`."
          }
        ],
        "states": []
      },
      "cssVars": [
        {
          "name": "--avatar-size",
          "default": "2.5rem",
          "description": "Diameter; set by `data-size`, can be overridden for special layouts."
        }
      ],
      "a11y": [
        "Decorative by default (`aria-hidden`) because the name is shown next to it; avoids \"LM Lea Müller\" being read twice.",
        "Standalone avatars need `label`; then the image is announced with that name.",
        "Initials use `--color-text-muted` on `--color-surface-2`, verified for contrast in both themes."
      ],
      "guidance": {
        "do": [
          "Fall back to initials whenever a photo is missing or not consented.",
          "Use first-name + initial for minors on shared screens."
        ],
        "dont": [
          "Don't show photos of minors without guardian consent.",
          "Don't use avatars as the only way to tell people apart in lists — always show the name."
        ]
      },
      "related": [
        "list",
        "app-shell"
      ]
    },
    {
      "id": "card",
      "name": "Card",
      "group": "Data display",
      "status": "stable",
      "summary": "A flat, bordered surface that groups one topic: a title with optional status, a few lines of text and up to three actions — or, as a whole, one link.",
      "description": [
        "Cards group content about one thing: a training (\"Kinder 7–10 · Mo 17:30\"), the detail of a registration, a dashboard item. They are flat (border, no shadow) and sit in a `.grid` or `.stack`. Don't nest cards in cards; use list rows or a `subtle` card inside instead.",
        "Two modes. **Static** (`<article>`): `.card__header` with title and optional status badge, `.card__body` with the muted `meta` line and body paragraphs, and an optional `.card__footer` with up to three actions (button data, primary last) above a divider. **Interactive** (`href`): the whole card is one `<a class=\"card\" data-interactive>`, for dashboard items that lead somewhere; it must not contain other actions. On hover it shifts to the second surface with a stronger border — no shadow, in-flow content stays flat (design guide §6.2).",
        "`variant: \"accent\"` tints the card with `--color-accent-subtle` (one hero card per screen at most; never an accent fill, design guide §2.4); `subtle` uses the second surface for secondary or nested panels.",
        "Body content is plain text paragraphs. Richer content (lists, key-value lists, stat tiles) is composed by the app: render the card markup by hand or place other components inside a `.card` element — never pass HTML through data."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Id of the card. When set together with `title`, the title gets `{id}-title` and the article is labelled by it (`aria-labelledby`)."
          },
          "title": {
            "type": "string",
            "minLength": 1,
            "description": "Card title (\"Kinder 7–10 · Mo 17:30\")."
          },
          "headingLevel": {
            "type": "integer",
            "minimum": 2,
            "maximum": 4,
            "default": 3,
            "description": "Heading level of the title (`h2`–`h4`); pick the level that fits the page outline."
          },
          "badge": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "label"
            ],
            "properties": {
              "label": {
                "type": "string",
                "minLength": 1,
                "description": "Status text, e.g. \"Wartet auf Freigabe\", \"Bezahlt\", \"Fehler in Webling\"."
              },
              "tone": {
                "type": "string",
                "enum": [
                  "neutral",
                  "info",
                  "success",
                  "warning",
                  "danger"
                ],
                "default": "neutral",
                "description": "Status, rendered as `data-status` + leading icon. `neutral` (draft, inactive; no icon), `info` (planned, new), `success` (done, paid), `warning` (waiting, needs attention), `danger` (failed, overdue)."
              },
              "title": {
                "type": "string",
                "description": "Optional tooltip with more detail (\"seit 3 Tagen offen\"). Not announced reliably; never put essential information only here."
              }
            },
            "description": "Optional status shown at the end of the header (see Status badge)."
          },
          "meta": {
            "type": "string",
            "description": "One muted line under the header (\"14 anwesend · 2 abwesend · Leitung: Sandra Keller\")."
          },
          "body": {
            "type": "array",
            "description": "Body paragraphs, plain text, in display order.",
            "items": {
              "type": "string",
              "minLength": 1,
              "description": "One paragraph."
            }
          },
          "variant": {
            "type": "string",
            "enum": [
              "default",
              "accent",
              "subtle"
            ],
            "default": "default",
            "description": "`accent`: accent-subtle hero tint (one hero card per screen); `subtle`: second surface for nested/secondary panels."
          },
          "href": {
            "type": "string",
            "description": "Makes the whole card one link (`<a class=\"card\" data-interactive href>`). Cannot be combined with `actions`."
          },
          "actions": {
            "type": "array",
            "maxItems": 3,
            "description": "Buttons in the `.card__footer` (see Button). Put the primary action last. Not allowed together with `href`.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [],
              "properties": {
                "label": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Visible text. Required unless `iconOnly`."
                },
                "variant": {
                  "type": "string",
                  "enum": [
                    "secondary",
                    "primary",
                    "ghost",
                    "danger",
                    "danger-ghost"
                  ],
                  "default": "secondary",
                  "description": "Visual weight / meaning."
                },
                "size": {
                  "type": "string",
                  "enum": [
                    "sm",
                    "md",
                    "lg"
                  ],
                  "default": "md",
                  "description": "`sm` (36 px) only in dense desktop tables; `lg` (52 px) for the single main action of a screen."
                },
                "icon": {
                  "type": "string",
                  "description": "Icon id from the sprite, shown before the label."
                },
                "iconOnly": {
                  "type": "boolean",
                  "default": false,
                  "description": "Square button showing only `icon`. Requires `ariaLabel`."
                },
                "ariaLabel": {
                  "type": "string",
                  "description": "Accessible name when the visible label is missing or ambiguous (\"Weitere Aktionen für Lea Müller\")."
                },
                "href": {
                  "type": "string",
                  "description": "Render an `<a>` that looks like a button (navigation, not actions)."
                },
                "type": {
                  "type": "string",
                  "enum": [
                    "button",
                    "submit",
                    "reset"
                  ],
                  "default": "button",
                  "description": "Button type inside forms."
                },
                "value": {
                  "type": "string",
                  "description": "Form value, e.g. `cancel` / `confirm` inside `<form method=\"dialog\">`."
                },
                "name": {
                  "type": "string",
                  "description": "Form field name when the button submits a value."
                },
                "disabled": {
                  "type": "boolean",
                  "default": false,
                  "description": "Not available now. Prefer explaining why nearby over silently disabling."
                },
                "busy": {
                  "type": "boolean",
                  "default": false,
                  "description": "Action in progress (`aria-busy`)."
                },
                "formNoValidate": {
                  "type": "boolean",
                  "default": false,
                  "description": "Submit without native validation — use on \"Abbrechen\" inside `<form method=\"dialog\">` so required fields do not block closing."
                },
                "block": {
                  "type": "string",
                  "enum": [
                    "always",
                    "mobile"
                  ],
                  "description": "Full width always, or only below 768 px."
                },
                "behavior": {
                  "type": "string",
                  "description": "Behaviour hook (`data-behavior`), e.g. `open-dialog`, `theme-toggle`."
                },
                "action": {
                  "type": "string",
                  "pattern": "^[a-z][a-zA-Z0-9-]*$",
                  "description": "Screen action name (`data-action`). Declared in the `actions` of the screen with a handler and a risk class; the engine adds undo or confirm accordingly."
                },
                "actionId": {
                  "type": "string",
                  "pattern": "^[A-Za-z0-9_-]+$",
                  "description": "Id of the thing the action applies to (`data-id`), when the button is not inside an element that has one."
                },
                "target": {
                  "type": "string",
                  "description": "Id of the element a behaviour acts on (`data-target`), e.g. the dialog to open."
                },
                "popoverTarget": {
                  "type": "string",
                  "description": "Id of a popover menu this button toggles (`popovertarget`)."
                }
              },
              "description": "One button (Button data)."
            }
          }
        }
      },
      "examples": [
        {
          "title": "Training card with status and action",
          "data": {
            "title": "Kinder 7–10 · Mo 17:30",
            "badge": {
              "label": "Präsenz erfasst",
              "tone": "success"
            },
            "meta": "14 anwesend · 2 abwesend · Leitung: Sandra Keller",
            "actions": [
              {
                "label": "Öffnen",
                "href": "#training-4711"
              }
            ]
          },
          "html": "<article class=\"card\"><header class=\"card__header\"><h3 class=\"card__title\">Kinder 7–10 · Mo 17:30</h3><span class=\"badge\" data-status=\"success\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check-circle\"/></svg>Präsenz erfasst</span></header><div class=\"card__body\"><p class=\"u-muted\">14 anwesend · 2 abwesend · Leitung: Sandra Keller</p></div><footer class=\"card__footer\"><a class=\"button\" href=\"#training-4711\">Öffnen</a></footer></article>"
        },
        {
          "title": "Detail card with body text",
          "data": {
            "id": "reg-17",
            "title": "Luca Gerber",
            "headingLevel": 2,
            "badge": {
              "label": "Wartet auf Freigabe",
              "tone": "warning"
            },
            "body": [
              "Gewünschte Gruppe: Kinder 7–10. Familientarif, weil die Schwester Nina bereits Mitglied ist.",
              "Eingegangen gestern über das öffentliche Anmeldeformular."
            ],
            "actions": [
              {
                "label": "Ablehnen",
                "variant": "ghost"
              },
              {
                "label": "Bearbeiten"
              },
              {
                "label": "Freigeben",
                "variant": "primary"
              }
            ]
          },
          "html": "<article class=\"card\" id=\"reg-17\" aria-labelledby=\"reg-17-title\"><header class=\"card__header\"><h2 class=\"card__title\" id=\"reg-17-title\">Luca Gerber</h2><span class=\"badge\" data-status=\"warning\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-triangle\"/></svg>Wartet auf Freigabe</span></header><div class=\"card__body\"><p>Gewünschte Gruppe: Kinder 7–10. Familientarif, weil die Schwester Nina bereits Mitglied ist.</p><p>Eingegangen gestern über das öffentliche Anmeldeformular.</p></div><footer class=\"card__footer\"><button class=\"button\" type=\"button\" data-variant=\"ghost\">Ablehnen</button><button class=\"button\" type=\"button\">Bearbeiten</button><button class=\"button\" type=\"button\" data-variant=\"primary\">Freigeben</button></footer></article>"
        },
        {
          "title": "Whole card as link",
          "data": {
            "title": "Gurtprüfung 14.11.",
            "meta": "38 Anmeldungen · 3 ohne Gebühr",
            "href": "#pruefung-2026-11"
          },
          "html": "<a class=\"card\" data-interactive href=\"#pruefung-2026-11\"><header class=\"card__header\"><h3 class=\"card__title\">Gurtprüfung 14.11.</h3></header><div class=\"card__body\"><p class=\"u-muted\">38 Anmeldungen · 3 ohne Gebühr</p></div></a>"
        },
        {
          "title": "Accent hero card (subtle tint)",
          "data": {
            "variant": "accent",
            "title": "Nächstes Training",
            "body": [
              "Heute 17:30 · Kinder 7–10 · Dojang Breitenrain"
            ]
          },
          "html": "<article class=\"card\" data-variant=\"accent\"><header class=\"card__header\"><h3 class=\"card__title\">Nächstes Training</h3></header><div class=\"card__body\"><p>Heute 17:30 · Kinder 7–10 · Dojang Breitenrain</p></div></article>"
        }
      ],
      "markup": {
        "root": ".card",
        "element": "article | a",
        "attributes": [
          {
            "name": "data-variant",
            "values": "accent | subtle",
            "default": "(none)",
            "description": "Accent-subtle hero tint or subtle second surface."
          },
          {
            "name": "data-interactive",
            "values": "—",
            "description": "Whole card is a link (`<a href>`): on hover second surface + strong border, no shadow."
          },
          {
            "name": "aria-labelledby",
            "values": "\"{id}-title\"",
            "description": "Names the article after its title (set when `id` and `title` are given)."
          }
        ],
        "parts": [
          {
            "name": ".card__header",
            "description": "Flex row: title at the start, badge (or a small action) at the end."
          },
          {
            "name": ".card__title",
            "description": "The heading (`h2`–`h4`)."
          },
          {
            "name": ".card__body",
            "description": "Content column: the meta line (`p.u-muted`) and body paragraphs."
          },
          {
            "name": ".card__footer",
            "description": "`<footer>` with the actions at the end side (primary last), above a `--color-border` divider."
          }
        ],
        "states": [
          {
            "name": "[data-interactive]:hover",
            "description": "Second surface + strong border (inside `@media (hover: hover)`)."
          },
          {
            "name": ":focus-visible",
            "description": "Global focus ring on the link card."
          }
        ]
      },
      "cssVars": [],
      "a11y": [
        "Static cards are `<article>`; with `id` + `title` they are labelled by their heading, so they appear as named regions.",
        "A link card is one `<a>`; its accessible name is all its text, so keep the content short and lead with the title.",
        "Never put buttons or links inside a link card (nested interactive content) — `render` throws when `href` and `actions` are combined.",
        "On `accent` cards all text uses `--color-accent-subtle-text`, which is verified on `--color-accent-subtle`; don't add status colours there, they aren't verified on the tint."
      ],
      "guidance": {
        "do": [
          "One topic per card; lead with what the person needs to decide.",
          "Use a grid of link cards for dashboard items that each lead to where the work is done."
        ],
        "dont": [
          "Don't nest cards.",
          "Don't add shadows or coloured backgrounds per card for decoration.",
          "Don't use more than one `accent` card per screen."
        ]
      },
      "related": [
        "stat",
        "list",
        "kv",
        "badge",
        "button"
      ]
    },
    {
      "id": "chip-group",
      "name": "Chip group",
      "group": "Data display",
      "status": "stable",
      "summary": "A labelled row of toggle chips for filtering a list or table, each with an optional result count.",
      "description": [
        "Filters show as chips above the list they filter (\"Offen 3 · Freigegeben 28 · Abgelehnt 2\"). Each chip is a `<button>` with `aria-pressed`; the row scrolls horizontally on small screens instead of wrapping.",
        "`mode: \"single\"` (default) means exactly one chip is pressed at a time — like a segmented status filter; `render` rejects data with more than one pressed chip. `mode: \"multi\"` lets any number be pressed (tags, several groups). The mode is written to `data-mode` for the page code, which enforces it on click.",
        "There is no built-in behaviour: the page listens for clicks on `[data-value]` inside the group, updates `aria-pressed`, writes the filter into the URL query string (UX guide §3.3) and reloads the results. Offer \"Filter zurücksetzen\" nearby when filters are active.",
        "Counts are the number of results that chip would show, as plain digits. They are part of the chip's accessible name (\"Offen 3\")."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "label",
          "chips"
        ],
        "properties": {
          "label": {
            "type": "string",
            "minLength": 1,
            "description": "Accessible name of the group (`aria-label`), e.g. \"Status filtern\"."
          },
          "name": {
            "type": "string",
            "pattern": "^[a-z][a-z0-9-]*$",
            "description": "Filter key (query-string parameter) written to `data-filter`, e.g. `status`."
          },
          "mode": {
            "type": "string",
            "enum": [
              "single",
              "multi"
            ],
            "default": "single",
            "description": "`single`: exactly one chip pressed (status filter). `multi`: any number pressed (tags). Written to `data-mode`."
          },
          "chips": {
            "type": "array",
            "minItems": 1,
            "description": "Chips in display order. Put \"Alle\" first for single-select filters.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "value",
                "label"
              ],
              "properties": {
                "value": {
                  "type": "string",
                  "pattern": "^[A-Za-z0-9_-]+$",
                  "description": "Filter value written to `data-value` (\"open\", \"approved\")."
                },
                "label": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Chip text (\"Offen\")."
                },
                "count": {
                  "type": "integer",
                  "minimum": 0,
                  "description": "Number of results for this chip, shown after the label."
                },
                "pressed": {
                  "type": "boolean",
                  "default": false,
                  "description": "Chip is active (`aria-pressed=\"true\"`)."
                }
              }
            }
          }
        }
      },
      "examples": [
        {
          "title": "Single-select status filter",
          "data": {
            "label": "Anmeldungen filtern",
            "name": "status",
            "chips": [
              {
                "value": "open",
                "label": "Offen",
                "count": 3,
                "pressed": true
              },
              {
                "value": "approved",
                "label": "Freigegeben",
                "count": 28
              },
              {
                "value": "rejected",
                "label": "Abgelehnt",
                "count": 2
              }
            ]
          },
          "html": "<div class=\"chip-row\" role=\"group\" aria-label=\"Anmeldungen filtern\" data-mode=\"single\" data-filter=\"status\"><button class=\"chip\" type=\"button\" data-value=\"open\" aria-pressed=\"true\"><span class=\"chip__check\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span>Offen <span class=\"chip__count\">3</span></button><button class=\"chip\" type=\"button\" data-value=\"approved\" aria-pressed=\"false\"><span class=\"chip__check\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span>Freigegeben <span class=\"chip__count\">28</span></button><button class=\"chip\" type=\"button\" data-value=\"rejected\" aria-pressed=\"false\"><span class=\"chip__check\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span>Abgelehnt <span class=\"chip__count\">2</span></button></div>"
        },
        {
          "title": "Multi-select groups",
          "data": {
            "label": "Trainingsgruppen",
            "name": "group",
            "mode": "multi",
            "chips": [
              {
                "value": "tigers",
                "label": "Tigers (4–6)",
                "pressed": true
              },
              {
                "value": "kids",
                "label": "Kinder 7–10",
                "pressed": true
              },
              {
                "value": "youth",
                "label": "Jugend 11–15"
              },
              {
                "value": "adults",
                "label": "Erwachsene"
              }
            ]
          },
          "html": "<div class=\"chip-row\" role=\"group\" aria-label=\"Trainingsgruppen\" data-mode=\"multi\" data-filter=\"group\"><button class=\"chip\" type=\"button\" data-value=\"tigers\" aria-pressed=\"true\"><span class=\"chip__check\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span>Tigers (4–6)</button><button class=\"chip\" type=\"button\" data-value=\"kids\" aria-pressed=\"true\"><span class=\"chip__check\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span>Kinder 7–10</button><button class=\"chip\" type=\"button\" data-value=\"youth\" aria-pressed=\"false\"><span class=\"chip__check\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span>Jugend 11–15</button><button class=\"chip\" type=\"button\" data-value=\"adults\" aria-pressed=\"false\"><span class=\"chip__check\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span>Erwachsene</button></div>"
        }
      ],
      "markup": {
        "root": ".chip-row",
        "element": "div",
        "attributes": [
          {
            "name": "role=\"group\" aria-label",
            "values": "text",
            "description": "Names the filter group."
          },
          {
            "name": "data-filter",
            "values": "filter key",
            "description": "Query-string parameter the group controls (page code)."
          },
          {
            "name": "data-mode",
            "values": "single | multi",
            "description": "Selection mode, enforced by page code."
          }
        ],
        "parts": [
          {
            "name": ".chip",
            "description": "`<button type=\"button\" aria-pressed data-value>` — one filter value. Pill on `--color-surface-2`, 32 px visual height with a 44 px hit area (`::after`)."
          },
          {
            "name": ".chip__check",
            "description": "16 px `check` icon, always rendered but shown only while the chip is pressed — so page code only toggles `aria-pressed`."
          },
          {
            "name": ".chip__count",
            "description": "Result count in muted tabular figures."
          }
        ],
        "states": [
          {
            "name": ".chip[aria-pressed=\"true\"]",
            "description": "Selected: `--color-accent-subtle` background, `--color-accent-subtle-text` and the check icon (design guide §9.3)."
          },
          {
            "name": ".chip:hover / :focus-visible",
            "description": "Surface shift (pointer devices) / global focus ring."
          }
        ]
      },
      "cssVars": [],
      "behaviour": {
        "module": "(page code)",
        "setup": "No built-in behaviour. One delegated listener on the group: `group.addEventListener(\"click\", e => { const chip = e.target.closest(\"[data-value]\"); … })`. In `single` mode set `aria-pressed=\"true\"` on the clicked chip and `false` on all others; in `multi` mode toggle it. Then update the URL query (`?{data-filter}={value}`) and reload results.",
        "api": [],
        "events": [
          {
            "name": "click",
            "detail": "event.target.closest(\"[data-value]\").dataset.value → filter value; group.dataset.filter → key",
            "description": "Native click on a chip."
          }
        ]
      },
      "a11y": [
        "Chips are native buttons with `aria-pressed`, announced as toggle buttons (\"Offen 3, Umschalter, gedrückt\").",
        "The group has `role=\"group\"` and a label, so the filter purpose is announced when entering it.",
        "Selected state is shown by colour **and** a check icon; the pressed state is also exposed to assistive tech. The icon is `aria-hidden`.",
        "After filtering, announce the new result count (e.g. in a live region \"18 von 312 Mitgliedern\")."
      ],
      "guidance": {
        "do": [
          "Keep filters in the URL so reload and back restore them.",
          "Show counts so empty filters are visible before clicking.",
          "Keep labels to one or two words."
        ],
        "dont": [
          "Don't use chips as status labels — that's a badge.",
          "Don't hide filters behind a chip row longer than ~8 chips; use a filter sheet instead.",
          "Don't use chips for navigation between pages — use tabs or nav items."
        ]
      },
      "related": [
        "badge",
        "list",
        "table",
        "tabs"
      ]
    },
    {
      "id": "count",
      "name": "Count bubble",
      "group": "Data display",
      "status": "stable",
      "summary": "A small red bubble with a number of new or open items (\"3 ungelesen\"), on nav items, tab bar items and buttons.",
      "description": [
        "Use the count bubble to say \"something new needs you here\": unread messages, open registrations, pending AI proposals. It sits on a nav item, a tab bar item or next to a heading.",
        "Give it an accessible `label` that says what is counted (\"3 offene Anmeldungen\"). The visible number is hidden from screen readers and the label is read instead, so \"Mitglieder 3\" becomes \"Mitglieder, 3 offene Anmeldungen\".",
        "With `value: 0` nothing is rendered (unless `showZero`), so callers can always pass the current number. Large numbers are capped at `max` and shown as \"99+\"."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "value"
        ],
        "properties": {
          "value": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of items."
          },
          "label": {
            "type": "string",
            "minLength": 1,
            "description": "Accessible text with number and noun, e.g. \"3 ungelesen\", \"3 offene Anmeldungen\". Read instead of the bare number. Strongly recommended."
          },
          "max": {
            "type": "integer",
            "minimum": 9,
            "default": 99,
            "description": "Above this value the bubble shows \"{max}+\". The `label` should still contain the exact number."
          },
          "showZero": {
            "type": "boolean",
            "default": false,
            "description": "Render the bubble for 0 as well (rarely useful; by default 0 renders nothing)."
          }
        }
      },
      "examples": [
        {
          "title": "Unread messages",
          "data": {
            "value": 3,
            "label": "3 ungelesen"
          },
          "html": "<span class=\"count\"><span aria-hidden=\"true\">3</span><span class=\"u-visually-hidden\">3 ungelesen</span></span>"
        },
        {
          "title": "Capped",
          "data": {
            "value": 128,
            "label": "128 offene Anmeldungen"
          },
          "html": "<span class=\"count\"><span aria-hidden=\"true\">99+</span><span class=\"u-visually-hidden\">128 offene Anmeldungen</span></span>"
        },
        {
          "title": "Without label (number is self-explanatory in context)",
          "data": {
            "value": 1
          },
          "html": "<span class=\"count\">1</span>"
        }
      ],
      "markup": {
        "root": ".count",
        "element": "span",
        "attributes": [],
        "parts": [
          {
            "name": "span[aria-hidden=\"true\"]",
            "description": "The visible number (only when `label` is set)."
          },
          {
            "name": ".u-visually-hidden",
            "description": "The accessible label, read instead of the number."
          }
        ],
        "states": []
      },
      "cssVars": [],
      "a11y": [
        "The number alone is meaningless to a screen reader (\"Mitglieder 3\"); always pass `label` unless the surrounding text already says what is counted.",
        "The count bubble is not a live region. If counts change while the page is open, announce important changes elsewhere (e.g. a toast), not on every update.",
        "Text is white on `--color-danger` (verified pair `--color-on-status`)."
      ],
      "guidance": {
        "do": [
          "Count only things the person can act on (open, unread, pending).",
          "Use the same label wording as the destination page (\"3 warten auf Freigabe\")."
        ],
        "dont": [
          "Don't use a count for totals (\"312 Mitglieder\") — that's a stat tile.",
          "Don't put a count on every nav item; it stops meaning \"needs you\"."
        ]
      },
      "related": [
        "badge",
        "app-shell",
        "chip-group"
      ]
    },
    {
      "id": "empty-state",
      "name": "Empty state",
      "group": "Data display",
      "status": "stable",
      "summary": "Explains why a list or area is empty and offers the next step.",
      "description": [
        "Every list, table and dashboard designs its empty state (UX guide §7.2). Three kinds: **first use** (\"Noch keine Lektionspläne.\" + \"Plan erstellen\"), **no results** (\"Keine Mitglieder für «Mueler».\" + \"Filter zurücksetzen\") and **all done** (\"Keine offenen Anmeldungen. Gut gemacht.\", usually without action).",
        "Anatomy (design guide §9.3): a 24 px icon in a 48 px `--color-surface-2` circle, a short title saying what is (not) there, one sentence explaining why or what to do, and at most one action (Button data, often a link via `href`). Centred in its container, max `--readable-max` wide.",
        "Set `live: true` when the empty state appears as the result of the user's own search or filter, so screen readers hear \"Keine Mitglieder gefunden\" without moving focus."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "title"
        ],
        "properties": {
          "icon": {
            "type": "string",
            "default": "info",
            "description": "Icon id from the sprite that fits the content (\"calendar\", \"users\", \"search\", \"check-circle\")."
          },
          "title": {
            "type": "string",
            "minLength": 1,
            "description": "What is (not) there: \"Heute keine Trainings\"."
          },
          "headingLevel": {
            "type": "integer",
            "minimum": 2,
            "maximum": 4,
            "description": "Render the title as `h2`–`h4` when the empty state replaces a whole section; otherwise it is a `p`."
          },
          "text": {
            "type": "string",
            "description": "One sentence: why it is empty or what to do next."
          },
          "action": {
            "type": "object",
            "additionalProperties": false,
            "required": [],
            "properties": {
              "label": {
                "type": "string",
                "minLength": 1,
                "description": "Visible text. Required unless `iconOnly`."
              },
              "variant": {
                "type": "string",
                "enum": [
                  "secondary",
                  "primary",
                  "ghost",
                  "danger",
                  "danger-ghost"
                ],
                "default": "secondary",
                "description": "Visual weight / meaning."
              },
              "size": {
                "type": "string",
                "enum": [
                  "sm",
                  "md",
                  "lg"
                ],
                "default": "md",
                "description": "`sm` (36 px) only in dense desktop tables; `lg` (52 px) for the single main action of a screen."
              },
              "icon": {
                "type": "string",
                "description": "Icon id from the sprite, shown before the label."
              },
              "iconOnly": {
                "type": "boolean",
                "default": false,
                "description": "Square button showing only `icon`. Requires `ariaLabel`."
              },
              "ariaLabel": {
                "type": "string",
                "description": "Accessible name when the visible label is missing or ambiguous (\"Weitere Aktionen für Lea Müller\")."
              },
              "href": {
                "type": "string",
                "description": "Render an `<a>` that looks like a button (navigation, not actions)."
              },
              "type": {
                "type": "string",
                "enum": [
                  "button",
                  "submit",
                  "reset"
                ],
                "default": "button",
                "description": "Button type inside forms."
              },
              "value": {
                "type": "string",
                "description": "Form value, e.g. `cancel` / `confirm` inside `<form method=\"dialog\">`."
              },
              "name": {
                "type": "string",
                "description": "Form field name when the button submits a value."
              },
              "disabled": {
                "type": "boolean",
                "default": false,
                "description": "Not available now. Prefer explaining why nearby over silently disabling."
              },
              "busy": {
                "type": "boolean",
                "default": false,
                "description": "Action in progress (`aria-busy`)."
              },
              "formNoValidate": {
                "type": "boolean",
                "default": false,
                "description": "Submit without native validation — use on \"Abbrechen\" inside `<form method=\"dialog\">` so required fields do not block closing."
              },
              "block": {
                "type": "string",
                "enum": [
                  "always",
                  "mobile"
                ],
                "description": "Full width always, or only below 768 px."
              },
              "behavior": {
                "type": "string",
                "description": "Behaviour hook (`data-behavior`), e.g. `open-dialog`, `theme-toggle`."
              },
              "action": {
                "type": "string",
                "pattern": "^[a-z][a-zA-Z0-9-]*$",
                "description": "Screen action name (`data-action`). Declared in the `actions` of the screen with a handler and a risk class; the engine adds undo or confirm accordingly."
              },
              "actionId": {
                "type": "string",
                "pattern": "^[A-Za-z0-9_-]+$",
                "description": "Id of the thing the action applies to (`data-id`), when the button is not inside an element that has one."
              },
              "target": {
                "type": "string",
                "description": "Id of the element a behaviour acts on (`data-target`), e.g. the dialog to open."
              },
              "popoverTarget": {
                "type": "string",
                "description": "Id of a popover menu this button toggles (`popovertarget`)."
              }
            },
            "description": "At most one action (Button data), e.g. `{ label: \"Plan erstellen\", variant: \"primary\" }` or a link with `href`."
          },
          "live": {
            "type": "boolean",
            "default": false,
            "description": "Announce politely (`role=\"status\"`) — for empty search/filter results."
          }
        }
      },
      "examples": [
        {
          "title": "Nothing today (with link)",
          "data": {
            "icon": "calendar",
            "title": "Heute keine Trainings",
            "text": "Du bist für heute für kein Training eingetragen.",
            "action": {
              "label": "Ganze Woche ansehen",
              "href": "#woche"
            }
          },
          "html": "<div class=\"empty\"><span class=\"empty__icon\"><svg class=\"icon\" data-size=\"lg\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#calendar\"/></svg></span><p class=\"empty__title\">Heute keine Trainings</p><p>Du bist für heute für kein Training eingetragen.</p><a class=\"button\" href=\"#woche\">Ganze Woche ansehen</a></div>"
        },
        {
          "title": "First use",
          "data": {
            "icon": "list",
            "title": "Noch keine Lektionspläne",
            "text": "Erstelle deinen ersten Plan oder übernimm einen Vorschlag.",
            "action": {
              "label": "Plan erstellen",
              "variant": "primary",
              "icon": "plus"
            }
          },
          "html": "<div class=\"empty\"><span class=\"empty__icon\"><svg class=\"icon\" data-size=\"lg\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#list\"/></svg></span><p class=\"empty__title\">Noch keine Lektionspläne</p><p>Erstelle deinen ersten Plan oder übernimm einen Vorschlag.</p><button class=\"button\" type=\"button\" data-variant=\"primary\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#plus\"/></svg>Plan erstellen</button></div>"
        },
        {
          "title": "No results",
          "data": {
            "icon": "search",
            "title": "Keine Mitglieder für «Mueler»",
            "text": "Prüfe die Schreibweise oder entferne Filter.",
            "action": {
              "label": "Filter zurücksetzen"
            },
            "live": true
          },
          "html": "<div class=\"empty\" role=\"status\"><span class=\"empty__icon\"><svg class=\"icon\" data-size=\"lg\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#search\"/></svg></span><p class=\"empty__title\">Keine Mitglieder für «Mueler»</p><p>Prüfe die Schreibweise oder entferne Filter.</p><button class=\"button\" type=\"button\">Filter zurücksetzen</button></div>"
        },
        {
          "title": "All done",
          "data": {
            "icon": "check-circle",
            "title": "Keine offenen Anmeldungen",
            "text": "Gut gemacht. Neue Anmeldungen erscheinen hier."
          },
          "html": "<div class=\"empty\"><span class=\"empty__icon\"><svg class=\"icon\" data-size=\"lg\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check-circle\"/></svg></span><p class=\"empty__title\">Keine offenen Anmeldungen</p><p>Gut gemacht. Neue Anmeldungen erscheinen hier.</p></div>"
        }
      ],
      "markup": {
        "root": ".empty",
        "element": "div",
        "attributes": [
          {
            "name": "role=\"status\"",
            "values": "—",
            "description": "Polite announcement (with `live`)."
          }
        ],
        "parts": [
          {
            "name": ".empty__icon",
            "description": "48 px `--color-surface-2` circle holding the 24 px icon in `--color-text-muted` (decorative)."
          },
          {
            "name": ".empty__title",
            "description": "Title (`p` or `h2`–`h4`), `--text-lg` semibold."
          },
          {
            "name": "p",
            "description": "One-sentence explanation in `--color-text-muted`."
          },
          {
            "name": ".button",
            "description": "Optional single action."
          }
        ],
        "states": []
      },
      "cssVars": [],
      "a11y": [
        "The icon is decorative (`aria-hidden`); title and text carry the message.",
        "With `live`, the region is a polite status so filter results are announced without stealing focus.",
        "Only one action, so keyboard users find the next step immediately."
      ],
      "guidance": {
        "do": [
          "Say why it is empty and what to do next.",
          "For \"no results\", repeat the search term and offer to reset filters.",
          "For \"all done\", tell what comes next (\"Nächstes Training: Mo 17:30\")."
        ],
        "dont": [
          "No illustrations or emoji.",
          "Don't show an empty table with headers only.",
          "Don't blame the user (\"Du hast nichts eingegeben\")."
        ]
      },
      "related": [
        "skeleton",
        "list",
        "table",
        "button"
      ]
    },
    {
      "id": "kv",
      "name": "Key-value list",
      "group": "Data display",
      "status": "stable",
      "summary": "Pairs of label and value for the attributes of one thing (a registration, a member, an event).",
      "description": [
        "Use in detail views and cards to show the facts of one object: \"Geburtsdatum 14.03.2017 (9 Jahre)\", \"Gewünschte Gruppe Kinder 7–10\". Two columns on wider screens, stacked (label above value) below 480 px.",
        "Values are plain text or numbers, already formatted for display (dates \"14.03.2017\", amounts \"CHF 195.–\"). Long values wrap. For a missing value write what it means (\"Nicht angegeben\", \"Keine Fotos\") instead of leaving it blank.",
        "Show only what the task needs (UX guide §12.4): attendance and lists don't show birthdates, phone numbers or addresses; detail views show them only to roles that need them."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "items"
        ],
        "properties": {
          "items": {
            "type": "array",
            "minItems": 1,
            "description": "Pairs in display order (most important first).",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "term",
                "value"
              ],
              "properties": {
                "term": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Label (\"Geburtsdatum\")."
                },
                "value": {
                  "type": [
                    "string",
                    "number"
                  ],
                  "description": "Display value, already formatted (\"14.03.2017 (9 Jahre)\"). Numbers are shown as given."
                }
              }
            }
          }
        }
      },
      "examples": [
        {
          "title": "Registration details",
          "data": {
            "items": [
              {
                "term": "Geburtsdatum",
                "value": "14.03.2017 (9 Jahre)"
              },
              {
                "term": "Erziehungsberechtigt",
                "value": "Martina Gerber · martina.gerber@example.ch"
              },
              {
                "term": "Gewünschte Gruppe",
                "value": "Kinder 7–10"
              },
              {
                "term": "Mitgliedschaft",
                "value": "Familie (Schwester Nina ist Mitglied)"
              },
              {
                "term": "Fotos",
                "value": "Einverstanden"
              }
            ]
          },
          "html": "<dl class=\"kv\"><dt>Geburtsdatum</dt><dd>14.03.2017 (9 Jahre)</dd><dt>Erziehungsberechtigt</dt><dd>Martina Gerber · martina.gerber@example.ch</dd><dt>Gewünschte Gruppe</dt><dd>Kinder 7–10</dd><dt>Mitgliedschaft</dt><dd>Familie (Schwester Nina ist Mitglied)</dd><dt>Fotos</dt><dd>Einverstanden</dd></dl>"
        },
        {
          "title": "Event facts",
          "data": {
            "items": [
              {
                "term": "Datum",
                "value": "Sa, 14.11.2026, 10:00–15:00"
              },
              {
                "term": "Ort",
                "value": "Dojang Breitenrain, Bern"
              },
              {
                "term": "Anmeldungen",
                "value": 38
              }
            ]
          },
          "html": "<dl class=\"kv\"><dt>Datum</dt><dd>Sa, 14.11.2026, 10:00–15:00</dd><dt>Ort</dt><dd>Dojang Breitenrain, Bern</dd><dt>Anmeldungen</dt><dd>38</dd></dl>"
        }
      ],
      "markup": {
        "root": ".kv",
        "element": "dl",
        "attributes": [],
        "parts": [
          {
            "name": "dt",
            "description": "Key (`--text-sm`, muted). Stacked above its value on phones; first column (~1/3) from 768 px."
          },
          {
            "name": "dd",
            "description": "Value (wraps anywhere for long e-mails)."
          }
        ],
        "states": []
      },
      "cssVars": [],
      "a11y": [
        "Native description list (`dl`/`dt`/`dd`), so each value is announced with its label.",
        "Stacked layout on narrow screens keeps labels and values together without horizontal scrolling."
      ],
      "guidance": {
        "do": [
          "Most important facts first.",
          "Write formatted values (dates, amounts, ages) — never raw ISO strings or codes."
        ],
        "dont": [
          "Don't show personal data the task doesn't need.",
          "Don't use a key-value list for comparisons between items — use a table."
        ]
      },
      "related": [
        "card",
        "table"
      ]
    },
    {
      "id": "list",
      "name": "List",
      "group": "Data display",
      "status": "stable",
      "summary": "A vertical list of items, each row with one primary action and optional secondary actions in a menu.",
      "description": [
        "The default way to show collections on phones and in admin screens: members, trainings, applicants, documents. One row = one thing; the whole row (`href`) is the primary action — usually \"open details\".",
        "Secondary actions go into the row's overflow menu (⋯). Never put more than one visible button in a row; that was the audit's \"row toolbox\" anti-pattern.",
        "At most one secondary line (`meta`). Status goes into the aside as a status badge; grades as a belt badge."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "items"
        ],
        "properties": {
          "label": {
            "type": "string",
            "description": "Accessible name of the list (`aria-label`), e.g. \"Offene Anmeldungen\"."
          },
          "flush": {
            "type": "boolean",
            "default": false,
            "description": "No border/background (list inside a card)."
          },
          "gestures": {
            "type": "string",
            "enum": [
              "shortcut",
              "primary"
            ],
            "default": "shortcut",
            "description": "How prominent swipes are. `shortcut`: swipes are extras for people who know them. `primary`: swiping is the main way to work this list (e.g. ticking exercises): the hint is shown, the first open row peeks once to show it can move, and quick actions become icon-only to give the gesture room. Only on phones; desktop always behaves like `shortcut`."
          },
          "swipeHint": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "id",
              "title",
              "items"
            ],
            "description": "One-time hint explaining the swipe gestures (athlete app pattern). Shown until dismissed; remembered per device under `id`.",
            "properties": {
              "id": {
                "type": "string",
                "pattern": "^[a-z0-9-]+$",
                "description": "Storage key for \"Nicht mehr anzeigen\", e.g. \"training-swipe\"."
              },
              "title": {
                "type": "string",
                "description": "Hint title."
              },
              "items": {
                "type": "array",
                "minItems": 1,
                "maxItems": 4,
                "description": "One line per gesture.",
                "items": {
                  "type": "string",
                  "description": "For example \"Nach rechts wischen: Übung fertig\"."
                }
              },
              "dismissLabel": {
                "type": "string",
                "default": "Nicht mehr anzeigen",
                "description": "Label of the dismiss button."
              }
            }
          },
          "items": {
            "type": "array",
            "description": "Rows in display order.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "id",
                "title"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "pattern": "^[A-Za-z0-9_-]+$",
                  "description": "Stable id of the thing (member id, registration id). Used for `data-id` and menu ids."
                },
                "title": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Main text (name, title)."
                },
                "meta": {
                  "type": "string",
                  "description": "One secondary line (\"Kinder 7–10 · seit 2024\")."
                },
                "href": {
                  "type": "string",
                  "description": "Primary action: link to the detail page. Without `href` the row is static."
                },
                "avatar": {
                  "type": "string",
                  "maxLength": 3,
                  "description": "Initials shown in an avatar circle (\"LM\")."
                },
                "belt": {
                  "type": "object",
                  "additionalProperties": false,
                  "required": [
                    "label",
                    "color"
                  ],
                  "properties": {
                    "label": {
                      "type": "string",
                      "minLength": 1,
                      "description": "Localised grade name from the grading system, e.g. \"6. Kup · Grüngurt\". Shown as text.",
                      "examples": [
                        "6. Kup · Grüngurt",
                        "1. Dan"
                      ]
                    },
                    "color": {
                      "type": "string",
                      "enum": [
                        "white",
                        "yellow",
                        "green",
                        "blue",
                        "red",
                        "black"
                      ],
                      "description": "Main belt colour; maps to the token `--belt-{color}`."
                    },
                    "tip": {
                      "type": "string",
                      "enum": [
                        "white",
                        "yellow",
                        "green",
                        "blue",
                        "red",
                        "black"
                      ],
                      "description": "Optional second colour for striped/tipped belts (e.g. yellow-green). Maps to `--belt-{tip}`."
                    },
                    "short": {
                      "type": "boolean",
                      "default": false,
                      "description": "Compact form for dense tables: the label should then be the short grade (\"6. Kup\"); the full name goes into `title`."
                    },
                    "title": {
                      "type": "string",
                      "description": "Full grade name as tooltip and accessible description when `short` is used."
                    },
                    "size": {
                      "type": "string",
                      "enum": [
                        "sm",
                        "md",
                        "lg"
                      ],
                      "default": "md",
                      "description": "Swatch size (design guide §9.7): `sm` in list rows, `md` (24×8 px) in cards and tables, `lg` on profiles, exam results and TV screens."
                    }
                  },
                  "description": "Optional grade shown in the aside (see Belt badge)."
                },
                "badge": {
                  "type": "object",
                  "additionalProperties": false,
                  "required": [
                    "label"
                  ],
                  "description": "Optional status badge in the aside.",
                  "properties": {
                    "label": {
                      "type": "string",
                      "description": "Status text (\"Wartet auf Freigabe\")."
                    },
                    "tone": {
                      "type": "string",
                      "enum": [
                        "neutral",
                        "info",
                        "success",
                        "warning",
                        "danger"
                      ],
                      "default": "neutral",
                      "description": "Status (see Status badge): written as `data-status` with a leading status icon; `neutral` has neither."
                    }
                  }
                },
                "current": {
                  "type": "boolean",
                  "default": false,
                  "description": "Selected row (e.g. shown in the detail pane next to the list)."
                },
                "quickAction": {
                  "type": "object",
                  "additionalProperties": false,
                  "required": [
                    "action",
                    "label"
                  ],
                  "description": "One visible button for the main job of the row when that job is not \"open details\", for example \"Fertig\" on an exercise. Also the tap alternative to a swipe. Must be in `actions`.",
                  "properties": {
                    "action": {
                      "type": "string",
                      "description": "Action key (also in `actions`)."
                    },
                    "label": {
                      "type": "string",
                      "description": "Button text, a verb. Becomes the accessible name when the button is icon-only."
                    },
                    "icon": {
                      "type": "string",
                      "description": "Icon; required for `gestures: primary` lists, where the button is icon-only on phones."
                    }
                  }
                },
                "swipe": {
                  "type": "object",
                  "additionalProperties": false,
                  "description": "Swipe shortcuts (js/swipe.js). commit: releasing past the threshold performs the action; only for safe or reversible actions. reveal: the swipe uncovers a button that performs it; for writes to external systems.",
                  "properties": {
                    "mode": {
                      "type": "string",
                      "enum": [
                        "commit",
                        "reveal"
                      ],
                      "default": "commit",
                      "description": "Gesture model (see description)."
                    },
                    "start": {
                      "type": "object",
                      "additionalProperties": false,
                      "required": [
                        "action",
                        "label"
                      ],
                      "description": "Layer uncovered by dragging towards the end side (to the right in LTR): the side where text starts.",
                      "properties": {
                        "action": {
                          "type": "string",
                          "description": "Action key; must also be in `actions` (swipe is a shortcut, never the only way)."
                        },
                        "label": {
                          "type": "string",
                          "description": "Text on the uncovered layer, for example \"Übung fertig\"."
                        },
                        "icon": {
                          "type": "string",
                          "description": "Icon id on the layer."
                        },
                        "tone": {
                          "type": "string",
                          "enum": [
                            "neutral",
                            "success",
                            "accent",
                            "danger"
                          ],
                          "default": "neutral",
                          "description": "Colour of the layer; turns solid when armed."
                        }
                      }
                    },
                    "end": {
                      "type": "object",
                      "additionalProperties": false,
                      "required": [
                        "action",
                        "label"
                      ],
                      "description": "Layer uncovered by dragging towards the start side (to the left in LTR).",
                      "properties": {
                        "action": {
                          "type": "string",
                          "description": "Action key; must also be in `actions` (swipe is a shortcut, never the only way)."
                        },
                        "label": {
                          "type": "string",
                          "description": "Text on the uncovered layer, for example \"Übung fertig\"."
                        },
                        "icon": {
                          "type": "string",
                          "description": "Icon id on the layer."
                        },
                        "tone": {
                          "type": "string",
                          "enum": [
                            "neutral",
                            "success",
                            "accent",
                            "danger"
                          ],
                          "default": "neutral",
                          "description": "Colour of the layer; turns solid when armed."
                        }
                      }
                    }
                  }
                },
                "progress": {
                  "type": "object",
                  "additionalProperties": false,
                  "required": [
                    "done",
                    "total"
                  ],
                  "description": "Set segments under the title (exercise with several sets).",
                  "properties": {
                    "done": {
                      "type": "integer",
                      "minimum": 0,
                      "description": "Sets done."
                    },
                    "total": {
                      "type": "integer",
                      "minimum": 1,
                      "maximum": 12,
                      "description": "Sets in total."
                    }
                  }
                },
                "done": {
                  "type": "boolean",
                  "default": false,
                  "description": "Completed/inactive item: muted and struck through."
                },
                "actions": {
                  "type": "array",
                  "description": "Secondary actions for the overflow menu. The page handles clicks via `data-action`.",
                  "items": {
                    "type": "object",
                    "additionalProperties": false,
                    "required": [
                      "action",
                      "label"
                    ],
                    "properties": {
                      "action": {
                        "type": "string",
                        "description": "Action key written to `data-action` (\"edit\", \"remove\")."
                      },
                      "label": {
                        "type": "string",
                        "description": "Menu text, a verb (\"Bearbeiten\")."
                      },
                      "icon": {
                        "type": "string",
                        "description": "Icon id."
                      },
                      "danger": {
                        "type": "boolean",
                        "default": false,
                        "description": "Destructive action (red text); still needs undo or confirm."
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "examples": [
        {
          "title": "Members with grade and menu",
          "data": {
            "label": "Mitglieder Kinder 7–10",
            "items": [
              {
                "id": "m-1042",
                "title": "Lea Müller",
                "meta": "Kinder 7–10 · seit 2024",
                "href": "#m-1042",
                "avatar": "LM",
                "belt": {
                  "label": "6. Kup",
                  "color": "green"
                },
                "actions": [
                  {
                    "action": "edit",
                    "label": "Bearbeiten",
                    "icon": "edit"
                  },
                  {
                    "action": "mail",
                    "label": "E-Mail senden",
                    "icon": "mail"
                  },
                  {
                    "action": "remove",
                    "label": "Aus Gruppe entfernen",
                    "icon": "trash",
                    "danger": true
                  }
                ]
              },
              {
                "id": "m-1077",
                "title": "Noah Keller",
                "meta": "Jugend 11–15",
                "href": "#m-1077",
                "avatar": "NK",
                "belt": {
                  "label": "7. Kup",
                  "color": "yellow",
                  "tip": "green"
                }
              },
              {
                "id": "m-0981",
                "title": "Elif Yilmaz",
                "meta": "abgemeldet per 31.12.",
                "avatar": "EY",
                "done": true,
                "badge": {
                  "label": "Inaktiv"
                }
              }
            ]
          },
          "html": "<ul class=\"list\" role=\"list\" aria-label=\"Mitglieder Kinder 7–10\"><li class=\"row\" data-id=\"m-1042\"><a class=\"row__main\" href=\"#m-1042\"><span class=\"avatar\">LM</span><span class=\"row__text\"><span class=\"row__title\">Lea Müller</span><span class=\"row__meta\">Kinder 7–10 · seit 2024</span></span></a><div class=\"row__aside\"><span class=\"belt\" style=\"--belt-color: var(--belt-green)\">6. Kup</span>\n      <button class=\"button\" type=\"button\" data-variant=\"ghost\" data-icon-only aria-label=\"Weitere Aktionen für Lea Müller\" popovertarget=\"menu-m-1042\" aria-haspopup=\"menu\" aria-expanded=\"false\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#more\"/></svg></button>\n      <div class=\"menu\" id=\"menu-m-1042\" popover role=\"menu\" data-behavior=\"menu\"><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"edit\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#edit\"/></svg>Bearbeiten</button><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"mail\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#mail\"/></svg>E-Mail senden</button><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"remove\" data-variant=\"danger\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#trash\"/></svg>Aus Gruppe entfernen</button></div></div></li><li class=\"row\" data-id=\"m-1077\"><a class=\"row__main\" href=\"#m-1077\"><span class=\"avatar\">NK</span><span class=\"row__text\"><span class=\"row__title\">Noah Keller</span><span class=\"row__meta\">Jugend 11–15</span></span></a><div class=\"row__aside\"><span class=\"belt\" style=\"--belt-color: var(--belt-yellow); --belt-tip: var(--belt-green)\">7. Kup</span></div></li><li class=\"row\" data-id=\"m-0981\" data-state=\"done\"><div class=\"row__main\"><span class=\"avatar\">EY</span><span class=\"row__text\"><span class=\"row__title\">Elif Yilmaz</span><span class=\"row__meta\">abgemeldet per 31.12.</span></span></div><div class=\"row__aside\"><span class=\"badge\">Inaktiv</span></div></li></ul>"
        },
        {
          "title": "Training: swipe as the primary gesture (athlete app)",
          "data": {
            "label": "Deine Übungen",
            "gestures": "primary",
            "swipeHint": {
              "id": "training-swipe",
              "title": "So geht es",
              "items": [
                "Nach rechts wischen: Übung fertig",
                "Nach links wischen: ein Satz geschafft",
                "Menü (⋯): Details und Zurücksetzen"
              ]
            },
            "items": [
              {
                "id": "ex-1",
                "title": "Dollyo Chagi an Pratze",
                "meta": "4×15 · Pause 60 s",
                "href": "#ex-1",
                "progress": {
                  "done": 2,
                  "total": 4
                },
                "quickAction": {
                  "action": "done",
                  "label": "Fertig",
                  "icon": "check"
                },
                "swipe": {
                  "mode": "commit",
                  "start": {
                    "action": "done",
                    "label": "Übung fertig",
                    "icon": "check",
                    "tone": "success"
                  },
                  "end": {
                    "action": "set",
                    "label": "Satz geschafft",
                    "icon": "plus",
                    "tone": "accent"
                  }
                },
                "actions": [
                  {
                    "action": "done",
                    "label": "Übung fertig",
                    "icon": "check"
                  },
                  {
                    "action": "set",
                    "label": "Ein Satz geschafft",
                    "icon": "plus"
                  },
                  {
                    "action": "details",
                    "label": "Details",
                    "icon": "info"
                  }
                ]
              },
              {
                "id": "ex-2",
                "title": "Plank",
                "meta": "3×45 s",
                "href": "#ex-2",
                "progress": {
                  "done": 0,
                  "total": 3
                },
                "quickAction": {
                  "action": "done",
                  "label": "Fertig",
                  "icon": "check"
                },
                "swipe": {
                  "mode": "commit",
                  "start": {
                    "action": "done",
                    "label": "Übung fertig",
                    "icon": "check",
                    "tone": "success"
                  },
                  "end": {
                    "action": "set",
                    "label": "Satz geschafft",
                    "icon": "plus",
                    "tone": "accent"
                  }
                },
                "actions": [
                  {
                    "action": "done",
                    "label": "Übung fertig",
                    "icon": "check"
                  },
                  {
                    "action": "set",
                    "label": "Ein Satz geschafft",
                    "icon": "plus"
                  }
                ]
              },
              {
                "id": "ex-3",
                "title": "Seilspringen",
                "meta": "3×120 s",
                "done": true,
                "progress": {
                  "done": 3,
                  "total": 3
                },
                "actions": [
                  {
                    "action": "reset",
                    "label": "Zurücksetzen",
                    "icon": "undo"
                  }
                ]
              }
            ]
          },
          "note": "gestures: primary, commit mode. Right = exercise done, left = one set done. The check button is the visible tap alternative; both swipe actions are also in the menu.",
          "html": "<div class=\"stack\" data-gap=\"sm\"><div class=\"alert\" data-status=\"neutral\" role=\"note\" data-behavior=\"dismiss-once\" data-dismiss-key=\"training-swipe\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#info\"/></svg><div class=\"alert__body\"><p class=\"alert__title\">So geht es</p><ul><li>Nach rechts wischen: Übung fertig</li><li>Nach links wischen: ein Satz geschafft</li><li>Menü (⋯): Details und Zurücksetzen</li></ul><div class=\"button-row\" data-align=\"start\"><button type=\"button\" class=\"button\" data-size=\"sm\" data-dismiss>Nicht mehr anzeigen</button></div></div></div><ul class=\"list\" role=\"list\" aria-label=\"Deine Übungen\" data-gestures=\"primary\" data-behavior=\"swipe-peek\"><li class=\"row\" data-id=\"ex-1\" data-behavior=\"swipe\" data-swipe-mode=\"commit\" data-swipe-start=\"done\" data-swipe-end=\"set\"><div class=\"row__swipe\" data-side=\"start\" data-tone=\"success\" aria-hidden=\"true\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg>Übung fertig</div><div class=\"row__swipe\" data-side=\"end\" data-tone=\"accent\" aria-hidden=\"true\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#plus\"/></svg>Satz geschafft</div><a class=\"row__main\" href=\"#ex-1\"><span class=\"row__text\"><span class=\"row__title\">Dollyo Chagi an Pratze</span><span class=\"row__meta\">4×15 · Pause 60 s</span><span class=\"segments\" role=\"img\" aria-label=\"2 von 4 Sätzen\"><span data-done></span><span data-done></span><span></span><span></span></span></span></a><div class=\"row__aside\"><button type=\"button\" class=\"button row__quick\" data-size=\"sm\" data-action=\"done\" aria-label=\"Fertig\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg><span class=\"row__quick-label\">Fertig</span></button>\n      <button class=\"button\" type=\"button\" data-variant=\"ghost\" data-icon-only aria-label=\"Weitere Aktionen für Dollyo Chagi an Pratze\" popovertarget=\"menu-ex-1\" aria-haspopup=\"menu\" aria-expanded=\"false\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#more\"/></svg></button>\n      <div class=\"menu\" id=\"menu-ex-1\" popover role=\"menu\" data-behavior=\"menu\"><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"done\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg>Übung fertig</button><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"set\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#plus\"/></svg>Ein Satz geschafft</button><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"details\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#info\"/></svg>Details</button></div></div></li><li class=\"row\" data-id=\"ex-2\" data-behavior=\"swipe\" data-swipe-mode=\"commit\" data-swipe-start=\"done\" data-swipe-end=\"set\"><div class=\"row__swipe\" data-side=\"start\" data-tone=\"success\" aria-hidden=\"true\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg>Übung fertig</div><div class=\"row__swipe\" data-side=\"end\" data-tone=\"accent\" aria-hidden=\"true\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#plus\"/></svg>Satz geschafft</div><a class=\"row__main\" href=\"#ex-2\"><span class=\"row__text\"><span class=\"row__title\">Plank</span><span class=\"row__meta\">3×45 s</span><span class=\"segments\" role=\"img\" aria-label=\"0 von 3 Sätzen\"><span></span><span></span><span></span></span></span></a><div class=\"row__aside\"><button type=\"button\" class=\"button row__quick\" data-size=\"sm\" data-action=\"done\" aria-label=\"Fertig\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg><span class=\"row__quick-label\">Fertig</span></button>\n      <button class=\"button\" type=\"button\" data-variant=\"ghost\" data-icon-only aria-label=\"Weitere Aktionen für Plank\" popovertarget=\"menu-ex-2\" aria-haspopup=\"menu\" aria-expanded=\"false\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#more\"/></svg></button>\n      <div class=\"menu\" id=\"menu-ex-2\" popover role=\"menu\" data-behavior=\"menu\"><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"done\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg>Übung fertig</button><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"set\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#plus\"/></svg>Ein Satz geschafft</button></div></div></li><li class=\"row\" data-id=\"ex-3\" data-state=\"done\"><div class=\"row__main\"><span class=\"row__text\"><span class=\"row__title\">Seilspringen</span><span class=\"row__meta\">3×120 s</span><span class=\"segments\" role=\"img\" aria-label=\"3 von 3 Sätzen\"><span data-done></span><span data-done></span><span data-done></span></span></span></div><div class=\"row__aside\">\n      <button class=\"button\" type=\"button\" data-variant=\"ghost\" data-icon-only aria-label=\"Weitere Aktionen für Seilspringen\" popovertarget=\"menu-ex-3\" aria-haspopup=\"menu\" aria-expanded=\"false\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#more\"/></svg></button>\n      <div class=\"menu\" id=\"menu-ex-3\" popover role=\"menu\" data-behavior=\"menu\"><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"reset\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#undo\"/></svg>Zurücksetzen</button></div></div></li></ul></div>"
        },
        {
          "title": "Attendance: swipe to reveal (Trainer app)",
          "data": {
            "label": "Kinder 7–10",
            "items": [
              {
                "id": "m-1042",
                "title": "Lea Müller",
                "avatar": "LM",
                "belt": {
                  "label": "6. Kup",
                  "color": "green"
                },
                "swipe": {
                  "mode": "reveal",
                  "start": {
                    "action": "move",
                    "label": "Verschieben",
                    "icon": "users"
                  },
                  "end": {
                    "action": "absent",
                    "label": "War nicht da",
                    "icon": "x",
                    "tone": "danger"
                  }
                },
                "actions": [
                  {
                    "action": "absent",
                    "label": "War nicht da",
                    "icon": "x"
                  },
                  {
                    "action": "move",
                    "label": "In andere Klasse verschieben",
                    "icon": "users"
                  }
                ]
              },
              {
                "id": "m-1077",
                "title": "Noah Keller",
                "avatar": "NK",
                "belt": {
                  "label": "7. Kup",
                  "color": "yellow",
                  "tip": "green"
                },
                "swipe": {
                  "mode": "reveal",
                  "start": {
                    "action": "move",
                    "label": "Verschieben",
                    "icon": "users"
                  },
                  "end": {
                    "action": "absent",
                    "label": "War nicht da",
                    "icon": "x",
                    "tone": "danger"
                  }
                },
                "actions": [
                  {
                    "action": "absent",
                    "label": "War nicht da",
                    "icon": "x"
                  },
                  {
                    "action": "move",
                    "label": "In andere Klasse verschieben",
                    "icon": "users"
                  }
                ]
              }
            ]
          },
          "note": "gestures: shortcut, reveal mode. The swipe only uncovers the button, because it writes to the presence list in the CRM.",
          "html": "<ul class=\"list\" role=\"list\" aria-label=\"Kinder 7–10\"><li class=\"row\" data-id=\"m-1042\" data-behavior=\"swipe\" data-swipe-mode=\"reveal\" data-swipe-start=\"move\" data-swipe-end=\"absent\"><div class=\"row__swipe\" data-side=\"start\" aria-hidden=\"true\"><button type=\"button\" class=\"button\" data-size=\"sm\" tabindex=\"-1\" data-action=\"move\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#users\"/></svg>Verschieben</button></div><div class=\"row__swipe\" data-side=\"end\" data-tone=\"danger\" aria-hidden=\"true\"><button type=\"button\" class=\"button\" data-size=\"sm\" tabindex=\"-1\" data-action=\"absent\" data-variant=\"danger\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#x\"/></svg>War nicht da</button></div><div class=\"row__main\"><span class=\"avatar\">LM</span><span class=\"row__text\"><span class=\"row__title\">Lea Müller</span></span></div><div class=\"row__aside\"><span class=\"belt\" style=\"--belt-color: var(--belt-green)\">6. Kup</span>\n      <button class=\"button\" type=\"button\" data-variant=\"ghost\" data-icon-only aria-label=\"Weitere Aktionen für Lea Müller\" popovertarget=\"menu-m-1042\" aria-haspopup=\"menu\" aria-expanded=\"false\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#more\"/></svg></button>\n      <div class=\"menu\" id=\"menu-m-1042\" popover role=\"menu\" data-behavior=\"menu\"><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"absent\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#x\"/></svg>War nicht da</button><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"move\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#users\"/></svg>In andere Klasse verschieben</button></div></div></li><li class=\"row\" data-id=\"m-1077\" data-behavior=\"swipe\" data-swipe-mode=\"reveal\" data-swipe-start=\"move\" data-swipe-end=\"absent\"><div class=\"row__swipe\" data-side=\"start\" aria-hidden=\"true\"><button type=\"button\" class=\"button\" data-size=\"sm\" tabindex=\"-1\" data-action=\"move\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#users\"/></svg>Verschieben</button></div><div class=\"row__swipe\" data-side=\"end\" data-tone=\"danger\" aria-hidden=\"true\"><button type=\"button\" class=\"button\" data-size=\"sm\" tabindex=\"-1\" data-action=\"absent\" data-variant=\"danger\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#x\"/></svg>War nicht da</button></div><div class=\"row__main\"><span class=\"avatar\">NK</span><span class=\"row__text\"><span class=\"row__title\">Noah Keller</span></span></div><div class=\"row__aside\"><span class=\"belt\" style=\"--belt-color: var(--belt-yellow); --belt-tip: var(--belt-green)\">7. Kup</span>\n      <button class=\"button\" type=\"button\" data-variant=\"ghost\" data-icon-only aria-label=\"Weitere Aktionen für Noah Keller\" popovertarget=\"menu-m-1077\" aria-haspopup=\"menu\" aria-expanded=\"false\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#more\"/></svg></button>\n      <div class=\"menu\" id=\"menu-m-1077\" popover role=\"menu\" data-behavior=\"menu\"><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"absent\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#x\"/></svg>War nicht da</button><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"move\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#users\"/></svg>In andere Klasse verschieben</button></div></div></li></ul>"
        },
        {
          "title": "Registrations with status",
          "data": {
            "label": "Offene Anmeldungen",
            "items": [
              {
                "id": "r-17",
                "title": "Luca Gerber",
                "meta": "9 Jahre · eingegangen gestern",
                "href": "#r-17",
                "avatar": "LG",
                "current": true,
                "badge": {
                  "label": "Familie",
                  "tone": "info"
                }
              },
              {
                "id": "r-18",
                "title": "Jonas Roth",
                "meta": "12 Jahre · vor 5 Tagen",
                "href": "#r-18",
                "avatar": "JR",
                "badge": {
                  "label": "Möglicher Doppeleintrag",
                  "tone": "warning"
                }
              }
            ]
          },
          "html": "<ul class=\"list\" role=\"list\" aria-label=\"Offene Anmeldungen\"><li class=\"row\" data-id=\"r-17\" aria-current=\"true\"><a class=\"row__main\" href=\"#r-17\"><span class=\"avatar\">LG</span><span class=\"row__text\"><span class=\"row__title\">Luca Gerber</span><span class=\"row__meta\">9 Jahre · eingegangen gestern</span></span></a><div class=\"row__aside\"><span class=\"badge\" data-status=\"info\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#info\"/></svg>Familie</span></div></li><li class=\"row\" data-id=\"r-18\"><a class=\"row__main\" href=\"#r-18\"><span class=\"avatar\">JR</span><span class=\"row__text\"><span class=\"row__title\">Jonas Roth</span><span class=\"row__meta\">12 Jahre · vor 5 Tagen</span></span></a><div class=\"row__aside\"><span class=\"badge\" data-status=\"warning\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-triangle\"/></svg>Möglicher Doppeleintrag</span></div></li></ul>"
        }
      ],
      "markup": {
        "root": ".list",
        "element": "ul",
        "attributes": [
          {
            "name": "role=\"list\"",
            "values": "—",
            "description": "Keeps list semantics."
          },
          {
            "name": "aria-label",
            "values": "text",
            "description": "List name."
          },
          {
            "name": "data-flush",
            "values": "—",
            "description": "Borderless variant."
          }
        ],
        "parts": [
          {
            "name": ".row",
            "description": "One item (`li`) with `data-id`."
          },
          {
            "name": ".row__main",
            "description": "Primary action — `<a href>` (or a `div` when static). Contains avatar and text."
          },
          {
            "name": ".row__text / .row__title / .row__meta",
            "description": "Title and the one secondary line, truncated with ellipsis."
          },
          {
            "name": ".row__aside",
            "description": "Belt, status badge (`.badge[data-status]`), overflow menu button."
          },
          {
            "name": ".menu",
            "description": "Popover with `[data-action]` items (see Menu)."
          },
          {
            "name": ".row__swipe[data-side=\"start|end\"]",
            "description": "Action layer uncovered by a swipe; holds the label (commit) or a button (reveal). aria-hidden: the menu is the accessible path."
          },
          {
            "name": ".row__quick",
            "description": "The visible quick-action button."
          },
          {
            "name": ".segments",
            "description": "Set progress under the title."
          },
          {
            "name": ".alert[data-status=\"neutral\"][data-behavior=\"dismiss-once\"]",
            "description": "The one-time swipe hint above the list."
          }
        ],
        "states": [
          {
            "name": ".row[aria-current=\"true\"]",
            "description": "Selected / \"you\" row: accent-subtle background and a 1 px accent outline."
          },
          {
            "name": ".row[data-state=\"done\"]",
            "description": "Completed/inactive."
          },
          {
            "name": ".list[data-gestures=\"primary\"]",
            "description": "Swipe is the main interaction (phones): peek on first open row, icon-only quick actions."
          },
          {
            "name": ".row[data-swipe-armed=\"start|end\"]",
            "description": "Commit swipe past the threshold: the layer turns solid; releasing performs the action."
          },
          {
            "name": ".row[data-swipe-open=\"start|end\"]",
            "description": "Reveal swipe: the row rests open, the button is reachable; Esc or a tap elsewhere closes it."
          }
        ]
      },
      "behaviour": {
        "module": "js/menu.js, js/swipe.js",
        "setup": "Menus, swipes and the hint need `mount(root)` (data-behavior \"menu\", \"swipe\", \"dismiss-once\"). A commit swipe clicks the matching menu item, so menu, quick action and swipe all arrive as the same click; in screens the engine then applies the risk class of the action. Handle actions with one delegated listener: `list.addEventListener(\"click\", e => { const a = e.target.closest(\"[data-action]\"); … a.dataset.action, a.closest(\".row\").dataset.id })`.",
        "api": [],
        "events": [
          {
            "name": "click on [data-action]",
            "detail": "action key + row `data-id`",
            "description": "A menu item, the quick action, a revealed swipe button, or a commit swipe (which clicks the menu item)."
          }
        ]
      },
      "a11y": [
        "The primary action is a real link; the overflow button has a specific label (\"Weitere Aktionen für Lea Müller\").",
        "Status and grade are text, not only colour.",
        "Menu keyboard support (arrows, Home/End, Esc) comes from the menu behaviour.",
        "Every swipe and quick action must also be in `actions` (render throws otherwise), so keyboard, switch-access and screen-reader users reach it without gestures.",
        "Vertical scrolling always wins over a swipe; swipes never start on buttons or form controls; with reduced motion the row does not animate."
      ],
      "guidance": {
        "do": [
          "Keep rows scannable: title, one meta line, one status.",
          "Sort by what the user needs next (e.g. oldest open registration first)."
        ],
        "dont": [
          "No inline buttons per row besides the menu, except one `quickAction` when it is the main job of the row (e.g. \"Fertig\").",
          "Never commit irreversible actions or CRM writes by swipe; use reveal mode.",
          "Don't put form controls inside reading lists (audit F2)."
        ]
      },
      "related": [
        "table",
        "menu",
        "belt",
        "badge",
        "avatar"
      ]
    },
    {
      "id": "progress",
      "name": "Progress bar",
      "group": "Data display",
      "status": "stable",
      "summary": "A labelled bar showing progress towards a known goal (\"Plan diese Woche: 1 von 3 Trainings\").",
      "description": [
        "Use a progress bar for determinate progress towards a goal: trainings this week, applicants checked, steps of an import. It always comes with a visible label above and a value text below — the bar alone says nothing exact.",
        "Rendered as the native `<progress>` element, labelled by the visible label via `aria-labelledby`. The value text defaults to \"{value} von {max}\"; pass `text` for units (\"1 von 3 Trainings\", \"CHF 340 von 500\").",
        "For indeterminate waiting use a spinner or skeleton; for sets of an exercise use Progress segments; for one percentage in a compact tile use the Progress ring."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "label",
          "value",
          "max"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Id prefix; the label gets `{id}-label` and the bar `{id}`. Must be unique on the page."
          },
          "label": {
            "type": "string",
            "minLength": 1,
            "description": "Visible label above the bar (\"Plan diese Woche\")."
          },
          "hideLabel": {
            "type": "boolean",
            "default": false,
            "description": "Hide the label visually (kept for screen readers) when a heading right above already names it."
          },
          "value": {
            "type": "number",
            "minimum": 0,
            "description": "Current value (≤ `max`)."
          },
          "max": {
            "type": "number",
            "minimum": 1,
            "description": "Goal value."
          },
          "text": {
            "type": "string",
            "description": "Visible value text under the bar; default \"{value} von {max}\". Include the unit (\"1 von 3 Trainings\")."
          }
        }
      },
      "examples": [
        {
          "title": "Weekly plan",
          "data": {
            "id": "plan-week",
            "label": "Plan diese Woche",
            "value": 1,
            "max": 3,
            "text": "1 von 3 Trainings"
          },
          "html": "<div class=\"field\"><span class=\"field__label\" id=\"plan-week-label\">Plan diese Woche</span><progress class=\"progress\" id=\"plan-week\" max=\"3\" value=\"1\" aria-labelledby=\"plan-week-label\">1 von 3 Trainings</progress><span class=\"u-subtle\">1 von 3 Trainings</span></div>"
        },
        {
          "title": "Admin task",
          "data": {
            "id": "applicants-checked",
            "label": "Bewerbungen geprüft",
            "value": 31,
            "max": 38
          },
          "html": "<div class=\"field\"><span class=\"field__label\" id=\"applicants-checked-label\">Bewerbungen geprüft</span><progress class=\"progress\" id=\"applicants-checked\" max=\"38\" value=\"31\" aria-labelledby=\"applicants-checked-label\">31 von 38</progress><span class=\"u-subtle\">31 von 38</span></div>"
        },
        {
          "title": "Label hidden",
          "data": {
            "id": "budget",
            "label": "KI-Budget Oktober",
            "hideLabel": true,
            "value": 1680,
            "max": 2000,
            "text": "1680 von 2000 Credits verbraucht"
          },
          "html": "<div class=\"field\"><span class=\"field__label u-visually-hidden\" id=\"budget-label\">KI-Budget Oktober</span><progress class=\"progress\" id=\"budget\" max=\"2000\" value=\"1680\" aria-labelledby=\"budget-label\">1680 von 2000 Credits verbraucht</progress><span class=\"u-subtle\">1680 von 2000 Credits verbraucht</span></div>"
        }
      ],
      "markup": {
        "root": ".progress",
        "element": "progress (inside div.field)",
        "attributes": [
          {
            "name": "value / max",
            "values": "numbers",
            "description": "Native progress values."
          },
          {
            "name": "aria-labelledby",
            "values": "\"{id}-label\"",
            "description": "Links the bar to its visible label."
          }
        ],
        "parts": [
          {
            "name": ".field",
            "description": "Vertical wrapper (label, bar, text)."
          },
          {
            "name": ".field__label",
            "description": "The label (`span`, id `{id}-label`)."
          },
          {
            "name": ".u-subtle",
            "description": "Visible value text."
          }
        ],
        "states": []
      },
      "cssVars": [],
      "a11y": [
        "Native `<progress>` exposes value and max; the visible label names it via `aria-labelledby`.",
        "The value is also visible as text, so it never depends on reading the bar length or colour.",
        "Fallback content inside `<progress>` repeats the value text for very old browsers."
      ],
      "guidance": {
        "do": [
          "Show progress towards the person's own goal (UX guide §13).",
          "Use a status fill only when it means status (over budget) — and say it in the text."
        ],
        "dont": [
          "Don't use a progress bar for unknown durations.",
          "Don't rank minors with bars against each other."
        ]
      },
      "related": [
        "segments",
        "ring",
        "stepper",
        "action-tile"
      ]
    },
    {
      "id": "ring",
      "name": "Progress ring",
      "group": "Data display",
      "status": "stable",
      "summary": "A compact circular indicator for one percentage (\"31 %\"), for tiles and list asides.",
      "description": [
        "Use the ring where a single percentage needs little space: monthly goal in an athlete tile, readiness in a list aside. The percentage is printed in the centre.",
        "The ring is one image for assistive tech with a label; default \"{value} Prozent erledigt\". Pass `label` to say what the percentage is about (\"Monatsziel: 31 Prozent erreicht\").",
        "Values are rounded to whole percent for display. For progress with absolute numbers (\"1 von 3\") prefer the progress bar or segments."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "value"
        ],
        "properties": {
          "value": {
            "type": "number",
            "minimum": 0,
            "maximum": 100,
            "description": "Percentage 0–100 (rounded for display)."
          },
          "label": {
            "type": "string",
            "description": "Accessible label; default \"{value} Prozent erledigt\"."
          },
          "size": {
            "type": "string",
            "enum": [
              "sm",
              "md",
              "lg"
            ],
            "default": "md",
            "description": "`sm` 40 px, `md` 56 px, `lg` 80 px (design guide §9.7), written as `data-size`."
          }
        }
      },
      "examples": [
        {
          "title": "Default",
          "data": {
            "value": 31
          },
          "html": "<div class=\"ring\" style=\"--value: 31\" role=\"img\" aria-label=\"31 Prozent erledigt\"><span>31%</span></div>"
        },
        {
          "title": "Large with context label",
          "data": {
            "value": 75,
            "size": "lg",
            "label": "Monatsziel: 75 Prozent erreicht"
          },
          "html": "<div class=\"ring\" data-size=\"lg\" style=\"--value: 75\" role=\"img\" aria-label=\"Monatsziel: 75 Prozent erreicht\"><span>75%</span></div>"
        }
      ],
      "markup": {
        "root": ".ring",
        "element": "div",
        "attributes": [
          {
            "name": "data-size",
            "values": "sm | lg",
            "default": "(none = md, 56 px)",
            "description": "Diameter: 40 / 80 px."
          },
          {
            "name": "style=\"--value: N\"",
            "values": "0–100",
            "description": "Fill amount (set by `render`; the only inline style)."
          },
          {
            "name": "role=\"img\" aria-label",
            "values": "text",
            "description": "Accessible label."
          }
        ],
        "parts": [
          {
            "name": "::before",
            "description": "The 4 px ring: `--color-surface-2` track, `--color-accent` arc (conic gradient, masked to a stroke so it works on any surface)."
          },
          {
            "name": "span",
            "description": "Centred percentage text (semibold, tabular figures)."
          }
        ],
        "states": []
      },
      "cssVars": [
        {
          "name": "--value",
          "default": "0",
          "description": "Percentage filled (conic gradient)."
        },
        {
          "name": "--ring-size",
          "default": "3.5rem",
          "description": "Diameter; set by `data-size`, can be overridden for special layouts."
        }
      ],
      "a11y": [
        "Announced once as an image with its label; the centre text is visible for everyone.",
        "The number is always printed — the arc alone is never the only information."
      ],
      "guidance": {
        "do": [
          "Say what the percentage is about in the label and nearby text.",
          "Use for the athlete's own goals."
        ],
        "dont": [
          "Don't place several rings side by side for comparison — use a table.",
          "Don't use status colours on the ring without a word explaining them."
        ]
      },
      "related": [
        "progress",
        "segments",
        "stat"
      ]
    },
    {
      "id": "segments",
      "name": "Progress segments",
      "group": "Data display",
      "status": "stable",
      "summary": "A short row of segments showing how many of a few discrete parts are done (\"2 von 4 Sätzen\").",
      "description": [
        "Use segments when progress is counted in a few discrete units the athlete recognises: sets of an exercise, sessions of the week. Up to 10 segments; beyond that use a progress bar.",
        "The whole strip is one image for assistive tech (`role=\"img\"`) with a label that states the numbers. By default the label is \"{done} von {total} {unit} erledigt\"; set `unit` (\"Sätzen\", \"Trainings\") or pass a complete `label`.",
        "Show the same information as visible text nearby (\"4×15 · 2 von 4\"), since the segments alone are hard to count at a glance."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "done",
          "total"
        ],
        "properties": {
          "done": {
            "type": "integer",
            "minimum": 0,
            "description": "Completed parts (≤ `total`)."
          },
          "total": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "description": "Number of parts (max 10)."
          },
          "unit": {
            "type": "string",
            "default": "Sätzen",
            "description": "Noun in the dative plural used in the default label (\"Sätzen\", \"Trainings\", \"Runden\")."
          },
          "label": {
            "type": "string",
            "description": "Full accessible label, overriding the default \"{done} von {total} {unit} erledigt\"."
          }
        }
      },
      "examples": [
        {
          "title": "Sets of an exercise",
          "data": {
            "done": 2,
            "total": 4
          },
          "html": "<div class=\"segments\" role=\"img\" aria-label=\"2 von 4 Sätzen erledigt\"><span data-done></span><span data-done></span><span></span><span></span></div>"
        },
        {
          "title": "Sessions this week",
          "data": {
            "done": 3,
            "total": 3,
            "unit": "Trainings"
          },
          "html": "<div class=\"segments\" role=\"img\" aria-label=\"3 von 3 Trainings erledigt\"><span data-done></span><span data-done></span><span data-done></span></div>"
        },
        {
          "title": "Custom label",
          "data": {
            "done": 1,
            "total": 5,
            "label": "Runde 2 von 5 läuft"
          },
          "html": "<div class=\"segments\" role=\"img\" aria-label=\"Runde 2 von 5 läuft\"><span data-done></span><span></span><span></span><span></span><span></span></div>"
        }
      ],
      "markup": {
        "root": ".segments",
        "element": "div",
        "attributes": [
          {
            "name": "role=\"img\" aria-label",
            "values": "text",
            "description": "One accessible image with the numbers."
          }
        ],
        "parts": [
          {
            "name": "span / span[data-done]",
            "description": "One 8 px pill per part: done ones `--color-accent`, open ones `--color-surface-2` (design guide §9.7)."
          }
        ],
        "states": [
          {
            "name": "span[data-done]",
            "description": "Completed part."
          }
        ]
      },
      "cssVars": [],
      "a11y": [
        "The strip is announced once with its label; individual segments are not separate items.",
        "Done segments differ by fill **and** border, not hue alone; the label carries the exact count."
      ],
      "guidance": {
        "do": [
          "Pair with visible text when the exact number matters.",
          "Use for the athlete's own progress."
        ],
        "dont": [
          "Don't use more than 10 segments.",
          "Don't use segments for percentages — use the progress ring or bar."
        ]
      },
      "related": [
        "progress",
        "ring",
        "streak"
      ]
    },
    {
      "id": "skeleton",
      "name": "Skeleton",
      "group": "Data display",
      "status": "stable",
      "summary": "Grey placeholder rows in the shape of a list while its content loads.",
      "description": [
        "Show a skeleton when loading takes longer than ~300 ms (show nothing before that to avoid flashes). It mimics the final layout — for lists: an optional avatar circle and one or two text lines per row — so the page doesn't jump when data arrives (UX guide §7.1).",
        "Skeletons replace one region (a list, a card body), never the whole app. The container is marked `aria-busy=\"true\"` and contains a visually hidden status text (\"Lädt …\"), so screen readers learn that content is coming.",
        "Visuals (design guide §9.3): `--color-surface-2` blocks, text lines at `--text-md` height with `--radius-sm`, avatars round (40 px, like the default avatar); a subtle opacity pulse based on `--duration-slow`, off under `prefers-reduced-motion`. Line widths are set with the `--skeleton-width` custom property (the only inline style allowed, §12.2).",
        "Replace the whole skeleton with the content (or an empty state / error) in one step; remove `aria-busy` with it."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [],
        "properties": {
          "rows": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "default": 3,
            "description": "Number of placeholder rows (match the expected count, max 10)."
          },
          "avatar": {
            "type": "boolean",
            "default": true,
            "description": "Show a round avatar placeholder at the start of each row."
          },
          "lines": {
            "type": "integer",
            "minimum": 1,
            "maximum": 2,
            "default": 2,
            "description": "Text lines per row (title + meta = 2)."
          },
          "label": {
            "type": "string",
            "minLength": 1,
            "default": "Lädt …",
            "description": "Visually hidden status text announced to screen readers (\"Mitglieder werden geladen …\")."
          }
        }
      },
      "examples": [
        {
          "title": "List with avatars",
          "data": {
            "rows": 3
          },
          "html": "<div class=\"stack\" aria-busy=\"true\"><span class=\"u-visually-hidden\" role=\"status\">Lädt …</span><div class=\"cluster\" aria-hidden=\"true\"><span class=\"skeleton\" data-shape=\"circle\"></span><div class=\"stack u-grow\" data-gap=\"sm\"><span class=\"skeleton\" style=\"--skeleton-width: 60%\"></span><span class=\"skeleton\" style=\"--skeleton-width: 35%\"></span></div></div><div class=\"cluster\" aria-hidden=\"true\"><span class=\"skeleton\" data-shape=\"circle\"></span><div class=\"stack u-grow\" data-gap=\"sm\"><span class=\"skeleton\" style=\"--skeleton-width: 50%\"></span><span class=\"skeleton\" style=\"--skeleton-width: 30%\"></span></div></div><div class=\"cluster\" aria-hidden=\"true\"><span class=\"skeleton\" data-shape=\"circle\"></span><div class=\"stack u-grow\" data-gap=\"sm\"><span class=\"skeleton\" style=\"--skeleton-width: 70%\"></span><span class=\"skeleton\" style=\"--skeleton-width: 40%\"></span></div></div></div>"
        },
        {
          "title": "Text-only rows",
          "data": {
            "rows": 4,
            "avatar": false,
            "lines": 1,
            "label": "Trainings werden geladen …"
          },
          "html": "<div class=\"stack\" aria-busy=\"true\"><span class=\"u-visually-hidden\" role=\"status\">Trainings werden geladen …</span><div class=\"cluster\" aria-hidden=\"true\"><div class=\"stack u-grow\" data-gap=\"sm\"><span class=\"skeleton\" style=\"--skeleton-width: 60%\"></span></div></div><div class=\"cluster\" aria-hidden=\"true\"><div class=\"stack u-grow\" data-gap=\"sm\"><span class=\"skeleton\" style=\"--skeleton-width: 50%\"></span></div></div><div class=\"cluster\" aria-hidden=\"true\"><div class=\"stack u-grow\" data-gap=\"sm\"><span class=\"skeleton\" style=\"--skeleton-width: 70%\"></span></div></div><div class=\"cluster\" aria-hidden=\"true\"><div class=\"stack u-grow\" data-gap=\"sm\"><span class=\"skeleton\" style=\"--skeleton-width: 45%\"></span></div></div></div>"
        }
      ],
      "markup": {
        "root": ".skeleton",
        "element": "span (inside div.stack[aria-busy])",
        "attributes": [
          {
            "name": "data-shape=\"circle\"",
            "values": "—",
            "description": "Round 40 px placeholder (avatar)."
          },
          {
            "name": "style=\"--skeleton-width: N%\"",
            "values": "length / percentage",
            "description": "Width of a text line (default 100%)."
          },
          {
            "name": "aria-busy=\"true\"",
            "values": "—",
            "description": "On the container: region is loading."
          }
        ],
        "parts": [
          {
            "name": ".stack[aria-busy]",
            "description": "Container replacing the region."
          },
          {
            "name": ".u-visually-hidden[role=\"status\"]",
            "description": "Announced loading text."
          },
          {
            "name": ".cluster",
            "description": "One placeholder row (hidden from assistive tech)."
          },
          {
            "name": ".skeleton",
            "description": "A `--color-surface-2` block at `--text-md` height with a subtle opacity pulse."
          }
        ],
        "states": [
          {
            "name": "@media (prefers-reduced-motion: reduce)",
            "description": "No pulse."
          }
        ]
      },
      "cssVars": [
        {
          "name": "--skeleton-width",
          "default": "100%",
          "description": "Width of a text-line placeholder."
        }
      ],
      "a11y": [
        "Placeholder rows are `aria-hidden`; only the status text is announced.",
        "The container has `aria-busy=\"true\"` while loading.",
        "The pulse is disabled for users who prefer reduced motion."
      ],
      "guidance": {
        "do": [
          "Match the final layout (avatar + two lines for member lists).",
          "Show skeletons per region so the rest of the page stays usable."
        ],
        "dont": [
          "Don't show a skeleton for fast loads (< 300 ms).",
          "Don't use a full-page spinner instead of skeletons.",
          "Don't leave a skeleton forever — after ~10 s show an error with \"Erneut versuchen\"."
        ]
      },
      "related": [
        "empty-state",
        "list"
      ]
    },
    {
      "id": "stat",
      "name": "Stat tile",
      "group": "Data display",
      "status": "stable",
      "summary": "One number with a label and a context line, usually leading to where you act on it (\"Offene Anmeldungen: 4\").",
      "description": [
        "Stat tiles answer a question the role actually asks, and ideally link to where it is resolved: \"Offene Anmeldungen 4 → list of open registrations\". A number for its own sake (\"Mitglieder: 312\") is decoration — leave it out (UX guide §4.2).",
        "`value` may be a number or a pre-formatted string. Numbers are formatted by `render` with `Intl.NumberFormat` in the given `locale` (default `de-CH`, e.g. 1240 → \"1’240\"). Pass a string when the value needs units, currency or rounding you control (\"CHF 1’240.–\", \"92 %\", \"3 von 5\").",
        "By default the tile is wrapped in a `.card` (a link card when `href` is set). Set `bare: true` to render only the `.stat` block, e.g. inside an existing card or list.",
        "The `meta` line gives context: a comparison (\"2 seit gestern\"), the next date (\"nächste Prüfung 14.11.\") or a rank (\"Silber · Platz 2\"). Write trends as words, not only arrows or colours."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "label",
          "value"
        ],
        "properties": {
          "label": {
            "type": "string",
            "minLength": 1,
            "description": "What is counted (\"Offene Anmeldungen\")."
          },
          "value": {
            "type": [
              "string",
              "number"
            ],
            "description": "The number. A number is formatted with `Intl.NumberFormat(locale)`; a string is shown as given (already formatted, may include units: \"CHF 1’240.–\")."
          },
          "meta": {
            "type": "string",
            "description": "Context line (\"2 seit gestern\", \"nächste Prüfung 14.11.\")."
          },
          "href": {
            "type": "string",
            "description": "Destination where the number is acted on; the whole tile becomes a link card."
          },
          "variant": {
            "type": "string",
            "enum": [
              "default",
              "accent",
              "subtle"
            ],
            "default": "default",
            "description": "Card variant. `accent` (accent-subtle tint, design guide §9.3) only for the single hero tile of a screen."
          },
          "bare": {
            "type": "boolean",
            "default": false,
            "description": "Render only the `.stat` block without the card wrapper (ignores `href` and `variant`)."
          },
          "locale": {
            "type": "string",
            "pattern": "^[a-z]{2}(-[A-Z]{2})?$",
            "default": "de-CH",
            "description": "Locale for formatting numeric values (`de-CH`, `fr-CH`, `it-CH`, `en-GB`)."
          }
        }
      },
      "examples": [
        {
          "title": "Link tile",
          "data": {
            "label": "Offene Anmeldungen",
            "value": 4,
            "meta": "2 seit gestern",
            "href": "#anmeldungen?status=offen"
          },
          "html": "<a class=\"card\" data-interactive href=\"#anmeldungen?status=offen\"><div class=\"stat\"><span class=\"stat__label\">Offene Anmeldungen</span><span class=\"stat__value\">4</span><span class=\"stat__meta\">2 seit gestern</span></div></a>"
        },
        {
          "title": "Static tile",
          "data": {
            "label": "Bereit für Prüfung",
            "value": 9,
            "meta": "nächste Prüfung 14.11."
          },
          "html": "<div class=\"card\"><div class=\"stat\"><span class=\"stat__label\">Bereit für Prüfung</span><span class=\"stat__value\">9</span><span class=\"stat__meta\">nächste Prüfung 14.11.</span></div></div>"
        },
        {
          "title": "Accent hero tile",
          "data": {
            "label": "Punkte diesen Monat",
            "value": 145,
            "meta": "Silber · Platz 2",
            "variant": "accent"
          },
          "html": "<div class=\"card\" data-variant=\"accent\"><div class=\"stat\"><span class=\"stat__label\">Punkte diesen Monat</span><span class=\"stat__value\">145</span><span class=\"stat__meta\">Silber · Platz 2</span></div></div>"
        },
        {
          "title": "Pre-formatted string",
          "data": {
            "label": "Offene Rechnungen",
            "value": "CHF 1’240.–",
            "meta": "7 Rechnungen, älteste vom 02.09.",
            "href": "#rechnungen?status=offen"
          },
          "html": "<a class=\"card\" data-interactive href=\"#rechnungen?status=offen\"><div class=\"stat\"><span class=\"stat__label\">Offene Rechnungen</span><span class=\"stat__value\">CHF 1’240.–</span><span class=\"stat__meta\">7 Rechnungen, älteste vom 02.09.</span></div></a>"
        },
        {
          "title": "Large number, formatted",
          "data": {
            "label": "Trainingsbesuche 2026",
            "value": 12480,
            "meta": "+8 % gegenüber 2025"
          },
          "html": "<div class=\"card\"><div class=\"stat\"><span class=\"stat__label\">Trainingsbesuche 2026</span><span class=\"stat__value\">12’480</span><span class=\"stat__meta\">+8 % gegenüber 2025</span></div></div>"
        }
      ],
      "markup": {
        "root": ".stat",
        "element": "div (inside div.card or a.card[data-interactive])",
        "attributes": [],
        "parts": [
          {
            "name": ".stat__label",
            "description": "What is counted (muted, small, medium weight; may start with a 16 px icon)."
          },
          {
            "name": ".stat__value",
            "description": "The number (3xl, bold, tabular figures)."
          },
          {
            "name": ".stat__meta",
            "description": "Context line."
          },
          {
            "name": ".card / .card[data-interactive]",
            "description": "Wrapper (unless `bare`)."
          }
        ],
        "states": [
          {
            "name": ".card[data-variant=\"accent\"] .stat__label/.stat__meta",
            "description": "Hero tile: accent-subtle background; label, value and meta use the accent-subtle text colour."
          }
        ]
      },
      "cssVars": [],
      "a11y": [
        "Label comes before the value in the DOM, so a screen reader reads \"Offene Anmeldungen, 4, 2 seit gestern\" — also the accessible name of a link tile.",
        "Tabular figures keep columns of tiles aligned; values never rely on colour (a red number needs a word like \"überfällig\")."
      ],
      "guidance": {
        "do": [
          "Link the tile to the filtered list that resolves it.",
          "Put tiles in a `.grid` with `--grid-min` ≈ 10–13rem.",
          "Format numbers with the club's locale (Swiss apostrophe \"1’240\")."
        ],
        "dont": [
          "Don't show vanity totals that lead nowhere.",
          "Don't colour whole tiles by status; use the meta line or a badge.",
          "Don't compare minors with each other in tiles (UX guide §13)."
        ]
      },
      "related": [
        "card",
        "progress",
        "ring"
      ]
    },
    {
      "id": "badge",
      "name": "Status badge",
      "group": "Data display",
      "status": "stable",
      "summary": "Shows the status of one thing (\"Wartet auf Freigabe\", \"Bezahlt\") as a small tinted pill with a status icon and text.",
      "description": [
        "A badge has exactly one meaning: the current status of the thing it sits next to (a registration, an invoice, a training). It is not a button, not a filter and not a counter — use a chip for things you press and a count bubble for unread numbers.",
        "The tone maps a status to a colour family and icon (design guide §9.3): `success` done/paid (check-circle), `warning` needs attention or is waiting (alert-triangle), `danger` failed or overdue (alert-circle), `info` planned, new or neutral information (info), `neutral` draft/archived/inactive (grey, no icon). It is written to the `data-status` attribute. The text always carries the meaning; icon and colour only support it. The club accent is never a status colour (§2.4), so there is no accent badge.",
        "Keep the label short (one to three words) and use the same wording for the same status everywhere (glossary, UX guide §9.3)."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "label"
        ],
        "properties": {
          "label": {
            "type": "string",
            "minLength": 1,
            "description": "Status text, e.g. \"Wartet auf Freigabe\", \"Bezahlt\", \"Fehler in Webling\"."
          },
          "tone": {
            "type": "string",
            "enum": [
              "neutral",
              "info",
              "success",
              "warning",
              "danger"
            ],
            "default": "neutral",
            "description": "Status, rendered as `data-status` + leading icon. `neutral` (draft, inactive; no icon), `info` (planned, new), `success` (done, paid), `warning` (waiting, needs attention), `danger` (failed, overdue)."
          },
          "title": {
            "type": "string",
            "description": "Optional tooltip with more detail (\"seit 3 Tagen offen\"). Not announced reliably; never put essential information only here."
          }
        }
      },
      "examples": [
        {
          "title": "Waiting for approval",
          "data": {
            "label": "Wartet auf Freigabe",
            "tone": "warning"
          },
          "html": "<span class=\"badge\" data-status=\"warning\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-triangle\"/></svg>Wartet auf Freigabe</span>"
        },
        {
          "title": "Done",
          "data": {
            "label": "Bezahlt",
            "tone": "success"
          },
          "html": "<span class=\"badge\" data-status=\"success\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check-circle\"/></svg>Bezahlt</span>"
        },
        {
          "title": "Failed",
          "data": {
            "label": "Fehler in Webling",
            "tone": "danger",
            "title": "Die Übertragung schlug am 01.10. fehl."
          },
          "html": "<span class=\"badge\" data-status=\"danger\" title=\"Die Übertragung schlug am 01.10. fehl.\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-circle\"/></svg>Fehler in Webling</span>"
        },
        {
          "title": "Neutral (draft)",
          "data": {
            "label": "Entwurf"
          },
          "html": "<span class=\"badge\">Entwurf</span>"
        },
        {
          "title": "New / planned",
          "data": {
            "label": "Neu",
            "tone": "info"
          },
          "html": "<span class=\"badge\" data-status=\"info\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#info\"/></svg>Neu</span>"
        }
      ],
      "markup": {
        "root": ".badge",
        "element": "span",
        "attributes": [
          {
            "name": "data-status",
            "values": "info | success | warning | danger",
            "default": "(none = neutral)",
            "description": "Status: subtle background + status text colour."
          },
          {
            "name": "title",
            "values": "text",
            "description": "Optional tooltip."
          }
        ],
        "parts": [
          {
            "name": "svg.icon[data-size=\"sm\"]",
            "description": "Leading status icon (16 px, decorative): info · check-circle · alert-triangle · alert-circle. None for neutral."
          }
        ],
        "states": []
      },
      "cssVars": [],
      "a11y": [
        "The status is real text; colour and icon are decorative support (design guide §3.5 \"never colour alone\"). The icon is `aria-hidden`.",
        "Every tone pairs a `*-subtle` background with its `*-text` token, which are verified for 4.5:1 contrast in light and dark themes.",
        "If the status changes while the user watches (e.g. after saving), announce it with the save status or a live region — the badge itself is not live."
      ],
      "guidance": {
        "do": [
          "Use one fixed vocabulary per status across all apps (\"Wartet auf Freigabe\", not \"Pending\" here and \"Offen\" there).",
          "Place the badge in the row aside, the card header or a table \"Status\" column."
        ],
        "dont": [
          "Don't make badges clickable — use a chip or a link.",
          "Don't use badges for counts (\"3\") — use the count bubble.",
          "Don't show raw codes (\"ERR_422\") as badge text."
        ]
      },
      "related": [
        "count",
        "chip-group",
        "list",
        "table",
        "card"
      ]
    },
    {
      "id": "table",
      "name": "Table",
      "group": "Data display",
      "status": "stable",
      "summary": "Compares many items across several columns (desktop admin); collapses to one card per row on phones.",
      "description": [
        "Use a table when comparing or sorting across attributes is the task (payments, test applicants, imports). When people act on one item at a time, especially on phones, use a list instead (UX guide §12.1). Keep to ~7 columns.",
        "The table lives in a scrollable, keyboard-focusable `.table-wrap` region named after the `caption`. Numeric columns (`numeric: true`) are right-aligned with tabular figures; numbers are formatted with `Intl.NumberFormat(locale)` (default `de-CH`). Pass pre-formatted strings for currency (\"CHF 60.00\").",
        "With `stack` (default) each row becomes a card below 768 px: the `rowHeaderKey` column becomes the card title and every cell shows its column label (from `data-label`). With `rowHeaderKey` the first cell of each row is a `<th scope=\"row\">`; add `href` to a row to make that cell a link (the one primary action of the row).",
        "Cells are plain text or numbers. Two structured cell types are supported because they are the same everywhere: `{ belt: {…} }` renders a Belt badge and `{ badge: {…} }` a Status badge. Anything richer (menus, inline buttons, avatars) is composed by the app with other components — tables never accept HTML strings."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "caption",
          "columns",
          "rows"
        ],
        "properties": {
          "caption": {
            "type": "string",
            "minLength": 1,
            "description": "Table caption, also the accessible name of the scroll region (\"Prüfungsgebühren November\")."
          },
          "captionHidden": {
            "type": "boolean",
            "default": false,
            "description": "Hide the caption visually (when a heading right above already says the same); it stays available to screen readers."
          },
          "columns": {
            "type": "array",
            "minItems": 1,
            "maxItems": 10,
            "description": "Columns in display order.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "key",
                "label"
              ],
              "properties": {
                "key": {
                  "type": "string",
                  "pattern": "^[A-Za-z][\\w-]*$",
                  "description": "Key into each row's `cells`."
                },
                "label": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Column header text; also the `data-label` shown in stacked mode."
                },
                "numeric": {
                  "type": "boolean",
                  "default": false,
                  "description": "Right-aligned, tabular figures (`data-numeric`)."
                }
              }
            }
          },
          "rows": {
            "type": "array",
            "description": "Rows in display order (already sorted by the app). For zero rows show an Empty state instead of the table.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "cells"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "pattern": "^[A-Za-z0-9_-]+$",
                  "description": "Stable id of the row's object; written to `data-id` for delegated event handling."
                },
                "href": {
                  "type": "string",
                  "description": "Detail page of the row; makes the row-header cell a link. Requires `rowHeaderKey`."
                },
                "selected": {
                  "type": "boolean",
                  "default": false,
                  "description": "The row is the selected/current one (e.g. shown in a detail pane next to the table): `aria-current=\"true\"`, accent-subtle background."
                },
                "cells": {
                  "type": "object",
                  "description": "Map of column key → value: a string, a number, `{ belt: Belt data }` or `{ badge: Badge data }`. Missing keys render an empty cell."
                }
              }
            }
          },
          "rowHeaderKey": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Column whose cells name the row (`<th scope=\"row\">`), usually the person or object name. Becomes the card title in stacked mode."
          },
          "stack": {
            "type": "boolean",
            "default": true,
            "description": "Below 768 px, show each row as a card with labelled cells (`data-stack`). Turn off only for narrow tables that fit a phone."
          },
          "density": {
            "type": "string",
            "enum": [
              "default",
              "dense"
            ],
            "default": "default",
            "description": "`dense`: rows `--control-h-sm` (36 px), smaller padding and `--text-sm` — applied from 1024 px only (design guide §5.2); desktop data tables only, ideally as a user choice. Never on phones."
          },
          "locale": {
            "type": "string",
            "pattern": "^[a-z]{2}(-[A-Z]{2})?$",
            "default": "de-CH",
            "description": "Locale for formatting numeric cell values."
          }
        }
      },
      "examples": [
        {
          "title": "Payments with belt and status (stacks on mobile)",
          "data": {
            "caption": "Prüfungsgebühren November",
            "rowHeaderKey": "name",
            "columns": [
              {
                "key": "name",
                "label": "Mitglied"
              },
              {
                "key": "belt",
                "label": "Gurt"
              },
              {
                "key": "amount",
                "label": "Betrag",
                "numeric": true
              },
              {
                "key": "status",
                "label": "Status"
              }
            ],
            "rows": [
              {
                "id": "m-1042",
                "href": "#m-1042",
                "cells": {
                  "name": "Lea Müller",
                  "belt": {
                    "belt": {
                      "label": "5. Kup",
                      "color": "green",
                      "tip": "blue"
                    }
                  },
                  "amount": "CHF 60.00",
                  "status": {
                    "badge": {
                      "label": "Bezahlt",
                      "tone": "success"
                    }
                  }
                }
              },
              {
                "id": "m-1077",
                "href": "#m-1077",
                "selected": true,
                "cells": {
                  "name": "Noah Keller",
                  "belt": {
                    "belt": {
                      "label": "3. Kup",
                      "color": "blue"
                    }
                  },
                  "amount": "CHF 60.00",
                  "status": {
                    "badge": {
                      "label": "Offen",
                      "tone": "warning"
                    }
                  }
                }
              },
              {
                "id": "m-1103",
                "href": "#m-1103",
                "cells": {
                  "name": "Mia Brunner",
                  "belt": {
                    "belt": {
                      "label": "8. Kup",
                      "color": "yellow"
                    }
                  },
                  "amount": "CHF 45.00",
                  "status": {
                    "badge": {
                      "label": "Fehler in Webling",
                      "tone": "danger"
                    }
                  }
                }
              }
            ]
          },
          "html": "<div class=\"table-wrap\" role=\"region\" tabindex=\"0\" aria-label=\"Prüfungsgebühren November\">\n  <table class=\"table\" data-stack>\n    <caption>Prüfungsgebühren November</caption>\n    <thead><tr><th scope=\"col\">Mitglied</th><th scope=\"col\">Gurt</th><th scope=\"col\" data-numeric>Betrag</th><th scope=\"col\">Status</th></tr></thead>\n    <tbody><tr data-id=\"m-1042\"><th scope=\"row\" data-label=\"Mitglied\"><a href=\"#m-1042\">Lea Müller</a></th><td data-label=\"Gurt\"><span class=\"belt\" style=\"--belt-color: var(--belt-green); --belt-tip: var(--belt-blue)\">5. Kup</span></td><td data-label=\"Betrag\" data-numeric>CHF 60.00</td><td data-label=\"Status\"><span class=\"badge\" data-status=\"success\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check-circle\"/></svg>Bezahlt</span></td></tr><tr data-id=\"m-1077\" aria-current=\"true\"><th scope=\"row\" data-label=\"Mitglied\"><a href=\"#m-1077\">Noah Keller</a></th><td data-label=\"Gurt\"><span class=\"belt\" style=\"--belt-color: var(--belt-blue)\">3. Kup</span></td><td data-label=\"Betrag\" data-numeric>CHF 60.00</td><td data-label=\"Status\"><span class=\"badge\" data-status=\"warning\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-triangle\"/></svg>Offen</span></td></tr><tr data-id=\"m-1103\"><th scope=\"row\" data-label=\"Mitglied\"><a href=\"#m-1103\">Mia Brunner</a></th><td data-label=\"Gurt\"><span class=\"belt\" style=\"--belt-color: var(--belt-yellow)\">8. Kup</span></td><td data-label=\"Betrag\" data-numeric>CHF 45.00</td><td data-label=\"Status\"><span class=\"badge\" data-status=\"danger\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-circle\"/></svg>Fehler in Webling</span></td></tr></tbody>\n  </table>\n</div>"
        },
        {
          "title": "Dense numbers table (desktop)",
          "data": {
            "caption": "Trainingsbesuche pro Gruppe, September",
            "rowHeaderKey": "group",
            "density": "dense",
            "columns": [
              {
                "key": "group",
                "label": "Gruppe"
              },
              {
                "key": "sessions",
                "label": "Trainings",
                "numeric": true
              },
              {
                "key": "visits",
                "label": "Besuche",
                "numeric": true
              },
              {
                "key": "avg",
                "label": "Ø pro Training",
                "numeric": true
              }
            ],
            "rows": [
              {
                "cells": {
                  "group": "Tigers (4–6)",
                  "sessions": 8,
                  "visits": 96,
                  "avg": 12
                }
              },
              {
                "cells": {
                  "group": "Kinder 7–10",
                  "sessions": 12,
                  "visits": 1340,
                  "avg": 111.7
                }
              },
              {
                "cells": {
                  "group": "Jugend 11–15",
                  "sessions": 12,
                  "visits": 204,
                  "avg": 17
                }
              }
            ]
          },
          "html": "<div class=\"table-wrap\" role=\"region\" tabindex=\"0\" aria-label=\"Trainingsbesuche pro Gruppe, September\">\n  <table class=\"table\" data-stack data-density=\"dense\">\n    <caption>Trainingsbesuche pro Gruppe, September</caption>\n    <thead><tr><th scope=\"col\">Gruppe</th><th scope=\"col\" data-numeric>Trainings</th><th scope=\"col\" data-numeric>Besuche</th><th scope=\"col\" data-numeric>Ø pro Training</th></tr></thead>\n    <tbody><tr><th scope=\"row\" data-label=\"Gruppe\">Tigers (4–6)</th><td data-label=\"Trainings\" data-numeric>8</td><td data-label=\"Besuche\" data-numeric>96</td><td data-label=\"Ø pro Training\" data-numeric>12</td></tr><tr><th scope=\"row\" data-label=\"Gruppe\">Kinder 7–10</th><td data-label=\"Trainings\" data-numeric>12</td><td data-label=\"Besuche\" data-numeric>1’340</td><td data-label=\"Ø pro Training\" data-numeric>111.7</td></tr><tr><th scope=\"row\" data-label=\"Gruppe\">Jugend 11–15</th><td data-label=\"Trainings\" data-numeric>12</td><td data-label=\"Besuche\" data-numeric>204</td><td data-label=\"Ø pro Training\" data-numeric>17</td></tr></tbody>\n  </table>\n</div>"
        }
      ],
      "markup": {
        "root": ".table-wrap",
        "element": "div > table",
        "attributes": [
          {
            "name": "role=\"region\" tabindex=\"0\" aria-label",
            "values": "caption text",
            "description": "On `.table-wrap`: named, keyboard-scrollable region."
          },
          {
            "name": "data-stack",
            "values": "—",
            "description": "On `.table`: rows become cards below 768 px."
          },
          {
            "name": "data-density",
            "values": "dense",
            "description": "On `.table`: 36 px rows, less padding, small text — from 1024 px only."
          },
          {
            "name": "aria-current=\"true\"",
            "values": "—",
            "description": "On `tr`: the selected row (from `selected`). `[aria-selected=\"true\"]` is styled the same for `role=\"grid\"` tables composed by apps."
          },
          {
            "name": "data-numeric",
            "values": "—",
            "description": "On `th`/`td`: right-aligned tabular figures."
          },
          {
            "name": "data-label",
            "values": "column label",
            "description": "On every body cell: label shown in stacked mode."
          },
          {
            "name": "data-id",
            "values": "row id",
            "description": "On `tr`: id of the row's object."
          }
        ],
        "parts": [
          {
            "name": ".table",
            "description": "The `<table>`."
          },
          {
            "name": "caption",
            "description": "Title of the table (optionally `.u-visually-hidden`)."
          },
          {
            "name": "thead th[scope=\"col\"]",
            "description": "Column headers."
          },
          {
            "name": "tbody th[scope=\"row\"]",
            "description": "Row header cell (from `rowHeaderKey`), optionally containing the row link."
          },
          {
            "name": ".belt / .badge",
            "description": "Structured cells (see Belt badge, Status badge)."
          }
        ],
        "states": [
          {
            "name": "tbody tr:hover",
            "description": "Row highlight (`--color-surface-2`, pointer devices only)."
          },
          {
            "name": "tbody tr[aria-current=\"true\"]",
            "description": "Selected row: `--color-accent-subtle` background, accent-subtle text and a 3 px accent bar at the start."
          },
          {
            "name": ".table:not([data-stack]) th[scope=\"row\"]",
            "description": "Below 768 px the row-header column stays sticky while the table scrolls horizontally."
          },
          {
            "name": "th[aria-sort]",
            "description": "Sortable column header with a button (app-provided; not rendered by `render`)."
          }
        ]
      },
      "cssVars": [],
      "a11y": [
        "Real table semantics: `caption`, `th scope=\"col\"` and `th scope=\"row\"`, so screen readers announce header and row name for each cell.",
        "The scroll wrapper is a named region with `tabindex=\"0\"` so keyboard users can scroll wide tables.",
        "In stacked mode the header row is visually hidden but still in the accessibility tree; `data-label` is a visual aid only.",
        "One link per row at most (the row header); further actions go into a menu composed by the app."
      ],
      "guidance": {
        "do": [
          "Sort rows by what the admin needs next and show the sort in the UI.",
          "Show the result count near the table (\"18 von 312 Mitgliedern\").",
          "Right-align amounts and use the same currency format in every row."
        ],
        "dont": [
          "Don't use a table for phone-first, act-on-one-item screens — use a list.",
          "Don't put inline button toolboxes in rows.",
          "Don't truncate silently: never cap results without \"Mehr laden\"."
        ]
      },
      "related": [
        "list",
        "belt",
        "badge",
        "empty-state",
        "chip-group"
      ]
    },
    {
      "id": "alert",
      "name": "Alert / banner",
      "group": "Feedback",
      "status": "stable",
      "summary": "A message box that is part of the page and stays: information, success, a warning or an error, with an optional next step.",
      "description": [
        "Use alerts for things that remain true while the page is open: \"Anmeldung offen bis 31. Oktober\", \"3 Bewerbende ohne Gebühr\", \"Webling ist gerade nicht erreichbar\". For short confirmations of what just happened use a toast; for field errors use the form field and error summary.",
        "The tone sets colour and icon and is written as `data-status`: `info` (default), `neutral`, `success`, `warning`, `danger`. Look (design guide §9.4): subtle status background, a 4 px status-fill edge on the start side, status icon (20 px) and title in the status text colour, body in `--color-text`. The title says what is going on in one line; the body says what it means and what to do. Offer the next step as a link or one action button.",
        "Page-level banners (offline, lock, impersonation) sit directly below the top bar, full width and without radius: add `data-banner` to the element (page code; `render` produces the in-page form). The connection banner is its own component (Offline banner).",
        "Errors follow UX guide §7.3: a human message, what happened, what to do — never raw HTTP codes or provider messages. Show the trace id small and selectable (`traceId`) so support can find the case.",
        "`role=\"status\"` (default) is announced politely; use `role: \"alert\"` only for urgent errors that appear after a user action. Alerts present when the page loads are not announced either way — they are read in page order."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "title"
        ],
        "properties": {
          "tone": {
            "type": "string",
            "enum": [
              "info",
              "neutral",
              "success",
              "warning",
              "danger"
            ],
            "default": "info",
            "description": "Meaning; sets colour and icon."
          },
          "title": {
            "type": "string",
            "minLength": 1,
            "description": "One-line summary (\"Webling ist gerade nicht erreichbar\")."
          },
          "body": {
            "type": "string",
            "description": "What it means and what to do, one or two sentences."
          },
          "link": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "href",
              "label"
            ],
            "description": "Optional link to the next step, appended to the body.",
            "properties": {
              "href": {
                "type": "string",
                "description": "Link target URL."
              },
              "label": {
                "type": "string",
                "minLength": 1,
                "description": "Link text naming the step (\"Gebühren festlegen\")."
              }
            }
          },
          "action": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "action",
              "label"
            ],
            "description": "Optional button for an in-place action (e.g. \"Erneut versuchen\"); the page handles clicks via `data-action`.",
            "properties": {
              "action": {
                "type": "string",
                "pattern": "^[a-z][a-z0-9-]*$",
                "description": "Action key written to `data-action` (\"retry\")."
              },
              "label": {
                "type": "string",
                "minLength": 1,
                "description": "Button text, a verb (\"Erneut versuchen\")."
              }
            }
          },
          "traceId": {
            "type": "string",
            "description": "Support/trace id for errors, shown small as \"Fehler-ID: …\"."
          },
          "role": {
            "type": "string",
            "enum": [
              "status",
              "alert"
            ],
            "default": "status",
            "description": "Live-region role: `alert` only for urgent errors after a user action."
          }
        }
      },
      "examples": [
        {
          "title": "Info",
          "data": {
            "title": "Anmeldung offen bis 31. Oktober",
            "body": "Danach erstellt das System die Prüfungsgruppen automatisch."
          },
          "html": "<div class=\"alert\" role=\"status\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#info\"/></svg><div class=\"alert__body\">\n  <p class=\"alert__title\">Anmeldung offen bis 31. Oktober</p>\n  <p>Danach erstellt das System die Prüfungsgruppen automatisch.</p>\n  \n  \n</div></div>"
        },
        {
          "title": "Success",
          "data": {
            "tone": "success",
            "title": "12 Gurtgrade in Webling gespeichert"
          },
          "html": "<div class=\"alert\" data-status=\"success\" role=\"status\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check-circle\"/></svg><div class=\"alert__body\">\n  <p class=\"alert__title\">12 Gurtgrade in Webling gespeichert</p>\n  \n  \n  \n</div></div>"
        },
        {
          "title": "Warning with link",
          "data": {
            "tone": "warning",
            "title": "3 Bewerbende ohne Gebühr",
            "body": "Ohne Gebühr können keine Rechnungen erstellt werden.",
            "link": {
              "href": "/belt-tests/2026-11/fees",
              "label": "Gebühren festlegen"
            }
          },
          "html": "<div class=\"alert\" data-status=\"warning\" role=\"status\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-triangle\"/></svg><div class=\"alert__body\">\n  <p class=\"alert__title\">3 Bewerbende ohne Gebühr</p>\n  <p>Ohne Gebühr können keine Rechnungen erstellt werden. <a href=\"/belt-tests/2026-11/fees\">Gebühren festlegen</a></p>\n  \n  \n</div></div>"
        },
        {
          "title": "Error with retry and trace id",
          "data": {
            "tone": "danger",
            "role": "alert",
            "title": "Webling ist gerade nicht erreichbar",
            "body": "Deine Änderungen sind gespeichert und werden übertragen, sobald Webling wieder antwortet.",
            "action": {
              "action": "retry",
              "label": "Erneut versuchen"
            },
            "traceId": "tr-8f2a"
          },
          "html": "<div class=\"alert\" data-status=\"danger\" role=\"alert\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-circle\"/></svg><div class=\"alert__body\">\n  <p class=\"alert__title\">Webling ist gerade nicht erreichbar</p>\n  <p>Deine Änderungen sind gespeichert und werden übertragen, sobald Webling wieder antwortet.</p>\n  <p class=\"u-small\">Fehler-ID: <code>tr-8f2a</code></p>\n  <div class=\"button-row\" data-align=\"start\"><button class=\"button\" type=\"button\" data-size=\"sm\" data-action=\"retry\">Erneut versuchen</button></div>\n</div></div>"
        },
        {
          "title": "Neutral note",
          "data": {
            "tone": "neutral",
            "title": "Dieses Training wurde aus der Vorlage «Kinder Dienstag» erstellt."
          },
          "html": "<div class=\"alert\" data-status=\"neutral\" role=\"status\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#info\"/></svg><div class=\"alert__body\">\n  <p class=\"alert__title\">Dieses Training wurde aus der Vorlage «Kinder Dienstag» erstellt.</p>\n  \n  \n  \n</div></div>"
        }
      ],
      "markup": {
        "root": ".alert",
        "element": "div",
        "attributes": [
          {
            "name": "data-status",
            "values": "neutral | success | warning | danger",
            "default": "(none = info)",
            "description": "Status: background, edge, icon and title colour."
          },
          {
            "name": "data-banner",
            "values": "—",
            "description": "Page-level banner below the top bar: no radius (set by page code)."
          },
          {
            "name": "role",
            "values": "status | alert",
            "description": "Live-region politeness."
          }
        ],
        "parts": [
          {
            "name": ".alert > svg.icon",
            "description": "Status icon, 20 px, in the status text colour (decorative): info · check-circle · alert-triangle · alert-circle."
          },
          {
            "name": ".alert__body",
            "description": "Text column."
          },
          {
            "name": ".alert__title",
            "description": "Semibold first line in the status text colour."
          },
          {
            "name": ".alert__body > p",
            "description": "Body text in `--color-text`; links inherit it."
          },
          {
            "name": ".button-row",
            "description": "Optional action button."
          }
        ],
        "states": []
      },
      "cssVars": [],
      "behaviour": {
        "module": "(page code)",
        "setup": "No built-in behaviour. Handle the optional action with a delegated listener on `[data-action]`.",
        "api": [],
        "events": [
          {
            "name": "click on [data-action]",
            "detail": "event.target.closest(\"[data-action]\").dataset.action",
            "description": "The action button was pressed."
          }
        ]
      },
      "a11y": [
        "Meaning is carried by the title text, not only by colour or icon.",
        "`role=\"status\"` for most alerts; `role=\"alert\"` only for urgent errors inserted after an action.",
        "The trace id is real text, selectable for copying."
      ],
      "guidance": {
        "do": [
          "Say what to do next and offer it as a link or button.",
          "Use a calm warning — not an error — for offline and sync delays (UX guide §7.6)."
        ],
        "dont": [
          "Don't stack several alerts; combine them or show the most important one.",
          "Don't show raw error codes, JSON or provider messages."
        ]
      },
      "related": [
        "toast",
        "offline-banner",
        "error-summary",
        "empty-state"
      ]
    },
    {
      "id": "offline-banner",
      "name": "Offline banner",
      "group": "Feedback",
      "status": "stable",
      "summary": "A calm full-width strip that says the device is offline and that changes are kept.",
      "description": [
        "The app shell shows it at the top of the content while `navigator.onLine` is false, and hides it again on the `online` event. Offline is never an error dialog (UX guide §7.6): people keep working, writes go into the outbox and the save status shows what is waiting.",
        "Look (design guide §9.4): the `info` status — `--color-info-subtle` background, `--color-info-text`, `wifi-off` icon — full width below the top bar, no radius.",
        "The message reassures and says what happens next. Mention the number of waiting changes only in the save status, not here.",
        "Render it once in the shell with `hidden: true` and toggle the `hidden` attribute, so the live region already exists when it becomes visible and the change is announced."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [],
        "properties": {
          "message": {
            "type": "string",
            "minLength": 1,
            "default": "Offline – Änderungen werden gespeichert und später übertragen",
            "description": "Banner text."
          },
          "hidden": {
            "type": "boolean",
            "default": false,
            "description": "Render with the `hidden` attribute (shell toggles it on online/offline)."
          }
        }
      },
      "examples": [
        {
          "title": "Default",
          "data": {},
          "html": "<div class=\"offline-banner\" role=\"status\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#wifi-off\"/></svg>Offline – Änderungen werden gespeichert und später übertragen</div>"
        },
        {
          "title": "Read-only screen",
          "data": {
            "message": "Offline – du siehst den Stand von heute 07:42"
          },
          "html": "<div class=\"offline-banner\" role=\"status\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#wifi-off\"/></svg>Offline – du siehst den Stand von heute 07:42</div>"
        },
        {
          "title": "Prepared in the shell (hidden)",
          "data": {
            "hidden": true
          },
          "html": "<div class=\"offline-banner\" role=\"status\" hidden><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#wifi-off\"/></svg>Offline – Änderungen werden gespeichert und später übertragen</div>"
        }
      ],
      "markup": {
        "root": ".offline-banner",
        "element": "div",
        "attributes": [
          {
            "name": "role=\"status\"",
            "values": "—",
            "description": "Polite announcement when it appears."
          },
          {
            "name": "hidden",
            "values": "—",
            "description": "Not shown while online."
          }
        ],
        "parts": [
          {
            "name": "svg.icon",
            "description": "Small `wifi-off` icon (decorative)."
          }
        ],
        "states": []
      },
      "cssVars": [],
      "behaviour": {
        "module": "(app shell)",
        "setup": "No built-in behaviour. `addEventListener(\"offline\", () => banner.hidden = false)` and `(\"online\", () => banner.hidden = true)`; set the initial state from `navigator.onLine`.",
        "api": [],
        "events": []
      },
      "a11y": [
        "`role=\"status\"`: appearing is announced politely, without stealing focus.",
        "Warning colours plus text and icon — not colour alone."
      ],
      "guidance": {
        "do": [
          "Keep the app usable offline and say so.",
          "Pair it with the save status for counts and failures."
        ],
        "dont": [
          "Don't block the screen or show a dialog when the connection drops.",
          "Don't use alarming words (\"Fehler\", \"Verbindung verloren!\")."
        ]
      },
      "related": [
        "save-status",
        "alert",
        "app-shell"
      ]
    },
    {
      "id": "save-status",
      "name": "Save status",
      "group": "Feedback",
      "status": "stable",
      "summary": "A small live indicator next to editable content: saved, saving, waiting to sync, or failed.",
      "description": [
        "The feedback for safe actions that save immediately (UX guide §5.1): attendance, stripes, notes, settings, drafts. Instead of a toast per change, one quiet status near the content or in the page header tells the truth about saving.",
        "States follow the offline outbox (UX guide §7.6): `saving` while a request runs, `saved` when the server confirmed (may fade after 2 s), `pending` when changes are queued offline (\"3 Änderungen warten\"), `error` when something needs a person — then place an action next to it that opens the failed items (\"Erneut senden\" / \"Verwerfen\").",
        "Look (design guide §9.4): `--text-sm` text with a 16 px icon — \"Gespeichert\" with `check` in `--color-text-muted`, \"Speichert …\" with a spinner, waiting offline with `wifi-off` in `--color-warning-text`, \"Nicht gespeichert\" with `alert-circle` in `--color-danger-text`. All three icons are rendered; CSS shows the one that matches `data-state`.",
        "Update `data-state` and the text of `.save-status__text` in the same element (don't replace the whole content — the icons live there too); it is a polite live region, so changes are announced without moving focus."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "state"
        ],
        "properties": {
          "state": {
            "type": "string",
            "enum": [
              "saved",
              "saving",
              "pending",
              "error"
            ],
            "description": "Current save state; sets colour and the icon/spinner."
          },
          "count": {
            "type": "integer",
            "minimum": 1,
            "description": "Number of queued changes (state `pending`): \"3 Änderungen warten\"."
          },
          "label": {
            "type": "string",
            "minLength": 1,
            "description": "Custom text, e.g. \"Entwurf gespeichert\". Defaults: \"Gespeichert\", \"Speichert …\", \"Wartet auf Verbindung\" / \"{n} Änderungen warten\", \"Nicht gespeichert\"."
          },
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Id so page code can find and update the element."
          }
        }
      },
      "examples": [
        {
          "title": "Saved",
          "data": {
            "state": "saved"
          },
          "html": "<span class=\"save-status\" data-state=\"saved\" role=\"status\"><span class=\"save-status__icon\" data-for=\"saved\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span><span class=\"save-status__icon\" data-for=\"pending\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#wifi-off\"/></svg></span><span class=\"save-status__icon\" data-for=\"error\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-circle\"/></svg></span><span class=\"save-status__text\">Gespeichert</span></span>"
        },
        {
          "title": "Saving",
          "data": {
            "state": "saving"
          },
          "html": "<span class=\"save-status\" data-state=\"saving\" role=\"status\"><span class=\"save-status__icon\" data-for=\"saved\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span><span class=\"save-status__icon\" data-for=\"pending\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#wifi-off\"/></svg></span><span class=\"save-status__icon\" data-for=\"error\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-circle\"/></svg></span><span class=\"save-status__text\">Speichert …</span></span>"
        },
        {
          "title": "Queued offline",
          "data": {
            "state": "pending",
            "count": 3,
            "id": "attendance-status"
          },
          "html": "<span class=\"save-status\" id=\"attendance-status\" data-state=\"pending\" role=\"status\"><span class=\"save-status__icon\" data-for=\"saved\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span><span class=\"save-status__icon\" data-for=\"pending\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#wifi-off\"/></svg></span><span class=\"save-status__icon\" data-for=\"error\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-circle\"/></svg></span><span class=\"save-status__text\">3 Änderungen warten</span></span>"
        },
        {
          "title": "Failed",
          "data": {
            "state": "error"
          },
          "html": "<span class=\"save-status\" data-state=\"error\" role=\"status\"><span class=\"save-status__icon\" data-for=\"saved\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span><span class=\"save-status__icon\" data-for=\"pending\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#wifi-off\"/></svg></span><span class=\"save-status__icon\" data-for=\"error\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-circle\"/></svg></span><span class=\"save-status__text\">Nicht gespeichert</span></span>"
        },
        {
          "title": "Draft (custom text)",
          "data": {
            "state": "saved",
            "label": "Entwurf gespeichert"
          },
          "html": "<span class=\"save-status\" data-state=\"saved\" role=\"status\"><span class=\"save-status__icon\" data-for=\"saved\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span><span class=\"save-status__icon\" data-for=\"pending\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#wifi-off\"/></svg></span><span class=\"save-status__icon\" data-for=\"error\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-circle\"/></svg></span><span class=\"save-status__text\">Entwurf gespeichert</span></span>"
        }
      ],
      "markup": {
        "root": ".save-status",
        "element": "span",
        "attributes": [
          {
            "name": "data-state",
            "values": "saved | saving | pending | error",
            "description": "Colour and indicator."
          },
          {
            "name": "role=\"status\"",
            "values": "—",
            "description": "Polite live region."
          }
        ],
        "parts": [
          {
            "name": ".save-status__icon[data-for=\"saved|pending|error\"]",
            "description": "State icons (`check`, `wifi-off`, `alert-circle`, decorative); only the one matching `data-state` is shown."
          },
          {
            "name": "::before",
            "description": "Spinning ring while `saving`."
          },
          {
            "name": ".save-status__text",
            "description": "The status text; page code updates it."
          }
        ],
        "states": [
          {
            "name": "[data-state=\"saved\"]",
            "description": "Muted text, check icon."
          },
          {
            "name": "[data-state=\"saving\"]",
            "description": "Muted text, spinner."
          },
          {
            "name": "[data-state=\"pending\"]",
            "description": "Warning text colour, wifi-off icon."
          },
          {
            "name": "[data-state=\"error\"]",
            "description": "Danger text colour, alert-circle icon."
          }
        ]
      },
      "cssVars": [],
      "behaviour": {
        "module": "(page code)",
        "setup": "No built-in behaviour. The page (or the offline outbox) updates `data-state` and the text of `.save-status__text` as saves start, finish, queue or fail.",
        "api": [],
        "events": []
      },
      "a11y": [
        "State is in the text (\"Nicht gespeichert\"), not only in the colour or icon; the icons are `aria-hidden`.",
        "`role=\"status\"` announces changes politely; don't update it on every keystroke — only when a save starts or ends."
      ],
      "guidance": {
        "do": [
          "Put it where the user looks while editing (header of the panel, next to the stripes).",
          "Show the pending count so people know nothing is lost."
        ],
        "dont": [
          "Don't use an error dialog for offline — `pending` is normal.",
          "Don't combine it with a toast for the same save."
        ]
      },
      "related": [
        "offline-banner",
        "toast",
        "stripes",
        "switch"
      ]
    },
    {
      "id": "spinner",
      "name": "Spinner",
      "group": "Feedback",
      "status": "stable",
      "summary": "A spinning ring that shows something is loading, with an accessible label.",
      "description": [
        "Use it sparingly (UX guide §7.1): show nothing for the first ~300 ms, then a skeleton in the shape of the content for regions. A spinner fits small, shapeless waits — loading more rows, a refresh inside a card, an upload thumbnail.",
        "Buttons have their own spinner: set `busy` on the button instead of placing a spinner next to it. Never cover the whole app with a spinner.",
        "The label is visually hidden and announced via `role=\"status\"`. When the spinner sits next to visible text that already says what is happening (\"Lädt Mitglieder …\"), mark it `decorative`."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [],
        "properties": {
          "label": {
            "type": "string",
            "minLength": 1,
            "default": "Wird geladen …",
            "description": "What is loading, read by screen readers (\"Trainings werden geladen …\")."
          },
          "size": {
            "type": "string",
            "enum": [
              "sm",
              "md",
              "lg"
            ],
            "default": "md",
            "description": "Diameter, matching the icon sizes (design guide §9.4): `sm` 16 px (inline in small text), `md` 20 px, `lg` 24 px."
          },
          "decorative": {
            "type": "boolean",
            "default": false,
            "description": "Hide from assistive technology because visible text next to it already says what is loading."
          }
        }
      },
      "examples": [
        {
          "title": "Default",
          "data": {},
          "html": "<span class=\"spinner\" role=\"status\"><span class=\"u-visually-hidden\">Wird geladen …</span></span>"
        },
        {
          "title": "Large, specific label",
          "data": {
            "size": "lg",
            "label": "Trainings werden geladen …"
          },
          "html": "<span class=\"spinner\" data-size=\"lg\" role=\"status\"><span class=\"u-visually-hidden\">Trainings werden geladen …</span></span>"
        },
        {
          "title": "Decorative next to text",
          "data": {
            "size": "sm",
            "decorative": true
          },
          "html": "<span class=\"spinner\" data-size=\"sm\" aria-hidden=\"true\"></span>"
        }
      ],
      "markup": {
        "root": ".spinner",
        "element": "span",
        "attributes": [
          {
            "name": "role=\"status\"",
            "values": "—",
            "description": "Announces the hidden label."
          },
          {
            "name": "aria-hidden=\"true\"",
            "values": "—",
            "description": "Decorative spinner."
          },
          {
            "name": "data-size",
            "values": "sm | lg",
            "default": "(none = md, --icon-md)",
            "description": "Diameter: `--icon-sm` / `--icon-lg`."
          }
        ],
        "parts": [
          {
            "name": "(border)",
            "description": "2 px ring in `currentColor` on a 25 % `currentColor` track — it takes the colour of the surrounding text."
          },
          {
            "name": ".u-visually-hidden",
            "description": "The label text."
          }
        ],
        "states": []
      },
      "cssVars": [],
      "a11y": [
        "The label is real text in a `role=\"status\"` region; decorative spinners are `aria-hidden`.",
        "The spinner keeps rotating under reduced motion because it conveys state (design guide §8); the label communicates the state too.",
        "Remove the spinner (or replace its label with the result) when loading ends, so screen readers don't hear \"loading\" forever."
      ],
      "guidance": {
        "do": [
          "Say what is loading in the label.",
          "Prefer skeletons for content regions."
        ],
        "dont": [
          "Don't show a spinner for waits under ~300 ms.",
          "Don't put a spinner next to a busy button — the button already shows one."
        ]
      },
      "related": [
        "button",
        "skeleton",
        "progress"
      ]
    },
    {
      "id": "toast",
      "name": "Toast",
      "group": "Feedback",
      "status": "stable",
      "summary": "A short, non-blocking message that confirms what just happened and disappears — optionally with \"Rückgängig\".",
      "description": [
        "Toasts are created from page code with `toast(message, options)` from `js/toast.js`; there is one toast region per page (bottom centre on phones above the tab bar, bottom end on desktop), created on first use. `render()` here only produces the same static markup for documentation and server-rendered previews.",
        "Use a toast when the result of an action is not visible on screen (\"Bestätigung an 23 Personen gesendet\") and for reversible actions with an undo (\"Lea als abwesend markiert\" + \"Rückgängig\", UX guide §5.1). If the result is visible — a ticked row, a moved item — no toast is needed.",
        "Look (design guide §9.4): inverted surface (`--color-inverse-bg` / `--color-inverse-text`), `--shadow-md`, a leading status icon, one line of text, the optional action as an underlined semibold text button. Status colour never goes on text there (it isn't tested on the inverse surface); `success` and `danger` put a small status fill behind the icon instead (§3.3). `neutral` shows the `info` icon without a fill.",
        "Timing: plain toasts stay 4 s, toasts with an action at least 8 s (WCAG 2.2.1), `tone: \"danger\"` toasts stay until dismissed and use `role=\"alert\"`. Hovering or focusing a toast pauses the timer. Every toast has a close button.",
        "Field errors never go into a toast — they belong to the form. Lasting problems belong in an alert."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "message"
        ],
        "properties": {
          "message": {
            "type": "string",
            "minLength": 1,
            "description": "What happened, in past tense (\"Anwesenheit gespeichert\")."
          },
          "tone": {
            "type": "string",
            "enum": [
              "neutral",
              "success",
              "danger"
            ],
            "default": "neutral",
            "description": "Status, written as `data-status` with its icon: `neutral` (info icon), `success` (check-circle on a success fill, for confirmations), `danger` (alert-circle on a danger fill; `role=\"alert\"`, stays until dismissed)."
          },
          "actionLabel": {
            "type": "string",
            "minLength": 1,
            "description": "Text of the optional action button, usually \"Rückgängig\"."
          },
          "closeLabel": {
            "type": "string",
            "default": "Schliessen",
            "description": "Accessible name of the close button."
          }
        }
      },
      "examples": [
        {
          "title": "Confirmation",
          "data": {
            "message": "Bestätigung an 23 Personen gesendet",
            "tone": "success"
          },
          "html": "<div class=\"toast\" data-status=\"success\">\n  <span class=\"toast__icon\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check-circle\"/></svg></span>\n  <span class=\"toast__text\">Bestätigung an 23 Personen gesendet</span>\n  \n  <button class=\"toast__action\" type=\"button\" data-close aria-label=\"Schliessen\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#x\"/></svg></button>\n</div>"
        },
        {
          "title": "Neutral",
          "data": {
            "message": "Training wurde in den Entwurf verschoben"
          },
          "html": "<div class=\"toast\">\n  <span class=\"toast__icon\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#info\"/></svg></span>\n  <span class=\"toast__text\">Training wurde in den Entwurf verschoben</span>\n  \n  <button class=\"toast__action\" type=\"button\" data-close aria-label=\"Schliessen\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#x\"/></svg></button>\n</div>"
        },
        {
          "title": "With undo",
          "data": {
            "message": "Lea Müller als abwesend markiert",
            "actionLabel": "Rückgängig"
          },
          "html": "<div class=\"toast\">\n  <span class=\"toast__icon\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#info\"/></svg></span>\n  <span class=\"toast__text\">Lea Müller als abwesend markiert</span>\n  <button class=\"toast__action\" type=\"button\" data-act>Rückgängig</button>\n  <button class=\"toast__action\" type=\"button\" data-close aria-label=\"Schliessen\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#x\"/></svg></button>\n</div>"
        },
        {
          "title": "Failure",
          "data": {
            "message": "Speichern fehlgeschlagen. Wird erneut versucht, sobald du online bist.",
            "tone": "danger"
          },
          "html": "<div class=\"toast\" data-status=\"danger\" role=\"alert\">\n  <span class=\"toast__icon\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-circle\"/></svg></span>\n  <span class=\"toast__text\">Speichern fehlgeschlagen. Wird erneut versucht, sobald du online bist.</span>\n  \n  <button class=\"toast__action\" type=\"button\" data-close aria-label=\"Schliessen\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#x\"/></svg></button>\n</div>"
        }
      ],
      "markup": {
        "root": ".toast",
        "element": "div (inside .toast-region[aria-live=\"polite\"])",
        "attributes": [
          {
            "name": "data-status",
            "values": "success | danger",
            "default": "(none = neutral)",
            "description": "Status: a status fill behind the icon."
          },
          {
            "name": "role=\"alert\"",
            "values": "—",
            "description": "Set for `danger` toasts."
          }
        ],
        "parts": [
          {
            "name": ".toast-region",
            "description": "Fixed container created by `toast()`; `aria-live=\"polite\"`. Offset above bottom bars with `--toast-offset`."
          },
          {
            "name": ".toast__icon",
            "description": "Leading status icon (decorative): info / check-circle / alert-circle; round status fill for `success` and `danger`."
          },
          {
            "name": ".toast__text",
            "description": "The message."
          },
          {
            "name": ".toast__action[data-act]",
            "description": "Optional action (\"Rückgängig\")."
          },
          {
            "name": ".toast__action[data-close]",
            "description": "Close button (x icon with `aria-label`)."
          }
        ],
        "states": [
          {
            "name": "[data-leaving]",
            "description": "Exit animation before removal."
          }
        ]
      },
      "cssVars": [
        {
          "name": "--toast-offset",
          "default": "0px",
          "description": "Extra distance from the bottom (set on the region or a parent), e.g. the height of a sticky bottom bar."
        }
      ],
      "behaviour": {
        "module": "js/toast.js",
        "setup": "`import { toast } from '@clubhub/ui/js/toast.js'` and call it; no markup or `mount()` needed.",
        "api": [
          {
            "name": "toast",
            "signature": "toast(message: string, { tone?: \"neutral\" | \"success\" | \"danger\", timeout?: number, action?: { label: string, onAction: () => void } }?)",
            "returns": "() => void — dismiss function",
            "description": "Shows a toast in the page's toast region. `timeout` in ms; default 4000, 8000 with an action, 0 (stay until dismissed) for `danger`. `onAction` runs when the action is pressed, then the toast closes. The timer pauses on hover/focus and resumes with half the time on pointer leave."
          }
        ],
        "events": []
      },
      "a11y": [
        "The region is `aria-live=\"polite\"`; danger toasts use `role=\"alert\"`.",
        "Toasts with an action stay at least 8 s and pause on hover/focus, so keyboard and screen-reader users can reach \"Rückgängig\".",
        "Never put the only way to do something into a toast; undo must also be possible elsewhere or within the timeout."
      ],
      "guidance": {
        "do": [
          "Write what happened: \"3 Streifen vergeben\".",
          "Offer \"Rückgängig\" instead of asking \"Sind Sie sicher?\" for reversible actions."
        ],
        "dont": [
          "Don't toast what is already visible.",
          "Don't show field or validation errors as toasts.",
          "Don't queue several toasts for one action."
        ]
      },
      "related": [
        "alert",
        "save-status",
        "confirm-dialog"
      ]
    },
    {
      "id": "sheet",
      "name": "Bottom sheet",
      "group": "Overlays",
      "status": "stable",
      "summary": "A panel that slides up from the bottom on phones (a centred dialog from 768 px) for details and quick actions in context.",
      "description": [
        "The phone-first overlay: show details of an item without leaving the list (an exercise during training, a student's stripes), or offer a short choice. The action sits at the bottom, in thumb reach (UX guide §5.4). From 768 px the same markup is shown as a centred dialog.",
        "Same mechanics as the dialog: native `<dialog>` + `showModal()`, `<form method=\"dialog\">`, every footer button closes it with its `value` as `returnValue`; Esc, the ✕ and a backdrop click close with `cancel`, and focus returns to the opener. The handle at the top is a visual cue only — there is no swipe-to-close, so the ✕ stays.",
        "Browser back should close an open sheet before leaving the page (UX guide §3.3) — push a history entry when opening if the sheet is a \"place\" people expect to go back from.",
        "Data covers text, a muted meta line and form fields; apps render richer content (stripe chips, lists) into `.dialog__body` themselves."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "title"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Id of the `<dialog>` (target of the opening button); prefix for `{id}-title` and `{id}-body`. Must be unique on the page."
          },
          "title": {
            "type": "string",
            "minLength": 1,
            "description": "Heading (the item's name, \"Dollyo Chagi an Pratze\")."
          },
          "meta": {
            "type": "string",
            "description": "One muted line under the title (\"15 Wiederholungen · Pause 60 s\")."
          },
          "paragraphs": {
            "type": "array",
            "description": "Body text, one string per paragraph. Linked as the dialog's description.",
            "items": {
              "type": "string",
              "minLength": 1,
              "description": "One paragraph."
            }
          },
          "fields": {
            "type": "array",
            "description": "Form fields in the body (see Form field). Their values are in the form when the dialog closes.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "id",
                "label"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "pattern": "^[A-Za-z][\\w-]*$",
                  "description": "Id of the control; also prefix for `{id}-hint` and `{id}-error`. Must be unique on the page."
                },
                "label": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Visible label (\"E-Mail\", \"Geburtsdatum\")."
                },
                "control": {
                  "type": "string",
                  "enum": [
                    "input",
                    "select",
                    "textarea"
                  ],
                  "default": "input",
                  "description": "Which control to render."
                },
                "type": {
                  "type": "string",
                  "enum": [
                    "text",
                    "email",
                    "tel",
                    "date",
                    "time",
                    "number",
                    "url",
                    "search",
                    "password"
                  ],
                  "default": "text",
                  "description": "Input type (control = input)."
                },
                "name": {
                  "type": "string",
                  "description": "Form field name; defaults to `id`."
                },
                "value": {
                  "type": [
                    "string",
                    "number"
                  ],
                  "description": "Current value."
                },
                "placeholder": {
                  "type": "string",
                  "description": "Example value (\"z. B. 3011\"). Never a substitute for the label."
                },
                "hint": {
                  "type": "string",
                  "description": "Help text shown between label and control."
                },
                "error": {
                  "type": "string",
                  "description": "Error message; when set the control is marked invalid. Say what to do: \"Bitte gib … ein\"."
                },
                "required": {
                  "type": "boolean",
                  "default": false,
                  "description": "Field must be filled. Optional fields are marked \"(optional)\" instead of marking required ones."
                },
                "optional": {
                  "type": "boolean",
                  "default": false,
                  "description": "Append \"(optional)\" to the label (use in forms where most fields are required)."
                },
                "disabled": {
                  "type": "boolean",
                  "default": false,
                  "description": "Read-only for now; explain why in the hint."
                },
                "autocomplete": {
                  "type": "string",
                  "description": "HTML autocomplete token, e.g. `email`, `given-name`, `bday`, `one-time-code`."
                },
                "inputmode": {
                  "type": "string",
                  "enum": [
                    "text",
                    "numeric",
                    "decimal",
                    "tel",
                    "email",
                    "url",
                    "search"
                  ],
                  "description": "Virtual keyboard hint, e.g. `numeric` for PLZ."
                },
                "maxlength": {
                  "type": "integer",
                  "minimum": 1,
                  "description": "Maximum characters."
                },
                "rows": {
                  "type": "integer",
                  "minimum": 2,
                  "default": 4,
                  "description": "Visible lines (control = textarea)."
                },
                "options": {
                  "type": "array",
                  "description": "Choices (control = select). Put a sensible default first or add an empty \"Bitte wählen\" option.",
                  "items": {
                    "type": "object",
                    "additionalProperties": false,
                    "required": [
                      "value",
                      "label"
                    ],
                    "properties": {
                      "value": {
                        "type": "string",
                        "description": "Submitted value."
                      },
                      "label": {
                        "type": "string",
                        "description": "Shown text."
                      },
                      "disabled": {
                        "type": "boolean",
                        "description": "Not selectable (e.g. full group)."
                      }
                    }
                  }
                }
              },
              "description": "One form field."
            }
          },
          "closeLabel": {
            "type": "string",
            "default": "Schliessen",
            "description": "Accessible name of the ✕ button in the header."
          },
          "showClose": {
            "type": "boolean",
            "default": true,
            "description": "Show the ✕ button in the header."
          },
          "actions": {
            "type": "array",
            "minItems": 1,
            "maxItems": 3,
            "description": "Footer buttons, primary last. A single action is shown full width. Optional — a pure detail sheet closes with the ✕.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "label",
                "value"
              ],
              "properties": {
                "label": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Button text, a verb naming the result (\"Gruppe ändern\"); \"Abbrechen\" for the cancel button."
                },
                "value": {
                  "type": "string",
                  "pattern": "^[a-z][a-z0-9-]*$",
                  "description": "Return value. Use `cancel` for the dismiss button (it skips form validation)."
                },
                "variant": {
                  "type": "string",
                  "enum": [
                    "secondary",
                    "primary",
                    "ghost",
                    "danger"
                  ],
                  "default": "secondary",
                  "description": "Button variant; one `primary` (or `danger` for destructive) per dialog."
                }
              }
            }
          },
          "trigger": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "label"
            ],
            "description": "Optional opening button rendered before the sheet (`data-behavior=\"open-dialog\"`).",
            "properties": {
              "label": {
                "type": "string",
                "minLength": 1,
                "description": "Button text; ends with \"…\" when it opens a dialog (\"Gruppe ändern …\")."
              },
              "variant": {
                "type": "string",
                "enum": [
                  "secondary",
                  "primary",
                  "ghost",
                  "danger",
                  "danger-ghost"
                ],
                "default": "secondary",
                "description": "Visual weight / meaning."
              },
              "icon": {
                "type": "string",
                "description": "Icon id shown before the label."
              }
            }
          }
        }
      },
      "examples": [
        {
          "title": "Exercise details with one action",
          "data": {
            "id": "exercise-42",
            "title": "Dollyo Chagi an Pratze",
            "meta": "15 Wiederholungen · Pause 60 s",
            "trigger": {
              "label": "Details",
              "variant": "ghost",
              "icon": "info"
            },
            "paragraphs": [
              "Saubere Technik, kontrolliert. Hüfte eindrehen, Standbein drehen."
            ],
            "actions": [
              {
                "label": "Satz erledigt",
                "value": "done",
                "variant": "primary"
              }
            ]
          },
          "html": "<button class=\"button\" type=\"button\" data-variant=\"ghost\" data-behavior=\"open-dialog\" data-target=\"exercise-42\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#info\"/></svg>Details</button>\n<dialog class=\"sheet\" id=\"exercise-42\" aria-labelledby=\"exercise-42-title\" aria-describedby=\"exercise-42-body\">\n  <form method=\"dialog\" class=\"dialog__inner\">\n    <div class=\"sheet__handle\" aria-hidden=\"true\"></div>\n    <header class=\"dialog__header\"><h2 id=\"exercise-42-title\">Dollyo Chagi an Pratze</h2><button class=\"button\" data-variant=\"ghost\" data-icon-only value=\"cancel\" formnovalidate aria-label=\"Schliessen\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#x\"/></svg></button></header>\n    <div class=\"dialog__body\">\n      <div class=\"stack\" data-gap=\"sm\" id=\"exercise-42-body\"><p class=\"u-muted\">15 Wiederholungen · Pause 60 s</p><p>Saubere Technik, kontrolliert. Hüfte eindrehen, Standbein drehen.</p></div>\n      \n    </div>\n    <footer class=\"dialog__footer button-row\" data-stack-mobile><button class=\"button\" value=\"done\" data-variant=\"primary\" data-block>Satz erledigt</button></footer>\n  </form>\n</dialog>"
        },
        {
          "title": "Quick note",
          "data": {
            "id": "note-sheet",
            "title": "Notiz zu Noah Keller",
            "fields": [
              {
                "id": "note-text",
                "label": "Notiz",
                "control": "textarea",
                "rows": 3,
                "hint": "Nur für Trainerinnen und Trainer sichtbar."
              }
            ],
            "actions": [
              {
                "label": "Abbrechen",
                "value": "cancel"
              },
              {
                "label": "Notiz speichern",
                "value": "save",
                "variant": "primary"
              }
            ]
          },
          "html": "<dialog class=\"sheet\" id=\"note-sheet\" aria-labelledby=\"note-sheet-title\">\n  <form method=\"dialog\" class=\"dialog__inner\">\n    <div class=\"sheet__handle\" aria-hidden=\"true\"></div>\n    <header class=\"dialog__header\"><h2 id=\"note-sheet-title\">Notiz zu Noah Keller</h2><button class=\"button\" data-variant=\"ghost\" data-icon-only value=\"cancel\" formnovalidate aria-label=\"Schliessen\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#x\"/></svg></button></header>\n    <div class=\"dialog__body\">\n      \n      <div class=\"field\">\n  <label class=\"field__label\" for=\"note-text\">Notiz</label>\n  <p class=\"field__hint\" id=\"note-text-hint\">Nur für Trainerinnen und Trainer sichtbar.</p>\n  <textarea class=\"textarea\" id=\"note-text\" name=\"note-text\" aria-describedby=\"note-text-hint\" rows=\"3\"></textarea>\n  \n</div>\n    </div>\n    <footer class=\"dialog__footer button-row\" data-stack-mobile><button class=\"button\" value=\"cancel\" formnovalidate>Abbrechen</button><button class=\"button\" value=\"save\" data-variant=\"primary\">Notiz speichern</button></footer>\n  </form>\n</dialog>"
        }
      ],
      "markup": {
        "root": ".sheet",
        "element": "dialog",
        "attributes": [
          {
            "name": "aria-labelledby",
            "values": "\"{id}-title\"",
            "description": "Named by its heading."
          },
          {
            "name": "aria-describedby",
            "values": "\"{id}-body\"",
            "description": "Meta and text as description (when present)."
          }
        ],
        "parts": [
          {
            "name": ".dialog__inner",
            "description": "`<form method=\"dialog\">` wrapping everything (shared with the dialog)."
          },
          {
            "name": ".sheet__handle",
            "description": "Grab-handle bar, decorative; hidden from 768 px."
          },
          {
            "name": ".dialog__header / .dialog__body / .dialog__footer",
            "description": "Same parts as the dialog."
          }
        ],
        "states": [
          {
            "name": "[open]",
            "description": "Slides up from the bottom (phones) / fades in centred (≥ 768 px)."
          },
          {
            "name": "::backdrop",
            "description": "Dimmed page; a click closes with `cancel`."
          }
        ]
      },
      "cssVars": [],
      "behaviour": {
        "module": "js/dialog.js",
        "setup": "Declarative: a button with `data-behavior=\"open-dialog\" data-target=\"{id}\"` and `mount(root)`. Imperative: `openDialog(dialogEl, openerEl)`.",
        "api": [
          {
            "name": "openDialog",
            "signature": "openDialog(dialog: HTMLDialogElement, opener?: HTMLElement)",
            "returns": "void",
            "description": "Opens with `showModal()`, closes on backdrop click (`returnValue = \"cancel\"`), returns focus to `opener` (default: the active element) on close."
          }
        ],
        "events": [
          {
            "name": "close (on the dialog)",
            "detail": "dialog.returnValue → value of the pressed button, or \"cancel\"",
            "description": "Native event after any way of closing. Read the form fields here when the value is not `cancel`."
          },
          {
            "name": "cancel (on the dialog)",
            "detail": "—",
            "description": "Native event on Esc; `preventDefault()` to keep unsaved input and ask first."
          }
        ]
      },
      "a11y": [
        "Native modal dialog: focus trapped, Esc closes, focus returns to the opener.",
        "Always has a visible ✕ (or a cancel action) — the handle is not a control and swiping is not required.",
        "Content respects the bottom safe area (home indicator) on phones."
      ],
      "guidance": {
        "do": [
          "Use for context on phones: details, a quick note, a short choice.",
          "Keep the primary action at the bottom, full width."
        ],
        "dont": [
          "Don't put long forms or multi-step flows into a sheet — use a page.",
          "Don't stack sheets on top of each other."
        ]
      },
      "related": [
        "dialog",
        "menu",
        "confirm-dialog"
      ]
    },
    {
      "id": "confirm-dialog",
      "name": "Confirm dialog",
      "group": "Overlays",
      "status": "stable",
      "summary": "A modal question before an irreversible or external action, naming its consequences and the action as the button label.",
      "description": [
        "Only for actions that can't be undone or that leave the system (UX guide §5.1): delete permanently, close a belt test, book invoices, send emails, write to the CRM. Reversible actions don't ask — they act and offer \"Rückgängig\" in a toast.",
        "The title is the question with the verb (\"Prüfung abschliessen?\"). The body says in plain words what will happen; `consequences` lists the effects with numbers (\"23 neue Gurte werden in Webling eingetragen\"). The confirm button repeats the verb and the main number (\"23 Gurte eintragen\") — never \"OK\" or \"Ja\".",
        "Normally created from page code with `confirmDialog({...})` from `js/dialog.js`, which returns a Promise that resolves `true` on confirm. Focus starts on \"Abbrechen\" (the safe option), and Esc, backdrop click and \"Abbrechen\" all resolve `false`. `render()` produces the same markup statically, for documentation and server-rendered pages.",
        "External actions also need a dry-run preview of their effects; render that preview as the `consequences` (UX guide §5.2). Browser `confirm()` is banned."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "title",
          "confirmLabel"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Id of the `<dialog>`; prefix for `{id}-title` and `{id}-body`. (`confirmDialog()` generates `ch-confirm-{n}` itself.)"
          },
          "title": {
            "type": "string",
            "minLength": 1,
            "description": "The question, with the action verb (\"Prüfung abschliessen?\")."
          },
          "body": {
            "type": "string",
            "description": "What will happen, in plain words; end with \"Das kann nicht rückgängig gemacht werden.\" when true."
          },
          "consequences": {
            "type": "array",
            "description": "Effects as a list, with numbers (dry-run result).",
            "items": {
              "type": "string",
              "minLength": 1,
              "description": "One effect (\"23 Prüfungsgebühren kommen in die Abrechnung\")."
            }
          },
          "confirmLabel": {
            "type": "string",
            "minLength": 1,
            "description": "Confirm button: verb + object, ideally with the count (\"23 Gurte eintragen\")."
          },
          "cancelLabel": {
            "type": "string",
            "default": "Abbrechen",
            "description": "Cancel button text."
          },
          "tone": {
            "type": "string",
            "enum": [
              "primary",
              "danger"
            ],
            "default": "primary",
            "description": "`danger` for destroying something of value (delete); `primary` otherwise."
          }
        }
      },
      "examples": [
        {
          "title": "Close a belt test",
          "data": {
            "id": "confirm-close-exam",
            "title": "Prüfung abschliessen?",
            "body": "Das kann nicht rückgängig gemacht werden.",
            "consequences": [
              "23 neue Gurte werden in Webling eingetragen.",
              "4 Personen haben nicht bestanden.",
              "23 Prüfungsgebühren kommen in die Abrechnung."
            ],
            "confirmLabel": "23 Gurte eintragen"
          },
          "html": "<dialog class=\"dialog\" id=\"confirm-close-exam\" aria-labelledby=\"confirm-close-exam-title\" aria-describedby=\"confirm-close-exam-body\">\n  <form method=\"dialog\" class=\"dialog__inner\">\n    <header class=\"dialog__header\"><h2 id=\"confirm-close-exam-title\">Prüfung abschliessen?</h2></header>\n    <div class=\"dialog__body\" id=\"confirm-close-exam-body\">\n      <p>Das kann nicht rückgängig gemacht werden.</p>\n      <div class=\"dialog__consequence\"><ul><li>23 neue Gurte werden in Webling eingetragen.</li><li>4 Personen haben nicht bestanden.</li><li>23 Prüfungsgebühren kommen in die Abrechnung.</li></ul></div>\n    </div>\n    <footer class=\"dialog__footer button-row\" data-stack-mobile>\n      <button class=\"button\" value=\"cancel\" autofocus>Abbrechen</button>\n      <button class=\"button\" data-variant=\"primary\" value=\"confirm\">23 Gurte eintragen</button>\n    </footer>\n  </form>\n</dialog>"
        },
        {
          "title": "Delete permanently",
          "data": {
            "id": "confirm-delete-plan",
            "title": "Lektionsplan «Kinder Dienstag» löschen?",
            "body": "Der Plan und seine 12 Übungen werden endgültig gelöscht. Trainings, die ihn schon verwendet haben, bleiben erhalten.",
            "confirmLabel": "Plan löschen",
            "tone": "danger"
          },
          "html": "<dialog class=\"dialog\" id=\"confirm-delete-plan\" aria-labelledby=\"confirm-delete-plan-title\" aria-describedby=\"confirm-delete-plan-body\">\n  <form method=\"dialog\" class=\"dialog__inner\">\n    <header class=\"dialog__header\"><h2 id=\"confirm-delete-plan-title\">Lektionsplan «Kinder Dienstag» löschen?</h2></header>\n    <div class=\"dialog__body\" id=\"confirm-delete-plan-body\">\n      <p>Der Plan und seine 12 Übungen werden endgültig gelöscht. Trainings, die ihn schon verwendet haben, bleiben erhalten.</p>\n      \n    </div>\n    <footer class=\"dialog__footer button-row\" data-stack-mobile>\n      <button class=\"button\" value=\"cancel\" autofocus>Abbrechen</button>\n      <button class=\"button\" data-variant=\"danger\" value=\"confirm\">Plan löschen</button>\n    </footer>\n  </form>\n</dialog>"
        }
      ],
      "markup": {
        "root": ".dialog",
        "element": "dialog",
        "attributes": [
          {
            "name": "aria-labelledby",
            "values": "\"{id}-title\"",
            "description": "The question names the dialog."
          },
          {
            "name": "aria-describedby",
            "values": "\"{id}-body\"",
            "description": "Body and consequences are read as its description."
          },
          {
            "name": "autofocus (on cancel)",
            "values": "—",
            "description": "Focus starts on the safe option."
          }
        ],
        "parts": [
          {
            "name": ".dialog__header > h2",
            "description": "The question (no ✕ button — Esc and \"Abbrechen\" close it)."
          },
          {
            "name": ".dialog__body",
            "description": "Body paragraph."
          },
          {
            "name": ".dialog__consequence",
            "description": "Grey box with the list of effects."
          },
          {
            "name": ".dialog__footer.button-row[data-stack-mobile]",
            "description": "\"Abbrechen\" (`value=\"cancel\"`) then the confirm button (`value=\"confirm\"`); stacked on phones with confirm at the bottom."
          }
        ],
        "states": [
          {
            "name": "[open]",
            "description": "Shown modally."
          }
        ]
      },
      "cssVars": [],
      "behaviour": {
        "module": "js/dialog.js",
        "setup": "`import { confirmDialog } from '@clubhub/ui/js/dialog.js'`; no markup needed. For rendered markup, open it with `openDialog()` or an `open-dialog` button and read `returnValue` on `close`.",
        "api": [
          {
            "name": "confirmDialog",
            "signature": "confirmDialog({ title: string, body?: string, consequences?: string[], confirmLabel: string, cancelLabel?: string = \"Abbrechen\", tone?: \"primary\" | \"danger\" = \"primary\" })",
            "returns": "Promise<boolean> — true when confirmed",
            "description": "Builds this markup, appends it to `<body>`, opens it modally and removes it after closing. Resolves `false` on cancel, Esc or backdrop click."
          },
          {
            "name": "openDialog",
            "signature": "openDialog(dialog: HTMLDialogElement, opener?: HTMLElement)",
            "returns": "void",
            "description": "Opens a rendered confirm dialog; focus returns to `opener` on close."
          }
        ],
        "events": [
          {
            "name": "close (on the dialog)",
            "detail": "dialog.returnValue → \"confirm\" | \"cancel\"",
            "description": "Native event when the rendered dialog closes."
          }
        ]
      },
      "a11y": [
        "Native modal dialog: focus trapped, Esc closes, focus returns to the opener.",
        "Focus starts on \"Abbrechen\", never on a danger button (UX guide §5.5).",
        "Title and consequences are announced as the dialog's name and description."
      ],
      "guidance": {
        "do": [
          "Ask a question with the verb; answer it with the verb.",
          "Name numbers and consequences: who, how many, where it is written."
        ],
        "dont": [
          "No \"Sind Sie sicher?\" with [OK] [Abbrechen].",
          "Don't confirm reversible actions — use undo.",
          "Never use browser `confirm()`."
        ]
      },
      "related": [
        "dialog",
        "toast",
        "button"
      ]
    },
    {
      "id": "dialog",
      "name": "Dialog",
      "group": "Overlays",
      "status": "stable",
      "summary": "A modal window on native `<dialog>` for a short, focused task: header, body, footer actions.",
      "description": [
        "Use a dialog for a small task that belongs to the current page and must be finished or cancelled before going on: change a group, add a note, pick a date. Longer or multi-step tasks get their own page; on phones, contextual details are often better in a bottom sheet.",
        "Built on `<dialog>` + `showModal()`: top layer, focus trap, inert background and Esc come from the browser. The content is a `<form method=\"dialog\">`, so every footer button closes the dialog and sets `dialog.returnValue` to its `value`. Esc, the close button and a click on the backdrop all close with `cancel`; focus then returns to the opening button.",
        "This renderer takes text paragraphs and optional form fields as data. Apps compose richer bodies (lists, alerts, belt pickers) by rendering other components into `.dialog__body` themselves.",
        "For \"are you sure\" questions about irreversible actions use the confirm dialog, which names the consequences."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "title",
          "actions"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Id of the `<dialog>` (target of the opening button); prefix for `{id}-title` and `{id}-body`. Must be unique on the page."
          },
          "title": {
            "type": "string",
            "minLength": 1,
            "description": "Heading naming the task (\"Trainingsgruppe ändern\")."
          },
          "paragraphs": {
            "type": "array",
            "description": "Body text, one string per paragraph. Linked as the dialog's description.",
            "items": {
              "type": "string",
              "minLength": 1,
              "description": "One paragraph."
            }
          },
          "fields": {
            "type": "array",
            "description": "Form fields in the body (see Form field). Their values are in the form when the dialog closes.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "id",
                "label"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "pattern": "^[A-Za-z][\\w-]*$",
                  "description": "Id of the control; also prefix for `{id}-hint` and `{id}-error`. Must be unique on the page."
                },
                "label": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Visible label (\"E-Mail\", \"Geburtsdatum\")."
                },
                "control": {
                  "type": "string",
                  "enum": [
                    "input",
                    "select",
                    "textarea"
                  ],
                  "default": "input",
                  "description": "Which control to render."
                },
                "type": {
                  "type": "string",
                  "enum": [
                    "text",
                    "email",
                    "tel",
                    "date",
                    "time",
                    "number",
                    "url",
                    "search",
                    "password"
                  ],
                  "default": "text",
                  "description": "Input type (control = input)."
                },
                "name": {
                  "type": "string",
                  "description": "Form field name; defaults to `id`."
                },
                "value": {
                  "type": [
                    "string",
                    "number"
                  ],
                  "description": "Current value."
                },
                "placeholder": {
                  "type": "string",
                  "description": "Example value (\"z. B. 3011\"). Never a substitute for the label."
                },
                "hint": {
                  "type": "string",
                  "description": "Help text shown between label and control."
                },
                "error": {
                  "type": "string",
                  "description": "Error message; when set the control is marked invalid. Say what to do: \"Bitte gib … ein\"."
                },
                "required": {
                  "type": "boolean",
                  "default": false,
                  "description": "Field must be filled. Optional fields are marked \"(optional)\" instead of marking required ones."
                },
                "optional": {
                  "type": "boolean",
                  "default": false,
                  "description": "Append \"(optional)\" to the label (use in forms where most fields are required)."
                },
                "disabled": {
                  "type": "boolean",
                  "default": false,
                  "description": "Read-only for now; explain why in the hint."
                },
                "autocomplete": {
                  "type": "string",
                  "description": "HTML autocomplete token, e.g. `email`, `given-name`, `bday`, `one-time-code`."
                },
                "inputmode": {
                  "type": "string",
                  "enum": [
                    "text",
                    "numeric",
                    "decimal",
                    "tel",
                    "email",
                    "url",
                    "search"
                  ],
                  "description": "Virtual keyboard hint, e.g. `numeric` for PLZ."
                },
                "maxlength": {
                  "type": "integer",
                  "minimum": 1,
                  "description": "Maximum characters."
                },
                "rows": {
                  "type": "integer",
                  "minimum": 2,
                  "default": 4,
                  "description": "Visible lines (control = textarea)."
                },
                "options": {
                  "type": "array",
                  "description": "Choices (control = select). Put a sensible default first or add an empty \"Bitte wählen\" option.",
                  "items": {
                    "type": "object",
                    "additionalProperties": false,
                    "required": [
                      "value",
                      "label"
                    ],
                    "properties": {
                      "value": {
                        "type": "string",
                        "description": "Submitted value."
                      },
                      "label": {
                        "type": "string",
                        "description": "Shown text."
                      },
                      "disabled": {
                        "type": "boolean",
                        "description": "Not selectable (e.g. full group)."
                      }
                    }
                  }
                }
              },
              "description": "One form field."
            }
          },
          "size": {
            "type": "string",
            "enum": [
              "md",
              "lg"
            ],
            "default": "md",
            "description": "`md` 32 rem wide, `lg` 48 rem for tables or previews."
          },
          "closeLabel": {
            "type": "string",
            "default": "Schliessen",
            "description": "Accessible name of the ✕ button in the header."
          },
          "showClose": {
            "type": "boolean",
            "default": true,
            "description": "Show the ✕ button in the header."
          },
          "actions": {
            "type": "array",
            "minItems": 1,
            "maxItems": 3,
            "description": "Footer buttons in display order — the primary action last. Each closes the dialog with its `value` as `dialog.returnValue`.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "label",
                "value"
              ],
              "properties": {
                "label": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Button text, a verb naming the result (\"Gruppe ändern\"); \"Abbrechen\" for the cancel button."
                },
                "value": {
                  "type": "string",
                  "pattern": "^[a-z][a-z0-9-]*$",
                  "description": "Return value. Use `cancel` for the dismiss button (it skips form validation)."
                },
                "variant": {
                  "type": "string",
                  "enum": [
                    "secondary",
                    "primary",
                    "ghost",
                    "danger"
                  ],
                  "default": "secondary",
                  "description": "Button variant; one `primary` (or `danger` for destructive) per dialog."
                }
              }
            }
          },
          "trigger": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "label"
            ],
            "description": "Optional opening button rendered before the dialog (`data-behavior=\"open-dialog\"`).",
            "properties": {
              "label": {
                "type": "string",
                "minLength": 1,
                "description": "Button text; ends with \"…\" when it opens a dialog (\"Gruppe ändern …\")."
              },
              "variant": {
                "type": "string",
                "enum": [
                  "secondary",
                  "primary",
                  "ghost",
                  "danger",
                  "danger-ghost"
                ],
                "default": "secondary",
                "description": "Visual weight / meaning."
              },
              "icon": {
                "type": "string",
                "description": "Icon id shown before the label."
              }
            }
          }
        }
      },
      "examples": [
        {
          "title": "Form dialog with trigger",
          "data": {
            "id": "change-group",
            "title": "Trainingsgruppe ändern",
            "trigger": {
              "label": "Gruppe ändern …",
              "icon": "users"
            },
            "paragraphs": [
              "Lea Müller wechselt ab dem nächsten Training in die neue Gruppe."
            ],
            "fields": [
              {
                "id": "new-group",
                "label": "Neue Gruppe",
                "control": "select",
                "value": "kids",
                "options": [
                  {
                    "value": "kids",
                    "label": "Kinder 7–10"
                  },
                  {
                    "value": "youth",
                    "label": "Jugend 11–15"
                  }
                ]
              }
            ],
            "actions": [
              {
                "label": "Abbrechen",
                "value": "cancel"
              },
              {
                "label": "Gruppe ändern",
                "value": "save",
                "variant": "primary"
              }
            ]
          },
          "html": "<button class=\"button\" type=\"button\" data-behavior=\"open-dialog\" data-target=\"change-group\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#users\"/></svg>Gruppe ändern …</button>\n<dialog class=\"dialog\" id=\"change-group\" aria-labelledby=\"change-group-title\" aria-describedby=\"change-group-body\">\n  <form method=\"dialog\" class=\"dialog__inner\">\n    <header class=\"dialog__header\"><h2 id=\"change-group-title\">Trainingsgruppe ändern</h2><button class=\"button\" data-variant=\"ghost\" data-icon-only value=\"cancel\" formnovalidate aria-label=\"Schliessen\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#x\"/></svg></button></header>\n    <div class=\"dialog__body\">\n      <div class=\"stack\" data-gap=\"sm\" id=\"change-group-body\"><p>Lea Müller wechselt ab dem nächsten Training in die neue Gruppe.</p></div>\n      <div class=\"field\">\n  <label class=\"field__label\" for=\"new-group\">Neue Gruppe</label>\n  \n  <select class=\"select\" id=\"new-group\" name=\"new-group\"><option value=\"kids\" selected>Kinder 7–10</option><option value=\"youth\">Jugend 11–15</option></select>\n  \n</div>\n    </div>\n    <footer class=\"dialog__footer button-row\" data-stack-mobile><button class=\"button\" value=\"cancel\" formnovalidate>Abbrechen</button><button class=\"button\" value=\"save\" data-variant=\"primary\">Gruppe ändern</button></footer>\n  </form>\n</dialog>"
        },
        {
          "title": "Information, large",
          "data": {
            "id": "exam-rules",
            "title": "Ablauf der Gurtprüfung",
            "size": "lg",
            "paragraphs": [
              "Die Prüfung beginnt um 10:00 im Dojang Bern. Bitte 30 Minuten vorher umgezogen bereit sein.",
              "Geprüft werden Form, Kyorugi, Hosinsul und Theorie. Die Resultate erscheinen am selben Abend im Mitgliederbereich."
            ],
            "actions": [
              {
                "label": "Verstanden",
                "value": "close",
                "variant": "primary"
              }
            ]
          },
          "html": "<dialog class=\"dialog\" id=\"exam-rules\" data-size=\"lg\" aria-labelledby=\"exam-rules-title\" aria-describedby=\"exam-rules-body\">\n  <form method=\"dialog\" class=\"dialog__inner\">\n    <header class=\"dialog__header\"><h2 id=\"exam-rules-title\">Ablauf der Gurtprüfung</h2><button class=\"button\" data-variant=\"ghost\" data-icon-only value=\"cancel\" formnovalidate aria-label=\"Schliessen\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#x\"/></svg></button></header>\n    <div class=\"dialog__body\">\n      <div class=\"stack\" data-gap=\"sm\" id=\"exam-rules-body\"><p>Die Prüfung beginnt um 10:00 im Dojang Bern. Bitte 30 Minuten vorher umgezogen bereit sein.</p><p>Geprüft werden Form, Kyorugi, Hosinsul und Theorie. Die Resultate erscheinen am selben Abend im Mitgliederbereich.</p></div>\n      \n    </div>\n    <footer class=\"dialog__footer button-row\" data-stack-mobile><button class=\"button\" value=\"close\" data-variant=\"primary\">Verstanden</button></footer>\n  </form>\n</dialog>"
        }
      ],
      "markup": {
        "root": ".dialog",
        "element": "dialog",
        "attributes": [
          {
            "name": "aria-labelledby",
            "values": "\"{id}-title\"",
            "description": "Named by its heading."
          },
          {
            "name": "aria-describedby",
            "values": "\"{id}-body\"",
            "description": "Body text as description (only when there are paragraphs)."
          },
          {
            "name": "data-size",
            "values": "lg",
            "default": "(none = md)",
            "description": "Width."
          }
        ],
        "parts": [
          {
            "name": ".dialog__inner",
            "description": "`<form method=\"dialog\">` wrapping everything."
          },
          {
            "name": ".dialog__header",
            "description": "Title (`h2`) and ✕ button (`value=\"cancel\"`)."
          },
          {
            "name": ".dialog__body",
            "description": "Scrollable content."
          },
          {
            "name": ".dialog__footer.button-row",
            "description": "Actions; stacked full width on phones with the primary at the bottom (`data-stack-mobile`)."
          }
        ],
        "states": [
          {
            "name": "[open]",
            "description": "Shown (with entry animation)."
          },
          {
            "name": "::backdrop",
            "description": "Dimmed page behind; a click on it closes with `cancel`."
          }
        ]
      },
      "cssVars": [],
      "behaviour": {
        "module": "js/dialog.js",
        "setup": "Declarative: a button with `data-behavior=\"open-dialog\" data-target=\"{id}\"` and `mount(root)`. Imperative: `openDialog(dialogEl, openerEl)`.",
        "api": [
          {
            "name": "openDialog",
            "signature": "openDialog(dialog: HTMLDialogElement, opener?: HTMLElement)",
            "returns": "void",
            "description": "Opens with `showModal()`, closes on backdrop click (`returnValue = \"cancel\"`), returns focus to `opener` (default: the active element) on close."
          }
        ],
        "events": [
          {
            "name": "close (on the dialog)",
            "detail": "dialog.returnValue → value of the pressed button, or \"cancel\"",
            "description": "Native event after any way of closing. Read the form fields here when the value is not `cancel`."
          },
          {
            "name": "cancel (on the dialog)",
            "detail": "—",
            "description": "Native event on Esc; `preventDefault()` to keep unsaved input and ask first."
          }
        ]
      },
      "a11y": [
        "Native modal dialog: focus moves inside, is trapped, Esc closes, focus returns to the opener.",
        "Named by its `h2` (`aria-labelledby`); the ✕ button has an accessible name.",
        "Cancel buttons use `formnovalidate`, so required fields never block closing.",
        "If the dialog contains unsaved input, ask before discarding it on Esc/backdrop (\"Änderungen verwerfen?\")."
      ],
      "guidance": {
        "do": [
          "Title names the task; the primary button repeats the verb (\"Gruppe ändern\").",
          "Keep it short — one topic, few fields."
        ],
        "dont": [
          "Don't open a dialog from a dialog.",
          "Don't use a dialog for messages that need no decision — use an alert or toast.",
          "Don't put a destructive button next to the primary without a confirm."
        ]
      },
      "related": [
        "confirm-dialog",
        "sheet",
        "button",
        "field"
      ]
    },
    {
      "id": "menu",
      "name": "Menu",
      "group": "Overlays",
      "status": "stable",
      "summary": "A popover list of actions or links opened from a button — the home of secondary actions (\"Weitere Aktionen\").",
      "description": [
        "Use a menu for secondary actions of a row, card or page once there are more than ~3 buttons (UX guide §5.4): edit, send email, move, remove. The primary action stays visible outside the menu.",
        "Built on the native `popover` attribute: the trigger button has `popovertarget`, so opening, Esc and outside clicks (light dismiss) need no script. The `menu` behaviour (`js/menu.js`) places the menu next to its trigger (end-aligned, flipping above when there is no room below, mirrored in RTL), keeps `aria-expanded` on the trigger in sync, moves focus to the first item and adds arrow-key navigation.",
        "Items are buttons with `data-action` (handled by the page with one delegated listener) or links with `href`. Separators group related items; destructive items go last, after a separator, in red — and still need undo or a confirm."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "label",
          "items"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Id of the popover (`popovertarget` of the trigger). Must be unique on the page, e.g. `menu-{memberId}`."
          },
          "label": {
            "type": "string",
            "minLength": 1,
            "description": "Accessible name of trigger and menu, specific to the object (\"Weitere Aktionen für Lea Müller\")."
          },
          "triggerText": {
            "type": "string",
            "minLength": 1,
            "description": "Visible trigger text (\"Aktionen\"). Without it the trigger is an icon-only button named by `label`."
          },
          "triggerIcon": {
            "type": "string",
            "default": "more",
            "description": "Icon id of the trigger."
          },
          "triggerVariant": {
            "type": "string",
            "enum": [
              "secondary",
              "ghost"
            ],
            "default": "ghost",
            "description": "Trigger button variant."
          },
          "items": {
            "type": "array",
            "minItems": 1,
            "description": "Menu entries in order: actions, links and separators.",
            "items": {
              "description": "An action/link item or a separator.",
              "oneOf": [
                {
                  "type": "object",
                  "additionalProperties": false,
                  "required": [
                    "label"
                  ],
                  "description": "An action (`action`) or a link (`href`).",
                  "properties": {
                    "label": {
                      "type": "string",
                      "minLength": 1,
                      "description": "Item text, a verb (\"Bearbeiten\", \"E-Mail senden\")."
                    },
                    "action": {
                      "type": "string",
                      "pattern": "^[a-z][a-z0-9-]*$",
                      "description": "Action key written to `data-action` (\"edit\", \"remove\"). Renders a `<button>`."
                    },
                    "href": {
                      "type": "string",
                      "description": "Link target URL. Renders an `<a>` instead of a button."
                    },
                    "icon": {
                      "type": "string",
                      "description": "Icon id shown before the text."
                    },
                    "danger": {
                      "type": "boolean",
                      "default": false,
                      "description": "Destructive item (red text); still needs undo or confirm."
                    },
                    "disabled": {
                      "type": "boolean",
                      "default": false,
                      "description": "Not available now; skipped by arrow keys (buttons only)."
                    }
                  }
                },
                {
                  "type": "object",
                  "additionalProperties": false,
                  "required": [
                    "separator"
                  ],
                  "description": "A divider line between groups of items.",
                  "properties": {
                    "separator": {
                      "const": true,
                      "description": "Marks this entry as a separator."
                    }
                  }
                }
              ]
            }
          }
        }
      },
      "examples": [
        {
          "title": "Row actions with a destructive item",
          "data": {
            "id": "menu-m-1042",
            "label": "Weitere Aktionen für Lea Müller",
            "items": [
              {
                "action": "edit",
                "label": "Bearbeiten",
                "icon": "edit"
              },
              {
                "action": "mail",
                "label": "E-Mail senden",
                "icon": "mail"
              },
              {
                "href": "/members/m-1042/print",
                "label": "Mitgliederausweis drucken",
                "icon": "printer"
              },
              {
                "separator": true
              },
              {
                "action": "remove",
                "label": "Aus Gruppe entfernen",
                "icon": "trash",
                "danger": true
              }
            ]
          },
          "html": "<button class=\"button\" type=\"button\" data-variant=\"ghost\" data-icon-only aria-label=\"Weitere Aktionen für Lea Müller\" popovertarget=\"menu-m-1042\" aria-haspopup=\"menu\" aria-expanded=\"false\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#more\"/></svg></button>\n<div class=\"menu\" id=\"menu-m-1042\" popover role=\"menu\" aria-label=\"Weitere Aktionen für Lea Müller\" data-behavior=\"menu\"><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"edit\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#edit\"/></svg>Bearbeiten</button><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"mail\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#mail\"/></svg>E-Mail senden</button><a class=\"menu__item\" role=\"menuitem\" href=\"/members/m-1042/print\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#printer\"/></svg>Mitgliederausweis drucken</a><div class=\"menu__separator\" role=\"separator\"></div><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"remove\" data-variant=\"danger\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#trash\"/></svg>Aus Gruppe entfernen</button></div>"
        },
        {
          "title": "Text trigger in a page header",
          "data": {
            "id": "menu-exam",
            "label": "Aktionen für Gurtprüfung November",
            "triggerText": "Aktionen",
            "triggerIcon": "chevron-down",
            "triggerVariant": "secondary",
            "items": [
              {
                "action": "export",
                "label": "Teilnehmerliste exportieren",
                "icon": "download"
              },
              {
                "action": "print",
                "label": "Urkunden drucken",
                "icon": "printer",
                "disabled": true
              }
            ]
          },
          "html": "<button class=\"button\" type=\"button\" aria-label=\"Aktionen für Gurtprüfung November\" popovertarget=\"menu-exam\" aria-haspopup=\"menu\" aria-expanded=\"false\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#chevron-down\"/></svg>Aktionen</button>\n<div class=\"menu\" id=\"menu-exam\" popover role=\"menu\" aria-label=\"Aktionen für Gurtprüfung November\" data-behavior=\"menu\"><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"export\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#download\"/></svg>Teilnehmerliste exportieren</button><button class=\"menu__item\" type=\"button\" role=\"menuitem\" data-action=\"print\" disabled><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#printer\"/></svg>Urkunden drucken</button></div>"
        }
      ],
      "markup": {
        "root": ".menu",
        "element": "div",
        "attributes": [
          {
            "name": "popover",
            "values": "—",
            "description": "Native popover (top layer, light dismiss)."
          },
          {
            "name": "role=\"menu\"",
            "values": "—",
            "description": "Menu semantics; items have `role=\"menuitem\"`."
          },
          {
            "name": "data-behavior=\"menu\"",
            "values": "—",
            "description": "Positioning and keyboard support from `js/menu.js`."
          },
          {
            "name": "popovertarget / aria-haspopup / aria-expanded (on trigger)",
            "values": "id / menu / true|false",
            "description": "Connects the trigger; `aria-expanded` is kept in sync by the behaviour."
          }
        ],
        "parts": [
          {
            "name": ".menu__item",
            "description": "`<button role=\"menuitem\" data-action>` or `<a role=\"menuitem\" href>`, 44 px high."
          },
          {
            "name": ".menu__separator",
            "description": "Divider (`role=\"separator\"`)."
          }
        ],
        "states": [
          {
            "name": ":popover-open",
            "description": "Shown."
          },
          {
            "name": ".menu__item:hover / :focus-visible",
            "description": "Highlighted row."
          },
          {
            "name": ".menu__item[data-variant=\"danger\"]",
            "description": "Destructive item in red."
          },
          {
            "name": ".menu__item:disabled",
            "description": "Not available; skipped by arrow keys."
          }
        ]
      },
      "cssVars": [],
      "behaviour": {
        "module": "js/menu.js",
        "setup": "`data-behavior=\"menu\"` on the popover + `mount(root)`. Opening/closing itself is native (`popovertarget`). Handle choices with one delegated listener: `root.addEventListener(\"click\", e => { const a = e.target.closest(\"[data-action]\"); if (a) … a.dataset.action })`.",
        "api": [],
        "events": [
          {
            "name": "click on [data-action]",
            "detail": "event.target.closest(\"[data-action]\").dataset.action",
            "description": "An action item was chosen; the menu closes itself."
          },
          {
            "name": "toggle (on the menu)",
            "detail": "event.newState → \"open\" | \"closed\"",
            "description": "Native popover event; the behaviour uses it to place the menu and focus the first item."
          }
        ]
      },
      "a11y": [
        "Keyboard: opening focuses the first item; ↓/↑ move (wrapping), Home/End jump to first/last, Esc closes and returns focus to the trigger (native), Tab closes the menu.",
        "Icon-only triggers are named by `label` — make it specific (\"Weitere Aktionen für Lea Müller\"), not just \"Mehr\".",
        "Destructive items are marked by text, not only by red colour."
      ],
      "guidance": {
        "do": [
          "Keep the primary action outside the menu.",
          "Put destructive items last, after a separator."
        ],
        "dont": [
          "Don't hide the only way to do a common task in a menu.",
          "Don't nest menus.",
          "Don't put form controls into a menu."
        ]
      },
      "related": [
        "button",
        "list",
        "sheet"
      ]
    },
    {
      "id": "app-shell",
      "name": "App shell",
      "group": "Navigation",
      "status": "stable",
      "summary": "The frame of every app: skip link, top bar, sidebar (desktop), main region and bottom tab bar (mobile).",
      "description": [
        "All apps use one shell; modules plug into it and never draw their own header, sidebar or \"back to hub\" bar (UX guide §3.1). Below 1024 px it shows the top bar and a bottom tab bar with at most 5 items (4 areas + \"Mehr\"); from 1024 px the sidebar replaces the tab bar.",
        "The top bar holds the page title (or the club switcher), an optional back link for detail pages on mobile, and up to three icon actions (search, notifications, user menu). The sidebar holds the brand, navigation sections (max ~7 top-level groups) and an optional footer.",
        "`render` outputs an **empty** `<main class=\"shell__main\" id=\"{mainId}\" tabindex=\"-1\">`. Page content is not shell data: the app renders each route into that element (e.g. `render(view, document.getElementById(\"main\"))`). Never pass page HTML through data.",
        "The schema enforces at most 5 tab bar items. Mark exactly one item `current` in each navigation. On route change, update `aria-current`, set the document title (\"{Seite} · {Bereich} · {Verein}\") and move focus to the page `h1` (UX guide §3.3)."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "title",
          "sidebar",
          "tabbar"
        ],
        "properties": {
          "title": {
            "type": "string",
            "minLength": 1,
            "description": "Page title in the top bar (\"Anmeldungen\")."
          },
          "titleHidden": {
            "type": "boolean",
            "default": false,
            "description": "Hide the title visually (e.g. home screens whose `h1` greets the user); it stays for screen readers."
          },
          "skipLabel": {
            "type": "string",
            "default": "Zum Inhalt springen",
            "description": "Text of the skip link."
          },
          "mainId": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "default": "main",
            "description": "Id of the main region; target of the skip link. The app renders page content into it."
          },
          "navLabel": {
            "type": "string",
            "default": "Hauptnavigation",
            "description": "Accessible name of sidebar and tab bar (only one is visible at a time)."
          },
          "clubSwitcher": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "current"
            ],
            "properties": {
              "current": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "name"
                ],
                "description": "The active club.",
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "description": "Full club name (\"Taekwondo Bern\"), used in the accessible label."
                  },
                  "shortName": {
                    "type": "string",
                    "description": "Short visible name when space is tight (\"TKD Bern\"). Defaults to `name`."
                  },
                  "logo": {
                    "type": "string",
                    "description": "Logo image URL (src); decorative, square."
                  }
                }
              },
              "menuId": {
                "type": "string",
                "pattern": "^[A-Za-z][\\w-]*$",
                "default": "club-menu",
                "description": "Id of the popover menu (`popovertarget`). Unique on the page."
              },
              "clubs": {
                "type": "array",
                "description": "All clubs of the person (including the current one), from their memberships. The menu is rendered only with 2 or more.",
                "items": {
                  "type": "object",
                  "additionalProperties": false,
                  "required": [
                    "id",
                    "name",
                    "href"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "pattern": "^[A-Za-z0-9_-]+$",
                      "description": "Club id (tenant), written to `data-club`."
                    },
                    "name": {
                      "type": "string",
                      "minLength": 1,
                      "description": "Club name shown in the menu."
                    },
                    "href": {
                      "type": "string",
                      "description": "Home URL of that club (switching target)."
                    },
                    "logo": {
                      "type": "string",
                      "description": "Logo image URL (src)."
                    },
                    "current": {
                      "type": "boolean",
                      "default": false,
                      "description": "This is the active club (check mark, `aria-current`)."
                    }
                  }
                }
              }
            },
            "description": "Optional club switcher at the start of the top bar (see Club switcher)."
          },
          "back": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "label",
              "href"
            ],
            "description": "Back link at the start of the top bar, for detail pages (points to the parent list, not to browser history).",
            "properties": {
              "label": {
                "type": "string",
                "minLength": 1,
                "description": "Parent page name (\"Mitglieder\"), not \"Zurück\"."
              },
              "href": {
                "type": "string",
                "description": "URL of the parent page."
              }
            }
          },
          "actions": {
            "type": "array",
            "maxItems": 3,
            "description": "Top bar actions (Button data), usually ghost icon-only buttons with `ariaLabel` (search, notifications).",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [],
              "properties": {
                "label": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Visible text. Required unless `iconOnly`."
                },
                "variant": {
                  "type": "string",
                  "enum": [
                    "secondary",
                    "primary",
                    "ghost",
                    "danger",
                    "danger-ghost"
                  ],
                  "default": "secondary",
                  "description": "Visual weight / meaning."
                },
                "size": {
                  "type": "string",
                  "enum": [
                    "sm",
                    "md",
                    "lg"
                  ],
                  "default": "md",
                  "description": "`sm` (36 px) only in dense desktop tables; `lg` (52 px) for the single main action of a screen."
                },
                "icon": {
                  "type": "string",
                  "description": "Icon id from the sprite, shown before the label."
                },
                "iconOnly": {
                  "type": "boolean",
                  "default": false,
                  "description": "Square button showing only `icon`. Requires `ariaLabel`."
                },
                "ariaLabel": {
                  "type": "string",
                  "description": "Accessible name when the visible label is missing or ambiguous (\"Weitere Aktionen für Lea Müller\")."
                },
                "href": {
                  "type": "string",
                  "description": "Render an `<a>` that looks like a button (navigation, not actions)."
                },
                "type": {
                  "type": "string",
                  "enum": [
                    "button",
                    "submit",
                    "reset"
                  ],
                  "default": "button",
                  "description": "Button type inside forms."
                },
                "value": {
                  "type": "string",
                  "description": "Form value, e.g. `cancel` / `confirm` inside `<form method=\"dialog\">`."
                },
                "name": {
                  "type": "string",
                  "description": "Form field name when the button submits a value."
                },
                "disabled": {
                  "type": "boolean",
                  "default": false,
                  "description": "Not available now. Prefer explaining why nearby over silently disabling."
                },
                "busy": {
                  "type": "boolean",
                  "default": false,
                  "description": "Action in progress (`aria-busy`)."
                },
                "formNoValidate": {
                  "type": "boolean",
                  "default": false,
                  "description": "Submit without native validation — use on \"Abbrechen\" inside `<form method=\"dialog\">` so required fields do not block closing."
                },
                "block": {
                  "type": "string",
                  "enum": [
                    "always",
                    "mobile"
                  ],
                  "description": "Full width always, or only below 768 px."
                },
                "behavior": {
                  "type": "string",
                  "description": "Behaviour hook (`data-behavior`), e.g. `open-dialog`, `theme-toggle`."
                },
                "action": {
                  "type": "string",
                  "pattern": "^[a-z][a-zA-Z0-9-]*$",
                  "description": "Screen action name (`data-action`). Declared in the `actions` of the screen with a handler and a risk class; the engine adds undo or confirm accordingly."
                },
                "actionId": {
                  "type": "string",
                  "pattern": "^[A-Za-z0-9_-]+$",
                  "description": "Id of the thing the action applies to (`data-id`), when the button is not inside an element that has one."
                },
                "target": {
                  "type": "string",
                  "description": "Id of the element a behaviour acts on (`data-target`), e.g. the dialog to open."
                },
                "popoverTarget": {
                  "type": "string",
                  "description": "Id of a popover menu this button toggles (`popovertarget`)."
                }
              },
              "description": "One button."
            }
          },
          "sidebar": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "brand",
              "sections"
            ],
            "description": "Desktop sidebar.",
            "properties": {
              "brand": {
                "type": "string",
                "minLength": 1,
                "description": "Brand line at the top (\"TKD Bern · Admin\")."
              },
              "logo": {
                "type": "string",
                "description": "Club logo image URL (src), decorative."
              },
              "sections": {
                "type": "array",
                "minItems": 1,
                "maxItems": 7,
                "description": "Groups of nav items. The first usually has no label.",
                "items": {
                  "type": "object",
                  "additionalProperties": false,
                  "required": [
                    "items"
                  ],
                  "properties": {
                    "label": {
                      "type": "string",
                      "description": "Section heading (\"Verein\"); omit for the first group."
                    },
                    "items": {
                      "type": "array",
                      "minItems": 1,
                      "description": "Items of the section.",
                      "items": {
                        "type": "object",
                        "additionalProperties": false,
                        "required": [
                          "label",
                          "href",
                          "icon"
                        ],
                        "description": "One navigation item.",
                        "properties": {
                          "label": {
                            "type": "string",
                            "minLength": 1,
                            "description": "Area name (\"Heute\", \"Mitglieder\"). Short — tab bar labels are tiny."
                          },
                          "href": {
                            "type": "string",
                            "description": "Route URL of the area."
                          },
                          "icon": {
                            "type": "string",
                            "description": "Icon id from the sprite (\"home\", \"users\", \"list\", \"award\", \"more\")."
                          },
                          "current": {
                            "type": "boolean",
                            "default": false,
                            "description": "The current area (`aria-current=\"page\"`). Exactly one item per navigation."
                          },
                          "count": {
                            "type": "integer",
                            "minimum": 0,
                            "description": "Number of items needing attention (count bubble); 0 shows nothing."
                          },
                          "countLabel": {
                            "type": "string",
                            "description": "Accessible text for the count (\"3 offene Anmeldungen\"); see Count bubble."
                          }
                        }
                      }
                    }
                  }
                }
              },
              "footer": {
                "type": "array",
                "description": "Items pinned to the bottom (help, settings).",
                "items": {
                  "type": "object",
                  "additionalProperties": false,
                  "required": [
                    "label",
                    "href",
                    "icon"
                  ],
                  "description": "One navigation item.",
                  "properties": {
                    "label": {
                      "type": "string",
                      "minLength": 1,
                      "description": "Area name (\"Heute\", \"Mitglieder\"). Short — tab bar labels are tiny."
                    },
                    "href": {
                      "type": "string",
                      "description": "Route URL of the area."
                    },
                    "icon": {
                      "type": "string",
                      "description": "Icon id from the sprite (\"home\", \"users\", \"list\", \"award\", \"more\")."
                    },
                    "current": {
                      "type": "boolean",
                      "default": false,
                      "description": "The current area (`aria-current=\"page\"`). Exactly one item per navigation."
                    },
                    "count": {
                      "type": "integer",
                      "minimum": 0,
                      "description": "Number of items needing attention (count bubble); 0 shows nothing."
                    },
                    "countLabel": {
                      "type": "string",
                      "description": "Accessible text for the count (\"3 offene Anmeldungen\"); see Count bubble."
                    }
                  }
                }
              }
            }
          },
          "tabbar": {
            "type": "array",
            "minItems": 2,
            "maxItems": 5,
            "description": "Mobile tab bar: max 5 items (4 areas by frequency for the role + \"Mehr\").",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "label",
                "href",
                "icon"
              ],
              "description": "One navigation item.",
              "properties": {
                "label": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Area name (\"Heute\", \"Mitglieder\"). Short — tab bar labels are tiny."
                },
                "href": {
                  "type": "string",
                  "description": "Route URL of the area."
                },
                "icon": {
                  "type": "string",
                  "description": "Icon id from the sprite (\"home\", \"users\", \"list\", \"award\", \"more\")."
                },
                "current": {
                  "type": "boolean",
                  "default": false,
                  "description": "The current area (`aria-current=\"page\"`). Exactly one item per navigation."
                },
                "count": {
                  "type": "integer",
                  "minimum": 0,
                  "description": "Number of items needing attention (count bubble); 0 shows nothing."
                },
                "countLabel": {
                  "type": "string",
                  "description": "Accessible text for the count (\"3 offene Anmeldungen\"); see Count bubble."
                }
              }
            }
          }
        }
      },
      "examples": [
        {
          "title": "Admin app (registrations)",
          "data": {
            "title": "Anmeldungen",
            "actions": [
              {
                "icon": "search",
                "iconOnly": true,
                "variant": "ghost",
                "ariaLabel": "Suchen"
              },
              {
                "icon": "bell",
                "iconOnly": true,
                "variant": "ghost",
                "ariaLabel": "Mitteilungen (1 neu)"
              }
            ],
            "sidebar": {
              "brand": "TKD Bern · Admin",
              "sections": [
                {
                  "items": [
                    {
                      "label": "Übersicht",
                      "href": "/admin/",
                      "icon": "home"
                    },
                    {
                      "label": "Mitglieder",
                      "href": "/admin/members",
                      "icon": "users",
                      "current": true,
                      "count": 3,
                      "countLabel": "3 offene Anmeldungen"
                    },
                    {
                      "label": "Prüfungen",
                      "href": "/admin/belt-tests",
                      "icon": "award"
                    },
                    {
                      "label": "Events",
                      "href": "/admin/events",
                      "icon": "calendar"
                    },
                    {
                      "label": "Kommunikation",
                      "href": "/admin/comms",
                      "icon": "mail"
                    }
                  ]
                },
                {
                  "label": "Verein",
                  "items": [
                    {
                      "label": "Einstellungen",
                      "href": "/admin/settings",
                      "icon": "settings"
                    }
                  ]
                }
              ]
            },
            "tabbar": [
              {
                "label": "Übersicht",
                "href": "/admin/",
                "icon": "home"
              },
              {
                "label": "Mitglieder",
                "href": "/admin/members",
                "icon": "users",
                "current": true,
                "count": 3,
                "countLabel": "3 offene Anmeldungen"
              },
              {
                "label": "Prüfungen",
                "href": "/admin/belt-tests",
                "icon": "award"
              },
              {
                "label": "Mehr",
                "href": "/admin/more",
                "icon": "more"
              }
            ]
          },
          "html": "<div class=\"shell\">\n  <a class=\"skip-link\" href=\"#main\">Zum Inhalt springen</a>\n  <header class=\"topbar\">\n    \n    <span class=\"topbar__title\">Anmeldungen</span>\n    <div class=\"topbar__actions\"><button class=\"button\" type=\"button\" data-variant=\"ghost\" data-icon-only aria-label=\"Suchen\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#search\"/></svg></button><button class=\"button\" type=\"button\" data-variant=\"ghost\" data-icon-only aria-label=\"Mitteilungen (1 neu)\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#bell\"/></svg></button></div>\n  </header>\n  <nav class=\"sidebar\" aria-label=\"Hauptnavigation\">\n    <div class=\"sidebar__brand\">TKD Bern · Admin</div>\n    <a class=\"nav-item\" href=\"/admin/\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#home\"/></svg>Übersicht</a><a class=\"nav-item\" href=\"/admin/members\" aria-current=\"page\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#users\"/></svg>Mitglieder<span class=\"count\"><span aria-hidden=\"true\">3</span><span class=\"u-visually-hidden\">3 offene Anmeldungen</span></span></a><a class=\"nav-item\" href=\"/admin/belt-tests\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#award\"/></svg>Prüfungen</a><a class=\"nav-item\" href=\"/admin/events\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#calendar\"/></svg>Events</a><a class=\"nav-item\" href=\"/admin/comms\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#mail\"/></svg>Kommunikation</a><p class=\"sidebar__section\">Verein</p><a class=\"nav-item\" href=\"/admin/settings\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#settings\"/></svg>Einstellungen</a>\n    \n  </nav>\n  <main class=\"shell__main\" id=\"main\" tabindex=\"-1\"></main>\n  <nav class=\"tabbar\" aria-label=\"Hauptnavigation\"><a class=\"tabbar__item\" href=\"/admin/\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#home\"/></svg>Übersicht</a><a class=\"tabbar__item\" href=\"/admin/members\" aria-current=\"page\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#users\"/></svg>Mitglieder<span class=\"count\"><span aria-hidden=\"true\">3</span><span class=\"u-visually-hidden\">3 offene Anmeldungen</span></span></a><a class=\"tabbar__item\" href=\"/admin/belt-tests\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#award\"/></svg>Prüfungen</a><a class=\"tabbar__item\" href=\"/admin/more\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#more\"/></svg>Mehr</a></nav>\n</div>"
        },
        {
          "title": "Trainer app with club switcher",
          "data": {
            "title": "Heute",
            "titleHidden": true,
            "clubSwitcher": {
              "current": {
                "name": "Taekwondo Bern",
                "shortName": "TKD Bern"
              },
              "clubs": [
                {
                  "id": "tkd-bern",
                  "name": "Taekwondo Bern",
                  "href": "/c/tkd-bern/",
                  "current": true
                },
                {
                  "id": "tkd-thun",
                  "name": "Taekwondo Club Thun",
                  "href": "/c/tkd-thun/"
                }
              ]
            },
            "actions": [
              {
                "icon": "bell",
                "iconOnly": true,
                "variant": "ghost",
                "ariaLabel": "Mitteilungen (1 neu)"
              }
            ],
            "sidebar": {
              "brand": "TKD Bern · Trainer",
              "sections": [
                {
                  "items": [
                    {
                      "label": "Heute",
                      "href": "/trainer/",
                      "icon": "home",
                      "current": true
                    },
                    {
                      "label": "Klassen",
                      "href": "/trainer/classes",
                      "icon": "users"
                    },
                    {
                      "label": "Pläne",
                      "href": "/trainer/plans",
                      "icon": "list"
                    },
                    {
                      "label": "Prüfung",
                      "href": "/trainer/belt-test",
                      "icon": "award"
                    }
                  ]
                }
              ],
              "footer": [
                {
                  "label": "Hilfe",
                  "href": "/help",
                  "icon": "info"
                }
              ]
            },
            "tabbar": [
              {
                "label": "Heute",
                "href": "/trainer/",
                "icon": "home",
                "current": true
              },
              {
                "label": "Klassen",
                "href": "/trainer/classes",
                "icon": "users"
              },
              {
                "label": "Pläne",
                "href": "/trainer/plans",
                "icon": "list"
              },
              {
                "label": "Prüfung",
                "href": "/trainer/belt-test",
                "icon": "award"
              },
              {
                "label": "Mehr",
                "href": "/trainer/more",
                "icon": "more"
              }
            ]
          },
          "html": "<div class=\"shell\">\n  <a class=\"skip-link\" href=\"#main\">Zum Inhalt springen</a>\n  <header class=\"topbar\">\n    <button class=\"club-switcher\" type=\"button\" aria-label=\"Verein wechseln: Taekwondo Bern\" popovertarget=\"club-menu\" aria-haspopup=\"menu\" aria-expanded=\"false\">TKD Bern<svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#chevron-down\"/></svg></button>\n<div class=\"menu\" id=\"club-menu\" popover role=\"menu\" data-behavior=\"menu\"><a class=\"menu__item\" role=\"menuitem\" href=\"/c/tkd-bern/\" data-club=\"tkd-bern\" aria-current=\"true\"><span class=\"u-grow\">Taekwondo Bern</span><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></a><a class=\"menu__item\" role=\"menuitem\" href=\"/c/tkd-thun/\" data-club=\"tkd-thun\"><span class=\"u-grow\">Taekwondo Club Thun</span></a></div>\n    <span class=\"topbar__title u-visually-hidden\">Heute</span>\n    <span class=\"u-grow\"></span><div class=\"topbar__actions\"><button class=\"button\" type=\"button\" data-variant=\"ghost\" data-icon-only aria-label=\"Mitteilungen (1 neu)\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#bell\"/></svg></button></div>\n  </header>\n  <nav class=\"sidebar\" aria-label=\"Hauptnavigation\">\n    <div class=\"sidebar__brand\">TKD Bern · Trainer</div>\n    <a class=\"nav-item\" href=\"/trainer/\" aria-current=\"page\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#home\"/></svg>Heute</a><a class=\"nav-item\" href=\"/trainer/classes\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#users\"/></svg>Klassen</a><a class=\"nav-item\" href=\"/trainer/plans\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#list\"/></svg>Pläne</a><a class=\"nav-item\" href=\"/trainer/belt-test\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#award\"/></svg>Prüfung</a>\n    <div class=\"sidebar__footer\"><a class=\"nav-item\" href=\"/help\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#info\"/></svg>Hilfe</a></div>\n  </nav>\n  <main class=\"shell__main\" id=\"main\" tabindex=\"-1\"></main>\n  <nav class=\"tabbar\" aria-label=\"Hauptnavigation\"><a class=\"tabbar__item\" href=\"/trainer/\" aria-current=\"page\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#home\"/></svg>Heute</a><a class=\"tabbar__item\" href=\"/trainer/classes\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#users\"/></svg>Klassen</a><a class=\"tabbar__item\" href=\"/trainer/plans\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#list\"/></svg>Pläne</a><a class=\"tabbar__item\" href=\"/trainer/belt-test\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#award\"/></svg>Prüfung</a><a class=\"tabbar__item\" href=\"/trainer/more\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#more\"/></svg>Mehr</a></nav>\n</div>"
        }
      ],
      "markup": {
        "root": ".shell",
        "element": "div",
        "attributes": [
          {
            "name": "aria-current=\"page\"",
            "values": "—",
            "description": "On the active `.nav-item` / `.tabbar__item`."
          },
          {
            "name": "aria-label (nav)",
            "values": "text",
            "description": "Names sidebar and tab bar (\"Hauptnavigation\")."
          }
        ],
        "parts": [
          {
            "name": ".skip-link",
            "description": "First focusable element; jumps to `#{mainId}`. Visible on focus."
          },
          {
            "name": ".topbar / .topbar__title / .topbar__actions",
            "description": "Sticky top bar with title and actions; may start with `.club-switcher` or `.back-link`."
          },
          {
            "name": ".sidebar / .sidebar__brand / .sidebar__section / .sidebar__footer",
            "description": "Desktop navigation (≥ 1024 px)."
          },
          {
            "name": ".nav-item",
            "description": "Sidebar link with icon, label and optional `.count`."
          },
          {
            "name": ".shell__main",
            "description": "Empty main region (`tabindex=\"-1\"`) that the app fills."
          },
          {
            "name": ".tabbar / .tabbar__item",
            "description": "Mobile bottom navigation (< 1024 px), max 5 items."
          }
        ],
        "states": [
          {
            "name": "[aria-current=\"page\"]",
            "description": "Active item. Sidebar: accent-subtle background, accent-text label and icon, 3 px accent bar on the start edge. Tab bar: accent-text + top indicator."
          },
          {
            "name": ".skip-link:focus",
            "description": "Skip link slides into view."
          }
        ]
      },
      "cssVars": [
        {
          "name": "--sidebar-w",
          "description": "Sidebar width (token)."
        },
        {
          "name": "--topbar-h / --tabbar-h",
          "description": "Bar heights (tokens)."
        },
        {
          "name": "--safe-top / --safe-bottom",
          "description": "Safe-area insets for notched phones."
        }
      ],
      "a11y": [
        "Skip link first; main has `tabindex=\"-1\"` so the skip link and route changes can move focus there.",
        "Landmarks: `header`, `nav` (labelled), `main`. Sidebar and tab bar share a label but only one is displayed at a time (the other is `display: none`).",
        "The active item has `aria-current=\"page\"`, not only the accent colour.",
        "Counts on nav items carry an accessible label (\"Mitglieder, 3 offene Anmeldungen\").",
        "Icons in nav items are decorative; labels are always visible (no icon-only navigation)."
      ],
      "guidance": {
        "do": [
          "Order items by frequency for the role (Trainer: Heute, Klassen, Pläne, Prüfung, Mehr).",
          "Build navigation from the session's capabilities: hide what the club didn't buy or the user may not use.",
          "Keep the club visible (switcher or brand) on every screen."
        ],
        "dont": [
          "Don't exceed 5 tab bar items or ~7 sidebar groups.",
          "Don't show disabled or locked teaser items.",
          "Don't let modules render their own header or sidebar."
        ]
      },
      "related": [
        "page-header",
        "club-switcher",
        "count",
        "tabs"
      ]
    },
    {
      "id": "club-switcher",
      "name": "Club switcher",
      "group": "Navigation",
      "status": "stable",
      "summary": "Shows the active club (logo + name) and, for people in several clubs, opens a menu to switch.",
      "description": [
        "One identity can belong to several clubs. The active club is always visible in the top bar or sidebar header, so a trainer of two clubs never records attendance in the wrong one (UX guide §3.4).",
        "With two or more `clubs`, the switcher is a button that opens a menu (popover, `js/menu.js`) listing every club membership with its logo; the active one is marked with a check and `aria-current`. Each entry is a link to that club's home — switching reloads navigation and data and never lands on a page of the previous club.",
        "With one club (or no `clubs`), only the logo and name are shown as static text. Never render a menu with a single entry."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "current"
        ],
        "properties": {
          "current": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "name"
            ],
            "description": "The active club.",
            "properties": {
              "name": {
                "type": "string",
                "minLength": 1,
                "description": "Full club name (\"Taekwondo Bern\"), used in the accessible label."
              },
              "shortName": {
                "type": "string",
                "description": "Short visible name when space is tight (\"TKD Bern\"). Defaults to `name`."
              },
              "logo": {
                "type": "string",
                "description": "Logo image URL (src); decorative, square."
              }
            }
          },
          "menuId": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "default": "club-menu",
            "description": "Id of the popover menu (`popovertarget`). Unique on the page."
          },
          "clubs": {
            "type": "array",
            "description": "All clubs of the person (including the current one), from their memberships. The menu is rendered only with 2 or more.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "id",
                "name",
                "href"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "pattern": "^[A-Za-z0-9_-]+$",
                  "description": "Club id (tenant), written to `data-club`."
                },
                "name": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Club name shown in the menu."
                },
                "href": {
                  "type": "string",
                  "description": "Home URL of that club (switching target)."
                },
                "logo": {
                  "type": "string",
                  "description": "Logo image URL (src)."
                },
                "current": {
                  "type": "boolean",
                  "default": false,
                  "description": "This is the active club (check mark, `aria-current`)."
                }
              }
            }
          }
        }
      },
      "examples": [
        {
          "title": "Member of two clubs (with menu)",
          "data": {
            "current": {
              "name": "Taekwondo Bern",
              "shortName": "TKD Bern",
              "logo": "/media/clubs/tkd-bern.svg"
            },
            "clubs": [
              {
                "id": "tkd-bern",
                "name": "Taekwondo Bern",
                "href": "/c/tkd-bern/",
                "logo": "/media/clubs/tkd-bern.svg",
                "current": true
              },
              {
                "id": "tkd-thun",
                "name": "Taekwondo Club Thun",
                "href": "/c/tkd-thun/",
                "logo": "/media/clubs/tkd-thun.svg"
              }
            ]
          },
          "html": "<button class=\"club-switcher\" type=\"button\" aria-label=\"Verein wechseln: Taekwondo Bern\" popovertarget=\"club-menu\" aria-haspopup=\"menu\" aria-expanded=\"false\"><span class=\"avatar\" data-size=\"sm\" data-variant=\"club\" aria-hidden=\"true\"><img src=\"/media/clubs/tkd-bern.svg\" alt=\"\"></span>TKD Bern<svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#chevron-down\"/></svg></button>\n<div class=\"menu\" id=\"club-menu\" popover role=\"menu\" data-behavior=\"menu\"><a class=\"menu__item\" role=\"menuitem\" href=\"/c/tkd-bern/\" data-club=\"tkd-bern\" aria-current=\"true\"><span class=\"avatar\" data-size=\"sm\" data-variant=\"club\" aria-hidden=\"true\"><img src=\"/media/clubs/tkd-bern.svg\" alt=\"\"></span><span class=\"u-grow\">Taekwondo Bern</span><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></a><a class=\"menu__item\" role=\"menuitem\" href=\"/c/tkd-thun/\" data-club=\"tkd-thun\"><span class=\"avatar\" data-size=\"sm\" data-variant=\"club\" aria-hidden=\"true\"><img src=\"/media/clubs/tkd-thun.svg\" alt=\"\"></span><span class=\"u-grow\">Taekwondo Club Thun</span></a></div>"
        },
        {
          "title": "Single club (static)",
          "data": {
            "current": {
              "name": "Taekwondo Bern",
              "shortName": "TKD Bern"
            }
          },
          "html": "<span class=\"club-switcher\">TKD Bern</span>"
        }
      ],
      "markup": {
        "root": ".club-switcher",
        "element": "button | span",
        "attributes": [
          {
            "name": "aria-label",
            "values": "\"Verein wechseln: {name}\"",
            "description": "Accessible name of the switch button (states the active club)."
          },
          {
            "name": "popovertarget / aria-haspopup=\"menu\" / aria-expanded",
            "values": "menu id",
            "description": "Opens the club menu (button only)."
          }
        ],
        "parts": [
          {
            "name": ".avatar[data-variant=\"club\"][data-size=\"sm\"] > img",
            "description": "Club logo as a 32 px club avatar (`--radius-md`), decorative (`alt=\"\"`)."
          },
          {
            "name": "text",
            "description": "Club (short) name, semibold."
          },
          {
            "name": ".icon (chevron-down)",
            "description": "Indicates the menu (button only, 16 px, muted)."
          },
          {
            "name": ".menu / .menu__item",
            "description": "Popover with one link per club: club avatar (logo) + name; the current club has `aria-current=\"true\"`, semibold text and a trailing `check` icon."
          }
        ],
        "states": [
          {
            "name": ":hover",
            "description": "Surface shift (button, pointer devices)."
          },
          {
            "name": "[aria-expanded=\"true\"]",
            "description": "Menu open (set by menu.js)."
          },
          {
            "name": ".menu__item[aria-current=\"true\"]",
            "description": "Current club: semibold label, accent-text check icon."
          }
        ]
      },
      "cssVars": [],
      "behaviour": {
        "module": "js/menu.js",
        "setup": "`mount(root)` activates `data-behavior=\"menu\"` on the popover: it positions the menu next to the button, focuses the first item and adds arrow keys, Home/End and Tab-to-close; Esc and outside clicks close natively (popover light dismiss).",
        "api": [],
        "events": [
          {
            "name": "click on .menu__item",
            "detail": "link `href` + `data-club`",
            "description": "Navigates to the chosen club's home (full reload of navigation and data)."
          }
        ]
      },
      "a11y": [
        "The button's name states the active club (\"Verein wechseln: Taekwondo Bern\") even when only the short name is visible.",
        "Menu items are links with `role=\"menuitem\"`; the active club has `aria-current=\"true\"` and a check icon, not colour alone.",
        "Logos are decorative; the club name is always text."
      ],
      "guidance": {
        "do": [
          "Keep the active club name visible on every screen.",
          "Land on the chosen club's home after switching."
        ],
        "dont": [
          "Don't show the switcher menu for people with one club.",
          "Don't keep the current page when switching — it belongs to the old club."
        ]
      },
      "related": [
        "app-shell",
        "avatar"
      ]
    },
    {
      "id": "page-header",
      "name": "Page header",
      "group": "Navigation",
      "status": "stable",
      "summary": "The top of every page: optional back link, the `h1`, one line of context and the page's actions.",
      "description": [
        "Every page starts with a page header (UX guide §3.2): an `h1` with the page title, an optional one-line `meta` (\"3 warten auf Freigabe · Formular online\") and at most one primary action plus up to two secondary ones. Further actions go into an overflow menu composed by the app.",
        "Detail pages get a `back` link to their parent list, named after it (\"Mitglieder\"), not \"Zurück\" and not browser history.",
        "The title is the target of focus after a route change: `render` gives the `h1` the id `{id}` (default `page-title`) and `tabindex=\"-1\"`, so the router can call `document.getElementById(\"page-title\").focus()` and screen readers announce the new page.",
        "On narrow screens the actions wrap below the title. `render` rejects more than one `primary` action."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "title"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "default": "page-title",
            "description": "Id of the `h1` (focus target after navigation)."
          },
          "title": {
            "type": "string",
            "minLength": 1,
            "description": "Page title — the same words as the nav item or the object name (\"Anmeldungen\", \"Lea Müller\")."
          },
          "meta": {
            "type": "string",
            "description": "One line of context or summary (\"3 warten auf Freigabe · Formular online\")."
          },
          "back": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "label",
              "href"
            ],
            "description": "Link to the parent page, shown above the title.",
            "properties": {
              "label": {
                "type": "string",
                "minLength": 1,
                "description": "Parent page name (\"Mitglieder\")."
              },
              "href": {
                "type": "string",
                "description": "URL of the parent page."
              }
            }
          },
          "actions": {
            "type": "array",
            "maxItems": 3,
            "description": "Page actions (Button data), primary last. At most one `primary`.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [],
              "properties": {
                "label": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Visible text. Required unless `iconOnly`."
                },
                "variant": {
                  "type": "string",
                  "enum": [
                    "secondary",
                    "primary",
                    "ghost",
                    "danger",
                    "danger-ghost"
                  ],
                  "default": "secondary",
                  "description": "Visual weight / meaning."
                },
                "size": {
                  "type": "string",
                  "enum": [
                    "sm",
                    "md",
                    "lg"
                  ],
                  "default": "md",
                  "description": "`sm` (36 px) only in dense desktop tables; `lg` (52 px) for the single main action of a screen."
                },
                "icon": {
                  "type": "string",
                  "description": "Icon id from the sprite, shown before the label."
                },
                "iconOnly": {
                  "type": "boolean",
                  "default": false,
                  "description": "Square button showing only `icon`. Requires `ariaLabel`."
                },
                "ariaLabel": {
                  "type": "string",
                  "description": "Accessible name when the visible label is missing or ambiguous (\"Weitere Aktionen für Lea Müller\")."
                },
                "href": {
                  "type": "string",
                  "description": "Render an `<a>` that looks like a button (navigation, not actions)."
                },
                "type": {
                  "type": "string",
                  "enum": [
                    "button",
                    "submit",
                    "reset"
                  ],
                  "default": "button",
                  "description": "Button type inside forms."
                },
                "value": {
                  "type": "string",
                  "description": "Form value, e.g. `cancel` / `confirm` inside `<form method=\"dialog\">`."
                },
                "name": {
                  "type": "string",
                  "description": "Form field name when the button submits a value."
                },
                "disabled": {
                  "type": "boolean",
                  "default": false,
                  "description": "Not available now. Prefer explaining why nearby over silently disabling."
                },
                "busy": {
                  "type": "boolean",
                  "default": false,
                  "description": "Action in progress (`aria-busy`)."
                },
                "formNoValidate": {
                  "type": "boolean",
                  "default": false,
                  "description": "Submit without native validation — use on \"Abbrechen\" inside `<form method=\"dialog\">` so required fields do not block closing."
                },
                "block": {
                  "type": "string",
                  "enum": [
                    "always",
                    "mobile"
                  ],
                  "description": "Full width always, or only below 768 px."
                },
                "behavior": {
                  "type": "string",
                  "description": "Behaviour hook (`data-behavior`), e.g. `open-dialog`, `theme-toggle`."
                },
                "action": {
                  "type": "string",
                  "pattern": "^[a-z][a-zA-Z0-9-]*$",
                  "description": "Screen action name (`data-action`). Declared in the `actions` of the screen with a handler and a risk class; the engine adds undo or confirm accordingly."
                },
                "actionId": {
                  "type": "string",
                  "pattern": "^[A-Za-z0-9_-]+$",
                  "description": "Id of the thing the action applies to (`data-id`), when the button is not inside an element that has one."
                },
                "target": {
                  "type": "string",
                  "description": "Id of the element a behaviour acts on (`data-target`), e.g. the dialog to open."
                },
                "popoverTarget": {
                  "type": "string",
                  "description": "Id of a popover menu this button toggles (`popovertarget`)."
                }
              },
              "description": "One button."
            }
          }
        }
      },
      "examples": [
        {
          "title": "List page with action",
          "data": {
            "title": "Anmeldungen",
            "meta": "3 warten auf Freigabe · Formular online",
            "actions": [
              {
                "label": "Formular ansehen",
                "href": "/public/registration",
                "icon": "external-link"
              }
            ]
          },
          "html": "<header class=\"page-header\">\n  <div class=\"page-header__text\">\n    \n    <h1 class=\"page-header__title\" id=\"page-title\" tabindex=\"-1\">Anmeldungen</h1>\n    <p class=\"page-header__meta\">3 warten auf Freigabe · Formular online</p>\n  </div>\n  <div class=\"button-row\"><a class=\"button\" href=\"/public/registration\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#external-link\"/></svg>Formular ansehen</a></div>\n</header>"
        },
        {
          "title": "Detail page with back link",
          "data": {
            "title": "Lea Müller",
            "meta": "Kinder 7–10 · 6. Kup · seit 2024",
            "back": {
              "label": "Mitglieder",
              "href": "/admin/members"
            },
            "actions": [
              {
                "icon": "more",
                "iconOnly": true,
                "variant": "ghost",
                "ariaLabel": "Weitere Aktionen für Lea Müller"
              },
              {
                "label": "Bearbeiten",
                "variant": "primary",
                "icon": "edit"
              }
            ]
          },
          "html": "<header class=\"page-header\">\n  <div class=\"page-header__text\">\n    <a class=\"back-link\" href=\"/admin/members\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#arrow-left\"/></svg>Mitglieder</a>\n    <h1 class=\"page-header__title\" id=\"page-title\" tabindex=\"-1\">Lea Müller</h1>\n    <p class=\"page-header__meta\">Kinder 7–10 · 6. Kup · seit 2024</p>\n  </div>\n  <div class=\"button-row\"><button class=\"button\" type=\"button\" data-variant=\"ghost\" data-icon-only aria-label=\"Weitere Aktionen für Lea Müller\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#more\"/></svg></button><button class=\"button\" type=\"button\" data-variant=\"primary\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#edit\"/></svg>Bearbeiten</button></div>\n</header>"
        },
        {
          "title": "Title only",
          "data": {
            "title": "Einstellungen"
          },
          "html": "<header class=\"page-header\">\n  <div class=\"page-header__text\">\n    \n    <h1 class=\"page-header__title\" id=\"page-title\" tabindex=\"-1\">Einstellungen</h1>\n    \n  </div>\n  \n</header>"
        }
      ],
      "markup": {
        "root": ".page-header",
        "element": "header",
        "attributes": [],
        "parts": [
          {
            "name": ".page-header__text",
            "description": "Column with back link, title and meta."
          },
          {
            "name": ".back-link",
            "description": "Back to the parent page (`arrow-left`, flips in RTL)."
          },
          {
            "name": ".page-header__title",
            "description": "The `h1` (`tabindex=\"-1\"` as focus target)."
          },
          {
            "name": ".page-header__meta",
            "description": "Muted context line."
          },
          {
            "name": ".button-row",
            "description": "Actions at the end side; wrap below on narrow screens."
          }
        ],
        "states": []
      },
      "cssVars": [],
      "a11y": [
        "Exactly one `h1` per page — the page header's title.",
        "The `h1` is focusable by script (`tabindex=\"-1\"`) so focus can move to it after a route change.",
        "The back link names its destination; its arrow icon is decorative."
      ],
      "guidance": {
        "do": [
          "Use the same title as the nav item and the document title.",
          "Put rarely used actions in an overflow menu."
        ],
        "dont": [
          "Don't put more than one primary action in a header.",
          "Don't write \"Zurück\" — name the parent page.",
          "Don't repeat the title in the meta line."
        ]
      },
      "related": [
        "app-shell",
        "button",
        "tabs"
      ]
    },
    {
      "id": "stepper",
      "name": "Stepper",
      "group": "Navigation",
      "status": "stable",
      "summary": "Shows where the person is in a multi-step task (3–5 steps): done, current and upcoming.",
      "description": [
        "Use a stepper for tasks with 3–5 dependent steps or more than ~8 fields: invoice import \"Datei wählen › Spalten zuordnen › Prüfen\", exam day \"Vorbereiten › Prüfungstag › Abschluss › Abrechnung\" (UX guide §4.4–4.5).",
        "Steps are named by what the person does. `current` is the zero-based index of the active step; steps before it are rendered as done (check mark), steps after it as upcoming. The last step is a review that shows exactly what will happen before the commit button.",
        "Two forms (design guide §9.6): from 768 px the numbered steps sit in a row — done: `check` in an accent circle; current: accent ring and semibold label; upcoming: `--color-border-strong` ring and muted label. Below 768 px it collapses to \"Schritt 2 von 3\" plus a progress bar; the step list stays available to screen readers.",
        "The stepper is an indicator, not navigation: each step has its own URL and the page provides \"Zurück\"/\"Weiter\" buttons. Progress is saved per step so leaving and returning resumes."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "steps",
          "current"
        ],
        "properties": {
          "label": {
            "type": "string",
            "default": "Fortschritt",
            "description": "Accessible name of the list (\"Fortschritt\", \"Prüfungstag\")."
          },
          "steps": {
            "type": "array",
            "minItems": 2,
            "maxItems": 5,
            "description": "Step names in order (max 5), verbs or task names (\"Datei wählen\").",
            "items": {
              "type": "string",
              "minLength": 1,
              "description": "One step name."
            }
          },
          "current": {
            "type": "integer",
            "minimum": 0,
            "description": "Zero-based index of the current step. Earlier steps are done. Use `steps.length` to mark all as done."
          },
          "doneLabel": {
            "type": "string",
            "default": "erledigt",
            "description": "Visually hidden suffix announced after done steps (\"Vorbereiten, erledigt\")."
          }
        }
      },
      "examples": [
        {
          "title": "Exam day",
          "data": {
            "steps": [
              "Vorbereiten",
              "Prüfungstag",
              "Abschluss",
              "Abrechnung"
            ],
            "current": 1
          },
          "html": "<div class=\"stepper\">\n  <div class=\"stepper__compact\" aria-hidden=\"true\"><p class=\"stepper__count\">Schritt 2 von 4</p><progress class=\"progress\" max=\"4\" value=\"2\"></progress></div>\n  <ol class=\"stepper__steps\" aria-label=\"Fortschritt\"><li data-state=\"done\"><span class=\"stepper__marker\" aria-hidden=\"true\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span><span class=\"stepper__label\">Vorbereiten</span><span class=\"u-visually-hidden\">, erledigt</span></li><li aria-current=\"step\"><span class=\"stepper__marker\" aria-hidden=\"true\">2</span><span class=\"stepper__label\">Prüfungstag</span></li><li><span class=\"stepper__marker\" aria-hidden=\"true\">3</span><span class=\"stepper__label\">Abschluss</span></li><li><span class=\"stepper__marker\" aria-hidden=\"true\">4</span><span class=\"stepper__label\">Abrechnung</span></li></ol>\n</div>"
        },
        {
          "title": "Invoice import, last step",
          "data": {
            "label": "Rechnungen importieren",
            "steps": [
              "Datei wählen",
              "Spalten zuordnen",
              "Prüfen"
            ],
            "current": 2
          },
          "html": "<div class=\"stepper\">\n  <div class=\"stepper__compact\" aria-hidden=\"true\"><p class=\"stepper__count\">Schritt 3 von 3</p><progress class=\"progress\" max=\"3\" value=\"3\"></progress></div>\n  <ol class=\"stepper__steps\" aria-label=\"Rechnungen importieren\"><li data-state=\"done\"><span class=\"stepper__marker\" aria-hidden=\"true\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span><span class=\"stepper__label\">Datei wählen</span><span class=\"u-visually-hidden\">, erledigt</span></li><li data-state=\"done\"><span class=\"stepper__marker\" aria-hidden=\"true\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span><span class=\"stepper__label\">Spalten zuordnen</span><span class=\"u-visually-hidden\">, erledigt</span></li><li aria-current=\"step\"><span class=\"stepper__marker\" aria-hidden=\"true\">3</span><span class=\"stepper__label\">Prüfen</span></li></ol>\n</div>"
        }
      ],
      "markup": {
        "root": ".stepper",
        "element": "div",
        "attributes": [
          {
            "name": "aria-label (on .stepper__steps)",
            "values": "text",
            "description": "Names the progress list."
          }
        ],
        "parts": [
          {
            "name": ".stepper__compact",
            "description": "Mobile form (< 768 px): `.stepper__count` (\"Schritt 2 von 4\") and a `.progress` bar. `aria-hidden`, because the list below says the same."
          },
          {
            "name": ".stepper__steps",
            "description": "The `<ol>` of steps: a row from 768 px, visually hidden (still read) below."
          },
          {
            "name": ".stepper__marker",
            "description": "Circle with the step number, or a `check` icon on done steps (decorative)."
          },
          {
            "name": ".stepper__label",
            "description": "Step name."
          },
          {
            "name": ".u-visually-hidden",
            "description": "Announced state of done steps."
          }
        ],
        "states": [
          {
            "name": "li[data-state=\"done\"]",
            "description": "Done: `check` in an accent-filled circle."
          },
          {
            "name": "li[aria-current=\"step\"]",
            "description": "Current: accent ring, semibold label."
          },
          {
            "name": "li (no state)",
            "description": "Upcoming: `--color-border-strong` ring, muted label."
          }
        ]
      },
      "cssVars": [],
      "a11y": [
        "Ordered list, so screen readers announce \"Liste, 4 Einträge\" and positions — also on phones, where it is visually replaced by the compact form.",
        "The current step has `aria-current=\"step\"`; done steps add a visually hidden \"erledigt\" — state is never colour-only."
      ],
      "guidance": {
        "do": [
          "Name steps by what the person does.",
          "End with a review step showing the consequences, then a verb button (\"3 Rechnungen erstellen\")."
        ],
        "dont": [
          "Don't use more than 5 steps.",
          "Don't use a stepper for independent sections — use tabs or a long form with headings."
        ]
      },
      "related": [
        "tabs",
        "progress",
        "page-header"
      ]
    },
    {
      "id": "tabs",
      "name": "Tabs",
      "group": "Navigation",
      "status": "stable",
      "summary": "Switches between a few views of the same object or area within one page.",
      "description": [
        "Use tabs for 2–6 sibling views of one thing (\"Aufgaben · Material · Helfer\" of an event). Beyond ~6, or when views are separate areas, use page navigation instead. The tab list scrolls horizontally rather than wrapping.",
        "`render` outputs the WAI-ARIA tabs markup with deterministic ids (`{id}-tab-{key}`, `{id}-panel-{key}`); the selected tab's panel is visible, the others are `hidden`. The keyboard behaviour comes from `js/tabs.js` via `data-behavior=\"tabs\"`.",
        "Panel content from data is plain text paragraphs (a short summary). Apps fill richer panel content themselves: render into `#{id}-panel-{key}` after mounting. Never pass HTML through data.",
        "Deep links: with `syncHash`, the selected tab is written to the URL hash (`#{id}-tab-{key}`) and restored on reload and back. For object sub-views that deserve their own route (UX guide §3.2 \"each tab has its own URL\"), prefer real routes and render the tab list as links in the app."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "label",
          "tabs"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Id prefix for tabs and panels. Unique on the page."
          },
          "label": {
            "type": "string",
            "minLength": 1,
            "description": "Accessible name of the tab list (`aria-label`), e.g. \"Event-Planung\"."
          },
          "selected": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]+$",
            "description": "Key of the initially selected tab; defaults to the first."
          },
          "syncHash": {
            "type": "boolean",
            "default": false,
            "description": "Keep the selected tab in the URL hash (`data-sync-hash`) so it survives reload and back."
          },
          "tabs": {
            "type": "array",
            "minItems": 2,
            "maxItems": 6,
            "description": "Tabs in display order (2–6).",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "key",
                "label"
              ],
              "properties": {
                "key": {
                  "type": "string",
                  "pattern": "^[A-Za-z0-9_-]+$",
                  "description": "Stable key; part of the tab and panel ids."
                },
                "label": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Tab text, one or two words (\"Aufgaben\")."
                },
                "panel": {
                  "type": "array",
                  "description": "Plain-text paragraphs of the panel. Leave empty when the app renders the panel content itself.",
                  "items": {
                    "type": "string",
                    "description": "One paragraph."
                  }
                }
              }
            }
          }
        }
      },
      "examples": [
        {
          "title": "Event planning",
          "data": {
            "id": "event",
            "label": "Event-Planung",
            "tabs": [
              {
                "key": "tasks",
                "label": "Aufgaben",
                "panel": [
                  "3 offene Aufgaben · 1 überfällig"
                ]
              },
              {
                "key": "material",
                "label": "Material",
                "panel": [
                  "12 von 15 Positionen bereit"
                ]
              },
              {
                "key": "helpers",
                "label": "Helfer",
                "panel": [
                  "7 von 9 Schichten besetzt"
                ]
              }
            ]
          },
          "html": "<div class=\"tabs\" data-behavior=\"tabs\">\n  <div role=\"tablist\" class=\"tabs__list\" aria-label=\"Event-Planung\"><button type=\"button\" role=\"tab\" id=\"event-tab-tasks\" aria-controls=\"event-panel-tasks\" aria-selected=\"true\">Aufgaben</button><button type=\"button\" role=\"tab\" id=\"event-tab-material\" aria-controls=\"event-panel-material\" aria-selected=\"false\" tabindex=\"-1\">Material</button><button type=\"button\" role=\"tab\" id=\"event-tab-helpers\" aria-controls=\"event-panel-helpers\" aria-selected=\"false\" tabindex=\"-1\">Helfer</button></div>\n  <div role=\"tabpanel\" id=\"event-panel-tasks\" aria-labelledby=\"event-tab-tasks\" tabindex=\"0\"><p>3 offene Aufgaben · 1 überfällig</p></div><div role=\"tabpanel\" id=\"event-panel-material\" aria-labelledby=\"event-tab-material\" tabindex=\"0\" hidden><p>12 von 15 Positionen bereit</p></div><div role=\"tabpanel\" id=\"event-panel-helpers\" aria-labelledby=\"event-tab-helpers\" tabindex=\"0\" hidden><p>7 von 9 Schichten besetzt</p></div>\n</div>"
        },
        {
          "title": "Synced to the URL hash, second tab selected",
          "data": {
            "id": "member",
            "label": "Mitglied Lea Müller",
            "selected": "belts",
            "syncHash": true,
            "tabs": [
              {
                "key": "profile",
                "label": "Profil"
              },
              {
                "key": "belts",
                "label": "Gurte",
                "panel": [
                  "6. Kup seit 14.05.2026. Nächste Prüfung: 14.11."
                ]
              },
              {
                "key": "invoices",
                "label": "Rechnungen"
              }
            ]
          },
          "html": "<div class=\"tabs\" data-behavior=\"tabs\" data-sync-hash>\n  <div role=\"tablist\" class=\"tabs__list\" aria-label=\"Mitglied Lea Müller\"><button type=\"button\" role=\"tab\" id=\"member-tab-profile\" aria-controls=\"member-panel-profile\" aria-selected=\"false\" tabindex=\"-1\">Profil</button><button type=\"button\" role=\"tab\" id=\"member-tab-belts\" aria-controls=\"member-panel-belts\" aria-selected=\"true\">Gurte</button><button type=\"button\" role=\"tab\" id=\"member-tab-invoices\" aria-controls=\"member-panel-invoices\" aria-selected=\"false\" tabindex=\"-1\">Rechnungen</button></div>\n  <div role=\"tabpanel\" id=\"member-panel-profile\" aria-labelledby=\"member-tab-profile\" tabindex=\"0\" hidden></div><div role=\"tabpanel\" id=\"member-panel-belts\" aria-labelledby=\"member-tab-belts\" tabindex=\"0\"><p>6. Kup seit 14.05.2026. Nächste Prüfung: 14.11.</p></div><div role=\"tabpanel\" id=\"member-panel-invoices\" aria-labelledby=\"member-tab-invoices\" tabindex=\"0\" hidden></div>\n</div>"
        }
      ],
      "markup": {
        "root": ".tabs",
        "element": "div",
        "attributes": [
          {
            "name": "data-behavior=\"tabs\"",
            "values": "—",
            "description": "Activates `js/tabs.js` on `mount()`."
          },
          {
            "name": "data-sync-hash",
            "values": "—",
            "description": "Selected tab kept in `location.hash`."
          },
          {
            "name": "aria-selected / aria-controls / aria-labelledby",
            "values": "ids",
            "description": "WAI-ARIA tab ↔ panel links."
          }
        ],
        "parts": [
          {
            "name": ".tabs__list",
            "description": "`role=\"tablist\"` with `aria-label`."
          },
          {
            "name": "[role=\"tab\"]",
            "description": "`<button>` with id `{id}-tab-{key}`; only the selected one is in the tab order."
          },
          {
            "name": "[role=\"tabpanel\"]",
            "description": "Panel with id `{id}-panel-{key}`, `tabindex=\"0\"`, `hidden` unless selected."
          }
        ],
        "states": [
          {
            "name": "[role=\"tab\"][aria-selected=\"true\"]",
            "description": "Accent text + 2 px accent underline."
          },
          {
            "name": "[role=\"tabpanel\"][hidden]",
            "description": "Inactive panel."
          }
        ]
      },
      "cssVars": [],
      "behaviour": {
        "module": "js/tabs.js",
        "setup": "Markup has `data-behavior=\"tabs\"`; call `mount(root)` from `js/behaviors.js` once after inserting it (idempotent; returns `unmount`).",
        "api": [
          {
            "name": "mount",
            "signature": "mount(root: ParentNode): () => void",
            "returns": "unmount function",
            "description": "Activates all behaviours below `root`, including tabs. Call the returned function when the view goes away."
          }
        ],
        "events": [
          {
            "name": "click",
            "detail": "on `[role=\"tab\"]`",
            "description": "Selects the tab and shows its panel."
          },
          {
            "name": "keydown",
            "detail": "ArrowLeft/ArrowRight (mirrored in RTL), Home, End",
            "description": "Moves to and activates the previous/next/first/last tab (automatic activation)."
          },
          {
            "name": "history.replaceState",
            "detail": "`#{tab id}` (with `data-sync-hash`)",
            "description": "Selected tab written to the URL hash; on mount a matching hash selects that tab."
          }
        ]
      },
      "a11y": [
        "WAI-ARIA tabs pattern: `tablist` / `tab` / `tabpanel` with `aria-controls` and `aria-labelledby`.",
        "Roving tab index: only the selected tab is in the tab order; arrow keys move between tabs.",
        "Panels have `tabindex=\"0\"` so text-only panels are reachable with Tab.",
        "Selection shown by colour and an underline; `aria-selected` exposes it."
      ],
      "guidance": {
        "do": [
          "Keep labels short and parallel (\"Profil · Gurte · Rechnungen\").",
          "Use `syncHash` (or real routes) so sharing a link opens the same tab."
        ],
        "dont": [
          "Don't use tabs for steps of a process — use the stepper.",
          "Don't nest tabs in tabs.",
          "Don't use more than 6 tabs."
        ]
      },
      "related": [
        "page-header",
        "stepper",
        "chip-group"
      ]
    },
    {
      "id": "action-tile",
      "name": "Action tile",
      "group": "Club",
      "status": "stable",
      "summary": "The one big \"what's next\" link at the top of a home screen (\"Jetzt · 17:30 — Präsenz\").",
      "description": [
        "Home screens answer \"what needs me now?\" (UX guide §4.2). The action tile is the single most important next step: the training that is starting (Trainer: \"Präsenz\"), today's session (Athlete: \"Start\"). Use at most one per screen; everything else is a list or cards below.",
        "The whole tile is one link. Anatomy: eyebrow (when/what, \"Jetzt · 17:30–18:30\"), a large verb-like title with an icon, a meta line (context, \"Kinder 7–10 · Leitung\"), and optionally a progress bar with visible text (\"1 von 3 diese Woche · +20 Punkte\").",
        "The tile uses the club accent fill, so it is the most prominent element on the page; don't combine it with an accent card on the same screen."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "href",
          "title"
        ],
        "properties": {
          "href": {
            "type": "string",
            "description": "Where the action happens (link target)."
          },
          "eyebrow": {
            "type": "string",
            "description": "Small line above the title: when/what (\"Jetzt · 17:30–18:30\", \"Training\")."
          },
          "title": {
            "type": "string",
            "minLength": 1,
            "description": "Short, large action word (\"Präsenz\", \"Start\")."
          },
          "icon": {
            "type": "string",
            "default": "chevron-right",
            "description": "Icon id after the title (\"play\", \"chevron-right\")."
          },
          "meta": {
            "type": "string",
            "description": "Context line (\"Kinder 7–10 · Leitung\", \"Wettkampfvorbereitung Herbst\")."
          },
          "progress": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "value",
              "max",
              "text"
            ],
            "description": "Optional progress bar with visible text.",
            "properties": {
              "value": {
                "type": "number",
                "minimum": 0,
                "description": "Current value (≤ `max`)."
              },
              "max": {
                "type": "number",
                "minimum": 1,
                "description": "Goal value."
              },
              "text": {
                "type": "string",
                "minLength": 1,
                "description": "Visible progress text (\"1 von 3 diese Woche · +20 Punkte\")."
              }
            }
          }
        }
      },
      "examples": [
        {
          "title": "Trainer: attendance now",
          "data": {
            "href": "#praesenz",
            "eyebrow": "Jetzt · 17:30–18:30",
            "title": "Präsenz",
            "meta": "Kinder 7–10 · Leitung"
          },
          "html": "<a class=\"action-tile\" href=\"#praesenz\"><span class=\"action-tile__eyebrow\">Jetzt · 17:30–18:30</span><span class=\"action-tile__title\">Präsenz <svg class=\"icon\" data-size=\"lg\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#chevron-right\"/></svg></span><span class=\"action-tile__meta\">Kinder 7–10 · Leitung</span></a>"
        },
        {
          "title": "Athlete: start training with progress",
          "data": {
            "href": "#training",
            "eyebrow": "Training",
            "title": "Start",
            "icon": "play",
            "meta": "Wettkampfvorbereitung Herbst",
            "progress": {
              "value": 1,
              "max": 3,
              "text": "1 von 3 diese Woche · +20 Punkte"
            }
          },
          "html": "<a class=\"action-tile\" href=\"#training\"><span class=\"action-tile__eyebrow\">Training</span><span class=\"action-tile__title\">Start <svg class=\"icon\" data-size=\"lg\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#play\"/></svg></span><span class=\"action-tile__meta\">Wettkampfvorbereitung Herbst</span><progress class=\"progress\" max=\"3\" value=\"1\" aria-hidden=\"true\"></progress><span>1 von 3 diese Woche · +20 Punkte</span></a>"
        }
      ],
      "markup": {
        "root": ".action-tile",
        "element": "a",
        "attributes": [],
        "parts": [
          {
            "name": ".action-tile__eyebrow",
            "description": "Small line above the title."
          },
          {
            "name": ".action-tile__title",
            "description": "Display-size title with trailing icon."
          },
          {
            "name": ".action-tile__meta",
            "description": "Context line."
          },
          {
            "name": ".progress",
            "description": "Progress bar on the accent fill (decorative; the text next to it is read)."
          }
        ],
        "states": [
          {
            "name": ":hover / :active",
            "description": "Accent hover colour (never shrinks, design guide §9)."
          },
          {
            "name": ":focus-visible",
            "description": "Global focus ring."
          }
        ]
      },
      "cssVars": [],
      "a11y": [
        "One link; its accessible name is the text in reading order (\"Jetzt · 17:30–18:30 Präsenz Kinder 7–10 · Leitung\") — keep the parts short.",
        "The progress bar is `aria-hidden`; its visible text carries the value.",
        "White text on the accent fill is a verified pair (`--color-on-accent`)."
      ],
      "guidance": {
        "do": [
          "Pick the action from the role's schedule (overdue → now → today).",
          "Use a verb or the task name as the title."
        ],
        "dont": [
          "Don't place more than one action tile on a screen.",
          "Don't put buttons inside the tile."
        ]
      },
      "related": [
        "card",
        "progress",
        "streak"
      ]
    },
    {
      "id": "belt",
      "name": "Belt badge",
      "group": "Club",
      "status": "stable",
      "summary": "Shows a grade as a small belt swatch plus its name.",
      "description": [
        "Use wherever a person's current or target grade appears: lists, tables, applicant cards, profiles.",
        "The colour comes from the club's grading system (tokens `--belt-*`), the label from the grading system's localised name. Two-colour belts (e.g. yellow with green tip) use `tip`.",
        "The label is always shown — colour alone never carries the meaning."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "label",
          "color"
        ],
        "properties": {
          "label": {
            "type": "string",
            "minLength": 1,
            "description": "Localised grade name from the grading system, e.g. \"6. Kup · Grüngurt\". Shown as text.",
            "examples": [
              "6. Kup · Grüngurt",
              "1. Dan"
            ]
          },
          "color": {
            "type": "string",
            "enum": [
              "white",
              "yellow",
              "green",
              "blue",
              "red",
              "black"
            ],
            "description": "Main belt colour; maps to the token `--belt-{color}`."
          },
          "tip": {
            "type": "string",
            "enum": [
              "white",
              "yellow",
              "green",
              "blue",
              "red",
              "black"
            ],
            "description": "Optional second colour for striped/tipped belts (e.g. yellow-green). Maps to `--belt-{tip}`."
          },
          "short": {
            "type": "boolean",
            "default": false,
            "description": "Compact form for dense tables: the label should then be the short grade (\"6. Kup\"); the full name goes into `title`."
          },
          "title": {
            "type": "string",
            "description": "Full grade name as tooltip and accessible description when `short` is used."
          },
          "size": {
            "type": "string",
            "enum": [
              "sm",
              "md",
              "lg"
            ],
            "default": "md",
            "description": "Swatch size (design guide §9.7): `sm` in list rows, `md` (24×8 px) in cards and tables, `lg` on profiles, exam results and TV screens."
          }
        }
      },
      "examples": [
        {
          "title": "Single colour",
          "data": {
            "label": "6. Kup · Grüngurt",
            "color": "green"
          },
          "html": "<span class=\"belt\" style=\"--belt-color: var(--belt-green)\">6. Kup · Grüngurt</span>"
        },
        {
          "title": "Two colours",
          "data": {
            "label": "8. Kup · Gelb-Grüngurt",
            "color": "yellow",
            "tip": "green"
          },
          "html": "<span class=\"belt\" style=\"--belt-color: var(--belt-yellow); --belt-tip: var(--belt-green)\">8. Kup · Gelb-Grüngurt</span>"
        },
        {
          "title": "White belt (outlined)",
          "data": {
            "label": "10. Kup · Weissgurt",
            "color": "white"
          },
          "html": "<span class=\"belt\" style=\"--belt-color: var(--belt-white)\">10. Kup · Weissgurt</span>"
        },
        {
          "title": "Short form in tables",
          "data": {
            "label": "1. Dan",
            "color": "black",
            "short": true,
            "title": "1. Dan · Schwarzgurt"
          },
          "html": "<span class=\"belt\" style=\"--belt-color: var(--belt-black)\" title=\"1. Dan · Schwarzgurt\">1. Dan</span>"
        },
        {
          "title": "Large (profile)",
          "data": {
            "label": "4. Kup · Blaugurt",
            "color": "blue",
            "size": "lg"
          },
          "html": "<span class=\"belt\" data-size=\"lg\" style=\"--belt-color: var(--belt-blue)\">4. Kup · Blaugurt</span>"
        }
      ],
      "markup": {
        "root": ".belt",
        "element": "span",
        "attributes": [
          {
            "name": "data-size",
            "values": "sm | lg",
            "default": "(none = md, 24×8 px swatch)",
            "description": "Swatch size."
          },
          {
            "name": "style=\"--belt-color: var(--belt-…)\"",
            "values": "one of the --belt-* tokens",
            "description": "Main colour (set by `render`)."
          },
          {
            "name": "style=\"--belt-tip: var(--belt-…)\"",
            "values": "one of the --belt-* tokens",
            "description": "Second colour for tipped belts."
          },
          {
            "name": "title",
            "values": "text",
            "description": "Full grade name when the short label is used."
          }
        ],
        "parts": [
          {
            "name": "::before",
            "description": "The swatch (24×8 px at md, `--radius-sm`), drawn by CSS with a 1 px inset outline (`--belt-edge`) so white, yellow and black belts stay visible on any surface."
          }
        ],
        "states": []
      },
      "cssVars": [
        {
          "name": "--belt-color",
          "default": "var(--belt-white)",
          "description": "Main colour of the swatch."
        },
        {
          "name": "--belt-tip",
          "default": "same as --belt-color",
          "description": "Colour of the last 30 % of the swatch."
        }
      ],
      "a11y": [
        "The grade name is real text; the swatch is decorative.",
        "Never render a swatch without text, even in dense tables — use the short label instead."
      ],
      "guidance": {
        "do": [
          "Take the label and colours from the club's grading system, never hard-code belt names.",
          "Use the short form only in tables where the column header says \"Gurt\"."
        ],
        "dont": [
          "Don't use belt colours for anything other than grades.",
          "Don't mix vocabularies (\"8 - Yellow\" next to \"7. Kup\") — that was one of the audit's top problems."
        ]
      },
      "related": [
        "stripes",
        "list",
        "table"
      ]
    },
    {
      "id": "note",
      "name": "Coach note",
      "group": "Club",
      "status": "stable",
      "summary": "A short message from a person (coach note, comment) with author and date.",
      "description": [
        "Notes show what a person wrote to someone: a coach's feedback to an athlete (\"Super Woche, Lea! Achte bei den Kicks auf die Hüfte.\"), a comment on a registration. The tinted surface marks it as a human message, distinct from system text.",
        "The meta line shows author and date (\"Sandra · 01.10.\"). Pass `date` as ISO (`YYYY-MM-DD`): it is rendered in a `<time datetime>` and formatted Swiss style (\"01.10.2026\"); pass `dateLabel` for relative wording you computed (\"gestern\").",
        "The text is plain paragraphs, never HTML. Notes about minors are visible only to the athlete, guardians and assigned coaches (UX guide §12.4)."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "author",
          "text"
        ],
        "properties": {
          "author": {
            "type": "string",
            "minLength": 1,
            "description": "Who wrote it, as the reader knows them (\"Sandra\", \"Sandra Keller\")."
          },
          "date": {
            "type": "string",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
            "description": "Date written (ISO `YYYY-MM-DD`); rendered as `<time datetime>`."
          },
          "dateLabel": {
            "type": "string",
            "description": "Display text for the date (\"01.10.\", \"gestern\"); default is `date` as \"DD.MM.YYYY\"."
          },
          "text": {
            "type": "array",
            "minItems": 1,
            "description": "Message paragraphs (plain text).",
            "items": {
              "type": "string",
              "minLength": 1,
              "description": "One paragraph."
            }
          }
        }
      },
      "examples": [
        {
          "title": "Coach note to an athlete",
          "data": {
            "author": "Sandra",
            "date": "2026-10-01",
            "dateLabel": "01.10.",
            "text": [
              "Super Woche, Lea! Achte bei den Kicks auf die Hüfte."
            ]
          },
          "html": "<div class=\"note\"><p class=\"note__meta\">Sandra · <time datetime=\"2026-10-01\">01.10.</time></p><p>Super Woche, Lea! Achte bei den Kicks auf die Hüfte.</p></div>"
        },
        {
          "title": "Comment with full date",
          "data": {
            "author": "Marco Huber",
            "date": "2026-09-28",
            "text": [
              "Mutter hat angerufen: Luca kann erst ab November ins Training.",
              "Bitte Rechnung anteilig ab 1.11. erstellen."
            ]
          },
          "html": "<div class=\"note\"><p class=\"note__meta\">Marco Huber · <time datetime=\"2026-09-28\">28.09.2026</time></p><p>Mutter hat angerufen: Luca kann erst ab November ins Training.</p><p>Bitte Rechnung anteilig ab 1.11. erstellen.</p></div>"
        }
      ],
      "markup": {
        "root": ".note",
        "element": "div",
        "attributes": [],
        "parts": [
          {
            "name": ".note__meta",
            "description": "Author · date line (muted, small)."
          },
          {
            "name": "time[datetime]",
            "description": "Machine-readable date."
          },
          {
            "name": "p",
            "description": "Message paragraphs."
          }
        ],
        "states": []
      },
      "cssVars": [],
      "a11y": [
        "Author and date come first, so the message is announced with its sender.",
        "The date is a `<time>` element with an ISO `datetime`.",
        "The tint is decorative; the meta line identifies it as a note."
      ],
      "guidance": {
        "do": [
          "Keep notes short and personal.",
          "Show the author's name as the reader knows them."
        ],
        "dont": [
          "Don't use notes for system messages — use an alert.",
          "Don't show health or wellness notes to people outside the athlete's circle."
        ]
      },
      "related": [
        "card",
        "proposal"
      ]
    },
    {
      "id": "proposal",
      "name": "Proposal card",
      "group": "Club",
      "status": "stable",
      "summary": "An action proposed by the AI assistant, a tenant agent or another person, waiting for a human to approve, adjust or reject it.",
      "description": [
        "**AI proposes, people decide** (UX guide P7, §11.2; Architecture §7.2). Irreversible actions requested by an AI or an agent — sending e-mail, writing to the CRM (Webling), booking invoices, printing — are never executed directly. The API turns them into a *pending action* with a dry-run; this card shows it to the person who may approve it, in the notification centre and on their home (\"Vorschläge zur Freigabe\").",
        "The card must say **what will happen** (`effects`, taken from the dry-run — not from the agent's own description), **who requested it** (the agent *and* the person it acts for), **when**, and when it expires. `render` refuses AI/agent proposals without `requestedBy`. The dashed accent border and the source line mark it as \"not done yet\".",
        "Actions: \"Freigeben\" (approve, primary, last), \"Ablehnen\" (reject) and optionally \"Anpassen\" (adjust before approving, when `adjustLabel` is set). Buttons carry `data-action=\"approve|reject|adjust\"`; the card carries `data-proposal=\"{id}\"`. The page handles them: approve runs the action through the same confirm/dry-run rules as a manual action of that risk class (e.g. a confirm dialog listing the consequences); reject opens a dialog with an optional reason that is fed back to the agent; adjust opens the editor with the proposal's values. Everything is written to the audit log.",
        "States: when the underlying data changed since the dry-run (`stale`), approval is replaced by \"Neu prüfen\" (`data-action=\"revalidate\"`) with a warning. After a decision (`status` approved/rejected/expired) the card gets `data-state`, loses the dashed accent border (it becomes an ordinary flat card with a `--color-border` outline, effects muted), and shows a status badge instead of actions — the accent marks only what still needs a decision (design guide §1.3)."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "source",
          "title",
          "effects"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z][\\w-]*$",
            "description": "Pending-action id; written to `data-proposal` and used for `{id}-title`."
          },
          "origin": {
            "type": "string",
            "enum": [
              "ai",
              "agent",
              "person"
            ],
            "default": "ai",
            "description": "`ai`: the assistant acting for a signed-in user; `agent`: a tenant agent (e.g. the inquiry auto-drafter); `person`: another human (e.g. a trainer proposing to the board)."
          },
          "source": {
            "type": "string",
            "minLength": 1,
            "description": "Who proposes it (\"Vorschlag vom Assistenten\", \"Vorschlag von Marco Huber\")."
          },
          "requestedBy": {
            "type": "string",
            "description": "The person the AI/agent acts for, or who asked for it (\"Sandra Keller\"). Required for `ai` and `agent`."
          },
          "requestedAt": {
            "type": "string",
            "description": "When it was requested, display text (\"vor 5 Min.\", \"01.10., 14:32\")."
          },
          "context": {
            "type": "string",
            "description": "Object or area it belongs to, shown above the title (\"Gurtprüfung Nov. 2026\")."
          },
          "title": {
            "type": "string",
            "minLength": 1,
            "description": "What is proposed, as an action (\"Prüfungsgruppen für den 14.11. bilden\")."
          },
          "headingLevel": {
            "type": "integer",
            "minimum": 2,
            "maximum": 4,
            "default": 3,
            "description": "Heading level of the title."
          },
          "effectsTitle": {
            "type": "string",
            "default": "Was passiert, wenn du zustimmst:",
            "description": "Heading of the effects box."
          },
          "effects": {
            "type": "array",
            "minItems": 1,
            "description": "Concrete consequences from the dry-run, one per line. Also say what does *not* happen when it matters (\"Nichts wird an Webling gesendet\").",
            "items": {
              "type": "string",
              "minLength": 1,
              "description": "One consequence."
            }
          },
          "expires": {
            "type": "string",
            "description": "Visible expiry (\"Gültig bis 16.10.\"). Proposals expire; say when."
          },
          "stale": {
            "type": "boolean",
            "default": false,
            "description": "Data changed since the dry-run: approval is replaced by \"Neu prüfen\" (`data-action=\"revalidate\"`) with a warning."
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "approved",
              "rejected",
              "expired"
            ],
            "default": "pending",
            "description": "Decision state. Anything but `pending` shows a status badge and hides the actions."
          },
          "approveLabel": {
            "type": "string",
            "default": "Freigeben",
            "description": "Approve button text; a verb naming the result (\"Übernehmen\", \"3 E-Mails senden\")."
          },
          "rejectLabel": {
            "type": "string",
            "default": "Ablehnen",
            "description": "Reject button text."
          },
          "adjustLabel": {
            "type": "string",
            "description": "Adjust button text (\"Anpassen\"); the button is shown only when set (proposal can be edited first)."
          }
        }
      },
      "examples": [
        {
          "title": "AI proposal with adjust",
          "data": {
            "id": "pa-2041",
            "source": "Vorschlag vom Assistenten",
            "requestedBy": "Sandra Keller",
            "requestedAt": "vor 5 Min.",
            "context": "Gurtprüfung Nov. 2026",
            "title": "Prüfungsgruppen für den 14.11. bilden",
            "effects": [
              "3 Gruppen (Kinder, Jugend, Erwachsene) mit 9, 7 und 5 Personen",
              "Startreihenfolge nach Gurt",
              "Nichts wird an Webling gesendet"
            ],
            "expires": "Gültig bis 16.10.",
            "approveLabel": "Übernehmen",
            "adjustLabel": "Anpassen"
          },
          "html": "<article class=\"proposal\" aria-labelledby=\"pa-2041-title\" data-proposal=\"pa-2041\">\n  <span class=\"proposal__source\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#sparkles\"/></svg>Vorschlag vom Assistenten · für Sandra Keller · vor 5 Min.</span>\n  <p class=\"proposal__context\">Gurtprüfung Nov. 2026</p>\n  <h3 class=\"proposal__title\" id=\"pa-2041-title\">Prüfungsgruppen für den 14.11. bilden</h3>\n  <div class=\"proposal__effects\"><p><strong>Was passiert, wenn du zustimmst:</strong></p><ul><li>3 Gruppen (Kinder, Jugend, Erwachsene) mit 9, 7 und 5 Personen</li><li>Startreihenfolge nach Gurt</li><li>Nichts wird an Webling gesendet</li></ul></div>\n  <p class=\"proposal__expiry\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#clock\"/></svg>Gültig bis 16.10.</p>\n  \n  <div class=\"button-row\"><button class=\"button\" type=\"button\" data-variant=\"ghost\" data-action=\"adjust\">Anpassen</button><button class=\"button\" type=\"button\" data-action=\"reject\">Ablehnen</button><button class=\"button\" type=\"button\" data-variant=\"primary\" data-action=\"approve\">Übernehmen</button></div>\n</article>"
        },
        {
          "title": "Tenant agent: send replies (irreversible)",
          "data": {
            "id": "pa-2043",
            "origin": "agent",
            "source": "Vorschlag vom Anfrage-Assistenten",
            "requestedBy": "Sekretariat",
            "requestedAt": "heute, 08:12",
            "title": "3 Antworten auf Probetraining-Anfragen senden",
            "effects": [
              "3 E-Mails an Familie Gerber, Roth und Schmid",
              "Absender: info@tkd-bern.ch",
              "Einladung zum Probetraining Mo 17:30"
            ],
            "approveLabel": "3 E-Mails senden",
            "adjustLabel": "Entwürfe ansehen"
          },
          "html": "<article class=\"proposal\" aria-labelledby=\"pa-2043-title\" data-proposal=\"pa-2043\">\n  <span class=\"proposal__source\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#sparkles\"/></svg>Vorschlag vom Anfrage-Assistenten · für Sekretariat · heute, 08:12</span>\n  \n  <h3 class=\"proposal__title\" id=\"pa-2043-title\">3 Antworten auf Probetraining-Anfragen senden</h3>\n  <div class=\"proposal__effects\"><p><strong>Was passiert, wenn du zustimmst:</strong></p><ul><li>3 E-Mails an Familie Gerber, Roth und Schmid</li><li>Absender: info@tkd-bern.ch</li><li>Einladung zum Probetraining Mo 17:30</li></ul></div>\n  \n  \n  <div class=\"button-row\"><button class=\"button\" type=\"button\" data-variant=\"ghost\" data-action=\"adjust\">Entwürfe ansehen</button><button class=\"button\" type=\"button\" data-action=\"reject\">Ablehnen</button><button class=\"button\" type=\"button\" data-variant=\"primary\" data-action=\"approve\">3 E-Mails senden</button></div>\n</article>"
        },
        {
          "title": "Stale (data changed)",
          "data": {
            "id": "pa-2039",
            "source": "Vorschlag vom Assistenten",
            "requestedBy": "Sandra Keller",
            "requestedAt": "gestern",
            "title": "Rechnungen Prüfungsgebühr erstellen",
            "effects": [
              "21 Rechnungen à CHF 60.00 in Webling",
              "Versand per E-Mail an die Rechnungsadressen"
            ],
            "stale": true
          },
          "html": "<article class=\"proposal\" aria-labelledby=\"pa-2039-title\" data-proposal=\"pa-2039\">\n  <span class=\"proposal__source\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#sparkles\"/></svg>Vorschlag vom Assistenten · für Sandra Keller · gestern</span>\n  \n  <h3 class=\"proposal__title\" id=\"pa-2039-title\">Rechnungen Prüfungsgebühr erstellen</h3>\n  <div class=\"proposal__effects\"><p><strong>Was passiert, wenn du zustimmst:</strong></p><ul><li>21 Rechnungen à CHF 60.00 in Webling</li><li>Versand per E-Mail an die Rechnungsadressen</li></ul></div>\n  \n  <div class=\"alert\" data-status=\"warning\" role=\"status\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#alert-triangle\"/></svg><div class=\"alert__body\"><p class=\"alert__title\">Daten haben sich geändert</p><p>Seit dem Vorschlag wurde etwas geändert. Prüfe den Vorschlag neu, bevor du ihn freigibst.</p></div></div>\n  <div class=\"button-row\"><button class=\"button\" type=\"button\" data-action=\"reject\">Ablehnen</button><button class=\"button\" type=\"button\" data-variant=\"primary\" data-action=\"revalidate\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#refresh\"/></svg>Neu prüfen</button></div>\n</article>"
        },
        {
          "title": "Person proposal, approved",
          "data": {
            "id": "pa-2030",
            "origin": "person",
            "source": "Vorschlag von Marco Huber",
            "requestedAt": "28.09.",
            "title": "Lea Müller zur Prüfung 5. Kup zulassen",
            "effects": [
              "Anmeldung zur Gurtprüfung 14.11.",
              "Rechnung CHF 60.00 an Familie Müller"
            ],
            "status": "approved"
          },
          "html": "<article class=\"proposal\" aria-labelledby=\"pa-2030-title\" data-proposal=\"pa-2030\" data-state=\"approved\">\n  <span class=\"proposal__source\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#user\"/></svg>Vorschlag von Marco Huber · 28.09.</span>\n  \n  <h3 class=\"proposal__title\" id=\"pa-2030-title\">Lea Müller zur Prüfung 5. Kup zulassen</h3>\n  <div class=\"proposal__effects\"><p><strong>Was passiert, wenn du zustimmst:</strong></p><ul><li>Anmeldung zur Gurtprüfung 14.11.</li><li>Rechnung CHF 60.00 an Familie Müller</li></ul></div>\n  \n  \n  <div class=\"button-row\" data-align=\"start\"><span class=\"badge\" data-status=\"success\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check-circle\"/></svg>Freigegeben</span></div>\n</article>"
        }
      ],
      "markup": {
        "root": ".proposal",
        "element": "article",
        "attributes": [
          {
            "name": "aria-labelledby",
            "values": "\"{id}-title\"",
            "description": "Card named by its title."
          },
          {
            "name": "data-proposal",
            "values": "pending-action id",
            "description": "Read by the page's action handler."
          },
          {
            "name": "data-action (buttons)",
            "values": "approve | reject | adjust | revalidate",
            "description": "Which decision a button makes."
          }
        ],
        "parts": [
          {
            "name": ".proposal__source",
            "description": "Icon (sparkles for AI/agent, user for a person) + who proposed, for whom, when (muted, small)."
          },
          {
            "name": ".proposal__context",
            "description": "Object/area above the title (muted, small)."
          },
          {
            "name": ".proposal__title",
            "description": "Title heading `h2`–`h4` (id `{id}-title`), `--text-lg` semibold."
          },
          {
            "name": ".proposal__effects",
            "description": "Box with \"Was passiert, wenn du zustimmst:\" and the dry-run consequences."
          },
          {
            "name": ".proposal__expiry",
            "description": "Expiry line with a `clock` icon (pending only)."
          },
          {
            "name": ".alert[data-status=\"warning\"]",
            "description": "Stale warning."
          },
          {
            "name": ".button-row",
            "description": "Adjust (ghost), reject, approve (primary, last); or a status badge after the decision."
          }
        ],
        "states": [
          {
            "name": "(pending)",
            "description": "2 px dashed `--color-accent` border."
          },
          {
            "name": "stale",
            "description": "Warning + \"Neu prüfen\" instead of approve."
          },
          {
            "name": "[data-state=\"approved|rejected|expired\"]",
            "description": "Decided: solid `--color-border` outline like a card, muted effects, status badge, no buttons."
          }
        ]
      },
      "cssVars": [],
      "behaviour": {
        "module": "(page code)",
        "setup": "No built-in behaviour. One delegated listener: `el.addEventListener(\"click\", e => { const b = e.target.closest(\"[data-action]\"); const id = b?.closest(\"[data-proposal]\")?.dataset.proposal; … })`. `approve` → confirm dialog with the same effects (`confirmDialog({ consequences })`), then `POST /pending-actions/{id}/approve`; `reject` → dialog with optional reason, then `…/reject`; `adjust` → open the editor; `revalidate` → re-run the dry-run and re-render. Show the result with a toast and update the card status.",
        "api": [],
        "events": [
          {
            "name": "click on [data-action]",
            "detail": "action (`approve|reject|adjust|revalidate`) + `data-proposal` id",
            "description": "A decision button was pressed."
          }
        ]
      },
      "a11y": [
        "An `<article>` named by its title; the source line is read first, so \"Vorschlag vom Assistenten · für Sandra Keller\" precedes the title.",
        "AI origin is stated in text, not only by the sparkles icon or the dashed border.",
        "Button labels name the outcome (\"3 E-Mails senden\"), and the primary is last in DOM and visual order.",
        "The stale warning is a `role=\"status\"` alert so it is announced when it appears after re-validation."
      ],
      "guidance": {
        "do": [
          "Take effects from the server's dry-run, including what will not happen.",
          "Name both the agent and the person it acts for.",
          "Show expiry and re-validate stale proposals before approval."
        ],
        "dont": [
          "Never execute an irreversible AI action without this approval step.",
          "Don't let the agent write the effects text in its own words.",
          "Don't offer \"Alle freigeben\" for proposals of different kinds."
        ]
      },
      "related": [
        "card",
        "button",
        "badge",
        "note"
      ]
    },
    {
      "id": "streak",
      "name": "Streak strip",
      "group": "Club",
      "status": "stable",
      "summary": "One week as seven day circles — trained days ticked, today ringed — with a summary row (flame + streak in weeks + sessions/goal).",
      "description": [
        "The streak strip motivates athletes by showing this week's planned sessions done (UX guide §13). It counts planned sessions, not calendar days, so rest days don't break anything: a day without training is just an empty circle, never a red failure.",
        "Exactly seven `days` in display order (usually starting today or on Monday). Each day is `done` (success circle with a check), `today` (2 px accent ring) or `none` (plain `--color-surface-2` circle). The list gets a summary label; by default \"Diese Woche: {done} von {goal} Trainings\" (or \"… {done} Trainings\" without a `goal`). Pass `label` to say more (\"…, heute ist Freitag\").",
        "Leading summary row (design guide §9.7): `flame` icon + the streak in weeks (\"2 Wochen\", from `weeks`) and, at the end, this week's sessions against the goal (\"1/3\", from the done days and `goal`). It is shown when `weeks` or `goal` is set. Meaning is carried by icon and text, not colour alone.",
        "Show it to the athlete themself (and their coach/guardians), never as a public comparison between minors."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "days"
        ],
        "properties": {
          "days": {
            "type": "array",
            "minItems": 7,
            "maxItems": 7,
            "description": "Seven days in display order.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "day"
              ],
              "properties": {
                "day": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 3,
                  "description": "Short weekday shown under the circle (\"Mo\", \"Di\")."
                },
                "state": {
                  "type": "string",
                  "enum": [
                    "done",
                    "today",
                    "none"
                  ],
                  "default": "none",
                  "description": "`done`: trained (check); `today`: today, not yet done (ring); `none`: no session."
                }
              }
            }
          },
          "goal": {
            "type": "integer",
            "minimum": 1,
            "maximum": 7,
            "description": "Planned sessions this week; used in the default summary label (\"2 von 3 Trainings\") and the trailing \"2/3\" of the summary row."
          },
          "weeks": {
            "type": "integer",
            "minimum": 0,
            "description": "Current streak in weeks (weeks in a row with the goal met), shown after the flame icon: \"1 Woche\", \"2 Wochen\"."
          },
          "label": {
            "type": "string",
            "description": "Full accessible summary of the week, overriding the default."
          }
        }
      },
      "examples": [
        {
          "title": "Mid-week with streak summary",
          "data": {
            "goal": 3,
            "weeks": 2,
            "label": "Diese Woche: 2 von 3 Trainings, heute ist Freitag",
            "days": [
              {
                "day": "Mi",
                "state": "done"
              },
              {
                "day": "Do",
                "state": "done"
              },
              {
                "day": "Fr",
                "state": "today"
              },
              {
                "day": "Sa"
              },
              {
                "day": "So"
              },
              {
                "day": "Mo"
              },
              {
                "day": "Di"
              }
            ]
          },
          "html": "<div class=\"streak\"><p class=\"streak__summary\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#flame\"/></svg><span>2 Wochen</span><span class=\"streak__goal\">2/3</span></p><ol class=\"streak__days\" aria-label=\"Diese Woche: 2 von 3 Trainings, heute ist Freitag\"><li data-state=\"done\"><span class=\"streak__dot\" aria-hidden=\"true\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span><span class=\"streak__day\">Mi</span><span class=\"u-visually-hidden\">: trainiert</span></li><li data-state=\"done\"><span class=\"streak__dot\" aria-hidden=\"true\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span><span class=\"streak__day\">Do</span><span class=\"u-visually-hidden\">: trainiert</span></li><li data-state=\"today\"><span class=\"streak__dot\" aria-hidden=\"true\"></span><span class=\"streak__day\">Fr</span><span class=\"u-visually-hidden\">: heute</span></li><li><span class=\"streak__dot\" aria-hidden=\"true\"></span><span class=\"streak__day\">Sa</span></li><li><span class=\"streak__dot\" aria-hidden=\"true\"></span><span class=\"streak__day\">So</span></li><li><span class=\"streak__dot\" aria-hidden=\"true\"></span><span class=\"streak__day\">Mo</span></li><li><span class=\"streak__dot\" aria-hidden=\"true\"></span><span class=\"streak__day\">Di</span></li></ol></div>"
        },
        {
          "title": "Week from Monday, goal only",
          "data": {
            "goal": 4,
            "days": [
              {
                "day": "Mo",
                "state": "done"
              },
              {
                "day": "Di"
              },
              {
                "day": "Mi",
                "state": "done"
              },
              {
                "day": "Do",
                "state": "today"
              },
              {
                "day": "Fr"
              },
              {
                "day": "Sa"
              },
              {
                "day": "So"
              }
            ]
          },
          "html": "<div class=\"streak\"><p class=\"streak__summary\"><svg class=\"icon\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#flame\"/></svg><span class=\"streak__goal\">2/4</span></p><ol class=\"streak__days\" aria-label=\"Diese Woche: 2 von 4 Trainings\"><li data-state=\"done\"><span class=\"streak__dot\" aria-hidden=\"true\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span><span class=\"streak__day\">Mo</span><span class=\"u-visually-hidden\">: trainiert</span></li><li><span class=\"streak__dot\" aria-hidden=\"true\"></span><span class=\"streak__day\">Di</span></li><li data-state=\"done\"><span class=\"streak__dot\" aria-hidden=\"true\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span><span class=\"streak__day\">Mi</span><span class=\"u-visually-hidden\">: trainiert</span></li><li data-state=\"today\"><span class=\"streak__dot\" aria-hidden=\"true\"></span><span class=\"streak__day\">Do</span><span class=\"u-visually-hidden\">: heute</span></li><li><span class=\"streak__dot\" aria-hidden=\"true\"></span><span class=\"streak__day\">Fr</span></li><li><span class=\"streak__dot\" aria-hidden=\"true\"></span><span class=\"streak__day\">Sa</span></li><li><span class=\"streak__dot\" aria-hidden=\"true\"></span><span class=\"streak__day\">So</span></li></ol></div>"
        },
        {
          "title": "Days only (no summary row)",
          "data": {
            "days": [
              {
                "day": "Mo"
              },
              {
                "day": "Di",
                "state": "done"
              },
              {
                "day": "Mi",
                "state": "today"
              },
              {
                "day": "Do"
              },
              {
                "day": "Fr"
              },
              {
                "day": "Sa"
              },
              {
                "day": "So"
              }
            ]
          },
          "html": "<div class=\"streak\"><ol class=\"streak__days\" aria-label=\"Diese Woche: 1 Trainings\"><li><span class=\"streak__dot\" aria-hidden=\"true\"></span><span class=\"streak__day\">Mo</span></li><li data-state=\"done\"><span class=\"streak__dot\" aria-hidden=\"true\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span><span class=\"streak__day\">Di</span><span class=\"u-visually-hidden\">: trainiert</span></li><li data-state=\"today\"><span class=\"streak__dot\" aria-hidden=\"true\"></span><span class=\"streak__day\">Mi</span><span class=\"u-visually-hidden\">: heute</span></li><li><span class=\"streak__dot\" aria-hidden=\"true\"></span><span class=\"streak__day\">Do</span></li><li><span class=\"streak__dot\" aria-hidden=\"true\"></span><span class=\"streak__day\">Fr</span></li><li><span class=\"streak__dot\" aria-hidden=\"true\"></span><span class=\"streak__day\">Sa</span></li><li><span class=\"streak__dot\" aria-hidden=\"true\"></span><span class=\"streak__day\">So</span></li></ol></div>"
        }
      ],
      "markup": {
        "root": ".streak",
        "element": "div",
        "attributes": [
          {
            "name": "aria-label (on .streak__days)",
            "values": "text",
            "description": "Summary of the week."
          }
        ],
        "parts": [
          {
            "name": ".streak__summary",
            "description": "Summary row: `flame` icon + streak in weeks; `.streak__goal` (\"1/3\", muted, tabular) at the end."
          },
          {
            "name": ".streak__days",
            "description": "The `<ol>` of seven days, with the summary label."
          },
          {
            "name": ".streak__dot",
            "description": "32 px day circle; holds the `check` icon on done days (decorative)."
          },
          {
            "name": ".streak__day",
            "description": "Weekday caption under the circle (`--text-xs`)."
          },
          {
            "name": "li > .u-visually-hidden",
            "description": "Announced day state (\"trainiert\", \"heute\")."
          }
        ],
        "states": [
          {
            "name": "li[data-state=\"done\"]",
            "description": "`--color-success` circle with a white check icon."
          },
          {
            "name": "li[data-state=\"today\"]",
            "description": "2 px `--color-accent` ring, accent caption."
          }
        ]
      },
      "cssVars": [],
      "a11y": [
        "The ordered list carries a summary label, so the week is understood without visiting every day.",
        "Each day's state is also text (visually hidden), not only the check or ring.",
        "The summary row is plain text (\"2 Wochen\", \"2/3\"); the flame icon is decorative.",
        "No colour-coded failure states — missed days are neutral."
      ],
      "guidance": {
        "do": [
          "Count planned sessions done, not days in a row.",
          "Offer a \"Pause\" for injury or holidays elsewhere on the screen."
        ],
        "dont": [
          "Don't shame (\"Du hast 3 Tage nichts gemacht\").",
          "Don't show streaks of minors on public screens or leaderboards."
        ]
      },
      "related": [
        "segments",
        "ring",
        "action-tile"
      ]
    },
    {
      "id": "stripes",
      "name": "Stripe chips",
      "group": "Club",
      "status": "stable",
      "summary": "The stripes (requirements) a student collects towards the next grade, as labelled chips.",
      "description": [
        "Each stripe is one requirement of the club's grading system (default for TKD Bern: Form, Kyorugi, 5 Tenets, Hosinsul, Theorie). Look (design guide §9.7): `--radius-sm` chip with a small colour square (outlined with `--belt-edge`) and the name. Earned: `--color-surface` with a `--color-border-strong` border, `check` icon, `--color-text`. Not earned: dashed `--color-border` outline, `--color-text-muted`, no check. The state is also in the accessible name (\"Form: erhalten\").",
        "Read-only by default. With `editable: true` every stripe is a toggle button; the page listens for `click` on `[data-stripe]` and saves the change (with the offline outbox in the Trainer app)."
      ],
      "data": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "stripes"
        ],
        "properties": {
          "label": {
            "type": "string",
            "description": "Accessible name of the group, e.g. \"Streifen für 5. Kup\". Required when several groups are on one page.",
            "default": "Streifen"
          },
          "editable": {
            "type": "boolean",
            "default": false,
            "description": "Render toggle buttons (aria-pressed) instead of a static list."
          },
          "stripes": {
            "type": "array",
            "minItems": 1,
            "maxItems": 10,
            "description": "Requirements in the order of the grading system.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "key",
                "label",
                "color",
                "earned"
              ],
              "properties": {
                "key": {
                  "type": "string",
                  "pattern": "^[a-z0-9-]+$",
                  "description": "Stable id of the requirement in the grading system, e.g. \"form\". Written to `data-stripe`."
                },
                "label": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Localised requirement name shown on the chip."
                },
                "color": {
                  "type": "string",
                  "enum": [
                    "red",
                    "blue",
                    "green",
                    "yellow",
                    "black"
                  ],
                  "description": "Stripe colour; maps to `--stripe-{color}`."
                },
                "earned": {
                  "type": "boolean",
                  "description": "Whether the student has this stripe."
                },
                "earnedAt": {
                  "type": "string",
                  "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
                  "description": "Date earned (ISO); shown as tooltip \"erhalten am …\" when set."
                }
              }
            }
          }
        }
      },
      "examples": [
        {
          "title": "Read-only (profile, applicant card)",
          "data": {
            "label": "Streifen für 5. Kup",
            "stripes": [
              {
                "key": "form",
                "label": "Form",
                "color": "red",
                "earned": true,
                "earnedAt": "2026-09-14"
              },
              {
                "key": "kyorugi",
                "label": "Kyorugi",
                "color": "blue",
                "earned": true
              },
              {
                "key": "tenets",
                "label": "5 Tenets",
                "color": "green",
                "earned": false
              },
              {
                "key": "hosinsul",
                "label": "Hosinsul",
                "color": "yellow",
                "earned": false
              },
              {
                "key": "theory",
                "label": "Theorie",
                "color": "black",
                "earned": false
              }
            ]
          },
          "html": "<ul class=\"stripes\" role=\"list\" aria-label=\"Streifen für 5. Kup\"><li class=\"stripe\" data-stripe=\"form\" style=\"--stripe-color: var(--stripe-red)\" data-earned title=\"erhalten am 14.09.2026\">Form<span class=\"u-visually-hidden\">: erhalten</span><span class=\"stripe__check\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span></li><li class=\"stripe\" data-stripe=\"kyorugi\" style=\"--stripe-color: var(--stripe-blue)\" data-earned>Kyorugi<span class=\"u-visually-hidden\">: erhalten</span><span class=\"stripe__check\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span></li><li class=\"stripe\" data-stripe=\"tenets\" style=\"--stripe-color: var(--stripe-green)\">5 Tenets<span class=\"u-visually-hidden\">: offen</span><span class=\"stripe__check\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span></li><li class=\"stripe\" data-stripe=\"hosinsul\" style=\"--stripe-color: var(--stripe-yellow)\">Hosinsul<span class=\"u-visually-hidden\">: offen</span><span class=\"stripe__check\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span></li><li class=\"stripe\" data-stripe=\"theory\" style=\"--stripe-color: var(--stripe-black)\">Theorie<span class=\"u-visually-hidden\">: offen</span><span class=\"stripe__check\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span></li></ul>"
        },
        {
          "title": "Editable (Trainer app, student panel)",
          "data": {
            "label": "Streifen von Lea Müller",
            "editable": true,
            "stripes": [
              {
                "key": "form",
                "label": "Form",
                "color": "red",
                "earned": true
              },
              {
                "key": "kyorugi",
                "label": "Kyorugi",
                "color": "blue",
                "earned": false
              },
              {
                "key": "tenets",
                "label": "5 Tenets",
                "color": "green",
                "earned": false
              }
            ]
          },
          "html": "<ul class=\"stripes\" role=\"list\" aria-label=\"Streifen von Lea Müller\"><li><button type=\"button\" class=\"stripe\" data-stripe=\"form\" aria-pressed=\"true\" style=\"--stripe-color: var(--stripe-red)\" data-earned>Form<span class=\"stripe__check\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span></button></li><li><button type=\"button\" class=\"stripe\" data-stripe=\"kyorugi\" aria-pressed=\"false\" style=\"--stripe-color: var(--stripe-blue)\">Kyorugi<span class=\"stripe__check\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span></button></li><li><button type=\"button\" class=\"stripe\" data-stripe=\"tenets\" aria-pressed=\"false\" style=\"--stripe-color: var(--stripe-green)\">5 Tenets<span class=\"stripe__check\"><svg class=\"icon\" data-size=\"sm\" aria-hidden=\"true\"><use href=\"../../packages/ui/icons/icons.svg#check\"/></svg></span></button></li></ul>"
        }
      ],
      "markup": {
        "root": ".stripes",
        "element": "ul",
        "attributes": [
          {
            "name": "aria-label",
            "values": "text",
            "description": "Group name (from `label`)."
          },
          {
            "name": "role=\"list\"",
            "values": "—",
            "description": "Keeps list semantics with list-style removed."
          }
        ],
        "parts": [
          {
            "name": ".stripe",
            "description": "One requirement (`li`, or `button` when editable) with `data-stripe=\"{key}\"`."
          },
          {
            "name": ".stripe::before",
            "description": "Colour square (`--stripe-color`, `--belt-edge` outline), drawn by CSS."
          },
          {
            "name": ".stripe__check",
            "description": "16 px `check` icon, always rendered, shown only when earned/pressed — so page code only toggles the attributes."
          },
          {
            "name": ".u-visually-hidden",
            "description": "Read-only chips: \": erhalten\" / \": offen\" so the state is in the accessible name."
          }
        ],
        "states": [
          {
            "name": "[data-earned]",
            "description": "Earned: surface background, solid `--color-border-strong` border, check icon, full text colour."
          },
          {
            "name": "(not earned)",
            "description": "Dashed `--color-border` outline, muted text, no check."
          },
          {
            "name": "[aria-pressed=\"true|false\"]",
            "description": "Editable form: toggle state, mirrors `data-earned` (pressed is styled as earned)."
          }
        ]
      },
      "cssVars": [
        {
          "name": "--stripe-color",
          "description": "Set per chip to `var(--stripe-{color})`."
        }
      ],
      "behaviour": {
        "module": "(page code)",
        "setup": "No built-in behaviour. Listen for clicks on `[data-stripe]` inside the group, toggle `aria-pressed`/`data-earned`, save, and show the save status.",
        "api": [],
        "events": [
          {
            "name": "click",
            "detail": "event.target.closest(\"[data-stripe]\").dataset.stripe → key",
            "description": "Native click on an editable chip."
          }
        ]
      },
      "a11y": [
        "Every chip shows its name — never colour-only dots (audit finding: stripes were unlabeled dots).",
        "Editable chips are buttons with `aria-pressed`, so screen readers announce \"Form, Umschalter, gedrückt\".",
        "The earned date is a tooltip only; important dates must also be visible elsewhere."
      ],
      "guidance": {
        "do": [
          "Keep the order of the grading system.",
          "Show the save status next to editable stripes (they are written to the CRM)."
        ],
        "dont": [
          "Don't invent a second stripe display; class and belt-test screens share this one."
        ]
      },
      "related": [
        "belt",
        "save-status"
      ]
    }
  ]
}