@aiaiai-pt/design-system 0.15.0 → 0.17.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.
@@ -7,6 +7,8 @@
7
7
  import CodeBlock from "./CodeBlock.svelte";
8
8
  import Input from "./Input.svelte";
9
9
  import MapPicker from "./MapPicker.svelte";
10
+ import FileUpload from "./FileUpload.svelte";
11
+ import FileUploadItem from "./FileUploadItem.svelte";
10
12
  import Select from "./Select.svelte";
11
13
  import Tag from "./Tag.svelte";
12
14
  import type { Snippet } from "svelte";
@@ -62,6 +64,11 @@
62
64
  /** Injected apply. Required for a working submit button; absent → no button
63
65
  * (so admin-preview / adapter-preview stay read-only). */
64
66
  onApply?: (payload: Record<string, unknown>) => Promise<ApplyResult>;
67
+ /** Transport for `file`-typed parameters (#75 M5 slice 4b): receives one
68
+ * validated File, returns its storage key. The renderer owns the UI +
69
+ * the attachment_keys payload contract; the CONSUMER owns transport/auth
70
+ * (FileUpload philosophy). Omitted → file params render disabled. */
71
+ uploadFile?: (file: File) => Promise<{ key: string } | { error: string }>;
65
72
  /** Environment-specific captcha (e.g. Turnstile), rendered above the button
66
73
  * in submit modes. */
67
74
  captcha?: Snippet;
@@ -76,11 +83,22 @@
76
83
  mode = "admin-preview",
77
84
  schema = null,
78
85
  onApply = undefined,
86
+ uploadFile = undefined,
79
87
  captcha = undefined,
80
88
  submitLabel = "Submit",
81
89
  }: Props = $props();
82
90
 
83
91
  let values = $state<Record<string, unknown>>({});
92
+ // `file` params live OUTSIDE values — their keys ride the payload's
93
+ // top-level attachment_keys, never raw_values (the form schema doesn't
94
+ // know them; validation would reject strays).
95
+ let fileUploads = $state<Record<string, Array<{ key: string; name: string }>>>({});
96
+ let fileBusy = $state<Record<string, boolean>>({});
97
+ let fileError = $state<Record<string, string>>({});
98
+ // Platform caps mirror the public upload surface (3 × 5MB, web image types).
99
+ const FILE_MAX_COUNT = 3;
100
+ const FILE_MAX_BYTES = 5 * 1024 * 1024;
101
+ const FILE_ACCEPT = "image/jpeg,image/png,image/webp";
84
102
 
85
103
  // Submit state (apply seam). Only meaningful in submit modes with an onApply.
86
104
  let submitting = $state(false);
@@ -124,7 +142,11 @@
124
142
  const canSubmit = $derived(
125
143
  visibleParameters
126
144
  .filter((parameter) => parameter.required)
127
- .every((parameter) => !isEmpty(values[parameterKey(parameter)])),
145
+ .every((parameter) =>
146
+ String(parameter.type ?? "") === "file"
147
+ ? (fileUploads[parameterKey(parameter)] ?? []).length > 0
148
+ : !isEmpty(values[parameterKey(parameter)]),
149
+ ),
128
150
  );
129
151
 
130
152
  async function handleSubmit(): Promise<void> {
@@ -287,6 +309,45 @@
287
309
  : {};
288
310
  }
289
311
 
312
+ async function handleFiles(key: string, files: File[]) {
313
+ if (!uploadFile) return;
314
+ fileError = { ...fileError, [key]: "" };
315
+ const existing = fileUploads[key] ?? [];
316
+ const room = FILE_MAX_COUNT - existing.length;
317
+ const batch = files.slice(0, room);
318
+ if (files.length > room) {
319
+ fileError = { ...fileError, [key]: `Up to ${FILE_MAX_COUNT} files` };
320
+ }
321
+ if (batch.length === 0) return;
322
+ fileBusy = { ...fileBusy, [key]: true };
323
+ try {
324
+ for (const file of batch) {
325
+ const result = await uploadFile(file);
326
+ if ("key" in result) {
327
+ fileUploads = {
328
+ ...fileUploads,
329
+ [key]: [...(fileUploads[key] ?? []), { key: result.key, name: file.name }],
330
+ };
331
+ } else {
332
+ fileError = { ...fileError, [key]: result.error };
333
+ }
334
+ }
335
+ } finally {
336
+ fileBusy = { ...fileBusy, [key]: false };
337
+ }
338
+ }
339
+
340
+ function removeFile(key: string, storageKey: string) {
341
+ fileUploads = {
342
+ ...fileUploads,
343
+ [key]: (fileUploads[key] ?? []).filter((f) => f.key !== storageKey),
344
+ };
345
+ }
346
+
347
+ function allAttachmentKeys(): string[] {
348
+ return Object.values(fileUploads).flatMap((list) => list.map((f) => f.key));
349
+ }
350
+
290
351
  function buildPayload(): Record<string, unknown> {
291
352
  return buildActionPayload({
292
353
  action: renderedAction ?? null,
@@ -294,6 +355,7 @@
294
355
  targetConfig: targetConfig(),
295
356
  sourceSchema: sourceSchema(),
296
357
  rawValues: visibleValueBag(),
358
+ attachmentKeys: allAttachmentKeys(),
297
359
  schemaVersion: schema?.schema_version ?? null,
298
360
  mode,
299
361
  }) as unknown as Record<string, unknown>;
@@ -350,11 +412,42 @@
350
412
  ]}
351
413
  onchange={(value: string) => setValue(key, value === "true")}
352
414
  />
