Development

Contributor workflow and verification protocol for Waymark JS.

Contributor workflow

  1. Install dependencies: npm install
  2. Start dev server: npm run dev
  3. Run formatting: npm run format
  4. Verify formatting: npm run format:check
  5. Regenerate docs/llms-full.txt and run docs/API sync checks: npm run docs:sync (test:ci first runs npm run check:llms, which fails if the committed bundle is stale)
  6. Preview and build the docs site: npm run docs:dev (Nuxt dev server; defaults to http://localhost:3000) and npm run docs:generate (builds the deployable static site to docs/.output/public)
  7. Run unit tests: npm test
  8. Build and verify the dist output: npm run build && npm run check:dist
  9. Run functional browser tests: npm run test:browser
  10. Regenerate the committed screenshots after an intentional visual change: npm run screenshots; verify byte-identity without touching the committed set: npm run screenshots:verify. Refresh the vendored basemap fixture — a deliberate, network-touching act — with npm run fixtures:refresh (see Screenshots).
  11. Run the full CI gate locally: npm run test:ci (format check → llms bundle check → docs sync → build → dist check → docs generate → unit tests → browser tests).

npm run test:browser boots a dedicated Playwright Vite server on 127.0.0.1:4173 with --strictPort, so browser tests are deterministic even when your normal dev port is occupied. Browser tests import src/entry.js directly — the built bundle is verified separately by npm run check:dist and the Vitest dist smoke test. playwright.config.js pins workers: 1 and retries: 1 because the suite includes WebGL/shader map tests that flake under parallel workers; single-worker execution was verified fully green. npm run test:browser runs the functional project only — committed pixels are captured by the separate screenshots project (see Screenshots) — and both projects run fully offline against the vendored basemap fixture.

npm run docs:sync chains node scripts/llms-full.mjs, npm run docs:contract, and npm run docs:api-sync (which runs api:contract first). The generator step rebuilds docs/llms-full.txt — the full content of every doc linked in docs/llms.txt, concatenated into one file for single-fetch consumption. Both docs/llms.txt and docs/llms-full.txt are also published as tracked, byte-for-byte copies under docs/public/ for repository indexing; docs:sync refreshes those copies and npm run check:llms verifies them. test:ci runs the generator in check mode first (npm run check:llms), so a stale committed bundle fails the gate before docs:sync regenerates it. No root SKILL.md is generated; consumer-facing agent docs follow the llms.txt convention and are distributed with the docs GitHub Pages deploy (see Docs site).

Vite config is split by role: vite.config.dev.js for local dev server, vite.config.lib.js for library builds, and vitest.config.js for tests.

vite.config.lib.js defines process.env.NODE_ENV as a build-time replacement for the dist bundle, preventing browser runtime errors like process is not defined. It also keeps maplibre-gl external (rollupOptions.external), so the build emits a single ESM bundle — dist/waymark.js (~31.5 kB, ~8.8 kB gzip) with a bare maplibre-gl import, no CSS and no inlined worker. npm run check:dist guards that output shape.

Conventions and definitions

Keep runtime internals internal. Consumer docs and tests should target public API behaviour.

Naming conventions

Use camelCase identifiers, with acronyms kept uppercase.

Use instanceDocument as the canonical variable/parameter name for the document payload, and map for the injected MapLibre map.

Prefer these canonical forms in source, tests, docs, and fixtures.

For data-layer ordering, use these canonical rules in docs/tests/fixtures:

For full data/GeoJSON contract details, prefer docs/5.data.md rather than duplicating rules in multiple docs.

Cross-module sync pattern (runtime-state owned)

Use this pattern when runtime modules must stay in sync.

  1. Runtime state owns mutable state
    • Keep authoritative mutable state in src/runtime/state/createInstanceState.js.
    • createInstanceCore(...) owns lifecycle orchestration and commands, not duplicate mutable state copies.
    • Modules and adapters consume snapshots and never become a second source of truth.
  2. Dispatch named commands
    • Route mutations through explicit dispatch paths (for example types.visibility.set, types.visibility.reset, debug.set).
    • Runtime commands dispatch state changes, then adapters apply map-side effects.
  3. Emit canonical state events
    • Every real state mutation emits waymark:state.changed plus a scoped state event (waymark:state.debug.changed, waymark:state.types.visibility.changed).
    • No-op dispatches emit nothing.
  4. Emit data-layer events only where useful
    • The GeoJSON module emits waymark:data.layer.added, waymark:data.layer.mounted, and waymark:data.layer.error for layer lifecycle observability.
  5. Emit feature interaction events from layer observation
    • The feature interaction module emits waymark:feature.click, waymark:feature.hover, and waymark:feature.leave with { id, feature, layerId, lngLat, originalEvent }.
    • These are observability events only: they do not mutate runtime state, the map cursor, or the camera.

Guidance for future sync features:

Testing strategy

npm run check:dist statically asserts the built output: dist/ contains only waymark.js, the bundle imports maplibre-gl as a bare external, and there is no base64 worker blob, no CSS and no Vue markers. test:ci runs it after npm run build.

Use jsdom tests to validate API contract behaviour, not as proof of non-browser runtime support. Browser tests are the runtime source of truth for instance behaviour.

Dev page setup

The development playground lives under dev/ as a vanilla ES module app (dev/index.html, dev/dev.js). Its data is the shared fixture set under tests/fixtures/ — the single source of truth for the dev harness and the browser tests. vite.config.dev.js points publicDir at tests/fixtures, so the dev server serves the north Vancouver Island demo fixtures at /: route.json (config) and geojson/ data. dev/dev.js imports maplibre-gl/dist/maplibre-gl.css (MapLibre requires its stylesheet for popup positioning), creates its own MapLibre map on #map (the vendored OpenGIS basemap style served at /basemap/style.json, initial centre on Holberg, fadeDuration: 0), and injects it via createInstance({ map, document }), booting one instance from /route.json plus four GeoJSON layers (track-north-island.geojson, track-port-hardy-cape-scott.geojson, markers.json, shapes.json) from /geojson/. It also fetches /geojson/sample-point.geojson for the add-layer control. The vendored style and the /__basemap__/** middleware make the dev harness fully offline — no live basemap requests (see Screenshots).

Development page with the four initial fixture layers
Development page with the four initial fixture layers

Before createInstance(...), the harness registers an inline PNG data URL as the photo-marker image (map.loadImage + map.addImage), so the photo type's symbol layers resolve the icon from first mount. It also demonstrates the consumer-owned feature interaction pattern: a Popup property inspector on waymark:feature.click, and cursor changes on waymark:feature.hover / waymark:feature.leave.

The click inspector renders a title heading, a single <img class="dev-popup__image"> when any image URL property is present, and the remaining properties as key/value rows. The image URL is picked in preference order image_thumbnail_url → image_medium_url → image_large_url, the image alt is the feature title (or Feature image), and all three image_*_url keys are omitted from the rows whenever an image is displayed. The popup anchors at feature.geometry.coordinates for Point geometry, and at the click lngLat for everything else. The harness emits no [waymark:dev] diagnostic logs during normal operation — interaction feedback lives in the inspector.

Vite dependency pre-bundling breaks MapLibre's worker: MapLibre resolves its worker relative to its own module URL, and Vite's pre-bundled copy under node_modules/.vite/deps/ has no worker file alongside it, so GeoJSON source processing silently stalls (map.loaded() never becomes true). vite.config.dev.js sets optimizeDeps.exclude: ["maplibre-gl"] so the package is served as-is. Vite consumers of Waymark should do the same in their own config.

dev/index.html renders a restrained toolbar — the Waymark JS title plus a "click a feature to inspect its properties" cue — with four grouped controls:

A layer added from the toolbar, with the view fitted to its bounds
A layer added from the toolbar, with the view fitted to its bounds

Fixture notes:

The page exposes window.waymarkInstance, window.waymarkInstances and window.waymarkMaps (both keyed by the dev map key map), and window.createWaymarkInstance for manual console testing.

This gives a stable baseline for browser smoke coverage in tests/browser/2.development.test.js:

Run:

npm run format:check
npm run docs:sync
npm run docs:generate
npm test
npm run build && npm run check:dist
npm run test:browser

Docs ↔ tests sync protocol

Treat docs and tests as one contract. Change both in the same slice.

Docs pageUnit testsBrowser tests
docs/1.api.mdtests/docs/1.api.test.jstests/browser/1.api.test.js
docs/3.instances.mdInternal-boundary assertions in tests/docs/1.api.test.jsDev smoke in tests/browser/2.development.test.js
docs/4.map.mdMap injection assertions in tests/docs/1.api.test.jsMap API coverage in tests/browser/1.api.test.js
docs/5.data.mdData-layer contract assertions in tests/docs/1.api.test.jsData-layer API coverage in tests/browser/1.api.test.js

Automation guards:

Keep docs/llms.txt's links and descriptions in step when the docs set changes; docs/llms-full.txt regenerates from them via npm run docs:sync and is never hand-edited. After editing any doc linked from docs/llms.txt, run npm run docs:sync so the committed bundle stays fresh — otherwise npm run check:llms (and therefore test:ci) fails.

Hard sync workflow (docs ↔ source ↔ tests)

npm run docs:sync is intentionally strict and should be treated as a hard gate before tests. CI runs npm run check:llms before regenerating, so a stale committed docs/llms-full.txt fails the gate instead of being silently overwritten.

Execution order:

  1. node scripts/llms-full.mjs
    • Reads the .md links from docs/llms.txt in order and regenerates docs/llms-full.txt (stripping frontmatter and api-contract markers).
  2. npm run docs:contract
    • Reads scripts/doc-test-contract.json.
    • Verifies required headings in tracked docs pages (docs/1.api.md, docs/4.map.md).
    • Verifies mapped describe(...) block names in tests/docs/1.api.test.js and tests/browser/1.api.test.js.
  3. npm run docs:api-sync
    • Runs npm run api:contract first, generating docs/.generated/api-contract.json from src/runtime/createInstanceEvents.js (event constants) and src/config/defaults.js (defaultConfig), together with the fixed createInstance({ map, document? }) signature.
    • Compares marker blocks in docs/1.api.md (signature, defaults, lifecycle-events, data-layer-events, feature-events) against generated contract values.

If this gate fails, fix docs headings/marker blocks and corresponding test describe(...) names in the same change.

Adding or changing API sections safely

Use this convention whenever you add, rename, or remove a section in docs/1.api.md.

API section naming

One-to-one heading and describe(...) mapping

Manifest updates

Marker blocks for source-derived facts

...

Extending source-derived contract fields

Practical checklist

  1. Add or rename the API section heading in docs/1.api.md (stable sentence case).
  2. Add or rename matching describe(...) blocks in both API test files with identical names.
  3. Update scripts/doc-test-contract.json (requiredHeadings and describeMappings).
  4. If the section includes source-derived facts, add or update the api-contract marker block.
  5. If you introduced a new source-derived field, update both sync scripts and the marker block together.
  6. Run npm run format:check and npm run docs:sync before handing off.

Sync checklist:

  1. Update the relevant docs headings and examples.
  2. Update matching describe(...) blocks and assertions.
  3. Run npm run docs:sync, npm test, and npm run test:browser.
  4. Ensure old filenames/headings are removed.

Screenshots

The browser suite commits seven PNG screenshots as visual evidence — three API-page captures and four dev-page captures — under screenshots/ at the repo root (tracked in git and published with the docs site). Playwright splits them from the functional suite with two projects (playwright.config.js):

ProjectScope
functionalAll assertions (1.api.test.js, 2.development.test.js); renders no committed pixels
screenshotsThe seven committed captures (screenshots.spec.js) under the pinned capture protocol

A safe-output guard in tests/browser/helpers/screenshot.js refuses to write into the tracked screenshots/ directory from a headed run or the wrong project; setting SCREENSHOT_OUT_DIR redirects captures elsewhere (the verify script uses this).

Capture protocol

Capture is fixed-point, not timed. screenshot(page, name, selector?) first awaits the shared waitForSettled(page) gate — bounded waits for network idle, every registered window.waymarkMaps map to be idle with tiles loaded, every <img> complete and decoded, document.fonts.ready, and two animation frames. It then accepts a frame only when three consecutive lossless PNG frames are byte-identical. Each round forces a render (map.redraw() plus the render event, then two animation frames) and toggles a full-viewport magenta probe overlay: a frame that fails to reflect the probe proves the compositor served a stale surface, and the round is discarded. Rounds are bounded; on exhaustion the helper throws rather than writing an unstable image — no tolerance and no fallback.

The scene builders in tests/browser/helpers/scenes.js gate capture readiness: the dev page waits until every mounted Waymark symbol layer's icon-image is registered, and the API scenes poll until their features have painted and the map is idle. Both the functional specs and the screenshots spec reuse the same builders, so committed pixels and functional assertions cannot drift apart.

Deterministic rendering

The screenshots project pins the render path in playwright.config.js: bundled-Chromium SwiftShader launch flags, locale en-CA, timezone America/Vancouver, colorScheme: "light", reducedMotion: "no-preference", a 1280×720 viewport and deviceScaleFactor: 1. Captured maps pass fadeDuration: 0 so symbol/icon fade cannot settle mid-transition. MapLibre popups are stabilised against a compositor promotion quirk — the stylesheet's will-change: transform promotes the popup to its own compositing layer at a fractional offset, which resamples non-deterministically — by overriding it to auto before capture. Re-verify this on any maplibre-gl upgrade.

A single-thread SwiftShader.ini pin is written by the Playwright global setup and removed by the teardown for invocations that include the screenshots project; functional-only runs never receive it. Keep it as a precaution, but note it was empirically inert on the baseline machine: byte stability comes from the capture protocol and harness fixes above, not from the pin.

Offline test environment (vendored basemap)

The dev harness and both browser suites run fully offline. tests/fixtures/basemap/ holds a generated, origin-free slice of the OpenGIS basemap: a canonical style.json, a site.manifest.json recording per-file provenance (source, bytes, sha256, licence note), and the site/** asset tree. The style is served by the existing publicDir mapping at /basemap/style.json; tiles, glyphs and sprite are served from /__basemap__/** by a small Vite middleware — a missing tile or glyph returns an empty 200 so MapLibre stays quiet, while a missing sprite returns a loud 404.

npm run fixtures:refresh regenerates the fixture as a deliberate act: it fetches the live style from the canonical host, allow-lists committable hosts (aborting loudly on anything else), rewrites asset URLs to root-relative local form, inlines the tile template and zoom range (no runtime descriptor fetch), vendors glyph range 0-255 for the declared fontstacks plus the 1× sprite, records provenance in the manifest, and prunes stale files. The full z0–14 pyramid is kept by explicit decision, bounded by the demo-area envelope (the seven capture viewports plus one tile of padding) and a MAX_TILE_COUNT = 5000 generator guard; the tree is around 22 MB. Attribution is preserved verbatim and carried from the TileJSON onto the inlined source — it must remain visible in rendered captures.

The suite enforces the guarantee: every test context installs a context-level route (tests/browser/helpers/test.js) that blocks every non-localhost http(s) request and fails the test naming the offending URL, so a live basemap dependency cannot creep back in.

Byte-identity scope and re-baselining

Regeneration is byte-identical for the same macOS major version, CPU architecture and pinned configuration. Cross-platform (Linux) identity is explicitly not targeted; the macOS identity workflow is the environment-drift canary (see CI/CD automation).

Re-baselining is a deliberate act: run npm run screenshots, review the image diff, then run npm run screenshots:verify. A macOS major upgrade, a SwiftShader-affecting browser change, or a move of the identity workflow to another architecture is a re-baseline event, not a bug.

Capture set

API captures (/browser-api.html) load the shared fixtures over the dev server (markers.json, track-port-hardy-cape-scott.geojson, shapes.json) and frame a Holberg closeup (centre [-128.0145, 50.6525], zoom 14) on the 640×360 #map element so the point, line, and polygon all sit in frame.

ScreenshotWhat it shows
api-quick-startInjected map with a GeoJSON layer (demo markers) rendered on the vendored basemap
api-geojson-familiesCircle (point), line, and fill (polygon) geometry families rendered
api-geojson-mixedPoint, line, and polygon sublayers from one mixed FeatureCollection

Dev-page captures (/) render the full 1280×720 viewport:

ScreenshotWhat it shows
dev-pageDefault dev page state — toolbar, map, and the four initial fixture layers
dev-layer-addedGeoJSON layer added from the toolbar, bounds fitted to [-127.998, 50.661]
dev-popupFeature inspector popup open after clicking the "The Scarlet Ibis" marker
dev-popup-lineLine popup anchored at the click location, with the feature's image

Docs site

The docs site is a Nuxt static-site app that lives entirely under docs/: docs/nuxt.config.js configures it, source lives in docs/app/, the build module in docs/modules/docs-pages.js, and the internal /api/docs/* routes in docs/server/. npm run docs:dev (nuxt dev docs) serves it locally for previewing prose changes; npm run docs:generate (nuxt generate docs) prerenders every route and writes the deployable site to the generated docs/.output/public.

At build time the module packages the site: it copies the tracked screenshots/ directory, llms.txt, llms-full.txt and docs/assets/waymark-logo.svg (as the site favicon) into the generated docs/public/ (served verbatim), then renders each page once with docs/app/lib/docs-content.mjs into JSON assets under docs/server/assets/docs-pages/, which the /api/docs/nav and /api/docs/[slug] routes read. The docs/public/llms.txt and docs/public/llms-full.txt copies are tracked in version control for repository indexing; npm run docs:sync refreshes them and npm run check:llms verifies they match their docs/ sources.

Pages, routes and the prev/next pagers follow the markdown links in docs/llms.txt: README.md is the home page, and the numbered docs keep their reading order.

Site configuration is via environment variables, documented in docs/.env.example; copy it to docs/.env (gitignored) to override — values are read from the app root (docs/) when the dev server starts or the site is generated, so restart or regenerate after changing them. NUXT_PUBLIC_PRODUCTION_URL is the single deployment value: it sets both the absolute site URL used for <link rel="canonical"> and the asset base path (derived from its pathname; default https://www.ogis.org/waymark-js, and the root path when unset). NUXT_PUBLIC_BRANDING_URL (default https://www.ogis.org/branding) sets the two branding asset links in the head — the theme stylesheet and the background-map.js module — and nothing else: the logo is a local asset and the Waymark v1 dependencies are resolved inside the hosted module.

docs/assets/ holds the hand-authored logo SVG. SVG assets are formatted via @prettier/plugin-xml, registered in .prettierrc.json, so the logo is covered by npm run format:check.

The Deploy docs workflow (.github/workflows/docs.yml) publishes the site: pushes to master touching docs/**, screenshots/**, scripts/llms-full.mjs, package.json/package-lock.json or the workflow itself — plus manual workflow_dispatch — run npm run check:llms, configure Pages (whose base_url output supplies the deploy target), then npm run docs:generate on Node 24 (.nvmrc) with NUXT_PUBLIC_PRODUCTION_URL derived at deploy time from that output — it drives both the canonical URLs and the asset paths — and upload docs/.output/public via GitHub Pages. One-time setup: Settings → Pages → Source = "GitHub Actions". Published at https://www.ogis.org/waymark-js/.

CI/CD automation

Four GitHub Actions workflows automate the release pipeline: