@humanforest/ui 0.3.4 → 0.3.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanforest/ui",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -9,7 +9,7 @@
9
9
  "preset.gen.js"
10
10
  ],
11
11
  "dependencies": {
12
- "@humanforest/tokens": "^0.3.4",
12
+ "@humanforest/tokens": "^0.3.6",
13
13
  "@internationalized/date": "^3.12.2",
14
14
  "@mapbox/search-js-core": "^1.6.0",
15
15
  "@turf/boolean-point-in-polygon": "^7.4.0",
package/preset.gen.js CHANGED
@@ -1017,7 +1017,8 @@ var progressGroupTheme = {
1017
1017
  var proseTheme = {
1018
1018
  codePreview: {
1019
1019
  slots: {
1020
- preview: "ed-demo"
1020
+ preview: "ed-demo",
1021
+ code: "[&>div]:rounded-t-none [&>div>div>pre]:rounded-t-none"
1021
1022
  }
1022
1023
  },
1023
1024
  codeCollapse: {
@@ -72,7 +72,7 @@ export function addressAutofill(options: AddressAutofillOptions): AddressAutofil
72
72
  signal,
73
73
  });
74
74
  session = new SessionToken();
75
- return features.length ? fromAutofill(features[0]) : null;
75
+ return features.length ? fromAutofill(features[0]!) : null;
76
76
  },
77
77
  };
78
78
  }
@@ -44,7 +44,7 @@ export function geocoder(options: GeocoderOptions): Geocoder {
44
44
  // the building, then the street, then the postcode, then the city. The first is the answer;
45
45
  // the rest are its context, and `fromGeocodingFeature` already reads that off `context`.
46
46
  const { features } = await client.reverse(at, { limit: 1, signal });
47
- return features.length ? fromGeocodingFeature(features[0]) : null;
47
+ return features.length ? fromGeocodingFeature(features[0]!) : null;
48
48
  },
49
49
 
50
50
  async forward(query, signal) {
@@ -62,7 +62,7 @@ export function addressValidator(options: AddressValidatorOptions): AddressValid
62
62
  const { features } = await client.validate(query, { sessionToken: new SessionToken(), signal });
63
63
  if (!features.length) return null;
64
64
 
65
- const feature = features[0];
65
+ const feature = features[0]!;
66
66
  // MatchCode's fields are typed as const-enum members; read as plain strings, since the enum has
67
67
  // no runtime object to compare against (see the note at the top of this file).
68
68
  const code = (feature.properties?.match_code ?? {}) as unknown as Record<string, string>;
@@ -88,7 +88,7 @@ const splitToken = (token: string): { prefixes: string[]; leaf: string } => {
88
88
  } else cur += ch;
89
89
  }
90
90
  parts.push(cur);
91
- return { prefixes: parts.slice(0, -1), leaf: parts[parts.length - 1] };
91
+ return { prefixes: parts.slice(0, -1), leaf: parts[parts.length - 1]! };
92
92
  };
93
93
 
94
94
  const MARKER_RE = /^forest-[\w-]+$/;
@@ -45,7 +45,7 @@ export type FilterSchema = Record<string, FilterField>;
45
45
  * The first entry of each list is that kind's default operator, so a freshly added clause reads the
46
46
  * way it would in Linear: an enum starts at `is`, and picking a second value promotes it to `is any of`.
47
47
  */
