@aiaiai-pt/design-system 0.8.3 → 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.
@@ -81,6 +81,15 @@
81
81
  "function",
82
82
  "expression",
83
83
  ],
84
+ /**
85
+ * Mode pre-selected when `value === null`, so the operator lands
86
+ * on an editable affordance instead of a "Pick a source"
87
+ * placeholder. The bound `value` stays `null` until the operator
88
+ * interacts — form dirty-tracking and consumer-side null intent
89
+ * survive. The default is forbidden if it isn't in `allowed`.
90
+ * @type {ValueSourceMode | null}
91
+ */
92
+ defaultMode = null,
84
93
  /** @type {ValueSourceContext} */
85
94
  context = {},
86
95
  /** @type {string | undefined} */
@@ -137,6 +146,19 @@
137
146
  currentMode !== null && !allowed.includes(currentMode),
138
147
  );
139
148
 
149
+ // Effective view of the picker — uses `defaultMode` as a fallback
150
+ // when there's no stored value, so the operator sees a mode +
151
+ // affordance pre-selected. Read-only: `value` stays null until the
152
+ // operator interacts. Forbidden defaults silently fall back to the
153
+ // null placeholder rather than rendering a mode that isn't allowed.
154
+ const effectiveValue = $derived.by(() => {
155
+ if (value !== null) return value;
156
+ if (defaultMode === null) return null;
157
+ if (!allowed.includes(defaultMode)) return null;
158
+ return makeBlankForMode(defaultMode);
159
+ });
160
+ const effectiveMode = $derived(effectiveValue?.mode ?? null);
161
+
140
162
  function emit(next) {
141
163
  value = next;
142
164
  onchange?.(next);
@@ -144,7 +166,9 @@
144
166
 
145
167
  function pickMode(/** @type {string} */ next) {
146
168
  const mode = /** @type {ValueSourceMode} */ (next);
147
- if (mode === currentMode) return;
169
+ // Compare against the effective mode so re-picking the
170
+ // pre-selected default is a no-op rather than an emit.
171
+ if (mode === effectiveMode) return;
148
172
  emit(makeBlankForMode(mode));
149
173
  }
150
174
 
@@ -374,8 +398,8 @@
374
398
  <Select
375
399
  id={`${groupId}-mode`}
376
400
  size="sm"
377
- placeholder={value === null ? "Pick a source" : undefined}
378
- value={currentMode ?? ""}
401
+ placeholder={effectiveValue === null ? "Pick a source" : undefined}
402
+ value={effectiveMode ?? ""}
379
403
  options={modeOptions}
380
404
  onchange={pickMode}
381
405
  {disabled}
@@ -388,90 +412,90 @@
388
412
  <Badge variant="error">no longer allowed: {MODE_LABEL[value.mode]}</Badge>
389
413
  <Button variant="ghost" size="sm" onclick={clear}>Clear</Button>
390
414
  </div>
391
- {:else if value === null}
415
+ {:else if effectiveValue === null}
392
416
  <span class="vsp-placeholder">{MODE_DESCRIPTION[allowed[0]] ?? ""}</span>
393
- {:else if value.mode === "literal"}
417
+ {:else if effectiveValue.mode === "literal"}
394
418
  <Input
395
419
  size="sm"
396
420
  placeholder={literalPlaceholder(expectedType)}
397
- value={String(value.value ?? "")}
421
+ value={String(effectiveValue.value ?? "")}
398
422
  oninput={(e) => emit({ mode: "literal", value: coerceLiteral(/** @type {HTMLInputElement} */ (e.target).value, expectedType) })}
399
423
  {disabled}
400
424
  />
401
- {:else if value.mode === "parameter"}
425
+ {:else if effectiveValue.mode === "parameter"}
402
426
  <Combobox
403
427
  size="sm"
404
428
  placeholder="Pick a parameter"
405
429
  items={paramOptions()}
406
- value={value.key}
430
+ value={effectiveValue.key}
407
431
  onchange={(k) => emit({ mode: "parameter", key: k })}
408
432
  {disabled}
409
433
  />
410
- {:else if value.mode === "entity-field"}
434
+ {:else if effectiveValue.mode === "entity-field"}
411
435
  <Combobox
412
436
  size="sm"
413
437
  placeholder="Pick a field"
414
438
  items={entityFieldOptions()}
415
- value={value.field}
439
+ value={effectiveValue.field}
416
440
  onchange={(f) => emit({ mode: "entity-field", field: f })}
417
441
  {disabled}
418
442
  />
419
- {:else if value.mode === "user-field"}
443
+ {:else if effectiveValue.mode === "user-field"}
420
444
  <Combobox
421
445
  size="sm"
422
446
  placeholder="Pick a user claim"
423
447
  items={userFieldOptions()}
424
- value={value.key}
448
+ value={effectiveValue.key}
425
449
  onchange={(k) => emit({ mode: "user-field", key: k })}
426
450
  {disabled}
427
451
  />
428
- {:else if value.mode === "now"}
452
+ {:else if effectiveValue.mode === "now"}
429
453
  <Badge variant="info">$now — current UTC timestamp</Badge>
430
- {:else if value.mode === "source-id"}
454
+ {:else if effectiveValue.mode === "source-id"}
431
455
  <Badge variant="info">$source.id — id of the entity being acted upon</Badge>
432
- {:else if value.mode === "config-list"}
456
+ {:else if effectiveValue.mode === "config-list"}
433
457
  <Combobox
434
458
  size="sm"
435
459
  placeholder="Pick a config type"
436
460
  items={configTypeOptions()}
437
- value={value.configType}
461
+ value={effectiveValue.configType}
438
462
  onchange={(c) => emit({ mode: "config-list", configType: c })}
439
463
  {disabled}
440
464
  />
441
465
  <Badge variant="neutral">list</Badge>
442
- {:else if value.mode === "expression"}
466
+ {:else if effectiveValue.mode === "expression"}
443
467
  <Input
444
468
  size="sm"
445
469
  placeholder="$entity.X + 1"
446
- value={value.expr}
470
+ value={effectiveValue.expr}
447
471
  oninput={(e) => emit({ mode: "expression", expr: /** @type {HTMLInputElement} */ (e.target).value })}
448
472
  {disabled}
449
473
  />
450
- {:else if value.mode === "created-field"}
474
+ {:else if effectiveValue.mode === "created-field"}
451
475
  <div class="vsp-inline">
452
476
  <Combobox
453
477
  size="sm"
454
478
  placeholder="prior create"
455
479
  items={priorCreateOptions()}
456
- value={String(value.index)}
457
- onchange={(i) => emit({ mode: "created-field", index: Number(i), field: value.field })}
480
+ value={String(effectiveValue.index)}
481
+ onchange={(i) => emit({ mode: "created-field", index: Number(i), field: /** @type {{mode:'created-field',index:number,field:string}} */ (effectiveValue).field })}
458
482
  {disabled}
459
483
  />
460
484
  <Combobox
461
485
  size="sm"
462
486
  placeholder="field"
463
487
  items={priorCreateFieldOptions()}
464
- value={value.field}
465
- onchange={(f) => emit({ mode: "created-field", index: value.index, field: f })}
488
+ value={effectiveValue.field}
489
+ onchange={(f) => emit({ mode: "created-field", index: /** @type {{mode:'created-field',index:number,field:string}} */ (effectiveValue).index, field: f })}
466
490
  {disabled}
467
491
  />
468
492
  </div>
469
- {:else if value.mode === "function"}
493
+ {:else if effectiveValue.mode === "function"}
470
494
  <Combobox
471
495
  size="sm"
472
496
  placeholder="Pick a function"
473
497
  items={functionOptions()}
474
- value={value.name}
498
+ value={effectiveValue.name}
475
499
  onchange={(n) => emit({ mode: "function", name: n, args: {} })}
476
500
  {disabled}
477
501
  />
@@ -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";