@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.
- package/components/KpiRegister.svelte +116 -73
- package/components/KpiRegister.svelte.d.ts +33 -22
- package/components/Popover.svelte +15 -18
- package/components/Popover.svelte.d.ts +1 -0
- package/components/SealChip.svelte +275 -52
- package/components/SealChip.svelte.d.ts +41 -14
- package/components/index.js +2 -1
- package/components/popover-position.d.ts +35 -0
- package/components/popover-position.js +93 -0
- package/package.json +2 -2
- package/tokens/base.css +4 -0
- package/tokens/components.css +13 -7
- package/tokens/semantic.css +15 -1
- package/tokens/themes/ubp.css +9 -9
- package/tokens/utilities.css +30 -0
|
@@ -1,113 +1,155 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component KpiRegister
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
in
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
21
|
+
Uses --type-overline-* tokens (sistema#67) for the overline label.
|
|
16
22
|
|
|
17
|
-
@example
|
|
18
|
-
<KpiRegister label="
|
|
23
|
+
@example A reading alone
|
|
24
|
+
<KpiRegister label="WATER LEVEL" primaryValue="+14 cm/h" />
|
|
19
25
|
|
|
20
|
-
@example
|
|
26
|
+
@example Reading above a sealed forecast, why one tap away
|
|
21
27
|
<KpiRegister
|
|
22
|
-
label="
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 {'
|
|
33
|
-
* @typedef {
|
|
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
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
|
|
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 {
|
|
45
|
-
* Seal
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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]
|
|
78
|
-
'
|
|
79
|
-
'
|
|
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
|
-
|
|
94
|
-
|
|
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-
|
|
97
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
|
106
|
-
<div class="kpi-register-
|
|
145
|
+
{#if secondaryValue !== undefined && secondarySeal != null}
|
|
146
|
+
<div class="kpi-register-secondary">
|
|
107
147
|
<SealChip
|
|
108
|
-
value={
|
|
109
|
-
evidence={
|
|
110
|
-
|
|
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
|
|
130
|
-
*
|
|
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
|
-
/*
|
|
143
|
-
.kpi-register-
|
|
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
|
-
/*
|
|
153
|
-
.kpi-register-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* in
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
26
|
+
* Uses --type-overline-* tokens (sistema#67) for the overline label.
|
|
21
27
|
*
|
|
22
|
-
* @example
|
|
23
|
-
* <KpiRegister label="
|
|
28
|
+
* @example A reading alone
|
|
29
|
+
* <KpiRegister label="WATER LEVEL" primaryValue="+14 cm/h" />
|
|
24
30
|
*
|
|
25
|
-
* @example
|
|
31
|
+
* @example Reading above a sealed forecast, why one tap away
|
|
26
32
|
* <KpiRegister
|
|
27
|
-
* label="
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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,70 +1,221 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component SealChip
|
|
3
3
|
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
<SealChip value="2.3 MW" evidence="measured" />
|
|
28
|
+
H1 Slice 2 — westeuropeco/atelier-urban-workspace#57.
|
|
18
29
|
|
|
19
|
-
@example
|
|
20
|
-
<SealChip value="
|
|
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
|
|
23
|
-
<SealChip
|
|
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 {'
|
|
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
|
-
/**
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
161
|
+
const panelId = $props.id();
|
|
49
162
|
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
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"> {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
|
|
79
|
-
.seal-chip-value {
|
|
80
|
-
/* intentionally empty */
|
|
81
|
-
}
|
|
229
|
+
/* Value text carries no override — it reads as its surrounding type. */
|
|
82
230
|
|
|
83
|
-
/*
|
|
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
|
-
/*
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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-
|
|
108
|
-
color: var(--seal-
|
|
109
|
-
background: var(--seal-
|
|
261
|
+
.seal-tone-info {
|
|
262
|
+
color: var(--seal-info-text);
|
|
263
|
+
background: var(--seal-info-bg);
|
|
110
264
|
}
|
|
111
265
|
|
|
112
|
-
|
|
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
|
|
121
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
22
|
-
* <SealChip value="2.3 MW" evidence="measured" />
|
|
33
|
+
* H1 Slice 2 — westeuropeco/atelier-urban-workspace#57.
|
|
23
34
|
*
|
|
24
|
-
* @example
|
|
25
|
-
* <SealChip value="
|
|
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
|
|
28
|
-
* <SealChip
|
|
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>;
|
package/components/index.js
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
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;
|
package/tokens/components.css
CHANGED
|
@@ -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 —
|
|
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-
|
|
257
|
-
--seal-
|
|
258
|
-
--seal-
|
|
259
|
-
--seal-
|
|
260
|
-
--seal-
|
|
261
|
-
--seal-
|
|
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);
|
package/tokens/semantic.css
CHANGED
|
@@ -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);
|
package/tokens/themes/ubp.css
CHANGED
|
@@ -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-
|
|
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-
|
|
157
|
-
--seal-
|
|
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-
|
|
258
|
-
--seal-
|
|
259
|
-
--seal-
|
|
260
|
-
--seal-
|
|
261
|
-
--seal-
|
|
262
|
-
--seal-
|
|
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
|
}
|
package/tokens/utilities.css
CHANGED
|
@@ -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);
|