48
- export const OPERATORS_BY_KIND: Record<FieldKind, readonly Operator[]> = {
48
+ export const OPERATORS_BY_KIND: Record<FieldKind, readonly [Operator, ...Operator[]]> = {
49
49
  enum: ['is', 'isNot', 'isAnyOf', 'isNoneOf'],
50
50
  text: ['contains', 'doesNotContain', 'is', 'isNot'],
51
51
  number: ['is', 'isNot', 'isAbove', 'isBelow', 'isBetween'],
@@ -65,9 +65,9 @@ const joined = computed(() => gtRun(word.value.toLowerCase(), leadAdvance.value)
65
65
  const ic = iconGeometry();
66
66
  // the icon letter is sized to the square's cap height, so it's set twice: once to measure, once at size
67
67
  const icon = computed(() => {
68
- const probe = setWord(word.value[0], { font: 'mohr', size: SUBLOGO_MOHR_SIZE });
68
+ const probe = setWord(word.value.charAt(0), { font: 'mohr', size: SUBLOGO_MOHR_SIZE });
69
69
  const size = (SUBLOGO_MOHR_SIZE * ic.letterCap) / (probe.inkBottom - probe.inkTop);
70
- const run = setWord(word.value[0], { font: 'mohr', size });
70
+ const run = setWord(word.value.charAt(0), { font: 'mohr', size });
71
71
  return {
72
72
  run,
73
73
  x: ic.cx - (run.inkLeft + (run.inkRight - run.inkLeft) / 2),
@@ -118,7 +118,7 @@ const joinedLight = computed(() => ramp(props.ink != null ? props.ink : props.fa
118
118
  const joinedDark = computed(() => {
119
119
  if (props.ink != null) return ramp(props.face);
120
120
  const i = STEPS.indexOf(props.face);
121
- return ramp(i > 0 ? STEPS[i - 1] : props.face);
121
+ return ramp(i > 0 ? STEPS[i - 1]! : props.face);
122
122
  });
123
123
 
124
124
  const vars = computed(() => ({
@@ -190,7 +190,7 @@ function onFocusOut() {
190
190
  });
191
191
  }
192
192
 
193
- function sourceOf(item: PaletteItem): MapSearchSource {
193
+ function sourceOf(item: PaletteItem): MapSearchSource | undefined {
194
194
  // A promoted row carries no source of its own, so find the group its result actually came from.
195
195
  const id =
196
196
  state.value?.groups.find((g) => item.result && g.results.includes(item.result))?.sourceId ??
@@ -223,7 +223,8 @@ async function choose(value: unknown) {
223
223
 
224
224
  if (geometry) moveTo(geometry);
225
225
  collapse();
226
- emit('select', result, source, geometry);
226
+ // With no source left to attribute it to, a pick still moves the camera but emits nothing.
227
+ if (source) emit('select', result, source, geometry);
227
228
  }
228
229
 
229
230
  // ★ Centring is a lie on a real ops screen — a side panel and this panel cover part of the map, so the
@@ -289,7 +290,6 @@ onBeforeUnmount(cancel);
289
290
  sideOffset: 8,
290
291
  // The field keeps focus while the panel opens — the roving highlight lives on the input via
291
292
  // aria-activedescendant, so moving focus into the content would break the combobox pattern.
292
- trapFocus: false,
293
293
  onOpenAutoFocus: (e: Event) => e.preventDefault(),
294
294
  }"
295
295
  :ui="{ content: ui.panel() }"
package/src/map/bin.ts CHANGED
@@ -36,7 +36,7 @@ export const H3_EDGE_M: Record<number, number> = {
36
36
  };
37
37
 
38
38
  /** The resolutions this ladder will choose between. Below 6 a cell is bigger than London. */
39
- export const H3_RES_RANGE: readonly number[] = [6, 7, 8, 9, 10, 11];
39
+ export const H3_RES_RANGE: readonly [number, ...number[]] = [6, 7, 8, 9, 10, 11];
40
40
 
41
41
  /**
42
42
  * How wide a cell should be ON SCREEN, in CSS px.
@@ -57,7 +57,7 @@ export const metresPerPixel = (zoom: number, lat: number): number =>
57
57
 
58
58
  /** How wide a cell of this resolution draws, in CSS px, at a zoom and latitude. */
59
59
  export const cellWidthPx = (res: number, zoom: number, lat: number): number =>
60
- (2 * H3_EDGE_M[res]) / metresPerPixel(zoom, lat);
60
+ (2 * (H3_EDGE_M[res] ?? NaN)) / metresPerPixel(zoom, lat);
61
61
 
62
62
  /**
63
63
  * The resolution to bin at for a zoom — the rung whose drawn cell lands nearest the ideal width.
@@ -173,7 +173,7 @@ export function robustDomain(values: readonly number[], percentile = 0.95): Doma
173
173
  const finite = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
174
174
  if (!finite.length) return { domain: [0, 0], clamped: 0, percentile };
175
175
  const idx = Math.min(finite.length - 1, Math.max(0, Math.ceil(percentile * finite.length) - 1));
176
- const top = finite[idx];
176
+ const top = finite[idx]!;
177
177
  return {
178
178
  domain: [0, top],
179
179
  clamped: finite.filter((v) => v > top).length,
@@ -170,6 +170,8 @@ export function densityLayers(opts: DensityOptions): OverlayLayer[] {
170
170
  alpha = DENSITY_ALPHA,
171
171
  } = opts;
172
172
 
173
+ // rampStops caps the count at the ramp's length, so buckets past it take the top stop.
174
+ const stops = densityStops(steps, ramp);
173
175
  const layers: OverlayLayer[] = [
174
176
  {
175
177
  id: `${prefix}-fill`,
@@ -180,7 +182,7 @@ export function densityLayers(opts: DensityOptions): OverlayLayer[] {
180
182
  // an unvalued cell falls into the `step`'s first bucket and is published as a counted zero.
181
183
  filter: ['has', prop],
182
184
  paint: {
183
- 'fill-color': stepColour(prop, domain, steps, (i) => densityStops(steps, ramp)[i]),
185
+ 'fill-color': stepColour(prop, domain, steps, (i) => stops[i] ?? stops.at(-1)!),
184
186
  'fill-opacity': alpha,
185
187
  // fill-emissive-strength is injected by useMapOverlay — `fill` is a shaded type, and an unlit
186
188
  // overlay paints at about a fifth of its luminance under the night preset.
@@ -272,7 +274,7 @@ export function balanceLayers(opts: BalanceOptions): OverlayLayer[] {
272
274
  slot: MAP_SLOT.route,
273
275
  filter,
274
276
  paint: {
275
- 'fill-color': stepColour(prop, domain, BALANCE_STEPS, (i) => balanceStops(ramp)[i]),
277
+ 'fill-color': stepColour(prop, domain, BALANCE_STEPS, (i) => balanceStops(ramp)[i]!),
276
278
  'fill-opacity': alpha,
277
279
  },
278
280
  },
@@ -347,17 +349,17 @@ export function densityLegend(opts: LegendOptions): RampLegend {
347
349
  return {
348
350
  kind: 'density',
349
351
  unit,
350
- cellM: res != null ? Math.round(2 * H3_EDGE_M[res]) : undefined,
352
+ cellM: res != null ? Math.round(2 * (H3_EDGE_M[res] ?? NaN)) : undefined,
351
353
  clamped,
352
354
  percentile,
353
355
  stops: edges.slice(0, -1).map((from, i) => ({
354
356
  key: String(i),
355
- colour: stops[i],
357
+ colour: stops[i] ?? stops.at(-1)!,
356
358
  from,
357
- to: edges[i + 1],
359
+ to: edges[i + 1]!,
358
360
  // The top bucket is open — everything at or above its floor is painted there, including the
359
361
  // clamped readings, so a closed label would understate it.
360
- label: i === steps - 1 ? `${fmt(from, span)}+` : `${fmt(from, span)}–${fmt(edges[i + 1], span)}`,
362
+ label: i === steps - 1 ? `${fmt(from, span)}+` : `${fmt(from, span)}–${fmt(edges[i + 1]!, span)}`,
361
363
  })),
362
364
  };
363
365
  }
@@ -378,16 +380,16 @@ export function balanceLegend(opts: LegendOptions & { low?: string; high?: strin
378
380
  return {
379
381
  kind: 'balance',
380
382
  unit,
381
- cellM: res != null ? Math.round(2 * H3_EDGE_M[res]) : undefined,
383
+ cellM: res != null ? Math.round(2 * (H3_EDGE_M[res] ?? NaN)) : undefined,
382
384
  clamped,
383
385
  percentile,
384
386
  stops: edges.slice(0, -1).map((from, i) => {
385
- const to = edges[i + 1];
387
+ const to = edges[i + 1]!;
386
388
  const centre = i === mid;
387
389
  const arm = i < mid ? low : high;
388
390
  return {
389
391
  key: String(i),
390
- colour: stops[i],
392
+ colour: stops[i]!,
391
393
  from,
392
394
  to,
393
395
  label: centre
package/src/map/draw.ts CHANGED
@@ -76,8 +76,8 @@ const toDeg = (rad: number) => (rad * 180) / Math.PI;
76
76
  /** GeoJSON's closed ring: the open one with its first position repeated at the end. */
77
77
  export function closeRing(ring: DrawRing): LngLat[] {
78
78
  if (ring.length < 3) return [...ring];
79
- const [first] = ring;
80
- const last = ring[ring.length - 1];
79
+ const first = ring[0]!;
80
+ const last = ring[ring.length - 1]!;
81
81
  return first[0] === last[0] && first[1] === last[1] ? [...ring] : [...ring, [first[0], first[1]]];
82
82
  }
83
83
 
@@ -102,7 +102,7 @@ export function drawFeatureToGeoJSON(feature: DrawFeature): GeoJSON.Feature<GeoJ
102
102
  export function midpoints(ring: DrawRing): LngLat[] {
103
103
  if (ring.length < 2) return [];
104
104
  return ring.map((a, i) => {
105
- const b = ring[(i + 1) % ring.length];
105
+ const b = ring[(i + 1) % ring.length]!;
106
106
  return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] as LngLat;
107
107
  });
108
108
  }
@@ -188,13 +188,13 @@ export function selfIntersects(ring: DrawRing): boolean {
188
188
  const n = ring.length;
189
189
  if (n < 4) return false;
190
190
  for (let i = 0; i < n; i++) {
191
- const a = ring[i];
192
- const b = ring[(i + 1) % n];
191
+ const a = ring[i]!;
192
+ const b = ring[(i + 1) % n]!;
193
193
  for (let j = i + 1; j < n; j++) {
194
194
  // share a vertex — adjacent edges, and the wrap-around pair the modulo makes adjacent too
195
195
  if (j === i || (j + 1) % n === i || (i + 1) % n === j) continue;
196
- const c = ring[j];
197
- const d = ring[(j + 1) % n];
196
+ const c = ring[j]!;
197
+ const d = ring[(j + 1) % n]!;
198
198
  if (segmentsCross(a, b, c, d)) return true;
199
199
  }
200
200
  }
@@ -209,7 +209,7 @@ export function selfIntersects(ring: DrawRing): boolean {
209
209
  * paints the problem instead and refuses the commit.
210
210
  */
211
211
  export function ringProblem(ring: DrawRing): DrawProblem | null {
212
- const distinct = ring.filter((p, i) => i === 0 || !samePoint(p, ring[i - 1]));
212
+ const distinct = ring.filter((p, i) => i === 0 || !samePoint(p, ring[i - 1]!));
213
213
  if (distinct.length < 3) return 'too-few';
214
214
  if (selfIntersects(ring)) return 'self-intersects';
215
215
  return null;
@@ -352,5 +352,5 @@ export function undoHistory<T>(h: DrawHistory<T>): DrawHistory<T> {
352
352
  export function redoHistory<T>(h: DrawHistory<T>): DrawHistory<T> {
353
353
  if (!h.future.length) return h;
354
354
  const [present, ...future] = h.future;
355
- return { past: [...h.past, h.present], present, future };
355
+ return { past: [...h.past, h.present], present: present as T, future };
356
356
  }
package/src/map/ease.ts CHANGED
@@ -47,9 +47,9 @@ const LINEAR = (t: number) => t;
47
47
  export const parseEasing = (value: string): ((t: number) => number) => {
48
48
  const m = value.trim().match(/^cubic-bezier\(([^)]+)\)$/);
49
49
  if (!m) return LINEAR;
50
- const parts = m[1].split(',').map((n) => Number.parseFloat(n));
50
+ const parts = m[1]!.split(',').map((n) => Number.parseFloat(n));
51
51
  if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return LINEAR;
52
- return cubicBezier(parts[0], parts[1], parts[2], parts[3]);
52
+ return cubicBezier(parts[0]!, parts[1]!, parts[2]!, parts[3]!);
53
53
  };
54
54
 
55
55
  /** Reads a CSS time token (`240ms`, `0.24s`) off an element, in ms. 0 for anything unparseable. */
@@ -195,7 +195,7 @@ export function paletteGroups(state: MapSearchState): PaletteGroup[] {
195
195
  icon: 'i-lucide-target',
196
196
  disabled: true,
197
197
  }
198
- : { ...skeletons(BEST)[0], id: `${BEST}-skeleton` };
198
+ : { ...skeletons(BEST)[0]!, id: `${BEST}-skeleton` };
199
199
 
200
200
  out.push({ id: BEST, ignoreFilter: true, items: [item] });
201
201
  }
@@ -261,7 +261,7 @@ function withGroup(
261
261
  const i = state.groups.findIndex((g) => g.sourceId === sourceId);
262
262
  if (i < 0) return state;
263
263
  const groups = state.groups.slice();
264
- groups[i] = { ...groups[i], ...patch };
264
+ groups[i] = { ...groups[i]!, ...patch };
265
265
  return { ...state, groups };
266
266
  }
267
267
 
@@ -156,8 +156,8 @@ export function worthQuerying(box: ScreenBox): boolean {
156
156
  export function pathPolygon(path: readonly ScreenPoint[]): GeoJSON.Feature<GeoJSON.Polygon> | null {
157
157
  if (path.length < 3) return null;
158
158
  const ring: ScreenPoint[] = [...path];
159
- const first = ring[0];
160
- const last = ring[ring.length - 1];
159
+ const first = ring[0]!;
160
+ const last = ring[ring.length - 1]!;
161
161
  if (first[0] !== last[0] || first[1] !== last[1]) ring.push([first[0], first[1]]);
162
162
  return polygon([ring]);
163
163
  }
@@ -63,7 +63,10 @@ export function mapboxPlaces(options: MapboxPlacesOptions): MapSearchSource {
63
63
  signal: retrieveSignal,
64
64
  });
65
65
  session = new SessionToken();
66
- return features[0].geometry;
66
+ // Nothing to place is a failed resolve, which FMapSearch answers by keeping its panel open.
67
+ const feature = features[0];
68
+ if (!feature) throw new Error(`mapboxPlaces: retrieve returned no feature for "${s.name}"`);
69
+ return feature.geometry;
67
70
  },
68
71
  }));
69
72
  },
package/src/map/track.ts CHANGED
@@ -79,8 +79,8 @@ export function bearingBetween(a: Fix, b: Fix): number {
79
79
  /** A zero span for an empty track rather than a throw: a scrubber renders before its data arrives. */
80
80
  export function trackSpan(track: Track): TrackSpan {
81
81
  if (!track.length) return { start: 0, end: 0, ms: 0 };
82
- const start = track[0].at;
83
- const end = track[track.length - 1].at;
82
+ const start = track[0]!.at;
83
+ const end = track[track.length - 1]!.at;
84
84
  return { start, end, ms: Math.max(0, end - start) };
85
85
  }
86
86
 
@@ -91,7 +91,7 @@ function indexAt(track: Track, at: number): number {
91
91
  let best = -1;
92
92
  while (lo <= hi) {
93
93
  const mid = (lo + hi) >> 1;
94
- if (track[mid].at <= at) {
94
+ if (track[mid]!.at <= at) {
95
95
  best = mid;
96
96
  lo = mid + 1;
97
97
  } else {
@@ -115,19 +115,19 @@ function indexAt(track: Track, at: number): number {
115
115
  export function positionAt(track: Track, at: number, maxGapMs = MAX_GAP_MS): TrackPoint | null {
116
116
  if (!track.length) return null;
117
117
 
118
- const first = track[0];
119
- const last = track[track.length - 1];
118
+ const first = track[0]!;
119
+ const last = track[track.length - 1]!;
120
120
  if (track.length === 1) return { lng: first.lng, lat: first.lat, bearing: 0, gap: false };
121
121
  if (at <= first.at) {
122
- return { lng: first.lng, lat: first.lat, bearing: bearingBetween(first, track[1]), gap: false };
122
+ return { lng: first.lng, lat: first.lat, bearing: bearingBetween(first, track[1]!), gap: false };
123
123
  }
124
124
  if (at >= last.at) {
125
- return { lng: last.lng, lat: last.lat, bearing: bearingBetween(track[track.length - 2], last), gap: false };
125
+ return { lng: last.lng, lat: last.lat, bearing: bearingBetween(track[track.length - 2]!, last), gap: false };
126
126
  }
127
127
 
128
128
  const i = indexAt(track, at);
129
- const a = track[i];
130
- const b = track[i + 1];
129
+ const a = track[i]!;
130
+ const b = track[i + 1]!;
131
131
  const bearing = bearingBetween(a, b);
132
132
  const dt = b.at - a.at;
133
133
  if (dt > maxGapMs) return { lng: a.lng, lat: a.lat, bearing, gap: true };
@@ -155,10 +155,10 @@ export function progressAt(track: Track, at: number): number {
155
155
  /** Split where the signal was lost. Every consumer that draws a track goes through this first. */
156
156
  export function trackSegments(track: Track, maxGapMs = MAX_GAP_MS): Fix[][] {
157
157
  if (!track.length) return [];
158
- const out: Fix[][] = [[track[0]]];
158
+ const out: Fix[][] = [[track[0]!]];
159
159
  for (let i = 1; i < track.length; i++) {
160
- if (track[i].at - track[i - 1].at > maxGapMs) out.push([track[i]]);
161
- else out[out.length - 1].push(track[i]);
160
+ if (track[i]!.at - track[i - 1]!.at > maxGapMs) out.push([track[i]!]);
161
+ else out[out.length - 1]!.push(track[i]!);
162
162
  }
163
163
  return out;
164
164
  }
@@ -181,7 +181,7 @@ export function trackLine(
181
181
  if (!parts.length) return null;
182
182
  const geometry: GeoJSON.LineString | GeoJSON.MultiLineString =
183
183
  parts.length === 1
184
- ? { type: 'LineString', coordinates: parts[0] }
184
+ ? { type: 'LineString', coordinates: parts[0]! }
185
185
  : { type: 'MultiLineString', coordinates: parts };
186
186
  return { type: 'Feature', properties: {}, geometry };
187
187
  }
@@ -247,7 +247,7 @@ export function trackBounds(track: Track): [[number, number], [number, number]]
247
247
  export function trackDistance(track: Track, maxGapMs = MAX_GAP_MS): number {
248
248
  let total = 0;
249
249
  for (const seg of trackSegments(track, maxGapMs)) {
250
- for (let i = 1; i < seg.length; i++) total += metresBetween(seg[i - 1], seg[i]);
250
+ for (let i = 1; i < seg.length; i++) total += metresBetween(seg[i - 1]!, seg[i]!);
251
251
  }
252
252
  return total;
253
253
  }
@@ -216,7 +216,7 @@ export function useMapDraw(map: ShallowRef<MapboxMap | null>, options: () => Map
216
216
  candidates.map((c) => c.at),
217
217
  at,
218
218
  );
219
- return hit == null ? raw : candidates[hit].lngLat;
219
+ return hit == null ? raw : candidates[hit]!.lngLat;
220
220
  }
221
221
 
222
222
  // ── the draft ────────────────────────────────────────────────────────────────────────────────────
@@ -232,7 +232,7 @@ export function useMapDraw(map: ShallowRef<MapboxMap | null>, options: () => Map
232
232
  }
233
233
  // Clicking the first vertex closes the ring — the gesture every drawing tool has, and the reason
234
234
  // the draft's own vertices are snap candidates.
235
- if (ring.length >= 3 && apart(toScreen(m, ring[0]), at) <= HANDLE_HIT_PX) {
235
+ if (ring.length >= 3 && apart(toScreen(m, ring[0]!), at) <= HANDLE_HIT_PX) {
236
236
  finish();
237
237
  return;
238
238
  }
@@ -320,7 +320,7 @@ export function useMapDraw(map: ShallowRef<MapboxMap | null>, options: () => Map
320
320
  function featureAt(m: MapboxMap, at: ScreenPoint): DrawFeature | null {
321
321
  const p = turfPoint(toLngLat(m, at));
322
322
  for (let i = features.value.length - 1; i >= 0; i--) {
323
- const f = features.value[i];
323
+ const f = features.value[i]!;
324
324
  if (f.ring.length < 3) continue;
325
325
  if (booleanPointInPolygon(p, turfPolygon([closeRing(f.ring)]))) return f;
326
326
  }
@@ -367,7 +367,7 @@ export function useMapDraw(map: ShallowRef<MapboxMap | null>, options: () => Map
367
367
  // that was not there when the reader started moving.
368
368
  const f = features.value.find((x) => x.id === handle.id);
369
369
  if (f) {
370
- const mid = midpoints(f.ring)[handle.index];
370
+ const mid = midpoints(f.ring)[handle.index]!;
371
371
  replaceRing(handle.id, insertVertex(f.ring, handle.index, mid));
372
372
  drag = { kind: 'vertex', id: handle.id, index: handle.index + 1 };
373
373
  }
@@ -166,7 +166,7 @@ export function useRegionSelect(
166
166
  const p = canvasPoint(m, e);
167
167
  // A lasso is everything between press and release; a box is its two corners, and a circle its
168
168
  // centre and a point on the rim — both of which only ever need the first point and the current one.
169
- path.value = shape.value === 'lasso' ? [...path.value, p] : [path.value[0], p];
169
+ path.value = shape.value === 'lasso' ? [...path.value, p] : [path.value[0] ?? p, p];
170
170
  }
171
171
 
172
172
  function onUp(e: PointerEvent) {
@@ -640,8 +640,8 @@ export function paintMarkerSvg(
640
640
  // vehicle's manifest here painted a place's glyphs with the vehicle's param names, which resolved to
641
641
  // nothing and left every glyph transparent.
642
642
  const declared = [...raw.matchAll(/<m:parameter[^>]*m:name="([^"]+)"[^>]*m:value="([^"]+)"/g)].map((m) => ({
643
- name: m[1],
644
- sentinel: m[2],
643
+ name: m[1]!,
644
+ sentinel: m[2]!,
645
645
  }));
646
646
  const table = declared.length ? declared : markerManifest.params;
647
647
  for (const p of table) {
@@ -780,7 +780,7 @@ export function unavailableReasons(v: VehicleFacts): string[] {
780
780
  export function unavailableReason(v: VehicleFacts): string | null {
781
781
  const reasons = unavailableReasons(v);
782
782
  if (!reasons.length) return null;
783
- if (reasons.length === 1) return reasons[0];
783
+ if (reasons.length === 1) return reasons[0]!;
784
784
  return `${reasons.slice(0, -1).join(', ')} and ${reasons.at(-1)}`;
785
785
  }
786
786
 
@@ -26,6 +26,10 @@ export const proseTheme = {
26
26
  codePreview: {
27
27
  slots: {
28
28
  preview: 'ed-demo',
29
+ // Stock squares `code > div > pre`, the top of a bare block joined under the pane. A collapsed
30
+ // block wraps that <pre> one level deeper, so the wrapper and the <pre> both kept 12px top
31
+ // corners and notched the joint at each end.
32
+ code: '[&>div]:rounded-t-none [&>div>div>pre]:rounded-t-none',
29
33
  },
30
34
  },
31
35