@humanforest/nuxt-layer 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.
- package/LICENSE +67 -0
- package/README.md +208 -0
- package/app.config.ts +10 -0
- package/assets/icons/forest/bike-asterisk.svg +21 -0
- package/assets/icons/forest/bike-down.svg +19 -0
- package/assets/icons/forest/bike-off.svg +20 -0
- package/assets/icons/forest/bike-up.svg +19 -0
- package/assets/icons/forest/bike-x.svg +19 -0
- package/assets/icons/forest/bike.svg +18 -0
- package/assets/icons/forest/mark-fill.svg +6 -0
- package/assets/icons/forest/mark.svg +6 -0
- package/assets/icons/forest/parking.svg +3 -0
- package/assets/icons/forest/star-fill.svg +4 -0
- package/assets/icons/forest/star.svg +4 -0
- package/assets/icons/forest/traffic-light-caution.svg +8 -0
- package/assets/icons/forest/traffic-light-go.svg +8 -0
- package/assets/icons/forest/traffic-light-lit.svg +8 -0
- package/assets/icons/forest/traffic-light-stop.svg +8 -0
- package/assets/icons/forest/traffic-light.svg +8 -0
- package/nuxt.config.ts +81 -0
- package/package.json +35 -0
- package/packages/charts/src/FBoroughShape.vue +52 -0
- package/packages/charts/src/FCalendarGrid.vue +332 -0
- package/packages/charts/src/FCellLegend.vue +54 -0
- package/packages/charts/src/FChartFrame.vue +201 -0
- package/packages/charts/src/FChartFrameSingle.vue +91 -0
- package/packages/charts/src/FChartLegend.vue +26 -0
- package/packages/charts/src/FDistributionBar.vue +224 -0
- package/packages/charts/src/FDottedMap.vue +335 -0
- package/packages/charts/src/FLondonMap.vue +399 -0
- package/packages/charts/src/FSparkline.vue +198 -0
- package/packages/charts/src/FStatusTrack.vue +151 -0
- package/packages/charts/src/cellSize.ts +46 -0
- package/packages/charts/src/distribution.ts +99 -0
- package/packages/charts/src/engine.ts +15 -0
- package/packages/charts/src/forestTooltip.ts +110 -0
- package/packages/charts/src/fromCategories.ts +43 -0
- package/packages/charts/src/index.ts +87 -0
- package/packages/charts/src/londonAreas.ts +75 -0
- package/packages/charts/src/londonGrid.ts +67 -0
- package/packages/charts/src/motionDuration.ts +49 -0
- package/packages/charts/src/presets.ts +143 -0
- package/packages/charts/src/resolveVar.ts +21 -0
- package/packages/charts/src/snapTooltip.ts +154 -0
- package/packages/charts/src/tooltipStandIn.ts +58 -0
- package/packages/charts/src/useCellScale.ts +155 -0
- package/packages/charts/src/useChartPalette.ts +61 -0
- package/packages/charts/src/useChartRepaintKey.ts +22 -0
- package/packages/charts/src/useThemeVersion.ts +17 -0
- package/packages/frames/src/FBrowserFrame.vue +53 -0
- package/packages/frames/src/FDeviceFrame.vue +242 -0
- package/packages/frames/src/FFitFrame.vue +83 -0
- package/packages/frames/src/art/iphone-11.svg +124 -0
- package/packages/frames/src/art/iphone-12.svg +601 -0
- package/packages/frames/src/art/iphone-13-pro.svg +820 -0
- package/packages/frames/src/art/iphone-13.svg +849 -0
- package/packages/frames/src/art/iphone-14.svg +681 -0
- package/packages/frames/src/art/iphone-15-pro.svg +633 -0
- package/packages/frames/src/art/iphone-15.svg +713 -0
- package/packages/frames/src/art/iphone-16-plus.svg +685 -0
- package/packages/frames/src/art/iphone-16-pro-max.svg +611 -0
- package/packages/frames/src/art/iphone-16-pro.svg +631 -0
- package/packages/frames/src/art/iphone-16.svg +713 -0
- package/packages/frames/src/art/iphone-17-pro-max.svg +355 -0
- package/packages/frames/src/art/iphone-17.svg +305 -0
- package/packages/frames/src/art/iphone-air.svg +389 -0
- package/packages/frames/src/art/iphone-se-3.svg +473 -0
- package/packages/frames/src/art/iphone-x.svg +652 -0
- package/packages/frames/src/art/pixel-10-pro-xl.svg +32 -0
- package/packages/frames/src/art/pixel-10-pro.svg +32 -0
- package/packages/frames/src/art/pixel-10.svg +32 -0
- package/packages/frames/src/art/pixel-8-pro.svg +208 -0
- package/packages/frames/src/art/pixel-8.svg +208 -0
- package/packages/frames/src/art/pixel-8a.svg +8 -0
- package/packages/frames/src/art/pixel-9-pro-xl.svg +32 -0
- package/packages/frames/src/art/pixel-9-pro.svg +34 -0
- package/packages/frames/src/art/pixel-9a.svg +25 -0
- package/packages/frames/src/art.ts +29 -0
- package/packages/frames/src/devices.ts +337 -0
- package/packages/frames/src/index.ts +15 -0
- package/packages/tokens/geo/london-boroughs.json +1 -0
- package/packages/tokens/geo/london-dots-coarse.json +1 -0
- package/packages/tokens/geo/london-dots-fine.json +1 -0
- package/packages/tokens/geo/london-dots-medium.json +1 -0
- package/packages/tokens/geo/london-dots-ultra.json +1 -0
- package/packages/tokens/geo/london-wards.json +1 -0
- package/packages/tokens/glyphs/glyphs.json +1 -0
- package/packages/tokens/logos/f-rot.svg +4 -0
- package/packages/tokens/logos/f.svg +4 -0
- package/packages/tokens/logos/favicon-c2w.svg +9 -0
- package/packages/tokens/logos/favicon-console.svg +9 -0
- package/packages/tokens/logos/favicon-ds.svg +9 -0
- package/packages/tokens/logos/favicon-fleet.svg +9 -0
- package/packages/tokens/logos/favicon-flex.svg +9 -0
- package/packages/tokens/logos/favicon-plus.svg +9 -0
- package/packages/tokens/logos/favicon-radar.svg +9 -0
- package/packages/tokens/logos/favicon-tower.svg +9 -0
- package/packages/tokens/logos/lockup-horizontal.svg +17 -0
- package/packages/tokens/logos/lockup-vertical.svg +17 -0
- package/packages/tokens/logos/mark-mono.svg +4 -0
- package/packages/tokens/logos/mark.svg +7 -0
- package/packages/tokens/logos/wordmark.svg +9 -0
- package/packages/tokens/src/colourEngine.ts +604 -0
- package/packages/tokens/src/glyphs.ts +127 -0
- package/packages/tokens/src/logo.ts +50 -0
- package/packages/tokens/src/subbrands.ts +44 -0
- package/packages/tokens/src/sublogo.ts +80 -0
- package/packages/ui/icons/forest/map/forest-place-count.svg +21 -0
- package/packages/ui/icons/forest/map/forest-place-marker.svg +17 -0
- package/packages/ui/icons/forest/map/forest-vehicle-marker.svg +73 -0
- package/packages/ui/icons/forest/map/manifest.json +373 -0
- package/packages/ui/icons/forest/map/place-count-manifest.json +83 -0
- package/packages/ui/icons/forest/map/place-manifest.json +74 -0
- package/packages/ui/src/address/address.ts +125 -0
- package/packages/ui/src/address/autofill.ts +78 -0
- package/packages/ui/src/address/geocode.ts +55 -0
- package/packages/ui/src/address/index.ts +31 -0
- package/packages/ui/src/address/validate.ts +91 -0
- package/packages/ui/src/canvas/FCanvasCard.vue +97 -0
- package/packages/ui/src/canvas/FCanvasControl.vue +121 -0
- package/packages/ui/src/canvas/FCanvasControlGroup.vue +100 -0
- package/packages/ui/src/canvas/FCanvasFlyout.vue +216 -0
- package/packages/ui/src/canvas/FCanvasHud.vue +402 -0
- package/packages/ui/src/canvas/canvasSurface.ts +39 -0
- package/packages/ui/src/canvas/index.ts +17 -0
- package/packages/ui/src/contexts/ForestContext.vue +53 -0
- package/packages/ui/src/contexts/contexts.ts +153 -0
- package/packages/ui/src/contexts/index.ts +23 -0
- package/packages/ui/src/contexts/marketing-theme.ts +214 -0
- package/packages/ui/src/contexts/mobile-theme.ts +402 -0
- package/packages/ui/src/filter/FFilterBar.vue +72 -0
- package/packages/ui/src/filter/FFilterChip.vue +362 -0
- package/packages/ui/src/filter/FFilterClauses.vue +104 -0
- package/packages/ui/src/filter/FFilterTrigger.vue +183 -0
- package/packages/ui/src/filter/filter.theme.ts +27 -0
- package/packages/ui/src/filter/filterClause.ts +265 -0
- package/packages/ui/src/filter/filterPredicate.ts +134 -0
- package/packages/ui/src/filter/filterSchema.ts +82 -0
- package/packages/ui/src/filter/index.ts +36 -0
- package/packages/ui/src/forest-preset.ts +68 -0
- package/packages/ui/src/icons.ts +79 -0
- package/packages/ui/src/index.ts +17 -0
- package/packages/ui/src/kpi/FKpi.vue +253 -0
- package/packages/ui/src/kpi/FKpiGroup.vue +161 -0
- package/packages/ui/src/kpi/index.ts +20 -0
- package/packages/ui/src/kpi/kpi.theme.ts +168 -0
- package/packages/ui/src/kpi/kpiDelta.ts +82 -0
- package/packages/ui/src/kpi/kpiFormat.ts +72 -0
- package/packages/ui/src/kpi/kpiGroupState.ts +63 -0
- package/packages/ui/src/logo/FLogo.vue +52 -0
- package/packages/ui/src/logo/FSubLogo.vue +214 -0
- package/packages/ui/src/logo/index.ts +8 -0
- package/packages/ui/src/map/FMap.vue +355 -0
- package/packages/ui/src/map/FMapCompass.vue +154 -0
- package/packages/ui/src/map/FMapCoordinates.vue +134 -0
- package/packages/ui/src/map/FMapDrawTools.vue +116 -0
- package/packages/ui/src/map/FMapFullscreen.vue +92 -0
- package/packages/ui/src/map/FMapGeolocate.vue +208 -0
- package/packages/ui/src/map/FMapLayers.vue +75 -0
- package/packages/ui/src/map/FMapLegend.vue +45 -0
- package/packages/ui/src/map/FMapPopup.vue +129 -0
- package/packages/ui/src/map/FMapRamp.vue +69 -0
- package/packages/ui/src/map/FMapRegion.vue +77 -0
- package/packages/ui/src/map/FMapScale.vue +107 -0
- package/packages/ui/src/map/FMapSearch.vue +362 -0
- package/packages/ui/src/map/FMapTilt.vue +87 -0
- package/packages/ui/src/map/FMapTools.vue +61 -0
- package/packages/ui/src/map/FMapZoom.vue +81 -0
- package/packages/ui/src/map/FMarker.vue +218 -0
- package/packages/ui/src/map/FTrackScrubber.vue +94 -0
- package/packages/ui/src/map/FVehicleLegend.vue +72 -0
- package/packages/ui/src/map/FVehicleMark.vue +166 -0
- package/packages/ui/src/map/bin.ts +196 -0
- package/packages/ui/src/map/coordinates.ts +83 -0
- package/packages/ui/src/map/density.ts +401 -0
- package/packages/ui/src/map/draw.ts +356 -0
- package/packages/ui/src/map/ease.ts +67 -0
- package/packages/ui/src/map/fullscreen.ts +59 -0
- package/packages/ui/src/map/heat.ts +160 -0
- package/packages/ui/src/map/index.ts +351 -0
- package/packages/ui/src/map/liveMarkers.ts +60 -0
- package/packages/ui/src/map/map.css +116 -0
- package/packages/ui/src/map/mapColor.ts +31 -0
- package/packages/ui/src/map/mapContext.ts +12 -0
- package/packages/ui/src/map/mapLayers.ts +55 -0
- package/packages/ui/src/map/mapLegend.ts +70 -0
- package/packages/ui/src/map/mapSearch.theme.ts +79 -0
- package/packages/ui/src/map/mapSearch.ts +279 -0
- package/packages/ui/src/map/mapUrl.ts +117 -0
- package/packages/ui/src/map/markerAsset.ts +3 -0
- package/packages/ui/src/map/padding.ts +39 -0
- package/packages/ui/src/map/place.ts +192 -0
- package/packages/ui/src/map/ramp.ts +95 -0
- package/packages/ui/src/map/regionSelect.ts +163 -0
- package/packages/ui/src/map/route.ts +167 -0
- package/packages/ui/src/map/scale.ts +329 -0
- package/packages/ui/src/map/scaleBar.ts +67 -0
- package/packages/ui/src/map/selectionModel.ts +50 -0
- package/packages/ui/src/map/sources/coordinates.ts +58 -0
- package/packages/ui/src/map/sources/mapboxPlaces.ts +71 -0
- package/packages/ui/src/map/style.ts +14 -0
- package/packages/ui/src/map/track.ts +279 -0
- package/packages/ui/src/map/useFeatureSelect.ts +230 -0
- package/packages/ui/src/map/useLiveMarkers.ts +84 -0
- package/packages/ui/src/map/useMap.ts +150 -0
- package/packages/ui/src/map/useMapDraw.ts +776 -0
- package/packages/ui/src/map/useMapOverlay.ts +421 -0
- package/packages/ui/src/map/useMapTableSelection.ts +101 -0
- package/packages/ui/src/map/useMapUrlState.ts +155 -0
- package/packages/ui/src/map/useMarkerSource.ts +163 -0
- package/packages/ui/src/map/useRegionSelect.ts +309 -0
- package/packages/ui/src/map/useTokenMode.ts +61 -0
- package/packages/ui/src/map/useTrackPlayback.ts +131 -0
- package/packages/ui/src/map/useVehicleFilter.ts +51 -0
- package/packages/ui/src/map/useVisibleFeatures.ts +124 -0
- package/packages/ui/src/map/vehicle.ts +807 -0
- package/packages/ui/src/map/vehicleFilter.ts +284 -0
- package/packages/ui/src/map/vehicleLegend.ts +155 -0
- package/packages/ui/src/map/visibleFeatures.ts +192 -0
- package/packages/ui/src/map/zone.ts +291 -0
- package/packages/ui/src/shell/FAppNavbar.vue +68 -0
- package/packages/ui/src/shell/FAppShell.vue +163 -0
- package/packages/ui/src/shell/FAppSidebar.vue +215 -0
- package/packages/ui/src/shell/FPageToolbar.vue +126 -0
- package/packages/ui/src/shell/FScrollShadow.vue +43 -0
- package/packages/ui/src/shell/FSidePanel.vue +258 -0
- package/packages/ui/src/shell/FToolbarActions.vue +32 -0
- package/packages/ui/src/shell/index.ts +33 -0
- package/packages/ui/src/shell/nav.ts +37 -0
- package/packages/ui/src/shell/pageToolbar.ts +69 -0
- package/packages/ui/src/shell/shell.ts +74 -0
- package/packages/ui/src/shell/sidePanel.ts +115 -0
- package/packages/ui/src/themes/accordion.ts +36 -0
- package/packages/ui/src/themes/alert.ts +16 -0
- package/packages/ui/src/themes/auth-form.ts +19 -0
- package/packages/ui/src/themes/avatar-group.ts +9 -0
- package/packages/ui/src/themes/avatar.ts +18 -0
- package/packages/ui/src/themes/badge.ts +51 -0
- package/packages/ui/src/themes/banner.ts +21 -0
- package/packages/ui/src/themes/breadcrumb.ts +10 -0
- package/packages/ui/src/themes/button.mechanics.ts +191 -0
- package/packages/ui/src/themes/button.ts +136 -0
- package/packages/ui/src/themes/calendar.ts +29 -0
- package/packages/ui/src/themes/card.ts +67 -0
- package/packages/ui/src/themes/carousel.ts +41 -0
- package/packages/ui/src/themes/checkbox-group.ts +20 -0
- package/packages/ui/src/themes/checkbox.ts +47 -0
- package/packages/ui/src/themes/chip.ts +50 -0
- package/packages/ui/src/themes/collapsible.ts +26 -0
- package/packages/ui/src/themes/color-picker.ts +13 -0
- package/packages/ui/src/themes/command-palette.ts +37 -0
- package/packages/ui/src/themes/container.ts +14 -0
- package/packages/ui/src/themes/context-menu.ts +38 -0
- package/packages/ui/src/themes/dashboard-group.ts +11 -0
- package/packages/ui/src/themes/dashboard-navbar.ts +10 -0
- package/packages/ui/src/themes/dashboard-panel.ts +18 -0
- package/packages/ui/src/themes/dashboard-resize-handle.ts +35 -0
- package/packages/ui/src/themes/dashboard-sidebar-collapse.ts +7 -0
- package/packages/ui/src/themes/dashboard-sidebar-toggle.ts +7 -0
- package/packages/ui/src/themes/dashboard-sidebar.ts +58 -0
- package/packages/ui/src/themes/dashboard-toolbar.ts +15 -0
- package/packages/ui/src/themes/drawer.ts +45 -0
- package/packages/ui/src/themes/dropdown-menu.ts +38 -0
- package/packages/ui/src/themes/empty.ts +43 -0
- package/packages/ui/src/themes/field-group.ts +52 -0
- package/packages/ui/src/themes/file-upload.ts +38 -0
- package/packages/ui/src/themes/footer-columns.ts +13 -0
- package/packages/ui/src/themes/footer.ts +9 -0
- package/packages/ui/src/themes/form-field.ts +25 -0
- package/packages/ui/src/themes/form.ts +22 -0
- package/packages/ui/src/themes/header.ts +12 -0
- package/packages/ui/src/themes/icon.ts +61 -0
- package/packages/ui/src/themes/index.ts +99 -0
- package/packages/ui/src/themes/input-date.ts +73 -0
- package/packages/ui/src/themes/input-menu.ts +66 -0
- package/packages/ui/src/themes/input-number.ts +58 -0
- package/packages/ui/src/themes/input-rating.ts +25 -0
- package/packages/ui/src/themes/input-tags.ts +63 -0
- package/packages/ui/src/themes/input-time.ts +70 -0
- package/packages/ui/src/themes/input.ts +148 -0
- package/packages/ui/src/themes/kbd.ts +27 -0
- package/packages/ui/src/themes/link.ts +27 -0
- package/packages/ui/src/themes/listbox.ts +48 -0
- package/packages/ui/src/themes/main.ts +6 -0
- package/packages/ui/src/themes/marquee.ts +45 -0
- package/packages/ui/src/themes/modal.ts +46 -0
- package/packages/ui/src/themes/navigation-menu.ts +80 -0
- package/packages/ui/src/themes/page-anchors.ts +7 -0
- package/packages/ui/src/themes/page-aside.ts +7 -0
- package/packages/ui/src/themes/page-card.ts +22 -0
- package/packages/ui/src/themes/page-cta.ts +18 -0
- package/packages/ui/src/themes/page-feature.ts +13 -0
- package/packages/ui/src/themes/page-header.ts +16 -0
- package/packages/ui/src/themes/page-hero.ts +15 -0
- package/packages/ui/src/themes/page-links.ts +12 -0
- package/packages/ui/src/themes/page-section.ts +14 -0
- package/packages/ui/src/themes/page.ts +7 -0
- package/packages/ui/src/themes/pagination.ts +17 -0
- package/packages/ui/src/themes/pin-input.ts +73 -0
- package/packages/ui/src/themes/popover.ts +30 -0
- package/packages/ui/src/themes/progress.ts +29 -0
- package/packages/ui/src/themes/prose.ts +45 -0
- package/packages/ui/src/themes/radio-group.ts +39 -0
- package/packages/ui/src/themes/scroll-area.ts +23 -0
- package/packages/ui/src/themes/select-menu.ts +71 -0
- package/packages/ui/src/themes/select.ts +72 -0
- package/packages/ui/src/themes/separator.ts +28 -0
- package/packages/ui/src/themes/sidebar.ts +127 -0
- package/packages/ui/src/themes/skeleton.ts +15 -0
- package/packages/ui/src/themes/slideover.ts +87 -0
- package/packages/ui/src/themes/slider.ts +48 -0
- package/packages/ui/src/themes/stepper.ts +41 -0
- package/packages/ui/src/themes/switch.ts +63 -0
- package/packages/ui/src/themes/table.ts +34 -0
- package/packages/ui/src/themes/tabs.ts +74 -0
- package/packages/ui/src/themes/textarea.ts +77 -0
- package/packages/ui/src/themes/timeline.ts +25 -0
- package/packages/ui/src/themes/toast.ts +29 -0
- package/packages/ui/src/themes/toaster.ts +66 -0
- package/packages/ui/src/themes/tooltip.ts +31 -0
- package/packages/ui/src/themes/tree.ts +41 -0
- package/packages/ui/src/themes/user.ts +28 -0
- package/styles.cdn.gen.css +2047 -0
- package/theme.gen.json +1 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// One canvas control that opens into a cluster of them, in place.
|
|
3
|
+
//
|
|
4
|
+
// <FCanvasFlyout icon="i-lucide-lasso" label="Selection tools" :items="tools" @select="pick" />
|
|
5
|
+
//
|
|
6
|
+
// ★ WHY IT EXISTS. A rail is a narrow column shared with the zoom pair, the compass and the rest, and
|
|
7
|
+
// four controls stacked is 144px of a 480px map before anything else gets its share. This is one
|
|
8
|
+
// control at rest, and the cluster exists only while a reader is choosing from it — the only moment it
|
|
9
|
+
// says anything.
|
|
10
|
+
//
|
|
11
|
+
// ★ IT OPENS ACROSS THE RAIL, NOT ALONG IT. `orientation` is the axis the cluster opens ALONG, and it
|
|
12
|
+
// defaults to horizontal precisely because rails are vertical: a group that grew downward would push
|
|
13
|
+
// every control below it along, every time someone glanced at the tools. Opening across spends space
|
|
14
|
+
// the canvas has and the rail does not.
|
|
15
|
+
//
|
|
16
|
+
// ★ AND IT IS AN OVERLAY, NOT A RESIZE. The wrapper only ever occupies the trigger; the cluster is
|
|
17
|
+
// absolutely positioned from it. A rail hugs its widest child (`fit-content`), so a cluster that grew
|
|
18
|
+
// in flow would widen the whole column and drag its neighbours sideways — measured, before this was
|
|
19
|
+
// positioned out of flow.
|
|
20
|
+
//
|
|
21
|
+
// ★ NOT A POPOVER. It grows contiguously from the trigger and shares one surface with it, so it reads
|
|
22
|
+
// as one object opening rather than a panel arriving beside it: no anchor to position, nothing to
|
|
23
|
+
// teleport, no second pane of glass. <FMapSearch> takes the same view for the same reason.
|
|
24
|
+
import { computed, ref, watch } from 'vue';
|
|
25
|
+
import FCanvasControl from './FCanvasControl.vue';
|
|
26
|
+
|
|
27
|
+
/** One member of the cluster. `value` is what comes back on select. */
|
|
28
|
+
export interface FlyoutItem {
|
|
29
|
+
value: string;
|
|
30
|
+
icon: string;
|
|
31
|
+
label: string;
|
|
32
|
+
/** lit — for a cluster of modes, the one currently held */
|
|
33
|
+
pressed?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
import FCanvasControlGroup from './FCanvasControlGroup.vue';
|
|
37
|
+
|
|
38
|
+
const open = defineModel<boolean>('open', { default: false });
|
|
39
|
+
|
|
40
|
+
const emit = defineEmits<{ select: [value: string] }>();
|
|
41
|
+
|
|
42
|
+
const props = withDefaults(
|
|
43
|
+
defineProps<{
|
|
44
|
+
/**
|
|
45
|
+
* The trigger's icon at rest.
|
|
46
|
+
*
|
|
47
|
+
* ★ Deliberately not derived. A flyout of MODES usually wants the active one here, so a reader can
|
|
48
|
+
* see what is in their hand without opening anything; a flyout of one-shot ACTIONS wants a fixed
|
|
49
|
+
* icon, because there is no state to report. The component cannot tell which it is in, so it does
|
|
50
|
+
* not try.
|
|
51
|
+
*/
|
|
52
|
+
icon: string;
|
|
53
|
+
label: string;
|
|
54
|
+
/** Lit at rest — for a mode flyout, whether the mode it names is active. */
|
|
55
|
+
pressed?: boolean;
|
|
56
|
+
/** The axis the cluster opens along. Horizontal suits a vertical rail, and vice versa. */
|
|
57
|
+
orientation?: 'vertical' | 'horizontal';
|
|
58
|
+
/** Which end the trigger is pinned to, so the cluster opens INWARD rather than off the canvas. */
|
|
59
|
+
side?: 'start' | 'end';
|
|
60
|
+
/**
|
|
61
|
+
* The controls the cluster holds.
|
|
62
|
+
*
|
|
63
|
+
* ★ Items rather than a slot, and the motion is why. A cluster's members enter and leave, and
|
|
64
|
+
* <TransitionGroup> can only drive vnodes it created — hand it slot content and the classes never
|
|
65
|
+
* land on anything (measured: members appeared with the button's own transition and no enter
|
|
66
|
+
* class at all). Owning the members buys working motion; the cost is that a member is an icon
|
|
67
|
+
* control rather than arbitrary markup, which is all a cluster of chrome ever is.
|
|
68
|
+
*/
|
|
69
|
+
items?: FlyoutItem[];
|
|
70
|
+
/** Icon for the dismiss the trigger becomes while open. */
|
|
71
|
+
closeIcon?: string;
|
|
72
|
+
/** Accessible name for that dismiss. */
|
|
73
|
+
closeLabel?: string;
|
|
74
|
+
}>(),
|
|
75
|
+
{
|
|
76
|
+
items: () => [],
|
|
77
|
+
pressed: undefined,
|
|
78
|
+
orientation: 'horizontal',
|
|
79
|
+
side: 'end',
|
|
80
|
+
closeIcon: 'i-lucide-x',
|
|
81
|
+
closeLabel: 'Close',
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const root = ref<HTMLElement | null>(null);
|
|
86
|
+
const close = () => (open.value = false);
|
|
87
|
+
|
|
88
|
+
// Picking closes: a cluster of modes has said everything it has to say once one is chosen.
|
|
89
|
+
function choose(value: string) {
|
|
90
|
+
emit('select', value);
|
|
91
|
+
close();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Where a member starts from: just inside the trigger, so the cluster reads as unfolding OUT of it
|
|
96
|
+
* rather than materialising beside it.
|
|
97
|
+
*
|
|
98
|
+
* ★ `translate-*`, and the transition names `translate` — not `transform`. In Tailwind v4 the
|
|
99
|
+
* translate utilities set the `translate` property, so a `transition-[opacity,transform]` would
|
|
100
|
+
* animate the fade alone and snap the movement.
|
|
101
|
+
*/
|
|
102
|
+
const from = computed(() => {
|
|
103
|
+
const d = '0.5rem';
|
|
104
|
+
if (props.orientation === 'horizontal')
|
|
105
|
+
return { '--forest-flyout-x': props.side === 'end' ? d : `-${d}`, '--forest-flyout-y': '0' };
|
|
106
|
+
return { '--forest-flyout-x': '0', '--forest-flyout-y': props.side === 'end' ? d : `-${d}` };
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* ★ A CSS ANIMATION, NOT <TransitionGroup>. Measured: with the members inside a TransitionGroup —
|
|
111
|
+
* both as slot content and rendered here — the enter classes never landed on them at all; every
|
|
112
|
+
* member showed the button's own `transition-property` and no opacity or translate of its own. An
|
|
113
|
+
* animation runs on mount by construction, which is exactly when a member appears, and nothing has
|
|
114
|
+
* to be wired correctly for it to fire.
|
|
115
|
+
*/
|
|
116
|
+
|
|
117
|
+
// ★ Focus alone cannot dismiss this. A map canvas is not focusable, so pressing on it may move focus
|
|
118
|
+
// nowhere at all, and the cluster would sit open over the very thing the reader reached for. Watching
|
|
119
|
+
// pointerdown catches the press wherever it lands.
|
|
120
|
+
watch(open, (isOpen, _was, onCleanup) => {
|
|
121
|
+
if (!isOpen) return;
|
|
122
|
+
const away = (e: PointerEvent) => {
|
|
123
|
+
if (!root.value?.contains(e.target as Node)) close();
|
|
124
|
+
};
|
|
125
|
+
// Capture, so a handler that stops propagation cannot leave the cluster open behind its own panel.
|
|
126
|
+
document.addEventListener('pointerdown', away, true);
|
|
127
|
+
onCleanup(() => document.removeEventListener('pointerdown', away, true));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// The keyboard still moves focus properly, so this covers tabbing away.
|
|
131
|
+
function onFocusOut() {
|
|
132
|
+
requestAnimationFrame(() => {
|
|
133
|
+
if (!open.value) return;
|
|
134
|
+
if (root.value?.contains(document.activeElement)) return;
|
|
135
|
+
close();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
</script>
|
|
139
|
+
|
|
140
|
+
<style>
|
|
141
|
+
/* One keyframe serves all four placements; the flyout sets the axis it enters from. */
|
|
142
|
+
@keyframes forest-flyout-in {
|
|
143
|
+
from {
|
|
144
|
+
opacity: 0;
|
|
145
|
+
translate: var(--forest-flyout-x, 0) var(--forest-flyout-y, 0);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
.forest-flyout-in {
|
|
149
|
+
/* No var() fallbacks. They had already DRIFTED: 150ms is Tailwind's stock default, not
|
|
150
|
+
--motion-fast's 140ms, and the bare `ease-out` keyword is the weak built-in the doctrine bans
|
|
151
|
+
outright — so the "safety net" quietly contradicted the ladder it was netting. The tokens ship
|
|
152
|
+
with the layer now (packages/tokens/motion/motion.css), so an absent value means a broken
|
|
153
|
+
install, which should look broken rather than silently animate off-doctrine. */
|
|
154
|
+
animation: forest-flyout-in var(--motion-fast) var(--motion-ease-out) both;
|
|
155
|
+
}
|
|
156
|
+
/* The global floor covers transitions; an animation has to be said separately. */
|
|
157
|
+
@media (prefers-reduced-motion: reduce) {
|
|
158
|
+
.forest-flyout-in {
|
|
159
|
+
animation: none;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
</style>
|
|
163
|
+
|
|
164
|
+
<template>
|
|
165
|
+
<div
|
|
166
|
+
ref="root"
|
|
167
|
+
class="pointer-events-auto relative"
|
|
168
|
+
:style="from"
|
|
169
|
+
:class="orientation === 'horizontal' ? 'h-(--forest-canvas-control)' : 'w-(--forest-canvas-control)'"
|
|
170
|
+
@keydown.escape="close"
|
|
171
|
+
@focusout="onFocusOut"
|
|
172
|
+
>
|
|
173
|
+
<FCanvasControlGroup
|
|
174
|
+
:orientation="orientation"
|
|
175
|
+
class="absolute"
|
|
176
|
+
:class="
|
|
177
|
+
orientation === 'horizontal'
|
|
178
|
+
? ['top-0', side === 'end' ? 'end-0' : 'start-0']
|
|
179
|
+
: ['start-0', side === 'end' ? 'bottom-0' : 'top-0']
|
|
180
|
+
"
|
|
181
|
+
>
|
|
182
|
+
<!-- Order follows `side` so the TRIGGER stays put and the cluster appears beside it. Pinned at
|
|
183
|
+
the end, members render first and the trigger last — otherwise opening would walk the
|
|
184
|
+
trigger off the edge it is anchored to. -->
|
|
185
|
+
<FCanvasControl
|
|
186
|
+
v-for="item in open && side === 'end' ? items : []"
|
|
187
|
+
:key="item.value"
|
|
188
|
+
:icon="item.icon"
|
|
189
|
+
:label="item.label"
|
|
190
|
+
:pressed="item.pressed"
|
|
191
|
+
class="forest-flyout-in"
|
|
192
|
+
@click="choose(item.value)"
|
|
193
|
+
/>
|
|
194
|
+
|
|
195
|
+
<!-- Closed it is the thing itself; open it is the way out. A dismiss rather than a member,
|
|
196
|
+
because a member that also closed the cluster would be indistinguishable from one that
|
|
197
|
+
does not. -->
|
|
198
|
+
<FCanvasControl
|
|
199
|
+
:icon="open ? closeIcon : icon"
|
|
200
|
+
:label="open ? closeLabel : label"
|
|
201
|
+
:pressed="open ? undefined : pressed"
|
|
202
|
+
@click="open = !open"
|
|
203
|
+
/>
|
|
204
|
+
|
|
205
|
+
<FCanvasControl
|
|
206
|
+
v-for="item in open && side === 'start' ? items : []"
|
|
207
|
+
:key="item.value"
|
|
208
|
+
:icon="item.icon"
|
|
209
|
+
:label="item.label"
|
|
210
|
+
:pressed="item.pressed"
|
|
211
|
+
class="forest-flyout-in"
|
|
212
|
+
@click="choose(item.value)"
|
|
213
|
+
/>
|
|
214
|
+
</FCanvasControlGroup>
|
|
215
|
+
</div>
|
|
216
|
+
</template>
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import { CANVAS_CARD_MAX, CANVAS_RAIL_BOTTOM_MAX, type CanvasSurface } from './canvasSurface';
|
|
4
|
+
// The canvas HUD — chrome OVER a full-bleed canvas (a map, a camera) instead of around content.
|
|
5
|
+
//
|
|
6
|
+
// ★ NOT A SHELL, and the distinction is the point. A shell DISPLACES: a sidebar takes its space and the
|
|
7
|
+
// content is simply smaller. A HUD OCCLUDES: the canvas keeps the whole surface and the chrome floats over
|
|
8
|
+
// it, which is why this component's budget is measured in occlusion and reachability — figures that mean
|
|
9
|
+
// nothing for a sidebar. It also composes WITH a shell rather than replacing one: on a console this sits
|
|
10
|
+
// inside the app shell's released panel, and on a phone it stands alone.
|
|
11
|
+
// See /layout/canvas.
|
|
12
|
+
//
|
|
13
|
+
// ★ THE CHROME IS A FRAME OF AREAS, NOT A SET OF CORNERS.
|
|
14
|
+
// Corner slots size themselves to whatever you put in them, which makes the layout a consequence of
|
|
15
|
+
// the content instead of a decision. Areas are the other way round: the HUD declares where chrome
|
|
16
|
+
// may live and how much room it has, and content fills them. That is what makes two screens built by
|
|
17
|
+
// two people line up, and it is what a Flutter implementation can match.
|
|
18
|
+
//
|
|
19
|
+
// Eight areas, as a STACK of rows — hub, top band, bottom band, dock:
|
|
20
|
+
//
|
|
21
|
+
// ┌───────────────────────────────────────┐
|
|
22
|
+
// │ hub │ a full-width row, contents centred. It centres on the
|
|
23
|
+
// ├──────┬─────────────┬────────┬─────────┤ CANVAS because it SPANS it.
|
|
24
|
+
// │ rail │ card-top │ │ rail │ TOP BAND — its own grid. Rails hug their control and
|
|
25
|
+
// │ -top │ │ │ -top │ cap; the card takes its own track; the middle is slack.
|
|
26
|
+
// │ start│ │ │ end │ Aligned to the band's TOP edge.
|
|
27
|
+
// ├──────┴─────────────┴────────┴─────────┤
|
|
28
|
+
// │ canvas visible │ NO element here at all — nothing to swallow a drag.
|
|
29
|
+
// ├──────┬─────────────┬────────┬─────────┤
|
|
30
|
+
// │ rail │ card-bottom │ │ rail │ BOTTOM BAND — its own grid, aligned to the BOTTOM edge,
|
|
31
|
+
// │ -bot │ │ │ -bot │ so it grows upward from the dock.
|
|
32
|
+
// │ start│ │ │ end │
|
|
33
|
+
// ├───────────────────────────────────────┤
|
|
34
|
+
// │ dock │ PHONE: its own full-width row, rails on its top edge.
|
|
35
|
+
// └───────────────────────────────────────┘ CONSOLE: joins the band's row, spanning it.
|
|
36
|
+
//
|
|
37
|
+
// ★ ONE GRID PER BAND, NOT ONE FOR THE HUD. A single grid means one set of columns for the whole
|
|
38
|
+
// layer, and that couples the bands to each other: an empty top rail cannot free its column because
|
|
39
|
+
// the BOTTOM rail is holding it open, and a card in each band is pinned to the same track whatever its
|
|
40
|
+
// band wants. Both were real — a card stayed indented by a rail that was not there. Bands sized
|
|
41
|
+
// independently have none of it, and nothing outside a band can move what is inside it.
|
|
42
|
+
//
|
|
43
|
+
// ★ THE HUB AND THE DOCK SPAN, WHICH IS WHY THEY ARE CENTRED. An area centred in a middle TRACK is
|
|
44
|
+
// centred on whatever the side tracks left over — and those are content-sized, so two cards of
|
|
45
|
+
// different lengths put it off centre. Measured: 11px with both sides filled, before anything is
|
|
46
|
+
// empty. Spanning the row makes it centre on the canvas by construction rather than by arithmetic.
|
|
47
|
+
//
|
|
48
|
+
// ★ RAILS HUG. The track is `fit-content(--forest-canvas-rail-max)`, so a 36px control leaves 36px and
|
|
49
|
+
// the hub keeps the rest. A fixed track left the control floating in dead space with the card beside it
|
|
50
|
+
// indented by space nothing occupied. The cap still earns its keep: an oversized child clamps there
|
|
51
|
+
// instead of starving the hub.
|
|
52
|
+
//
|
|
53
|
+
// ★ ONE CARD PER BAND. The slot names the BAND (`card-top`, `card-bottom`) and a prop names the side,
|
|
54
|
+
// so a band holding two cards is unrepresentable rather than merely discouraged. Two side by side need
|
|
55
|
+
// ~424px before the hub gets anything, which a 390pt phone does not have — measured at 79px each,
|
|
56
|
+
// against 164px for one.
|
|
57
|
+
//
|
|
58
|
+
// ★ AN UNUSED AREA RENDERS NOTHING — `v-if`, never an empty div. An empty div still carries the area's
|
|
59
|
+
// padding and holds its track open, so the neighbour never gets the width back.
|
|
60
|
+
//
|
|
61
|
+
// canvas the default slot. Full-bleed, reaches the physical edge, runs under the status bar.
|
|
62
|
+
// hub a full-width row: filter or mode chips, a search pill, a status line.
|
|
63
|
+
// rails four control stacks, hugging their content up to the rail cap.
|
|
64
|
+
// cards one per band, on the side its prop names — at every width, because the card hugs its
|
|
65
|
+
// content rather than stretching. Capped at the card measure on a console; on a phone its
|
|
66
|
+
// AREA spans between the rails, and a card that wants all of that says `w-full` itself.
|
|
67
|
+
// dock the primary action, or a stacked pair. Its row is `auto`, so it grows with its
|
|
68
|
+
// content and the bottom rails rise to meet it.
|
|
69
|
+
// drawer NOT here: that is UDrawer, portalled, and it owns its own scrim and drag. It does not
|
|
70
|
+
// need the dock hidden — a bottom drawer occupies the bottom of the screen, so it covers
|
|
71
|
+
// the dock on its own. The convention worth following is that the primary action rides the
|
|
72
|
+
// DRAWER's foot once one is open; the HUD does not enforce it, because a drawer opened as
|
|
73
|
+
// a peek over a still-relevant dock is a legitimate arrangement too.
|
|
74
|
+
// takeover NOT here, and not a slot. A takeover REPLACES the canvas, so it is `v-if` on this component
|
|
75
|
+
// or a route of its own — never an element inside it. A slot could only ever COVER the canvas,
|
|
76
|
+
// leaving the map mounted with its GL context live and its tiles loading behind an opaque box.
|
|
77
|
+
// A CAMERA earns a takeover; nothing else does. It replaces the canvas with a full-screen
|
|
78
|
+
// capture frame of its own.
|
|
79
|
+
//
|
|
80
|
+
// The grid does the arranging: top rails align to the top of their row, bottom rails to the BOTTOM of
|
|
81
|
+
// theirs, so they sit directly above the dock and move with it. That dependency is the reason this is
|
|
82
|
+
// one component rather than six absolutely-positioned boxes.
|
|
83
|
+
//
|
|
84
|
+
// Two rules it enforces structurally rather than by memo:
|
|
85
|
+
//
|
|
86
|
+
// 1. POINTER-EVENTS. The layer is `pointer-events-none`, the area wrappers STAY none, and only the
|
|
87
|
+
// children placed inside them opt back in (`[&>*]:pointer-events-auto`). That distinction is
|
|
88
|
+
// load-bearing: an area is a zone that spans real estate, so a wrapper that opted in itself would
|
|
89
|
+
// swallow every drag across its whole column — the defining bug of hand-rolled canvas chrome, and
|
|
90
|
+
// invisible until someone tries to pan the map behind a transparent box. The canvas column holds
|
|
91
|
+
// no element at all.
|
|
92
|
+
//
|
|
93
|
+
// 2. SAFE AREA IS CHROME'S, NEVER THE CANVAS'S. The canvas bleeds under the notch by definition;
|
|
94
|
+
// only chrome is inset. The inset reads `var(--forest-safe-*, env(safe-area-inset-*))` so a device
|
|
95
|
+
// mock can override with measured numbers while a real device falls through to `env()`.
|
|
96
|
+
const props = defineProps<{
|
|
97
|
+
/** Element the canvas renders as — `section` where the HUD is a page's main region. */
|
|
98
|
+
as?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Which arrangement to draw. `auto` — the default — reflows on the width of its OWN container,
|
|
101
|
+
* so one instance is correct on a phone browser and on a console without the caller doing anything.
|
|
102
|
+
*
|
|
103
|
+
* `phone` and `web` PIN it, overriding the query in both directions. That is for a specimen that has to
|
|
104
|
+
* draw a given surface regardless of how wide it happens to be rendered (a diagram, a device mock) — not
|
|
105
|
+
* for production, where the point is that it adapts.
|
|
106
|
+
*
|
|
107
|
+
* The reflow itself is CSS: see `.forest-hud` / `.forest-hud-band` in forest.css.
|
|
108
|
+
*/
|
|
109
|
+
surface?: CanvasSurface;
|
|
110
|
+
/**
|
|
111
|
+
* Ceiling on a rail's width, overriding the surface's own. A rail HUGS its control — the track is
|
|
112
|
+
* `fit-content(this)` — so a 36px button leaves 36px and the hub keeps the rest. The cap still
|
|
113
|
+
* matters: an oversized child clamps here instead of starving the hub.
|
|
114
|
+
*/
|
|
115
|
+
railMax?: string;
|
|
116
|
+
/** Ceiling on a card area's width, overriding the surface's own. */
|
|
117
|
+
card?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Which side of its band each card takes. Both default to `start`.
|
|
120
|
+
*
|
|
121
|
+
* ★ It means the same thing at every width. On a phone the card AREA spans between the rails, but the
|
|
122
|
+
* card inside hugs its content, so there is a side to take — a narrow card sits at the start or the
|
|
123
|
+
* end of that span. Only a card that asks for `w-full` has no side left to choose.
|
|
124
|
+
*/
|
|
125
|
+
cardTop?: 'start' | 'end';
|
|
126
|
+
cardBottom?: 'start' | 'end';
|
|
127
|
+
/**
|
|
128
|
+
* Ceiling on how tall a bottom rail may grow, as a share of the HUD.
|
|
129
|
+
*
|
|
130
|
+
* Expressed in `cqh` because that is the only unit that means what it says here: `%` on a grid item
|
|
131
|
+
* resolves against its grid area, not the screen, and `dvh` would measure the browser viewport — wrong
|
|
132
|
+
* inside a device mock. The chrome layer is declared a size container so `cqh` resolves against the
|
|
133
|
+
* HUD's own safe region.
|
|
134
|
+
*/
|
|
135
|
+
railBottomMax?: string;
|
|
136
|
+
/**
|
|
137
|
+
* Ceiling on how tall a card may grow, as a share of the HUD. Half the canvas per band.
|
|
138
|
+
*
|
|
139
|
+
* A card is the one area whose content has no natural stopping point — a rail holds two buttons, a card
|
|
140
|
+
* holds however many bays are in view — so it is the one that has to be told. Uncapped it pushes the
|
|
141
|
+
* dock out of the layer entirely: measured on a phone, a tall card moved the primary action 1582px past
|
|
142
|
+
* the bottom edge, where `overflow-hidden` clipped it away.
|
|
143
|
+
*/
|
|
144
|
+
cardMax?: string;
|
|
145
|
+
}>();
|
|
146
|
+
|
|
147
|
+
const el = computed(() => props.as ?? 'div');
|
|
148
|
+
const surface = computed(() => props.surface ?? 'auto');
|
|
149
|
+
const railMax = computed(() => props.railMax);
|
|
150
|
+
const card = computed(() => props.card);
|
|
151
|
+
// Defaulted here rather than with withDefaults so the class name can never interpolate `undefined`.
|
|
152
|
+
const cardTop = computed(() => props.cardTop ?? 'start');
|
|
153
|
+
const cardBottom = computed(() => props.cardBottom ?? 'start');
|
|
154
|
+
const railBottomMax = computed(() => props.railBottomMax ?? CANVAS_RAIL_BOTTOM_MAX);
|
|
155
|
+
const cardMax = computed(() => props.cardMax ?? CANVAS_CARD_MAX);
|
|
156
|
+
|
|
157
|
+
// ★ THE DOCK CENTRES ON THE CANVAS, not on whatever the rails leave over — and in the wide arrangement the
|
|
158
|
+
// band's side columns are declared symmetrically for exactly that reason, whether or not a rail is there to
|
|
159
|
+
// fill them. Asymmetric columns would push the primary action off centre by half a rail whenever one corner
|
|
160
|
+
// held a panel and the other did not. Reserving both costs nothing: they are transparent and pointer-none.
|
|
161
|
+
// This used to be JS, and had to stop being JS — a `computed` reading `$slots` caches its first answer,
|
|
162
|
+
// because `$slots` is not reactive, so a reserved column outlived the rail that justified it. Grid columns
|
|
163
|
+
// have no such failure mode.
|
|
164
|
+
//
|
|
165
|
+
// The hub does NOT balance, deliberately: it sits BETWEEN two cards as a peer in a band, so centring it in
|
|
166
|
+
// the space they leave is right. The dock is a single anchored action on the surface.
|
|
167
|
+
|
|
168
|
+
// ★ The safe inset ONLY. The gutter is padding INSIDE each area, not a gap between them — see the
|
|
169
|
+
// note on the grid below.
|
|
170
|
+
// Every area shares this: pointer-transparent wrapper, children opt in, and the gutter as INTERNAL
|
|
171
|
+
// padding.
|
|
172
|
+
//
|
|
173
|
+
// ★ Height is NOT here. Two `height` utilities at equal specificity are resolved by Tailwind's
|
|
174
|
+
// stylesheet order, not by the order they are listed on the element — so a shared `h-full` cannot be
|
|
175
|
+
// overridden per area. Each area states its own height.
|
|
176
|
+
// ★ HALF the gutter here, and the layer pads by the other half on top of the safe inset. Content is
|
|
177
|
+
// still one gutter from the screen edge, but two things in NEIGHBOURING areas are one gutter apart
|
|
178
|
+
// rather than two — a full gutter on each side of a shared edge read as dead space.
|
|
179
|
+
const AREA_BASE = 'pointer-events-none flex flex-col gap-2 p-[calc(var(--forest-chrome-gutter)/2)]';
|
|
180
|
+
// ★ `shrink-0` ON A RAIL'S CHILDREN, and it is a correctness rule rather than a spacing one.
|
|
181
|
+
//
|
|
182
|
+
// A bottom rail carries the same 50cqh ceiling a card does, but it has no scroll wrapper — that is the
|
|
183
|
+
// CARD constant below, which pins its children `shrink-0` and scrolls the overflow. Without the same
|
|
184
|
+
// pin here, a rail child taller than half the canvas is simply flex-shrunk to fit, and because every
|
|
185
|
+
// <FCanvasCard> is `overflow-hidden`, whatever no longer fits DISAPPEARS: measured on a five-row
|
|
186
|
+
// legend, the last row was gone with no scrollbar, no clipping edge and nothing in the DOM to say so —
|
|
187
|
+
// the element reported all five children while painting four.
|
|
188
|
+
//
|
|
189
|
+
// Pinned, the same child overflows its rail instead. That spills over the canvas, which is not pretty,
|
|
190
|
+
// but it is visible the moment it happens and it is fixable by whoever sees it. Silent loss is neither.
|
|
191
|
+
const AREA = `${AREA_BASE} [&>*]:shrink-0 [&>*]:pointer-events-auto`;
|
|
192
|
+
// ★ The hub and the dock use AREA_BASE, WITHOUT the grant. Their child is the reading wrapper, which is
|
|
193
|
+
// `w-full` up to 46rem — granting it pointers made a transparent 736px box that takes clicks either side
|
|
194
|
+
// of a 72px chip row. The grant cannot be undone on the wrapper either: `pointer-events-none` there and
|
|
195
|
+
// `[&>*]:pointer-events-auto` here are equal specificity, so Tailwind's stylesheet order decides it and
|
|
196
|
+
// the element loses. The wrapper passes the grant to ITS children instead.
|
|
197
|
+
|
|
198
|
+
// The safe inset per side. The gutter is padding INSIDE each area, not part of this.
|
|
199
|
+
//
|
|
200
|
+
// ★ The bottom also clears --forest-canvas-basemap-strip, which is a LICENCE constraint rather than
|
|
201
|
+
// spacing: Mapbox requires its wordmark (bottom-left) and its attribution (bottom-right) to stay
|
|
202
|
+
// visible, so the whole bottom edge of a map canvas belongs to the basemap. Reserving it here means
|
|
203
|
+
// compliance does not depend on whoever fills the areas remembering it. Defaults to 0, so a camera or
|
|
204
|
+
// board canvas pays nothing for a rule that does not apply to it.
|
|
205
|
+
// ★ THE HUB AND THE DOCK ARE CENTRED AND CONTENT-SIZED, with the reading measure as their ceiling.
|
|
206
|
+
//
|
|
207
|
+
// These are the only two areas that span the canvas, so they are the only two that can stretch a control
|
|
208
|
+
// cluster across the whole surface. On a phone that is right — a dock IS a full-width CTA across the thumb
|
|
209
|
+
// zone. On a 1360px console it is wrong: a button bar 1358px wide reads as a toolbar for the window rather
|
|
210
|
+
// than as actions for the canvas.
|
|
211
|
+
//
|
|
212
|
+
// The fix is NOT a width. It is `items-center` on the area plus this wrapper, which together make the
|
|
213
|
+
// child's own declaration decide:
|
|
214
|
+
//
|
|
215
|
+
// <UButton block> → w-full, so it fills, up to the reading measure. The phone dock.
|
|
216
|
+
// <UButton> → content width, centred. The desktop dock.
|
|
217
|
+
//
|
|
218
|
+
// That is already the Nuxt UI idiom for "fill your container", so the HUD adds no second mechanism and
|
|
219
|
+
// the same markup is correct on both surfaces. `max-w` is the ceiling for the `block` case only — it stops
|
|
220
|
+
// a filling child at the same measure the well's reading rung uses, and never binds on a phone, where the
|
|
221
|
+
// screen is narrower than the measure.
|
|
222
|
+
//
|
|
223
|
+
// The AREA still tiles the frame either way: this constrains its contents, so the grid and the overlay are
|
|
224
|
+
// untouched.
|
|
225
|
+
// `flex flex-col items-center` and not just `mx-auto`: the wrapper centres ITSELF in the area, which
|
|
226
|
+
// leaves a content-width child sitting against the wrapper's start edge rather than in the middle of the
|
|
227
|
+
// canvas. A `block` child is w-full and fills regardless, so this costs the filling case nothing. `gap-2`
|
|
228
|
+
// is for the stacked pair the dock is allowed to grow into.
|
|
229
|
+
// ★ POINTER-TRANSPARENT, and its children opt back in. The area grants `[&>*]:pointer-events-auto`,
|
|
230
|
+
// which lands on THIS wrapper — and it is `w-full` up to the reading measure, so it was a transparent
|
|
231
|
+
// 736px box taking clicks either side of a 72px chip row. Measured on a console: the hub and the dock
|
|
232
|
+
// each claimed 736×36 of canvas that looks empty and does not pan.
|
|
233
|
+
const READING =
|
|
234
|
+
'mx-auto flex w-full max-w-(--forest-well-reading) flex-col items-center gap-2 [&>*]:pointer-events-auto';
|
|
235
|
+
|
|
236
|
+
// A card scrolls at its ceiling rather than spilling past it, so the area caps the BOX and this scrolls
|
|
237
|
+
// the CONTENT. It has to be a wrapper: the area is pointer-transparent and a pointer-none box cannot be
|
|
238
|
+
// scrolled directly, whereas a wheel or a drag landing on the card itself chains up to this one.
|
|
239
|
+
// ★ Pointer-transparent regardless of its width, and the grant lands on its children — which is where the
|
|
240
|
+
// card actually is. A wrapper that took clicks itself would own every pixel of its box, including whatever
|
|
241
|
+
// sits beside a card narrower than it.
|
|
242
|
+
// ★ IT HUGS ITS CONTENT — no `w-full`. The AREA still spans columns 2/5 below 60rem, because a third-width
|
|
243
|
+
// track plus two rails wants ~424px and a phone has not got it; what does not follow is that the CARD must
|
|
244
|
+
// stretch to fill that span. Stretching it made two things wrong at once: `cardTop`/`cardBottom` became
|
|
245
|
+
// inert on a phone, since aligning a full-width box does nothing, and every author who wanted a card the
|
|
246
|
+
// size of its content wrote around it — `w-64` appears on three of the five shipped call sites. Hugging
|
|
247
|
+
// makes the side prop mean the same thing at every width, and anything that genuinely wants the whole
|
|
248
|
+
// measure (a phone sheet) says `w-full` on its own card, where it reads as a decision.
|
|
249
|
+
// ★ `[&>*]:shrink-0` is what makes it scroll rather than crush. A card carries `overflow`, which gives it
|
|
250
|
+
// an automatic minimum size of 0 — so as a flex item it shrank to the ceiling and clipped its own content,
|
|
251
|
+
// leaving the scroller with nothing to scroll. Measured: a 2000px card became 367px with the excess simply
|
|
252
|
+
// gone. Holding children at their natural height puts the overflow where the scrollbar is.
|
|
253
|
+
const CARD = 'flex min-h-0 flex-col gap-2 overflow-y-auto [&>*]:shrink-0 [&>*]:pointer-events-auto';
|
|
254
|
+
|
|
255
|
+
// ★ The side insets also clear whatever an open <FSidePanel> declared on the container they share
|
|
256
|
+
// (--forest-canvas-inset-start/end, 0 when none is open). A panel OCCLUDES the canvas, so a rail on
|
|
257
|
+
// that edge would otherwise sit underneath it — visible chrome that cannot be clicked, and no z-index
|
|
258
|
+
// resolves it because the panel is deliberately on top. Adding it to the safe inset rather than
|
|
259
|
+
// replacing it means a panel and a device notch compose instead of one erasing the other.
|
|
260
|
+
//
|
|
261
|
+
// ★ PHYSICAL sides, matching the safe-area insets they are added to (`env(safe-area-inset-*)` is
|
|
262
|
+
// physical — a notch does not move under RTL). FSidePanel maps its logical `side` to the physical
|
|
263
|
+
// property when it writes it; an RTL surface would need that mapping to become direction-aware, which
|
|
264
|
+
// nothing here ships today.
|
|
265
|
+
const inset = (side: 'top' | 'right' | 'bottom' | 'left') => {
|
|
266
|
+
const safe = `var(--forest-safe-${side}, env(safe-area-inset-${side}, 0px))`;
|
|
267
|
+
// ★ Plus HALF the gutter. Each area carries the other half, so content sits one gutter from the
|
|
268
|
+
// edge while neighbours across a shared edge stay one gutter apart rather than two. The safe inset
|
|
269
|
+
// is a MINIMUM, not a target, so adding to it is legitimate.
|
|
270
|
+
const half = 'calc(var(--forest-chrome-gutter) / 2)';
|
|
271
|
+
if (side === 'bottom')
|
|
272
|
+
return `calc(${safe} + var(--forest-canvas-basemap-strip, 0px) + ${half})`;
|
|
273
|
+
if (side === 'top') return `calc(${safe} + ${half})`;
|
|
274
|
+
return `calc(${safe} + var(--forest-canvas-inset-${side}, 0px) + ${half})`;
|
|
275
|
+
};
|
|
276
|
+
</script>
|
|
277
|
+
|
|
278
|
+
<template>
|
|
279
|
+
<!-- ★ The named query container the reflow keys on, and it is the ROOT rather than the viewport: on a
|
|
280
|
+
console this HUD is the interior of an inset panel, narrower than the window and narrower again
|
|
281
|
+
when the sidebar expands. `inline-size` because only the width is queried; the chrome layer inside
|
|
282
|
+
declares `size` separately, for the rails' `cqh` ceiling. -->
|
|
283
|
+
<!-- ★ `data-forest-canvas` is a HANDLE, not a styling hook: it is how a control finds the whole
|
|
284
|
+
canvas — chrome and all — from the thing it is chrome for. <FMapFullscreen> walks up from the
|
|
285
|
+
map's own container to it, because fullscreening the map element alone leaves every control
|
|
286
|
+
behind. Nothing in this file styles off it, and nothing should. -->
|
|
287
|
+
<component
|
|
288
|
+
:is="el"
|
|
289
|
+
data-forest-canvas
|
|
290
|
+
class="relative isolate h-full w-full overflow-hidden"
|
|
291
|
+
style="container: forest-canvas / inline-size"
|
|
292
|
+
>
|
|
293
|
+
<!-- CANVAS — z-0, edge to edge, untouched by safe area -->
|
|
294
|
+
<div class="absolute inset-0 z-0">
|
|
295
|
+
<slot />
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
<!-- CHROME — one layer, pointer-transparent; areas opt in.
|
|
299
|
+
A STACK, not a grid: hub row, top band, bottom band, and each band brings its own columns. -->
|
|
300
|
+
<div
|
|
301
|
+
class="forest-hud pointer-events-none absolute inset-0 z-10 flex flex-col"
|
|
302
|
+
:data-surface="surface"
|
|
303
|
+
:style="{
|
|
304
|
+
containerType: 'size',
|
|
305
|
+
...(railMax ? { '--forest-canvas-rail-max': railMax } : {}),
|
|
306
|
+
...(card ? { '--forest-canvas-card': card } : {}),
|
|
307
|
+
'--forest-canvas-card-max': cardMax,
|
|
308
|
+
paddingTop: inset('top'),
|
|
309
|
+
paddingRight: inset('right'),
|
|
310
|
+
paddingBottom: inset('bottom'),
|
|
311
|
+
paddingLeft: inset('left'),
|
|
312
|
+
transitionProperty: 'padding',
|
|
313
|
+
transitionDuration: `var(${'--forest-canvas-inset-duration'}, var(--motion-slow))`,
|
|
314
|
+
transitionTimingFunction: 'var(--motion-ease-out)',
|
|
315
|
+
}"
|
|
316
|
+
>
|
|
317
|
+
<!-- HUB — a full-width row of the stack, so it centres on the CANVAS by construction rather than
|
|
318
|
+
on whatever the rails leave over. It was a middle track between two rails, which put it off
|
|
319
|
+
centre the moment the two sides held different content — and starved it to nothing on a
|
|
320
|
+
phone, where two card-width rails leave ~16px between them. -->
|
|
321
|
+
<div v-if="$slots.hub" data-area="hub" :class="[AREA_BASE, 'items-center']">
|
|
322
|
+
<div :class="READING">
|
|
323
|
+
<slot name="hub" />
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
<!-- TOP BAND -->
|
|
328
|
+
<div v-if="$slots['rail-top-start'] || $slots['card-top'] || $slots['rail-top-end']" class="forest-hud-band">
|
|
329
|
+
<div
|
|
330
|
+
v-if="$slots['rail-top-start']"
|
|
331
|
+
data-area="rail-top-start"
|
|
332
|
+
:class="[AREA, 'items-start justify-start']"
|
|
333
|
+
style="grid-column: 1 / 2; grid-row: 1"
|
|
334
|
+
>
|
|
335
|
+
<slot name="rail-top-start" />
|
|
336
|
+
</div>
|
|
337
|
+
<div
|
|
338
|
+
v-if="$slots['card-top']"
|
|
339
|
+
data-area="card-top"
|
|
340
|
+
:class="[AREA_BASE, 'forest-hud-card min-h-0', `forest-hud-card--${cardTop}`, cardTop === 'end' ? 'items-end' : 'items-start', 'justify-start']"
|
|
341
|
+
:style="{ gridRow: '1', maxHeight: '100%' }"
|
|
342
|
+
>
|
|
343
|
+
<div :class="CARD">
|
|
344
|
+
<slot name="card-top" />
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
<div
|
|
348
|
+
v-if="$slots['rail-top-end']"
|
|
349
|
+
data-area="rail-top-end"
|
|
350
|
+
:class="[AREA, 'items-end justify-start']"
|
|
351
|
+
style="grid-column: 5 / 6; grid-row: 1"
|
|
352
|
+
>
|
|
353
|
+
<slot name="rail-top-end" />
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
<!-- ★ NO SPACER ELEMENT between the bands. The canvas region holds nothing at all, so there is no
|
|
358
|
+
box to swallow a drag across it. `mt-auto` on the bottom band does the pushing. -->
|
|
359
|
+
<div class="forest-hud-band forest-hud-band--dock mt-auto">
|
|
360
|
+
<div
|
|
361
|
+
v-if="$slots['rail-bottom-start']"
|
|
362
|
+
data-area="rail-bottom-start"
|
|
363
|
+
:class="[AREA, 'items-start justify-end']"
|
|
364
|
+
:style="{ gridColumn: '1 / 2', gridRow: '1', maxHeight: railBottomMax }"
|
|
365
|
+
>
|
|
366
|
+
<slot name="rail-bottom-start" />
|
|
367
|
+
</div>
|
|
368
|
+
<div
|
|
369
|
+
v-if="$slots['card-bottom']"
|
|
370
|
+
data-area="card-bottom"
|
|
371
|
+
:class="[AREA_BASE, 'forest-hud-card min-h-0', `forest-hud-card--${cardBottom}`, cardBottom === 'end' ? 'items-end' : 'items-start', 'justify-end']"
|
|
372
|
+
:style="{ gridRow: '1', maxHeight: '100%' }"
|
|
373
|
+
>
|
|
374
|
+
<div :class="CARD">
|
|
375
|
+
<slot name="card-bottom" />
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
<div
|
|
379
|
+
v-if="$slots['rail-bottom-end']"
|
|
380
|
+
data-area="rail-bottom-end"
|
|
381
|
+
:class="[AREA, 'items-end justify-end']"
|
|
382
|
+
:style="{ gridColumn: '5 / 6', gridRow: '1', maxHeight: railBottomMax }"
|
|
383
|
+
>
|
|
384
|
+
<slot name="rail-bottom-end" />
|
|
385
|
+
</div>
|
|
386
|
+
|
|
387
|
+
<!-- The dock spans its row, so it centres on the canvas. Phone: its own row under the band.
|
|
388
|
+
Console: the band's single row, between the rails. Which one is CSS, so `auto` reflows. -->
|
|
389
|
+
<div
|
|
390
|
+
v-if="$slots.dock"
|
|
391
|
+
data-area="dock"
|
|
392
|
+
:class="[AREA_BASE, 'forest-hud-dock min-w-0 items-center justify-end']"
|
|
393
|
+
>
|
|
394
|
+
<div :class="READING">
|
|
395
|
+
<slot name="dock" />
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
400
|
+
|
|
401
|
+
</component>
|
|
402
|
+
</template>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canvas HUD's surface vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* ★ THE MEASURES ARE NOT HERE, and deliberately so. `railTop` and `railBottom` live once, as the
|
|
5
|
+
* `--forest-canvas-rail-*` custom properties in styles/forest.css, where a container query reflows them
|
|
6
|
+
* between the two surfaces. Anything that needs to state a number reads that property back:
|
|
7
|
+
*
|
|
8
|
+
* · the HUD applies them as `var(--forest-canvas-rail-top)`, never as a JS value;
|
|
9
|
+
* · a diagram sizes its rail chips from the same property, so it cannot draw a cap the HUD is not
|
|
10
|
+
* applying;
|
|
11
|
+
* · documentation reads them off a hidden probe (apps/docs useCanvasMeasures), so prose prints what the
|
|
12
|
+
* HUD resolved.
|
|
13
|
+
*
|
|
14
|
+
* A TS mirror of those values used to live here for documentation to quote. It was one copy too many: this
|
|
15
|
+
* page had already shipped a caption claiming 18rem while the component passed 17. Numbers belong wherever
|
|
16
|
+
* they are used, and everything else asks.
|
|
17
|
+
*/
|
|
18
|
+
export type CanvasSurface = 'auto' | 'phone' | 'web';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Ceiling on how tall a bottom rail may grow, as a share of the HUD. Same on both surfaces, applied as an
|
|
22
|
+
* inline `max-height`, so unlike the rail widths it has no CSS counterpart to drift from.
|
|
23
|
+
*
|
|
24
|
+
* `cqh` because it is the only unit that means what it says here: `%` on a grid item resolves against its
|
|
25
|
+
* grid area, and `dvh` would measure the browser viewport — wrong inside a device mock. The HUD's chrome
|
|
26
|
+
* layer is declared a size container so this resolves against the HUD's own safe region.
|
|
27
|
+
*/
|
|
28
|
+
export const CANVAS_RAIL_BOTTOM_MAX = '50cqh';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ceiling on how tall a card may grow, as a share of the HUD — half the canvas per band, so a card in each
|
|
32
|
+
* band still leaves the map its middle. Same unit and the same reasoning as the rail ceiling above.
|
|
33
|
+
*
|
|
34
|
+
* ★ Applied to the band's ROW — `minmax(0, cap)` — and not as a `max-height` on the card. A max-height on a
|
|
35
|
+
* grid item clamps only what it paints: the auto row still sizes to the item's content, so a 2000px card
|
|
36
|
+
* drew at its 383px ceiling inside a 2116px band and pushed the dock out of the layer regardless. On the
|
|
37
|
+
* track it binds, and the card takes `max-height: 100%` of the row it is given.
|
|
38
|
+
*/
|
|
39
|
+
export const CANVAS_CARD_MAX = '50cqh';
|