diff --git a/solstone/apps/settings/routes.py b/solstone/apps/settings/routes.py
index c8a71a50d..ebbc31e83 100644
--- a/solstone/apps/settings/routes.py
+++ b/solstone/apps/settings/routes.py
@@ -26,6 +26,7 @@ from solstone.apps.settings import install_copy, transcribe_resource
from solstone.apps.utils import log_app_action
from solstone.convey import chat_stream, state
from solstone.convey import copy as convey_copy
+from solstone.convey.icons import resolve_facet_icon_svg
from solstone.convey.reasons import (
ACTIVITY_INVALID,
ACTIVITY_NOT_FOUND,
@@ -121,6 +122,10 @@ def _public_facet_record(name: str, data: dict[str, object]) -> dict[str, object
"title": str(data.get("title") or name),
"color": str(data.get("color") or ""),
"emoji": str(data.get("emoji") or ""),
+ "icon": str(data.get("icon") or ""),
+ "icon_svg": resolve_facet_icon_svg(
+ data.get("icon"), str(data.get("emoji") or "")
+ ),
"muted": bool(data.get("muted", False)),
}
@@ -1161,6 +1166,20 @@ def get_muted_facets() -> Any:
return _settings_operation_failed()
+@settings_bp.route("/api/icons")
+def search_icons() -> Any:
+ try:
+ from solstone.convey.icons import search_lucide_icons
+
+ q = request.args.get("q", "")
+ limit = request.args.get("limit", default=80, type=int) or 80
+ limit = max(1, min(limit, 200))
+ return jsonify({"icons": search_lucide_icons(q, limit=limit)})
+ except Exception:
+ logger.exception("error searching icons")
+ return _settings_operation_failed()
+
+
@settings_bp.route("/api/facet", methods=["POST"])
def create_facet() -> Any:
"""Create a new facet.
@@ -1184,6 +1203,7 @@ def create_facet() -> Any:
# Optional fields with defaults
emoji = data.get("emoji", "📦")
color = data.get("color", "#667eea")
+ icon = (data.get("icon") or "").strip()
# Generate slug from title: lowercase, replace spaces/special chars with hyphens
slug = re.sub(r"[^a-z0-9]+", "-", title.lower())
@@ -1203,7 +1223,7 @@ def create_facet() -> Any:
detail=f"Facet '{slug}' already exists",
)
- facets.create_facet(title, emoji=emoji, color=color)
+ facets.create_facet(title, emoji=emoji, color=color, icon=icon)
config = {
"title": title,
@@ -1211,6 +1231,8 @@ def create_facet() -> Any:
"color": color,
"emoji": emoji,
}
+ if icon:
+ config["icon"] = icon
return jsonify({"success": True, "facet": slug, "config": config}), 201
@@ -1250,7 +1272,7 @@ def update_facet_config(facet_name: str) -> Any:
update_fields = {
key: data[key]
- for key in ("title", "description", "color", "emoji")
+ for key in ("title", "description", "color", "emoji", "icon")
if key in data
}
if update_fields:
@@ -1266,6 +1288,8 @@ def update_facet_config(facet_name: str) -> Any:
return jsonify({"success": True, "facet": facet_name, "config": config})
except FileNotFoundError:
return error_response(FACET_NOT_FOUND, detail="Facet not found")
+ except ValueError as e:
+ return error_response(INVALID_REQUEST_VALUE, detail=str(e))
except Exception:
logger.exception("error saving facet config")
return _settings_operation_failed()
diff --git a/solstone/apps/settings/tests/test_facet_config_routes.py b/solstone/apps/settings/tests/test_facet_config_routes.py
index 76e9cb180..b3790f3e4 100644
--- a/solstone/apps/settings/tests/test_facet_config_routes.py
+++ b/solstone/apps/settings/tests/test_facet_config_routes.py
@@ -7,6 +7,7 @@ import json
from solstone.apps.settings import routes as settings_routes
from solstone.convey import create_app
+from solstone.convey.icons import _lucide_tags
from solstone.think import facets
@@ -88,6 +89,26 @@ def test_create_facet_route_maps_owner_value_error(settings_env, monkeypatch):
assert _reason_code(response) == "invalid_request_value"
+def test_create_facet_route_with_icon_persists_and_returns_config(settings_env):
+ journal, client = _settings_client(settings_env)
+ payload = {
+ "title": "Treasury",
+ "emoji": "\U0001fa99",
+ "color": "#007bff",
+ "icon": "coins",
+ }
+
+ response = client.post("/app/settings/api/facet", json=payload)
+
+ assert response.status_code == 201
+ body = response.get_json()
+ assert body["config"]["icon"] == "coins"
+ facet_payload = json.loads(
+ (journal / "facets" / "treasury" / "facet.json").read_text(encoding="utf-8")
+ )
+ assert facet_payload["icon"] == "coins"
+
+
def test_update_facet_route_updates_field_and_returns_config_without_path(settings_env):
journal, client = _settings_client(settings_env)
slug = facets.create_facet("Research", emoji="R", color="#667eea")
@@ -115,6 +136,79 @@ def test_update_facet_route_updates_field_and_returns_config_without_path(settin
) == _facet_json_bytes(expected_file)
+def test_update_facet_route_icon_round_trip(settings_env):
+ _journal, client = _settings_client(settings_env)
+ slug = facets.create_facet("Research", emoji="📚", color="#667eea")
+
+ response = client.put(
+ f"/app/settings/api/facet/{slug}",
+ json={"icon": "brain"},
+ )
+ reload_response = client.get(f"/app/settings/api/facet/{slug}")
+
+ assert response.status_code == 200
+ assert response.get_json()["config"]["icon"] == "brain"
+ assert reload_response.status_code == 200
+ assert reload_response.get_json()["config"]["icon"] == "brain"
+
+
+def test_update_facet_route_invalid_icon_maps_value_error(settings_env):
+ journal, client = _settings_client(settings_env)
+ slug = facets.create_facet("Stable", emoji="S", color="#667eea")
+ facet_path = journal / "facets" / slug / "facet.json"
+ before = facet_path.read_text(encoding="utf-8")
+
+ response = client.put(
+ f"/app/settings/api/facet/{slug}",
+ json={"title": "Changed", "icon": "bogus"},
+ )
+
+ assert response.status_code == 400
+ assert _reason_code(response) == "invalid_request_value"
+ assert "open the picker or see lucide.dev/icons" in response.get_json()["detail"]
+ assert facet_path.read_text(encoding="utf-8") == before
+
+
+def test_update_facet_route_icon_clear_removes_key(settings_env):
+ journal, client = _settings_client(settings_env)
+ slug = facets.create_facet("Clearable", emoji="C", color="#667eea", icon="brain")
+
+ response = client.put(
+ f"/app/settings/api/facet/{slug}",
+ json={"icon": ""},
+ )
+
+ assert response.status_code == 200
+ body = response.get_json()
+ assert "icon" not in body["config"]
+ facet_payload = json.loads(
+ (journal / "facets" / slug / "facet.json").read_text(encoding="utf-8")
+ )
+ assert "icon" not in facet_payload
+
+
+def test_icons_route_search_and_limit(settings_env):
+ _journal, client = _settings_client(settings_env)
+ tags = _lucide_tags()
+
+ lock = client.get("/app/settings/api/icons?q=lock")
+ default = client.get("/app/settings/api/icons")
+ limited = client.get("/app/settings/api/icons?limit=5")
+
+ assert lock.status_code == 200
+ lock_icons = lock.get_json()["icons"]
+ assert lock_icons
+ assert all(
+ "lock" in icon["name"]
+ or any("lock" in tag for tag in tags.get(icon["name"], []))
+ for icon in lock_icons
+ )
+ assert default.status_code == 200
+ assert len(default.get_json()["icons"]) <= 80
+ assert limited.status_code == 200
+ assert len(limited.get_json()["icons"]) <= 5
+
+
def test_update_facet_route_muted_only_regression(settings_env, monkeypatch):
journal, client = _settings_client(settings_env)
slug = facets.create_facet("Quiet", emoji="Q", color="#667eea")
diff --git a/solstone/apps/settings/tests/test_facet_detail.py b/solstone/apps/settings/tests/test_facet_detail.py
index 3b5d57410..069c8dfe9 100644
--- a/solstone/apps/settings/tests/test_facet_detail.py
+++ b/solstone/apps/settings/tests/test_facet_detail.py
@@ -9,6 +9,7 @@ from pathlib import Path
from solstone.apps.settings import copy as settings_copy
from solstone.convey import create_app
+from solstone.convey.icons import lucide_svg
def _write_facet(
@@ -17,6 +18,7 @@ def _write_facet(
*,
title: str,
emoji: str = "TF",
+ icon: str = "",
color: str = "#123456",
muted: bool = False,
) -> None:
@@ -28,6 +30,8 @@ def _write_facet(
"emoji": emoji,
"color": color,
}
+ if icon:
+ payload["icon"] = icon
if muted:
payload["muted"] = True
(facet_dir / "facet.json").write_text(
@@ -103,6 +107,20 @@ def test_settings_facets_api_returns_all_facets(settings_env):
"title": "Active Facet",
"color": "#123456",
"emoji": "TF",
+ "icon": "",
+ "icon_svg": None,
"muted": False,
}
assert by_name["muted-facet"]["muted"] is True
+
+
+def test_settings_facets_api_returns_icon_override_svg(settings_env):
+ journal, client = _settings_client(settings_env)
+ _write_facet(journal, "icon-facet", title="Icon Facet", emoji="📚", icon="brain")
+
+ response = client.get("/app/settings/api/facets")
+
+ assert response.status_code == 200
+ facet = response.get_json()["facets"][0]
+ assert facet["icon"] == "brain"
+ assert facet["icon_svg"] == lucide_svg("brain")
diff --git a/solstone/apps/settings/workspace.html b/solstone/apps/settings/workspace.html
index 26135009d..044646c68 100644
--- a/solstone/apps/settings/workspace.html
+++ b/solstone/apps/settings/workspace.html
@@ -630,6 +630,138 @@ input:checked + .slider:before {
text-align: center;
}
+/* Lucide icon picker */
+.icon-picker-trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.75em;
+ cursor: pointer;
+ padding: 0.6em 0.75em;
+ border: 2px solid #ddd;
+ border-radius: 8px;
+ background: #fff;
+ color: var(--facet-color, #E8923A);
+ transition: all 0.2s;
+}
+
+.icon-picker-trigger:hover {
+ background: #f5f5f5;
+ border-color: #999;
+}
+
+.icon-picker-trigger:focus-visible {
+ outline: 2px solid var(--facet-color, #E8923A);
+ outline-offset: 2px;
+}
+
+.icon-picker-preview,
+.icon-modal-preview-glyph {
+ width: 32px;
+ height: 32px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--pill-color, var(--facet-color, #E8923A));
+}
+
+.icon-picker-preview svg,
+.icon-modal-preview-glyph svg,
+.icon-result-btn svg {
+ width: 24px;
+ height: 24px;
+ stroke-width: 1.5;
+ color: currentColor;
+}
+
+.icon-picker-name {
+ color: #333;
+ font-weight: 600;
+}
+
+.icon-search-input {
+ width: 100%;
+ padding: 0.65em 0.75em;
+ border: 2px solid #ddd;
+ border-radius: 8px;
+ font: inherit;
+ margin-bottom: 1em;
+}
+
+.icon-results-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
+ gap: 0.6em;
+ max-height: 280px;
+ overflow: auto;
+ margin-bottom: 1em;
+}
+
+.icon-result-btn {
+ aspect-ratio: 1;
+ border: 2px solid #ddd;
+ border-radius: 8px;
+ background: #fff;
+ color: var(--pill-color, var(--facet-color, #E8923A));
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.icon-result-btn:hover {
+ background: #f5f5f5;
+ border-color: #999;
+}
+
+.icon-result-btn:active {
+ transform: scale(0.95);
+}
+
+.icon-result-btn.selected {
+ border-color: var(--facet-color, #E8923A);
+ box-shadow: 0 0 0 2px #fff, 0 0 0 5px var(--facet-color, #E8923A);
+}
+
+.icon-picker-message {
+ padding: 0.75em;
+ border-radius: 8px;
+ background: #f8f8f8;
+ color: #555;
+ margin-bottom: 1em;
+}
+
+.icon-picker-message.error {
+ background: #fff4f4;
+ color: #a33;
+}
+
+.icon-picker-actions {
+ display: flex;
+ gap: 0.75em;
+}
+
+.icon-picker-actions .save-color-btn {
+ flex: 1;
+}
+
+.icon-clear-btn {
+ flex: 1;
+ padding: 0.75em 1.5em;
+ background: #fff;
+ color: #333;
+ border: 2px solid #ddd;
+ border-radius: 8px;
+ font-size: 1em;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.icon-clear-btn:hover {
+ background: #f5f5f5;
+ border-color: #999;
+}
+
/* Color picker */
.color-picker-trigger {
display: flex;
@@ -1862,6 +1994,9 @@ input:checked + .slider:before {
.log-more-btn:focus-visible,
.color-close:focus-visible,
.color-swatch:focus-visible,
+.icon-picker-trigger:focus-visible,
+.icon-result-btn:focus-visible,
+.icon-clear-btn:focus-visible,
.field-display:focus-visible,
button:focus-visible {
outline: 2px solid var(--facet-color, #E8923A);
@@ -1887,6 +2022,7 @@ button:focus-visible {
.save-color-btn:focus:not(:focus-visible),
.field-display:focus:not(:focus-visible),
.color-picker-trigger:focus:not(:focus-visible),
+.icon-picker-trigger:focus:not(:focus-visible),
.emoji-display:focus:not(:focus-visible),
button:focus:not(:focus-visible) {
outline: none;
@@ -2947,6 +3083,15 @@ button:focus:not(:focus-visible) {
click to change emoji
+
@@ -3131,6 +3299,7 @@ let keyValidationData = {};
let solVoiceData = null;
let solVoiceThrottledTimer = null;
let transcribeResource = null;
+const iconSvgCache = new Map();
const escapeHtml = (value) => window.AppServices.escapeHtml(value);
const solVoiceCopy = {
autoMutedFormat: {{ sol_voice_copy.SOL_VOICE_SETTINGS_CATEGORY_CAP_AUTO_MUTED_FORMAT | tojson }},
@@ -3165,14 +3334,20 @@ function setTabAttention(section, on) {
}
// ========== MODAL ACCESSIBILITY ==========
+const MODAL_FOCUSABLE_SELECTOR = 'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+function refreshModalFocusables(modal) {
+ const focusable = modal.querySelectorAll(MODAL_FOCUSABLE_SELECTOR);
+ modal._focusableEls = Array.from(focusable);
+}
+
function setupModalAccessibility(modalId) {
const modal = document.getElementById(modalId);
if (!modal) return;
modal._openAccessible = function(triggerEl) {
this._triggerEl = triggerEl || document.activeElement;
- const focusable = this.querySelectorAll('input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])');
- this._focusableEls = Array.from(focusable);
+ refreshModalFocusables(this);
if (this._focusableEls.length) {
requestAnimationFrame(() => this._focusableEls[0].focus());
}
@@ -3211,6 +3386,7 @@ function setupModalAccessibility(modalId) {
}
setupModalAccessibility('colorPickerModal');
+setupModalAccessibility('iconPickerModal');
setupModalAccessibility('customActivityModal');
setupModalAccessibility('cleanupModal');
@@ -4254,6 +4430,10 @@ function populateFacetForm(facetName, config) {
document.getElementById('facetDescInput').value = config.description || '';
document.getElementById('emojiDisplay').textContent = config.emoji || '\uD83D\uDCE6';
+ updateIconDisplay(config.icon || '', null);
+ if (config.icon) {
+ loadIconDisplaySvg(config.icon);
+ }
const color = config.color || '#667eea';
document.getElementById('colorPreview').style.backgroundColor = color;
@@ -4268,6 +4448,7 @@ function setupFacetEditing() {
setupDoubleTapEdit('facetTitleField', 'facetTitleDisplay', 'facetTitleInput', 'title');
setupDoubleTapEdit('facetDescField', 'facetDescDisplay', 'facetDescInput', 'description');
setupEmojiPicker();
+ setupIconPicker();
setupColorPicker();
setupMutedToggle();
}
@@ -4349,6 +4530,227 @@ function setupEmojiPicker() {
};
}
+function setIconSvg(element, svg) {
+ element.innerHTML = svg;
+ const svgEl = element.querySelector('svg');
+ if (svgEl) {
+ svgEl.setAttribute('stroke-width', '1.5');
+ svgEl.setAttribute('aria-hidden', 'true');
+ svgEl.style.color = 'currentColor';
+ }
+}
+
+function updateIconDisplay(iconName, svg) {
+ const preview = document.getElementById('iconPreview');
+ const nameDisplay = document.getElementById('iconNameDisplay');
+ if (!preview || !nameDisplay) return;
+
+ if (iconName && svg) {
+ setIconSvg(preview, svg);
+ nameDisplay.textContent = iconName;
+ } else if (iconName) {
+ preview.textContent = '?';
+ nameDisplay.textContent = iconName;
+ } else {
+ preview.textContent = '—';
+ nameDisplay.textContent = 'use emoji';
+ }
+}
+
+async function loadIconDisplaySvg(iconName) {
+ if (!iconName) return;
+ if (iconSvgCache.has(iconName)) {
+ updateIconDisplay(iconName, iconSvgCache.get(iconName));
+ return;
+ }
+
+ try {
+ const response = await fetch(`/app/settings/api/icons?q=${encodeURIComponent(iconName)}&limit=80`);
+ const data = await response.json();
+ if (!response.ok || data.error) throw new Error(data.error || 'icon lookup failed');
+ const match = (data.icons || []).find(icon => icon.name === iconName);
+ if (match) {
+ iconSvgCache.set(match.name, match.svg);
+ if (facetConfig?.icon === iconName) {
+ updateIconDisplay(iconName, match.svg);
+ }
+ }
+ } catch (err) {
+ console.error('Icon lookup error:', err);
+ }
+}
+
+function setupIconPicker() {
+ const modal = document.getElementById('iconPickerModal');
+ const trigger = document.getElementById('iconTrigger');
+ const closeBtn = document.getElementById('iconPickerClose');
+ const searchInput = document.getElementById('iconSearchInput');
+ const grid = document.getElementById('iconResultsGrid');
+ const message = document.getElementById('iconPickerMessage');
+ const applyBtn = document.getElementById('applyIconBtn');
+ const clearBtn = document.getElementById('clearIconBtn');
+ const previewGlyph = document.getElementById('iconModalPreviewGlyph');
+ const previewTitle = document.getElementById('iconModalPreviewTitle');
+ const previewPill = document.getElementById('iconModalPreview');
+ if (!modal || !trigger || !closeBtn || !searchInput || !grid || !message || !applyBtn || !clearBtn || !previewGlyph || !previewTitle || !previewPill) return;
+
+ let selectedIcon = '';
+ let selectedSvg = null;
+ let searchTimer = null;
+ let searchRequestId = 0;
+
+ function clearIconMessage() {
+ message.style.display = 'none';
+ message.classList.remove('error');
+ message.replaceChildren();
+ }
+
+ function showIconMessage(text, isError = false, retryQuery = null) {
+ message.replaceChildren();
+ const span = document.createElement('span');
+ span.textContent = text;
+ message.appendChild(span);
+ if (retryQuery !== null) {
+ const retry = document.createElement('button');
+ retry.type = 'button';
+ retry.className = 'icon-clear-btn';
+ retry.textContent = 'retry';
+ retry.style.marginTop = '0.75em';
+ retry.addEventListener('click', () => fetchIcons(retryQuery));
+ message.appendChild(retry);
+ }
+ message.style.display = 'block';
+ message.classList.toggle('error', isError);
+ refreshModalFocusables(modal);
+ }
+
+ function updateIconPreview() {
+ const color = facetConfig?.color || '#667eea';
+ previewPill.style.borderColor = color;
+ previewPill.style.backgroundColor = color + '1a';
+ previewGlyph.style.setProperty('--pill-color', color);
+ previewTitle.style.color = color;
+ previewTitle.textContent = document.getElementById('facetTitleDisplay').textContent;
+ if (selectedIcon && selectedSvg) {
+ setIconSvg(previewGlyph, selectedSvg);
+ } else {
+ previewGlyph.textContent = document.getElementById('emojiDisplay').textContent || '—';
+ }
+ applyBtn.disabled = !selectedIcon;
+ refreshModalFocusables(modal);
+ }
+
+ function setSelectedIcon(name, svg) {
+ selectedIcon = name;
+ selectedSvg = svg;
+ grid.querySelectorAll('.icon-result-btn').forEach(btn => {
+ btn.classList.toggle('selected', btn.dataset.iconName === name);
+ });
+ updateIconPreview();
+ }
+
+ function renderIconResults(icons, query) {
+ grid.replaceChildren();
+ if (!icons.length) {
+ showIconMessage(query ? 'no icons match' : 'no icons available');
+ return;
+ }
+
+ clearIconMessage();
+ icons.forEach(icon => {
+ iconSvgCache.set(icon.name, icon.svg);
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'icon-result-btn';
+ button.dataset.iconName = icon.name;
+ button.setAttribute('aria-label', icon.name);
+ button.title = icon.name;
+ setIconSvg(button, icon.svg);
+ if (icon.name === selectedIcon) {
+ button.classList.add('selected');
+ }
+ button.addEventListener('click', () => setSelectedIcon(icon.name, icon.svg));
+ grid.appendChild(button);
+ });
+ refreshModalFocusables(modal);
+ }
+
+ async function fetchIcons(query) {
+ const requestId = ++searchRequestId;
+ showIconMessage('loading icons...');
+ try {
+ const response = await fetch(`/app/settings/api/icons?q=${encodeURIComponent(query)}`);
+ const data = await response.json();
+ if (requestId !== searchRequestId) return;
+ if (!response.ok || data.error) throw new Error(data.error || 'icon search failed');
+ renderIconResults(data.icons || [], query);
+ if (selectedIcon && !selectedSvg && iconSvgCache.has(selectedIcon)) {
+ selectedSvg = iconSvgCache.get(selectedIcon);
+ updateIconPreview();
+ }
+ } catch (err) {
+ if (requestId !== searchRequestId) return;
+ console.error('Icon search error:', err);
+ showIconMessage('icon search failed', true, query);
+ }
+ }
+
+ function queueIconSearch() {
+ clearTimeout(searchTimer);
+ searchTimer = setTimeout(() => fetchIcons(searchInput.value), 200);
+ }
+
+ grid.onkeydown = (e) => {
+ const target = e.target.closest('.icon-result-btn');
+ if (!target) return;
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ target.click();
+ }
+ if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)) {
+ e.preventDefault();
+ const all = [...grid.querySelectorAll('.icon-result-btn')];
+ const idx = all.indexOf(target);
+ const cols = Math.floor(grid.clientWidth / all[0].offsetWidth) || 4;
+ let next = idx;
+ if (e.key === 'ArrowRight') next = Math.min(idx + 1, all.length - 1);
+ if (e.key === 'ArrowLeft') next = Math.max(idx - 1, 0);
+ if (e.key === 'ArrowDown') next = Math.min(idx + cols, all.length - 1);
+ if (e.key === 'ArrowUp') next = Math.max(idx - cols, 0);
+ all[next].focus();
+ }
+ };
+
+ trigger.onclick = () => {
+ selectedIcon = facetConfig?.icon || '';
+ selectedSvg = selectedIcon && iconSvgCache.has(selectedIcon) ? iconSvgCache.get(selectedIcon) : null;
+ searchInput.value = selectedIcon;
+ updateIconPreview();
+ modal.style.display = 'block';
+ modal._openAccessible(trigger);
+ fetchIcons(searchInput.value);
+ };
+
+ closeBtn.onclick = () => modal._closeAccessible();
+ modal.onclick = (e) => { if (e.target === modal) modal._closeAccessible(); };
+ searchInput.oninput = queueIconSearch;
+
+ applyBtn.onclick = () => {
+ if (!selectedIcon) return;
+ updateIconDisplay(selectedIcon, selectedSvg);
+ saveFacetField('icon', selectedIcon, null);
+ modal._closeAccessible();
+ };
+
+ clearBtn.onclick = () => {
+ selectedIcon = '';
+ selectedSvg = null;
+ updateIconDisplay('', null);
+ saveFacetField('icon', '', null);
+ modal._closeAccessible();
+ };
+}
+
function colorName(hex) {
const names = {
'#007bff': 'Blue', '#28a745': 'Green', '#17a2b8': 'Teal',
@@ -4476,7 +4878,7 @@ async function saveFacetField(field, value, triggerEl) {
showFieldStatus(triggerEl, 'saved');
}
// Prompt reload for changes that affect the facet bar
- if (field === 'title' || field === 'emoji' || field === 'color' || field === 'muted') {
+ if (field === 'title' || field === 'emoji' || field === 'icon' || field === 'color' || field === 'muted') {
promptReload();
}
} else {
diff --git a/solstone/convey/apps.py b/solstone/convey/apps.py
index 7f66f7c16..76cfa4054 100644
--- a/solstone/convey/apps.py
+++ b/solstone/convey/apps.py
@@ -14,7 +14,7 @@ from solstone.apps import AppRegistry
from solstone.convey.provider_readiness import is_blocking_reason, present_for_reason
from solstone.think.talent_runs import AgentFailure, read_unresolved_agent_failures
-from .icons import APP_LUCIDE_MAP, lucide_svg, lucide_svg_for_emoji
+from .icons import APP_LUCIDE_MAP, lucide_svg, resolve_facet_icon_svg
def _get_facets_data() -> list[dict]:
@@ -36,7 +36,10 @@ def _get_facets_data() -> list[dict]:
"title": data.get("title", name),
"color": data.get("color", ""),
"emoji": data.get("emoji", ""),
- "icon_svg": lucide_svg_for_emoji(data.get("emoji", "")),
+ "icon": data.get("icon", ""),
+ "icon_svg": resolve_facet_icon_svg(
+ data.get("icon"), data.get("emoji", "")
+ ),
}
)
diff --git a/solstone/convey/icons.py b/solstone/convey/icons.py
index fe64939c7..a124a7ed8 100644
--- a/solstone/convey/icons.py
+++ b/solstone/convey/icons.py
@@ -42,6 +42,13 @@ def _lucide_icons() -> dict[str, str]:
return json.load(handle)
+@functools.lru_cache(maxsize=1)
+def _lucide_tags() -> dict[str, list[str]]:
+ path = Path(__file__).parent / "static" / "icons" / "lucide-tags.json"
+ with path.open(encoding="utf-8") as handle:
+ return json.load(handle)
+
+
@functools.lru_cache(maxsize=1)
def _emoji_lucide_map() -> dict[str, str]:
path = Path(__file__).parent / "static" / "icons" / "emoji-lucide.json"
@@ -54,6 +61,11 @@ def lucide_svg(name: str) -> str | None:
return _lucide_icons().get(name)
+def is_lucide_icon(name: str) -> bool:
+ """Return whether name is a vendored Lucide icon."""
+ return bool(name) and lucide_svg(name) is not None
+
+
def emoji_to_lucide(emoji: str, default: str | None = None) -> str | None:
"""Translate an emoji to a Lucide icon name."""
if not isinstance(emoji, str):
@@ -89,3 +101,35 @@ def lucide_svg_for_emoji(emoji: str) -> str | None:
if icon_name is None:
return None
return lucide_svg(icon_name)
+
+
+def resolve_facet_icon_svg(icon: str | None, emoji: str) -> str | None:
+ """Resolve a facet icon override, falling back to the emoji mapping."""
+ if icon:
+ svg = lucide_svg(icon)
+ if svg is not None:
+ return svg
+ return lucide_svg_for_emoji(emoji)
+
+
+def search_lucide_icons(query: str, limit: int = 80) -> list[dict[str, str]]:
+ """Search vendored Lucide icons by name first, then tags."""
+ q = (query or "").strip().lower()
+ names = sorted(_lucide_icons())
+ if q:
+ name_matches = [name for name in names if q in name]
+ tag_matches = [
+ name
+ for name in names
+ if q not in name and any(q in tag for tag in _lucide_tags().get(name, []))
+ ]
+ chosen = (name_matches + tag_matches)[:limit]
+ else:
+ chosen = names[:limit]
+
+ results: list[dict[str, str]] = []
+ for name in chosen:
+ svg = lucide_svg(name)
+ if svg is not None:
+ results.append({"name": name, "svg": svg})
+ return results
diff --git a/solstone/talent/journal/references/cli.md b/solstone/talent/journal/references/cli.md
index 4ae0ddc9e..232105e07 100644
--- a/solstone/talent/journal/references/cli.md
+++ b/solstone/talent/journal/references/cli.md
@@ -65,13 +65,14 @@ sol call journal facet show # uses SOL_FACET
## facet create
```bash
-sol call journal facet create
[--emoji EMOJI] [--color COLOR] [--description DESC] [--consent]
+sol call journal facet create [--emoji EMOJI] [--icon ICON] [--color COLOR] [--description DESC] [--consent]
```
Create a new facet directory and initial `facet.json`.
- `title`: display title used for the facet.
- `--emoji`: optional icon emoji (default: `📦`).
+- `--icon`: optional Lucide icon name that overrides the emoji-derived interface icon.
- `--color`: optional hex color (default: `#667eea`).
- `--description`: optional description text.
- `--consent`: asserts that the agent has received a direct owner request or explicit owner approval before calling this command. Pass when acting proactively (cogitate, suggestion flows) rather than in direct response to an owner instruction. Adds `"consent": true` to the audit log entry.
@@ -81,12 +82,13 @@ Examples:
```bash
sol call journal facet create "Acme Project"
sol call journal facet create "Personal" --emoji "🏠" --color "#ff6f61" --description "Life admin"
+sol call journal facet create "Research" --emoji "📚" --icon library
```
## facet update
```bash
-sol call journal facet update [--title T] [--description D] [--emoji E] [--color C]
+sol call journal facet update [--title T] [--description D] [--emoji E] [--icon ICON] [--color C]
```
Update facet metadata fields.
@@ -95,12 +97,14 @@ Update facet metadata fields.
- `--title`: optional new display title.
- `--description`: optional new description.
- `--emoji`: optional new icon emoji.
+- `--icon`: optional Lucide icon name; pass an empty string to clear and use the emoji-derived icon.
- `--color`: optional new hex color.
Example:
```bash
sol call journal facet update work --description "Client work and planning" --emoji "🛠"
+sol call journal facet update work --icon brain
```
## facet rename
diff --git a/solstone/think/facets.py b/solstone/think/facets.py
index fde3d431a..d756c4699 100644
--- a/solstone/think/facets.py
+++ b/solstone/think/facets.py
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional
+from solstone.convey.icons import is_lucide_icon
from solstone.think.journal_io import append_text, atomic_replace, hold_lock
from solstone.think.utils import day_dirs, day_path, get_journal, iter_segments
@@ -245,6 +246,13 @@ def _write_facet_json(path: Path, data: dict[str, Any]) -> None:
atomic_replace(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
+def _validate_icon(icon: str) -> None:
+ if not is_lucide_icon(icon):
+ raise ValueError(
+ f"unknown Lucide icon '{icon}' — open the picker or see lucide.dev/icons"
+ )
+
+
def ensure_facet(slug: str) -> bool:
"""Ensure a facet directory has facet metadata."""
facet_path = Path(get_journal()) / "facets" / slug
@@ -328,6 +336,8 @@ def get_facets() -> dict[str, dict[str, object]]:
"emoji": facet_data.get("emoji", ""),
"muted": facet_data.get("muted", False),
}
+ if "icon" in facet_data:
+ facet_info["icon"] = facet_data["icon"]
facets[facet_name] = facet_info
except (
@@ -759,6 +769,7 @@ def create_facet(
color: str = "#667eea",
description: str = "",
*,
+ icon: str = "",
consent: bool = False,
) -> str:
"""Create a new facet directory with facet.json.
@@ -768,6 +779,7 @@ def create_facet(
emoji: Icon emoji (default: "📦")
color: Hex color (default: "#667eea")
description: Facet description
+ icon: Lucide icon name override
Returns:
The generated slug name for the facet
@@ -789,6 +801,9 @@ def create_facet(
if slug in get_facets():
raise ValueError(f"Facet '{slug}' already exists")
+ if icon:
+ _validate_icon(icon)
+
facet_path = Path(get_journal()) / "facets" / slug
facet_path.mkdir(parents=True, exist_ok=True)
facet_json_path = facet_path / "facet.json"
@@ -799,6 +814,8 @@ def create_facet(
"color": color,
"emoji": emoji,
}
+ if icon:
+ facet_data["icon"] = icon
_write_facet_json(facet_json_path, facet_data)
@@ -808,6 +825,8 @@ def create_facet(
"color": color,
"description": description,
}
+ if icon:
+ log_params["icon"] = icon
if consent:
log_params["consent"] = True
log_call_action(
@@ -823,7 +842,7 @@ def update_facet(name: str, **kwargs: Any) -> dict[str, Any]:
Args:
name: Facet name
- **kwargs: Fields to update (title, description, emoji, color)
+ **kwargs: Fields to update (title, description, emoji, color, icon)
Returns:
Dict of changed fields {field: {"old": ..., "new": ...}}
@@ -846,7 +865,8 @@ def update_facet(name: str, **kwargs: Any) -> dict[str, Any]:
allowed_fields = {"title", "description", "color", "emoji"}
changed_fields: dict[str, Any] = {}
filtered = {k: v for k, v in kwargs.items() if k in allowed_fields}
- if not filtered:
+ icon_provided = "icon" in kwargs
+ if not filtered and not icon_provided:
raise ValueError("No valid fields to update")
for field, new_value in filtered.items():
@@ -855,6 +875,19 @@ def update_facet(name: str, **kwargs: Any) -> dict[str, Any]:
changed_fields[field] = {"old": old_value, "new": new_value}
facet_data[field] = new_value
+ if icon_provided:
+ new_icon = kwargs["icon"]
+ old_icon = facet_data.get("icon")
+ if new_icon:
+ _validate_icon(new_icon)
+ if old_icon != new_icon:
+ changed_fields["icon"] = {"old": old_icon, "new": new_icon}
+ facet_data["icon"] = new_icon
+ else:
+ if "icon" in facet_data:
+ changed_fields["icon"] = {"old": old_icon, "new": None}
+ facet_data.pop("icon")
+
_write_facet_json(facet_json_path, facet_data)
if changed_fields:
diff --git a/solstone/think/tools/call.py b/solstone/think/tools/call.py
index 26cb19103..a671f6a22 100644
--- a/solstone/think/tools/call.py
+++ b/solstone/think/tools/call.py
@@ -246,6 +246,9 @@ def create(
emoji: str = typer.Option("📦", "--emoji", help="Icon emoji."),
color: str = typer.Option("#667eea", "--color", help="Hex color."),
description: str = typer.Option("", "--description", help="Facet description."),
+ icon: str = typer.Option(
+ "", "--icon", help="Lucide icon name (overrides the emoji-derived icon)."
+ ),
consent: bool = typer.Option(
False,
"--consent",
@@ -259,6 +262,7 @@ def create(
emoji=emoji,
color=color,
description=description,
+ icon=icon,
consent=consent,
)
except ValueError as e:
@@ -276,6 +280,9 @@ def update(
),
emoji: str | None = typer.Option(None, "--emoji", help="New icon emoji."),
color: str | None = typer.Option(None, "--color", help="New hex color."),
+ icon: str | None = typer.Option(
+ None, "--icon", help="Lucide icon name; pass empty string to clear."
+ ),
) -> None:
"""Update facet configuration."""
kwargs = {}
@@ -287,10 +294,12 @@ def update(
kwargs["emoji"] = emoji
if color is not None:
kwargs["color"] = color
+ if icon is not None:
+ kwargs["icon"] = icon
if not kwargs:
typer.echo(
- "Error: No fields to update. Use --title, --description, --emoji, or --color.",
+ "Error: No fields to update. Use --title, --description, --emoji, --color, or --icon.",
err=True,
)
raise typer.Exit(1)
diff --git a/tests/test_call.py b/tests/test_call.py
index 39dabb54b..8f98fb412 100644
--- a/tests/test_call.py
+++ b/tests/test_call.py
@@ -483,6 +483,17 @@ class TestFacetCRUD:
assert payload["color"] == "#ff0000"
assert payload["description"] == "A cool project"
+ def test_facet_create_with_icon(self, facet_journal):
+ """Create persists a Lucide icon override."""
+ result = runner.invoke(
+ call_app,
+ ["journal", "facet", "create", "Icon Project", "--icon", "coins"],
+ )
+ assert result.exit_code == 0
+ facet_file = facet_journal / "facets" / "icon-project" / "facet.json"
+ payload = json.loads(facet_file.read_text(encoding="utf-8"))
+ assert payload["icon"] == "coins"
+
def test_facet_create_duplicate(self, facet_journal):
"""Create rejects duplicate slugs."""
result = runner.invoke(call_app, ["journal", "facet", "create", "Test Facet"])
@@ -511,6 +522,55 @@ class TestFacetCRUD:
assert payload["description"] == "New desc"
assert payload["emoji"] == "🎯"
+ def test_facet_update_with_icon_and_emoji(self, facet_journal):
+ """Update persists an icon override without mutating emoji behavior."""
+ result = runner.invoke(
+ call_app,
+ [
+ "journal",
+ "facet",
+ "update",
+ "test-facet",
+ "--emoji",
+ "🎯",
+ "--icon",
+ "brain",
+ ],
+ )
+ assert result.exit_code == 0
+ facet_file = facet_journal / "facets" / "test-facet" / "facet.json"
+ payload = json.loads(facet_file.read_text(encoding="utf-8"))
+ assert payload["emoji"] == "🎯"
+ assert payload["icon"] == "brain"
+
+ def test_facet_update_icon_clear(self, facet_journal):
+ """Update --icon '' clears the icon override."""
+ facet_file = facet_journal / "facets" / "test-facet" / "facet.json"
+ payload = json.loads(facet_file.read_text(encoding="utf-8"))
+ payload["icon"] = "brain"
+ facet_file.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+
+ result = runner.invoke(
+ call_app,
+ ["journal", "facet", "update", "test-facet", "--icon", ""],
+ )
+ assert result.exit_code == 0
+ payload = json.loads(facet_file.read_text(encoding="utf-8"))
+ assert "icon" not in payload
+
+ def test_facet_update_invalid_icon(self, facet_journal):
+ """Invalid icon names fail with the guiding validation message."""
+ facet_file = facet_journal / "facets" / "test-facet" / "facet.json"
+ before = facet_file.read_text(encoding="utf-8")
+
+ result = runner.invoke(
+ call_app,
+ ["journal", "facet", "update", "test-facet", "--icon", "bogus"],
+ )
+ assert result.exit_code == 1
+ assert "open the picker or see lucide.dev/icons" in result.output
+ assert facet_file.read_text(encoding="utf-8") == before
+
def test_facet_update_not_found(self, facet_journal):
"""Update fails when facet does not exist."""
result = runner.invoke(
diff --git a/tests/test_convey_apps.py b/tests/test_convey_apps.py
index 006a6b2f4..f91fa5a8d 100644
--- a/tests/test_convey_apps.py
+++ b/tests/test_convey_apps.py
@@ -12,6 +12,7 @@ from jinja2 import ChoiceLoader, FileSystemLoader
from solstone.apps import AppRegistry
from solstone.convey.apps import register_app_context
from solstone.convey.chat_stream import append_chat_event
+from solstone.convey.icons import lucide_svg
from solstone.convey.sol_initiated.copy import CATEGORIES, KIND_SOL_CHAT_REQUEST
REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -137,6 +138,18 @@ def test_get_facets_data_adds_icon_svg_and_preserves_emoji(monkeypatch):
"color": "#654321",
"emoji": "🪮",
},
+ "override": {
+ "title": "Override",
+ "color": "#abcdef",
+ "emoji": "📚",
+ "icon": "brain",
+ },
+ "dangling": {
+ "title": "Dangling",
+ "color": "#fedcba",
+ "emoji": "📚",
+ "icon": "definitely-not-an-icon",
+ },
},
)
monkeypatch.setattr("solstone.convey.config.load_convey_config", lambda: {})
@@ -145,9 +158,16 @@ def test_get_facets_data_adds_icon_svg_and_preserves_emoji(monkeypatch):
by_name = {facet["name"]: facet for facet in facets}
assert by_name["library"]["emoji"] == "📚"
+ assert by_name["library"]["icon"] == ""
assert "