@aiaiai-pt/design-system 0.14.0 → 0.16.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>;
@@ -24,11 +24,18 @@
24
24
  <span>Clickable row</span>
25
25
  {/snippet}
26
26
  </ListItem>
27
+
28
+ @example Link row (navigation — renders an <a>, like Button/Card href)
29
+ <ListItem href="/services/potholes">
30
+ {#snippet leading()}<span>Report a pothole</span>{/snippet}
31
+ </ListItem>
27
32
  -->
28
33
  <script>
29
34
  let {
30
35
  /** @type {boolean} */
31
36
  interactive = false,
37
+ /** @type {string | undefined} — renders an <a> when set (navigation row) */
38
+ href = undefined,
32
39
  /** @type {string} */
33
40
  class: className = '',
34
41
  /** @type {import('svelte').Snippet | undefined} */
@@ -56,7 +63,16 @@
56
63
  {/if}
57
64
  {/snippet}
58
65
 
59
- {#if interactive}
66
+ {#if href}
67
+ <a
68
+ {href}
69
+ class="list-item list-item-interactive list-item-link {className}"
70
+ role="listitem"
71
+ {...rest}
72
+ >
73
+ {@render body()}
74
+ </a>
75
+ {:else if interactive}
60
76
  <button
61
77
  class="list-item list-item-interactive {className}"
62
78
  role="listitem"
@@ -75,6 +91,12 @@
75
91
  {/if}
76
92
 
77
93
  <style>
94
+ /* Anchor reset — a link row reads as a row, not as link text. */
95
+ .list-item-link {
96
+ text-decoration: none;
97
+ color: inherit;
98
+ }
99
+
78
100
  .list-item {
79
101
  display: flex;
80
102
  align-items: center;
@@ -29,9 +29,15 @@ type ListItem = {
29
29
  * <span>Clickable row</span>
30
30
  * {/snippet}
31
31
  * </ListItem>
32
+ *
33
+ * @example Link row (navigation — renders an <a>, like Button/Card href)
34
+ * <ListItem href="/services/potholes">
35
+ * {#snippet leading()}<span>Report a pothole</span>{/snippet}
36
+ * </ListItem>
32
37
  */
33
38
  declare const ListItem: import("svelte").Component<{
34
39
  interactive?: boolean;
40
+ href?: any;
35
41
  class?: string;
36
42
  leading?: any;
37
43
  trailing?: any;
@@ -39,6 +45,7 @@ declare const ListItem: import("svelte").Component<{
39
45
  } & Record<string, any>, {}, "">;
40
46
  type $$ComponentProps = {
41
47
  interactive?: boolean;
48
+ href?: any;
42
49
  class?: string;
43
50
  leading?: any;
44
51
  trailing?: 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>;
@@ -52,6 +52,10 @@
52
52
  tileSource = { type: 'osm' },
53
53
  /** @type {((coords: [number, number] | number[][]) => void) | undefined} */
54
54
  onchange = undefined,
55
+ /** @type {((displayName: string) => void) | undefined} — the resolved
56
+ * address whenever the pin settles (search pick or reverse geocode of a
57
+ * map click). Feed it to an address field; coords stay `onchange`. */
58
+ onaddress = undefined,
55
59
  /** @type {string} */
56
60
  height = '100%',
57
61
  /** @type {string | undefined} */
@@ -234,6 +238,7 @@
234
238
  providerUrl={searchProviderUrl}
235
239
  viewbox={searchViewbox}
236
240
  onlocation={handleGeoLocation}
241
+ onresolved={onaddress}
237
242
  bind:coords={searchCoords}
238
243
  size="sm"
239
244
  />
@@ -35,6 +35,7 @@ declare const MapPicker: import("svelte").Component<{
35
35
  searchViewbox?: any;
36
36
  tileSource?: Record<string, any>;
37
37
  onchange?: any;
38
+ onaddress?: any;
38
39
  height?: string;
39
40
  id?: any;
40
41
  class?: string;
@@ -53,6 +54,7 @@ type $$ComponentProps = {
53
54
  searchViewbox?: any;
54
55
  tileSource?: Record<string, any>;
55
56
  onchange?: any;
57
+ onaddress?: any;
56
58
  height?: string;
57
59
  id?: any;
58
60
  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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.14.0",
3
+ "version": "0.16.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);
@@ -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 {