mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-26 00:44:38 +02:00
feat(places): surface geocoding privacy notices in autocomplete UI
The mana-geocoding wrapper now returns `notice: 'fallback_used' | 'sensitive_local_unavailable'` alongside results so the UI can show the user *why* a query had unusual behavior. This commit wires that all the way through the Places module's address-autocomplete inputs. Geocoding client (lib/geocoding/index.ts): - Add `GeocodingNotice` and `SearchOutcome` types - Add `searchAddressDetailed` and `reverseGeocodeDetailed` — same semantics as the existing functions but return the wrapper's provider/notice metadata. Existing `searchAddress`/`reverseGeocode` stay backward-compatible (they call the detailed variants under the hood and discard the metadata). - Extend GeocodingResult with optional `provider` field. Places ListView (the only current consumer that exposes typed addresses to users): - Both autocomplete inputs (tracking-edit + main address-search) now use searchAddressDetailed and surface notices inline. - 'sensitive_local_unavailable' renders an amber explainer block in the dropdown — title + body — so the user knows why their medical query returned 0 hits without leaking the search to a public API. - 'fallback_used' renders a small "≈ ungefähr" footer badge so users understand the result came from public OSM (less precise but still valid). - The dropdown opens when EITHER results exist OR a notice is present — sensitive blocked queries with empty results still surface their explainer. i18n: new `places.geocoding_notice.*` sub-namespace in all 5 locales (de/en/es/fr/it) — 4 strings each. All validators green. Other consumers (places DetailView, events, photos, contacts) keep the existing searchAddress/reverseGeocode calls — they don't need the privacy notices today and would just add noise. They can adopt the detailed variant if/when the use case warrants it.
This commit is contained in:
parent
f20a411fd8
commit
b1fa55dbca
8 changed files with 218 additions and 25 deletions
|
|
@ -66,20 +66,59 @@ export interface GeocodingResult {
|
||||||
longitude: number;
|
longitude: number;
|
||||||
address: GeocodingAddress;
|
address: GeocodingAddress;
|
||||||
category: PlaceCategory;
|
category: PlaceCategory;
|
||||||
/** Raw Pelias categories (food, retail, transport, …) */
|
/** Raw Pelias categories (food, retail, transport, …) — only present
|
||||||
|
* when the result came from Pelias. */
|
||||||
peliasCategories?: string[];
|
peliasCategories?: string[];
|
||||||
confidence: number;
|
confidence: number;
|
||||||
|
/** Which backend served this result. `pelias` is local; `photon` and
|
||||||
|
* `nominatim` are public APIs (the wrapper applies sensitive-query
|
||||||
|
* blocking + coord quantization before forwarding to those). */
|
||||||
|
provider?: 'pelias' | 'photon' | 'nominatim';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Out-of-band information returned alongside results — the wrapper uses
|
||||||
|
* this to signal *why* a query had unusual behavior:
|
||||||
|
*
|
||||||
|
* - `'fallback_used'`: Pelias was unreachable, so a public-API provider
|
||||||
|
* served the request. Results are still valid but may be less precise.
|
||||||
|
* UI should show a subtle "approximate" badge.
|
||||||
|
* - `'sensitive_local_unavailable'`: the query matched the wrapper's
|
||||||
|
* sensitive-keyword list (medical / mental-health / crisis service)
|
||||||
|
* AND the local Pelias was unreachable. The wrapper deliberately did
|
||||||
|
* NOT forward the query to public APIs. Results are empty by design.
|
||||||
|
* UI should explain this to the user.
|
||||||
|
*/
|
||||||
|
export type GeocodingNotice = 'fallback_used' | 'sensitive_local_unavailable';
|
||||||
|
|
||||||
interface GeocodingResponse {
|
interface GeocodingResponse {
|
||||||
results: GeocodingResult[];
|
results: GeocodingResult[];
|
||||||
cached?: boolean;
|
cached?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
provider?: 'pelias' | 'photon' | 'nominatim';
|
||||||
|
notice?: GeocodingNotice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detailed search outcome — includes provider + notice metadata.
|
||||||
|
*
|
||||||
|
* Most call sites use the simpler `searchAddress` (returns just the
|
||||||
|
* results array). The detailed variant is for places where the UI needs
|
||||||
|
* to surface a notice — typically the address-autocomplete input where
|
||||||
|
* a "search blocked for privacy" message has to be shown.
|
||||||
|
*/
|
||||||
|
export interface SearchOutcome {
|
||||||
|
results: GeocodingResult[];
|
||||||
|
provider?: 'pelias' | 'photon' | 'nominatim';
|
||||||
|
notice?: GeocodingNotice;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forward geocoding / autocomplete.
|
* Forward geocoding / autocomplete.
|
||||||
* Returns places matching the search query, biased towards the focus point.
|
* Returns places matching the search query, biased towards the focus point.
|
||||||
|
*
|
||||||
|
* For UI that needs to display privacy-related notices (e.g. "this
|
||||||
|
* search stays local"), use {@link searchAddressDetailed} instead.
|
||||||
*/
|
*/
|
||||||
export async function searchAddress(
|
export async function searchAddress(
|
||||||
query: string,
|
query: string,
|
||||||
|
|
@ -90,7 +129,29 @@ export async function searchAddress(
|
||||||
lang?: string;
|
lang?: string;
|
||||||
}
|
}
|
||||||
): Promise<GeocodingResult[]> {
|
): Promise<GeocodingResult[]> {
|
||||||
if (!query || query.trim().length < 2) return [];
|
const outcome = await searchAddressDetailed(query, options);
|
||||||
|
return outcome.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detailed forward-search variant that surfaces the wrapper's `provider`
|
||||||
|
* and `notice` fields. Use this in the address-autocomplete UI so we can:
|
||||||
|
* - Show a subtle "≈ ungefähr" badge when a public-API fallback served
|
||||||
|
* the result (`notice: 'fallback_used'`).
|
||||||
|
* - Explain to the user why a sensitive query (Hausarzt, Klinikum,
|
||||||
|
* etc.) returned 0 results without leaking it to a public API
|
||||||
|
* (`notice: 'sensitive_local_unavailable'`).
|
||||||
|
*/
|
||||||
|
export async function searchAddressDetailed(
|
||||||
|
query: string,
|
||||||
|
options?: {
|
||||||
|
limit?: number;
|
||||||
|
focusLat?: number;
|
||||||
|
focusLon?: number;
|
||||||
|
lang?: string;
|
||||||
|
}
|
||||||
|
): Promise<SearchOutcome> {
|
||||||
|
if (!query || query.trim().length < 2) return { results: [] };
|
||||||
|
|
||||||
const params = new URLSearchParams({ q: query.trim() });
|
const params = new URLSearchParams({ q: query.trim() });
|
||||||
if (options?.limit) params.set('limit', String(options.limit));
|
if (options?.limit) params.set('limit', String(options.limit));
|
||||||
|
|
@ -100,23 +161,36 @@ export async function searchAddress(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(geocodeUrl('search', params));
|
const res = await fetch(geocodeUrl('search', params));
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return { results: [] };
|
||||||
const data: GeocodingResponse = await res.json();
|
const data: GeocodingResponse = await res.json();
|
||||||
return data.results;
|
return { results: data.results, provider: data.provider, notice: data.notice };
|
||||||
} catch {
|
} catch {
|
||||||
console.warn('Geocoding search failed — service may be offline');
|
console.warn('Geocoding search failed — service may be offline');
|
||||||
return [];
|
return { results: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reverse geocoding — resolve coordinates to an address and place type.
|
* Reverse geocoding — resolve coordinates to an address and place type.
|
||||||
|
*
|
||||||
|
* For UI that needs to surface the privacy notice (e.g. "approximate match"
|
||||||
|
* badge on a coordinate-derived address), use {@link reverseGeocodeDetailed}.
|
||||||
*/
|
*/
|
||||||
export async function reverseGeocode(
|
export async function reverseGeocode(
|
||||||
lat: number,
|
lat: number,
|
||||||
lon: number,
|
lon: number,
|
||||||
lang = 'de'
|
lang = 'de'
|
||||||
): Promise<GeocodingResult | null> {
|
): Promise<GeocodingResult | null> {
|
||||||
|
const outcome = await reverseGeocodeDetailed(lat, lon, lang);
|
||||||
|
return outcome.results[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detailed reverse-geocode variant — see {@link searchAddressDetailed}. */
|
||||||
|
export async function reverseGeocodeDetailed(
|
||||||
|
lat: number,
|
||||||
|
lon: number,
|
||||||
|
lang = 'de'
|
||||||
|
): Promise<SearchOutcome> {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
lat: String(lat),
|
lat: String(lat),
|
||||||
|
|
@ -124,12 +198,12 @@ export async function reverseGeocode(
|
||||||
lang,
|
lang,
|
||||||
});
|
});
|
||||||
const res = await fetch(geocodeUrl('reverse', params));
|
const res = await fetch(geocodeUrl('reverse', params));
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return { results: [] };
|
||||||
const data: GeocodingResponse = await res.json();
|
const data: GeocodingResponse = await res.json();
|
||||||
return data.results[0] ?? null;
|
return { results: data.results, provider: data.provider, notice: data.notice };
|
||||||
} catch {
|
} catch {
|
||||||
console.warn('Reverse geocoding failed — service may be offline');
|
console.warn('Reverse geocoding failed — service may be offline');
|
||||||
return null;
|
return { results: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,5 +32,11 @@
|
||||||
"meta_last_visit": "Letzter Besuch: {date}",
|
"meta_last_visit": "Letzter Besuch: {date}",
|
||||||
"meta_created": "Erstellt: {date}",
|
"meta_created": "Erstellt: {date}",
|
||||||
"meta_updated": "Bearbeitet: {date}"
|
"meta_updated": "Bearbeitet: {date}"
|
||||||
|
},
|
||||||
|
"geocoding_notice": {
|
||||||
|
"sensitive_local_unavailable_title": "Diese Suche bleibt bewusst lokal",
|
||||||
|
"sensitive_local_unavailable_body": "Sensible Suchbegriffe (Arzt, Klinik, …) werden nie an externe Dienste geschickt. Der lokale Index ist gerade nicht erreichbar — versuche es später nochmal oder formuliere allgemeiner.",
|
||||||
|
"fallback_used_badge": "≈ ungefähr",
|
||||||
|
"fallback_used_title": "Adress-Suche über öffentliches OSM"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,5 +32,11 @@
|
||||||
"meta_last_visit": "Last visit: {date}",
|
"meta_last_visit": "Last visit: {date}",
|
||||||
"meta_created": "Created: {date}",
|
"meta_created": "Created: {date}",
|
||||||
"meta_updated": "Edited: {date}"
|
"meta_updated": "Edited: {date}"
|
||||||
|
},
|
||||||
|
"geocoding_notice": {
|
||||||
|
"sensitive_local_unavailable_title": "This search stays local on purpose",
|
||||||
|
"sensitive_local_unavailable_body": "Sensitive terms (doctor, clinic, …) are never forwarded to public services. The local index is unavailable right now — try again later or use a more general query.",
|
||||||
|
"fallback_used_badge": "≈ approximate",
|
||||||
|
"fallback_used_title": "Address lookup via public OSM"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,5 +32,11 @@
|
||||||
"meta_last_visit": "Última visita: {date}",
|
"meta_last_visit": "Última visita: {date}",
|
||||||
"meta_created": "Creado: {date}",
|
"meta_created": "Creado: {date}",
|
||||||
"meta_updated": "Editado: {date}"
|
"meta_updated": "Editado: {date}"
|
||||||
|
},
|
||||||
|
"geocoding_notice": {
|
||||||
|
"sensitive_local_unavailable_title": "Esta búsqueda permanece local a propósito",
|
||||||
|
"sensitive_local_unavailable_body": "Los términos sensibles (médico, clínica, …) nunca se envían a servicios externos. El índice local no está disponible — vuelve a intentarlo o usa una búsqueda más general.",
|
||||||
|
"fallback_used_badge": "≈ aprox.",
|
||||||
|
"fallback_used_title": "Búsqueda de dirección vía OSM público"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,5 +32,11 @@
|
||||||
"meta_last_visit": "Dernière visite : {date}",
|
"meta_last_visit": "Dernière visite : {date}",
|
||||||
"meta_created": "Créé : {date}",
|
"meta_created": "Créé : {date}",
|
||||||
"meta_updated": "Modifié : {date}"
|
"meta_updated": "Modifié : {date}"
|
||||||
|
},
|
||||||
|
"geocoding_notice": {
|
||||||
|
"sensitive_local_unavailable_title": "Cette recherche reste locale par principe",
|
||||||
|
"sensitive_local_unavailable_body": "Les termes sensibles (médecin, clinique, …) ne sont jamais transmis à des services externes. L'index local est indisponible — réessaie plus tard ou utilise une formulation plus générale.",
|
||||||
|
"fallback_used_badge": "≈ approx.",
|
||||||
|
"fallback_used_title": "Recherche d'adresse via OSM public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,5 +32,11 @@
|
||||||
"meta_last_visit": "Ultima visita: {date}",
|
"meta_last_visit": "Ultima visita: {date}",
|
||||||
"meta_created": "Creato: {date}",
|
"meta_created": "Creato: {date}",
|
||||||
"meta_updated": "Modificato: {date}"
|
"meta_updated": "Modificato: {date}"
|
||||||
|
},
|
||||||
|
"geocoding_notice": {
|
||||||
|
"sensitive_local_unavailable_title": "Questa ricerca resta locale di proposito",
|
||||||
|
"sensitive_local_unavailable_body": "Termini sensibili (medico, clinica, …) non vengono mai inoltrati a servizi esterni. L'indice locale non è disponibile — riprova più tardi o usa una formulazione più generale.",
|
||||||
|
"fallback_used_badge": "≈ approssimativo",
|
||||||
|
"fallback_used_title": "Ricerca indirizzo tramite OSM pubblico"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,15 @@
|
||||||
import { placesStore } from './stores/places.svelte';
|
import { placesStore } from './stores/places.svelte';
|
||||||
import { trackingStore } from './stores/tracking.svelte';
|
import { trackingStore } from './stores/tracking.svelte';
|
||||||
import {
|
import {
|
||||||
searchAddress,
|
searchAddressDetailed,
|
||||||
reverseGeocode,
|
reverseGeocode,
|
||||||
formatAddress,
|
formatAddress,
|
||||||
formatLocality,
|
formatLocality,
|
||||||
formatFullAddress,
|
formatFullAddress,
|
||||||
|
type GeocodingNotice,
|
||||||
type GeocodingResult,
|
type GeocodingResult,
|
||||||
} from '$lib/geocoding';
|
} from '$lib/geocoding';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
import { Star, MapPin, Plus, PencilSimple, Trash, MagnifyingGlass } from '@mana/shared-icons';
|
import { Star, MapPin, Plus, PencilSimple, Trash, MagnifyingGlass } from '@mana/shared-icons';
|
||||||
import type { ViewProps } from '$lib/app-registry';
|
import type { ViewProps } from '$lib/app-registry';
|
||||||
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
|
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
|
||||||
|
|
@ -77,6 +79,7 @@
|
||||||
let editingLocation = $state(false);
|
let editingLocation = $state(false);
|
||||||
let locationDraft = $state('');
|
let locationDraft = $state('');
|
||||||
let locationSuggestions = $state<GeocodingResult[]>([]);
|
let locationSuggestions = $state<GeocodingResult[]>([]);
|
||||||
|
let locationNotice = $state<GeocodingNotice | undefined>(undefined);
|
||||||
let showLocationSuggestions = $state(false);
|
let showLocationSuggestions = $state(false);
|
||||||
let locationDebounce: ReturnType<typeof setTimeout> | undefined;
|
let locationDebounce: ReturnType<typeof setTimeout> | undefined;
|
||||||
let locationInputEl = $state<HTMLInputElement | null>(null);
|
let locationInputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
|
@ -129,6 +132,7 @@
|
||||||
editingLocation = false;
|
editingLocation = false;
|
||||||
showLocationSuggestions = false;
|
showLocationSuggestions = false;
|
||||||
locationSuggestions = [];
|
locationSuggestions = [];
|
||||||
|
locationNotice = undefined;
|
||||||
locationDraft = '';
|
locationDraft = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,17 +141,23 @@
|
||||||
const q = locationDraft.trim();
|
const q = locationDraft.trim();
|
||||||
if (q.length < 2) {
|
if (q.length < 2) {
|
||||||
locationSuggestions = [];
|
locationSuggestions = [];
|
||||||
|
locationNotice = undefined;
|
||||||
showLocationSuggestions = false;
|
showLocationSuggestions = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
locationDebounce = setTimeout(async () => {
|
locationDebounce = setTimeout(async () => {
|
||||||
const pos = trackingStore.currentPosition;
|
const pos = trackingStore.currentPosition;
|
||||||
locationSuggestions = await searchAddress(q, {
|
const outcome = await searchAddressDetailed(q, {
|
||||||
limit: 6,
|
limit: 6,
|
||||||
focusLat: pos?.coords.latitude,
|
focusLat: pos?.coords.latitude,
|
||||||
focusLon: pos?.coords.longitude,
|
focusLon: pos?.coords.longitude,
|
||||||
});
|
});
|
||||||
showLocationSuggestions = locationSuggestions.length > 0;
|
locationSuggestions = outcome.results;
|
||||||
|
locationNotice = outcome.notice;
|
||||||
|
// Show the dropdown when we have results OR when we have a
|
||||||
|
// notice to display (sensitive-query-blocked queries return
|
||||||
|
// empty results but should still surface the explainer).
|
||||||
|
showLocationSuggestions = outcome.results.length > 0 || !!outcome.notice;
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,6 +175,7 @@
|
||||||
// --- Address autocomplete ---
|
// --- Address autocomplete ---
|
||||||
let addressQuery = $state('');
|
let addressQuery = $state('');
|
||||||
let suggestions = $state<GeocodingResult[]>([]);
|
let suggestions = $state<GeocodingResult[]>([]);
|
||||||
|
let suggestionsNotice = $state<GeocodingNotice | undefined>(undefined);
|
||||||
let showSuggestions = $state(false);
|
let showSuggestions = $state(false);
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
|
@ -172,18 +183,24 @@
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
if (addressQuery.trim().length < 2) {
|
if (addressQuery.trim().length < 2) {
|
||||||
suggestions = [];
|
suggestions = [];
|
||||||
|
suggestionsNotice = undefined;
|
||||||
showSuggestions = false;
|
showSuggestions = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
debounceTimer = setTimeout(async () => {
|
debounceTimer = setTimeout(async () => {
|
||||||
const focusLat = trackingStore.currentPosition?.coords.latitude;
|
const focusLat = trackingStore.currentPosition?.coords.latitude;
|
||||||
const focusLon = trackingStore.currentPosition?.coords.longitude;
|
const focusLon = trackingStore.currentPosition?.coords.longitude;
|
||||||
suggestions = await searchAddress(addressQuery, {
|
const outcome = await searchAddressDetailed(addressQuery, {
|
||||||
limit: 6,
|
limit: 6,
|
||||||
focusLat,
|
focusLat,
|
||||||
focusLon,
|
focusLon,
|
||||||
});
|
});
|
||||||
showSuggestions = suggestions.length > 0;
|
suggestions = outcome.results;
|
||||||
|
suggestionsNotice = outcome.notice;
|
||||||
|
// Same as the tracking-edit input: show the dropdown when we
|
||||||
|
// have either results OR a notice. A sensitive query with
|
||||||
|
// empty results still needs to surface the explainer.
|
||||||
|
showSuggestions = outcome.results.length > 0 || !!outcome.notice;
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -313,6 +330,23 @@
|
||||||
/>
|
/>
|
||||||
{#if showLocationSuggestions}
|
{#if showLocationSuggestions}
|
||||||
<div class="tracking-suggestions">
|
<div class="tracking-suggestions">
|
||||||
|
{#if locationNotice === 'sensitive_local_unavailable'}
|
||||||
|
<div class="suggestion-notice notice-sensitive">
|
||||||
|
<div class="suggestion-notice-title">
|
||||||
|
{$_('places.geocoding_notice.sensitive_local_unavailable_title')}
|
||||||
|
</div>
|
||||||
|
<div class="suggestion-notice-body">
|
||||||
|
{$_('places.geocoding_notice.sensitive_local_unavailable_body')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if locationNotice === 'fallback_used'}
|
||||||
|
<div
|
||||||
|
class="suggestion-notice notice-fallback"
|
||||||
|
title={$_('places.geocoding_notice.fallback_used_title')}
|
||||||
|
>
|
||||||
|
{$_('places.geocoding_notice.fallback_used_badge')}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#each locationSuggestions as result}
|
{#each locationSuggestions as result}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -387,6 +421,23 @@
|
||||||
</div>
|
</div>
|
||||||
{#if showSuggestions}
|
{#if showSuggestions}
|
||||||
<div class="suggestions">
|
<div class="suggestions">
|
||||||
|
{#if suggestionsNotice === 'sensitive_local_unavailable'}
|
||||||
|
<div class="suggestion-notice notice-sensitive">
|
||||||
|
<div class="suggestion-notice-title">
|
||||||
|
{$_('places.geocoding_notice.sensitive_local_unavailable_title')}
|
||||||
|
</div>
|
||||||
|
<div class="suggestion-notice-body">
|
||||||
|
{$_('places.geocoding_notice.sensitive_local_unavailable_body')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if suggestionsNotice === 'fallback_used'}
|
||||||
|
<div
|
||||||
|
class="suggestion-notice notice-fallback"
|
||||||
|
title={$_('places.geocoding_notice.fallback_used_title')}
|
||||||
|
>
|
||||||
|
{$_('places.geocoding_notice.fallback_used_badge')}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#each suggestions as result}
|
{#each suggestions as result}
|
||||||
<button class="suggestion-item" onclick={() => selectSuggestion(result)}>
|
<button class="suggestion-item" onclick={() => selectSuggestion(result)}>
|
||||||
<div class="suggestion-icon">
|
<div class="suggestion-icon">
|
||||||
|
|
@ -792,6 +843,44 @@
|
||||||
border-top: 1px solid hsl(var(--color-border) / 0.5);
|
border-top: 1px solid hsl(var(--color-border) / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Privacy notices in suggestion dropdown ─────────────────
|
||||||
|
Two visual styles:
|
||||||
|
- notice-sensitive: full explainer block (sensitive query was
|
||||||
|
blocked from public APIs). Soft amber tone.
|
||||||
|
- notice-fallback: tiny right-aligned badge (results came from
|
||||||
|
a public-API fallback). Muted footer style. */
|
||||||
|
.suggestion-notice {
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.notice-sensitive {
|
||||||
|
background: hsl(40 90% 96%);
|
||||||
|
color: hsl(40 80% 24%);
|
||||||
|
border-bottom: 1px solid hsl(40 60% 85%);
|
||||||
|
}
|
||||||
|
:global(.dark) .notice-sensitive {
|
||||||
|
background: hsl(40 30% 16%);
|
||||||
|
color: hsl(40 80% 78%);
|
||||||
|
border-bottom-color: hsl(40 30% 28%);
|
||||||
|
}
|
||||||
|
.suggestion-notice-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
.suggestion-notice-body {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.notice-fallback {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
font-style: italic;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
background: hsl(var(--color-muted) / 0.3);
|
||||||
|
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.suggestion-icon {
|
.suggestion-icon {
|
||||||
color: #0ea5e9;
|
color: #0ea5e9;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@
|
||||||
"apps/mana/apps/web/src/lib/components/dashboard/widgets/CardsProgressWidget.svelte": 4,
|
"apps/mana/apps/web/src/lib/components/dashboard/widgets/CardsProgressWidget.svelte": 4,
|
||||||
"apps/mana/apps/web/src/lib/components/dashboard/widgets/MusicLibraryWidget.svelte": 1,
|
"apps/mana/apps/web/src/lib/components/dashboard/widgets/MusicLibraryWidget.svelte": 1,
|
||||||
"apps/mana/apps/web/src/lib/components/DetailViewShell.svelte": 1,
|
"apps/mana/apps/web/src/lib/components/DetailViewShell.svelte": 1,
|
||||||
"apps/mana/apps/web/src/lib/components/feedback/FeedbackQuickModal.svelte": 7,
|
"apps/mana/apps/web/src/lib/components/feedback/FeedbackForm.svelte": 5,
|
||||||
"apps/mana/apps/web/src/lib/components/feedback/GlobalFeedbackPill.svelte": 1,
|
"apps/mana/apps/web/src/lib/components/feedback/FeedbackQuickModal.svelte": 2,
|
||||||
"apps/mana/apps/web/src/lib/components/KeyboardShortcutsModal.svelte": 1,
|
"apps/mana/apps/web/src/lib/components/KeyboardShortcutsModal.svelte": 1,
|
||||||
"apps/mana/apps/web/src/lib/components/landing/LandingEditor.svelte": 16,
|
"apps/mana/apps/web/src/lib/components/landing/LandingEditor.svelte": 16,
|
||||||
"apps/mana/apps/web/src/lib/components/layout/SpaceCreateDialog.svelte": 4,
|
"apps/mana/apps/web/src/lib/components/layout/SpaceCreateDialog.svelte": 4,
|
||||||
|
|
@ -85,11 +85,6 @@
|
||||||
"apps/mana/apps/web/src/lib/modules/comic/ListView.svelte": 1,
|
"apps/mana/apps/web/src/lib/modules/comic/ListView.svelte": 1,
|
||||||
"apps/mana/apps/web/src/lib/modules/comic/views/CharactersView.svelte": 1,
|
"apps/mana/apps/web/src/lib/modules/comic/views/CharactersView.svelte": 1,
|
||||||
"apps/mana/apps/web/src/lib/modules/comic/views/ListView.svelte": 2,
|
"apps/mana/apps/web/src/lib/modules/comic/views/ListView.svelte": 2,
|
||||||
"apps/mana/apps/web/src/lib/modules/community/components/ItemCard.svelte": 1,
|
|
||||||
"apps/mana/apps/web/src/lib/modules/community/views/DetailView.svelte": 1,
|
|
||||||
"apps/mana/apps/web/src/lib/modules/community/views/ListView.svelte": 1,
|
|
||||||
"apps/mana/apps/web/src/lib/modules/community/views/MyWishesView.svelte": 6,
|
|
||||||
"apps/mana/apps/web/src/lib/modules/community/views/RoadmapView.svelte": 1,
|
|
||||||
"apps/mana/apps/web/src/lib/modules/companion/components/CompanionChat.svelte": 1,
|
"apps/mana/apps/web/src/lib/modules/companion/components/CompanionChat.svelte": 1,
|
||||||
"apps/mana/apps/web/src/lib/modules/companion/components/RitualRunner.svelte": 5,
|
"apps/mana/apps/web/src/lib/modules/companion/components/RitualRunner.svelte": 5,
|
||||||
"apps/mana/apps/web/src/lib/modules/companion/ListView.svelte": 1,
|
"apps/mana/apps/web/src/lib/modules/companion/ListView.svelte": 1,
|
||||||
|
|
@ -106,6 +101,11 @@
|
||||||
"apps/mana/apps/web/src/lib/modules/core/widgets/RecentContactsWidget.svelte": 2,
|
"apps/mana/apps/web/src/lib/modules/core/widgets/RecentContactsWidget.svelte": 2,
|
||||||
"apps/mana/apps/web/src/lib/modules/core/widgets/TasksTodayWidget.svelte": 1,
|
"apps/mana/apps/web/src/lib/modules/core/widgets/TasksTodayWidget.svelte": 1,
|
||||||
"apps/mana/apps/web/src/lib/modules/core/widgets/UpcomingEventsWidget.svelte": 1,
|
"apps/mana/apps/web/src/lib/modules/core/widgets/UpcomingEventsWidget.svelte": 1,
|
||||||
|
"apps/mana/apps/web/src/lib/modules/feedback/components/ItemCard.svelte": 1,
|
||||||
|
"apps/mana/apps/web/src/lib/modules/feedback/views/DetailView.svelte": 1,
|
||||||
|
"apps/mana/apps/web/src/lib/modules/feedback/views/ListView.svelte": 1,
|
||||||
|
"apps/mana/apps/web/src/lib/modules/feedback/views/MyWishesView.svelte": 6,
|
||||||
|
"apps/mana/apps/web/src/lib/modules/feedback/views/RoadmapView.svelte": 1,
|
||||||
"apps/mana/apps/web/src/lib/modules/goals/ListView.svelte": 1,
|
"apps/mana/apps/web/src/lib/modules/goals/ListView.svelte": 1,
|
||||||
"apps/mana/apps/web/src/lib/modules/guides/ListView.svelte": 1,
|
"apps/mana/apps/web/src/lib/modules/guides/ListView.svelte": 1,
|
||||||
"apps/mana/apps/web/src/lib/modules/habits/components/HabitDetail.svelte": 5,
|
"apps/mana/apps/web/src/lib/modules/habits/components/HabitDetail.svelte": 5,
|
||||||
|
|
@ -286,11 +286,11 @@
|
||||||
"apps/mana/apps/web/src/routes/+error.svelte": 1,
|
"apps/mana/apps/web/src/routes/+error.svelte": 1,
|
||||||
"apps/mana/apps/web/src/routes/auth/callback/+page.svelte": 3,
|
"apps/mana/apps/web/src/routes/auth/callback/+page.svelte": 3,
|
||||||
"apps/mana/apps/web/src/routes/auth/reset-password/+page.svelte": 1,
|
"apps/mana/apps/web/src/routes/auth/reset-password/+page.svelte": 1,
|
||||||
"apps/mana/apps/web/src/routes/community/+layout.svelte": 5,
|
"apps/mana/apps/web/src/routes/feedback/+layout.svelte": 5,
|
||||||
"apps/mana/apps/web/src/routes/community/+page.svelte": 1,
|
"apps/mana/apps/web/src/routes/feedback/+page.svelte": 1,
|
||||||
"apps/mana/apps/web/src/routes/community/admin/+page.svelte": 6,
|
"apps/mana/apps/web/src/routes/feedback/admin/+page.svelte": 6,
|
||||||
"apps/mana/apps/web/src/routes/community/eule/[hash]/+page.svelte": 1,
|
"apps/mana/apps/web/src/routes/feedback/eule/[hash]/+page.svelte": 1,
|
||||||
"apps/mana/apps/web/src/routes/community/roadmap/+page.svelte": 1,
|
"apps/mana/apps/web/src/routes/feedback/roadmap/+page.svelte": 1,
|
||||||
"apps/mana/apps/web/src/routes/g/[code]/+page.svelte": 6,
|
"apps/mana/apps/web/src/routes/g/[code]/+page.svelte": 6,
|
||||||
"apps/mana/apps/web/src/routes/rsvp/[token]/+page.svelte": 1,
|
"apps/mana/apps/web/src/routes/rsvp/[token]/+page.svelte": 1,
|
||||||
"apps/mana/apps/web/src/routes/share/[token]/+layout.svelte": 1,
|
"apps/mana/apps/web/src/routes/share/[token]/+layout.svelte": 1,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue