@humanforest/ui 0.1.3 → 0.3.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/package.json +5 -4
- package/preset.gen.js +26 -6
- package/src/card/FInsetCard.vue +237 -0
- package/src/card/index.ts +3 -0
- package/src/map/FMap.vue +14 -4
- package/src/shell/FSidePanel.vue +20 -1
- package/src/styles/forest.css +14 -0
- package/src/themes/checkbox.ts +34 -16
- package/src/themes/index.ts +2 -0
- package/src/themes/progress-group.ts +39 -0
- package/src/themes/splitter.ts +33 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanforest/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"preset.gen.js"
|
|
10
10
|
],
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@humanforest/tokens": "^0.
|
|
12
|
+
"@humanforest/tokens": "^0.3.0",
|
|
13
13
|
"@internationalized/date": "^3.12.2",
|
|
14
14
|
"@mapbox/search-js-core": "^1.6.0",
|
|
15
15
|
"@turf/boolean-point-in-polygon": "^7.4.0",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"./map-search-coordinates": "./src/map/sources/coordinates.ts",
|
|
31
31
|
"./address": "./src/address/index.ts",
|
|
32
32
|
"./canvas": "./src/canvas/index.ts",
|
|
33
|
+
"./card": "./src/card/index.ts",
|
|
33
34
|
"./kpi": "./src/kpi/index.ts",
|
|
34
35
|
"./logo": "./src/logo/index.ts",
|
|
35
36
|
"./shell": "./src/shell/index.ts",
|
|
@@ -50,12 +51,12 @@
|
|
|
50
51
|
"./kpi/format": "./src/kpi/kpiFormat.ts"
|
|
51
52
|
},
|
|
52
53
|
"devDependencies": {
|
|
53
|
-
"@nuxt/ui": "^4.
|
|
54
|
+
"@nuxt/ui": "^4.11.0",
|
|
54
55
|
"@tailwindcss/vite": "^4.3.3",
|
|
55
56
|
"@vitejs/plugin-vue": "^6.0.8",
|
|
56
57
|
"@vitest/browser": "^4.1.10",
|
|
57
58
|
"playwright": "^1.61.1",
|
|
58
|
-
"reka-ui": "2.10.
|
|
59
|
+
"reka-ui": "2.10.4",
|
|
59
60
|
"tailwindcss": "^4.3.3",
|
|
60
61
|
"vite": "^8.1.5",
|
|
61
62
|
"vitest": "^4.1.10",
|
package/preset.gen.js
CHANGED
|
@@ -28,6 +28,7 @@ __export(exports_themes, {
|
|
|
28
28
|
table: () => tableTheme,
|
|
29
29
|
switch: () => switchTheme,
|
|
30
30
|
stepper: () => stepperTheme,
|
|
31
|
+
splitter: () => splitterTheme,
|
|
31
32
|
slider: () => sliderTheme,
|
|
32
33
|
slideover: () => slideoverTheme,
|
|
33
34
|
skeleton: () => skeletonTheme,
|
|
@@ -38,6 +39,7 @@ __export(exports_themes, {
|
|
|
38
39
|
scrollArea: () => scrollAreaTheme,
|
|
39
40
|
radioGroup: () => radioGroupTheme,
|
|
40
41
|
prose: () => proseTheme,
|
|
42
|
+
progressGroup: () => progressGroupTheme,
|
|
41
43
|
progress: () => progressTheme,
|
|
42
44
|
popover: () => popoverTheme,
|
|
43
45
|
pinInput: () => pinInputTheme,
|
|
@@ -345,16 +347,21 @@ var carouselTheme = {
|
|
|
345
347
|
// packages/ui/src/themes/checkbox.ts
|
|
346
348
|
var checkboxTheme = {
|
|
347
349
|
slots: {
|
|
348
|
-
base: "rounded focus-visible:outline-
|
|
350
|
+
base: "rounded focus-visible:outline-offset-2"
|
|
349
351
|
},
|
|
350
352
|
variants: {
|
|
351
353
|
size: {
|
|
352
|
-
xs: { base: "coarse:size-3.5" },
|
|
353
|
-
sm: { base: "coarse:size-4", container: "coarse:h-5", wrapper: "coarse:text-sm" },
|
|
354
|
-
md: { base: "coarse:size-4.5" },
|
|
355
|
-
lg: { base: "coarse:size-5", container: "coarse:h-6", wrapper: "coarse:text-base" }
|
|
354
|
+
xs: { base: "coarse:size-3.5", icon: "coarse:size-3" },
|
|
355
|
+
sm: { base: "coarse:size-4", icon: "coarse:size-3.5", container: "coarse:h-5", wrapper: "coarse:text-sm" },
|
|
356
|
+
md: { base: "coarse:size-4.5", icon: "coarse:size-4" },
|
|
357
|
+
lg: { base: "coarse:size-5", icon: "coarse:size-4.5", container: "coarse:h-6", wrapper: "coarse:text-base" }
|
|
356
358
|
}
|
|
357
|
-
}
|
|
359
|
+
},
|
|
360
|
+
compoundVariants: [
|
|
361
|
+
{ variant: "list", indicator: ["start", "end"], class: { base: "focus-visible:outline-2" } },
|
|
362
|
+
{ variant: "card", class: { root: "has-focus-visible:outline-2 has-focus-visible:outline-offset-2" } },
|
|
363
|
+
{ variant: "list", indicator: "hidden", class: { root: "has-focus-visible:outline-2 has-focus-visible:outline-offset-2" } }
|
|
364
|
+
]
|
|
358
365
|
};
|
|
359
366
|
// packages/ui/src/themes/checkbox-group.ts
|
|
360
367
|
var checkboxGroupTheme = {
|
|
@@ -999,6 +1006,13 @@ var progressTheme = {
|
|
|
999
1006
|
indicator: "duration-(--motion-slow) ease-(--motion-ease-out) will-change-transform"
|
|
1000
1007
|
}
|
|
1001
1008
|
};
|
|
1009
|
+
// packages/ui/src/themes/progress-group.ts
|
|
1010
|
+
var progressGroupTheme = {
|
|
1011
|
+
slots: {
|
|
1012
|
+
segment: "duration-(--motion-slow) ease-(--motion-ease-out)",
|
|
1013
|
+
status: "duration-(--motion-slow) ease-(--motion-ease-out)"
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1002
1016
|
// packages/ui/src/themes/prose.ts
|
|
1003
1017
|
var proseTheme = {
|
|
1004
1018
|
codePreview: {
|
|
@@ -1221,6 +1235,12 @@ var sliderTheme = {
|
|
|
1221
1235
|
}
|
|
1222
1236
|
}
|
|
1223
1237
|
};
|
|
1238
|
+
// packages/ui/src/themes/splitter.ts
|
|
1239
|
+
var splitterTheme = {
|
|
1240
|
+
slots: {
|
|
1241
|
+
handle: "focus-visible:outline-offset-2 focus-visible:outline-[var(--forest-500)]"
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1224
1244
|
// packages/ui/src/themes/stepper.ts
|
|
1225
1245
|
var stepperTheme = {
|
|
1226
1246
|
slots: {
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// A card whose body is an INSET BOX: the box carries the content, and the card's own ground shows
|
|
3
|
+
// as ONE strip — a header above it or a footer below, never both. For dashboard tiles.
|
|
4
|
+
//
|
|
5
|
+
// <FInsetCard> …a chart… <template #footer>…</template> </FInsetCard>
|
|
6
|
+
// <FInsetCard :gap="4"> …a chart… <template #header>…</template> </FInsetCard>
|
|
7
|
+
// <FInsetCard bare> <template #default="{ boxUi }"> <FKpi :ui="boxUi" … />
|
|
8
|
+
//
|
|
9
|
+
// ★ WHY A WRAPPER, NOT A CARD VARIANT. Card variants are a FILL axis (soft/outline/subtle/solid +
|
|
10
|
+
// the tints). "Inset box" is a composition, and making it a variant would put its blast radius on
|
|
11
|
+
// every card in the system. Same reasoning as FlushCard, which is the other end of this axis: it
|
|
12
|
+
// zeroes the body padding so a table runs to the card's edge; this one shrinks it to a visible gap.
|
|
13
|
+
//
|
|
14
|
+
// ★ THE GAP IS ONE VALUE DOING THREE JOBS. Setting --forest-card-pad drives (a) the body padding,
|
|
15
|
+
// so the box is inset by it, (b) the concentric radius, because forest.css derives a nested card's
|
|
16
|
+
// corner as `outer − pad`, and (c) FKpi's bleed margin, which pulls by the same var. Set it once
|
|
17
|
+
// here and all three stay in step; set the padding with a class instead and they silently diverge.
|
|
18
|
+
//
|
|
19
|
+
// ★ THE BODY DROPS ITS PADDING ON THE SIDE THE STRIP IS ON. The box is inset on three sides; on the
|
|
20
|
+
// fourth it meets the strip directly, and the strip owns all the space there. Two consequences, both
|
|
21
|
+
// load-bearing: the box's edge lands flush against the strip (as it does in the reference), and the
|
|
22
|
+
// strip's `pt` and `pb` can then be EQUAL and actually read equal. Leave the body padding on and it
|
|
23
|
+
// stacks — the label sits `gap + pt` from the box but only `pb` from the card edge, which is the
|
|
24
|
+
// lopsided look this pattern kept producing.
|
|
25
|
+
//
|
|
26
|
+
// ★ THE STRIP RESTATES ITS PADDING UNDER THE FRAGMENT'S OWN VARIANT. Forest's `flatGap` carries
|
|
27
|
+
// `footer: '[&:not(:first-child)]:pt-0'` / `header: '[&:not(:last-child)]:pb-0'` to collapse a
|
|
28
|
+
// doubled gap on a normal card. Here the body already zeroed that side, so the collapse would eat
|
|
29
|
+
// the strip's only top padding. A bare `py-*` cannot answer it — the `:not()` selector out-specifies
|
|
30
|
+
// a plain class — so the value is restated under the same variant.
|
|
31
|
+
//
|
|
32
|
+
// ★ WHOLE CLASS STRINGS, NEVER CONCATENATED. Tailwind scans source text, so `p-${gap}` and
|
|
33
|
+
// `pt-[${n}px]` generate nothing at all — they fail silently, at runtime, with no error. Every
|
|
34
|
+
// combination is spelled out below. This is why `gap` is an enum and not a number.
|
|
35
|
+
import { computed, useSlots, watchEffect } from 'vue';
|
|
36
|
+
|
|
37
|
+
type Gap = 4 | 8 | 12;
|
|
38
|
+
|
|
39
|
+
// ★ ONE SOURCE OF TRUTH FOR THE GAP. The var is not a stylistic choice — two rules OUTSIDE this
|
|
40
|
+
// component read it and no Tailwind class can reach either: forest.css derives the nested card's
|
|
41
|
+
// concentric radius as `calc(--forest-card-radius - --forest-card-pad)`, and kpi.theme.ts pulls the
|
|
42
|
+
// bleed by `-mx-[var(--forest-card-pad)]`. Measured: with the var set, box radii are 20/16/12 for
|
|
43
|
+
// gaps 4/8/12; without it they all stay at 8.
|
|
44
|
+
// So the padding CLASS reads the same var rather than restating the number — otherwise the value
|
|
45
|
+
// lives in two places and a future edit can move one and not the other. That also makes the body
|
|
46
|
+
// classes gap-independent: only `pad` varies.
|
|
47
|
+
const PAD: Record<Gap, string> = { 4: '0.25rem', 8: '0.5rem', 12: '0.75rem' };
|
|
48
|
+
|
|
49
|
+
// ★ THE BOX NEEDS THE VAR ON ITSELF, NOT JUST INHERITED. The bold contexts set
|
|
50
|
+
// `[--forest-card-pad:1.25rem]` on EVERY card root (mobile-theme.ts / marketing-theme.ts), and the
|
|
51
|
+
// box is a card — so the context's value lands on the box's own element and beats the frame's,
|
|
52
|
+
// which only reaches it by inheritance. The concentric rule then runs on the box with the CONTEXT's
|
|
53
|
+
// padding while the body was inset by OURS: 8px of inset with a corner struck for 20px.
|
|
54
|
+
// Restating the gap on the box makes both halves read the same number. Literal strings per gap
|
|
55
|
+
// because Tailwind scans source text and cannot generate `[--forest-card-pad:${x}]`.
|
|
56
|
+
const PAD_CLASS: Record<Gap, string> = {
|
|
57
|
+
4: '[--forest-card-pad:0.25rem]',
|
|
58
|
+
8: '[--forest-card-pad:0.5rem]',
|
|
59
|
+
12: '[--forest-card-pad:0.75rem]',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// The body drops its padding on whichever side the strip is on — see the note above. Both
|
|
63
|
+
// breakpoints every time: the card fragment re-pins `sm:p-*`, so a bare `pb-0` still pads at ≥640px.
|
|
64
|
+
const BODY = {
|
|
65
|
+
none: 'p-[var(--forest-card-pad)] sm:p-[var(--forest-card-pad)]',
|
|
66
|
+
header: 'p-[var(--forest-card-pad)] pt-0 sm:p-[var(--forest-card-pad)] sm:pt-0',
|
|
67
|
+
footer: 'p-[var(--forest-card-pad)] pb-0 sm:p-[var(--forest-card-pad)] sm:pb-0',
|
|
68
|
+
} as const;
|
|
69
|
+
|
|
70
|
+
// Gap-independent, because the body no longer contributes on the strip's side: pt === pb.
|
|
71
|
+
// The 8px here deliberately equals the DEFAULT gap: the space around the box and the space around
|
|
72
|
+
// the strip's own label are then one number, so the frame reads as a single rhythm rather than two
|
|
73
|
+
// unrelated ones. Change the default gap and this should move with it.
|
|
74
|
+
// ★ THE STRIP OWNS ITS HEIGHT, THE CONTENT DOES NOT. Without a floor the band is as tall as whatever
|
|
75
|
+
// sits in it, so a footer holding a UButton (min-h-8, its touch target — see themes/button.ts, where
|
|
76
|
+
// `min-h` is the sole height driver) came out 48px against a header of text at ~40px, and a card
|
|
77
|
+
// with one of each looked lopsided. min-h-10 is that button plus the strip's own 8+8, so the tallest
|
|
78
|
+
// realistic content still fits and a plain text row is centred in the same band.
|
|
79
|
+
//
|
|
80
|
+
// `grid items-center` rather than `flex items-center`: a lone grid child spans the column, so a row
|
|
81
|
+
// using `ms-auto` to push its meta right still works. Under flex it would shrink to content and the
|
|
82
|
+
// meta would collapse leftward.
|
|
83
|
+
const STRIP = {
|
|
84
|
+
header: 'grid min-h-10 items-center px-4 pt-2 sm:px-4 [&:not(:last-child)]:pb-2',
|
|
85
|
+
footer: 'grid min-h-10 items-center px-4 pb-2 sm:px-4 [&:not(:first-child)]:pt-2',
|
|
86
|
+
} as const;
|
|
87
|
+
|
|
88
|
+
const props = withDefaults(
|
|
89
|
+
defineProps<{
|
|
90
|
+
/** The inset, in px. An enum because the classes must exist in source — see the note above. */
|
|
91
|
+
gap?: Gap;
|
|
92
|
+
/** The card's hairline. Uses the relative ring, so it holds at any surface level. */
|
|
93
|
+
outlined?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* The child already draws its own surface (an FKpi does), so this card must not draw a second
|
|
96
|
+
* one. Named for the exception rather than the rule so the common case stays free and the
|
|
97
|
+
* exception is a bare attribute — `<FInsetCard bare>` — since Vue's shorthand can only ever
|
|
98
|
+
* express `true`, which would make the opposite spelling cost `:box="false"` at every KPI.
|
|
99
|
+
*/
|
|
100
|
+
bare?: boolean;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The strip's content, for the shape nearly every card wants: an icon, a title, something
|
|
104
|
+
* right-aligned above; a single action below. Given as props so a call site does not hand-roll
|
|
105
|
+
* the same flex row each time — and so the voice (overline/toned title, caption meta, muted
|
|
106
|
+
* action) is decided once here rather than re-chosen per page.
|
|
107
|
+
*
|
|
108
|
+
* The `#header` / `#footer` slots still win when a card needs a shape these cannot express.
|
|
109
|
+
*/
|
|
110
|
+
icon?: string;
|
|
111
|
+
title?: string;
|
|
112
|
+
meta?: string;
|
|
113
|
+
/** Footer action label. `to` makes it a link; without one it is a button. */
|
|
114
|
+
action?: string;
|
|
115
|
+
to?: string;
|
|
116
|
+
|
|
117
|
+
}>(),
|
|
118
|
+
{ gap: 8, outlined: true, bare: false },
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const slots = useSlots();
|
|
122
|
+
|
|
123
|
+
// A strip is present when its slot OR its props are supplied — the body's padding and the
|
|
124
|
+
// one-strip rule both key off this, so neither can be fooled by the prop form.
|
|
125
|
+
const hasHeader = computed(() => !!(slots.header || props.title));
|
|
126
|
+
const hasFooter = computed(() => !!(slots.footer || props.action));
|
|
127
|
+
|
|
128
|
+
// One strip is the whole idea: the box plus the ground it sits on. With both, the box becomes the
|
|
129
|
+
// filling in a sandwich, neither side can drop its padding, and the frame stops reading as a frame.
|
|
130
|
+
if (import.meta.env.DEV) {
|
|
131
|
+
watchEffect(() => {
|
|
132
|
+
if (hasHeader.value && hasFooter.value)
|
|
133
|
+
console.warn('[FInsetCard] `header` and `footer` together — this pattern takes one strip, not both.');
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
// Set as a var rather than a class so the radius and FKpi's bleed follow it.
|
|
140
|
+
const style = computed(() => ({ '--forest-card-pad': PAD[props.gap] }));
|
|
141
|
+
|
|
142
|
+
const ui = computed(() => ({
|
|
143
|
+
body: hasFooter.value ? BODY.footer : hasHeader.value ? BODY.header : BODY.none,
|
|
144
|
+
header: STRIP.header,
|
|
145
|
+
footer: STRIP.footer,
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
// ★ THE FRAME IS ALWAYS `soft`, and the variant is not exposed. Probed 2026-09-10 with the prop
|
|
149
|
+
// restored and the divider suppressed, measuring frame vs box in both modes:
|
|
150
|
+
//
|
|
151
|
+
// soft frame l1 box page → box visible
|
|
152
|
+
// subtle frame l1 box page → box visible
|
|
153
|
+
// outline frame PAGE box page → BOX INVISIBLE, both modes
|
|
154
|
+
// solid frame inverted box page → visible, but a light box in a black card
|
|
155
|
+
//
|
|
156
|
+
// `outline` fills the frame with the page colour, and the box is pinned to the page colour too, so
|
|
157
|
+
// the inset box vanishes into its own frame — the one thing this component exists to draw. That is
|
|
158
|
+
// the real reason, and it is a property of the fill being pinned, not of the divider: `divide-y-0`
|
|
159
|
+
// suppressed the hairline in one class, so the divider was never the objection it looked like.
|
|
160
|
+
//
|
|
161
|
+
// `soft` + a ring, rather than `subtle`: subtle also restores divide-y, and the gap is what
|
|
162
|
+
// separates the strip here — a hairline in it would say the same thing twice.
|
|
163
|
+
// ★ `h-fit` IS LOAD-BEARING, not a layout preference. A grid or flex row stretches its children by
|
|
164
|
+
// default, and a stretched card grows past its content — the extra height lands BELOW the box, so
|
|
165
|
+
// the even inset turns into a lopsided gap at the bottom and the frame stops reading as a frame.
|
|
166
|
+
// The card must hug its content vertically.
|
|
167
|
+
// ⚠ NOT `self-start`, which was the first cut: `align-self` acts on the CROSS axis, so in a flex
|
|
168
|
+
// COLUMN it stops the card filling the width instead of capping its height — the card shrank to its
|
|
169
|
+
// content in every column layout. `h-fit` caps the height and leaves width alone, which is the axis
|
|
170
|
+
// actually at issue.
|
|
171
|
+
const rootClass = computed(() =>
|
|
172
|
+
['h-fit', props.outlined ? 'ring ring-[var(--forest-card-ring)]' : ''].filter(Boolean).join(' '),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// ★ THE BOX IS ALWAYS RAISED — there is no ramp option, on purpose. Letting the nesting ladder fill
|
|
176
|
+
// the box steps it one level AWAY from the page, which in light means DARKER than the card it sits
|
|
177
|
+
// in. That reads as a recess, not as a panel, and it is not a look this pattern should be able to
|
|
178
|
+
// produce. --forest-card-inset (forest.css) is the raised surface: always lighter than the card, in
|
|
179
|
+
// both modes. It replaced a bare `bg-default`, which is the PAGE colour and so went darker than the
|
|
180
|
+
// card in dark — the box read as a hole punched in the frame.
|
|
181
|
+
//
|
|
182
|
+
// The box takes no padding of its own: UCard already pads its BODY slot, and adding p-* to the root
|
|
183
|
+
// stacks on top of it for double the intended space.
|
|
184
|
+
const boxUi = computed(() => ({ root: `bg-[var(--forest-card-inset)] ${PAD_CLASS[props.gap]}` }));
|
|
185
|
+
|
|
186
|
+
// ★ FOR `bare`, WHERE THE CHILD IS ITS OWN SURFACE (an FKpi). Exposed as a slot prop so the
|
|
187
|
+
// call site passes it straight to the child's `ui` without needing to know any of this:
|
|
188
|
+
// · the child pads its ROOT (FKpi's root IS the padded grid), unlike UCard which pads its body
|
|
189
|
+
// · pb is left OFF so FKpi's `visual-fit="bleed"` keeps its own `pb-0` and the graphic reaches the
|
|
190
|
+
// bottom edge — a blanket `p-4` here silently re-adds it
|
|
191
|
+
// · the bleed margin is restated to match the reading padding actually in force. FKpi pulls by
|
|
192
|
+
// `-mx-[var(--forest-card-pad)]`, which is the GAP — not the 16px this box pads by — so without
|
|
193
|
+
// this the graphic stops short on both sides.
|
|
194
|
+
// A workaround for FKpi's coupling to --forest-card-pad; the real fix belongs in kpi.theme.ts, at
|
|
195
|
+
// which point the `visual` line here can go.
|
|
196
|
+
const selfBoxUi = computed(() => ({
|
|
197
|
+
root: `bg-[var(--forest-card-inset)] px-4 pt-4 ${PAD_CLASS[props.gap]}`,
|
|
198
|
+
visual: '-mx-4',
|
|
199
|
+
}));
|
|
200
|
+
</script>
|
|
201
|
+
|
|
202
|
+
<template>
|
|
203
|
+
<UCard variant="soft" :class="rootClass" :style="style" :ui="ui">
|
|
204
|
+
<template v-if="hasHeader" #header>
|
|
205
|
+
<!-- The slot's FALLBACK is the prop form: pass `#header` and it replaces this wholesale. -->
|
|
206
|
+
<slot name="header">
|
|
207
|
+
<div class="flex items-center gap-2">
|
|
208
|
+
<UIcon v-if="icon" :name="icon" class="size-4 shrink-0 text-muted" />
|
|
209
|
+
<span class="type-overline truncate text-toned">{{ title }}</span>
|
|
210
|
+
<span v-if="meta" class="ms-auto shrink-0 type-caption text-muted">{{ meta }}</span>
|
|
211
|
+
</div>
|
|
212
|
+
</slot>
|
|
213
|
+
</template>
|
|
214
|
+
|
|
215
|
+
<UCard v-if="!bare" :ui="boxUi">
|
|
216
|
+
<slot />
|
|
217
|
+
</UCard>
|
|
218
|
+
<slot v-else :box-ui="selfBoxUi" />
|
|
219
|
+
|
|
220
|
+
<template v-if="hasFooter" #footer>
|
|
221
|
+
<!-- `xs` on purpose: the strip's floor is 40px, and a `md` button's 32px touch target plus the
|
|
222
|
+
strip's own 8+8 would push it to 48 and leave the header shorter than the footer. -->
|
|
223
|
+
<slot name="footer">
|
|
224
|
+
<UButton
|
|
225
|
+
:to="to"
|
|
226
|
+
size="xs"
|
|
227
|
+
variant="link"
|
|
228
|
+
color="neutral"
|
|
229
|
+
trailing-icon="i-lucide-arrow-right"
|
|
230
|
+
class="w-full justify-between px-0 text-muted"
|
|
231
|
+
>
|
|
232
|
+
{{ action }}
|
|
233
|
+
</UButton>
|
|
234
|
+
</slot>
|
|
235
|
+
</template>
|
|
236
|
+
</UCard>
|
|
237
|
+
</template>
|
package/src/map/FMap.vue
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// owns the boilerplate
|
|
4
4
|
// every surface would otherwise repeat: the access-token guard (with a setup fallback), a lazy WebGL
|
|
5
5
|
// init (the context is created only when the frame nears the viewport, so a page with several maps
|
|
6
|
-
// doesn't pay for all of them up front), the load fade,
|
|
7
|
-
//
|
|
6
|
+
// doesn't pay for all of them up front), the load fade, and teardown. Cooperative gestures are
|
|
7
|
+
// available (`cooperative`) but OFF unless a surface asks: a map in a document should not swallow
|
|
8
|
+
// the scroll, and a map that IS the surface should not need two fingers to move. Emits `ready(map)` once the style has loaded — add sources, layers, config
|
|
8
9
|
// and interactions in that handler.
|
|
9
10
|
//
|
|
10
11
|
// Token: pass `access-token`, or set `mapboxgl.accessToken` once at app start. Public `pk.…` tokens
|
|
@@ -75,7 +76,16 @@ const props = withDefaults(
|
|
|
75
76
|
* the surface — only turn this off when the surface carries it elsewhere (e.g. an app's legal
|
|
76
77
|
* screen, or chrome drawn above a bottom sheet). */
|
|
77
78
|
attribution?: boolean;
|
|
78
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Cooperative gestures. ONE flag, TWO behaviours — Mapbox does not separate them: scroll-zoom
|
|
81
|
+
* then needs ⌘/ctrl held, AND a touch pan needs TWO FINGERS (pitch, three). It is the second
|
|
82
|
+
* half that decides the default.
|
|
83
|
+
*
|
|
84
|
+
* OFF by default, which is Mapbox's own default too. A map is usually the subject of the
|
|
85
|
+
* surface it is on — an ops canvas, a rider finding a vehicle — and there one finger should
|
|
86
|
+
* move it. Turn it ON for a map embedded in a document, where a map that swallows the scroll
|
|
87
|
+
* traps the reader on the way past.
|
|
88
|
+
*/
|
|
79
89
|
cooperative?: boolean;
|
|
80
90
|
/** create the WebGL context only when the frame nears the viewport */
|
|
81
91
|
lazy?: boolean;
|
|
@@ -102,7 +112,7 @@ const props = withDefaults(
|
|
|
102
112
|
lightPreset: 'auto',
|
|
103
113
|
height: 'h-105',
|
|
104
114
|
attribution: true,
|
|
105
|
-
cooperative:
|
|
115
|
+
cooperative: false,
|
|
106
116
|
lazy: true,
|
|
107
117
|
frame: false,
|
|
108
118
|
},
|
package/src/shell/FSidePanel.vue
CHANGED
|
@@ -54,7 +54,12 @@ const props = withDefaults(
|
|
|
54
54
|
// navigation and the weight split between two edges. It is what a console with a left rail does
|
|
55
55
|
// everywhere (Linear, Figma, Retool). Pass `side="start"` where the panel IS the navigation for its
|
|
56
56
|
// view, which in practice means a settings sub-nav.
|
|
57
|
-
|
|
57
|
+
// ★ `close: undefined` is load-bearing, not noise. A type-declared `close?: boolean` compiles to
|
|
58
|
+
// `{ type: Boolean }`, and Vue casts an ABSENT boolean prop to `false` unless the options carry a
|
|
59
|
+
// `default` key — so without this `props.close ?? overlay` never sees `undefined`, and an overlay
|
|
60
|
+
// panel drew no close at all unless a consumer asked for one. Declaring the default keeps the three
|
|
61
|
+
// states the prop is written for: unset (follow the behaviour), true, false.
|
|
62
|
+
{ behaviour: 'push', side: 'end', size: 'md', close: undefined },
|
|
58
63
|
);
|
|
59
64
|
|
|
60
65
|
const open = defineModel<boolean>('open', { default: true });
|
|
@@ -214,6 +219,20 @@ const sidebarUi = computed(() => ({
|
|
|
214
219
|
// panel title and a card title share a baseline instead of nearly sharing one.
|
|
215
220
|
header: 'min-h-0 p-4 pb-0',
|
|
216
221
|
inner: overlay.value ? 'forest-canvas-chrome divide-y-0' : '',
|
|
222
|
+
// ★ THE PANEL OWNS DISMISS — the button it renders into `#actions`, wired to its own `open`. Stock adds a
|
|
223
|
+
// SECOND one under 1024px and it is inert: `canClose` is `close && collapsible !== 'none' ||
|
|
224
|
+
// isMobile`, and that `|| isMobile` survives this component's `collapsible="none"`, while the click
|
|
225
|
+
// writes USidebar's own `open` model — which this never binds and which changes nothing in the
|
|
226
|
+
// `collapsible="none"` branch (no `data-state`, no mobile menu). So it is a dead control, and on a
|
|
227
|
+
// phone it sat next to a live one.
|
|
228
|
+
//
|
|
229
|
+
// ★ Hidden through the theme rather than through `<template #close />`, which LOOKS like the tighter
|
|
230
|
+
// fix and does not work: Vue falls back to a slot's default content when the passed slot renders no
|
|
231
|
+
// valid vnode, so an empty template hands back exactly the button it was meant to suppress (measured
|
|
232
|
+
// — stock's close was still in the DOM). And not `:open` either: USidebar's
|
|
233
|
+
// `watch(isMobile, …, { immediate: true })` sets that model to `false` on crossing 1024px, so a bound
|
|
234
|
+
// panel would shut itself on a phone.
|
|
235
|
+
close: 'hidden',
|
|
217
236
|
}));
|
|
218
237
|
</script>
|
|
219
238
|
|
package/src/styles/forest.css
CHANGED
|
@@ -409,6 +409,20 @@
|
|
|
409
409
|
--forest-card-l2: color-mix(in oklab, var(--ui-bg), var(--ui-text-highlighted) 8%);
|
|
410
410
|
--forest-card-l3: color-mix(in oklab, var(--ui-bg), var(--ui-text-highlighted) 13%);
|
|
411
411
|
}
|
|
412
|
+
/* ── Inset surface — a RAISED panel sitting on a card, the inverse of the ramp above ──────────────
|
|
413
|
+
The ramp steps AWAY from the page (light darker, dark lighter), which reads as a recess. A panel
|
|
414
|
+
inset into a card's own ground is the opposite gesture: it should read as lifted, so it steps
|
|
415
|
+
TOWARD white in both modes — i.e. always lighter than the card it sits in.
|
|
416
|
+
Light lands back on the page plane (a white panel on a grey card, the dashboard look); dark cannot
|
|
417
|
+
do that, because the page is the darkest thing there, so it borrows the ramp's own next level.
|
|
418
|
+
Measured against a card at L1 (.967 light / .229 dark): this is .994 / .268 — lighter in both.
|
|
419
|
+
⚠ Declared on EVERY mode-boundary selector for the same reason the levels above are: the value
|
|
420
|
+
derives from --ui-bg, a custom property resolves ONCE where it is declared, and on :root alone a
|
|
421
|
+
nested force-light or force-dark island would inherit the outer mode's already-resolved colour.
|
|
422
|
+
⚠ NOT --ui-bg-elevated or --ui-bg-muted, which sound right and are not: both are DARKER than the
|
|
423
|
+
Forest card surface in dark (.156 against the card's .229), so a panel on them reads as a hole. */
|
|
424
|
+
:root, .light { --forest-card-inset: var(--ui-bg); }
|
|
425
|
+
.dark { --forest-card-inset: var(--forest-card-l2); }
|
|
412
426
|
/* Map — WHICH PALETTE OWNS WHICH REGION (José, 2026-07-26). Two rules, and they do not overlap:
|
|
413
427
|
the DISC and the BADGE take the VIVID set (plus neutrals). They are categorical: they say WHICH
|
|
414
428
|
kind of thing this is, and vivid separates further than the CVD-gated set does at marker size.
|
package/src/themes/checkbox.ts
CHANGED
|
@@ -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;
|
package/src/themes/index.ts
CHANGED
|
@@ -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;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forest theme fragment — USplitter (resizable panels with a draggable handle).
|
|
3
|
+
*
|
|
4
|
+
* New in Nuxt UI 4.11. Nothing in Forest uses it yet — FAppSidebar resizes through
|
|
5
|
+
* UDashboardSidebar's own `resizable`, and FSidePanel is deliberately two fixed rungs rather than a
|
|
6
|
+
* drag. The fragment is here for the same reason as progress-group's: the component is typeable by
|
|
7
|
+
* any consumer on the layer, so its focus ring should be the house one on arrival.
|
|
8
|
+
*
|
|
9
|
+
* Forest opinions applied:
|
|
10
|
+
*
|
|
11
|
+
* handle — the house focus ring: offset, and pinned to the ramp var rather than the semantic
|
|
12
|
+
* `outline-primary` token, matching link.ts, accordion.ts and carousel.ts. Stock's width is
|
|
13
|
+
* already `outline-2`, so only the offset and the colour are stated here.
|
|
14
|
+
*
|
|
15
|
+
* We do NOT override:
|
|
16
|
+
* - The handle's resting appearance. Stock paints nothing until focus, and that is right for a
|
|
17
|
+
* seam between two panels — see dashboard-resize-handle.ts for the case where Forest does draw
|
|
18
|
+
* a grab affordance, and note it does not port: that handle's box is zero-wide and sits ON the
|
|
19
|
+
* seam, so its pill is positioned against a zero-width edge. This one is a real 2-unit column.
|
|
20
|
+
* - `panel`, `root`, and the orientation cursors — layout and affordance, no Forest opinion.
|
|
21
|
+
*
|
|
22
|
+
* ★ If a split pane is ever built in Forest, it should be built on this rather than hand-rolled,
|
|
23
|
+
* and this fragment will need the resting/hover treatment that decision implies.
|
|
24
|
+
*
|
|
25
|
+
* Slots touched: handle.
|
|
26
|
+
* Slot names sourced from: apps/docs/node_modules/.nuxt-ui/ui/splitter.ts
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const splitterTheme = {
|
|
30
|
+
slots: {
|
|
31
|
+
handle: 'focus-visible:outline-offset-2 focus-visible:outline-[var(--forest-500)]',
|
|
32
|
+
},
|
|
33
|
+
} as const;
|