@skra/ui 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,90 +1,92 @@
1
- # Consuming Skra — Agent Guide (Core)
2
-
3
- > **DRAFT (2026-06-25; 8th section added 2026-08-30; 9th — Feedback — 2026-09-27).** All 9 sections drafted. Snippets are grounded in source (verified
4
- > against `libs/ui/*`) + the `apps/dogfood` friction journal (`F-n`) + the Iron Boundaries.
5
- > Still draft: not yet referenced from a real consumer repo, not lint-gated against ADR drift,
6
- > and cross-engine/Safari caveats inherit Skra's own open item O-9.
7
-
8
- This guide is for an **agent working inside a repository that consumes Skra** (imports
9
- `@skra/*`), not for authoring Skra itself. It is written to be **copied into / referenced
10
- from a consumer repo**.
11
-
12
- ---
13
-
14
- ## How to use this guide (read this first — it is the point)
15
-
16
- This file is **tiered on purpose** (same model as `FORGE_ADR_SUMMARY.md`). To keep token
17
- cost low:
18
-
19
- 1. **Always load this core** (it is small) — the Iron Rules + the Section Router below.
20
- 2. Then load **at most the ONE section** that matches your current task (the Router maps
21
- task → file). Do **not** read every section "to be safe" — that defeats the design.
22
- 3. Open the full Skra ADR / spec only when a section explicitly points you there.
23
-
24
- ---
25
-
26
- ## Iron Rules of Consumption (the 6 a consumer breaks on day one)
27
-
28
- Violating these is a review blocker in a consumer repo just as inside Skra.
29
-
30
- 1. **Import only from public `@skra/ui/*` entry points** — never a deep relative `libs/...`
31
- path. Skra ships as one package, `@skra/ui`, with a secondary entry point per component
32
- (e.g. `@skra/ui/button`, `@skra/ui/typography`, `@skra/ui/select`); the primary
33
- `@skra/ui` exports nothing. (Full entry-point list → `setup-and-gotchas.md`.)
34
- 2. **You own the value.** Skra form atoms do **not** implement `ControlValueAccessor` /
35
- `FormValueControl` (ADR 037). Native-host atoms take `[formField]`/`[formControl]`/
36
- `[(ngModel)]`; value-owned overlays need an explicit `[value]`/`(valueChange)` bridge.
37
- (How → `forms-and-binding.md`.)
38
- 3. **Accessible name has exactly ONE path** (ADR 033). No Skra atom exposes an `ariaLabel`
39
- input; never put `aria-label` next to visible text on the same host. (Paths →
40
- `accessible-names.md`.)
41
- 4. **Inside `<skra-field>`, do not pass `id` / `for` / `aria-describedby` by hand** — the
42
- DI context wires label↔control↔hint↔error automatically (ADR 013).
43
- 5. **Under Signal Forms, native constraint attributes go in the schema, not the template.**
44
- `name` on grouped radios and `maxlength` / `min` / `pattern` on a `[formField]` host raise
45
- `NG8022` — Signal Forms owns them (F-1/F-2). (Details → `forms-and-binding.md`.)
46
- 6. **Never theme with inline styles.** No `[style.--skra-*]` bindings or `style="…"` (breaks
47
- CSP, ADR 021). Override tokens via a CSS class / `data-skra-*` scope; components are
48
- `margin: 0` (ADR 022) — spacing comes from layout atoms, never a margin on the component.
49
-
50
- ---
51
-
52
- ## Section Router — load the ONE that matches your task
53
-
54
- | Section | File | Load when | Status |
55
- | --------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | -------- |
56
- | **Setup & gotchas** | `setup-and-gotchas.md` | First integration; import fails; which `@skra/*` package exports what; boolean-attribute & theming pitfalls | ✅ draft |
57
- | **Forms & value binding** | `forms-and-binding.md` | Wiring an input / select / checkbox / rating to Signal Forms, Reactive Forms, or `ngModel` | ✅ draft |
58
- | **Accessible names** | `accessible-names.md` | Naming buttons, icon-only controls, overlays; using `skra-sr-only` | ✅ draft |
59
- | **Theming & encapsulation** | `theming-and-encapsulation.md` | Skins, dark mode, density; mounting under Shadow DOM / a microfrontend / an iframe | ✅ draft |
60
- | **Feedback** | `feedback.md` | Alert vs toast vs badge vs field error; `live` announcing; dismissal; an alert with a next-step action | ✅ draft |
61
- | **Overlays** | `overlays.md` | Dialog / tooltip / popover / menu / select in the top layer; shadow & iframe limits | ✅ draft |
62
- | **Layout** | `layout.md` | Spacing & composition with Stack / Box / Surface / Grid; building a card; the gap scale | ✅ draft |
63
- | **Wrapping Skra** | `wrapping-patterns.md` | Building your own higher-level components over Skra atoms (simplified API) | ✅ draft |
64
- | **Custom components** | `custom-components.md` | Authoring a net-new widget Skra does not ship (nothing to wrap) on Skra tokens — own token tier, state, promotion path | ✅ draft |
65
-
66
- ---
67
-
68
- ## Quick reference — task → section
69
-
70
- | I want to… | Go to |
71
- | ----------------------------------------------------------------------- | ----------------------------------------- |
72
- | Add `@skra/ui/*` to a project and import an atom | `setup-and-gotchas.md` |
73
- | Bind a text input / textarea to a form | `forms-and-binding.md` |
74
- | Bind a `skra-select` / `skra-datepicker` / `skra-star-rating` to a form | `forms-and-binding.md` (the value bridge) |
75
- | Name an icon-only button | `accessible-names.md` |
76
- | Switch skin / dark mode at runtime | `theming-and-encapsulation.md` |
77
- | Mount Skra inside a Web Component / microfrontend | `theming-and-encapsulation.md` |
78
- | Show an alert / toast / badge, or add a "Try again" button to an alert | `feedback.md` |
79
- | Open a dialog or tooltip | `overlays.md` |
80
- | Space components apart | `layout.md` |
81
- | Build a card / panel / tile (there is no `skra-card`) | `layout.md` (Surface) |
82
- | Wrap a Skra atom in a project-specific component | `wrapping-patterns.md` |
83
- | Build a widget Skra does not ship (scrubber, timeline, domain visual) | `custom-components.md` |
84
- | Keep an app-local component on-skin, on-density, dark-mode-correct | `custom-components.md` |
85
-
86
- ---
87
-
88
- _Source of truth for the rules: Skra `FORGE_ADR_SUMMARY.md` + `adr/decisions/`. Real
89
- consumption evidence: `apps/dogfood/FRICTION_JOURNAL.md`. This guide restates the consumer
90
- slice of those — on divergence, the ADRs win._
1
+ # Consuming Skra — Agent Guide (Core)
2
+
3
+ > **DRAFT (2026-06-25; 8th section added 2026-08-30; 9th — Feedback — 2026-09-27).** All 9 sections drafted. Snippets are grounded in source (verified
4
+ > against `libs/ui/*`) + the `apps/dogfood` friction journal (`F-n`) + the Iron Boundaries.
5
+ > Still draft: not yet referenced from a real consumer repo, not lint-gated against ADR drift,
6
+ > and cross-engine/Safari caveats inherit Skra's own open item O-9.
7
+
8
+ This guide is for an **agent working inside a repository that consumes Skra** (imports
9
+ `@skra/*`), not for authoring Skra itself. It is written to be **copied into / referenced
10
+ from a consumer repo**.
11
+
12
+ ---
13
+
14
+ ## How to use this guide (read this first — it is the point)
15
+
16
+ This file is **tiered on purpose** (same model as `FORGE_ADR_SUMMARY.md`). To keep token
17
+ cost low:
18
+
19
+ 1. **Always load this core** (it is small) — the Iron Rules + the Section Router below.
20
+ 2. Then load **at most the ONE section** that matches your current task (the Router maps
21
+ task → file). Do **not** read every section "to be safe" — that defeats the design.
22
+ 3. Open the full Skra ADR / spec only when a section explicitly points you there.
23
+
24
+ ---
25
+
26
+ ## Iron Rules of Consumption (the 6 a consumer breaks on day one)
27
+
28
+ Violating these is a review blocker in a consumer repo just as inside Skra.
29
+
30
+ 1. **Import only from public `@skra/ui/*` entry points** — never a deep relative `libs/...`
31
+ path. Skra ships as one package, `@skra/ui`, with a secondary entry point per component
32
+ (e.g. `@skra/ui/button`, `@skra/ui/typography`, `@skra/ui/select`); the primary
33
+ `@skra/ui` exports nothing. (Full entry-point list → `setup-and-gotchas.md`.)
34
+ 2. **You own the value.** Skra form atoms do **not** implement `ControlValueAccessor` /
35
+ `FormValueControl` (ADR 037). Native-host atoms take `[formField]`/`[formControl]`/
36
+ `[(ngModel)]`; value-owned overlays need an explicit `[value]`/`(valueChange)` bridge.
37
+ (How → `forms-and-binding.md`.)
38
+ 3. **Accessible name has exactly ONE path** (ADR 033). No Skra atom exposes an `ariaLabel`
39
+ input; never put `aria-label` next to visible text on the same host. (Paths →
40
+ `accessible-names.md`.)
41
+ 4. **Inside `<skra-field>`, do not pass `id` / `for` / `aria-describedby` by hand** — the
42
+ DI context wires label↔control↔hint↔error automatically (ADR 013).
43
+ 5. **Under Signal Forms, native constraint attributes go in the schema, not the template.**
44
+ `name` on grouped radios and `maxlength` / `min` / `pattern` on a `[formField]` host raise
45
+ `NG8022` — Signal Forms owns them (F-1/F-2). (Details → `forms-and-binding.md`.)
46
+ 6. **Never theme with inline styles.** No `[style.--skra-*]` bindings or `style="…"` (breaks
47
+ CSP, ADR 021). Override tokens via a CSS class / `data-skra-*` scope; components are
48
+ `margin: 0` (ADR 022) — spacing comes from layout atoms, never a margin on the component.
49
+
50
+ ---
51
+
52
+ ## Section Router — load the ONE that matches your task
53
+
54
+ | Section | File | Load when | Status |
55
+ | --------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | -------- |
56
+ | **Setup & gotchas** | `setup-and-gotchas.md` | First integration; import fails; which `@skra/*` package exports what; boolean-attribute & theming pitfalls | ✅ draft |
57
+ | **Forms & value binding** | `forms-and-binding.md` | Wiring an input / select / checkbox / rating to Signal Forms, Reactive Forms, or `ngModel` | ✅ draft |
58
+ | **Accessible names** | `accessible-names.md` | Naming buttons, icon-only controls, overlays; using `skra-sr-only` | ✅ draft |
59
+ | **Theming & encapsulation** | `theming-and-encapsulation.md` | Skins, dark mode, density; mounting under Shadow DOM / a microfrontend / an iframe | ✅ draft |
60
+ | **File upload** | `file-upload.md` | Choosing files (picker / drop zone), a file list with progress, the queue model, a tus upload; `accept` and language limits | ✅ draft |
61
+ | **Feedback** | `feedback.md` | Alert vs toast vs badge vs field error; `live` announcing; dismissal; an alert with a next-step action | ✅ draft |
62
+ | **Overlays** | `overlays.md` | Dialog / tooltip / popover / menu / select in the top layer; shadow & iframe limits | ✅ draft |
63
+ | **Layout** | `layout.md` | Spacing & composition with Stack / Box / Surface / Grid; cards, nav links, the gap scale | ✅ draft |
64
+ | **Wrapping Skra** | `wrapping-patterns.md` | Building your own higher-level components over Skra atoms (simplified API) | ✅ draft |
65
+ | **Custom components** | `custom-components.md` | Authoring a net-new widget Skra does not ship (nothing to wrap) on Skra tokens — own token tier, state, promotion path | ✅ draft |
66
+
67
+ ---
68
+
69
+ ## Quick reference — task → section
70
+
71
+ | I want to… | Go to |
72
+ | ----------------------------------------------------------------------- | ----------------------------------------- |
73
+ | Add `@skra/ui/*` to a project and import an atom | `setup-and-gotchas.md` |
74
+ | Bind a text input / textarea to a form | `forms-and-binding.md` |
75
+ | Bind a `skra-select` / `skra-datepicker` / `skra-star-rating` to a form | `forms-and-binding.md` (the value bridge) |
76
+ | Name an icon-only button | `accessible-names.md` |
77
+ | Switch skin / dark mode at runtime | `theming-and-encapsulation.md` |
78
+ | Mount Skra inside a Web Component / microfrontend | `theming-and-encapsulation.md` |
79
+ | Let the user pick or drop files, show them with progress, upload them | `file-upload.md` |
80
+ | Show an alert / toast / badge, or add a "Try again" button to an alert | `feedback.md` |
81
+ | Open a dialog or tooltip | `overlays.md` |
82
+ | Space components apart | `layout.md` |
83
+ | Build a card / panel / tile (there is no `skra-card`) | `layout.md` (Surface) |
84
+ | Wrap a Skra atom in a project-specific component | `wrapping-patterns.md` |
85
+ | Build a widget Skra does not ship (scrubber, timeline, domain visual) | `custom-components.md` |
86
+ | Keep an app-local component on-skin, on-density, dark-mode-correct | `custom-components.md` |
87
+
88
+ ---
89
+
90
+ _Source of truth for the rules: Skra `FORGE_ADR_SUMMARY.md` + `adr/decisions/`. Real
91
+ consumption evidence: `apps/dogfood/FRICTION_JOURNAL.md`. This guide restates the consumer
92
+ slice of those — on divergence, the ADRs win._
@@ -0,0 +1,210 @@
1
+ # File upload — choosing files, showing them, uploading them yourself
2
+
3
+ > Section of the Skra consumer guide (added 2026-11-01, O-80). Load it when you need a file
4
+ > picker, a drop zone, or a list of files with progress. Source of truth:
5
+ > `adr/specs/component-file-upload-spec.md` + ADR 0056 Groups B and D.
6
+
7
+ Entry point: **`@skra/ui/file-upload`** — three atoms and two helpers:
8
+
9
+ | Export | What it is |
10
+ | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
11
+ | `SkraFileInput` — `input[type="file"][skraFileInput]` | The native control, skinned. Field wiring, drop normalisation, `filesChange` / `filesRejected`, `clear()`. |
12
+ | `SkraFileUpload` — `<skra-file-upload>` | Optional face over the input: your own action text, a drop zone, a live status. No inputs, no outputs. |
13
+ | `SkraFileItem` — `li[skraFileItem]` | One row of YOUR file list: middle-truncated name, formatted size, slots for preview / progress / error / actions. |
14
+ | `SkraFileUploadAction` / `…Instruction` / `…Status` / `…Start`, `SkraFileItemStart` / `…Detail` / `…Error` / `…End` | Slot markers — import the ones you use (projection works without them; slot DETECTION needs them). |
15
+ | `skraFormatFileSize(bytes, locale?)`, `skraFileMatchesAccept(file, accept)` | Pure helpers: SI sizes via `Intl` (`2,3 GB`), the HTML `accept` grammar. |
16
+
17
+ **Skra never uploads.** Transport (tus, multipart `fetch`, S3 presigned `PUT`), retry,
18
+ pause / resume and queue policy are yours. Skra gives you the control, the drop surface,
19
+ the rows and the two helpers.
20
+
21
+ ---
22
+
23
+ ## 1. Native atom or composite? — decide first
24
+
25
+ | You need… | Use |
26
+ | ----------------------------------------------------------- | -------------------------------------------------------------- |
27
+ | A plain file field in a form, browser wording is acceptable | `input[skraFileInput]` alone, inside `<skra-field>` |
28
+ | The button / status text **in your page's language** | `<skra-file-upload>` around the input |
29
+ | A drop zone ("or drag it here") | `<skra-file-upload>` **with** `[skra-file-upload-instruction]` |
30
+ | A compact, field-height row with your own wording | `<skra-file-upload>` **without** an instruction |
31
+ | A list of chosen / uploading files | a **sibling** `ul[skraStack]` of `li[skraFileItem]` |
32
+
33
+ **Why the composite exists.** The native control's two strings — "Choose File" and
34
+ "No file chosen" — come from the **browser's UI language, not your page's `lang`**, and no CSS
35
+ or attribute can replace them (measured in Chromium, ADR 0056 §1.6). A Polish app in an
36
+ English browser shows an English button. `<skra-file-upload>` paints its own face with your
37
+ text while the native input stays the real control — transparent, on top, covering the whole
38
+ face — so focus, keyboard, the OS picker (incl. the iOS camera / library sheet), `required`,
39
+ form submission and drops all keep working.
40
+
41
+ Even with the composite, the control's **accessibility value** stays the browser's string
42
+ ("No file chosen"); that is why your status text is linked as a description and announced.
43
+
44
+ ---
45
+
46
+ ## 2. The patterns
47
+
48
+ ### Single file in a form (`station`'s case)
49
+
50
+ ```html
51
+ <skra-field [hasError]="!!error()">
52
+ <label skraLabelFor>Session recording</label>
53
+ <skra-file-upload>
54
+ <skra-icon skra-start aria-hidden="true">…</skra-icon>
55
+ <span skra-file-upload-action>Choose a video</span>
56
+ <span skra-file-upload-status>{{ summary() }}</span>
57
+ <input skraFileInput type="file" accept="video/*" (filesChange)="onFiles($event)" (filesRejected)="onRejected($event)" />
58
+ </skra-file-upload>
59
+ <skra-hint>MP4 or MOV, up to 20 GB. Required.</skra-hint>
60
+ <skra-error>{{ error() }}</skra-error>
61
+ </skra-field>
62
+ ```
63
+
64
+ ```ts
65
+ readonly file = signal<File | null>(null);
66
+ readonly error = signal('');
67
+ readonly summary = computed(() => {
68
+ const f = this.file();
69
+ return f ? `${f.name}, ${skraFormatFileSize(f.size, 'pl-PL')}` : 'Nie wybrano nagrania';
70
+ });
71
+
72
+ onFiles(files: readonly File[]) {
73
+ const f = files[0] ?? null;
74
+ // Product policy is YOURS — Skra enforces only what the platform's picker enforces.
75
+ if (f && f.size > 20e9) { this.error.set('Plik jest większy niż 20 GB.'); return; }
76
+ this.error.set('');
77
+ this.file.set(f);
78
+ }
79
+
80
+ onRejected(rejected: readonly SkraFileRejection[]) {
81
+ const r = rejected[0];
82
+ this.error.set(
83
+ r.reason === 'type' ? `${r.file.name} nie jest plikiem wideo.`
84
+ : r.reason === 'count' ? 'Upuść jedno nagranie naraz.'
85
+ : `${r.file.name} to folder — wybierz plik.`,
86
+ );
87
+ }
88
+ ```
89
+
90
+ - The name is **"field label + your action text"** automatically (`aria-labelledby`; WCAG 2.5.3) —
91
+ **inside `<skra-field>` with `label[skraLabelFor]`**. Outside a field the action text alone names
92
+ the control, and that reference overrides a plain `<label for>` (dev mode warns).
93
+ Never put `aria-label` on the input.
94
+ - **Never project anything interactive into `<skra-file-upload>`** — the transparent input
95
+ covers the whole face and swallows the click (dev mode reports it). Buttons go in the list.
96
+
97
+ ### Many files + a list
98
+
99
+ ```html
100
+ <skra-file-upload>
101
+ <span skra-file-upload-action>Choose videos</span>
102
+ <span skra-file-upload-instruction>or drag them here</span>
103
+ <span skra-file-upload-status>{{ summary() }}</span>
104
+ <input skraFileInput type="file" accept="video/*" multiple (filesChange)="add($event)" />
105
+ </skra-file-upload>
106
+
107
+ <ul skraStack gap="sm" aria-label="Uploads">
108
+ @for (u of uploads(); track u.id) {
109
+ <li skraFileItem [name]="u.file.name" [size]="u.file.size" locale="pl-PL">
110
+ <progress skraProgress skra-file-item-detail [value]="u.sent" [max]="u.file.size" [attr.aria-label]="'Wysyłanie ' + u.file.name"></progress>
111
+ @if (u.error) { <span skra-file-item-error>{{ u.error }}</span> }
112
+ <button skraButton skra-end emphasis="low" (click)="cancel(u)">Anuluj<span skra-sr-only> {{ u.file.name }}</span></button>
113
+ </li>
114
+ }
115
+ </ul>
116
+ ```
117
+
118
+ - A row turns **invalid only while `[skra-file-item-error]` text is projected** — no `hasError`
119
+ input exists, so the colour can never appear without the words.
120
+ - Row actions: visible verb + `[skra-sr-only]` file name (ADR 033 Path 2). The progress bar has no
121
+ text, so it takes `aria-label` (Path 3).
122
+ - Previews are your content: put an `<img [src]="objectUrl">` in `[skra-start]`
123
+ (`URL.createObjectURL`, and `revokeObjectURL` when the row goes).
124
+
125
+ ### The queue model — `clear()` so the same file can be picked again
126
+
127
+ A file input fires no `change` when the user picks the file that is already selected. In a
128
+ queue (pick → enqueue → pick again) clear the native selection after taking the files:
129
+
130
+ ```html
131
+ <input skraFileInput type="file" multiple #picker (filesChange)="enqueue($event); picker.clear()" />
132
+ ```
133
+
134
+ `clear()` emits nothing. `#picker` resolves to the `SkraFileInput` component.
135
+
136
+ ### A resumable upload with tus (multi-GB, `station`)
137
+
138
+ Skra hands you `File`s; the transport is yours. With `tus-js-client` (your dependency):
139
+
140
+ ```ts
141
+ import { Upload } from 'tus-js-client';
142
+
143
+ start(file: File) {
144
+ const row = { id: crypto.randomUUID(), file, sent: 0, error: '' };
145
+ this.uploads.update((all) => [...all, row]);
146
+ const upload = new Upload(file, {
147
+ endpoint: '/api/uploads',
148
+ chunkSize: 50 * 1024 * 1024,
149
+ retryDelays: [0, 3000, 10000, 30000],
150
+ metadata: { filename: file.name, filetype: file.type },
151
+ onProgress: (sent) => this.patch(row.id, { sent }),
152
+ onError: () => this.patch(row.id, { error: 'Połączenie przerwane. Spróbuj ponownie.' }),
153
+ onSuccess: () => this.patch(row.id, { sent: file.size }),
154
+ });
155
+ upload.findPreviousUploads().then((prev) => {
156
+ if (prev.length) upload.resumeFromPreviousUpload(prev[0]);
157
+ upload.start();
158
+ });
159
+ }
160
+ ```
161
+
162
+ While any `<skra-file-upload>` is mounted, a file dropped a few pixels **outside** the zone is
163
+ cancelled instead of making the browser navigate to it (which would abort the upload). Text and
164
+ link drags, any native `<input type="file">` and editable content are left alone.
165
+
166
+ ---
167
+
168
+ ## 3. Rules and limits you must know
169
+
170
+ 1. **`accept` is never a guarantee.** Every OS lets the user bypass it ("All files", Android
171
+ pickers). Skra re-checks picks AND drops against it (`filesRejected` reason `type`), but the
172
+ **server must validate** type and size.
173
+ 2. **Skra enforces only what the platform's picker enforces**: `type` (accept), `count` (several
174
+ files on a non-`multiple` input — ALL are rejected, never one of N), `directory` (a dropped
175
+ folder). Size limits, a maximum count, duplicates — yours, in `filesChange`.
176
+ 3. **Typeless files** (`File.type === ''`, common for `.mkv` / `.heic`): matched by extension
177
+ tokens only, and **accepted** when `accept` lists only MIME types. Add `.mkv` etc. to `accept`
178
+ if you want a strict list.
179
+ 4. **Order: `filesChange` first, `filesRejected` last** — on both paths. Clear your error in
180
+ `onFiles` and set it in `onRejected`, and a mixed selection ends on the message. A selection where
181
+ every file was rejected emits only `filesRejected`.
182
+ 5. **`webkitdirectory`** hands over every file of the folder and counts as multiple — no `count`
183
+ rejection, even without `multiple`.
184
+ 6. **Native `(change)` vs `filesChange`.** `(change)` still fires and reports the RAW selection;
185
+ `filesChange` is the normalised one. A drop that is fully rejected changes nothing (no
186
+ `change`, only `filesRejected`).
187
+ 7. **"Required" does not reach screen readers on a file input in Chromium.** The control is
188
+ exposed as a `button`, which carries neither `aria-required` nor native `required` (measured).
189
+ Skra still sets `aria-required` from `<skra-field required>`; **also say "required" in visible
190
+ text** — the label or the hint.
191
+ 8. **Readonly** (`<skra-field readonly>`): native `readonly` does not apply to file inputs, and
192
+ `aria-readonly` is silent for a `button`, so the control is `aria-disabled` (still focusable,
193
+ announced "unavailable"); the picker and drops are blocked.
194
+ 9. **The native atom alone shows the browser's words** — fine for internal tools; use the
195
+ composite when the page language matters. In a narrow container the browser also CLIPS its
196
+ own status text ("No file ch…") — Skra cannot wrap a user-agent internal. The composite
197
+ wraps instead: its status moves below the action, and a file row's actions move below the
198
+ name, whenever they cannot share a line with at least half of it.
199
+ 10. **Inputs you will NOT find**: `accept`, `multiple`, `capture`, `required`, `disabled` are the
200
+ native attributes on your `<input>` — Skra reads them, never mirrors them. No `ariaLabel`, no
201
+ `maxSize`, no upload URL.
202
+
203
+ ---
204
+
205
+ ## 4. Verification status
206
+
207
+ Built and verified on Chromium, Firefox and desktop WebKit by automated tests (the picker opens
208
+ from every point of the face, drop normalisation, drag-over, height parity at every density,
209
+ the accessibility tree in Chromium, forced colours in Chromium). **Real-device iOS Safari,
210
+ Android and screen-reader passes are still open (O-81)** — see the gate table in the spec.
@@ -11,8 +11,12 @@ a native host** (a bounded allowlist of `div`/`section`/`nav`/`ul`/…), **never
11
11
  `<skra-stack>` custom element. Put them on the semantic element you'd use anyway:
