@humanforest/nuxt-layer
v0.4.0
Published
Forest design system as a Nuxt layer — extend it to inherit the theme, colour roles, icons and brand fonts in one line.
Readme
@humanforest/nuxt-layer
Forest design system as a Nuxt layer. Extend it and a Nuxt app inherits the Forest theme, colour families, icons and the real brand fonts — no npm publish involved.
The layer is self-contained — no @humanforest/* imports, no climbing to sibling packages — so giget
can fetch this one directory and it still works. The generated files are committed; regenerate them
from the source packages with bun run build:layer (in the design-system repo root).
What the layer delivers
nuxt.config.ts— registers@nuxt/ui, the Forest colour families (color="forest|maple|river|warm|acid"), thei-forest-*icon set (SVGs inassets/icons/forest/), the 23 Forest components below, and the composables listed under "Composables and helpers".app.config.ts— the Nuxt UI theme: per-component tailwind-variants fragments + the semantic role map (primary→forest,error→maple, …), read fromtheme.gen.json.styles.gen.css— fonts + ramps + theme registration + type scale + the motion ladder + the Forest base layer. The@font-facerules point at./files/, so a giget consumer renders in the brand faces offline. This file is not published to npm.styles.cdn.gen.css— byte-identical from the first token onward; only the@font-facerules differ, namingassets.forest.bikeinstead. This is the build the npm package ships, because a public tarball may not carry licensed binaries.files/— the real brand font binaries. Giget only; excluded from the npm tarball.packages/tokens/logos/— the 15 generated brand SVGs (mark, wordmark, both lockups, thef, and eight sub-brand favicons). Nothing in the layer imports them:FLogoandFSubLogocompose their marks live. They ship for the uses a component cannot serve — a Vue component does not go in<link rel="icon">, an email template or an OG image.packages/ui/src,packages/charts/src,packages/frames/src(+ their supporting trees, mirrored at repo-relative paths — see "Regenerating") — the 39 registered Forest components, auto-registered like any Nuxt component, no import needed:ForestContext;FLogo,FSubLogo;FFilterBar,FFilterChip;FKpi,FKpiGroup; six shell components (FAppShell,FAppSidebar,FAppNavbar,FPageToolbar,FScrollShadow,FSidePanel); four canvas components (FCanvasHud,FCanvasCard,FCanvasControl,FCanvasControlGroup); eight map components (FMap,FMapPopup,FMarker,FVehicleMark, and the four controlsFMapZoom,FMapCompass,FMapTilt,FMapGeolocate); eleven chart components (FBoroughShape,FCalendarGrid,FCellLegend,FChartFrame,FChartFrameSingle,FChartLegend,FDistributionBar,FDottedMap,FLondonMap,FSparkline,FStatusTrack); three presentation frames (FDeviceFrame,FBrowserFrame,FFitFrame) for staging a prototype at true scale — these carry CC BY-SA 4.0 device art, which is why they ship here and not on public npm. It is the mirrored source trees that make auto-registration possible — the components aren't published separately, they ARE those trees.
Composables and helpers
Eight are auto-imported alongside the components — FMap's imperative API plus the chart palette and
the KPI formatters:
// no import needed
const { flyTo } = useMapCamera()
const palette = useChartPalette()
formatMetric(4218, { format: 'compact' }) // "4.2k"useMapReady · useMapCamera · useMapOverlay · useFeatureSelect · MAP_SLOT ·
useChartPalette · formatMetric · formatPercent
This is an ALLOW-LIST, not imports.dirs, and the rest is deliberate. A directory scan would take
every export in the mirrored trees, and those barrels carry names like token, isSelected,
nextKey and addClause — generic enough to collide with a consuming app's own code, silently and
at a distance. Everything else stays reachable by path:
import { STATE_LABEL, resolveMapColor } from '#layers/forest/packages/ui/src/map'
import { distributionShares } from '#layers/forest/packages/charts/src/distribution'Those paths are the layer's internal shape, so treat them as less stable than the auto-imported set: they move when the source tree is reorganised.
Quickstart
Three files in the consuming app. This exact setup is verified end to end: a Nuxt 4 app in a separate
repo consuming this layer over giget, with the colour ramps, semantic roles, .type-* utilities,
i-forest-* icons and the brand font binaries all reaching the production build. The 21 Forest
components are verified too, but via a local-extends probe app rather than a giget one — see "Local
development against the layer" for a dependency step that a giget consumer gets automatically and a
local one does not.
nuxt.config.ts
export default defineNuxtConfig({
// `install: true` is load-bearing — see "Staying up to date".
// Keep Forest LAST in extends so your own layers and app can override it.
extends: [['github:humanforest/design-system/packages/nuxt-layer#main', { install: true }]],
modules: ['@nuxt/ui'],
css: ['~/assets/css/main.css'],
})app/assets/css/main.css
@import "tailwindcss";
@import "@nuxt/ui";
@import "#layers/forest/styles.gen.css";Installed from npm instead, the same line names the CDN build — the self-hosted one is not in the tarball, so a stale copy of this snippet fails to resolve rather than quietly falling back:
@import "@humanforest/nuxt-layer/styles.cdn.gen.css";package.json — nuxt ^4, @nuxt/ui ^4, tailwindcss ^4, plus typescript in
devDependencies. Without typescript, @nuxt/ui's unresolved peer makes bun discard the lockfile and
re-resolve on every build — noisy rather than fatal, but easy to avoid.
GIGET_AUTH — a GitHub PAT with read access to this private repo, present for both install and
build:
GIGET_AUTH=$(gh auth token) bun install
GIGET_AUTH=$(gh auth token) bun run buildWhy the CSS needs its own import line
Forest's stylesheet uses Tailwind at-rules (@utility, @custom-variant) and mode-order-sensitive
:root re-points, so it must compile in-band with your app's @import "tailwindcss", after
@nuxt/ui. A layer css: [] entry would be the wrong build and the wrong order, so the consumer
imports it from their own entry CSS.
It does not need copying out of the cache. #layers/forest is the alias Nuxt derives from this
layer's $meta.name, and it resolves straight into giget's fetched copy — including the relative
url('./files/…') @font-face paths, which Vite rewrites into content-hashed font assets emitted
alongside the CSS that references them.
You do not need a @source line. Other Nuxt layer setups carry one (e.g.
@source ".../node_modules/.c12/*/app/**") to make Tailwind scan the fetched layer for used classes.
@nuxt/ui already does this for you: it detects every extended layer — the giget-fetched one included —
and emits its own @source pointing at the resolved path. Verified by removing the manual line and
rebuilding: byte-identical CSS, same 3,997 selectors, including Forest-only classes such as the
coarse: touch-target utilities that appear in no app template. Copying another layer's glob is
duplicate work, and here it would also walk the ~27k vendored files that install: true creates inside
the cache. If a future @nuxt/ui ever stops auto-detecting layers and Forest classes go missing, add a
narrow @source scoped to the layer root rather than a ** glob that descends into its node_modules.
One detail bites silently: the alias is #layers/forest (from $meta.name), not the directory name.
Precedence: earlier in extends wins, and the app wins over every layer. As a foundation, keep
Forest last.
Staying up to date
Push a change to main and the next build of every consuming app picks it up — no cache-busting
step, no manual cache deletion.
That follows from { install: true }: Nuxt's config loader (c12) passes it to giget as force: true,
which makes giget re-fetch the ref's tarball and extract it over node_modules/.c12/<hash>/ on every
build. Drop install: true and giget's "destination already exists" guard re-engages, and an app can
keep building against a stale layer. It is also load-bearing for the build to succeed at all: giget
only installs the layer's own dependencies (mapbox-gl, @unovis/vue, @unovis/ts,
tailwind-variants, @internationalized/date) when install: true runs it inside the cache — drop
the flag and every map or chart component fails to resolve its imports, not just goes stale.
The trade-off: freshness and reproducibility are the same knob. There is no lockfile pin, so a push
to main reaches consumers unbidden. An app that needs reproducible builds should extend a tag or commit
SHA instead of #main, and will then stop receiving updates automatically. Versioning is lockstep across
the design system, cut as v0.x.y git tags. Every build also reaches the
GitHub API, which is why GIGET_AUTH is required at build time and not only at install time.
Because giget serves whatever is committed, run bun run build:layer and commit the regenerated
files after any token, theme, preset or font change — this includes the mirrored component sources (see
"What the layer delivers"), since editing packages/ui/src or packages/charts/src doesn't touch this
layer's copy until you regenerate it. The layer ships generated output (theme.gen.json,
styles.gen.css, the icon set, the font files) alongside 171 mirrored component-source files, not
generated output alone.
Local development against the layer
When you are changing the layer itself, point the app at your local checkout so you see edits without pushing:
const useLocalLayer = process.env.USE_LOCAL_LAYER === 'true'
export default defineNuxtConfig({
extends: [
useLocalLayer
? '../design-system/packages/nuxt-layer' // adjust to wherever you cloned it
: ['github:humanforest/design-system/packages/nuxt-layer#main', { install: true }],
],
})USE_LOCAL_LAYER=true bun run devThis path does not install the layer's dependencies. Extending a local directory skips giget
entirely, so nothing installs mapbox-gl, @unovis/vue, @unovis/ts, tailwind-variants or
@internationalized/date: the layer directory has no node_modules of its own, and climbing to the
design-system repo's root node_modules doesn't find them either — bun links dependencies
per-workspace, not at the root. Add all five to the consuming app's own package.json dependencies.
Measured against a real probe app: skip this and the build fails with Failed to resolve import
"mapbox-gl" from packages/ui/src/map/FMap.vue.
The CSS import is unchanged — #layers/forest is derived from $meta.name, so it resolves whether the
layer came from a local path or the giget cache.
Fonts: bundled, real, offline
This layer has two channels, and the font source is the only thing that differs between them.
Via giget, from this private repo with a PAT, it carries the real brand fonts: files/ ships the
actual woff/woff2 (GT Haptik, Mohr, JetBrains Mono) and styles.gen.css points at them with relative
url('./files/…') paths — no CDN, no cross-origin fetch, no Access-Control-Allow-Origin
requirement. Real Mohr renders offline the moment a consuming app resolves this layer. That closed,
access-controlled distribution is what makes shipping the binaries defensible.
Via npm it carries none of them. styles.cdn.gen.css names the faces on assets.forest.bike, and
the files array leaves both files/ and styles.gen.css out of the tarball — Mohr and GT Haptik
are licensed to Forest, not to whoever runs npm add. A licence-holder who wants to self-host from
npm brings their own @font-face block.
Mohr remains licensed Black + UPPERCASE only. Because distribution is limited to the private repo
plus a PAT-gated giget fetch, this stays within the licence — do not copy files/ out of this channel
into a publicly published package.
Regenerating
Run after any token / theme / preset / font change, then commit the generated files:
bun run build:layer # emits theme.gen.json, styles.gen.css, assets/icons/forest, files, packages/, apps/