Paint & selection
The colours the map paints planning content with, and the interaction states that spend no colour at all. Numbers measured from the shipping map.
Assessment Ramps
Code
import { tierMatchExpression, fabric } from "@gridd/ui/cartography"
map.addLayer({
id: "lots-fill",
type: "fill",
source: "lots",
paint: {
"fill-color": tierMatchExpression("tier"), // baked integer 0–4 → colour
"fill-opacity": 0.62
}
})
// In a TEMPLATE, never import from here — use var(--gd-cartography-*),
// so a theme change stays a theme change.The Ramps
--gd-cartography-assessment-0#B9BCC4#B9BCC4tier 0 — not codified, or not applicable. Neutral by obligation: an unassessed parcel must not borrow a colour that asserts something
--gd-cartography-assessment-1#1B8A4C#1B8A4Ctier 1 — no approval needed. Equals --gd-success TODAY BY COINCIDENCE; never alias it
--gd-cartography-assessment-2#2F6FB0#2F6FB0tier 2 — building approval (a certifier, not council). Coincides with --gd-info; never alias it
--gd-cartography-assessment-3#C9A21A#C9A21Atier 3 — council approval, code assessed. DOES NOT equal --gd-caution (#A8850F): aliasing would darken every code-assessable parcel
--gd-cartography-assessment-4#C43D3D#C43D3Dtier 4 — council approval, impact assessed. Coincides with --gd-danger; never alias it
--gd-cartography-assessment-0-line#696D77#B9BCC4tier 0 casing — the darker stroke for an unassessed parcel
--gd-cartography-assessment-1-line#15703D#5BD08Btier 1 stroke — also the pass outline in the 3D scene, where a 2.2px line needs the ink rather than the fill
--gd-cartography-assessment-2-line#285F96#7FB3E6tier 2 stroke
--gd-cartography-assessment-3-line#7F6508#EAB94Dtier 3 stroke
--gd-cartography-assessment-4-line#A83232#E97B7Btier 4 stroke — the fail outline in the 3D scene
--gd-cartography-parcel-line#C2C8D2#C2C8D2the cadastre hairline between parcels. At 0.5px over every parcel in view this is the highest-frequency line on the map, so it sets the texture of the whole fabric — dark enough to read the subdivision pattern, light enough never to compete with a road or the fill beneath it
--gd-cartography-building#E8E9ED#E8E9EDexisting building massing, extruded from LiDAR. A warm near-white, not pure white: the layer is semi-transparent so a parcel's fill still reads through, and pure white picks up a cold tint from whatever is beneath it
--gd-cartography-massing#17A6A0#17A6A0GRIDD's generated best-fit massing
--gd-cartography-massing-alt#0F7D78#0F7D78the alternating storey shade, so a stack of levels is legible rather than one fused block
--gd-cartography-easement#8A7AB5#8A7AB5a registered easement's fill. A title fact, not a hazard — which is what the dashed edge says
--gd-cartography-easement-line#6B5A9E#6B5A9Ethe easement's dashed edge
--gd-cartography-basemap-ground#F8F7F7#22242Bthe basemap ground plane — the paper the cadastre is drawn on
--gd-cartography-basemap-road#BCC8D5#3A3F4Aroad fill. Deliberately heavier than the cadastre hairline: a road is a place you can go, a boundary is not
--gd-cartography-basemap-park#FFFFFF#282B33parks and reserves. White in the light, NOT green: a green basemap park collides with the zone palette, which already uses light greens to mean specific things (sport & recreation, rural, open space). Basemap green under those reads as a zone the lot does not have
--gd-cartography-basemap-water#A2D8EC#1E3A47rivers, creeks and the bay — the one basemap feature that stays recognisably itself in both themes
--gd-cartography-basemap-label#5C6470#9AA3B0street names — the basemap's working label
--gd-cartography-basemap-label-strong#4A525E#C2C9D4suburb and town names — the label that anchors the view
--gd-cartography-basemap-label-halo#FFFFFF#101116the halo behind a basemap label. It is the GROUND's colour, not a colour of its own: its whole job is to punch the label's own paper out from under it where the label crosses the parcel fabric
--gd-cartography-utility-water#12809C#12809Cwater main
--gd-cartography-utility-sewer#8A6A2B#8A6A2Bsewer main
--gd-cartography-utility-storm#708B96#708B96stormwater main
--gd-cartography-seq-1#E8EEF6#E8EEF6sequential ramp, lowest class — a number's smallest values. Means MORE-vs-less and nothing else; never an assessment tier
--gd-cartography-seq-2#B9CFE6#B9CFE6sequential ramp, second class
--gd-cartography-seq-3#7FA9CE#7FA9CEsequential ramp, middle class
--gd-cartography-seq-4#4A7FAF#4A7FAFsequential ramp, fourth class
--gd-cartography-seq-5#1F4E79#1F4E79sequential ramp, highest class. The five steps fall monotonically in lightness, so the order survives greyscale and colour-vision deficiency
--gd-cartography-cat-1#3F7CAC#3F7CACcategorical fill 1 — one colour per value, no order implied
--gd-cartography-cat-2#C1663F#C1663Fcategorical fill 2
--gd-cartography-cat-3#4E9A6A#4E9A6Acategorical fill 3
--gd-cartography-cat-4#8C6BB1#8C6BB1categorical fill 4
--gd-cartography-cat-5#B8983F#B8983Fcategorical fill 5
--gd-cartography-cat-6#6E7B8B#6E7B8Bcategorical fill 6 — the last one; a seventh category wants grouping, not another hue
--gd-cartography-cat-other#C2C8D2#C2C8D2everything past the sixth category, as one neutral 'other' class. Neutral by obligation, exactly like assessment-0: a bucket must not borrow a colour that asserts it is a value
Reading Tokens in Paint Specs
tierFill(tier)tierLine(tier)fabric()massing()services()Code
import { tierFill, tierLine, fabric, services, basemap } from "@gridd/ui/cartography"
tierFill(3) // the light value
tierLine(4, "dark") // theme is a PARAMETER, not a second palette
fabric().parcelLine
services().easementLine
basemap("dark").ground // the one group that fully forks
// token() throws on an unknown name rather than returning a fallback:
// a silent fallback in a paint spec is a wrong colour nobody notices for a month.The tier match
["match",["get","tier"],0,"#B9BCC4",1,"#1B8A4C",2,"#2F6FB0",3,"#C9A21A",4,"#C43D3D","#B9BCC4"] That is the real output of tierMatchExpression("tier"), built in the package rather than at the call site.
Selection and Hover
Code
import { token } from "@gridd/ui/tokens"
map.addLayer({
id: "lots-hover", type: "line", source: "lots", "source-layer": "lots",
paint: {
"line-color": token("--gd-ink", theme),
"line-width": ["case", ["boolean", ["feature-state", "hover"], false], 2, 0]
}
})
map.addLayer({
id: "lots-selected", type: "line", source: "lots", "source-layer": "lots",
paint: {
"line-color": token("--gd-accent", theme),
"line-width": ["case", ["boolean", ["feature-state", "selected"], false], 2.6, 0]
}
})
// The FILL is untouched in both. That is the rule.- Hover — a 2 px stroke in
--gd-ink, driven by feature-state. - Selected — a 2.6 px stroke in
--gd-accent(#4740DBlight,#5F57E3dark). - The cursor is the third signal — pointer over a parcel, default elsewhere, hit-tested on the same layer the hover strokes.
- The search focus is a separate thing — a suburb result gets a 0.05 accent wash and a dashed 2.5 px outline; a city result gets the outline only, with the wash at 0.
Constraints
0.620.350.320.05Code
import { services } from "@gridd/ui/cartography"
// a registered easement: a TITLE fact, and the dashed edge is what says so
map.addLayer({
id: "easement-fill", type: "fill", source: "easements",
paint: { "fill-color": services().easement, "fill-opacity": 0.35 }
})
map.addLayer({
id: "easement-line", type: "line", source: "easements",
paint: {
"line-color": services().easementLine,
"line-width": 1.6,
"line-dasharray": [2, 1.6]
}
})
// constraint overlays take their opacity from the group catalogue (0.14–0.28),
// declared once per group rather than per layer.- Zoning — 0.62. The loudest fill on the map.
- Constraint overlays — a per-group fill opacity between 0.14 and 0.28, declared once in the overlay catalogue. Flood is 0.28, bushfire 0.22, character housing 0.14.
- Registered easements — 0.35 fill with a dashed 1.6 px edge (
[2, 1.6]). - The regional urban footprint — 0.32, and it exists only below the council gate.
- Subdivision cells — alternating 0.16 / 0.28 by index parity.
Basemap Layers
The paperThe plane the cadastre is drawn on. Charcoal in the dark, not black: the fills above it are translucent and were tuned against a light-ish ground.RoadsDeliberately DARKER than the ground. Upstream Positron draws white roads on near-white land — about 1.05:1, effectively invisible — and inverting that polarity is the whole reason the retune exists.ParksWhite in the light, not green. A green basemap park collides with the zone palette, which already uses light greens to mean specific things; green under those reads as a zone the lot does not have.WaterThe one basemap feature that stays recognisably itself in both themes.Street namesPositron's own ink is ~3.3:1 on this ground, under the floor. This clears 4.5:1 on the ground token above.Suburb namesThe label that anchors the view — the darker of the pair in the light theme, the lighter in the dark. In both, the one that wins.Label haloThe GROUND's colour, not a colour of its own: its job is to punch the label's own paper out from under it where it crosses the parcel fabric.Rules
- Never alias a cartography token to a status token. Not tier 1 to
--gd-success, not tier 4 to--gd-danger, however exactly they match today. - Use these tokens for fills and strokes only — geometry on a canvas, and the legend swatch that describes it. Never a badge, a border, a chip, a row or a sentence.
- In a template, use
var(--gd-cartography-*). Never import from@gridd/ui/cartographyinto a template. - In a paint spec or a three.js material, use the accessors.
var()inside a MapLibre paint value is a parse error, not a colour. - Build the tier expression with
tierMatchExpression, never inline — the fallback has to be tier 0. - Keep the interaction states colourless. Hover and selection are strokes; the fill never changes.
- Do not put a wash over a whole LGA. A city-scale focus gets the dashed outline and nothing else.
- Change theme with
setPaintProperty, neversetStyle. - Guard every third-party layer id, and count the misses. Report when more than half are gone.
- Keep a publisher's own fills out of the design system. A council's zone palette belongs in the spec's colour registry, with its provenance.
Behavior & Anatomy
Zoning fills sit at 0.62 over the light basemap: strong enough that the colour IS the map, weak enough that boundaries and labels survive on top of it. That leaves nothing for interaction to spend, which is why hover and selection are lines. A hover fill would sit on top of the zoning colour — the one thing the map exists to show.
The coincidences are coincidences
Tiers 1, 2 and 4 equal --gd-success, --gd-info and --gd-danger today and are deliberately NOT aliased to them — aliasing would re-couple a red error toast to an impact-assessable parcel. Tier 3 proves it: it is #C9A21A while --gd-caution is the darker #A8850F (promoted because #C9A21A is 2.41:1 on white and fails as a non-text mark), so an alias would silently darken every code-assessable parcel on every map we serve.
Tiers are baked; colours are not
The pipeline writes an integer 0–4 per parcel and the tier→colour step is a runtime match. Re-tuning the palette costs a rebuild, never a re-bake of a council's tiles — which is the difference between a colour decision taking an afternoon and taking a week.
Published fills — a council's zone palette, the flood bands, ShapingSEQ's footprint — are not in the design system. They are facts about a publisher and live in the spec's colour registry with their provenance attached.
Why there is an accessor and not a second copy
A MapLibre paint spec cannot read a CSS custom property: var(--gd-cartography-assessment-3) inside one is a parse error, not a colour. So the map needs the palette as JavaScript — but every accessor resolves through token(), the same model that generates tokens.css. There is one source and this is an accessor, not a mirror, which is what stops the canvas and the stylesheet disagreeing.
token() throws on an unknown name rather than returning a fallback, because a silent fallback in a paint spec is a wrong colour on a map that nobody notices for a month. tierMatchExpression is built in the package for the same class of reason: the fallback matters, and an unbaked or unknown tier must land on tier 0's neutral rather than on a colour that asserts an assessment nobody computed.
The fills are the same value in both themes on purpose. --gd-surface-map is charcoal rather than black precisely so a parcel's fill keeps the relationship to its ground that it has in daylight. Only the strokes fork, because a hairline has to hold against its own backdrop.
The focus wash, and why a city gets no fill
A suburb search result gets a 0.05 accent wash — so faint it is presence rather than paint — and a dashed outline. A CITY result gets the outline only, with the fill explicitly set to 0: a 5% indigo wash over an entire LGA reads as a hazard overlay, and gets mistaken for council-wide flood. The dashed boundary alone frames the council.
Note what this means for the selected PARCEL: it has no wash at all. Hover and selection are strokes and nothing else, and the 0.05 in the ladder above belongs to search focus, not to selection.
Why overlays are translucent and easements are dashed
Two hazards overlapping must both stay legible, and the darker sum is the intersection — which is only true while each layer is faint enough to be seen through. The catalogue sets each group's opacity once, between 0.14 and 0.28, so a new hazard inherits the behaviour instead of picking a number beside it.
A registered easement is a TITLE fact, not a hazard, and the dash is what says so. The regional urban footprint sits at 0.32 and exists only below the council gate — context for choosing a council, never competing with that council's own layers. Subdivision cells alternate 0.16 / 0.28 so adjacent proposed lots separate without introducing a second hue.
The layers panel's swatches take the map's ACTUAL colours — data, not tokens — which is why those literals are marked gd-colour-ok in the docs rather than tokenised (Layers panel).
The basemap is the one thing that should change when the lights go out
The shipped map repaints CARTO Positron rather than using it as it comes. The seven values are returned together by basemap(theme) on purpose: they are a measured set, not seven independent choices. Swap one and you have broken the pair it was measured against.
- Content does not fork; the paper does. Every value above this section holds across themes so an assessment tier means the same thing at midnight as at noon. The basemap is the deliberate opposite.
- Never
setStyleto change theme. It tears down every source and layer — the parcel fabric, the selection, the 3D scene — and reloads the tiles behind them.setPaintPropertyswaps the colours in place and the map does not blink. - Every layer id is third-party, not an API. CARTO can rename them upstream, so each call is guarded — but a silent degrade to plain Positron reads as a styling whim rather than a broken dependency, so the app counts the misses and reports when more than half are gone.