@aiaiai-pt/design-system 0.18.0 → 0.20.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.
@@ -0,0 +1,111 @@
1
+ <!--
2
+ @component ActionPanel
3
+
4
+ A config-driven vertical stack of detail-page call-to-action buttons —
5
+ vote / support / comment / acknowledge / reserve … Vertical-agnostic: every
6
+ CTA is data ({ label, href, icon?, variant?, disabled?, reason? }).
7
+
8
+ Presentational only — the consumer decides each CTA's enabled/disabled state
9
+ and the reason it's closed (grant or row-state). An ENABLED CTA renders as a
10
+ link (it navigates to the action form); a CLOSED CTA renders as a disabled
11
+ control with its reason as VISIBLE, programmatically-associated helper text
12
+ (`aria-describedby`) — never a dead button. Decorative icons are aria-hidden.
13
+
14
+ Consumes semantic tokens (`--space-*`, `--color-*`) so dark / high-contrast
15
+ schemes (#244) ride through without per-component token entries.
16
+
17
+ @example
18
+ <ActionPanel actions={[
19
+ { key: 'vote', label: 'Vote for this proposal', href: '/submit/vote', icon: ThumbsUpIcon },
20
+ { key: 'support', label: 'Support', disabled: true, reason: 'Voting has closed.' },
21
+ ]} />
22
+ -->
23
+ <script module>
24
+ let _actionPanelUid = 0;
25
+ /**
26
+ * @typedef {{
27
+ * key?: string,
28
+ * label: string,
29
+ * href?: string,
30
+ * icon?: import('svelte').Component,
31
+ * variant?: 'primary' | 'secondary' | 'ghost' | 'destructive',
32
+ * disabled?: boolean,
33
+ * reason?: string,
34
+ * }} ActionPanelItem
35
+ */
36
+ </script>
37
+
38
+ <script>
39
+ import Button from './Button.svelte';
40
+
41
+ let {
42
+ /** @type {ActionPanelItem[]} The CTAs to render, in order. */
43
+ actions = [],
44
+ /** @type {string} */
45
+ class: className = '',
46
+ ...rest
47
+ } = $props();
48
+
49
+ const uid = `action-panel-${_actionPanelUid++}`;
50
+ </script>
51
+
52
+ {#if actions.length > 0}
53
+ <div class="action-panel {className}" {...rest}>
54
+ {#each actions as action, i (action.key ?? i)}
55
+ {@const Icon = action.icon}
56
+ {@const isClosed = action.disabled === true || !action.href}
57
+ {@const reasonId =
58
+ action.disabled === true && action.reason ? `${uid}-reason-${i}` : undefined}
59
+ <div class="action-panel-item">
60
+ <Button
61
+ variant={action.variant ?? 'primary'}
62
+ href={isClosed ? undefined : action.href}
63
+ disabled={isClosed}
64
+ class="action-panel-btn"
65
+ aria-describedby={reasonId}
66
+ >
67
+ {#if Icon}<span class="action-panel-icon" aria-hidden="true"><Icon /></span>{/if}
68
+ {action.label}
69
+ </Button>
70
+ {#if action.disabled === true && action.reason}
71
+ <p class="action-panel-reason" id={reasonId}>{action.reason}</p>
72
+ {/if}
73
+ </div>
74
+ {/each}
75
+ </div>
76
+ {/if}
77
+
78
+ <style>
79
+ .action-panel {
80
+ display: flex;
81
+ flex-direction: column;
82
+ gap: var(--space-sm);
83
+ }
84
+
85
+ .action-panel-item {
86
+ display: flex;
87
+ flex-direction: column;
88
+ gap: var(--space-2xs);
89
+ }
90
+
91
+ /* Ficha CTAs read as a stacked column — each button spans the panel. */
92
+ .action-panel :global(.action-panel-btn) {
93
+ width: 100%;
94
+ }
95
+
96
+ .action-panel-icon {
97
+ display: inline-flex;
98
+ align-items: center;
99
+ }
100
+
101
+ .action-panel-icon :global(svg) {
102
+ width: var(--icon-size-sm);
103
+ height: var(--icon-size-sm);
104
+ }
105
+
106
+ .action-panel-reason {
107
+ margin: 0;
108
+ font-size: var(--type-body-sm-size);
109
+ color: var(--color-text-secondary);
110
+ }
111
+ </style>
@@ -0,0 +1,44 @@
1
+ export default ActionPanel;
2
+ export type ActionPanelItem = {
3
+ key?: string;
4
+ label: string;
5
+ href?: string;
6
+ icon?: import("svelte").Component;
7
+ variant?: "primary" | "secondary" | "ghost" | "destructive";
8
+ disabled?: boolean;
9
+ reason?: string;
10
+ };
11
+ type ActionPanel = {
12
+ $on?(type: string, callback: (e: any) => void): () => void;
13
+ $set?(props: Partial<$$ComponentProps>): void;
14
+ };
15
+ /**
16
+ * ActionPanel
17
+ *
18
+ * A config-driven vertical stack of detail-page call-to-action buttons —
19
+ * vote / support / comment / acknowledge / reserve … Vertical-agnostic: every
20
+ * CTA is data ({ label, href, icon?, variant?, disabled?, reason? }).
21
+ *
22
+ * Presentational only — the consumer decides each CTA's enabled/disabled state
23
+ * and the reason it's closed (grant or row-state). An ENABLED CTA renders as a
24
+ * link (it navigates to the action form); a CLOSED CTA renders as a disabled
25
+ * control with its reason as VISIBLE, programmatically-associated helper text
26
+ * (`aria-describedby`) — never a dead button. Decorative icons are aria-hidden.
27
+ *
28
+ * Consumes semantic tokens (`--space-*`, `--color-*`) so dark / high-contrast
29
+ * schemes (#244) ride through without per-component token entries.
30
+ *
31
+ * @example
32
+ * <ActionPanel actions={[
33
+ * { key: 'vote', label: 'Vote for this proposal', href: '/submit/vote', icon: ThumbsUpIcon },
34
+ * { key: 'support', label: 'Support', disabled: true, reason: 'Voting has closed.' },
35
+ * ]} />
36
+ */
37
+ declare const ActionPanel: import("svelte").Component<{
38
+ actions?: any[];
39
+ class?: string;
40
+ } & Record<string, any>, {}, "">;
41
+ type $$ComponentProps = {
42
+ actions?: any[];
43
+ class?: string;
44
+ } & Record<string, any>;
@@ -0,0 +1,190 @@
1
+ <!--
2
+ @component FeedView
3
+
4
+ A segmented view-switch for one feed (e.g. list ⇄ map). Presentational only:
5
+ it renders the available views as a WAI-ARIA tablist and emits the chosen
6
+ view via `onchange` — it does NOT render the views' content (the consumer
7
+ swaps that in response to the change, typically by re-resolving the feed).
8
+
9
+ Keyboard (automatic activation, per the tablist pattern for cheap-to-reveal
10
+ views): Arrow keys move focus AND select; Home/End jump to the ends; focus
11
+ roves (active tab is the only tab stop).
12
+
13
+ Consumes --feedview-* tokens from components.css.
14
+
15
+ @example
16
+ <FeedView
17
+ views={[
18
+ { value: 'list', label: 'List' },
19
+ { value: 'map', label: 'Map' },
20
+ ]}
21
+ bind:value={activeView}
22
+ onchange={(v) => goto(`?view=${v}`)}
23
+ />
24
+
25
+ @example With icons (icon is a Svelte component, e.g. phosphor-svelte)
26
+ <FeedView views={[
27
+ { value: 'list', label: 'List', icon: ListIcon },
28
+ { value: 'map', label: 'Map', icon: MapIcon },
29
+ ]} bind:value={activeView} onchange={onChange} />
30
+ -->
31
+ <script module>
32
+ let _feedviewUid = 0;
33
+ /**
34
+ * @typedef {{ value: string, label: string, icon?: import('svelte').Component }} FeedViewOption
35
+ */
36
+ </script>
37
+
38
+ <script>
39
+ let {
40
+ /** @type {FeedViewOption[]} The available views (first = default). */
41
+ views = [],
42
+ /** @type {string} The active view's value (bindable). */
43
+ value = $bindable(''),
44
+ /** @type {((value: string) => void) | undefined} Fires when the view changes. */
45
+ onchange = undefined,
46
+ /** @type {string} Accessible name for the tablist. */
47
+ label = 'View',
48
+ /** @type {string} */
49
+ class: className = '',
50
+ ...rest
51
+ } = $props();
52
+
53
+ const uid = `feedview-${_feedviewUid++}`;
54
+ /** @type {HTMLButtonElement[]} */
55
+ let tabs = $state([]);
56
+
57
+ const activeIndex = $derived(Math.max(0, views.findIndex((v) => v.value === value)));
58
+
59
+ /** @param {string} v */
60
+ function select(v) {
61
+ if (v === value) return;
62
+ value = v;
63
+ onchange?.(v);
64
+ }
65
+
66
+ /**
67
+ * @param {number} index
68
+ * @param {boolean} focus
69
+ */
70
+ function activate(index, focus) {
71
+ const view = views[index];
72
+ if (!view) return;
73
+ if (focus) tabs[index]?.focus();
74
+ select(view.value);
75
+ }
76
+
77
+ /** @param {KeyboardEvent} event */
78
+ function onKeydown(event) {
79
+ const last = views.length - 1;
80
+ let next = -1;
81
+ switch (event.key) {
82
+ case 'ArrowRight':
83
+ case 'ArrowDown':
84
+ next = activeIndex >= last ? 0 : activeIndex + 1;
85
+ break;
86
+ case 'ArrowLeft':
87
+ case 'ArrowUp':
88
+ next = activeIndex <= 0 ? last : activeIndex - 1;
89
+ break;
90
+ case 'Home':
91
+ next = 0;
92
+ break;
93
+ case 'End':
94
+ next = last;
95
+ break;
96
+ default:
97
+ return;
98
+ }
99
+ event.preventDefault();
100
+ activate(next, true);
101
+ }
102
+ </script>
103
+
104
+ <div
105
+ class="feedview {className}"
106
+ role="tablist"
107
+ aria-label={label}
108
+ onkeydown={onKeydown}
109
+ {...rest}
110
+ >
111
+ {#each views as view, i (view.value)}
112
+ {@const Icon = view.icon}
113
+ {@const isActive = view.value === value}
114
+ <button
115
+ type="button"
116
+ role="tab"
117
+ id={`${uid}-${view.value}`}
118
+ aria-selected={isActive}
119
+ tabindex={isActive ? 0 : -1}
120
+ class="feedview-tab"
121
+ class:feedview-tab-active={isActive}
122
+ bind:this={tabs[i]}
123
+ onclick={() => activate(i, false)}
124
+ >
125
+ {#if Icon}<span class="feedview-icon" aria-hidden="true"><Icon /></span>{/if}
126
+ <span class="feedview-label">{view.label}</span>
127
+ </button>
128
+ {/each}
129
+ </div>
130
+
131
+ <style>
132
+ .feedview {
133
+ display: inline-flex;
134
+ align-items: center;
135
+ gap: var(--space-2xs);
136
+ background: var(--feedview-bg);
137
+ border-radius: var(--feedview-radius);
138
+ padding: var(--feedview-padding);
139
+ }
140
+
141
+ .feedview-tab {
142
+ all: unset;
143
+ box-sizing: border-box;
144
+ display: inline-flex;
145
+ align-items: center;
146
+ justify-content: center;
147
+ gap: var(--space-xs);
148
+ height: var(--feedview-tab-height);
149
+ padding: 0 var(--feedview-tab-padding-x);
150
+ border-radius: var(--feedview-tab-radius);
151
+ font-family: var(--feedview-tab-font);
152
+ font-size: var(--feedview-tab-size);
153
+ letter-spacing: var(--feedview-tab-tracking);
154
+ color: var(--feedview-tab-color);
155
+ cursor: pointer;
156
+ white-space: nowrap;
157
+ transition: all var(--feedview-tab-transition);
158
+ }
159
+
160
+ .feedview-tab:hover {
161
+ color: var(--color-text);
162
+ }
163
+
164
+ .feedview-tab:focus-visible {
165
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
166
+ outline-offset: var(--focus-ring-offset);
167
+ }
168
+
169
+ .feedview-tab-active {
170
+ color: var(--feedview-tab-color-active);
171
+ background: var(--feedview-tab-bg-active);
172
+ box-shadow: var(--feedview-tab-shadow-active);
173
+ }
174
+
175
+ .feedview-icon {
176
+ display: inline-flex;
177
+ align-items: center;
178
+ }
179
+
180
+ .feedview-icon :global(svg) {
181
+ width: var(--icon-size-sm);
182
+ height: var(--icon-size-sm);
183
+ }
184
+
185
+ @media (prefers-reduced-motion: reduce) {
186
+ .feedview-tab {
187
+ transition: none;
188
+ }
189
+ }
190
+ </style>
@@ -0,0 +1,54 @@
1
+ export default FeedView;
2
+ export type FeedViewOption = {
3
+ value: string;
4
+ label: string;
5
+ icon?: import("svelte").Component;
6
+ };
7
+ type FeedView = {
8
+ $on?(type: string, callback: (e: any) => void): () => void;
9
+ $set?(props: Partial<$$ComponentProps>): void;
10
+ };
11
+ /**
12
+ * FeedView
13
+ *
14
+ * A segmented view-switch for one feed (e.g. list ⇄ map). Presentational only:
15
+ * it renders the available views as a WAI-ARIA tablist and emits the chosen
16
+ * view via `onchange` — it does NOT render the views' content (the consumer
17
+ * swaps that in response to the change, typically by re-resolving the feed).
18
+ *
19
+ * Keyboard (automatic activation, per the tablist pattern for cheap-to-reveal
20
+ * views): Arrow keys move focus AND select; Home/End jump to the ends; focus
21
+ * roves (active tab is the only tab stop).
22
+ *
23
+ * Consumes --feedview-* tokens from components.css.
24
+ *
25
+ * @example
26
+ * <FeedView
27
+ * views={[
28
+ * { value: 'list', label: 'List' },
29
+ * { value: 'map', label: 'Map' },
30
+ * ]}
31
+ * bind:value={activeView}
32
+ * onchange={(v) => goto(`?view=${v}`)}
33
+ * />
34
+ *
35
+ * @example With icons (icon is a Svelte component, e.g. phosphor-svelte)
36
+ * <FeedView views={[
37
+ * { value: 'list', label: 'List', icon: ListIcon },
38
+ * { value: 'map', label: 'Map', icon: MapIcon },
39
+ * ]} bind:value={activeView} onchange={onChange} />
40
+ */
41
+ declare const FeedView: import("svelte").Component<{
42
+ views?: any[];
43
+ value?: string;
44
+ onchange?: any;
45
+ label?: string;
46
+ class?: string;
47
+ } & Record<string, any>, {}, "value">;
48
+ type $$ComponentProps = {
49
+ views?: any[];
50
+ value?: string;
51
+ onchange?: any;
52
+ label?: string;
53
+ class?: string;
54
+ } & Record<string, any>;
@@ -1,49 +1,90 @@
1
1
  <!--
2
2
  @component FilterBar
3
3
 
4
- Horizontal bar of filter chips with active-filter state and clear-all action.
5
- Accepts a declarative config of filters or renders children directly.
6
- Consumes --filter-chip-* and --filter-bar-* tokens from components.css.
4
+ Filter controls over a feed. Two modes:
7
5
 
8
- @example Declarative (common case)
6
+ **Facet mode** (set `facets` and/or `searchable`) — a row of labelled
7
+ dropdowns + an optional search box, the shape a browse/list/map surface
8
+ uses for its declared facets. Options are supplied by the consumer (it
9
+ owns the data source); FilterBar is presentational. A misconfigured /
10
+ undeclared facet is surfaced LOUDLY via `error` — never a silent dead
11
+ control. Consumes --filter-bar-* tokens.
12
+
13
+ • **Chip mode** (default — `filters` and/or `children`) — horizontal bar of
14
+ filter chips with active-filter state and clear-all. Consumes
15
+ --filter-chip-* and --filter-bar-* tokens.
16
+
17
+ @example Facet mode (browse/list/map)
18
+ <FilterBar
19
+ facets={[
20
+ { key: 'status', label: 'Status', value: '', options: [
21
+ { value: '', label: 'All' },
22
+ { value: 'open', label: 'Open' },
23
+ ]},
24
+ ]}
25
+ searchable
26
+ searchPlaceholder="Search"
27
+ onfacetchange={(key, value) => refetch(key, value)}
28
+ onsearch={(term) => refetch('search', term)}
29
+ />
30
+
31
+ @example Chip mode (declarative)
9
32
  <FilterBar
10
33
  filters={[
11
34
  { key: 'status', label: 'Status', type: 'toggle', options: [
12
35
  { value: 'active', label: 'Active' },
13
36
  { value: 'draft', label: 'Draft' },
14
- { value: 'error', label: 'Error' },
15
37
  ]},
16
38
  ]}
17
39
  bind:value={activeFilters}
18
40
  onchange={handleFilterChange}
19
41
  />
20
-
21
- @example Composable (custom rendering)
22
- <FilterBar bind:value={activeFilters} onchange={handleFilterChange}>
23
- {#snippet children(toggle, isActive)}
24
- <FilterBar.Chip active={isActive('status', 'active')} onclick={() => toggle('status', 'active')}>
25
- Active
26
- </FilterBar.Chip>
27
- {/snippet}
28
- </FilterBar>
29
42
  -->
30
43
  <script module>
31
44
  /**
32
45
  * @typedef {{ value: string, label: string, icon?: import('svelte').Component }} FilterOption
33
46
  * @typedef {{ key: string, label: string, type: 'toggle' | 'select' | 'multi-select', options?: FilterOption[], defaultValue?: any }} FilterDef
47
+ * @typedef {{ value: string, label: string, disabled?: boolean }} FacetOption
48
+ * @typedef {{ key: string, label: string, value?: string, options: FacetOption[] }} FacetControl
34
49
  */
35
50
  </script>
36
51
 
37
52
  <script>
53
+ import Select from './Select.svelte';
54
+ import SearchInput from './SearchInput.svelte';
55
+ import Alert from './Alert.svelte';
56
+
38
57
  let {
39
- /** @type {FilterDef[]} Declarative filter definitions */
58
+ // ─── Facet mode ───
59
+ /** @type {FacetControl[]} Labelled dropdown controls (facet mode). */
60
+ facets = [],
61
+ /** @type {boolean} Render a search box (facet mode). */
62
+ searchable = false,
63
+ /** @type {string} */
64
+ searchPlaceholder = 'Search',
65
+ /** @type {string} */
66
+ searchValue = '',
67
+ /** @type {boolean} Spinner on the search box / aria-busy on the bar. */
68
+ busy = false,
69
+ /** @type {string | undefined} Loud-fail message for misconfigured facets. */
70
+ error = undefined,
71
+ /** @type {((key: string, value: string) => void) | undefined} */
72
+ onfacetchange = undefined,
73
+ /** @type {((value: string) => void) | undefined} */
74
+ onsearch = undefined,
75
+ /** @type {(() => void) | undefined} Fires when the search box is cleared. */
76
+ onsearchclear = undefined,
77
+
78
+ // ─── Chip mode ───
79
+ /** @type {FilterDef[]} Declarative chip definitions */
40
80
  filters = [],
41
- /** @type {Record<string, any>} Active filter values (bindable) */
81
+ /** @type {Record<string, any>} Active chip values (bindable) */
42
82
  value = $bindable({}),
43
- /** @type {((value: Record<string, any>) => void) | undefined} Fires when any filter changes */
83
+ /** @type {((value: Record<string, any>) => void) | undefined} Fires when any chip changes */
44
84
  onchange = undefined,
45
85
  /** @type {(() => void) | undefined} Fires when "Clear all" is clicked */
46
86
  onclear = undefined,
87
+
47
88
  /** @type {string} */
48
89
  class: className = '',
49
90
  /** @type {import('svelte').Snippet | undefined} */
@@ -51,6 +92,10 @@
51
92
  ...rest
52
93
  } = $props();
53
94
 
95
+ // Facet mode wins whenever facet controls or search are configured; chip
96
+ // mode is the default (back-compat with the original FilterBar contract).
97
+ const isFacetMode = $derived(facets.length > 0 || searchable);
98
+
54
99
  const activeCount = $derived(
55
100
  Object.values(value).filter(v => {
56
101
  if (Array.isArray(v)) return v.length > 0;
@@ -61,7 +106,7 @@
61
106
  const showClearAll = $derived(activeCount >= 2);
62
107
 
63
108
  /**
64
- * Toggle a filter value
109
+ * Toggle a filter value (chip mode)
65
110
  * @param {string} key
66
111
  * @param {string} optionValue
67
112
  */
@@ -90,7 +135,7 @@
90
135
  }
91
136
 
92
137
  /**
93
- * Check if a filter option is active
138
+ * Check if a filter option is active (chip mode)
94
139
  * @param {string} key
95
140
  * @param {string} optionValue
96
141
  * @returns {boolean}
@@ -106,48 +151,82 @@
106
151
  onchange?.({});
107
152
  onclear?.();
108
153
  }
109
-
110
154
  </script>
111
155
 
112
- <div
113
- class="filter-bar {className}"
114
- role="group"
115
- aria-label="Filters"
116
- {...rest}
117
- >
118
- {#if children}
119
- {@render children(toggle, isActive)}
120
- {:else}
121
- <div class="filter-bar-chips">
122
- {#each filters as filter}
123
- {#if filter.options}
124
- {#each filter.options as option}
125
- {@const active = isActive(filter.key, option.value)}
126
- <button
127
- class="filter-chip"
128
- class:filter-chip-active={active}
129
- onclick={() => toggle(filter.key, option.value)}
130
- aria-pressed={active}
131
- type="button"
132
- >
133
- {option.label}
134
- </button>
135
- {/each}
136
- {/if}
137
- {/each}
138
- </div>
139
-
140
- {#if showClearAll}
141
- <button
142
- class="filter-bar-clear"
143
- onclick={clearAll}
144
- type="button"
145
- >
146
- Clear all
147
- </button>
148
- {/if}
156
+ {#if isFacetMode}
157
+ <!-- Loud-fail (never a silent dead control): a configured facet the surface
158
+ does not declare can't filter — surface it as an operator fix. -->
159
+ {#if error}
160
+ <Alert variant="error" data-testid="filter-bar-error" class="filter-bar-alert">
161
+ {error}
162
+ </Alert>
149
163
  {/if}
150
- </div>
164
+ <div
165
+ class="filter-bar filter-bar-facets {className}"
166
+ role="group"
167
+ aria-label="Filters"
168
+ aria-busy={busy}
169
+ {...rest}
170
+ >
171
+ {#if searchable}
172
+ <SearchInput
173
+ placeholder={searchPlaceholder}
174
+ value={searchValue}
175
+ loading={busy}
176
+ onsearch={(v) => onsearch?.((v ?? '').trim())}
177
+ onclear={() => onsearchclear?.()}
178
+ />
179
+ {/if}
180
+ {#each facets as facet (facet.key)}
181
+ <Select
182
+ label={facet.label}
183
+ options={facet.options}
184
+ value={facet.value ?? ''}
185
+ onchange={(v) => onfacetchange?.(facet.key, v)}
186
+ />
187
+ {/each}
188
+ </div>
189
+ {:else}
190
+ <div
191
+ class="filter-bar {className}"
192
+ role="group"
193
+ aria-label="Filters"
194
+ {...rest}
195
+ >
196
+ {#if children}
197
+ {@render children(toggle, isActive)}
198
+ {:else}
199
+ <div class="filter-bar-chips">
200
+ {#each filters as filter}
201
+ {#if filter.options}
202
+ {#each filter.options as option}
203
+ {@const active = isActive(filter.key, option.value)}
204
+ <button
205
+ class="filter-chip"
206
+ class:filter-chip-active={active}
207
+ onclick={() => toggle(filter.key, option.value)}
208
+ aria-pressed={active}
209
+ type="button"
210
+ >
211
+ {option.label}
212
+ </button>
213
+ {/each}
214
+ {/if}
215
+ {/each}
216
+ </div>
217
+
218
+ {#if showClearAll}
219
+ <button
220
+ class="filter-bar-clear"
221
+ onclick={clearAll}
222
+ type="button"
223
+ >
224
+ Clear all
225
+ </button>
226
+ {/if}
227
+ {/if}
228
+ </div>
229
+ {/if}
151
230
 
152
231
  <style>
153
232
  .filter-bar {
@@ -157,6 +236,16 @@
157
236
  flex-wrap: wrap;
158
237
  }
159
238
 
239
+ /* Facet mode: labelled dropdowns + search align to their baselines. */
240
+ .filter-bar-facets {
241
+ align-items: flex-end;
242
+ gap: var(--space-sm);
243
+ }
244
+
245
+ .filter-bar-alert {
246
+ margin-bottom: var(--space-sm);
247
+ }
248
+
160
249
  .filter-bar-chips {
161
250
  display: flex;
162
251
  align-items: center;
@@ -11,6 +11,17 @@ export type FilterDef = {
11
11
  options?: FilterOption[];
12
12
  defaultValue?: any;
13
13
  };
14
+ export type FacetOption = {
15
+ value: string;
16
+ label: string;
17
+ disabled?: boolean;
18
+ };
19
+ export type FacetControl = {
20
+ key: string;
21
+ label: string;
22
+ value?: string;
23
+ options: FacetOption[];
24
+ };
14
25
  type FilterBar = {
15
26
  $on?(type: string, callback: (e: any) => void): () => void;
16
27
  $set?(props: Partial<$$ComponentProps>): void;
@@ -18,33 +29,55 @@ type FilterBar = {
18
29
  /**
19
30
  * FilterBar
20
31
  *
21
- * Horizontal bar of filter chips with active-filter state and clear-all action.
22
- * Accepts a declarative config of filters or renders children directly.
23
- * Consumes --filter-chip-* and --filter-bar-* tokens from components.css.
32
+ * Filter controls over a feed. Two modes:
33
+ *
34
+ * **Facet mode** (set `facets` and/or `searchable`) a row of labelled
35
+ * dropdowns + an optional search box, the shape a browse/list/map surface
36
+ * uses for its declared facets. Options are supplied by the consumer (it
37
+ * owns the data source); FilterBar is presentational. A misconfigured /
38
+ * undeclared facet is surfaced LOUDLY via `error` — never a silent dead
39
+ * control. Consumes --filter-bar-* tokens.
24
40
  *
25
- * @example Declarative (common case)
41
+ * **Chip mode** (default — `filters` and/or `children`) — horizontal bar of
42
+ * filter chips with active-filter state and clear-all. Consumes
43
+ * --filter-chip-* and --filter-bar-* tokens.
44
+ *
45
+ * @example Facet mode (browse/list/map)
46
+ * <FilterBar
47
+ * facets={[
48
+ * { key: 'status', label: 'Status', value: '', options: [
49
+ * { value: '', label: 'All' },
50
+ * { value: 'open', label: 'Open' },
51
+ * ]},
52
+ * ]}
53
+ * searchable
54
+ * searchPlaceholder="Search"
55
+ * onfacetchange={(key, value) => refetch(key, value)}
56
+ * onsearch={(term) => refetch('search', term)}
57
+ * />
58
+ *
59
+ * @example Chip mode (declarative)
26
60
  * <FilterBar
27
61
  * filters={[
28
62
  * { key: 'status', label: 'Status', type: 'toggle', options: [
29
63
  * { value: 'active', label: 'Active' },
30
64
  * { value: 'draft', label: 'Draft' },
31
- * { value: 'error', label: 'Error' },
32
65
  * ]},
33
66
  * ]}
34
67
  * bind:value={activeFilters}
35
68
  * onchange={handleFilterChange}
36
69
  * />
37
- *
38
- * @example Composable (custom rendering)
39
- * <FilterBar bind:value={activeFilters} onchange={handleFilterChange}>
40
- * {#snippet children(toggle, isActive)}
41
- * <FilterBar.Chip active={isActive('status', 'active')} onclick={() => toggle('status', 'active')}>
42
- * Active
43
- * </FilterBar.Chip>
44
- * {/snippet}
45
- * </FilterBar>
46
70
  */
47
71
  declare const FilterBar: import("svelte").Component<{
72
+ facets?: any[];
73
+ searchable?: boolean;
74
+ searchPlaceholder?: string;
75
+ searchValue?: string;
76
+ busy?: boolean;
77
+ error?: any;
78
+ onfacetchange?: any;
79
+ onsearch?: any;
80
+ onsearchclear?: any;
48
81
  filters?: any[];
49
82
  value?: Record<string, any>;
50
83
  onchange?: any;
@@ -53,6 +86,15 @@ declare const FilterBar: import("svelte").Component<{
53
86
  children?: any;
54
87
  } & Record<string, any>, {}, "value">;
55
88
  type $$ComponentProps = {
89
+ facets?: any[];
90
+ searchable?: boolean;
91
+ searchPlaceholder?: string;
92
+ searchValue?: string;
93
+ busy?: boolean;
94
+ error?: any;
95
+ onfacetchange?: any;
96
+ onsearch?: any;
97
+ onsearchclear?: any;
56
98
  filters?: any[];
57
99
  value?: Record<string, any>;
58
100
  onchange?: any;
@@ -39,6 +39,14 @@
39
39
  layers = [],
40
40
  /** @type {((marker: { id: string, lon: number, lat: number, label?: string }) => void) | undefined} */
41
41
  onclick = undefined,
42
+ /** @type {import('svelte').Snippet<[any, () => void]> | undefined} —
43
+ * click-popup content. When provided, clicking a single marker (or a
44
+ * stacked pile) opens an anchored OL Overlay rendering this snippet with
45
+ * `(markerData, close)` — the "ficha resumo" intermediate step before the
46
+ * consumer's detail link — INSTEAD of firing `onclick`. Omit for the
47
+ * legacy click-to-navigate behaviour. Compose with the DS `MapPopup` for
48
+ * the card chrome (the original `let:popup` contract, now a snippet). */
49
+ popup = undefined,
42
50
  /** @type {string} */
43
51
  height = '100%',
44
52
  /** @type {string} */
@@ -48,6 +56,18 @@
48
56
 
49
57
  /** @type {HTMLElement | undefined} */
50
58
  let container = $state();
59
+ /** @type {HTMLElement | undefined} — the popup overlay's element (Svelte
60
+ * renders the snippet into it; OL positions it). */
61
+ let popupEl = $state();
62
+ /** @type {any} — the marker data of the open popup, or null. */
63
+ let selected = $state(null);
64
+ /** @type {any} — popup OL Overlay ref, set at mount. */
65
+ let _popupOverlay;
66
+
67
+ function closePopup() {
68
+ selected = null;
69
+ _popupOverlay?.setPosition(undefined);
70
+ }
51
71
 
52
72
  // Hoisted references for reactive effects
53
73
  /** @type {import('ol/Map.js').default | undefined} */
@@ -211,6 +231,22 @@
211
231
  stopEvent: false,
212
232
  });
213
233
 
234
+ // Click-popup overlay (the "ficha resumo"): anchored above the marker.
235
+ // `stopEvent: true` (OL default) keeps it in the interactive overlay
236
+ // pane so the link/close button inside the popup snippet are clickable.
237
+ // Its element is the Svelte-rendered `popupEl`; Svelte owns the content,
238
+ // OL owns the position. Only created when a `popup` snippet is supplied.
239
+ const overlays = [tooltipOverlay];
240
+ if (popup && popupEl) {
241
+ _popupOverlay = new Overlay({
242
+ element: popupEl,
243
+ positioning: 'bottom-center',
244
+ offset: [0, -18],
245
+ stopEvent: true,
246
+ });
247
+ overlays.push(_popupOverlay);
248
+ }
249
+
214
250
  const viewCenter = center
215
251
  ? fromLonLat(center)
216
252
  : markers.length > 0
@@ -225,7 +261,7 @@
225
261
  // The `layers` overlays are NOT built here — the reactive owner
226
262
  // effect above inserts them between the tiles and the cluster layer.
227
263
  layers: [tileLayer, clusterLayer],
228
- overlays: [tooltipOverlay],
264
+ overlays,
229
265
  view: new View({
230
266
  center: viewCenter,
231
267
  zoom,
@@ -267,16 +303,32 @@
267
303
  }
268
304
  });
269
305
 
306
+ // `popup` opens the anchored ficha-resumo overlay; otherwise `onclick`
307
+ // navigates (legacy). A single marker (or a stacked pile that can't split
308
+ // by zoom) "selects"; a spread cluster zooms in.
309
+ const selectMarker = (data, coords) => {
310
+ if (!data) return;
311
+ if (popup) {
312
+ selected = data;
313
+ if (coords) _popupOverlay?.setPosition(coords);
314
+ } else {
315
+ onclick?.(data);
316
+ }
317
+ };
318
+
270
319
  // Click handler
271
- if (onclick) {
320
+ if (onclick || popup) {
272
321
  map.on('click', (evt) => {
273
322
  const feature = map?.forEachFeatureAtPixel(evt.pixel, f => f);
274
- if (!feature) return;
323
+ // Clicking empty map dismisses an open popup (no-op without one).
324
+ if (!feature) { closePopup(); return; }
275
325
 
276
326
  const clustered = feature.get('features');
277
327
  if (clustered?.length === 1) {
278
- const data = clustered[0].get('markerData');
279
- if (data) onclick(data);
328
+ selectMarker(
329
+ clustered[0].get('markerData'),
330
+ (/** @type {any} */ (feature.getGeometry()))?.getCoordinates(),
331
+ );
280
332
  } else if (clustered?.length > 1) {
281
333
  // A cluster of (near-)IDENTICAL coordinates can never split by
282
334
  // zooming (stacked reports at one point are common in civic
@@ -286,8 +338,10 @@
286
338
  const lats = coords.map((c) => c[1]);
287
339
  const spread = Math.max(...lons) - Math.min(...lons) + (Math.max(...lats) - Math.min(...lats));
288
340
  if (spread < 1) { // metres in web-mercator units — a stacked pile
289
- const data = clustered[0].get('markerData');
290
- if (data) onclick(data);
341
+ selectMarker(
342
+ clustered[0].get('markerData'),
343
+ (/** @type {any} */ (feature.getGeometry()))?.getCoordinates(),
344
+ );
291
345
  return;
292
346
  }
293
347
  const view = map?.getView();
@@ -307,9 +361,16 @@
307
361
  });
308
362
  } catch (err) { renderMapError(container, 'MapCluster', /** @type {Error} */ (err)); } })();
309
363
 
364
+ // Escape closes an open popup (keyboard dismissal).
365
+ const onKeydown = (/** @type {KeyboardEvent} */ e) => {
366
+ if (e.key === 'Escape') closePopup();
367
+ };
368
+ if (typeof window !== 'undefined') window.addEventListener('keydown', onKeydown);
369
+
310
370
  return () => {
311
371
  disposed = true;
312
372
  disposeTheme?.();
373
+ if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown);
313
374
  map?.setTarget(undefined);
314
375
  };
315
376
  });
@@ -324,6 +385,15 @@
324
385
  {...rest}
325
386
  ></div>
326
387
 
388
+ <!-- Popup overlay element. Always in the DOM (OL moves it into the overlay
389
+ pane + positions it); Svelte owns its content. The snippet receives the
390
+ selected marker + a `close` callback (the original `let:popup` contract). -->
391
+ {#if popup}
392
+ <div bind:this={popupEl} class="map-cluster-popup">
393
+ {#if selected}{@render popup(selected, closePopup)}{/if}
394
+ </div>
395
+ {/if}
396
+
327
397
  <style>
328
398
  .map-cluster {
329
399
  width: 100%;
@@ -336,4 +406,11 @@
336
406
  .map-cluster :global(.ol-viewport) {
337
407
  border-radius: inherit;
338
408
  }
409
+
410
+ /* Overlay element wrapper — OL owns its position (inline transform); this
411
+ just lifts it above the map controls. The card chrome is the consumer's
412
+ MapPopup. */
413
+ .map-cluster-popup {
414
+ z-index: 2;
415
+ }
339
416
  </style>
@@ -0,0 +1,131 @@
1
+ <!--
2
+ @component RecordList
3
+
4
+ A typed list of child records rendered from FIELD CONFIG — the generalisation
5
+ of a comment list into any detail-page child collection (reviews, pareceres,
6
+ occurrence updates, maintenance logs). Vertical-agnostic: `items` is the child
7
+ rows, `fields` declares which keys to show and how.
8
+
9
+ Layout convention (so one component serves every collection): the FIRST field
10
+ is the record's primary line; the rest render as a muted meta line, separated
11
+ by middots. A field may `format` as a date (locale-formatted) or `badge`
12
+ (rendered as a Badge). Soft-empty: no items → nothing; an absent field value
13
+ is skipped (no dangling separators).
14
+
15
+ Consumes semantic tokens so dark / high-contrast schemes (#244) ride through.
16
+
17
+ @example
18
+ <RecordList
19
+ items={updates}
20
+ fields={[
21
+ { key: 'note', label: 'Update' },
22
+ { key: 'author' },
23
+ { key: 'created_at', format: 'date' },
24
+ { key: 'status', format: 'badge' },
25
+ ]}
26
+ locale="pt"
27
+ />
28
+ -->
29
+ <script module>
30
+ /**
31
+ * @typedef {{ key: string, label?: string, format?: 'text' | 'date' | 'badge' }} RecordField
32
+ */
33
+ </script>
34
+
35
+ <script>
36
+ import List from './List.svelte';
37
+ import ListItem from './ListItem.svelte';
38
+ import Badge from './Badge.svelte';
39
+
40
+ let {
41
+ /** @type {Record<string, unknown>[]} The child records to render. */
42
+ items = [],
43
+ /** @type {RecordField[]} The columns; fields[0] is the primary line. */
44
+ fields = [],
45
+ /** @type {string} BCP-47 locale for date formatting. */
46
+ locale = 'en',
47
+ /** @type {string} */
48
+ class: className = '',
49
+ ...rest
50
+ } = $props();
51
+
52
+ /**
53
+ * @param {unknown} value
54
+ * @param {RecordField} field
55
+ * @returns {string}
56
+ */
57
+ function display(value, field) {
58
+ if (value === null || value === undefined || value === '') return '';
59
+ if (field.format === 'date') {
60
+ const d = new Date(String(value));
61
+ return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString(locale);
62
+ }
63
+ return String(value);
64
+ }
65
+
66
+ /**
67
+ * The non-empty meta fields (everything after the primary) for one record —
68
+ * precomputed so the template never emits a leading/dangling separator.
69
+ * @param {Record<string, unknown>} item
70
+ * @returns {{ field: RecordField, value: unknown }[]}
71
+ */
72
+ function metaParts(item) {
73
+ return fields
74
+ .slice(1)
75
+ .map((field) => ({ field, value: item[field.key] }))
76
+ .filter((p) => p.value !== null && p.value !== undefined && p.value !== '');
77
+ }
78
+ </script>
79
+
80
+ {#if items.length > 0 && fields.length > 0}
81
+ <List class={className} {...rest}>
82
+ {#each items as item, i (item.id ?? i)}
83
+ {@const primary = fields[0]}
84
+ {@const primaryValue = item[primary.key]}
85
+ {@const meta = metaParts(item)}
86
+ <ListItem>
87
+ {#snippet leading()}
88
+ {#if primaryValue !== null && primaryValue !== undefined && primaryValue !== ''}
89
+ <span class="record-primary">
90
+ {#if primary.format === 'badge'}
91
+ <Badge>{String(primaryValue)}</Badge>
92
+ {:else}
93
+ {display(primaryValue, primary)}
94
+ {/if}
95
+ </span>
96
+ {/if}
97
+ {#if meta.length > 0}
98
+ <span class="record-meta">
99
+ {#each meta as part, mi (part.field.key)}
100
+ {#if mi > 0}<span class="record-sep" aria-hidden="true">·</span>{/if}
101
+ {#if part.field.format === 'badge'}
102
+ <Badge>{String(part.value)}</Badge>
103
+ {:else}<span>{display(part.value, part.field)}</span>{/if}
104
+ {/each}
105
+ </span>
106
+ {/if}
107
+ {/snippet}
108
+ </ListItem>
109
+ {/each}
110
+ </List>
111
+ {/if}
112
+
113
+ <style>
114
+ .record-primary {
115
+ display: block;
116
+ }
117
+
118
+ .record-meta {
119
+ display: flex;
120
+ flex-wrap: wrap;
121
+ align-items: center;
122
+ gap: var(--space-2xs);
123
+ margin-top: var(--space-2xs);
124
+ font-size: var(--type-body-sm-size);
125
+ color: var(--color-text-secondary);
126
+ }
127
+
128
+ .record-sep {
129
+ color: var(--color-text-secondary);
130
+ }
131
+ </style>
@@ -0,0 +1,50 @@
1
+ export default RecordList;
2
+ export type RecordField = {
3
+ key: string;
4
+ label?: string;
5
+ format?: "text" | "date" | "badge";
6
+ };
7
+ type RecordList = {
8
+ $on?(type: string, callback: (e: any) => void): () => void;
9
+ $set?(props: Partial<$$ComponentProps>): void;
10
+ };
11
+ /**
12
+ * RecordList
13
+ *
14
+ * A typed list of child records rendered from FIELD CONFIG — the generalisation
15
+ * of a comment list into any detail-page child collection (reviews, pareceres,
16
+ * occurrence updates, maintenance logs). Vertical-agnostic: `items` is the child
17
+ * rows, `fields` declares which keys to show and how.
18
+ *
19
+ * Layout convention (so one component serves every collection): the FIRST field
20
+ * is the record's primary line; the rest render as a muted meta line, separated
21
+ * by middots. A field may `format` as a date (locale-formatted) or `badge`
22
+ * (rendered as a Badge). Soft-empty: no items → nothing; an absent field value
23
+ * is skipped (no dangling separators).
24
+ *
25
+ * Consumes semantic tokens so dark / high-contrast schemes (#244) ride through.
26
+ *
27
+ * @example
28
+ * <RecordList
29
+ * items={updates}
30
+ * fields={[
31
+ * { key: 'note', label: 'Update' },
32
+ * { key: 'author' },
33
+ * { key: 'created_at', format: 'date' },
34
+ * { key: 'status', format: 'badge' },
35
+ * ]}
36
+ * locale="pt"
37
+ * />
38
+ */
39
+ declare const RecordList: import("svelte").Component<{
40
+ items?: any[];
41
+ fields?: any[];
42
+ locale?: string;
43
+ class?: string;
44
+ } & Record<string, any>, {}, "">;
45
+ type $$ComponentProps = {
46
+ items?: any[];
47
+ fields?: any[];
48
+ locale?: string;
49
+ class?: string;
50
+ } & Record<string, any>;
@@ -21,6 +21,9 @@ export { default as AppFrame } from "./AppFrame.svelte";
21
21
  export { default as SiteHeader } from "./SiteHeader.svelte";
22
22
  export { default as SiteFooter } from "./SiteFooter.svelte";
23
23
  export { default as SkipLink } from "./SkipLink.svelte";
24
+ export { default as TextSizeAdjuster } from "./TextSizeAdjuster.svelte";
25
+ export { default as ContrastToggle } from "./ContrastToggle.svelte";
26
+ export { default as LinkHighlightToggle } from "./LinkHighlightToggle.svelte";
24
27
  export { default as ServiceNavigation } from "./ServiceNavigation.svelte";
25
28
  export { default as Link } from "./Link.svelte";
26
29
  export { default as Hero } from "./Hero.svelte";
@@ -47,6 +50,9 @@ export { default as Tabs } from "./Tabs.svelte";
47
50
  export { default as TabList } from "./TabList.svelte";
48
51
  export { default as Tab } from "./Tab.svelte";
49
52
  export { default as TabPanel } from "./TabPanel.svelte";
53
+ export { default as FeedView } from "./FeedView.svelte";
54
+ export { default as ActionPanel } from "./ActionPanel.svelte";
55
+ export { default as RecordList } from "./RecordList.svelte";
50
56
  export { default as Alert } from "./Alert.svelte";
51
57
  export { default as Toast } from "./Toast.svelte";
52
58
  export { default as ToastManager } from "./ToastManager.svelte";
@@ -75,6 +75,11 @@ export { default as Tabs } from "./Tabs.svelte";
75
75
  export { default as TabList } from "./TabList.svelte";
76
76
  export { default as Tab } from "./Tab.svelte";
77
77
  export { default as TabPanel } from "./TabPanel.svelte";
78
+ export { default as FeedView } from "./FeedView.svelte";
79
+
80
+ // Entity ficha (#105 Phase 2)
81
+ export { default as ActionPanel } from "./ActionPanel.svelte";
82
+ export { default as RecordList } from "./RecordList.svelte";
78
83
 
79
84
  // Feedback
80
85
  export { default as Alert } from "./Alert.svelte";
@@ -73,6 +73,77 @@ export function cssPx(el: Element, prop: string, fallback: number): number;
73
73
  * @returns {string[]}
74
74
  */
75
75
  export function getHeatmapGradient(el: Element): string[];
76
+ /**
77
+ * @typedef {object} OverlayLayerStyle
78
+ * Flat style subset applied to a whole overlay layer. Colors are CSS color
79
+ * strings (they come from layer DATA — geolayers GeoStyler styles — not
80
+ * from design tokens; absent values fall back to the DS-tokened defaults).
81
+ * @property {string} [pointColor]
82
+ * @property {number} [pointRadius]
83
+ * @property {string} [strokeColor]
84
+ * @property {number} [strokeWidth]
85
+ * @property {string} [fillColor]
86
+ *
87
+ * @typedef {object} OverlayLayerDef
88
+ * One ordered overlay rendered between the tile layer and the component's
89
+ * own interactive vector layer. v1 is GeoJSON-only — the platform's citizen
90
+ * data plane serves GeoJSON (`/{app}/public/layers/{id}/features`); WMS/tile
91
+ * overlays arrive with the per-tenant raster principal (spec open question).
92
+ * @property {string} [id]
93
+ * @property {'geojson'} [type]
94
+ * @property {object} [data] — inline GeoJSON (Feature/FeatureCollection)
95
+ * @property {string} [url] — fetched when `data` is absent
96
+ * @property {OverlayLayerStyle | object} [style] — flat style or GeoStyler
97
+ * JSON (best-effort subset via `geoStylerToFlat`)
98
+ * @property {boolean} [visible]
99
+ */
100
+ /**
101
+ * Best-effort GeoStyler → flat style. Reads the first symbolizer of the
102
+ * first rule — enough for the platform's single-rule layer styles. Unknown
103
+ * shapes return {} so the DS-tokened defaults apply.
104
+ *
105
+ * @param {any} style
106
+ * @returns {OverlayLayerStyle}
107
+ */
108
+ export function geoStylerToFlat(style: any): OverlayLayerStyle;
109
+ /**
110
+ * Builds OL vector layers for `layers` overlay defs, ordered as given.
111
+ * URL-sourced layers load asynchronously into their source; a fetch failure
112
+ * leaves that overlay empty and logs a warning (the map still renders).
113
+ *
114
+ * @param {OverlayLayerDef[]} defs
115
+ * @param {import('./map-utils.js').MapStyles} styles — DS default styles
116
+ * @returns {Promise<import('ol/layer/Vector.js').default[]>}
117
+ */
118
+ export function createOverlayLayers(defs: OverlayLayerDef[], styles: import("./map-utils.js").MapStyles): Promise<import("ol/layer/Vector.js").default[]>;
119
+ /**
120
+ * Normalises a boundary prop into polygon rings (lon/lat WGS84).
121
+ * Accepts: raw ring `number[][]`, GeoJSON Polygon / MultiPolygon,
122
+ * Feature, or FeatureCollection (first polygonal feature per entry).
123
+ *
124
+ * @param {any} boundary
125
+ * @returns {number[][][]} — array of outer rings (one per polygon)
126
+ */
127
+ export function boundaryToRings(boundary: any): number[][][];
128
+ /**
129
+ * Ray-casting point-in-polygon over WGS84 rings (outer rings only — holes
130
+ * are out of scope for the boundary gate; the BFF hard-gate is authoritative).
131
+ *
132
+ * @param {[number, number]} lonLat
133
+ * @param {number[][][]} rings — from `boundaryToRings`
134
+ * @returns {boolean} true when the point is inside ANY ring
135
+ */
136
+ export function pointInRings(lonLat: [number, number], rings: number[][][]): boolean;
137
+ /**
138
+ * Builds the boundary overlay layer (dashed outline, faint fill — distinct
139
+ * from the selection polygon so "the edge of the allowed area" reads as
140
+ * context, not content). Token-driven: --map-boundary-*.
141
+ *
142
+ * @param {number[][][]} rings
143
+ * @param {Element} el — token read context
144
+ * @returns {Promise<import('ol/layer/Vector.js').default | null>}
145
+ */
146
+ export function createBoundaryLayer(rings: number[][][], el: Element): Promise<import("ol/layer/Vector.js").default | null>;
76
147
  export type OsmSource = {
77
148
  type: "osm";
78
149
  };
@@ -98,3 +169,39 @@ export type MapStyles = {
98
169
  */
99
170
  refresh: () => void;
100
171
  };
172
+ /**
173
+ * Flat style subset applied to a whole overlay layer. Colors are CSS color
174
+ * strings (they come from layer DATA — geolayers GeoStyler styles — not
175
+ * from design tokens; absent values fall back to the DS-tokened defaults).
176
+ */
177
+ export type OverlayLayerStyle = {
178
+ pointColor?: string;
179
+ pointRadius?: number;
180
+ strokeColor?: string;
181
+ strokeWidth?: number;
182
+ fillColor?: string;
183
+ };
184
+ /**
185
+ * One ordered overlay rendered between the tile layer and the component's
186
+ * own interactive vector layer. v1 is GeoJSON-only — the platform's citizen
187
+ * data plane serves GeoJSON (`/{app}/public/layers/{id}/features`); WMS/tile
188
+ * overlays arrive with the per-tenant raster principal (spec open question).
189
+ */
190
+ export type OverlayLayerDef = {
191
+ id?: string;
192
+ type?: "geojson";
193
+ /**
194
+ * — inline GeoJSON (Feature/FeatureCollection)
195
+ */
196
+ data?: object;
197
+ /**
198
+ * — fetched when `data` is absent
199
+ */
200
+ url?: string;
201
+ /**
202
+ * — flat style or GeoStyler
203
+ * JSON (best-effort subset via `geoStylerToFlat`)
204
+ */
205
+ style?: OverlayLayerStyle | object;
206
+ visible?: boolean;
207
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -403,6 +403,25 @@
403
403
  --tabs-trigger-transition: var(--duration-instant) var(--easing-default);
404
404
  --tabs-content-padding-top: var(--space-md);
405
405
 
406
+ /* ═══════════════════════════════════════════════
407
+ FEEDVIEW (segmented view-switch — list ⇄ map)
408
+ ═══════════════════════════════════════════════ */
409
+
410
+ --feedview-bg: var(--color-surface-secondary);
411
+ --feedview-radius: var(--radius-md);
412
+ --feedview-padding: var(--space-2xs);
413
+ --feedview-tab-font: var(--type-label-font);
414
+ --feedview-tab-size: var(--type-label-size);
415
+ --feedview-tab-tracking: var(--type-label-tracking);
416
+ --feedview-tab-height: 32px;
417
+ --feedview-tab-padding-x: var(--space-md);
418
+ --feedview-tab-radius: var(--radius-sm);
419
+ --feedview-tab-color: var(--color-text-secondary);
420
+ --feedview-tab-color-active: var(--color-text);
421
+ --feedview-tab-bg-active: var(--color-surface);
422
+ --feedview-tab-shadow-active: var(--elevation-raised);
423
+ --feedview-tab-transition: var(--duration-instant) var(--easing-default);
424
+
406
425
  /* ═══════════════════════════════════════════════
407
426
  MODAL (centered overlay)
408
427
  ═══════════════════════════════════════════════ */