diff --git a/apps/calendar/src/runtime.ts b/apps/calendar/src/runtime.ts index e8ba310b..9b58bad2 100644 --- a/apps/calendar/src/runtime.ts +++ b/apps/calendar/src/runtime.ts @@ -12,6 +12,7 @@ import type { CalDavService, + IconsService, PropertiesService, VaultEntity as SdkVaultEntity, SettingsService, @@ -83,6 +84,8 @@ export type CalendarRuntime = { /** Per-device, vault-scoped settings — persists the source-filter * visibility (9.15f). */ settings?: SettingsService; + /** Vault icon store — backs the icon picker's Upload + Library tabs. */ + icons?: IconsService; }; }; diff --git a/apps/calendar/src/ui/react/event-detail.tsx b/apps/calendar/src/ui/react/event-detail.tsx index 96d1c617..c176b1b9 100644 --- a/apps/calendar/src/ui/react/event-detail.tsx +++ b/apps/calendar/src/ui/react/event-detail.tsx @@ -39,6 +39,7 @@ import { utcToZonedParts, zonedTimeToUtc, } from "../../logic/timezone"; +import { getCalendarRuntime } from "../../runtime"; import type { Attendee } from "../../types/attendee"; import type { Event } from "../../types/event"; import { reminderOffsetLabel } from "../reminder-labels"; @@ -239,6 +240,7 @@ export function EventDetail({ const chooseIcon = (): void => { openIconPicker({ value: draft.icon ?? null, + icons: getCalendarRuntime()?.services?.icons, onChange: (icon: Icon | null) => { patch({ icon }); closePicker(); diff --git a/apps/contacts/src/ui/person-detail.tsx b/apps/contacts/src/ui/person-detail.tsx index 32cad84d..ba4b56b0 100644 --- a/apps/contacts/src/ui/person-detail.tsx +++ b/apps/contacts/src/ui/person-detail.tsx @@ -17,7 +17,7 @@ import { RichTextPropertyEditor } from "@brainstorm-os/editor"; import type { PropertiesService } from "@brainstorm-os/sdk-types"; import { CoverPicker, type CoverPickerService } from "@brainstorm-os/sdk/cover-picker"; import { Icon, IconName } from "@brainstorm-os/sdk/icon"; -import { IconPicker, type IconUploadService } from "@brainstorm-os/sdk/icon-picker"; +import { IconPicker, iconUploadFromIcons } from "@brainstorm-os/sdk/icon-picker"; import { PropertiesPanel } from "@brainstorm-os/sdk/properties-panel"; import { DEFAULT_PROPERTY_UI_LABELS, @@ -170,14 +170,7 @@ export function PersonDetail({ () => getBrainstorm()?.services?.covers ?? COVERS_UNAVAILABLE, [], ); - const iconUpload = useMemo(() => { - const icons = getBrainstorm()?.services?.icons; - if (!icons) return undefined; - return { - upload: (filename, bytes) => icons.uploadBytes(filename, bytes), - list: async () => (await icons.list()).map((e) => ({ url: e.url, thumbUrl: e.thumbUrl })), - }; - }, []); + const iconUpload = useMemo(() => iconUploadFromIcons(getBrainstorm()?.services?.icons), []); // Stable cover subject — the band re-renders off the explicit `cover` prop. const coverSubject = useMemo(() => ({ id: person.id }), [person.id]); diff --git a/apps/database/src/app.ts b/apps/database/src/app.ts index 4786896e..153bc7fb 100644 --- a/apps/database/src/app.ts +++ b/apps/database/src/app.ts @@ -46,6 +46,7 @@ import { type FileImportResult, GENERIC_OBJECT_TYPE, type Icon, + type IconsService, type ObjectDragPayload, type PropertiesService, type PropertiesSnapshot, @@ -1114,6 +1115,7 @@ function renderStageHeader(state: AppState): void { createIconPickerButton({ value: list.icon, size: 18, + icons: getRuntime()?.services?.icons, ariaLabel: t("brainstorm.database.stage.changeListIcon"), onChange: (icon) => commitListIcon(state, list.id, icon), }), @@ -1156,6 +1158,7 @@ function renderStageHeader(state: AppState): void { createIconPickerButton({ value: list.icon, size: 18, + icons: getRuntime()?.services?.icons, ariaLabel: t("brainstorm.database.stage.changeListIcon"), onChange: (icon) => commitListIcon(state, list.id, icon), }), @@ -1777,6 +1780,7 @@ function renderInspector(state: AppState): void { if (recordLocked) return; openIconPicker({ value: readEntityIcon(entity), + icons: getRuntime()?.services?.icons, onChange: (icon) => void persistEntityPatch(state, entity, { icon }), }); }); @@ -5525,6 +5529,8 @@ type BrainstormRuntime = { uploadBytes(filename: string, bytes: Uint8Array): Promise<{ url: string; thumbUrl: string }>; list(): Promise>; }; + /** Vault icon store — backs the icon picker's Upload + Library tabs. */ + icons?: IconsService; /** Pin-any-object-to-dashboard (7.13). Drives the shared object * menu's Pin / Unpin toggle. Manifest grants `dashboard.pin` * (default-minimum). */ diff --git a/apps/database/src/i18n/de.json b/apps/database/src/i18n/de.json index a9b49a36..e2ae4e66 100644 --- a/apps/database/src/i18n/de.json +++ b/apps/database/src/i18n/de.json @@ -36,7 +36,7 @@ "brainstorm.database.delete.body": "„{name}“ wird aus dem Vault gelöscht — es verschwindet aus allen Listen, der Suche und dem Graphen.", "brainstorm.database.delete.confirm": "Löschen", "brainstorm.database.delete.cancel": "Abbrechen", - "brainstorm.database.delete.unavailable": "Löschen benötigt den Entitätendienst (von dieser Shell nicht bereitgestellt)", + "brainstorm.database.delete.unavailable": "Löschen ist hier nicht verfügbar.", "brainstorm.database.delete.done": "„{name}“ gelöscht", "brainstorm.database.delete.failed": "Löschen fehlgeschlagen — {message}", "brainstorm.database.timeline.emptyNoValues": "Keine Elemente haben einen Wert für „{property}“. Wählen Sie unter Ansichtseinstellungen → Daten eine andere Datumseigenschaft.", @@ -172,7 +172,7 @@ "brainstorm.database.status.addedToColumn": "Zur Spalte hinzugefügt", "brainstorm.database.status.addedToGroup": "Zu „{group}“ hinzugefügt", "brainstorm.database.status.movedToDate": "Verschoben auf {date}", - "brainstorm.database.status.dropNeedsEntities": "Ablegen benötigt den Entitätendienst (von dieser Shell nicht bereitgestellt)", + "brainstorm.database.status.dropNeedsEntities": "Ablegen ist hier nicht verfügbar.", "brainstorm.database.filter.add": "+ Filter", "brainstorm.database.sort.add": "+ Sortierung", "brainstorm.database.filter.clearAll": "Alle löschen", @@ -183,21 +183,21 @@ "brainstorm.database.filter.valuePlaceholder": "Wert eingeben…", "brainstorm.database.filter.valueListPlaceholder": "Wert, Wert…", "brainstorm.database.filter.valueAria": "Filterwert", - "brainstorm.database.status.createNeedsEntities": "Erstellen benötigt den Entitätendienst (von dieser Shell nicht bereitgestellt)", + "brainstorm.database.status.createNeedsEntities": "Erstellen ist hier nicht verfügbar.", "brainstorm.database.status.openCollectionFirst": "Öffnen oder erstellen Sie eine Sammlung, um hier ein Objekt hinzuzufügen", "brainstorm.database.status.creating": "Wird erstellt…", "brainstorm.database.status.created": "Erstellt", "brainstorm.database.status.createFailed": "Erstellen fehlgeschlagen — {message}", "brainstorm.database.row.untitled": "Ohne Titel", - "brainstorm.database.status.saveTemplateNeedsEntities": "Als Vorlage speichern benötigt den Entitätendienst (von dieser Shell nicht bereitgestellt)", + "brainstorm.database.status.saveTemplateNeedsEntities": "Als Vorlage speichern ist hier nicht verfügbar.", "brainstorm.database.status.saveTemplateFailed": "Als Vorlage speichern fehlgeschlagen — {message}", - "brainstorm.database.status.savedLocally": "Lokal gespeichert — Entitäten-Schreibzugriff von dieser Shell nicht bereitgestellt", + "brainstorm.database.status.savedLocally": "Lokal gespeichert — Änderungen können von hier aus nicht im Vault gespeichert werden.", "brainstorm.database.status.saveFailed": "Speichern fehlgeschlagen — {message}", "brainstorm.database.search.placeholder": "Zeilen suchen…", "brainstorm.database.search.aria": "Zeilen suchen", "brainstorm.database.collection.defaultName": "Neue Sammlung", "brainstorm.database.list.defaultName": "Neue Liste", - "brainstorm.database.status.importNeedsServices": "Importieren benötigt die Datei- und Entitätendienste (von dieser Shell nicht bereitgestellt)", + "brainstorm.database.status.importNeedsServices": "Importieren ist hier nicht verfügbar.", "brainstorm.database.import.csv.title": "CSV importieren", "brainstorm.database.import.csv.defaultName": "Importierte CSV", "brainstorm.database.status.csvReadFailed": "CSV-Datei konnte nicht gelesen werden", @@ -260,7 +260,7 @@ "brainstorm.database.status.propertyAdded": "Eigenschaft „{name}“ hinzugefügt", "brainstorm.database.status.propertyExists": "Die Eigenschaft „{name}“ existiert bereits — verwenden Sie sie wieder, statt ein Duplikat zu erstellen", "brainstorm.database.status.propertyAddedToCollection": "„{name}“ zu dieser Sammlung hinzugefügt", - "brainstorm.database.status.propertiesUnavailable": "Speichern nicht möglich — der Eigenschaftendienst wird von dieser Shell nicht bereitgestellt", + "brainstorm.database.status.propertiesUnavailable": "Speichern nicht möglich — Eigenschaftsänderungen sind hier nicht verfügbar.", "brainstorm.database.status.propertySaveFailed": "Speichern der Eigenschaft fehlgeschlagen — {message}", "brainstorm.database.timeline.empty": "Keine Elemente entsprechen dieser Ansicht.", "brainstorm.database.timeline.now": "Jetzt", diff --git a/apps/database/src/i18n/en.json b/apps/database/src/i18n/en.json index 7433aff3..7446b21f 100644 --- a/apps/database/src/i18n/en.json +++ b/apps/database/src/i18n/en.json @@ -36,7 +36,7 @@ "brainstorm.database.delete.body": "“{name}” will be deleted from the vault — it disappears from every list, search, and the graph.", "brainstorm.database.delete.confirm": "Delete", "brainstorm.database.delete.cancel": "Cancel", - "brainstorm.database.delete.unavailable": "Delete needs the entities service (not exposed by this shell)", + "brainstorm.database.delete.unavailable": "Delete isn't available here.", "brainstorm.database.delete.done": "Deleted “{name}”", "brainstorm.database.delete.failed": "Delete failed — {message}", "brainstorm.database.timeline.emptyNoValues": "No items have a value for “{property}”. Pick a different date property under View settings → Dates.", @@ -172,7 +172,7 @@ "brainstorm.database.status.addedToColumn": "Added to column", "brainstorm.database.status.addedToGroup": "Added to “{group}”", "brainstorm.database.status.movedToDate": "Moved to {date}", - "brainstorm.database.status.dropNeedsEntities": "Drop needs the entities service (not exposed by this shell)", + "brainstorm.database.status.dropNeedsEntities": "Drop isn't available here.", "brainstorm.database.filter.add": "+ Filter", "brainstorm.database.sort.add": "+ Sort", "brainstorm.database.filter.clearAll": "Clear all", @@ -183,21 +183,21 @@ "brainstorm.database.filter.valuePlaceholder": "Type a value…", "brainstorm.database.filter.valueListPlaceholder": "value, value…", "brainstorm.database.filter.valueAria": "Filter value", - "brainstorm.database.status.createNeedsEntities": "Create needs the entities service (not exposed by this shell)", + "brainstorm.database.status.createNeedsEntities": "Creating items isn't available here.", "brainstorm.database.status.openCollectionFirst": "Open or create a collection to add an object here", "brainstorm.database.status.creating": "Creating…", "brainstorm.database.status.created": "Created", "brainstorm.database.status.createFailed": "Create failed — {message}", "brainstorm.database.row.untitled": "Untitled", - "brainstorm.database.status.saveTemplateNeedsEntities": "Save as template needs the entities service (not exposed by this shell)", + "brainstorm.database.status.saveTemplateNeedsEntities": "Save as template isn't available here.", "brainstorm.database.status.saveTemplateFailed": "Save as template failed — {message}", - "brainstorm.database.status.savedLocally": "Saved locally — entities write not exposed by this shell", + "brainstorm.database.status.savedLocally": "Saved locally — changes can't be saved to the vault from here.", "brainstorm.database.status.saveFailed": "Save failed — {message}", "brainstorm.database.search.placeholder": "Search rows…", "brainstorm.database.search.aria": "Search rows", "brainstorm.database.collection.defaultName": "New collection", "brainstorm.database.list.defaultName": "New list", - "brainstorm.database.status.importNeedsServices": "Import needs the files + entities services (not exposed by this shell)", + "brainstorm.database.status.importNeedsServices": "Import isn't available here.", "brainstorm.database.import.csv.title": "Import CSV", "brainstorm.database.import.csv.defaultName": "Imported CSV", "brainstorm.database.status.csvReadFailed": "Couldn't read the CSV file", @@ -260,7 +260,7 @@ "brainstorm.database.status.propertyAdded": "Added property “{name}”", "brainstorm.database.status.propertyExists": "Property “{name}” already exists — reuse it instead of creating a twin", "brainstorm.database.status.propertyAddedToCollection": "Added “{name}” to this collection", - "brainstorm.database.status.propertiesUnavailable": "Couldn't save — properties service not exposed by this shell", + "brainstorm.database.status.propertiesUnavailable": "Couldn't save — property changes aren't available here.", "brainstorm.database.status.propertySaveFailed": "Property save failed — {message}", "brainstorm.database.timeline.empty": "No items match this view.", "brainstorm.database.timeline.now": "Now", diff --git a/apps/database/src/i18n/es.json b/apps/database/src/i18n/es.json index 3be3f4f3..a931b344 100644 --- a/apps/database/src/i18n/es.json +++ b/apps/database/src/i18n/es.json @@ -36,7 +36,7 @@ "brainstorm.database.delete.body": "“{name}” se eliminará de la bóveda — desaparecerá de todas las listas, la búsqueda y el grafo.", "brainstorm.database.delete.confirm": "Eliminar", "brainstorm.database.delete.cancel": "Cancelar", - "brainstorm.database.delete.unavailable": "Eliminar requiere el servicio de entidades (no expuesto por este shell)", + "brainstorm.database.delete.unavailable": "Eliminar no está disponible aquí.", "brainstorm.database.delete.done": "“{name}” eliminado", "brainstorm.database.delete.failed": "Error al eliminar — {message}", "brainstorm.database.timeline.emptyNoValues": "Ningún elemento tiene un valor para “{property}”. Elija otra propiedad de fecha en Ajustes de vista → Fechas.", @@ -172,7 +172,7 @@ "brainstorm.database.status.addedToColumn": "Añadido a la columna", "brainstorm.database.status.addedToGroup": "Añadido a “{group}”", "brainstorm.database.status.movedToDate": "Movido a {date}", - "brainstorm.database.status.dropNeedsEntities": "Soltar requiere el servicio de entidades (no expuesto por este shell)", + "brainstorm.database.status.dropNeedsEntities": "Soltar no está disponible aquí.", "brainstorm.database.filter.add": "+ Filtro", "brainstorm.database.sort.add": "+ Orden", "brainstorm.database.filter.clearAll": "Borrar todo", @@ -183,21 +183,21 @@ "brainstorm.database.filter.valuePlaceholder": "Escriba un valor…", "brainstorm.database.filter.valueListPlaceholder": "valor, valor…", "brainstorm.database.filter.valueAria": "Valor del filtro", - "brainstorm.database.status.createNeedsEntities": "Crear requiere el servicio de entidades (no expuesto por este shell)", + "brainstorm.database.status.createNeedsEntities": "Crear no está disponible aquí.", "brainstorm.database.status.openCollectionFirst": "Abra o cree una colección para añadir un objeto aquí", "brainstorm.database.status.creating": "Creando…", "brainstorm.database.status.created": "Creado", "brainstorm.database.status.createFailed": "Error al crear — {message}", "brainstorm.database.row.untitled": "Sin título", - "brainstorm.database.status.saveTemplateNeedsEntities": "Guardar como plantilla requiere el servicio de entidades (no expuesto por este shell)", + "brainstorm.database.status.saveTemplateNeedsEntities": "Guardar como plantilla no está disponible aquí.", "brainstorm.database.status.saveTemplateFailed": "Error al guardar como plantilla — {message}", - "brainstorm.database.status.savedLocally": "Guardado localmente — la escritura de entidades no está expuesta por este shell", + "brainstorm.database.status.savedLocally": "Guardado localmente — los cambios no se pueden guardar en la bóveda desde aquí.", "brainstorm.database.status.saveFailed": "Error al guardar — {message}", "brainstorm.database.search.placeholder": "Buscar filas…", "brainstorm.database.search.aria": "Buscar filas", "brainstorm.database.collection.defaultName": "Nueva colección", "brainstorm.database.list.defaultName": "Nueva lista", - "brainstorm.database.status.importNeedsServices": "Importar requiere los servicios de archivos y entidades (no expuestos por este shell)", + "brainstorm.database.status.importNeedsServices": "Importar no está disponible aquí.", "brainstorm.database.import.csv.title": "Importar CSV", "brainstorm.database.import.csv.defaultName": "CSV importado", "brainstorm.database.status.csvReadFailed": "No se pudo leer el archivo CSV", @@ -260,7 +260,7 @@ "brainstorm.database.status.propertyAdded": "Propiedad “{name}” añadida", "brainstorm.database.status.propertyExists": "La propiedad “{name}” ya existe — reutilícela en lugar de crear un duplicado", "brainstorm.database.status.propertyAddedToCollection": "“{name}” añadida a esta colección", - "brainstorm.database.status.propertiesUnavailable": "No se pudo guardar — el servicio de propiedades no está expuesto por este shell", + "brainstorm.database.status.propertiesUnavailable": "No se pudo guardar — los cambios de propiedades no están disponibles aquí.", "brainstorm.database.status.propertySaveFailed": "Error al guardar la propiedad — {message}", "brainstorm.database.timeline.empty": "Ningún elemento coincide con esta vista.", "brainstorm.database.timeline.now": "Ahora", diff --git a/apps/database/src/i18n/fr.json b/apps/database/src/i18n/fr.json index 92a44976..084f8088 100644 --- a/apps/database/src/i18n/fr.json +++ b/apps/database/src/i18n/fr.json @@ -36,7 +36,7 @@ "brainstorm.database.delete.body": "« {name} » sera supprimé du coffre — il disparaîtra de toutes les listes, de la recherche et du graphe.", "brainstorm.database.delete.confirm": "Supprimer", "brainstorm.database.delete.cancel": "Annuler", - "brainstorm.database.delete.unavailable": "La suppression nécessite le service d'entités (non exposé par ce shell)", + "brainstorm.database.delete.unavailable": "La suppression n'est pas disponible ici.", "brainstorm.database.delete.done": "« {name} » supprimé", "brainstorm.database.delete.failed": "Échec de la suppression — {message}", "brainstorm.database.timeline.emptyNoValues": "Aucun élément n'a de valeur pour « {property} ». Choisissez une autre propriété de date dans Réglages de la vue → Dates.", @@ -172,7 +172,7 @@ "brainstorm.database.status.addedToColumn": "Ajouté à la colonne", "brainstorm.database.status.addedToGroup": "Ajouté à « {group} »", "brainstorm.database.status.movedToDate": "Déplacé au {date}", - "brainstorm.database.status.dropNeedsEntities": "Le dépôt nécessite le service d'entités (non exposé par ce shell)", + "brainstorm.database.status.dropNeedsEntities": "Le dépôt n'est pas disponible ici.", "brainstorm.database.filter.add": "+ Filtre", "brainstorm.database.sort.add": "+ Tri", "brainstorm.database.filter.clearAll": "Tout effacer", @@ -183,21 +183,21 @@ "brainstorm.database.filter.valuePlaceholder": "Saisissez une valeur…", "brainstorm.database.filter.valueListPlaceholder": "valeur, valeur…", "brainstorm.database.filter.valueAria": "Valeur du filtre", - "brainstorm.database.status.createNeedsEntities": "La création nécessite le service d'entités (non exposé par ce shell)", + "brainstorm.database.status.createNeedsEntities": "La création n'est pas disponible ici.", "brainstorm.database.status.openCollectionFirst": "Ouvrez ou créez une collection pour ajouter un objet ici", "brainstorm.database.status.creating": "Création…", "brainstorm.database.status.created": "Créé", "brainstorm.database.status.createFailed": "Échec de la création — {message}", "brainstorm.database.row.untitled": "Sans titre", - "brainstorm.database.status.saveTemplateNeedsEntities": "Enregistrer comme modèle nécessite le service d'entités (non exposé par ce shell)", + "brainstorm.database.status.saveTemplateNeedsEntities": "Enregistrer comme modèle n'est pas disponible ici.", "brainstorm.database.status.saveTemplateFailed": "Échec de l'enregistrement comme modèle — {message}", - "brainstorm.database.status.savedLocally": "Enregistré localement — l'écriture d'entités n'est pas exposée par ce shell", + "brainstorm.database.status.savedLocally": "Enregistré localement — les modifications ne peuvent pas être enregistrées dans le coffre depuis ici.", "brainstorm.database.status.saveFailed": "Échec de l'enregistrement — {message}", "brainstorm.database.search.placeholder": "Rechercher des lignes…", "brainstorm.database.search.aria": "Rechercher des lignes", "brainstorm.database.collection.defaultName": "Nouvelle collection", "brainstorm.database.list.defaultName": "Nouvelle liste", - "brainstorm.database.status.importNeedsServices": "L'import nécessite les services de fichiers et d'entités (non exposés par ce shell)", + "brainstorm.database.status.importNeedsServices": "L'import n'est pas disponible ici.", "brainstorm.database.import.csv.title": "Importer un CSV", "brainstorm.database.import.csv.defaultName": "CSV importé", "brainstorm.database.status.csvReadFailed": "Impossible de lire le fichier CSV", @@ -260,7 +260,7 @@ "brainstorm.database.status.propertyAdded": "Propriété « {name} » ajoutée", "brainstorm.database.status.propertyExists": "La propriété « {name} » existe déjà — réutilisez-la au lieu de créer un doublon", "brainstorm.database.status.propertyAddedToCollection": "« {name} » ajoutée à cette collection", - "brainstorm.database.status.propertiesUnavailable": "Impossible d'enregistrer — le service de propriétés n'est pas exposé par ce shell", + "brainstorm.database.status.propertiesUnavailable": "Impossible d'enregistrer — les modifications de propriétés ne sont pas disponibles ici.", "brainstorm.database.status.propertySaveFailed": "Échec de l'enregistrement de la propriété — {message}", "brainstorm.database.timeline.empty": "Aucun élément ne correspond à cette vue.", "brainstorm.database.timeline.now": "Maintenant", diff --git a/apps/database/src/i18n/it.json b/apps/database/src/i18n/it.json index 45b628ca..a4e3e98c 100644 --- a/apps/database/src/i18n/it.json +++ b/apps/database/src/i18n/it.json @@ -36,7 +36,7 @@ "brainstorm.database.delete.body": "“{name}” verrà eliminato dal vault — scomparirà da tutti gli elenchi, dalla ricerca e dal grafo.", "brainstorm.database.delete.confirm": "Elimina", "brainstorm.database.delete.cancel": "Annulla", - "brainstorm.database.delete.unavailable": "L'eliminazione richiede il servizio entità (non esposto da questa shell)", + "brainstorm.database.delete.unavailable": "L'eliminazione non è disponibile qui.", "brainstorm.database.delete.done": "“{name}” eliminato", "brainstorm.database.delete.failed": "Eliminazione non riuscita — {message}", "brainstorm.database.timeline.emptyNoValues": "Nessun elemento ha un valore per “{property}”. Scegli un'altra proprietà data in Impostazioni vista → Date.", @@ -172,7 +172,7 @@ "brainstorm.database.status.addedToColumn": "Aggiunto alla colonna", "brainstorm.database.status.addedToGroup": "Aggiunto a “{group}”", "brainstorm.database.status.movedToDate": "Spostato a {date}", - "brainstorm.database.status.dropNeedsEntities": "Il rilascio richiede il servizio entità (non esposto da questa shell)", + "brainstorm.database.status.dropNeedsEntities": "Il rilascio non è disponibile qui.", "brainstorm.database.filter.add": "+ Filtro", "brainstorm.database.sort.add": "+ Ordinamento", "brainstorm.database.filter.clearAll": "Cancella tutto", @@ -183,21 +183,21 @@ "brainstorm.database.filter.valuePlaceholder": "Digita un valore…", "brainstorm.database.filter.valueListPlaceholder": "valore, valore…", "brainstorm.database.filter.valueAria": "Valore del filtro", - "brainstorm.database.status.createNeedsEntities": "La creazione richiede il servizio entità (non esposto da questa shell)", + "brainstorm.database.status.createNeedsEntities": "La creazione non è disponibile qui.", "brainstorm.database.status.openCollectionFirst": "Apri o crea una raccolta per aggiungere qui un oggetto", "brainstorm.database.status.creating": "Creazione…", "brainstorm.database.status.created": "Creato", "brainstorm.database.status.createFailed": "Creazione non riuscita — {message}", "brainstorm.database.row.untitled": "Senza titolo", - "brainstorm.database.status.saveTemplateNeedsEntities": "Salvare come modello richiede il servizio entità (non esposto da questa shell)", + "brainstorm.database.status.saveTemplateNeedsEntities": "Salvare come modello non è disponibile qui.", "brainstorm.database.status.saveTemplateFailed": "Salvataggio come modello non riuscito — {message}", - "brainstorm.database.status.savedLocally": "Salvato localmente — la scrittura delle entità non è esposta da questa shell", + "brainstorm.database.status.savedLocally": "Salvato localmente — le modifiche non possono essere salvate nel vault da qui.", "brainstorm.database.status.saveFailed": "Salvataggio non riuscito — {message}", "brainstorm.database.search.placeholder": "Cerca righe…", "brainstorm.database.search.aria": "Cerca righe", "brainstorm.database.collection.defaultName": "Nuova raccolta", "brainstorm.database.list.defaultName": "Nuovo elenco", - "brainstorm.database.status.importNeedsServices": "L'importazione richiede i servizi file ed entità (non esposti da questa shell)", + "brainstorm.database.status.importNeedsServices": "L'importazione non è disponibile qui.", "brainstorm.database.import.csv.title": "Importa CSV", "brainstorm.database.import.csv.defaultName": "CSV importato", "brainstorm.database.status.csvReadFailed": "Impossibile leggere il file CSV", @@ -260,7 +260,7 @@ "brainstorm.database.status.propertyAdded": "Proprietà “{name}” aggiunta", "brainstorm.database.status.propertyExists": "La proprietà “{name}” esiste già — riutilizzala invece di creare un duplicato", "brainstorm.database.status.propertyAddedToCollection": "“{name}” aggiunta a questa raccolta", - "brainstorm.database.status.propertiesUnavailable": "Impossibile salvare — il servizio proprietà non è esposto da questa shell", + "brainstorm.database.status.propertiesUnavailable": "Impossibile salvare — le modifiche alle proprietà non sono disponibili qui.", "brainstorm.database.status.propertySaveFailed": "Salvataggio della proprietà non riuscito — {message}", "brainstorm.database.timeline.empty": "Nessun elemento corrisponde a questa vista.", "brainstorm.database.timeline.now": "Adesso", diff --git a/apps/database/src/i18n/pt.json b/apps/database/src/i18n/pt.json index dcd3c6cf..f770d02b 100644 --- a/apps/database/src/i18n/pt.json +++ b/apps/database/src/i18n/pt.json @@ -36,7 +36,7 @@ "brainstorm.database.delete.body": "“{name}” será excluído do vault — ele desaparecerá de todas as listas, da pesquisa e do grafo.", "brainstorm.database.delete.confirm": "Excluir", "brainstorm.database.delete.cancel": "Cancelar", - "brainstorm.database.delete.unavailable": "Excluir requer o serviço de entidades (não exposto por este shell)", + "brainstorm.database.delete.unavailable": "Excluir não está disponível aqui.", "brainstorm.database.delete.done": "“{name}” excluído", "brainstorm.database.delete.failed": "Falha ao excluir — {message}", "brainstorm.database.timeline.emptyNoValues": "Nenhum item tem um valor para “{property}”. Escolha outra propriedade de data em Configurações da visualização → Datas.", @@ -172,7 +172,7 @@ "brainstorm.database.status.addedToColumn": "Adicionado à coluna", "brainstorm.database.status.addedToGroup": "Adicionado a “{group}”", "brainstorm.database.status.movedToDate": "Movido para {date}", - "brainstorm.database.status.dropNeedsEntities": "Soltar requer o serviço de entidades (não exposto por este shell)", + "brainstorm.database.status.dropNeedsEntities": "Soltar não está disponível aqui.", "brainstorm.database.filter.add": "+ Filtro", "brainstorm.database.sort.add": "+ Ordenação", "brainstorm.database.filter.clearAll": "Limpar tudo", @@ -183,21 +183,21 @@ "brainstorm.database.filter.valuePlaceholder": "Digite um valor…", "brainstorm.database.filter.valueListPlaceholder": "valor, valor…", "brainstorm.database.filter.valueAria": "Valor do filtro", - "brainstorm.database.status.createNeedsEntities": "Criar requer o serviço de entidades (não exposto por este shell)", + "brainstorm.database.status.createNeedsEntities": "Criar não está disponível aqui.", "brainstorm.database.status.openCollectionFirst": "Abra ou crie uma coleção para adicionar um objeto aqui", "brainstorm.database.status.creating": "Criando…", "brainstorm.database.status.created": "Criado", "brainstorm.database.status.createFailed": "Falha ao criar — {message}", "brainstorm.database.row.untitled": "Sem título", - "brainstorm.database.status.saveTemplateNeedsEntities": "Salvar como modelo requer o serviço de entidades (não exposto por este shell)", + "brainstorm.database.status.saveTemplateNeedsEntities": "Salvar como modelo não está disponível aqui.", "brainstorm.database.status.saveTemplateFailed": "Falha ao salvar como modelo — {message}", - "brainstorm.database.status.savedLocally": "Salvo localmente — a gravação de entidades não é exposta por este shell", + "brainstorm.database.status.savedLocally": "Salvo localmente — as alterações não podem ser salvas no vault a partir daqui.", "brainstorm.database.status.saveFailed": "Falha ao salvar — {message}", "brainstorm.database.search.placeholder": "Pesquisar linhas…", "brainstorm.database.search.aria": "Pesquisar linhas", "brainstorm.database.collection.defaultName": "Nova coleção", "brainstorm.database.list.defaultName": "Nova lista", - "brainstorm.database.status.importNeedsServices": "Importar requer os serviços de arquivos e entidades (não expostos por este shell)", + "brainstorm.database.status.importNeedsServices": "Importar não está disponível aqui.", "brainstorm.database.import.csv.title": "Importar CSV", "brainstorm.database.import.csv.defaultName": "CSV importado", "brainstorm.database.status.csvReadFailed": "Não foi possível ler o arquivo CSV", @@ -260,7 +260,7 @@ "brainstorm.database.status.propertyAdded": "Propriedade “{name}” adicionada", "brainstorm.database.status.propertyExists": "A propriedade “{name}” já existe — reutilize-a em vez de criar uma duplicata", "brainstorm.database.status.propertyAddedToCollection": "“{name}” adicionada a esta coleção", - "brainstorm.database.status.propertiesUnavailable": "Não foi possível salvar — o serviço de propriedades não é exposto por este shell", + "brainstorm.database.status.propertiesUnavailable": "Não foi possível salvar — alterações de propriedades não estão disponíveis aqui.", "brainstorm.database.status.propertySaveFailed": "Falha ao salvar a propriedade — {message}", "brainstorm.database.timeline.empty": "Nenhum item corresponde a esta visualização.", "brainstorm.database.timeline.now": "Agora", diff --git a/apps/files/src/app.tsx b/apps/files/src/app.tsx index 972ade2d..bf82fc0c 100644 --- a/apps/files/src/app.tsx +++ b/apps/files/src/app.tsx @@ -947,6 +947,7 @@ export function FilesApp() { icon={(appearanceFolder.properties.icon as never) ?? null} cover={(appearanceFolder.properties.cover as never) ?? null} covers={runtime?.services?.covers} + icons={runtime?.services?.icons} onChangeIcon={(icon) => store.setEntityIcon(appearanceFolder.id, icon)} onChangeCover={(cover) => store.setEntityCover(appearanceFolder.id, cover)} onClose={() => setAppearance(null)} diff --git a/apps/files/src/types/runtime.ts b/apps/files/src/types/runtime.ts index 90d59aea..8f4ca816 100644 --- a/apps/files/src/types/runtime.ts +++ b/apps/files/src/types/runtime.ts @@ -8,7 +8,7 @@ * replaces only the broker registration behind it, never this shape. */ -import type { StoredAsset } from "@brainstorm-os/sdk-types"; +import type { IconsService, StoredAsset } from "@brainstorm-os/sdk-types"; export type VaultEntityShape = { id: string; @@ -60,6 +60,8 @@ export type BrainstormRuntime = { }) => unknown | Promise; }; services?: { + /** Vault icon store — backs the icon picker's Upload + Library tabs. */ + icons?: IconsService; vaultEntities?: { list(): Promise; onChange?(listener: () => void): BrainstormSubscription; diff --git a/apps/files/src/ui/dialogs.tsx b/apps/files/src/ui/dialogs.tsx index 54cb8bf5..3cb317dc 100644 --- a/apps/files/src/ui/dialogs.tsx +++ b/apps/files/src/ui/dialogs.tsx @@ -5,10 +5,10 @@ * editor (shared `` / ``). */ -import type { Cover, Icon as IconValue } from "@brainstorm-os/sdk-types"; +import type { Cover, Icon as IconValue, IconsService } from "@brainstorm-os/sdk-types"; import { CoverPicker, type CoverPickerService } from "@brainstorm-os/sdk/cover-picker"; import { Icon, IconName } from "@brainstorm-os/sdk/icon"; -import { IconPicker } from "@brainstorm-os/sdk/icon-picker"; +import { IconPicker, iconUploadFromIcons } from "@brainstorm-os/sdk/icon-picker"; import { Popover, PopoverAlign, PopoverSize } from "@brainstorm-os/sdk/popover"; import { useEffect, useRef, useState } from "react"; import { t } from "../i18n"; @@ -347,6 +347,7 @@ export type FolderAppearanceProps = { icon: IconValue | null; cover: Cover | null; covers: CoverPickerService | undefined; + icons: IconsService | undefined; onChangeIcon: (icon: IconValue | null) => void; onChangeCover: (cover: Cover | null) => void; onClose: () => void; @@ -357,6 +358,7 @@ export function FolderAppearanceDialog({ icon, cover, covers, + icons, onChangeIcon, onChangeCover, onClose, @@ -373,7 +375,12 @@ export function FolderAppearanceDialog({ testId="folder-appearance" > {target === AppearanceTarget.Icon ? ( - onChangeIcon(next)} onClose={onClose} /> + onChangeIcon(next)} + onClose={onClose} + iconUpload={iconUploadFromIcons(icons)} + /> ) : ( { openIconPicker({ value: icon, + icons: runtime?.services?.icons, onChange: (next) => store.setEntityIcon(entity.id, next), }); }; diff --git a/apps/graph/src/i18n/de.json b/apps/graph/src/i18n/de.json index 926aea89..5774dcc8 100644 --- a/apps/graph/src/i18n/de.json +++ b/apps/graph/src/i18n/de.json @@ -223,7 +223,7 @@ "link.created": "{source} → {target} als {name} verlinkt", "link.already": "Bereits über {name} verlinkt", "link.failed": "Der Link {name} konnte nicht erstellt werden", - "link.unavailable": "Linkerstellung benötigt den gemeinsamen Entitätendienst", + "link.unavailable": "Links können hier nicht erstellt werden.", "lock.refusedEdit": "{name} ist gesperrt — zum Bearbeiten entsperren", "lock.refusedLink": "{name} ist gesperrt — zum Verlinken entsperren", "history.fab": "Verlaufswiedergabe anzeigen", diff --git a/apps/graph/src/i18n/en.json b/apps/graph/src/i18n/en.json index dbf0eb94..0cde1015 100644 --- a/apps/graph/src/i18n/en.json +++ b/apps/graph/src/i18n/en.json @@ -223,7 +223,7 @@ "link.created": "Linked {source} → {target} as {name}", "link.already": "Already linked via {name}", "link.failed": "Couldn't create the {name} link", - "link.unavailable": "Link creation needs the shared entities service", + "link.unavailable": "Links can't be created here.", "lock.refusedEdit": "{name} is locked — unlock it to edit", "lock.refusedLink": "{name} is locked — unlock it to add links", "history.fab": "Show history playback", diff --git a/apps/graph/src/i18n/es.json b/apps/graph/src/i18n/es.json index 83e3759f..2f92f08d 100644 --- a/apps/graph/src/i18n/es.json +++ b/apps/graph/src/i18n/es.json @@ -223,7 +223,7 @@ "link.created": "Se enlazó {source} → {target} como {name}", "link.already": "Ya enlazado mediante {name}", "link.failed": "No se pudo crear el enlace {name}", - "link.unavailable": "Crear enlaces requiere el servicio compartido de entidades", + "link.unavailable": "No se pueden crear enlaces aquí.", "lock.refusedEdit": "{name} está bloqueado: desbloquéalo para editarlo", "lock.refusedLink": "{name} está bloqueado: desbloquéalo para añadir enlaces", "history.fab": "Mostrar reproducción del historial", diff --git a/apps/graph/src/i18n/fr.json b/apps/graph/src/i18n/fr.json index 48af187a..eb0b1001 100644 --- a/apps/graph/src/i18n/fr.json +++ b/apps/graph/src/i18n/fr.json @@ -223,7 +223,7 @@ "link.created": "{source} → {target} lié en tant que {name}", "link.already": "Déjà lié via {name}", "link.failed": "Impossible de créer le lien {name}", - "link.unavailable": "La création de liens nécessite le service d'entités partagé", + "link.unavailable": "Les liens ne peuvent pas être créés ici.", "lock.refusedEdit": "{name} est verrouillé — déverrouillez-le pour le modifier", "lock.refusedLink": "{name} est verrouillé — déverrouillez-le pour ajouter des liens", "history.fab": "Afficher la lecture de l'historique", diff --git a/apps/graph/src/i18n/it.json b/apps/graph/src/i18n/it.json index a959d994..d51745ed 100644 --- a/apps/graph/src/i18n/it.json +++ b/apps/graph/src/i18n/it.json @@ -223,7 +223,7 @@ "link.created": "Collegato {source} → {target} come {name}", "link.already": "Già collegato tramite {name}", "link.failed": "Impossibile creare il link {name}", - "link.unavailable": "La creazione dei link richiede il servizio condiviso delle entità", + "link.unavailable": "I link non possono essere creati qui.", "lock.refusedEdit": "{name} è bloccato — sbloccalo per modificarlo", "lock.refusedLink": "{name} è bloccato — sbloccalo per aggiungere link", "history.fab": "Mostra riproduzione della cronologia", diff --git a/apps/graph/src/i18n/pt.json b/apps/graph/src/i18n/pt.json index a1afd4da..0f5c2a60 100644 --- a/apps/graph/src/i18n/pt.json +++ b/apps/graph/src/i18n/pt.json @@ -223,7 +223,7 @@ "link.created": "Vinculado {source} → {target} como {name}", "link.already": "Já vinculado via {name}", "link.failed": "Não foi possível criar o link {name}", - "link.unavailable": "A criação de links requer o serviço compartilhado de entidades", + "link.unavailable": "Links não podem ser criados aqui.", "lock.refusedEdit": "{name} está bloqueado — desbloqueie para editar", "lock.refusedLink": "{name} está bloqueado — desbloqueie para adicionar links", "history.fab": "Mostrar reprodução do histórico", diff --git a/apps/journal/src/app.tsx b/apps/journal/src/app.tsx index 24303d0e..0712a33a 100644 --- a/apps/journal/src/app.tsx +++ b/apps/journal/src/app.tsx @@ -1112,6 +1112,7 @@ export function JournalApp(): ReactElement { void setEntryIcon(focus, icon)} /> diff --git a/apps/journal/src/runtime.ts b/apps/journal/src/runtime.ts index 5088ad2d..b78b0f60 100644 --- a/apps/journal/src/runtime.ts +++ b/apps/journal/src/runtime.ts @@ -12,7 +12,7 @@ * falls back to the in-memory demo dataset per [[preview-drop-pattern]]. */ -import type { BpService, VaultEntitiesService } from "@brainstorm-os/sdk-types"; +import type { BpService, IconsService, VaultEntitiesService } from "@brainstorm-os/sdk-types"; import { entityTitleOr } from "@brainstorm-os/sdk/entity-title"; import type { SaveFileService } from "@brainstorm-os/sdk/export-file"; import type { NoteLike } from "./logic/journal-projection"; @@ -158,6 +158,8 @@ export type JournalRuntime = { /** File-upload surface — backs the shared editor's media blocks * (drag-drop / paste / `/image`). Optional; absent in preview. */ storage?: StorageService; + /** Vault icon store — backs the icon picker's Upload + Library tabs. */ + icons?: IconsService; /** Block-registry lookups for the shared editor's `/embed` card. * Optional; absent in preview / standalone. */ blocks?: BlocksService; diff --git a/apps/notes/src/i18n/de.json b/apps/notes/src/i18n/de.json index bdbd73e5..f51e7267 100644 --- a/apps/notes/src/i18n/de.json +++ b/apps/notes/src/i18n/de.json @@ -221,7 +221,7 @@ "notes.format.invalidPhone": "Keine gültige Telefonnummer", "notes.file.region": "Dateien für {name}", "notes.file.empty": "Keine Dateien", - "notes.file.uploadsPending": "Datei-Uploads folgen, sobald die Speicher-Upload-API angebunden ist.", + "notes.file.uploadsPending": "Datei-Uploads sind hier noch nicht verfügbar.", "notes.link.pickerRegion": "{name} mit einer Notiz verknüpfen", "notes.link.search": "Notizen suchen", "notes.link.searchPlaceholder": "Notizen zum Verknüpfen suchen…", @@ -382,11 +382,15 @@ "notes.iconPicker.tab.icon": "Symbol", "notes.iconPicker.tab.upload": "Hochladen", "notes.iconPicker.tab.library": "Bibliothek", - "notes.iconPicker.upload.pending": "Uploads eigener Bilder folgen, sobald der Symbol-Dienst des SDK angebunden ist. Wählen Sie vorerst ein Emoji oder ein Symbol aus dem Symbolpaket.", - "notes.iconPicker.library.pending": "Ihre hochgeladenen Symbole erscheinen hier, sobald der Symbol-Dienst des SDK angebunden ist.", + "notes.iconPicker.upload.pending": "Eigene Bilder lassen sich in dieser App noch nicht hochladen. Wählen Sie stattdessen ein Emoji oder ein Symbol.", + "notes.iconPicker.library.pending": "Hochgeladene Symbole erscheinen hier.", "notes.iconPicker.upload.action": "Bild auswählen…", "notes.iconPicker.upload.uploading": "Wird hochgeladen…", + "notes.iconPicker.upload.dropHint": "Bild hierher ziehen oder klicken, um zu durchsuchen", + "notes.iconPicker.upload.unsupported": "Dieser Dateityp wird nicht unterstützt. Verwenden Sie PNG, JPEG, WebP, GIF, AVIF oder SVG.", "notes.iconPicker.library.empty": "Noch keine eigenen Symbole — laden Sie eines über den Tab „Hochladen“ hoch.", + "notes.iconPicker.library.loading": "Wird geladen…", + "notes.iconPicker.library.item": "Hochgeladenes Symbol", "notes.iconPicker.skinTone": "Hautton", "notes.iconPicker.tint": "Symbolfarbe", "notes.coverPicker.open": "Titelbild ändern", diff --git a/apps/notes/src/i18n/en.json b/apps/notes/src/i18n/en.json index 6aafb93b..6f91659e 100644 --- a/apps/notes/src/i18n/en.json +++ b/apps/notes/src/i18n/en.json @@ -221,7 +221,7 @@ "notes.format.invalidPhone": "Not a valid phone number", "notes.file.region": "Files for {name}", "notes.file.empty": "No files", - "notes.file.uploadsPending": "File uploads land when the storage upload API is wired up.", + "notes.file.uploadsPending": "File uploads aren't available here yet.", "notes.link.pickerRegion": "Link {name} to a note", "notes.link.search": "Search notes", "notes.link.searchPlaceholder": "Search notes to link…", @@ -382,11 +382,15 @@ "notes.iconPicker.tab.icon": "Icon", "notes.iconPicker.tab.upload": "Upload", "notes.iconPicker.tab.library": "Library", - "notes.iconPicker.upload.pending": "Custom-image uploads land when the SDK's icons service is wired up. Pick an emoji or an icon-pack glyph for now.", - "notes.iconPicker.library.pending": "Your uploaded icons will appear here once the SDK's icons service is wired up.", + "notes.iconPicker.upload.pending": "Custom image uploads aren't available in this app yet. Pick an emoji or an icon instead.", + "notes.iconPicker.library.pending": "Icons you upload will appear here.", "notes.iconPicker.upload.action": "Choose image…", "notes.iconPicker.upload.uploading": "Uploading…", + "notes.iconPicker.upload.dropHint": "Drag an image here, or click to browse", + "notes.iconPicker.upload.unsupported": "That file type isn't supported. Use PNG, JPEG, WebP, GIF, AVIF or SVG.", "notes.iconPicker.library.empty": "No custom icons yet — upload one from the Upload tab.", + "notes.iconPicker.library.loading": "Loading…", + "notes.iconPicker.library.item": "Uploaded icon", "notes.iconPicker.skinTone": "Skin tone", "notes.iconPicker.tint": "Icon colour", "notes.coverPicker.open": "Change cover", diff --git a/apps/notes/src/i18n/es.json b/apps/notes/src/i18n/es.json index 0d340906..134a084a 100644 --- a/apps/notes/src/i18n/es.json +++ b/apps/notes/src/i18n/es.json @@ -221,7 +221,7 @@ "notes.format.invalidPhone": "No es un número de teléfono válido", "notes.file.region": "Archivos de {name}", "notes.file.empty": "Sin archivos", - "notes.file.uploadsPending": "Las subidas de archivos llegarán cuando la API de subida de almacenamiento esté conectada.", + "notes.file.uploadsPending": "Las subidas de archivos aún no están disponibles aquí.", "notes.link.pickerRegion": "Enlazar {name} a una nota", "notes.link.search": "Buscar notas", "notes.link.searchPlaceholder": "Buscar notas para enlazar…", @@ -382,11 +382,15 @@ "notes.iconPicker.tab.icon": "Icono", "notes.iconPicker.tab.upload": "Subir", "notes.iconPicker.tab.library": "Biblioteca", - "notes.iconPicker.upload.pending": "Las subidas de imágenes personalizadas llegarán cuando el servicio de iconos del SDK esté conectado. Por ahora elija un emoji o un glifo del paquete de iconos.", - "notes.iconPicker.library.pending": "Sus iconos subidos aparecerán aquí cuando el servicio de iconos del SDK esté conectado.", + "notes.iconPicker.upload.pending": "Esta app aún no permite subir imágenes personalizadas. Elija un emoji o un icono en su lugar.", + "notes.iconPicker.library.pending": "Los iconos que suba aparecerán aquí.", "notes.iconPicker.upload.action": "Elegir imagen…", "notes.iconPicker.upload.uploading": "Subiendo…", + "notes.iconPicker.upload.dropHint": "Arrastre una imagen aquí o haga clic para buscar", + "notes.iconPicker.upload.unsupported": "Ese tipo de archivo no es compatible. Use PNG, JPEG, WebP, GIF, AVIF o SVG.", "notes.iconPicker.library.empty": "Aún no hay iconos personalizados — suba uno desde la pestaña Subir.", + "notes.iconPicker.library.loading": "Cargando…", + "notes.iconPicker.library.item": "Icono subido", "notes.iconPicker.skinTone": "Tono de piel", "notes.iconPicker.tint": "Color del icono", "notes.coverPicker.open": "Cambiar portada", diff --git a/apps/notes/src/i18n/fr.json b/apps/notes/src/i18n/fr.json index ee5e2505..eb37f662 100644 --- a/apps/notes/src/i18n/fr.json +++ b/apps/notes/src/i18n/fr.json @@ -221,7 +221,7 @@ "notes.format.invalidPhone": "Numéro de téléphone non valide", "notes.file.region": "Fichiers pour {name}", "notes.file.empty": "Aucun fichier", - "notes.file.uploadsPending": "Les envois de fichiers arriveront quand l'API d'envoi du stockage sera connectée.", + "notes.file.uploadsPending": "Les envois de fichiers ne sont pas encore disponibles ici.", "notes.link.pickerRegion": "Lier {name} à une note", "notes.link.search": "Rechercher des notes", "notes.link.searchPlaceholder": "Rechercher des notes à lier…", @@ -382,11 +382,15 @@ "notes.iconPicker.tab.icon": "Icône", "notes.iconPicker.tab.upload": "Importer", "notes.iconPicker.tab.library": "Bibliothèque", - "notes.iconPicker.upload.pending": "L'import d'images personnalisées arrivera quand le service d'icônes du SDK sera connecté. Choisissez pour l'instant un emoji ou un glyphe du pack d'icônes.", - "notes.iconPicker.library.pending": "Vos icônes importées apparaîtront ici quand le service d'icônes du SDK sera connecté.", + "notes.iconPicker.upload.pending": "Cette app ne permet pas encore d'importer des images personnalisées. Choisissez plutôt un emoji ou une icône.", + "notes.iconPicker.library.pending": "Les icônes que vous importez apparaîtront ici.", "notes.iconPicker.upload.action": "Choisir une image…", "notes.iconPicker.upload.uploading": "Import en cours…", + "notes.iconPicker.upload.dropHint": "Glissez une image ici, ou cliquez pour parcourir", + "notes.iconPicker.upload.unsupported": "Ce type de fichier n'est pas pris en charge. Utilisez PNG, JPEG, WebP, GIF, AVIF ou SVG.", "notes.iconPicker.library.empty": "Pas encore d'icônes personnalisées — importez-en une depuis l'onglet Importer.", + "notes.iconPicker.library.loading": "Chargement…", + "notes.iconPicker.library.item": "Icône importée", "notes.iconPicker.skinTone": "Couleur de peau", "notes.iconPicker.tint": "Couleur de l'icône", "notes.coverPicker.open": "Changer la couverture", diff --git a/apps/notes/src/i18n/it.json b/apps/notes/src/i18n/it.json index 3f5cb26e..c217d337 100644 --- a/apps/notes/src/i18n/it.json +++ b/apps/notes/src/i18n/it.json @@ -221,7 +221,7 @@ "notes.format.invalidPhone": "Numero di telefono non valido", "notes.file.region": "File per {name}", "notes.file.empty": "Nessun file", - "notes.file.uploadsPending": "I caricamenti di file arriveranno quando l'API di caricamento dello storage sarà collegata.", + "notes.file.uploadsPending": "I caricamenti di file non sono ancora disponibili qui.", "notes.link.pickerRegion": "Colleghi {name} a una nota", "notes.link.search": "Cerca note", "notes.link.searchPlaceholder": "Cerca note da collegare…", @@ -382,11 +382,15 @@ "notes.iconPicker.tab.icon": "Icona", "notes.iconPicker.tab.upload": "Carica", "notes.iconPicker.tab.library": "Libreria", - "notes.iconPicker.upload.pending": "Il caricamento di immagini personalizzate arriverà quando il servizio icone dell'SDK sarà collegato. Per ora scelga un emoji o un glifo dal pacchetto di icone.", - "notes.iconPicker.library.pending": "Le sue icone caricate appariranno qui quando il servizio icone dell'SDK sarà collegato.", + "notes.iconPicker.upload.pending": "Questa app non consente ancora di caricare immagini personalizzate. Scelga invece un emoji o un'icona.", + "notes.iconPicker.library.pending": "Le icone che carica appariranno qui.", "notes.iconPicker.upload.action": "Scegli immagine…", "notes.iconPicker.upload.uploading": "Caricamento…", + "notes.iconPicker.upload.dropHint": "Trascini un'immagine qui, oppure clicchi per sfogliare", + "notes.iconPicker.upload.unsupported": "Questo tipo di file non è supportato. Usi PNG, JPEG, WebP, GIF, AVIF o SVG.", "notes.iconPicker.library.empty": "Ancora nessuna icona personalizzata — ne carichi una dalla scheda Carica.", + "notes.iconPicker.library.loading": "Caricamento…", + "notes.iconPicker.library.item": "Icona caricata", "notes.iconPicker.skinTone": "Tonalità della pelle", "notes.iconPicker.tint": "Colore dell'icona", "notes.coverPicker.open": "Cambia copertina", diff --git a/apps/notes/src/i18n/pt.json b/apps/notes/src/i18n/pt.json index 0762aa4c..d5b29743 100644 --- a/apps/notes/src/i18n/pt.json +++ b/apps/notes/src/i18n/pt.json @@ -221,7 +221,7 @@ "notes.format.invalidPhone": "Número de telefone inválido", "notes.file.region": "Arquivos de {name}", "notes.file.empty": "Nenhum arquivo", - "notes.file.uploadsPending": "O envio de arquivos chegará quando a API de upload do armazenamento estiver conectada.", + "notes.file.uploadsPending": "O envio de arquivos ainda não está disponível aqui.", "notes.link.pickerRegion": "Vincular {name} a uma nota", "notes.link.search": "Pesquisar notas", "notes.link.searchPlaceholder": "Pesquisar notas para vincular…", @@ -382,11 +382,15 @@ "notes.iconPicker.tab.icon": "Ícone", "notes.iconPicker.tab.upload": "Enviar", "notes.iconPicker.tab.library": "Biblioteca", - "notes.iconPicker.upload.pending": "O envio de imagens personalizadas chegará quando o serviço de ícones do SDK estiver conectado. Por enquanto, escolha um emoji ou um glifo do pacote de ícones.", - "notes.iconPicker.library.pending": "Seus ícones enviados aparecerão aqui quando o serviço de ícones do SDK estiver conectado.", + "notes.iconPicker.upload.pending": "Este app ainda não permite enviar imagens personalizadas. Escolha um emoji ou um ícone em vez disso.", + "notes.iconPicker.library.pending": "Os ícones que você enviar aparecerão aqui.", "notes.iconPicker.upload.action": "Escolher imagem…", "notes.iconPicker.upload.uploading": "Enviando…", + "notes.iconPicker.upload.dropHint": "Arraste uma imagem para cá ou clique para procurar", + "notes.iconPicker.upload.unsupported": "Esse tipo de arquivo não é compatível. Use PNG, JPEG, WebP, GIF, AVIF ou SVG.", "notes.iconPicker.library.empty": "Ainda não há ícones personalizados — envie um pela aba Enviar.", + "notes.iconPicker.library.loading": "Carregando…", + "notes.iconPicker.library.item": "Ícone enviado", "notes.iconPicker.skinTone": "Tom de pele", "notes.iconPicker.tint": "Cor do ícone", "notes.coverPicker.open": "Alterar capa", diff --git a/apps/notes/src/ui/icon-picker.tsx b/apps/notes/src/ui/icon-picker.tsx index 7376d1c6..deadf07b 100644 --- a/apps/notes/src/ui/icon-picker.tsx +++ b/apps/notes/src/ui/icon-picker.tsx @@ -7,10 +7,7 @@ */ import type { Icon } from "@brainstorm-os/sdk-types"; -import { - type IconUploadService, - IconPicker as SdkIconPicker, -} from "@brainstorm-os/sdk/icon-picker"; +import { IconPicker as SdkIconPicker, iconUploadFromIcons } from "@brainstorm-os/sdk/icon-picker"; import { useMemo } from "react"; import { t } from "../i18n/t"; import { ActionId } from "../keyboard/action-ids"; @@ -32,15 +29,7 @@ export function IconPicker({ value, onChange, onClose }: IconPickerProps) { // Wire the custom-image Upload/Library tabs to the vault icon store when the // shell exposes it (B11.14). Absent on older shells — the tabs stay // placeholders (the SDK picker handles the undefined case). - const iconUpload = useMemo(() => { - const icons = getBrainstorm()?.services.icons; - if (!icons) return undefined; - return { - upload: (filename, bytes) => icons.uploadBytes(filename, bytes), - list: async () => - (await icons.list()).map((entry) => ({ url: entry.url, thumbUrl: entry.thumbUrl })), - }; - }, []); + const iconUpload = useMemo(() => iconUploadFromIcons(getBrainstorm()?.services.icons), []); return ( { it("interpolates the stage error keys", () => { expect(t("stage.noPreviewFor", { mime: "image/heic" })).toBe("No preview for image/heic"); - expect(t("stage.rendererNotWired", { kind: "pdf" })).toBe("Renderer for pdf not yet wired"); + expect(t("stage.rendererNotWired", { kind: "pdf" })).toBe( + "No preview available for pdf files yet.", + ); expect(t("stage.rendererFailed", { detail: "boom" })).toBe("Renderer failed: boom"); }); diff --git a/apps/preview/src/i18n/de.json b/apps/preview/src/i18n/de.json index b613e9ea..6726a570 100644 --- a/apps/preview/src/i18n/de.json +++ b/apps/preview/src/i18n/de.json @@ -18,7 +18,7 @@ "stage.noFileSelected": "Nichts zum Vorschauen", "stage.noFileSelectedHint": "Öffne eine Datei oder ein Bild aus Dateien oder drücke die Leertaste auf einem Objekt, um es hier per Quick Look anzusehen.", "stage.noPreviewFor": "Keine Vorschau für {mime}", - "stage.rendererNotWired": "Renderer für {kind} noch nicht angebunden", + "stage.rendererNotWired": "Für {kind}-Dateien gibt es noch keine Vorschau.", "stage.rendererFailed": "Renderer fehlgeschlagen: {detail}", "stage.unavailable": "Vorschau nicht verfügbar", "context.fromNote": "Aus Notiz:", diff --git a/apps/preview/src/i18n/en.json b/apps/preview/src/i18n/en.json index 7f26e73d..d01c6711 100644 --- a/apps/preview/src/i18n/en.json +++ b/apps/preview/src/i18n/en.json @@ -18,7 +18,7 @@ "stage.noFileSelected": "Nothing to preview", "stage.noFileSelectedHint": "Open a file or image from Files, or press Space on any object to Quick Look it here.", "stage.noPreviewFor": "No preview for {mime}", - "stage.rendererNotWired": "Renderer for {kind} not yet wired", + "stage.rendererNotWired": "No preview available for {kind} files yet.", "stage.rendererFailed": "Renderer failed: {detail}", "stage.unavailable": "Preview unavailable", "context.fromNote": "From note:", diff --git a/apps/preview/src/i18n/es.json b/apps/preview/src/i18n/es.json index 5d309b2f..9b272610 100644 --- a/apps/preview/src/i18n/es.json +++ b/apps/preview/src/i18n/es.json @@ -18,7 +18,7 @@ "stage.noFileSelected": "Nada que previsualizar", "stage.noFileSelectedHint": "Abre un archivo o imagen desde Archivos, o pulsa Espacio en cualquier objeto para verlo aquí con Vista rápida.", "stage.noPreviewFor": "Sin vista previa para {mime}", - "stage.rendererNotWired": "El renderizador de {kind} aún no está conectado", + "stage.rendererNotWired": "Aún no hay vista previa para archivos {kind}.", "stage.rendererFailed": "Error del renderizador: {detail}", "stage.unavailable": "Vista previa no disponible", "context.fromNote": "De la nota:", diff --git a/apps/preview/src/i18n/fr.json b/apps/preview/src/i18n/fr.json index 2d22a6cc..3b502292 100644 --- a/apps/preview/src/i18n/fr.json +++ b/apps/preview/src/i18n/fr.json @@ -18,7 +18,7 @@ "stage.noFileSelected": "Rien à prévisualiser", "stage.noFileSelectedHint": "Ouvrez un fichier ou une image depuis Fichiers, ou appuyez sur Espace sur un objet pour l'aperçu rapide ici.", "stage.noPreviewFor": "Aucun aperçu pour {mime}", - "stage.rendererNotWired": "Moteur de rendu pour {kind} pas encore branché", + "stage.rendererNotWired": "Pas encore d'aperçu pour les fichiers {kind}.", "stage.rendererFailed": "Échec du rendu : {detail}", "stage.unavailable": "Aperçu indisponible", "context.fromNote": "Depuis la note :", diff --git a/apps/preview/src/i18n/it.json b/apps/preview/src/i18n/it.json index 58d9781f..a6ed49e8 100644 --- a/apps/preview/src/i18n/it.json +++ b/apps/preview/src/i18n/it.json @@ -18,7 +18,7 @@ "stage.noFileSelected": "Niente da visualizzare in anteprima", "stage.noFileSelectedHint": "Apri un file o un'immagine da File, o premi Spazio su un oggetto per vederlo qui con Quick Look.", "stage.noPreviewFor": "Nessuna anteprima per {mime}", - "stage.rendererNotWired": "Renderer per {kind} non ancora collegato", + "stage.rendererNotWired": "Nessuna anteprima disponibile per i file {kind} per ora.", "stage.rendererFailed": "Renderer non riuscito: {detail}", "stage.unavailable": "Anteprima non disponibile", "context.fromNote": "Dalla nota:", diff --git a/apps/preview/src/i18n/pt.json b/apps/preview/src/i18n/pt.json index 4649fd4c..cec1bc1f 100644 --- a/apps/preview/src/i18n/pt.json +++ b/apps/preview/src/i18n/pt.json @@ -18,7 +18,7 @@ "stage.noFileSelected": "Nada para visualizar", "stage.noFileSelectedHint": "Abra um arquivo ou imagem em Arquivos, ou pressione Espaço em um objeto para vê-lo aqui com o Quick Look.", "stage.noPreviewFor": "Sem visualização para {mime}", - "stage.rendererNotWired": "Renderizador para {kind} ainda não conectado", + "stage.rendererNotWired": "Ainda não há pré-visualização para arquivos {kind}.", "stage.rendererFailed": "Falha no renderizador: {detail}", "stage.unavailable": "Visualização indisponível", "context.fromNote": "Da nota:", diff --git a/apps/tasks/src/app.tsx b/apps/tasks/src/app.tsx index ffe5f37d..b2085891 100644 --- a/apps/tasks/src/app.tsx +++ b/apps/tasks/src/app.tsx @@ -727,6 +727,7 @@ export function TasksApp({ entityTitleSource }: TasksAppProps) { (task: Task) => { openIconPicker({ value: task.icon ?? null, + icons: getBrainstorm()?.services.icons, onChange: (icon) => patchTask(task.id, (existing) => ({ ...existing, icon, updatedAt: nowAnchor() })), }); @@ -1759,6 +1760,7 @@ export function TasksApp({ entityTitleSource }: TasksAppProps) { rowProps: (task) => rowPropsFor(task, now), ...(objectMenuRuntime ? { objectMenuRuntime } : {}), onPickProjectIcon, + icons: getBrainstorm()?.services.icons, ...(repository ? { onRemoveProject, onRenameProject, onReorderTasks } : {}), }), ); @@ -2081,6 +2083,7 @@ export function TasksApp({ entityTitleSource }: TasksAppProps) { value: proj.icon ?? null, ariaLabel: t("tasks.header.iconPicker.open"), size: 18, + icons: getBrainstorm()?.services.icons, onChange: (icon) => onPickProjectIcon(proj.id, icon), }); }, [headerProject?.id, headerProject?.icon, openTaskRecord, onPickProjectIcon]); diff --git a/apps/tasks/src/storage/runtime.ts b/apps/tasks/src/storage/runtime.ts index 49fdcd16..7e189b84 100644 --- a/apps/tasks/src/storage/runtime.ts +++ b/apps/tasks/src/storage/runtime.ts @@ -13,6 +13,7 @@ import type { BlocksService, BpService, ExportTextFormat, + IconsService, LaunchContext, PropertiesService, VaultEntitiesService as SdkVaultEntitiesService, @@ -171,6 +172,8 @@ export type TasksBrainstorm = { properties?: PropertiesService; /** Cross-app intent dispatch (object-menu Open routes through it). */ intents?: IntentsService; + /** Vault icon store — backs the icon picker's Upload + Library tabs. */ + icons?: IconsService; /** Dashboard pin surface (object-menu Pin/Unpin). */ dashboard?: DashboardService; /** Vault-entities aggregator. We consume `onChange` only — fans on diff --git a/apps/tasks/src/ui/surface-view.ts b/apps/tasks/src/ui/surface-view.ts index c35bec13..d92be7a5 100644 --- a/apps/tasks/src/ui/surface-view.ts +++ b/apps/tasks/src/ui/surface-view.ts @@ -13,6 +13,7 @@ import type { Icon } from "@brainstorm-os/sdk-types"; import { Orientation, attachCompositeKeyboard } from "@brainstorm-os/sdk/a11y"; import { createEmptyState } from "@brainstorm-os/sdk/empty-state"; import { IconName, createIconElement } from "@brainstorm-os/sdk/icon"; +import type { IconUploadSource } from "@brainstorm-os/sdk/icon-picker"; import { type ObjectMenuContext, type ObjectMenuRuntime, @@ -56,6 +57,8 @@ export type SurfaceViewProps = { * omits them and the icon/menu fall back to display-only chrome. */ objectMenuRuntime?: ObjectMenuRuntime; onPickProjectIcon?(projectId: string, icon: Icon | null): void; + /** The runtime's `services.icons` for the project-icon picker's Upload tab. */ + icons?: IconUploadSource | null | undefined; /** Commit a new name for the body-title inline rename (double-click). * Optional — preview mode (no runtime) omits it and the title is * display-only. */ @@ -171,6 +174,7 @@ function renderHeader(props: SurfaceViewProps): HTMLElement { value: project.icon ?? null, ariaLabel: t("tasks.header.iconPicker.open"), size: 22, + icons: props.icons, onChange: (icon) => props.onPickProjectIcon?.(projectId, icon), }), ); diff --git a/apps/whiteboard/src/engine.ts b/apps/whiteboard/src/engine.ts index a7127558..e7c8cdd5 100644 --- a/apps/whiteboard/src/engine.ts +++ b/apps/whiteboard/src/engine.ts @@ -2600,6 +2600,7 @@ export function createWhiteboardEngine(hosts: EngineHosts): WhiteboardEngine { void import("@brainstorm-os/sdk/picker-host").then(({ openIconPicker }) => { openIconPicker({ value: state.whiteboard.icon ?? null, + icons: getBrainstorm()?.services?.icons, onChange: (next) => { state.whiteboard.icon = next; persistBoard(); diff --git a/apps/whiteboard/src/storage/runtime.ts b/apps/whiteboard/src/storage/runtime.ts index 46b555f8..95517249 100644 --- a/apps/whiteboard/src/storage/runtime.ts +++ b/apps/whiteboard/src/storage/runtime.ts @@ -10,7 +10,7 @@ * [[preview-drop-pattern]]. */ -import type { SettingsService } from "@brainstorm-os/sdk-types"; +import type { IconsService, SettingsService } from "@brainstorm-os/sdk-types"; type StorageValue = unknown; @@ -124,6 +124,8 @@ export type WhiteboardBrainstorm = { /** Per-device, app-namespaced settings — backs the per-board camera * memory (9.17.24). Absent in preview / standalone. */ settings?: SettingsService; + /** Vault icon store — backs the icon picker's Upload + Library tabs. */ + icons?: IconsService; /** Present once the shell exposes the shared entities service. * When available Whiteboard reads/writes the shared object space * (9.3.5.5) instead of its `kv.json` silo. */ diff --git a/packages/sdk/src/cover-picker/cover-picker.css b/packages/sdk/src/cover-picker/cover-picker.css index 0b70088a..05695bf7 100644 --- a/packages/sdk/src/cover-picker/cover-picker.css +++ b/packages/sdk/src/cover-picker/cover-picker.css @@ -1,9 +1,9 @@ /** * Cover-specific bits layered on the shared `.icon-picker` chrome - * (panel / backdrop / tabs / action / body / empty). Only the - * cover-unique surfaces live here: the live preview band, the upload - * drop zone, the row-virtualised library, the focal-point control, the - * gradient/colour swatch grid, and the Apply footer. + * (panel / backdrop / tabs / action / body / empty / drop zone). Only the + * cover-unique surfaces live here: the live preview band, the compact + * drop-zone modifier, the row-virtualised library, the focal-point + * control, the gradient/colour swatch grid, and the Apply footer. * * Layout contract: the body is a non-scrolling flex column. The * fixed-height bits (preview / drop zone / focal / footer) never shrink; @@ -52,65 +52,19 @@ gap: var(--space-3, var(--space-3)); } -/* Upload drop zone — the click affordance and the drag-and-drop target - in one, replacing the old lone button + bare empty sentence. */ -.cover-picker__dropzone { +/* The drop zone itself is the shared `.icon-picker__dropzone`; here it + keeps its natural height so the library below takes the leftover. */ +.cover-picker__image .icon-picker__dropzone { flex: 0 0 auto; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--space-1, var(--space-1)); - width: 100%; - padding: var(--space-5, var(--space-5)) var(--space-3, var(--space-3)); - border: var(--border-width) dashed var(--border-strong, rgba(127, 127, 127, 0.45)); - border-radius: var(--radius-md); - background: var(--color-surface-default, rgba(127, 127, 127, 0.08)); - color: var(--text, inherit); - cursor: pointer; - transition: border-color 120ms ease, background-color 120ms ease; -} - -.cover-picker__dropzone:hover, -.cover-picker__dropzone--over { - border-color: var(--color-accent-default); - background: var(--color-accent-subtle); -} - -.cover-picker__dropzone:disabled { - opacity: 0.6; - cursor: progress; -} - -.cover-picker__dropzone:focus-visible { - outline: 2px solid var(--color-focus-ring); - outline-offset: -1px; - /* Ring replaces the dashed border (no sandwich) — match the swatch. */ - border-color: transparent; -} - -.cover-picker__dropzone-title { - font-size: var(--text-size-md); - font-weight: 500; -} - -.cover-picker__dropzone-hint { - font-size: var(--text-size-xs); - color: var(--text-dim, rgba(127, 127, 127, 0.9)); } /* Slim "replace image" bar once a cover is chosen — keeps the focal control + Apply on screen instead of a tall empty-state target. */ -.cover-picker__dropzone--compact { +.cover-picker__image .icon-picker__dropzone--compact { flex-direction: row; padding: var(--space-2, var(--space-2)) var(--space-3, var(--space-3)); } -.cover-picker__dropzone--compact .cover-picker__dropzone-title { - font-size: var(--text-size-md); - font-weight: 500; -} - /* "Remove cover" is an icon-only button — square, centred glyph, sized to match the surrounding tab buttons' visual weight. Quiet at rest (no resting red border / fill), danger color reveals on hover, so it diff --git a/packages/sdk/src/cover-picker/picker.tsx b/packages/sdk/src/cover-picker/picker.tsx index 4cc97e3c..62dcca38 100644 --- a/packages/sdk/src/cover-picker/picker.tsx +++ b/packages/sdk/src/cover-picker/picker.tsx @@ -289,12 +289,12 @@ function ImageTab({ {library.length === 0 ? ( diff --git a/packages/sdk/src/i18n/common-labels.ts b/packages/sdk/src/i18n/common-labels.ts index cbf02de4..c44d4f54 100644 --- a/packages/sdk/src/i18n/common-labels.ts +++ b/packages/sdk/src/i18n/common-labels.ts @@ -32,7 +32,15 @@ export type IconPickerLabels = { * `*Pending` placeholders show). */ uploadAction: string; uploading: string; + /** Drop-zone affordance under the action, e.g. "Drag an image here, or click to browse". */ + dropHint: string; + /** Shown when a dropped file's extension is outside the accepted image set. */ + unsupportedFile: string; libraryEmpty: string; + /** Library tab while the uploaded-icon list is being fetched. */ + loading: string; + /** Accessible name of one uploaded-icon cell (the images carry no title). */ + libraryItem: string; skinToneRegion: string; /** Per-tone option label, `{tone}` substituted with the friendly tone * name from `skinToneNames`. Drives the radio's accessible name. */ @@ -150,11 +158,15 @@ export const DEFAULT_ICON_PICKER_LABELS: IconPickerLabels = { tabIcon: "Icon", tabUpload: "Upload", tabLibrary: "Library", - uploadPending: "Custom-image uploads land with the entities write half.", - libraryPending: "Your uploaded icons will appear here.", + uploadPending: "This app can't upload custom images yet. Pick an emoji or an icon instead.", + libraryPending: "Icons you upload will appear here.", uploadAction: "Choose image…", uploading: "Uploading…", + dropHint: "Drag an image here, or click to browse", + unsupportedFile: "That file type isn't supported. Use PNG, JPEG, WebP, GIF, AVIF or SVG.", libraryEmpty: "No custom icons yet — upload one from the Upload tab.", + loading: "Loading…", + libraryItem: "Uploaded icon", skinToneRegion: "Skin tone", skinToneOption: "Skin tone: {tone}", skinToneNames: { diff --git a/packages/sdk/src/icon-picker/icon-picker.css b/packages/sdk/src/icon-picker/icon-picker.css index 26639b2a..2050dd21 100644 --- a/packages/sdk/src/icon-picker/icon-picker.css +++ b/packages/sdk/src/icon-picker/icon-picker.css @@ -337,33 +337,95 @@ cursor: pointer; } -/* Upload + Library tabs (B11.14 custom emoji). */ +/* Upload + Library tabs (B11.14 custom emoji). The Upload tab is ONE + * drop zone that fills the body — click to browse, or drag an image onto + * it — so the whole panel is the target instead of a lone button adrift + * in empty space. */ .icon-picker__upload { + flex: 1; + min-height: 0; display: flex; flex-direction: column; - align-items: center; - justify-content: center; gap: var(--space-3); - min-height: 160px; - padding: var(--space-5); - text-align: center; + padding: var(--space-4); } .icon-picker__upload-input { display: none; } +.icon-picker__upload .icon-picker__dropzone { + flex: 1; + min-height: 0; +} + .icon-picker__upload-error { - color: var(--danger, #dc2626); + flex: 0 0 auto; + color: var(--danger); + font-size: var(--text-size-sm); + text-align: center; +} + +/* Shared drop zone — the click affordance and the drag-and-drop target in + * one. The cover picker reuses this base and adds only its compact + * "replace image" modifier; a consumer sets its own flex sizing. */ +.icon-picker__dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-1); + width: 100%; + padding: var(--space-5) var(--space-3); + border: var(--border-width) dashed var(--border-strong); + border-radius: var(--radius-md); + background: transparent; + color: var(--text); + font: inherit; + text-align: center; + cursor: pointer; + transition: + border-color var(--motion-duration-fast) var(--motion-easing-standard), + background var(--motion-duration-fast) var(--motion-easing-standard); +} + +.icon-picker__dropzone:hover, +.icon-picker__dropzone--over { + border-color: var(--color-accent-default); + background: var(--color-accent-subtle); +} + +.icon-picker__dropzone:disabled { + opacity: 0.6; + cursor: progress; +} + +.icon-picker__dropzone:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: -1px; + /* Ring replaces the dashed border (no sandwich). */ + border-color: transparent; +} + +.icon-picker__dropzone-title { + font-size: var(--text-size-md); + font-weight: 500; +} + +.icon-picker__dropzone-hint { font-size: var(--text-size-xs); - max-width: 260px; + color: var(--text-dim); } +/* The library owns the body's one scrollbar — without `flex: 1; min-height: 0` + * a long list would simply be clipped by the panel's `overflow: hidden`. */ .icon-picker__library { + flex: 1; + min-height: 0; display: grid; grid-template-columns: repeat(auto-fill, 40px); gap: var(--space-2); - padding: var(--space-2); + padding: var(--space-3); overflow-y: auto; align-content: start; } @@ -379,8 +441,13 @@ } .icon-picker__library-cell:hover { - background: var(--color-accent-subtle, rgba(0, 0, 0, 0.06)); - border-color: var(--color-border-subtle, rgba(0, 0, 0, 0.1)); + background: var(--color-accent-subtle); + border-color: var(--color-border-subtle); +} + +.icon-picker__library-cell:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: -1px; } .icon-picker__library-img { diff --git a/packages/sdk/src/icon-picker/icon-upload.ts b/packages/sdk/src/icon-picker/icon-upload.ts new file mode 100644 index 00000000..7f2071bd --- /dev/null +++ b/packages/sdk/src/icon-picker/icon-upload.ts @@ -0,0 +1,23 @@ +import type { IconUploadService } from "./picker"; + +/** The minimum of the runtime's `services.icons` the picker needs — structural, + * so each app's narrow runtime type (and the full `IconsService`) both fit. */ +export type IconUploadSource = { + uploadBytes(filename: string, bytes: Uint8Array): Promise<{ url: string; thumbUrl: string }>; + list(): Promise>; +}; + +/** Adapt `services.icons` into the picker's upload backing. This is the one + * adapter every host goes through: Notes and Contacts each hand-rolled it + * while six DOM-hosted apps passed nothing, leaving their Upload and Library + * tabs as permanent placeholders (F-524). `undefined` in → `undefined` out, + * so a host without the service keeps the placeholder path. */ +export function iconUploadFromIcons( + icons: IconUploadSource | null | undefined, +): IconUploadService | undefined { + if (!icons) return undefined; + return { + upload: (filename, bytes) => icons.uploadBytes(filename, bytes), + list: async () => (await icons.list()).map((e) => ({ url: e.url, thumbUrl: e.thumbUrl })), + }; +} diff --git a/packages/sdk/src/icon-picker/index.ts b/packages/sdk/src/icon-picker/index.ts index d889782e..cef6a41b 100644 --- a/packages/sdk/src/icon-picker/index.ts +++ b/packages/sdk/src/icon-picker/index.ts @@ -12,6 +12,7 @@ export { type IconPickerLabels, type IconUploadService, } from "./picker"; +export { type IconUploadSource, iconUploadFromIcons } from "./icon-upload"; export { DEFAULT_ICON_PICKER_LABELS } from "../i18n/common-labels"; export { EMOJI_GROUPS, diff --git a/packages/sdk/src/icon-picker/picker.test.tsx b/packages/sdk/src/icon-picker/picker.test.tsx index b9e98e53..37060a25 100644 --- a/packages/sdk/src/icon-picker/picker.test.tsx +++ b/packages/sdk/src/icon-picker/picker.test.tsx @@ -12,7 +12,8 @@ import { IconKind } from "@brainstorm-os/sdk-types"; import { act } from "react"; import { type Root, createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { IconPicker, type IconPickerLabels } from "./picker"; +import { iconUploadFromIcons } from "./icon-upload"; +import { IconPicker, type IconPickerLabels, type IconUploadService } from "./picker"; const LABELS: IconPickerLabels = { region: "Pick icon", @@ -29,7 +30,11 @@ const LABELS: IconPickerLabels = { libraryPending: "Library pending", uploadAction: "Choose image…", uploading: "Uploading…", + dropHint: "Drop an image here", + unsupportedFile: "Unsupported file", libraryEmpty: "No custom icons yet", + loading: "Loading library…", + libraryItem: "Uploaded icon", skinToneRegion: "Skin tone", skinToneOption: "Skin tone: {tone}", skinToneNames: { @@ -111,6 +116,146 @@ describe("IconPicker (shared SDK component)", () => { expect(active?.textContent).toBe("Icon"); }); + function clickTab(label: string) { + const tab = [...container.querySelectorAll('[role="tab"]')].find( + (t) => t.textContent === label, + ); + if (!tab) throw new Error(`no tab ${label}`); + act(() => tab.click()); + } + + function flush() { + return act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + } + + function service(list: { url: string; thumbUrl: string }[] = []): IconUploadService { + return { + upload: vi.fn(async (name: string) => ({ + url: `brainstorm://icon/${name}`, + thumbUrl: `thumb:${name}`, + })), + list: vi.fn(async () => list), + }; + } + + it("Upload tab is a drop zone that fills the body, with no stray footer", () => { + act(() => { + root.render( + {}} + onClose={() => {}} + labels={LABELS} + iconUpload={service()} + />, + ); + }); + clickTab("Upload"); + const zone = container.querySelector(".icon-picker__dropzone"); + expect(zone?.textContent).toBe("Choose image…Drop an image here"); + // The old 28px icon-button class crammed "Choose image…" into a square. + expect(zone?.classList.contains("icon-picker__action")).toBe(false); + // Skin-tone / tint rows belong to Emoji / Icon only — Upload has no footer band. + expect(container.querySelector(".icon-picker__footer")).toBeNull(); + expect(container.querySelector(".icon-picker__search")).toBeNull(); + }); + + it("dropping an image uploads it and picks the stored URL", async () => { + const onChange = vi.fn(); + const onClose = vi.fn(); + const svc = service(); + act(() => { + root.render( + , + ); + }); + clickTab("Upload"); + const zone = container.querySelector(".icon-picker__dropzone"); + if (!zone) throw new Error("no drop zone"); + const file = new File([new Uint8Array([1, 2, 3])], "logo.png", { type: "image/png" }); + await act(async () => { + const event = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { value: { files: [file] } }); + zone.dispatchEvent(event); + }); + await flush(); + expect(svc.upload).toHaveBeenCalledWith("logo.png", new Uint8Array([1, 2, 3])); + expect(onChange).toHaveBeenCalledWith({ + kind: IconKind.Image, + value: "brainstorm://icon/logo.png", + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("rejects a dropped non-image without touching the store", async () => { + const onChange = vi.fn(); + const svc = service(); + act(() => { + root.render( + {}} + labels={LABELS} + iconUpload={svc} + />, + ); + }); + clickTab("Upload"); + const zone = container.querySelector(".icon-picker__dropzone"); + if (!zone) throw new Error("no drop zone"); + const file = new File(["hello"], "notes.txt", { type: "text/plain" }); + await act(async () => { + const event = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { value: { files: [file] } }); + zone.dispatchEvent(event); + }); + await flush(); + expect(svc.upload).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + expect(container.querySelector('[role="alert"]')?.textContent).toBe("Unsupported file"); + }); + + it("Library tab says it is loading, then names every uploaded icon", async () => { + const svc = service([{ url: "brainstorm://icon/a.png", thumbUrl: "thumb:a" }]); + act(() => { + root.render( + {}} + onClose={() => {}} + labels={LABELS} + iconUpload={svc} + />, + ); + }); + clickTab("Library"); + expect(container.querySelector(".icon-picker__pending")?.textContent).toBe("Loading library…"); + await flush(); + const cells = container.querySelectorAll(".icon-picker__library-cell"); + expect(cells).toHaveLength(1); + expect(cells[0]?.getAttribute("aria-label")).toBe("Uploaded icon"); + expect(container.querySelector(".icon-picker__footer")).toBeNull(); + }); + + it("without an upload service the Upload tab shows the plain-language placeholder", () => { + act(() => { + root.render( {}} onClose={() => {}} labels={LABELS} />); + }); + clickTab("Upload"); + expect(container.querySelector(".icon-picker__pending")?.textContent).toBe("Upload pending"); + expect(container.querySelector(".icon-picker__dropzone")).toBeNull(); + }); + it("backdrop click closes without mutating the icon", () => { const onChange = vi.fn(); const onClose = vi.fn(); @@ -124,3 +269,23 @@ describe("IconPicker (shared SDK component)", () => { expect(onChange).not.toHaveBeenCalled(); }); }); + +describe("iconUploadFromIcons", () => { + it("is undefined without a service, so hosts keep the placeholder path", () => { + expect(iconUploadFromIcons(undefined)).toBeUndefined(); + expect(iconUploadFromIcons(null)).toBeUndefined(); + }); + + it("adapts uploadBytes/list and strips the extra entry fields", async () => { + const bytes = new Uint8Array([1]); + const icons = { + uploadBytes: vi.fn(async () => ({ url: "u", thumbUrl: "t" })), + list: vi.fn(async () => [{ url: "a", thumbUrl: "ta", hash: "h", bytes: 3 }]), + }; + const adapted = iconUploadFromIcons(icons); + if (!adapted) throw new Error("expected an adapter"); + await expect(adapted.upload("x.png", bytes)).resolves.toEqual({ url: "u", thumbUrl: "t" }); + expect(icons.uploadBytes).toHaveBeenCalledWith("x.png", bytes); + await expect(adapted.list()).resolves.toEqual([{ url: "a", thumbUrl: "ta" }]); + }); +}); diff --git a/packages/sdk/src/icon-picker/picker.tsx b/packages/sdk/src/icon-picker/picker.tsx index 81ea0542..613fde04 100644 --- a/packages/sdk/src/icon-picker/picker.tsx +++ b/packages/sdk/src/icon-picker/picker.tsx @@ -8,8 +8,9 @@ * - Icon tab: every Phosphor icon from `@phosphor-icons/core` * (1530 glyphs, 18 categories), virtualised; the React components * come from a lazy chunk so the host bundle stays small. - * - Upload + Library: tabs render placeholders pending an SDK - * `icons` service for apps (B7.2). + * - Upload + Library: a drag-and-drop / click-to-browse zone and the + * grid of previously-uploaded images, backed by the host's + * `iconUpload` service; without one both tabs show a placeholder. * * Both grids share a column-count driven by ResizeObserver so cells stay * uniformly sized across resizes. @@ -260,10 +261,16 @@ export function IconPicker({ value, onChange, onClose, labels, iconUpload }: Ico ))} -
- {tab === PickerTab.Emoji && } - {tab === PickerTab.Icon && } -
+ {tab === PickerTab.Emoji && ( +
+ +
+ )} + {tab === PickerTab.Icon && ( +
+ +
+ )} ); @@ -506,6 +513,7 @@ function PendingTab({ message }: { message: string }) { // ─── Upload + Library tabs (B11.14 custom emoji) ──────────────────────── const ICON_UPLOAD_ACCEPT = ".png,.jpg,.jpeg,.webp,.gif,.avif,.svg"; +const ICON_UPLOAD_EXTS = /\.(png|jpe?g|webp|gif|avif|svg)$/i; function UploadTab({ service, @@ -518,10 +526,17 @@ function UploadTab({ }) { const inputRef = useRef(null); const [busy, setBusy] = useState(false); + const [dragOver, setDragOver] = useState(false); const [error, setError] = useState(null); async function onFile(file: File | undefined) { if (!file) return; + // The hidden input filters by `accept`, but a drop bypasses it — check + // the extension on both paths so a stray .txt never reaches the store. + if (!ICON_UPLOAD_EXTS.test(file.name)) { + setError(labels.unsupportedFile); + return; + } setBusy(true); setError(null); try { @@ -543,18 +558,40 @@ function UploadTab({ accept={ICON_UPLOAD_ACCEPT} className="icon-picker__upload-input" aria-label={labels.uploadAction} - onChange={(e) => void onFile(e.target.files?.[0])} + onChange={(e) => { + void onFile(e.target.files?.[0]); + e.target.value = ""; + }} disabled={busy} /> - {error &&
{error}
} + {error && ( +
+ {error} +
+ )} ); } @@ -585,7 +622,7 @@ function LibraryTab({ }; }, [service]); - if (items === null) return
{labels.uploading}
; + if (items === null) return
{labels.loading}
; if (items.length === 0) return
{labels.libraryEmpty}
; // Plain focusable buttons in a grid — natively Tab-reachable + Enter/Space // activate, so the library is keyboard-accessible without a custom listbox. @@ -596,7 +633,7 @@ function LibraryTab({ key={item.url} type="button" className="icon-picker__library-cell" - title={labels.tabLibrary} + aria-label={labels.libraryItem} onClick={() => onPick(item.url)} > diff --git a/packages/sdk/src/picker-host.test.tsx b/packages/sdk/src/picker-host.test.tsx index f1bd269c..9965fa81 100644 --- a/packages/sdk/src/picker-host.test.tsx +++ b/packages/sdk/src/picker-host.test.tsx @@ -201,6 +201,34 @@ describe("openInlinePropertyForm", () => { }); describe("openIconPicker", () => { + it("threads the host's icons service into the Upload tab (F-524)", () => { + const icons = { + uploadBytes: vi.fn(async () => ({ url: "brainstorm://icon/x", thumbUrl: "t" })), + list: vi.fn(async () => []), + }; + flushSync(() => openIconPicker({ value: null, onChange: () => {}, icons })); + const upload = [...document.querySelectorAll('[role="tab"]')].find( + (tab) => tab.textContent === "Upload", + ); + if (!upload) throw new Error("no Upload tab"); + flushSync(() => upload.click()); + expect(document.querySelector(".icon-picker__dropzone")).not.toBeNull(); + expect(document.querySelector(".icon-picker__pending")).toBeNull(); + flushSync(() => closePicker()); + }); + + it("without an icons service the Upload tab keeps its placeholder", () => { + flushSync(() => openIconPicker({ value: null, onChange: () => {} })); + const upload = [...document.querySelectorAll('[role="tab"]')].find( + (tab) => tab.textContent === "Upload", + ); + if (!upload) throw new Error("no Upload tab"); + flushSync(() => upload.click()); + expect(document.querySelector(".icon-picker__dropzone")).toBeNull(); + expect(document.querySelector(".icon-picker__pending")).not.toBeNull(); + flushSync(() => closePicker()); + }); + it("Escape closes the icon picker (keyboard parity with the backdrop)", () => { try { flushSync(() => openIconPicker({ value: null, onChange: vi.fn() })); diff --git a/packages/sdk/src/picker-host.tsx b/packages/sdk/src/picker-host.tsx index 1d5b2592..13aef210 100644 --- a/packages/sdk/src/picker-host.tsx +++ b/packages/sdk/src/picker-host.tsx @@ -23,7 +23,12 @@ import { type CoverPickerService, } from "@brainstorm-os/sdk/cover-picker"; import { createEntityIconElement } from "@brainstorm-os/sdk/entity-icon"; -import { IconPicker, type IconPickerLabels } from "@brainstorm-os/sdk/icon-picker"; +import { + IconPicker, + type IconPickerLabels, + type IconUploadSource, + iconUploadFromIcons, +} from "@brainstorm-os/sdk/icon-picker"; import { type ReactNode, useEffect, useRef } from "react"; import { type Root, createRoot } from "react-dom/client"; import { @@ -84,13 +89,18 @@ export function openIconPicker(opts: { value: Icon | null; onChange: (icon: Icon | null) => void; labels?: Partial; + /** The host runtime's `services.icons`; without it the Upload + Library + * tabs show their "not available here" placeholder. */ + icons?: IconUploadSource | null | undefined; }): void { const r = ensureRoot(); + const iconUpload = iconUploadFromIcons(opts.icons); r.render( opts.onChange(icon)} onClose={closePicker} /> @@ -150,6 +160,8 @@ export type IconPickerButtonOptions = { ariaLabel: string; onChange: (icon: Icon | null) => void; labels?: Partial; + /** The host runtime's `services.icons` — see `openIconPicker`. */ + icons?: IconUploadSource | null | undefined; /** Rendered when the object has no own icon. Defaults to the * dashed-add glyph (a 'set me' affordance); callers that want a * type fallback (e.g. the per-object canonical icon from @@ -189,6 +201,7 @@ export function createIconPickerButton(opts: IconPickerButtonOptions): HTMLButto openIconPicker({ value: current, ...(opts.labels ? { labels: opts.labels } : {}), + ...(opts.icons ? { icons: opts.icons } : {}), onChange: (icon) => { current = icon; paint(); @@ -213,11 +226,12 @@ export function IconPickerButton(props: IconPickerButtonOptions) { ...(props.size !== undefined ? { size: props.size } : {}), ariaLabel: props.ariaLabel, ...(props.labels ? { labels: props.labels } : {}), + ...(props.icons ? { icons: props.icons } : {}), onChange: (icon) => onChange.current(icon), }); el.replaceChildren(btn); return () => el.replaceChildren(); - }, [props.value, props.size, props.ariaLabel, props.labels]); + }, [props.value, props.size, props.ariaLabel, props.labels, props.icons]); return ; } diff --git a/packages/sdk/src/property-ui/cells/b59-cells.test.tsx b/packages/sdk/src/property-ui/cells/b59-cells.test.tsx index 5c1fee4e..8160d06a 100644 --- a/packages/sdk/src/property-ui/cells/b59-cells.test.tsx +++ b/packages/sdk/src/property-ui/cells/b59-cells.test.tsx @@ -179,7 +179,7 @@ describe("File cells (accept-only)", () => { onChange: vi.fn(), noteId: "n1", }); - expect(h.container.querySelector(".bs-cell-file-pending")?.textContent).toContain("uploads land"); + expect(h.container.querySelector(".bs-cell-file-pending")?.textContent).toContain("aren't available here"); expect(h.container.querySelector(".bs-cell-file-empty")).not.toBeNull(); }); diff --git a/packages/sdk/src/property-ui/seams.ts b/packages/sdk/src/property-ui/seams.ts index 463792ca..e09ba999 100644 --- a/packages/sdk/src/property-ui/seams.ts +++ b/packages/sdk/src/property-ui/seams.ts @@ -219,7 +219,7 @@ export const DEFAULT_PROPERTY_UI_LABELS: PropertyUiLabels = { fileRegion: (name) => `Files for ${name}`, fileEmpty: "No files", - fileUploadsPending: "File uploads land when the storage upload API is wired up.", + fileUploadsPending: "File uploads aren't available here yet.", linkPickerRegion: (name) => `Link ${name}`, linkSearch: "Search to link", diff --git a/packages/shell/package.json b/packages/shell/package.json index a522ae3c..6be81cf2 100644 --- a/packages/shell/package.json +++ b/packages/shell/package.json @@ -72,6 +72,11 @@ "from": "art/wallpaper", "to": "art/wallpaper" }, + { + "from": "art/emoji", + "to": "art/emoji", + "filter": ["*.webp"] + }, { "from": "art", "to": "art", diff --git a/packages/shell/scripts/preview-package.mjs b/packages/shell/scripts/preview-package.mjs index 2124111a..20042069 100644 --- a/packages/shell/scripts/preview-package.mjs +++ b/packages/shell/scripts/preview-package.mjs @@ -174,6 +174,15 @@ function main() { } } + // art/emoji — the icon picker's built-in glyph set, served via + // brainstorm://emoji/.webp. It lives OUTSIDE the asar as an + // extraResources entry; forgetting it ships a picker full of broken + // images (0.15.0 regression). + const emojiDir = join(found.root, "art", "emoji"); + const sampleEmoji = join(emojiDir, "1f600.webp"); + if (!existsSync(sampleEmoji)) fail(`missing art/emoji extraResources (expected ${sampleEmoji})`); + else ok(`art/emoji present (${readdirSync(emojiDir).length} glyphs)`); + // at least one bundled .node — look in resources/app.asar.unpacked/ // (asarUnpack rewrites the path) or anywhere under the resources root. const nativeFile = findFileRecursive(found.root, (_abs, name) => name.endsWith(".node")); diff --git a/packages/shell/src/main/packaging-resources.test.ts b/packages/shell/src/main/packaging-resources.test.ts new file mode 100644 index 00000000..26631bd6 --- /dev/null +++ b/packages/shell/src/main/packaging-resources.test.ts @@ -0,0 +1,38 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * The `brainstorm://` protocol handler serves static art from + * `process.resourcesPath/art` when packaged — and electron-builder only + * copies what `build.extraResources` names. Every directory the handler + * reads from there must be listed, or the packaged app silently 404s what + * dev (which reads `packages/shell/art` straight from source) renders fine. + * 0.15.0 shipped without `art/emoji`: the icon picker's emoji grid and + * category tabs were all broken images. + */ +const PKG_DIR = join(__dirname, "../.."); +const pkg = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8")) as { + build: { extraResources: Array<{ from: string; to: string; filter?: string[] }> }; +}; +const extra = pkg.build.extraResources; + +describe("packaged extraResources", () => { + it("ships the emoji glyph set the brainstorm://emoji handler serves", () => { + const entry = extra.find((e) => e.to === "art/emoji"); + expect(entry?.from).toBe("art/emoji"); + // The filter must admit the files that are actually there. + const files = readdirSync(join(PKG_DIR, "art", "emoji")); + expect(files.length).toBeGreaterThan(1000); + expect(files.every((f) => f.endsWith(".webp"))).toBe(true); + if (entry?.filter) expect(entry.filter).toContain("*.webp"); + }); + + it("ships the wallpapers and the tray/app icons", () => { + expect(extra.find((e) => e.to === "art/wallpaper")?.from).toBe("art/wallpaper"); + const icons = extra.find((e) => e.to === "art" && e.from === "art"); + expect(icons?.filter).toEqual( + expect.arrayContaining(["icon.png", "tray.png", "trayTemplate.png"]), + ); + }); +}); diff --git a/packages/shell/src/renderer/i18n/en.json b/packages/shell/src/renderer/i18n/en.json index 4b9ed32d..e87907cc 100644 --- a/packages/shell/src/renderer/i18n/en.json +++ b/packages/shell/src/renderer/i18n/en.json @@ -1473,7 +1473,7 @@ "shell.settings.network.outcome.aborted": "Aborted", "shell.settings.network.outcome.errored": "Errored", "shell.settings.network.embeds.title": "Embed providers", - "shell.settings.network.embeds.placeholder": "Curated provider list ships with the Stage 9 embed sandbox.", + "shell.settings.network.embeds.placeholder": "Links from YouTube, Vimeo, Loom, Figma and CodeSandbox embed in a sandboxed frame. Other links stay bookmark cards.", "shell.format.justNow": "just now", "shell.format.secondsAgo": "{count}s ago", "shell.format.minutesAgo": "{count}m ago",