12
12
 
13
13
  ```html
14
- <section skraStack gap="md">…</section> <!-- not <div><skra-stack> -->
15
- <ul skraStack gap="sm">…</ul> <!-- list markers normalized, restorable -->
14
+ <section skraStack gap="md">…</section>
15
+ <!-- not <div><skra-stack> -->
16
+ <ul skraStack gap="sm">
17
+ …
18
+ </ul>
19
+ <!-- list markers normalized, restorable -->
16
20
  ```
17
21
 
18
22
  ## Sizing container-filling components (e.g. `skra-select`)
@@ -38,16 +42,16 @@ width.
38
42
 
39
43
  Arranges children in a row or column.
40
44
 
41
- | Input | Values | Default |
42
- | --- | --- | --- |
43
- | `direction` | `row` \| `column` | `column` |
44
- | `gap` | `none` \| `sm` \| `md` \| `lg` | `md` |
45
- | `align` | `start` \| `center` \| `end` \| `stretch` | `stretch` |
46
- | `justify` | `start` \| `center` \| `end` \| `between` \| `around` | `start` |
47
- | `wrap` | boolean | `false` |
45
+ | Input | Values | Default |
46
+ | ----------- | ------------------------------------------------------- | --------- |
47
+ | `direction` | `row` \| `column` | `column` |
48
+ | `gap` | `none` \| `sm` \| `md` \| `lg` | `md` |
49
+ | `align` | `start` \| `center` \| `end` \| `stretch` \| `baseline` | `stretch` |
50
+ | `justify` | `start` \| `center` \| `end` \| `between` \| `around` | `start` |
51
+ | `wrap` | boolean | `false` |
48
52
 
