@aiaiai-pt/design-system 0.8.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,292 @@
1
+ <!--
2
+ @component VotingWidget
3
+
4
+ Participation vote panel — the Valongo V10 participation-grid primitive
5
+ (participatory budget, consultations, local polls). One question, a radio
6
+ group of options, a submit path, and a pseudonymised receipt after voting.
7
+ Presentational: it emits `onsubmit(optionId)`; the portal wires that to the
8
+ BFF public submit surface (`POST /{app}/public/submit`, voting placement —
9
+ #70 M4), and renders the bot-protection challenge into the `captcha` slot.
10
+
11
+ States (mutually exclusive vote-ability):
12
+ - open — radio group + submit (the default)
13
+ - submitted — confirmation + the `receipt` slot, voting locked
14
+ - closed — voting period over; results only
15
+ - disabled — not eligible (e.g. anonymous where login is required);
16
+ options shown read-only with an announced `disabledReason`
17
+ Pass `showResults` to render the tally as labelled bars (typically once
18
+ `closed` or `submitted`).
19
+
20
+ Accessibility:
21
+ - `<fieldset>` + `<legend>` (the question) groups the options as a native
22
+ radio group — arrow-key navigation and "n of m" come for free.
23
+ - Submit is disabled until an option is chosen; the confirmation + receipt
24
+ land in an `aria-live="polite"` region so a screen reader announces them.
25
+ - Result bars are `aria-hidden`; each option's percentage is in its visible
26
+ text, so the meaning isn't colour/width-only.
27
+ - Radios keep a visible focus ring; reduced-motion disables the bar fill.
28
+
29
+ @example
30
+ <VotingWidget
31
+ question="Which project should Valongo fund first?"
32
+ name="pb-2026"
33
+ options={[{ id: "a", label: "Riverside path" }, { id: "b", label: "School playground" }]}
34
+ bind:selected
35
+ onsubmit={(id) => postVote(id)}
36
+ >
37
+ {#snippet captcha()}<TurnstileWidget /> {/snippet}
38
+ </VotingWidget>
39
+ -->
40
+ <script>
41
+ /**
42
+ * @typedef {{ id: string, label: string, votes?: number }} VoteOption
43
+ */
44
+
45
+ let {
46
+ /** @type {string} The question (rendered as the fieldset legend). */
47
+ question = "",
48
+ /** @type {VoteOption[]} */
49
+ options = [],
50
+ /** @type {string | undefined} Bindable selected option id. */
51
+ selected = $bindable(undefined),
52
+ /** @type {string} Radio-group name — unique per widget on the page. */
53
+ name = "vote",
54
+ /** @type {boolean} Voting period is over. */
55
+ closed = false,
56
+ /** @type {boolean} This citizen has already voted. */
57
+ submitted = false,
58
+ /** @type {boolean} Voting unavailable (e.g. not eligible / login required). */
59
+ disabled = false,
60
+ /** @type {boolean} Render the tally as labelled bars. */
61
+ showResults = false,
62
+ /** @type {string} Submit button text (localize it). */
63
+ submitLabel = "Submit vote",
64
+ /** @type {string} Heading shown in the submitted state (localize it). */
65
+ submittedLabel = "Your vote has been recorded.",
66
+ /** @type {string} Notice shown in the closed state (localize it). */
67
+ closedLabel = "Voting has closed.",
68
+ /** @type {string} Reason voting is unavailable, announced (localize it). */
69
+ disabledReason = "",
70
+ /** @type {((optionId: string) => void) | undefined} */
71
+ onsubmit = undefined,
72
+ /** @type {string} */
73
+ class: className = "",
74
+ /** @type {import('svelte').Snippet | undefined} Bot-protection challenge (Turnstile). */
75
+ captcha = undefined,
76
+ /** @type {import('svelte').Snippet | undefined} Pseudonymised receipt, shown after voting. */
77
+ receipt = undefined,
78
+ ...rest
79
+ } = $props();
80
+
81
+ const canVote = $derived(!closed && !submitted && !disabled);
82
+ const total = $derived(
83
+ options.reduce((sum, o) => sum + (o.votes ?? 0), 0),
84
+ );
85
+
86
+ /** @param {number | undefined} votes */
87
+ const pct = (votes) => (total > 0 ? Math.round(((votes ?? 0) / total) * 100) : 0);
88
+
89
+ /** @param {SubmitEvent} e */
90
+ function handleSubmit(e) {
91
+ e.preventDefault();
92
+ if (canVote && selected) onsubmit?.(selected);
93
+ }
94
+ </script>
95
+
96
+ <section class="voting-widget {className}" {...rest}>
97
+ <form class="voting-form" onsubmit={handleSubmit} novalidate>
98
+ <fieldset class="voting-fieldset" disabled={!canVote}>
99
+ <legend class="voting-legend">{question}</legend>
100
+
101
+ {#if disabled && disabledReason}
102
+ <p class="voting-reason" role="note">{disabledReason}</p>
103
+ {/if}
104
+
105
+ <ul class="voting-options">
106
+ {#each options as option (option.id)}
107
+ <li class="voting-option">
108
+ <label class="voting-label">
109
+ <input
110
+ type="radio"
111
+ class="voting-radio"
112
+ {name}
113
+ value={option.id}
114
+ bind:group={selected}
115
+ disabled={!canVote}
116
+ />
117
+ <span class="voting-option-text">{option.label}</span>
118
+ {#if showResults}
119
+ <span class="voting-pct">{pct(option.votes)}%</span>
120
+ {/if}
121
+ </label>
122
+ {#if showResults}
123
+ <div class="voting-bar" aria-hidden="true">
124
+ <div class="voting-bar-fill" style:width={`${pct(option.votes)}%`}></div>
125
+ </div>
126
+ {/if}
127
+ </li>
128
+ {/each}
129
+ </ul>
130
+ </fieldset>
131
+
132
+ {#if canVote}
133
+ {#if captcha}<div class="voting-captcha">{@render captcha()}</div>{/if}
134
+ <button type="submit" class="voting-submit" disabled={!selected}>
135
+ {submitLabel}
136
+ </button>
137
+ {/if}
138
+ </form>
139
+
140
+ <!-- Status region: confirmation / closed notice land here so AT announces them. -->
141
+ <div class="voting-status" role="status" aria-live="polite">
142
+ {#if submitted}
143
+ <p class="voting-confirm">{submittedLabel}</p>
144
+ {#if receipt}<div class="voting-receipt">{@render receipt()}</div>{/if}
145
+ {:else if closed}
146
+ <p class="voting-closed">{closedLabel}</p>
147
+ {/if}
148
+ </div>
149
+ </section>
150
+
151
+ <style>
152
+ .voting-widget {
153
+ display: flex;
154
+ flex-direction: column;
155
+ gap: var(--space-lg);
156
+ }
157
+
158
+ .voting-form {
159
+ display: flex;
160
+ flex-direction: column;
161
+ gap: var(--space-lg);
162
+ }
163
+
164
+ .voting-fieldset {
165
+ margin: 0;
166
+ padding: 0;
167
+ border: 0;
168
+ min-width: 0;
169
+ }
170
+
171
+ .voting-legend {
172
+ padding: 0;
173
+ margin-bottom: var(--space-md);
174
+ font-family: var(--type-heading-sm-font);
175
+ font-size: var(--type-heading-sm-size);
176
+ color: var(--color-text);
177
+ }
178
+
179
+ .voting-reason {
180
+ margin: 0 0 var(--space-md);
181
+ font-family: var(--type-body-sm-font);
182
+ font-size: var(--type-body-sm-size);
183
+ color: var(--color-text-muted);
184
+ }
185
+
186
+ .voting-options {
187
+ list-style: none;
188
+ margin: 0;
189
+ padding: 0;
190
+ display: flex;
191
+ flex-direction: column;
192
+ gap: var(--space-sm);
193
+ }
194
+
195
+ .voting-label {
196
+ display: flex;
197
+ align-items: center;
198
+ gap: var(--space-sm);
199
+ padding: var(--space-sm) var(--space-md);
200
+ border: 1px solid var(--color-border);
201
+ border-radius: var(--radius-md);
202
+ background: var(--color-surface);
203
+ cursor: pointer;
204
+ }
205
+ /* Selected row reads as chosen without relying on the native dot alone. */
206
+ .voting-label:has(.voting-radio:checked) {
207
+ border-color: var(--color-accent);
208
+ background: var(--color-accent-subtle);
209
+ }
210
+ .voting-label:has(.voting-radio:focus-visible) {
211
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
212
+ outline-offset: var(--focus-ring-offset);
213
+ }
214
+ .voting-fieldset:disabled .voting-label {
215
+ cursor: default;
216
+ opacity: 0.7;
217
+ }
218
+
219
+ .voting-radio {
220
+ flex-shrink: 0;
221
+ accent-color: var(--color-accent);
222
+ }
223
+
224
+ .voting-option-text {
225
+ flex: 1;
226
+ font-family: var(--type-body-font);
227
+ font-size: var(--type-body-size);
228
+ color: var(--color-text);
229
+ }
230
+
231
+ .voting-pct {
232
+ font-family: var(--type-data-font);
233
+ font-size: var(--type-data-size);
234
+ color: var(--color-text-secondary);
235
+ }
236
+
237
+ .voting-bar {
238
+ height: var(--space-2xs);
239
+ margin-top: var(--space-2xs);
240
+ border-radius: var(--radius-pill);
241
+ background: var(--color-surface-tertiary);
242
+ overflow: hidden;
243
+ }
244
+ .voting-bar-fill {
245
+ height: 100%;
246
+ background: var(--color-accent);
247
+ transition: width var(--duration-normal) var(--easing-default);
248
+ }
249
+ @media (prefers-reduced-motion: reduce) {
250
+ .voting-bar-fill {
251
+ transition: none;
252
+ }
253
+ }
254
+
255
+ .voting-submit {
256
+ align-self: flex-start;
257
+ padding: var(--space-sm) var(--space-lg);
258
+ border: 0;
259
+ border-radius: var(--radius-md);
260
+ background: var(--color-accent);
261
+ color: var(--color-text-on-accent);
262
+ font-family: var(--type-label-font);
263
+ font-size: var(--type-label-size);
264
+ cursor: pointer;
265
+ }
266
+ .voting-submit:hover:not(:disabled) {
267
+ background: var(--color-accent-hover);
268
+ }
269
+ .voting-submit:focus-visible {
270
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
271
+ outline-offset: var(--focus-ring-offset);
272
+ }
273
+ .voting-submit:disabled {
274
+ opacity: 0.5;
275
+ cursor: not-allowed;
276
+ }
277
+
278
+ .voting-confirm,
279
+ .voting-closed {
280
+ margin: 0;
281
+ font-family: var(--type-label-font);
282
+ font-size: var(--type-label-size);
283
+ color: var(--color-text);
284
+ }
285
+ .voting-confirm {
286
+ color: var(--color-success);
287
+ }
288
+
289
+ .voting-receipt {
290
+ margin-top: var(--space-sm);
291
+ }
292
+ </style>
@@ -0,0 +1,80 @@
1
+ export default VotingWidget;
2
+ type VotingWidget = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<$$ComponentProps>): void;
5
+ };
6
+ /**
7
+ * VotingWidget
8
+ *
9
+ * Participation vote panel — the Valongo V10 participation-grid primitive
10
+ * (participatory budget, consultations, local polls). One question, a radio
11
+ * group of options, a submit path, and a pseudonymised receipt after voting.
12
+ * Presentational: it emits `onsubmit(optionId)`; the portal wires that to the
13
+ * BFF public submit surface (`POST /{app}/public/submit`, voting placement —
14
+ * #70 M4), and renders the bot-protection challenge into the `captcha` slot.
15
+ *
16
+ * States (mutually exclusive vote-ability):
17
+ * - open — radio group + submit (the default)
18
+ * - submitted — confirmation + the `receipt` slot, voting locked
19
+ * - closed — voting period over; results only
20
+ * - disabled — not eligible (e.g. anonymous where login is required);
21
+ * options shown read-only with an announced `disabledReason`
22
+ * Pass `showResults` to render the tally as labelled bars (typically once
23
+ * `closed` or `submitted`).
24
+ *
25
+ * Accessibility:
26
+ * - `<fieldset>` + `<legend>` (the question) groups the options as a native
27
+ * radio group — arrow-key navigation and "n of m" come for free.
28
+ * - Submit is disabled until an option is chosen; the confirmation + receipt
29
+ * land in an `aria-live="polite"` region so a screen reader announces them.
30
+ * - Result bars are `aria-hidden`; each option's percentage is in its visible
31
+ * text, so the meaning isn't colour/width-only.
32
+ * - Radios keep a visible focus ring; reduced-motion disables the bar fill.
33
+ *
34
+ * @example
35
+ * <VotingWidget
36
+ * question="Which project should Valongo fund first?"
37
+ * name="pb-2026"
38
+ * options={[{ id: "a", label: "Riverside path" }, { id: "b", label: "School playground" }]}
39
+ * bind:selected
40
+ * onsubmit={(id) => postVote(id)}
41
+ * >
42
+ * {#snippet captcha()}<TurnstileWidget /> {/snippet}
43
+ * </VotingWidget>
44
+ */
45
+ declare const VotingWidget: import("svelte").Component<{
46
+ question?: string;
47
+ options?: any[];
48
+ selected?: any;
49
+ name?: string;
50
+ closed?: boolean;
51
+ submitted?: boolean;
52
+ disabled?: boolean;
53
+ showResults?: boolean;
54
+ submitLabel?: string;
55
+ submittedLabel?: string;
56
+ closedLabel?: string;
57
+ disabledReason?: string;
58
+ onsubmit?: any;
59
+ class?: string;
60
+ captcha?: any;
61
+ receipt?: any;
62
+ } & Record<string, any>, {}, "selected">;
63
+ type $$ComponentProps = {
64
+ question?: string;
65
+ options?: any[];
66
+ selected?: any;
67
+ name?: string;
68
+ closed?: boolean;
69
+ submitted?: boolean;
70
+ disabled?: boolean;
71
+ showResults?: boolean;
72
+ submitLabel?: string;
73
+ submittedLabel?: string;
74
+ closedLabel?: string;
75
+ disabledReason?: string;
76
+ onsubmit?: any;
77
+ class?: string;
78
+ captcha?: any;
79
+ receipt?: any;
80
+ } & Record<string, any>;
@@ -0,0 +1,83 @@
1
+ <!--
2
+ @component WidgetGrid
3
+
4
+ Responsive dashboard layout for the citizen portal's `landing` /
5
+ information-portal pages, where blocks vary in weight — a hero stat, a list of
6
+ recent consultations, a map, a voting panel. Where CardGrid lays out equal
7
+ cards, WidgetGrid is a column grid whose children may declare a span, so a
8
+ feature widget can sit beside two narrow ones.
9
+
10
+ Collapses to one column on small screens (every widget full-width), goes to
11
+ two at tablet, and to `columns` at desktop. Children opt into width via the
12
+ span helper classes (applied to the direct child, e.g. a `<Card>` or
13
+ `<section>`):
14
+
15
+ - `widget-span-2` — span two columns from tablet up
16
+ - `widget-span-full` — span the full row at every breakpoint
17
+
18
+ Layout only — it sets no landmark and imposes no semantics; the page supplies
19
+ headings and regions.
20
+
21
+ @example
22
+ <WidgetGrid columns="3">
23
+ <section class="widget-span-2"><Hero ... /></section>
24
+ <Card>…</Card>
25
+ <Card class="widget-span-full">…</Card>
26
+ </WidgetGrid>
27
+ -->
28
+ <script>
29
+ let {
30
+ /** @type {'2' | '3' | '4'} Column count at the desktop breakpoint. */
31
+ columns = "3",
32
+ /** @type {string} */
33
+ class: className = "",
34
+ /** @type {import('svelte').Snippet | undefined} */
35
+ children = undefined,
36
+ ...rest
37
+ } = $props();
38
+ </script>
39
+
40
+ <div class="widget-grid widget-grid-{columns} {className}" {...rest}>
41
+ {#if children}{@render children()}{/if}
42
+ </div>
43
+
44
+ <style>
45
+ .widget-grid {
46
+ display: grid;
47
+ gap: var(--grid-gutter);
48
+ grid-template-columns: 1fr;
49
+ align-items: start;
50
+ }
51
+
52
+ /* Keep widgets from blowing out their track. */
53
+ .widget-grid > :global(*) {
54
+ min-width: 0;
55
+ }
56
+
57
+ /* ─── Tablet: two columns, span helpers come alive ─── */
58
+ @media (min-width: 768px) {
59
+ .widget-grid-2,
60
+ .widget-grid-3,
61
+ .widget-grid-4 {
62
+ grid-template-columns: repeat(2, 1fr);
63
+ }
64
+ .widget-grid > :global(.widget-span-2) {
65
+ grid-column: span 2;
66
+ }
67
+ }
68
+
69
+ /* ─── Desktop: open up to the configured column count ─── */
70
+ @media (min-width: 1024px) {
71
+ .widget-grid-3 {
72
+ grid-template-columns: repeat(3, 1fr);
73
+ }
74
+ .widget-grid-4 {
75
+ grid-template-columns: repeat(4, 1fr);
76
+ }
77
+ }
78
+
79
+ /* Full-width span at every breakpoint. */
80
+ .widget-grid > :global(.widget-span-full) {
81
+ grid-column: 1 / -1;
82
+ }
83
+ </style>
@@ -0,0 +1,42 @@
1
+ export default WidgetGrid;
2
+ type WidgetGrid = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<$$ComponentProps>): void;
5
+ };
6
+ /**
7
+ * WidgetGrid
8
+ *
9
+ * Responsive dashboard layout for the citizen portal's `landing` /
10
+ * information-portal pages, where blocks vary in weight — a hero stat, a list of
11
+ * recent consultations, a map, a voting panel. Where CardGrid lays out equal
12
+ * cards, WidgetGrid is a column grid whose children may declare a span, so a
13
+ * feature widget can sit beside two narrow ones.
14
+ *
15
+ * Collapses to one column on small screens (every widget full-width), goes to
16
+ * two at tablet, and to `columns` at desktop. Children opt into width via the
17
+ * span helper classes (applied to the direct child, e.g. a `<Card>` or
18
+ * `<section>`):
19
+ *
20
+ * - `widget-span-2` — span two columns from tablet up
21
+ * - `widget-span-full` — span the full row at every breakpoint
22
+ *
23
+ * Layout only — it sets no landmark and imposes no semantics; the page supplies
24
+ * headings and regions.
25
+ *
26
+ * @example
27
+ * <WidgetGrid columns="3">
28
+ * <section class="widget-span-2"><Hero ... /></section>
29
+ * <Card>…</Card>
30
+ * <Card class="widget-span-full">…</Card>
31
+ * </WidgetGrid>
32
+ */
33
+ declare const WidgetGrid: import("svelte").Component<{
34
+ columns?: string;
35
+ class?: string;
36
+ children?: any;
37
+ } & Record<string, any>, {}, "">;
38
+ type $$ComponentProps = {
39
+ columns?: string;
40
+ class?: string;
41
+ children?: any;
42
+ } & Record<string, any>;
@@ -33,6 +33,19 @@ export { default as List } from "./List.svelte";
33
33
  export { default as ListItem } from "./ListItem.svelte";
34
34
  export { default as PageContainer } from "./PageContainer.svelte";
35
35
 
36
+ // Public site shell (locked a11y chrome — citizen portal, #7/#71)
37
+ export { default as AppFrame } from "./AppFrame.svelte";
38
+ export { default as SiteHeader } from "./SiteHeader.svelte";
39
+ export { default as SiteFooter } from "./SiteFooter.svelte";
40
+ export { default as SkipLink } from "./SkipLink.svelte";
41
+ export { default as ServiceNavigation } from "./ServiceNavigation.svelte";
42
+ export { default as Link } from "./Link.svelte";
43
+ export { default as Hero } from "./Hero.svelte";
44
+ export { default as ContentBlock } from "./ContentBlock.svelte";
45
+ export { default as StatusTimeline } from "./StatusTimeline.svelte";
46
+ export { default as WidgetGrid } from "./WidgetGrid.svelte";
47
+ export { default as VotingWidget } from "./VotingWidget.svelte";
48
+
36
49
  // Containers
37
50
  export { default as Card } from "./Card.svelte";
38
51
  export { default as Panel } from "./Panel.svelte";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.8.4",
3
+ "version": "0.9.0",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,44 @@
1
+ /*
2
+ * aiaiai Design System — Tier 1: Tenant Theme — Município de Valongo
3
+ *
4
+ * Example citizen-portal tenant theme (#7 portal template system). Demonstrates
5
+ * the Tier-1 contract: a municipality re-skins brand tokens only; structure,
6
+ * typography, spacing, motion, radius, and a11y stay frozen and
7
+ * conformance-checkable (Designers Italia "Modello Comuni" model).
8
+ *
9
+ * In production these overrides come from the tenant's `portal` ontology row
10
+ * and are injected at runtime by the portal's hooks.server.ts; this static
11
+ * file is the seed/example.
12
+ *
13
+ * Tier-1 rules: override < 20 semantic tokens; change accent + surface tint;
14
+ * keep everything else.
15
+ *
16
+ * Apply via: <html data-theme="valongo">
17
+ */
18
+
19
+ [data-theme="valongo"] {
20
+ /* ─── Accent: Valongo civic green ─── */
21
+ --color-accent: #2e7d32;
22
+ --color-accent-hover: #1b5e20;
23
+ --color-accent-subtle: #eaf4ea;
24
+
25
+ /* ─── Surface: warm neutral ─── */
26
+ --color-surface: #ffffff;
27
+ --color-surface-secondary: #f4f6f3;
28
+ --color-surface-tertiary: #e8ece6;
29
+
30
+ /* ─── Borders ─── */
31
+ --color-border: #d7ded3;
32
+ --color-border-strong: #abb8a4;
33
+
34
+ /* ─── Text ─── */
35
+ --color-text: #1c241b;
36
+ --color-text-secondary: #44513f;
37
+ --color-text-muted: #71806b;
38
+
39
+ /* ─── Overlay + focus follow the brand ─── */
40
+ --color-overlay: rgba(28, 36, 27, 0.5);
41
+ --focus-ring-color: var(--color-accent);
42
+
43
+ /* Total overrides: 14 tokens. Still an aiaiai product, in Valongo's colours. */
44
+ }