@humanforest/ui 0.1.2 → 0.1.3

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.
@@ -23,7 +23,7 @@
23
23
  * Button: keycap voice — rounder (lg), 2px edge, deeper lip with a bigger hover lift (rest −3px,
24
24
  * hover −5px; web md is −2/−3). Labels stay GT Haptik: Mohr is for headlines — the italic-Mohr
25
25
  * button label is the MOBILE voice, not marketing's. The base classes below re-voice the
26
- * edge/lip-bearing rungs (keycap, pad, framed, gloss, subtle, outline — they read --btn-edge /
26
+ * edge/lip-bearing rungs (keycap, pad, framed, subtle, outline — they read --btn-edge /
27
27
  * --btn-y*); the flat rungs (solid, soft, ghost, link) read neither and share the restrained web
28
28
  * rendering — bold delivery rides on the keycap/lg prop default (CONTEXTS-SPEC.md §6).
29
29
  *
@@ -10,6 +10,7 @@
10
10
  // where the is/is-any-of promotion lives.
11
11
  import { computed, onMounted, ref, shallowRef } from 'vue';
12
12
  import { CalendarDate } from '@internationalized/date';
13
+ import type { DateRange, DateValue } from 'reka-ui';
13
14
  import { filterTheme, type FilterSlot } from './filter.theme';
14
15
  import { OPERATOR_LABEL, operatorsFor, type FilterSchema, type Operator } from './filterSchema';
15
16
  import { isComplete, summariseClause, type Clause, type RelativeUnit } from './filterClause';
@@ -149,18 +150,35 @@ const onDateOpen = (open: boolean) => {
149
150
  if (open) dateKey.value += 1;
150
151
  };
151
152
 
152
- const onRangePicked = (range: { start?: CalendarDate; end?: CalendarDate } | undefined) => {
153
- if (!range?.start || !range.end) return;
153
+ /*
154
+ THESE HANDLERS TAKE WHAT UCalendar EMITS, NOT WHAT THIS COMPONENT WANTS. They used to be typed
155
+ as the narrow shape the body reads — `{ start?: CalendarDate; end?: CalendarDate }` — which is a
156
+ SUBSET of the emitted `DateRange | null`, and a handler cannot be narrower than its emit. It type-
157
+ checked in this repo (nothing here typechecks packages/ui) and failed in every consumer's own
158
+ `vue-tsc`, against source they cannot edit.
159
+
160
+ So the parameter is the emitted type and the narrowing is explicit. `instanceof` rather than a
161
+ cast: the calendar can hand back a CalendarDateTime or a ZonedDateTime, and calendarToIso is only
162
+ correct for a plain date.
163
+ */
164
+ const asCalendarDate = (value: unknown): CalendarDate | null =>
165
+ value instanceof CalendarDate ? value : null;
166
+
167
+ const onRangePicked = (range: DateRange | null | undefined) => {
168
+ const start = asCalendarDate(range?.start);
169
+ const end = asCalendarDate(range?.end);
170
+ if (!start || !end) return;
154
171
  emit('update', {
155
- value: [calendarToIso(range.start), calendarToIso(range.end)] as [string | null, string | null],
172
+ value: [calendarToIso(start), calendarToIso(end)] as [string | null, string | null],
156
173
  });
157
174
  dateOpen.value = false;
158
175
  };
159
176
 
160
177
  /** A single date is complete the moment it is picked, so the calendar closes behind it. */
161
- const onDatePicked = (picked: CalendarDate | undefined) => {
162
- if (!picked) return;
163
- emit('update', { value: calendarToIso(picked) });
178
+ const onDatePicked = (picked: DateValue | DateValue[] | DateRange | null | undefined) => {
179
+ const date = asCalendarDate(picked);
180
+ if (!date) return;
181
+ emit('update', { value: calendarToIso(date) });
164
182
  dateOpen.value = false;
165
183
  };
166
184
 
package/src/kpi/FKpi.vue CHANGED
@@ -9,7 +9,7 @@
9
9
  // registers itself, spans four subgrid rows, and may become a tab or a toggle.
10
10
  import { computed, inject, onBeforeUnmount, onMounted, useId, useSlots } from 'vue';
11
11
  import { kpiTheme, KPI_GROUP_KEY, type KpiSlot } from './kpi.theme';
12
- import { formatMetric, type KpiFormat } from './kpiFormat';
12
+ import { formatMetric, joinCaption, type KpiFormat } from './kpiFormat';
13
13
  import { resolveDelta, type KpiDelta, type KpiSentiment } from './kpiDelta';
14
14
 
15
15
  const props = withDefaults(
@@ -117,16 +117,6 @@ const delta = computed(() =>
117
117
  }),
118
118
  );
119
119
 
120
- // Period first, then the baseline it was measured against — "last 7 days · vs previous 7 days".
121
- // The other order reads as one repeated phrase.
122
- const captionParts = computed(() =>
123
- [
124
- props.period,
125
- props.delta ? `vs ${props.delta.comparisonLabel}` : undefined,
126
- props.partial ? 'Period in progress' : undefined,
127
- ].filter(Boolean),
128
- );
129
-
130
120
  const asOfText = computed(() => {
131
121
  if (!props.asOf) return undefined;
132
122
  const d = props.asOf instanceof Date ? props.asOf : new Date(props.asOf);
@@ -135,6 +125,21 @@ const asOfText = computed(() => {
135
125
  : `Updated ${new Intl.DateTimeFormat(props.locale ?? 'en-GB', { timeStyle: 'short' }).format(d)}`;
136
126
  });
137
127
 
128
+ // Period first, then the baseline it was measured against — "last 7 days · vs previous 7 days".
129
+ // The other order reads as one repeated phrase. `asOf` joins the SAME run so it gets a separator for
130
+ // free; the caveat is the only part kept as its own element, because aria-describedby needs an id to
131
+ // point at and a run of text cannot carry one.
132
+ const captionLead = computed(() =>
133
+ isBlank.value && props.reason
134
+ ? REASON_COPY[props.reason]
135
+ : joinCaption(
136
+ props.period,
137
+ props.delta && `vs ${props.delta.comparisonLabel}`,
138
+ props.partial && 'Period in progress',
139
+ asOfText.value,
140
+ ),
141
+ );
142
+
138
143
  const caveatId = computed(() => (props.caveat ? `${uid}-caveat` : undefined));
139
144
 
140
145
  const styles = computed(() =>
@@ -235,12 +240,8 @@ const onKeydown = (e: KeyboardEvent) => {
235
240
  </dd>
236
241
 
237
242
  <p :class="cls('caption')">
238
- <slot name="caption">
239
- <span v-if="isBlank && reason">{{ REASON_COPY[reason] }}</span>
240
- <span v-else>{{ captionParts.join(' · ') }}</span>
241
- </slot>
242
- <span v-if="asOfText" class="ms-1">{{ asOfText }}</span>
243
- <span v-if="caveat" :id="caveatId" class="ms-1">{{ caveat }}</span>
243
+ <slot name="caption">{{ captionLead }}</slot>
244
+ <span v-if="caveat" :id="caveatId">{{ captionLead ? ' · ' : '' }}{{ caveat }}</span>
244
245
  </p>
245
246
 
246
247
  <div v-if="$slots.visual" :class="cls('visual')"><slot name="visual" /></div>
@@ -133,7 +133,7 @@ const gridStyle = computed(() =>
133
133
  :aria-orientation="selectable && !multiple ? orientation : undefined"
134
134
  class="grid grid-rows-[auto_auto_auto_auto] items-stretch"
135
135
  :class="[
136
- divided ? 'gap-px bg-[var(--ui-border)]' : 'gap-4',
136
+ divided ? 'gap-px' : 'gap-4',
137
137
  // Ring outset, not inset: `divided` rules are this element's background through 1px gaps,
138
138
  // so the tiles sit flush to its border box and an inset stroke lands on them.
139
139
  divided && !flush && 'overflow-hidden rounded-lg ring-1 ring-default',
@@ -143,13 +143,21 @@ const gridStyle = computed(() =>
143
143
  >
144
144
  <!-- The dl is display:contents so the tiles join the grid directly, which also means it is
145
145
  the group's ONLY element child — a `divide-x` on the grid would target the dl and never
146
- reach a tile. The hairlines are the grid's own 1px gaps showing the container's colour
147
- through, which is the one treatment that stays correct when the row wraps: a `divide-*`
148
- rule would draw a stray edge on the first tile of every wrapped row.
146
+ reach a tile. A `divide-*` rule is wrong for a second reason too: it would draw a stray
147
+ edge on the first tile of every wrapped row.
149
148
 
150
- That colour is --ui-border, not bg-accented: these gaps read as the same rules as the
151
- ring around the row, and accented sits several steps lighter, so the separators inside
152
- the group came out brighter than the border enclosing them. -->
149
+ ★★ THE HAIRLINES ARE THE TILES' OWN RINGS MEETING IN THE 1px GAP, not this element's
150
+ background showing through. They were the background until a four-tile group on three
151
+ columns painted the two leftover cells as a filled grey slab a ~460x130px rectangle at
152
+ every width from 768 to 1100px, iPad landscape included. A background cannot tell a seam
153
+ from an empty cell; a ring on the tile only exists where a tile does.
154
+
155
+ Tailwind's ring is an OUTSET box-shadow, so two neighbours each cast 1px into the same
156
+ 1px gap and the seam is the same ink and the same geometry as before — `ring-default` is
157
+ --ui-border, which is what this comment used to insist the colour had to be: the gaps read
158
+ as the same rules as the ring around the row, where `bg-accented` sits several steps
159
+ lighter and came out brighter than the border enclosing it. `overflow-hidden` clips the
160
+ outer tiles' rings, so the perimeter stays the group's own single ring and never doubles. -->
153
161
  <dl class="contents">
154
162
  <slot />
155
163
  </dl>
@@ -54,10 +54,14 @@ export const kpiTheme = tv({
54
54
  // radius. Keeping the radius would round each segment's fill inside a joined strip and leave
55
55
  // the selected tile's underline looking detached at both ends; the group's own container
56
56
  // supplies the outer corners.
57
- // The tile stays OPAQUE: the group's hairlines are its own 1px grid gaps showing the container
58
- // colour through, so a transparent tile would leak that colour across the whole strip.
57
+ // The tile stays OPAQUE, and it draws its OWN seam: `ring-1 ring-default` is an outset
58
+ // box-shadow, so two neighbours each cast 1px into the group's 1px gap and meet as one hairline.
59
+ // The group used to paint --ui-border edge to edge and let the gaps show it through, which drew
60
+ // the same seam but could not tell a seam from an empty grid cell — four tiles on three columns
61
+ // left the two leftovers as a filled grey slab. Ink that lives on the tile exists only where a
62
+ // tile does. See FKpiGroup.vue.
59
63
  naked: {
60
- true: { root: 'forest-kpi-joined bg-default shadow-none ring-0' },
64
+ true: { root: 'forest-kpi-joined bg-default shadow-none ring-1 ring-default' },
61
65
  },
62
66
  /**
63
67
  * How the `visual` slot meets the card. Two graphics, opposite needs, and the slot used to
@@ -133,7 +137,11 @@ export const kpiTheme = tv({
133
137
  naked: true,
134
138
  selected: true,
135
139
  class: {
136
- root: 'ring-0 bg-elevated after:absolute after:inset-x-0 after:-bottom-px after:h-0.5 after:bg-primary',
140
+ // ⚠ `ring-1 ring-default` AND NOT `ring-0`: the seam now lives on the tile, so cancelling
141
+ // the ring outright would punch a gap in the strip's hairlines around the selected segment.
142
+ // Both halves are needed — a bare `ring-1` loses the width fight but keeps `selected`'s
143
+ // primary colour, leaving a coloured seam.
144
+ root: 'ring-1 ring-default bg-elevated after:absolute after:inset-x-0 after:-bottom-px after:h-0.5 after:bg-primary',
137
145
  },
138
146
  },
139
147
  // Surface-painting reaches for the tinted card variants. There is no red accent, so a critical
@@ -70,3 +70,17 @@ export const formatPercent = (
70
70
  minimumFractionDigits: 0,
71
71
  maximumFractionDigits: precision,
72
72
  }).format(fraction);
73
+
74
+ /**
75
+ * The caption's parts in reading order, joined by the tile's separator.
76
+ *
77
+ * ★★ EVERY PART GOES THROUGH HERE, which is the point. FKpi used to join three parts with ' · ' and
78
+ * then append `asOf` and `caveat` as bare sibling spans carrying only a 4px margin — so a tile given
79
+ * both a comparison label and a caveat rendered "vs previous quarterVerified with our partners", with
80
+ * no separator at all. The margin was never the fix: Vue's whitespace condense emits no text node
81
+ * between sibling spans, so `textContent` ran the words together and assistive tech read one word.
82
+ *
83
+ * Falsy parts drop out, so a tile carrying one part gets no stray leading or trailing dot.
84
+ */
85
+ export const joinCaption = (...parts: (string | false | null | undefined)[]): string =>
86
+ parts.filter((p): p is string => !!p).join(' · ');
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // Variants: lockup (lead + keycap) · badge (keycap alone) · icon (square, first letter) · joined
11
11
  // (lead + sub-brand butted on as one word).
12
- import { computed } from 'vue';
12
+ import { computed, useId } from 'vue';
13
13
  import { WORDMARK_PATHS, WORDMARK_W, WORDMARK_H } from '@humanforest/tokens/logo';
14
14
  import { setWord, runInk } from '@humanforest/tokens/glyphs';
15
15
  import {
@@ -91,7 +91,14 @@ const rect = computed(() => {
91
91
 
92
92
  // One clip per instance trims the flat ascenders. The id must be unique per mark — two lockups on a
93
93
  // page would otherwise share a def, and a downloaded file has to carry its own.
94
- const clipId = `fsub-cut-${++uid}`;
94
+ //
95
+ // ★★ useId(), NOT A MODULE COUNTER. Module state lives for the whole SSR process, so request N emits
96
+ // `fsub-cut-N` while a freshly hydrating client always starts at 1. The server's markup is
97
+ // internally consistent, so the mark looks right and only a hydration warning fires — but the client
98
+ // vnode holds an id the document does not, and the first `variant` change to mount a new glyph group
99
+ // points it at `url(#fsub-cut-1)`. That def does not exist, the ascender trim silently stops, and
100
+ // GT's flat ascenders come back — which is the entire reason this clip exists.
101
+ const clipId = `fsub-cut-${useId()}`;
95
102
  // the trim line: the t's ink top, which is where the drawn f stops
96
103
  const CUT_Y = setWord('t', { font: 'gt', size: SUBLOGO_GT_SIZE, baseline: SUBLOGO_BASELINE }).cutY;
97
104
 
@@ -127,11 +134,6 @@ const vars = computed(() => ({
127
134
  const label = computed(() => `${props.lead} ${word.value}`);
128
135
  </script>
129
136
 
130
- <script lang="ts">
131
- // module-scoped so every instance on a page gets its own clip id
132
- let uid = 0;
133
- </script>
134
-
135
137
  <template>
136
138
  <svg
137
139
  class="fsub" :viewBox="viewBox" :style="vars"
@@ -1,13 +1,3 @@
1
- <script lang="ts">
2
- /**
3
- * ⚠ MODULE SCOPE, deliberately. `<script setup>` runs once PER INSTANCE, so a counter declared there resets
4
- * to the same value for every mark — every instance would take the id `fvm1`, and the gradient collision this
5
- * prefix exists to prevent would be back with extra steps.
6
- */
7
- let seq = 0;
8
- const nextUid = () => `fvm${(seq += 1)}`;
9
- </script>
10
-
11
1
  <script setup lang="ts">
12
2
  // FVehicleMark — one vehicle, drawn from its facts. NOT a map component.
13
3
  //
@@ -37,7 +27,7 @@ const nextUid = () => `fvm${(seq += 1)}`;
37
27
  // <FMarker :lng-lat="v.at" selected interactive><FVehicleMark v-bind="v.facts" /></FMarker>
38
28
  // Usage anywhere else:
39
29
  // <FVehicleMark state="FUNCTIONAL" battery="LOW" :size="28" />
40
- import { computed } from 'vue';
30
+ import { computed, useId } from 'vue';
41
31
  import { MARKER_SVG } from './markerAsset';
42
32
  import manifest from '../../icons/forest/map/manifest.json';
43
33
  import {
@@ -114,7 +104,11 @@ const colour = (t: string) => resolveMapColor(`var(${t})`);
114
104
  * icon is its own document, and broken here: two of these on one page would both resolve `url(#inUse)` to the
115
105
  * first one's stops, so a list of vehicles would show the first row's gradient on every row.
116
106
  */
117
- const uid = nextUid();
107
+ // ★★ useId() AND NOT A MODULE COUNTER. A module-scope counter does give every instance a distinct
108
+ // id, which is what this needs — but it never resets under SSR, so the server writes `fvm7` into the
109
+ // v-html'd asset where the hydrating client generates `fvm1`. useId() is stable across the pair and
110
+ // still unique per instance, which is the whole requirement.
111
+ const uid = useId();
118
112
 
119
113
  const artwork = computed(() => {
120
114
  void tokenMode.value;
@@ -98,22 +98,29 @@ export const SOLID = [
98
98
  FOCUS,
99
99
  ].join(' ');
100
100
 
101
- export const GLOSS = [
102
- // Web-only "gloss" emphasis framed's lip outline with a line of light ONE pixel inside it along the
103
- // top (the shine sits below the border, over the face — never on the border itself). The line is the
104
- // face's OWN colour lightened (oklch l + --btn-shine-l, default 0.25), not white, so the sheen is
105
- // on-brand per intent and works in both modes. Neutral's near-black face needs a bigger lift to read
106
- // (PAL_NEUTRAL bumps --btn-shine-l to 0.5 in light; dark stays 0.25 its near-white face would clip).
107
- // One box-shadow, two inset layers: layer 1 (FRONT) the lip outline, --btn-edge
108
- // wide; layer 2 (BEHIND) a light band offset calc(edge + 1px), so the outline covers its top `edge` px
109
- // and only the next 1px shows. Both inset → footprint identical to framed.
110
- 'border-0 text-[var(--btn-ink)] bg-[var(--btn-face)]',
111
- 'shadow-[inset_0_0_0_var(--btn-edge,1px)_var(--btn-lip),inset_0_calc(var(--btn-edge,1px)_+_1px)_0_0_oklch(from_var(--btn-face)_calc(l_+_var(--btn-shine-l,0.25))_c_h)]',
112
- // hover/press shift --btn-face itself (not just bg), so BOTH the fill and the shine (derived from
113
- // --btn-face) track the current state the line stays +0.25 lighter than whatever face is showing.
114
- 'hover:[--btn-face:var(--btn-hover)] active:[--btn-face:var(--btn-press)]',
115
- FOCUS,
116
- ].join(' ');
101
+ /*
102
+ GLOSS IS GONE, AND IT IS WORTH KNOWING WHY BEFORE BRINGING IT BACK.
103
+
104
+ It was a web-only emphasis: framed's lip outline with a line of light one pixel inside the top,
105
+ drawn as the face's OWN colour lightened in-gamut
106
+ oklch(from var(--btn-face) calc(l + var(--btn-shine-l,0.25)) c h)
107
+ so the sheen stayed on-brand per intent and worked in both modes. Switched off in d6e99218
108
+ (2026-07-08) and never switched back, which left two faults standing:
109
+
110
+ · `variant: { gloss: '' }` stayed in the enum with no compound to style it, so
111
+ <UButton variant="gloss"> type-checked, rendered, and looked like nothing at all.
112
+ · it was the ONLY relative-colour construct in the shipped surface, and postcss-calc's Jison
113
+ grammar has no term for a bare channel keyword so every consumer's production build logged
114
+ Lexical error on line 1: Unrecognized text … l + var(--btn-shine-l,.25)
115
+ The declaration survived into the CSS, so it was cosmetic; it also fails any build treating
116
+ warnings as errors, and it fired for everyone.
117
+
118
+ REVIVING IT: the arithmetic is load-bearing and must stay channel arithmetic — `color-mix(… white)`
119
+ drags chroma to zero and desaturates the line. To keep the warning away, write the sum as
120
+ `max(l + var(--btn-shine-l,0.25), 0)`: measured identical to the last decimal in Chromium across a
121
+ dark face, neutral-900 at shine 0.5 and neutral-50, because L can never go under 0 — it simply
122
+ drops the `calc` token the lexer chokes on.
123
+ */
117
124
 
118
125
  export const SUBTLE = [
119
126
  // A ramp-pinned INSET RING (a box-shadow, not a border → no added height) over a plain surface fill.
@@ -184,7 +191,10 @@ export const PAL_SECONDARY = '[--btn-face:var(--warm-500)] dark:[--btn-face:var(
184
191
  // outline hover edge stays monochrome instead of leaking brand green via the shared --btn-ring.
185
192
  // Rest --btn-line is 300/700 (not the ramp's 400/600) to match UInput's resting border
186
193
  // (ring-accented → --ui-border-accented = neutral-300/700), so a neutral button sits flush with a field.
187
- export const PAL_NEUTRAL = '[--btn-face:var(--neutral-900)] dark:[--btn-face:var(--neutral-50)] [--btn-hover:var(--neutral-800)] dark:[--btn-hover:var(--neutral-100)] [--btn-press:var(--neutral-700)] dark:[--btn-press:var(--neutral-200)] [--btn-lip:var(--neutral-400)] dark:[--btn-lip:var(--neutral-300)] [--btn-ring:var(--neutral-500)] [--btn-t1:var(--neutral-50)] dark:[--btn-t1:var(--neutral-950)] [--btn-t2:var(--neutral-100)] dark:[--btn-t2:var(--neutral-900)] [--btn-t3:var(--neutral-200)] dark:[--btn-t3:var(--neutral-800)] [--btn-line:var(--neutral-300)] dark:[--btn-line:var(--neutral-700)] [--btn-text:var(--neutral-900)] dark:[--btn-text:var(--neutral-100)] [--btn-ink:var(--neutral-0)] dark:[--btn-ink:var(--neutral-900)] [--btn-shine-l:0.5] dark:[--btn-shine-l:0.25]';
194
+ // --btn-shine-l went with GLOSS: neutral's near-black face needed a bigger lift than 0.25 to read,
195
+ // and nothing reads the variable now. It was still emitting two dead utility classes into every
196
+ // consumer's stylesheet. Restore both halves (0.5 light / 0.25 dark) if gloss ever comes back.
197
+ export const PAL_NEUTRAL = '[--btn-face:var(--neutral-900)] dark:[--btn-face:var(--neutral-50)] [--btn-hover:var(--neutral-800)] dark:[--btn-hover:var(--neutral-100)] [--btn-press:var(--neutral-700)] dark:[--btn-press:var(--neutral-200)] [--btn-lip:var(--neutral-400)] dark:[--btn-lip:var(--neutral-300)] [--btn-ring:var(--neutral-500)] [--btn-t1:var(--neutral-50)] dark:[--btn-t1:var(--neutral-950)] [--btn-t2:var(--neutral-100)] dark:[--btn-t2:var(--neutral-900)] [--btn-t3:var(--neutral-200)] dark:[--btn-t3:var(--neutral-800)] [--btn-line:var(--neutral-300)] dark:[--btn-line:var(--neutral-700)] [--btn-text:var(--neutral-900)] dark:[--btn-text:var(--neutral-100)] [--btn-ink:var(--neutral-0)] dark:[--btn-ink:var(--neutral-900)]';
188
198
 
189
199
  // All seven intents share the colour-keyed solid/flat/outline/soft/ghost classes.
190
200
  export const ALL = ['primary', 'secondary', 'success', 'info', 'warning', 'error', 'neutral'];
@@ -70,7 +70,6 @@ export const buttonTheme = {
70
70
  keycap: '',
71
71
  pad: '',
72
72
  framed: '',
73
- gloss: '',
74
73
  subtle: '',
75
74
  link: '',
76
75
  },
@@ -114,7 +113,6 @@ export const buttonTheme = {
114
113
  ...emphasis('keycap', KEYCAP),
115
114
  ...emphasis('pad', PAD),
116
115
  ...emphasis('framed', FRAMED),
117
- // ...emphasis('gloss', GLOSS),
118
116
  ...emphasis('solid', SOLID),
119
117
  ...emphasis('subtle', SUBTLE),
120
118
  ...emphasis('soft', SOFT),