@semplice-studio/pa-design-system 0.1.0 → 0.2.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.
@@ -0,0 +1,590 @@
1
+ # @semplice-studio/pa-design-system — Design System
2
+
3
+ > Source of truth for tokens, components, and Figma → code mappings.
4
+ > Read before generating any code (`/figma-component`, `/ui-composer`, or hand-written work).
5
+
6
+ **Figma source:** [SEMPLICE PA — design system](https://www.figma.com/design/kCk7aFcwXzFb0wzbqIbJ6K/SEMPLICE-PA---design-system?node-id=884-50)
7
+ **Last imported:** 2026-05-11 (tokens only; components deferred to user-story builds)
8
+
9
+ ---
10
+
11
+ ## 1. Overview
12
+
13
+ `@semplice-studio/pa-design-system` provides the visual language for Semplice's Italian Pubblica Amministrazione portals. Brand tone is **clear, accessible, government-trustworthy**: high-contrast primary blues, restrained accent cyans, semantic system colors for status, and a neutral gray scale for chrome. Typography is Inter for all UI text (Roboto Mono reserved for numeric labels).
14
+
15
+ Accessibility targets follow AgID / Legge Stanca: WCAG 2.1 AA, focus rings always visible, semantic color contrast ≥ 4.5:1 against intended backgrounds.
16
+
17
+ ## 2. Architecture
18
+
19
+ ```
20
+ src/
21
+ ├── tokens/
22
+ │ ├── tokens.css # all CSS custom properties (--pa-*) + @import './layout.css'
23
+ │ └── layout.css # `.pa-*` utility-class layer (v1.35; structural + compositional helpers)
24
+ ├── components/
25
+ │ └── Pa<Name>/ # one folder per component; created on demand
26
+ │ ├── Pa<Name>.vue
27
+ │ ├── Pa<Name>.story.vue
28
+ │ ├── Pa<Name>.test.ts
29
+ │ └── index.ts
30
+ └── index.ts # public barrel; mirrors package.json#exports
31
+ ```
32
+
33
+ Consumers import the stylesheet once:
34
+
35
+ ```ts
36
+ import '@semplice-studio/pa-design-system/tokens.css'
37
+ ```
38
+
39
+ This single import surfaces both the token vocabulary and the `.pa-*` utility-class layer (no separate `layout.css` import needed — it's `@import`-ed inside `tokens.css`).
40
+
41
+ Then reference variables in scoped styles:
42
+
43
+ ```vue
44
+ <style scoped>
45
+ .pa-button { background: var(--pa-color-primary); }
46
+ </style>
47
+ ```
48
+
49
+ …or compose pages with the utility-class layer directly:
50
+
51
+ ```vue
52
+ <template>
53
+ <div class="pa-page-frame">
54
+ <main class="pa-container pa-section">…</main>
55
+ </div>
56
+ </template>
57
+ ```
58
+
59
+ Never read token values from JS — CSS variables are the only interface.
60
+
61
+ See [`layouts.md`](./layouts.md) for the full `.pa-*` page-composition guide.
62
+
63
+ ## 3. Design tokens
64
+
65
+ ### 3.1 Colors
66
+
67
+ #### Primary (interactive brand)
68
+ | Token | Value |
69
+ |---|---|
70
+ | `--pa-color-primary` | `#0071FF` |
71
+ | `--pa-color-primary-hover` | `#00368F` |
72
+ | `--pa-color-primary-focus` | `#0071FF` |
73
+ | `--pa-color-primary-focus-border` | `#0065D1` |
74
+ | `--pa-color-primary-pressed` | `#00173D` |
75
+
76
+ #### Accent
77
+ | Token | Value |
78
+ |---|---|
79
+ | `--pa-color-accent` | `#18CEE7` |
80
+ | `--pa-color-accent-hover` | `#8CE7F3` |
81
+ | `--pa-color-accent-focus` | `#18CEE7` |
82
+ | `--pa-color-accent-pressed` | `#0F7D8A` |
83
+
84
+ #### Neutral
85
+ | Token | Value |
86
+ |---|---|
87
+ | `--pa-color-neutral-gray` | `#454D56` |
88
+ | `--pa-color-neutral-gray-hover` | `#2D3339` |
89
+ | `--pa-color-neutral-gray-focus` | `#454D56` |
90
+ | `--pa-color-neutral-charcoal` | `#0B0D0E` |
91
+
92
+ #### System (semantic)
93
+ | Token | Value | Use |
94
+ |---|---|---|
95
+ | `--pa-color-success` | `#008069` | success states |
96
+ | `--pa-color-success-hover` | `#005C4B` | |
97
+ | `--pa-color-success-pressed` | `#001A15` | |
98
+ | `--pa-color-warning` | `#F48E1B` | warnings |
99
+ | `--pa-color-warning-hover` | `#D67D17` | |
100
+ | `--pa-color-warning-pressed` | `#9C3A16` | |
101
+ | `--pa-color-error` | `#DE2049` | errors, destructive actions |
102
+ | `--pa-color-error-hover` | `#B41A3B` | |
103
+ | `--pa-color-error-pressed` | `#3F0D1B` | |
104
+
105
+ (`-focus` variants exist for each and equal the base value — see `tokens.css`.)
106
+
107
+ #### Text
108
+ | Token | Value |
109
+ |---|---|
110
+ | `--pa-color-text-white` | `#FFFFFF` |
111
+ | `--pa-color-text-light-blue-50` | `#D9E4F7` |
112
+ | `--pa-color-text-primary-blue` | `#0071FF` |
113
+ | `--pa-color-text-dark-blue` | `#00368F` |
114
+ | `--pa-color-text-secondary-gray` | `#454D56` |
115
+ | `--pa-color-text-disabled-gray` | `#5B6571` |
116
+ | `--pa-color-text-black` | `#000000` |
117
+
118
+ #### Background — primary scale
119
+ | Token | Value |
120
+ |---|---|
121
+ | `--pa-color-bg-white` | `#FFFFFF` |
122
+ | `--pa-color-bg-disabled-gray` | `#E7E8E3` |
123
+ | `--pa-color-bg-primary-light-40` | `#F2F6FC` |
124
+ | `--pa-color-bg-primary-light-50` | `#D9E4F7` |
125
+ | `--pa-color-bg-primary-light-60` | `#AEC6EF` |
126
+ | `--pa-color-bg-primary-dark-110` | `#00368F` |
127
+ | `--pa-color-bg-primary-dark-115` | `#002460` |
128
+ | `--pa-color-bg-primary-dark-120` | `#00173D` |
129
+
130
+ #### Background — secondary / neutral scale
131
+ | Token | Value |
132
+ |---|---|
133
+ | `--pa-color-bg-secondary-light-40` | `#E3E5E8` |
134
+ | `--pa-color-bg-secondary-light-50` | `#C6CCD2` |
135
+ | `--pa-color-bg-secondary-mid-90` | `#5B6571` |
136
+ | `--pa-color-bg-secondary-dark-110` | `#2D3339` |
137
+ | `--pa-color-bg-secondary-dark-120` | `#17191C` |
138
+ | `--pa-color-bg-secondary-dark-125` | `#0B0D0E` |
139
+ | `--pa-color-bg-black` | `#000000` |
140
+
141
+ #### Overlay
142
+ Base colors meant to be applied with an alpha channel at the call site (`color-mix`, `rgba`, or an `opacity` wrapper). The number in the Figma layer name is the intended opacity.
143
+
144
+ | Token | Value | Intended opacity |
145
+ |---|---|---|
146
+ | `--pa-color-overlay-dark` | `#00173D` | 90% |
147
+ | `--pa-color-overlay-light` | `#FFFFFF` | 25% |
148
+ | `--pa-color-overlay-gray` | `#454D56` | 15% |
149
+
150
+ #### Semantic aliases (v1.35)
151
+
152
+ Added so non-button uses of common values (muted text, subtle card/form borders, primary action color) are discoverable without grepping the templates.
153
+
154
+ | Token | Value | Aliases | Typical use |
155
+ |---|---|---|---|
156
+ | `--pa-color-action-primary` | `var(--pa-color-primary)` | `--pa-color-primary` | semantic alias for non-button interactive surfaces (links, icon accents) |
157
+ | `--pa-color-text-muted` | `#6E7780` | — | secondary copy (eyebrows, meta lines, card descriptions) |
158
+ | `--pa-color-border-subtle` | `#E5E9F0` | — | 1 px border on cards, panels, form fields, dividers |
159
+
160
+ ### 3.2 Typography
161
+
162
+ Font families:
163
+
164
+ | Token | Value |
165
+ |---|---|
166
+ | `--pa-font-family-base` | `'Inter', system-ui, ...` |
167
+ | `--pa-font-family-mono` | `'Roboto Mono', ui-monospace, ...` |
168
+
169
+ **Since v1.20** the library ships Inter Variable (axis 100–900, full Latin + Latin Extended + Cyrillic + Greek + Vietnamese coverage) and Roboto Mono Variable (axis 100–700) as bundled WOFF2 files under `dist/fonts/`, declared via `@font-face` in `tokens.css` with `font-display: swap`. Consumers get correct typography by importing `@semplice-studio/pa-design-system/tokens.css` alone — no separate Google Fonts `<link>` or self-hosting required. Italic axis is **not** bundled at v1.20; reserve for a future release if a real PA page needs it.
170
+
171
+ Scale (size / line-height / weight / letter-spacing):
172
+
173
+ | Role | Family | Size | Line-height | Weight | LS |
174
+ |---|---|---|---|---|---|
175
+ | H1 | Inter | 48 | 52 | 800 (ExtraBold) | -1 |
176
+ | H2 | Inter | 40 | 44 | 700 (Bold) | -1 |
177
+ | H3 | Inter | 32 | 36 | 700 (Bold) | -1 |
178
+ | H4 | Inter | 24 | 28 | 700 (Bold) | -0.5 |
179
+ | H5 | Inter | 20 | 24 | 600 (SemiBold) | -0.5 |
180
+ | H6 | Inter | 18 | 22 | 600 (SemiBold) | -0.5 |
181
+ | Lead | Inter | 20 | 26 | 500 (Medium) | -0.5 |
182
+ | Body | Inter | 16 | 20 | 400 (Regular) | 0 |
183
+ | Label XL | Inter | 20 | 24 | 600 | 0 |
184
+ | Label MD | Inter | 18 | 22 | 600 | 0 |
185
+ | Label SM | Inter | 16 | 20 | 600 | 0 |
186
+ | Label Helper | Inter | 12 | 14 | 500 | 0 |
187
+ | Label Numbers | Roboto Mono | 16 | 18 | 400 | 0 |
188
+ | Eyebrow (v1.35) | Inter | 13 | 16 | 600 | 0.04em / uppercase |
189
+ | Small (v1.35) | Inter | 14 | 20 | 400 | 0 |
190
+
191
+ Token names follow the pattern `--pa-font-<role>-{size,line-height,weight,letter-spacing}`. See `tokens.css` for the full list. The eyebrow group also exposes `--pa-font-eyebrow-transform: uppercase` since uppercase is part of the type-style contract, not a per-call decision.
192
+
193
+ ### 3.3 Spacing
194
+
195
+ Unified scale. The Figma file defines a vertical and a horizontal axis; both reuse these values, with two caveats documented below.
196
+
197
+ | Token | Value | T-shirt label (Figma) |
198
+ |---|---|---|
199
+ | `--pa-space-3xs` | `2px` | XXXS (vertical only) |
200
+ | `--pa-space-2xs` | `4px` | XXS |
201
+ | `--pa-space-xs` | `8px` | XS |
202
+ | `--pa-space-xs-plus` | `12px` | XS+ (added v1.35) |
203
+ | `--pa-space-sm` | `16px` | S |
204
+ | `--pa-space-sm-plus` | `20px` | S+ (added v1.35) |
205
+ | `--pa-space-md` | `24px` | M |
206
+ | `--pa-space-lg` | `32px` | L |
207
+ | `--pa-space-xl` | `40px` | XL (vertical) |
208
+ | `--pa-space-2xl` | `48px` | XXL (vertical) / XL (horizontal) |
209
+ | `--pa-space-2xl-plus` | `64px` | XXL+ (added v1.35) |
210
+ | `--pa-space-3xl` | `96px` | XXXL (vertical only) |
211
+
212
+ **Axis caveats:**
213
+ - The vertical axis includes `2px` (3xs) and `96px` (3xl); the horizontal axis does not — its smallest step is 4px and its largest is 48px.
214
+ - "XL" means **40px on vertical** but **48px on horizontal** in Figma layer names. In code, always use the numeric `2xl`/`xl` token to avoid the ambiguity.
215
+
216
+ ### 3.4 Radius
217
+
218
+ | Token | Value | Use |
219
+ |---|---|---|
220
+ | `--pa-radius-sharp` (v1.35) | `4px` | small chips, info-row corners, dense surfaces |
221
+ | `--pa-radius-smooth` | `8px` | cards, inputs, default surfaces |
222
+ | `--pa-radius-soft` (v1.35) | `12px` | auth card, prominent content surfaces |
223
+ | `--pa-radius-rounded` | `40px` | pills, prominent CTAs |
224
+ | `--pa-radius-circle` | `80px` | avatars, fully-circular elements (use with equal width/height) |
225
+
226
+ ### 3.5 Borders
227
+
228
+ | Token | Value |
229
+ |---|---|
230
+ | `--pa-border-xs` | `1px` |
231
+ | `--pa-border-sm` | `2px` |
232
+ | `--pa-border-md` | `4px` |
233
+ | `--pa-border-lg` | `8px` |
234
+
235
+ ### 3.6. Shadows
236
+
237
+ Figma's named effect styles, tokenized in v1.27 and extended in v1.35.
238
+
239
+ | Token | Value | Figma source | Used by |
240
+ |---|---|---|---|
241
+ | `--pa-shadow-elevation-01` | `0 0 16px #E3E5E8` | `elevation/elevation-01` | `PaCard` |
242
+ | `--pa-shadow-elevation-02` | `0 0 24px #E3E5E8` | `elevation/elevation-02` | (reserved) |
243
+ | `--pa-shadow-elevation-03` (v1.35) | `0 4px 24px rgba(0, 54, 143, 0.08)` | `elevation/elevation-03` | login auth card, soft drop-shadow surfaces |
244
+ | `--pa-shadow-elevation-04` | `0 0 32px rgba(170, 178, 187, 0.5)` | `elevation/elevation-04` | `PaModal` |
245
+
246
+ Figma's elevation ramp may include `elevation-05`; it is intentionally not tokenized at v1.35 because no component consumes it yet. Adding it later is non-breaking.
247
+
248
+ ### 3.7 Layout widths (v1.35)
249
+
250
+ Page-level max-widths previously hardcoded across all 11 templates. Consumed by the `.pa-page-frame` / `.pa-container` / `.pa-narrow` / `.pa-layout-sidebar` utility classes shipped in the same release (see §3.9).
251
+
252
+ | Token | Value | Use |
253
+ |---|---|---|
254
+ | `--pa-layout-frame-max` | `1280px` | outer page frame max-width (`.pa-page-frame`) |
255
+ | `--pa-layout-container-max` | `1168px` | content container max-width (`.pa-container`) |
256
+ | `--pa-layout-prose-max` | `880px` | long-form article body (`.pa-narrow--prose`) |
257
+ | `--pa-layout-narrow-max` | `720px` | centered narrow content (CTA, banner, search) (`.pa-narrow`) |
258
+ | `--pa-layout-auth-max` | `480px` | auth card (`.pa-narrow--xs`) |
259
+ | `--pa-layout-sidebar-md` | `320px` | medium sidebar column (`.pa-layout-sidebar`) |
260
+ | `--pa-layout-sidebar-sm` | `280px` | small article sidebar (`.pa-layout-sidebar--sm`) |
261
+
262
+ ### 3.8 Motion (v1.35)
263
+
264
+ Minimal motion tokens for UI feedback (hover, focus, dropdown reveal).
265
+
266
+ | Token | Value | Use |
267
+ |---|---|---|
268
+ | `--pa-motion-duration-fast` | `150ms` | hover / focus / dropdown reveal |
269
+ | `--pa-motion-easing-standard` | `cubic-bezier(0.4, 0, 0.2, 1)` | standard easing curve |
270
+
271
+ ### 3.9 Layout utility classes (v1.35)
272
+
273
+ `src/tokens/layout.css` ships a `.pa-*` utility-class layer alongside the token vocabulary. The stylesheet is `@import`-ed by `tokens.css`, so consumers receive the classes through the existing `import '@semplice-studio/pa-design-system/tokens.css'` entry — no new package subpath, no migration.
274
+
275
+ Every declaration sources from a `--pa-*` token. The only literals in the file are unitless numbers, CSS keywords, and the icon-circle dimensions (28 / 40 / 48 / 64 px — a documented sizing-token gap).
276
+
277
+ #### Page chrome
278
+
279
+ | Class | Effect |
280
+ |---|---|
281
+ | `.pa-page-frame` | Outer wrapper. `max-width: 1280; margin: 0 auto`; white background. |
282
+ | `.pa-container` | Content column. `max-width: 1168; margin: 0 auto`; 16 px side gutter. |
283
+ | `.pa-narrow` | Centered narrow band, `max-width: 720`. |
284
+ | `.pa-narrow--xs` | Narrow modifier — `max-width: 480` (auth card). |
285
+ | `.pa-narrow--prose` | Narrow modifier — `max-width: 880` (article body). |
286
+
287
+ #### Sections (compose with one or more `--variant` modifiers)
288
+
289
+ | Class | Effect |
290
+ |---|---|
291
+ | `.pa-section` | `padding: 32px 16px` vertical-rhythm band. |
292
+ | `.pa-section--lg` | Override padding to `48px 16px`. |
293
+ | `.pa-section--tint` | `background: --pa-color-bg-primary-light-40`. |
294
+ | `.pa-section--dark` | `background: --pa-color-bg-primary-dark-120; color: white`. |
295
+ | `.pa-section--gradient` | 135° gradient from `--pa-color-bg-primary-dark-110` to `--pa-color-primary`; white text. |
296
+
297
+ #### Grids
298
+
299
+ | Class | Effect |
300
+ |---|---|
301
+ | `.pa-grid-2` / `-3` / `-4` / `-6` | `display: grid` with N equal `minmax(0, 1fr)` columns + `--pa-space-sm` gap. |
302
+ | `.pa-split` | Two equal columns + `--pa-space-xl` gap. |
303
+ | `.pa-layout-sidebar` (default `--md`) | Main column + 320 px sidebar + `--pa-space-2xl` gap. |
304
+ | `.pa-layout-sidebar--sm` | Sidebar variant — 280 px column. |
305
+ | `.pa-field-full-row` | Grid utility — `grid-column: 1 / -1`. |
306
+
307
+ #### Stacks
308
+
309
+ | Class | Gap token |
310
+ |---|---|
311
+ | `.pa-stack-xs` | `--pa-space-xs` (8) |
312
+ | `.pa-stack-sm` | `--pa-space-sm` (16) |
313
+ | `.pa-stack-md` | `--pa-space-md` (24) |
314
+ | `.pa-stack-lg` | `--pa-space-lg` (32) |
315
+ | `.pa-stack-xl` | `--pa-space-xl` (40) |
316
+
317
+ #### Typography helper
318
+
319
+ | Class | Effect |
320
+ |---|---|
321
+ | `.pa-eyebrow` | Consumes the full `--pa-font-eyebrow-*` group + `--pa-color-text-muted`. |
322
+ | `.pa-eyebrow--sm` | Drops font-size / line-height to the helper scale (12 / 14). |
323
+
324
+ #### Surfaces
325
+
326
+ | Class | Effect |
327
+ |---|---|
328
+ | `.pa-panel` | White bg + 1 px `--pa-color-border-subtle` + smooth radius + 32 px padding. |
329
+ | `.pa-tint-card` | Tint bg + smooth radius + 24 px padding. |
330
+ | `.pa-tint-card--sticky` | Adds `position: sticky; top: var(--pa-space-md)`. |
331
+
332
+ #### Dividers
333
+
334
+ | Class | Effect |
335
+ |---|---|
336
+ | `.pa-divider` | 1 px horizontal rule using `--pa-color-border-subtle`; 24 px vertical margin. |
337
+ | `.pa-divider--text` | Flex rule with centered text label; uses `--pa-font-small-*`. |
338
+
339
+ #### Form chrome / tags
340
+
341
+ | Class | Effect |
342
+ |---|---|
343
+ | `.pa-form-action-bar` | Flex justify-end + 12 px gap + subtle top border + token-spaced padding. |
344
+ | `.pa-tag-cloud` | Flex-wrap row + `--pa-space-xs` gap. |
345
+
346
+ #### Icon circles
347
+
348
+ | Class | Dimension |
349
+ |---|---|
350
+ | `.pa-icon-circle-sm` | 28 × 28 |
351
+ | `.pa-icon-circle-md` | 40 × 40 |
352
+ | `.pa-icon-circle-lg` | 48 × 48 |
353
+ | `.pa-icon-circle-xl` | 64 × 64 |
354
+
355
+ All four share `display: inline-flex`, centered children, `border-radius: 50%`. Dimensions are sizing literals (no `--pa-size-icon-*` tokens yet — flagged as a known gap).
356
+
357
+ #### Compositional helpers (US-140)
358
+
359
+ Multi-element patterns recurring across content-heavy templates (article body, event detail, dashboard, office detail). Each helper is one class — consumers compose without re-deriving the same multi-rule CSS in every project.
360
+
361
+ | Class | Effect |
362
+ |---|---|
363
+ | `.pa-prose` | Article-body wrapper. Scopes typography for nested `h2` / `h3` / `p` / `ul` / `ol` / `figure` / `figcaption`. `scroll-margin-top` on headings for anchor TOCs. |
364
+ | `.pa-blockquote` | Left-border-md tint blockquote (`--pa-color-primary` rule, `--pa-color-bg-primary-light-40` background, italic). |
365
+ | `.pa-stat` + `.pa-stat__value` + `.pa-stat__label` | Big-number stat block. Value consumes the h1 token group; label consumes `--pa-font-small-*` + `--pa-color-text-muted`. Wrapper is `text-align: center`. |
366
+ | `.pa-info-strip` | Bordered horizontal info-strip with vertical dividers between children. Children flex equally; `> * + *` inserts a `--pa-color-border-subtle` left border. |
367
+ | `.pa-map-placeholder` | 320 px tall gradient placeholder block. **Known gap:** the gradient stops `#E0E8F1 → #C6D5E5` are inline literals until a `--pa-gradient-placeholder-light` token is added. |
368
+ | `.pa-article-meta` | Article-header meta row. Flex-wrap, `--pa-space-sm` gap, `--pa-font-small-*` typography, muted color, bottom rule via `--pa-color-border-subtle`. |
369
+ | `.pa-byline` | Flex avatar + name signature block used in article footers. |
370
+
371
+ A live preview of every class lives in the **Layout** story under *design system → Layout* in Histoire.
372
+
373
+ ## 4. Component registry
374
+
375
+ **Status: empty.** Components will be added one user story at a time via the `/user-story implement vX.X` flow. Each story is responsible for:
376
+
377
+ 1. Building the Pa* component folder under `src/components/<Name>/`
378
+ 2. Adding the entry to `src/index.ts` and `package.json#exports`
379
+ 3. Appending a row to the table below (status: ✅ Implemented)
380
+
381
+ | Component | Type | Stable since | Status | Notes |
382
+ |---|---|---|---|---|
383
+ | `PaButton` | atom | v1.1 | ✅ Implemented | 7 variants × 2 sizes, icon slots (`#icon-left`, `#icon-right`), icon-only mode |
384
+ | `PaInput` | atom | v1.2 | ✅ Implemented | `type` ∈ {text, email, tel, url, password, number, date}; sm+lg; full anatomy (label, helper, error, valid); required / readonly / clearable; prefix/suffix icon slots; password show/hide toggle (Italian aria-labels); native attr forwarding |
385
+ | `PaTextarea` | atom | v1.2 | ✅ Implemented | Mirrors PaInput anatomy; rows-driven height; vertical resize; character counter when `maxlength` is set |
386
+ | `PaIcon` | atom | v1.3 | ✅ Implemented | Phosphor-name resolver backed by curated registry (129 icons — see §5); t-shirt sizes sm/md/lg → 16/20/24 px; decorative by default, `title` prop makes it labelled |
387
+ | `PaSelect` | atom | v1.4 | ✅ Implemented | Native `<select>` wrapper with stripped browser chrome and a PaIcon chevron (`caret-up-down`); mirrors PaInput anatomy (label, helper, error, valid, required, readonly, disabled); sm+lg sizes; `{ value, label, disabled? }[]` options; placeholder via hidden+disabled first option; per-option `disabled` |
388
+ | `PaRadioGroup` | atom (composite) | v1.5 | ✅ Implemented | Single-select group of styled native radios; `string` v-model; `{ value, label, disabled? }[]` options; shared auto-generated `name`; group label, helper / error / valid messages with PaInput precedence; per-option `disabled`; vertical layout; `role="radiogroup"` + `aria-labelledby` |
389
+ | `PaCheckbox` | atom | v1.6 | ✅ Implemented | Boolean v-model atom; 24×24 rounded-8 styled native checkbox (Figma 250:5066); `indeterminate` prop renders minus glyph (via PaIcon `minus`); checked renders check glyph (via PaIcon `check`); label / helper / error / valid / required props; native `name` + `value` forwarding |
390
+ | `PaCheckboxGroup` | atom (composite) | v1.6 | ✅ Implemented | Multi-select group composing PaCheckbox; `string[]` v-model with option-array order preservation on every toggle; shared auto-generated `name`; group label, helper / error / valid messages; `required` (`*` after label + `aria-required` on wrapper); group-level and per-option `disabled`; vertical layout; `role="group"` + `aria-labelledby` |
391
+ | `PaChip` | atom | v1.7 | ✅ Implemented | Pill atom with `variant: 'dismissible' \| 'selectable'`. Dismissible: dark Neutral/Gray bg, optional leading `icon: PaIconName`, trailing 26 px circular close button emitting payload-less `close`; wrapper reflects close-button state via `:has()` selectors. Selectable: `<button aria-pressed>` toggle with `boolean` v-model + intrinsic `check` glyph when selected. 32 px pill, 100 px radius, Inter Semi Bold 16/20, focus ring via 2 px primary outline at offset 2 px. Full hover/focus/pressed/disabled state matrix on both variants |
392
+ | `PaTag` | atom | v1.8 | ✅ Implemented | Static colored label / status badge; `variant` union mirrors Figma verbatim (`default`, `info`, `alert`, `warning`, `success`, `default-dark`); optional 16 × 16 leading `icon: PaIconName` whose color tracks the label via `currentColor`; warning variant uses black text for AA contrast on orange; `default-dark` is the inverse (white bg / gray text) for placement on dark surfaces; 8 px radius, Inter Semi Bold 16/20; rendered as a plain `<span>` (no role, no `aria-*`) so consumers add ARIA at the surrounding container if needed |
393
+ | `PaBreadcrumb` | atom (composite) | v1.9 | ✅ Implemented | Navigation trail rendering `<nav aria-label><ol>` from an `items: PaBreadcrumbItem[]` array; the last item is always rendered as `<span aria-current="page">` (never a link), non-last items render `<a href>` when `href` is set otherwise `<button type="button">`; auto-truncates when `items.length > maxItems` (default 3) into `first / … / second-to-last / current` with a non-interactive `aria-hidden` ellipsis; `@select` payload `{ index, item, event }` preserves the original array index even when truncated and includes the native MouseEvent so consumers can `event.preventDefault()` for SPA mode; per-item `icon: PaIconName`; configurable `separator` (default `/`) and `label` (default `Breadcrumb`); focus ring via `:focus-visible` outline at 2 px offset with 4 px radius around the link text only |
394
+ | `PaToggle` | atom | v1.10 | ✅ Implemented | Binary on/off switch built on `<button type="button" role="switch" :aria-checked>`; 50 × 24 pill with `border-radius: 12px` and a 16 × 16 circular knob that slides between `left: 5px` (false) and `left: 29px` (true) via a 150 ms `ease-in-out` left transition; 4 visual states per side (Default / Hover / Focus / Disabled) including the Figma-specified knob flip to primary blue on false-focus; full PaCheckbox-style anatomy (`label`, `helperText`, `error`, `valid`, `required`, `name`, `value`) with the same precedence rules; `aria-labelledby` ties the button to the sibling clickable label; `aria-required` maps `required` since HTML `required` doesn't apply to `<button>`; focus ring via `:focus-visible` outline at 2 px offset following the 12 px border-radius |
395
+ | `PaFileUpload` | atom (composed) | v1.11 | ✅ Implemented | Single-file picker; the library's first **composed** component — internally renders `PaButton` (variant `primary`, size `sm`, trailing `arrow-right` PaIcon) as the trigger, a visually-hidden `<input type="file">` that the button click-forwards to via `inputRef.value?.click()`, and a `PaChip` (variant `dismissible`) below the button when `modelValue` is a `File`; v-model type `File \| null` (clearing via the chip's × emits `null` and resets `input.value` so the same file can be re-picked); full PaCheckbox-style anatomy (`label`, `helperText`, `error`, `valid`, `required`, `name`) with same precedence rules (`error` > `internalError` > `valid` > `helper`); client-side validation gates — `accept` (extension + MIME with `*` wildcards, case-insensitive) and `maxSize` in bytes (auto-formats error message via `formatBytes`: B / KB / MB / GB) — both emit `invalid: { file, reason: 'maxSize' \| 'accept' }` and suppress the `update:modelValue` on rejection; `aria-invalid` and `aria-describedby` on the input track error / message presence; `required` forwards as the native HTML attribute and renders a red `*` after the label; `chipLabel?: (file) => string` lets consumers override the chip text (default is `file.name`); default `buttonLabel` is `"Carica file"`; selection only — HTTP upload, drag-and-drop, and multi-file are deferred to later releases |
396
+ | `PaAccordion` | atom | v1.12 | ✅ Implemented | Collapsible disclosure atom; native `<button>` header with `aria-expanded`, `aria-controls` → paired panel `role="region"` + `aria-labelledby`; auto-generated `pa-accordion-N` id when not supplied; `v-model: boolean` with vnode-props inspection at setup to distinguish controlled from uncontrolled (Vue coerces absent Boolean props to `false`, so `props.modelValue !== undefined` is unreliable); `defaultOpen` for uncontrolled initial state; `title` prop or `#title` named slot for rich header content; optional leading `icon: PaIconName` (20 × 20); trailing `caret-down` PaIcon rotates 180° via CSS transform when `[data-open="true"]`; body wrapped in Vue `<Transition name="pa-accordion-body">` with 150 ms opacity + 4 px translateY enter/leave (overridden to `transition-duration: 0ms` under `prefers-reduced-motion: reduce`); header state matrix: Default `--pa-color-primary` / Hover-closed `--pa-color-primary-hover` / Open `--pa-color-primary-pressed` / Hover-open `--pa-color-primary-hover` / focus ring via `:focus-visible` outline `var(--pa-border-sm) solid var(--pa-color-primary-focus-border)` at 2 px offset; group-aware via `inject(PaAccordionGroupKey)` |
397
+ | `PaAccordionGroup` | atom (composed) | v1.12 | ✅ Implemented | Composed coordinator for stacked PaAccordion children — the library's second composed component after PaFileUpload; `multiple` prop flips v-model type between `string \| null` (single-open, opening one closes the others) and `string[]` (multi-open, append-on-open + remove-on-toggle-again, preserves append order); provides `PaAccordionGroupKey` (private `InjectionKey<PaAccordionGroupContext>` symbol from `src/components/PaAccordion/groupKey.ts`) exposing `register(id) / unregister(id) / isOpen(id): ComputedRef<boolean> / toggle(id)`; nested PaAccordion `inject`s the key and switches its `open` source to `groupOpen.value` + delegates `toggle()` to the group; `defaultOpen` on a child seeds the group's v-model once on mount via a single `toggle(id)` call when the id isn't already open; duplicate-id registration emits a `console.warn` in dev; renders `<div role="region" class="pa-accordion-group">` with `display: flex; flex-direction: column; gap: var(--pa-space-3xs)` (2 px — matches Figma list frame 104:14252); standalone PaAccordion outside a group works unchanged (`inject(..., null)` falls back to local state) |
398
+ | `PaFab` | atom | v1.13 | ✅ Implemented | Floating action button; single `<button type="button">` (hard-coded — protects against accidental form submits) with native click fall-through via Vue `$attrs` (no `defineEmits`). Three orthogonal props compose the six Figma variants: `variant: 'primary' \| 'inverse'` (default `'primary'`) × `extended: boolean` (default `false`) × `size: 'sm' \| 'md'` (default `'md'`, silently ignored when `extended` is true since Figma has no extended-sm cell). Required `icon: PaIconName` for the leading glyph; computed mapping `PA_FAB_ICON_SIZE` resolves PaIcon size to `md` (20 px) for compressed-sm, `lg` (24 px) for compressed-md and extended; `label?: string` rendered in `<span class="pa-fab__label">` (Inter Semi Bold 16/20, `color: inherit`) only when extended; `ariaLabel?: string` forwards verbatim to `<button aria-label>` — required by WCAG for icon-only usage (no runtime warn at v1.13, consumer responsibility). Geometry: compressed = `border-radius: 50%` + width/height `56px` (md) or `40px` (sm); extended = `border-radius: 22px` + `height: 44px` + `padding: 0 var(--pa-space-sm)` + `gap: var(--pa-space-xs)`. State matrix per variant (keyed on `:hover` / `:focus-visible` / `:active` CSS pseudos, no JS) — primary: Default `--pa-color-primary` / hover `--pa-color-primary-hover` / pressed `--pa-color-primary-pressed`; inverse: Default `--pa-color-bg-white` / hover `--pa-color-accent-hover` / pressed `--pa-color-accent` (brand-distinctive cyan rather than a darker primary). Focus ring on both: `outline: var(--pa-border-sm) solid var(--pa-color-primary-focus-border)` at 2 px offset. **No `disabled` prop**, no loading state, no fixed positioning — atom is purely visual; consumers wrap with their own positioning shell |
399
+ | `PaImage` | atom | v1.14 | ✅ Implemented | Ratio-locked image atom rendered as `background-image` on a `<div>` (per designer's spec — not an `<img>` element), with `background-size: cover; background-position: center; background-repeat: no-repeat` so off-ratio sources crop without distortion. Ratio expressed via CSS `aspect-ratio` property (modern, no `padding-bottom` hack). Preset union `ratio: '1:1' \| '4:3' \| '16:9' \| '3:2' \| '2:3' \| '3:4' \| '9:16'` + free-form `ratioCustom?: string` escape hatch (W:H string parsed via regex; invalid input logs `console.warn` and falls back to `'1 / 1'`). Resolution chain (highest wins): `ratioCustom` → local `ratio` → injected grid ratio (via `inject(PaImageGridKey, null)`) → `'1 / 1'`. Required `alt: string` — when truthy, wrapper exposes `role="img"` + `aria-label="<alt>"` (substitutes for the native `<img alt>` since background-image isn't readable by screen readers); when `alt === ''`, both attributes drop entirely (canonical WCAG decorative-image opt-out, AgID-compliant). Empty-state placeholder fires when `src` is `undefined` / `''` — same ratio-locked box gets the `pa-image--empty` modifier + a centered `<PaIcon name="image" size="lg">` glyph at `color: var(--pa-color-text-disabled-gray)`. Base color `--pa-color-bg-disabled-gray` shows through. **No** loading state, **no** error fallback, **no** `srcset` / lazy-loading at v1.14 (background-image rendering precludes those without an underlying `<img>`) |
400
+ | `PaImageGrid` | atom (composed) | v1.14 | ✅ Implemented | Composed coordinator for evenly-distributed image rows — the library's third composed component after `PaFileUpload` and `PaAccordionGroup`. Pure CSS-Grid wrapper rendered as `<div class="pa-image-grid">` with `display: grid; grid-template-columns: repeat({columns}, 1fr); gap: {gap}; width: 100%`. Props: `columns?: number` (default `3`), `ratio?: PaImageRatio`, `gap?: string` (default `'var(--pa-space-sm)'`). Provides `PaImageGridKey` (private `InjectionKey<{ ratio: Ref<PaImageRatio \| undefined> }>` symbol from `src/components/PaImage/gridKey.ts`) using `toRef(() => props.ratio)` so the inject stays reactive — flipping the grid's `ratio` prop at runtime updates every child without a local override. Children nest via default slot (slot-driven, not array-driven, mirroring PaAccordionGroup). Per-child `ratio` prop always wins over the injected value. Standalone `PaImage` outside a grid keeps working — `inject(..., null)` falls back to local-only resolution. Designer's `(100% - gap) / N` math is satisfied natively by `repeat(N, 1fr)` + `gap` — no JS distribution logic |
401
+ | `PaStepProgressBar` | atom (composite) | v1.15 | ✅ Implemented | Wizard / multi-step progress indicator — items-array driven, mirroring PaBreadcrumb's shape. Renders `<nav class="pa-step-progress-bar pa-step-progress-bar--{orientation}" :aria-label><ol><li :aria-current="step?"></li></ol></nav>` from a `steps: { label: string; status?: 'done' \| 'current' \| 'upcoming' \| 'error' }[]` array + `currentStep?: number` (default `0`) that index-derives statuses (`< → done`, `=== → current`, `> → upcoming`); per-step `status` field always wins when set, gating the beyond-Figma `error` visual against the existing alert palette. Four-state 32 × 32 dot: `done` → `--pa-color-success` + white `<PaIcon name="check" size="sm">`; `current` → `--pa-color-primary` + 2-digit-padded number (Inter Semi Bold 18/22, `--pa-color-text-white`); `error` → `--pa-color-error` + white `<PaIcon name="warning" size="sm">`; `upcoming` → `--pa-color-bg-disabled-gray` + `--pa-color-text-disabled-gray` 2-digit number. `pad2(n)` zero-pads `1..99`; `n >= 100` falls back unpadded. `orientation: 'horizontal' \| 'vertical'` (default `'horizontal'`) — horizontal is a transparent flex row (`flex: 1 1 0` columns), label centered below the dot at `Paragraph/Body` 16/20 (`Label/Small` 16/20 Semi Bold for the current step), 2 px connector lines (`--pa-color-bg-disabled-gray`) running through each row with the first step's leading line and the last step's trailing line forced to `background: transparent` via the `--hidden` modifier. Vertical layout flips to a column: outer wrapper gets `background: var(--pa-color-bg-primary-light-40)` + `border-bottom-left-radius / border-bottom-right-radius: var(--pa-border-md)` (always-on; horizontal has no bg), each `<li>` is a 60 px-min row with `padding: 0 var(--pa-space-sm)` + `gap: var(--pa-space-sm)`, the dot rail collapses to a 32 px-wide flex column with vertical 2 px connector lines, label is a left-aligned flex-grow region on the right. Click contract hard-coded: `isClickable(i)` returns `true` for `done` / `current` / `error` — those dots render `<button type="button">` and emit `select: { index, item, status: PaStepProgressBarStatus, event: MouseEvent }` (typed via `defineEmits`); `upcoming` dots render `<div aria-disabled="true">` (untabbable, no handler, `cursor: default`). Hover: `filter: brightness(0.95)` across all interactive statuses (no per-status hover tokens at v1.15). Focus ring: `outline: var(--pa-border-sm) solid var(--pa-color-primary-focus-border)` at 2 px offset (matches PaAccordion / PaFab / PaToggle). A11y wiring: outer `<nav aria-label="<label, default Stepper>">`; ordered list semantics via `<ol>`; `aria-current="step"` on the current `<li>` regardless of click handling; each dot's `aria-label` reads `"Step NN — <STATUS>"` in Italian via a hard-coded `STATUS_LABELS` map (no i18n prop at v1.15). Label area is non-clickable — only the disc is the interactive target. **No** keyboard arrow-navigation (native Tab order suffices), **no** animation on status change, **no** auto-responsive horizontal-↔-vertical collapse, **no** `navigable` prop |
402
+ | `PaBanner` | atom (composed) | v1.16 | ✅ Implemented | Composed advisory banner — the library's fourth composed component after `PaFileUpload`, `PaAccordionGroup`, and `PaImageGrid`. Renders `<aside class="pa-banner pa-banner--{variant}" :role :aria-live :aria-labelledby :aria-label>` with an inner `.pa-banner__container` holding `.pa-banner__icon--leading` (column to the left of the content) + `.pa-banner__content` (a `.pa-banner__title-row` wrapping `.pa-banner__icon--inline` + `<h3 class="pa-banner__title">` then a `.pa-banner__body` containing the default slot) + a `v-if="$slots.action"` `.pa-banner__action` containing the `#action` slot. Variant union `'info' \| 'info-dark' \| 'success' \| 'warning' \| 'error'` (default `'info'`) drives a triple resolved via top-level consts: `VARIANT_ICONS`, `VARIANT_A11Y` (`{ role: 'status'\|'alert'; ariaLive: 'polite'\|'assertive' }`), `VARIANT_LABELS` (Italian defaults). Custom-property plumbing on `.pa-banner` (`--pa-banner-bg`, `--pa-banner-text`, `--pa-banner-body`, `--pa-banner-link`, `--pa-banner-icon-color`) lets `.pa-banner--{variant}` selectors override only what's variant-specific — `info-dark` swaps all five, the three status variants only swap `--pa-banner-icon-color`. Title is a `string` prop (`<h3>` fixed at v1.16, no `titleLevel` config). Body is the **default slot** so consumers drop inline markup including `<a>` (auto-retinted via `.pa-banner__body :deep(a) { color: var(--pa-banner-link) }`). Action is a **named `#action` slot** — the trailing column auto-drops via `v-if` when empty. **Responsive without a `layout` prop**: `.pa-banner` declares `container-type: inline-size`; an `@container (max-width: 600px)` block flips the desktop row (8 px radius, max-width 1172 px, 56 px gutters, leading icon column, body with `margin-top: 12px`) to the mobile column (no radius, 24 px padding all sides, `.pa-banner__icon--leading` hidden + `.pa-banner__icon--inline` shown via `display: flex`, body with `margin-top: var(--pa-space-md)` 24 px, action becomes `display: block` so any wrapped child fills the row, `.pa-banner__action :deep(.pa-button) { width: 100% }` stretches the slotted PaButton including the story's `.dark-action` wrapper case where the wrapper would otherwise content-size the button). Container query (not viewport media query) is intentional: a banner nested in a 375 px sidebar gets the mobile shape even on a 1920 px viewport, and the same `.phone` story wrapper drives the responsive flip without `useMediaQuery`. Auto-generated `pa-banner-N__title` id (module-level counter) paired with `aria-labelledby` so SRs announce the title when the live region activates; `aria-label` falls back to per-variant Italian default and is consumer-overridable for landmark disambiguation. **No** `defineEmits` (dismiss removed during US-63 visual review — absolutely-positioned close X competed with content-driven height; deferred to a future story that ships dismiss as a dedicated layout slot rather than absolute positioning), **no** icon override (variant → icon is the visual contract), **no** `titleLevel` prop, **no** auto-i18n on default labels (Italian hard-coded) |
403
+ | `PaTable` | atom (composed) | v1.17 | ✅ Implemented | Composed data-table atom — the library's fifth composed component after `PaFileUpload`, `PaAccordionGroup`, `PaImageGrid`, and `PaBanner`. Renders `<div class="pa-table"><div class="pa-table__scroll"><table class="pa-table__el" :aria-label>` with a `<colgroup>` (one `<col>` per column carrying `style="width: ..."` when `column.width` is set), a `<thead><tr.pa-table__tr--head>` of `<th scope="col" class="pa-table__th--align-<a>">` cells, and a `<tbody><tr.pa-table__tr--body>` per row of `<td class="pa-table__td--align-<a>">` cells whose body is `<slot :name="cell-<col.key>" :row :value :index>{{ formatCell(row[col.key]) }}</slot>` (slot wins; the text fallback uses a local `formatCell(v) => v == null ? '' : String(v)`). Public types: `PaTableColumn = { key: string; label: string; align?: PaTableAlign; width?: string }`, `PaTableAlign = 'left' \| 'right' \| 'center'`, `PaTableCellValue = string \| number \| null \| undefined`. Scoped CSS: `.pa-table__el` carries `table-layout: auto` + `border-collapse: collapse`; every cell carries `border: 1px solid var(--pa-color-bg-primary-light-50)` (#D9E4F7) so adjacent borders merge into a single 1 px grid + a 1 px outer frame for free. `.pa-table__thead { background: var(--pa-color-bg-primary-dark-110); }` (#00368F — Figma `Text/Dark-Blue` reused as bg, **not** the lighter `--pa-color-primary` #0071FF). Header cells 72 px, body cells 76 px (Figma literals — 4 px asymmetry intentional); both use `--pa-font-body-*` (Inter Regular 16/20) — header is **not** bold, per Figma. `.pa-table__scroll` carries `overflow-x: auto` so a wider-than-viewport table scrolls horizontally inside the wrapper, no `@container` query. Per-column alignment via `.pa-table__th--align-<a>` + `.pa-table__td--align-<a>` modifiers (`left` is the default and adds no class beyond the base). **Selection** opt-in via `selectable: boolean`: `v-if`-rendered leading column in `<colgroup>` + `<thead>` (empty `<th--selection>` — no select-all checkbox per Figma constraint) + `<tbody>` (one `PaCheckbox` per body row); v-model is `selectedRowIds: string[]` with `update:selectedRowIds` emitted on every toggle (append-on-check preserves selection order, filter-on-uncheck). `rowId: (row, index) => string` factory keys the selection (default `(_, i) => String(i)` is unsafe under row reorder / pagination — consumers should pass a stable factory like `row => row.cf`); `selectionLabel: (row, index) => string` (default `'Seleziona riga'`) provides per-row `aria-label` on each PaCheckbox (falls through to PaCheckbox's root `<div>` since PaCheckbox has no `inheritAttrs: false`). A11y: native `<table>` (no `role` override — browser maps to ARIA `table` automatically), `<th scope="col">` on every header cell, `<table aria-label>` from optional `label?: string` (when unset, the attribute is omitted entirely — no empty `aria-label=""`). **No** sort chevrons / `sortable` prop, **no** header select-all, **no** pagination, **no** sticky header, **no** `@row-click` event, **no** row hover state, **no** zebra striping, **no** empty / loading state, **no** expandable rows, **no** virtualization, **no** mobile container-query flip — all deferred to future stories |
404
+ | `PaProgressBar` | atom | v1.18 | ✅ Implemented | Determinate + indeterminate progress indicator — `<div role="progressbar" :aria-label :aria-valuemin :aria-valuemax :aria-valuenow :aria-busy>` wrapping an optional `.pa-progress-bar__label` row, a `.pa-progress-bar__track`, and an optional `.pa-progress-bar__readout`. Track is 2 px tall in `--pa-color-bg-primary-light-50` (#D9E4F7) with `border-radius: 1px` (half-height — pill ends) and `overflow: hidden` so the indeterminate animation clips correctly. Fill is 100 %-height `--pa-color-primary` (#0071FF) with `transition: width 200ms ease-out` for determinate mode; flipping `indeterminate: true` swaps it to a `width: 30%` segment running `@keyframes pa-progress-bar-indeterminate` (`translateX(-100% → 333%)`, 1.5 s linear infinite) so the segment fully exits the right edge before reappearing on the left. Props: `value?: number` (default `0`; `clamp(Number(value), 0, 100)` with `NaN → 0` so API rounding bugs like 101 / -3 silently render correctly), `label?: string`, `showPercentage?: boolean` (default `true`), `indeterminate?: boolean` (default `false`). ARIA wiring intentionally omits `aria-valuemin/max/now` entirely (not `undefined` string) when indeterminate, and adds `aria-busy="true"` instead — per WAI-ARIA spec for indeterminate progressbars. `aria-valuenow` preserves the precise clamped value (fractional inputs survive) while the readout glyph is rounded via `Math.round(clamped)`. Label uses `--pa-font-label-sm-*` (Inter Semi Bold 16/20) at `--pa-color-text-secondary-gray`; readout uses `var(--pa-font-family-mono)` (Roboto Mono — first library use of the mono family) at literal `10px / 12px`, `align-self: flex-end` for right-alignment in the wrapper's flex column. `@media (prefers-reduced-motion: reduce)` collapses both transitions: determinate fill drops the width transition, indeterminate collapses to a static `width: 100%` + `opacity: 0.3` bar so SR users still see "something is happening" without motion. **No** `defineEmits` (presentational only), **no** custom readout formatter (rounded percent is hard-coded), **no** size variants, **no** color variants — all deferred |
405
+ | `PaTabs` | atom (composed) | v1.19 | ✅ Implemented | Horizontal tab strip + per-tab panel coordinator — the library's sixth composed component after `PaFileUpload`, `PaAccordionGroup`, `PaImageGrid`, `PaBanner`, and `PaTable`. Renders `<div class="pa-tabs"><div class="pa-tabs__strip pa-tabs__strip--variant-{v}" role="tablist" :aria-label aria-orientation="horizontal">` of `<button type="button" role="tab" :id :aria-selected :aria-controls :aria-disabled :tabindex :disabled>` cells (each carrying a `.pa-tabs__tab-accent` 3 px bar + a `.pa-tabs__tab-content` row of optional `<PaIcon size="sm">` + `<span class="pa-tabs__tab-label">`), then a `.pa-tabs__panels` container with an active `<div role="tabpanel" :id :aria-labelledby tabindex="0">` (array API) or the default slot for composition mode. **Hybrid API**: `tabs?: PaTabsTab[]` (`{ id: string; label: string; icon?: PaIconName; disabled?: boolean }`) + per-tab `#panel-<id>` named slots **or** `<PaTab>` children that register themselves via the provided `PaTabsKey` (`InjectionKey<PaTabsContext>` — public, named-exported alongside the context interface for advanced consumers). Mix detection: `useSlots().default?.()` in `onMounted` — when the array API is active AND the default slot has VNodes, a single `[PaTabs] cannot mix…` warn fires and the array API wins. `usingArrayApi` computed gates which template branch renders the active panel wrapper. **Auto-activation**: `resolvedActiveId` falls back to the first non-disabled tab when `props.activeId` is `undefined` or refers to a disabled / missing tab; the fallback does **not** emit `update:activeId` (matches the uncontrolled-default pattern from PaAccordionGroup). Two visual variants — `variant: 'line' \| 'background'` (default `'line'`) — share the same JS / ARIA / keyboard contract; CSS modifiers are the only difference: line has a 3 px bottom accent bar (`order: 2` on the bar, `order: 1` on the content) over a flat white strip, default bar `--pa-color-bg-primary-light-50` → selected `--pa-color-primary` → hover `--pa-color-primary-hover` → disabled `--pa-color-bg-secondary-light-50`; background uses a `--pa-color-bg-primary-light-40` pill (selected `--pa-color-bg-white`, disabled `--pa-color-bg-disabled-gray`) with a 3 px top accent (`order: 1` on the bar, `order: 2` on the content), strip gap `2px` + `padding: 0 1px` so focus outlines don't get clipped. Tab `<button>` reuses Inter 20/24 — `font-weight: 400` default, `font-weight: 600` when `[aria-selected="true"]` (typography reuses `--pa-font-label-xl-size` + `--pa-font-label-xl-line-height` since no `--pa-font-label-lg-*` Regular tier exists yet — literal weights). Per-tab icons render through `<PaIcon size="sm">` (16 px in PaIcon's enum) but are forced to 18 × 18 via `.pa-tabs__tab-content :deep(svg) { width: 18px; height: 18px }` — Figma's 18 px sits between PaIcon's `sm` (16) and `md` (20), so the scoped override avoided adding a new PaIcon size for one consumer. **Full WAI-ARIA tabs pattern** wired in `onTabKeyDown(event, currentTab)`: Arrow Left/Right cycles enabled tabs (wraps via modulo on the enabled-only sub-array — disabled tabs are skipped entirely), Home/End jumps to first/last enabled, Space/Enter activates the focused tab — all `preventDefault`'d. Selection follows focus by default (automatic activation per WAI-ARIA APG); `focusTab(id)` queues a `nextTick(() => document.getElementById(...).focus())` so the visible focus ring follows keyboard activations but mouse clicks skip the programmatic refocus. Roving `tabindex`: 0 on active, -1 on others — Tab/Shift+Tab enters/leaves the tablist on the active cell instead of cycling through every tab. Auto-generated `pa-tabs-N__tab-{id}` / `pa-tabs-N__panel-{id}` ids via a module-level counter so multiple PaTabs on the same page never collide on `aria-controls` / `aria-labelledby`. Focus ring: `outline: var(--pa-border-xs) solid var(--pa-color-primary-focus-border)` at 0 offset on tabs, 2 px offset on the panel. Only the active panel is in the DOM — consumers needing persistent panel state wrap their bodies in `<KeepAlive>` themselves. **No** vertical orientation (no Figma spec exists yet — `aria-orientation` is hard-coded `"horizontal"`), **no** scrollable overflow chrome, **no** closable / lazy / animated panels, **no** manual activation mode (selection-follows-focus is hard-coded), **no** RTL handling, **no** badge / counter / rich-label slot per tab — all deferred |
406
+ | `PaTab` | atom (companion) | v1.19 | ✅ Implemented | Composition-API companion for `PaTabs` — valid **only** as a child of a `<PaTabs>` parent. `inject(PaTabsKey, null)` resolves the parent context; when `null`, logs `[PaTab] must be nested inside <PaTabs>` and renders nothing (no slot output, no markup). Props: `id: string` (required, stable for the lifetime of the instance — re-mount the component to change), `label: string`, `icon?: PaIconName`, `disabled?: boolean` (default `false`). Lifecycle: `onMounted` calls `parent.register({ id, label, icon, disabled })`; a `watch` on `[label, icon, disabled]` re-registers the descriptor so prop changes propagate to the parent's strip; `onBeforeUnmount` calls `parent.unregister(id)`. The parent maintains a `ref<PaTabsTab[]>` registry deduplicated by id (`findIndex` + `splice` on existing, `push` on new) so children show in mount order and updates surface live. The tab `<button>` is **not** rendered by PaTab — PaTabs owns every `<button role="tab">` regardless of API choice, so the strip stays a flat flexbox row. When active, PaTab renders its default slot wrapped in `<div role="tabpanel" :id="`${parent.tabsetId}__panel-${id}`" :aria-labelledby="`${parent.tabsetId}__tab-${id}`" tabindex="0" class="pa-tab__panel">` so a11y semantics live next to the body content; when inactive, renders nothing (no `display: none` carry — non-active bodies are fully out of the DOM, mirroring PaTabs's array-API panel behaviour). Focus ring on the panel wrapper: `outline: var(--pa-border-xs) solid var(--pa-color-primary-focus-border)` at 2 px offset (duplicated from PaTabs's scoped style since scoped CSS doesn't cross component boundaries — single rule per scope is cheaper than a `:deep()` chain) |
407
+ | `PaHero` | atom (composed) | v1.22 | ✅ Implemented | First page-level composed component — pairs a text column (breadcrumb / label / title / subtitle / body / cta) with an optional PaImage column. Renders `<section class="pa-hero" :aria-labelledby>` → `<div class="pa-hero__inner" :class>` → `<div class="pa-hero__text">` (with `__breadcrumb` / `__label` / `__title-block` / `__cta` children, each `v-if`'d on slot/prop presence) → `<div class="pa-hero__image-col">` containing `<PaImage :src :alt :ratio>`. **Six slots** (`#breadcrumb` / `#label` / `#title` / `#subtitle` / `#body` / `#cta`) plus structured-prop fallbacks (`title`, `titleLevel: 1..6` default `1`, `subtitle`, `body`, `imageSrc`, `imageAlt`, `imageRatio: PaImageRatio` default `'3:2'`, `imagePosition: 'left' \| 'right'` default `'right'`). Slot wins over prop for title/subtitle/body. Title renders via `<component :is="`h${titleLevel}`">` so the heading tag is consumer-configurable while the typography stays selector-based (`.pa-hero__title`) — visual is identical whether the consumer picks `<h1>` or `<h3>`. Subtitle + body always render as `<p>` so SR heading-outline navigation isn't polluted. `aria-labelledby` auto-wires to the heading's `:id`, which is minted as `pa-hero-${nextHeroId()}__title` via a module-level counter; when neither title prop nor `#title` slot is present, the title element is omitted and `aria-labelledby` is dropped (no dangling reference) — consumers needing an accessible name in the no-title case set `aria-label` via `$attrs` fall-through. **Image-position flip** is CSS-only (`.pa-hero__inner--image-left { flex-direction: row-reverse }`) so DOM source order stays text → image regardless of visual order (predictable for keyboard tab + SR linearization). Image column is removed from the DOM entirely when `imageSrc` is absent; the text column then takes 100% width naturally via `flex: 1 0 0` flex math (no special "fullwidth" class needed). **Mobile collapse via `@container (max-width: 720px)`** (denser than PaBanner / PaPagination's 600 px — hero columns need ~340 px each to comfortably hold Figma-spec content; below 720 they cramp). Inside the @container block: both `.pa-hero__inner` and `.pa-hero__inner--image-left` map to `flex-direction: column; align-items: stretch` so the image always ends up *below* the text on mobile (not flipped to the top via `column-reverse`); `.pa-hero` gains `padding: var(--pa-space-sm)` + `border-bottom: var(--pa-border-xs) solid var(--pa-color-bg-primary-light-50)` per Figma node `598:24896`; `.pa-hero__image-col` drops `align-self: center` and stretches to `width: 100%` so its `aspect-ratio` (default `3:2`) sizes its height naturally. White bg (`--pa-color-bg-white`) applies on both desktop and mobile. **Typography is Figma-literal**, not token-driven, because the designer drew sizes outside the project's `--pa-font-*` token tier: title is `Inter Bold 40/60 letter-spacing 0 color var(--pa-color-bg-primary-dark-110)` (#00368F — same Text/Dark-Blue reuse PaTable's header uses); subtitle is `Inter Bold 24/28 letter-spacing -0.5px color var(--pa-color-text-secondary-gray)` (#454D56); body is `Inter Regular 16/20 color var(--pa-color-text-secondary-gray)`. Title / subtitle / body all carry `margin: 0; width: 100%` so the title-block's 8 px flex gap is the sole inter-row driver (no UA heading margin contamination). Body inline-link default color via `.pa-hero__body :deep(a) { color: var(--pa-hero-body-link-color, #2f6dd5) }` + `:deep(a:hover) { text-decoration: underline }` — the `--pa-hero-body-link-color` custom property is the single overridability knob (set it inline / on an ancestor / globally and every nested link picks up the new colour without forking the SFC). Image rendering goes through `PaImage` so its empty-state placeholder + `aria-label` semantics carry; `imageAlt` defaults to `''` at the template level so PaImage's decorative-image opt-out (drops `role="img"` + `aria-label`) is the natural state when the consumer doesn't provide alt text. **No** background-image / full-bleed variant, **no** video media, **no** sticky / scroll-collapse behaviour, **no** loading skeleton, **no** secondary CTA slot, **no** dedicated `PaLabel` atom (consumer drops `<PaTag variant='info' .../>` into `#label` — visually identical to Figma's "Label Tag"), **no** RTL handling, **no** `variant` prop (single split layout at v1.22) — all deferred |
408
+ | `PaPagination` | atom (composed) | v1.21 | ✅ Implemented | Composed pagination atom — the library's seventh composed component after `PaFileUpload`, `PaAccordionGroup`, `PaImageGrid`, `PaBanner`, `PaTable`, and `PaTabs`. Renders `<nav class="pa-pagination pa-pagination--{variant}" :aria-label>` with two structurally-different layouts gated on `variant: 'cursor-number' \| 'items-page'` (default `'cursor-number'`). Cursor-number: white wrapper + 1 px `--pa-color-bg-primary-light-50` border + 8 px radius + 4 px outer padding, inner `<ul class="pa-pagination__list">` of prev `<button class="pa-pagination__caret">` + page `<button class="pa-pagination__tile">` or `<li class="pa-pagination__ellipsis" aria-hidden="true">..</li>` + next `<button>`. Each numbered tile carries `aria-label="{pageLabel(n)}"` + `aria-current="page"` when current. Page-window truncation lives in the sibling `usePageWindow.ts` pure module (named export `pageWindow(currentPage, totalPages): (number \| 'ellipsis')[]`) so it can be unit-tested directly — algorithm: returns `[]` when `totalPages <= 0`, returns full `[1..totalPages]` when `totalPages <= 5`, otherwise builds a 3-page window centered on `currentPage` (biased to `[1,2,3]` at the start / `[total-2, total-1, total]` at the end), unions with `{1, totalPages}`, sorts, then inserts `'ellipsis'` between non-adjacent runs. Five-state tile matrix is CSS-only (no JS state): current (`.pa-pagination__tile--current` → `--pa-color-primary-pressed` bg + white text + `var(--pa-font-family-mono)` at literal 16/24 — no `--pa-font-label-md-number-*` token tier exists yet), default (white bg + `--pa-color-text-secondary-gray` + `var(--pa-font-body-*)` Inter Regular 16/20), hover (`--pa-color-primary-hover` + white, scoped via `:hover:not(.--current):not(.--disabled)`), focus-visible (white bg + secondary-gray text + mono font + `box-shadow: inset 0 0 0 1px var(--pa-color-primary)` so the 1 px ring renders inside the 4 px radius with zero layout shift), disabled (`--pa-color-bg-secondary-light-40` + secondary-gray + mono + `aria-disabled="true"` + `tabindex="-1"`, applied when component-level `disabled: true`). Tile box: `min-width / min-height: 48px` a11y hit-target, `padding: 14px`, `border-radius: var(--pa-radius-xs)`. Prev / next ghost icon-only buttons (`.pa-pagination__caret`) use `<PaIcon name="caret-left" / "caret-right" size="sm">` at 48 × 48; hover bg `--pa-color-bg-primary-light-40`, focus-visible outline `var(--pa-border-sm) solid var(--pa-color-primary-focus-border)` at 2 px offset; `aria-disabled="true"` + `tabindex="-1"` at boundaries (`currentPage === 1` for prev, `currentPage === totalPages` for next) and under component-level `disabled`. Items-page: 74 px-tall light-blue (`--pa-color-bg-primary-light-50`) bar with `display: flex; align-items: stretch; container-type: inline-size`. Desktop layout (container ≥ 601 px) splits into `.pa-pagination__items-block` (`flex: 1 1 auto; padding: 4px 16px; gap: 24px`: `<label>Elementi per pagina:</label>` + `<PaSelect size='lg' class='pa-pagination__select'>` width-forced to 82 px + range readout `<span class="pa-pagination__num">{rangeStart} - {rangeEnd}</span> di <span class="pa-pagination__num">{totalItems}</span> Elementi`) + 58-px vertical divider line (`--pa-color-bg-primary-light-60`) + `.pa-pagination__page-block` (`flex: 0 0 auto; padding: 4px 40px 4px 32px; gap: 32px`: `<PaSelect>` page-jumper + `di <span class="pa-pagination__num">{totalPages}</span> Pagine` + prev / next ghost carets). PaSelect renders a native `<select>` under `defineModel<string>`, so PaPagination bridges `:model-value="String(currentPage)"` and `@update:model-value="(s) => emit('update:currentPage', Number(s))"` (same for `pageSize`). `pageSizeSelectOptions` unions `pageSizeOptions` with the current `pageSize` (so legacy / URL-restored values aren't lost), filters `> 0`, sorts ascending. Mobile collapse (container ≤ 600 px) via `@container (max-width: 600px)`: items-per-page label, both PaSelects, divider, and the page-block's `di N Pagine` range all `display: none`; outer wrapper flips to `justify-content: space-between; padding: 16px 32px`; items-block keeps its desktop 16 px inline padding (intentional — provides the 16 px text inset that balances the right-side caret's internal 16 px padding for visual symmetry); page-block overrides to `padding: 0` so carets pin flush against the outer 32 px wrapper inset. All hidden elements remain in the DOM (CSS visibility, not `v-if`) — toggling container width is zero Vue re-render. Italian-default labels (`label='Paginazione'`, `previousLabel='Pagina precedente'`, `nextLabel='Pagina successiva'`, `pageLabel: (p) => 'Vai alla pagina ${p}'`, `itemsPerPageLabel='Elementi per pagina:'`, `rangeSeparator='di'`, `itemsLabel='Elementi'`, `pagesLabel='Pagine'`) — all prop-overridable. `totalPages` computed from `Math.ceil(totalItems / pageSize)` (not a prop). Module-level `paginationUid` mints `pa-pagination-N__items-select` + `__page-select` ids. Empty state (`totalItems === 0` or `pageSize <= 0`) omits the `<nav>` entirely; single-page sets render with both carets disabled. **No** jump-to-first / jump-to-last buttons, **no** ARIA live announcements, **no** RTL / vertical orientation, **no** "go to page N" free-text input, **no** page-size persistence (consumer concern), **no** server-cursor adapter, **no** clickable ellipsis (skip ±5 pages), **no** tile size variants, **no** loading skeleton — all deferred |
409
+
410
+ | `PaTitleBar` | atom (composed) | v1.23 | ✅ Implemented | Section title bar — `<div class="pa-title-bar-wrapper">` (carries `container-type: inline-size`) containing `<header class="pa-title-bar pa-title-bar--{variant}">` + an optional `<ul class="pa-title-bar__menu" role="menu">` sibling. Props: `title: string` (required), `titleLevel?: 1..6` (default `3`), `subtitle?: string`, `variant?: 'dark' \| 'light'` (default `'dark'`), `menuItems?: { label, onClick?, disabled? }[]`, `menuLabel?: string` (default `'Apri menu'`); `v-model:menuOpen` via `defineModel<boolean>('menuOpen', { default: false })` — `menuOpen` is intentionally **not** in `PaTitleBarProps` (parallels PaTextarea/PaSelect/etc. where v-model lives outside the props interface). Title renders via `<component :is="`h${titleLevel}`">`; subtitle is always `<p>` so SR heading-outline navigation stays clean. **Single `#actions` slot** for desktop inline actions — consumer drops 0–3 elements (typically `<PaButton variant="ghost">` retinted via the exposed `--pa-title-bar-action-color` knob); slot is `v-if="$slots.actions"` so the wrapper drops entirely when empty. **Container-query mobile collapse at 720 px** (matches PaHero; denser than PaBanner/PaPagination's 600 px because title + subtitle + 3 actions need more horizontal headroom): `@container (max-width: 720px)` hides `.pa-title-bar__subtitle` + `.pa-title-bar__actions` via `display: none`, reveals `.pa-title-bar__kebab` via `display: inline-flex`, and tightens padding to `8px 8px 8px 16px`. Kebab is a 44 × 44 `<button>` with `<PaIcon name="dots-three-vertical" size="sm">` — `display: none` desktop, `inline-flex` ≤ 720 px; carries `aria-haspopup="menu"`, `aria-expanded` (toggles with state), `aria-controls` paired to the menu's id; hidden entirely when `menuItems.length === 0` (no dead button on mobile). Kebab open-state flips bg to `--pa-color-primary-pressed` + white icon on both variants via the `.pa-title-bar__kebab--open` modifier. Dropdown items are `<li role="none"><button role="menuitem" :disabled>{label}</button>`; first/last get rounded corners; adjacent borders collapse via `margin-bottom: -1px` over a 1 px `--pa-color-bg-secondary-light-50` border so the line between items renders single-pixel. Rows are 56 px tall, 16 px padded, Inter Regular 16/20 in `--pa-color-text-secondary-gray`. Module-level `titleBarUid` counter mints `pa-title-bar-N__kebab` + `pa-title-bar-N__menu` ids so multiple instances never collide. **Full WAI-ARIA menu-button pattern**: clicking the kebab toggles `menuOpen` (via `defineModel`) and, on open, focuses the first non-disabled item via `nextTick`. `Esc` on kebab or any menuitem closes + refocuses the kebab via `closeMenu()`. `ArrowDown` / `ArrowUp` cycle enabled items (wrap via modulo on the enabled-only sub-array — disabled indices are filtered out). `Home` / `End` jump to first / last enabled. All keyboard handlers `preventDefault`. `Enter` / `Space` fire the native button click (no special wiring). **Click-outside**: a `mousedown` listener on `document` is attached only while `menuOpen === true` (via a `watch(menuOpen, ..., { immediate: true })` so controlled-mode initial-open also wires) and removed both on close and in `onBeforeUnmount`; when `!wrapper.contains(event.target)`, `menuOpen` flips to `false`. Variant styling driven by CSS custom properties (`--pa-title-bar-title-color` / `--pa-title-bar-subtitle-color` / `--pa-title-bar-action-color` / `--pa-title-bar-kebab-color`): Dark → `--pa-color-primary` bg + white text/icons; Light → `--pa-color-bg-primary-light-40` bg + literal `#000` title (Figma Background/Black, single-use literal) + `--pa-color-text-secondary-gray` subtitle + `--pa-color-primary` action/kebab color. PaTitleBar exposes `--pa-title-bar-action-color` as a *hint* — consumers retint their slotted PaButtons themselves (Dark variant typically needs a `:deep()` override to white in the consumer's scoped CSS, since `PaButton variant='ghost'` defaults to `--pa-color-text-primary-blue` which is invisible on the blue bar; the story uses a `.dark-context` wrapper + `:deep(.pa-button--ghost)` color/hover/active overrides as the reference pattern). Italian-default `menuLabel='Apri menu'` overridable for non-Italian consumers. **No** nested submenus, **no** icons / dividers / section labels in dropdown rows (Figma shows plain-text only — `menuItems` shape has no `icon` field), **no** portaled dropdown (in-document positioning is enough — PaTitleBar always sits at the top of a panel, no z-index conflicts; a `portal: boolean` opt-in is reserved for v2 if a consumer hits an `overflow: hidden` ancestor), **no** menu-alignment knob (Figma always anchors right under the kebab — hard-coded), **no** open/close animation (PA portals prefer instant transitions; nothing to suppress for `prefers-reduced-motion`), **no** search / filter inside dropdown, **no** `closeOnSelect: false` opt-in (clicking an item always closes), **no** RTL, **no** sticky / pin-on-scroll behavior, **no** background-image variant beyond Dark / Light, **no** mobile inline primary action — all deferred |
411
+
412
+ | `PaCard` | atom (composed) | v1.24 | ✅ Implemented | Composed content card covering 3 typologies × 2 variants for editorial / document / process / status surfaces. Root chrome carries `box-shadow: var(--pa-shadow-elevation-01)` (Figma elevation-01, tokenized in v1.27) — applies uniformly across all typology × variant combinations. Renders `<article class="pa-card pa-card--{typology} pa-card--{variant}" :aria-labelledby>` (carries `container-type: inline-size`) containing optional `.pa-card__image-cell` (PaImage, with the cell suppressed via `v-if="imageSrc && typology !== 'process'"` so Process never renders an image even if `imageSrc` is set) + `.pa-card__content` with optional rows for header (`.pa-card__header-leading` + `.pa-card__header-trailing`), title-block (`<h${titleLevel}>` + subtitle `<p>`), body, signature, and footer (`v-if="$slots.footer"`). **API**: `typology?: 'landscape' \| 'portrait' \| 'process'` (default `'portrait'`); `variant?: 'light' \| 'dark'` (default `'light'`, works on all typologies — Figma drew Dark only on Process but the navy + cyan skin is reusable); `title?: string`, `titleLevel?: 1..6` (default `3`), `subtitle?: string`, `body?: string`, `signature?: string`, `date?: string`, `category?: string` (renders PaTag with `info` variant), `categoryIcon?: PaIconName` (renders 32×32 PaIcon — Process default; precedence wins over `category`), `imageSrc?: string`, `imageAlt?: string` (default `''` for decorative-image opt-out), `imageRatio?: PaImageRatio` (no `withDefaults` default; computed `resolvedRatio` = consumer prop > typology default → `'1:1'` landscape / `'3:2'` portrait; Process ignores). **Seven named slots** (`#title` / `#subtitle` / `#body` / `#signature` / `#header-leading` / `#header-trailing` / `#footer`) — slot always wins over the structured-prop fallback via `useSlots()` + `computed` flags (`hasTitle = !!slots.title \|\| !!props.title`, etc.); the slot template uses the `<slot name="x">{{ propFallback }}</slot>` pattern (same as PaHero / PaTitleBar). Module-level `cardUid` counter mints `pa-card-N__title` ids; the heading's `:id` is paired to the `<article aria-labelledby>` whenever a title (prop OR slot) is present, and `aria-labelledby` is omitted entirely (no dangling reference) when neither is set. Subtitle is `<p>` not a heading, so SR heading-outline navigation stays clean; signature is `<p>` not `<address>` (Figma's "Firma Autore" isn't always a postal address — `<address>` would be semantically wrong for "John Doe — Editor"). Date is a plain `<span>` (no `<time>` — PaCard can't parse the consumer-supplied string without forcing a separate `dateISO` prop; deferred). **Variant theming via CSS custom properties** (`--pa-card-bg` / `--pa-card-title-color` / `--pa-card-body-color` / `--pa-card-date-color` / `--pa-card-signature-color` / `--pa-card-subtitle-color` / `--pa-card-action-color`): Light → `--pa-color-bg-white` bg + `--pa-color-primary` title on Landscape/Portrait (the link-blue navigational cue) flipped to `--pa-color-text-secondary-gray` on Process via the `.pa-card--process.pa-card--light` joint selector (Process tiles read informational, not navigational); subtitle / body / date / signature in `--pa-color-text-secondary-gray`; `--pa-card-action-color: var(--pa-color-primary)`. Dark → `--pa-color-bg-primary-dark-115` (#002460) bg + `--pa-color-bg-white` title / body / date / signature / subtitle; `--pa-card-action-color: var(--pa-color-accent)` (#18CEE7 — reuses the existing accent token rather than introducing a `--pa-color-accent-cyan`). A second joint selector `.pa-card--process.pa-card--dark { --pa-card-title-color: var(--pa-color-bg-white) }` re-asserts the white title on Process+Dark (the Process+Light gray override would otherwise win by specificity). **Typology layouts** (CSS-only, no JS branching): Landscape (`flex-direction: row`; `.pa-card__image-cell` `width: 370px; align-self: stretch; flex-shrink: 0`; `.pa-card__content` `flex: 1 0 0; min-width: 0; padding: 32px 48px; gap: 24px`; title H3 `32px/36px letter-spacing: -1px`; subtitle H5 `20px/24px letter-spacing: -0.5px`; footer `justify-content: flex-end`); Portrait (`flex-direction: column`; image cell `width: 100%` and rendered height comes from PaImage's `aspect-ratio` via `resolvedRatio`; content `padding: 32px; gap: 24px; min-width: 0`; title H4 `24px/28px letter-spacing: -0.5px`; subtitle H6 `18px/22px letter-spacing: -0.5px`); Process (`flex-direction: column`; no image cell; content `padding: 32px; gap: 24px`; title H4 in gray on Light / white on Dark; `.pa-card--process .pa-card__subtitle, .pa-card--process .pa-card__signature { display: none }` so consumer-supplied subtitle / signature don't visually leak — `display: none` rather than `v-if` so flipping typology at runtime restores the rows; footer `padding-top: 24px`; 32×32 categoryIcon via `.pa-card--process .pa-card__header-leading :deep(svg) { width: 32px; height: 32px }` because PaIcon size `lg` is 24 px but Figma draws Process icons at 32). **`@container (max-width: 720px)` collapse is Landscape-only**: Landscape stacks to `flex-direction: column`, image cell becomes `width: 100%; align-self: auto`, content padding drops to `24px`, title scales H3 → H4 (`24/28/-0.5`) — matching Portrait at mobile widths because the Landscape distinction visually disappears below ~720. Portrait + Process intentionally do **not** tighten via @container because both Figma frames (`117:2063` desktop and `117:2083` mobile for Portrait; same dual-cell shape for Process) render at 360 px wide in *both* device cells — container-width cannot distinguish device context for narrow typologies. Consumers needing tighter Portrait / Process spacing override the `--pa-card-*` CSS variables themselves. **Composed reuse**: `PaImage` (image cell — gets the decorative-image opt-out + empty-state placeholder + `aspect-ratio` lock for free); `PaTag` (category, rendered with `info` variant + label string); `PaIcon` (categoryIcon, rendered at size `lg` then scoped-overridden to 32 px on Process). The footer is **slot-only** — PaCard ships the spacing / gap / alignment chrome but no default icons or buttons, because Landscape / Portrait want share+bookmark icon-only ghosts while Process wants tertiary+kebab; auto-wiring either would force the consumer to override per-icon and per-text. Italian-default labels: none (PaCard is fully content-driven; no internal screen-reader label to translate). **No** whole-card clickability / `href` prop, **no** hover / pressed state on the card, **no** loading skeleton, **no** image-on-top overlay-text variant, **no** multi-image gallery, **no** `<img srcset>` (PaImage v1.14 limitation — background-image rendering), **no** body truncate-with-ellipsis, **no** custom border-radius tokens (single 8 px radius), **no** RTL, **no** date parsing / `Intl.DateTimeFormat`, **no** `<address>` semantics on signature, **no** sticky / pin-on-scroll, **no** card-flip / expand-on-click, **no** background-image-fill variant, **no** dedicated cyan `PaButton` variant for Process Dark (consumer retints via `--pa-card-action-color`), **no** mobile inline-only-action variant — all deferred |
413
+ | `PaModal` | atom (composed) | v1.25 | ✅ Implemented | Slot-driven dialog component for confirmations, inline forms, and informational alerts. Renders `<Teleport to="body">` containing a `<div class="pa-modal__wrapper">` (carries `container-type: inline-size` + `position: fixed; inset: 0`) with a sibling `<div class="pa-modal__backdrop">` (`rgba(0,0,0,0.5)`, z-index 1000) and `<div role="dialog" aria-modal="true" :aria-labelledby :aria-describedby class="pa-modal pa-modal--{size}" tabindex="-1">` (z-index 1001, `border-radius: 16px`, `box-shadow: var(--pa-shadow-elevation-04)` — Figma `elevation-04` (tokenized in v1.27)). **API**: `title?: string`, `titleLevel?: 1..6` (default `2` — modals interrupt the page outline, h2 is the natural tier), `subtitle?: string`, `leadingIcon?: PaIconName`, `showClose?: boolean` (default `true`), `closeLabel?: string` (default `'Chiudi'`), `size?: 'sm' \| 'md' \| 'lg'` (default `'md'` → 400 / 600 / 800 px via `--pa-modal-width` CSS variable per-size modifier class). `v-model:open` via `defineModel<boolean>('open', { default: false })`. **Two named slots** (`#default` and `#footer`) with the body wrapper / footer wrapper dropped when their respective slot is empty; body has no internal padding/gap (consumer owns its content spacing), footer is `display: flex; gap: 24px; padding-top: 16px; justify-content: flex-end`. Header is **component-owned** (no `#header` slot): optional leading-icon row (`pb-16`, scoped `:deep(svg)` 32×32), `<h${titleLevel}>` title in Inter Bold 24/28 -0.5 `--pa-color-text-secondary-gray` (not primary blue — modal titles are informational, not navigational), `<p>` subtitle in Inter Regular 16/20 same color, top-right transparent close-button tile (`50×48`, 8 px radius) carrying PaIcon `name="x" size="sm"` + `:aria-label="closeLabel"` and hover bg `--pa-color-bg-secondary-light-50` + focus-visible 2-px primary outline at 2-px offset. **A11y**: module-level `modalUid` counter mints unique `pa-modal-N__title` + `__description` ids; `aria-labelledby` paired to the title id when title is set (omitted otherwise), `aria-describedby` paired to subtitle id when subtitle is set (omitted otherwise), `role="dialog" aria-modal="true"` always present, `tabindex="-1"` on the modal root so it can receive focus when no descendants are focusable. **Open/close**: `v-model:open` only — Esc / backdrop click / close button all unconditionally emit `update:open(false)`; no `closeOnEscape` / `closeOnBackdrop` knobs (intercept the emit and re-emit `true` to veto). **Full a11y orchestrator** via single `watch(open, ..., { flush: 'post', immediate: true })`: on open → snapshots `document.activeElement` as `previouslyFocusedElement`, applies body scroll lock (snapshots + overrides `document.body.style.overflow = 'hidden'` + scrollbar-width-compensating `paddingRight` measured via `innerWidth - documentElement.clientWidth`, only when scrollbar > 0), attaches `window.keydown` Esc listener, awaits `nextTick`, focuses close button (if `showClose`) → first focusable descendant → modal root itself; on close → reverses every snapshot, detaches Esc, restores focus to `previouslyFocusedElement` if it's still in the DOM (silently drops focus otherwise). **Focus trap** via `@keydown` on the modal root checking `event.key === 'Tab'` + computing first/last focusable via the selector `'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'`: Tab from last wraps to first, Shift+Tab from first wraps to last, both with `event.preventDefault()`. `onBeforeUnmount` runs scroll-lock + Esc cleanup defensively (modal unmounted while open scenario). Inner `.pa-modal__container` centers content on desktop (`align-items: center; justify-content: center; gap: 24px`). **`@container (max-width: 600px)` collapse** on the wrapper: modal width clamps to `calc(100vw - 32px)` with `max-width: none` (`size` preset ignored on mobile — modal fills viewport with 16 px gutter), padding tightens from `24/32/32/32` to `24px` all sides, inner container left-aligns + gap drops to 16 px, leading icon scales to 24×24, footer `flex-direction: column; align-items: stretch` (DOM order preserved — no `column-reverse`, SR reading order matters; consumers wanting Primary on top stack-order place Primary first). 600 px breakpoint (vs library standard 720) chosen so the default `md` 600 px modal tips into fill-viewport at its own width. Note: ancestor sizing does NOT constrain the fixed-position wrapper, so devtools responsive mode is the verification path — Histoire variants render at desktop layout regardless of any framing container. Italian-default labels: `closeLabel: 'Chiudi'`. **No** `closeOnEscape` / `closeOnBackdrop` props, **no** `#header` / `#trigger` slots, **no** initial-focus-ref prop, **no** enter/leave animations, **no** drawer / side-sheet variant, **no** confirmation-modal preset, **no** RTL, **no** Teleport target override, **no** stacked-modal z-index manager / scroll-lock counter, **no** `inert` on background siblings, **no** `role="alertdialog"` variant, **no** `size: 'full'` fullscreen, **no** custom backdrop color / opacity tokens, **no** `@opened` / `@closed` lifecycle events beyond `update:open` — all deferred |
414
+
415
+ | `PaCarousel` | atom (composed) | v1.26 | ✅ Implemented | Content-agnostic paginated slide strip — the library's tenth composed component after `PaFileUpload`, `PaAccordionGroup`, `PaImageGrid`, `PaBanner`, `PaTable`, `PaTabs`, `PaPagination`, `PaHero`, `PaTitleBar`, `PaCard`, `PaModal`. Renders `<div class="pa-carousel" role="region" aria-roledescription="carousel" tabindex="0" :aria-label>` (carries `container-type: inline-size`) containing `.pa-carousel__row` (flex row with the prev / next chevron columns flanking a `.pa-carousel__track` scroll container) + an optional `.pa-carousel__dots` strip below. **Content API**: default slot only — consumer drops N children (any component, typically `<PaCard>`); the carousel reads `useSlots().default?.()` and flattens Fragments (`vnode.type === Fragment`, recursive) + skips Comment / Text nodes so v-for-driven slots are enumerated correctly. Each surviving vnode is wrapped in a `<div class="pa-carousel__slide" role="group" aria-roledescription="slide" :aria-label="\`${i + 1} di ${slideCount}\`">` rendered through a tiny inline `defineComponent({ setup: (p) => () => p.node })` helper (raw VNodes cannot be passed to `<component :is>`; the helper's render function returns the VNode directly). **Props**: `itemsPerPage?: 1 \| 2 \| 3` (default `3`; mobile forces 1 via the `@container` block + JS reactive), `label?: string` (default `'Carosello'`), `showChevrons?: boolean` (default `true`), `showDots?: boolean` (default `true`), `previousLabel?: string` (default `'Pagina precedente'`), `nextLabel?: string` (default `'Pagina successiva'`), `pageLabel?: (n: number) => string` (default `(n) => \`Vai alla pagina ${n}\`` — 1-indexed for humans). `v-model:currentPage: number` via `defineModel<number>('currentPage', { default: 0 })` — **page-indexed** (0-indexed), so the semantic stays consistent across the @container collapse: `currentPage = 2` always means "the 3rd snap point is visible". **Scroll-snap motion**: track uses `display: flex; gap: 28px; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; scroll-behavior: smooth` with `::-webkit-scrollbar { display: none }`. Each slide carries `flex: 0 0 calc((100% - (var(--pa-carousel-items-per-page) - 1) * 28px) / var(--pa-carousel-items-per-page)); scroll-snap-align: start; min-width: 0`. **Page-step math measured at runtime from the DOM**: `step = (firstSlide.offsetWidth + getComputedStyle(track).columnGap) * effectiveItemsPerPage` — survives the mobile collapse where slide width changes from `(100% - 56) / 3` to `(100% - 36 - 28)`. `scrollToPage(page)` does `track.scrollTo({ left: page * step, behavior })` (`behavior` = `'smooth'` on desktop / mobile-no-reduced-motion, `'auto'` under reduced-motion); the watcher short-circuits when `Math.abs(scrollLeft - expectedLeft) <= 2`. User scroll detected via a 120 ms `@scroll` debounce → `onScrollSettled()` maps `Math.round(scrollLeft / step)` back to a page index and emits `update:currentPage` only on change. `prefers-reduced-motion: reduce` query is read inline at scroll time (`window.matchMedia('(prefers-reduced-motion: reduce)').matches`) and the CSS sets `scroll-behavior: auto` + drops the dot transition. **Chevrons**: 32 × 32 ghost `<button type="button" class="pa-carousel__chevron pa-carousel__chevron--prev|--next">` tiles flanking the track, transparent bg, no border, color `--pa-color-primary`, containing PaIcon `caret-left` / `caret-right` at `size="md"` (scoped `:deep(svg) { width: 20px; height: 20px }` forces the 20 px chevron rendering inside the 32 px column to match Figma `510:17617` / `510:17622`). Boundary state: at `currentPage === 0` the prev chevron carries `aria-disabled="true"` + `tabindex="-1"` + CSS `opacity: 0.4; pointer-events: none`; same for the next chevron at the last page. Both chevrons stay in the DOM at all times (SR-discoverable, consistent with PaPagination). `v-if="showChevrons && hasMultiplePages"` removes them entirely when chrome is unwanted or only one page exists. **Pagination dots**: `<button type="button" class="pa-carousel__dot" :aria-label="pageLabel(i + 1)" :aria-current="i === currentPage">` per page in a 28 × 28 hit-target wrapper (`padding: 7px`). The active-vs-inactive visual is rendered as the button's `::after` pseudo-element (28 × 14 `--pa-color-primary` pill `border-radius: 100px` active / 14 × 14 `--pa-color-text-secondary-gray` circle `border-radius: 12px` inactive) — the wrapper button stays a constant 28 × 28 across both states so the morph doesn't shift its neighbors. Click sets `currentPage = i` clamped to `[0, pageCount - 1]`. `v-if="showDots && hasMultiplePages"` removes the strip when not needed. **Keyboard nav**: root carries `tabindex="0"` and a `@keydown` handler — `ArrowLeft` / `ArrowRight` cycle pages (using `goToPrev` / `goToNext` which respect boundaries), `Home` / `End` jump to first / last, all `preventDefault`'d. Handler short-circuits when `event.target.closest('button, a, input, textarea, select, [contenteditable]')` so a `PaInput` or footer `PaButton` inside a slide doesn't have its Arrow keys hijacked; **exception**: targets inside the carousel's own `.pa-carousel__chevron` / `.pa-carousel__dot` still propagate to the root handler so Arrow keys keep working when a chevron / dot has focus. **Full WAI-ARIA carousel pattern**: root `role="region"` + `aria-roledescription="carousel"` + `aria-label` from `label` prop; each slide `role="group"` + `aria-roledescription="slide"` + `aria-label="N di M"`; chevrons / dots are native `<button type="button">` with semantic labels; `aria-current="true"` on the active dot. **Focus rings**: root `:focus-visible` outline `var(--pa-border-sm) solid var(--pa-color-primary-focus-border)` at 2 px offset (matches PaTabs / PaModal / PaPagination); `:focus { outline: none }` suppresses click-focus rings; chevrons + dots reuse the same primary-focus-border outline via their own `:focus-visible` rules. **Mobile collapse via `@container (max-width: 720px)`** on the root: chevrons hidden (`display: none`); track gap drops 28 → 16 px; `padding-left: 36px` so the first slide doesn't kiss the left edge (matches Figma `I510:17556;16043:77132`); `scroll-padding-left: 36px` so snapped slides align to the 36 px inset; each slide width overrides to `flex: 0 0 calc(100% - 36px - 28px)` so the second slide peeks ~28 px past the right edge (matches Figma `I510:17556;16043:77134`). **Dot count flips JS-side** because CSS can't drive a JS-rendered list count: a `ResizeObserver` on the root tracks `containerWidth`, an `isMobileViewport` computed flips at the 720 px boundary, `effectiveItemsPerPage` resolves to 1 on mobile + the prop value on desktop, and `pageCount = Math.max(1, Math.ceil(slideCount / effectiveItemsPerPage))` recomputes — so on mobile dots count = N slides (one per individual slide), on desktop = N / itemsPerPage. A `watch(effectiveItemsPerPage, ...)` clamps `currentPage` to the new max + re-anchors scroll via `nextTick(() => scrollToPage(currentPage.value, false))` when the breakpoint flips, so a desktop "page 3 of 3" doesn't become an out-of-bounds "page 3 of 1" on mobile. `ResizeObserver` is feature-detected (`typeof ResizeObserver !== 'undefined'`) and disconnected in `onBeforeUnmount`. 720 px = library standard breakpoint (matches PaCard, PaHero, PaTitleBar). **Composed reuse**: `PaIcon` (chevron glyphs `caret-left` / `caret-right`). **Tokens used**: `--pa-color-primary`, `--pa-color-text-secondary-gray`, `--pa-color-primary-focus-border`, `--pa-color-bg-white`, `--pa-border-sm`. Italian-default labels: `label`, `previousLabel`, `nextLabel`, `pageLabel` (all prop-overridable). **No** autoplay / auto-advance timer (WCAG 2.2.2 anti-pattern without pause/stop controls — would be a footgun-by-default), **no** infinite loop / wrap-around (no play affordance means the user has already navigated past the boundary deliberately; loop also complicates v-model + dot animation + focus management), **no** vertical orientation, **no** lazy rendering of off-screen slides (all default-slot children stay mounted; consumers with very large carousels wrap their bodies in `<KeepAlive>` themselves), **no** custom transition durations / easing (uses browser's default smooth-scroll timing ~300 ms ease-out), **no** itemsPerPage beyond 3 (literal-union type prevents misuse; layout primitives break down for 4+ per-Figma), **no** thumbnail navigation strip, **no** pinch-zoom / drag-to-pan on individual slides (scroll-snap handles horizontal between-slide swipe; in-slide gestures are consumer concern), **no** RTL — all deferred |
416
+
417
+ | `PaSearchBar` | atom (composed) | v1.28 | ✅ Implemented | Composed search bar — `<input type="search" role="combobox">` + flush-right blue "Ricerca" `<button>` inside a 1-px-outlined rounded-9 px wrapper, with optional label / helper text / × clear button / dropdown options listbox. **API**: `label?: string`, `placeholder?: string`, `helperText?: string`, `disabled?: boolean` (default `false`), `id?: string` (otherwise module-counter mints `pa-search-bar-N`), `searchLabel?: string` (default `'Ricerca'`), `clearLabel?: string` (default `'Cancella ricerca'`), `debounceMs?: number` (default `0` — disabled), `options?: PaSearchBarOption[]` where `PaSearchBarOption = { value: string; disabled?: boolean }`. `v-model:value` via `defineModel<string>('value', { default: '' })`. **Search trigger** (hybrid): button click + `Enter` keydown both fire `@search(value)` and blur the input; `debounceMs > 0` additionally fires `@search` after that many ms of input inactivity (timer cleared on each keystroke + on explicit trigger to avoid duplicate emits). **Clear button** (`×`): 16 × 16 PaIcon `x` inside the input cell aligned right, rendered only when `value.length > 0 && !disabled`; click clears (`value = ''`), refocuses the input, fires `@clear` (no payload), carries `aria-label="<clearLabel>"` + `type="button"`. **Options dropdown** (combobox suggestions): when `options.length > 0 && inputFocused && !disabled`, a sibling `<ul role="listbox" :id="<baseId>__listbox">` renders flush below the wrapper with N `<li role="option" :id="<baseId>__option-K">` rows. Consumer pre-filters (no internal `substring` / `startsWith` / `fuzzy` filter — PaSearchBar renders the passed array as-is); when `options` is undefined or `[]` the dropdown machinery is fully bypassed (no `<ul>` in the DOM, no "Nessun risultato" row). Click on enabled option sets `value = option.value`, closes the dropdown, refocuses the input, emits `@select(option)`. Per-option state matrix (CSS-only): `:hover` → `--pa-color-primary-hover` (#00368F) + white; `[aria-selected="true"]` → `--pa-color-primary` + white + trailing `check` PaIcon; `:active` → `--pa-color-primary-pressed` (#00173D) + white; `[aria-disabled="true"]` (via `.pa-search-bar__option--disabled` class) → `--pa-color-bg-disabled-gray` (#E7E8E3) + `--pa-color-text-disabled-gray` + `pointer-events: none`. Each `<li>` has 1 px `--pa-color-bg-secondary-light-50` border with `margin-bottom: -1px` border-collapse + bottom-rounded-8 px on last; the wrapper above flattens its bottom corners (`pa-search-bar--open`) so the dropdown attaches flush. **Full WAI-ARIA 1.2 combobox pattern** (input-as-combobox, not the older wrapping-div pattern): input carries `role="combobox"` + `aria-autocomplete="list"` + `aria-controls="<listboxId>"` + `aria-expanded` + `aria-activedescendant` (omitted when `activeIndex === -1`); each option carries `role="option"` + `:id` + `:aria-selected` (`'true'`/`'false'`) + `:aria-disabled` (`'true'` or undefined). DOM focus stays on the input throughout; `aria-activedescendant` is the SR-driven cursor. **Keyboard nav**: `ArrowDown` opens dropdown when closed + activeIndex = first non-disabled, advances + wraps when open; `ArrowUp` mirror (wraps to last); `Home` / `End` first / last non-disabled (open only); `Enter` selects active option (or fires `@search` if none active); `Esc` closes dropdown + `activeIndex = -1` (preventDefault only when actually closed, so Esc still bubbles for parent modals); `Tab` does not consume — input's blur (150 ms debounce so click-on-option lands first) closes the dropdown. Disabled options are skipped by Arrow / Home / End nav + defensive guard in click + Enter handlers. Active option auto-`scrollIntoView({ block: 'nearest' })` on `activeIndex` change so long lists work without manual scrolling. **`@container (max-width: 600px)` mobile collapse** on the root (`container-type: inline-size`): wrapper flips `flex-direction: row → column`, drops its 1-px outer border (input + button each render their own), input cell gains its own 1-px secondary-light-50 border + full 8 px radius + focus ring, button stretches to `width: 100%; height: 52px; border-radius: var(--pa-radius-smooth)`. 600 px breakpoint (vs library's 720) chosen to match PaModal's narrow-tipping point — SearchBars commonly live inside narrow columns / sidebars / drawers where the cramped-button case kicks in well before viewport-mobile. Container query (not viewport media query) so a SearchBar inside a 400 px sidebar on a 1920 px viewport reflows independently. **Composed reuse**: `PaIcon` (`magnifying-glass` for the search button, `x` for the clear button, `check` for the active option). **Tokens used**: `--pa-color-primary`, `--pa-color-primary-hover`, `--pa-color-primary-pressed`, `--pa-color-primary-focus-border`, `--pa-color-text-secondary-gray`, `--pa-color-text-disabled-gray`, `--pa-color-text-white`, `--pa-color-bg-white`, `--pa-color-bg-disabled-gray`, `--pa-color-bg-secondary-light-40`, `--pa-color-bg-secondary-light-50`, `--pa-color-neutral-gray`, `--pa-color-neutral-charcoal`, `--pa-radius-smooth`, `--pa-border-xs`, `--pa-font-body-*`, `--pa-font-label-sm-*`, `--pa-font-label-helper-*`, `--pa-space-2xs`, `--pa-space-xs`, `--pa-space-sm`, `--pa-space-2xl`. Italian-default labels: `searchLabel`, `clearLabel` (both prop-overridable). **No** internal option filtering (consumer pre-filters), **no** async loading state inside the dropdown (consumer renders own UI), **no** "Nessun risultato" empty-state row, **no** grouped options / section headers, **no** per-option icons / metadata / two-line layouts, **no** multi-select / chip-stack input, **no** `error` / `valid` / `required` / `readonly` props (SearchBar's failure mode is "no results", not "invalid input" — use PaInput for validated input), **no** recent-searches dropdown (localStorage = consumer concern), **no** server-cursor pagination of options ("load more" footer), **no** icon-only / iconified compact button variant, **no** RTL, **no** inline-suggest / typeahead — all deferred |
418
+
419
+ | `PaDrawer` | atom (composed) | v1.29 | ✅ Implemented | Composed slide-out modal-like side panel — variation on PaModal v1.25 with edge-anchored positioning instead of centered overlay. Renders `<Teleport to="body">` containing a `<div class="pa-drawer__wrapper">` (carries `container-type: inline-size` + `position: fixed; inset: 0; z-index: 1000; pointer-events: none`) wrapping `<div class="pa-drawer__backdrop">` (`rgba(0,0,0,0.5)`, `pointer-events: auto`) + sibling `<div role="dialog" aria-modal="true" :aria-labelledby class="pa-drawer pa-drawer--{position} pa-drawer--{size}" tabindex="-1">` (z-index 1001, `pointer-events: auto`, `background: var(--pa-color-bg-white)`, `box-shadow: var(--pa-shadow-elevation-01)`, `padding: 32px`). **API**: `title?: string`, `titleLevel?: 1..6` (default `2`), `showClose?: boolean` (default `true`), `closeLabel?: string` (default `'Chiudi'`), `position?: 'right' \| 'left' \| 'top' \| 'bottom'` (default `'right'`), `size?: 'sm' \| 'md' \| 'lg'` (default `'md'`), `id?: string`. `v-model:open` via `defineModel<boolean>('open', { default: false })`. **Position-driven anchor** via `.pa-drawer--{position}` modifier classes: right → `top: 0; right: 0; height: 100vh; width: var(--pa-drawer-width)`; left → mirror with `left: 0`; top → `top: 0; left: 0; width: 100vw; height: var(--pa-drawer-height)`; bottom → mirror with `bottom: 0`. **Size-driven dimensions** via `.pa-drawer--{size}` modifier classes setting the CSS custom properties: sm → `--pa-drawer-width: 400px; --pa-drawer-height: 320px`; md → `550 / 480`; lg → `720 / 640`. The inert axis on each position picks the right variable (right/left consume `--pa-drawer-width`, top/bottom consume `--pa-drawer-height`). Header carries a 44 × 44 top-right close `<button>` (PaIcon `name="x" size="sm"` in `--pa-color-primary` stroke; only when `showClose === true`; transparent bg, no border, hover bg `--pa-color-bg-secondary-light-50`, focus-visible 2-px primary outline at 2-px offset) + `<h${titleLevel}>` title in Inter Semi Bold 20/24 letter-spacing -0.5 `--pa-color-primary` (drawer titles are navigational — distinct from PaModal v1.25's `--pa-color-text-secondary-gray` informational tier). Module-level `drawerUid` counter mints unique `pa-drawer-N` + `__title` ids paired to `aria-labelledby` when title is present (omitted otherwise — no dangling reference). **Two named slots**: `#default` (body — wrapped in `.pa-drawer__body { flex: 1 1 auto; overflow-y: auto; width: 100%; margin-top: var(--pa-space-sm) }` so header + footer stay pinned while body scrolls); `#footer` (sticky CTA bar — when `$slots.footer` is truthy, renders `.pa-drawer__footer { display: flex; align-items: center; gap: var(--pa-space-sm); min-height: 64px; padding: var(--pa-space-xs) var(--pa-space-lg); margin: 0 calc(-1 * var(--pa-space-lg)) calc(-1 * var(--pa-space-lg)) calc(-1 * var(--pa-space-lg)); background: var(--pa-color-primary); color: var(--pa-color-text-white); flex-shrink: 0 }` — negative-margin breakout extends the bar to the drawer's edges past the outer 32 px padding). Consumer composes the two Figma footer shapes (icon-row variant + Azioni+kebab variant) themselves from PaButton + PaIcon + a hand-rolled dropdown at v1.29; the `PaActionBar` extraction is deferred until a reusable kebab+menu primitive can be lifted out of PaTitleBar. **Open/close**: `v-model:open` only — close button + backdrop click + Esc keydown all unconditionally emit `update:open(false)`; no `closeOnEscape` / `closeOnBackdrop` knobs (intercept the emit and re-emit `true` to veto, same pattern as PaModal). **Full a11y orchestrator** via single `watch(open, ..., { flush: 'post', immediate: true })`: on open → snapshots `document.activeElement` as `previouslyFocusedElement`, applies body scroll lock (snapshots + overrides `document.body.style.overflow = 'hidden'` + scrollbar-width-compensating `paddingRight` measured via `innerWidth - documentElement.clientWidth`, only when scrollbar > 0), attaches `window.keydown` Esc listener, awaits `nextTick`, then focuses (in order): close button (if `showClose`) → first focusable descendant matching the `'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'` selector → drawer root. On close → reverses every snapshot, detaches Esc, restores focus to `previouslyFocusedElement` if it's still in the DOM. `onBeforeUnmount` runs scroll-lock + Esc cleanup defensively. **Focus trap** via `@keydown` on drawer root checking `event.key === 'Tab'`: Tab from last wraps to first; Shift+Tab from first wraps to last; both `event.preventDefault()`'d. **Slide-in / slide-out animation** via Vue `<Transition>` — drawer wrapped in `<Transition :name="\`pa-drawer-${position}\`" appear>` with per-position transform keyframes (right: `translateX(100% → 0)`; left: `translateX(-100% → 0)`; top: `translateY(-100% → 0)`; bottom: `translateY(100% → 0)`) over 250 ms ease-out enter / 200 ms ease-in leave; backdrop wrapped in `<Transition name="pa-drawer-backdrop" appear>` (opacity 0 ↔ 1, 200 ms each direction). `@media (prefers-reduced-motion: reduce)` collapses all transitions to `transition-duration: 0ms`. **`@container (max-width: 600px)` mobile fill-viewport collapse** on the wrapper (which carries `container-type: inline-size`): right / left → `width: 100vw; max-width: none`; top / bottom → `height: 100vh; max-height: none`; drawer padding tightens 32 → 24 px; title scales 20/24 → 18/22; footer padding tightens to `8px 24px` with proportional negative-margin breakout. 600 px breakpoint matches PaModal's narrow-tipping point. **Composed reuse**: `PaIcon` (close button glyph `x`). **Tokens used**: `--pa-color-primary`, `--pa-color-text-white`, `--pa-color-text-secondary-gray`, `--pa-color-bg-white`, `--pa-color-bg-primary-light-50`, `--pa-color-bg-secondary-light-50`, `--pa-color-primary-focus-border`, `--pa-shadow-elevation-01`, `--pa-radius-smooth`, `--pa-border-xs`, `--pa-border-sm`, `--pa-space-2xs`, `--pa-space-xs`, `--pa-space-sm`, `--pa-space-md`, `--pa-space-lg`, `--pa-font-family-base`. Italian-default labels: `closeLabel: 'Chiudi'`. **No** `subtitle` / `leadingIcon` props (defer — Figma drawer header is title + close only), **no** `closeOnEscape` / `closeOnBackdrop` opt-outs, **no** persistent (non-modal) side-panel mode (`role="complementary"` — different focus / Esc semantics), **no** nested drawers / scroll-lock counter, **no** `PaActionBar` sub-component for the two Figma footer shapes (icon-row + Azioni+kebab) — consumer composes from PaButton + PaIcon + hand-rolled dropdown until a future story extracts it, **no** `size: 'full'` fullscreen, **no** custom backdrop color / opacity tokens, **no** Teleport target override, **no** drag-to-resize handle / pull-to-dismiss gesture, **no** `#trigger` slot, **no** `inert` on background siblings, **no** `role="alertdialog"` variant, **no** `@opened` / `@closed` lifecycle events beyond `update:open`, **no** RTL — all deferred |
420
+ | `PaDrawerInfoRow` | atom (composed) | v1.29 | ✅ Implemented | Typed key-value row primitive paired with `PaDrawer` for the master-detail "Dettaglio pratica" use case. Renders `<div class="pa-drawer-info-row pa-drawer-info-row--{variant}">` with bottom border `var(--pa-border-xs) solid var(--pa-color-bg-primary-light-50)` (#D9E4F7), `padding: 12px 0`, `display: flex; flex-direction: column; gap: var(--pa-space-2xs)` (4 px), `width: 100%`. `.pa-drawer-info-row:last-child { border-bottom: none }` drops the trailing border for the last row in a list (consumer can override per-instance via `:deep` if needed). **API**: `variant: PaDrawerInfoRowVariant` (required — `'header' \| 'text' \| 'text-multiline' \| 'link' \| 'number' \| 'date'`), `label?: string`, `value?: string`, `href?: string` (used only when `variant === 'link'`). **Label rendering**: `<p class="pa-drawer-info-row__label">` in Inter Semi Bold 16/20 `--pa-color-text-secondary-gray`; hidden via `v-if` when both `label` prop is empty AND `#label` slot is absent. **Value rendering per variant**: `header` → no value element (the label-only row acts as a section divider, e.g. `'Anagrafica'`); `text` → `<p class="pa-drawer-info-row__value">` Inter Regular 16/20 secondary-gray with `white-space: nowrap; overflow: hidden; text-overflow: ellipsis` (single-line by default); `text-multiline` → same as `text` but the `.pa-drawer-info-row__value--multiline` class flips `white-space: normal; overflow-wrap: anywhere` so long paragraphs wrap; `link` with `href` → `<a :href class="pa-drawer-info-row__value pa-drawer-info-row__value--link">` Inter Semi Bold 16/20 `--pa-color-primary` + `text-decoration: underline` on hover / focus-visible; `link` without `href` → `<span class="pa-drawer-info-row__value--link">` fallback (same chrome, consumer wires their own router via `@click` on the slot escape); `number` / `date` → `<span class="pa-drawer-info-row__value--mono">` in `var(--pa-font-family-mono)` (Roboto Mono) 16/24 `--pa-color-text-secondary-gray` (matches Figma `label-md-number-400` tier). **Two named slots — `#label` and `#value`** as escape hatches: when the slot is provided, it renders *instead of* the matching prop's text content (same precedence pattern as PaCard / PaHero / PaTitleBar — `<slot name="x">{{ propFallback }}</slot>`). Lets consumers drop a `<PaTag variant="success">In lavorazione</PaTag>`, a `<PaChip>`, a leading `<PaIcon>`, `<strong>`, or any inline-formatted markup. **Tokens used**: `--pa-color-text-secondary-gray`, `--pa-color-primary`, `--pa-color-bg-primary-light-50`, `--pa-border-xs`, `--pa-space-2xs`, `--pa-font-family-base`, `--pa-font-family-mono`. **No** promoted-to-toplevel `PaInfoRow` alias (the primitive is shipped paired with PaDrawer at v1.29; promoting to a general-purpose key-value display atom is a future scope decision once a second consumer appears outside the drawer body), **no** `icon` prop (use the `#label` slot — keeps the prop surface minimal), **no** copy-to-clipboard action / hover toolbar, **no** `<time datetime>` semantics on `date` variant (consumer-supplied string; date-parsing helper would force a separate `dateISO` prop pair — deferred), **no** `<dt>` / `<dd>` `<dl>` semantics (the row is `<div>`-based so consumers can mix with non-key-value content inside the same drawer body without rebuilding the DOM tree), **no** multi-value lists per row (consumer composes multiple rows or uses the `#value` slot for an inline list) — all deferred |
421
+ | `PaFooter` | composed (page-level) | v1.30 | ✅ Implemented | Composed page-level footer rendering `<footer class="pa-footer" role="contentinfo">` with `container-type: inline-size` (so the @container query is scoped to the footer instance). Four vertically-stacked bands rendered in canonical order, each individually omitted by data presence — **(1) back-to-top band** (`--pa-color-bg-primary-dark-110` bg, centered ghost-cyan 201 × 48 `<button>` with `arrow-up` PaIcon + 1-px `#00368F` divider below); **(2) sitemap band** (`--pa-color-bg-primary-dark-120` bg, max-width 1168 px CSS Grid `repeat(3, 1fr)` with 28 px column-gap, per-column 48 px vertical padding); **(3) contact band** (`--pa-color-bg-primary-dark-120` bg, max-width 1172 px flex layout — logo on the left + stacked address + 7-icon social row + extra slot on the right, justified between); **(4) privacy band** (`--pa-color-bg-primary-dark-110` bg, max-width 600 px centered, inline 44 × 50 Italian Republic emblem SVG + centered Inter Regular 14/21 white copyright text). **API**: `backToTop?: boolean \| { label?: string, ariaLabel?: string }` (default `true`; Italian default `'Torna su'`; object form overrides label + aria), `sitemap?: PaFooterColumn[]` (default `[]`; `PaFooterColumn = { heading?: string, links: PaFooterLink[] }`, `PaFooterLink = { label: string, href: string }`), `address?: PaFooterAddress` (`{ title?: string, lines: string[] }`), `socials?: PaFooterSocial[]` (`{ icon: PaIconName, href: string, label: string }`, default `[]`), `copyright?: string`, `showRepublicEmblem?: boolean` (default `true`). **Sitemap rendering**: `column.heading` presence implicitly switches between *headingless* tile (large cyan `<a>` links in Inter Semi Bold 20/30 `--pa-color-accent`, 8 px row-gap, no top divider) and *heading-led* tile (`<h3>` Inter Bold 18/27 + sub-link `<a>`s in Inter Regular 16/20 cyan, 12 px row-gap). Per-row heading-color rule via single `:nth-of-type(n+4)` CSS selector — row-0 heading-led columns render the heading in `--pa-color-accent`; row-1+ heading-led columns paint a 1-px `--pa-color-bg-primary-dark-110` top border + flip the heading to `--pa-color-text-white`. **Back-to-top click** emits `@back-to-top` with payload `{ originalEvent: MouseEvent, preventDefault: () => void, defaultPrevented: boolean }`; default behavior invokes `window.scrollTo({ top: 0, behavior })` where `behavior = matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'`. Calling `payload.preventDefault()` from the listener skips the built-in scroll (consumer can wire a custom scroll container). SSR-safe (`typeof window !== 'undefined'` guard). **Seven named slots as escape hatches**: `#logo` (left column of contact band — nothing renders if absent), `#sitemap` (full-band escape replacing the grid with arbitrary markup, preserves band bg + max-width), `#address` (replaces the prop-rendered address block), `#socials` (replaces the prop-rendered social row), `#contact-extra` (forward-compat slot for a newsletter / payment-method strip, renders below the social row), `#privacy-logo` (replaces the bundled Italian Republic emblem SVG — for regional government / comune crest), `#copyright` (replaces the `copyright` prop text). All "slot wins over prop" precedence matches PaHero / PaCard / PaTitleBar. **Italian Republic emblem**: bundled inline in the SFC's template as a single `<svg viewBox="0 0 44 50" fill="currentColor">` block (5-pointed star inside a thin circle + three curved fronds below) so consumers don't need an asset-pipeline hookup; `currentColor` + `color: --pa-color-text-white` paints it white on the band, future US can recolor without re-bundling. **`@container (max-width: 720px)` mobile collapse** on the footer wrapper: sitemap grid → 1 column (`grid-template-columns: 1fr`), per-column padding tightens to `var(--pa-space-md) var(--pa-space-sm)`, headingless columns gain a 1-px `#00368F` bottom border (except the last) for visual separation between stacked tiles; **heading-led columns automatically swap to `<PaAccordion>`** instead of `<div>` via a hand-rolled `ResizeObserver` on the footer root that watches `contentRect.width` and flips an `isMobile` ref at the 720 px boundary — toggling the `.pa-footer--mobile` class + swapping the template branch via `v-if`. PaAccordion is **dark-recolored** via scoped `:deep()` overrides — header bg `--pa-color-bg-primary-dark-120`, header text white, body bg same `#00173D`, body links `--pa-color-accent`, chevron white, `#00368F` divider, padding `22px 16px`. No API change to PaAccordion itself — the recolor lives only inside `.pa-footer`. Contact band layout flips to `flex-direction: column; align-items: stretch` with `padding: 0 var(--pa-space-sm) var(--pa-space-sm)`, the contact-stack stays vertical at full width, the social row stretches to 100 % container width. Privacy band gets 24 px horizontal padding. 720 px breakpoint matches PaCarousel + PaCard Landscape (library standard for composed-component mobile-tipping). **Composed reuse**: `PaIcon` (`arrow-up` for the back-to-top button + whatever phosphor glyphs the consumer passes via `socials[].icon` — registered icons include `facebook-logo`, `x-logo`, `whatsapp-logo`, `youtube-logo`, `instagram-logo`, `linkedin-logo`, `megaphone`), `PaAccordion` (mobile heading-led collapse, dark-recolored via scoped `:deep`). **Tokens used**: `--pa-color-bg-primary-dark-110` (#00368F), `--pa-color-bg-primary-dark-120` (#00173D), `--pa-color-accent` (#18CEE7), `--pa-color-text-white`, `--pa-radius-smooth`, `--pa-border-xs`, `--pa-border-sm`, `--pa-space-xs`, `--pa-space-sm`, `--pa-space-md`, `--pa-space-2xl`, `--pa-font-family-base`, `--pa-font-label-sm-*`. Italian-default labels: `backToTop.label = 'Torna su'`. **No** `PaFooterColumn` / `PaFooterMini` sub-components (flat-prop API handles every shipped Figma case; consumer composes structured markup via the `#sitemap` slot when needed), **no** per-column `accordionOnMobile` flag (`heading` presence is the sole signal — heading-less stays open, heading-led collapses), **no** per-column `headingColor` override (`:nth-of-type(n+4)` CSS rule is locked spec), **no** RTL, **no** built-in i18n / multi-language (override props for non-Italian locales), **no** custom band order / band-hiding via array prop (canonical order hardcoded; each band omitted by data presence), **no** cookie banner / consent strip (separate concern), **no** built-in newsletter form (use `#contact-extra`), **no** sticky / pinned-to-viewport mode (consumer adds `position: sticky` themselves), **no** multiple social rows / channel grouping, **no** `scrollTo` target override (use `@back-to-top` + `preventDefault()` for custom scroll containers), **no** PaAccordion `variant: 'dark'` API change (scoped recolor only at v1.30; promote when a second consumer needs it), **no** bundled regional emblem variants (`#privacy-logo` slot for non-Republic marks) — all deferred |
422
+
423
+ | `PaHeaderMobile` | composed (page-level) | v1.31 | ✅ Implemented | Composed mobile page-header rendering `<header class="pa-header-mobile" role="banner">` with a visually-hidden skip-to-main link as the first child + a 60 px white bar (`position: relative; container-type: inline-size`) + a Teleport-to-body full-screen menu panel mounted on `v-model:menuOpen`. **Bar anatomy**: left 56 × 60 `<button class="pa-header-mobile__menu-toggle">` (`--pa-color-bg-primary-light-40` bg, PaIcon swaps `list` ↔ `x` per `menuOpen`, carries `aria-expanded` + `aria-controls={panelId}` + Italian-default `aria-label` flipping `'Apri menu'` ↔ `'Chiudi menu'`); brand zone absolutely positioned at `left: 68px; top: 10px; height: 40px` hosting a `#logo` slot with a default 70 × 40 `--pa-color-primary` placeholder rectangle; right group `<slot name="actions">` defaulting to two 56 × 60 `<button>`s — search tile (`magnifying-glass` PaIcon, emits `@search-click`) + user tile (PaIcon `user` ↔ `user-fill` per `loggedIn`, emits `@user-click`, `#user` slot replaces the icon while keeping the button chrome). **API**: `loggedIn?: boolean` (default `false`), `primaryNav?: PaHeaderNavItem[]` (default `[]`), `secondaryNav?: PaHeaderNavItem[]` (default `[]`), `languages?: PaHeaderLanguage[]` (default `[]`), `currentLanguage?: string`, `menuOpen?: boolean` (`v-model:menuOpen` controlled / uncontrolled fallback), `skipLink?: boolean \| { label?, target? }` (default `true`; Italian default `'Vai al contenuto'` → `'#main'`), `teleportTarget?: string \| HTMLElement \| false` (default `'body'`; `false` switches the panel to `position: absolute` for constrained previews / Histoire — also skips body-scroll-lock). Italian-default copy props for every label (`panelLabel`, `openMenuLabel`, `closeMenuLabel`, `searchActionLabel`, `userActionLabelLoggedOut`/`LoggedIn`, `languageGroupLabel`, `backLabel`, `toolsOpenLabel`, `toolsCloseLabel`, `exploreLabel`) — all consumer-overridable. **Shared types** (re-exported from `@semplice-studio/pa-design-system` root): `PaHeaderNavItem = { label, href?, children?: PaHeaderNavItem[], tools?: PaHeaderTools }`, `PaHeaderTools = { icon: PaIconName, heading, body?, links?, cta? }`, `PaHeaderLink = { label, href, ariaCurrent? }`, `PaHeaderLanguage = { code, label, href? }`. **Full-screen panel** mounts via `<Teleport :to :disabled>` when `isMenuOpen === true` — `<div role="dialog" aria-modal="true" tabindex="-1">` with `position: fixed; inset: 0; z-index: 1000; background: #FFFFFF; display: flex; flex-direction: column; min-height: 100vh` (or `position: absolute; inset: 0; z-index: 1` in inline mode). On open: snapshots `document.activeElement`, locks body scroll (`document.body.style.overflow = 'hidden'` + scrollbar-width-compensating `paddingRight`; skipped when `teleportTarget: false`), attaches `window keydown` Esc listener, awaits `nextTick`, focuses the first focusable inside the panel via `focus({ preventScroll: true })`. On close: detaches listeners, unlocks scroll, resets `drillStack` to `[]` + `toolsOpen` to `false`, restores focus to the previously-focused element with `preventScroll`. **Focus trap** via `@keydown` on the panel root: Tab from last wraps to first; Shift+Tab from first wraps to last (same `FOCUSABLE_SELECTOR` as PaDrawer / PaModal). **Drill stack**: internal `{ item, tier }[]` ref; stack-length 0 renders Primary level (white-bg group of `primaryNav` tiles + light-blue group of `secondaryNav` tiles + language switcher pinned above the bottom bar); stack-length 1 renders Secondary level (back row + children list + optional tools card + section-link footer). Each tile = `<button class="pa-header-mobile__tile">` (Inter Semi Bold 16/20 `--pa-color-text-dark-blue`, 16/24 padding, `--pa-color-bg-primary-light-50` bottom divider) with a trailing `caret-right` PaIcon only when the item has children or tools. **Tile click**: drills (push onto stack) when the item has children or tools; else emits `@navigate` with `{ item, originalEvent, preventDefault, defaultPrevented }` — `preventDefault()` skips the default `window.location.assign(item.href)`. **Tools card** (rendered only when drilled item has `tools` AND `tier === 'primary'`): `<div class="pa-header-mobile__tools">` with header row (24 × 24 PaIcon + Inter Bold 24/28 heading) + right-action button toggling collapsed (Apri underlined-bold cyan link) ↔ expanded (`caret-up` icon); body when `toolsOpen` includes body text + bullet-link list + full-width primary CTA. **Language switcher**: `<div role="group" :aria-label>` of `<button :aria-pressed>` pills (active = 4 px black bottom border + black text); clicking emits `@language-change` with the preventable payload; if not prevented + `lang.href` set, `window.location.assign(lang.href)`. **Bar pinned at panel bottom** with the left-toggle in close (`x`) state — closes the menu when clicked. **Composed reuse**: `PaIcon` (`list`, `x`, `magnifying-glass`, `user`, `user-fill`, `caret-left`, `caret-right`, `caret-up`, `arrow-right`, plus consumer-selected `tools.icon`). **Tokens used**: `--pa-color-primary`, `--pa-color-accent`, `--pa-color-bg-primary-light-40`, `--pa-color-bg-primary-light-50`, `--pa-color-text-dark-blue`, `--pa-color-neutral-gray`, `--pa-radius-smooth`, `--pa-border-xs`, `--pa-border-sm`, `--pa-space-xs`, `--pa-space-sm`, `--pa-space-md`, `--pa-font-label-sm-*`, `--pa-font-family-base`. **No** unified `<PaHeader>` auto-switcher (consumer chooses Desktop / Mobile via their own viewport gate), **no** built-in sticky positioning, **no** inline search panel triggered by the mobile search icon (consumer wires `@search-click`), **no** router integration / `linkComponent` injection (intercept `@navigate` + `preventDefault` + call `router.push`), **no** `v-model:toolsOpen`, **no** per-secondary-tier tools cards (`tools` ignored when drilled into a `secondaryNav` item), **no** RTL, **no** built-in i18n — all deferred |
424
+ | `PaHeaderDesktop` | composed (page-level) | v1.31 | ✅ Implemented | Composed desktop page-header rendering `<header class="pa-header-desktop" role="banner">` with the skip link as the first child + two vertically-stacked rows + an optional click-to-open 3-column mega-menu overlay. **Top secondary row** (48 px, white bg, 1-px `--pa-color-bg-primary-light-50` bottom border, content max-width 1172 px centered, justified-between): left zone `<div v-if="$slots['utility-links']">` hosts the consumer's plain `<a>` strip — scoped `:deep(a)` styles give bare links neutral-gray text + underline-on-hover + cyan focus outline without wrapping markup; right zone `[language switcher → utility-icons slot → login CTA / user widget]`. Language switcher (rendered when `languages.length > 0` or `#language` slot is set) = 48 × 48 `<button :aria-pressed>` tiles (active = black bottom underline + black text). Utility-icons slot consumes the consumer's bare `<button>` / `<a>` children and applies 48 × 48 `--pa-color-bg-primary-light-40` tile chrome via scoped `:deep(button, a)`. Login CTA (when `loggedIn === false`): `<a :href="loginHref">{loginLabel} <PaIcon name="user" /></a>` (Inter Bold 16/24 underlined `--pa-color-primary`, `--pa-color-bg-primary-light-40` bg, 4-px radius, 12/24 padding, 16 px gap). When `loggedIn === true`: `<slot name="user">` with a default `<PaIcon name="user-fill">` chip. **Primary row** (white bg, 16 px vertical padding, content max-width 1172 px, flex column with 16 px gap; `position: relative` so the mega menu anchors here): centered `<PaSearchBar>` (max 970 px wide; forwarded `placeholder` / `searchLabel` / `options` / `value`; re-emits `@search`) — replaceable via `#search` slot. Below: `<nav :aria-label="mainNavLabel">` rendering each `mainNav` item as **leaf** (`<a :href :aria-current="item.href === currentHref ? 'page' : undefined">`, Inter Semi Bold 20/30 neutral-gray, hover/current = `--pa-color-text-dark-blue` bottom underline) or **branch** (`<button :aria-expanded :aria-haspopup="true" :aria-controls="megaMenuId">` carrying the label + `caret-down` PaIcon). **API**: `loggedIn?: boolean`, `mainNav?: PaHeaderNavItem[]`, `languages?: PaHeaderLanguage[]`, `currentLanguage?: string`, `currentHref?: string`, `menuOpen?: string \| null` (`v-model:menuOpen` — the open trigger's label or null), `searchEnabled?: boolean` (default `true`), `searchPlaceholder?` (`'Cerca'`), `searchButtonLabel?` (`'Ricerca'`), `searchOptions?: PaSearchBarOption[]`, `searchValue?: string`, `loginLabel?` (`'Accedi'`), `loginHref?` (`'/login'`), `mainNavLabel?` (`'Navigazione principale'`), `languageGroupLabel?` (`'Seleziona la lingua'`), `skipLink?: boolean \| { label?, target? }` (default `true`). **Mega menu overlay** (`role="region" :aria-labelledby="megaMenuTriggerId"`) positioned `position: absolute; left/right: 0; top: 100%; z-index: 50; background: #FFFFFF; border-radius: 4px; box-shadow: var(--pa-shadow-elevation-01)` relative to `.pa-header-desktop__primary`; inner 32 / 24 padding flex row with 24 px gap holds 3 columns derived from a `megaColumns` computed (`[children[0], children[1], children[2]]` — additional children clipped). Left column: link list (Inter Bold 16/24 underlined `--pa-color-primary`) + optional `Esplora {label} →` action link with 1-px top border when `children[0].href` is set. Center column: same chrome, no action footer. Right column: `--right` modifier (`--pa-color-bg-primary-light-40` bg, 32 / 24 padding, 4 px radius), header row = 24 × 24 PaIcon (`children[2].tools?.icon ?? 'wrench'`) + `<h3>` heading (Inter Bold 24/28 neutral-gray). **Per-column slot escapes** `#mega-column-0`, `#mega-column-1`, `#mega-column-2` (each with `:column="megaColumns[N]"` scope) replace the default content wholesale; chrome (bg, padding, radius) stays. **Single-open invariant**: opening another branch trigger replaces the open trigger. **Dismissal**: Esc keydown (window-scoped while open) → close; outside `mousedown` (`document` listener; ignored when target is inside the menu OR the open trigger) → close; clicking the open trigger again → toggle close. **Focus management**: on open, snapshot `document.activeElement`; after `nextTick`, focus the first `a, button:not([disabled])` inside the menu via `focus({ preventScroll: true })` (the `preventScroll` is **critical** — without it the browser scrolls the page to bring the focused link into view, causing a visible jump when the user has scrolled down). On close, restore focus to the previously-focused element with the same `preventScroll`. **No focus trap** — Tab continues past the last mega-menu link to the next focusable element on the page (WAI-ARIA expanding-menu pattern, distinct from PaHeaderMobile's modal-dialog pattern). **Motion**: `<Transition name="pa-mega-menu">` with 150 ms opacity + 4 px translateY; `prefers-reduced-motion` collapses to `0ms` + zeros the transform. **Events**: `update:menuOpen` (label or null), `search` (re-emit from PaSearchBar), `language-change` (preventable), `navigate` (preventable; `preventDefault()` calls `event.preventDefault()` on the original click event so consumer routers can intercept). **Composed reuse**: `PaSearchBar` (v1.28 default primary-row content), `PaIcon` (`user`, `user-fill`, `caret-down`, `arrow-right`, `wrench`, plus consumer-selected utility-icons). **Tokens used**: `--pa-color-primary`, `--pa-color-accent`, `--pa-color-bg-primary-light-40`, `--pa-color-bg-primary-light-50`, `--pa-color-text-dark-blue`, `--pa-color-neutral-gray`, `--pa-shadow-elevation-01`, `--pa-radius-smooth`, `--pa-border-xs`, `--pa-border-sm`, `--pa-space-xs`, `--pa-space-sm`, `--pa-space-md`, `--pa-font-family-base`. **No** unified `<PaHeader>` auto-switcher, **no** built-in sticky positioning, **no** featured-cards row below the mega menu (page content the consumer renders below the header using PaCard), **no** hover-to-open mega menu (click-only — fails AgID keyboard audits; flickers on touch), **no** multi-open mega menus (single-open invariant), **no** mega menu auto-positioning / RTL flipping, **no** `linkComponent` injection (intercept `@navigate` + `preventDefault`), **no** built-in 4+ mega-menu columns (additional children clipped), **no** visual regression / snapshot tests (Phase-3 AgID a11y pass) — all deferred |
425
+
426
+ | `PaDatePicker` | composed (form) | v1.32 | ✅ Implemented | Single-day picker over V-Calendar v3 `<DatePicker>`. **API**: `modelValue?: Date \| null` (`v-model`), `display?: 'popover' \| 'inline'` (default `'popover'`), `placeholder?: string` (default `'gg/mm/aaaa'`). Trigger = PaInput-shaped `<button>` (white bg, 1-px `--pa-color-neutral-gray` border, 4-px radius, Inter 16/20 value text, `#suffix` slot defaulting to `<PaIcon name="calendar-blank" />`); popover = anchored card (8-px radius, 24-px padding, `--pa-shadow-elevation-02`, `z-index: 900`). **V-Calendar binding is one-way**: `:model-value="model"` (prop only, no `v-model`) + `@dayclick="onDayClick"`; the sole writer is `onDayClick({ date })` which normalizes to local midnight via `new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0)` to avoid UTC drift from `.toISOString()`. Custom `#nav-prev-button` / `#nav-next-button` slots paint 32 × 32 cyan PaIcon chevrons. Click trigger toggles; outside-`mousedown` + Esc close; selection closes (popover mode); inline mode skips trigger. Focus returns to trigger on close. Scoped `:deep()` overrides paint V-Calendar's weekday header (Inter Semi Bold 16/20), day cells (Roboto Mono 16/18, 32 × 32 pill on hover / select / focus), selected highlight (`--pa-color-primary` bg + white). Italian `it-IT` locale + Monday-first week hardcoded. **Form chrome (v1.33)**: `label?` / `required?` / `helperText?` / `error?: string \| boolean` / `valid?` / `disabled?` / `readonly?` / `id?` / `name?` mirroring `PaInput`. Auto-generated `id` via `nextPaDatePickerId()`. Error string takes precedence over helper. Hidden sibling `<input type="hidden">` serializes the selected date as `yyyy-mm-dd` from LOCAL components when `name` is set. Disabled = `togglePopover` / `openPopover` early-return + trigger + hidden input both `[disabled]`. Readonly = `togglePopover` / `openPopover` early-return (popover never opens) + `onDayClick` early-return (covers inline mode); hidden input carries `[readonly]` and value IS submitted. **Constraints (v1.34)**: `min?: Date \| null` + `max?: Date \| null` + `disabledDates?: (date: Date) => boolean` props. `disabledDatesForVCalendar` assembles min/max range segments plus predicate-true days enumerated within a bounded window into V-Calendar's `:disabled-dates` array — enumeration is required because V-Calendar's `DateRangeSource[]` doesn't accept functions; this delivers native `is-disabled` class + keyboard skip in a single source of truth. `onDayClick` defends with `isDisabledDate(picked)` early-return. **No** mobile sheet / multi-locale / RTL / transitions — deferred |
427
+ | `PaRangePicker` | composed (form) | v1.32 | ✅ Implemented | Start → end day-range picker over V-Calendar v3. Public type `PaRangePickerValue = { start: Date; end: Date }`. **API**: `modelValue?: PaRangePickerValue \| null` (`v-model`), `display?: 'popover' \| 'inline'`, `placeholder?: string` (default `'gg/mm/aaaa – gg/mm/aaaa'`). Same trigger + popover chrome as `PaDatePicker`. Body = `<VDatePicker :attributes :locale @dayclick @daymouseenter>`. **Range commit is hand-driven via `@dayclick` count-to-2** (NOT V-Calendar's `v-model.range` — null initial model doesn't emit reliably): first click sets internal `pendingStart`; second click commits `model.value = { start: localMidnight(min), end: localEndOfDay(max) }` and closes (popover mode). Reverse-click order (later first, earlier second) normalizes to start ≤ end via `getTime()` comparison. **Day-precision normalization**: start always 00:00:00.000, end always 23:59:59.999 — DST-safe, see [[feedback_date_local_midnight]]. **`attributes` prop drives V-Calendar's visual range pill**: three states (committed solid, in-progress hover preview light, single in-progress cell solid). Scoped `:deep()` overrides paint `.vc-highlight-base-start / -middle / -end` in `--pa-color-primary` (solid) or `--pa-color-primary-hover` (preview). Trigger formatter = `'dd/MM/yyyy – dd/MM/yyyy'`. **Popover does NOT close on first `@dayclick`** — count-to-2 close signal is deterministic; Esc / outside-click also close without committing. **Form chrome (v1.33)**: same set as `PaDatePicker` with `nextPaRangePickerId()`. Hidden input serializes the committed range as `yyyy-mm-dd/yyyy-mm-dd` (slash-separated ISO 8601 interval, single field) from LOCAL components on both ends. Readonly blocks `onDayClick` AND `onDayMouseEnter` (covers inline mode + freezes the V-Calendar preview pill). **Constraints (v1.34)**: same `min` / `max` / `disabledDates` shape as `PaDatePicker`. `onDayClick` defends both first and second clicks with `isDisabledDate(picked)` early-return; mid-range disabled days inside a committed range are allowed (range integrity deferred). `onDayMouseEnter` skips the `hoveredDate` update when the hovered day is disabled — preview pill stops at the last non-disabled hovered day. **No** mobile sheet / multi-locale / RTL / transitions — deferred |
428
+ | `PaMonthPicker` | composed (form) | v1.32 | ✅ Implemented | Hand-rolled month picker (no V-Calendar — v3 has no month-only mode). **API**: `modelValue?: Date \| null` (`v-model` — always 1st-of-month at local midnight), `display?: 'popover' \| 'inline'`, `placeholder?: string` (default `'mese / aaaa'`). Same trigger + popover chrome as `PaDatePicker`. Body = year-nav header (`<button class="pa-month-picker__nav-arrow">` prev + `<span aria-live="polite">{{ pageYear }}</span>` + next) + `<div role="grid">` with `grid-template-columns: repeat(3, 1fr); gap: 8px 24px;` holding 12 `<button class="pa-month-picker__tile">` tiles labeled with hardcoded Italian month names (`Gennaio … Dicembre`). Selection commits `new Date(pageYear, monthIdx, 1, 0, 0, 0, 0)` and closes the popover. `pageYear` re-anchors to the model's year on each `openPopover()` so the user always lands on the page containing the committed month. **Tile state styles**: default white + neutral-gray text, `:hover` → `--pa-color-primary-hover` + white, `:focus-visible` → 2-px `--pa-color-primary-focus-border` inset outline, `:active` → `--pa-color-primary-pressed` + white, `--selected` (model's year + month) → `--pa-color-primary` + white. `border-radius: 9999px`, `padding: 12px 16px`. **Keyboard navigation**: ArrowLeft / ArrowRight ±1 (wraps), ArrowUp / ArrowDown ±3 (column-preserving), Home / End first / last, PageUp / PageDown shift `pageYear` ±1 and re-focus the same column, Enter / Space select. Trigger formatter = `capitalize(Intl.DateTimeFormat('it-IT', { month: 'long', year: 'numeric' }).format(model))` → e.g. `'Maggio 2026'`. Same outside-click + Esc dismiss; focus returns to trigger. **Form chrome (v1.33)**: same set as `PaDatePicker` with `nextPaMonthPickerId()`. Hidden input serializes as `yyyy-mm` from LOCAL components. Readonly = trigger inert + inline-mode `selectMonth` early-return (year-nav stays functional). **Constraints (v1.34)**: `min?: Date \| null` + `max?: Date \| null` + `disabledMonths?: (date: Date) => boolean` props. `truncateToMonthStart` normalizes consumer-passed Dates; `isMonthDisabled(monthIdx)` + `disabledStates` array memoize the 12 booleans for the current page. Disabled tiles get `[disabled]` + `tabindex="-1"` + `pa-month-picker__tile--disabled` class (`--pa-color-text-disabled` + `pointer-events: none`). `selectMonth` defends with early-return. Keyboard skip via `nextEnabledTile(from, step)` wrap-walk; ArrowKeys / Home / End / PageUp / PageDown all delegate to skip-aware helpers. Year-nav chevrons stay functional even when all 12 tiles are disabled. **No** mobile sheet / multi-locale / RTL — deferred |
429
+ | `PaYearPicker` | composed (form) | v1.32 | ✅ Implemented | Hand-rolled year picker (no V-Calendar). **API**: `modelValue?: Date \| null` (`v-model` — always Jan 1 at local midnight), `display?: 'popover' \| 'inline'`, `placeholder?: string` (default `'aaaa'`). Same chrome as `PaMonthPicker`; trigger value rendered in Roboto Mono. Body = decade-nav header (prev + `<span aria-live="polite">{{ pageStartYear }} – {{ pageStartYear + 11 }}</span>` + next) + 4 × 3 grid of 12 `<button class="pa-year-picker__tile">` tiles. **Page math**: `pageStartFor(y) = Math.floor((y − 2000) / 12) * 12 + 2000` — anchored to year 2000 so pages line up as `2000–2011 / 2012–2023 / 2024–2035 / 2036–2047 / …`. `2026` lands on the 2024–2035 page (matches the Figma decade grid). Prev / next chevrons + PageUp / PageDown shift `pageStartYear` by 12. `pageStartYear` re-anchors on each `openPopover()`. Selection commits `new Date(year, 0, 1, 0, 0, 0, 0)` and closes. Tile state styles + keyboard navigation identical to `PaMonthPicker`. Tile numerals in Roboto Mono 16/18. Trigger formatter = `String(model.getFullYear())`. **Form chrome (v1.33)**: same set as `PaDatePicker` with `nextPaYearPickerId()`. Hidden input serializes as `yyyy` (plain `String(getFullYear())`). Readonly = trigger inert + inline-mode `selectYear` early-return (decade-nav stays functional). **Constraints (v1.34)**: `min?: Date \| null` + `max?: Date \| null` + `disabledYears?: (date: Date) => boolean` props. `truncateToYearStart` normalizes consumer-passed Dates; `isYearDisabled(year)` + `disabledStates` array memoize the 12 booleans. Disabled tiles + `selectYear` early-return + keyboard-skip helpers mirror `PaMonthPicker`. Decade-nav chevrons stay functional even when all 12 tiles are disabled. **No** mobile sheet / RTL — deferred |
430
+ | `PaTimePicker` | composed (form) | v1.32 | ✅ Implemented | Hand-rolled HH ∶ MM spinner (no V-Calendar). Public type `PaTimePickerValue = { hours: number; minutes: number }` (0–23, 0–59). **API**: `modelValue?: PaTimePickerValue \| null` (`v-model`), `display?: 'popover' \| 'inline'`, `placeholder?: string` (default `'hh:mm'`). Trigger + popover chrome same as the other pickers, except `#suffix` slot defaults to `<PaIcon name="clock" />` (distinguishes time triggers from date triggers at a glance). Body = `<div class="pa-time-picker__spinner">` (`display: grid; grid-template-columns: auto auto auto; gap: 16px; align-items: center; justify-content: center;`) with two columns flanking a static `<span>:</span>` separator. Each column = caret-up chevron (32 × 32 pill, `--pa-color-primary` icon) + numeric `<button class="pa-time-picker__cell">` (56 × 40 white-bg, Roboto Mono 16/18, focus-visible inset outline) + caret-down chevron. **Null-model fallback**: spinner shows `00 : 00` but **no commit fires** until the user interacts (chevron click or arrow keydown). **Wrap semantics**: `(h + 1) % 24`, `(h + 23) % 24`, `(m + 1) % 60`, `(m + 59) % 60`. **Keyboard navigation** on cells: ArrowUp / ArrowDown call inc / dec; PageUp / PageDown ±10 with wrap; Home / End jump to 0 / max; Enter commits current display value (in case the user pressed Enter without ever clicking) and closes the popover. **Popover does NOT close on chevron click** — time picking is incremental; user dismisses via Esc / outside-click / Enter on a cell. Display formatter = `${pad2(hours)}:${pad2(minutes)}`. **Form chrome (v1.33)**: same set as `PaDatePicker` with `nextPaTimePickerId()`. Hidden input serializes as `HH:mm` with `pad2` on both columns. Readonly = trigger inert + inline-mode `writeModel` + every inc / dec + cell-keydown handler ALL early-return (chevron clicks + ArrowUp / ArrowDown / PageUp / PageDown / Home / End / Enter all no-op; cells stay focusable). **Constraints (v1.34)**: `min?: PaTimePickerValue \| null` + `max?: PaTimePickerValue \| null` + `disabledTimes?: (time: PaTimePickerValue) => boolean` props. Inline `toMinutes` + `isDisabledTime` test min / max / predicate. `inc/dec` walk UP TO 24 (hours) / 60 (minutes) steps skipping disabled candidates; if the entire cycle is disabled they're no-ops. PageUp / PageDown jump ±10 then walk outward for the nearest allowed; Home / End set 0 / 23 (hours) or 0 / 59 (minutes) then walk outward. Enter on a cell whose current display value is disabled is a no-op. Display fallback: `firstAllowed` computed scans 00:00 upward; when every minute of the 24h cycle is disabled, the cells render `--:--` and all four chevrons flip to the `--inert` class (gray + `aria-disabled="true"`). **No** mobile sheet / multi-locale / RTL / 12-hour AM/PM / second-precision — deferred |
431
+
432
+ When a new batch of components is designed in Figma without yet being implemented, re-run `/import-design-system` against the components frame and they'll be added here as 🟡 Designed.
433
+
434
+ ## 5. Icon registry
435
+
436
+ **Source:** [Phosphor Icons](https://phosphoricons.com/), exposed via `PaIcon` (since v1.3). The design system bundles a **curated** subset of Phosphor — only the icons listed below are available. `@phosphor-icons/vue` is a runtime dependency of the library, so consumers do not install it separately.
437
+
438
+ **Usage:** `<PaIcon name="arrow-right" size="md" />` — `name` is typed as a union of the kebab-keys in the table below; non-listed names produce a TypeScript error. See `src/components/PaIcon/PaIcon.vue` for the canonical `iconRegistry`.
439
+
440
+ **Adding a new icon:** (1) named-import the Phosphor component in `PaIcon.vue`, (2) add a new entry to `iconRegistry`, (3) append a row to the table below. The story's `All icons` variant picks it up automatically.
441
+
442
+ **Weight handling:** v1.3 is Regular-weight by default. A small number of icons in the Figma file specify Fill or Light weight — those are baked into the registry entry (`{ component, weight: 'fill' }`), not exposed as a public `weight` prop. If you need a new weight, update the registry entry rather than the public API.
443
+
444
+ **Convention:** Figma layer names starting with `ph:` (e.g. `ph:arrow-left`) map directly to the kebab-case registry key (drop the `ph:` prefix). If a Figma icon doesn't have a same-named Phosphor component, prefer flagging back to the designer; substitution is acceptable when the visual is semantically adjacent (see `map-pin-minus` below).
445
+
446
+ ### Registered icons (129)
447
+
448
+ | Kebab name | Phosphor component | Weight | Notes |
449
+ |---|---|---|---|
450
+ | `archive` | `PhArchive` | — | |
451
+ | `arrow-circle-down` | `PhArrowCircleDown` | — | |
452
+ | `arrow-circle-left` | `PhArrowCircleLeft` | — | |
453
+ | `arrow-circle-right` | `PhArrowCircleRight` | — | |
454
+ | `arrow-circle-up` | `PhArrowCircleUp` | — | |
455
+ | `arrow-clockwise` | `PhArrowClockwise` | — | |
456
+ | `arrow-counter-clockwise` | `PhArrowCounterClockwise` | — | |
457
+ | `arrow-down` | `PhArrowDown` | — | |
458
+ | `arrow-left` | `PhArrowLeft` | — | |
459
+ | `arrow-right` | `PhArrowRight` | — | |
460
+ | `arrow-square-out` | `PhArrowSquareOut` | — | |
461
+ | `arrow-up` | `PhArrowUp` | — | |
462
+ | `arrows-counter-clockwise` | `PhArrowsCounterClockwise` | — | |
463
+ | `arrows-out` | `PhArrowsOut` | — | |
464
+ | `arrows-out-cardinal` | `PhArrowsOutCardinal` | — | |
465
+ | `bank` | `PhBank` | — | |
466
+ | `behance-logo` | `PhBehanceLogo` | — | |
467
+ | `bell` | `PhBell` | — | |
468
+ | `bookmark-simple` | `PhBookmarkSimple` | — | |
469
+ | `calendar-blank` | `PhCalendarBlank` | — | |
470
+ | `camera` | `PhCamera` | — | |
471
+ | `caret-double-left-light` | `PhCaretDoubleLeft` | light | |
472
+ | `caret-double-right` | `PhCaretDoubleRight` | — | |
473
+ | `caret-down` | `PhCaretDown` | — | |
474
+ | `caret-down-fill` | `PhCaretDown` | fill | |
475
+ | `caret-left` | `PhCaretLeft` | — | |
476
+ | `caret-left-fill` | `PhCaretLeft` | fill | |
477
+ | `caret-right` | `PhCaretRight` | — | |
478
+ | `caret-right-fill` | `PhCaretRight` | fill | |
479
+ | `caret-up` | `PhCaretUp` | — | |
480
+ | `caret-up-down` | `PhCaretUpDown` | — | |
481
+ | `caret-up-fill` | `PhCaretUp` | fill | |
482
+ | `chat-centered-dots` | `PhChatCenteredDots` | — | |
483
+ | `check` | `PhCheck` | — | |
484
+ | `check-circle` | `PhCheckCircle` | — | |
485
+ | `clock` | `PhClock` | — | |
486
+ | `code` | `PhCode` | — | |
487
+ | `copy` | `PhCopy` | — | |
488
+ | `corners-in` | `PhCornersIn` | — | |
489
+ | `corners-out` | `PhCornersOut` | — | |
490
+ | `credit-card` | `PhCreditCard` | — | |
491
+ | `currency-eur` | `PhCurrencyEur` | — | Added in US-18 to support the `PaInput` "amount" example. |
492
+ | `diamonds-four` | `PhDiamondsFour` | — | |
493
+ | `dots-three` | `PhDotsThree` | — | |
494
+ | `dots-three-vertical` | `PhDotsThreeVertical` | — | |
495
+ | `download-simple` | `PhDownloadSimple` | — | |
496
+ | `ear` | `PhEar` | — | |
497
+ | `envelope` | `PhEnvelope` | — | |
498
+ | `envelope-open` | `PhEnvelopeOpen` | — | |
499
+ | `exclamation-mark` | `PhExclamationMark` | — | |
500
+ | `eye` | `PhEye` | — | |
501
+ | `eye-slash` | `PhEyeSlash` | — | |
502
+ | `facebook-logo` | `PhFacebookLogo` | — | |
503
+ | `figma-logo` | `PhFigmaLogo` | — | |
504
+ | `file-audio` | `PhFileAudio` | — | |
505
+ | `file-csv` | `PhFileCsv` | — | |
506
+ | `file-pdf` | `PhFilePdf` | — | |
507
+ | `file-ppt` | `PhFilePpt` | — | |
508
+ | `file-text` | `PhFileText` | — | |
509
+ | `file-txt` | `PhFileTxt` | — | |
510
+ | `file-video` | `PhFileVideo` | — | |
511
+ | `file-xls` | `PhFileXls` | — | |
512
+ | `file-zip` | `PhFileZip` | — | |
513
+ | `files` | `PhFiles` | — | |
514
+ | `flag` | `PhFlag` | — | |
515
+ | `folder` | `PhFolder` | — | |
516
+ | `funnel` | `PhFunnel` | — | |
517
+ | `gear` | `PhGear` | — | |
518
+ | `github-logo` | `PhGithubLogo` | — | |
519
+ | `hexagon` | `PhHexagon` | — | |
520
+ | `house` | `PhHouse` | — | Added in v1.9 to match Figma's `ph:house-fill` breadcrumb home icon. |
521
+ | `house-fill` | `PhHouse` | fill | Added in v1.9 to match Figma's `ph:house-fill` breadcrumb home icon. |
522
+ | `image` | `PhImage` | — | |
523
+ | `info` | `PhInfo` | — | |
524
+ | `instagram-logo` | `PhInstagramLogo` | — | |
525
+ | `key` | `PhKey` | — | |
526
+ | `link` | `PhLink` | — | |
527
+ | `linkedin-logo` | `PhLinkedinLogo` | — | |
528
+ | `list` | `PhList` | — | |
529
+ | `list-bullets` | `PhListBullets` | — | |
530
+ | `lock-key` | `PhLockKey` | — | |
531
+ | `magnifying-glass` | `PhMagnifyingGlass` | — | |
532
+ | `magnifying-glass-minus` | `PhMagnifyingGlassMinus` | — | |
533
+ | `magnifying-glass-plus` | `PhMagnifyingGlassPlus` | — | |
534
+ | `map-pin` | `PhMapPin` | — | |
535
+ | `map-pin-minus` | `PhMapPinLine` | — | Substituted — Phosphor has no `PhMapPinMinus`; `PhMapPinLine` (pin-with-line) used as semantically adjacent "remove location" visual. |
536
+ | `map-pin-plus` | `PhMapPinPlus` | — | |
537
+ | `map-pin-simple` | `PhMapPinSimple` | — | |
538
+ | `mastodon-logo` | `PhMastodonLogo` | — | |
539
+ | `megaphone` | `PhMegaphone` | — | |
540
+ | `minus` | `PhMinus` | — | |
541
+ | `minus-circle` | `PhMinusCircle` | — | |
542
+ | `note` | `PhNote` | — | |
543
+ | `paperclip` | `PhPaperclip` | — | |
544
+ | `pencil-simple` | `PhPencilSimple` | — | |
545
+ | `pinterest-logo` | `PhPinterestLogo` | — | |
546
+ | `plugs` | `PhPlugs` | — | |
547
+ | `plus` | `PhPlus` | — | |
548
+ | `plus-circle` | `PhPlusCircle` | — | |
549
+ | `presentation-chart` | `PhPresentationChart` | — | |
550
+ | `printer` | `PhPrinter` | — | |
551
+ | `prohibit` | `PhProhibit` | — | |
552
+ | `push-pin` | `PhPushPin` | — | |
553
+ | `question` | `PhQuestion` | — | |
554
+ | `question-mark` | `PhQuestionMark` | — | |
555
+ | `reddit-logo` | `PhRedditLogo` | — | |
556
+ | `share-network` | `PhShareNetwork` | — | |
557
+ | `sign-out` | `PhSignOut` | — | |
558
+ | `slack-logo` | `PhSlackLogo` | — | |
559
+ | `snapchat-logo` | `PhSnapchatLogo` | — | |
560
+ | `star` | `PhStar` | — | |
561
+ | `star-fill` | `PhStar` | fill | |
562
+ | `tiktok-logo` | `PhTiktokLogo` | — | |
563
+ | `trash` | `PhTrash` | — | |
564
+ | `tray` | `PhTray` | — | |
565
+ | `trend-up` | `PhTrendUp` | — | |
566
+ | `upload-simple` | `PhUploadSimple` | — | |
567
+ | `user` | `PhUser` | — | |
568
+ | `user-circle` | `PhUserCircle` | — | |
569
+ | `video-camera` | `PhVideoCamera` | — | |
570
+ | `warning` | `PhWarning` | — | |
571
+ | `warning-circle` | `PhWarningCircle` | — | |
572
+ | `whatsapp-logo` | `PhWhatsappLogo` | — | |
573
+ | `wifi-high` | `PhWifiHigh` | — | |
574
+ | `wrench` | `PhWrench` | — | |
575
+ | `x` | `PhX` | — | |
576
+ | `x-circle` | `PhXCircle` | — | |
577
+ | `x-logo` | `PhXLogo` | — | |
578
+ | `youtube-logo` | `PhYoutubeLogo` | — | |
579
+
580
+ ## 6. Naming conventions
581
+
582
+ - **Component prefix:** every public component is `Pa*` (PascalCase). Applies to atoms and composed alike.
583
+ - **File location:** one folder per component, `src/components/<Name>/`. Folder name equals component name.
584
+ - **Token prefix:** every CSS custom property is `--pa-*`. No exceptions.
585
+ - **Public exports:** every symbol exported from `src/index.ts` must have a matching subpath in `package.json#exports`.
586
+ - **Scoped styles:** components use scoped `<style>` blocks. No global CSS, no CSS-in-JS, no inline style objects derived from JS tokens.
587
+
588
+ ## 7. Figma → component mapping
589
+
590
+ Will be seeded by `/import-design-system` (re-run) once the first batch of components is enumerated. Until then, `/figma-component` reads the per-node Figma context directly and infers the target component name from the layer hierarchy.