415
+ {:else if type === "file"}
416
+ <!-- `file` parameter (#75 M5 slice 4b): upload-as-you-attach. The keys
417
+ ride payload.attachment_keys; raw_values never sees this param. -->
418
+ <div class="afr-file-param">
419
+ <span class="afr-file-label">{String(parameter.label ?? key)}</span>
420
+ {#each fileUploads[key] ?? [] as f (f.key)}
421
+ <FileUploadItem
422
+ name={f.name}
423
+ onremove={() => removeFile(key, f.key)}
424
+ />
425
+ {/each}
426
+ {#if (fileUploads[key] ?? []).length < FILE_MAX_COUNT}
427
+ <FileUpload
428
+ accept={FILE_ACCEPT}
429
+ maxSize={FILE_MAX_BYTES}
430
+ multiple
431
+ disabled={!uploadFile || fileBusy[key]}
432
+ onfiles={(files: File[]) => handleFiles(key, files)}
433
+ onreject={() => {
434
+ fileError = { ...fileError, [key]: "JPEG, PNG or WebP up to 5MB" };
435
+ }}
436
+ />
437
+ {/if}
438
+ {#if fileError[key]}
439
+ <p class="afr-file-error" role="alert">{fileError[key]}</p>
440
+ {/if}
441
+ </div>
353
442
  {:else if type === "geo"}
354
443
  <!-- A `geo` parameter renders the DS map-picker; its value is the
355
444
  [lon, lat] the BFF's GEO_PARSE binding transform turns into a Point.
356
445
  The renderer stays generic — geo is just another param type, the
357
- occurrence/location coupling lives entirely in the ontology binding. -->
446
+ occurrence/location coupling lives entirely in the ontology binding.
447
+ SIBLING-ADDRESS CONVENTION: when the form also declares a parameter
448
+ keyed `address`, the picker's resolved address (search pick or
449
+ reverse geocode) auto-fills it — tracking the pin; the citizen can
450
+ still edit the field afterwards (a later pin overwrites). -->
358
451
  <MapPicker
359
452
  mode="point"
360
453
  height="20rem"
@@ -366,6 +459,11 @@
366
459
  ? (values[key] as [number, number])
367
460
  : undefined}
368
461
  onchange={(coords: [number, number]) => setValue(key, coords)}
462
+ onaddress={(displayName: string) => {
463
+ if (renderedParameters.some((p) => parameterKey(p) === "address")) {
464
+ setValue("address", displayName);
465
+ }
466
+ }}
369
467
  />
370
468
  {:else}
371
469
  <Input
@@ -538,6 +636,23 @@
538
636
  gap: var(--space-lg);
539
637
  }
540
638
 
639
+ .afr-file-param {
640
+ display: flex;
641
+ flex-direction: column;
642
+ gap: var(--space-xs);
643
+ }
644
+
645
+ .afr-file-label {
646
+ font-size: var(--input-label-size, var(--type-body-sm-size));
647
+ color: var(--color-text-secondary);
648
+ }
649
+
650
+ .afr-file-error {
651
+ margin: 0;
652
+ font-size: var(--type-body-sm-size);
653
+ color: var(--input-error-text);
654
+ }
655
+
541
656
  .submit-captcha {
542
657
  min-height: var(--space-4xl);
543
658
  }
@@ -45,6 +45,15 @@ interface Props {
45
45
  /** Injected apply. Required for a working submit button; absent → no button
46
46
  * (so admin-preview / adapter-preview stay read-only). */
47
47
  onApply?: (payload: Record<string, unknown>) => Promise<ApplyResult>;
48
+ /** Transport for `file`-typed parameters (#75 M5 slice 4b): receives one
49
+ * validated File, returns its storage key. The renderer owns the UI +
50
+ * the attachment_keys payload contract; the CONSUMER owns transport/auth
51
+ * (FileUpload philosophy). Omitted → file params render disabled. */
52
+ uploadFile?: (file: File) => Promise<{
53
+ key: string;
54
+ } | {
55
+ error: string;
56
+ }>;
48
57
  /** Environment-specific captcha (e.g. Turnstile), rendered above the button
49
58
  * in submit modes. */
50
59
  captcha?: Snippet;
@@ -43,6 +43,10 @@
43
43
  viewbox = undefined,
44
44
  /** @type {((lon: number, lat: number, item: { label: string, value: string }) => void) | undefined} */
45
45
  onlocation = undefined,
46
+ /** @type {((displayName: string) => void) | undefined} — fires with the
47
+ * resolved address label on BOTH paths: reverse geocode (map click /
48
+ * coords set) and forward selection. Consumers feed address fields. */
49
+ onresolved = undefined,
46
50
  /** @type {[number, number] | undefined} — set externally to reverse-geocode and show address */
47
51
  coords = $bindable(undefined),
48
52
  /** @type {string} */
@@ -78,6 +82,7 @@
78
82
  if (result.display_name) {
79
83
  value = `${lon},${lat}`;
80
84
  items = [{ value: `${lon},${lat}`, label: result.display_name, description: result.type?.replace(/_/g, ' ') ?? '' }];
85
+ onresolved?.(result.display_name);
81
86
  }
82
87
  } catch {
83
88
  // Reverse geocoding failed — leave search bar as-is
@@ -135,9 +140,10 @@
135
140
  const [lonStr, latStr] = val.split(',');
136
141
  const lon = parseFloat(lonStr);
137
142
  const lat = parseFloat(latStr);
138
- if (!Number.isNaN(lon) && !Number.isNaN(lat) && onlocation) {
143
+ if (!Number.isNaN(lon) && !Number.isNaN(lat)) {
139
144
  const selectedItem = items.find((i) => i.value === val);
140
- onlocation(lon, lat, selectedItem ?? { label: '', value: val });
145
+ if (selectedItem?.label) onresolved?.(selectedItem.label);
146
+ onlocation?.(lon, lat, selectedItem ?? { label: '', value: val });
141
147
  }
142
148
  }
143
149
  </script>
@@ -36,6 +36,7 @@ declare const GeoSearch: import("svelte").Component<{
36
36
  providerUrl?: string;
37
37
  viewbox?: any;
38
38
  onlocation?: any;
39
+ onresolved?: any;
39
40
  coords?: any;
40
41
  class?: string;
41
42
  } & Record<string, any>, {}, "coords">;
@@ -49,6 +50,7 @@ type $$ComponentProps = {
49
50
  providerUrl?: string;
50
51
  viewbox?: any;
51
52
  onlocation?: any;
53
+ onresolved?: any;
52
54
  coords?: any;
53
55
  class?: string;
54
56
  } & Record<string, any>;
@@ -14,13 +14,17 @@
14
14
  -->
15
15
  <script>
16
16
  import { fromLonLat } from 'ol/proj.js';
17
+ import { boundingExtent } from 'ol/extent.js';
17
18
  import { createTileLayer, createMapStyles, watchTheme, renderMapError } from './map-utils.js';
18
19
 
19
20
  let {
20
21
  /** @type {{ id: string, lon: number, lat: number, label?: string, [key: string]: any }[]} */
21
22
  markers = [],
22
- /** @type {[number, number]} — initial center [lon, lat] */
23
- center = [0, 0],
23
+ /** @type {[number, number] | undefined} — initial center [lon, lat].
24
+ * Omit to auto-FIT the markers' extent (the right default for "show
25
+ * my data" maps; explicit center wins for dashboards pinned to a
26
+ * region). */
27
+ center = undefined,
24
28
  /** @type {number} */
25
29
  zoom = 6,
26
30
  /** @type {number} — cluster distance in pixels */
@@ -51,13 +55,21 @@
51
55
  /** @type {any} — Point constructor */
52
56
  let _Point;
53
57
 
54
- // Reactive: animate to new center/zoom when props change
58
+ // Reactive: animate when the CALLER's center/zoom props change. Guarded
59
+ // against the mount-time run — with no explicit `center` the view was
60
+ // initialised to the markers' extent/mean, and animating to the prop
61
+ // default ([0,0], null island) shoved every marker off-screen.
62
+ let _lastCenterKey = $state("");
55
63
  $effect(() => {
56
- if (!_map) return;
64
+ const key = center ? `${center[0]},${center[1]},${zoom}` : "";
65
+ if (!_map || !center) return;
66
+ if (key === _lastCenterKey) return;
67
+ const first = _lastCenterKey === "";
68
+ _lastCenterKey = key;
69
+ if (first) return; // initial view already honours the props
57
70
  const view = _map.getView();
58
71
  if (!view) return;
59
- const targetCenter = fromLonLat(center);
60
- view.animate({ center: targetCenter, zoom, duration: 300 });
72
+ view.animate({ center: fromLonLat(center), zoom, duration: 300 });
61
73
  });
62
74
 
63
75
  // Reactive: update markers when props change
@@ -155,14 +167,22 @@
155
167
  element: tooltipEl,
156
168
  positioning: 'bottom-center',
157
169
  offset: [0, -12],
170
+ // The hover tooltip anchors AT the feature — OL's default
171
+ // stopEvent:true puts it in the event-stopping overlay pane, so the
172
+ // very click the tooltip invites lands on the overlay container and
173
+ // dies (the element's own pointer-events:none can't help; the
174
+ // CONTAINER captures). Informational overlay → never stop events.
175
+ stopEvent: false,
158
176
  });
159
177
 
160
- const viewCenter = markers.length > 0
161
- ? fromLonLat([
162
- markers.reduce((s, m) => s + m.lon, 0) / markers.length,
163
- markers.reduce((s, m) => s + m.lat, 0) / markers.length,
164
- ])
165
- : fromLonLat(center);
178
+ const viewCenter = center
179
+ ? fromLonLat(center)
180
+ : markers.length > 0
181
+ ? fromLonLat([
182
+ markers.reduce((s, m) => s + m.lon, 0) / markers.length,
183
+ markers.reduce((s, m) => s + m.lat, 0) / markers.length,
184
+ ])
185
+ : fromLonLat([0, 0]);
166
186
 
167
187
  map = new OlMap({
168
188
  target: container,
@@ -174,6 +194,14 @@
174
194
  }),
175
195
  });