49
53
  ```html
50
- <div skraStack direction="row" gap="sm" wrap align="center">
54
+ <div skraStack direction="row" gap="sm" wrap [align]="'center'">
51
55
  <skra-chip>One</skra-chip>
52
56
  <skra-chip>Two</skra-chip>
53
57
  </div>
@@ -56,13 +60,24 @@ Arranges children in a row or column.
56
60
  (`wrap` is a bare boolean attribute — every Skra boolean input carries `booleanAttribute`, so
57
61
  the bare form works; see `setup-and-gotchas.md`.)
58
62
 
63
+ **Gotcha — bind `align`, don't write it as a static attribute.** `align` is also a legacy HTML
64
+ attribute. Written statically (`<div skraStack align="center">`), Angular sets the input but the
65
+ attribute stays in the DOM, and the browser turns it into `text-align` on the host — Chromium
66
+ does so on every host Stack accepts (`div`, `section`, `header`, `ul`, …) and for both `center`
67
+ and `end`. Any text inside the stack is then centred or end-aligned. The bound form
68
+ `[align]="'center'"` sets the input without writing the attribute. The same applies to
69
+ `skraGrid`'s `align`. Since O-89 the atoms neutralise the hint themselves (`text-align: inherit`
70
+ while the attribute is present), so the static form no longer misaligns text — the bound form
71
+ stays the recommended one. If you want aligned text inside a stack, set `text-align` on a child
72
+ (or on the host with a selector more specific than a single class).
73
+
59
74
  ## Box — padding / spacing (`[skraBox]`)
60
75
 
61
76
  A semantically-neutral padding primitive. Does **not** arrange children (no list hosts).
62
77
 
63
- | Input | Values | Default |
64
- | --- | --- | --- |
65
- | `padding` | `none` \| `sm` \| `md` \| `lg` | `none` |
78
+ | Input | Values | Default |
79
+ | --------- | ----------------------------------- | ------- |
80
+ | `padding` | `none` \| `sm` \| `md` \| `lg` | `none` |
66
81
  | `display` | `block` \| `inline-block` \| `flex` | `block` |
67
82
 
68
83
  ```html
