@humanforest/nuxt-layer 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/nuxt.config.ts +3 -2
- package/package.json +1 -1
- package/packages/charts/src/FCalendarGrid.vue +1 -1
- package/packages/charts/src/FDistributionBar.vue +1 -1
- package/packages/charts/src/FDottedMap.vue +3 -1
- package/packages/charts/src/FLondonMap.vue +1 -1
- package/packages/charts/src/FSparkline.vue +11 -2
- package/packages/charts/src/FStatusTrack.vue +1 -1
- package/packages/ui/src/contexts/marketing-theme.ts +1 -1
- package/packages/ui/src/filter/FFilterChip.vue +24 -6
- package/packages/ui/src/kpi/FKpi.vue +18 -17
- package/packages/ui/src/kpi/FKpiGroup.vue +15 -7
- package/packages/ui/src/kpi/kpi.theme.ts +12 -4
- package/packages/ui/src/kpi/kpiFormat.ts +14 -0
- package/packages/ui/src/logo/FSubLogo.vue +9 -7
- package/packages/ui/src/map/FVehicleMark.vue +6 -12
- package/packages/ui/src/themes/button.mechanics.ts +27 -17
- package/packages/ui/src/themes/button.ts +0 -2
- package/packages/ui/src/themes/checkbox.ts +34 -16
- package/packages/ui/src/themes/index.ts +2 -0
- package/packages/ui/src/themes/progress-group.ts +39 -0
- package/packages/ui/src/themes/splitter.ts +33 -0
- package/theme.gen.json +1 -1
package/nuxt.config.ts
CHANGED
|
@@ -42,8 +42,9 @@ export default defineNuxtConfig({
|
|
|
42
42
|
{ path: join(currentDir, 'packages/ui/src'), pathPrefix: false, pattern: '**/*.vue' },
|
|
43
43
|
{ path: join(currentDir, 'packages/charts/src'), pathPrefix: false, pattern: '**/*.vue' },
|
|
44
44
|
// Presentation frames (FDeviceFrame / FBrowserFrame / FFitFrame). They stage a prototype rather
|
|
45
|
-
// than appear in one, so they carry CC BY-SA device art
|
|
46
|
-
//
|
|
45
|
+
// than appear in one, so they carry CC BY-SA device art. That art travels — @humanforest/frames
|
|
46
|
+
// is on public npm and its tarball carries the 25 renders, LICENSE and ATTRIBUTION.md, which is
|
|
47
|
+
// the licence's own condition rather than a courtesy. See packages/frames/ATTRIBUTION.md.
|
|
47
48
|
{ path: join(currentDir, 'packages/frames/src'), pathPrefix: false, pattern: '**/*.vue' }
|
|
48
49
|
],
|
|
49
50
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanforest/nuxt-layer",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"description": "Forest design system as a Nuxt layer — extend it to inherit the theme, colour roles, icons and brand fonts in one line.",
|
|
6
6
|
"main": "./nuxt.config.ts",
|
|
7
7
|
"files": [
|
|
@@ -269,7 +269,7 @@ const hasEmpty = computed(() =>
|
|
|
269
269
|
>{{ m.label }}</span>
|
|
270
270
|
</div>
|
|
271
271
|
|
|
272
|
-
<UTooltip :text="tip.text" :reference="tip.el" :open="tipOpen">
|
|
272
|
+
<UTooltip :text="tip.text" :reference="tip.el ?? undefined" :open="tipOpen">
|
|
273
273
|
<div class="relative" :role="interactive ? 'group' : 'img'" :aria-label="gridLabel">
|
|
274
274
|
<div class="flex" :style="{ gap: `${cellGap}px` }">
|
|
275
275
|
<div v-for="(week, wi) in weeks" :key="wi" class="flex flex-col" :style="{ gap: `${cellGap}px` }">
|
|
@@ -159,7 +159,7 @@ const hideTip = () => {
|
|
|
159
159
|
wrong the moment the segments are buttons — the buttons would be hidden from the very
|
|
160
160
|
readers the role is for. Interactive, it becomes a labelled group whose children stay
|
|
161
161
|
reachable, and each segment states itself. -->
|
|
162
|
-
<UTooltip :text="tip.text" :reference="tip.el" :open="tipOpen">
|
|
162
|
+
<UTooltip :text="tip.text" :reference="tip.el ?? undefined" :open="tipOpen">
|
|
163
163
|
<div
|
|
164
164
|
class="grid w-full"
|
|
165
165
|
:role="interactive ? 'group' : 'img'"
|
|
@@ -247,7 +247,9 @@ const tipOpen = ref(false);
|
|
|
247
247
|
const tipReference = computed(() =>
|
|
248
248
|
tip.value.box
|
|
249
249
|
? { getBoundingClientRect: () => new DOMRect(tip.value.box!.x, tip.value.box!.y, tip.value.box!.w, tip.value.box!.h) }
|
|
250
|
-
|
|
250
|
+
// `undefined`, not `null`: UTooltip's `reference` is `ReferenceElement | undefined`, and a
|
|
251
|
+
// consumer's own typecheck reads this source — `null` is an error they cannot fix.
|
|
252
|
+
: undefined,
|
|
251
253
|
);
|
|
252
254
|
|
|
253
255
|
// One listener on an overlay, rather than one per dot. The grid is regular, so the dot under the
|
|
@@ -310,7 +310,7 @@ const labelled = computed<AreaDatum[]>(() => {
|
|
|
310
310
|
package, so a class used only here is never generated and the rule silently does
|
|
311
311
|
nothing. width and height stay attributes for the intrinsic size, and height:auto is
|
|
312
312
|
what keeps the aspect once max-width takes over in a container narrower than the map. -->
|
|
313
|
-
<UTooltip :text="tip.text" :reference="tip.el" :open="tipOpen">
|
|
313
|
+
<UTooltip :text="tip.text" :reference="tip.el ?? undefined" :open="tipOpen">
|
|
314
314
|
<svg
|
|
315
315
|
:width="width"
|
|
316
316
|
:height="height"
|
|
@@ -32,7 +32,16 @@ const props = withDefaults(
|
|
|
32
32
|
* this period sits above day one of the last. A shorter run simply stops early.
|
|
33
33
|
*/
|
|
34
34
|
compare?: (number | null)[];
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Any CSS colour. Defaults to the brand accent, which flips with the mode on its own.
|
|
37
|
+
*
|
|
38
|
+
* ★ `currentColor` WAS THE DEFAULT AND IT WAS WRONG. A tile does not pass its intent down to the
|
|
39
|
+
* `visual` slot — FKpi colours `metric` and `delta`, never the root — so inheriting there picks
|
|
40
|
+
* up body ink whatever the tile's status, and a sparkline dropped on any surface renders in the
|
|
41
|
+
* surrounding text colour. That shipped: one chart in near-black beside four in Forest green.
|
|
42
|
+
* Every call site in this repo already passed `var(--ui-primary)` by hand, which is the tell.
|
|
43
|
+
* Pass `currentColor` explicitly for one set inline in a sentence, where inking as text is right.
|
|
44
|
+
*/
|
|
36
45
|
color?: string;
|
|
37
46
|
/** The comparison line's colour. Defaults to the series colour at low opacity. */
|
|
38
47
|
compareColor?: string;
|
|
@@ -62,7 +71,7 @@ const props = withDefaults(
|
|
|
62
71
|
*/
|
|
63
72
|
mark?: 'line' | 'bar';
|
|
64
73
|
}>(),
|
|
65
|
-
{ height: 32, color: '
|
|
74
|
+
{ height: 32, color: 'var(--ui-primary)', area: false, endpoint: false, curve: true, mark: 'line', compare: undefined, compareColor: undefined },
|
|
66
75
|
);
|
|
67
76
|
|
|
68
77
|
if (import.meta.env.DEV) {
|
|
@@ -109,7 +109,7 @@ const hideTip = () => {
|
|
|
109
109
|
<div class="flex w-full flex-col gap-2">
|
|
110
110
|
<div class="flex items-center gap-3">
|
|
111
111
|
<span v-if="label && showChrome" class="type-label shrink-0 text-default">{{ label }}</span>
|
|
112
|
-
<UTooltip :text="tip.text" :reference="tip.el" :open="tipOpen">
|
|
112
|
+
<UTooltip :text="tip.text" :reference="tip.el ?? undefined" :open="tipOpen">
|
|
113
113
|
<div
|
|
114
114
|
class="flex min-w-0 flex-1"
|
|
115
115
|
:role="interactive ? 'group' : 'img'"
|
|
@@ -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,
|
|
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
|
-
|
|
153
|
-
|
|
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(
|
|
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:
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
the
|
|
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
|
|
58
|
-
//
|
|
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-
|
|
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
|
-
|
|
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 '../../../tokens/src/logo';
|
|
14
14
|
import { setWord, runInk } from '../../../tokens/src/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
'
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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),
|
|
@@ -7,16 +7,24 @@
|
|
|
7
7
|
* while aligning with Forest's overall soft-radius vocabulary (--ui-radius = 0.5 rem).
|
|
8
8
|
* - `label`: font-medium is already stock; we keep it and add nothing — the Nuxt UI default is
|
|
9
9
|
* correct here.
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* - focus ring: Forest's house convention `outline-2 outline-offset-2` (an offset ring), over
|
|
11
|
+
* stock's flush `outline-3`. The outline COLOUR is stock's semantic value, resolved to the
|
|
12
|
+
* Forest OKLCH ramp via docs.css / forest-preset — no colour override needed, only the width
|
|
13
|
+
* and offset. Where that ring has to be written is the awkward part; see below.
|
|
14
14
|
* - `legend` (CheckboxGroup): font-bold — bold against the regular item labels, giving the
|
|
15
15
|
* group label a clear hierarchy above the individual checkbox labels.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
17
|
+
* ★ THE RING IS A COMPOUND, NOT A SLOT. Stock paints it in two places and neither is a plain slot:
|
|
18
|
+
* on `base` for the `list` variant with a visible box, and on `root` (as `has-focus-visible:`) for
|
|
19
|
+
* `card`, and for `list` once the box is hidden. Both live in stock's compoundVariants, which tv
|
|
20
|
+
* appends AFTER the slots — so a `slots.base` override loses to them and silently does nothing.
|
|
21
|
+
* The width therefore has to be restated at the same layer, in the entries below.
|
|
18
22
|
*
|
|
19
|
-
*
|
|
23
|
+
* The offset is the exception: no stock rule sets `outline-offset`, so the one on `base` still
|
|
24
|
+
* reaches from `slots` and is not repeated. The root entries carry their own, `has-focus-visible:`
|
|
25
|
+
* being a different property from the bare `outline-offset-2` on base.
|
|
26
|
+
*
|
|
27
|
+
* Slots touched: base, root, icon.
|
|
20
28
|
* Slot names sourced from: apps/docs/node_modules/.nuxt-ui/ui/checkbox.ts
|
|
21
29
|
*
|
|
22
30
|
* The CheckboxGroup legend opinion lives in its own sibling fragment (checkbox-group.ts),
|
|
@@ -26,22 +34,32 @@
|
|
|
26
34
|
export const checkboxTheme = {
|
|
27
35
|
slots: {
|
|
28
36
|
// Deltas over stock: `rounded` (4px) for the Forest soft radius (stock rounded-sm/2px), and the
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
base: 'rounded focus-visible:outline-2 focus-visible:outline-offset-2',
|
|
37
|
+
// ring's offset. The WIDTH is not here — it would be overridden; see the compounds below.
|
|
38
|
+
base: 'rounded focus-visible:outline-offset-2',
|
|
32
39
|
},
|
|
33
40
|
// Touch: a checkbox's tap target is the whole label ROW, and at md that row is short (~20px). Under
|
|
34
|
-
// a coarse (finger) pointer each size grows one size up — box (base),
|
|
35
|
-
// label text (wrapper) — so the row becomes a taller, easier target. No sliding
|
|
36
|
-
// is clean (unlike the switch thumb). xl is the ceiling; each size lists only
|
|
41
|
+
// a coarse (finger) pointer each size grows one size up — box (base), tick (icon), row height
|
|
42
|
+
// (container) and label text (wrapper) — so the row becomes a taller, easier target. No sliding
|
|
43
|
+
// part, so the bump is clean (unlike the switch thumb). xl is the ceiling; each size lists only
|
|
44
|
+
// its deltas up, which is why container and wrapper appear on two rungs and the box on four.
|
|
45
|
+
//
|
|
46
|
+
// ★ THE TICK IS SIZED SEPARATELY FROM THE BOX. Stock pins `icon` per size rather than filling the
|
|
47
|
+
// box, so growing `base` alone leaves the tick at its fine-pointer size inside a bigger square.
|
|
37
48
|
// `coarse:` carries the `:not(.cut-fixed)` escape so the mobile cut (baked size lg) opts out.
|
|
38
49
|
variants: {
|
|
39
50
|
size: {
|
|
40
|
-
xs: { base: 'coarse:size-3.5' },
|
|
41
|
-
sm: { base: 'coarse:size-4', container: 'coarse:h-5', wrapper: 'coarse:text-sm' },
|
|
42
|
-
md: { base: 'coarse:size-4.5' },
|
|
43
|
-
lg: { base: 'coarse:size-5', container: 'coarse:h-6', wrapper: 'coarse:text-base' },
|
|
51
|
+
xs: { base: 'coarse:size-3.5', icon: 'coarse:size-3' },
|
|
52
|
+
sm: { base: 'coarse:size-4', icon: 'coarse:size-3.5', container: 'coarse:h-5', wrapper: 'coarse:text-sm' },
|
|
53
|
+
md: { base: 'coarse:size-4.5', icon: 'coarse:size-4' },
|
|
54
|
+
lg: { base: 'coarse:size-5', icon: 'coarse:size-4.5', container: 'coarse:h-6', wrapper: 'coarse:text-base' },
|
|
44
55
|
// xl: ceiling
|
|
45
56
|
},
|
|
46
57
|
},
|
|
58
|
+
// The house width, restated where stock's own compounds would otherwise win. Colour is left to
|
|
59
|
+
// stock, so these carry no `color` discriminator and apply across every ramp.
|
|
60
|
+
compoundVariants: [
|
|
61
|
+
{ variant: 'list', indicator: ['start', 'end'], class: { base: 'focus-visible:outline-2' } },
|
|
62
|
+
{ variant: 'card', class: { root: 'has-focus-visible:outline-2 has-focus-visible:outline-offset-2' } },
|
|
63
|
+
{ variant: 'list', indicator: 'hidden', class: { root: 'has-focus-visible:outline-2 has-focus-visible:outline-offset-2' } },
|
|
64
|
+
],
|
|
47
65
|
} as const;
|
|
@@ -73,6 +73,7 @@ export { paginationTheme as pagination } from './pagination';
|
|
|
73
73
|
export { pinInputTheme as pinInput } from './pin-input';
|
|
74
74
|
export { popoverTheme as popover } from './popover';
|
|
75
75
|
export { progressTheme as progress } from './progress';
|
|
76
|
+
export { progressGroupTheme as progressGroup } from './progress-group';
|
|
76
77
|
// Prose components are namespaced under ui.prose.* — the fragment runs stock, carrying only the
|
|
77
78
|
// `ed-demo` marker the docs editorial grid reads off a demo pane.
|
|
78
79
|
export { proseTheme as prose } from './prose';
|
|
@@ -85,6 +86,7 @@ export { sidebarTheme as sidebar } from './sidebar';
|
|
|
85
86
|
export { skeletonTheme as skeleton } from './skeleton';
|
|
86
87
|
export { slideoverTheme as slideover } from './slideover';
|
|
87
88
|
export { sliderTheme as slider } from './slider';
|
|
89
|
+
export { splitterTheme as splitter } from './splitter';
|
|
88
90
|
export { stepperTheme as stepper } from './stepper';
|
|
89
91
|
export { switchTheme as switch } from './switch';
|
|
90
92
|
export { tableTheme as table } from './table';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forest theme fragment — UProgressGroup (several values as segments of one track, plus a legend).
|
|
3
|
+
*
|
|
4
|
+
* New in Nuxt UI 4.11, and it reaches a Forest consumer whether or not we document it: the layer
|
|
5
|
+
* registers @nuxt/ui wholesale, so the component is typeable the moment the dependency resolves.
|
|
6
|
+
* This fragment exists so that what arrives is not off-brand, not because the system recommends it.
|
|
7
|
+
*
|
|
8
|
+
* Forest opinions applied:
|
|
9
|
+
*
|
|
10
|
+
* segment, status — the motion tokens, replacing stock's hard-coded `duration-200 ease-out`.
|
|
11
|
+
* UProgress next to it already animates on `--motion-slow` / `--motion-ease-out` (progress.ts),
|
|
12
|
+
* and two bars of the same family easing differently is the kind of seam that only ever shows
|
|
13
|
+
* up once both are on screen.
|
|
14
|
+
*
|
|
15
|
+
* We do NOT override:
|
|
16
|
+
* - `base` (the track) — stock's `bg-accented` groove and `rounded-full` are already the Forest
|
|
17
|
+
* reading; radius follows --ui-radius through the preset.
|
|
18
|
+
* - Colour variants — `bg-primary`, `bg-error` and the Forest ramps (forest/maple/river/warm/acid)
|
|
19
|
+
* are registered by forest-preset, and a per-item `color` that is not a registered name is
|
|
20
|
+
* passed through as an inline background, so `var(--dataviz-categorical-safe-N)` works as-is.
|
|
21
|
+
* - `motion-reduce:transition-none` — stock sets it, and the global reduced-motion floor in
|
|
22
|
+
* packages/tokens/motion supersedes it regardless. Restating it would be drift.
|
|
23
|
+
* - The size ladder, the legend slots, and `list`/`item` layout — no Forest opinion here.
|
|
24
|
+
*
|
|
25
|
+
* ★ NOT A REPLACEMENT FOR FDistributionBar. Its bar segments carry no slot, so nothing can hang a
|
|
26
|
+
* tooltip or a focus stop on them, and each segment is its own `role="progressbar"` — a five-part
|
|
27
|
+
* figure announces as five progress bars rather than one. FDistributionBar exists for the
|
|
28
|
+
* composition read: one `role="img"`, one sentence, and optional interactive segments.
|
|
29
|
+
*
|
|
30
|
+
* Slots touched: segment, status.
|
|
31
|
+
* Slot names sourced from: apps/docs/node_modules/.nuxt-ui/ui/progress-group.ts
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const progressGroupTheme = {
|
|
35
|
+
slots: {
|
|
36
|
+
segment: 'duration-(--motion-slow) ease-(--motion-ease-out)',
|
|
37
|
+
status: 'duration-(--motion-slow) ease-(--motion-ease-out)',
|
|
38
|
+
},
|
|
39
|
+
} as const;
|