176
196
  _map = map;
197
+ // No explicit center → fit the markers' extent so every cluster is on
198
+ // screen regardless of how the data spreads (mean-centre alone leaves
199
+ // distant clouds outside the default zoom).
200
+ if (!center && markers.length > 1) {
201
+ const coords = markers.map((m) => fromLonLat([m.lon, m.lat]));
202
+ const ext = boundingExtent(coords);
203
+ map.getView().fit(ext, { padding: [48, 48, 48, 48], maxZoom: 15 });
204
+ }
177
205
 
178
206
  // Hover: show tooltip
179
207
  map.on('pointermove', (evt) => {
@@ -212,6 +240,18 @@
212
240
  const data = clustered[0].get('markerData');
213
241
  if (data) onclick(data);
214
242
  } else if (clustered?.length > 1) {
243
+ // A cluster of (near-)IDENTICAL coordinates can never split by
244
+ // zooming (stacked reports at one point are common in civic
245
+ // data) — open the first item instead of zoom-looping forever.
246
+ const coords = clustered.map((f) => f.getGeometry()?.getCoordinates()).filter(Boolean);
247
+ const lons = coords.map((c) => c[0]);
248
+ const lats = coords.map((c) => c[1]);
249
+ const spread = Math.max(...lons) - Math.min(...lons) + (Math.max(...lats) - Math.min(...lats));
250
+ if (spread < 1) { // metres in web-mercator units — a stacked pile
251
+ const data = clustered[0].get('markerData');
252
+ if (data) onclick(data);
253
+ return;
254
+ }
215
255
  const view = map?.getView();
216
256
  const currentZoom = view?.getZoom() ?? zoom;
217
257
  view?.animate({
@@ -19,7 +19,7 @@ type MapCluster = {
19
19
  */
20
20
  declare const MapCluster: import("svelte").Component<{
21
21
  markers?: any[];
22
- center?: any[];
22
+ center?: any;
23
23
  zoom?: number;
24
24
  distance?: number;
25
25
  tileSource?: Record<string, any>;
@@ -29,7 +29,7 @@ declare const MapCluster: import("svelte").Component<{
29
29
  } & Record<string, any>, {}, "">;
30
30
  type $$ComponentProps = {
31
31
  markers?: any[];
32
- center?: any[];
32
+ center?: any;
33
33
  zoom?: number;
34
34
  distance?: number;
35
35
  tileSource?: Record<string, any>;
@@ -12,7 +12,7 @@
12
12
  -->
13
13
  <script>
14
14
  import { fromLonLat } from 'ol/proj.js';
15
- import { createTileLayer, createMapStyles, watchTheme, renderMapError } from './map-utils.js';
15
+ import { createTileLayer, createMapStyles, createOverlayLayers, watchTheme, renderMapError } from './map-utils.js';
16
16
 
17
17
  let {
18
18
  /** @type {[number, number]} — [longitude, latitude] WGS84 */
@@ -25,6 +25,12 @@
25
25
  polygon = undefined,
26
26
  /** @type {import('./map-utils.js').TileSourceConfig} */
27
27
  tileSource = { type: 'osm' },
28
+ /** @type {import('./map-utils.js').OverlayLayerDef[]} — ordered GeoJSON
29
+ * overlays rendered between the tiles and the marker/polygon layer.
30
+ * Each entry: inline `data` or a `url` (e.g. the platform's
31
+ * `/{app}/public/layers/{id}/features`), optional flat or GeoStyler
32
+ * `style`. See map-utils OverlayLayerDef. */
33
+ layers = [],
28
34
  /** @type {string} */
29
35
  height = '100%',
30
36
  /** @type {string} */
@@ -71,6 +77,9 @@
71
77
  ]);
72
78
  if (disposed) return;
73
79
 
80
+ const overlayLayers = await createOverlayLayers(layers, styles);
81
+ if (disposed) return;
82
+
74
83
  /** @type {Feature[]} */
75
84
  const features = [];
76
85
 
@@ -94,7 +103,7 @@
94
103
 
95
104
  map = new OlMap({
96
105
  target: container,
97
- layers: [tileLayer, vectorLayer],
106
+ layers: [tileLayer, ...overlayLayers, vectorLayer],
98
107
  view: new View({
99
108
  center: fromLonLat(center),
100
109
  zoom,
@@ -105,6 +114,9 @@
105
114
  disposeTheme = watchTheme(() => {
106
115
  styles.refresh();
107
116
  vectorLayer.getSource()?.changed();
117
+ // Token-styled overlays (no custom style) re-read via the shared
118
+ // styles object; poke their sources so OL repaints.
119
+ for (const l of overlayLayers) l.getSource()?.changed();
108
120
  });
109
121
  } catch (err) { renderMapError(container, 'MapDisplay', /** @type {Error} */ (err)); } })();
110
122
 
@@ -21,6 +21,7 @@ declare const MapDisplay: import("svelte").Component<{
21
21
  marker?: any;
22
22
  polygon?: any;
23
23
  tileSource?: Record<string, any>;
24
+ layers?: any[];
24
25
  height?: string;
25
26
  class?: string;
26
27
  } & Record<string, any>, {}, "">;
@@ -30,6 +31,7 @@ type $$ComponentProps = {
30
31
  marker?: any;
31
32
  polygon?: any;
32
33
  tileSource?: Record<string, any>;
34
+ layers?: any[];
33
35
  height?: string;
34
36
  class?: string;
35
37
  } & Record<string, any>;
@@ -22,7 +22,16 @@
22
22
 
23
23
  <script>
24
24
  import { fromLonLat, toLonLat } from 'ol/proj.js';
25
- import { createTileLayer, createMapStyles, watchTheme, renderMapError } from './map-utils.js';
25
+ import {
26
+ createTileLayer,
27
+ createMapStyles,
28
+ createOverlayLayers,
29
+ createBoundaryLayer,
30
+ boundaryToRings,
31
+ pointInRings,
32
+ watchTheme,
33
+ renderMapError,
34
+ } from './map-utils.js';
26
35
  import GeoSearch from './GeoSearch.svelte';
27
36
 
28
37
  let {
@@ -50,8 +59,24 @@
50
59
  searchViewbox = undefined,
51
60
  /** @type {import('./map-utils.js').TileSourceConfig} */
52
61
  tileSource = { type: 'osm' },
62
+ /** @type {import('./map-utils.js').OverlayLayerDef[]} — ordered GeoJSON
63
+ * overlays between the tiles and the draw layer (see MapDisplay). */
64
+ layers = [],
65
+ /** @type {any} — tenant boundary: GeoJSON Polygon/MultiPolygon/Feature/
66
+ * FeatureCollection or a raw ring [[lon,lat],...]. Rendered as a dashed
67
+ * --map-boundary-* overlay; point placements are tested against it. */
68
+ boundary = undefined,
69
+ /** @type {((outside: boolean, coords: [number, number]) => void) | undefined}
70
+ * Fired on every point placement (map click or search pick) when a
71
+ * `boundary` is set. The pin still lands — surfacing/blocking is the
72
+ * consumer's call (the intake hard-gate is server-side regardless). */
73
+ onoutofbounds = undefined,
53
74
  /** @type {((coords: [number, number] | number[][]) => void) | undefined} */
54
75
  onchange = undefined,
76
+ /** @type {((displayName: string) => void) | undefined} — the resolved
77
+ * address whenever the pin settles (search pick or reverse geocode of a
78
+ * map click). Feed it to an address field; coords stay `onchange`. */
79
+ onaddress = undefined,
55
80
  /** @type {string} */
56
81
  height = '100%',
57
82
  /** @type {string | undefined} */
@@ -99,6 +124,7 @@
99
124
  _vectorSource.clear();
100
125
  _vectorSource.addFeature(new _Feature({ geometry: new _Point(fromLonLat([lon, lat])) }));
101
126
  value = [lon, lat];
127
+ checkBoundary([lon, lat]);
102
128
  onchange?.([lon, lat]);
103
129
  }
104
130
  }
@@ -108,6 +134,14 @@
108
134
  searchCoords = [lon, lat];
109
135
  }
110
136
 
137
+ const boundaryRings = $derived(boundaryToRings(boundary));
138
+
139
+ /** @param {[number, number]} coords */
140
+ function checkBoundary(coords) {
141
+ if (!boundaryRings.length || !onoutofbounds) return;
142
+ onoutofbounds(!pointInRings(coords, boundaryRings), coords);
143
+ }
144
+
111
145
  $effect(() => {
112
146
  if (!container || disabled) return;
113
147
 
@@ -144,6 +178,12 @@
144
178
  ]);
145
179
  if (disposed) return;
146
180
 
181
+ const [overlayLayers, boundaryLayer] = await Promise.all([
182
+ createOverlayLayers(layers, styles),
183
+ createBoundaryLayer(boundaryRings, container),
184
+ ]);
185
+ if (disposed) return;
186
+
147
187
  const vectorSource = new VectorSource();
148
188
  _vectorSource = vectorSource;
149
189
  _Feature = Feature;
@@ -187,6 +227,7 @@
187
227
  const wgs84 = /** @type {[number, number]} */ (toLonLat(coords));
188
228
  value = wgs84;
189
229
  handleMapPointPlaced(wgs84[0], wgs84[1]);
230
+ checkBoundary(wgs84);
190
231
  onchange?.(wgs84);
191
232
  } else {
192
233
  const coords = /** @type {import('ol/geom/Polygon.js').default} */ (geom).getCoordinates()[0];
@@ -199,7 +240,12 @@
199
240
 
200
241
  map = new OlMap({
201
242
  target: container,
202
- layers: [tileLayer, vectorLayer],
243
+ layers: [
244
+ tileLayer,
245
+ ...overlayLayers,
246
+ ...(boundaryLayer ? [boundaryLayer] : []),
247
+ vectorLayer,
248
+ ],
203
249
  view: new View({
204
250
  center: initialCenter,
205
251
  zoom,
@@ -212,6 +258,7 @@
212
258
  disposeTheme = watchTheme(() => {
213
259
  styles.refresh();
214
260
  vectorSource.changed();
261
+ for (const l of overlayLayers) l.getSource()?.changed();
215
262
  });
216
263
  } catch (err) { renderMapError(container, 'MapPicker', /** @type {Error} */ (err)); } })();
217
264
 
@@ -234,6 +281,7 @@
234
281
  providerUrl={searchProviderUrl}
235
282
  viewbox={searchViewbox}
236
283
  onlocation={handleGeoLocation}
284
+ onresolved={onaddress}
237
285
  bind:coords={searchCoords}
238
286
  size="sm"
239
287
  />
@@ -34,7 +34,11 @@ declare const MapPicker: import("svelte").Component<{
34
34
  searchProviderUrl?: any;
35
35
  searchViewbox?: any;
36
36
  tileSource?: Record<string, any>;
37
+ layers?: any[];
38
+ boundary?: any;
39
+ onoutofbounds?: any;
37
40
  onchange?: any;
41
+ onaddress?: any;
38
42
  height?: string;
39
43
  id?: any;
40
44
  class?: string;
@@ -52,7 +56,11 @@ type $$ComponentProps = {
52
56
  searchProviderUrl?: any;
53
57
  searchViewbox?: any;
54
58
  tileSource?: Record<string, any>;
59
+ layers?: any[];
60
+ boundary?: any;
61
+ onoutofbounds?: any;
55
62
  onchange?: any;
63
+ onaddress?: any;
56
64
  height?: string;
57
65
  id?: any;
58
66
  class?: string;
@@ -39,10 +39,16 @@ export interface BuildArgs {
39
39
  sourceSchema: Record<string, unknown>;
40
40
  /** Submitted values keyed by parameter source_field_path or key. */
41
41
  rawValues: Record<string, unknown>;
42
+ /** Uploaded file storage keys (`file`-typed parameters) — the platform
43
+ * attachment contract: a TOP-LEVEL `attachment_keys` sibling, never part
44
+ * of raw_values (the form schema doesn't validate file params). */
45
+ attachmentKeys?: string[];
42
46
  schemaVersion: string | null;
43
47
  mode: RendererMode;
44
48
  }
45
49
  export interface ActionPayload {
50
+ /** Uploaded file storage keys (file-typed params) — absent when none. */
51
+ attachment_keys?: string[];
46
52
  source: string;
47
53
  action: {
48
54
  id: string | null;
@@ -42,11 +42,17 @@ export interface BuildArgs {
42
42
  sourceSchema: Record<string, unknown>;
43
43
  /** Submitted values keyed by parameter source_field_path or key. */
44
44
  rawValues: Record<string, unknown>;
45
+ /** Uploaded file storage keys (`file`-typed parameters) — the platform
46
+ * attachment contract: a TOP-LEVEL `attachment_keys` sibling, never part
47
+ * of raw_values (the form schema doesn't validate file params). */
48
+ attachmentKeys?: string[];
45
49
  schemaVersion: string | null;
46
50
  mode: RendererMode;
47
51
  }
48
52
 
49
53
  export interface ActionPayload {
54
+ /** Uploaded file storage keys (file-typed params) — absent when none. */
55
+ attachment_keys?: string[];
50
56
  source: string;
51
57
  action: {
52
58
  id: string | null;
@@ -102,7 +108,7 @@ function pickScope(targetConfig: Record<string, unknown>): Record<string, unknow
102
108
  }
103
109
 
104
110
  export function buildActionPayload(args: BuildArgs): ActionPayload {
105
- const { action, placement, targetConfig, sourceSchema, rawValues, schemaVersion, mode } = args;
111
+ const { action, placement, targetConfig, sourceSchema, rawValues, schemaVersion, mode, attachmentKeys } = args;
106
112
 
107
113
  const sourceFromSchema = nullableString(sourceSchema.source);
108
114
  const targetModel =
@@ -143,6 +149,9 @@ export function buildActionPayload(args: BuildArgs): ActionPayload {
143
149
  target_model: targetModel,
144
150
  form_definition_id: nullableString(targetConfig.form_definition_id),
145
151
  },
152
+ ...(attachmentKeys && attachmentKeys.length > 0
153
+ ? { attachment_keys: attachmentKeys }
154
+ : {}),
146
155
  };
147
156
  }
148
157
 
@@ -336,3 +336,264 @@ export function getHeatmapGradient(el) {
336
336
 
337
337
  return ["rgba(251, 227, 142, 0)", "#fbe38e", "#fb923c", "#e85a28", "#ae2a1e"];
338
338
  }
339
+
340
+ // ─── Overlay Layers (#187 D-c) ───────────────────────────────────
341
+
342
+ /**
343
+ * @typedef {object} OverlayLayerStyle
344
+ * Flat style subset applied to a whole overlay layer. Colors are CSS color
345
+ * strings (they come from layer DATA — geolayers GeoStyler styles — not
346
+ * from design tokens; absent values fall back to the DS-tokened defaults).
347
+ * @property {string} [pointColor]
348
+ * @property {number} [pointRadius]
349
+ * @property {string} [strokeColor]
350
+ * @property {number} [strokeWidth]
351
+ * @property {string} [fillColor]
352
+ *
353
+ * @typedef {object} OverlayLayerDef
354
+ * One ordered overlay rendered between the tile layer and the component's
355
+ * own interactive vector layer. v1 is GeoJSON-only — the platform's citizen
356
+ * data plane serves GeoJSON (`/{app}/public/layers/{id}/features`); WMS/tile
357
+ * overlays arrive with the per-tenant raster principal (spec open question).
358
+ * @property {string} [id]
359
+ * @property {'geojson'} [type]
360
+ * @property {object} [data] — inline GeoJSON (Feature/FeatureCollection)
361
+ * @property {string} [url] — fetched when `data` is absent
362
+ * @property {OverlayLayerStyle | object} [style] — flat style or GeoStyler
363
+ * JSON (best-effort subset via `geoStylerToFlat`)
364
+ * @property {boolean} [visible]
365
+ */
366
+
367
+ /**
368
+ * Best-effort GeoStyler → flat style. Reads the first symbolizer of the
369
+ * first rule — enough for the platform's single-rule layer styles. Unknown
370
+ * shapes return {} so the DS-tokened defaults apply.
371
+ *
372
+ * @param {any} style
373
+ * @returns {OverlayLayerStyle}
374
+ */
375
+ export function geoStylerToFlat(style) {
376
+ if (!style || typeof style !== "object") return {};
377
+ if (!Array.isArray(style.rules)) return /** @type {OverlayLayerStyle} */ (style);
378
+ const sym = style.rules?.[0]?.symbolizers?.[0];
379
+ if (!sym || typeof sym !== "object") return {};
380
+ /** @type {OverlayLayerStyle} */
381
+ const flat = {};
382
+ if (sym.kind === "Mark" || sym.kind === "Icon") {
383
+ if (typeof sym.color === "string") flat.pointColor = sym.color;
384
+ if (typeof sym.radius === "number") flat.pointRadius = sym.radius;
385
+ if (typeof sym.strokeColor === "string") flat.strokeColor = sym.strokeColor;
386
+ if (typeof sym.strokeWidth === "number") flat.strokeWidth = sym.strokeWidth;
387
+ } else if (sym.kind === "Line") {
388
+ if (typeof sym.color === "string") flat.strokeColor = sym.color;
389
+ if (typeof sym.width === "number") flat.strokeWidth = sym.width;
390
+ } else if (sym.kind === "Fill") {
391
+ if (typeof sym.color === "string") flat.fillColor = sym.color;
392
+ if (typeof sym.outlineColor === "string") flat.strokeColor = sym.outlineColor;
393
+ if (typeof sym.outlineWidth === "number") flat.strokeWidth = sym.outlineWidth;
394
+ }
395
+ return flat;
396
+ }
397
+
398
+ /**
399
+ * Builds OL vector layers for `layers` overlay defs, ordered as given.
400
+ * URL-sourced layers load asynchronously into their source; a fetch failure
401
+ * leaves that overlay empty and logs a warning (the map still renders).
402
+ *
403
+ * @param {OverlayLayerDef[]} defs
404
+ * @param {import('./map-utils.js').MapStyles} styles — DS default styles
405
+ * @returns {Promise<import('ol/layer/Vector.js').default[]>}
406
+ */
407
+ export async function createOverlayLayers(defs, styles) {
408
+ if (!Array.isArray(defs) || defs.length === 0) return [];
409
+
410
+ const [
411
+ { default: VectorLayer },
412
+ { default: VectorSource },
413
+ { default: GeoJSON },
414
+ { default: Style },
415
+ { default: CircleStyle },
416
+ { default: Fill },
417
+ { default: Stroke },
418
+ ] = await Promise.all([
419
+ import("ol/layer/Vector.js"),
420
+ import("ol/source/Vector.js"),
421
+ import("ol/format/GeoJSON.js"),
422
+ import("ol/style/Style.js"),
423
+ import("ol/style/Circle.js"),
424
+ import("ol/style/Fill.js"),
425
+ import("ol/style/Stroke.js"),
426
+ ]);
427
+
428
+ const format = new GeoJSON({ featureProjection: "EPSG:3857" });
429
+
430
+ /** @param {OverlayLayerDef} def */
431
+ function buildStyleFn(def) {
432
+ const flat = geoStylerToFlat(def.style);
433
+ const hasCustom = Object.keys(flat).length > 0;
434
+ if (!hasCustom) {
435
+ return (/** @type {any} */ feature) => {
436
+ const type = feature.getGeometry()?.getType();
437
+ return type === "Point" || type === "MultiPoint"
438
+ ? styles.marker
439
+ : styles.polygon;
440
+ };
441
+ }
442
+ const stroke = new Stroke({
443
+ color: flat.strokeColor ?? flat.pointColor ?? "#3366cc",
444
+ width: flat.strokeWidth ?? 2,
445
+ });
446
+ const fill = new Fill({
447
+ color: flat.fillColor ?? "rgba(51,102,204,0.15)",
448
+ });
449
+ const point = new Style({
450
+ image: new CircleStyle({
451
+ radius: flat.pointRadius ?? 6,
452
+ fill: new Fill({ color: flat.pointColor ?? flat.strokeColor ?? "#3366cc" }),
453
+ stroke,
454
+ }),
455
+ });
456
+ const shape = new Style({ fill, stroke });
457
+ return (/** @type {any} */ feature) => {
458
+ const type = feature.getGeometry()?.getType();
459
+ return type === "Point" || type === "MultiPoint" ? point : shape;
460
+ };
461
+ }
462
+
463
+ return defs
464
+ .filter((def) => def && def.visible !== false)
465
+ .map((def) => {
466
+ const source = new VectorSource();
467
+ if (def.data) {
468
+ source.addFeatures(format.readFeatures(def.data));
469
+ } else if (def.url) {
470
+ fetch(def.url)
471
+ .then((r) => {
472
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
473
+ return r.json();
474
+ })
475
+ .then((geojson) => source.addFeatures(format.readFeatures(geojson)))
476
+ .catch((err) =>
477
+ console.warn(
478
+ `[map] overlay layer ${def.id ?? def.url} failed to load:`,
479
+ err,
480
+ ),
481
+ );
482
+ }
483
+ return new VectorLayer({ source, style: buildStyleFn(def) });
484
+ });
485
+ }
486
+
487
+ // ─── Boundary (#187 D-e prep) ────────────────────────────────────
488
+
489
+ /**
490
+ * Normalises a boundary prop into polygon rings (lon/lat WGS84).
491
+ * Accepts: raw ring `number[][]`, GeoJSON Polygon / MultiPolygon,
492
+ * Feature, or FeatureCollection (first polygonal feature per entry).
493
+ *
494
+ * @param {any} boundary
495
+ * @returns {number[][][]} — array of outer rings (one per polygon)
496
+ */
497
+ export function boundaryToRings(boundary) {
498
+ if (!boundary) return [];
499
+ if (Array.isArray(boundary)) {
500
+ // Raw ring: [[lon,lat], ...]
501
+ return boundary.length >= 3 ? [boundary] : [];
502
+ }
503
+ if (boundary.type === "FeatureCollection") {
504
+ return (boundary.features ?? []).flatMap((/** @type {any} */ f) =>
505
+ boundaryToRings(f),
506
+ );
507
+ }
508
+ if (boundary.type === "Feature") return boundaryToRings(boundary.geometry);
509
+ if (boundary.type === "Polygon") {
510
+ const ring = boundary.coordinates?.[0];
511
+ return ring && ring.length >= 3 ? [ring] : [];
512
+ }
513
+ if (boundary.type === "MultiPolygon") {
514
+ return (boundary.coordinates ?? [])
515
+ .map((/** @type {any} */ poly) => poly?.[0])
516
+ .filter((/** @type {any} */ r) => r && r.length >= 3);
517
+ }
518
+ return [];
519
+ }
520
+
521
+ /**
522
+ * Ray-casting point-in-polygon over WGS84 rings (outer rings only — holes
523
+ * are out of scope for the boundary gate; the BFF hard-gate is authoritative).
524
+ *
525
+ * @param {[number, number]} lonLat
526
+ * @param {number[][][]} rings — from `boundaryToRings`
527
+ * @returns {boolean} true when the point is inside ANY ring
528
+ */
529
+ export function pointInRings(lonLat, rings) {
530
+ const [x, y] = lonLat;
531
+ for (const ring of rings) {
532
+ let inside = false;
533
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
534
+ const [xi, yi] = ring[i];
535
+ const [xj, yj] = ring[j];
536
+ const intersects =
537
+ yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
538
+ if (intersects) inside = !inside;
539
+ }
540
+ if (inside) return true;
541
+ }
542
+ return false;
543
+ }
544
+
545
+ /**
546
+ * Builds the boundary overlay layer (dashed outline, faint fill — distinct
547
+ * from the selection polygon so "the edge of the allowed area" reads as
548
+ * context, not content). Token-driven: --map-boundary-*.
549
+ *
550
+ * @param {number[][][]} rings
551
+ * @param {Element} el — token read context
552
+ * @returns {Promise<import('ol/layer/Vector.js').default | null>}
553
+ */
554
+ export async function createBoundaryLayer(rings, el) {
555
+ if (!rings.length) return null;
556
+ const [
557
+ { default: VectorLayer },
558
+ { default: VectorSource },
559
+ { default: Feature },
560
+ { default: Polygon },
561
+ { default: Style },
562
+ { default: Fill },
563
+ { default: Stroke },
564
+ { fromLonLat },
565
+ ] = await Promise.all([
566
+ import("ol/layer/Vector.js"),
567
+ import("ol/source/Vector.js"),
568
+ import("ol/Feature.js"),
569
+ import("ol/geom/Polygon.js"),
570
+ import("ol/style/Style.js"),
571
+ import("ol/style/Fill.js"),
572
+ import("ol/style/Stroke.js"),
573
+ import("ol/proj.js"),
574
+ ]);
575
+
576
+ const dash = cssPx(el, "--map-boundary-dash", 8);
577
+ const style = new Style({
578
+ fill: new Fill({
579
+ color: cssVar(el, "--map-boundary-fill", "rgba(255,107,53,0.06)"),
580
+ }),
581
+ stroke: new Stroke({
582
+ color: cssVar(el, "--map-boundary-stroke", "#ff6b35"),
583
+ width: cssPx(el, "--map-boundary-stroke-width", 2),
584
+ lineDash: [dash, dash * 0.75],
585
+ }),
586
+ });
587
+
588
+ const features = rings.map(
589
+ (ring) =>
590
+ new Feature({
591
+ geometry: new Polygon([ring.map((c) => fromLonLat(c))]),
592
+ }),
593
+ );
594
+
595
+ return new VectorLayer({
596
+ source: new VectorSource({ features }),
597
+ style,
598
+ });
599
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -514,7 +514,7 @@
514
514
  POPOVER
515
515
  ═══════════════════════════════════════════════ */
516
516
 
517
- --popover-bg: var(--raw-color-white);
517
+ --popover-bg: var(--color-surface-raised);
518
518
  --popover-border: var(--elevation-border-strong);
519
519
  --popover-shadow: var(--elevation-overlay);
520
520
  --popover-radius: var(--radius-md);
@@ -797,6 +797,12 @@
797
797
  --map-polygon-stroke: var(--color-accent);
798
798
  --map-polygon-stroke-width: var(--border-width-thick);
799
799
 
800
+ /* Boundary overlay (#187 — tenant geofence context, not content) */
801
+ --map-boundary-fill: color-mix(in srgb, var(--color-accent) 8%, transparent);
802
+ --map-boundary-stroke: var(--map-polygon-stroke);
803
+ --map-boundary-stroke-width: var(--map-polygon-stroke-width);
804
+ --map-boundary-dash: var(--space-sm);
805
+
800
806
  /* Heatmap (accent-derived sequential ramp — follows theme) */
801
807
  --map-heatmap-stop-1: var(--color-accent-subtle);
802
808
  --map-heatmap-stop-2: color-mix(
@@ -18,6 +18,9 @@
18
18
  --color-surface: var(--raw-color-neutral-50);
19
19
  --color-surface-secondary: var(--raw-color-neutral-100);
20
20
  --color-surface-tertiary: var(--raw-color-neutral-200);
21
+ /* Raised surface — popovers/menus that float ABOVE the page surface
22
+ (lighter than the page in light scheme, lighter-than-base in dark). */
23
+ --color-surface-raised: var(--raw-color-white);
21
24
  --color-overlay: rgba(44, 40, 37, 0.5);
22
25
 
23
26
  /* Borders */
@@ -216,6 +219,71 @@
216
219
  --content-padding-y: var(--space-lg);
217
220
  }
218
221
 
222
+ /* ═══════════════════════════════════════════════
223
+ DARK SCHEME — `<html data-scheme="dark">`
224
+ ═══════════════════════════════════════════════
225
+
226
+ One generic layer over the NEUTRAL-derived roles (surfaces, borders,
227
+ text, overlay). Brand hues survive untouched — the accent stays the
228
+ theme's accent — while every `-subtle` wash is re-derived from its
229
+ live hue via color-mix, so a tenant theme's accent gets a correct
230
+ dark wash for free. Per-theme bespoke dark = a `[data-theme="x"]`
231
+ block in the theme file, not here.
232
+
233
+ Selector is `:root[data-scheme="dark"]` (0,1,1): it TIES tenant theme
234
+ blocks (`:root[data-theme="x"]`, runtime-injected in <head>) and wins
235
+ on order — DS styles load after the injected theme block. Consumers
236
+ resolving an "auto" preference must set the RESOLVED value on <html>
237
+ (a pre-paint `prefers-color-scheme` read), never "auto" itself. */
238
+
239
+ :root[data-scheme="dark"] {
240
+ color-scheme: dark;
241
+
242
+ /* Surfaces — darkest at the base, lighter as elevation rises */
243
+ --color-surface: var(--raw-color-neutral-950);
244
+ --color-surface-secondary: var(--raw-color-neutral-900);
245
+ --color-surface-tertiary: var(--raw-color-neutral-800);
246
+ --color-surface-raised: var(--raw-color-neutral-900);
247
+ --color-overlay: rgba(0, 0, 0, 0.65);
248
+
249
+ /* Borders */
250
+ --color-border: var(--raw-color-neutral-800);
251
+ --color-border-strong: var(--raw-color-neutral-700);
252
+
253
+ /* Text — warm off-whites off the same neutral ramp */
254
+ --color-text: var(--raw-color-neutral-100);
255
+ --color-text-secondary: var(--raw-color-neutral-400);
256
+ --color-text-muted: var(--raw-color-neutral-500);
257
+
258
+ /* Subtle washes — derived from the LIVE hue (theme-set or default),
259
+ mixed into the dark surface instead of the light-only raw tints. */
260
+ --color-accent-subtle: color-mix(
261
+ in srgb,
262
+ var(--color-accent) 16%,
263
+ var(--raw-color-neutral-950)
264
+ );
265
+ --color-destructive-subtle: color-mix(
266
+ in srgb,
267
+ var(--color-destructive) 14%,
268
+ var(--raw-color-neutral-950)
269
+ );
270
+ --color-success-subtle: color-mix(
271
+ in srgb,
272
+ var(--color-success) 14%,
273
+ var(--raw-color-neutral-950)
274
+ );
275
+ --color-warning-subtle: color-mix(
276
+ in srgb,
277
+ var(--color-warning) 14%,
278
+ var(--raw-color-neutral-950)
279
+ );
280
+ --color-info-subtle: color-mix(
281
+ in srgb,
282
+ var(--color-info) 14%,
283
+ var(--raw-color-neutral-950)
284
+ );
285
+ }
286
+
219
287
  /* ─── Tablet (768px+) ─── */
220
288
  @media (min-width: 768px) {
221
289
  :root {