@@ -74,8 +89,8 @@ A semantically-neutral padding primitive. Does **not** arrange children (no list
74
89
  Box's **painting** sibling: background, border, radius, shadow — plus its own padding. Same
75
90
  host allowlist as Box (`div`/`section`/`article`/`main`/`nav`/`aside`/`header`/`footer`/`figure`).
76
91
 
77
- | Input | Values | Default |
78
- | --- | --- | --- |
92
+ | Input | Values | Default |
93
+ | --------- | ------------------------------ | -------- |
79
94
  | `padding` | `none` \| `sm` \| `md` \| `lg` | **`md`** |
80
95
 
81
96
  That is the whole API. Note the default differs from Box's `none` — Box is spacing-neutral by
@@ -109,16 +124,16 @@ There is no `selected` input, and there never will be one — `aria-selected` is
109
124
  producing invalid ARIA. Instead, Surface paints a selected state when **your own markup** says
110
125
  so, from exactly two signals:
111
126
 
112
- | Your markup | When it is the right one |
113
- | --- | --- |
114
- | `aria-current="true"` (or `page` / `step` / `date`) on the Surface host | the current item of a set — a global ARIA state, so it is valid on a role-less `<article>` |
115
- | `aria-selected="true"` on a host that **already** carries `option`/`row`/`gridcell`/`tab` | only inside a listbox / grid / tablist you actually built |
127
+ | Your markup | When it is the right one |
128
+ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
129
+ | `aria-current="true"` (or `page` / `step` / `date`) on the Surface host | the current item of a set — a global ARIA state, so it is valid on a role-less `<article>` |
130
+ | `aria-selected="true"` on a host that **already** carries `option`/`row`/`gridcell`/`tab` | only inside a listbox / grid / tablist you actually built |
116
131
 
117
132
  ```html
118
133
  <article skraSurface padding="md" aria-current="true" aria-labelledby="t">…</article>
119
134
  ```
120
135
 
121
- `aria-current="false"` and `aria-selected="false"` are explicit *not*-current and paint as
136
+ `aria-current="false"` and `aria-selected="false"` are explicit _not_-current and paint as
122
137
  resting.
123
138
 
124
139
  **Gotcha — a checked control inside the card is NOT a signal.** A card wrapping a checked
@@ -133,34 +148,97 @@ there: the radio already announces "selected", and adding `aria-current` makes a
133
148
  technology announce one fact twice under two names. A dedicated marker for that shape is a known
134
149
  gap (Skra-side `[skraSurfaceSelect]`), not something to emulate with a class.
135
150
 
136
- ### Interactive (clickable) cards are not shipped yet
151
+ ### Interactive (clickable) cards — `[skraSurfaceLink]`
137
152
 
138
153
  Surface is a container, not a widget, and the host allowlist deliberately excludes `a` and
139
- `button`: `<button>` cannot legally contain flow content (its content model is phrasing content
140
- with no interactive descendant), and an `<a>` wrapping the whole card takes the card's **entire**
141
- text as its accessible name — which `aria-label` may not fix (Iron Rule 3).
154
+ `button`: `<button>` cannot legally contain flow content, and an `<a>` wrapping the whole card
155
+ takes the card's **entire** text as its accessible name — which `aria-label` may not fix
156
+ (Iron Rule 3). Instead, put `skraSurfaceLink` on **your own** primary link (or `<button>`, when
157
+ the card performs an action) inside the Surface:
158
+
159
+ ```html
160
+ <article skraSurface padding="lg">
161
+ <h3 skraTypography="title-small">
162
+ <a skraSurfaceLink routerLink="/runs/2026-08-24">Morning run</a>
163
+ </h3>
164
+ <p skraTypography="body-medium">5.2 km · 24:31</p>
165
+ </article>
166
+ ```
167
+
168
+ What you get, with no inputs:
169
+
170
+ - The link's hit area covers the whole card; one tab stop; the accessible name is the link
171
+ text ("Morning run"), not the whole card body.
172
+ - Hover fills the card (pointer devices only — it does not latch after a tap), and the focus
173
+ ring is drawn **around the card**, the real target.
174
+ - The link text takes the card's text colour, without an underline (a skin may change that).
142
175
 
143
- Until the stretched-link marker ships, put a normal `a[skraLink]`/`button[skraButton]` **inside**
144
- the card and let it be the target. Do not make the card itself clickable with a `(click)` on a
145
- `<div>` — that fails keyboard access (WCAG 2.1.1) and has no role.
176
+ Rules:
177
+
178
+ - **One `skraSurfaceLink` per Surface.** A second one, or one with no Surface ancestor, is a
179
+ dev-mode console error. A list of targets is a list of Surfaces — `<li><article skraSurface>…` — and
180
+ a Surface nested in another paints only itself; the outer panel does not light up.
181
+ - **`skraLink` + `skraSurfaceLink` on one `<a>` is not possible** (both are components). The
182
+ marker already carries the text look.
183
+ - **A second control inside a linked card** (a "Share" button) sits under the overlay unless
184
+ you raise it: give it `position: relative` and keep it after the link in the DOM. Keep it at
185
+ least 24×24 CSS px (WCAG 2.5.8).
186
+ - Text inside a linked card cannot be selected with the mouse — a known, accepted cost of the
187
+ pattern.
188
+ - Do not make a card clickable with `(click)` on a `<div>` — that fails keyboard access
189
+ (WCAG 2.1.1) and has no role.
190
+
191
+ **Hover without a link:** an informational card has no hover state, and there is no
192
+ `hoverable` input. If you add your own hover paint in app CSS, gate it behind
193
+ `@media (hover: hover) and (pointer: fine)`, or it will latch after every tap on touch.
194
+
195
+ ## Navigation — `a[skraNavLink]` (ADR 0060)
196
+
197
+ A primary or section navigation whose **current** destination is visible, not only announced.
198
+ `skraNavLink` is a link — middle-click, "open in new tab", "copy link" all stay — with a
199
+ surface shape (pill or square, the skin decides) and a current state:
200
+
201
+ ```html
202
+ <nav aria-label="Primary">
203
+ <ul skraStack direction="row" gap="sm">
204
+ <li>
205
+ <a skraNavLink routerLink="/groups" routerLinkActive ariaCurrentWhenActive="page">Groups</a>
206
+ </li>
207
+ <li>
208
+ <a skraNavLink routerLink="/runs" routerLinkActive ariaCurrentWhenActive="page">Runs</a>
209
+ </li>
210
+ </ul>
211
+ </nav>
212
+ ```
146
213
 
147
- **Hover:** Surface has no hover state in v1, and there is no `hoverable` input — an
148
- informational card correctly stays inert. If you add your own hover paint in app CSS, gate it
149
- behind `@media (hover: hover) and (pointer: fine)`, or it will latch after every tap on touch.
214
+ - **The current state comes from `aria-current`, which you (or the router) write.**
215
+ `skraNavLink` has no `active` input — it reads the attribute, so what the eye sees and what a
216
+ screen reader announces cannot disagree. Without `routerLinkActive` (or your own
217
+ `[attr.aria-current]`), no item ever shows as current. `aria-current="false"` is not current.
218
+ - **It must sit inside `<nav>`** (or `role="navigation"`); outside one it is a dev-mode console
219
+ error. A pill-shaped link in a hero section is a marketing CTA, which Skra does not ship
220
+ (ADR 032) — use `skraButton` for an action, `skraLink` for a link in text.
221
+ - **No `intent`, `emphasis`, `size` or `disabled`.** A destination the user cannot visit is not a
222
+ link: leave it out of the nav, or render it as plain text.
223
+ - **Name = the visible text.** Do not add `aria-label`.
224
+ - Spacing between items is the list's `skraStack` gap; for a side nav use
225
+ `direction="column"`.
226
+ - `skraLink` vs `skraNavLink`: underlined text in a sentence → `skraLink`; a destination in a
227
+ navigation bar → `skraNavLink`. Do not put both on one `<a>` (both are components).
150
228
 
151
229
  ## Grid — 2D (`[skraGrid]`)
152
230
 
153
231
  Bucket-only CSS Grid. The default `columns="auto-fit"` + `min` gives an intrinsically
154
232
  responsive card grid (column count tracks width, **no media/container queries**):
155
233
 
156
- | Input | Values | Default |
157
- | --- | --- | --- |
158
- | `columns` | `1`…`6` \| `auto-fit` \| `auto-fill` | `auto-fit` |
159
- | `min` | `sm` \| `md` \| `lg` | — |
160
- | `gap` | `none` \| `sm` \| `md` \| `lg` | — |
161
- | `flow` | `row` \| `column` \| `dense` | — |
162
- | `align` | `start` \| `center` \| `end` \| `stretch` | — |
163
- | `justifyItems` | `start` \| `center` \| `end` \| `stretch` | — |
234
+ | Input | Values | Default |
235
+ | -------------- | ------------------------------------------------------------------------------------------------ | ---------- |
236
+ | `columns` | `1`…`6` \| `auto-fit` \| `auto-fill` | `auto-fit` |
237
+ | `min` | `sm` \| `md` \| `lg` | — |
238
+ | `gap` | `none` \| `sm` \| `md` \| `lg` | — |
239
+ | `flow` | `row` \| `column` \| `dense` | — |
240
+ | `align` | `start` \| `center` \| `end` \| `stretch` — bind it: `[align]="'center'"` (see the Stack gotcha) | — |
241
+ | `justifyItems` | `start` \| `center` \| `end` \| `stretch` | — |
164
242
 
165
243
  ```html
166
244
  <div skraGrid columns="auto-fit" min="md" gap="md">