@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,291 @@
|
|
|
1
|
+
// A ZONE is a rule with a boundary — where riding, throttling or parking changes.
|
|
2
|
+
//
|
|
3
|
+
// ★ ZONES ARE PAINT, NOT MARKS. A region has no centre worth claiming, so a zone is a `fill` plus a
|
|
4
|
+
// `line`, described by `useMapOverlay` like any other overlay. There is no zone primitive and no zone
|
|
5
|
+
// component: the style spec already says everything a fill and a line need to say.
|
|
6
|
+
//
|
|
7
|
+
// ★ AUDIENCE IS THE FIRST AXIS, AND IT IS A SOURCE DECISION.
|
|
8
|
+
// The same geometry does not mean the same thing to an operator and to a rider, and one zone — the
|
|
9
|
+
// exclusion — is an internal control the rider is never told about. So audience never rides
|
|
10
|
+
// `visibility`: a hidden layer still ships its polygons to the client, and a control that is one
|
|
11
|
+
// devtools tab away is not a control. `zoneFeatures()` filters the DATA; `zoneLayers()` only paints
|
|
12
|
+
// what it is given.
|
|
13
|
+
//
|
|
14
|
+
// ★ FILLS AND LINES COME FROM DIFFERENT FEATURE SETS, and this is the part that is easy to get wrong.
|
|
15
|
+
// Restrictions overlap — a low-speed zone can cross a no-parking zone — and two translucent fills
|
|
16
|
+
// blend, so an overlap paints a colour that belongs to no zone kind at all. A LINE has no such
|
|
17
|
+
// problem: every zone draws its own complete boundary, and boundaries crossing is what boundaries do.
|
|
18
|
+
// So: lines take the raw features, fills take a DISJOINT set where the strictest rule already won.
|
|
19
|
+
// See ZONE_RANK and the note on `zoneSources`.
|
|
20
|
+
import { MAP_SLOT, token, type OverlayLayer } from './useMapOverlay';
|
|
21
|
+
import type { MapLegendItem } from './mapLegend';
|
|
22
|
+
|
|
23
|
+
export type ZoneKind = 'BUSINESS' | 'LOW_SPEED' | 'NO_PARKING' | 'EVENT' | 'EXCLUSION';
|
|
24
|
+
|
|
25
|
+
/** Who is looking at the map. It decides which zones exist, not how they are painted. */
|
|
26
|
+
export type ZoneAudience = 'operations' | 'rider';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What each zone does to a rental, stated as the two things a rider can be doing.
|
|
30
|
+
*
|
|
31
|
+
* `speed` is the only three-valued one, and only because a low-speed zone permits the ride and limits
|
|
32
|
+
* it at the same time.
|
|
33
|
+
*/
|
|
34
|
+
export interface ZoneRule {
|
|
35
|
+
/** may a rider ride here */
|
|
36
|
+
ride: boolean;
|
|
37
|
+
/** may a rider END A RENTAL here */
|
|
38
|
+
park: boolean;
|
|
39
|
+
/** is the vehicle throttled here */
|
|
40
|
+
throttled: boolean;
|
|
41
|
+
/** does this zone appear and disappear on a schedule */
|
|
42
|
+
scheduled: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const ZONE_RULE: Record<ZoneKind, ZoneRule> = {
|
|
46
|
+
BUSINESS: { ride: true, park: true, throttled: false, scheduled: false },
|
|
47
|
+
LOW_SPEED: { ride: true, park: true, throttled: true, scheduled: false },
|
|
48
|
+
NO_PARKING: { ride: true, park: false, throttled: false, scheduled: false },
|
|
49
|
+
// An event zone IS a no-parking zone. What separates it is that it is temporary and tied to an
|
|
50
|
+
// event, so it appears and disappears on a schedule where a no-parking zone is standing policy.
|
|
51
|
+
EVENT: { ride: true, park: false, throttled: false, scheduled: true },
|
|
52
|
+
// ★ THE EXCLUSION ZONE PERMITS. It is a pocket cut out of a no-parking zone where a rental may end
|
|
53
|
+
// after all. The rider is shown the no-parking zone whole and is never told the pocket is there; if
|
|
54
|
+
// they happen to park inside it, the system simply does not refuse.
|
|
55
|
+
EXCLUSION: { ride: true, park: true, throttled: false, scheduled: false },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ★ WHO SEES WHICH ZONE. Four of the five are rules a rider must be able to obey, so a rider is shown
|
|
60
|
+
* them. The exclusion is not a rule a rider is asked to obey — it is a hole in one — so it is
|
|
61
|
+
* operations-only, and `zoneFeatures()` drops it from the payload rather than hiding it.
|
|
62
|
+
*/
|
|
63
|
+
export const ZONE_AUDIENCE: Record<ZoneKind, readonly ZoneAudience[]> = {
|
|
64
|
+
BUSINESS: ['operations', 'rider'],
|
|
65
|
+
LOW_SPEED: ['operations', 'rider'],
|
|
66
|
+
NO_PARKING: ['operations', 'rider'],
|
|
67
|
+
EVENT: ['operations', 'rider'],
|
|
68
|
+
EXCLUSION: ['operations'],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const zoneVisibleTo = (kind: ZoneKind, audience: ZoneAudience): boolean =>
|
|
72
|
+
ZONE_AUDIENCE[kind].includes(audience);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* ★ COLOUR IS WHAT THE RULE DOES TO A RENTAL (José, 2026-07-26).
|
|
76
|
+
*
|
|
77
|
+
* Green permits, amber limits, red forbids. It is a SEVERITY, so it takes the semantic roles — the
|
|
78
|
+
* same reason the battery ring does, and the reason the vivid categorical set stays with the vehicle
|
|
79
|
+
* discs. PARKING is the axis, not riding: a low-speed zone limits the ride and leaves parking alone,
|
|
80
|
+
* which is what makes it amber rather than red.
|
|
81
|
+
*
|
|
82
|
+
* Two of the five are not on that scale, and each says so by not taking a hue from it:
|
|
83
|
+
*
|
|
84
|
+
* BUSINESS is INK. It is the container the others nest in and it claims nothing about behaviour
|
|
85
|
+
* inside itself. The line is where the service stops, and ink flips with the mode, so the
|
|
86
|
+
* outermost boundary stays maximally separated from the page in both.
|
|
87
|
+
* EVENT is PINK — one step from red, because an event zone IS a no-parking zone with a schedule.
|
|
88
|
+
* The dash carries the schedule and the hue carries the kinship.
|
|
89
|
+
*/
|
|
90
|
+
export const ZONE_COLOUR: Record<ZoneKind, string> = {
|
|
91
|
+
BUSINESS: '--forest-map-zone',
|
|
92
|
+
LOW_SPEED: '--forest-map-zone-low-speed',
|
|
93
|
+
NO_PARKING: '--forest-map-zone-no-parking',
|
|
94
|
+
EVENT: '--forest-map-zone-event',
|
|
95
|
+
EXCLUSION: '--forest-map-zone-exclusion',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* ★ THE BUSINESS ZONE HAS NO FILL. It is the normal condition and it covers the whole screen, so an
|
|
100
|
+
* 8% wash over every pixel is a permanent tint a reader stops seeing — and every restriction inside
|
|
101
|
+
* it would then be a second layer on top of it. Its boundary is the whole of its message: the
|
|
102
|
+
* operating area is where the line is, and the rest of the map is outside it.
|
|
103
|
+
*
|
|
104
|
+
* The exclusion has no fill either, for the opposite reason: it is an ABSENCE. It marks where the
|
|
105
|
+
* no-parking fill stops, and painting an absence is how it stops being one.
|
|
106
|
+
*/
|
|
107
|
+
export const ZONE_FILL_OPACITY: Record<ZoneKind, number> = {
|
|
108
|
+
BUSINESS: 0,
|
|
109
|
+
LOW_SPEED: 0.1,
|
|
110
|
+
NO_PARKING: 0.12,
|
|
111
|
+
EVENT: 0.12,
|
|
112
|
+
EXCLUSION: 0,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* ★ THE DASH SAYS "THIS BOUNDARY IS NOT A STANDING RULE".
|
|
117
|
+
*
|
|
118
|
+
* An event zone is temporary and an exclusion is internal, and both of those are the same statement
|
|
119
|
+
* about the line: do not read this edge as policy. Solid means standing, dashed means conditional.
|
|
120
|
+
* Dashes are in LINE WIDTHS, so the pattern holds its proportions as the line scales.
|
|
121
|
+
*/
|
|
122
|
+
export const ZONE_DASH: Partial<Record<ZoneKind, readonly number[]>> = {
|
|
123
|
+
EVENT: [3, 2],
|
|
124
|
+
EXCLUSION: [2, 2],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* ★ STRICTEST WINS, AND IT WINS IN THE DATA.
|
|
129
|
+
*
|
|
130
|
+
* Two overlapping translucent fills blend: 12% over 12% paints 23%, which reads as a fourth zone kind
|
|
131
|
+
* that does not exist. Mapbox has no per-pixel precedence to reach for — a `fill` layer blends every
|
|
132
|
+
* feature it draws — so the only correct fix is to hand the fill layer a DISJOINT feature set.
|
|
133
|
+
*
|
|
134
|
+
* This rank is what resolves it: where regions overlap, the higher rank owns the pixels, and the
|
|
135
|
+
* exclusion (rank 0, and permissive) subtracts from whatever it sits inside. A feed that cannot
|
|
136
|
+
* flatten its own geometry can still be drawn correctly — see `zoneSources`, which takes the fills
|
|
137
|
+
* and the boundaries separately for exactly this reason.
|
|
138
|
+
*/
|
|
139
|
+
export const ZONE_RANK: Record<ZoneKind, number> = {
|
|
140
|
+
EXCLUSION: 0,
|
|
141
|
+
BUSINESS: 1,
|
|
142
|
+
LOW_SPEED: 2,
|
|
143
|
+
NO_PARKING: 3,
|
|
144
|
+
EVENT: 4,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const ZONE_LABEL: Record<ZoneKind, string> = {
|
|
148
|
+
BUSINESS: 'Business zone',
|
|
149
|
+
LOW_SPEED: 'Low speed zone',
|
|
150
|
+
NO_PARKING: 'No parking zone',
|
|
151
|
+
EVENT: 'Event zone',
|
|
152
|
+
EXCLUSION: 'Exclusion zone',
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** What the rule does, in one line, for a legend or a popup. */
|
|
156
|
+
export const ZONE_RULE_TEXT: Record<ZoneKind, string> = {
|
|
157
|
+
BUSINESS: 'Ride and park inside it.',
|
|
158
|
+
LOW_SPEED: 'Ride, throttled. Parking is unaffected.',
|
|
159
|
+
NO_PARKING: 'Ride through it. Do not end a rental in it.',
|
|
160
|
+
EVENT: 'A no parking zone with a schedule.',
|
|
161
|
+
EXCLUSION: 'A pocket inside a no parking zone where a rental may end. Operations only.',
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** the kinds an audience is allowed to know about, strictest last so paint order follows the rank */
|
|
165
|
+
export const zoneKinds = (audience: ZoneAudience): ZoneKind[] =>
|
|
166
|
+
(Object.keys(ZONE_RULE) as ZoneKind[])
|
|
167
|
+
.filter((kind) => zoneVisibleTo(kind, audience))
|
|
168
|
+
.sort((a, b) => ZONE_RANK[a] - ZONE_RANK[b]);
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The legend rows for an audience — the same kinds `zoneKinds` allows, described so a swatch can be
|
|
172
|
+
* drawn from them.
|
|
173
|
+
*
|
|
174
|
+
* ★ IT TAKES THE AUDIENCE FOR THE SAME REASON THE DATA DOES. A rider legend naming the exclusion zone
|
|
175
|
+
* would hand back exactly what `zoneFeatures` withholds, so both filters run through `zoneVisibleTo`
|
|
176
|
+
* rather than each restating the rule.
|
|
177
|
+
*
|
|
178
|
+
* ★ AND IT READS IN A DIFFERENT ORDER THAN IT PAINTS. `zoneKinds` sorts by ZONE_RANK so the strictest
|
|
179
|
+
* zone paints over the rest, which puts the exclusion first. A legend read in that order opens on an
|
|
180
|
+
* ops-only exception. Ranking below the business zone is what makes a kind an exception to it, so
|
|
181
|
+
* those read last: here is where you operate, here is what narrows it, and here is what gives some
|
|
182
|
+
* back.
|
|
183
|
+
*/
|
|
184
|
+
export function zoneLegendItems(audience: ZoneAudience): MapLegendItem[] {
|
|
185
|
+
const count = Object.keys(ZONE_RULE).length;
|
|
186
|
+
const order = (kind: ZoneKind) =>
|
|
187
|
+
ZONE_RANK[kind] < ZONE_RANK.BUSINESS ? ZONE_RANK[kind] + count : ZONE_RANK[kind];
|
|
188
|
+
|
|
189
|
+
return zoneKinds(audience)
|
|
190
|
+
.sort((a, b) => order(a) - order(b))
|
|
191
|
+
.map((kind) => ({
|
|
192
|
+
key: kind,
|
|
193
|
+
// ★ The shared noun comes off. Every row in a zone legend is a zone, so carrying it five times
|
|
194
|
+
// says nothing and costs the width that made four of the five labels wrap in a rail. Derived
|
|
195
|
+
// rather than a second table of names, so ZONE_LABEL stays the only place a zone is named.
|
|
196
|
+
label: ZONE_LABEL[kind].replace(/ zone$/i, ''),
|
|
197
|
+
colour: ZONE_COLOUR[kind],
|
|
198
|
+
// A zone with no fill is drawn as its boundary alone, and the swatch says so rather than
|
|
199
|
+
// implying a wash the map never paints.
|
|
200
|
+
swatch: ZONE_FILL_OPACITY[kind] > 0 ? ('fill' as const) : ('line' as const),
|
|
201
|
+
dash: ZONE_DASH[kind],
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Drop every feature the audience must not receive.
|
|
207
|
+
*
|
|
208
|
+
* ★ Call this where the payload is BUILT, not where it is painted. On a rider surface the exclusion
|
|
209
|
+
* geometry should never leave the server; this function is the last line of that defence, not the
|
|
210
|
+
* whole of it.
|
|
211
|
+
*/
|
|
212
|
+
export function zoneFeatures<T extends { properties?: Record<string, unknown> | null }>(
|
|
213
|
+
features: readonly T[],
|
|
214
|
+
audience: ZoneAudience,
|
|
215
|
+
prop = 'kind',
|
|
216
|
+
): T[] {
|
|
217
|
+
return features.filter((f) => {
|
|
218
|
+
const kind = f.properties?.[prop] as ZoneKind | undefined;
|
|
219
|
+
return !!kind && zoneVisibleTo(kind, audience);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface ZoneLayerOptions {
|
|
224
|
+
/** the source holding the DISJOINT fill features. Omit to draw boundaries only */
|
|
225
|
+
fillSource?: string;
|
|
226
|
+
/** the source holding every zone's complete boundary */
|
|
227
|
+
lineSource: string;
|
|
228
|
+
/** which audience the surface is for. Decides which kinds get layers at all */
|
|
229
|
+
audience: ZoneAudience;
|
|
230
|
+
/** feature property holding the kind. Default `kind` */
|
|
231
|
+
prop?: string;
|
|
232
|
+
/** id prefix, so two zone overlays can coexist. Default `zone` */
|
|
233
|
+
prefix?: string;
|
|
234
|
+
/** the width of a boundary, in px. Default 1.5 */
|
|
235
|
+
lineWidth?: number;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The overlay layers for a zone set: one fill layer, then one line layer per kind.
|
|
240
|
+
*
|
|
241
|
+
* ONE fill layer, because a fill answers "which rule applies here" and the answer is single-valued
|
|
242
|
+
* once the geometry is disjoint — a `match` on the kind paints it. MANY line layers, because a dash
|
|
243
|
+
* pattern is not data-driven in the way a colour is, and because a boundary per kind is what lets an
|
|
244
|
+
* operator turn one off.
|
|
245
|
+
*
|
|
246
|
+
* Everything lands in `MAP_SLOT.zone` (`bottom`): a zone is context, so it goes under the basemap's own
|
|
247
|
+
* labels, under the 3D buildings, and under every mark. Emissive strength is added by `useMapOverlay` — `fill` and `line` are both
|
|
248
|
+
* shaded types, and an unlit overlay paints at about a fifth of its luminance under the night preset.
|
|
249
|
+
*/
|
|
250
|
+
export function zoneLayers(opts: ZoneLayerOptions): OverlayLayer[] {
|
|
251
|
+
const { fillSource, lineSource, audience, prop = 'kind', prefix = 'zone', lineWidth = 1.5 } = opts;
|
|
252
|
+
const kinds = zoneKinds(audience);
|
|
253
|
+
const filled = kinds.filter((k) => ZONE_FILL_OPACITY[k] > 0);
|
|
254
|
+
const kindProp = ['get', prop];
|
|
255
|
+
|
|
256
|
+
const layers: OverlayLayer[] = [];
|
|
257
|
+
|
|
258
|
+
if (fillSource && filled.length) {
|
|
259
|
+
layers.push({
|
|
260
|
+
id: `${prefix}-fill`,
|
|
261
|
+
type: 'fill',
|
|
262
|
+
source: fillSource,
|
|
263
|
+
slot: MAP_SLOT.zone,
|
|
264
|
+
// A kind this audience must not see is filtered out of the data as well; the filter is here so
|
|
265
|
+
// that a feed which sends one anyway paints nothing rather than painting it in a fallback colour.
|
|
266
|
+
filter: ['in', kindProp, ['literal', filled]],
|
|
267
|
+
paint: {
|
|
268
|
+
'fill-color': ['match', kindProp, ...filled.flatMap((k) => [k, token(ZONE_COLOUR[k])]), token(ZONE_COLOUR.BUSINESS)],
|
|
269
|
+
'fill-opacity': ['match', kindProp, ...filled.flatMap((k) => [k, ZONE_FILL_OPACITY[k]]), 0],
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
for (const kind of kinds) {
|
|
275
|
+
const dash = ZONE_DASH[kind];
|
|
276
|
+
layers.push({
|
|
277
|
+
id: `${prefix}-line-${kind.toLowerCase().replace('_', '-')}`,
|
|
278
|
+
type: 'line',
|
|
279
|
+
source: lineSource,
|
|
280
|
+
slot: MAP_SLOT.zone,
|
|
281
|
+
filter: ['==', kindProp, kind],
|
|
282
|
+
paint: {
|
|
283
|
+
'line-color': token(ZONE_COLOUR[kind]),
|
|
284
|
+
'line-width': lineWidth,
|
|
285
|
+
...(dash ? { 'line-dasharray': [...dash] } : {}),
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return layers;
|
|
291
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The app shell's header — the rail control, the crumb, and the app's own actions.
|
|
3
|
+
//
|
|
4
|
+
// <FAppNavbar :crumbs="[{ label: 'Fleet' }, { label: 'Vehicles' }]">
|
|
5
|
+
// <template #right> …buttons… </template>
|
|
6
|
+
// </FAppNavbar>
|
|
7
|
+
//
|
|
8
|
+
// The counterpart to <FAppSidebar>: the app supplies the CRUMB and the actions, everything else is
|
|
9
|
+
// the same in every Forest app. It exists so a shell header needs no `:ui` at the usage site — three
|
|
10
|
+
// things were being restated by hand in every app that composed <UDashboardNavbar> directly:
|
|
11
|
+
//
|
|
12
|
+
// · The CHROME GUTTER. <FAppShell> puts the header in the chrome gutter, whose edges already track
|
|
13
|
+
// the card's (mx-(--forest-panel-inset)), so stock's own px-4 sm:px-6 lands on top of a margin
|
|
14
|
+
// that is already there. Stock's padding is right where stock puts a navbar — inside a panel,
|
|
15
|
+
// lining up with its body — and wrong in the gutter, so this is the shell's to state, not the theme's.
|
|
16
|
+
// · The CRUMB CONVENTION. Forest's crumb is `group · page` on a middot, not a chevron trail: a nav
|
|
17
|
+
// group is not a route. The middot is slot CONTENT, so no theme fragment can carry it — only a
|
|
18
|
+
// component can, which is why breadcrumb.ts is empty and this is not.
|
|
19
|
+
// · The SEPARATOR SPACING. A middot is narrower than a chevron and reads cramped on the list's own
|
|
20
|
+
// gap-1.5; mx-1 is what the middot needs, so it travels with the middot rather than apart from it.
|
|
21
|
+
import { computed } from 'vue';
|
|
22
|
+
import type { FCrumb } from './nav';
|
|
23
|
+
|
|
24
|
+
const props = withDefaults(defineProps<{
|
|
25
|
+
/**
|
|
26
|
+
* The crumb, in reading order — `group · page`. Unlinked by default: a crumb names where you are,
|
|
27
|
+
* and a nav group has no route of its own. Pass `to` where a step genuinely is a destination.
|
|
28
|
+
*/
|
|
29
|
+
crumbs?: FCrumb[];
|
|
30
|
+
/** The desktop rail's collapse control, first in the header. */
|
|
31
|
+
collapse?: boolean;
|
|
32
|
+
}>(), { crumbs: () => [], collapse: true });
|
|
33
|
+
|
|
34
|
+
const hasCrumbs = computed(() => props.crumbs.length > 0);
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<!-- `px-0 sm:px-2` is the chrome gutter — see the note above. Everything else is stock. -->
|
|
39
|
+
<UDashboardNavbar :ui="{ root: 'px-0 sm:px-2' }">
|
|
40
|
+
<template #leading>
|
|
41
|
+
<UDashboardSidebarCollapse v-if="collapse" />
|
|
42
|
+
|
|
43
|
+
<UBreadcrumb
|
|
44
|
+
v-if="hasCrumbs"
|
|
45
|
+
:items="crumbs"
|
|
46
|
+
color="neutral"
|
|
47
|
+
class="ms-1.5"
|
|
48
|
+
:ui="{ separator: 'mx-1' }"
|
|
49
|
+
>
|
|
50
|
+
<template #separator>·</template>
|
|
51
|
+
</UBreadcrumb>
|
|
52
|
+
|
|
53
|
+
<slot name="leading" />
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<template v-if="$slots.title" #title><slot name="title" /></template>
|
|
57
|
+
<template v-if="$slots.trailing" #trailing><slot name="trailing" /></template>
|
|
58
|
+
<template v-if="$slots.default" #default><slot /></template>
|
|
59
|
+
|
|
60
|
+
<template #right>
|
|
61
|
+
<!-- gap-3 is the action cluster's own rhythm, not the navbar's — square icon triggers sitting
|
|
62
|
+
together read as one group at 12px and as separate controls beyond it. -->
|
|
63
|
+
<div v-if="$slots.right" class="flex items-center gap-3">
|
|
64
|
+
<slot name="right" />
|
|
65
|
+
</div>
|
|
66
|
+
</template>
|
|
67
|
+
</UDashboardNavbar>
|
|
68
|
+
</template>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The Forest app shell — sidebar, chrome, and the inset card the page lives in.
|
|
3
|
+
//
|
|
4
|
+
// Forest's panel is a CARD floating on the group's muted shell (themes/dashboard-panel.ts), not
|
|
5
|
+
// stock's flat column, and that changes where chrome belongs. The header sits in the gutter ABOVE the
|
|
6
|
+
// card, so the card holds nothing but the page. The card inset, the margin collapse and the teleport
|
|
7
|
+
// plumbing live here so an app composing a shell does not restate them.
|
|
8
|
+
//
|
|
9
|
+
// <FAppShell storage-key="ops">
|
|
10
|
+
// <template #sidebar> <UDashboardSidebar …/> </template>
|
|
11
|
+
// <template #header> <UDashboardNavbar …/> </template>
|
|
12
|
+
// …the page…
|
|
13
|
+
// </FAppShell>
|
|
14
|
+
//
|
|
15
|
+
// A PAGE fills the toolbar with <FPageToolbar>, which teleports into the element provided here — see
|
|
16
|
+
// ./shell.ts for why it is an element rather than an id.
|
|
17
|
+
import { computed, onBeforeUnmount, onMounted, provide, ref } from 'vue';
|
|
18
|
+
import { createToolbarClaims, TOOLBAR_SEAT_KEY } from './shell';
|
|
19
|
+
|
|
20
|
+
withDefaults(defineProps<{
|
|
21
|
+
/** Where the sidebar's width and collapsed state persist. */
|
|
22
|
+
storageKey?: string;
|
|
23
|
+
/**
|
|
24
|
+
* `local` rather than stock's cookie by default: a Vite SPA has no server, and Nuxt UI's useCookie
|
|
25
|
+
* stub is a no-op there — the rail would silently reset to its default on every load.
|
|
26
|
+
*/
|
|
27
|
+
storage?: 'local' | 'cookie';
|
|
28
|
+
/** Unit the sidebar's sizes are expressed in. `rem` reads as widths rather than percentages. */
|
|
29
|
+
unit?: 'rem' | '%' | 'px';
|
|
30
|
+
}>(), { storageKey: 'forest-shell', storage: 'local', unit: 'rem' });
|
|
31
|
+
|
|
32
|
+
const toolbar = ref<HTMLElement | null>(null);
|
|
33
|
+
|
|
34
|
+
// The first claimant owns the seam, so a section stacking a nav row over a filter row keeps the nav
|
|
35
|
+
// row's — and if the nav row goes, the filter row is promoted rather than leaving a live toolbar
|
|
36
|
+
// with a null seam. The ordering lives in ./shell.ts, shared with the docs preview that stages a
|
|
37
|
+
// toolbar in a frame of its own. `claimants` is left behind here: the seat carries the three keys a
|
|
38
|
+
// toolbar uses and nothing else.
|
|
39
|
+
const { actions, claimActions, releaseActions } = createToolbarClaims();
|
|
40
|
+
|
|
41
|
+
provide(TOOLBAR_SEAT_KEY, { toolbar, actions, claimActions, releaseActions });
|
|
42
|
+
|
|
43
|
+
// The scrollport lane. <FPageToolbar> bleeds its root to the card's edge but wants its WELL to line
|
|
44
|
+
// up with the page underneath — and the page scrolls in its own container, which the toolbar sits
|
|
45
|
+
// above and outside of. A classic (non-overlay) scrollbar shrinks that container's content box, so
|
|
46
|
+
// its centred well drifts from the toolbar's by however wide the scrollbar is. The shell can measure
|
|
47
|
+
// that drift because it owns the seat; the toolbar cannot, because Teleport puts it outside the
|
|
48
|
+
// scrolling element entirely.
|
|
49
|
+
//
|
|
50
|
+
// Contract: the shell does not parse the page's markup. Among the body wrapper's children EXCLUDING
|
|
51
|
+
// the toolbar seat, the scrollport is whichever one is actually a vertical scroll container —
|
|
52
|
+
// `overflow-y: auto` or `scroll`, CSS's own definition, not a class name or a tag guess. A page that
|
|
53
|
+
// ships no such child (or one that never overflows) leaves the lane at 0px; nothing throws.
|
|
54
|
+
//
|
|
55
|
+
// The scrollport belongs to the PAGE, so a route can replace it with a different element — or with
|
|
56
|
+
// none. The seat's siblings are watched for exactly that, and the lane re-resolves against whatever
|
|
57
|
+
// the incoming page ships. The lane is only ever spent as padding INSIDE the toolbar, never on the
|
|
58
|
+
// scrollport, so measuring it cannot move what is being measured.
|
|
59
|
+
const scrollportLane = ref(0);
|
|
60
|
+
let scrollport: HTMLElement | null = null;
|
|
61
|
+
let scrollportSize: ResizeObserver | null = null;
|
|
62
|
+
let seatSiblings: MutationObserver | null = null;
|
|
63
|
+
|
|
64
|
+
function findScrollport(): HTMLElement | null {
|
|
65
|
+
const seat = toolbar.value;
|
|
66
|
+
const parent = seat?.parentElement;
|
|
67
|
+
if (!parent) return null;
|
|
68
|
+
for (const child of parent.children) {
|
|
69
|
+
if (child === seat || !(child instanceof HTMLElement)) continue;
|
|
70
|
+
const { overflowY } = getComputedStyle(child);
|
|
71
|
+
if (overflowY === 'auto' || overflowY === 'scroll') return child;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// `offsetWidth - clientWidth` counts the horizontal BORDERS as well as the scrollbar, so a
|
|
77
|
+
// scrollport carrying an edge would reserve their width too and push the toolbar's well off the
|
|
78
|
+
// page's. Take the borders back out and what remains is the scrollbar alone.
|
|
79
|
+
function measureLane(el: HTMLElement): number {
|
|
80
|
+
const { borderLeftWidth, borderRightWidth } = getComputedStyle(el);
|
|
81
|
+
const borders = parseFloat(borderLeftWidth) + parseFloat(borderRightWidth);
|
|
82
|
+
return Math.max(0, el.offsetWidth - el.clientWidth - borders);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function bindScrollport() {
|
|
86
|
+
const next = findScrollport();
|
|
87
|
+
if (next === scrollport) return;
|
|
88
|
+
if (scrollport) scrollportSize?.unobserve(scrollport);
|
|
89
|
+
scrollport = next;
|
|
90
|
+
if (!next) {
|
|
91
|
+
scrollportLane.value = 0;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
scrollportSize?.observe(next, { box: 'content-box' });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
onMounted(() => {
|
|
98
|
+
// `content-box`: a scrollbar appearing changes the content box without necessarily changing the
|
|
99
|
+
// border box, so the default (border-box) observation can miss exactly the resize that matters.
|
|
100
|
+
scrollportSize = new ResizeObserver(() => {
|
|
101
|
+
if (scrollport) scrollportLane.value = measureLane(scrollport);
|
|
102
|
+
});
|
|
103
|
+
seatSiblings = new MutationObserver(bindScrollport);
|
|
104
|
+
const parent = toolbar.value?.parentElement;
|
|
105
|
+
if (parent) seatSiblings.observe(parent, { childList: true });
|
|
106
|
+
bindScrollport();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
onBeforeUnmount(() => {
|
|
110
|
+
scrollportSize?.disconnect();
|
|
111
|
+
seatSiblings?.disconnect();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Declared on the SEAT, not the panel. A custom property inherits, and the panel is an ancestor of the
|
|
115
|
+
// whole page — so publishing it there hands the lane to every nested thing that happens to use the
|
|
116
|
+
// same token, including a toolbar staged inside a page's own card, which has no scrollbar of its own
|
|
117
|
+
// to reserve for. The seat holds exactly one thing, the teleported toolbar, which is the one element
|
|
118
|
+
// the lane is about.
|
|
119
|
+
const seatStyle = computed(() => ({ '--forest-scrollport-lane': `${scrollportLane.value}px` }));
|
|
120
|
+
</script>
|
|
121
|
+
|
|
122
|
+
<template>
|
|
123
|
+
<UDashboardGroup :storage="storage" :storage-key="storageKey" :unit="unit">
|
|
124
|
+
<slot name="sidebar" />
|
|
125
|
+
|
|
126
|
+
<div class="flex min-w-0 flex-1 flex-col">
|
|
127
|
+
<!-- The chrome gutter. Aligned to the card's inset (mx-4 lg:ms-0) so its edges track the
|
|
128
|
+
card's. -->
|
|
129
|
+
<div class="fshell-gutter mx-(--forest-panel-inset) lg:ms-0"><slot name="header" /></div>
|
|
130
|
+
|
|
131
|
+
<!-- The page. `p-0` clears stock's body padding: the card is the frame, and what a page puts
|
|
132
|
+
inside it owns its own rhythm and its own scroll container.
|
|
133
|
+
|
|
134
|
+
`gap-0 sm:gap-0` is what keeps a toolbar FLUSH. Stock's body carries gap-4 sm:gap-6, which
|
|
135
|
+
would open a 24px trench between the toolbar's rule and the page under it — and gap-0
|
|
136
|
+
alone does not close it, because tailwind-merge drops gap-4 and keeps sm:gap-6. Same shape
|
|
137
|
+
as the p-0 sm:p-0 beside it, for the same reason. -->
|
|
138
|
+
<UDashboardPanel class="fshell-panel" :ui="{ body: 'overflow-hidden p-0 sm:p-0 gap-0 sm:gap-0' }">
|
|
139
|
+
<template #body>
|
|
140
|
+
<!-- A page's toolbar lands here: inside the card, above the page's own scrollport, so it
|
|
141
|
+
holds while the page scrolls under it. `empty:hidden` so a route without one costs
|
|
142
|
+
nothing — Teleport inserts real children, so :empty tracks it. -->
|
|
143
|
+
<div ref="toolbar" :style="seatStyle" class="fshell-toolbar empty:hidden shrink-0" />
|
|
144
|
+
<slot />
|
|
145
|
+
</template>
|
|
146
|
+
</UDashboardPanel>
|
|
147
|
+
</div>
|
|
148
|
+
</UDashboardGroup>
|
|
149
|
+
</template>
|
|
150
|
+
|
|
151
|
+
<!-- Unscoped: the first rule reads a SIBLING's emptiness, and scoping would stamp the attribute on
|
|
152
|
+
the panel without giving the selector a way to reach past it. -->
|
|
153
|
+
<style>
|
|
154
|
+
/* The card carries its own m-4. The gutter above would stack its gap on top of that and push the
|
|
155
|
+
card twice as far from the chrome as it should, so the card yields its top margin whenever the
|
|
156
|
+
gutter has a header in it. */
|
|
157
|
+
.fshell-gutter:not(:empty) + .fshell-panel { margin-top: 0; }
|
|
158
|
+
|
|
159
|
+
/* Chrome in the gutter sits on no surface, so it has nothing to divide and takes no edge.
|
|
160
|
+
UDashboardNavbar is the case that needs saying: its stock recipe carries `border-b border-default`,
|
|
161
|
+
which reads as a rule floating in the gutter above the card. */
|
|
162
|
+
.fshell-gutter > * { border-block: 0; }
|
|
163
|
+
</style>
|