@aiaiai-pt/design-system 0.50.0 → 0.51.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,113 +1,155 @@
1
1
  <!--
2
2
  @component KpiRegister
3
3
 
4
- A StatCard-variant register for KPI values where a measured reading
5
- visually out-ranks a projected value (larger display text, higher position
6
- in the visual hierarchy).
7
-
8
- BOUNDARY ELEMENT: throws when a projected value is provided without a seal.
9
- This is the enforcement point for the seal rule (prd.md §5.1): no unsealed
10
- projected value may cross this boundary into the rendering surface. The throw
11
- is unconditional — production code must never pass an unsealed projected value
12
- here; the guard exists to catch contract violations early.
4
+ The two-register composition for one KPI: the more authoritative value above,
5
+ the less authoritative beneath. It is where "measured out-ranks projected"
6
+ becomes concrete — in type SIZE, in POSITION, and in SCREEN-READER READING
7
+ ORDER, all three.
8
+
9
+ The ranking is enforced STRUCTURALLY, by evidence class, not by word. A
10
+ reading outranks a model output, which outranks a forecast
11
+ (EVIDENCE_CLASS_RANK in @aiaiai-pt/widget-system/core). That ordering holds
12
+ under every enumeration in the corpus, so this component needs no vocabulary
13
+ to enforce it — and it throws if a caller tries to seat a more authoritative
14
+ value in the lower register.
15
+
16
+ BOUNDARY ELEMENT: throws when a value that requires a seal is handed over
17
+ without one. No unsealed seal-required value crosses this boundary onto a
18
+ rendering surface.
13
19
 
14
20
  H1 Slice 2 — westeuropeco/atelier-urban-workspace#57.
