@aiaiai-pt/design-system 0.17.1 → 0.18.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.
@@ -397,6 +397,11 @@
397
397
  {@const parameter = rawParameter as Entity}
398
398
  {@const key = parameterKey(parameter)}
399
399
  {@const type = parameterType(parameter)}
400
+ <!-- A11y (#244 C7 / Selo item 4): required fields are marked
401
+ `aria-required` on the control itself, so a screen reader announces
402
+ "required" on the field — not just the disconnected visual hint below.
403
+ Passed through the DS field components' `{...rest}` onto the input. -->
404
+ {@const ariaRequired = parameter.required ? "true" : undefined}
400
405
  {#if type === "enum" || type === "select" || enumOptions(parameter).length}
401
406
  <Select
402
407
  label={String(parameter.label ?? key)}
@@ -405,6 +410,7 @@
405
410
  options={enumOptions(parameter)}
406
411
  placeholder="Select value"
407
412
  onchange={(value: string) => setValue(key, value)}
413
+ aria-required={ariaRequired}
408
414
  />
409
415
  {:else if type === "number" || type === "integer"}
410
416
  <Input
@@ -416,6 +422,7 @@
416
422
  const value = (event.target as HTMLInputElement).value;
417
423
  setValue(key, value === "" ? "" : Number(value));
418
424
  }}
425
+ aria-required={ariaRequired}
419
426
  />
420
427
  {:else if type === "bool" || type === "boolean"}
421
428
  <Select
@@ -427,12 +434,20 @@
427
434
  { value: "false", label: "No" },
428
435
  ]}
429
436
  onchange={(value: string) => setValue(key, value === "true")}
437
+ aria-required={ariaRequired}
430
438
  />
431
439
  {:else if type === "file"}
432
440
  <!-- `file` parameter (#75 M5 slice 4b): upload-as-you-attach. The keys
433
441
  ride payload.attachment_keys; raw_values never sees this param. -->
434
- <div class="afr-file-param">
435
- <span class="afr-file-label">{String(parameter.label ?? key)}</span>
442
+ <!-- A11y: the file param is a labelled group (the FileUpload's own input
443
+ can't take a `for`/label from here), so SR users get the field name +
444
+ required state when they enter it. -->
445
+ <div class="afr-file-param" role="group" aria-labelledby={`${key}-file-label`}>
446
+ <span id={`${key}-file-label`} class="afr-file-label"
447
+ >{String(parameter.label ?? key)}{parameter.required
448
+ ? " (required)"
449
+ : ""}</span
450
+ >
436
451
  {#each fileUploads[key] ?? [] as f (f.key)}
437
452
  <FileUploadItem
438
453
  name={f.name}
@@ -491,6 +506,7 @@
491
506
  name={key}
492
507
  value={String(values[key] ?? "")}
493
508
  oninput={(event: Event) => setValue(key, (event.target as HTMLInputElement).value)}
509
+ aria-required={ariaRequired}
494
510
  />
495
511
  {/if}
496
512
  {#if mode === "public-submit"}
@@ -526,7 +542,14 @@
526
542
  <p class="muted">This action has no fields yet.</p>
527
543
  {:else}
528
544
  {@const Layout = resolvedLayout.component}
529
- <form class="rendered-form">
545
+ <!-- A11y: name the form region so SR users land on a labelled form, not an
546
+ anonymous group of inputs (Selo item 4 / WCAG 1.3.1). -->
547
+ <form
548
+ class="rendered-form"
549
+ aria-label={String(
550
+ renderedAction?.label ?? renderedAction?.key ?? "Form",
551
+ )}
552
+ >
530
553
  <Layout {sections} field={fieldRow} />
531
554
  </form>
532
555
  {/if}
@@ -544,6 +567,15 @@
544
567
  {#if captcha}
545
568
  <div class="submit-captcha">{@render captcha()}</div>
546
569
  {/if}
570
+ <!-- A11y: a disabled submit gives no reason on its own. This polite
571
+ status spells out the gate (and disappears when satisfied), so SR
572
+ users know WHY they can't submit yet — paired with the per-field
573
+ `aria-required` that marks which fields are needed. -->
574
+ {#if !canSubmit && !submitting}
575
+ <p class="submit-hint" role="status">
576
+ Fill in all required fields to submit.
577
+ </p>
578
+ {/if}
547
579
  <Button
548
580
  variant="primary"
549
581
  loading={submitting}
@@ -642,6 +674,12 @@
642
674
  color: var(--color-text-muted);
643
675
  }
644
676
 
677
+ .submit-hint {
678
+ color: var(--color-text-secondary);
679
+ font-size: var(--type-body-sm-size);
680
+ margin: 0;
681
+ }
682
+
645
683
  .rendered-form,
646
684
  .admin-preview,
647
685
  .preview-block,
@@ -0,0 +1,36 @@
1
+ <!--
2
+ @component ContrastToggle
3
+
4
+ Citizen high-contrast preference (#244 C7 accessibility pack). A single on/off
5
+ switch: "on" asks for a high-contrast rendering (max text/surface separation,
6
+ stronger borders, underlined links) on top of the active light/dark scheme.
7
+
8
+ Presentational, mirrors the scheme/motion controls: it reports the new value
9
+ via `onchange`; the consumer persists it (cookie) and applies the
10
+ `:root[data-contrast="high"]` layer (tokens/semantic.css) server-side, so SSR
11
+ paints it with no flash. The label is a prop for i18n (default English).
12
+
13
+ @example
14
+ <ContrastToggle high={prefs.contrast === "high"} onchange={(v) => setContrast(v)} />
15
+ -->
16
+ <script>
17
+ import Toggle from "./Toggle.svelte";
18
+
19
+ let {
20
+ /** @type {boolean} Whether high-contrast is currently on. */
21
+ high = false,
22
+ /** @type {((high: boolean) => void) | undefined} */
23
+ onchange = undefined,
24
+ /** @type {string} Accessible label (i18n). */
25
+ label = "High contrast",
26
+ ...rest
27
+ } = $props();
28
+ </script>
29
+
30
+ <Toggle
31
+ checked={high}
32
+ {label}
33
+ {onchange}
34
+ data-testid="contrast-toggle"
35
+ {...rest}
36
+ />
@@ -0,0 +1,30 @@
1
+ export default ContrastToggle;
2
+ type ContrastToggle = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<$$ComponentProps>): void;
5
+ };
6
+ /**
7
+ * ContrastToggle
8
+ *
9
+ * Citizen high-contrast preference (#244 C7 accessibility pack). A single on/off
10
+ * switch: "on" asks for a high-contrast rendering (max text/surface separation,
11
+ * stronger borders, underlined links) on top of the active light/dark scheme.
12
+ *
13
+ * Presentational, mirrors the scheme/motion controls: it reports the new value
14
+ * via `onchange`; the consumer persists it (cookie) and applies the
15
+ * `:root[data-contrast="high"]` layer (tokens/semantic.css) server-side, so SSR
16
+ * paints it with no flash. The label is a prop for i18n (default English).
17
+ *
18
+ * @example
19
+ * <ContrastToggle high={prefs.contrast === "high"} onchange={(v) => setContrast(v)} />
20
+ */
21
+ declare const ContrastToggle: import("svelte").Component<{
22
+ high?: boolean;
23
+ onchange?: any;
24
+ label?: string;
25
+ } & Record<string, any>, {}, "">;
26
+ type $$ComponentProps = {
27
+ high?: boolean;
28
+ onchange?: any;
29
+ label?: string;
30
+ } & Record<string, any>;
@@ -158,6 +158,20 @@
158
158
  }
159
159
  </script>
160
160
 
161
+ <!-- The file input is a SIBLING of the trigger button, not a child: a focusable
162
+ <input> nested inside a <button> is a nested-interactive a11y violation
163
+ (axe, #244 S5). It is visually hidden + pointer-events:none and is opened
164
+ via the `inputEl` ref from the button's onclick, so behaviour is unchanged. -->
165
+ <input
166
+ bind:this={inputEl}
167
+ type="file"
168
+ {accept}
169
+ {multiple}
170
+ class="fileupload-input"
171
+ onchange={handleInputChange}
172
+ tabindex={-1}
173
+ aria-hidden="true"
174
+ />
161
175
  <button
162
176
  type="button"
163
177
  class="fileupload {className}"
@@ -171,16 +185,6 @@
171
185
  {...rest}
172
186
  onclick={handleClick}
173
187
  >
174
- <input
175
- bind:this={inputEl}
176
- type="file"
177
- {accept}
178
- {multiple}
179
- class="fileupload-input"
180
- onchange={handleInputChange}
181
- tabindex={-1}
182
- aria-hidden="true"
183
- />
184
188
  {#if children}
185
189
  {@render children()}
186
190
  {:else}
@@ -0,0 +1,37 @@
1
+ <!--
2
+ @component LinkHighlightToggle
3
+
4
+ Citizen "underline links" preference (#244 C7 accessibility pack). A single
5
+ on/off switch: "on" forces a visible underline on every in-content link, so
6
+ links are distinguishable by more than colour (WCAG 1.4.1 Use of Colour /
7
+ Selo item 6 — hyperlinks should not rely on colour alone).
8
+
9
+ Presentational, mirrors the scheme/motion controls: it reports the new value
10
+ via `onchange`; the consumer persists it (cookie) and applies the
11
+ `:root[data-link-highlight="on"]` layer (tokens/semantic.css) server-side, so
12
+ SSR paints it with no flash. The label is a prop for i18n (default English).
13
+
14
+ @example
15
+ <LinkHighlightToggle on={prefs.linkHighlight} onchange={(v) => setLinkHighlight(v)} />
16
+ -->
17
+ <script>
18
+ import Toggle from "./Toggle.svelte";
19
+
20
+ let {
21
+ /** @type {boolean} Whether link-underlining is currently on. */
22
+ on = false,
23
+ /** @type {((on: boolean) => void) | undefined} */
24
+ onchange = undefined,
25
+ /** @type {string} Accessible label (i18n). */
26
+ label = "Underline links",
27
+ ...rest
28
+ } = $props();
29
+ </script>
30
+
31
+ <Toggle
32
+ checked={on}
33
+ {label}
34
+ {onchange}
35
+ data-testid="link-highlight-toggle"
36
+ {...rest}
37
+ />
@@ -0,0 +1,31 @@
1
+ export default LinkHighlightToggle;
2
+ type LinkHighlightToggle = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<$$ComponentProps>): void;
5
+ };
6
+ /**
7
+ * LinkHighlightToggle
8
+ *
9
+ * Citizen "underline links" preference (#244 C7 accessibility pack). A single
10
+ * on/off switch: "on" forces a visible underline on every in-content link, so
11
+ * links are distinguishable by more than colour (WCAG 1.4.1 Use of Colour /
12
+ * Selo item 6 — hyperlinks should not rely on colour alone).
13
+ *
14
+ * Presentational, mirrors the scheme/motion controls: it reports the new value
15
+ * via `onchange`; the consumer persists it (cookie) and applies the
16
+ * `:root[data-link-highlight="on"]` layer (tokens/semantic.css) server-side, so
17
+ * SSR paints it with no flash. The label is a prop for i18n (default English).
18
+ *
19
+ * @example
20
+ * <LinkHighlightToggle on={prefs.linkHighlight} onchange={(v) => setLinkHighlight(v)} />
21
+ */
22
+ declare const LinkHighlightToggle: import("svelte").Component<{
23
+ on?: boolean;
24
+ onchange?: any;
25
+ label?: string;
26
+ } & Record<string, any>, {}, "">;
27
+ type $$ComponentProps = {
28
+ on?: boolean;
29
+ onchange?: any;
30
+ label?: string;
31
+ } & Record<string, any>;
@@ -25,6 +25,7 @@ declare const MapCluster: import("svelte").Component<{
25
25
  tileSource?: Record<string, any>;
26
26
  layers?: any[];
27
27
  onclick?: any;
28
+ popup?: any;
28
29
  height?: string;
29
30
  class?: string;
30
31
  } & Record<string, any>, {}, "">;
@@ -36,6 +37,7 @@ type $$ComponentProps = {
36
37
  tileSource?: Record<string, any>;
37
38
  layers?: any[];
38
39
  onclick?: any;
40
+ popup?: any;
39
41
  height?: string;
40
42
  class?: string;
41
43
  } & Record<string, any>;
@@ -0,0 +1,150 @@
1
+ <!--
2
+ @component TextSizeAdjuster
3
+
4
+ Citizen text-size control (#244 C7 accessibility pack). Three buttons —
5
+ decrease (A−), reset (A), increase (A+) — step the root font scale across four
6
+ rungs (100–160%), so a citizen can enlarge the rem-based type system without
7
+ breaking the layout (WCAG 1.4.4 Resize Text). The classic pt-gov
8
+ "diminuir / normal / aumentar" pattern.
9
+
10
+ Presentational: it reports the chosen size via `onchange`; the consumer
11
+ persists it (cookie) and applies the `:root[data-text-size="N"]` layer
12
+ (tokens/semantic.css) server-side, so SSR paints it with no flash. Controls
13
+ disable at the bounds; a polite live region announces the current size for
14
+ screen-reader users. Labels are props for i18n (default English).
15
+
16
+ @example
17
+ <TextSizeAdjuster size={prefs.textSize} onchange={(n) => setTextSize(n)} />
18
+ -->
19
+ <script>
20
+ import {
21
+ DEFAULT_TEXT_SIZE,
22
+ normalizeTextSize,
23
+ increaseTextSize,
24
+ decreaseTextSize,
25
+ isMinTextSize,
26
+ isMaxTextSize,
27
+ } from "./text-size.js";
28
+
29
+ let {
30
+ /** @type {number} Current size step (percent). Off-ladder values normalize. */
31
+ size = DEFAULT_TEXT_SIZE,
32
+ /** @type {((size: number) => void) | undefined} */
33
+ onchange = undefined,
34
+ /** @type {string} Group label (i18n). */
35
+ label = "Text size",
36
+ /** @type {string} Decrease-button accessible label (i18n). */
37
+ decreaseLabel = "Decrease text size",
38
+ /** @type {string} Reset-button accessible label (i18n). */
39
+ resetLabel = "Reset text size",
40
+ /** @type {string} Increase-button accessible label (i18n). */
41
+ increaseLabel = "Increase text size",
42
+ /** @type {string} */
43
+ class: className = "",
44
+ ...rest
45
+ } = $props();
46
+
47
+ const current = $derived(normalizeTextSize(size));
48
+ const atMin = $derived(isMinTextSize(current));
49
+ const atMax = $derived(isMaxTextSize(current));
50
+ const atDefault = $derived(current === DEFAULT_TEXT_SIZE);
51
+
52
+ function emit(next) {
53
+ if (next !== current) onchange?.(next);
54
+ }
55
+ </script>
56
+
57
+ <div
58
+ class="text-size-adjuster {className}"
59
+ role="group"
60
+ aria-label={label}
61
+ data-testid="text-size-adjuster"
62
+ {...rest}
63
+ >
64
+ <button
65
+ type="button"
66
+ class="ts-btn ts-decrease"
67
+ aria-label={decreaseLabel}
68
+ disabled={atMin}
69
+ onclick={() => emit(decreaseTextSize(current))}
70
+ >A<span aria-hidden="true">&minus;</span></button>
71
+ <button
72
+ type="button"
73
+ class="ts-btn ts-reset"
74
+ aria-label={resetLabel}
75
+ disabled={atDefault}
76
+ onclick={() => emit(DEFAULT_TEXT_SIZE)}
77
+ >A</button>
78
+ <button
79
+ type="button"
80
+ class="ts-btn ts-increase"
81
+ aria-label={increaseLabel}
82
+ disabled={atMax}
83
+ onclick={() => emit(increaseTextSize(current))}
84
+ >A<span aria-hidden="true">+</span></button>
85
+ <span class="sr-only" aria-live="polite" data-testid="text-size-readout"
86
+ >{current}%</span
87
+ >
88
+ </div>
89
+
90
+ <style>
91
+ .text-size-adjuster {
92
+ display: inline-flex;
93
+ align-items: center;
94
+ gap: var(--space-2xs);
95
+ }
96
+
97
+ .ts-btn {
98
+ display: inline-flex;
99
+ align-items: baseline;
100
+ justify-content: center;
101
+ min-width: var(--space-lg);
102
+ font: inherit;
103
+ color: var(--color-text-secondary);
104
+ background: none;
105
+ border: var(--border-width-thin, 1px) solid var(--color-border);
106
+ border-radius: var(--radius-md);
107
+ padding: var(--space-2xs) var(--space-xs);
108
+ cursor: pointer;
109
+ line-height: 1;
110
+ }
111
+
112
+ /* The glyph "A" sizes telegraph the control: smaller on decrease, larger on
113
+ increase, so the affordance reads without relying on the +/− alone. */
114
+ .ts-decrease {
115
+ font-size: var(--type-body-sm-size);
116
+ }
117
+ .ts-reset {
118
+ font-size: var(--type-body-size);
119
+ }
120
+ .ts-increase {
121
+ font-size: var(--type-heading-sm-size);
122
+ }
123
+
124
+ .ts-btn:hover:not(:disabled) {
125
+ color: var(--color-text);
126
+ background: var(--color-surface-secondary);
127
+ }
128
+
129
+ .ts-btn:focus-visible {
130
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
131
+ outline-offset: var(--focus-ring-offset);
132
+ }
133
+
134
+ .ts-btn:disabled {
135
+ opacity: 0.4;
136
+ cursor: not-allowed;
137
+ }
138
+
139
+ .sr-only {
140
+ position: absolute;
141
+ width: 1px;
142
+ height: 1px;
143
+ padding: 0;
144
+ margin: -1px;
145
+ overflow: hidden;
146
+ clip: rect(0, 0, 0, 0);
147
+ white-space: nowrap;
148
+ border: 0;
149
+ }
150
+ </style>
@@ -0,0 +1,42 @@
1
+ export default TextSizeAdjuster;
2
+ type TextSizeAdjuster = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<$$ComponentProps>): void;
5
+ };
6
+ /**
7
+ * TextSizeAdjuster
8
+ *
9
+ * Citizen text-size control (#244 C7 accessibility pack). Three buttons —
10
+ * decrease (A−), reset (A), increase (A+) — step the root font scale across four
11
+ * rungs (100–160%), so a citizen can enlarge the rem-based type system without
12
+ * breaking the layout (WCAG 1.4.4 Resize Text). The classic pt-gov
13
+ * "diminuir / normal / aumentar" pattern.
14
+ *
15
+ * Presentational: it reports the chosen size via `onchange`; the consumer
16
+ * persists it (cookie) and applies the `:root[data-text-size="N"]` layer
17
+ * (tokens/semantic.css) server-side, so SSR paints it with no flash. Controls
18
+ * disable at the bounds; a polite live region announces the current size for
19
+ * screen-reader users. Labels are props for i18n (default English).
20
+ *
21
+ * @example
22
+ * <TextSizeAdjuster size={prefs.textSize} onchange={(n) => setTextSize(n)} />
23
+ */
24
+ declare const TextSizeAdjuster: import("svelte").Component<{
25
+ size?: typeof DEFAULT_TEXT_SIZE;
26
+ onchange?: any;
27
+ label?: string;
28
+ decreaseLabel?: string;
29
+ resetLabel?: string;
30
+ increaseLabel?: string;
31
+ class?: string;
32
+ } & Record<string, any>, {}, "">;
33
+ type $$ComponentProps = {
34
+ size?: typeof DEFAULT_TEXT_SIZE;
35
+ onchange?: any;
36
+ label?: string;
37
+ decreaseLabel?: string;
38
+ resetLabel?: string;
39
+ increaseLabel?: string;
40
+ class?: string;
41
+ } & Record<string, any>;
42
+ import { DEFAULT_TEXT_SIZE } from "./text-size.js";
@@ -38,6 +38,9 @@ export { default as AppFrame } from "./AppFrame.svelte";
38
38
  export { default as SiteHeader } from "./SiteHeader.svelte";
39
39
  export { default as SiteFooter } from "./SiteFooter.svelte";
40
40
  export { default as SkipLink } from "./SkipLink.svelte";
41
+ export { default as TextSizeAdjuster } from "./TextSizeAdjuster.svelte";
42
+ export { default as ContrastToggle } from "./ContrastToggle.svelte";
43
+ export { default as LinkHighlightToggle } from "./LinkHighlightToggle.svelte";
41
44
  export { default as ServiceNavigation } from "./ServiceNavigation.svelte";
42
45
  export { default as Link } from "./Link.svelte";
43
46
  export { default as Hero } from "./Hero.svelte";
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Clamp an arbitrary value to a valid ladder step. The cookie can carry junk or
3
+ * a numeric string (`"120"`), so coerce + validate; anything off-ladder falls
4
+ * back to the default. Idempotent.
5
+ * @param {unknown} value
6
+ * @returns {number}
7
+ */
8
+ export function normalizeTextSize(value: unknown): number;
9
+ /**
10
+ * The next step up, clamped at the top rung.
11
+ * @param {unknown} value
12
+ * @returns {number}
13
+ */
14
+ export function increaseTextSize(value: unknown): number;
15
+ /**
16
+ * The next step down, clamped at the bottom rung.
17
+ * @param {unknown} value
18
+ * @returns {number}
19
+ */
20
+ export function decreaseTextSize(value: unknown): number;
21
+ /**
22
+ * Whether the value is at the bottom rung (decrease control should be disabled).
23
+ * @param {unknown} value
24
+ * @returns {boolean}
25
+ */
26
+ export function isMinTextSize(value: unknown): boolean;
27
+ /**
28
+ * Whether the value is at the top rung (increase control should be disabled).
29
+ * @param {unknown} value
30
+ * @returns {boolean}
31
+ */
32
+ export function isMaxTextSize(value: unknown): boolean;
33
+ /**
34
+ * Text-size preference ladder (#244 C7 — DS accessibility pack).
35
+ *
36
+ * Four steps spanning 100–160% let a citizen scale the rem-based type system
37
+ * (WCAG 1.4.4 Resize Text) without breaking the layout. Pure logic: the
38
+ * `TextSizeAdjuster` component renders the controls, and the consumer persists
39
+ * the choice (cookie) + applies it as a root font-scale via the
40
+ * `:root[data-text-size="N"]` layer in tokens/semantic.css. Kept dependency-free
41
+ * + framework-agnostic so it is unit-testable with `node:test`.
42
+ */
43
+ /** The valid text-size steps, ascending (percent of the base root font-size). */
44
+ export const TEXT_SIZE_STEPS: number[];
45
+ /**
46
+ * The default (no preference) — the bottom rung. Derived from the ladder (not a
47
+ * literal `100`) so its inferred type stays `number`: consumers bind it to a
48
+ * `number`-typed prop, and a literal type would reject any other rung.
49
+ */
50
+ export const DEFAULT_TEXT_SIZE: number;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Text-size preference ladder (#244 C7 — DS accessibility pack).
3
+ *
4
+ * Four steps spanning 100–160% let a citizen scale the rem-based type system
5
+ * (WCAG 1.4.4 Resize Text) without breaking the layout. Pure logic: the
6
+ * `TextSizeAdjuster` component renders the controls, and the consumer persists
7
+ * the choice (cookie) + applies it as a root font-scale via the
8
+ * `:root[data-text-size="N"]` layer in tokens/semantic.css. Kept dependency-free
9
+ * + framework-agnostic so it is unit-testable with `node:test`.
10
+ */
11
+
12
+ /** The valid text-size steps, ascending (percent of the base root font-size). */
13
+ export const TEXT_SIZE_STEPS = [100, 120, 140, 160];
14
+
15
+ /**
16
+ * The default (no preference) — the bottom rung. Derived from the ladder (not a
17
+ * literal `100`) so its inferred type stays `number`: consumers bind it to a
18
+ * `number`-typed prop, and a literal type would reject any other rung.
19
+ */
20
+ export const DEFAULT_TEXT_SIZE = TEXT_SIZE_STEPS[0];
21
+
22
+ /**
23
+ * Clamp an arbitrary value to a valid ladder step. The cookie can carry junk or
24
+ * a numeric string (`"120"`), so coerce + validate; anything off-ladder falls
25
+ * back to the default. Idempotent.
26
+ * @param {unknown} value
27
+ * @returns {number}
28
+ */
29
+ export function normalizeTextSize(value) {
30
+ const n = Number(value);
31
+ return TEXT_SIZE_STEPS.includes(n) ? n : DEFAULT_TEXT_SIZE;
32
+ }
33
+
34
+ /**
35
+ * The next step up, clamped at the top rung.
36
+ * @param {unknown} value
37
+ * @returns {number}
38
+ */
39
+ export function increaseTextSize(value) {
40
+ const i = TEXT_SIZE_STEPS.indexOf(normalizeTextSize(value));
41
+ return TEXT_SIZE_STEPS[Math.min(i + 1, TEXT_SIZE_STEPS.length - 1)];
42
+ }
43
+
44
+ /**
45
+ * The next step down, clamped at the bottom rung.
46
+ * @param {unknown} value
47
+ * @returns {number}
48
+ */
49
+ export function decreaseTextSize(value) {
50
+ const i = TEXT_SIZE_STEPS.indexOf(normalizeTextSize(value));
51
+ return TEXT_SIZE_STEPS[Math.max(i - 1, 0)];
52
+ }
53
+
54
+ /**
55
+ * Whether the value is at the bottom rung (decrease control should be disabled).
56
+ * @param {unknown} value
57
+ * @returns {boolean}
58
+ */
59
+ export function isMinTextSize(value) {
60
+ return normalizeTextSize(value) === TEXT_SIZE_STEPS[0];
61
+ }
62
+
63
+ /**
64
+ * Whether the value is at the top rung (increase control should be disabled).
65
+ * @param {unknown} value
66
+ * @returns {boolean}
67
+ */
68
+ export function isMaxTextSize(value) {
69
+ return (
70
+ normalizeTextSize(value) === TEXT_SIZE_STEPS[TEXT_SIZE_STEPS.length - 1]
71
+ );
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -255,6 +255,15 @@
255
255
  --color-text-secondary: var(--raw-color-neutral-400);
256
256
  --color-text-muted: var(--raw-color-neutral-500);
257
257
 
258
+ /* Status colours lifted one ramp step (600 → 400) for dark surfaces. The
259
+ light -600 values are too dark on the dark (or dark-tinted -subtle) surface
260
+ a badge/alert sits on — e.g. info blue-600 #2e63a3 was 2.57:1, an AA fail
261
+ (axe #244 S5). The -400 ramp reads as the status colour AND clears 4.5:1. */
262
+ --color-destructive: var(--raw-color-red-400);
263
+ --color-success: var(--raw-color-green-400);
264
+ --color-warning: var(--raw-color-amber-400);
265
+ --color-info: var(--raw-color-blue-400);
266
+
258
267
  /* Subtle washes — derived from the LIVE hue (theme-set or default),
259
268
  mixed into the dark surface instead of the light-only raw tints. */
260
269
  --color-accent-subtle: color-mix(
@@ -284,6 +293,72 @@
284
293
  );
285
294
  }
286
295
 
296
+ /* ═══════════════════════════════════════════════
297
+ ACCESSIBILITY PREFERENCE LAYERS — #244 C7
298
+ ═══════════════════════════════════════════════
299
+
300
+ Citizen-selectable a11y affordances, each driven by a `data-*` attribute the
301
+ consumer stamps on <html> server-side (from a cookie, no flash) and toggles
302
+ live. Independent of scheme/theme — they LAYER on whatever brand + light/dark
303
+ is active. Placed after the dark block so a tie on specificity resolves here.
304
+
305
+ - text-size → `:root[data-text-size="N"]` scales the root font-size; the
306
+ whole rem-based type system grows with it (WCAG 1.4.4).
307
+ - contrast → `:root[data-contrast="high"]` re-maps the neutral roles to a
308
+ black-on-white (or white-on-black in dark) maximum, kills the
309
+ muted greys that fail AA, and strengthens borders (the
310
+ "alto contraste" pattern). A second selector handles the dark
311
+ pairing; (0,2,0) beats the generic dark layer.
312
+ - link-mark → `:root[data-link-highlight="on"] a` underlines every in-content
313
+ link so it is distinguishable by more than colour (1.4.1). */
314
+
315
+ :root[data-text-size="100"] {
316
+ font-size: 100%;
317
+ }
318
+ :root[data-text-size="120"] {
319
+ font-size: 120%;
320
+ }
321
+ :root[data-text-size="140"] {
322
+ font-size: 140%;
323
+ }
324
+ :root[data-text-size="160"] {
325
+ font-size: 160%;
326
+ }
327
+
328
+ :root[data-contrast="high"] {
329
+ --color-text: #000000;
330
+ --color-text-secondary: #000000;
331
+ --color-text-muted: #1a1a1a;
332
+ --color-surface: #ffffff;
333
+ --color-surface-secondary: #ffffff;
334
+ --color-surface-tertiary: #ffffff;
335
+ --color-surface-raised: #ffffff;
336
+ --color-border: #000000;
337
+ --color-border-strong: #000000;
338
+ --focus-ring-color: #000000;
339
+ }
340
+
341
+ :root[data-contrast="high"][data-scheme="dark"] {
342
+ --color-text: #ffffff;
343
+ --color-text-secondary: #ffffff;
344
+ --color-text-muted: #e6e6e6;
345
+ --color-surface: #000000;
346
+ --color-surface-secondary: #000000;
347
+ --color-surface-tertiary: #000000;
348
+ --color-surface-raised: #000000;
349
+ --color-border: #ffffff;
350
+ --color-border-strong: #ffffff;
351
+ --focus-ring-color: #ffffff;
352
+ }
353
+
354
+ /* High contrast implies link distinction — underline links even if the citizen
355
+ hasn't separately enabled the link-highlight pref. */
356
+ :root[data-contrast="high"] a,
357
+ :root[data-link-highlight="on"] a {
358
+ text-decoration: underline;
359
+ text-underline-offset: 0.15em;
360
+ }
361
+
287
362
  /* ─── Tablet (768px+) ─── */
288
363
  @media (min-width: 768px) {
289
364
  :root {
@@ -34,7 +34,9 @@
34
34
  /* ─── Text ─── */
35
35
  --color-text: #1c241b;
36
36
  --color-text-secondary: #44513f;
37
- --color-text-muted: #71806b;
37
+ /* Muted hits 4.5:1 even on the secondary/tertiary surfaces it sits on
38
+ (#f4f6f3 → 5.2:1) — the old #71806b was 3.86:1, an AA fail (axe #244 S5). */
39
+ --color-text-muted: #5e6b57;
38
40
 
39
41
  /* ─── Overlay + focus follow the brand ─── */
40
42
  --color-overlay: rgba(28, 36, 27, 0.5);
@@ -42,3 +44,28 @@
42
44
 
43
45
  /* Total overrides: 14 tokens. Still an aiaiai product, in Valongo's colours. */
44
46
  }
47
+
48
+ /*
49
+ * Dark-scheme brand override (#244 C7 + S5 contrast remediation).
50
+ *
51
+ * The generic dark layer (`:root[data-scheme="dark"]` in semantic.css) re-derives
52
+ * surfaces/text/borders from the neutral ramp but leaves the BRAND accent
53
+ * untouched, and the light civic green (#2e7d32) is too dark on dark surfaces.
54
+ *
55
+ * The accent is DUAL-USE — link text (on a dark surface) AND fill (a button,
56
+ * with `--color-text-on-accent` text). Those pull opposite ways: link text wants
57
+ * a LIGHT accent (≥4.5:1 vs the surface), white-on-fill wants a DARK one. So lift
58
+ * the accent for link contrast (#57bb5d clears 4.5:1 even on the lightest dark
59
+ * surface, neutral-800) AND flip `--color-text-on-accent` to dark, so fills read
60
+ * dark-on-light-green (the standard dark-mode resolution). Validated by axe.
61
+ *
62
+ * Selector (0,2,0) ties the theme block + beats the generic dark layer (both
63
+ * (0,1,1)). `auto` is resolved before paint, so this only matches a real
64
+ * `data-scheme="dark"`. The portal injects the equivalent at runtime; this seeds.
65
+ */
66
+ [data-theme="valongo"][data-scheme="dark"] {
67
+ --color-accent: #57bb5d; /* link text ≥4.5:1 on every dark surface (4.78 on n-800) */
68
+ --color-accent-hover: #81c784;
69
+ --color-accent-subtle: #15311a;
70
+ --color-text-on-accent: #14210f; /* dark text on the lighter accent fill */
71
+ }