@humanforest/ui 0.1.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.
Files changed (235) hide show
  1. package/LICENSE +67 -0
  2. package/icons/forest/bike-asterisk.svg +21 -0
  3. package/icons/forest/bike-down.svg +19 -0
  4. package/icons/forest/bike-off.svg +20 -0
  5. package/icons/forest/bike-up.svg +19 -0
  6. package/icons/forest/bike-x.svg +19 -0
  7. package/icons/forest/bike.svg +18 -0
  8. package/icons/forest/map/forest-place-count.svg +21 -0
  9. package/icons/forest/map/forest-place-marker.svg +17 -0
  10. package/icons/forest/map/forest-vehicle-marker.svg +73 -0
  11. package/icons/forest/map/manifest.json +373 -0
  12. package/icons/forest/map/place-count-manifest.json +83 -0
  13. package/icons/forest/map/place-manifest.json +74 -0
  14. package/icons/forest/mark-fill.svg +6 -0
  15. package/icons/forest/mark.svg +6 -0
  16. package/icons/forest/parking.svg +3 -0
  17. package/icons/forest/star-fill.svg +4 -0
  18. package/icons/forest/star.svg +4 -0
  19. package/icons/forest/traffic-light-caution.svg +8 -0
  20. package/icons/forest/traffic-light-go.svg +8 -0
  21. package/icons/forest/traffic-light-lit.svg +8 -0
  22. package/icons/forest/traffic-light-stop.svg +8 -0
  23. package/icons/forest/traffic-light.svg +8 -0
  24. package/package.json +64 -0
  25. package/src/address/address.ts +125 -0
  26. package/src/address/autofill.ts +78 -0
  27. package/src/address/geocode.ts +55 -0
  28. package/src/address/index.ts +31 -0
  29. package/src/address/validate.ts +91 -0
  30. package/src/canvas/FCanvasCard.vue +97 -0
  31. package/src/canvas/FCanvasControl.vue +121 -0
  32. package/src/canvas/FCanvasControlGroup.vue +100 -0
  33. package/src/canvas/FCanvasFlyout.vue +216 -0
  34. package/src/canvas/FCanvasHud.vue +402 -0
  35. package/src/canvas/canvasSurface.ts +39 -0
  36. package/src/canvas/index.ts +17 -0
  37. package/src/contexts/ForestContext.vue +53 -0
  38. package/src/contexts/contexts.ts +153 -0
  39. package/src/contexts/index.ts +23 -0
  40. package/src/contexts/marketing-theme.ts +214 -0
  41. package/src/contexts/mobile-theme.ts +402 -0
  42. package/src/filter/FFilterBar.vue +72 -0
  43. package/src/filter/FFilterChip.vue +362 -0
  44. package/src/filter/FFilterClauses.vue +104 -0
  45. package/src/filter/FFilterTrigger.vue +183 -0
  46. package/src/filter/filter.theme.ts +27 -0
  47. package/src/filter/filterClause.ts +265 -0
  48. package/src/filter/filterPredicate.ts +134 -0
  49. package/src/filter/filterSchema.ts +82 -0
  50. package/src/filter/index.ts +36 -0
  51. package/src/forest-preset.ts +68 -0
  52. package/src/icons.ts +79 -0
  53. package/src/index.ts +17 -0
  54. package/src/kpi/FKpi.vue +253 -0
  55. package/src/kpi/FKpiGroup.vue +161 -0
  56. package/src/kpi/index.ts +20 -0
  57. package/src/kpi/kpi.theme.ts +168 -0
  58. package/src/kpi/kpiDelta.ts +82 -0
  59. package/src/kpi/kpiFormat.ts +72 -0
  60. package/src/kpi/kpiGroupState.ts +63 -0
  61. package/src/logo/FLogo.vue +52 -0
  62. package/src/logo/FSubLogo.vue +214 -0
  63. package/src/logo/index.ts +8 -0
  64. package/src/map/FMap.vue +355 -0
  65. package/src/map/FMapCompass.vue +154 -0
  66. package/src/map/FMapCoordinates.vue +134 -0
  67. package/src/map/FMapDrawTools.vue +116 -0
  68. package/src/map/FMapFullscreen.vue +92 -0
  69. package/src/map/FMapGeolocate.vue +208 -0
  70. package/src/map/FMapLayers.vue +75 -0
  71. package/src/map/FMapLegend.vue +45 -0
  72. package/src/map/FMapPopup.vue +129 -0
  73. package/src/map/FMapRamp.vue +69 -0
  74. package/src/map/FMapRegion.vue +77 -0
  75. package/src/map/FMapScale.vue +107 -0
  76. package/src/map/FMapSearch.vue +362 -0
  77. package/src/map/FMapTilt.vue +87 -0
  78. package/src/map/FMapTools.vue +61 -0
  79. package/src/map/FMapZoom.vue +81 -0
  80. package/src/map/FMarker.vue +218 -0
  81. package/src/map/FTrackScrubber.vue +94 -0
  82. package/src/map/FVehicleLegend.vue +72 -0
  83. package/src/map/FVehicleMark.vue +166 -0
  84. package/src/map/bin.ts +196 -0
  85. package/src/map/coordinates.ts +83 -0
  86. package/src/map/density.ts +401 -0
  87. package/src/map/draw.ts +356 -0
  88. package/src/map/ease.ts +67 -0
  89. package/src/map/fullscreen.ts +59 -0
  90. package/src/map/heat.ts +160 -0
  91. package/src/map/index.ts +351 -0
  92. package/src/map/liveMarkers.ts +60 -0
  93. package/src/map/map.css +116 -0
  94. package/src/map/mapColor.ts +31 -0
  95. package/src/map/mapContext.ts +12 -0
  96. package/src/map/mapLayers.ts +55 -0
  97. package/src/map/mapLegend.ts +70 -0
  98. package/src/map/mapSearch.theme.ts +79 -0
  99. package/src/map/mapSearch.ts +279 -0
  100. package/src/map/mapUrl.ts +117 -0
  101. package/src/map/markerAsset.ts +3 -0
  102. package/src/map/padding.ts +39 -0
  103. package/src/map/place.ts +192 -0
  104. package/src/map/ramp.ts +95 -0
  105. package/src/map/regionSelect.ts +163 -0
  106. package/src/map/route.ts +167 -0
  107. package/src/map/scale.ts +329 -0
  108. package/src/map/scaleBar.ts +67 -0
  109. package/src/map/selectionModel.ts +50 -0
  110. package/src/map/sources/coordinates.ts +58 -0
  111. package/src/map/sources/mapboxPlaces.ts +71 -0
  112. package/src/map/style.ts +14 -0
  113. package/src/map/track.ts +279 -0
  114. package/src/map/useFeatureSelect.ts +230 -0
  115. package/src/map/useLiveMarkers.ts +84 -0
  116. package/src/map/useMap.ts +150 -0
  117. package/src/map/useMapDraw.ts +776 -0
  118. package/src/map/useMapOverlay.ts +421 -0
  119. package/src/map/useMapTableSelection.ts +101 -0
  120. package/src/map/useMapUrlState.ts +155 -0
  121. package/src/map/useMarkerSource.ts +163 -0
  122. package/src/map/useRegionSelect.ts +309 -0
  123. package/src/map/useTokenMode.ts +61 -0
  124. package/src/map/useTrackPlayback.ts +131 -0
  125. package/src/map/useVehicleFilter.ts +51 -0
  126. package/src/map/useVisibleFeatures.ts +124 -0
  127. package/src/map/vehicle.ts +807 -0
  128. package/src/map/vehicleFilter.ts +284 -0
  129. package/src/map/vehicleLegend.ts +155 -0
  130. package/src/map/visibleFeatures.ts +192 -0
  131. package/src/map/zone.ts +291 -0
  132. package/src/shell/FAppNavbar.vue +68 -0
  133. package/src/shell/FAppShell.vue +163 -0
  134. package/src/shell/FAppSidebar.vue +215 -0
  135. package/src/shell/FPageToolbar.vue +126 -0
  136. package/src/shell/FScrollShadow.vue +43 -0
  137. package/src/shell/FSidePanel.vue +258 -0
  138. package/src/shell/FToolbarActions.vue +32 -0
  139. package/src/shell/index.ts +33 -0
  140. package/src/shell/nav.ts +37 -0
  141. package/src/shell/pageToolbar.ts +69 -0
  142. package/src/shell/shell.ts +74 -0
  143. package/src/shell/sidePanel.ts +115 -0
  144. package/src/styles/forest.css +852 -0
  145. package/src/styles/index.css +39 -0
  146. package/src/themes/accordion.ts +36 -0
  147. package/src/themes/alert.ts +16 -0
  148. package/src/themes/auth-form.ts +19 -0
  149. package/src/themes/avatar-group.ts +9 -0
  150. package/src/themes/avatar.ts +18 -0
  151. package/src/themes/badge.ts +51 -0
  152. package/src/themes/banner.ts +21 -0
  153. package/src/themes/breadcrumb.ts +10 -0
  154. package/src/themes/button.mechanics.ts +191 -0
  155. package/src/themes/button.ts +136 -0
  156. package/src/themes/calendar.ts +29 -0
  157. package/src/themes/card.ts +67 -0
  158. package/src/themes/carousel.ts +41 -0
  159. package/src/themes/checkbox-group.ts +20 -0
  160. package/src/themes/checkbox.ts +47 -0
  161. package/src/themes/chip.ts +50 -0
  162. package/src/themes/collapsible.ts +26 -0
  163. package/src/themes/color-picker.ts +13 -0
  164. package/src/themes/command-palette.ts +37 -0
  165. package/src/themes/container.ts +14 -0
  166. package/src/themes/context-menu.ts +38 -0
  167. package/src/themes/dashboard-group.ts +11 -0
  168. package/src/themes/dashboard-navbar.ts +10 -0
  169. package/src/themes/dashboard-panel.ts +18 -0
  170. package/src/themes/dashboard-resize-handle.ts +35 -0
  171. package/src/themes/dashboard-sidebar-collapse.ts +7 -0
  172. package/src/themes/dashboard-sidebar-toggle.ts +7 -0
  173. package/src/themes/dashboard-sidebar.ts +58 -0
  174. package/src/themes/dashboard-toolbar.ts +15 -0
  175. package/src/themes/drawer.ts +45 -0
  176. package/src/themes/dropdown-menu.ts +38 -0
  177. package/src/themes/empty.ts +43 -0
  178. package/src/themes/field-group.ts +52 -0
  179. package/src/themes/file-upload.ts +38 -0
  180. package/src/themes/footer-columns.ts +13 -0
  181. package/src/themes/footer.ts +9 -0
  182. package/src/themes/form-field.ts +25 -0
  183. package/src/themes/form.ts +22 -0
  184. package/src/themes/header.ts +12 -0
  185. package/src/themes/icon.ts +61 -0
  186. package/src/themes/index.ts +99 -0
  187. package/src/themes/input-date.ts +73 -0
  188. package/src/themes/input-menu.ts +66 -0
  189. package/src/themes/input-number.ts +58 -0
  190. package/src/themes/input-rating.ts +25 -0
  191. package/src/themes/input-tags.ts +63 -0
  192. package/src/themes/input-time.ts +70 -0
  193. package/src/themes/input.ts +148 -0
  194. package/src/themes/kbd.ts +27 -0
  195. package/src/themes/link.ts +27 -0
  196. package/src/themes/listbox.ts +48 -0
  197. package/src/themes/main.ts +6 -0
  198. package/src/themes/marquee.ts +45 -0
  199. package/src/themes/modal.ts +46 -0
  200. package/src/themes/navigation-menu.ts +80 -0
  201. package/src/themes/page-anchors.ts +7 -0
  202. package/src/themes/page-aside.ts +7 -0
  203. package/src/themes/page-card.ts +22 -0
  204. package/src/themes/page-cta.ts +18 -0
  205. package/src/themes/page-feature.ts +13 -0
  206. package/src/themes/page-header.ts +16 -0
  207. package/src/themes/page-hero.ts +15 -0
  208. package/src/themes/page-links.ts +12 -0
  209. package/src/themes/page-section.ts +14 -0
  210. package/src/themes/page.ts +7 -0
  211. package/src/themes/pagination.ts +17 -0
  212. package/src/themes/pin-input.ts +73 -0
  213. package/src/themes/popover.ts +30 -0
  214. package/src/themes/progress.ts +29 -0
  215. package/src/themes/prose.ts +45 -0
  216. package/src/themes/radio-group.ts +39 -0
  217. package/src/themes/scroll-area.ts +23 -0
  218. package/src/themes/select-menu.ts +71 -0
  219. package/src/themes/select.ts +72 -0
  220. package/src/themes/separator.ts +28 -0
  221. package/src/themes/sidebar.ts +127 -0
  222. package/src/themes/skeleton.ts +15 -0
  223. package/src/themes/slideover.ts +87 -0
  224. package/src/themes/slider.ts +48 -0
  225. package/src/themes/stepper.ts +41 -0
  226. package/src/themes/switch.ts +63 -0
  227. package/src/themes/table.ts +34 -0
  228. package/src/themes/tabs.ts +74 -0
  229. package/src/themes/textarea.ts +77 -0
  230. package/src/themes/timeline.ts +25 -0
  231. package/src/themes/toast.ts +29 -0
  232. package/src/themes/toaster.ts +66 -0
  233. package/src/themes/tooltip.ts +31 -0
  234. package/src/themes/tree.ts +41 -0
  235. package/src/themes/user.ts +28 -0