15
- References sistema#67 for .type-overline (--type-overline-* tokens).
21
+ Uses --type-overline-* tokens (sistema#67) for the overline label.
16
22
 
17
- @example Measured only
18
- <KpiRegister label="ENERGY OUTPUT" measuredValue="2.3 MW" />
23
+ @example A reading alone
24
+ <KpiRegister label="WATER LEVEL" primaryValue="+14 cm/h" />
19
25
 
20
- @example With sealed projected value
26
+ @example Reading above a sealed forecast, why one tap away
21
27
  <KpiRegister
22
- label="ENERGY OUTPUT"
23
- measuredValue="2.3 MW"
24
- projectedValue="4.1 MW"
25
- projectedSeal={{ evidence: 'projected', stale: false }}
28
+ label="WATER LEVEL"
29
+ primaryValue="+14 cm/h"
30
+ secondaryValue="−18 cm"
31
+ secondarySeal={resolveSeal(assignment, vocabulary)}
32
+ secondaryWhy="derived from the 14:00–16:00 tide model run"
26
33
  />
27
34
  -->
28
35
  <script>
29
36
  import SealChip from './SealChip.svelte';
30
37
 
31
38
  /**
32
- * @typedef {'measured' | 'inferred' | 'projected'} EvidenceState
33
- * @typedef {{ evidence: EvidenceState; stale: boolean }} SealRef
39
+ * @typedef {'direct_reading' | 'model_derived' | 'future_window'} EvidenceClass
40
+ * @typedef {'positive' | 'info' | 'caution' | 'neutral'} SealTone
41
+ * @typedef {{ value: string, label: string, tone?: SealTone }} SealTerm
42
+ * @typedef {{ evidence: SealTerm, evidenceClass: EvidenceClass, probability?: SealTerm, stale: boolean }} ResolvedSeal
34
43
  */
35
44
 
36
45
  let {
37
46
  /** @type {string} — overline label (KPI name / dimension) */
38
47
  label,
39
- /** @type {string} — the measured (authoritative) value; always required */
40
- measuredValue,
41
- /** @type {string | undefined} — a projected value for the same KPI */
42
- projectedValue = undefined,
48
+ /** @type {string} — the more authoritative value; always required */
49
+ primaryValue,
50
+ /**
51
+ * @type {ResolvedSeal | undefined}
52
+ * Seal for the primary value. Omit only when the value needs none — a
53
+ * direct reading of a current or past window (assignSeal.sealRequired).
54
+ */
55
+ primarySeal = undefined,
56
+ /** @type {string | undefined} — the less authoritative value for the same KPI */
57
+ secondaryValue = undefined,
43
58
  /**
44
- * @type {SealRef | undefined}
45
- * Seal metadata for the projected value. REQUIRED when projectedValue
46
- * is provided. The boundary guard throws when this is absent.
47
- * Obtain from assignSeal() in @aiaiai-pt/widget-system/core.
59
+ * @type {ResolvedSeal | undefined}
60
+ * Seal for the secondary value. REQUIRED when secondaryValue is provided:
61
+ * a value seated in the lower register is, by construction, not a plain
62
+ * current reading.
48
63
  */
49
- projectedSeal = undefined,
64
+ secondarySeal = undefined,
65
+ /**
66
+ * @type {string | undefined}
67
+ * The provenance one-liner behind the secondary value's seal — what makes
68
+ * the «why» reachable one tap from the seal.
69
+ */
70
+ secondaryWhy = undefined,
50
71
  /** @type {string} */
51
72
  class: className = '',
52
73
  ...rest
53
74
  } = $props();
54
75
 
76
+ /** Structural authority order — highest first. Mirrors EVIDENCE_CLASS_RANK. */
77
+ const RANK = {
78
+ direct_reading: 3,
79
+ model_derived: 2,
80
+ future_window: 1,
81
+ };
82
+
55
83
  /*
56
- * BOUNDARY GUARD — the seal rule enforcement point.
57
- *
58
- * Runs synchronously at component initialisation. Also runs reactively
59
- * via $effect so prop changes after mount are also checked.
60
- *
61
- * Throws unconditionally when projectedValue is provided without a seal;
62
- * production surfaces must never reach this state (prd.md §5.1 #3:
63
- * "the product, deterministically" assigns the seal — a missing seal
64
- * means the caller skipped assignSeal, which is the defect).
84
+ * BOUNDARY GUARDS. Both run synchronously at initialisation and again
85
+ * reactively, so a prop change after mount cannot smuggle a violation in.
65
86
  */
66
- if (projectedValue !== undefined && projectedSeal == null) {
67
- throw new Error(
68
- '[KpiRegister] A projected value must carry a seal. ' +
69
- 'Provide the `projectedSeal` prop (result of assignSeal()). ' +
70
- 'An unsealed projected value may not cross this boundary.',
71
- );
72
- }
73
-
74
- $effect(() => {
75
- if (projectedValue !== undefined && projectedSeal == null) {
87
+ const assertRegisters = (
88
+ /** @type {string | undefined} */ secondary,
89
+ /** @type {ResolvedSeal | undefined} */ secSeal,
90
+ /** @type {ResolvedSeal | undefined} */ primSeal,
91
+ ) => {
92
+ // 1. No unsealed value in the lower register.
93
+ if (secondary !== undefined && secSeal == null) {
76
94
  throw new Error(
77
- '[KpiRegister] A projected value must carry a seal. ' +
78
- 'Provide the `projectedSeal` prop (result of assignSeal()). ' +
79
- 'An unsealed projected value may not cross this boundary.',
95
+ '[KpiRegister] The secondary value must carry a seal. Pass ' +
96
+ '`secondarySeal` (from resolveSeal()). An unsealed value may not ' +
97
+ 'cross this boundary onto a rendering surface.',
80
98
  );
81
99
  }
82
- });
100
+ // 2. The lower register may never out-rank the upper one.
101
+ if (secSeal != null && primSeal != null) {
102
+ const secRank = RANK[secSeal.evidenceClass];
103
+ const primRank = RANK[primSeal.evidenceClass];
104
+ if (secRank > primRank) {
105
+ throw new Error(
106
+ `[KpiRegister] The secondary value's evidence class ` +
107
+ `"${secSeal.evidenceClass}" out-ranks the primary's ` +
108
+ `"${primSeal.evidenceClass}". The more authoritative value takes ` +
109
+ 'the upper register — swap them.',
110
+ );
111
+ }
112
+ }
113
+ };
114
+
115
+ assertRegisters(secondaryValue, secondarySeal, primarySeal);
116
+ $effect(() => assertRegisters(secondaryValue, secondarySeal, primarySeal));
83
117
  </script>
84
118
 
85
119
  <div class="kpi-register {className}" {...rest}>
86
- <!--
87
- Overline label — micro-heading above the value.
88
- Uses --type-overline-* tokens directly (sistema#67 .type-overline utility).
89
- -->
120
+ <!-- Overline label — micro-heading above the value (sistema#67 tokens). -->
90
121
  <span class="kpi-register-label">{label}</span>
91
122
 
92
123
  <!--
93
- Measured value: the authoritative reading.
94
- Display-scale type — visually out-ranks the projected row.
124
+ The primary register: display-scale type, first in the DOM, and therefore
125
+ first in reading order. Out-ranks the secondary on all three axes.
95
126
  -->
96
- <div class="kpi-register-measured">
97
- <span class="kpi-register-measured-value">{measuredValue}</span>
127
+ <div class="kpi-register-primary">
128
+ {#if primarySeal != null}
129
+ <SealChip
130
+ class="kpi-register-primary-value"
131
+ value={primaryValue}
132
+ evidence={primarySeal.evidence}
133
+ probability={primarySeal.probability}
134
+ stale={primarySeal.stale}
135
+ />
136
+ {:else}
137
+ <span class="kpi-register-primary-value">{primaryValue}</span>
138
+ {/if}
98
139
  </div>
99
140
 
100
141
  <!--
101
- Projected row: subordinate to the measured value (smaller type, lower position).
102
- Only rendered when a sealed projected value is provided.
103
- projectedSeal is guaranteed non-null here (boundary guard above).
142
+ The secondary register: body-scale, lower position, read last.
143
+ secondarySeal is guaranteed non-null here by the boundary guard.
104
144
  -->
105
- {#if projectedValue !== undefined && projectedSeal != null}
106
- <div class="kpi-register-projected">
145
+ {#if secondaryValue !== undefined && secondarySeal != null}
146
+ <div class="kpi-register-secondary">
107
147
  <SealChip
108
- value={projectedValue}
109
- evidence={projectedSeal.evidence}
110
- stale={projectedSeal.stale}
148
+ value={secondaryValue}
149
+ evidence={secondarySeal.evidence}
150
+ probability={secondarySeal.probability}
151
+ stale={secondarySeal.stale}
152
+ why={secondaryWhy}
111
153
  />
112
154
  </div>
113
155
  {/if}
@@ -126,8 +168,8 @@
126
168
 
127
169
  /*
128
170
  * Overline label — consumes --type-overline-* tokens (sistema#67).
129
- * Equivalent to the .type-overline utility class from utilities.css,
130
- * declared here so the component is self-contained.
171
+ * Equivalent to the .type-overline utility, declared here so the component
172
+ * is self-contained.
131
173
  */
132
174
  .kpi-register-label {
133
175
  font-family: var(--type-overline-font);
@@ -139,8 +181,9 @@
139
181
  color: var(--stat-label-color);
140
182
  }
141
183
 
142
- /* Measured value: display scale — the dominant visual element */
143
- .kpi-register-measured-value {
184
+ /* Primary: display scale — the dominant element. */
185
+ .kpi-register-primary :global(.kpi-register-primary-value),
186
+ .kpi-register-primary .kpi-register-primary-value {
144
187
  font-family: var(--stat-value-font);
145
188
  font-size: var(--stat-value-size);
146
189
  font-weight: var(--stat-value-weight);
@@ -149,8 +192,8 @@
149
192
  line-height: 1;
150
193
  }
151
194
 
152
- /* Projected row: body-scale — subordinate to the measured value */
153
- .kpi-register-projected {
195
+ /* Secondary: body scale — subordinate to the primary register. */
196
+ .kpi-register-secondary {
154
197
  display: flex;
155
198
  align-items: center;
156
199
  margin-top: var(--space-2xs);
@@ -6,41 +6,52 @@ type KpiRegister = {
6
6
  /**
7
7
  * KpiRegister
8
8
  *
9
- * A StatCard-variant register for KPI values where a measured reading
10
- * visually out-ranks a projected value (larger display text, higher position
11
- * in the visual hierarchy).
9
+ * The two-register composition for one KPI: the more authoritative value above,
10
+ * the less authoritative beneath. It is where "measured out-ranks projected"
11
+ * becomes concrete — in type SIZE, in POSITION, and in SCREEN-READER READING
12
+ * ORDER, all three.
12
13
  *
13
- * BOUNDARY ELEMENT: throws when a projected value is provided without a seal.
14
- * This is the enforcement point for the seal rule (prd.md §5.1): no unsealed
15
- * projected value may cross this boundary into the rendering surface. The throw
16
- * is unconditional — production code must never pass an unsealed projected value
17
- * here; the guard exists to catch contract violations early.
14
+ * The ranking is enforced STRUCTURALLY, by evidence class, not by word. A
15
+ * reading outranks a model output, which outranks a forecast
16
+ * (EVIDENCE_CLASS_RANK in @aiaiai-pt/widget-system/core). That ordering holds
17
+ * under every enumeration in the corpus, so this component needs no vocabulary
18
+ * to enforce it — and it throws if a caller tries to seat a more authoritative
19
+ * value in the lower register.
20
+ *
21
+ * BOUNDARY ELEMENT: throws when a value that requires a seal is handed over
22
+ * without one. No unsealed seal-required value crosses this boundary onto a
23
+ * rendering surface.
18
24
  *
19
25
  * H1 Slice 2 — westeuropeco/atelier-urban-workspace#57.
20
- * References sistema#67 for .type-overline (--type-overline-* tokens).
26
+ * Uses --type-overline-* tokens (sistema#67) for the overline label.
21
27
  *
22
- * @example Measured only
23
- * <KpiRegister label="ENERGY OUTPUT" measuredValue="2.3 MW" />
28
+ * @example A reading alone
29
+ * <KpiRegister label="WATER LEVEL" primaryValue="+14 cm/h" />
24
30
  *
25
- * @example With sealed projected value
31
+ * @example Reading above a sealed forecast, why one tap away
26
32
  * <KpiRegister
27
- * label="ENERGY OUTPUT"
28
- * measuredValue="2.3 MW"
29
- * projectedValue="4.1 MW"
30
- * projectedSeal={{ evidence: 'projected', stale: false }}
33
+ * label="WATER LEVEL"
34
+ * primaryValue="+14 cm/h"
35
+ * secondaryValue="−18 cm"
36
+ * secondarySeal={resolveSeal(assignment, vocabulary)}
37
+ * secondaryWhy="derived from the 14:00–16:00 tide model run"
31
38
  * />
32
39
  */
33
40
  declare const KpiRegister: import("svelte").Component<{
34
41
  label: any;
35
- measuredValue: any;
36
- projectedValue?: any;
37
- projectedSeal?: any;
42
+ primaryValue: any;
43
+ primarySeal?: any;
44
+ secondaryValue?: any;
45
+ secondarySeal?: any;
46
+ secondaryWhy?: any;
38
47
  class?: string;
39
48
  } & Record<string, any>, {}, "">;
40
49
  type $$ComponentProps = {
41
50
  label: any;
42
- measuredValue: any;
43
- projectedValue?: any;
44
- projectedSeal?: any;
51
+ primaryValue: any;
52
+ primarySeal?: any;
53
+ secondaryValue?: any;
54
+ secondarySeal?: any;
55
+ secondaryWhy?: any;
45
56
  class?: string;
46
57
  } & Record<string, any>;
@@ -25,6 +25,8 @@
25
25
  * @typedef {'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'} Placement
26
26
  */
27
27
 
28
+ import { calculatePopoverPosition } from './popover-position.js';
29
+
28
30
  let {
29
31
  /** @type {boolean} */
30
32
  open = $bindable(false),
@@ -52,6 +54,7 @@
52
54
 
53
55
  let posX = $state(0);
54
56
  let posY = $state(0);
57
+ let posMaxHeight = /** @type {number | undefined} */ ($state(undefined));
55
58
  let width = $state(0);
56
59
 
57
60
  function reposition() {
@@ -66,27 +69,19 @@
66
69
  width = rect.width;
67
70
  }
68
71
 
69
- const isTop = placement.startsWith('top');
70
- const isEnd = placement.endsWith('end');
71
-
72
- let x = isEnd ? rect.right - (matchWidth ? rect.width : popRect.width) : rect.left;
73
- let y = isTop ? rect.top - popRect.height - offset : rect.bottom + offset;
74
-
75
- // Flip vertical if out of viewport
76
- if (!isTop && y + popRect.height > viewportH) {
77
- y = rect.top - popRect.height - offset;
78
- } else if (isTop && y < 0) {
79
- y = rect.bottom + offset;
80
- }
81
-
82
- // Clamp horizontal
83
- if (x + popRect.width > viewportW) {
84
- x = viewportW - popRect.width - offset;
85
- }
86
- if (x < 0) x = offset;
72
+ const { x, y, maxHeight } = calculatePopoverPosition(
73
+ rect,
74
+ { width: popRect.width, height: popRect.height },
75
+ viewportH,
76
+ viewportW,
77
+ placement,
78
+ offset,
79
+ matchWidth,
80
+ );
87
81
 
88
82
  posX = x;
89
83
  posY = y;
84
+ posMaxHeight = maxHeight;
90
85
  }
91
86
 
92
87
  // Position + close listeners
@@ -157,6 +152,8 @@
157
152
  style:left="{posX}px"
158
153
  style:top="{posY}px"
159
154
  style:width={matchWidth ? `${width}px` : undefined}
155
+ style:max-height={posMaxHeight != null ? `${posMaxHeight}px` : undefined}
156
+ style:overflow-y={posMaxHeight != null ? 'auto' : undefined}
160
157
  {...rest}
161
158
  >
162
159
  {#if children}{@render children()}{/if}
@@ -1,4 +1,5 @@
1
1
  export default Popover;
2
+ export type Placement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
2
3
  type Popover = {
3
4
  $on?(type: string, callback: (e: any) => void): () => void;
4
5
  $set?(props: Partial<$$ComponentProps>): void;
@@ -1,70 +1,221 @@
1
1
  <!--
2
2
  @component SealChip
3
3
 
4
- Renders a value paired with its evidence seal as ONE phrase.
4
+ The one seal renderer for every value surface — KPI registers, map badges,
5
+ paths-strip columns, record lines, citations, saved projections, briefs.
6
+ No surface renders a seal any other way.
5
7
 
6
- The seal must survive quotation: an sr-only span adjacent to the value
7
- carries the evidence state word so copying or citing the value also
8
- carries the seal (prd.md §5.1 #5: "a citation carries the seal of the
9
- value it cites").
8
+ THIS COMPONENT KNOWS NO SEAL WORDS. The design system owns the LAWS and a
9
+ presentation tone scale; it does not own the vocabulary. Terms arrive as
10
+ declared data — `{ value, label, tone }` resolved from ontology vocabulary
11
+ rows at read time — so widening an axis or re-wording a term is a declaration
12
+ change with zero code change and no package release.
10
13
 
11
- DS-owned evidence vocabulary: measured | inferred | projected.
12
- Built over --badge-* + --seal-* component tokens.
14
+ The laws it does enforce, all of which hold under every enumeration in the
15
+ corpus:
16
+ · two orthogonal axes, rendered as two adjacent chips, never one phrase
17
+ · never label-alone — a seal never appears without its evidence chip
18
+ · evidence alone is valid; probability alone is not
19
+ · the seal rides the DATUM, not the styling — it is adjacent DOM text, so
20
+ it survives re-render, copy, quotation and screen readers
21
+ · a «why» is always one tap away, keyboard-reachable
13
22
 
14
- H1 Slice 2 — westeuropeco/atelier-urban-workspace#57.
23
+ Value and seal read to a screen reader as ONE stitched phrase:
24
+ «{value} — {evidence}[, {probability}][, stale]».
25
+
26
+ `stale` is a separate orthogonal state, never a member of either vocabulary.
15
27
 
16
- @example Measured reading
17
- <SealChip value="2.3 MW" evidence="measured" />
28
+ H1 Slice 2 — westeuropeco/atelier-urban-workspace#57.
18
29
 
19
- @example Projected (future window)
20
- <SealChip value="4.1 MW" evidence="projected" />
30
+ @example A reading, using whatever terms the deployment declares
31
+ <SealChip value="+14 cm/h" evidence={{ value: 'measured', label: 'medido', tone: 'positive' }} />
21
32
 
22
- @example Stale seal (horizon has passed)
23
- <SealChip value="1.9 MW" evidence="projected" stale />
33
+ @example Both axes, horizon passed, why one tap away
34
+ <SealChip
35
+ value="2,1 m"
36
+ evidence={{ value: 'inferred', label: 'inferido', tone: 'info' }}
37
+ probability={{ value: 'uncertain', label: 'incerto' }}
38
+ stale
39
+ why="the 16:00 window has already passed"
40
+ />
24
41
  -->
25
42
  <script>
26
43
  /**
27
- * @typedef {'measured' | 'inferred' | 'projected'} EvidenceState
44
+ * @typedef {'positive' | 'info' | 'caution' | 'neutral'} SealTone
45
+ * @typedef {{ value: string, label: string, tone?: SealTone }} SealTerm
28
46
  */
29
47
 
30
48
  let {
31
49
  /** @type {string} — the formatted display value */
32
50
  value,
33
- /** @type {EvidenceState} — evidence state (from assignSeal) */
51
+ /**
52
+ * @type {SealTerm}
53
+ * The declared evidence term. Required: a seal never appears without its
54
+ * evidence chip. Resolve it with `resolveSeal` from
55
+ * `@aiaiai-pt/widget-system/core` — never construct one from a literal.
56
+ */
34
57
  evidence,
58
+ /**
59
+ * @type {SealTerm | undefined}
60
+ * The declared probability term. Orthogonal to evidence and rendered as its
61
+ * own adjacent chip. Optional because evidence alone is a valid seal.
62
+ */
63
+ probability = undefined,
35
64
  /** @type {boolean} — true when the validity window has passed */
36
65
  stale = false,
66
+ /**
67
+ * @type {string | undefined}
68
+ * The provenance one-liner behind the seal. When supplied, the chip is a
69
+ * real button that discloses it — the «why», one tap away.
70
+ */
71
+ why = undefined,
72
+ /**
73
+ * @type {string}
74
+ * Accessible name for the why trigger. Localized by the caller, since this
75
+ * package owns no product copy. `{term}` is replaced by the evidence label.
76
+ */
77
+ whyLabel = 'why — {term}',
78
+ /** @type {string} — accessible name for the disclosure's close affordance */
79
+ closeLabel = 'Close',
80
+ /** @type {string} — screen-reader word for the stale state; localized by the caller */
81
+ staleLabel = 'stale',
37
82
  /** @type {string} */
38
83
  class: className = '',
39
84
  ...rest
40
85
  } = $props();
41
86
 
42
- const EVIDENCE_LABELS = /** @type {Record<EvidenceState, string>} */ ({
43
- measured: 'measured',
44
- inferred: 'inferred',
45
- projected: 'projected',
46
- });
87
+ const TONES = ['positive', 'info', 'caution', 'neutral'];
88
+
89
+ /**
90
+ * BOUNDARY GUARD — "never label-alone; a seal never appears without evidence
91
+ * chips", and "evidence alone is valid, probability alone is not".
92
+ *
93
+ * Without this, an absent term renders an empty badge and an empty sr-text:
94
+ * a value dressed as a sealed datum, carrying no evidence and announcing
95
+ * nothing to a screen reader. That failure is silent, which is exactly why
96
+ * it is a throw and not a fallback.
97
+ *
98
+ * The guard checks SHAPE, never membership — this component has no opinion
99
+ * about which words are legal, only that a term is present and renderable.
100
+ */
101
+ const assertTerm = (
102
+ /** @type {unknown} */ term,
103
+ /** @type {string} */ axis,
104
+ ) => {
105
+ const ok =
106
+ typeof term === 'object' &&
107
+ term !== null &&
108
+ typeof (/** @type {SealTerm} */ (term).label) === 'string' &&
109
+ /** @type {SealTerm} */ (term).label.trim() !== '';
110
+ if (!ok) {
111
+ throw new Error(
112
+ `[SealChip] The ${axis} axis needs a declared term ` +
113
+ '{ value, label, tone? } with a non-empty label. Received ' +
114
+ `${JSON.stringify(term)}. Resolve it from the declared vocabulary ` +
115
+ 'with resolveSeal(); this component enumerates no seal words.',
116
+ );
117
+ }
118
+ const tone = /** @type {SealTerm} */ (term).tone;
119
+ if (tone !== undefined && !TONES.includes(tone)) {
120
+ throw new Error(
121
+ `[SealChip] Unknown tone ${JSON.stringify(tone)} on the ${axis} axis. ` +
122
+ `The design system's tone scale is ${TONES.join(' | ')}.`,
123
+ );
124
+ }
125
+ };
126
+
127
+ const assertSeal = (/** @type {unknown} */ ev, /** @type {unknown} */ prob) => {
128
+ // Evidence first: probability-alone must fail as a MISSING EVIDENCE error,
129
+ // because that is the law it breaks.
130
+ assertTerm(ev, 'evidence');
131
+ if (prob !== undefined) assertTerm(prob, 'probability');
132
+ };
133
+
134
+ assertSeal(evidence, probability);
135
+ $effect(() => assertSeal(evidence, probability));
136
+
137
+ const evidenceTone = $derived(evidence?.tone ?? 'neutral');
138
+ const probabilityTone = $derived(probability?.tone ?? 'neutral');
139
+
140
+ /**
141
+ * The stitched phrase AT reads and a citation carries:
142
+ * «{value} — {evidence}[, {probability}][, stale]».
143
+ */
144
+ const srPhrase = $derived(
145
+ [
146
+ `${value} — ${evidence.label}`,
147
+ probability?.label,
148
+ stale ? staleLabel : undefined,
149
+ ]
150
+ .filter(Boolean)
151
+ .join(', '),
152
+ );
153
+
154
+ const whyTriggerLabel = $derived(whyLabel.replace('{term}', evidence.label));
155
+
156
+ /** Disclosure state lives here, never in a DOM attribute. */
157
+ let open = $state(false);
158
+ /** @type {HTMLButtonElement | undefined} */
159
+ let trigger = $state();
47
160
 
48
- const evidenceLabel = $derived(EVIDENCE_LABELS[evidence] ?? evidence);
161
+ const panelId = $props.id();
49
162
 
50
- /** Label as spoken by AT and included in citations. */
51
- const srLabel = $derived(stale ? `${evidenceLabel} (stale)` : evidenceLabel);
163
+ const closeWhy = () => {
164
+ open = false;
165
+ trigger?.focus();
166
+ };
167
+
168
+ /** Esc anywhere inside the open disclosure closes it (cascade slot 2). */
169
+ const onDocumentKeydown = (/** @type {KeyboardEvent} */ event) => {
170
+ if (event.key === 'Escape' && open) closeWhy();
171
+ };
52
172
  </script>
53
173
 
174
+ <svelte:document onkeydown={onDocumentKeydown} />
175
+
176
+ {#snippet chips()}
177
+ <span class="seal-chip-badge seal-tone-{evidenceTone}" aria-hidden="true"
178
+ >{evidence.label}{stale ? ` (${staleLabel})` : ''}</span
179
+ >{#if probability}<span
180
+ class="seal-chip-probability seal-tone-{probabilityTone}"
181
+ aria-hidden="true">{probability.label}</span
182
+ >{/if}
183
+ {/snippet}
184
+
54
185
  <!--
55
- The chip is one inline phrase. Structure:
56
- [value text]
57
- [visual badge — aria-hidden, not read by AT]
58
- [sr-only span — read by AT; position:absolute but in DOM so text
59
- travels with the value when the surrounding content is copied]
186
+ One inline phrase. The visible value and chips are aria-hidden so the
187
+ stitched sr-text is the single thing announced — nothing reads twice.
188
+ The sr-text follows the value in DOM order, so a sweep or a copy captures
189
+ «value seal» in that order.
60
190
  -->
61
191
  <span
62
- class="seal-chip seal-chip-{evidence} {stale ? 'seal-chip-stale' : ''} {className}"
192
+ class="seal-chip {stale ? 'seal-chip-stale' : ''} {className}"
193
+ data-evidence={evidence.value}
63
194
  {...rest}
64
195
  >
65
- <span class="seal-chip-value">{value}</span><!--
66
- --><span class="seal-chip-badge" aria-hidden="true">{evidenceLabel}{stale ? ' (stale)' : ''}</span><!--
67
- --><span class="seal-chip-sr-text">&thinsp;{srLabel}</span>
196
+ <span class="seal-chip-value" aria-hidden="true">{value}</span><!--
197
+ -->{#if why}<button
198
+ type="button"
199
+ class="seal-chip-why-trigger"
200
+ aria-expanded={open}
201
+ aria-controls={panelId}
202
+ aria-label={whyTriggerLabel}
203
+ bind:this={trigger}
204
+ onclick={() => (open = !open)}>{@render chips()}</button
205
+ >{:else}{@render chips()}{/if}<!--
206
+ --><span class="seal-chip-sr-text">&thinsp;{srPhrase}</span>
207
+
208
+ {#if why && open}
209
+ <span id={panelId} class="seal-chip-why-panel">
210
+ <span class="seal-chip-why-text">{why}</span>
211
+ <button
212
+ type="button"
213
+ class="seal-chip-why-close"
214
+ aria-label={closeLabel}
215
+ onclick={closeWhy}>×</button
216
+ >
217
+ </span>
218
+ {/if}
68
219
  </span>
69
220
 
70
221
  <style>
@@ -75,13 +226,11 @@
75
226
  position: relative;
76
227
  }
77
228
 
78
- /* Value text: no override — reads as its context's surrounding type */
79
- .seal-chip-value {
80
- /* intentionally empty */
81
- }
229
+ /* Value text carries no override — it reads as its surrounding type. */
82
230
 
83
- /* Visual evidence badge — seen by sighted users; hidden from AT */
84
- .seal-chip-badge {
231
+ /* Both axes share one chip shape; only the tone differs. */
232
+ .seal-chip-badge,
233
+ .seal-chip-probability {
85
234
  display: inline-flex;
86
235
  align-items: center;
87
236
  font-family: var(--badge-font);
@@ -93,32 +242,106 @@
93
242
  line-height: 1;
94
243
  }
95
244
 
96
- /* Evidence-state colours via --seal-* component tokens */
97
- .seal-chip-measured .seal-chip-badge {
98
- color: var(--seal-measured-text);
99
- background: var(--seal-measured-bg);
245
+ /* The second axis is always its own element beside the first, so the two
246
+ never merge into one phrase. */
247
+ .seal-chip-probability {
248
+ margin-left: var(--space-2xs);
100
249
  }
101
250
 
102
- .seal-chip-inferred .seal-chip-badge {
103
- color: var(--seal-inferred-text);
104
- background: var(--seal-inferred-bg);
251
+ /*
252
+ * TONES — the presentation scale the design system owns. Which declared term
253
+ * wears which tone is data, declared beside the word and never inferred from
254
+ * it, so these rules carry no vocabulary.
255
+ */
256
+ .seal-tone-positive {
257
+ color: var(--seal-positive-text);
258
+ background: var(--seal-positive-bg);
105
259
  }
106
260
 
107
- .seal-chip-projected .seal-chip-badge {
108
- color: var(--seal-projected-text);
109
- background: var(--seal-projected-bg);
261
+ .seal-tone-info {
262
+ color: var(--seal-info-text);
263
+ background: var(--seal-info-bg);
110
264
  }
111
265
 
112
- /* Stale modifier: muted on all evidence states */
266
+ .seal-tone-caution {
267
+ color: var(--seal-caution-text);
268
+ background: var(--seal-caution-bg);
269
+ }
270
+
271
+ .seal-tone-neutral {
272
+ color: var(--badge-neutral-text);
273
+ background: var(--badge-neutral-bg);
274
+ }
275
+
276
+ /* Stale is an orthogonal state, not a vocabulary member: it mutes whatever
277
+ tone the evidence term carries. The word carries the meaning; the wash is
278
+ redundant emphasis. */
113
279
  .seal-chip-stale .seal-chip-badge {
114
280
  color: var(--seal-stale-text);
115
281
  background: var(--seal-stale-bg);
116
282
  }
117
283
 
284
+ /*
285
+ * The why trigger is a real button wrapping the chips — the whole seal is
286
+ * the affordance, so the «why» is one tap away from the seal itself.
287
+ */
288
+ .seal-chip-why-trigger {
289
+ display: inline-flex;
290
+ align-items: center;
291
+ gap: var(--space-2xs);
292
+ padding: 0;
293
+ border: none;
294
+ background: none;
295
+ font: inherit;
296
+ color: inherit;
297
+ cursor: pointer;
298
+ }
299
+
300
+ .seal-chip-why-trigger:focus-visible,
301
+ .seal-chip-why-close:focus-visible {
302
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
303
+ outline-offset: var(--focus-ring-offset);
304
+ border-radius: var(--radius-sm);
305
+ }
306
+
307
+ /* The provenance one-liner. Plain text plus a visible close (Esc/X parity). */
308
+ .seal-chip-why-panel {
309
+ position: absolute;
310
+ top: 100%;
311
+ left: 0;
312
+ z-index: 1;
313
+ display: inline-flex;
314
+ align-items: flex-start;
315
+ gap: var(--space-xs);
316
+ margin-top: var(--space-2xs);
317
+ padding: var(--space-xs) var(--space-sm);
318
+ max-width: 32ch;
319
+ border: var(--elevation-border);
320
+ border-radius: var(--radius-md);
321
+ background: var(--color-surface-secondary);
322
+ color: var(--color-text-secondary);
323
+ font-family: var(--type-caption-font);
324
+ font-size: var(--type-caption-size);
325
+ font-weight: var(--type-caption-weight);
326
+ line-height: var(--type-caption-leading);
327
+ letter-spacing: var(--type-caption-tracking);
328
+ text-align: left;
329
+ }
330
+
331
+ .seal-chip-why-close {
332
+ padding: 0;
333
+ border: none;
334
+ background: none;
335
+ font: inherit;
336
+ line-height: 1;
337
+ color: inherit;
338
+ cursor: pointer;
339
+ }
340
+
118
341
  /*
119
342
  * sr-only — visually hidden but present in the DOM so the text travels
120
- * with the value when content is copied or cited (prd.md §5.1 #5).
121
- * clip-path: inset(50%) is the modern replacement for deprecated clip: rect().
343
+ * with the value when content is copied or cited: a citation carries the
344
+ * seal of the value it cites.
122
345
  */
123
346
  .seal-chip-sr-text {
124
347
  position: absolute;
@@ -6,36 +6,63 @@ type SealChip = {
6
6
  /**
7
7
  * SealChip
8
8
  *
9
- * Renders a value paired with its evidence seal as ONE phrase.
9
+ * The one seal renderer for every value surface — KPI registers, map badges,
10
+ * paths-strip columns, record lines, citations, saved projections, briefs.
11
+ * No surface renders a seal any other way.
10
12
  *
11
- * The seal must survive quotation: an sr-only span adjacent to the value
12
- * carries the evidence state word so copying or citing the value also
13
- * carries the seal (prd.md §5.1 #5: "a citation carries the seal of the
14
- * value it cites").
13
+ * THIS COMPONENT KNOWS NO SEAL WORDS. The design system owns the LAWS and a
14
+ * presentation tone scale; it does not own the vocabulary. Terms arrive as
15
+ * declared data — `{ value, label, tone }` resolved from ontology vocabulary
16
+ * rows at read time — so widening an axis or re-wording a term is a declaration
17
+ * change with zero code change and no package release.
15
18
  *
16
- * DS-owned evidence vocabulary: measured | inferred | projected.
17
- * Built over --badge-* + --seal-* component tokens.
19
+ * The laws it does enforce, all of which hold under every enumeration in the
20
+ * corpus:
21
+ * · two orthogonal axes, rendered as two adjacent chips, never one phrase
22
+ * · never label-alone — a seal never appears without its evidence chip
23
+ * · evidence alone is valid; probability alone is not
24
+ * · the seal rides the DATUM, not the styling — it is adjacent DOM text, so
25
+ * it survives re-render, copy, quotation and screen readers
26
+ * · a «why» is always one tap away, keyboard-reachable
18
27
  *
19
- * H1 Slice 2 — westeuropeco/atelier-urban-workspace#57.
28
+ * Value and seal read to a screen reader as ONE stitched phrase:
29
+ * «{value} — {evidence}[, {probability}][, stale]».
30
+ *
31
+ * `stale` is a separate orthogonal state, never a member of either vocabulary.
20
32
  *
21
- * @example Measured reading
22
- * <SealChip value="2.3 MW" evidence="measured" />
33
+ * H1 Slice 2 — westeuropeco/atelier-urban-workspace#57.
23
34
  *
24
- * @example Projected (future window)
25
- * <SealChip value="4.1 MW" evidence="projected" />
35
+ * @example A reading, using whatever terms the deployment declares
36
+ * <SealChip value="+14 cm/h" evidence={{ value: 'measured', label: 'medido', tone: 'positive' }} />
26
37
  *
27
- * @example Stale seal (horizon has passed)
28
- * <SealChip value="1.9 MW" evidence="projected" stale />
38
+ * @example Both axes, horizon passed, why one tap away
39
+ * <SealChip
40
+ * value="2,1 m"
41
+ * evidence={{ value: 'inferred', label: 'inferido', tone: 'info' }}
42
+ * probability={{ value: 'uncertain', label: 'incerto' }}
43
+ * stale
44
+ * why="the 16:00 window has already passed"
45
+ * />
29
46
  */
30
47
  declare const SealChip: import("svelte").Component<{
31
48
  value: any;
32
49
  evidence: any;
50
+ probability?: any;
33
51
  stale?: boolean;
52
+ why?: any;
53
+ whyLabel?: string;
54
+ closeLabel?: string;
55
+ staleLabel?: string;
34
56
  class?: string;
35
57
  } & Record<string, any>, {}, "">;
36
58
  type $$ComponentProps = {
37
59
  value: any;
38
60
  evidence: any;
61
+ probability?: any;
39
62
  stale?: boolean;
63
+ why?: any;
64
+ whyLabel?: string;
65
+ closeLabel?: string;
66
+ staleLabel?: string;
40
67
  class?: string;
41
68
  } & Record<string, any>;
@@ -16,7 +16,8 @@ export { default as Skeleton } from "./Skeleton.svelte";
16
16
  export { default as KeyValue } from "./KeyValue.svelte";
17
17
 
18
18
  // Evidence seal (H1 Slice 2 — westeuropeco/atelier-urban-workspace#57)
19
- // DS-owned evidence vocabulary: measured | inferred | projected
19
+ // These own the LAWS and a presentation tone scale. They own NO seal
20
+ // vocabulary: terms arrive as declared data resolved by resolveSeal().
20
21
  export { default as SealChip } from "./SealChip.svelte";
21
22
  export { default as KpiRegister } from "./KpiRegister.svelte";
22
23
  export { default as CaminhoStateChip } from "./CaminhoStateChip.svelte";
@@ -0,0 +1,35 @@
1
+ /**
2
+ * calculatePopoverPosition — pure positioning algorithm for Popover.svelte.
3
+ *
4
+ * Returns the fixed-position coordinate (x, y) for the popover, plus an
5
+ * optional maxHeight cap (in px) when neither the preferred nor the
6
+ * opposite side fits and we must scroll the popover content in-place.
7
+ *
8
+ * Preference order:
9
+ * 1. Preferred side (from `placement`) if it fits.
10
+ * 2. Opposite side if that fits.
11
+ * 3. Side with more room — capped to that room, overflow-y scrollable.
12
+ *
13
+ * @param {{ top: number, bottom: number, left: number, right: number, width: number }} anchorRect
14
+ * @param {{ width: number, height: number }} popRect
15
+ * @param {number} viewportH
16
+ * @param {number} viewportW
17
+ * @param {string} placement — 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end'
18
+ * @param {number} offset — gap between anchor and popover (px)
19
+ * @param {boolean} [matchWidth]
20
+ * @returns {{ x: number, y: number, maxHeight: number | undefined }}
21
+ */
22
+ export function calculatePopoverPosition(anchorRect: {
23
+ top: number;
24
+ bottom: number;
25
+ left: number;
26
+ right: number;
27
+ width: number;
28
+ }, popRect: {
29
+ width: number;
30
+ height: number;
31
+ }, viewportH: number, viewportW: number, placement: string, offset: number, matchWidth?: boolean): {
32
+ x: number;
33
+ y: number;
34
+ maxHeight: number | undefined;
35
+ };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * calculatePopoverPosition — pure positioning algorithm for Popover.svelte.
3
+ *
4
+ * Returns the fixed-position coordinate (x, y) for the popover, plus an
5
+ * optional maxHeight cap (in px) when neither the preferred nor the
6
+ * opposite side fits and we must scroll the popover content in-place.
7
+ *
8
+ * Preference order:
9
+ * 1. Preferred side (from `placement`) if it fits.
10
+ * 2. Opposite side if that fits.
11
+ * 3. Side with more room — capped to that room, overflow-y scrollable.
12
+ *
13
+ * @param {{ top: number, bottom: number, left: number, right: number, width: number }} anchorRect
14
+ * @param {{ width: number, height: number }} popRect
15
+ * @param {number} viewportH
16
+ * @param {number} viewportW
17
+ * @param {string} placement — 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end'
18
+ * @param {number} offset — gap between anchor and popover (px)
19
+ * @param {boolean} [matchWidth]
20
+ * @returns {{ x: number, y: number, maxHeight: number | undefined }}
21
+ */
22
+ export function calculatePopoverPosition(
23
+ anchorRect,
24
+ popRect,
25
+ viewportH,
26
+ viewportW,
27
+ placement,
28
+ offset,
29
+ matchWidth = false,
30
+ ) {
31
+ const isTop = placement.startsWith("top");
32
+ const isEnd = placement.endsWith("end");
33
+
34
+ // Horizontal: preserve matchWidth behaviour for end-aligned placement
35
+ let x = isEnd
36
+ ? anchorRect.right - (matchWidth ? anchorRect.width : popRect.width)
37
+ : anchorRect.left;
38
+
39
+ // Available room on each side (gap to viewport edge after the offset)
40
+ const roomAbove = anchorRect.top - offset;
41
+ const roomBelow = viewportH - anchorRect.bottom - offset;
42
+
43
+ const fitsAbove = popRect.height <= roomAbove;
44
+ const fitsBelow = popRect.height <= roomBelow;
45
+
46
+ let y;
47
+ let maxHeight = undefined;
48
+
49
+ if (isTop) {
50
+ if (fitsAbove) {
51
+ // Preferred side fits
52
+ y = anchorRect.top - popRect.height - offset;
53
+ } else if (fitsBelow) {
54
+ // Flip to below
55
+ y = anchorRect.bottom + offset;
56
+ } else {
57
+ // Neither fits: open on the side with more room and cap
58
+ if (roomAbove >= roomBelow) {
59
+ maxHeight = roomAbove;
60
+ y = anchorRect.top - maxHeight - offset; // = anchorRect.top - (anchorRect.top - offset) - offset = 0
61
+ } else {
62
+ y = anchorRect.bottom + offset;
63
+ maxHeight = roomBelow;
64
+ }
65
+ }
66
+ } else {
67
+ // Bottom placement
68
+ if (fitsBelow) {
69
+ // Preferred side fits
70
+ y = anchorRect.bottom + offset;
71
+ } else if (fitsAbove) {
72
+ // Flip to above
73
+ y = anchorRect.top - popRect.height - offset;
74
+ } else {
75
+ // Neither fits: open on the side with more room and cap
76
+ if (roomBelow >= roomAbove) {
77
+ y = anchorRect.bottom + offset;
78
+ maxHeight = roomBelow;
79
+ } else {
80
+ maxHeight = roomAbove;
81
+ y = anchorRect.top - maxHeight - offset; // = 0
82
+ }
83
+ }
84
+ }
85
+
86
+ // Clamp horizontal
87
+ if (x + popRect.width > viewportW) {
88
+ x = viewportW - popRect.width - offset;
89
+ }
90
+ if (x < 0) x = offset;
91
+
92
+ return { x, y, maxHeight };
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.50.0",
3
+ "version": "0.51.1",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -16,7 +16,7 @@
16
16
  "packages/widget-system"
17
17
  ],
18
18
  "scripts": {
19
- "build:types": "rm -f components/*.d.ts components/renderer/*.d.ts && svelte-package --input . --output dist && cp dist/components/*.d.ts components/ && cp dist/components/renderer/*.d.ts components/renderer/ && rm -rf dist",
19
+ "build:types": "rm -rf node_modules/@aiaiai-pt/design-system packages/widget-system/node_modules/@aiaiai-pt/design-system && rm -f components/*.d.ts components/renderer/*.d.ts && svelte-package --input . --output dist && cp dist/components/*.d.ts components/ && cp dist/components/renderer/*.d.ts components/renderer/ && rm -rf dist",
20
20
  "test": "vitest run",
21
21
  "prepublishOnly": "npm run build:types"
22
22
  },
package/tokens/base.css CHANGED
@@ -96,6 +96,10 @@
96
96
  --raw-font-size-18: 1.125rem;
97
97
  --raw-font-size-24: 1.5rem;
98
98
  --raw-font-size-36: 2.25rem;
99
+ /* Marketing steps. Application surfaces stop at 36; these exist for public
100
+ pages where the headline is the message, not a page title. */
101
+ --raw-font-size-48: 3rem;
102
+ --raw-font-size-64: 4rem;
99
103
 
100
104
  /* ─── Typography: Font Weights ─── */
101
105
  --raw-font-weight-regular: 400;
@@ -248,17 +248,23 @@
248
248
  --badge-neutral-bg: var(--color-surface-tertiary);
249
249
  --badge-neutral-text: var(--color-text-secondary);
250
250
 
251
- /* Evidence Seal — DS-owned vocabulary: measured | inferred | projected
251
+ /* Evidence Seal — TONES, not seal words.
252
252
  H1 Slice 2 — westeuropeco/atelier-urban-workspace#57.
253
+
254
+ The design system owns the presentation scale (positive / info / caution /
255
+ stale); it does NOT own the seal vocabulary. Which word wears which tone is
256
+ declared data resolved outside this package, so widening or re-wording an
257
+ axis is a declaration change and never a DS release.
258
+
253
259
  Defaults resolve via semantic color tokens so they work for every theme.
254
260
  UBP dark scheme overrides below in tokens/themes/ubp.css to maintain
255
261
  WCAG AA on the cool-steel dark surface (#0b182a). */
256
- --seal-measured-text: var(--color-success);
257
- --seal-measured-bg: var(--color-success-subtle);
258
- --seal-inferred-text: var(--color-info);
259
- --seal-inferred-bg: var(--color-info-subtle);
260
- --seal-projected-text: var(--color-warning);
261
- --seal-projected-bg: var(--color-warning-subtle);
262
+ --seal-positive-text: var(--color-success);
263
+ --seal-positive-bg: var(--color-success-subtle);
264
+ --seal-info-text: var(--color-info);
265
+ --seal-info-bg: var(--color-info-subtle);
266
+ --seal-caution-text: var(--color-warning);
267
+ --seal-caution-bg: var(--color-warning-subtle);
262
268
  /* stale modifier: muted regardless of base evidence state */
263
269
  --seal-stale-text: var(--color-text-muted);
264
270
  --seal-stale-bg: var(--color-surface-secondary);
@@ -63,7 +63,21 @@
63
63
  --font-sans: var(--raw-font-sans);
64
64
  --font-mono: var(--raw-font-mono);
65
65
 
66
- /* Type scale — categorical */
66
+ /* Type scale — categorical.
67
+ display-2xl / display-xl are MARKETING roles: public pages where the
68
+ headline is the message. Application surfaces start at `display`. */
69
+ --type-display-2xl-size: var(--raw-font-size-64);
70
+ --type-display-2xl-weight: var(--raw-font-weight-semibold);
71
+ --type-display-2xl-leading: var(--raw-line-height-tight);
72
+ --type-display-2xl-tracking: var(--raw-tracking-tight);
73
+ --type-display-2xl-font: var(--font-sans);
74
+
75
+ --type-display-xl-size: var(--raw-font-size-48);
76
+ --type-display-xl-weight: var(--raw-font-weight-semibold);
77
+ --type-display-xl-leading: var(--raw-line-height-tight);
78
+ --type-display-xl-tracking: var(--raw-tracking-tight);
79
+ --type-display-xl-font: var(--font-sans);
80
+
67
81
  --type-display-size: var(--raw-font-size-36);
68
82
  --type-display-weight: var(--raw-font-weight-semibold);
69
83
  --type-display-leading: var(--raw-line-height-tight);
@@ -149,12 +149,12 @@
149
149
  Contrast on subtle backgrounds (AA = 4.5:1 for small badge text):
150
150
  measured #256f20 (green-700) on #f0fdf4 (success-subtle): ~5.46:1 [AA]
151
151
  projected #9e6308 (amber-700) on #fffbeb (warning-subtle): ~4.79:1 [AA]
152
- --seal-inferred-text inherits --color-info (#2e63a3): 5.66:1 on
152
+ --seal-info-text inherits --color-info (#2e63a3): 5.66:1 on
153
153
  info-subtle (#f0f5fa) — no override needed.
154
154
  --seal-stale-text inherits --color-text-muted (≥3.0:1 non-essential).
155
155
  ═══════════════════════════════════════════════════════════════ */
156
- --seal-measured-text: #256f20; /* green-700 → 5.46:1 on success-subtle [AA] */
157
- --seal-projected-text: #9e6308; /* amber-700 → 4.79:1 on warning-subtle [AA] */
156
+ --seal-positive-text: #256f20; /* green-700 → 5.46:1 on success-subtle [AA] */
157
+ --seal-caution-text: #9e6308; /* amber-700 → 4.79:1 on warning-subtle [AA] */
158
158
  }
159
159
 
160
160
  /* Desktop density: compact button heights for toolbar/sidebar layouts.
@@ -254,12 +254,12 @@
254
254
  projected #fbbf24 (amber-400) lum ≈ 0.598 → ~11.14:1 [AAA]
255
255
  stale inherits --seal-stale-text: var(--color-text-muted) = #60758a (3.74:1)
256
256
  ═══════════════════════════════════════════════════════════════ */
257
- --seal-measured-text: #4ade80;
258
- --seal-measured-bg: color-mix(in srgb, #4ade80 12%, #0b182a);
259
- --seal-inferred-text: #60a5fa;
260
- --seal-inferred-bg: color-mix(in srgb, #60a5fa 12%, #0b182a);
261
- --seal-projected-text: #fbbf24;
262
- --seal-projected-bg: color-mix(in srgb, #fbbf24 12%, #0b182a);
257
+ --seal-positive-text: #4ade80;
258
+ --seal-positive-bg: color-mix(in srgb, #4ade80 12%, #0b182a);
259
+ --seal-info-text: #60a5fa;
260
+ --seal-info-bg: color-mix(in srgb, #60a5fa 12%, #0b182a);
261
+ --seal-caution-text: #fbbf24;
262
+ --seal-caution-bg: color-mix(in srgb, #fbbf24 12%, #0b182a);
263
263
  /* stale: inherits --seal-stale-text (--color-text-muted = #60758a, 3.74:1 non-essential)
264
264
  and --seal-stale-bg (--color-surface-secondary = #1a2a3a) */
265
265
  }
@@ -984,6 +984,36 @@
984
984
  Instrument Sans is the quiet backdrop for prose.
985
985
  ═══════════════════════════════════════════════ */
986
986
 
987
+ /* Marketing roles — public pages where the headline is the message.
988
+ Application surfaces start at .type-display. Both step down one role at
989
+ narrow viewports, which holds a headline near its intended line count
990
+ rather than doubling it. */
991
+ .type-display-2xl {
992
+ font-family: var(--type-display-2xl-font);
993
+ font-size: var(--type-display-2xl-size);
994
+ font-weight: var(--type-display-2xl-weight);
995
+ line-height: var(--type-display-2xl-leading);
996
+ letter-spacing: var(--type-display-2xl-tracking);
997
+ }
998
+
999
+ .type-display-xl {
1000
+ font-family: var(--type-display-xl-font);
1001
+ font-size: var(--type-display-xl-size);
1002
+ font-weight: var(--type-display-xl-weight);
1003
+ line-height: var(--type-display-xl-leading);
1004
+ letter-spacing: var(--type-display-xl-tracking);
1005
+ }
1006
+
1007
+ @media (max-width: 900px) {
1008
+ .type-display-2xl {
1009
+ font-size: var(--type-display-xl-size);
1010
+ }
1011
+
1012
+ .type-display-xl {
1013
+ font-size: var(--type-display-size);
1014
+ }
1015
+ }
1016
+
987
1017
  .type-display {
988
1018
  font-family: var(--type-display-font);
989
1019
  font-size: var(--type-display-size);