@@ -0,0 +1,39 @@
1
+ /* @humanforest/ui CSS barrel — the whole Forest layer for a Nuxt UI app, in the correct order.
2
+ The app owns the framework base; import this AFTER it:
3
+ @import "tailwindcss";
4
+ @import "@nuxt/ui";
5
+ @import "@humanforest/ui/css";
6
+ This barrel is font-free — fonts are the consumer's choice. Bring your own via a CDN <link> to
7
+ forest-fonts.cdn.css, an @import "@humanforest/fonts/forest-fonts.cdn.css", or your own
8
+ self-hosted @font-face rules.
9
+ Charts are opt-in (heavy palette) — add separately when needed:
10
+ @import "@humanforest/tokens/dataviz/dataviz.css";
11
+ @import "@humanforest/tokens/dataviz/unovis.css";
12
+ In-repo these resolve via relative paths; the @humanforest/* specifiers above are the published-package
13
+ equivalents (same files via each package's exports map). */
14
+
15
+ /* Colour: the engine ramp (P3) then its Tailwind/Nuxt UI registration. Order matters — theme.css
16
+ references the ramp vars. */
17
+ @import "@humanforest/tokens/scales/ramps.css";
18
+ @import "@humanforest/tokens/scales/theme.css";
19
+
20
+ /* Typography scale (--type-* + .type-<role> utilities). */
21
+ @import "@humanforest/tokens/type/type.css";
22
+
23
+ /* Motion ladder (--motion-* + the system-wide reduced-motion floor) and its Tailwind half, which
24
+ re-points --default-transition-duration/-timing-function at the ladder. BOTH are required: unlike
25
+ tokens/type/type.theme.css — which is genuinely opt-in, because the ramp it registers already IS
26
+ Tailwind's default scale — dropping motion.theme.css hands every bare `transition` in the stock
27
+ Nuxt UI recipes back to Tailwind's 150ms/`ease`, off the doctrine entirely. */
28
+ @import "@humanforest/tokens/motion/motion.css";
29
+ @import "@humanforest/tokens/motion/motion.theme.css";
30
+
31
+ /* Non-colour brand layer: font vars, radius, .font-display, the coarse: variant. */
32
+ @import "./forest.css";
33
+
34
+ /* Class-based dark mode (toggled on <html>). A nested `.light` re-lights its subtree — so a
35
+ locked-light island (marketing context canvases) suppresses `dark:` for the components inside,
36
+ matching how forest.css re-points its plain vars for `:root:not(.dark), .light`. Without the
37
+ `:not(.light …)` exclusion, a component that sets colours via `dark:` utilities (the button's
38
+ `dark:[--btn-*]`) keeps its dark values even on a `.light` canvas. */
39
+ @custom-variant dark (&:where(.dark, .dark *):not(:where(.light, .light *)));
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Forest theme fragment — UAccordion (collapsible sections).
3
+ *
4
+ * Forest opinions applied:
5
+ * - `trigger`: font-bold on the trigger label (stock is font-medium) — lifts the section heading
6
+ * above body copy in the two-weight type system; adds a brand focus ring using the forest-500
7
+ * ramp var so tab navigation is clearly visible. Stock's `outline-primary/25` resolves correctly
8
+ * but we anchor to the ramp directly for determinism across all consumer surfaces.
9
+ * - `item`: the default border-b divider is retained (it reads cleanly); no radius override needed
10
+ * at the item level — the root container carries any shell radius from the consumer.
11
+ * - `body`: text-sm is stock; we add text-muted so body copy reads at the Forest semantic muted
12
+ * level (slightly softer than text-default), keeping content subordinate to the trigger label.
13
+ * - `content` / `trailingIcon`: the height animation and the chevron rotate move onto the shared
14
+ * motion tokens (stock is a raw 200 ms both). The accordion runs the same keyframe pair as the
15
+ * collapsible, so it takes the same timing — --motion-slow for the height travel — and the
16
+ * chevron, a small near element, takes --motion-fast.
17
+ *
18
+ * Slots touched: trigger, body, content, trailingIcon.
19
+ * Slot names sourced from: apps/docs/node_modules/.nuxt-ui/ui/accordion.ts
20
+ */
21
+
22
+ export const accordionTheme = {
23
+ slots: {
24
+ // font-bold: clear heading hierarchy; focus ring pinned to forest-500 ramp var for
25
+ // dark-mode correctness (stock outline-primary resolves correctly but we anchor to the ramp
26
+ // directly for determinism across all consumer surfaces).
27
+ trigger: 'font-bold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-forest-500)]',
28
+ // text-muted: subordinates body content below the trigger label in the Forest reading hierarchy.
29
+ body: 'text-muted',
30
+ // Height travel on --motion-slow, matching the collapsible (same keyframes, same bucket).
31
+ content:
32
+ 'data-[state=open]:animate-[accordion-down_var(--motion-slow)_var(--motion-ease-out)] data-[state=closed]:animate-[accordion-up_var(--motion-slow)_var(--motion-ease-out)]',
33
+ // Chevron rotate — a small near element, so --motion-fast.
34
+ trailingIcon: 'duration-(--motion-fast) ease-(--motion-ease-out)',
35
+ },
36
+ } as const;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Forest theme fragment — UAlert (inline message banner, all intents).
3
+ *
4
+ * Deltas only — the preset APPENDS to stock, so restating stock's layout classes would only invite
5
+ * drift. One change:
6
+ * - `title`: font-bold (over stock font-medium) — clear hierarchy above the description
7
+ * without font-bold, which is too heavy for inline copy.
8
+ * Everything else (root layout/radius, text-sm, description opacity-90, icon/actions/close) is stock.
9
+ * The bold contexts round the banner corner up to rounded-xl (mobile/marketing).
10
+ */
11
+
12
+ export const alertTheme = {
13
+ slots: {
14
+ title: 'font-bold',
15
+ },
16
+ } as const;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Forest theme fragment for UAuthForm.
3
+ *
4
+ * UAuthForm ships the contents of a sign-in screen — fields, provider buttons, the separator — and
5
+ * takes its title and description sizes from Tailwind's default scale (`text-xl` and `text-base`)
6
+ * rather than from Forest's. That is a second type ladder running beside the real one and drifting the
7
+ * moment the real one moves. Both slots move onto the ladder: `.type-title` and `.type-body`, which
8
+ * carry size, line-height, tracking and family together.
9
+ *
10
+ * Weight was never the problem — `styles/forest.css` remaps `--font-weight-semibold` to 700 in
11
+ * `@theme`, so stock's `font-semibold` already lands on a cut the brand fonts ship.
12
+ */
13
+
14
+ export const authFormTheme = {
15
+ slots: {
16
+ title: 'type-title',
17
+ description: 'type-body',
18
+ },
19
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Forest theme fragment — UAvatarGroup. No delta: the stock recipe is correct as-is.
3
+ *
4
+ * - The overlap ring is ring-bg (surface-coloured), so it adapts to light/dark and to tinted
5
+ * surfaces through the semantic token — nothing to re-point.
6
+ * - Shape and sizing ride UAvatar, which carries the Forest opinion in its own fragment.
7
+ */
8
+
9
+ export const avatarGroupTheme = {} as const
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Forest theme fragment — Avatar / AvatarGroup
3
+ *
4
+ * Deltas only (the preset APPENDS to stock, so this carries just what changes):
5
+ * - root: `font-bold` — confident initials; stock root is unweighted.
6
+ * - fallback: `font-bold` — stock is `font-medium`, which reads thin at xs/2xs on the tint fills.
7
+ *
8
+ * Everything else is stock and correct: rounded-full shape, the /10 colour-ramp fills
9
+ * (bg-{color}/10 · text-{color}, wired to the Forest ramps in docs.css), the 3xs–3xl ladder,
10
+ * `object-cover rounded-[inherit]` images, and the AvatarGroup `ring-bg` separator. AvatarGroup
11
+ * needs no override.
12
+ */
13
+ export const avatarTheme = {
14
+ slots: {
15
+ root: 'font-bold',
16
+ fallback: 'font-bold',
17
+ },
18
+ } as const;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Forest theme fragment for UBadge.
3
+ *
4
+ * Standard badge is a soft-cornered tag (rounded-sm/8px), not a pill: rectangular enough to
5
+ * read as a label on a data-dense web surface. Bold, uppercase, wide-tracked lettering is the
6
+ * Forest opinion at every scale; the bold contexts round it to a full pill (mobile/marketing).
7
+ *
8
+ * Stock UBadge sets the corner PER SIZE (rounded-sm on xs/sm, rounded-md on md/lg/xl), and tv
9
+ * applies the size variant after the base slot — so `rounded-sm` in base alone would lose to
10
+ * stock's rounded-md at md/lg/xl. Follow stock's shape: override the corner in the size
11
+ * variants (tv deep-merges variants, tailwind-merge collapses the per-size token). The Forest
12
+ * ladder tightens toward the small end: xs/sm rounded-xs, md/lg/xl rounded-sm. The base copy
13
+ * (rounded-sm) is the representative Standard value the derived contexts card reads.
14
+ */
15
+
16
+ export const badgeTheme = {
17
+ slots: { base: 'rounded-sm font-bold uppercase tracking-wide' },
18
+ variants: {
19
+ // Per size: the Forest radius override (see comment above) PLUS a `coarse:` touch bump.
20
+ // Under a coarse primary pointer each size renders as the NEXT SIZE UP — the full bundle
21
+ // (text, padding, gap, radius, icon), so a touched md badge reads like an lg badge. Unlike
22
+ // the button this is legibility, not a tap target (a badge is static) — a chip at arm's
23
+ // length on a phone wants the bigger step. `coarse:` is the custom variant in
24
+ // styles/forest.css: it fires only on a coarse pointer AND skips `.cut-fixed` elements, so
25
+ // the mobile cut (which bakes its own increase and stamps cut-fixed) opts out and doesn't
26
+ // double-promote; marketing has no cut-fixed, so it adapts like standard. xl is the ceiling.
27
+ size: {
28
+ xs: { base: 'rounded-xs coarse:text-[10px]/3 coarse:px-1.5 coarse:py-1' },
29
+ sm: {
30
+ base: 'rounded-xs coarse:text-xs coarse:px-2 coarse:rounded-sm',
31
+ leadingIcon: 'coarse:size-4',
32
+ trailingIcon: 'coarse:size-4',
33
+ },
34
+ md: {
35
+ base: 'rounded-sm coarse:text-sm coarse:gap-1.5',
36
+ leadingIcon: 'coarse:size-5',
37
+ trailingIcon: 'coarse:size-5',
38
+ },
39
+ lg: {
40
+ base: 'rounded-sm coarse:text-base coarse:px-2.5',
41
+ leadingIcon: 'coarse:size-6',
42
+ trailingIcon: 'coarse:size-6',
43
+ },
44
+ xl: { base: 'rounded-sm' },
45
+ },
46
+ },
47
+ // A badge labels something; it does not ask to be pressed. Subtle (tinted fill + inset ring)
48
+ // rather than stock's solid, which reads as a control. Being ringed, it also carries the bold
49
+ // contexts' 2px edge.
50
+ defaultVariants: { variant: 'subtle' },
51
+ } as const
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Forest theme fragment — UBanner.
3
+ *
4
+ * Deltas only (the preset appends to stock, so nothing stock is restated):
5
+ * • title — steps up to font-bold so the copy holds against every solid brand fill, and
6
+ * takes `me-auto` to shove the actions to the far right.
7
+ * • layout — Forest reads the strip as title-LEFT / actions-RIGHT. Stock centres the title
8
+ * between two flex-1 spacers (left/right) with the actions beside it; we drop those
9
+ * spacers (`lg:flex-none`) and let the centre grow (`flex-1`) so the actions land at
10
+ * the end of the row, just before the close button.
11
+ * The banner has no radius by design — it spans the viewport edge to edge.
12
+ */
13
+
14
+ export const bannerTheme = {
15
+ slots: {
16
+ left: 'lg:flex-none',
17
+ center: 'flex-1',
18
+ title: 'font-bold me-auto',
19
+ right: 'lg:flex-none',
20
+ },
21
+ } as const;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Forest theme fragment — UBreadcrumb. No delta: the stock recipe is correct as-is.
3
+ *
4
+ * - Inactive crumb text-muted with hover:text-default, active crumb font-semibold (bold via
5
+ * the two-weight re-map), per-colour focus ring, text-muted separator — all right by default.
6
+ * - In a two-weight system there is no intermediate label weight to seat, so the crumb ladder
7
+ * is regular → bold exactly as stock wires it.
8
+ */
9
+
10
+ export const breadcrumbTheme = {} as const
@@ -0,0 +1,191 @@
1
+ /**
2
+ * UButton mechanics — the shared emphasis recipes and per-colour palettes consumed by ./button.ts.
3
+ * Private to the button theme: deliberately NOT re-exported by ./index.ts, so none of this reaches
4
+ * forestPreset.ui as a component key.
5
+ *
6
+ * NOTE: these class strings MUST be literal — Tailwind extracts utilities by scanning source text,
7
+ * so no template interpolation here.
8
+ */
9
+
10
+ export const FOCUS = 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--btn-ring)]';
11
+
12
+ // Emphasis classes — each consumes the per-colour --btn-* vars, so one string covers every intent.
13
+ // The keycap keeps the lip ANCHORED (a box-shadow lip moves with the button — the jank). The whole
14
+ // button (face + label) is the moving key; ::before is the lip slab behind it. ::before
15
+ // counter-translates by the EXACT inverse of the button's lift (--btn-yi/--btn-yhi), so it cancels
16
+ // out and sits at absolute 0 in every state — the key rises/presses, the base never moves.
17
+ // The keycap is built from TWO pseudo-layers, both BELOW the inline label (negative z), so the
18
+ // label always shows. ::after = the face (--btn-face + a 1px --btn-lip border); ::before = the base
19
+ // slab (--btn-lip). The button is a transparent frame that translates — the label + ::after face ride
20
+ // with it (lift on hover, flush on press), while ::before counter-translates by the exact inverse
21
+ // so it stays pinned at net 0. The lip NEVER moves; only the key does.
22
+ // NOTE: Tailwind v4 translate utilities set the `translate` property (not `transform`) — the
23
+ // transition MUST list `translate` or the motion snaps.
24
+ export const KEYCAP = [
25
+ // bg lives on ::after — keep the button frame transparent in every state (clears base-theme
26
+ // `hover:bg-primary` / `active:bg-primary` leaks that would otherwise sit behind the face).
27
+ 'relative isolate overflow-visible border-0 text-[var(--btn-ink)]',
28
+ 'bg-transparent hover:bg-transparent active:bg-transparent disabled:bg-transparent aria-disabled:bg-transparent',
29
+ // --btn-y-focus / --btn-yi-focus = the FOCUS lift + its ::before counter. Default to the REST values
30
+ // (focus moves nothing — web leans on the ::before ring below). The mobile theme overrides them to a
31
+ // -7px pop, the sole focus signal on touch (no hover to lean on). Living on the KEYCAP recipe (PAD
32
+ // inherits it) is what scopes the pop to the lip variants: the flat variants never read these vars, so
33
+ // the slot-level mobile base can't make them jump on focus.
34
+ // --btn-ring-w = the ::before focus-ring width; mobile sets it to 0 (pop replaces the ring). Routing
35
+ // the ring through a VAR (instead of a `before:outline-0!` override in the mobile base) is deliberate:
36
+ // a `before:` utility on the slot-level base auto-adds `content:''`, which on the flat variants — whose
37
+ // ::before is NOT absolute — becomes an in-flow flex item, and `gap-1.5` then shoves the label right
38
+ // (a phantom leading gap on focus). Keeping the only `before:` here, where ::before is absolute, avoids it.
39
+ '[--btn-y-focus:var(--btn-y)] [--btn-yi-focus:var(--btn-yi)] [--btn-ring-w:2px]',
40
+ // motion-reduce: the lift/press still HAPPENS (it's state, not decoration) but snaps instead of
41
+ // animating — no positional animation under prefers-reduced-motion (WCAG 2.3.3).
42
+ 'translate-y-[var(--btn-y)] transition-[translate] duration-(--motion-instant) ease-(--motion-ease-out) motion-reduce:transition-none',
43
+ 'hover:translate-y-[var(--btn-y-h)] active:translate-y-0 focus-visible:translate-y-[var(--btn-y-focus)]',
44
+ 'disabled:translate-y-0 aria-disabled:translate-y-0',
45
+ // face — rides with the button (label sits above it), darkens on hover/press
46
+ // Edge = a CSS border on the absolute, border-box ::after (face). No layout shift (the pseudo is out
47
+ // of flow; border draws inside inset-0), even/concentric corners, no sliver. Width = --btn-edge (1px web,
48
+ // 2px mobile via the platform override).
49
+ "after:content-[''] after:absolute after:inset-0 after:-z-10 after:rounded-[inherit] after:bg-[var(--btn-face)] after:border-solid after:[border-width:var(--btn-edge,1px)] after:border-[var(--btn-lip)] after:transition-[background-color] after:duration-(--motion-instant) after:ease-(--motion-ease-out)",
50
+ 'hover:after:bg-[var(--btn-hover)] active:after:bg-[var(--btn-hover)]',
51
+ // base slab. Bottom stays anchored (counter-translate cancels the button's lift); the TOP tracks
52
+ // the face lift (--btn-y per state) so the slab spans the FULL keycap silhouette — face top → lip
53
+ // bottom — in EVERY state (lifted at rest/hover, flush when pressed/disabled). The visible lip is
54
+ // still only the bottom strip: the extended top sits behind the opaque face. `top` animates with
55
+ // the lift so the ring tracks smoothly.
56
+ // Lip = a STATIC slab (full footprint, constant shape). It never transforms: the key (button +
57
+ // face) translates, and ::before counter-translates by the exact inverse so it stays anchored at
58
+ // net-0 in every state. Only the face moving over it reveals/hides the lip — the lip never morphs.
59
+ "before:content-[''] before:absolute before:inset-x-0 before:bottom-0 before:top-[var(--btn-y)] before:-z-20 before:rounded-[inherit] before:bg-[var(--btn-lip)]",
60
+ 'before:translate-y-[var(--btn-yi)] before:transition-[translate,top] before:duration-(--motion-instant) before:ease-(--motion-ease-out) before:motion-reduce:transition-none',
61
+ 'hover:before:translate-y-[var(--btn-yhi)] hover:before:top-[var(--btn-y-h)] active:before:translate-y-0 active:before:top-0',
62
+ // ::before tracks the FOCUS lift so the slab stays anchored when the key pops (mobile); on web the
63
+ // focus vars equal the rest values, so it doesn't budge.
64
+ 'focus-visible:before:top-[var(--btn-y-focus)] focus-visible:before:translate-y-[var(--btn-yi-focus)]',
65
+ 'disabled:before:translate-y-0 disabled:before:top-0 aria-disabled:before:translate-y-0 aria-disabled:before:top-0',
66
+ // Focus ring rides on ::before — which now spans the full silhouette — NOT the button, so it wraps
67
+ // the whole key with an EVEN 2px gap on all sides in every state. outline-none kills the button's
68
+ // UA default ring; rounded-[inherit] keeps the corners matched.
69
+ 'focus-visible:outline-none focus-visible:before:outline-[length:var(--btn-ring-w)] focus-visible:before:outline-offset-2 focus-visible:before:outline-[var(--btn-ring)]',
70
+ ].join(' ');
71
+
72
+ export const PAD = [
73
+ // Quiet tactile key — the KEYCAP engine fed the TINT ramp instead of the solid face: tint fill
74
+ // (t1→t2→t3), a line-colour border + lip, dark text. Same lift/press/anchored focus ring as the
75
+ // keycap, lower emphasis (subtle's calm + the keycap's feel). Each remap is declared TWICE — plain
76
+ // AND `dark:` — because the colour PALs set `dark:--btn-*`, and a `.dark`-scoped rule outranks a
77
+ // plain one; without the dark: copy the PAL's dark face beats the remap (pad == keycap in dark).
78
+ // var(--btn-t1) etc. are already mode-aware, so both copies point at the live tint value.
79
+ '[--btn-face:var(--btn-t1)] dark:[--btn-face:var(--btn-t1)] [--btn-hover:var(--btn-t2)] dark:[--btn-hover:var(--btn-t2)] [--btn-press:var(--btn-t3)] dark:[--btn-press:var(--btn-t3)] [--btn-lip:var(--btn-line)] dark:[--btn-lip:var(--btn-line)] [--btn-ink:var(--btn-text)] dark:[--btn-ink:var(--btn-text)]',
80
+ KEYCAP,
81
+ ].join(' ');
82
+
83
+ export const FRAMED = [
84
+ // Filled face + an INSET RING border (Nuxt-style: a box-shadow, NOT a CSS border — so it adds no
85
+ // height, unlike `border-2`, keeping every variant the same size). The lip demotes to the ring. No
86
+ // lip, no motion; leans on face→hover→press. Keeps light-face fills (amber, secondary) visible.
87
+ 'ring-[length:var(--btn-edge,1px)] ring-inset ring-[var(--btn-lip)] text-[var(--btn-ink)] bg-[var(--btn-face)]',
88
+ 'hover:bg-[var(--btn-hover)] active:bg-[var(--btn-press)]',
89
+ FOCUS,
90
+ ].join(' ');
91
+
92
+ export const SOLID = [
93
+ // Plain filled face — no lip, no border, no motion (the standard Nuxt-style solid). Feedback
94
+ // comes from the face→hover→press colour steps. A light face (amber, secondary) has no edge on a
95
+ // light surface by design — reach for `framed` when a light solid needs a border.
96
+ 'border-0 text-[var(--btn-ink)] bg-[var(--btn-face)]',
97
+ 'hover:bg-[var(--btn-hover)] active:bg-[var(--btn-press)]',
98
+ FOCUS,
99
+ ].join(' ');
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(' ');
117
+
118
+ export const SUBTLE = [
119
+ // A ramp-pinned INSET RING (a box-shadow, not a border → no added height) over a plain surface fill.
120
+ // Our width + colour override Nuxt's native subtle ring (`ring ring-inset ring-{color}/25`), so
121
+ // there's a single edge; `ring-inset` itself comes from that stock compound (deltas only). Same
122
+ // ring approach as OUTLINE. Rest sits on `bg-default` (not the --btn-t1 tint) so it reads like a
123
+ // select's outline field — white at rest, tinting to --btn-t2 on hover — rather than a filled slab.
124
+ 'ring-[length:var(--btn-edge,1px)] ring-[var(--btn-line)] text-[var(--btn-text)] bg-default',
125
+ 'hover:bg-[var(--btn-t2)] active:bg-[var(--btn-t3)]',
126
+ FOCUS,
127
+ ].join(' ');
128
+
129
+ export const OUTLINE = [
130
+ // Inset ring (box-shadow), NOT a CSS border — overrides Nuxt's native outline ring's width and
131
+ // colour and adds no height, so every variant stays the same size. `ring-inset` itself comes
132
+ // from the stock outline compound (deltas only); FRAMED keeps its own — no stock counterpart.
133
+ 'ring-[length:var(--btn-edge,1px)] ring-[var(--btn-line)] text-[var(--btn-text)] bg-transparent',
134
+ // Border holds at --btn-line through hover — only the fill tints — so it reads like an input, not a
135
+ // ring that darkens on approach.
136
+ 'hover:bg-[var(--btn-t1)] active:bg-[var(--btn-t2)]',
137
+ FOCUS,
138
+ ].join(' ');
139
+
140
+ export const SOFT = [
141
+ // Tinted fill: rest t1 → hover t2 → press t3.
142
+ 'text-[var(--btn-text)] bg-[var(--btn-t1)]',
143
+ 'hover:bg-[var(--btn-t2)] active:bg-[var(--btn-t3)]',
144
+ FOCUS,
145
+ ].join(' ');
146
+
147
+ export const GHOST = [
148
+ // Transparent rest → hover t1 → press t2.
149
+ 'text-[var(--btn-text)] bg-transparent',
150
+ 'hover:bg-[var(--btn-t1)] active:bg-[var(--btn-t2)]',
151
+ FOCUS,
152
+ ].join(' ');
153
+
154
+ export const LINK = [
155
+ // Text-only, link-styled — no fill, no border. Underlines on hover and deepens to the press
156
+ // colour. Tertiary actions ("Learn more"). Inherits the size padding so it lines up in a button row.
157
+ 'border-0 bg-transparent text-[var(--btn-text)] underline-offset-4',
158
+ 'hover:underline hover:text-[var(--btn-press)] active:text-[var(--btn-press)]',
159
+ FOCUS,
160
+ ].join(' ');
161
+
162
+ // Docs mirror: apps/docs/src/pages/components/ButtonPage.vue (Specs + Engineering notes) restates
163
+ // these recipes in prose — keep both in sync.
164
+ // Per-colour --btn-* palettes. LITERAL strings (Tailwind-scannable), sourced from the OKLCH ramp
165
+ // (--<family>-50..950). Solids step one level lighter in dark so they pop on a dark surface; the
166
+ // tint / text / line layers flip too, since the ramp is monotonic. Ramp levels — light / dark:
167
+ // face 500/400 · hover 600/500 · press 700/600 · lip 700/600 · ring 500 · t1 100/900 · t2 200/800 · t3 300/700 · line 400/600 · text 700/300 · ink 0/950
168
+ // On-solid ink follows FACE lightness, not mode: a dark face takes the light 0, a light face the dark 950.
169
+ // Solids use 50 in light / 950 in dark; amber's face is light in BOTH modes, so it's 950 in both — same
170
+ // rule, not a special case. Secondary→warm is a standard colour key (face 500/400). Neutral is the
171
+ // exception: Nuxt-style monochrome — face-based variants invert (900/50), the rest are surface greys.
172
+ export const PAL_FOREST = '[--btn-face:var(--forest-500)] dark:[--btn-face:var(--forest-400)] [--btn-hover:var(--forest-600)] dark:[--btn-hover:var(--forest-500)] [--btn-press:var(--forest-700)] dark:[--btn-press:var(--forest-600)] [--btn-lip:var(--forest-700)] dark:[--btn-lip:var(--forest-600)] [--btn-ring:var(--forest-500)] [--btn-t1:var(--forest-100)] dark:[--btn-t1:var(--forest-900)] [--btn-t2:var(--forest-200)] dark:[--btn-t2:var(--forest-800)] [--btn-t3:var(--forest-300)] dark:[--btn-t3:var(--forest-700)] [--btn-line:var(--forest-400)] dark:[--btn-line:var(--forest-600)] [--btn-text:var(--forest-700)] dark:[--btn-text:var(--forest-300)] [--btn-ink:var(--forest-0)] dark:[--btn-ink:var(--forest-950)]';
173
+ export const PAL_RIVER = '[--btn-face:var(--river-500)] dark:[--btn-face:var(--river-400)] [--btn-hover:var(--river-600)] dark:[--btn-hover:var(--river-500)] [--btn-press:var(--river-700)] dark:[--btn-press:var(--river-600)] [--btn-lip:var(--river-700)] dark:[--btn-lip:var(--river-600)] [--btn-ring:var(--river-500)] [--btn-t1:var(--river-100)] dark:[--btn-t1:var(--river-900)] [--btn-t2:var(--river-200)] dark:[--btn-t2:var(--river-800)] [--btn-t3:var(--river-300)] dark:[--btn-t3:var(--river-700)] [--btn-line:var(--river-400)] dark:[--btn-line:var(--river-600)] [--btn-text:var(--river-700)] dark:[--btn-text:var(--river-300)] [--btn-ink:var(--river-0)] dark:[--btn-ink:var(--river-950)]';
174
+ export const PAL_MAPLE = '[--btn-face:var(--maple-500)] dark:[--btn-face:var(--maple-400)] [--btn-hover:var(--maple-600)] dark:[--btn-hover:var(--maple-500)] [--btn-press:var(--maple-700)] dark:[--btn-press:var(--maple-600)] [--btn-lip:var(--maple-700)] dark:[--btn-lip:var(--maple-600)] [--btn-ring:var(--maple-500)] [--btn-t1:var(--maple-100)] dark:[--btn-t1:var(--maple-900)] [--btn-t2:var(--maple-200)] dark:[--btn-t2:var(--maple-800)] [--btn-t3:var(--maple-300)] dark:[--btn-t3:var(--maple-700)] [--btn-line:var(--maple-400)] dark:[--btn-line:var(--maple-600)] [--btn-text:var(--maple-700)] dark:[--btn-text:var(--maple-300)] [--btn-ink:var(--maple-0)] dark:[--btn-ink:var(--maple-950)]';
175
+ // Warning = light solid: a 300 face with frozen dark ink (a dark amber would fail dark-ink contrast).
176
+ export const PAL_AMBER = '[--btn-face:var(--amber-300)] [--btn-hover:var(--amber-400)] [--btn-press:var(--amber-500)] [--btn-lip:var(--amber-600)] [--btn-ring:var(--amber-500)] [--btn-t1:var(--amber-100)] dark:[--btn-t1:var(--amber-900)] [--btn-t2:var(--amber-200)] dark:[--btn-t2:var(--amber-800)] [--btn-t3:var(--amber-300)] dark:[--btn-t3:var(--amber-700)] [--btn-line:var(--amber-400)] dark:[--btn-line:var(--amber-600)] [--btn-text:var(--amber-700)] dark:[--btn-text:var(--amber-300)] [--btn-ink:var(--amber-950)]';
177
+ // Secondary = warm intent — standard colour key (face 500/400 like the brand families); white ink on
178
+ // the dark 500 face, dark ink on the lighter 400 in dark mode. Own warm focus ring.
179
+ export const PAL_SECONDARY = '[--btn-face:var(--warm-500)] dark:[--btn-face:var(--warm-400)] [--btn-hover:var(--warm-600)] dark:[--btn-hover:var(--warm-500)] [--btn-press:var(--warm-700)] dark:[--btn-press:var(--warm-600)] [--btn-lip:var(--warm-700)] dark:[--btn-lip:var(--warm-600)] [--btn-ring:var(--warm-500)] [--btn-t1:var(--warm-100)] dark:[--btn-t1:var(--warm-900)] [--btn-t2:var(--warm-200)] dark:[--btn-t2:var(--warm-800)] [--btn-t3:var(--warm-300)] dark:[--btn-t3:var(--warm-700)] [--btn-line:var(--warm-400)] dark:[--btn-line:var(--warm-600)] [--btn-text:var(--warm-700)] dark:[--btn-text:var(--warm-300)] [--btn-ink:var(--warm-0)] dark:[--btn-ink:var(--warm-950)]';
180
+ // Neutral = Nuxt-style monochrome (color="neutral"). Face-based variants (keycap/framed/solid) are the
181
+ // INVERTED key — near-black face + white ink in light, near-white face + dark ink in dark (high
182
+ // contrast, like Nuxt's bg-inverted/text-inverted). Tint/text variants (subtle/soft/outline/ghost/
183
+ // link) fall to surface greys (t 100–300 / text 900). Grey focus/hover ring (neutral-500), so the
184
+ // outline hover edge stays monochrome instead of leaking brand green via the shared --btn-ring.
185
+ // Rest --btn-line is 300/700 (not the ramp's 400/600) to match UInput's resting border
186
+ // (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]';
188
+
189
+ // All seven intents share the colour-keyed solid/flat/outline/soft/ghost classes.
190
+ export const ALL = ['primary', 'secondary', 'success', 'info', 'warning', 'error', 'neutral'];
191
+ export const emphasis = (variant: string, cls: string) => ALL.map((color) => ({ color, variant, class: cls }));
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Forest theme fragment for UButton — the 9-rung emphasis ladder. The default is the signature
3
+ * tactile "keycap" (à la Sentry): a coloured face on a darker bottom lip (pseudo-layers behind the
4
+ * label) that lifts on hover and collapses flush on press.
5
+ *
6
+ * Intent × emphasis taxonomy, mapped onto Nuxt UI's color × variant axes:
7
+ *
8
+ * color (intent) | variant (emphasis, high → low)
9
+ * ----------------------------------- | -------------------------------------------------
10
+ * primary primary forest | keycap tactile lip (lifts on hover, flush on press)
11
+ * success success forest | pad tint + border + lip — quiet but tactile
12
+ * error danger maple | framed filled face + border (light faces stay visible)
13
+ * info info river | solid filled face, no border, no lip
14
+ * warning warning amber (ink) | subtle tint + border
15
+ * secondary secondary warm | soft tint, no border
16
+ * neutral neutral neutral | outline border only
17
+ * | ghost text, bg tint on hover
18
+ * | link text-only, underline on hover
19
+ *
20
+ * Each `color` sets per-family CSS custom properties (--btn-*) consumed by the shared emphasis
21
+ * classes in ./button.mechanics, so each variant is written once. Buttons override their own
22
+ * colours via these vars, independent of the preset's semantic role map.
23
+ *
24
+ * On-face ink follows FACE lightness, not mode: a dark face takes the family's 0, a light face its
25
+ * 950 (so amber — a light face in both modes — is 950 in both). Neutral is Nuxt-style monochrome:
26
+ * its face-based variants are the inverted key (face 900/50, ink 0/900); the rest fall to greys.
27
+ * Token source of truth: forest-web.DESIGN.md › Components.
28
+ */
29
+
30
+ import {
31
+ KEYCAP,
32
+ PAD,
33
+ FRAMED,
34
+ SOLID,
35
+ SUBTLE,
36
+ SOFT,
37
+ OUTLINE,
38
+ GHOST,
39
+ LINK,
40
+ PAL_FOREST,
41
+ PAL_RIVER,
42
+ PAL_MAPLE,
43
+ PAL_AMBER,
44
+ PAL_SECONDARY,
45
+ PAL_NEUTRAL,
46
+ emphasis,
47
+ } from './button.mechanics';
48
+
49
+ export const buttonTheme = {
50
+ slots: {
51
+ // motion-reduce: colour fades may stay; transform/box-shadow movement must not animate.
52
+ base: 'font-bold transition-[transform,box-shadow,background-color,border-color,color] duration-(--motion-fast) motion-reduce:transition-[background-color,border-color,color]',
53
+ },
54
+
55
+ variants: {
56
+ // `color` sets the per-family --btn-* vars (string → applies to the base slot).
57
+ color: {
58
+ primary: PAL_FOREST,
59
+ success: PAL_FOREST,
60
+ info: PAL_RIVER,
61
+ error: PAL_MAPLE,
62
+ warning: PAL_AMBER,
63
+ secondary: PAL_SECONDARY,
64
+ neutral: PAL_NEUTRAL,
65
+ },
66
+ // Custom emphasis keys not in Nuxt UI's native variant set — registered so the
67
+ // compoundVariants below bind. subtle/link are registered too, so binding never depends on
68
+ // the base preset's variant list. All are styled via the compounds.
69
+ variant: {
70
+ keycap: '',
71
+ pad: '',
72
+ framed: '',
73
+ gloss: '',
74
+ subtle: '',
75
+ link: '',
76
+ },
77
+ // Size-scaled padding, radius, and lip elevation.
78
+ size: {
79
+ // --btn-y = key lift (lip thickness), --btn-y-h = hover lift; --btn-yi/--btn-yhi = the exact
80
+ // inverses, applied to the base slab so it cancels the lift and stays anchored.
81
+ // Radius on the system's named scale (rounded-sm 8 · rounded-md 12 · rounded-lg 16 — the
82
+ // --radius-* tokens) instead of arbitrary [Npx]. The scale is coarser than the old 2px ladder,
83
+ // so sm snaps 10→8 and lg snaps 14→16, each sharing a step with a neighbour; xs/md/xl are
84
+ // unchanged (8/12/16). --btn-y* are lip mechanics, not a scale — left as-is.
85
+ // TWO LADDERS, selected by pointer, not by context:
86
+ // fine 24/28/32/36/40 (xs→xl) — the pointer ladder, matching UInput at every size.
87
+ // touch 28/36/40/44/52 — bigger box + a type step, so lg is Apple's 44 and xl 52.
88
+ // A control's height changes ONLY with its `size` and with the ladder in force. Per-ladder
89
+ // height does NOT break a mixed <UFieldGroup>: every field member carries the same `touch:`
90
+ // ladder, so button==input at every rung on either side.
91
+ // `min-h` is RETAINED (not removed): it is the height driver `aspect-square` mirrors into width
92
+ // for icon-only buttons (stock supplies the equal `p-1.5` square padding). A prior attempt that
93
+ // removed min-h collapsed every icon button — only its VALUE moved down to the input's.
94
+ // touch: = the touch ladder (custom variant, styles/forest.css). ONE set of numbers, fired
95
+ // either by a coarse primary pointer (any context — a touched web page counts) or by the mobile
96
+ // cut's baked `.cut-fixed` marker. Same values under both, so a real phone can't step twice and
97
+ // the desktop mock of the mobile app shows true size with no pointer to query.
98
+ // height min-h 28/36/40/44/52 — min-h is the authority, and the ONLY height driver for
99
+ // icon-only buttons (the square compound zeroes the padding, which would skew them).
100
+ // type sm/lg/xl gain a step; xs/md keep their pair's text (12·14·14·16·18 across the ladder).
101
+ // icons size-5/6/7 follow the box, so the glyph doesn't strand inside a bigger target.
102
+ // Radius is deliberately absent — the mobile cut is already rounded-full, and the web radius
103
+ // ramp reads correctly at both ladders. Lip vars (--btn-y*, keycap-only, ~1px) stay on `coarse:`:
104
+ // they're depth, not size, and the mobile cut sets its own flat lip in mobile-theme.ts.
105
+ xs: { base: 'min-h-6 px-2.5 rounded-sm touch:min-h-7 [--btn-y:-1px] [--btn-yi:1px] [--btn-y-h:-2px] [--btn-yhi:2px] coarse:[--btn-y:-2px] coarse:[--btn-yi:2px] coarse:[--btn-y-h:-3px] coarse:[--btn-yhi:3px]' },
106
+ sm: { base: 'min-h-7 px-3 rounded-md touch:min-h-9 touch:text-sm [--btn-y:-2px] [--btn-yi:2px] [--btn-y-h:-3px] [--btn-yhi:3px]', leadingIcon: 'touch:size-5', trailingIcon: 'touch:size-5' },
107
+ md: { base: 'min-h-8 px-3.5 rounded-md touch:min-h-10 [--btn-y:-2px] [--btn-yi:2px] [--btn-y-h:-3px] [--btn-yhi:3px] coarse:[--btn-y:-3px] coarse:[--btn-yi:3px] coarse:[--btn-y-h:-4px] coarse:[--btn-yhi:4px]' },
108
+ lg: { base: 'min-h-9 px-4 rounded-lg touch:min-h-11 touch:text-base [--btn-y:-3px] [--btn-yi:3px] [--btn-y-h:-4px] [--btn-yhi:4px]', leadingIcon: 'touch:size-6', trailingIcon: 'touch:size-6' },
109
+ xl: { base: 'min-h-10 px-4 rounded-lg touch:min-h-13 touch:text-lg [--btn-y:-3px] [--btn-yi:3px] [--btn-y-h:-4px] [--btn-yhi:4px]', leadingIcon: 'touch:size-7', trailingIcon: 'touch:size-7' },
110
+ },
111
+ },
112
+
113
+ compoundVariants: [
114
+ ...emphasis('keycap', KEYCAP),
115
+ ...emphasis('pad', PAD),
116
+ ...emphasis('framed', FRAMED),
117
+ // ...emphasis('gloss', GLOSS),
118
+ ...emphasis('solid', SOLID),
119
+ ...emphasis('subtle', SUBTLE),
120
+ ...emphasis('soft', SOFT),
121
+ ...emphasis('outline', OUTLINE),
122
+ ...emphasis('ghost', GHOST),
123
+ ...emphasis('link', LINK),
124
+ // Icon-only: `min-h` floors the height, `aspect-square` mirrors it into width; stock supplies the
125
+ // equal `p-*` square padding, which asymmetric touch padding would skew — so zero it and let the
126
+ // size's `touch:min-h-*` rung carry the growth on its own. That rung must therefore exist for
127
+ // every size: without it a touched icon button grows its glyph while the box holds still.
128
+ { square: true, class: 'justify-center aspect-square touch:px-0 touch:py-0' },
129
+ ],
130
+
131
+ defaultVariants: {
132
+ color: 'primary',
133
+ variant: 'solid',
134
+ size: 'md',
135
+ },
136
+ } as const
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Forest theme fragment — UCalendar
3
+ *
4
+ * Deltas only — every token here changes stock. The card shell, the confident heading weight, and
5
+ * the offset brand focus ring are the whole opinion; shape, colour intents, sizes and the range /
6
+ * today fills are stock Nuxt UI on the Forest ramps.
7
+ *
8
+ * Slots touched: root, headingLabel, cellTrigger.
9
+ * Stock recipe (for the diff): apps/docs/node_modules/.nuxt-ui/ui/calendar.ts
10
+ */
11
+
12
+ export const calendarTheme = {
13
+ slots: {
14
+ // Card-style container — stock `root` is empty; Forest frames the inline grid as a contained
15
+ // card (soft radius + border + gentle padding) rather than a raw grid floating in white space.
16
+ root: 'rounded-xl border border-default bg-elevated/30 p-4 inline-block',
17
+
18
+ // Month / year label — the actual text span (nested inside `heading`, which is why the weight
19
+ // must land here, not on the wrapper). Confident weight replaces stock's `font-medium` (banned
20
+ // by the two-weight system) and picks up display tracking without the full Mohr uppercase.
21
+ headingLabel: 'font-bold tracking-tight text-highlighted',
22
+
23
+ // Date cell — the only cell delta: a brand focus ring pushed out 2px (replaces stock's flat
24
+ // `focus-visible:outline-3`) so it clears the circle edge, plus a bolder today marker
25
+ // (`font-bold` over stock `font-semibold`). Shape (rounded-full), sizes and the colour-variant
26
+ // fills stay stock.
27
+ cellTrigger: 'focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none data-today:font-bold',
28
+ },
29
+ } as const